Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c09361b
docs(plans): add service interval tracking implementation plan
unclesp1d3r Aug 3, 2026
81ba468
feat(service-intervals): add service rule and event schema (U1)
unclesp1d3r Aug 3, 2026
1336fd4
feat(service-intervals): add pure derivation core (U2)
unclesp1d3r Aug 3, 2026
a39e6a7
feat(service-intervals): add defaults and item-rule service layer (U3)
unclesp1d3r Aug 3, 2026
c941c05
feat(service-intervals): add service events and due loaders (U4)
unclesp1d3r Aug 3, 2026
af3867c
feat(service-intervals)!: retire cleaned and lubed log events (U5)
unclesp1d3r Aug 3, 2026
dc55808
feat(firearms): add an acquired date (U6)
unclesp1d3r Aug 3, 2026
d3e1679
feat(service-intervals): add the defaults settings surface (U7)
unclesp1d3r Aug 3, 2026
e1fbaa7
feat(service-intervals): add item detail service surfaces (U8)
unclesp1d3r Aug 3, 2026
674a5a4
feat(service-intervals): add due roll-up and list indicators (U9)
unclesp1d3r Aug 3, 2026
e5defcf
feat(service-intervals): seed the demo and cover the flow end to end …
unclesp1d3r Aug 3, 2026
ea01df0
refactor(service-intervals): remove duplicate work and duplicated code
unclesp1d3r Aug 4, 2026
3c36da2
feat(service-intervals): mark many items serviced at once (R16)
unclesp1d3r Aug 4, 2026
7640bb8
fix(service-intervals): close code-review findings
unclesp1d3r Aug 4, 2026
58e41c2
feat(service-intervals): correct service events and date accessories
unclesp1d3r Aug 4, 2026
b3e73f6
fix(service-intervals): address PR review feedback (#99)
unclesp1d3r Aug 4, 2026
1fe60e3
fix(service-intervals): repoint history on default rename, fix actor-…
unclesp1d3r Aug 4, 2026
8ad253a
fix(service-intervals): close review findings across types, errors, a…
unclesp1d3r Aug 5, 2026
4a0c28f
test(service-intervals): restore spies so they cannot leak between files
unclesp1d3r Aug 5, 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
18 changes: 16 additions & 2 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ A single logged range trip for one Firearm — the date and the rounds fired tha

### Inventory Log

An append-only history of physical-handling events on a single Firearm or Magazine — each **Log Entry** records an **Event Type**, the acting user, when it happened, and optional notes. A **child record** family: entries inherit their parent's owner and grants, cannot be shared on their own, and are removed with the parent. Entries are created and listed but not edited or deleted.
An append-only history of physical-handling events on a single Firearm or Magazine — each **Log Entry** records an **Event Type**, the acting user, when it happened, and optional notes. A **child record** family: entries inherit their parent's owner and grants, cannot be shared on their own, and are removed with the parent. Entries are created and listed but not edited or deleted. `cleaned` and `lubed` were retired as Firearm Event Types once **Service Event** shipped (service-intervals plan, U5) — logging service against a **Service Rule** is now the single way to record either act; every prior `cleaned`/`lubed` entry converted to a Service Event and the Inventory Log now carries only `inventoried` for both parent families.

### Event Type

The controlled kind of a **Log Entry** (*inventoried* for any item; *cleaned*, *lubed* for Firearms), drawn from a fixed value set whose valid members depend on the parent family. Deliberately not called an "action" — that name already means a Firearm's operating mechanism (see **Firearm Action**).
The controlled kind of a **Log Entry** — currently *inventoried*, the only member for either parent family — drawn from a fixed value set whose valid members depend on the parent family. Deliberately not called an "action" — that name already means a Firearm's operating mechanism (see **Firearm Action**).

### Child record

Expand Down Expand Up @@ -95,6 +95,16 @@ A short string an owner has used to start Magazine labels. Recorded per owner an

An owner setting that, when on, constrains Magazine labels to what can physically be written in the dot cells of a Magpul magazine floorplate — a limited character set and length. When off, labels are free text.

## Service intervals

### Service Rule

A named maintenance concern tracked against a Firearm or Accessory — Cleaning, Barrel, Recoil spring, and so on — that sets at least one of three thresholds: elapsed days, range sessions, or rounds fired. An item's Service Rules come from its owner's category defaults, live: each is *inherited*, *overridden* with the item's own thresholds, or *suppressed* (removed from the item entirely); an item may also carry *item-only* rules no default defines. A Service Rule belongs to its item's Owner, so a shared item's rules come from the owner's defaults, never the viewer's.

### Service Event

A single logged act of service against one Service Rule — the date it happened, the acting user, and optional notes. Logging a Service Event sets that rule's measurement point: elapsed days, sessions, and rounds all start counting fresh from it. A Firearm or Accessory's service history is every Service Event against it, newest first.

## Derived values

### Total Capacity
Expand All @@ -113,6 +123,10 @@ The derived state of an **Ammo** lot whose quantity in rounds is at or under its

A Magazine's most recent physical-count date: the `occurredAt` of the latest **Inventory Log** entry with **Event Type** `inventoried`. Derived, not stored — and blank (a first-class state) when the Magazine has never been inventoried. Respects owner-scoping: derived only from entries visible through the parent Magazine.

### Due

The derived state of a **Service Rule** whose elapsed days, sessions, or rounds fired — measured since its last **Service Event**, or the item's origin date when none exists — meets or exceeds any threshold it sets. Binary, never a severity tier: distance past a threshold is shown as the raw counts, not a "due soon" gradation. Never stored — computed from the resolved rule and its elapsed counts wherever it is shown (item detail panel, `/summary` roll-up, list indicators).

## Design identity

### Machined Console
Expand Down
159 changes: 159 additions & 0 deletions app/(app)/accessories/[id]/__tests__/service-props.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { NotFoundError } from "@/src/auth/errors";
import * as dueService from "@/src/domain/service-intervals/due-service";
import * as eventsService from "@/src/domain/service-intervals/events-service";
import * as rulesService from "@/src/domain/service-intervals/rules-service";
import { loadAccessoryServiceProps } from "../page";

/**
* Unit tests for `loadAccessoryServiceProps` (the accessory detail page's
* owner-only service-data loader) — specifically the 404 guard added
* alongside the firearm detail page's `asNotFound` (mirrored via
* `@/src/lib/as-not-found`).
*
* Uses `spyOn` on the real `due-service`/`events-service`/`rules-service`
* module namespaces rather than `mock.module(...)` on those specifiers:
* `rules-service` is ALSO `mock.module`-replaced, with a differently-shaped
* export set, by `settings/service/__tests__/actions.test.ts` — a second,
* incompatible `mock.module` on the same path from this file would collide
* when `bun test app` runs every file in one process (mirrors the same
* tradeoff documented in `firearms/__tests__/service-actions.test.ts`).
* `spyOn` overrides only the named export on the already-loaded module
* object, so it doesn't touch that shared registry.
*/

const USER_ID = "user-1";
const ACCESSORY_ID = "accessory-1";

describe("loadAccessoryServiceProps", () => {
let getItemDueStateSpy: ReturnType<typeof spyOn>;
let listItemRulesSpy: ReturnType<typeof spyOn>;
let listServiceHistorySpy: ReturnType<typeof spyOn>;

beforeEach(() => {
getItemDueStateSpy = spyOn(dueService, "getItemDueState").mockResolvedValue(
[],
);
listItemRulesSpy = spyOn(rulesService, "listItemRules").mockResolvedValue(
[],
);
listServiceHistorySpy = spyOn(
eventsService,
"listServiceHistory",
).mockResolvedValue([]);
});
Comment thread
unclesp1d3r marked this conversation as resolved.

// `bun test app` runs every file in one process and Bun's `spyOn`
// replacements persist until restored, so an unrestored spy here would leak
// into whichever file runs next.
afterEach(() => {
getItemDueStateSpy.mockRestore();
listItemRulesSpy.mockRestore();
listServiceHistorySpy.mockRestore();
});

test("a non-owner viewer gets null in every field, without calling any loader", async () => {
const result = await loadAccessoryServiceProps(
USER_ID,
ACCESSORY_ID,
false,
);

expect(result).toEqual({
serviceRules: null,
suppressedServiceRuleNames: null,
serviceHistory: null,
});
expect(getItemDueStateSpy).not.toHaveBeenCalled();
expect(listItemRulesSpy).not.toHaveBeenCalled();
expect(listServiceHistorySpy).not.toHaveBeenCalled();
});

test("an owner viewer gets the resolved rules, suppressed names, and history on the happy path", async () => {
const dueRuleStub = {
name: "Cleaning",
} as unknown as dueService.RuleDueState;
const keptRuleStub = {
name: "Cleaning",
suppressed: false,
} as unknown as rulesService.ServiceRuleRow;
const suppressedRuleStub = {
name: "Lube",
suppressed: true,
} as unknown as rulesService.ServiceRuleRow;
getItemDueStateSpy.mockResolvedValue([dueRuleStub]);
listItemRulesSpy.mockResolvedValue([keptRuleStub, suppressedRuleStub]);
listServiceHistorySpy.mockResolvedValue([]);

const result = await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true);

expect(result.serviceRules).toEqual([dueRuleStub]);
expect(result.suppressedServiceRuleNames).toEqual(["Lube"]);
expect(result.serviceHistory).toEqual([]);
});

// `getItemDueState`/`listItemRules`/`listServiceHistory` route through
// `requireAccessoryOwner`, which authorizes internally and can throw
// `NotFoundError` if the accessory was deleted or reassigned between the
// page's earlier `getAccessory` check and this call. Each loader is
// independently exercised so the fix applied to all three isn't only
// proven for whichever settles first in the `Promise.all`.
const guardedLoaders: Array<{
name: string;
reject: () => void;
}> = [
{
name: "getItemDueState",
reject: () => {
getItemDueStateSpy.mockRejectedValue(new NotFoundError());
},
},
{
name: "listItemRules",
reject: () => {
listItemRulesSpy.mockRejectedValue(new NotFoundError());
},
},
{
name: "listServiceHistory",
reject: () => {
listServiceHistorySpy.mockRejectedValue(new NotFoundError());
},
},
];

for (const loader of guardedLoaders) {
test(`a NotFoundError from ${loader.name} surfaces as Next's clean 404, not an unhandled rejection`, async () => {
loader.reject();

let caught: unknown;
try {
await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true);
} catch (error: unknown) {
caught = error;
}

// `notFound()` throws a real Next.js error carrying this digest, which
// is what the framework's router boundary keys off of to render the
// clean 404 UI instead of the generic error boundary.
expect(caught).toBeInstanceOf(Error);
expect((caught as { digest?: string }).digest).toBe(
"NEXT_HTTP_ERROR_FALLBACK;404",
);
});
}

test("a non-NotFoundError from a loader propagates unchanged, not converted to a 404", async () => {
const boom = new Error("boom");
getItemDueStateSpy.mockRejectedValue(boom);

let caught: unknown;
try {
await loadAccessoryServiceProps(USER_ID, ACCESSORY_ID, true);
} catch (error: unknown) {
caught = error;
}

expect(caught).toBe(boom);
});
});
94 changes: 89 additions & 5 deletions app/(app)/accessories/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,75 @@ 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 { withActorNames } from "@/src/domain/service-intervals/actor-names";
import {
getItemDueState,
type RuleDueState,
} from "@/src/domain/service-intervals/due-service";
import { listServiceHistory } from "@/src/domain/service-intervals/events-service";
import {
listItemRules,
listOwnerAccessoryCategories,
} from "@/src/domain/service-intervals/rules-service";
import { asNotFound } from "@/src/lib/as-not-found";
import { isUuid } from "@/src/lib/uuid";
import type { ServiceHistoryEntry } from "../../firearms/service-history";
import { AccessoryDetailView } from "../accessory-detail-view";

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

export interface AccessoryServiceProps {
serviceRules: RuleDueState[] | null;
suppressedServiceRuleNames: string[] | null;
serviceHistory: ServiceHistoryEntry[] | null;
}

/**
* Loads service data (U8) for the OWNER only — accessories are owner-only
* throughout for service (KTD3), so a non-owner viewer gets `null` in every
* field rather than empty arrays, and the detail view doesn't render the
* section at all for them (matching `requireAccessoryOwner` throwing were we
* to call it for a non-owner anyway).
*
* Exported so `__tests__/service-props.test.ts` can exercise the 404-guard
* race (a loader throwing `NotFoundError` between the page's earlier
* `getAccessory` check and this call) directly, without standing up the rest
* of the page's dependency graph (`listFirearms`, `visibleFirearmPermissions`,
* `AccessoryDetailView`, ...) just to reach it.
*/
export async function loadAccessoryServiceProps(
userId: string,
accessoryId: string,
isOwner: boolean,
): Promise<AccessoryServiceProps> {
if (!isOwner) {
return {
serviceRules: null,
suppressedServiceRuleNames: null,
serviceHistory: null,
};
}
// These loaders route through `requireAccessoryOwner`, which authorizes
// internally and throws `NotFoundError` if the row is deleted or ownership
// changes between the page's earlier `getAccessory` check and this call (a
// narrow race, mirrors the equivalent guard on the firearm detail page) —
// that must surface as the page's clean 404, not an unhandled 500.
const [dueRules, itemRules, history] = await Promise.all([
getItemDueState(userId, "accessory", accessoryId).catch(asNotFound),
listItemRules(userId, "accessory", accessoryId).catch(asNotFound),
listServiceHistory(userId, "accessory", accessoryId).catch(asNotFound),
]);
return {
serviceRules: dueRules,
suppressedServiceRuleNames: itemRules
.filter((rule) => rule.suppressed)
.map((rule) => rule.name),
serviceHistory: await withActorNames(history),
};
}

export default async function AccessoryDetailPage({ params }: PageProps) {
const { id } = await params;
const user = await getCurrentUser();
Expand All @@ -33,15 +95,32 @@ export default async function AccessoryDetailPage({ params }: PageProps) {
},
);

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

const [firearms, permissions, serviceProps, ownerCategories] =
await Promise.all([
listFirearms(user.id),
visibleFirearmPermissions(db, user.id),
loadAccessoryServiceProps(user.id, id, isOwner),
// The ACCESSORY'S OWNER's categories (row.ownerId, not the actor) —
// suggestions should reflect the owner whose category defaults (KD10)
// this accessory actually inherits from, even when an edit-grantee is
// the one editing a shared mount. But `listOwnerAccessoryCategories`
// returns EVERY category across ALL of that owner's accessories, not
// just the ones visible to this viewer — for a non-owner grantee that
// would leak the owner's unrelated-accessory category vocabulary, so
// fall back to the actor's own categories instead (still useful
// autocomplete, no cross-tenant leak).
isOwner
? listOwnerAccessoryCategories(row.ownerId)
: listOwnerAccessoryCategories(user.id),
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
// that owner's own guns, accessories-tracker plan KTD5's cross-tenant
// guard) AND editable by the
// acting user.
const { firearmNames, editableFirearms } = buildFirearmMountContext(
firearms,
Expand All @@ -58,6 +137,7 @@ export default async function AccessoryDetailPage({ params }: PageProps) {
model: row.model,
serialNumber: row.serialNumber,
installedDate: row.installedDate ?? "",
acquiredDate: row.acquiredDate ?? "",
cost: costCentsToInputValue(row.costCents),
notes: row.notes,
isNfa: row.isNfa,
Expand All @@ -66,6 +146,10 @@ export default async function AccessoryDetailPage({ params }: PageProps) {
permission={permission}
editableFirearms={editableFirearms}
firearmNames={firearmNames}
serviceRules={serviceProps.serviceRules}
suppressedServiceRuleNames={serviceProps.suppressedServiceRuleNames}
serviceHistory={serviceProps.serviceHistory}
ownerCategories={ownerCategories}
/>
);
}
21 changes: 21 additions & 0 deletions app/(app)/accessories/accessories-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ export interface AccessoryListItem {
notes: string;
isNfa: boolean;
currentFirearmId: string | null;
/**
* True when this accessory itself has at least one due service rule (U9,
* R20) — never true merely because the firearm it's mounted to is due.
* Advisory only (R21): a marker, not a gate.
*/
serviceDue: boolean;
}

interface AccessoriesViewProps {
Expand All @@ -52,6 +58,9 @@ interface AccessoriesViewProps {
/** Firearm to pre-select as the mount target, from a firearm detail page's
* "Add accessory" link (F1). Auto-opens the create form when present. */
initialMountFirearmId?: string;
/** The actor's own previously-typed categories (KTD8-reuse), merged into
* the create form's category suggestions — see `AccessoryForm`'s doc. */
ownerCategories: string[];
}

type FormState = { open: false } | { open: true };
Expand All @@ -62,6 +71,7 @@ export function AccessoriesView({
editableFirearms,
firearmNames,
initialMountFirearmId,
ownerCategories,
}: AccessoriesViewProps) {
const router = useRouter();
// Auto-open the create form pre-mounted to a firearm when arriving from that
Expand Down Expand Up @@ -157,6 +167,16 @@ export function AccessoriesView({
cell: ({ row }) =>
row.original.isNfa ? <Badge tone="destructive">NFA</Badge> : null,
},
{
id: "serviceDue",
header: "Service",
meta: { label: "Service" },
enableSorting: false,
cell: ({ row }) =>
row.original.serviceDue ? (
<Badge tone="destructive">Service due</Badge>
) : null,
},
{
id: "cost",
header: "Cost",
Expand Down Expand Up @@ -251,6 +271,7 @@ export function AccessoriesView({
<AccessoryForm
editableFirearms={editableFirearms}
initialFirearmId={initialMountFirearmId}
ownerCategories={ownerCategories}
onDone={refresh}
onCancel={() => setForm({ open: false })}
/>
Expand Down
Loading
Loading