mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
417 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|