mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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.
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useState, type ComponentType, type ReactNode, type Ref } from "react";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
History,
|
|
MessageCircle,
|
|
Plus,
|
|
X,
|
|
} from "lucide-react";
|
|
|
|
import {
|
|
CAPTURE_KIND_ORDER,
|
|
CapturePicker,
|
|
visibleCaptureKinds,
|
|
} from "@/components/layout/capture-picker";
|
|
import { Button } from "@/components/ui/button";
|
|
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useAiCapability } from "@/hooks/use-ai-capability";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
|
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
|
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
|
import { resolveIntlLocale } from "@/lib/format-locale";
|
|
import { useTranslations } from "@/lib/i18n/context";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import type { DayFocus } from "./day-layer-controller";
|
|
import {
|
|
DayEvents,
|
|
DayFocusCard,
|
|
DayNotableLines,
|
|
DayRunning,
|
|
DayValues,
|
|
} from "./day-sections";
|
|
import { curateDayValues, tileKeyOf } from "./day-values-model";
|
|
import { useDay } from "./use-day";
|
|
import { useDayValueFormat } from "./use-day-value-format";
|
|
|
|
/**
|
|
* One day, as the layer shows it in every shell: a header with the date and
|
|
* the way to the neighbouring days, the sections, and a footer with the two
|
|
* ways onward.
|
|
*
|
|
* The shell decides only the frame (docked column, sheet from the right,
|
|
* sheet from the bottom). Header, body and footer are this component, so the
|
|
* three frames cannot drift apart.
|
|
*/
|
|
|
|
/** "Saturday, 3 January 2026", for the header and the spoken name. */
|
|
export function useLongDayLabel(): (date: DateKey) => string {
|
|
const { locale } = useTranslations();
|
|
const intl = resolveIntlLocale(locale);
|
|
return (date: DateKey) =>
|
|
new Intl.DateTimeFormat(intl, {
|
|
weekday: "long",
|
|
day: "numeric",
|
|
month: "long",
|
|
year: "numeric",
|
|
timeZone: "UTC",
|
|
}).format(new Date(`${date}T12:00:00.000Z`));
|
|
}
|
|
|
|
export interface DayViewProps {
|
|
date: DateKey;
|
|
today: DateKey;
|
|
focus: DayFocus | null;
|
|
shell: "docked" | "sheet" | "bottom";
|
|
onClose: () => void;
|
|
onStep: (delta: number) => void;
|
|
/** The heading element: an `h2`, or the sheet primitive's title. */
|
|
Title: ComponentType<{
|
|
id?: string;
|
|
className?: string;
|
|
children: ReactNode;
|
|
ref?: Ref<HTMLHeadingElement>;
|
|
tabIndex?: number;
|
|
}>;
|
|
titleId: string;
|
|
titleRef?: Ref<HTMLHeadingElement>;
|
|
/** Shell chrome above the header (the bottom sheet's grabber). */
|
|
above?: ReactNode;
|
|
/** Classes for the header row, which the docked shell aligns to the top bar. */
|
|
headerClassName?: string;
|
|
}
|
|
|
|
const ICON_BUTTON =
|
|
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9";
|
|
|
|
export function DayView({
|
|
date,
|
|
today,
|
|
focus,
|
|
shell,
|
|
onClose,
|
|
onStep,
|
|
Title,
|
|
titleId,
|
|
titleRef,
|
|
above,
|
|
headerClassName,
|
|
}: DayViewProps) {
|
|
const { t, tCount } = useTranslations();
|
|
const longLabel = useLongDayLabel();
|
|
const pathname = usePathname();
|
|
const day = useDay(date);
|
|
const { labelFor, formatTile } = useDayValueFormat();
|
|
const timelineOn = useModuleEnabled("timeline");
|
|
const coach = useAiCapability("coach");
|
|
const capabilities = useRecordCapabilities();
|
|
const { user } = useAuth();
|
|
const [captureOpen, setCaptureOpen] = useState(false);
|
|
|
|
const canCapture =
|
|
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules)
|
|
.length > 0;
|
|
const onTimeline = pathname.startsWith("/timeline");
|
|
const data = day.data;
|
|
const focusTypes = focus?.types ?? [];
|
|
const focusKeys = new Set(focusTypes.map(tileKeyOf));
|
|
const focusNotables =
|
|
data?.notable.filter(
|
|
(n) => n.type !== null && focusKeys.has(tileKeyOf(n.type)),
|
|
) ?? [];
|
|
const otherNotables =
|
|
data?.notable.filter(
|
|
(n) => !(n.type !== null && focusKeys.has(tileKeyOf(n.type))),
|
|
) ?? [];
|
|
const focusTile =
|
|
focus && data
|
|
? curateDayValues(data.values, focusTypes).curated.find((tile) =>
|
|
focusKeys.has(tile.key),
|
|
)
|
|
: undefined;
|
|
const isEmpty =
|
|
data !== undefined &&
|
|
data.values.length === 0 &&
|
|
data.events.length === 0 &&
|
|
data.running.length === 0;
|
|
const notShared = data
|
|
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
|
|
: false;
|
|
const meta = data
|
|
? [
|
|
tCount("day.countValues", data.counts.values),
|
|
tCount("day.countEntries", data.counts.entries),
|
|
].join(" · ")
|
|
: null;
|
|
const compact = shell === "bottom";
|
|
|
|
return (
|
|
<div
|
|
data-slot="day-view"
|
|
data-day={date}
|
|
className="flex min-h-0 flex-1 flex-col"
|
|
>
|
|
{above}
|
|
<div
|
|
data-slot="day-header"
|
|
className={cn(
|
|
"flex shrink-0 items-center gap-1",
|
|
compact
|
|
? "pt-1.5 pr-2 pb-2.5 pl-4"
|
|
: "border-border border-b pr-3 pl-6",
|
|
headerClassName,
|
|
)}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<Title
|
|
id={titleId}
|
|
ref={titleRef}
|
|
tabIndex={-1}
|
|
className="truncate text-base leading-snug font-semibold focus-visible:outline-none"
|
|
>
|
|
{longLabel(date)}
|
|
</Title>
|
|
<p
|
|
className="text-muted-foreground truncate text-xs tabular-nums"
|
|
data-slot="day-meta"
|
|
>
|
|
{meta ?? <span className="invisible">·</span>}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant={compact ? "ghost" : "outline"}
|
|
size="icon"
|
|
data-slot="day-prev"
|
|
aria-label={t("day.previousDay")}
|
|
title={t("day.previousDayShortcut")}
|
|
onClick={() => onStep(-1)}
|
|
className={ICON_BUTTON}
|
|
>
|
|
<ChevronLeft
|
|
className="size-5 pointer-fine:size-4"
|
|
aria-hidden="true"
|
|
/>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={compact ? "ghost" : "outline"}
|
|
size="icon"
|
|
data-slot="day-next"
|
|
aria-label={t("day.nextDay")}
|
|
title={t("day.nextDayShortcut")}
|
|
disabled={date >= today}
|
|
onClick={() => onStep(1)}
|
|
className={ICON_BUTTON}
|
|
>
|
|
<ChevronRight
|
|
className="size-5 pointer-fine:size-4"
|
|
aria-hidden="true"
|
|
/>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
data-slot="day-close"
|
|
aria-label={t("day.close")}
|
|
title={t("day.close")}
|
|
onClick={onClose}
|
|
className={ICON_BUTTON}
|
|
>
|
|
<X className="size-5 pointer-fine:size-4" aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
data-slot="day-body"
|
|
className={cn(
|
|
"flex min-h-0 flex-1 flex-col overflow-x-clip overflow-y-auto overscroll-contain",
|
|
compact ? "gap-6 px-4 pt-0.5 pb-4" : "gap-6 px-6 pt-5 pb-6",
|
|
)}
|
|
>
|
|
{focus ? (
|
|
<div className="space-y-2.5">
|
|
<DayFocusCard
|
|
focus={focus}
|
|
usual={focusTile ? formatTile(focusTile).usual : null}
|
|
/>
|
|
<DayNotableLines
|
|
notables={focusNotables}
|
|
labelFor={labelFor}
|
|
withMetric={false}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{day.isError ? (
|
|
<QueryErrorRow
|
|
message={t("day.loadFailed")}
|
|
onRetry={() => void day.refetch()}
|
|
slot="day-error"
|
|
/>
|
|
) : data === undefined ? (
|
|
<DaySkeleton />
|
|
) : isEmpty ? (
|
|
<p className="text-muted-foreground text-sm" data-slot="day-empty">
|
|
{t("day.empty")}
|
|
</p>
|
|
) : (
|
|
<>
|
|
<DayRunning items={data.running} />
|
|
{data.values.length > 0 ? (
|
|
<div className="space-y-2.5">
|
|
<DayValues values={data.values} focusTypes={focusTypes} />
|
|
<DayNotableLines
|
|
notables={otherNotables}
|
|
labelFor={labelFor}
|
|
withMetric
|
|
/>
|
|
</div>
|
|
) : (
|
|
<DayNotableLines
|
|
notables={otherNotables}
|
|
labelFor={labelFor}
|
|
withMetric
|
|
/>
|
|
)}
|
|
<DayEvents events={data.events} />
|
|
</>
|
|
)}
|
|
|
|
{notShared ? (
|
|
<p
|
|
className="text-muted-foreground text-xs"
|
|
data-slot="day-not-shared"
|
|
>
|
|
{t("day.notShared")}
|
|
</p>
|
|
) : null}
|
|
|
|
{coach.available && !capabilities.inSharedRecord ? (
|
|
<Link
|
|
href={`/coach?ask=${encodeURIComponent(
|
|
t("day.askCoachPrompt", { date: longLabel(date) }),
|
|
)}`}
|
|
data-slot="day-ask-coach"
|
|
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center gap-2 self-start rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
|
|
>
|
|
<MessageCircle className="size-4" aria-hidden="true" />
|
|
{t("day.askCoach")}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
|
|
{canCapture || (timelineOn && !onTimeline) ? (
|
|
<div
|
|
data-slot="day-footer"
|
|
className={cn(
|
|
"border-border flex shrink-0 items-center justify-between gap-2 border-t",
|
|
compact
|
|
? "px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))]"
|
|
: "px-6 py-3.5",
|
|
)}
|
|
>
|
|
{timelineOn && !onTimeline ? (
|
|
<Button
|
|
asChild
|
|
variant="outline"
|
|
size="sm"
|
|
className={cn("min-h-11 sm:min-h-9", compact && "flex-1")}
|
|
>
|
|
<Link
|
|
href={`/timeline?${DAY_QUERY_PARAM}=${date}`}
|
|
data-slot="day-in-timeline"
|
|
>
|
|
<History className="size-4" aria-hidden="true" />
|
|
{compact ? t("day.inTimelineShort") : t("day.inTimeline")}
|
|
</Link>
|
|
</Button>
|
|
) : (
|
|
<span />
|
|
)}
|
|
{canCapture ? (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
data-slot="day-capture"
|
|
onClick={() => setCaptureOpen(true)}
|
|
className={cn("min-h-11 sm:min-h-9", compact && "flex-1")}
|
|
>
|
|
<Plus className="size-4" aria-hidden="true" />
|
|
{compact ? t("day.captureShort") : t("day.captureForDay")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{canCapture ? (
|
|
<CapturePicker
|
|
open={captureOpen}
|
|
onOpenChange={setCaptureOpen}
|
|
defaultDate={date}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DaySkeleton() {
|
|
return (
|
|
<div className="space-y-6" data-slot="day-loading" aria-hidden="true">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-3 w-40" />
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{Array.from({ length: 4 }, (_, i) => (
|
|
<Skeleton key={i} className="h-16 rounded-lg" />
|
|
))}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-3 w-24" />
|
|
<Skeleton className="h-9 w-full" />
|
|
<Skeleton className="h-9 w-full" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|