Skip to content

feat(service-intervals): track service intervals and maintenance reminders - #99

Merged
unclesp1d3r merged 19 commits into
mainfrom
10-add-service-interval-tracking-and-in-app-maintenance-reminders-for-firearms-and-accessories
Aug 5, 2026
Merged

unclesp1d3r merged 19 commits into
mainfrom
10-add-service-interval-tracking-and-in-app-maintenance-reminders-for-firearms-and-accessories

Conversation

@unclesp1d3r

Copy link
Copy Markdown
Owner

Closes #10.

Tracks service intervals for firearms and accessories: an owner sets per-category default maintenance rules, items inherit them live, and due state is derived from real usage rather than stored.

Plan: docs/plans/2026-08-02-002-feat-service-intervals-plan.md

What this does

An owner defines default rules per category — Cleaning, Barrel, and so on — each setting at least one of three thresholds: elapsed days, range sessions, or rounds fired. Every firearm and accessory in that category inherits them immediately, with no per-item setup. An item can override a rule's thresholds, suppress it entirely, or carry item-only rules no default defines.

A rule is due when elapsed days, sessions, or rounds meet or exceed any threshold it sets. Counting measures from the last time that rule was serviced, or from the item's origin date when it never has been. This is binary and advisory: distance past a threshold shows as raw counts, never a severity tier, and nothing is blocked, gated, or escalated.

Counting from day one means a mature collection lights up on arrival — that is deliberate (KD6: honest totals beat zeroed ones), so /summary carries a backlog checklist that marks many items serviced in one action.

Notable decisions

  • Due state is derived, never stored, matching Lifetime Total and Last Inventoried. Rules and events are the only persisted facts.
  • Rules are keyed by name, so a category default and an item override are the same rule and a service event needs no rule row to exist. This is what let the cleaned/lubed conversion avoid inventing threshold values.
  • Service rows attach through two nullable foreign keys with an exactly-one CHECK, not the parent_type shape used by grant and inventory_log — native ON DELETE CASCADE replaces a hand-written cleanup trigger.
  • Authorization splits by family: an edit-grantee may log service on a shared firearm (matching what cleaned already permitted), rule configuration is owner-only, and accessories are owner-only throughout since they are not a grantable parent type.
  • Collection-wide due resolution runs in a bounded number of queries regardless of collection size.

Breaking change

inventoried is now the only firearm inventory-log event type. Every existing cleaned entry becomes a Cleaning service event and every lubed entry a Lubrication one, preserving actor, notes, and insertion time.

This migration is irreversible and has no down-migration. It reconciles row counts inside a PL/pgSQL block — rows read must equal rows inserted and rows deleted — and raises on any mismatch, rolling the whole migration transaction back rather than silently losing history. It assumes no other writer is inserting cleaned/lubed rows during the migration window; that assumption is documented in the migration itself.

Converted events carry no rules with them by design. History reattaches once an owner creates a rule of the matching name.

Scope added after the plan was approved

Three items were pulled in during implementation, each recorded in the plan's Scope Boundaries:

  • Bulk mark-serviced had no surface. R16 was implemented in the domain layer but nothing called it, so the day-one backlog still had to be cleared one item at a time.
  • A mis-logged service event could not be corrected. Since due state is the latest event per rule, one wrong entry skewed that rule permanently. Owners can now edit or delete an event.
  • Accessories gained an acquired date, closing the same cold start firearms already had, plus category suggestions drawn from the owner's own existing categories. Category remains free text with exact matching per KD10 — suggestions only reduce the typo that silently costs an owner a default set.

Review

Nine simplification findings and every code-review finding were fixed in this branch; none were deferred to follow-up. The review caught, among others:

  • The future-date check compared against the server's clock, so a user east of it had their own local "today" rejected for part of every day — the exact date the form pre-fills.
  • The bulk checklist offered view-only grantees rows they could not act on, and checking one rolled back every other item in the batch.
  • A rule renamed between page render and submit wrote history under a dead name that nothing measured from.
  • "Suppress" on an item-only rule destroyed its thresholds while "Restore" claimed it could bring them back; item-only rules now offer Remove.
  • The U5 conversion had no test coverage — the plan required five scenarios for it and none had been written. The migration now runs against seeded pre-migration rows in its own container, proving the mapping, field fidelity, untouched rows, and that a reconciliation mismatch rolls the whole batch back.

Test plan

  • just ci-check green before every one of the 15 commits — lockfile, lint, format, typecheck, pre-commit, bun test, and the full e2e suite. Never bypassed.
  • Pure derivation passes under UTC, Asia/Tokyo, and America/New_York
  • Migration applies to a fresh database; db:generate produces no diff
  • Conversion proven against seeded pre-migration rows, including the abort-and-rollback path
  • Authorization covered for owner, edit-grantee, view-grantee, and no-visibility on both families
  • Collection-wide due resolution proven bounded as the collection grows
  • Backup coverage guard green for all three new tables
  • No data-testid introduced; e2e targets ARIA roles, accessible names, and visible text
  • Confirm the demo walkthrough reads well with the seeded mix of due and not-due rules

Requirements from issue #10 enriched to implementation-ready: ten
implementation units, verification contract, and definition of done.
Records the ten product decisions and ten technical decisions settled
during brainstorming and planning.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Adds service_rule_default, service_rule, and service_event, plus a
nullable acquired_date on firearm.

Service rows attach to a firearm or an accessory through two nullable
foreign keys with an exactly-one CHECK rather than the parent_type
shape used by grant and inventory_log, so native ON DELETE CASCADE
replaces a hand-written cleanup trigger. Rules are keyed by name so a
default and an item override are the same rule, and events name their
rule by name rather than referencing a rule row.

Registers all three tables in EXPORT_TABLE_ORDER and adds factories.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
resolveEffectiveRules folds category defaults and item rules into one
effective rule set, marking each inherited, overridden, or item-only;
a suppressed item rule removes its matching default. elapsedCounts
folds days, sessions, and rounds from a measure-from date, counting
only sessions strictly after it. isDue reports met-or-exceeded on any
set threshold and names the axis that tripped.

No database access here — every surface reads this layer's output, so
it stays exhaustively testable without Docker. All calendar work is in
the local frame with date fixtures built as new Date(y, month, d);
tests pass under UTC, Asia/Tokyo, and America/New_York.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Owner-scoped CRUD for category defaults, plus per-item overrides,
suppressions, and item-only rules.

Authorization splits by family and by operation: firearm rule writes
are owner-only, firearm rule reads follow the firearm's own visibility
so a view-grantee sees the owner's resolved rules, and accessory reads
and writes check accessory ownership directly rather than inheriting
permission from a mounted firearm. Unseen items raise NotFoundError so
existence is never revealed.

Renaming an item rule re-points that item's service events in the same
transaction, and a rename onto a name the item already carries is
rejected before the write rather than surfacing as a constraint
violation.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Logs service against a rule on one or many items, reads history back
newest-first, and resolves due state across a whole visible collection
in a bounded number of queries.

Bulk mark-serviced authorizes each item with the same per-family rules
as single logging, so it stays a convenience over the single path
rather than a higher-privilege one, and rejects the whole batch if any
item fails. Accessory session attribution restricts to firearms in the
requester's visible set so a remounted accessory cannot leak rounds
from a firearm the actor cannot see, and collection-wide reads scope
accessories to the owner because accessory service is owner-only.

Loaders only load; every due decision comes from the pure layer.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
BREAKING CHANGE: inventoried is now the only firearm inventory-log
event type. Every existing cleaned entry becomes a Cleaning service
event and every lubed entry a Lubrication service event, preserving
actor, notes, and insertion time.

The conversion is irreversible and has no down-migration, so it
reconciles counts inside a PL/pgSQL block: rows read must equal rows
inserted and rows deleted, and any mismatch raises, rolling the whole
migration transaction back rather than silently losing history. The
CHECK replacement follows the delete, since narrowing it first would
reject the table's own historical rows.

No service rule is seeded — a converted event is simply a point any
later rule of that name measures from.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Surfaces the nullable acquired date through the firearm form, detail
view, validator, and service, matching how magazines and ammo already
carry theirs. The field is clearable — blanking it persists as null
rather than a zero date.

Service-interval day counting measures from this date when it is set
and falls back to the record's creation date when it is not, so an
owner who enters a rifle owned for years is not told it was acquired
today.

Format is validated here (magazines and ammo persist theirs
unvalidated) following the accessory installed-date precedent, since
a malformed date would silently corrupt the day axis.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
An owner arms the whole feature from /settings/service without
visiting a single item: firearm categories come from the existing type
list, accessory categories from the owner's own distinct categories
with free entry still allowed so a category can be armed before any
accessory uses it.

Editing a default states how many items it reaches, so the live
inheritance consequence is visible before saving. Server actions carry
the owner-only gate and never trust a client-supplied owner.

The settings page links through to it — the screen that arms the
feature is not reachable by URL alone.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
One panel serves both firearms and accessories, since the derivation
already produces a single shape for both. Each rule shows its
thresholds, elapsed counts on all three axes, its inheritance state,
and whether it is due — as raw counts with no severity tier, and as a
marker rather than a modal. This is advisory; nothing blocks.

Rule actions are override, reset-to-inherited, suppress, restore, and
add-item-only; override and add-item-only share one named rule form
with the defaults screen's fields and messages. Log-service shows for
an edit-grantee on a firearm and the owner only on an accessory, while
rule actions stay owner-only on both. An item with no rules points at
the defaults screen instead of rendering blank.

Extracts the event validator into a database-free module so the
client-side form can import it without pulling pg into the browser
bundle.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
/summary gains a service line counting both items due and rules due,
so an owner sees breadth and volume together. It folds into the
existing Summary shape rather than a parallel one, since the page
already loads the visible inventory once and the service loaders take
the same visible set — the query count stays flat as the collection
grows.

Firearm and accessory rows carry a Service due badge with visible
text, never color alone. An item due only because of an accessory
mounted to it is not itself marked; the accessory is.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
…(U10)

The demo inventory now carries acquired dates, category defaults, a
couple of overrides, and some service history, seeded relative to now
so the walkthrough keeps showing a mix of due and not-due rules
instead of going stale.

Two end-to-end specs close the loop: the full flow proves a default
set from settings marks items due across the collection without
visiting one, that logging service clears that rule everywhere, and
that an override survives a later default change. The sharing spec
proves the permission split — an edit-grantee logs service but gets no
rule actions, a view-grantee gets neither, and accessory service
configuration never reaches a firearm's grantee.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
The due-resolution pipeline ran twice on every firearms page load —
inventorySummary resolved it internally and the page resolved it again
for the row markers. Both now share one result, and the visible
firearm rows are threaded through instead of being fetched three
times. The defaults settings page replaces two queries per category
with one grouped query per scope.

Consolidates the two near-identical rule-editing forms into one
component, extracts the calendar-date helpers that this branch had
grown to four copies, and drops getEffectiveRules, whose only callers
were its own tests — those now exercise the live read path instead.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
The bulk path existed in the domain layer but nothing called it, so an
owner with a large collection still had to open every item — the exact
backlog that counting from day one creates.

/summary now carries a checklist of every due item and rule with one
date for the whole batch. Nothing is preselected, since marking
service has no undo, and the checklist itself shows precisely what
will be written rather than interrupting with a confirmation.

Bulk authorization now resolves in two queries instead of one per
item, with the outcome unchanged: every item is still checked and the
whole batch still rolls back if any item fails.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Logging service no longer rejects a submitter's own local date. The
server compared against its own clock, so anyone east of it had today
refused for part of every day — exactly the date the form pre-fills.
Acquired dates get the same tolerance, and a future one no longer
freezes day counting at zero.

The bulk checklist now offers only items the actor can actually write
to. A view-only grantee was shown the owner's due rules as selectable
rows, and checking one rolled back every other item in the batch.
Roll-up counts are unchanged — seeing a shared item's due state is
still information, just not an action.

Service events now verify their rule still exists, so a rename between
render and submit fails loudly instead of writing history under a dead
name that nothing measures from. Renames lock the row before
repointing, so two of them cannot strand history on an intermediate
name. Bulk batches are capped, future-dated sessions no longer count
toward elapsed totals, and suppressing a rule with thresholds is
rejected rather than silently dropping them.

An item-only rule now offers Remove instead of Suppress. Suppression
masks an inherited default; an item-only rule has nothing underneath
it, so suppressing destroyed the thresholds and Restore could not
bring them back despite saying so.

Adds the U5 conversion tests the plan required and this branch had
missed: the migration now runs against seeded pre-migration rows in
its own container, proving the mapping, field fidelity, untouched
rows, and that a reconciliation mismatch rolls the whole batch back.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
A mis-logged service event could not be fixed. Because due state is
the latest event per rule, one wrong entry skewed that rule forever
with no way back. Owners — and edit-grantees on a shared firearm — can
now correct a date or notes, or delete the event outright. The rule
and the parent item are deliberately not editable; renaming and
re-logging cover that, and letting an event move between rules would
rewrite two rules' due state at once.

Deleting needs no special handling: due state derives on read, so
removing the newest event falls back to the previous one, or to the
item's origin date when it was the only one.

Accessories gain an acquired date, closing the same cold start
firearms already had — one owned for years but entered today no longer
reads as freshly acquired. The accessory form now also suggests the
owner's own existing categories. Category stays free text with exact
matching per KD10; suggestions only reduce the typo that silently
costs an owner a default set.

Also fixes the migration-conversion test's folder truncation, which
stripped one migration by name rather than everything past it —
drizzle tracks a high-water mark, so a newer migration made the
older one look applied and silently skipped it.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Copilot AI lite review requested due to automatic review settings August 4, 2026 13:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 7d1e7a1e-f7fc-4bce-a5cd-00c33916a41c

📥 Commits

Reviewing files that changed from the base of the PR and between 8ad253a and 4a0c28f.

📒 Files selected for processing (2)
  • app/(app)/accessories/[id]/__tests__/service-props.test.ts
  • app/(app)/settings/service/__tests__/actions.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/(app)/accessories/[id]/tests/service-props.test.ts

  • Added service interval tracking for firearms and accessories. Owners configure category defaults at /settings/service; items can inherit, override, suppress, or add item-only rules. Due state uses elapsed days, range sessions, or rounds fired.
  • Added Next.js detail, list, and /summary surfaces for due indicators, service history, bulk actions, event correction/deletion, acquired dates, and accessory category suggestions. Server actions enforce owner-only rule configuration and edit-grantee service logging.
  • Added Drizzle service tables, constraints, indexes, transactional rule operations, validation, ownership checks, and "Unknown" actor-name fallback.
  • Added bun:test unit and integration coverage, component validation, migration tests, and Playwright E2E coverage for due states, sharing, rule actions, bulk service, history, and authorization.
  • Migration 0020 irreversibly converts cleaned and lubed inventory events to service events and rolls back on reconciliation mismatch. External email and push notifications remain deferred beyond Phase 1.

Walkthrough

Service interval tracking adds configurable rules, service events, acquired dates, due-state calculation, service history, bulk servicing, settings, migrations, and permission-aware firearm and accessory workflows. Retired cleaned and lubed inventory events convert to service events.

Changes

Service interval tracking

Layer / File(s) Summary
Schema and domain services
src/db/..., src/domain/service-intervals/..., src/lib/dates.ts
Adds service-rule and service-event persistence, validation, inheritance, suppression, due-state derivation, authorization, history, bulk operations, and shared date utilities.
Settings and item workflows
app/(app)/settings/service/..., app/(app)/firearms/..., app/(app)/accessories/...
Adds owner-scoped rule configuration, acquired-date fields, detail-page service panels, service logging, history editing, rule actions, and due badges.
Summary and bulk servicing
app/(app)/summary/..., src/domain/summary/...
Adds due-item and due-rule rollups, permission-filtered service backlogs, and bulk service submission.
Inventory migration and compatibility
src/db/migrations/..., src/domain/inventory-log/..., app/(app)/inventory-log/...
Converts retired firearm inventory events into service events and restricts firearm and magazine inventory logs to inventoried.
Validation, fixtures, and coverage
src/domain/**/__tests__/*, src/db/__tests__/*, e2e/*, src/demo/*, scripts/seed-demo.ts
Adds schema, domain, action, migration, summary, demo, and end-to-end coverage for service intervals and acquired dates.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant SummaryPage
  participant ServiceBacklogControl
  participant markServicedBulkAction
  participant EventsService
  User->>SummaryPage: view due-service rollup
  SummaryPage->>ServiceBacklogControl: render actionable backlog
  User->>ServiceBacklogControl: select items and submit date
  ServiceBacklogControl->>markServicedBulkAction: submit selected service items
  markServicedBulkAction->>EventsService: create service events
  EventsService-->>markServicedBulkAction: return created events
  markServicedBulkAction-->>ServiceBacklogControl: return result
Loading

Possibly related PRs

Suggested labels: enhancement, documentation, testing, backend, frontend, shared, priority:medium

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR satisfies most requirements in #10, but service events capture dates and notes without the required optional round count. Add an optional round-count field to service events and forms, persist it, and use it in due-state calculations where required by #10.
Docstring Coverage ⚠️ Warning Docstring coverage is 56.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commits and accurately describes the service-interval feature.
Description check ✅ Passed The description covers the change, linked issue, implementation details, test plan, and known follow-up work, although it does not use every template heading.
Out of Scope Changes check ✅ Passed The changes remain focused on service intervals, migration, acquired dates, UI surfaces, authorization, tests, documentation, and related demo data.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch 10-add-service-interval-tracking-and-in-app-maintenance-reminders-for-firearms-and-accessories

Warning

Review ran into problems

🔥 Problems

These MCP integrations need to be re-authenticated in the Integrations settings: Notion


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/(app)/summary/page.tsx (1)

58-64: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

The empty-state gate hides the new Service section for accessory-only owners.

The gate checks magazines, firearm counts, and ammo lots only. An owner who has accessories but no firearms, magazines, or ammo passes all three checks. SummaryTables never renders, so the due roll-up and the bulk mark-serviced control stay hidden even when summary.itemsDue > 0. Accessory due entries are reachable in that state: listDueForVisibleCollection builds accessory entries independently of the firearm set.

Include the service roll-up in the gate.

🐛 Include the service roll-up in the empty-state gate
       {summary.totalMagazines === 0 &&
       summary.firearmCounts.length === 0 &&
-      summary.totalAmmoLots === 0 ? (
+      summary.totalAmmoLots === 0 &&
+      summary.itemsDue === 0 ? (
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/summary/page.tsx around lines 58 - 64, Update the empty-state
condition in the summary page to also require no service items due, using
summary.itemsDue alongside the existing magazine, firearm, and ammo checks. This
must allow SummaryTables to render for accessory-only owners when due entries
exist, preserving access to the service roll-up and bulk mark-serviced control.
app/(app)/firearms/firearm-form.tsx (1)

153-159: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

acquiredDateInFuture blocks submit with no visible error.

validateFirearm returns acquiredDateInFuture for a date beyond FUTURE_DATE_TOLERANCE_DAYS (src/domain/firearms/validate.ts:63-86). This form handles only invalidAcquiredDate: focusOrder omits the future code, and the Field error reads firstMessage(codes, ["invalidAcquiredDate"]). On a future date, found.length > 0 returns early, no field error renders, and focus does not move. The submit button appears dead.

🐛 Proposed fix
+const ACQUIRED_DATE_CODES: FirearmValidationCode[] = [
+  "invalidAcquiredDate",
+  "acquiredDateInFuture",
+];
+
-    { codes: ["invalidAcquiredDate"], id: dateId },
+    { codes: ACQUIRED_DATE_CODES, id: dateId },
   ];
         <Field
           label="Acquired date"
           controlId={dateId}
           hint="Optional"
-          error={firstMessage(codes, ["invalidAcquiredDate"])}
+          error={firstMessage(codes, ACQUIRED_DATE_CODES)}
         >
           <Input
             id={dateId}
             type="date"
             value={values.acquiredDate}
             onChange={(e) => set("acquiredDate", e.target.value)}
-            aria-invalid={codes.includes("invalidAcquiredDate")}
+            aria-invalid={ACQUIRED_DATE_CODES.some((c) => codes.includes(c))}
           />
         </Field>

Also applies to: 310-324

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/firearms/firearm-form.tsx around lines 153 - 159, Update the
firearm form’s validation handling to include acquiredDateInFuture alongside
invalidAcquiredDate: add it to focusOrder for dateId and include it in the date
Field error codes used by firstMessage. Preserve the existing submit-blocking
behavior while ensuring future dates display an error and move focus to the date
field.
🧹 Nitpick comments (21)
src/test-support/factories.ts (2)

217-233: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Protect ownerId the same way the parent keys are protected.

overrides is Partial<typeof serviceRuleDefault.$inferInsert>, which includes ownerId, and it spreads after the explicit ownerId. A caller that passes ownerId in overrides silently overrides the first argument. makeServiceRule and makeServiceEvent guard against exactly this for the parent keys by omitting them from the override type. Apply the same shape here — these fixtures back owner-scoped authorization tests, where a mis-owned row makes a test pass for the wrong reason.

♻️ Proposed fix
 export async function makeServiceRuleDefault(
   ownerId: string,
-  overrides: Partial<typeof serviceRuleDefault.$inferInsert> = {},
+  overrides: Partial<
+    Omit<typeof serviceRuleDefault.$inferInsert, "ownerId">
+  > = {},
 ): Promise<typeof serviceRuleDefault.$inferSelect> {
   const [row] = await db
     .insert(serviceRuleDefault)
     .values({
-      ownerId,
       scope: "firearm",
       category: "rifle",
       name: "Cleaning",
       intervalRounds: 500,
       ...overrides,
+      ownerId,
     })
     .returning();
   return row;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test-support/factories.ts` around lines 217 - 233, Update
makeServiceRuleDefault so its overrides type omits ownerId from
serviceRuleDefault.$inferInsert, matching the parent-key protection used by
makeServiceRule and makeServiceEvent. Keep the explicit ownerId argument
authoritative while preserving all other override fields.

242-258: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

{ suppressed: true } alone fails the DB CHECK.

The default sets intervalRounds: 500, and overrides spreads over it. A caller writing makeServiceRule(parent, { suppressed: true }) keeps intervalRounds: 500 and violates service_rule_suppressed_thresholds_consistent in src/db/inventory-schema.ts. The test then fails with an opaque Postgres CHECK error instead of a clear signal, and the caller must remember to null all three thresholds.

Force the thresholds to null when suppressed is true, so the factory can only produce rows the schema accepts.

♻️ Proposed fix
 ): Promise<typeof serviceRule.$inferSelect> {
+  const suppressed = overrides.suppressed ?? false;
   const [row] = await db
     .insert(serviceRule)
     .values({
       name: "Cleaning",
       intervalRounds: 500,
       ...overrides,
+      ...(suppressed
+        ? {
+            intervalDays: null,
+            intervalSessions: null,
+            intervalRounds: null,
+          }
+        : {}),
       ...parent,
     })
     .returning();
   return row;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test-support/factories.ts` around lines 242 - 258, Update makeServiceRule
so that when overrides.suppressed is true, intervalRounds and the other
service-rule threshold fields are explicitly set to null after applying
overrides and parent values. Preserve the existing defaults for non-suppressed
rules, ensuring suppressed factory rows satisfy the schema CHECK without
requiring callers to null thresholds manually.
src/domain/service-intervals/rules-service.ts (2)

536-546: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the stale doc block.

This comment documents a function that no longer exists in the file ("this function was removed"). It now sits directly above listItemRules and describes different behavior. Delete it, or fold the useful part (defaults load against the item's owner, not the viewer) into getItemDueState's doc in due-service.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/service-intervals/rules-service.ts` around lines 536 - 546, Remove
the stale documentation block immediately above listItemRules in
rules-service.ts, since it describes the deleted function rather than
listItemRules. Do not alter listItemRules behavior; only retain the owner-based
defaults detail if it is incorporated into the existing getItemDueState
documentation in due-service.ts.

161-169: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Map the unique-constraint violation back to duplicateName.

assertNameAvailable reads siblings and then writes in the same transaction. Under READ COMMITTED, two concurrent creates (a double submit) both pass this check and both reach the insert. The DB unique constraints (service_rule_default_owner_scope_category_name_unique, service_rule_firearm_name_unique, service_rule_accessory_name_unique) then reject the second one with a raw driver error, not the ValidationError(["duplicateName"]) the form knows how to render. That contradicts the claim on Line 158-159 and Line 271.

Catch the unique violation at each write site and rethrow it as the same ValidationError, so the pre-check stays an optimization and the constraint stays the guarantee.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/service-intervals/rules-service.ts` around lines 161 - 169, Update
each write site that persists service rules to catch database unique-constraint
violations for the named constraints and rethrow
ValidationError(["duplicateName"]). Keep assertNameAvailable as the pre-check
optimization, while ensuring concurrent inserts receive the same form-renderable
validation error instead of the raw driver error.
src/domain/service-intervals/due-service.ts (2)

300-318: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the two identical origin-date helpers.

firearmOriginDate and accessoryOriginDate have identical bodies and identical parameter shapes. One itemOriginDate(row: { acquiredDate: string | null; createdAt: Date }) covers both and removes the risk of the two drifting apart.

♻️ Proposed refactor
-function firearmOriginDate(row: {
-  acquiredDate: string | null;
-  createdAt: Date;
-}): Date {
-  return row.acquiredDate !== null ? parseISO(row.acquiredDate) : row.createdAt;
-}
-
-/**
- * An accessory's origin date (KTD9, updated during implementation) —
- * `acquiredDate` when set, else `createdAt`, exactly parallel to
- * `firearmOriginDate` above. Added after the plan's original scope; see this
- * file's header comment.
- */
-function accessoryOriginDate(row: {
-  acquiredDate: string | null;
-  createdAt: Date;
-}): Date {
-  return row.acquiredDate !== null ? parseISO(row.acquiredDate) : row.createdAt;
-}
+/**
+ * An item's origin date (KTD9, updated during implementation) — `acquiredDate`
+ * when set, else `createdAt`. Identical for firearms and accessories; see this
+ * file's header comment for why accessories gained `acquiredDate`.
+ */
+function itemOriginDate(row: {
+  acquiredDate: string | null;
+  createdAt: Date;
+}): Date {
+  return row.acquiredDate !== null ? parseISO(row.acquiredDate) : row.createdAt;
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/service-intervals/due-service.ts` around lines 300 - 318, Replace
the duplicate firearmOriginDate and accessoryOriginDate helpers with a single
itemOriginDate helper using the shared row shape and existing date-selection
logic. Update all callers to use itemOriginDate, removing the redundant helper
and preserving current behavior.

591-599: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Sensitive Data Exposure (CWE-200): Exposure of Sensitive Information to an Unauthorized Actor

Reachability: Internal · Exploitability: Theoretical

Reachability path
● Entry
  app/(app)/summary/service-backlog-control.tsx:82
  submit
│
▼
● Hop
  src/domain/service-intervals/events-service.ts:183
  loadEffectiveRuleNames
│
▼
● Sink
  src/domain/service-intervals/due-service.ts

Rename preloadedFirearms to encode its visibility contract. The current callers pass listFirearms(actorId) results, but loadVisibleItems trusts this parameter and uses its IDs to filter accessory round counts. Name it visibleFirearmsAlreadyLoaded to make the required scope explicit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/service-intervals/due-service.ts` around lines 591 - 599, Rename
the listDueForVisibleCollection parameter preloadedFirearms to
visibleFirearmsAlreadyLoaded and update its use when calling loadVisibleItems.
Preserve the existing behavior while making clear that callers must provide
firearms already filtered to the actor’s visible collection.
src/domain/service-intervals/validate.ts (1)

39-43: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add integer validation to hasThresholdBelowMin.

The form component toRuleInput uses Number() to parse threshold strings (not Number.parseInt). This allows fractional values like 1.5 and invalid values like NaN to reach the validator. The hasThresholdBelowMin check only validates the lower bound; non-integer thresholds pass validation because numeric comparison 1.5 < 1 and NaN < 1 produce the expected false result. These values then fail at database insert time as unhandled driver errors instead of returning a ValidationError with a message the form can render.

Add !Number.isInteger(value) to catch non-integer thresholds:

Proposed change
 function hasThresholdBelowMin(rule: ServiceRuleInput): boolean {
   return [rule.intervalDays, rule.intervalSessions, rule.intervalRounds].some(
-    (value) => isSetThreshold(value) && value < MIN_THRESHOLD,
+    (value) =>
+      isSetThreshold(value) &&
+      (!Number.isInteger(value) || value < MIN_THRESHOLD),
   );
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/service-intervals/validate.ts` around lines 39 - 43, Update
hasThresholdBelowMin to treat any set threshold that is not an integer as
invalid, alongside the existing MIN_THRESHOLD check, so fractional and NaN
values produce the normal validation error path.

Source: Coding guidelines

src/domain/service-intervals/events-service.ts (1)

104-157: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Authorization Bypass (CWE-863): Incorrect Authorization

Reachability: External

Reachability path
● Entry
  app/(app)/summary/service-backlog-control.tsx:82
  submit
│
▼
● Sink
  src/domain/service-intervals/events-service.ts

Add a test that verifies authorization parity between single and bulk paths for firearm access tiers.

authorizeEventWritesBatch reimplements firearm authorization logic inline using visibleFirearmPermissions instead of calling the single-path's authorizeUpdate function (which itself calls resolvePermission). Both paths currently produce identical outcomes for owner, edit, view, and not-found cases. However, if resolvePermission is later enhanced to include additional authorization conditions—archived items, suspended accounts, maintenance gates—the batch path will not inherit those changes, silently creating a looser authorization gate.

Add a test that calls both logServiceEvent and logServiceEventsBulk against the same firearm with the same actor under owner, edit, view, and stranger permission states. Verify both paths reject and allow identically.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/service-intervals/events-service.ts` around lines 104 - 157, Add a
test covering authorization parity between logServiceEvent and
logServiceEventsBulk for the same firearm and actor across owner, edit, view,
and stranger states. Assert both paths allow or reject identically, including
the expected authorization error categories, so future changes to single-path
authorization cannot leave authorizeEventWritesBatch inconsistent.
src/domain/firearms/__tests__/validate.test.ts (1)

123-132: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pass an explicit asOf to the valid-date test.

This test relies on the default asOf = new Date(). "2026-06-14" only stays valid while the system clock is at or after 2026-06-13. Every other date-sensitive test in this file passes asOf explicitly. Match that pattern so the case is clock-independent.

♻️ Proposed fix
   test("a real ISO calendar date is valid", () => {
     expect(
-      validateFirearm({
-        name: "Glock 19",
-        caliber: "9mm",
-        ...CLASS,
-        acquiredDate: "2026-06-14",
-      }),
+      validateFirearm(
+        {
+          name: "Glock 19",
+          caliber: "9mm",
+          ...CLASS,
+          acquiredDate: "2026-06-14",
+        },
+        new Date(2026, 5, 15),
+      ),
     ).toEqual([]);
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/firearms/__tests__/validate.test.ts` around lines 123 - 132,
Update the “a real ISO calendar date is valid” test to pass an explicit fixed
asOf date to validateFirearm, matching the other date-sensitive tests and
ensuring the 2026-06-14 case remains independent of the system clock.
src/domain/summary/__tests__/summary.test.ts (1)

624-658: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Exact query-count equality can flake if any other query runs during the measured window.

countPoolQueries patches the shared Pool.prototype.query, so it counts every query issued by the whole process during fn. expect(largeCount).toBe(smallCount) then fails if an unrelated concurrent query lands in either window. Prefer an upper bound so the test still proves the property (no per-item query) without depending on process-wide isolation.

♻️ Bound the count instead of requiring exact equality
-    // Same bounded query count regardless of collection size (Definition of
-    // Done, U9/U4) — never a per-item query.
-    expect(largeCount).toBe(smallCount);
+    // Bounded regardless of collection size (Definition of Done, U9/U4) —
+    // never a per-item query: 5x the items must not grow the query count.
+    expect(largeCount).toBeLessThanOrEqual(smallCount);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/summary/__tests__/summary.test.ts` around lines 624 - 658, Update
the query-count assertion in the “inventorySummary's service roll-up loads in a
bounded number of queries” test to verify an upper bound rather than exact
equality between largeCount and smallCount. Keep the existing measurements and
item-count assertions, and ensure the assertion still detects per-item query
growth without relying on process-wide query-count isolation.
src/domain/summary/summary.ts (1)

262-273: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Forward firearms to listDueForVisibleCollection when dueEntries is omitted.

When a caller passes firearms but not dueEntries, the due pipeline reloads the visible firearm set itself (getVisibleIds + select). listDueForVisibleCollection accepts a preloadedFirearms argument for exactly that case. The current signature types firearms as FirearmIdentity[], which is narrower than the FirearmRow[] the due service expects, so forwarding requires widening the parameter type.

This is an optimization only; the present callers pass both arguments, so no current path double-loads.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/summary/summary.ts` around lines 262 - 273, Update
inventorySummary to accept the firearm shape required by
listDueForVisibleCollection, and when dueEntries is omitted, pass the resolved
or supplied firearms as its preloadedFirearms argument. Preserve the existing
dueEntries path and ensure the shared firearm value remains available to both
Promise.all operations.
app/(app)/summary/page.tsx (1)

24-34: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Magazine and ammo loads now wait on the due pipeline.

inventorySummary runs listMagazines and listAmmo in parallel with its own due load. Because dueEntries is passed in, those two loads start only after listDueForVisibleCollection finishes. The result is three sequential phases instead of two. This is latency only; correctness is unaffected.

If the extra round-trip time matters on this page, start the due load in the same Promise.all as the item loads and pass firearms after it resolves, or load magazines and ammo in the page and keep inventorySummary purely computational.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/summary/page.tsx around lines 24 - 34, Update the summary loading
flow around inventorySummary so magazine and ammunition loading overlaps with
listDueForVisibleCollection instead of waiting for it; either start the due
request in the existing Promise.all and pass the resolved dueEntries to
inventorySummary after firearms are available, or move those loads into the page
while keeping inventorySummary computational.
app/(app)/summary/service-backlog-control.tsx (1)

146-163: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Row id values derive from user data and can contain spaces.

rowKey embeds row.ruleName, and rule names accept spaces (for example Recoil spring). inputId therefore becomes an id attribute with spaces, which is invalid HTML and breaks any CSS or querySelector lookup for that element. The prefix also reuses dateId, which belongs to the date input and is unrelated to these rows.

Use a dedicated id prefix and a positional suffix.

♻️ Derive row ids from a dedicated prefix and index
   const dateId = useId();
   const notesId = useId();
   const selectAllId = useId();
+  const rowIdPrefix = useId();
-          {backlog.map((row) => {
+          {backlog.map((row, index) => {
             const key = rowKey(row);
-            const inputId = `${dateId}-${key}`;
+            const inputId = `${rowIdPrefix}-${index}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/summary/service-backlog-control.tsx around lines 146 - 163, Update
the backlog map in the row-rendering component to derive each inputId from a
dedicated row-specific prefix and the map index, rather than dateId and rowKey.
Keep rowKey for selection and React key behavior, while ensuring every checkbox
id is stable, unique within the list, and free of user-provided spaces.
app/(app)/summary/__tests__/actions.test.ts (1)

126-139: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add the no-revalidation assertion to the ValidationError case.

The NotFoundError test proves revalidateCalls stays empty. The ValidationError test asserts only the mapped codes. Both failures must leave the cache untouched, and only one of them pins that behavior.

💚 Pin the cache behavior on the validation path too
     expect(result.ok).toBe(false);
     expect(result.ok === false && result.codes).toEqual(["servicedOnInFuture"]);
+    expect(revalidateCalls).toEqual([]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/summary/__tests__/actions.test.ts around lines 126 - 139, Add an
assertion to the “a future servicedOn maps a thrown ValidationError to a failed
ActionResult with codes” test verifying that revalidateCalls remains empty,
matching the existing NotFoundError test and preserving the cache-untouched
behavior for validation failures.
app/(app)/settings/service/__tests__/actions.test.ts (1)

198-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a delete-failure case to match the update suite.

The delete suite covers only the signed-out and success paths. The update suite already asserts that a NotFoundError (another owner's default) maps to a non-leaking failure. Mirror it here so a regression in delete error mapping fails a test.

💚 Proposed test
+  test("maps a NotFoundError (another owner's default) to a non-leaking failed ActionResult, and does not revalidate", async () => {
+    currentUserId = "user-1";
+    deleteThrows = new NotFoundError();
+
+    const result = await deleteServiceRuleDefaultAction("default-1");
+
+    expect(result.ok).toBe(false);
+    expect(revalidateCalls).toHaveLength(0);
+  });
As per coding guidelines: "new behavior needs coverage, and bug fixes should include regression tests where practical."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/settings/service/__tests__/actions.test.ts around lines 198 - 217,
Add a delete-failure test in the deleteServiceRuleDefaultAction suite that makes
deleteServiceRuleDefault throw NotFoundError for another owner’s default, then
assert a non-leaking failure result and no revalidation. Mirror the existing
update-suite error-mapping setup and assertions, while preserving the
unauthenticated and success cases.

Source: Coding guidelines

app/(app)/firearms/service-rules-panel.tsx (1)

214-289: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the four mutation handlers into one helper.

reset, suppress, restore, and remove are the same shape: run the action in a transition, toast a success message, or toast result.error with the destructive tone. One helper taking the action, the success message, and the failure message removes about 60 lines and keeps the four call sites readable.

♻️ Proposed shape
+  function runRuleAction(
+    action: () => Promise<ActionResult>,
+    success: string,
+    failure: string,
+  ) {
+    startTransition(async () => {
+      const result = await action();
+      if (result.ok) {
+        afterMutation(success);
+      } else {
+        toast({ message: result.error ?? failure, tone: "destructive" });
+      }
+    });
+  }

Call sites then read as:

runRuleAction(
  () => resetServiceRuleAction(parentType, parentId, ruleName),
  `${ruleName} reset to inherited`,
  "Could not reset rule.",
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/firearms/service-rules-panel.tsx around lines 214 - 289, Replace
the duplicated logic in reset, suppress, restore, and remove with a shared
runRuleAction helper that accepts the action callback, success message, and
failure fallback, while preserving the existing transition, success mutation,
and destructive error toast behavior. Update each handler to call the helper
with its corresponding service action and messages, including
removeItemOnlyRuleAction and its “removed” success text.
app/(app)/firearms/[id]/page.tsx (1)

82-87: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Wrap the new service loaders in the same NotFoundError → notFound() guard as their siblings.

Lines 69-75 state the convention: if access is revoked between getFirearm and these calls, a NotFoundError must become the page's clean 404 instead of a 500. getItemDueState, listItemRules, and listServiceHistory all authorize internally and can throw the same error, but they run unguarded in the same Promise.all.

Extract the guard so the three new calls share it.

♻️ Proposed refactor
+  const asNotFound = (error: unknown): never => {
+    if (error instanceof NotFoundError) notFound();
+    throw error;
+  };
+
   const [
     caliberSuggestions,
@@
-    getItemDueState(user.id, "firearm", id),
+    getItemDueState(user.id, "firearm", id).catch(asNotFound),
     // Raw item-rule rows — only their suppressed names are used here, to
     // list what's hidden from `serviceRules` above (KTD6).
-    listItemRules(user.id, "firearm", id),
-    listServiceHistory(user.id, "firearm", id),
+    listItemRules(user.id, "firearm", id).catch(asNotFound),
+    listServiceHistory(user.id, "firearm", id).catch(asNotFound),
   ]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/firearms/[id]/page.tsx around lines 82 - 87, Update the
Promise.all loading flow around getItemDueState, listItemRules, and
listServiceHistory so all three calls run through the existing
NotFoundError-to-notFound() guard used by their sibling loaders. Extract or
reuse that guard for the new service loaders, preserving the clean 404 behavior
when authorization is revoked.
app/(app)/accessories/[id]/page.tsx (1)

40-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The actor-name mapping is duplicated verbatim across both detail pages. Both pages collect distinct non-null actorId values, call namesByIds, and map ServiceEventRow[] to ServiceHistoryEntry[] with the same UNKNOWN_ACTOR_LABEL and the same raw-id fallback. Two copies of the fallback policy will drift.

  • app/(app)/accessories/[id]/page.tsx#L40-L61: export withActorNames and UNKNOWN_ACTOR_LABEL from a shared module next to ServiceHistoryEntry, since the helper already exists here in reusable form.
  • app/(app)/firearms/[id]/page.tsx#L105-L125: replace the inline actorIds / nameById / serviceHistory block with a call to the shared withActorNames(history) and drop the local UNKNOWN_ACTOR_LABEL.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/accessories/[id]/page.tsx around lines 40 - 61, The actor-name
mapping is duplicated across both detail pages. In
app/(app)/accessories/[id]/page.tsx lines 40-61, move/export withActorNames and
UNKNOWN_ACTOR_LABEL from a shared module next to ServiceHistoryEntry; in
app/(app)/firearms/[id]/page.tsx lines 105-125, replace the local
actorIds/nameById/serviceHistory logic with withActorNames(history) and remove
the local UNKNOWN_ACTOR_LABEL.
app/(app)/firearms/page.tsx (1)

34-48: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Both list pages serialize listDueForVisibleCollection ahead of queries that do not depend on it. The shared root cause is awaiting the due-state pipeline as its own step instead of joining it to the surrounding Promise.all. That pipeline is the heaviest load on each page, so the extra round trips add directly to page latency.

  • app/(app)/firearms/page.tsx#L34-L48: start the due promise, put it in the Promise.all beside calibersForInput, visibleFirearmPermissions, and primaryThumbnailsFor, then await inventorySummary from the resolved dueEntries.
  • app/(app)/accessories/page.tsx#L43-L47: the due call needs only firearms, and nothing in the preceding Promise.all depends on it — move the listFirearms result into a promise and resolve the due entries in the same batch.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/firearms/page.tsx around lines 34 - 48, In
app/(app)/firearms/page.tsx lines 34-48, start listDueForVisibleCollection as a
promise and resolve it within the existing Promise.all alongside
calibersForInput, visibleFirearmPermissions, and primaryThumbnailsFor, then pass
the resolved dueEntries to inventorySummary. In app/(app)/accessories/page.tsx
lines 43-47, move the listFirearms result into the same promise batch and
resolve listDueForVisibleCollection there using firearms, preserving the
existing dependent processing.
src/domain/service-intervals/__tests__/validate.test.ts (1)

43-61: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an intervalSessions case.

validateServiceRuleSet treats three threshold axes. This file exercises intervalRounds and intervalDays only. intervalSessions is never validated here, so a regression in the sessions branch of hasAnyThreshold or hasThresholdBelowMin would pass this suite.

As per coding guidelines: "new behavior needs coverage".

💚 Suggested additions
   test("rejects a negative threshold", () => {
     const rules: ServiceRuleInput[] = [{ name: "Cleaning", intervalDays: -1 }];
     expect(validateServiceRuleSet(rules)).toContain("thresholdTooLow");
   });
 
+  test("accepts a rule whose only threshold is intervalSessions", () => {
+    const rules: ServiceRuleInput[] = [
+      { name: "Cleaning", intervalSessions: 5 },
+    ];
+    expect(validateServiceRuleSet(rules)).toEqual([]);
+  });
+
+  test("rejects a zero intervalSessions threshold", () => {
+    const rules: ServiceRuleInput[] = [
+      { name: "Cleaning", intervalSessions: 0 },
+    ];
+    expect(validateServiceRuleSet(rules)).toContain("thresholdTooLow");
+  });
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/domain/service-intervals/__tests__/validate.test.ts` around lines 43 -
61, Add coverage for the `intervalSessions` threshold in the tests around
`validateServiceRuleSet`: include valid positive sessions behavior and invalid
zero or negative sessions behavior, matching the existing `intervalRounds` and
`intervalDays` assertions. Ensure the tests also cover a sessions-only rule so
both `hasAnyThreshold` and `hasThresholdBelowMin` paths are exercised.

Source: Coding guidelines

e2e/service-intervals-sharing.spec.ts (1)

148-150: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the actor by seeded identity, not by the user-pool key.

Line 150 asserts the history region contains the literal "service-intervals-viewer". That passes only while the seeded display name or email embeds the pool key. Use the artifact value already loaded at line 31 so the assertion survives a change to the seeding scheme.

♻️ Suggested change
-      await expect(history).toContainText("service-intervals-viewer");
+      await expect(history).toContainText(grantee.email);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/service-intervals-sharing.spec.ts` around lines 148 - 150, Update the
service history assertion in the relevant test to use the seeded identity
artifact loaded near line 31 instead of the hardcoded "service-intervals-viewer"
pool key. Preserve the existing history region and "Cleaning" assertions while
interpolating the artifact value for the actor check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/`(app)/accessories/[id]/page.tsx:
- Around line 122-127: Update the category-loading logic around
listOwnerAccessoryCategories and ownerCategories so edit-grantees cannot receive
the owner’s unrelated category vocabulary. Gate the owner-category lookup on
isOwner, and for non-owners use the actor’s own categories or another
actor-visible scope, while preserving owner behavior for the accessory owner.

In `@app/`(app)/firearms/service-actions.ts:
- Around line 76-87: The createItemRule error handling should recognize
PostgreSQL unique-constraint violations (code 23505) for the item-rule
constraint and map them through toActionError as codes ["duplicateName"] instead
of the generic error. Update the relevant toActionError mapping used by
createItemRule while preserving existing handling for other database errors.

In `@app/`(app)/firearms/service-history.tsx:
- Around line 299-309: Add a stable React key based on editingEntry.id to the
EditServiceEventForm instance in the editingEntry render branch, so switching
rows remounts the form and reinitializes its state for the selected entry.

In `@app/`(app)/settings/service/actions.ts:
- Around line 28-47: Add runtime validation at the start of
createServiceRuleDefaultAction for input.scope being "firearm" or "accessory"
and input.category and input.name being strings. On invalid input, return the
existing ActionResult validation-error shape without calling
createServiceRuleDefault; preserve the current delegation and revalidation flow
for valid input.

In `@app/`(app)/settings/service/default-rule-form.tsx:
- Around line 255-262: Update the name Input in the default rule form to use
readOnly={nameLocked} instead of disabled={nameLocked}, keeping the locked value
focusable and announced while preserving the existing nameLocked behavior.
- Around line 99-136: Update validateServiceRuleSet to reject threshold values
that are not integers, returning ValidationError before they reach the integer
database columns. Preserve the existing minimum-threshold validation for days,
sessions, and rounds, and apply the integer check to each converted value
produced by toRuleInput.

In `@app/`(app)/summary/service-backlog-control.tsx:
- Around line 109-113: When result.codes is set, check if any codes fall outside
DATE_CODES (such as bulkTooLarge or emptyRuleName). For codes not handled by the
date Field component, map them to appropriate error messages and either set
serverError with a message for the unhandled code or render a fallback message
in the existing error display. Ensure bulkTooLarge maps to a specific message if
one is defined in your firstMessage mapping. Apply this same logic to both the
initial codes check and the other location at lines 179-186 to prevent silent
failures when non-date validation codes are returned from logServiceEventsBulk.

In `@docs/plans/2026-08-02-002-feat-service-intervals-plan.md`:
- Line 212: Align the entire plan’s accessory origin-date contract: use
acquired_date when present, otherwise created_at, and never installed_date.
Update the referenced U1/U6 scope, test cases, and verification steps
consistently, including the R10 statement, so all sections describe and validate
the same fallback behavior.

In `@e2e/service-intervals-sharing.spec.ts`:
- Around line 59-64: Scope the form locator in the rule and service-log flows to
the open dialog by using the dialog locator before selecting the form. Update
the relevant form references in the test so they no longer query all forms on
the page, while preserving the existing field fills and submissions.

In `@e2e/service-intervals.spec.ts`:
- Line 164: Update the threshold formatter used by the interval UI to use
singular “day” when the value is exactly 1, while retaining plural “days” for
other values; then update the barrelRow assertion in the interval test to expect
“of 1 day”.

In `@scripts/seed-demo.ts`:
- Around line 63-69: Update the reset preflight in seed() to query
serviceRuleDefault before the hasInventory() early-return decision. Ensure
owners with service defaults but no inventory still enter reset handling and
delete those defaults before creating new inventory, while preserving the
existing behavior for owners with neither inventory nor defaults.

In `@src/db/migrations/0020_naive_scarlet_spider.sql`:
- Around line 21-50: In the migration’s DO block, acquire a transaction-held
lock on inventory_log before the initial read_count query so legacy writes are
serialized throughout the reconciliation. Use a lock mode that conflicts with
INSERT, UPDATE, and DELETE, and keep the lock acquisition ahead of the INSERT
... SELECT conversion.

In `@src/demo/__tests__/inventory.test.ts`:
- Around line 101-123: The tests call isoDateDaysAgo and todayIso independently,
and if local midnight occurs between these calls, the expected calendar-day
offsets shift by one day causing failures even in correct implementations.
Freeze the current date/time across all test cases using a shared clock mock, or
modify isoDateDaysAgo to accept an optional reference date parameter and pass
the result from todayIso into each isoDateDaysAgo call to ensure both functions
use the same base date throughout each test.

In `@src/demo/inventory.ts`:
- Around line 70-80: Add an accessory service-rule default alongside the
existing firearm defaults in the service-rule configuration, matching the
category of the seeded backdated accessory created in the seed flow. Ensure the
accessory seed uses or resolves this default rule so the cold-start example can
determine its service status and the acquiredDaysAgo behavior produces the
expected due result.

In `@src/domain/firearms/__tests__/service.test.ts`:
- Around line 711-805: The integration test suites create users and query
PostgreSQL but are not gated on DATABASE_URL, causing database connection
attempts during unit-only test runs. At both
src/domain/firearms/__tests__/service.test.ts (lines 711-805, the describe block
starting with "firearms service — acquired date (U6)") and
src/domain/accessories/__tests__/service.test.ts (lines 238-325), define a live
constant as const live = process.env.DATABASE_URL ? describe : describe.skip
before the test suite, then replace the top-level describe call with live to
conditionally skip the entire suite when DATABASE_URL is unset.

In `@src/domain/service-intervals/__tests__/events-service.test.ts`:
- Around line 189-205: The future-date test currently passes for the wrong
ValidationError because the Cleaning rule is not armed. In the test around the
logServiceEvent call, add armCleaningRule for fa before invoking it, then assert
the rejection matches codes ["servicedOnInFuture"] instead of only checking the
error type; apply the same setup and specific-code assertion to the edit-path
test near the referenced section.

In `@src/domain/service-intervals/rules-service.ts`:
- Around line 308-349: Update updateServiceRuleDefault to preserve identity when
the default name changes: either reject renames or migrate all affected
service_rule and service_event name references within the same transaction,
resolving conflicts when an item already uses the target name. Ensure inherited,
overridden, suppressed, and historical event behavior remains associated with
the renamed default, and add coverage for each case.

In `@src/domain/validation-messages.ts`:
- Line 51: Update the duplicateName validation message to use scope-neutral
wording that is accurate for both category defaults and per-item rules. Preserve
assertNameAvailable’s behavior and the existing validation-message key; only
revise the message text so it does not specifically reference a category.

In `@src/lib/dates.ts`:
- Around line 23-27: Update isRealCalendarDate to reject ISO dates with year
"0000" before Date.parse and round-trip validation. Add regression coverage
showing validateFirearm and validateAccessory return their existing invalid date
errors for year-zero inputs.

---

Outside diff comments:
In `@app/`(app)/firearms/firearm-form.tsx:
- Around line 153-159: Update the firearm form’s validation handling to include
acquiredDateInFuture alongside invalidAcquiredDate: add it to focusOrder for
dateId and include it in the date Field error codes used by firstMessage.
Preserve the existing submit-blocking behavior while ensuring future dates
display an error and move focus to the date field.

In `@app/`(app)/summary/page.tsx:
- Around line 58-64: Update the empty-state condition in the summary page to
also require no service items due, using summary.itemsDue alongside the existing
magazine, firearm, and ammo checks. This must allow SummaryTables to render for
accessory-only owners when due entries exist, preserving access to the service
roll-up and bulk mark-serviced control.

---

Nitpick comments:
In `@app/`(app)/accessories/[id]/page.tsx:
- Around line 40-61: The actor-name mapping is duplicated across both detail
pages. In app/(app)/accessories/[id]/page.tsx lines 40-61, move/export
withActorNames and UNKNOWN_ACTOR_LABEL from a shared module next to
ServiceHistoryEntry; in app/(app)/firearms/[id]/page.tsx lines 105-125, replace
the local actorIds/nameById/serviceHistory logic with withActorNames(history)
and remove the local UNKNOWN_ACTOR_LABEL.

In `@app/`(app)/firearms/[id]/page.tsx:
- Around line 82-87: Update the Promise.all loading flow around getItemDueState,
listItemRules, and listServiceHistory so all three calls run through the
existing NotFoundError-to-notFound() guard used by their sibling loaders.
Extract or reuse that guard for the new service loaders, preserving the clean
404 behavior when authorization is revoked.

In `@app/`(app)/firearms/page.tsx:
- Around line 34-48: In app/(app)/firearms/page.tsx lines 34-48, start
listDueForVisibleCollection as a promise and resolve it within the existing
Promise.all alongside calibersForInput, visibleFirearmPermissions, and
primaryThumbnailsFor, then pass the resolved dueEntries to inventorySummary. In
app/(app)/accessories/page.tsx lines 43-47, move the listFirearms result into
the same promise batch and resolve listDueForVisibleCollection there using
firearms, preserving the existing dependent processing.

In `@app/`(app)/firearms/service-rules-panel.tsx:
- Around line 214-289: Replace the duplicated logic in reset, suppress, restore,
and remove with a shared runRuleAction helper that accepts the action callback,
success message, and failure fallback, while preserving the existing transition,
success mutation, and destructive error toast behavior. Update each handler to
call the helper with its corresponding service action and messages, including
removeItemOnlyRuleAction and its “removed” success text.

In `@app/`(app)/settings/service/__tests__/actions.test.ts:
- Around line 198-217: Add a delete-failure test in the
deleteServiceRuleDefaultAction suite that makes deleteServiceRuleDefault throw
NotFoundError for another owner’s default, then assert a non-leaking failure
result and no revalidation. Mirror the existing update-suite error-mapping setup
and assertions, while preserving the unauthenticated and success cases.

In `@app/`(app)/summary/__tests__/actions.test.ts:
- Around line 126-139: Add an assertion to the “a future servicedOn maps a
thrown ValidationError to a failed ActionResult with codes” test verifying that
revalidateCalls remains empty, matching the existing NotFoundError test and
preserving the cache-untouched behavior for validation failures.

In `@app/`(app)/summary/page.tsx:
- Around line 24-34: Update the summary loading flow around inventorySummary so
magazine and ammunition loading overlaps with listDueForVisibleCollection
instead of waiting for it; either start the due request in the existing
Promise.all and pass the resolved dueEntries to inventorySummary after firearms
are available, or move those loads into the page while keeping inventorySummary
computational.

In `@app/`(app)/summary/service-backlog-control.tsx:
- Around line 146-163: Update the backlog map in the row-rendering component to
derive each inputId from a dedicated row-specific prefix and the map index,
rather than dateId and rowKey. Keep rowKey for selection and React key behavior,
while ensuring every checkbox id is stable, unique within the list, and free of
user-provided spaces.

In `@e2e/service-intervals-sharing.spec.ts`:
- Around line 148-150: Update the service history assertion in the relevant test
to use the seeded identity artifact loaded near line 31 instead of the hardcoded
"service-intervals-viewer" pool key. Preserve the existing history region and
"Cleaning" assertions while interpolating the artifact value for the actor
check.

In `@src/domain/firearms/__tests__/validate.test.ts`:
- Around line 123-132: Update the “a real ISO calendar date is valid” test to
pass an explicit fixed asOf date to validateFirearm, matching the other
date-sensitive tests and ensuring the 2026-06-14 case remains independent of the
system clock.

In `@src/domain/service-intervals/__tests__/validate.test.ts`:
- Around line 43-61: Add coverage for the `intervalSessions` threshold in the
tests around `validateServiceRuleSet`: include valid positive sessions behavior
and invalid zero or negative sessions behavior, matching the existing
`intervalRounds` and `intervalDays` assertions. Ensure the tests also cover a
sessions-only rule so both `hasAnyThreshold` and `hasThresholdBelowMin` paths
are exercised.

In `@src/domain/service-intervals/due-service.ts`:
- Around line 300-318: Replace the duplicate firearmOriginDate and
accessoryOriginDate helpers with a single itemOriginDate helper using the shared
row shape and existing date-selection logic. Update all callers to use
itemOriginDate, removing the redundant helper and preserving current behavior.
- Around line 591-599: Rename the listDueForVisibleCollection parameter
preloadedFirearms to visibleFirearmsAlreadyLoaded and update its use when
calling loadVisibleItems. Preserve the existing behavior while making clear that
callers must provide firearms already filtered to the actor’s visible
collection.

In `@src/domain/service-intervals/events-service.ts`:
- Around line 104-157: Add a test covering authorization parity between
logServiceEvent and logServiceEventsBulk for the same firearm and actor across
owner, edit, view, and stranger states. Assert both paths allow or reject
identically, including the expected authorization error categories, so future
changes to single-path authorization cannot leave authorizeEventWritesBatch
inconsistent.

In `@src/domain/service-intervals/rules-service.ts`:
- Around line 536-546: Remove the stale documentation block immediately above
listItemRules in rules-service.ts, since it describes the deleted function
rather than listItemRules. Do not alter listItemRules behavior; only retain the
owner-based defaults detail if it is incorporated into the existing
getItemDueState documentation in due-service.ts.
- Around line 161-169: Update each write site that persists service rules to
catch database unique-constraint violations for the named constraints and
rethrow ValidationError(["duplicateName"]). Keep assertNameAvailable as the
pre-check optimization, while ensuring concurrent inserts receive the same
form-renderable validation error instead of the raw driver error.

In `@src/domain/service-intervals/validate.ts`:
- Around line 39-43: Update hasThresholdBelowMin to treat any set threshold that
is not an integer as invalid, alongside the existing MIN_THRESHOLD check, so
fractional and NaN values produce the normal validation error path.

In `@src/domain/summary/__tests__/summary.test.ts`:
- Around line 624-658: Update the query-count assertion in the
“inventorySummary's service roll-up loads in a bounded number of queries” test
to verify an upper bound rather than exact equality between largeCount and
smallCount. Keep the existing measurements and item-count assertions, and ensure
the assertion still detects per-item query growth without relying on
process-wide query-count isolation.

In `@src/domain/summary/summary.ts`:
- Around line 262-273: Update inventorySummary to accept the firearm shape
required by listDueForVisibleCollection, and when dueEntries is omitted, pass
the resolved or supplied firearms as its preloadedFirearms argument. Preserve
the existing dueEntries path and ensure the shared firearm value remains
available to both Promise.all operations.

In `@src/test-support/factories.ts`:
- Around line 217-233: Update makeServiceRuleDefault so its overrides type omits
ownerId from serviceRuleDefault.$inferInsert, matching the parent-key protection
used by makeServiceRule and makeServiceEvent. Keep the explicit ownerId argument
authoritative while preserving all other override fields.
- Around line 242-258: Update makeServiceRule so that when overrides.suppressed
is true, intervalRounds and the other service-rule threshold fields are
explicitly set to null after applying overrides and parent values. Preserve the
existing defaults for non-suppressed rules, ensuring suppressed factory rows
satisfy the schema CHECK without requiring callers to null thresholds manually.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: ab356fc9-2822-409c-8238-10f22fce5bc4

📥 Commits

Reviewing files that changed from the base of the PR and between c924fee and 58e41c2.

📒 Files selected for processing (85)
  • CONCEPTS.md
  • app/(app)/accessories/[id]/page.tsx
  • app/(app)/accessories/accessories-view.tsx
  • app/(app)/accessories/accessory-detail-view.tsx
  • app/(app)/accessories/accessory-form.tsx
  • app/(app)/accessories/page.tsx
  • app/(app)/firearms/[id]/page.tsx
  • app/(app)/firearms/__tests__/service-actions.test.ts
  • app/(app)/firearms/firearm-detail-view.tsx
  • app/(app)/firearms/firearm-form.tsx
  • app/(app)/firearms/firearms-view.tsx
  • app/(app)/firearms/log-service-form.tsx
  • app/(app)/firearms/page.tsx
  • app/(app)/firearms/range-session-form.tsx
  • app/(app)/firearms/service-actions.ts
  • app/(app)/firearms/service-history.tsx
  • app/(app)/firearms/service-rules-panel.tsx
  • app/(app)/inventory-log/log-entry-form.tsx
  • app/(app)/settings/page.tsx
  • app/(app)/settings/service/__tests__/actions.test.ts
  • app/(app)/settings/service/actions.ts
  • app/(app)/settings/service/default-rule-form.tsx
  • app/(app)/settings/service/page.tsx
  • app/(app)/settings/service/service-defaults-form.tsx
  • app/(app)/settings/service/types.ts
  • app/(app)/summary/__tests__/actions.test.ts
  • app/(app)/summary/actions.ts
  • app/(app)/summary/page.tsx
  • app/(app)/summary/service-backlog-control.tsx
  • app/(app)/summary/summary-tables.tsx
  • docs/plans/2026-08-02-002-feat-service-intervals-plan.md
  • e2e/fixtures/user-pool.ts
  • e2e/inventory-log.spec.ts
  • e2e/service-intervals-bulk-sharing.spec.ts
  • e2e/service-intervals-bulk.spec.ts
  • e2e/service-intervals-rule-actions.spec.ts
  • e2e/service-intervals-sharing.spec.ts
  • e2e/service-intervals.spec.ts
  • scripts/seed-demo.ts
  • src/backup/table-order.ts
  • src/db/__tests__/migration-0020-service-event-conversion.test.ts
  • src/db/__tests__/service-intervals-schema.test.ts
  • src/db/inventory-schema.ts
  • src/db/migrations/0019_perfect_nebula.sql
  • src/db/migrations/0020_naive_scarlet_spider.sql
  • src/db/migrations/0021_shocking_shooting_star.sql
  • src/db/migrations/meta/0019_snapshot.json
  • src/db/migrations/meta/0020_snapshot.json
  • src/db/migrations/meta/0021_snapshot.json
  • src/db/migrations/meta/_journal.json
  • src/demo/__tests__/inventory.test.ts
  • src/demo/inventory.ts
  • src/domain/accessories/__tests__/service.test.ts
  • src/domain/accessories/__tests__/validate.test.ts
  • src/domain/accessories/service.ts
  • src/domain/accessories/validate.ts
  • src/domain/firearms/__tests__/service.test.ts
  • src/domain/firearms/__tests__/validate.test.ts
  • src/domain/firearms/service.ts
  • src/domain/firearms/validate.ts
  • src/domain/inventory-log/__tests__/last-inventoried.test.ts
  • src/domain/inventory-log/__tests__/service.test.ts
  • src/domain/inventory-log/__tests__/validate.test.ts
  • src/domain/inventory-log/constants.ts
  • src/domain/range-sessions/validate.ts
  • src/domain/service-intervals/__tests__/backlog.test.ts
  • src/domain/service-intervals/__tests__/derive.test.ts
  • src/domain/service-intervals/__tests__/due-service.test.ts
  • src/domain/service-intervals/__tests__/events-service.test.ts
  • src/domain/service-intervals/__tests__/rules-service.test.ts
  • src/domain/service-intervals/__tests__/validate-event.test.ts
  • src/domain/service-intervals/__tests__/validate.test.ts
  • src/domain/service-intervals/backlog.ts
  • src/domain/service-intervals/constants.ts
  • src/domain/service-intervals/derive.ts
  • src/domain/service-intervals/due-service.ts
  • src/domain/service-intervals/events-service.ts
  • src/domain/service-intervals/rules-service.ts
  • src/domain/service-intervals/validate-event.ts
  • src/domain/service-intervals/validate.ts
  • src/domain/summary/__tests__/summary.test.ts
  • src/domain/summary/summary.ts
  • src/domain/validation-messages.ts
  • src/lib/dates.ts
  • src/test-support/factories.ts

Comment thread app/(app)/accessories/[id]/page.tsx
Comment thread app/(app)/firearms/service-actions.ts
Comment thread app/(app)/firearms/service-history.tsx
Comment thread app/(app)/settings/service/actions.ts
Comment thread app/(app)/settings/service/default-rule-form.tsx
Comment thread src/domain/firearms/__tests__/service.test.ts
Comment thread src/domain/service-intervals/__tests__/events-service.test.ts
Comment thread src/domain/service-intervals/rules-service.ts
Comment thread src/domain/validation-messages.ts Outdated
Comment thread src/lib/dates.ts
Resolves CodeRabbit's review. Service history now shows who performed
each service by display name rather than a raw account id, resolved
once for both detail pages — a view-grantee reading a shared firearm's
history sees a name, never another account's address. A rule name that
no longer resolves surfaces its validation message on the log-service
form instead of failing silently.

Also corrects two assertions in the sharing spec that targeted a
dialog the rule form does not use, and one that expected an email
where the history deliberately renders a display name.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/`(app)/settings/service/actions.ts:
- Around line 39-53: Update validateServiceRuleDefaultInput to accept unknown
and reject null or non-object values before accessing scope, category, or name,
returning the appropriate invalid-input code through
createServiceRuleDefaultAction’s ActionResult envelope. Add regression tests
covering null and undefined action inputs while preserving existing field
validation for objects.

In `@src/domain/service-intervals/actor-names.ts`:
- Around line 45-48: Update the actorName mapping in the event transformation to
return UNKNOWN_ACTOR_LABEL whenever nameById.get(event.actorId) misses,
including non-null deleted actor IDs; preserve named actors and existing
null-actor handling, and add a regression test covering an unresolved non-null
actor identity.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 29daefd0-84e5-461c-bf11-907e5563e6ec

📥 Commits

Reviewing files that changed from the base of the PR and between 58e41c2 and b3e73f6.

📒 Files selected for processing (31)
  • app/(app)/accessories/[id]/page.tsx
  • app/(app)/firearms/[id]/page.tsx
  • app/(app)/firearms/firearm-form.tsx
  • app/(app)/firearms/log-service-form.tsx
  • app/(app)/firearms/service-history.tsx
  • app/(app)/firearms/service-rules-panel.tsx
  • app/(app)/settings/service/__tests__/actions.test.ts
  • app/(app)/settings/service/actions.ts
  • app/(app)/settings/service/default-rule-form.tsx
  • app/(app)/summary/__tests__/actions.test.ts
  • app/(app)/summary/page.tsx
  • app/(app)/summary/service-backlog-control.tsx
  • docs/plans/2026-08-02-002-feat-service-intervals-plan.md
  • e2e/service-intervals-sharing.spec.ts
  • e2e/service-intervals.spec.ts
  • scripts/seed-demo.ts
  • src/demo/__tests__/inventory.test.ts
  • src/demo/inventory.ts
  • src/domain/accessories/__tests__/validate.test.ts
  • src/domain/firearms/__tests__/validate.test.ts
  • src/domain/service-intervals/__tests__/events-service.test.ts
  • src/domain/service-intervals/__tests__/rules-service.test.ts
  • src/domain/service-intervals/__tests__/validate.test.ts
  • src/domain/service-intervals/actor-names.ts
  • src/domain/service-intervals/due-service.ts
  • src/domain/service-intervals/rules-service.ts
  • src/domain/service-intervals/validate.ts
  • src/domain/summary/__tests__/summary.test.ts
  • src/domain/validation-messages.ts
  • src/lib/dates.ts
  • src/test-support/factories.ts
🚧 Files skipped from review as they are similar to previous changes (21)
  • src/demo/tests/inventory.test.ts
  • src/domain/accessories/tests/validate.test.ts
  • src/domain/firearms/tests/validate.test.ts
  • src/domain/validation-messages.ts
  • scripts/seed-demo.ts
  • e2e/service-intervals.spec.ts
  • app/(app)/firearms/log-service-form.tsx
  • src/domain/summary/tests/summary.test.ts
  • app/(app)/summary/page.tsx
  • app/(app)/firearms/firearm-form.tsx
  • app/(app)/summary/service-backlog-control.tsx
  • app/(app)/firearms/service-history.tsx
  • src/domain/service-intervals/rules-service.ts
  • src/domain/service-intervals/due-service.ts
  • app/(app)/firearms/service-rules-panel.tsx
  • e2e/service-intervals-sharing.spec.ts
  • src/demo/inventory.ts
  • app/(app)/settings/service/default-rule-form.tsx
  • docs/plans/2026-08-02-002-feat-service-intervals-plan.md
  • app/(app)/accessories/[id]/page.tsx
  • app/(app)/summary/tests/actions.test.ts

Comment thread app/(app)/settings/service/actions.ts
Comment thread src/domain/service-intervals/actor-names.ts Outdated
…id leak, guard malformed default input

Three PR #99 review findings closed:

Renaming a category default did not repoint the service_rule/service_event
rows of items that inherit or override it. Since everything is keyed by rule
name, a rename silently stranded every reachable item's service history
under the old name, and the rule reappeared under the new name measuring
from the item's origin date again instead of its last service. This is the
same bug class already fixed for item-rule renames in updateItemRule, so
updateServiceRuleDefault now repoints, in the same transaction: an
inheriting item's service_event rows, and an overriding/suppressing item's
service_rule row alongside its service_event rows. The rename lock (SELECT
... FOR UPDATE) and the pre-write duplicate-name rejection already used for
item-rule renames are applied here too, and the whole repoint is scoped
strictly to the owner's own items in the exact scope+category being edited.
A genuinely item-only rule can never be caught in this repoint: since
resolveEffectiveRules matches an item's own rule to a default by name alone
within that item's own category, any item-rule row sharing the renamed
default's old name is, by that same logic, already an override or
suppression of it -- never item-only -- so there is no ambiguous case left
to special-case.

validateServiceRuleDefaultInput in the settings/service server action read
input.scope before confirming input was a non-null object, so a malformed
payload threw a raw TypeError instead of returning the normal failed
ActionResult. Added the missing guard. The sibling action files
(firearms/service-actions.ts, summary/actions.ts) have no equivalent
pre-domain input validator of their own, so they don't share this shape and
needed no matching change.

withActorNames fell back to the raw account id when a service event's
actorId was set but the account no longer existed (deleted between the
event write and the read) -- exactly the raw-id leak the display-name
resolution exists to prevent. It now falls back to UNKNOWN_ACTOR_LABEL like
the null-actorId case already does.

Tests added for all three: default-rename history repoint (inheriting item,
overriding item, cross-owner isolation, duplicate-name rejection, sequential
renames), a malformed non-object payload to the settings action, and a
service-event actor-id lookup miss.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@coderabbitai coderabbitai Bot added backend documentation Improvements or additions to documentation enhancement New feature or request frontend priority:medium shared testing labels Aug 4, 2026
…nd comments

A future acquired date and several rule-action failures were rejected
by the server but rendered nothing in the form — the submit button
just looked dead. Every validation code a form's actions can return is
now surfaced, and rule actions read the specific code instead of
falling back to a generic message.

The accessory detail page now degrades to a clean 404 like the firearm
page when access is revoked mid-load, sharing one helper rather than
two copies. All three category-default actions guard a malformed
payload, not just create. The summary empty state no longer hides the
service roll-up from an owner who has accessories but no firearms,
magazines, or ammo.

Due state is now a discriminated union, so a tripped axis exists
exactly when a rule is due and callers stop null-checking what the
logic already guarantees. The exactly-one-parent narrowing lives in
one helper instead of three copies.

Corrects comment rot the review surfaced: a KTD-7 citation that
matched no decision in any plan, a docblock claiming firearms still
carry maintenance event types after U5 left only inventoried, two
docblocks stranded above the wrong function — one of them the
cross-visibility trust contract — and cross-plan citations that read
as belonging to this plan.

Adds tests for the shared date helpers, which had been verified only
through duplicated cases in three consumers.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@unclesp1d3r

Copy link
Copy Markdown
Owner Author

Addressed the outside-diff-range findings from the review bodies (no inline thread to resolve on those), plus the review-toolkit findings, in 8ad253a.

acquiredDateInFuture blocks submit with no visible error.

Fixed. This turned out to be one instance of a class rather than a one-off — three separate reviews found the same shape from different angles: this form, runRuleAction in service-rules-panel.tsx (which read result.error while ValidationError returns { ok: false, codes }, so reset/suppress/restore/remove showed a generic toast instead of the real reason), and suppressedWithThresholds having no rendering path at all. Swept every form against every code its actions can return rather than patching the one field.

The empty-state gate hides the new Service section for accessory-only owners.

Fixed. An owner with accessories but no firearms, magazines, or ammo passed all three checks and never saw the roll-up or the bulk control, even with items due — accessory due entries are built independently of the firearm set, so they were reachable in exactly that state.

Protect ownerId the same way the parent keys are protected / { suppressed: true } alone fails the DB CHECK

Both already fixed in b3e73f6. That review body was written against the earlier commit range.

Same for the two inline items in the second review body — the validateServiceRuleDefaultInput object guard and the actor-names.ts UNKNOWN_ACTOR_LABEL fallback both landed in 1fe60e3. The object guard has since been extended to update and delete, which had been left inconsistent.

Also in this commit, from a parallel review pass: DueResult became a discriminated union so a tripped axis exists exactly when a rule is due; the exactly-one-parent narrowing was consolidated from three copies into one helper; the accessory detail page gained the same NotFoundError → 404 guard the firearm page already had; and several comment citations were corrected, including a KTD-7 reference that matched no decision in any plan and a trust-contract docblock stranded above the wrong function.

just ci-check green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/(app)/settings/service/actions.ts (1)

104-121: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject malformed default IDs before calling the domain service.

Line 108 validates only the update payload. Line 120 accepts every string. A non-UUID ID reaches the UUID-backed service query instead of returning invalidPayload.

Validate UUID format in validateServiceRuleDefaultId and call it from both actions. Add tests that assert no service call and no revalidation for "not-a-uuid".

Proposed validation
+import { isUuid } from "`@/src/lib/uuid`";
+
-function validateServiceRuleDefaultId(id: string): string[] {
-  return typeof id === "string" ? [] : ["invalidPayload"];
+function validateServiceRuleDefaultId(id: unknown): string[] {
+  return typeof id === "string" && isUuid(id) ? [] : ["invalidPayload"];
 }

 export async function updateServiceRuleDefaultAction(id: string, input: ServiceRuleDefaultUpdateInput) {
+  const idCodes = validateServiceRuleDefaultId(id);
+  if (idCodes.length > 0) return { ok: false, codes: idCodes };
   const codes = validateServiceRuleDefaultUpdateInput(input);
   if (codes.length > 0) return { ok: false, codes };

As per coding guidelines, “Validate all external and user input at system boundaries and fail fast with clear errors.” As per path instructions, “Server Actions must resolve the session and authorization server-side, validate input, and return non-leaking errors via the ActionResult envelope.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`(app)/settings/service/actions.ts around lines 104 - 121, Update both
updateServiceRuleDefaultAction and deleteServiceRuleDefaultAction to call
validateServiceRuleDefaultId alongside their existing validation, and enhance
that validator to reject malformed UUIDs with invalidPayload. Return immediately
before invoking updateServiceRuleDefault, the delete service, or revalidation;
add tests confirming “not-a-uuid” produces no service call and no path
revalidation.

Sources: Coding guidelines, Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/`(app)/accessories/[id]/__tests__/service-props.test.ts:
- Around line 33-44: Restore the three spy handles created in the
service-props.test.ts beforeEach by adding their restoration in afterEach. In
actions.test.ts, retain handles for all three spyOn calls and restore each
handle in afterEach, ensuring no mocked implementations leak into later tests.

---

Outside diff comments:
In `@app/`(app)/settings/service/actions.ts:
- Around line 104-121: Update both updateServiceRuleDefaultAction and
deleteServiceRuleDefaultAction to call validateServiceRuleDefaultId alongside
their existing validation, and enhance that validator to reject malformed UUIDs
with invalidPayload. Return immediately before invoking
updateServiceRuleDefault, the delete service, or revalidation; add tests
confirming “not-a-uuid” produces no service call and no path revalidation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 0c68e77e-875c-4f55-8b17-44d7ed0b7532

📥 Commits

Reviewing files that changed from the base of the PR and between 1fe60e3 and 8ad253a.

📒 Files selected for processing (27)
  • app/(app)/accessories/[id]/__tests__/service-props.test.ts
  • app/(app)/accessories/[id]/page.tsx
  • app/(app)/accessories/page.tsx
  • app/(app)/firearms/[id]/page.tsx
  • app/(app)/firearms/__tests__/service-actions.test.ts
  • app/(app)/firearms/service-actions.ts
  • app/(app)/firearms/service-history.tsx
  • app/(app)/firearms/service-rules-panel.tsx
  • app/(app)/inventory-log/log-entry-form.tsx
  • app/(app)/settings/service/__tests__/actions.test.ts
  • app/(app)/settings/service/actions.ts
  • app/(app)/summary/summary-tables.tsx
  • src/db/__tests__/service-intervals-schema.test.ts
  • src/db/inventory-schema.ts
  • src/domain/accessories/validate.ts
  • src/domain/firearms/validate.ts
  • src/domain/service-intervals/__tests__/backlog.test.ts
  • src/domain/service-intervals/__tests__/events-service.test.ts
  • src/domain/service-intervals/__tests__/rules-service.test.ts
  • src/domain/service-intervals/derive.ts
  • src/domain/service-intervals/due-service.ts
  • src/domain/service-intervals/events-service.ts
  • src/domain/service-intervals/rules-service.ts
  • src/domain/summary/__tests__/summary.test.ts
  • src/lib/__tests__/dates.test.ts
  • src/lib/as-not-found.ts
  • src/test-support/factories.ts
🚧 Files skipped from review as they are similar to previous changes (17)
  • app/(app)/firearms/[id]/page.tsx
  • app/(app)/summary/summary-tables.tsx
  • app/(app)/firearms/service-history.tsx
  • src/domain/firearms/validate.ts
  • app/(app)/accessories/page.tsx
  • app/(app)/inventory-log/log-entry-form.tsx
  • src/domain/service-intervals/tests/backlog.test.ts
  • app/(app)/firearms/service-rules-panel.tsx
  • src/db/tests/service-intervals-schema.test.ts
  • app/(app)/firearms/tests/service-actions.test.ts
  • src/domain/summary/tests/summary.test.ts
  • src/test-support/factories.ts
  • app/(app)/firearms/service-actions.ts
  • src/domain/service-intervals/derive.ts
  • src/domain/accessories/validate.ts
  • src/domain/service-intervals/due-service.ts
  • src/domain/service-intervals/tests/events-service.test.ts

Comment thread app/(app)/accessories/[id]/__tests__/service-props.test.ts
bun test app runs every file in one process and Bun's spyOn
replacements persist until restored, so six unrestored spies across
two new test files could have altered whichever file ran next. Both
now restore per handle in afterEach, matching the convention the
firearm and summary action tests already follow.

This is the same cross-file contamination that already surfaced twice
on this branch through mock.module.

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
@unclesp1d3r
unclesp1d3r merged commit 181c2bb into main Aug 5, 2026
7 checks passed
@unclesp1d3r
unclesp1d3r deleted the 10-add-service-interval-tracking-and-in-app-maintenance-reminders-for-firearms-and-accessories branch August 5, 2026 00:48
unclesp1d3r added a commit that referenced this pull request Aug 5, 2026
…ranch (#102)

This repo squash-merges with `squash_title: PR_TITLE` and `squash_msg:
PR_BODY`, so the PR title and body *are* the commit message and every
branch commit message is discarded at merge.

That means a breaking change marked only on a branch commit silently
disappears. It already happened: #99 retired the `cleaned` and `lubed`
inventory-log event types and irreversibly converted their history. The
branch carried `feat(service-intervals)!: retire cleaned and lubed log
events`, but the squash subject on `main` is `feat(service-intervals):
track service intervals and maintenance reminders (#99)` — no `!`, and
no `BREAKING CHANGE:` footer either, because the PR body used a prose
"Breaking change" heading rather than the conventional footer.

Nothing here parses commits to compute a version — releases are cut by
pushing a `v*.*.*` tag by hand — so these markers are the only surviving
record of what the next version should be.

## Changes

`AGENTS.md` gains an explicit rule under **Workflow & boundaries**: the
PR title must be a valid Conventional Commit subject, and a breaking
change must carry `!` in the title *and* a `BREAKING CHANGE:` footer in
the body, with a note that marking it on a branch commit does not work.

`.coderabbit.yml` is updated in two places that both described the
format without `!`:
- `auto_title_instructions` — CodeRabbit generates title suggestions
from this, so as written it would never produce the `!` form and could
suggest stripping it. Now includes the marker, a breaking example, and
why it must not be dropped.
- `pre_merge_checks.title.requirements` — now states `!` is valid and
must not be flagged, and lists the signals that require it.

## Follow-up, not in this PR

Given #99 shipped a breaking change, the next release should be
**v2.0.0** rather than v1.6.0. Current latest tag is v1.5.0.

## Test plan

- [x] `just ci-check` passes
- [x] `.coderabbit.yml` parses as valid YAML (`check-yaml` + js-yaml)
- [ ] Confirm CodeRabbit's title check accepts this PR's title, which
deliberately uses the plain form (this change is not itself breaking)
- [ ] Confirm a future `!` title passes the check rather than being
flagged

---------

Signed-off-by: UncleSp1d3r <unclesp1d3r@evilbitlabs.io>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend documentation Improvements or additions to documentation enhancement New feature or request frontend priority:medium shared testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add service interval tracking and in-app maintenance reminders for firearms and accessories

2 participants