mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
d8a3614120
commit
9d5a529d53
15 files changed
+555
-21
No files matched your search
+79
-1
@@ -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) => {
|
||||
|
||||
@@ -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}?",
|
||||
|
||||
@@ -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}?",
|
||||
|
||||
@@ -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}?",
|
||||
|
||||
@@ -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} ?",
|
||||
|
||||
@@ -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}?",
|
||||
|
||||
@@ -12242,6 +12242,10 @@
|
||||
},
|
||||
"previousDayShortcut": "전날 (Alt + 왼쪽 화살표)",
|
||||
"nextDayShortcut": "다음 날 (Alt + 오른쪽 화살표)",
|
||||
"pickDate": "날짜 선택",
|
||||
"pickerTitle": "날짜로 이동",
|
||||
"today": "오늘",
|
||||
"hasEntries": "기록 있음",
|
||||
"captureShort": "기록",
|
||||
"inTimelineShort": "타임라인",
|
||||
"askCoachPrompt": "{date}에 무슨 일이 있었나요?",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user