chore(release): v1.4.38

Robustness sweep, perf hotspots, full localization, coach gate
cascade closure.

- /api/dashboard/summary cold-mount 4.6 s → ~500 ms via DAY-bucket
  sparkline + 60 s response cache.
- Coach API gate inventory test surfaced and closed two orphan
  routes (chat[id] GET+DELETE, chat-message feedback POST).
- Cross-tz fast-path runtime guard falls back to live for users
  more than ±3 h from UTC; proper bucket minting stays v1.5.
- es / fr / it / pl coverage 27 % → 63 % (~3 400 new strings) plus
  three medication-cluster placeholder restorations.
- IANA timezone validation closes the User.timezone self-DoS
  regression the new dashboard SQL would have surfaced.
- Unit suite 4520 → 4524.

See CHANGELOG.md for the full set.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 19:07:03 +02:00
1 parent fe64f66c7f
commit a550031a84
66 files changed
+7910 -3717

No files matched your search

@@ -165,6 +165,17 @@ export function MedicationIntakeQuickAdd({
const json = await res.json();
return json.data as MedicationOption[];
},
// v1.4.38 — share the parent dashboard's medications cache.
// `queryKeys.medications()` resolves to `["medications"]` — the
// same key the dashboard's onboarding checklist subscriber holds.
// Without a staleTime the sheet-mount triggers a fresh
// `/api/medications` GET on every open even when the parent cache
// already has current data. Match the dashboard's 60s window and
// skip the refetch-on-mount so opening the sheet inside the TTL
// reads straight from the React-Query cache.
staleTime: 60_000,
refetchOnMount: false,
refetchOnWindowFocus: false,
});
const medications = useMemo(
@@ -297,9 +308,18 @@ export function MedicationIntakeQuickAdd({
);
// Empty-state branch: the user has no active medications. Render a
// hint + CTA into the body so the user has a single tap into the
// medication-creation surface. The form itself is suppressed so we
// don't ship an unusable Save button.
// hint into the body and promote the "Medikament anlegen" CTA into
// the sheet footer slot so the primary action lives where the user
// expects it (matches the populated form's Save-button slot). The
// form itself is suppressed so we don't ship an unusable Save
// button.
//
// v1.4.38 W-D P2-1 — symmetry fix. The previous shape rendered the
// CTA in the body and a footer with only the Close button, which
// read as the dialog losing a save button. Now the body carries the
// hint and the footer carries `<Cancel> <Link asChild>Medikament
// anlegen</Link>` so the footer-slot promise holds across both
// branches.
if (!medicationsLoading && medications.length === 0) {
const emptyFooter = (
<div className="flex w-full items-center justify-end gap-2">
@@ -310,9 +330,14 @@ export function MedicationIntakeQuickAdd({
onClick={onCancel}
className="min-h-11 sm:min-h-9"
>
{t("common.close")}
{t("common.cancel")}
</Button>
)}
<Button asChild className="min-h-11 sm:min-h-9">
<Link href="/medications">
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
</Link>
</Button>
</div>
);
return (
@@ -332,16 +357,6 @@ export function MedicationIntakeQuickAdd({
{t("dashboard.medicationIntakeQuickAdd.emptyDescription")}
</p>
</div>
<Button
asChild
size="sm"
variant="outline"
className="min-h-11 sm:min-h-9"
>
<Link href="/medications">
{t("dashboard.medicationIntakeQuickAdd.emptyCta")}
</Link>
</Button>
</div>
{footerSlot ? createPortal(emptyFooter, footerSlot) : emptyFooter}
</>
@@ -105,7 +105,15 @@ export function InsightStatusCard({
}
return (
<Card className="animate-insight-in border-l-dracula-purple border-l-2">
// v1.4.38 W-D P2-2 — `aria-live="polite"` so screen readers
// announce the assessment when it swaps into the same slot the
// structured skeleton occupied. The skeleton card already carries
// `aria-busy="true" aria-live="polite"`; mirroring the live region
// on the success card closes the load→loaded transition signal.
<Card
aria-live="polite"
className="animate-insight-in border-l-dracula-purple border-l-2"
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -525,6 +525,11 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
const isExpanded = isGrouped
? expandedDayKeys.has(m.dayKey as string)
: false;
// v1.4.38 W-D P1-1 — stable drill-down id so the
// disclosure chevron can thread aria-controls to the
// expanded panel. dayKey is unique per row when
// grouped; fall back to m.id otherwise.
const drilldownId = `drilldown-desktop-${m.dayKey ?? m.id}`;
return (
<Fragment key={m.id}>
<TableRow>
@@ -585,6 +590,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
toggleExpand(m.dayKey as string)
}
aria-expanded={isExpanded}
aria-controls={drilldownId}
aria-label={
isExpanded
? t("measurements.collapseDay")
@@ -617,7 +623,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
</TableCell>
</TableRow>
{isGrouped && isExpanded && (
<TableRow>
<TableRow id={drilldownId}>
<TableCell colSpan={6} className="p-0">
<DayDrillDown
type={m.type}
@@ -644,6 +650,8 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
const isExpanded = isGrouped
? expandedDayKeys.has(m.dayKey as string)
: false;
// v1.4.38 W-D P1-1 — see desktop counterpart.
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
return (
<div
key={m.id}
@@ -712,6 +720,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
data-testid="measurement-day-expand"
onClick={() => toggleExpand(m.dayKey as string)}
aria-expanded={isExpanded}
aria-controls={drilldownId}
aria-label={
isExpanded
? t("measurements.collapseDay")
@@ -742,12 +751,14 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
</div>
</div>
{isGrouped && isExpanded && (
<DayDrillDown
type={m.type}
dayKey={m.dayKey as string}
unit={m.unit}
layout="mobile"
/>
<div id={drilldownId}>
<DayDrillDown
type={m.type}
dayKey={m.dayKey as string}
unit={m.unit}
layout="mobile"
/>
</div>
)}
</div>
);
@@ -4,7 +4,10 @@ import { useEffect, useReducer, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import {
AlertCircle,
AlertTriangle,
Check,
CircleCheck,
Flame,
History,
Loader2,
@@ -395,13 +398,28 @@ export function Glp1MedicationCard({
<p className="text-sm">
<span
className={
currentWindowStatus.status === "in_window"
? "text-success font-medium"
"inline-flex items-center gap-1 font-medium " +
(currentWindowStatus.status === "in_window"
? "text-success"
: currentWindowStatus.status === "late"
? "text-dracula-yellow font-medium"
: "text-warning font-medium"
? "text-dracula-yellow"
: "text-warning")
}
>
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
glyph so colour-blind users (red-green) can
disambiguate take-now from late from very-late.
WCAG 1.4.1 (Use of Color). */}
{currentWindowStatus.status === "in_window" ? (
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
) : currentWindowStatus.status === "late" ? (
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<AlertTriangle
className="size-3.5 shrink-0"
aria-hidden="true"
/>
)}
{currentWindowStatus.status === "in_window"
? t("medications.takeNow")
: currentWindowStatus.status === "late"
+22 -4
View File
@@ -17,7 +17,10 @@ import {
toBerlinDate,
} from "@/lib/medications/window-status";
import {
AlertCircle,
AlertTriangle,
Check,
CircleCheck,
SkipForward,
Flame,
Pencil,
@@ -294,13 +297,28 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
<p className="text-sm">
<span
className={
currentWindowStatus.status === "in_window"
? "text-success font-medium"
"inline-flex items-center gap-1 font-medium " +
(currentWindowStatus.status === "in_window"
? "text-success"
: currentWindowStatus.status === "late"
? "text-dracula-yellow font-medium"
: "text-warning font-medium"
? "text-dracula-yellow"
: "text-warning")
}
>
{/* v1.4.38 W-D P2-3 — pair the colour with a Lucide
glyph so colour-blind users (red-green) can
disambiguate take-now from late from very-late.
WCAG 1.4.1 (Use of Color). */}
{currentWindowStatus.status === "in_window" ? (
<CircleCheck className="size-3.5 shrink-0" aria-hidden="true" />
) : currentWindowStatus.status === "late" ? (
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<AlertTriangle
className="size-3.5 shrink-0"
aria-hidden="true"
/>
)}
{currentWindowStatus.status === "in_window"
? t("medications.takeNow")
: currentWindowStatus.status === "late"
@@ -365,7 +365,11 @@ export function GettingStartedChecklist() {
// few primary buttons rendered above the fold on the
// onboarded-user dashboard so the regression was visible to
// the touch-target spec.
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-10"
// v1.4.38 W-D P3-3 — align the desktop floor with the
// dashboard +Hinzufügen pattern (`sm:min-h-9`); the
// `sm:min-h-10` here was a no-op against the Button default
// of `h-10`.
className="text-muted-foreground hover:text-foreground min-h-11 sm:min-h-9"
onClick={() => setDismissedAll(true)}
>
{t("gettingStarted.dismissAll")}
@@ -163,7 +163,11 @@ export function ArztberichtHeroCard() {
// `min-h-11` (44 px) is the WCAG 2.5.5 touch-target floor;
// we override the default `<Button>` height on mobile and
// fall back to the compact `sm:h-10` on pointer devices.
className="h-11 px-5 text-sm font-medium sm:h-10"
// v1.4.38 W-D P3-2 — pair the explicit `h-11` with a
// `min-h-11` floor (and a `sm:min-h-9` desktop floor) so a
// future className override can't accidentally re-lift the
// mobile minimum below the touch-target contract.
className="h-11 min-h-11 px-5 text-sm font-medium sm:h-10 sm:min-h-9"
onClick={() => setOpen(true)}
disabled={busy}
aria-describedby={valueStatementId}
+12 -8
View File
@@ -42,14 +42,18 @@ function SelectTrigger({
// desktop input rhythm. `size=sm` (filter chips) intentionally
// keeps the 32 px footprint.
// v1.4.37 W4a item 4 — split the trigger's horizontal padding
// (`pl-3 pr-2.5`) and push the chevron icon 4 px further right
// via `[&_svg:last-child]:mr-1` so the trailing glyph parks at
// the same visual gutter as the browser-native date-input
// calendar icon (~16-20 px on Chromium). The old uniform
// `px-3` parked the chevron 12 px from the edge, which read
// visibly tighter than the date-picker glyph when both
// controls shared a form row.
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-2.5 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:last-child]:mr-1",
// (`pl-3 pr-2`) so the trailing chevron parks at the same
// visual gutter as the browser-native date-input calendar
// icon. The old uniform `px-3` parked the chevron 12 px from
// the edge, which read visibly tighter than the date-picker
// glyph when both controls shared a form row.
// v1.4.38 W-D P1-6 — drop the `[&_svg:last-child]:mr-1`
// double-shift and tighten `pr-2.5` to `pr-2`. The previous
// 14 px combined gutter (10 + 4) hit Chromium-Material native
// date-inputs cleanly but lagged ~4 px against Safari and
// Chromium-legacy themes; `pr-2` (8 px) lands the chevron
// inside the gutter the non-Material engines actually paint.
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent pl-3 pr-2 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}