chore(release): v1.4.27

v1.4.27 — Mobile capability and maintainer-finding cleanup.

ResponsiveSheet primitive (bottom-sheet on narrow viewports, centred dialog on desktop)
applied to BD, Weight, Glucose, Mood, Activity forms. Maintainer-finding cleanups across
Coach, Insights, Reports, Privacy. GeoLite2 offline geocoding with resilience layer
(graceful fallback when EULA not yet propagated). E2E gap-fill coverage. 450-line
release notes attached to the GitHub Release.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-15 22:59:10 +02:00
1 parent d98e85cd03
commit 4f09c3f862
314 files changed
+26801 -4419

No files matched your search

@@ -261,8 +261,142 @@ describe("<Glp1Tile>", () => {
expect(html).not.toContain("Ozempic 0mg");
// No weight delta caption when both sides are null.
expect(html).not.toContain('data-slot="glp1-tile-delta"');
// No chart when the series is empty.
expect(html).not.toContain('data-slot="glp1-tile-chart"');
// v1.4.27 B1 — the tab strip + range strip always render; the
// weight pane shows the muted "no data yet" hint when the series
// is empty rather than suppressing the whole chart row.
expect(html).toContain('data-slot="glp1-tile-chart"');
expect(html).toContain('data-slot="glp1-tile-tab-level"');
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
});
it("v1.4.27 B1 — exposes the tab strip and range strip with drug-level default", () => {
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 7.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
nextInjection: { date: "2026-05-17", daysAway: 3 },
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: ["2026-04-26", "2026-05-03", "2026-05-10"],
},
],
},
isPending: false,
};
const html = render();
// Tab strip + range strip are present on a populated tile.
expect(html).toContain('data-slot="glp1-tile-tabs"');
expect(html).toContain('data-slot="glp1-tile-tab-level"');
expect(html).toContain('data-slot="glp1-tile-tab-weight"');
expect(html).toContain('data-slot="glp1-tile-range-strip"');
// Drug-Level tab is the default selection.
expect(html).toMatch(
/data-slot="glp1-tile-tab-level"[^>]*data-active="true"/,
);
expect(html).toMatch(
/data-slot="glp1-tile-tab-weight"[^>]*data-active="false"/,
);
// Range strip exposes 7d / 30d / 90d / All buttons.
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="7"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="30"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="90"/);
expect(html).toMatch(/data-slot="glp1-tile-range-button"[^>]*data-points="0"/);
// 30d preset is the default.
expect(html).toMatch(
/data-slot="glp1-tile-range-button"[^>]*data-points="30"[^>]*data-active="true"/,
);
});
it("v1.4.27 B1 — promotes the schedule to a pill row and drops the green seam", () => {
queryReturn = {
data: {
active: true,
medications: [
{
name: "Mounjaro",
genericName: "tirzepatide",
medicationId: "med-1",
currentDose: {
value: 7.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: { date: "2026-05-10", site: null, weeksAgo: 0 },
nextInjection: { date: "2026-05-17", daysAway: 3 },
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: ["2026-05-10"],
},
],
},
isPending: false,
};
const html = render();
// Schedule wrapper is the new pill row, not the old <dl>.
expect(html).toContain('data-slot="glp1-tile-schedule"');
expect(html).not.toContain("<dl");
// Pills still carry the per-pill slot anchors the existing tests use.
expect(html).toContain('data-slot="glp1-tile-last"');
expect(html).toContain('data-slot="glp1-tile-next"');
// The green left-seam classes drop from the outer wrapper.
expect(html).not.toContain("border-l-dracula-green/60");
expect(html).not.toContain("border-l-2");
});
it("v1.4.27 B1 — surfaces the level-unavailable hint when no medicationId is wired", () => {
// Some legacy meds don't have a Medication.id surfaced through the
// dashboard route (the route returns medicationId: null). The
// drug-level pane needs a stable medication id to mount the
// chart, so we render a muted hint instead of the chart.
queryReturn = {
data: {
active: true,
medications: [
{
name: "Ozempic",
genericName: "semaglutide",
medicationId: null,
currentDose: {
value: 0.5,
unit: "mg",
since: "2026-04-01",
weeksOnDose: 6,
},
doseHistory: [],
lastInjection: null,
nextInjection: null,
startWeight: 92.0,
currentWeight: 87.8,
weightDeltaKg: -4.2,
weightSeries: [{ date: "2026-05-13", weight: 87.8 }],
injectionDates: [],
},
],
},
isPending: false,
};
const html = render();
// Drug-level tab stays selected by default; pane renders the
// unavailable hint because medicationId is null.
expect(html).toContain('data-slot="glp1-tile-level-unavailable"');
});
it("renders German copy under the de locale", () => {
+202 -43
View File
@@ -26,13 +26,14 @@
*/
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { Syringe, ArrowDown, ArrowUp, Minus, Calendar } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { CHART_RANGE_PRESETS } from "@/lib/charts/constants";
const HealthChart = dynamic(
() =>
@@ -42,6 +43,28 @@ const HealthChart = dynamic(
{ ssr: false },
);
const DrugLevelChart = dynamic(
() =>
import("@/components/medications/DrugLevelChart").then((mod) => ({
default: mod.DrugLevelChart,
})),
{ ssr: false },
);
/**
* Map a `CHART_RANGE_PRESETS` `points` value to the drug-level chart's
* window length in hours. `points: 0` (the "All" preset) maps to 365
* days — past that point the unit-less curve flattens to invisibility,
* so a year of history is the practical ceiling.
*/
const HOURS_PER_DAY = 24;
function rangePointsToHours(points: number): number {
if (points === 0) return 365 * HOURS_PER_DAY;
return points * HOURS_PER_DAY;
}
type Glp1ChartTab = "level" | "weight";
interface DoseHistoryEntry {
value: number;
unit: string;
@@ -166,6 +189,14 @@ export function Glp1Tile() {
const dateWithWeekday = useDateWithWeekday();
const deltaDisplay = useDeltaDisplay();
// v1.4.27 B1 — the chart pane carries two views (drug-level / weight)
// and a 7d / 30d / 90d / All range strip above. Drug-level is the
// default because it's the more informative pane the maintainer
// asked for; the weight pane stays one tap away. Both states live
// on the tile so a parent re-render doesn't reset the user's pick.
const [activeTab, setActiveTab] = useState<Glp1ChartTab>("level");
const [rangePoints, setRangePoints] = useState<number>(30);
const { data, isPending } = useQuery({
queryKey: queryKeys.dashboardGlp1(),
enabled: isAuthenticated,
@@ -215,11 +246,12 @@ export function Glp1Tile() {
data-medication-id={med.medicationId ?? ""}
className={cn(
"bg-card/65 relative overflow-hidden rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
// A faint green seam on the left edge so the tile reads as
// "active therapy" at a glance without needing to read the
// header. The same green is used for the injection-day markers
// below — visual consistency across the tile.
"border-l-dracula-green/60 border-l-2",
// v1.4.27 B1 — the green left-seam drops. The Syringe-icon in
// the title row already carries the "active therapy" signal;
// the seam was decoration without a semantic tie to the
// schedule dates next to it. The schedule pill row below now
// groups the two dates visually so the user reads them as one
// cohesive unit.
)}
>
<div className="flex items-center gap-2 pb-3">
@@ -249,50 +281,132 @@ export function Glp1Tile() {
)}
</div>
<dl className="text-muted-foreground grid grid-cols-1 gap-y-1 pb-3 text-xs sm:grid-cols-2 sm:gap-x-4">
{med.lastInjection && (
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3 shrink-0" aria-hidden="true" />
<dt className="font-medium">
{t("dashboard.glp1.lastInjection")}:
</dt>
<dd
{/* v1.4.27 B1 — schedule pill row promotes the two injection
dates to a header band. Pills carry a soft dracula-green tint
so the two dates read as one cohesive "therapy schedule"
unit; the old <dl> grid + arbitrary green seam are gone. */}
{(med.lastInjection || med.nextInjection) && (
<div
data-slot="glp1-tile-schedule"
className="flex flex-wrap items-center gap-1.5 pb-3 text-xs"
>
{med.lastInjection && (
<span
data-slot="glp1-tile-last"
className="text-foreground tabular-nums"
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
>
{dateWithWeekday(med.lastInjection.date)}
</dd>
</div>
)}
{med.nextInjection && (
<div className="flex items-center gap-1.5">
<Syringe className="h-3 w-3 shrink-0" aria-hidden="true" />
<dt className="font-medium">
{t("dashboard.glp1.nextInjection")}:
</dt>
<dd
<Calendar
className="text-dracula-green/80 h-3 w-3 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground font-medium">
{t("dashboard.glp1.lastInjection")}:
</span>
<span>{dateWithWeekday(med.lastInjection.date)}</span>
</span>
)}
{med.nextInjection && (
<span
data-slot="glp1-tile-next"
className="text-foreground tabular-nums"
className="border-dracula-green/30 bg-dracula-green/10 text-foreground inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 tabular-nums"
>
{dateWithWeekday(med.nextInjection.date)}
<span className="text-muted-foreground ml-1">
(
<Syringe
className="text-dracula-green/80 h-3 w-3 shrink-0"
aria-hidden="true"
/>
<span className="text-muted-foreground font-medium">
{t("dashboard.glp1.nextInjection")}:
</span>
<span>{dateWithWeekday(med.nextInjection.date)}</span>
<span className="text-muted-foreground">
·{" "}
{t("dashboard.glp1.inDays", {
count: med.nextInjection.daysAway,
})}
)
</span>
</dd>
</div>
)}
</dl>
</span>
)}
</div>
)}
{/* Compact weight chart with the vertical injection-day markers
the v1.4.25 W6 chart-extension adds. Skipped when we have no
weight readings since the GLP-1 was started — the empty
chart would draw nothing useful. */}
{med.weightSeries.length > 0 && (
<div data-slot="glp1-tile-chart">
{/* v1.4.27 B1 — chart pane carrying a two-tab segmented control
(Drug-Level default / Weight) plus a 7d / 30d / 90d / All
range strip above. Drug-Level reads the unit-less curve from
`<DrugLevelChart compact …>` (Research Mode gating is owned
by the chart itself); Weight retains the v1.4.25 W6 mini
chart with vertical injection markers. */}
<div data-slot="glp1-tile-chart" className="space-y-2 pt-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div
data-slot="glp1-tile-tabs"
role="tablist"
aria-label={t("dashboard.glp1.tabsAria")}
className="bg-muted/40 inline-flex rounded-md p-0.5"
>
<TabButton
active={activeTab === "level"}
onClick={() => setActiveTab("level")}
label={t("dashboard.glp1.tabLevel")}
slot="glp1-tile-tab-level"
/>
<TabButton
active={activeTab === "weight"}
onClick={() => setActiveTab("weight")}
label={t("dashboard.glp1.tabWeight")}
slot="glp1-tile-tab-weight"
/>
</div>
<div
data-slot="glp1-tile-range-strip"
role="radiogroup"
aria-label={t("dashboard.glp1.rangeStripLabel")}
className="inline-flex items-center gap-1 text-[11px]"
>
{CHART_RANGE_PRESETS.map((preset) => (
<button
key={preset.points}
type="button"
role="radio"
aria-checked={rangePoints === preset.points}
data-slot="glp1-tile-range-button"
data-points={preset.points}
data-active={rangePoints === preset.points ? "true" : "false"}
title={t(preset.titleKey)}
onClick={() => setRangePoints(preset.points)}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded px-3 font-medium tabular-nums",
"hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
rangePoints === preset.points
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground",
)}
>
{t(preset.labelKey)}
</button>
))}
</div>
</div>
{activeTab === "level" && med.medicationId ? (
<DrugLevelChart
compact
medication={{
id: med.medicationId,
name: med.name,
dose: med.currentDose
? `${med.currentDose.value}${med.currentDose.unit}`
: "",
}}
windowHoursBefore={rangePointsToHours(rangePoints)}
/>
) : activeTab === "level" ? (
<p
data-slot="glp1-tile-level-unavailable"
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
>
{t("dashboard.glp1.levelUnavailable")}
</p>
) : med.weightSeries.length > 0 ? (
<HealthChart
mini
types={["WEIGHT"]}
@@ -302,12 +416,57 @@ export function Glp1Tile() {
verticalMarkers={med.injectionDates.map((date) => ({ date }))}
userTimezone={user?.timezone}
/>
</div>
)}
) : (
<p
data-slot="glp1-tile-weight-unavailable"
className="text-muted-foreground bg-muted/40 rounded-md p-3 text-xs"
>
{t("dashboard.glp1.weightUnavailable")}
</p>
)}
</div>
</div>
);
}
/**
* v1.4.27 B1 — small segmented-control button used by the tile's
* tab strip. Carved into its own component so the active/inactive
* styling lives in one place and the test can pin the data-slot
* markers per tab.
*/
function TabButton({
active,
onClick,
label,
slot,
}: {
active: boolean;
onClick: () => void;
label: string;
slot: string;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
data-slot={slot}
data-active={active ? "true" : "false"}
onClick={onClick}
className={cn(
"inline-flex min-h-11 items-center justify-center rounded px-3 text-xs font-medium",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
active
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
}
/**
* Carve the delta caption pill into its own component so the
* loss/gain/flat tone selection is colocated with the rendering. The