Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e1bfd35
feat: add justfile for managing development tasks and environment setup
unclesp1d3r Jul 4, 2026
75030d3
fix: align shellcheck version for consistency in mise.toml
unclesp1d3r Jul 4, 2026
19de441
feat: add Taplo configuration for TOML formatting
unclesp1d3r Jul 4, 2026
c42e0f3
fix: reorder configuration options in .mdformat.toml for clarity
unclesp1d3r Jul 4, 2026
a2efbdb
feat: add generated SBOM entry to .gitignore
unclesp1d3r Jul 4, 2026
80da90f
feat: add read-only detail view for firearm and magazine records
unclesp1d3r Jul 4, 2026
98ff353
docs: add pre-commit gate requirement to AGENTS.md
unclesp1d3r Jul 4, 2026
f4d2522
feat: refine read-only detail view for firearm and magazine records
unclesp1d3r Jul 4, 2026
ae65705
docs(plan): implementation-ready plan for firearm & magazine detail v…
unclesp1d3r Jul 4, 2026
22a7db4
feat(magazines): enforce owner-only editing server-side; share view-o…
unclesp1d3r Jul 4, 2026
3abd64e
feat(firearms): read-only detail route /firearms/[id] (#19)
unclesp1d3r Jul 4, 2026
00ffc5d
feat(magazines): read-only detail route /magazines/[id] (#19)
unclesp1d3r Jul 4, 2026
9cb7096
feat(inventory): link list names to detail routes; relocate row actio…
unclesp1d3r Jul 4, 2026
66ce3f7
test(e2e): detail-view sharing coverage; migrate specs to detail-page…
unclesp1d3r Jul 4, 2026
3ee63b2
fix(inventory): disambiguate row-link names with an id fragment, not …
unclesp1d3r Jul 4, 2026
9af28a5
fix(review): 404 on malformed detail-route ids; drop dead permission …
unclesp1d3r Jul 4, 2026
88dbad9
fix(review): harden not-found, pair compatible firearms, fix comment …
unclesp1d3r Jul 4, 2026
0cda598
refactor(detail): single-source permission, targeted count, deeper e2…
unclesp1d3r Jul 4, 2026
5026f5e
Address PR review feedback (#38)
unclesp1d3r Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts

# generated SBOM (`just sbom`)
/sbom.cdx.json

# local env files
**/*.local.*

Expand Down
4 changes: 2 additions & 2 deletions .mdformat.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
validate = true
end_of_line = "lf"
number = true
validate = true
wrap = "no"
end_of_line = "lf"

[plugin.mkdocs]
align_semantic_breaks_in_lists = true
Expand Down
29 changes: 29 additions & 0 deletions .taplo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Taplo (TOML formatter) config. oxfmt has no TOML alignment knobs
# (per https://oxc.rs/docs/guide/usage/formatter/config-file-reference.html);
# taplo handles `mise.toml` and any future TOML files. `oxfmt` ignores
# TOMLs via `.oxfmtrc.json` so the two formatters don't fight.

exclude = [
"node_modules/**",
"**/node_modules/**",
"dist/**",
"**/dist/**",
".turbo/**",
"**/.turbo/**",
]
include = [ "**/*.toml" ]

[formatting]
# Pad `=` so adjacent keys align in a column.
align_entries = true
# Pad inline comments to align across consecutive lines.
align_comments = true
# Match the rest of the toolchain (oxfmt uses 100).
array_auto_collapse = true
array_auto_expand = true
array_trailing_comma = true
column_width = 100
compact_arrays = false
indent_string = " "
reorder_keys = true
trailing_newline = true
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This repo runs a **trimmed ECC surface**, not the full bundle. Full map + ration
- **`BETTER_AUTH_URL` must equal the request origin** or Better Auth returns 403 "Invalid origin". `mise.toml` (`env_cache=true`, `_.file=['.env','.env.local']`) injects env vars stickily (default `:3000`, full_setup used `:3100`) — override explicitly when serving on another port.
- **DB:** Postgres + Drizzle over a lazy `pg` Pool (`src/db/client.ts`). `requireDatabaseUrl()` reads `DATABASE_URL` (not in `.env.example` — supply it). Inventory is `owner_id`-scoped; user delete CASCADEs children.
- **Commands:** `bun run db:migrate` · `bun run seed:admin` (needs `ADMIN_EMAIL`/`ADMIN_PASSWORD`) · `bun run lint` (biome) · `bun run typecheck` · `bun test`.
- **MUST-PASS PRE-COMMIT GATE:** You **MUST** run `just ci-check` and ensure it passes **before every commit**. Do not commit — for any reason — while `just ci-check` is failing. No `--no-verify`, no skipping, no "I'll fix it in a follow-up." A red `just ci-check` blocks the commit.
- **Tests:** integration tests gate on `DATABASE_URL` (`const live = process.env.DATABASE_URL ? describe : describe.skip`); reuse `src/test-support/factories.ts`. **Integration & E2E must use Testcontainers** (idiomatic module + Ryuk cleanup). **No `data-testid` in the app** — target UI via ARIA roles / accessible names / visible text. The Playwright suite lives in `e2e/` (`bun run test:e2e`, Docker required); see `e2e/README.md` for the harness.

<!-- BEGIN:nextjs-agent-rules -->
Expand Down
67 changes: 67 additions & 0 deletions app/(app)/firearms/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { notFound, redirect } from "next/navigation";
import { NotFoundError } from "@/src/auth/errors";
import { getCurrentUser } from "@/src/auth/session";
import { db } from "@/src/db/client";
import { getFirearm, listFirearms } from "@/src/domain/firearms/service";
import { magazineCountForFirearm } from "@/src/domain/magazines/service";
import {
calibersForInput,
manufacturers,
} from "@/src/domain/reference/reference";
import { isUuid } from "@/src/lib/uuid";
import { FirearmDetailView } from "../firearm-detail-view";

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

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

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

const [caliberSuggestions, magazineCount, firearms] = await Promise.all([
calibersForInput(db, user.id),
magazineCountForFirearm(user.id, id),
listFirearms(user.id),
]);

const subtypeSuggestions = [
...new Set(firearms.map((f) => f.subtype).filter((s) => s.trim() !== "")),
].sort((a, b) => a.localeCompare(b));

return (
<FirearmDetailView
firearm={{
id: row.id,
name: row.name,
nickname: row.nickname,
manufacturer: row.manufacturer,
caliber: row.caliber,
type: row.type,
action: row.action,
subtype: row.subtype,
serialNumber: row.serialNumber,
notes: row.notes,
}}
permission={permission}
magazineCount={magazineCount}
caliberSuggestions={caliberSuggestions}
manufacturerSuggestions={manufacturers()}
subtypeSuggestions={subtypeSuggestions}
/>
);
}
209 changes: 209 additions & 0 deletions app/(app)/firearms/firearm-detail-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { ShareControl } from "@/app/(app)/grants/share-control";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Badge } from "@/components/ui/feedback";
import { Card } from "@/components/ui/surface";
import { useDeleteConfirmation } from "@/hooks/use-delete-confirmation";
import type { Permission } from "@/src/auth/visibility";
import {
firearmActionLabel,
firearmTypeLabel,
} from "@/src/domain/firearms/constants";
import { firearmDisplayName, hasNickname } from "@/src/domain/firearms/display";
import { deleteFirearmAction } from "./actions";
import { FirearmForm, type FirearmFormValues } from "./firearm-form";
import { RangeSessionHistory } from "./range-session-history";

export interface FirearmDetail extends FirearmFormValues {
id: string;
}

interface FirearmDetailViewProps {
firearm: FirearmDetail;
permission: Permission;
magazineCount: number;
caliberSuggestions: string[];
manufacturerSuggestions: string[];
subtypeSuggestions: string[];
}

/** One read-only label/value row. */
function DetailRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex flex-col gap-0.5 border-b border-line py-2 last:border-b-0 sm:flex-row sm:gap-4">
<dt className="w-40 shrink-0 text-xs font-medium uppercase tracking-wide text-ink-faint">
{label}
</dt>
<dd className="text-sm text-ink">{value}</dd>
</div>
);
}

function orDash(value: string): ReactNode {
return value.trim() !== "" ? (
value
) : (
<span className="text-ink-faint">—</span>
);
}

export function FirearmDetailView({
firearm,
permission,
magazineCount,
caliberSuggestions,
manufacturerSuggestions,
subtypeSuggestions,
}: FirearmDetailViewProps) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const headingRef = useRef<HTMLHeadingElement>(null);
// Ownership and edit rights derive from the server-resolved permission (single
// source of truth) — no separate ownerId/currentUserId compare to drift from it.
const isOwner = permission === "owner";
const canEdit = permission === "owner" || permission === "edit";
const del = useDeleteConfirmation<FirearmDetail>({
entityLabel: "Firearm",
getName: (item) => firearmDisplayName(item),
remove: deleteFirearmAction,
redirectTo: "/firearms",
});

// Move focus to the heading on mount so a client navigation (or a browser
// reload landing here) announces the new page to a screen reader (R16).
useEffect(() => {
headingRef.current?.focus();
}, []);

const displayName = firearmDisplayName(firearm);

return (
<div className="space-y-6">
<Link
href="/firearms"
className="inline-block text-sm font-medium text-blaze hover:underline"
>
← Firearms
</Link>

<header className="flex flex-wrap items-start justify-between gap-4 border-b border-line pb-4">
<div className="space-y-1">
<h1
ref={headingRef}
tabIndex={-1}
className="text-pretty text-[1.75rem] font-bold leading-none tracking-[-0.02em] text-ink outline-none"
>
{displayName}
</h1>
<p className="flex flex-wrap items-center gap-2 text-sm text-ink-soft">
{hasNickname(firearm) ? <span>{firearm.name}</span> : null}
{!isOwner ? (
<Badge tone="blaze">Shared with you · {permission}</Badge>
) : null}
</p>
</div>
<div className="flex items-center gap-2">
{isOwner ? (
<ShareControl
parentType="firearm"
parentId={firearm.id}
itemName={displayName}
/>
) : null}
{canEdit && !editing ? (
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
Edit
</Button>
) : null}
{isOwner ? (
<Button
variant="danger"
size="sm"
onClick={() => del.request(firearm)}
>
Delete
</Button>
) : null}
</div>
</header>

{editing ? (
<Card>
<h2 className="mb-4 text-sm font-semibold text-ink">Edit firearm</h2>
<FirearmForm
initial={firearm}
caliberSuggestions={caliberSuggestions}
manufacturerSuggestions={manufacturerSuggestions}
subtypeSuggestions={subtypeSuggestions}
onDone={() => {
setEditing(false);
router.refresh();
}}
onCancel={() => setEditing(false)}
/>
</Card>
) : (
<Card>
<dl>
<DetailRow label="Product name" value={firearm.name} />
<DetailRow
label="Manufacturer"
value={orDash(firearm.manufacturer)}
/>
<DetailRow label="Caliber" value={firearm.caliber} />
<DetailRow label="Type" value={firearmTypeLabel(firearm.type)} />
<DetailRow
label="Action"
value={firearmActionLabel(firearm.action)}
/>
<DetailRow label="Subtype" value={orDash(firearm.subtype)} />
<DetailRow
label="Serial number"
value={
<span className="font-mono text-xs">
{orDash(firearm.serialNumber)}
</span>
}
/>
<DetailRow
label="Compatible magazines"
value={<span className="tabular">{magazineCount}</span>}
/>
<DetailRow
label="Notes"
value={
firearm.notes.trim() !== "" ? (
<span className="whitespace-pre-wrap">{firearm.notes}</span>
) : (
orDash("")
)
}
/>
</dl>
</Card>
)}

<RangeSessionHistory
firearmId={firearm.id}
firearmName={displayName}
canEdit={canEdit}
onChange={() => router.refresh()}
/>

<ConfirmDialog
open={del.target !== null}
title={`Delete “${displayName}”?`}
description="Linked magazines keep their other compatibility. This can’t be undone."
pending={del.pending}
onConfirm={del.confirm}
onCancel={del.cancel}
/>
</div>
);
}
Loading
Loading