Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8323ea6
feat(tests): document e2e test failure due to local .env clobbering T…
unclesp1d3r Jul 3, 2026
d330ffa
refactor(e2e): serve Next in-process instead of a subprocess + shim
unclesp1d3r Jul 3, 2026
c4e4ec3
refactor(e2e): serve Next in-process (launcher + learning doc)
unclesp1d3r Jul 3, 2026
bdde4aa
feat(range-sessions): add range_session schema, migration, and factory
unclesp1d3r Jul 3, 2026
2501765
feat(range-sessions): add pure session validation (rounds >= 1, valid…
unclesp1d3r Jul 3, 2026
2955092
feat(range-sessions): add visibility-scoped session service + derived…
unclesp1d3r Jul 3, 2026
fa31238
feat(range-sessions): add server actions for log/update/delete/list
unclesp1d3r Jul 3, 2026
48bf100
feat(range-sessions): surface lifetime total, log form, and history UI
unclesp1d3r Jul 3, 2026
01d692b
test(range-sessions): e2e for derived total (AE1)
unclesp1d3r Jul 3, 2026
f235ac2
fix(range-sessions): close update existence leak + review/simplify fo…
unclesp1d3r Jul 3, 2026
419ec4f
perf(firearms)+test(range-sessions): resolve both deferred review res…
unclesp1d3r Jul 3, 2026
294f7b8
chore(dependencies): update markdownlint-cli2 to version 0.23.0 and j…
unclesp1d3r Jul 3, 2026
d16c3e2
chore(dependencies): update next to version 16.2.10 and @types/node t…
unclesp1d3r Jul 3, 2026
8285f1f
fix(range-sessions): address PR review feedback (#34)
unclesp1d3r Jul 3, 2026
9191688
chore(pre-commit): remove sqlfluff hooks to streamline configuration
unclesp1d3r Jul 3, 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
4 changes: 4 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ repos:
- repo: https://github.com/sqlfluff/sqlfluff
rev: 4.2.2 # 4.2.2
hooks:
# drizzle-kit owns the generated migration SQL format; hand-linting it
# fights the generator (e.g. PG01 NOT VALID/CONCURRENTLY it never emits).
- id: sqlfluff-lint
types: [sql]
exclude: "^src/db/migrations/"
- id: sqlfluff-fix
types: [sql]
exclude: "^src/db/migrations/"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
2 changes: 2 additions & 0 deletions .sqlfluff
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[sqlfluff]
dialect = postgres
29 changes: 29 additions & 0 deletions app/(app)/firearms/firearms-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Card, PageHeader } from "@/components/ui/surface";
import { DataTable, TD, TH, THead, TRow } from "@/components/ui/table";
import { useDeleteConfirmation } from "@/hooks/use-delete-confirmation";
import { useRowFlash } from "@/hooks/use-row-flash";
import type { Permission } from "@/src/auth/visibility";
import {
FIREARM_TYPES,
firearmActionLabel,
Expand All @@ -19,6 +20,7 @@ import {
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";

/** Filter sentinel: show every type. */
const ALL_TYPES = "all";
Expand All @@ -27,6 +29,10 @@ export interface FirearmListItem extends FirearmFormValues {
id: string;
ownerId: string;
magazineCount: number;
/** Derived lifetime rounds fired across this firearm's sessions (#11). */
roundTotal: number;
/** The viewer's own permission on this firearm, for session-control gating. */
permission: Permission;
}

interface FirearmsViewProps {
Expand All @@ -50,6 +56,7 @@ export function FirearmsView({
}: FirearmsViewProps) {
const router = useRouter();
const [form, setForm] = useState<FormState>({ open: false });
const [sessionsFor, setSessionsFor] = useState<FirearmListItem | null>(null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const [typeFilter, setTypeFilter] = useState<string>(ALL_TYPES);
const filterId = useId();
const { flashId, flash } = useRowFlash();
Expand Down Expand Up @@ -124,6 +131,19 @@ export function FirearmsView({
</Card>
) : null}

{sessionsFor ? (
<RangeSessionHistory
firearmId={sessionsFor.id}
firearmName={firearmDisplayName(sessionsFor)}
canEdit={
sessionsFor.permission === "owner" ||
sessionsFor.permission === "edit"
}
onClose={() => setSessionsFor(null)}
onChange={() => router.refresh()}
/>
) : null}

{firearms.length === 0 && !form.open ? (
<EmptyState
title="No firearms yet"
Expand Down Expand Up @@ -168,6 +188,7 @@ export function FirearmsView({
<TH>Action</TH>
{showSerial ? <TH>Serial</TH> : null}
<TH className="text-right"># Mags</TH>
<TH className="text-right">Rounds</TH>
<TH className="text-right">Actions</TH>
</THead>
<tbody>
Expand All @@ -188,6 +209,7 @@ export function FirearmsView({
<TD className="font-mono text-xs">{item.serialNumber}</TD>
) : null}
<TD className="text-right tabular">{item.magazineCount}</TD>
<TD className="text-right tabular">{item.roundTotal}</TD>
<TD className="text-right">
<div className="flex justify-end gap-1">
{item.ownerId === currentUserId ? (
Expand All @@ -197,6 +219,13 @@ export function FirearmsView({
itemName={firearmDisplayName(item)}
/>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => setSessionsFor(item)}
>
Sessions
</Button>
<Button
variant="ghost"
size="sm"
Expand Down
17 changes: 12 additions & 5 deletions app/(app)/firearms/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/src/auth/session";
import { visibleFirearmPermissions } from "@/src/auth/visibility";
import { db } from "@/src/db/client";
import { listFirearms } from "@/src/domain/firearms/service";
import { lifetimeRoundTotals } from "@/src/domain/range-sessions/service";
import {
calibersForInput,
manufacturers,
Expand All @@ -13,11 +15,14 @@ export default async function FirearmsPage() {
const user = await getCurrentUser();
if (!user) redirect("/login");

const [firearms, summary, caliberSuggestions] = await Promise.all([
listFirearms(user.id),
inventorySummary(user.id),
calibersForInput(db, user.id),
]);
const [firearms, summary, caliberSuggestions, roundTotals, permissions] =
await Promise.all([
listFirearms(user.id),
inventorySummary(user.id),
calibersForInput(db, user.id),
lifetimeRoundTotals(user.id),
visibleFirearmPermissions(db, user.id),
]);

// Subtype suggestions are the in-use values on the user's visible firearms
// (owned + shared, per listFirearms), already fetched above — derive them here
Expand All @@ -41,6 +46,8 @@ export default async function FirearmsPage() {
serialNumber: f.serialNumber,
notes: f.notes,
magazineCount: counts.get(f.id) ?? 0,
roundTotal: roundTotals.get(f.id) ?? 0,
permission: permissions.get(f.id) ?? "view",
}));
// Serial column shows only when at least one visible firearm has a serial (R71).
const showSerial = firearms.some((f) => f.serialNumber.trim() !== "");
Expand Down
159 changes: 159 additions & 0 deletions app/(app)/firearms/range-session-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"use client";

import { useId, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Callout } from "@/components/ui/feedback";
import { Field } from "@/components/ui/field";
import { Input, Textarea } from "@/components/ui/input";
import { useToast } from "@/components/ui/toast";
import { validateRangeSession } from "@/src/domain/range-sessions/validate";
import { firstMessage } from "@/src/domain/validation-messages";
import {
logRangeSessionAction,
updateRangeSessionAction,
} from "./session-actions";

export interface RangeSessionFormValues {
id?: string;
date: string;
/** Held as a string for the input; parsed to a number on submit. */
roundsFired: string;
notes: string;
}

const DATE_CODES = ["emptyDate", "invalidDate"];
const ROUNDS_CODES = ["invalidRoundsFired"];

/** Today's calendar date (KTD5 default), local time. */
function todayIso(): string {
const now = new Date();
const offsetMs = now.getTimezoneOffset() * 60_000;
return new Date(now.getTime() - offsetMs).toISOString().slice(0, 10);
}

interface RangeSessionFormProps {
firearmId: string;
initial?: RangeSessionFormValues;
onDone: () => void;
onCancel: () => void;
}

export function RangeSessionForm({
firearmId,
initial,
onDone,
onCancel,
}: RangeSessionFormProps) {
const { toast } = useToast();
const isEdit = Boolean(initial?.id);
const [values, setValues] = useState<RangeSessionFormValues>(
initial ?? { date: todayIso(), roundsFired: "", notes: "" },
);
const [codes, setCodes] = useState<string[]>([]);
const [serverError, setServerError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const dateId = useId();
const roundsId = useId();
const notesId = useId();

function set<K extends keyof RangeSessionFormValues>(key: K, value: string) {
setValues((v) => ({ ...v, [key]: value }));
}

function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const roundsFired = Number(values.roundsFired);
const found = validateRangeSession({
firearmId,
date: values.date,
roundsFired,
});
setCodes(found);
setServerError(null);
if (found.length > 0) {
document
.getElementById(
found.includes("invalidRoundsFired") ? roundsId : dateId,
)
?.focus();
return;
}
startTransition(async () => {
const payload = { date: values.date, roundsFired, notes: values.notes };
const result =
isEdit && initial?.id
? await updateRangeSessionAction(initial.id, payload)
: await logRangeSessionAction({ firearmId, ...payload });
if (result.ok) {
toast({
message: isEdit ? "Session updated" : "Session logged",
detail: `${roundsFired} rounds`,
});
onDone();
} else if (result.codes) {
setCodes(result.codes);
} else {
setServerError(result.error ?? "Could not save.");
}
});
}

return (
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
{serverError ? <Callout tone="danger">{serverError}</Callout> : null}
<div className="grid gap-4 sm:grid-cols-2">
<Field
label="Date"
controlId={dateId}
required
error={firstMessage(codes, DATE_CODES)}
>
<Input
id={dateId}
type="date"
value={values.date}
onChange={(e) => set("date", e.target.value)}
aria-invalid={DATE_CODES.some((c) => codes.includes(c))}
/>
</Field>
<Field
label="Rounds fired"
controlId={roundsId}
required
error={firstMessage(codes, ROUNDS_CODES)}
>
<Input
id={roundsId}
type="number"
inputMode="numeric"
min={1}
step={1}
value={values.roundsFired}
onChange={(e) => set("roundsFired", e.target.value)}
aria-invalid={codes.includes("invalidRoundsFired")}
/>
</Field>
</div>
<Field label="Notes" controlId={notesId}>
<Textarea
id={notesId}
value={values.notes}
onChange={(e) => set("notes", e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Button type="submit" disabled={pending}>
{pending ? "Saving…" : isEdit ? "Save changes" : "Log session"}
</Button>
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={pending}
>
Cancel
</Button>
</div>
</form>
);
}
Loading
Loading