Merge branch 'main' into fix/handover-p1-batch

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-07 18:24:33 +02:00
commit 384992bb15
34 files changed
+1305 -383

No files matched your search

@@ -267,11 +267,15 @@ describe("<CoachInput>", () => {
expect(controls?.[0]).not.toMatch(/\bjustify-between\b/);
expect(controls?.[0]).toMatch(/\bshrink-0\b/);
// And the row itself is a row at every width, bottom-aligned so the send
// button stays beside the last line as the textarea grows.
// And the row itself is a row at every width. The textarea is centred in
// it — bottom-aligning a one-line textarea against a 44 px tap target left
// the placeholder sitting visibly low in the pill — while the controls
// keep their own bottom alignment, so the send button stays beside the
// LAST line as the textarea grows rather than drifting to the middle.
const shell = html.match(/<div[^>]*class="[^"]*\bflex-row\b[^"]*"[^>]*>/);
expect(shell?.[0]).toMatch(/\bitems-end\b/);
expect(shell?.[0]).toMatch(/\bitems-center\b/);
expect(shell?.[0]).not.toMatch(/\bflex-col\b/);
expect(controls?.[0]).toMatch(/\bself-end\b/);
});
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
@@ -427,9 +427,15 @@ export function CoachInput({
// hold: focus follows DOM order, and the DOM order (textarea,
// actions, Send) is identical in both layouts. Nothing jumps.
//
// `items-end` keeps the controls beside the LAST line as the textarea
// grows, so the send button stays where the thumb left it.
"flex flex-row items-end gap-1.5 p-1.5",
// `items-center` with the controls pinned `self-end` below. The row
// used to be `items-end` throughout, which bottom-aligned an empty
// one-line textarea against a 44 px touch target: the placeholder sat
// roughly 6 px low in the pill, reading as if the text had slipped.
// Centring the textarea fixes the empty and single-line cases, and
// `self-end` on the controls preserves the reason `items-end` was
// there — as the textarea grows, the send button stays beside the
// LAST line rather than drifting to the middle.
"flex flex-row items-center gap-1.5 p-1.5",
)}
>
<textarea
@@ -455,7 +461,7 @@ export function CoachInput({
/>
<div
data-slot="coach-input-controls"
className="order-2 flex shrink-0 items-center gap-1.5"
className="order-2 flex shrink-0 items-center gap-1.5 self-end"
>
<div
data-slot="coach-input-leading"
@@ -97,8 +97,16 @@ export function LabsChangesCard({
{labelKey ? ` · ${t(labelKey)}` : ""}
</p>
</div>
{/* The delta is CONTENT — the number the row exists to report —
so it takes the analyte's tier, not the meta tier of the
line under it (UI-STANDARDS §3). At `text-xs` muted it wore
the sub-line's typography and optically attached itself to
that lower line, which is what made a row centred to the
pixel read as bottom-aligned. `self-center` states the row's
alignment on the cell itself so it survives a future change
to the row's own `items-*`. */}
<p
className="text-muted-foreground shrink-0 text-xs tabular-nums"
className="text-foreground shrink-0 self-center text-sm tabular-nums"
data-slot="labs-change-delta"
>
{signedDelta}
+12 -19
View File
@@ -122,27 +122,20 @@ export function LayoutCoachFab() {
},
});
// v1.22 (M4) — surface a due/surfaced Coach reminder on the FAB. Additive:
// the dot also lights when the daily sweep has flipped a "remind me" reminder
// to due, independent of the nudge seen-stamp (resolving the reminder in the
// ledger clears it). Same cheap, gated, stale-while-revalidate read shape.
const { data: dueReminders } = useQuery({
queryKey: queryKeys.coachReminders("due,surfaced"),
queryFn: async () => {
const data = await apiGet<{ reminders?: unknown[] } | undefined>(
"/api/coach/reminders?status=due,surfaced",
);
return data?.reminders ?? [];
},
enabled: coachAvailable && !onCoachPage,
staleTime: 5 * 60 * 1000,
});
const hasDueReminders = (dueReminders?.length ?? 0) > 0;
const nudgedAt = status?.nudgedAt ?? null;
// ONE source for the dot: an assistant message the user has not opened yet.
//
// v1.22 added a second arm — a separate "any due reminder?" query — and that
// is the arm that produced a badge over an empty Coach. The sweep flipped a
// reminder to `due` without writing anything into a conversation, so the dot
// promised a message that did not exist; opening the Coach showed the empty
// state and did not clear the dot either, because only resolving the reminder
// in Settings could. The sweep now writes the reminder into the thread as a
// real assistant message (`coach-reminder-sweep.ts`), which the nudge status
// already reports — so the reminder still lights the dot, through the message
// it actually wrote, and opening the Coach clears it like any other.
const unread = isNudgeUnread(status, seenStamp);
// The visual dot lights for either an unread nudge OR a due reminder.
const showDot = unread || hasDueReminders;
const showDot = unread;
// The unread dot is visual-only (`aria-hidden`) and the swapped
// `aria-label` is not announced on mutation — a screen-reader user
@@ -28,9 +28,7 @@
* the page someone arrives at to take stock.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
@@ -179,18 +177,11 @@ export function MentalWellbeing() {
)}
</section>
{/* 2026-07-17 UX/IA audit M9 — mood tracking, this screener surface,
and the mood insights page form one mental-health domain but used
to be three unconnected islands. A quiet pointer to mood history,
mirroring the one added on `/mood` pointing back here. */}
<Link
href="/mood"
data-slot="mental-wellbeing-mood-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 self-start text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mentalHealth.moodLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{/* The pointer to mood history that used to sit here is gone. The
cross-link on `/mood` still points at this page, which is the
direction that earns its keep — someone logging a mood may not
know the screeners exist, while someone who came here for a
screener already knows where their mood diary is. */}
{/* Per-instrument detail, opened from a card body: last result,
trend chart, dated history, Start — the Verlauf lives here,
@@ -114,7 +114,12 @@ describe("health-profile fact removal control", () => {
.filter(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
// The Save control is the entry's PRIMARY action, so it carries the
// default variant. Matching on `variant === "outline"` used to be the
// discriminator and silently stopped matching anything when the
// action row was rebuilt — the button was still there, the selector
// was not.
props.variant === undefined &&
typeof props.onClick === "function",
)
.map((props) => {
@@ -132,12 +137,18 @@ describe("health-profile fact removal control", () => {
const { html } = render();
expect(confirmButtons).toHaveLength(1);
// The visible label is short — three entries side by side each carrying
// their own field name read as one run of words — but the ACCESSIBLE name
// still says which entry it removes, and so does the dialog it opens.
expect(confirmButtons[0]).toMatchObject({
slot: "health-profile-fact-remove-SMOKING_STATUS",
label: "Remove Smoking status",
label: "Remove",
ariaLabel: "Remove Smoking status",
confirmLabel: "Remove Smoking status",
title: "Remove Smoking status?",
body: "The current value will become not recorded. Its dated history will be retained.",
});
expect(html).toContain("Remove Smoking status");
expect(html).toContain("Remove");
expect(apiDelete).not.toHaveBeenCalled();
});
@@ -173,7 +184,7 @@ describe("health-profile fact removal control", () => {
const saveButton = renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
props.variant === undefined &&
typeof props.onClick === "function",
);
@@ -207,7 +218,7 @@ describe("health-profile fact removal control", () => {
const saveButton = renderedButtons.find(
(props) =>
props.type === "button" &&
props.variant === "outline" &&
props.variant === undefined &&
typeof props.onClick === "function",
);
@@ -228,26 +228,21 @@ export function HealthProfileFactsManager() {
))}
</SelectContent>
</Select>
<div className="space-y-2">
<Button
type="button"
size="sm"
variant="outline"
className="min-h-11 w-full sm:min-h-9"
disabled={disabled || !dirty || !value}
onClick={() => save.mutate({ kind, value, current })}
>
{save.isPending && save.variables?.kind === kind && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
{/* One action row per entry (UI-STANDARDS §11 + the action-row
rule): right-aligned, primary last, the quiet destructive
beside it. Two full-width stacked blocks — a grey disabled
slab over a red-outlined one — carried the same visual weight
as the field they belong to and ran into each other when
scanned, which is what "Save" directly above "Remove smoking
status" read as. */}
<div className="flex items-center justify-end gap-2 pt-1">
{current ? (
<ConfirmButton
label={t("records.profileFacts.remove", {
// Short label inside the row; the full phrase stays the
// accessible name, because "Remove" on its own is
// ambiguous once three of them are on screen.
label={t("records.profileFacts.removeShort")}
ariaLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
title={t("records.profileFacts.removeConfirmTitle", {
@@ -262,12 +257,28 @@ export function HealthProfileFactsManager() {
remove.isPending && remove.variables?.id === current.id
}
disabled={disabled}
variant="ghost"
size="sm"
className="text-destructive min-h-11 w-full sm:min-h-9"
className="text-destructive min-h-11 sm:min-h-9"
icon={<Trash2 className="size-4" aria-hidden />}
slot={`health-profile-fact-remove-${kind}`}
/>
) : null}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || !dirty || !value}
onClick={() => save.mutate({ kind, value, current })}
>
{save.isPending && save.variables?.kind === kind && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
</div>
</div>
);
@@ -113,8 +113,13 @@ export function AvatarSection() {
title={t("settings.avatar.title")}
className="mb-4"
/>
{/* The accepted formats and limits belong to the sentence that explains
what this card does, not to a caption of their own under the buttons.
Two strings rather than one merged key: the same wording is the
upload's own constraint copy, and a translator keeps the shorter unit
either way. */}
<p className="text-muted-foreground mb-4 pl-7 text-sm">
{t("settings.avatar.description")}
{t("settings.avatar.description")} {t("settings.avatar.hint")}
</p>
<div className="flex items-center gap-4 pl-7">
<Avatar className="size-16">
@@ -167,9 +172,6 @@ export function AvatarSection() {
)}
</div>
</div>
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.avatar.hint")}
</p>
{msg && (
<p
role="alert"
+14 -2
View File
@@ -48,8 +48,16 @@ import {
// only applies to the native value text — here no native input is rendered).
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
// `min-w-0` is load-bearing, not cosmetic. A field is almost always a flex item
// (the date/time row, a form grid cell), and a flex item's `min-width: auto`
// resolves to its content-based minimum. The content here contains a text
// `<input>`, whose intrinsic width comes from the `size` attribute's default of
// 20 characters (~180 px) — so without this the field refuses to render below
// ~230 px no matter what width the parent hands it, and the surplus spills out
// of whatever dialog or sheet it sits in. See the matching note on the overlay
// input below; both ends are needed, one alone still floors the box.
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
export interface DateFieldProps {
id?: string;
@@ -182,7 +190,11 @@ export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
inputMode="numeric"
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
// `min-w-0` releases the input's own automatic minimum size (the
// 20-character intrinsic width of a text input). Without it the
// input is the widest thing in the field and the field cannot
// shrink to the width its parent offers.
className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={overlayValue}
placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)}
disabled={disabled}
+11 -7
View File
@@ -110,13 +110,17 @@ export const DateTimeField = React.forwardRef<
<div
className={cn(
// v1.25.10 — stack the date and time halves on a narrow viewport and
// only sit them side-by-side from `sm:` up. A native `<input
// type="date">` keeps its intrinsic width on mobile WebKit and won't
// shrink below it, so the `date + w-32 time` row overran a phone-width
// sheet (the time field + clock icon spilled ~70px past the edge,
// forcing a sideways scroll on every add form). Stacked, each half
// takes the full sheet width and nothing overflows.
"flex flex-col gap-2 sm:flex-row sm:items-stretch",
// only sit them side-by-side from `sm:` up. Stacked, each half takes
// the full sheet width, which is what a phone-width sheet can give.
//
// The side-by-side row above `sm:` needs `min-w-0` on both halves as
// well, and that lives in the field primitives themselves: each field
// wraps a text `<input>` whose 20-character intrinsic width was the
// field's automatic minimum size, so `date + w-32 time` demanded
// ~470 px inside a 400 px dialog body and spilled past the edge on
// every add / edit form. `min-w-0` here lets the row itself shrink
// inside whatever grid or flex cell hosts it.
"flex min-w-0 flex-col gap-2 sm:flex-row sm:items-stretch",
className,
)}
data-slot="date-time-field"
+18 -1
View File
@@ -69,7 +69,24 @@ function DialogContent({
// viewport on short / soft-keyboard-collapsed mobile screens.
// Consumers that need a different ceiling can override via
// `className`.
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
//
// `grid-cols-[minmax(0,1fr)]` pins the implicit column. A bare
// `grid` gives its single column an `auto` track, which sizes to the
// widest child's max-content and grows the box past the dialog's own
// `max-w-*` — and because `overflow-y:auto` makes `overflow-x`
// compute to `auto`, that surplus becomes a horizontal scrollbar
// rather than a visible spill. `minmax(0,1fr)` caps the track at the
// dialog width so an over-wide child is contained instead of
// dragging the surface out with it.
//
// `overflow-x-clip` pairs with it: `overflow-y-auto` alone promotes
// the other axis from `visible` to `auto`, so a single over-wide
// child made the whole dialog pannable sideways. A dialog scrolls
// vertically; anything that genuinely needs sideways scroll brings
// its own `overflow-x-auto` wrapper. Consumers that set `overflow-*`
// themselves still win — `<ResponsiveSheet>` passes
// `overflow-hidden` and scrolls its body instead.
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid max-h-[calc(100dvh-2rem)] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] grid-cols-[minmax(0,1fr)] gap-4 overflow-x-clip overflow-y-auto rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
+18 -2
View File
@@ -220,7 +220,10 @@ export function ResponsiveSheet({
<div
data-slot="responsive-sheet-body"
className={cn(
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
// Vertical-only scroll port — see the note on the Dialog
// branch's body below for why `overflow-y-auto` alone does not
// say that.
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto p-4",
bodyClassName,
)}
>
@@ -304,7 +307,20 @@ export function ResponsiveSheet({
<div
data-slot="responsive-sheet-body"
className={cn(
"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto",
// `overflow-x-clip` states the invariant the surrounding comment
// already claims: this body scrolls VERTICALLY. `overflow-y-auto`
// alone does not say that — CSS promotes the other axis from
// `visible` to `auto`, so any child wider than the port turned the
// whole form into a sideways-pannable surface. That is how the
// date/time row and `<SheetSection>`'s deliberate 4 px hover bleed
// (`-mx-1 w-[calc(100%+0.5rem)]`) both produced a horizontal
// scrollbar on the desktop dialog. `clip` is not `visible`, so the
// promotion does not happen and the vertical scroll is kept.
//
// Content that genuinely needs to scroll sideways (a wide table)
// owns its own `overflow-x-auto` wrapper — that is the correct
// place for it, and it still works inside a clipped port.
"flex min-h-0 flex-1 flex-col gap-4 overflow-x-clip overflow-y-auto",
bodyClassName,
)}
>
+9 -1
View File
@@ -60,7 +60,15 @@ export function SheetSection({
>
<CollapsiblePrimitive.Trigger
data-slot="sheet-section-trigger"
className="group focus-visible:ring-ring/50 -mx-1 flex w-[calc(100%+0.5rem)] items-center gap-2 rounded-md px-1 py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
// `w-full`, not `-mx-1 w-[calc(100%+0.5rem)] px-1`. The old pair held
// the row's text at the section's own left edge while pushing the box
// 4 px outward on each side, purely to hold the focus ring off the
// label — but a Tailwind ring is drawn OUTSIDE the border box anyway,
// so the bleed bought nothing and made every row 8 px wider than the
// sheet body that scrolls it. In the desktop dialog, whose body has no
// horizontal padding to absorb it, that surfaced as a sideways-
// scrollable form on the cycle and mood sheets.
className="group focus-visible:ring-ring/50 flex w-full items-center gap-2 rounded-md py-1.5 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
{icon ? (
<span
+8 -2
View File
@@ -38,8 +38,12 @@ import { hourCycleOptions } from "@/lib/format-locale";
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
// `min-w-0` mirrors `<DateField>` and carries the same reason: a flex item's
// automatic minimum size would otherwise be the 20-character intrinsic width of
// the text input inside, which floors the whole field at ~230 px and overflows
// the dialog that hosts it. See the note in `date-field.tsx`.
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full min-w-0 items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
@@ -153,7 +157,9 @@ export const TimeField = React.forwardRef<HTMLInputElement, TimeFieldProps>(
inputMode="numeric"
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
// See `date-field.tsx`: without `min-w-0` the input's intrinsic
// 20-character width becomes the field's floor.
className="placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={overlayValue}
placeholder={placeholder ?? (use12h ? "--:-- --" : "--:--")}
disabled={disabled}