Merge the day running-items and docked-panel changes into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 08:54:46 +02:00
commit fb7f1eea61
29 files changed
+668 -103

No files matched your search

+57 -1
View File
@@ -146,6 +146,7 @@ async function render(
date: string;
today: string;
focus: unknown;
shell: "docked" | "sheet" | "bottom";
}> = {},
) {
const { I18nProvider } = await import("@/lib/i18n/context");
@@ -156,7 +157,7 @@ async function render(
date={props.date ?? "2026-01-03"}
today={props.today ?? "2026-10-08"}
focus={(props.focus as never) ?? null}
shell="docked"
shell={props.shell ?? "docked"}
onClose={() => undefined}
onStep={() => undefined}
Title={(p) => <h2 {...p} />}
@@ -190,6 +191,61 @@ describe("<DayView>", () => {
expect(html).toContain('href="/illness/ep1"');
});
it("states no day count for a record without a start, and words a life event's category", async () => {
dayQuery = {
data: {
...DAY,
running: [
{
kind: "medication",
section: "medications",
id: "m9",
title: "Levothyroxine",
sub: "50 µg",
since: null,
until: null,
dayIndex: null,
dayCount: null,
href: "/medications/m9",
},
{
kind: "lifeEvent",
section: "lifeEvents",
id: "le1",
title: "Parental leave",
sub: "FAMILY",
since: "2025-12-01",
until: "2026-02-28",
dayIndex: 34,
dayCount: 90,
href: null,
},
],
},
};
const html = await render();
const rows = html.split('data-slot="day-running-item"').slice(1);
const med = rows.find((row) => row.includes("Levothyroxine")) ?? "";
expect(med).toContain("50 µg");
expect(med.split("</li>")[0]).not.toMatch(/Day \d|since/);
const leave = rows.find((row) => row.includes("Parental leave")) ?? "";
expect(leave).toContain("Family · Day 34 of 90");
expect(leave).not.toContain("FAMILY");
});
it("collapses the docked day and closes a sheet, and says which", async () => {
const docked = await render();
const hide = docked.match(/<button[^>]*data-slot="day-close"[^>]*>/)?.[0];
expect(hide).toContain('aria-label="Hide day"');
expect(hide).toContain('aria-expanded="true"');
for (const shell of ["sheet", "bottom"] as const) {
const sheet = await render({ shell });
const close = sheet.match(/<button[^>]*data-slot="day-close"[^>]*>/)?.[0];
expect(close).toContain('aria-label="Close day"');
expect(close).not.toContain("aria-expanded");
}
});
it("folds the blood pressure into one tile on its number line", async () => {
const html = await render();
expect(html).toContain('data-type="BLOOD_PRESSURE"');
+132 -8
View File
@@ -12,21 +12,32 @@ import {
useSyncExternalStore,
} from "react";
import { PanelRightOpen } from "lucide-react";
import { ShellSidePanel } from "@/components/layout/shell-side-panel";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import { useAuth } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
import {
readLastDay,
subscribeLastDay,
writeLastDay,
} from "@/lib/day/last-day";
import { getRecordScope } from "@/lib/query-keys/record-scope";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
closeDay,
openDay,
peekDayTriggerAt,
publishOpenDay,
stepDay,
@@ -34,7 +45,12 @@ import {
useDayFocus,
} from "./day-layer-controller";
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
import { DayView, useLongDayLabel } from "./day-view";
import {
DayView,
PANEL_TOGGLE,
PANEL_TOGGLE_ICON,
useLongDayLabel,
} from "./day-view";
import { usePrefetchDay } from "./use-day";
import { useTodayKey } from "./use-today-key";
@@ -47,7 +63,10 @@ import { useTodayKey } from "./use-today-key";
* - from 1280 px, a column docked beside the page, the same place and the
* same breakpoint as the Coach's conversations panel. Not modal: the
* chart beside it stays usable, and another point swaps the day. It is a
* landmark (`complementary`) named by the date.
* landmark (`complementary`) named by the date. It collapses like a
* sidebar instead of going away: hidden, a narrow edge stays on the
* right with a button that brings the last day back, on this page or any
* other (`last-day.ts` remembers it per browser and account).
* - from 768 px, a sheet from the right, modal.
* - on a phone, a sheet from the bottom at a bit over half the height, so
* the chart stays in view above it; dragging the handle up (or tapping
@@ -57,7 +76,9 @@ import { useTodayKey } from "./use-today-key";
* there is never more than one docked panel: the day opens as a sheet there.
*
* The parameter is the state. A date that is not a calendar date, or lies in
* the future, is removed from the URL without a word.
* the future, is removed from the URL without a word. Collapsing the docked
* day removes the parameter like closing does; only the remembered date
* stays, and expanding opens it again.
*/
export function DayLayerMount() {
// `useSearchParams` suspends a statically rendered route until the client
@@ -120,6 +141,18 @@ function DayLayer() {
}, [date]);
useEffect(() => () => publishOpenDay(null), []);
// The last open day, bound to the account and the record it belongs to.
const { user } = useAuth();
const owner = user ? `${user.id}:${getRecordScope() ?? "own"}` : null;
useEffect(() => {
if (date !== null && owner !== null) writeLastDay(owner, date);
}, [date, owner]);
const lastDay = useSyncExternalStore(
subscribeLastDay,
() => readLastDay(owner),
() => null,
);
const wide = useWideViewport();
const phone = useIsMobile();
const shell: "docked" | "sheet" | "bottom" = phone
@@ -136,9 +169,27 @@ function DayLayer() {
const prefetchDay = usePrefetchDay();
const focus = useDayFocus(date);
const longLabel = useLongDayLabel();
const shortLabel = useLongDayLabel("short");
const { t } = useTranslations();
const titleId = useId();
const titleRef = useRef<HTMLHeadingElement>(null);
const panelRef = useRef<HTMLElement>(null);
const expandRef = useRef<HTMLButtonElement>(null);
// Set by the collapse control: focus then lands on the control that
// brings the day back, in the same place, as a sidebar's toggle does.
const collapsing = useRef(false);
// The collapsed edge: the docked frame only, and only with a day to bring
// back that is still a day the layer would open.
const railDay =
shell === "docked" && date === null && lastDay !== null
? parseDayParam(lastDay, today)
: null;
const collapse = useCallback(() => {
collapsing.current = true;
closeDay();
}, []);
// What the polite region says: the day that just opened or was stepped to.
const announcement = date !== null ? longLabel(date) : "";
@@ -154,7 +205,16 @@ function DayLayer() {
}
return;
}
if (before !== null && shell === "docked") returnFocus();
if (before !== null && shell === "docked") {
const rail = expandRef.current;
if (collapsing.current && rail) {
takeDayTrigger();
rail.focus({ preventScroll: true });
} else {
returnFocus(rail);
}
}
collapsing.current = false;
}, [date, shell]);
const onStep = useCallback(
@@ -213,7 +273,64 @@ function DayLayer() {
</p>
);
if (date === null) return live;
if (date === null) {
if (railDay === null) return live;
const label = t("day.showPanel", { date: longLabel(railDay) });
const expand = () =>
openDay(railDay, { trigger: expandRef.current ?? undefined });
return (
<>
{live}
<ShellSidePanel>
<div
data-slot="day-rail"
data-day={railDay}
className="bg-card text-card-foreground border-border flex h-full w-12 shrink-0 flex-col border-l"
>
{/* The top bar's band, so the two bottom borders draw one line;
the control sits where the open day keeps its own. */}
<div
className={cn(
SHELL_HEADER_BAND,
"border-border flex shrink-0 items-center justify-center",
)}
>
<Button
ref={expandRef}
type="button"
variant="ghost"
size="icon"
data-slot="day-expand"
aria-expanded={false}
aria-label={label}
title={label}
onClick={expand}
className={PANEL_TOGGLE}
>
<PanelRightOpen
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</div>
{/* The rest of the edge takes a click too and names the day it
holds; the keyboard has the button above. */}
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={expand}
className="hover:bg-muted/60 text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
>
<span className="[writing-mode:vertical-rl]">
{shortLabel(railDay)}
</span>
</button>
</div>
</ShellSidePanel>
</>
);
}
const view = (
Title: Parameters<typeof DayView>[0]["Title"],
@@ -224,7 +341,7 @@ function DayLayer() {
today={today}
focus={focus}
shell={shell}
onClose={closeDay}
onClose={shell === "docked" ? collapse : closeDay}
onStep={onStep}
Title={Title}
titleId={titleId}
@@ -334,13 +451,20 @@ function DockedTitle({
return <h2 ref={ref} {...props} />;
}
/** Return focus to whatever opened the day, or to the page. */
function returnFocus() {
/**
* Return focus to whatever opened the day; failing that, to `fallback` (the
* collapsed edge's control), or to the page.
*/
function returnFocus(fallback?: HTMLElement | null) {
const trigger = takeDayTrigger();
if (trigger && trigger.isConnected) {
trigger.focus({ preventScroll: true });
return;
}
if (fallback && fallback.isConnected) {
fallback.focus({ preventScroll: true });
return;
}
document.getElementById("main-content")?.focus({ preventScroll: true });
}
+18 -10
View File
@@ -23,13 +23,14 @@ import {
type LucideIcon,
} from "lucide-react";
import type {
DayEvent,
DayEventKind,
DayNotable,
DayRunningItem,
DayRunningKind,
DayValue,
import {
LIFE_EVENT_CATEGORIES,
type DayEvent,
type DayEventKind,
type DayNotable,
type DayRunningItem,
type DayRunningKind,
type DayValue,
} from "@/lib/day/contract";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -157,7 +158,6 @@ const RUNNING_COLOR: Record<DayRunningKind, string> = {
restMode: "var(--chart-5)",
cyclePhase: "var(--chart-3)",
travel: "var(--chart-4)",
lifestyle: "var(--muted-foreground)",
lifeEvent: "var(--chart-2)",
};
@@ -181,6 +181,10 @@ export function dayTitle(
const FLOW_LEVELS = new Set(["NONE", "SPOTTING", "LIGHT", "MEDIUM", "HEAVY"]);
const LIFE_EVENT_CATEGORY_SET: ReadonlySet<string> = new Set(
LIFE_EVENT_CATEGORIES,
);
function dayMeta(
kind: string,
meta: string,
@@ -189,6 +193,10 @@ function dayMeta(
if (kind === "cycleDayLog" && FLOW_LEVELS.has(meta)) {
return `${t("cycle.flow.label")}: ${t(`cycle.flow.${meta}`)}`;
}
// A life event's second line is its category, a closed code.
if (kind === "lifeEvent" && LIFE_EVENT_CATEGORY_SET.has(meta)) {
return t(`lifeEvents.category.${meta}`);
}
return meta;
}
@@ -202,13 +210,13 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
<ul>
{items.map((item) => {
const parts = [
item.sub,
item.sub === null ? null : dayMeta(item.kind, item.sub, t),
item.dayIndex !== null
? item.dayCount !== null
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
: t("day.dayN", { n: item.dayIndex })
: null,
item.dayCount === null
item.dayCount === null && item.since !== null
? t("day.since", {
date: fmt.dateShortSmartCalendar(item.since),
})
+13 -9
View File
@@ -96,14 +96,15 @@ const ICON_BUTTON =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9";
/**
* The close control is the Coach panel's own toggle, in the same place: the
* panel's top-left corner, the panel icon mirrored so it points the way the
* panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as a
* copy so the day layer does not pull the Coach's panel into the shell).
* The collapse control is the Coach panel's own toggle, in the same place:
* the panel's top-left corner, the panel icon mirrored so it points the way
* the panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as
* a copy so the day layer does not pull the Coach's panel into the shell).
* The collapsed edge's control in `day-layer.tsx` takes the same pair.
*/
const PANEL_TOGGLE =
export const PANEL_TOGGLE =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7";
const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4";
export const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4";
export function DayView({
date,
@@ -178,6 +179,7 @@ export function DayView({
].join(" · ")
: null;
const compact = shell === "bottom";
const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
return (
<div
@@ -194,14 +196,16 @@ export function DayView({
headerClassName,
)}
>
{/* Top left, where the Coach panel keeps its toggle: the way out. */}
{/* Top left, where the Coach panel keeps its toggle: the way out.
Docked, it collapses the day to the edge; a sheet closes. */}
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={t("day.close")}
title={t("day.close")}
aria-label={closeLabel}
aria-expanded={shell === "docked" ? true : undefined}
title={closeLabel}
onClick={onClose}
className={PANEL_TOGGLE}
>
@@ -261,6 +261,9 @@ export function LayoutCoachFab() {
// the page and never over the day's footer actions. The `:has()`
// gate keys off the docked panel's slot, like the yields below.
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+2rem)]",
// Collapsed, the day keeps a 3 rem edge on the right; the launcher
// stays clear of it the same way.
"[body:has([data-slot=day-rail])_&]:right-[calc(3rem+2rem)]",
// Yield to the data-list selection bar: its delete action lands
// in the same lower-right band, and the destructive control
// wins. The `:has()` gate keys off the bar's `data-slot`.