mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
One bottom sheet on top of the Radix dialog, so focus, Escape, the backdrop, aria and the page scroll lock stay where they were. The sheet follows a touch with a transform, the backdrop lightens with it, and a release past a quarter of the height or a flick down closes it; anything less springs back. Grip and header always drag; content only from its very top, and sideways gestures never count. Every `side="bottom"` sheet and ResponsiveSheet on a phone render through it: the More sheet, the capture and form sheets, the Coach drawer on a phone and the day. The day opens on the new preview step and pulls up to 90 dvh, its footer held on the bottom edge. Focus now returns to whatever opened a sheet, not only to a Radix trigger. The command palette fills the window and keeps no grip or drag.
424 lines
14 KiB
TypeScript
424 lines
14 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>;
|
|
/** 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,
|
|
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}
|
|
// `bg-inherit` passes the sheet's surface down to the footer, which
|
|
// paints it while it stands over the body (see `day-footer`).
|
|
className={cn("flex min-h-0 flex-1 flex-col", compact && "bg-inherit")}
|
|
>
|
|
<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
|
|
? // Under the sheet's grip: the title row's centre lands where
|
|
// every bottom sheet puts it, 52 px down. The header drags
|
|
// the sheet, so it never pans the page.
|
|
"touch-none pt-1.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"
|
|
data-sheet-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"
|
|
// On the phone sheet's preview step the footer is held on the
|
|
// window's bottom edge while the sheet moves (`bottom-sheet.tsx`),
|
|
// over the body: it carries the sheet's own background for that.
|
|
data-sheet-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",
|
|
compact && "bg-inherit",
|
|
)}
|
|
>
|
|
{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>
|
|
);
|
|
}
|