Files
HealthLog/src/components/day/day-view.tsx
T
Marc-André Bombeck 1cc1cda7aa feat(ui): make every bottom sheet draggable
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.
2026-10-22 10:20:28 +02:00

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>
);
}