From 541e7d3de354983101d4fa2f6221b089649d28ab Mon Sep 17 00:00:00 2001 From: goetchstone Date: Tue, 27 Oct 2026 06:19:21 -0400 Subject: [PATCH 1/5] fix: non-draft invoice edit redirects, not 404s redirect() threw inside a try whose catch called notFound(). Fetch with .catch(notFound) and render outside, as react-hooks requires. --- .../dashboard/crm/clients/[id]/page.tsx | 8 +-- .../dashboard/invoices/[id]/edit/page.tsx | 52 +++++++++---------- .../dashboard/invoices/[id]/page.tsx | 8 +-- .../dashboard/tickets/[id]/page.tsx | 14 ++--- docs/LESSONS.md | 10 ++++ 5 files changed, 43 insertions(+), 49 deletions(-) diff --git a/app/(dashboard)/dashboard/crm/clients/[id]/page.tsx b/app/(dashboard)/dashboard/crm/clients/[id]/page.tsx index 335fb7c..09d8a41 100644 --- a/app/(dashboard)/dashboard/crm/clients/[id]/page.tsx +++ b/app/(dashboard)/dashboard/crm/clients/[id]/page.tsx @@ -13,10 +13,6 @@ export default async function ClientDetailPage({ params }: Props) { const { id } = await params; const caller = await createCachedCaller(); - try { - const client = await caller.crm.clientById(id); - return ; - } catch { - notFound(); - } + const client = await caller.crm.clientById(id).catch(() => notFound()); + return ; } diff --git a/app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx b/app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx index b276409..771bc31 100644 --- a/app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx +++ b/app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx @@ -13,36 +13,32 @@ export default async function EditInvoicePage({ params }: Props) { const { id } = await params; const caller = await createCachedCaller(); - try { - const invoice = await caller.invoices.byId(id); + const invoice = await caller.invoices.byId(id).catch(() => notFound()); - if (invoice.status !== "DRAFT") { - redirect(`/dashboard/invoices/${id}`); - } + if (invoice.status !== "DRAFT") { + redirect(`/dashboard/invoices/${id}`); + } - const [clients, taxRates] = await Promise.all([ - caller.crm.listClients({ page: 1, limit: 100 }), - caller.tax.list({ includeInactive: false }), - ]); + const [clients, taxRates] = await Promise.all([ + caller.crm.listClients({ page: 1, limit: 100 }), + caller.tax.list({ includeInactive: false }), + ]); - return ( -
-
-

- Edit {invoice.invoiceNumber} -

-

- Only draft invoices can be edited. -

-
- + return ( +
+
+

+ Edit {invoice.invoiceNumber} +

+

+ Only draft invoices can be edited. +

- ); - } catch { - notFound(); - } + +
+ ); } diff --git a/app/(dashboard)/dashboard/invoices/[id]/page.tsx b/app/(dashboard)/dashboard/invoices/[id]/page.tsx index 444427b..1383359 100644 --- a/app/(dashboard)/dashboard/invoices/[id]/page.tsx +++ b/app/(dashboard)/dashboard/invoices/[id]/page.tsx @@ -13,10 +13,6 @@ export default async function InvoiceDetailPage({ params }: Props) { const { id } = await params; const caller = await createCachedCaller(); - try { - const invoice = await caller.invoices.byId(id); - return ; - } catch { - notFound(); - } + const invoice = await caller.invoices.byId(id).catch(() => notFound()); + return ; } diff --git a/app/(dashboard)/dashboard/tickets/[id]/page.tsx b/app/(dashboard)/dashboard/tickets/[id]/page.tsx index b40246f..1f0c702 100644 --- a/app/(dashboard)/dashboard/tickets/[id]/page.tsx +++ b/app/(dashboard)/dashboard/tickets/[id]/page.tsx @@ -13,13 +13,9 @@ export default async function TicketDetailPage({ params }: Props) { const { id } = await params; const caller = await createCachedCaller(); - try { - const [ticket, users] = await Promise.all([ - caller.tickets.byId(id), - caller.users.list(), - ]); - return ; - } catch { - notFound(); - } + const [ticket, users] = await Promise.all([ + caller.tickets.byId(id), + caller.users.list(), + ]).catch(() => notFound()); + return ; } diff --git a/docs/LESSONS.md b/docs/LESSONS.md index 5233314..7a3a6d6 100644 --- a/docs/LESSONS.md +++ b/docs/LESSONS.md @@ -326,6 +326,16 @@ Read at session start (loaded by `/boot`). Add to it whenever: --- +## 2026-10-27 — `redirect()` inside try/catch became a 404 + +**What happened:** The invoice edit page called `redirect()` inside a `try` whose `catch` called `notFound()`. Next's `redirect()` and `notFound()` work by throwing, so opening the edit page for a non-draft invoice showed a 404 instead of the invoice. Fixing the lint rule against JSX inside try/catch exposed it. + +**Lesson:** Keep `redirect()`/`notFound()` out of `try` blocks. Catch only the fetch (`await call().catch(() => notFound())`), then branch and render outside. + +**Where it applies:** Server components and route handlers under `app/`. + +--- + ## How to add to this file When you finish a task and a real lesson emerged, add an entry. Keep it terse. The point is to avoid repeating the mistake — not to write an essay. If the lesson is big enough to drive an architectural change, it goes in `docs/DECISIONS.md` instead. If it's about how the codebase works, update `CLAUDE.md`. If it's about how *we* work — it lives here. From 3a0a4cce72e54ffb20ace6bbeaf0b3032f580cf8 Mon Sep 17 00:00:00 2001 From: goetchstone Date: Tue, 27 Oct 2026 06:19:21 -0400 Subject: [PATCH 2/5] refactor: derive availability editor state The selected service and editable rows come from query data; no effects copying it into state. --- components/scheduling/availability-editor.tsx | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/components/scheduling/availability-editor.tsx b/components/scheduling/availability-editor.tsx index eee913b..bc136d0 100644 --- a/components/scheduling/availability-editor.tsx +++ b/components/scheduling/availability-editor.tsx @@ -1,7 +1,7 @@ // components/scheduling/availability-editor.tsx "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { trpc } from "@/lib/trpc/client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,35 +20,40 @@ interface DayRow { const CLOSED: DayRow = { enabled: false, startTime: "09:00", endTime: "17:00" }; +type SavedWindow = { dayOfWeek: number; startTime: string; endTime: string }; + +function toRows(saved: SavedWindow[] | undefined): DayRow[] { + const rows = DAYS.map(() => ({ ...CLOSED })); + for (const a of saved ?? []) { + if (a.dayOfWeek >= 0 && a.dayOfWeek <= 6) { + rows[a.dayOfWeek] = { enabled: true, startTime: a.startTime, endTime: a.endTime }; + } + } + return rows; +} + export function AvailabilityEditor() { const services = trpc.scheduling.listServices.useQuery({ publicOnly: false }); - const [serviceId, setServiceId] = useState(""); + const [pickedServiceId, setPickedServiceId] = useState(""); // Availability is per-service, and availableSlots unions service-scoped rows // with global ones — so the editor always targets one explicit service rather // than writing "global" hours that would silently add slots on top. - useEffect(() => { - const first = services.data?.[0]; - if (!serviceId && first) setServiceId(first.id); - }, [services.data, serviceId]); + const serviceId = pickedServiceId || services.data?.[0]?.id || ""; const availability = trpc.scheduling.getAvailability.useQuery( { serviceId: serviceId || undefined }, { enabled: serviceId.length > 0 } ); - const [rows, setRows] = useState(() => DAYS.map(() => ({ ...CLOSED }))); - - useEffect(() => { - if (!availability.data) return; - const next = DAYS.map(() => ({ ...CLOSED })); - for (const a of availability.data) { - if (a.dayOfWeek >= 0 && a.dayOfWeek <= 6) { - next[a.dayOfWeek] = { enabled: true, startTime: a.startTime, endTime: a.endTime }; - } - } - setRows(next); - }, [availability.data]); + // A draft remembers which fetch it was edited from, so a refetch (after + // saving or switching service) shows the server's hours again. + const [draft, setDraft] = useState<{ + source: SavedWindow[] | undefined; + rows: DayRow[]; + } | null>(null); + const rows = + draft && draft.source === availability.data ? draft.rows : toRows(availability.data); const utils = trpc.useUtils(); const save = trpc.scheduling.setAvailability.useMutation({ @@ -56,7 +61,10 @@ export function AvailabilityEditor() { }); function update(index: number, patch: Partial) { - setRows((rs) => rs.map((r, i) => (i === index ? { ...r, ...patch } : r))); + setDraft({ + source: availability.data, + rows: rows.map((r, i) => (i === index ? { ...r, ...patch } : r)), + }); } // Lexical compare is safe on zero-padded HH:MM and catches the end-before-start @@ -81,7 +89,7 @@ export function AvailabilityEditor() { - ) -} - -function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarGroupLabel({ - className, - render, - ...props -}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) { - return useRender({ - defaultTagName: "div", - props: mergeProps<"div">( - { - className: cn( - "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", - className - ), - }, - props - ), - render, - state: { - slot: "sidebar-group-label", - sidebar: "group-label", - }, - }) -} - -function SidebarGroupAction({ - className, - render, - ...props -}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) { - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">( - { - className: cn( - "absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", - className - ), - }, - props - ), - render, - state: { - slot: "sidebar-group-action", - sidebar: "group-action", - }, - }) -} - -function SidebarGroupContent({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ) -} - -function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { - return ( -