diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 93d60ed88..363e22769 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -80936,7 +80936,6 @@ components: - workouts - cycle - environment - - lifestyle - lifeEvents description: Each day in the window that holds anything, keyed `YYYY-MM-DD`, with the sections it holds. Days without entries are absent. @@ -81087,7 +81086,6 @@ components: - workouts - cycle - environment - - lifestyle - lifeEvents id: type: string @@ -81190,7 +81188,6 @@ components: - workouts - cycle - environment - - lifestyle - lifeEvents additionalProperties: type: object @@ -81231,7 +81228,6 @@ components: - restMode - cyclePhase - travel - - lifestyle - lifeEvent section: type: string @@ -81252,7 +81248,6 @@ components: - workouts - cycle - environment - - lifestyle - lifeEvents id: type: string @@ -81265,8 +81260,12 @@ components: - type: "null" description: A second line, already formatted (a dose, a destination). since: - type: string - description: A local calendar date, `YYYY-MM-DD`. + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: The record's own start date. Null when the record holds none (a medication filed without a start date); the + entry's creation date is never put in its place. until: anyOf: - type: string @@ -81279,7 +81278,7 @@ components: exclusiveMinimum: 0 maximum: 9007199254740991 - type: "null" - description: Day n of the period, counting `since` as day 1. + description: Day n of the period, counting `since` as day 1; null when `since` is null. dayCount: anyOf: - type: integer @@ -81405,7 +81404,6 @@ components: - workouts - cycle - environment - - lifestyle - lifeEvents id: type: string diff --git a/e2e/day-doors.spec.ts b/e2e/day-doors.spec.ts index 357e2773c..c650f5c81 100644 --- a/e2e/day-doors.spec.ts +++ b/e2e/day-doors.spec.ts @@ -266,7 +266,7 @@ test.describe("the dashboard and the list of all values", () => { ); }); - test("the docked day moves the Coach button off its footer", async ({ + test("the docked day, open or collapsed, moves the Coach button off it", async ({ page, }, testInfo) => { test.skip(isPhone(testInfo), "docked from 1280 px"); @@ -290,6 +290,16 @@ test.describe("the dashboard and the list of all values", () => { return b.x + b.width; }) .toBeLessThanOrEqual(panelBox.x); + + // Collapsed, the day keeps a narrow edge; the launcher stays clear of it. + await page.locator('[data-slot="day-close"]').click(); + const edge = (await page.locator('[data-slot="day-rail"]').boundingBox())!; + await expect + .poll(async () => { + const b = (await fab.boundingBox())!; + return b.x + b.width; + }) + .toBeLessThanOrEqual(edge.x); }); }); diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts index 6a04b16f0..679e57884 100644 --- a/e2e/day-view.spec.ts +++ b/e2e/day-view.spec.ts @@ -21,11 +21,14 @@ import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture"; * - `?day=` opens the layer on any page, Back and Escape close it, a date in * the future is dropped; * - stepping to the neighbouring days never adds history entries; + * - docked, the day collapses to a narrow edge that brings it back on any + * page; a sheet closes for good; * - the dashboard's today area offers no door; its charts below do. * * Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`, * `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` / - * `day-next` / `day-close`. + * `day-next` / `day-close`, and the collapsed edge `day-rail` (+ `data-day`) + * with its `day-expand`. */ const panel = (page: Page) => page.locator('[data-slot="day-panel"]'); @@ -117,6 +120,68 @@ test.describe("the day view", () => { await expect(page).toHaveURL(/\/mood$/); }); + test("the docked day collapses to an edge and comes back on another page", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "docked from 1280 px", + ); + const day = isoDaysAgo(3); + await page.goto(`/mood?day=${day}`); + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + + await page.locator('[data-slot="day-close"]').click(); + await expect(panel(page)).toHaveCount(0); + await expect(page).toHaveURL(/\/mood$/); + // A narrow edge stays, holding the day, and focus waits on its button. + const rail = page.locator('[data-slot="day-rail"]'); + const expand = page.locator('[data-slot="day-expand"]'); + await expect(rail).toHaveAttribute("data-day", day); + await expect(expand).toBeFocused(); + await expect(expand).toHaveAttribute("aria-expanded", "false"); + expect(await expand.getAttribute("aria-label")).toMatch(/^Show /); + expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(48); + + // Another page: the edge is still there and brings the same day back, + // from the keyboard too. + await page.goto("/labs"); + await expect(rail).toHaveAttribute("data-day", day); + await expand.focus(); + await page.keyboard.press("Enter"); + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day, + ); + await expect(page).toHaveURL(new RegExp(`/labs\\?day=${day}$`)); + await expect(panel(page).locator("h2").first()).toBeFocused(); + await expect(rail).toHaveCount(0); + }); + + test("a sheet closes for good and leaves no edge behind", async ({ + page, + }, testInfo) => { + const desktop = testInfo.project.name === "chromium-desktop"; + // Below 1280 px the day is a sheet from the right; on a phone, from the + // bottom. Neither collapses. + if (desktop) await page.setViewportSize({ width: 1024, height: 800 }); + const day = isoDaysAgo(3); + await page.goto(`/mood?day=${day}`); + await expect(panel(page)).toHaveAttribute( + "data-shell", + desktop ? "sheet" : "bottom", + ); + await expect(page.locator('[data-slot="day-close"]')).toHaveAttribute( + "aria-label", + "Close day", + ); + await page.locator('[data-slot="day-close"]').click(); + await expect(panel(page)).toHaveCount(0); + await expect(page).toHaveURL(/\/mood$/); + await expect(page.locator('[data-slot="day-rail"]')).toHaveCount(0); + }); + test("a future or malformed ?day= is dropped without a word", async ({ page, }) => { diff --git a/messages/de.json b/messages/de.json index 795904f21..bf34146de 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12209,6 +12209,8 @@ "previousDay": "Vortag", "nextDay": "Folgetag", "close": "Tag schließen", + "hidePanel": "Tag ausblenden", + "showPanel": "{date} einblenden", "openDay": "Tag öffnen", "viewWholeDay": "Ganzen Tag ansehen", "captureForDay": "Für diesen Tag erfassen", diff --git a/messages/en.json b/messages/en.json index a30ac986b..9d38f2574 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12209,6 +12209,8 @@ "previousDay": "Previous day", "nextDay": "Next day", "close": "Close day", + "hidePanel": "Hide day", + "showPanel": "Show {date}", "openDay": "Open day", "viewWholeDay": "View the whole day", "captureForDay": "Add for this day", diff --git a/messages/es.json b/messages/es.json index c46dc490e..4979a7936 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12209,6 +12209,8 @@ "previousDay": "Día anterior", "nextDay": "Día siguiente", "close": "Cerrar día", + "hidePanel": "Ocultar día", + "showPanel": "Mostrar {date}", "openDay": "Abrir día", "viewWholeDay": "Ver el día completo", "captureForDay": "Registrar para este día", diff --git a/messages/fr.json b/messages/fr.json index 3b61b97e9..c2e16bf6e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12209,6 +12209,8 @@ "previousDay": "Jour précédent", "nextDay": "Jour suivant", "close": "Fermer le jour", + "hidePanel": "Masquer le jour", + "showPanel": "Afficher {date}", "openDay": "Ouvrir le jour", "viewWholeDay": "Voir toute la journée", "captureForDay": "Saisir pour ce jour", diff --git a/messages/it.json b/messages/it.json index 62db0cedd..f4cb777dc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12209,6 +12209,8 @@ "previousDay": "Giorno precedente", "nextDay": "Giorno successivo", "close": "Chiudi giorno", + "hidePanel": "Nascondi giorno", + "showPanel": "Mostra {date}", "openDay": "Apri giorno", "viewWholeDay": "Vedi l’intera giornata", "captureForDay": "Registra per questo giorno", diff --git a/messages/ko.json b/messages/ko.json index 244aa0850..b570216a0 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12209,6 +12209,8 @@ "previousDay": "전날", "nextDay": "다음 날", "close": "하루 닫기", + "hidePanel": "하루 숨기기", + "showPanel": "{date} 보기", "openDay": "하루 열기", "viewWholeDay": "하루 전체 보기", "captureForDay": "이 날에 기록하기", diff --git a/messages/pl.json b/messages/pl.json index 2c66a69ef..46f29666b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12209,6 +12209,8 @@ "previousDay": "Poprzedni dzień", "nextDay": "Następny dzień", "close": "Zamknij dzień", + "hidePanel": "Ukryj dzień", + "showPanel": "Pokaż {date}", "openDay": "Otwórz dzień", "viewWholeDay": "Zobacz cały dzień", "captureForDay": "Dodaj dla tego dnia", diff --git a/src/components/day/__tests__/day-view.test.tsx b/src/components/day/__tests__/day-view.test.tsx index db80cd99f..038aa97ee 100644 --- a/src/components/day/__tests__/day-view.test.tsx +++ b/src/components/day/__tests__/day-view.test.tsx @@ -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) =>

} @@ -190,6 +191,61 @@ describe("", () => { 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("")[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(/]*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(/]*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"'); diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index d2a002d75..ea8f5cc73 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -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(null); const panelRef = useRef(null); + const expandRef = useRef(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() {

); - 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} + +
+ {/* The top bar's band, so the two bottom borders draw one line; + the control sits where the open day keeps its own. */} +
+ +
+ {/* The rest of the edge takes a click too and names the day it + holds; the keyboard has the button above. */} + +
+
+ + ); + } const view = ( Title: Parameters[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

; } -/** 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 }); } diff --git a/src/components/day/day-sections.tsx b/src/components/day/day-sections.tsx index df27e869b..a53e2edd4 100644 --- a/src/components/day/day-sections.tsx +++ b/src/components/day/day-sections.tsx @@ -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 = { 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 = 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[] }) {
    {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), }) diff --git a/src/components/day/day-view.tsx b/src/components/day/day-view.tsx index 14ee0b7ac..dbe4c8baa 100644 --- a/src/components/day/day-view.tsx +++ b/src/components/day/day-view.tsx @@ -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 (
    - {/* 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. */}