Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d7ff9d2
feat(accessories-tracker): add plan for tracking aftermarket parts an…
unclesp1d3r Jul 8, 2026
8871275
docs(plans): implementation-ready accessories tracker plan (#8)
unclesp1d3r Jul 8, 2026
092a55d
feat(accessories): add accessory table and firearm NFA flag (#8)
unclesp1d3r Jul 8, 2026
6f3035a
feat(accessories): accessory validation and category constants (#8)
unclesp1d3r Jul 8, 2026
eea4ab1
feat(accessories): visibility inheritance and mount authorization (#8)
unclesp1d3r Jul 8, 2026
d899e9d
feat(accessories): accessory service with CRUD and mount operations (#8)
unclesp1d3r Jul 8, 2026
27b8e77
feat(accessories): mounted-accessories section, valuation, firearm NF…
unclesp1d3r Jul 8, 2026
f0e9bd5
feat(accessories): top-level accessories screens, form, and nav (#8)
unclesp1d3r Jul 8, 2026
2d5c0f4
feat(accessories): link range sessions to mounted accessories (#8)
unclesp1d3r Jul 8, 2026
b315283
test(accessories): e2e coverage and accessory factory (#8)
unclesp1d3r Jul 8, 2026
3707250
refactor(accessories): derive firearm value total from fetched rows (#8)
unclesp1d3r Jul 8, 2026
a11657d
fix(accessories): scope mount picker to owner, couple installed date …
unclesp1d3r Jul 9, 2026
58bff55
test(accessories): cover view-grantee write denial and cross-tenant c…
unclesp1d3r Jul 9, 2026
f629ac0
docs(accessories): correct requirement citations and issue tags in co…
unclesp1d3r Jul 9, 2026
e5b75ba
Merge branch 'main' into 8-accessories-tracker-per-firearm-log-afterm…
unclesp1d3r Jul 9, 2026
c2f2a07
test(demo): reusable README demo-image generator (#8)
unclesp1d3r Jul 9, 2026
1d0ac2b
docs(readme): showcase accessories and regenerate demo assets (#8)
unclesp1d3r Jul 9, 2026
34737ba
fix(accessories): address PR review feedback (#59)
unclesp1d3r Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
# MagStacker

MagStacker keeps track of your firearms and magazines: what you own, what fits what, and how much it all holds. It's a self-hosted web app for shooting ranges, clubs, and individual owners who'd rather not keep their inventory in a spreadsheet or somebody else's cloud.
MagStacker keeps track of your firearms, magazines, ammo, and aftermarket accessories: what you own, what fits what, how much it all holds, and what it's worth. It's a self-hosted web app for shooting ranges, clubs, and individual owners who'd rather not keep their inventory in a spreadsheet or somebody else's cloud.

You run it on your own server, behind your own login, and the data stays with you.

![MagStacker walkthrough: adding a magazine, mapping firearm compatibility, the caliber summary, and switching themes](docs/images/demo.gif)
![MagStacker walkthrough: the magazine list, a firearm and its mounted accessories, the accessories tracker, the caliber summary, and switching themes](docs/images/demo.gif)

It ships a two-mode interface that follows your system by default — a dark "Field Console" and a light "Machined Instrument":

| Dark | Light |
| :-----------------------------------------------------------: | :-------------------------------------------------------------: |
| ![Magazines list, dark theme](docs/images/magazines-dark.png) | ![Magazines list, light theme](docs/images/magazines-light.png) |

Track the aftermarket parts on each firearm — optics, suppressors, triggers, lights — with cost, serial, and NFA status, and see the total value mounted on a gun:

| Accessories | On a firearm |
| :-----------------------------------------------------------: | :-----------------------------------------------------------------: |
| ![Accessories list](docs/images/accessories-dark.png) | ![A firearm's mounted accessories and value total](docs/images/firearm-accessories.png) |

## Who it's for

- **Individuals** keeping a personal collection straight. Label your mags, see how many you've got per caliber and per firearm, and pull a copy for your own records or insurance.
Expand All @@ -26,6 +32,8 @@ Everyone sees only what they own or what's been shared with them, and only an it
- Link each magazine to the firearms it fits. The order you set is the order it shows up in everywhere else.
- Bulk-add a labeled batch in one go (say, 60 mags numbered `AR-01` through `AR-60`), and the count picks up where it left off the next time you add.
- Filter magazines by brand or model, exact caliber, or which firearm they fit.
- Track ammo lots by caliber, load, and grain, with a low-stock threshold that flags when you're running short.
- Track the accessories on each firearm — optics, suppressors, triggers, grips — with cost, serial, installed date, and NFA status. Move a part between guns and it keeps its identity; a firearm's page rolls up the total value mounted on it. Suppressors and other NFA items are flagged, and their serials stay sensitive.
- Check a summary: a running total, plus counts per caliber and per firearm, over everything you can see.
- Export to CSV for a spreadsheet. Serial numbers stay out of the export, and a cell that starts with `=` won't turn into a live formula when someone opens the file.
- Share one item with another account at view or edit, optionally let them add records on your behalf, and take the access back whenever you want.
Expand Down Expand Up @@ -118,6 +126,8 @@ bun test # unit + integration

> `mise` (`mise.toml`) pins the toolchain and loads `.env` into your shell, then caches it. After you edit `.env`, run `mise cache clear`, or a stale value can shadow both your tooling and `docker compose`.

The README's demo images and walkthrough gif are generated from the live UI. Regenerate them all before a release with `just demo-images` (needs Docker + ffmpeg). The generators are `e2e/demo-*.spec.ts`, gated behind `DEMO=1` so they stay out of the normal test run, and they share one sample dataset from `e2e/fixtures/demo-seed.ts`.

Layout:

```text
Expand Down
71 changes: 71 additions & 0 deletions app/(app)/accessories/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { notFound, redirect } from "next/navigation";
import { NotFoundError } from "@/src/auth/errors";
import { getCurrentUser } from "@/src/auth/session";
import { visibleFirearmPermissions } from "@/src/auth/visibility";
import { db } from "@/src/db/client";
import { costCentsToInputValue } from "@/src/domain/accessories/display";
import { getAccessory } from "@/src/domain/accessories/service";
import { buildFirearmMountContext } from "@/src/domain/firearms/mount-options";
import { listFirearms } from "@/src/domain/firearms/service";
import { isUuid } from "@/src/lib/uuid";
import { AccessoryDetailView } from "../accessory-detail-view";

interface PageProps {
params: Promise<{ id: string }>;
}

export default async function AccessoryDetailPage({ params }: PageProps) {
const { id } = await params;
const user = await getCurrentUser();
if (!user) redirect("/login");
// A malformed id can match no record and would raise a uuid-cast error on the
// query — treat it as not-found at the boundary (R9).
if (!isUuid(id)) notFound();

// getAccessory resolves the viewer's permission and throws NotFoundError for
// an accessory that is not owned nor mounted on a visible firearm — the
// not-found path never reveals existence (R9). It returns the permission so
// we don't re-resolve it.
const { accessory: row, permission } = await getAccessory(user.id, id).catch(
(error: unknown) => {
if (error instanceof NotFoundError) notFound();
throw error;
},
);

const [firearms, permissions] = await Promise.all([
listFirearms(user.id),
visibleFirearmPermissions(db, user.id),
]);

// The reassign-mount picker must offer only firearms owned by the
// ACCESSORY's owner (`row.ownerId`, not the actor — an edit-grantee acting
// on someone else's mounted accessory must still only relocate it among
// that owner's own guns, KTD5's cross-tenant guard) AND editable by the
// acting user.
const { firearmNames, editableFirearms } = buildFirearmMountContext(
firearms,
permissions,
row.ownerId,
);

return (
<AccessoryDetailView
accessory={{
id: row.id,
category: row.category,
brand: row.brand,
model: row.model,
serialNumber: row.serialNumber,
installedDate: row.installedDate ?? "",
cost: costCentsToInputValue(row.costCents),
notes: row.notes,
isNfa: row.isNfa,
currentFirearmId: row.currentFirearmId,
}}
permission={permission}
editableFirearms={editableFirearms}
firearmNames={firearmNames}
/>
);
}
Loading