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