feat(day): open any day from a calendar on the header's date

The written date in the day's header is now the way to any other day. A
click (or Enter, or Space) opens a month calendar beside it: the day on
screen is selected, every day that holds anything carries a dot read from
GET /api/day/index for the month shown, nothing past today can be picked,
and a Today button sits under the grid. Picking a day swaps it in like a
step, without a new history entry; Escape closes only the calendar and
focus returns to the date. The header keeps its one line and gains no
icon; the arrows stay. The same popover serves the docked column and both
sheets, with 40 px cells for a finger.

On /coach the day now docks beside the conversation like everywhere else,
right of the conversations, instead of opening as a sheet. A neighbouring
panel can fold the docked day to its edge without moving focus
(yieldDay).

The day's count line reads "3 values, 2 entries".
This commit is contained in:
Marc-André Bombeck committed 2026-10-09 13:38:11 +02:00
1 parent d8a3614120
commit 9d5a529d53
15 files changed
+555 -21

No files matched your search

+79 -1
View File
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import type { Page, TestInfo } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
@@ -26,6 +26,8 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
* - the dashboard's today area offers no door; its charts below do.
*
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
* the header's `day-date-button` and its `day-date-picker` (days keyed by
* `data-date-key`, dotted by `data-entries`, and `day-date-today`),
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
* `day-next` / `day-close`, and the collapsed edge `day-rail` (+ `data-day`)
* with its `day-expand`.
@@ -33,6 +35,18 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
async function shot(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
const dir = process.env.DAY_SHOTS_DIR;
if (dir) {
await page.screenshot({
path: `${dir}/${name}-${testInfo.project.name}.png`,
});
}
}
function isoDaysAgo(days: number): string {
const d = new Date(Date.now() - days * 86_400_000);
return d.toISOString().slice(0, 10);
@@ -159,6 +173,70 @@ test.describe("the day view", () => {
await expect(rail).toHaveCount(0);
});
test("the date opens a calendar; a picked day opens, Today goes to today", async ({
page,
}, testInfo) => {
const desktop = testInfo.project.name === "chromium-desktop";
// The days of the month that hold anything carry a dot.
await mockDay(page, { index: "full" });
const day = isoDaysAgo(5);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute(
"data-shell",
desktop ? "docked" : "bottom",
);
const date = panel(page).locator('[data-slot="day-date-button"]');
const picker = page.locator('[data-slot="day-date-picker"]');
// The date stays one line in the header, the arrows beside it.
await expect(date).toHaveAttribute("aria-expanded", "false");
await expect(panel(page).locator('[data-slot="day-prev"]')).toBeVisible();
await shot(page, testInfo, "day-date-closed");
await date.click();
await expect(picker).toBeVisible();
await expect(date).toHaveAttribute("aria-expanded", "true");
await expect(picker.locator(`[data-date-key="${day}"]`)).toHaveAttribute(
"data-selected-single",
"true",
);
await expect(picker.locator('[data-entries="true"]').first()).toBeVisible();
// Nothing past today can be picked.
const tomorrow = picker.locator(`[data-date-key="${isoDaysAgo(-2)}"]`);
if ((await tomorrow.count()) > 0) await expect(tomorrow).toBeDisabled();
await shot(page, testInfo, "day-date-open");
// Another day of the same month: the 1st, or the 2nd when the open day
// is the 1st itself.
const first = `${day.slice(0, 8)}01`;
const target = first === day ? isoDaysAgo(4) : first;
const start = await page.evaluate(() => window.history.length);
await picker.locator(`[data-date-key="${target}"]`).click();
await expect(picker).toHaveCount(0);
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
target,
);
await expect(page).toHaveURL(new RegExp(`[?&]day=${target}`));
// Like a step: no new history entry, and focus back on the date.
expect(await page.evaluate(() => window.history.length)).toBe(start);
await expect(date).toBeFocused();
// The keyboard: Enter opens, Escape closes the calendar only.
await page.keyboard.press("Enter");
await expect(picker).toBeVisible();
await page.keyboard.press("Escape");
await expect(picker).toHaveCount(0);
await expect(panel(page)).toBeVisible();
await expect(date).toBeFocused();
// Today.
await page.keyboard.press("Space");
await expect(picker).toBeVisible();
await picker.locator('[data-slot="day-date-today"]').click();
await expect(picker).toHaveCount(0);
await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled();
});
test("a sheet closes for good and leaves no edge behind", async ({
page,
}, testInfo) => {
+4
View File
@@ -12242,6 +12242,10 @@
},
"previousDayShortcut": "Vortag (Alt + Pfeil links)",
"nextDayShortcut": "Folgetag (Alt + Pfeil rechts)",
"pickDate": "Datum wählen",
"pickerTitle": "Zu einem Tag springen",
"today": "Heute",
"hasEntries": "mit Einträgen",
"captureShort": "Erfassen",
"inTimelineShort": "Zeitleiste",
"askCoachPrompt": "Was war am {date}?",
+4
View File
@@ -12242,6 +12242,10 @@
},
"previousDayShortcut": "Previous day (Alt + Left arrow)",
"nextDayShortcut": "Next day (Alt + Right arrow)",
"pickDate": "Choose a date",
"pickerTitle": "Go to a day",
"today": "Today",
"hasEntries": "has entries",
"captureShort": "Log",
"inTimelineShort": "Timeline",
"askCoachPrompt": "What happened on {date}?",
+4
View File
@@ -12242,6 +12242,10 @@
},
"previousDayShortcut": "Día anterior (Alt + flecha izquierda)",
"nextDayShortcut": "Día siguiente (Alt + flecha derecha)",
"pickDate": "Elegir una fecha",
"pickerTitle": "Ir a un día",
"today": "Hoy",
"hasEntries": "con entradas",
"captureShort": "Registrar",
"inTimelineShort": "Cronología",
"askCoachPrompt": "¿Qué pasó el {date}?",
+4
View File
@@ -12242,6 +12242,10 @@
},
"previousDayShortcut": "Jour précédent (Alt + flèche gauche)",
"nextDayShortcut": "Jour suivant (Alt + flèche droite)",
"pickDate": "Choisir une date",
"pickerTitle": "Aller à un jour",
"today": "Aujourd’hui",
"hasEntries": "avec des entrées",
"captureShort": "Enregistrer",
"inTimelineShort": "Chronologie",
"askCoachPrompt": "Que s’est-il passé le {date} ?",
+4
View File
@@ -12242,6 +12242,10 @@
},
"previousDayShortcut": "Giorno precedente (Alt + freccia sinistra)",
"nextDayShortcut": "Giorno successivo (Alt + freccia destra)",
"pickDate": "Scegli una data",
"pickerTitle": "Vai a un giorno",
"today": "Oggi",
"hasEntries": "con voci",
"captureShort": "Registra",
"inTimelineShort": "Cronologia",
"askCoachPrompt": "Cosa è successo il {date}?",
+4
View File
@@ -12242,6 +12242,10 @@
},
"previousDayShortcut": "전날 (Alt + 왼쪽 화살표)",
"nextDayShortcut": "다음 날 (Alt + 오른쪽 화살표)",
"pickDate": "날짜 선택",
"pickerTitle": "날짜로 이동",
"today": "오늘",
"hasEntries": "기록 있음",
"captureShort": "기록",
"inTimelineShort": "타임라인",
"askCoachPrompt": "{date}에 무슨 일이 있었나요?",
+4
View File
@@ -12242,6 +12242,10 @@
},
"previousDayShortcut": "Poprzedni dzień (Alt + strzałka w lewo)",
"nextDayShortcut": "Następny dzień (Alt + strzałka w prawo)",
"pickDate": "Wybierz datę",
"pickerTitle": "Przejdź do dnia",
"today": "Dzisiaj",
"hasEntries": "z wpisami",
"captureShort": "Zapisz",
"inTimelineShort": "Oś czasu",
"askCoachPrompt": "Co się działo {date}?",
@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import enMessages from "../../../../messages/en.json";
import { I18nProvider } from "@/lib/i18n/context";
import {
DayCalendar,
DayDatePicker,
keyToLocalDate,
localDateToKey,
monthWindow,
pickedDay,
} from "../day-date-picker";
/**
* The day header's calendar: the date is the button that opens it, the month
* marks the days that hold anything, nothing past today can be picked, and
* "Today" goes to today. SSR, like the rest of the suite; the clicks
* themselves run in `e2e/day-view.spec.ts`.
*/
vi.mock("../use-day", () => ({
useDayIndex: () => ({ data: undefined }),
}));
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en" initialMessages={enMessages}>
{node}
</I18nProvider>,
);
}
function calendar(
props: Partial<Parameters<typeof DayCalendar>[0]> = {},
): string {
return render(
<DayCalendar
date="2026-09-24"
today="2026-10-08"
month={keyToLocalDate("2026-09-24")}
onMonthChange={() => undefined}
withEntries={["2026-09-03", "2026-09-24"]}
onPick={() => undefined}
{...props}
/>,
);
}
function dayButton(html: string, key: string): string {
return (
html.match(new RegExp(`<button[^>]*data-date-key="${key}"[^>]*>`))?.[0] ??
""
);
}
describe("<DayDatePicker>", () => {
it("makes the written date the button, with no icon beside it", () => {
const html = render(
<DayDatePicker
date="2026-09-24"
today="2026-10-08"
label="Thursday, September 24, 2026"
onPick={() => undefined}
/>,
);
const button = html.match(/<button[^>]*data-slot="day-date-button"[^>]*>/);
expect(button?.[0]).toContain('aria-haspopup="dialog"');
expect(button?.[0]).toContain('aria-expanded="false"');
expect(html).toContain("Thursday, September 24, 2026");
expect(html).not.toContain("<svg");
});
});
describe("<DayCalendar>", () => {
it("shows the month of the day on screen with that day selected", () => {
const html = calendar();
expect(html).toContain("September 2026");
expect(dayButton(html, "2026-09-24")).toContain(
'data-selected-single="true"',
);
});
it("marks the days that hold anything, and says so", () => {
const html = calendar();
expect(dayButton(html, "2026-09-03")).toContain('data-entries="true"');
expect(dayButton(html, "2026-09-03")).toContain(
'aria-label="Thursday, September 3, 2026, has entries"',
);
expect(dayButton(html, "2026-09-04")).not.toContain("data-entries");
});
it("offers nothing past today", () => {
const html = calendar({
date: "2026-10-01",
month: keyToLocalDate("2026-10-01"),
});
expect(dayButton(html, "2026-10-08")).not.toMatch(/\sdisabled=""/);
expect(dayButton(html, "2026-10-09")).toMatch(/\sdisabled=""/);
});
it("offers Today, except on today", () => {
const today = (html: string) =>
html.match(/<button[^>]*data-slot="day-date-today"[^>]*>Today/)?.[0];
expect(today(calendar())).not.toMatch(/\sdisabled=""/);
expect(
today(
calendar({ date: "2026-10-08", month: keyToLocalDate("2026-10-08") }),
),
).toMatch(/\sdisabled=""/);
});
});
describe("picking a day", () => {
it("opens a past day or today, never the open day or a future one", () => {
expect(pickedDay("2026-09-03", "2026-09-24", "2026-10-08")).toBe(
"2026-09-03",
);
expect(pickedDay("2026-10-08", "2026-09-24", "2026-10-08")).toBe(
"2026-10-08",
);
expect(pickedDay("2026-09-24", "2026-09-24", "2026-10-08")).toBeNull();
expect(pickedDay("2026-10-09", "2026-09-24", "2026-10-08")).toBeNull();
});
it("reads the month it shows, and never past today", () => {
expect(monthWindow(keyToLocalDate("2026-09-24"), "2026-10-08")).toEqual({
from: "2026-09-01",
to: "2026-09-30",
});
expect(monthWindow(keyToLocalDate("2026-10-02"), "2026-10-08")).toEqual({
from: "2026-10-01",
to: "2026-10-08",
});
expect(localDateToKey(keyToLocalDate("2024-02-29"))).toBe("2024-02-29");
});
});
@@ -109,6 +109,7 @@ let canWrite = true;
vi.mock("../use-day", () => ({
useDay: () => ({ ...dayQuery, refetch: () => undefined }),
usePrefetchDay: () => () => undefined,
useDayIndex: () => ({ data: undefined }),
}));
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
vi.mock("@/hooks/use-auth", () => ({
@@ -160,6 +161,7 @@ async function render(
shell={props.shell ?? "docked"}
onClose={() => undefined}
onStep={() => undefined}
onPick={() => undefined}
Title={(p) => <h2 {...p} />}
titleId="t"
/>
@@ -179,7 +181,7 @@ describe("<DayView>", () => {
it("names the day and counts what it holds", async () => {
const html = await render();
expect(html).toContain("Saturday, January 3, 2026");
expect(html).toContain("3 values · 2 entries");
expect(html).toContain("3 values, 2 entries");
});
it("lists what ran through the day with its running count", async () => {
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar, CalendarDayButton } from "@/components/ui/calendar";
import { resolveDateFnsLocale } from "@/components/ui/date-field";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { DateKey } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { useDayIndex } from "./use-day";
/**
* The date in the day's header, as the way to any other day: the written
* date is the button, and it opens a month calendar beside it. Picking a
* day opens that day; "Today" opens today. Days that hold anything carry a
* dot, read from `GET /api/day/index` for the month on screen.
*
* No icon and no field: the header keeps its one line, and the arrows
* beside it stay the way to the neighbouring days. Radix owns the keyboard
* (Enter or Space opens, Escape closes, focus returns to the date), and
* the same popover serves the docked column and both sheets, with touch-
* sized cells wherever the pointer is coarse.
*/
/** `YYYY-MM-DD` → a local `Date` at midnight, the calendar's own unit. */
export function keyToLocalDate(key: DateKey): Date {
const [y, m, d] = key.split("-").map(Number) as [number, number, number];
return new Date(y, m - 1, d);
}
/** A local `Date` → `YYYY-MM-DD`, read in the zone it was made in. */
export function localDateToKey(date: Date): DateKey {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate(),
)}` as DateKey;
}
/** First and last day of the month that holds `month`, never past today. */
export function monthWindow(
month: Date,
today: DateKey,
): { from: DateKey; to: DateKey } {
const from = localDateToKey(
new Date(month.getFullYear(), month.getMonth(), 1),
);
const last = localDateToKey(
new Date(month.getFullYear(), month.getMonth() + 1, 0),
);
return { from, to: last > today ? today : last };
}
/**
* What a pick opens: the day itself, unless it is the day on screen already
* or lies past today (the calendar disables those, this is the backstop).
*/
export function pickedDay(
next: DateKey,
date: DateKey,
today: DateKey,
): DateKey | null {
return next !== date && next <= today ? next : null;
}
export function DayDatePicker({
date,
today,
label,
onPick,
className,
}: {
date: DateKey;
today: DateKey;
/** The date as the header writes it: the button's text. */
label: React.ReactNode;
onPick: (date: DateKey) => void;
className?: string;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [month, setMonth] = useState(() => keyToLocalDate(date));
const range = monthWindow(month, today);
const index = useDayIndex(range.from, range.to, open);
return (
<Popover
open={open}
onOpenChange={(next) => {
// Each opening starts on the month of the day on screen.
if (next) setMonth(keyToLocalDate(date));
setOpen(next);
}}
>
<PopoverTrigger asChild>
<button
type="button"
data-slot="day-date-button"
aria-haspopup="dialog"
title={t("day.pickDate")}
className={cn(
"hover:bg-muted/60 focus-visible:ring-ring/50 data-[state=open]:bg-muted/60 -mx-1 block max-w-full truncate rounded-md px-1 text-left transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
className,
)}
>
{label}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
data-slot="day-date-picker"
aria-label={t("day.pickerTitle")}
className="w-auto p-0"
>
<DayCalendar
date={date}
today={today}
month={month}
onMonthChange={setMonth}
withEntries={Object.keys(index.data?.days ?? {}) as DateKey[]}
onPick={(next) => {
setOpen(false);
const day = pickedDay(next, date, today);
if (day !== null) onPick(day);
}}
/>
</PopoverContent>
</Popover>
);
}
/** The calendar's day button, with its date key and dot as attributes. */
function KeyedDayButton(props: React.ComponentProps<typeof CalendarDayButton>) {
return (
<CalendarDayButton
{...props}
data-date-key={localDateToKey(props.day.date)}
data-entries={props.modifiers.entries ? "true" : undefined}
/>
);
}
/**
* The popover's body: the month with the day on screen selected, a dot on
* every day that holds anything, nothing past today, and "Today" below.
*/
export function DayCalendar({
date,
today,
month,
onMonthChange,
withEntries,
onPick,
}: {
date: DateKey;
today: DateKey;
month: Date;
onMonthChange: (month: Date) => void;
withEntries: readonly DateKey[];
onPick: (date: DateKey) => void;
}) {
const { t, locale } = useTranslations();
const todayDate = keyToLocalDate(today);
const dateLabel = new Intl.DateTimeFormat(resolveIntlLocale(locale), {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
// The calendar hands out local midnights; the label reads the same
// calendar day at noon UTC, so no zone can move it.
const labelOf = (day: Date) =>
dateLabel.format(new Date(`${localDateToKey(day)}T12:00:00.000Z`));
return (
<>
<Calendar
mode="single"
required
selected={keyToLocalDate(date)}
onSelect={(picked) => {
if (picked) onPick(localDateToKey(picked));
}}
month={month}
onMonthChange={onMonthChange}
endMonth={todayDate}
disabled={{ after: todayDate }}
showOutsideDays={false}
locale={resolveDateFnsLocale(locale)}
modifiers={{ entries: withEntries.map(keyToLocalDate) }}
modifiersClassNames={{
// A dot under the number; the selected day's dot takes the
// colour of the number on it.
entries:
"after:bg-primary data-[selected=true]:after:bg-primary-foreground after:pointer-events-none after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full",
}}
labels={{
labelDayButton: (day, modifiers) =>
modifiers.entries
? `${labelOf(day)}, ${t("day.hasEntries")}`
: labelOf(day),
}}
components={{ DayButton: KeyedDayButton }}
// 32 px cells beside a fine pointer, 40 px for a finger.
className="[--cell-size:--spacing(10)] pointer-fine:[--cell-size:--spacing(8)]"
autoFocus
/>
<div className="border-border flex justify-end border-t p-2">
<Button
type="button"
variant="ghost"
size="sm"
data-slot="day-date-today"
disabled={date === today}
onClick={() => onPick(today)}
>
{t("day.today")}
</Button>
</div>
</>
);
}
+30 -1
View File
@@ -158,6 +158,15 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
/** Move the open day by `delta` calendar days, without a new history entry. */
export function stepDay(from: DateKey, delta: number): DateKey {
const next = shiftDateKey(from, delta);
jumpDay(next);
return next;
}
/**
* Swap the open day for `next` (a day picked from the header's calendar),
* the way a step does: no new history entry, so one Back still leaves.
*/
export function jumpDay(next: DateKey): void {
memory.focus = null;
emit();
const { pathname, search } = currentLocation();
@@ -169,7 +178,27 @@ export function stepDay(from: DateKey, delta: number): DateKey {
"",
withDayHref(pathname, search, next),
);
return next;
}
/**
* Set while the docked day collapses to make room for another panel (the
* Coach's conversations below 1600 px): focus then stays with the control
* that asked for the room instead of moving to the day's edge.
*/
let yielding = false;
/** Collapse the docked day to its edge for a neighbouring panel. */
export function yieldDay(): void {
yielding = true;
memory.trigger = null;
closeDay();
}
/** Read and clear the flag `yieldDay` set. */
export function takeDayYield(): boolean {
const was = yielding;
yielding = false;
return was;
}
/**
+24 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import {
Suspense,
useCallback,
@@ -37,11 +37,13 @@ import { cn } from "@/lib/utils";
import {
closeDay,
jumpDay,
openDay,
peekDayTriggerAt,
publishOpenDay,
stepDay,
takeDayTrigger,
takeDayYield,
useDayFocus,
} from "./day-layer-controller";
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
@@ -72,8 +74,10 @@ import { useTodayKey } from "./use-today-key";
* the chart stays in view above it; dragging the handle up (or tapping
* it) takes it to full height, dragging it down closes it.
*
* On `/coach` the conversations panel already holds the side column, and
* there is never more than one docked panel: the day opens as a sheet there.
* On `/coach` the day docks too, right of the Coach's conversations: page,
* conversations, day. Below 1600 px only one of the two is open at a time;
* opening the day folds the conversations to their edge, and opening the
* conversations folds the day to its edge (`yieldDay`).
*
* 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. Collapsing the docked
@@ -121,7 +125,6 @@ function modalSurfaceOpen(): boolean {
function DayLayer() {
const searchParams = useSearchParams();
const pathname = usePathname();
const today = useTodayKey();
const raw = searchParams.get(DAY_QUERY_PARAM);
const date = parseDayParam(raw, today);
@@ -157,7 +160,7 @@ function DayLayer() {
const phone = useIsMobile();
const shell: "docked" | "sheet" | "bottom" = phone
? "bottom"
: wide && !pathname.startsWith("/coach")
: wide
? "docked"
: "sheet";
@@ -206,6 +209,11 @@ function DayLayer() {
return;
}
if (before !== null && shell === "docked") {
// Collapsed for a neighbouring panel: focus stays where it was.
if (takeDayYield()) {
collapsing.current = false;
return;
}
const rail = expandRef.current;
if (collapsing.current && rail) {
takeDayTrigger();
@@ -226,6 +234,14 @@ function DayLayer() {
[date, today],
);
const onPick = useCallback(
(next: DateKey) => {
if (next > today) return;
jumpDay(next);
},
[today],
);
// Keyboard: Escape closes the docked column (a sheet's own Escape is the
// dialog's), Alt with an arrow steps a day. Bare arrows stay with scrolling.
useEffect(() => {
@@ -285,7 +301,7 @@ function DayLayer() {
<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"
className="bg-card text-card-foreground border-border order-2 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. */}
@@ -343,6 +359,7 @@ function DayLayer() {
shell={shell}
onClose={shell === "docked" ? collapse : closeDay}
onStep={onStep}
onPick={onPick}
Title={Title}
titleId={titleId}
titleRef={titleRef}
@@ -363,7 +380,7 @@ function DayLayer() {
// A short fade, not a slide: the column takes its width at once
// (the page beside it reflows in the same frame either way), so
// only its content eases in. None under reduced motion.
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 order-2 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
>
{view(DockedTitle, {
// No status-bar inset here: the docked column sits inside
+23 -10
View File
@@ -30,6 +30,7 @@ import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { DayDatePicker } from "./day-date-picker";
import type { DayFocus } from "./day-layer-controller";
import {
DayEvents,
@@ -76,6 +77,8 @@ export interface DayViewProps {
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;
@@ -113,6 +116,7 @@ export function DayView({
shell,
onClose,
onStep,
onPick,
Title,
titleId,
titleRef,
@@ -176,7 +180,7 @@ export function DayView({
? [
tCount("day.countValues", data.counts.values),
tCount("day.countEntries", data.counts.entries),
].join(" · ")
].join(", ")
: null;
const compact = shell === "bottom";
const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
@@ -228,14 +232,23 @@ export function DayView({
tabIndex={-1}
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
>
{compact ? (
<>
<span aria-hidden="true">{shortLabel(date)}</span>
<span className="sr-only">{longLabel(date)}</span>
</>
) : (
longLabel(date)
)}
{/* 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}
label={
compact ? (
<>
<span aria-hidden="true">{shortLabel(date)}</span>
<span className="sr-only">{longLabel(date)}</span>
</>
) : (
longLabel(date)
)
}
/>
</Title>
<Button
type="button"
@@ -289,7 +302,7 @@ export function DayView({
className="text-muted-foreground -mb-3 text-xs tabular-nums"
data-slot="day-meta"
>
{meta ?? <span className="invisible">·</span>}
{meta ?? <span className="invisible">0</span>}
</p>
{focus ? (
<div className="space-y-2.5">
+1 -1
View File
@@ -315,7 +315,7 @@ function resolveOrder(
* Map the app locale → the date-fns locale so the calendar's month / weekday
* names match the UI language. Defaults to enUS for anything unmapped.
*/
function resolveDateFnsLocale(
export function resolveDateFnsLocale(
locale: ReturnType<typeof useTranslations>["locale"],
): DateFnsLocale {
switch (locale) {