Files
HealthLog/src/components/day/day-view.tsx
T
Marc-André Bombeck e7ee416e19 refactor(ui): move 62 hand-written empty states onto EmptyState
The admin sections, the cycle, illness, medication, mood, records and
settings cards, the pickers and dialogs said nothing-here-yet in a
hand-written muted paragraph in five sizes and three alignments. List
and section states now render through EmptyState (plain, compact; the
three with a next step carry it as the action), and the six chart
bodies through ChartEmptyState at their former height. The thirteen
that stay are a field without a value or a hint inside an editor, not
a list state. The ratchet ceiling drops from 75 to 13.
2026-10-20 13:31:02 +02:00

417 lines
13 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 { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { useLongDayLabel } from "./day-label";
import { DayDatePicker } from "./day-date-picker";
import type { DayFocus } from "./day-layer-controller";
import {
DayEvents,
DayFocusCard,
DayNotableLines,
DayRunning,
DayValues,
} from "./day-sections";
import { DayMovementSection } from "./day-movement";
import { DayScores } from "./day-scores";
import { tileKeyOf } from "./day-values-model";
import { shiftDateKey } from "./day-url";
import { useDay, usePrefetchDay } from "./use-day";
import { useDayValueFormat } from "./use-day-value-format";
import { EmptyState } from "@/components/ui/empty-state";
/**
* 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.
*/
export interface DayViewProps {
date: DateKey;
today: DateKey;
focus: DayFocus | null;
shell: "docked" | "sheet" | "bottom";
onClose: () => void;
onStep: (delta: number) => void;
/** Open another day, picked from the header's calendar. */
onPick: (date: DateKey) => 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";
/**
* The sheets' close control, at the trailing end of the header like the
* Coach sheet's own. The docked day has none: its strip at the right edge of
* the window (`shell-dock.tsx`) is the one control that opens and closes it.
*/
const CLOSE_BUTTON =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7";
export function DayView({
date,
today,
focus,
shell,
onClose,
onStep,
onPick,
Title,
titleId,
titleRef,
above,
headerClassName,
}: DayViewProps) {
const { t } = useTranslations();
const longLabel = useLongDayLabel();
// "Mo., 5. Okt. 2026" in the phone sheet's header: a September Thursday
// spelled out in full does not fit beside the arrows at 360 px, and a
// date cut off before its year reads as the wrong day.
const shortLabel = useLongDayLabel("short");
const pathname = usePathname();
const day = useDay(date);
const { labelFor } = useDayValueFormat();
const timelineOn = useModuleEnabled("timeline");
const coach = useAiCapability("coach");
const capabilities = useRecordCapabilities();
const { user } = useAuth();
const [captureOpen, setCaptureOpen] = useState(false);
// The neighbouring day is read while the pointer rests on its arrow (or
// the keyboard reaches it, or a finger lands on it), so the step paints a
// day, not a skeleton.
const prefetchDay = usePrefetchDay();
const warm = (delta: number) => () => {
const next = shiftDateKey(date, delta);
if (delta > 0 && next > today) return;
prefetchDay(next);
};
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 isEmpty =
data !== undefined &&
data.values.length === 0 &&
data.events.length === 0 &&
data.running.length === 0 &&
(data.scores ?? []).length === 0 &&
!data.movement;
const notShared = data
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
: false;
const compact = shell === "bottom";
const docked = shell === "docked";
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",
// The date starts on the body's reading edge (`px-4` / `px-6`).
compact
? "pt-0.5 pr-2 pb-2 pl-4"
: "border-border border-b pr-3 pl-6",
headerClassName,
)}
>
{/* One line, centred in the band: the date and nothing under it. */}
<Title
id={titleId}
ref={titleRef}
tabIndex={-1}
className="min-w-0 flex-1 truncate pr-1 text-base leading-snug font-semibold focus-visible:outline-none"
>
{/* The date is the way to any other day: a calendar opens from
it, so the header needs no icon of its own. */}
<DayDatePicker
date={date}
today={today}
onPick={onPick}
variant={compact ? "sheet" : "popover"}
label={
compact ? (
<>
<span aria-hidden="true">{shortLabel(date)}</span>
<span className="sr-only">{longLabel(date)}</span>
</>
) : (
longLabel(date)
)
}
/>
</Title>
<Button
type="button"
variant={compact ? "ghost" : "outline"}
size="icon"
data-slot="day-prev"
aria-label={t("day.previousDay")}
title={t("day.previousDayShortcut")}
onPointerEnter={warm(-1)}
onPointerDown={warm(-1)}
onFocus={warm(-1)}
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}
onPointerEnter={warm(1)}
onPointerDown={warm(1)}
onFocus={warm(1)}
onClick={() => onStep(1)}
className={ICON_BUTTON}
>
<ChevronRight
className="size-5 pointer-fine:size-4"
aria-hidden="true"
/>
</Button>
{docked ? null : (
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={t("day.close")}
title={t("day.close")}
onClick={onClose}
className={CLOSE_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-4 pb-6",
)}
>
{focus ? (
<div className="space-y-2.5">
<DayFocusCard focus={focus} />
<DayNotableLines
notables={focusNotables}
labelFor={labelFor}
withMetric={false}
/>
</div>
) : null}
{day.isError ? (
<QueryErrorRow
error={day.error}
message={t("day.loadFailed")}
onRetry={() => void day.refetch()}
slot="day-error"
/>
) : data === undefined ? (
<DaySkeleton />
) : isEmpty ? (
<EmptyState
variant="plain"
size="compact"
title={t("day.empty")}
data-slot="day-empty"
/>
) : (
<>
<DayRunning items={data.running} date={date} />
{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
/>
)}
<DayScores scores={data.scores ?? []} />
{data.movement ? (
<DayMovementSection movement={data.movement} />
) : null}
<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>
);
}