diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e06221c --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs` +2294ac57371ee75c597505bf4a3a4f5c7e1834ef # prettier, 304 files diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 29dcf88..f814320 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -19,10 +19,10 @@ name: Auto-merge on: workflow_run: - workflows: ['CI'] + workflows: ["CI"] types: [completed] schedule: - - cron: '*/10 * * * *' + - cron: "*/10 * * * *" workflow_dispatch: {} # Declared on the CALLER as well as inside the reusable workflow: a called @@ -46,4 +46,4 @@ jobs: # SPACE-separated: the sweep word-splits this. A comma would become one # bogus token, every dispatch would fail, and the only symptom would be # that nothing deploys — while the sweep still reported success. - rearm_workflows: 'ci.yml deploy.yml' + rearm_workflows: "ci.yml deploy.yml" diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1ed5ea5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,21 @@ +# Build output and vendored trees — formatting these is noise. +node_modules +.next +dist +build +out +coverage +.turbo +.vercel +*.min.js +*.min.css + +# Lockfiles are generated; prettier would rewrite them wholesale. +package-lock.json +pnpm-lock.yaml +yarn.lock + +# Markdown is deliberately out of scope for now. Prettier rewraps prose, which +# is where it is most opinionated and least useful, and it would bury the real +# diff. Remove this line when you want docs formatted too. +*.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a2f11f0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2, + "trailingComma": "all", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/app/(admin)/admin.module.css b/app/(admin)/admin.module.css index de35a01..65b860c 100644 --- a/app/(admin)/admin.module.css +++ b/app/(admin)/admin.module.css @@ -67,7 +67,9 @@ color: var(--white-60); text-decoration: none; font-size: 0.82rem; - transition: background var(--transition-fast), color var(--transition-fast); + transition: + background var(--transition-fast), + color var(--transition-fast); display: flex; align-items: center; gap: 0.65rem; @@ -142,7 +144,6 @@ background: var(--warn); } - .mainWrap { composes: mainWrap from "../shared.module.css"; } @@ -163,7 +164,6 @@ composes: main from "../shared.module.css"; } - /* Shared admin UI — geometry from app/shared.module.css */ .pageTitle { composes: pageTitle from "../shared.module.css"; @@ -181,9 +181,15 @@ composes: cardMb from "../shared.module.css"; } -.checkboxRow { composes: checkboxRow from "../shared.module.css"; } -.checkboxInput { composes: checkboxInput from "../shared.module.css"; } -.checkboxHint { composes: checkboxHint from "../shared.module.css"; } +.checkboxRow { + composes: checkboxRow from "../shared.module.css"; +} +.checkboxInput { + composes: checkboxInput from "../shared.module.css"; +} +.checkboxHint { + composes: checkboxHint from "../shared.module.css"; +} .table { width: 100%; @@ -310,12 +316,31 @@ margin-top: 0.2rem; } -.phasePill[data-phase="intake"] { color: var(--muted); background: var(--muted-10); } -.phasePill[data-phase="assessment"] { color: var(--teal); background: var(--teal-10); } -.phasePill[data-phase="planning"] { color: var(--warn); background: var(--warn-10); } -.phasePill[data-phase="active"] { color: var(--teal); background: var(--teal-15); font-weight: 600; } -.phasePill[data-phase="review"] { color: var(--warn); background: var(--warn-10); } -.phasePill[data-phase="completed"] { color: var(--muted); background: var(--muted-8); } +.phasePill[data-phase="intake"] { + color: var(--muted); + background: var(--muted-10); +} +.phasePill[data-phase="assessment"] { + color: var(--teal); + background: var(--teal-10); +} +.phasePill[data-phase="planning"] { + color: var(--warn); + background: var(--warn-10); +} +.phasePill[data-phase="active"] { + color: var(--teal); + background: var(--teal-15); + font-weight: 600; +} +.phasePill[data-phase="review"] { + color: var(--warn); + background: var(--warn-10); +} +.phasePill[data-phase="completed"] { + color: var(--muted); + background: var(--muted-8); +} /* ─── Sparkline ──────────────────────────────────────────────────────────────── */ @@ -333,9 +358,15 @@ flex-shrink: 0; } -.sparkDot[data-level="low"] { background: var(--danger); } -.sparkDot[data-level="mid"] { background: var(--warn); } -.sparkDot[data-level="high"] { background: var(--teal); } +.sparkDot[data-level="low"] { + background: var(--danger); +} +.sparkDot[data-level="mid"] { + background: var(--warn); +} +.sparkDot[data-level="high"] { + background: var(--teal); +} /* ─── Card label (used in section headers within detail cards) ─────────────── */ @@ -903,7 +934,7 @@ } .stretchedLink::after { - content: ''; + content: ""; position: absolute; inset: 0; z-index: 0; @@ -1010,7 +1041,10 @@ cursor: pointer; text-transform: capitalize; letter-spacing: 0.04em; - transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); + transition: + background var(--transition-fast), + color var(--transition-fast), + border-color var(--transition-fast); } .filterTab:hover, @@ -1903,9 +1937,18 @@ /* Tone is the whole design of this screen: a section that shouts must be one the reader can trust, so only two of the five are allowed to. */ -.inboxTitleUrgent { composes: inboxTitle; color: var(--danger); } -.inboxTitleAttention { composes: inboxTitle; color: var(--warn); } -.inboxTitleRoutine { composes: inboxTitle; color: var(--muted); } +.inboxTitleUrgent { + composes: inboxTitle; + color: var(--danger); +} +.inboxTitleAttention { + composes: inboxTitle; + color: var(--warn); +} +.inboxTitleRoutine { + composes: inboxTitle; + color: var(--muted); +} .inboxCount { font-size: 0.7rem; diff --git a/app/(admin)/admin/bookings/page.tsx b/app/(admin)/admin/bookings/page.tsx index ec3d5f4..17941fe 100644 --- a/app/(admin)/admin/bookings/page.tsx +++ b/app/(admin)/admin/bookings/page.tsx @@ -4,16 +4,24 @@ import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import styles from "../../admin.module.css"; import { - BOOKING_STATUS, BOOKING_STATUS_CONFIG, BOOKING_STATUS_VALUES, - BOOKING_TYPE_CONFIG, MACHINE_TYPE_CONFIG, - type BookingRowWithUser, type BookingStatus, + BOOKING_STATUS, + BOOKING_STATUS_CONFIG, + BOOKING_STATUS_VALUES, + BOOKING_TYPE_CONFIG, + MACHINE_TYPE_CONFIG, + type BookingRowWithUser, + type BookingStatus, } from "@/lib/config/booking-status"; import { BookingActions } from "@/components/clinical/BookingActions"; -import { formatDateShort, formatDateNumeric, formatSlotDay, formatSlotTime } from "@/lib/utils/format"; +import { + formatDateShort, + formatDateNumeric, + formatSlotDay, + formatSlotTime, +} from "@/lib/utils/format"; import { ADMIN_ROUTES } from "@/lib/config/routes"; import { LoadingState } from "@/components/LoadingState"; - // "upcoming" answers the clinician's daily question — who am I seeing next? — // and is the default: slot bookings are born confirmed, so the old "pending" // default showed an empty list on a normal day. @@ -33,7 +41,10 @@ export default function AdminBookingsPage() { const load = useCallback(async () => { try { const res = await fetch("/api/bookings"); - if (!res.ok) { setLoadError(true); return; } + if (!res.ok) { + setLoadError(true); + return; + } const data = await res.json(); setBookings(data.data ?? []); } catch { @@ -43,7 +54,9 @@ export default function AdminBookingsPage() { } }, []); - useEffect(() => { load(); }, [load]); + useEffect(() => { + load(); + }, [load]); // Clears the admin nav's "new bookings" badge — fire-and-forget, the same // "I looked at this page" signal the patient portal's goals page sends. @@ -72,7 +85,12 @@ export default function AdminBookingsPage() { const now = Date.now(); const upcoming = bookings - .filter((b) => b.scheduledAt && new Date(b.scheduledAt).getTime() >= now && ACTIVE_STATUSES.includes(b.status)) + .filter( + (b) => + b.scheduledAt && + new Date(b.scheduledAt).getTime() >= now && + ACTIVE_STATUSES.includes(b.status), + ) .sort((a, b) => new Date(a.scheduledAt!).getTime() - new Date(b.scheduledAt!).getTime()); const filtered = filter === "upcoming" @@ -100,7 +118,9 @@ export default function AdminBookingsPage() { went unused: nobody had connected a real calendar. */}

Working hours, intake and calendar sync now live on{" "} - My Profile → + + My Profile → +

@@ -111,7 +131,8 @@ export default function AdminBookingsPage() { onClick={() => setFilter(f)} className={`${styles.filterTab}${filter === f ? ` ${styles.filterTabActive}` : ""}`} > - {f}{f === BOOKING_STATUS.pending && pendingCount > 0 ? ` (${pendingCount})` : ""} + {f} + {f === BOOKING_STATUS.pending && pendingCount > 0 ? ` (${pendingCount})` : ""} ))}
@@ -155,22 +176,29 @@ export default function AdminBookingsPage() { {b.user.name ?? No name}
{b.user.email}
- + View patient →
{BOOKING_TYPE_CONFIG[b.bookingType]?.label ?? b.bookingType}
{b.machineType && ( -
{MACHINE_TYPE_CONFIG[b.machineType]?.label}
+
+ {MACHINE_TYPE_CONFIG[b.machineType]?.label} +
)} - {b.scheduledAt - ? `${formatSlotDay(b.scheduledAt)}, ${formatSlotTime(b.scheduledAt)}` - : b.preferredDate - ? formatDateShort(b.preferredDate) - : } + {b.scheduledAt ? ( + `${formatSlotDay(b.scheduledAt)}, ${formatSlotTime(b.scheduledAt)}` + ) : b.preferredDate ? ( + formatDateShort(b.preferredDate) + ) : ( + + )} {b.notes ? ( @@ -179,13 +207,9 @@ export default function AdminBookingsPage() { )} - - {formatDateNumeric(b.createdAt)} - + {formatDateNumeric(b.createdAt)} - - {s.label} - + {s.label} {/* A late cancellation is clinical information, not just accounting — a pattern of them is worth a conversation. */} {b.lateCancellation && ( diff --git a/app/(admin)/admin/documents/page.tsx b/app/(admin)/admin/documents/page.tsx index 0d5bc28..42d14e2 100644 --- a/app/(admin)/admin/documents/page.tsx +++ b/app/(admin)/admin/documents/page.tsx @@ -97,25 +97,21 @@ export default function AdminDocumentsPage() {

Patient documents and lab results

- - {success && ( -
Document added successfully.
- )} + {success &&
Document added successfully.
} {showForm && (

New document

- +
- + {error &&

{error}

}
- -
@@ -201,7 +193,10 @@ export default function AdminDocumentsPage() { {documents.map((doc) => ( - +
{doc.user.name ?? No name}
@@ -209,12 +204,8 @@ export default function AdminDocumentsPage() { {doc.title} - - {doc.mimeType ?? "—"} - - - {formatDateShort(doc.createdAt)} - + {doc.mimeType ?? "—"} + {formatDateShort(doc.createdAt)} { try { const res = await fetch(`/api/messages/${threadId}`); - if (!res.ok) { setLoadError(true); return; } + if (!res.ok) { + setLoadError(true); + return; + } const data = await res.json(); setThread(data.data); } catch { @@ -32,7 +35,9 @@ export default function AdminThreadPage() { } }, [threadId]); - useEffect(() => { load(); }, [load]); + useEffect(() => { + load(); + }, [load]); // Poll for new messages while the tab is focused — matches portal thread behaviour useEffect(() => { @@ -42,7 +47,9 @@ export default function AdminThreadPage() { return () => clearInterval(interval); }, [load]); - useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, [thread?.messages.length]); + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [thread?.messages.length]); async function handleSend(e: React.FormEvent) { e.preventDefault(); @@ -55,7 +62,10 @@ export default function AdminThreadPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body }), }); - if (!res.ok) { setSendError("Failed to send. Please try again."); return; } + if (!res.ok) { + setSendError("Failed to send. Please try again."); + return; + } setBody(""); load(); } catch { @@ -65,7 +75,8 @@ export default function AdminThreadPage() { } } - if (loadError) return
Failed to load thread. Please refresh the page.
; + if (loadError) + return
Failed to load thread. Please refresh the page.
; if (!thread) return ; return ( @@ -80,7 +91,10 @@ export default function AdminThreadPage() { Patient: {thread.patient.name ?? thread.patient.email} {thread.patient.name && ` · ${thread.patient.email}`} {" · "} - + View profile → {thread.clinician?.name && ` · → ${thread.clinician.name}`} @@ -124,7 +138,12 @@ export default function AdminThreadPage() { onChange={(e) => setBody(e.target.value)} maxLength={MESSAGE_BODY_MAX_LENGTH} placeholder="Reply to patient…" - onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(e); } }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(e); + } + }} />
@@ -84,15 +96,11 @@ export function BookingHistoryList({ {machineLabel ? `${typeLabel} — ${machineLabel}` : typeLabel} {b.clinician?.name ? ` with ${b.clinician.name}` : ""}

- {b.scheduledAt && ( -

{CLINIC_LOCATION}

- )} + {b.scheduledAt &&

{CLINIC_LOCATION}

} {b.notes &&

{b.notes}

}
- - {s.label} - + {s.label} {/* Was gated to `pending`, which is a status no booked appointment ever has — every patient who picked a time was stuck with it. The control now decides for itself, diff --git a/app/(portal)/bookings/ClinicianPicker.tsx b/app/(portal)/bookings/ClinicianPicker.tsx index b801078..c1bf7f3 100644 --- a/app/(portal)/bookings/ClinicianPicker.tsx +++ b/app/(portal)/bookings/ClinicianPicker.tsx @@ -21,13 +21,24 @@ type Props = { }; /** Step 1 of the booking picker: "who you'd like to see". */ -export function ClinicianPicker({ clinicians, clinicianId, careTeam, selfId, nextFreeDay, onSelect }: Props) { +export function ClinicianPicker({ + clinicians, + clinicianId, + careTeam, + selfId, + nextFreeDay, + onSelect, +}: Props) { if (clinicians.length === 0) return null; return (

1 · Who you'd like to see

-
+
{clinicians.map((c) => { const active = c.id === clinicianId; const mine = careTeam.includes(c.id); @@ -56,7 +67,9 @@ export function ClinicianPicker({ clinicians, clinicianId, careTeam, selfId, nex {c.name ?? "Clinician"} {mine && Your clinician} {closedToMe && ( - Not accepting new patients + + Not accepting new patients + )} diff --git a/app/(portal)/bookings/RequestBookingForm.tsx b/app/(portal)/bookings/RequestBookingForm.tsx index 593305f..837bf4a 100644 --- a/app/(portal)/bookings/RequestBookingForm.tsx +++ b/app/(portal)/bookings/RequestBookingForm.tsx @@ -32,12 +32,18 @@ type Props = { * confirmed slot. */ export function RequestBookingForm({ - bookingType, onBookingTypeChange, - machineType, onMachineTypeChange, - preferredDate, onPreferredDateChange, - notes, onNotesChange, - submitting, submitError, - onSubmit, onCancel, + bookingType, + onBookingTypeChange, + machineType, + onMachineTypeChange, + preferredDate, + onPreferredDateChange, + notes, + onNotesChange, + submitting, + submitError, + onSubmit, + onCancel, }: Props) { return (
@@ -55,7 +61,10 @@ export function RequestBookingForm({ key={t} type="button" className={`${bookingStyles.typeBtn}${bookingType === t ? ` ${bookingStyles.typeBtnActive}` : ""}`} - onClick={() => { onBookingTypeChange(t); onMachineTypeChange(""); }} + onClick={() => { + onBookingTypeChange(t); + onMachineTypeChange(""); + }} > {BOOKING_TYPE_CONFIG[t].label} @@ -65,7 +74,9 @@ export function RequestBookingForm({ {bookingType === "machine" && (
- +
)}
- - onPreferredDateChange(e.target.value)} /> + + onPreferredDateChange(e.target.value)} + />