From 9d5a529d53afdc35959bbed04d315eb95ede5e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 9 Oct 2026 13:38:11 +0200 Subject: [PATCH] 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". --- e2e/day-view.spec.ts | 80 +++++- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/ko.json | 4 + messages/pl.json | 4 + .../day/__tests__/day-date-picker.test.tsx | 138 +++++++++++ .../day/__tests__/day-view.test.tsx | 4 +- src/components/day/day-date-picker.tsx | 229 ++++++++++++++++++ src/components/day/day-layer-controller.ts | 31 ++- src/components/day/day-layer.tsx | 31 ++- src/components/day/day-view.tsx | 33 ++- src/components/ui/date-field.tsx | 2 +- 15 files changed, 555 insertions(+), 21 deletions(-) create mode 100644 src/components/day/__tests__/day-date-picker.test.tsx create mode 100644 src/components/day/day-date-picker.tsx diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 679e57884..31427c9fe 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -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) => { diff --git a/messages/de.json b/messages/de.json index 018ff5319..8c0b6ce51 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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}?", diff --git a/messages/en.json b/messages/en.json index b965c2afe..03f5cc9cf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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}?", diff --git a/messages/es.json b/messages/es.json index aa5917aa9..012417745 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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}?", diff --git a/messages/fr.json b/messages/fr.json index 50a5b0c48..f6f88b2ed 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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} ?", diff --git a/messages/it.json b/messages/it.json index 4db7ce4b9..a95c1dd44 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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}?", diff --git a/messages/ko.json b/messages/ko.json index 95e1b73b4..fc6a36883 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12242,6 +12242,10 @@ }, "previousDayShortcut": "전날 (Alt + 왼쪽 화살표)", "nextDayShortcut": "다음 날 (Alt + 오른쪽 화살표)", + "pickDate": "날짜 선택", + "pickerTitle": "날짜로 이동", + "today": "오늘", + "hasEntries": "기록 있음", "captureShort": "기록", "inTimelineShort": "타임라인", "askCoachPrompt": "{date}에 무슨 일이 있었나요?", diff --git a/messages/pl.json b/messages/pl.json index 3cfb2d900..ddae38713 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}?", diff --git a/src/components/day/__tests__/day-date-picker.test.tsx b/src/components/day/__tests__/day-date-picker.test.tsx new file mode 100644 index 000000000..2ec887d26 --- /dev/null +++ b/src/components/day/__tests__/day-date-picker.test.tsx @@ -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( + + {node} + , + ); +} + +function calendar( + props: Partial[0]> = {}, +): string { + return render( + undefined} + withEntries={["2026-09-03", "2026-09-24"]} + onPick={() => undefined} + {...props} + />, + ); +} + +function dayButton(html: string, key: string): string { + return ( + html.match(new RegExp(`]*data-date-key="${key}"[^>]*>`))?.[0] ?? + "" + ); +} + +describe("", () => { + it("makes the written date the button, with no icon beside it", () => { + const html = render( + undefined} + />, + ); + const button = html.match(/]*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("", () => { + 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(/]*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"); + }); +}); diff --git a/src/components/day/__tests__/day-view.test.tsx b/src/components/day/__tests__/day-view.test.tsx index 038aa97ee..a462d4bb8 100644 --- a/src/components/day/__tests__/day-view.test.tsx +++ b/src/components/day/__tests__/day-view.test.tsx @@ -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) =>

} titleId="t" /> @@ -179,7 +181,7 @@ describe("", () => { 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 () => { diff --git a/src/components/day/day-date-picker.tsx b/src/components/day/day-date-picker.tsx new file mode 100644 index 000000000..a85fef543 --- /dev/null +++ b/src/components/day/day-date-picker.tsx @@ -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 ( + { + // Each opening starts on the month of the day on screen. + if (next) setMonth(keyToLocalDate(date)); + setOpen(next); + }} + > + + + + + { + setOpen(false); + const day = pickedDay(next, date, today); + if (day !== null) onPick(day); + }} + /> + + + ); +} + +/** The calendar's day button, with its date key and dot as attributes. */ +function KeyedDayButton(props: React.ComponentProps) { + return ( + + ); +} + +/** + * 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 ( + <> + { + 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 + /> +
+ +
+ + ); +} diff --git a/src/components/day/day-layer-controller.ts b/src/components/day/day-layer-controller.ts index bb12eca77..50041a777 100644 --- a/src/components/day/day-layer-controller.ts +++ b/src/components/day/day-layer-controller.ts @@ -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; } /** diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index ea8f5cc73..1f3fbf1b8 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -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() {
{/* 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 diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index dbe4c8baa..1cfd7d6b1 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -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 ? ( - <> - - {longLabel(date)} - - ) : ( - 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. */} + + + {longLabel(date)} + + ) : ( + longLabel(date) + ) + } + />