mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
A date is now a door to its day (#613). `?day=YYYY-MM-DD` opens the day over whichever page is open: docked beside it from 1280 px, the way the Coach's conversations panel sits, a sheet from the right below that, and a bottom sheet on a phone that opens at a little over half the height and grows to full height from its handle. On /coach it is always a sheet, so there is never more than one docked panel. Back and Escape close it, the previous and next day replace the history entry instead of adding one, Alt with an arrow steps a day, focus moves to the day's heading on open and returns to whatever opened it on close, and a polite region names the day. A malformed or future date is dropped from the URL. The day shows the value it was opened from with its comparison, what ran through the day, the readings against the person's own usual range on a short number line (eight curated, the rest behind "All values"), and what happened in order of the clock, each row linking to its record. Loading, failure, an empty day and areas a grant does not cover each read as such. The footer offers the timeline when that module is on and capture for the day, with the capture forms starting on that date. Doors into it: a click on a daily point of a metric chart (a tap shows the value and the tooltip offers the day on touch screens), the dates in the chart's data table, a quiet row of dots under the axis for days with entries and a ring for notable ones, the lab chart and lab readings, the measurement, mood and intake lists, symptom and illness day headings, episode onset and recovery, document dates, workout dates, the workout detail, the mood and adherence heatmaps, and the days a Coach answer's get_day calls read, shown as chips. The next planned visit carries what happened since the last one, each date opening its day, with a doctor report for that window. Not here, and frozen by a guard: the dashboard's today area, mini charts in tiles, the cycle calendar, model-written text, future dates, the clinician share link and the doctor-report PDF.
178 lines
6.6 KiB
TypeScript
178 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* "Termine" — the visits half of the checkups page.
|
|
*
|
|
* Two lists rather than one, and the split is the server's: upcoming reads
|
|
* soonest-first, past reads newest-first, and the API resolves both so the
|
|
* ordering lives in one place. A client that split and re-sorted one array
|
|
* would be the second place it lives.
|
|
*
|
|
* No new nav entry and no new route. `/checkups` already owns exactly the right
|
|
* sentence — when do I have to do what, where — and is deliberately not module
|
|
* gated, which is also why a visit carries no module key of its own.
|
|
*/
|
|
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { Fragment, useState } from "react";
|
|
|
|
import { SinceLastVisit } from "@/components/day/since-last-visit";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { PageHeader } from "@/components/ui/page-header";
|
|
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
|
import { SectionHeading } from "@/components/ui/section-heading";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
|
|
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
|
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { EncounterSheet } from "./encounter-sheet";
|
|
import { VisitCard } from "./visit-card";
|
|
|
|
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
|
const { t } = useTranslations();
|
|
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
|
// `POST /api/encounters` answers at WRITE under the profile section; the
|
|
// edit and the delete behind the row are both MANAGE. Only the add was
|
|
// asked, so every row stayed tappable into a sheet whose Save and Delete
|
|
// the server refuses.
|
|
const canAddVisit = canWriteDomain("profile");
|
|
const canManageVisits = canManageDomain("profile");
|
|
const list = useEncounters(enabled);
|
|
|
|
// `null` closed, `"new"` create, a row edits it.
|
|
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
|
|
const [session, setSession] = useState(0);
|
|
const openSheet = (target: Encounter | "new") => {
|
|
setEditing(target);
|
|
setSession((n) => n + 1);
|
|
};
|
|
|
|
const upcoming = list.data?.upcoming ?? [];
|
|
const past = list.data?.past ?? [];
|
|
|
|
// `?visit=<id>` — a document's link chip names the visit it is filed
|
|
// against. Scroll to it, and open it for someone who may edit it.
|
|
useDeepLinkedRecord({
|
|
param: "visit",
|
|
records: list.data ? [...upcoming, ...past] : undefined,
|
|
anchorAttribute: "data-encounter-id",
|
|
open: canManageVisits ? openSheet : undefined,
|
|
});
|
|
const empty = upcoming.length === 0 && past.length === 0;
|
|
// The last visit that took place: the start of the visit preparation.
|
|
const lastDone = past.find((encounter) => encounter.status === "DONE");
|
|
|
|
const addButton = canAddVisit ? (
|
|
<Button
|
|
type="button"
|
|
className="min-h-11"
|
|
data-slot="visits-add"
|
|
onClick={() => openSheet("new")}
|
|
>
|
|
<Plus className="size-4" aria-hidden />
|
|
{t("encounters.add")}
|
|
</Button>
|
|
) : null;
|
|
|
|
return (
|
|
<section aria-labelledby="visits-section-title" className="space-y-4">
|
|
<PageHeader
|
|
titleId="visits-section-title"
|
|
title={t("encounters.sectionTitle")}
|
|
description={t("encounters.sectionDescription")}
|
|
actions={
|
|
<>
|
|
<Button asChild variant="outline" className="min-h-11">
|
|
<Link href="/checkups/practitioners">
|
|
<ContactRound className="size-4" aria-hidden />
|
|
{t("practitioners.linkLabel")}
|
|
</Link>
|
|
</Button>
|
|
{addButton}
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{list.isError ? (
|
|
<QueryErrorCard
|
|
title={t("encounters.loadError")}
|
|
onRetry={() => void list.refetch()}
|
|
/>
|
|
) : list.isPending ? (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 3 }, (_, index) => (
|
|
<Skeleton key={index} className="h-20 w-full rounded-xl" />
|
|
))}
|
|
</div>
|
|
) : empty ? (
|
|
<EmptyState
|
|
icon={<CalendarClock className="size-6" aria-hidden />}
|
|
title={t("encounters.emptyTitle")}
|
|
description={t("encounters.emptyDescription")}
|
|
action={addButton ?? undefined}
|
|
ctaSize="lg"
|
|
/>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{upcoming.length > 0 ? (
|
|
<div className="space-y-2" data-slot="visits-upcoming">
|
|
<SectionHeading
|
|
icon={CalendarClock}
|
|
title={t("encounters.upcomingHeading")}
|
|
/>
|
|
{upcoming.map((encounter, i) => (
|
|
<Fragment key={encounter.id}>
|
|
<VisitCard
|
|
encounter={encounter}
|
|
onOpen={canManageVisits ? openSheet : undefined}
|
|
/>
|
|
{/* v1.42 — the next appointment carries what happened
|
|
since the last one, each date opening its day. */}
|
|
{i === 0 && lastDone ? (
|
|
<Card className="gap-3 py-3 md:gap-3 md:py-4">
|
|
<CardContent>
|
|
<SinceLastVisit next={encounter} last={lastDone} />
|
|
</CardContent>
|
|
</Card>
|
|
) : null}
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{past.length > 0 ? (
|
|
<div className="space-y-2" data-slot="visits-past">
|
|
<SectionHeading
|
|
icon={History}
|
|
title={t("encounters.pastHeading")}
|
|
/>
|
|
{past.map((encounter) => (
|
|
<VisitCard
|
|
key={encounter.id}
|
|
encounter={encounter}
|
|
onOpen={canManageVisits ? openSheet : undefined}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* `key` changes on every open, so the sheet remounts with a fresh draft
|
|
instead of copying props into state from an effect. It stays mounted
|
|
while closing, which is what keeps the exit animation. */}
|
|
<EncounterSheet
|
|
key={session}
|
|
open={editing !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditing(null);
|
|
}}
|
|
encounter={editing === "new" ? null : editing}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|