mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(ux): close the feedback gaps a new user runs into first
The Withings OAuth callback finally reports its outcome — connected or a readable reason — instead of dropping the user on the settings page without a word. The adherence number carries one name everywhere and an explanation right where it is shown. Demo mode announces itself with a banner instead of failing silently in English. Bug reports become reachable from the mobile menu, the about section returns to the settings navigation, charts link to the target settings they draw, integrations and source priority point at each other, and the medication wizard warns when reminders are switched on without a configured channel. The longest explainer texts shrink to two sentences.
This commit is contained in:
1 parent
0cf9bc9348
commit
b108ae7318
25 files changed
+433
-50
No files matched your search
@@ -160,6 +160,7 @@ export default function InsightsBlutdruckPage() {
|
||||
unit="mmHg"
|
||||
yAxisUnit="mmHg"
|
||||
targetZones={bpTargetZones}
|
||||
targetSettingsHref="/settings/thresholds"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function InsightsGewichtPage() {
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
targetSettingsHref="/settings/thresholds"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
onVisibleStats={onVisibleStats}
|
||||
|
||||
+7
-1
@@ -120,7 +120,13 @@ export default async function RootLayout({
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<WebVitalsReporter />
|
||||
<AuthShell>{children}</AuthShell>
|
||||
{/* `DEMO_MODE` is a server-only env var; the proxy uses it to
|
||||
block mutations. Resolve it here (server component) and
|
||||
thread the boolean into the client shell so the demo
|
||||
banner can render without a client-side detection path. */}
|
||||
<AuthShell demoMode={process.env.DEMO_MODE === "true"}>
|
||||
{children}
|
||||
</AuthShell>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1424,6 +1424,7 @@ export default function DashboardPage() {
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
targetSettingsHref="/settings/thresholds"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
@@ -1470,6 +1471,7 @@ export default function DashboardPage() {
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
targetZones={bpTargetZones}
|
||||
targetSettingsHref="/settings/thresholds"
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
ReferenceArea,
|
||||
ReferenceDot,
|
||||
} from "recharts";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, SlidersHorizontal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
@@ -106,6 +107,15 @@ interface HealthChartProps {
|
||||
textColor?: string;
|
||||
lineOpacity?: number;
|
||||
}>;
|
||||
/**
|
||||
* Optional href for a small "adjust targets" link in the chart
|
||||
* header. Callers whose charts paint a target band / zones (weight,
|
||||
* blood pressure) pass `/settings/thresholds` so the band's origin —
|
||||
* and the page that edits personal target ranges — is one click
|
||||
* away. Omitted (every other mount) renders no link. Ignored in
|
||||
* `mini` mode, which has no header.
|
||||
*/
|
||||
targetSettingsHref?: string;
|
||||
/**
|
||||
* v1.4.16 phase B5c — compact chart mode used by the Oura-style
|
||||
* rationale card. Drops the range tabs, the moving-average / trend
|
||||
@@ -514,6 +524,7 @@ export function HealthChart({
|
||||
showYAxisUnit = true,
|
||||
valueBands,
|
||||
targetZones,
|
||||
targetSettingsHref,
|
||||
mini = false,
|
||||
windowOverride,
|
||||
compareBaseline = "none",
|
||||
@@ -1444,6 +1455,19 @@ export function HealthChart({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{/* Discoverability link from the painted target band to the
|
||||
page that edits personal target ranges. Only mounts when
|
||||
a caller passes `targetSettingsHref` (weight + BP). */}
|
||||
{targetSettingsHref ? (
|
||||
<Link
|
||||
href={targetSettingsHref}
|
||||
data-slot="chart-target-settings-link"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 transition-colors hover:underline"
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3" aria-hidden="true" />
|
||||
{t("charts.adjustTargets")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-nowrap items-center justify-end gap-1 self-end sm:self-auto"
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { ArrowDown, ArrowRight, ArrowUp, Loader2, Pill } from "lucide-react";
|
||||
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -329,6 +330,9 @@ export function MedicationComplianceChart({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill className="text-muted-foreground h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{displayTitle}</h3>
|
||||
{/* `?` explainer for what the adherence percentage measures —
|
||||
shares the i18n body with the per-card bars. */}
|
||||
<ComplianceInfoTip />
|
||||
{/* v1.4.18 — 7-day trend chip is now opt-in via the
|
||||
"7-day trend" overlay toggle. Default OFF; the user
|
||||
activates it from the chart settings popover. */}
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("<SectionToggles> v1.4.43 M4 disabled-row behaviour", () => {
|
||||
/data-unavailable="true"[\s\S]*?Resting pulse/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-unavailable="true"[\s\S]*?Medication compliance/,
|
||||
/data-unavailable="true"[\s\S]*?Medication adherence/,
|
||||
);
|
||||
expect(html).toMatch(/data-unavailable="true"[\s\S]*?Sleep duration/);
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("<SectionToggles> v1.4.43 M4 disabled-row behaviour", () => {
|
||||
);
|
||||
// The compliance row exists and is marked unavailable…
|
||||
expect(html).toMatch(
|
||||
/data-unavailable="true"[\s\S]*?Medication compliance/,
|
||||
/data-unavailable="true"[\s\S]*?Medication adherence/,
|
||||
);
|
||||
// …and the disabled switch is rendered (aria-disabled or
|
||||
// data-disabled survives in the SSR markup).
|
||||
|
||||
@@ -61,8 +61,8 @@ describe("<CorrelationCard>", () => {
|
||||
it("renders title + subtitle + interpretation when status=ok", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />);
|
||||
expect(html).toMatch(/data-slot="correlation-card"/);
|
||||
expect(html).toContain("BP × medication compliance");
|
||||
expect(html).toContain("Daily systolic against compliance %");
|
||||
expect(html).toContain("BP × medication adherence");
|
||||
expect(html).toContain("Daily systolic against adherence in %");
|
||||
expect(html).toContain("a pattern worth watching");
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("<CorrelationCard>", () => {
|
||||
|
||||
it("renders German copy when locale=de", () => {
|
||||
const html = render(<CorrelationCard result={okResult} />, "de");
|
||||
expect(html).toContain("BD × Medikamenten-Compliance");
|
||||
expect(html).toContain("BD × Therapietreue");
|
||||
expect(html).toContain("7-Tage-Experiment");
|
||||
});
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("<HealthScoreDeltaExplainer>", () => {
|
||||
expect(body).not.toBeNull();
|
||||
const text = body?.[1] ?? "";
|
||||
// Components — window — action triad pinned.
|
||||
expect(text).toContain("BP, weight, mood and meds");
|
||||
expect(text).toContain("BP, weight, mood and medication adherence");
|
||||
expect(text).toContain("same time last week");
|
||||
expect(text).toContain("Log a fresh reading");
|
||||
// 3-sentence ceiling. Soft check: at most 4 sentence-ending
|
||||
@@ -171,7 +171,7 @@ describe("<HealthScoreDeltaExplainer>", () => {
|
||||
const text = body?.[1] ?? "";
|
||||
expect(text).toContain("Blutdruck");
|
||||
expect(text).toContain("Stimmung");
|
||||
expect(text).toContain("Einnahmetreue");
|
||||
expect(text).toContain("Therapietreue");
|
||||
expect(text).toContain("vor einer Woche");
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import { BottomNav } from "./bottom-nav";
|
||||
import { DemoBanner } from "./demo-banner";
|
||||
import { OfflineBanner } from "./offline-banner";
|
||||
import { SidebarNav } from "./sidebar-nav";
|
||||
import { TopBar } from "./top-bar";
|
||||
@@ -32,7 +33,18 @@ const PUBLIC_PATHS = [
|
||||
"/c/",
|
||||
];
|
||||
|
||||
export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
export function AuthShell({
|
||||
children,
|
||||
demoMode = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* True when the instance runs with `DEMO_MODE=true` (resolved by the
|
||||
* server-side root layout). Renders the persistent demo banner above
|
||||
* the app chrome so a visitor knows mutations won't persist.
|
||||
*/
|
||||
demoMode?: boolean;
|
||||
}) {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const pathname = usePathname();
|
||||
@@ -119,6 +131,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
return (
|
||||
<div className="flex h-dvh flex-col">
|
||||
{demoMode ? <DemoBanner /> : null}
|
||||
<MaintainershipBanner />
|
||||
<main
|
||||
id="main-content"
|
||||
@@ -197,6 +210,11 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
the first chrome line a user sees in the offline branch.
|
||||
*/}
|
||||
<OfflineBanner />
|
||||
{/* Demo instances surface a persistent "changes are not
|
||||
saved" strip — the proxy blocks every mutation, and
|
||||
without the banner that block only surfaced as a raw API
|
||||
error after the user already filled in a form. */}
|
||||
{demoMode ? <DemoBanner /> : null}
|
||||
<MaintainershipBanner />
|
||||
<TopBar />
|
||||
<main
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
Bug,
|
||||
Droplets,
|
||||
Dumbbell,
|
||||
Home,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useAppSettings } from "@/components/app-settings-provider";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -80,18 +82,38 @@ const CYCLE_HUB_ITEM: NavLink = {
|
||||
icon: Droplets,
|
||||
};
|
||||
|
||||
// The bug-report entry, spliced into the More hub under the same
|
||||
// `bugReportEnabled` operator flag that gates the desktop sidebar
|
||||
// entry. Pre-splice the route was desktop-only — a phone user had no
|
||||
// path to `/bugreport` at all.
|
||||
const BUGREPORT_HUB_ITEM: NavLink = {
|
||||
href: "/bugreport",
|
||||
tKey: "nav.bugreport",
|
||||
icon: Bug,
|
||||
};
|
||||
|
||||
export function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const { bugReportEnabled } = useAppSettings();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [captureOpen, setCaptureOpen] = useState(false);
|
||||
|
||||
// v1.15.0 — splice the cycle entry into the More hub after Mood when the
|
||||
// account's gate is on, so it never appears for accounts without it.
|
||||
const moreHub: ReadonlyArray<NavLink> = user?.cycleTrackingEnabled
|
||||
const cycleAwareHub: ReadonlyArray<NavLink> = user?.cycleTrackingEnabled
|
||||
? [MORE_HUB[0], MORE_HUB[1], CYCLE_HUB_ITEM, ...MORE_HUB.slice(2)]
|
||||
: MORE_HUB;
|
||||
// Bug report sits directly before Settings (the hub's last entry)
|
||||
// when the operator flag is on — same gate as the desktop sidebar.
|
||||
const moreHub: ReadonlyArray<NavLink> = bugReportEnabled
|
||||
? [
|
||||
...cycleAwareHub.slice(0, -1),
|
||||
BUGREPORT_HUB_ITEM,
|
||||
cycleAwareHub[cycleAwareHub.length - 1],
|
||||
]
|
||||
: cycleAwareHub;
|
||||
|
||||
function isActiveLink(href: string) {
|
||||
return href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Slim persistent banner shown while the instance runs with
|
||||
* `DEMO_MODE=true`. The proxy already blocks every mutation outside a
|
||||
* small allowlist (`src/proxy.ts`), but pre-banner the only feedback a
|
||||
* demo visitor got was an English API error after submitting a form.
|
||||
* This strip tells the visitor up front that nothing they change will
|
||||
* persist.
|
||||
*
|
||||
* Mount strategy mirrors `<OfflineBanner>`: rendered once inside
|
||||
* `<AuthShell>` so every routed page inherits it. The flag itself is a
|
||||
* server-only env var, so the root layout (a server component) reads
|
||||
* `process.env.DEMO_MODE` and threads the boolean through the
|
||||
* `demoMode` prop on `<AuthShell>` — no client-side detection, no
|
||||
* extra request.
|
||||
*
|
||||
* Copy lives in `messages/*.json` under `demoBanner.message`; all six
|
||||
* locales ship together.
|
||||
*/
|
||||
export function DemoBanner() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-slot="demo-banner"
|
||||
className="bg-primary/10 border-primary/30 text-foreground flex items-center justify-center gap-2 border-b px-3 py-2 text-xs"
|
||||
>
|
||||
<FlaskConical className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="text-center">{t("demoBanner.message")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronsRight,
|
||||
Droplets,
|
||||
Home,
|
||||
Info,
|
||||
Lightbulb,
|
||||
LogOut,
|
||||
Monitor,
|
||||
@@ -156,10 +157,18 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
{t("nav.notifications")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.36 W4e — About moved into the Admin Console
|
||||
(`/admin/about`). Admins reach it through the Admin sidebar
|
||||
and the mobile section strip; non-admin users see the
|
||||
public `/about` landing instead. */}
|
||||
{/* "About HealthLog" links the signed-in `/settings/about`
|
||||
section (version, licences, update check). The slug also has
|
||||
its own entry at the end of the settings shell nav; this
|
||||
dropdown item keeps the historic discovery path alive. The
|
||||
public `/about` landing stays separate for signed-out
|
||||
visitors. */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/about" className="cursor-pointer">
|
||||
<Info className="mr-2 h-4 w-4" />
|
||||
{t("nav.about")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
{themeIcon}
|
||||
|
||||
@@ -49,8 +49,8 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
const html = render(
|
||||
<MedicationComplianceBars rate7={90} rate30={88} streak={5} />,
|
||||
);
|
||||
expect(html).toContain("7-day compliance");
|
||||
expect(html).toContain("30-day compliance");
|
||||
expect(html).toContain("Adherence (7 days)");
|
||||
expect(html).toContain("Adherence (30 days)");
|
||||
expect(html).toContain("90%");
|
||||
expect(html).toContain("88%");
|
||||
expect(html).toContain("lucide-flame");
|
||||
@@ -72,10 +72,10 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
longDays={365}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("90-day compliance");
|
||||
expect(html).toContain("365-day compliance");
|
||||
expect(html).not.toContain("7-day compliance");
|
||||
expect(html).not.toContain("30-day compliance");
|
||||
expect(html).toContain("Adherence (90 days)");
|
||||
expect(html).toContain("Adherence (365 days)");
|
||||
expect(html).not.toContain("Adherence (7 days)");
|
||||
expect(html).not.toContain("Adherence (30 days)");
|
||||
});
|
||||
|
||||
it("compliance bars hide the streak flame when streak is zero", () => {
|
||||
@@ -99,8 +99,8 @@ describe("medication card-parts — shared presentational components", () => {
|
||||
expect(html).not.toContain("·");
|
||||
expect(html).not.toContain("doses");
|
||||
// The bar + the window-days label survive.
|
||||
expect(html).toContain("7-day compliance");
|
||||
expect(html).toContain("30-day compliance");
|
||||
expect(html).toContain("Adherence (7 days)");
|
||||
expect(html).toContain("Adherence (30 days)");
|
||||
});
|
||||
|
||||
it("status pill stamps the success token + take-now glyph in window", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyOptimisticSlotMark,
|
||||
complianceFromLedger,
|
||||
formatSlotDelta,
|
||||
groupLedgerByDay,
|
||||
isSlotActionable,
|
||||
type LedgerPayload,
|
||||
@@ -109,6 +110,33 @@ describe("isSlotActionable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// v1.15.20 — the signed offset on the ad-hoc "fällig gewesen" subline.
|
||||
describe("formatSlotDelta", () => {
|
||||
const slot = "2026-06-01T05:00:00.000Z";
|
||||
|
||||
it("renders a take after the slot with a plus sign in minutes", () => {
|
||||
expect(formatSlotDelta("2026-06-01T05:45:00.000Z", slot)).toBe("+45 min");
|
||||
});
|
||||
|
||||
it("renders a take before the slot with a minus sign", () => {
|
||||
expect(formatSlotDelta("2026-06-01T04:30:00.000Z", slot)).toBe("-30 min");
|
||||
});
|
||||
|
||||
it("renders whole hours without a minutes part", () => {
|
||||
expect(formatSlotDelta("2026-06-01T07:00:00.000Z", slot)).toBe("+2 h");
|
||||
});
|
||||
|
||||
it("renders mixed hours + minutes", () => {
|
||||
expect(formatSlotDelta("2026-06-01T06:20:00.000Z", slot)).toBe(
|
||||
"+1 h 20 min",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses a sub-minute delta to +0 min", () => {
|
||||
expect(formatSlotDelta("2026-06-01T05:00:20.000Z", slot)).toBe("+0 min");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOptimisticSlotMark", () => {
|
||||
const payload: LedgerPayload = {
|
||||
from: "2026-06-01T00:00:00.000Z",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Small `?` affordance that explains what the adherence percentage
|
||||
* actually measures ("share of expected doses you logged — weekly
|
||||
* schedules count week by week"). Sits next to the number on the two
|
||||
* surfaces that render an adherence rate without context: the
|
||||
* dashboard compliance card header and the per-medication bars
|
||||
* (`medication-compliance-bars.tsx`).
|
||||
*
|
||||
* Pattern follows `health-score-delta-explainer.tsx` (icon-only
|
||||
* trigger, 44 px hit target collapsed back to the row's stride via
|
||||
* negative margins). A click-opened Popover is used on every viewport
|
||||
* — unlike a hover Tooltip it stays reachable on touch devices, where
|
||||
* the medication cards see most of their use.
|
||||
*/
|
||||
export function ComplianceInfoTip({ className }: { className?: string }) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="compliance-info-trigger"
|
||||
aria-label={t("medications.complianceInfoLabel")}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-mx-2 -my-3",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="compliance-info-body"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("medications.complianceInfo")}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Flame } from "lucide-react";
|
||||
|
||||
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
@@ -64,7 +65,13 @@ export function MedicationComplianceBars({
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{shortLabel}</span>
|
||||
<span className="text-muted-foreground flex items-center gap-1">
|
||||
{shortLabel}
|
||||
{/* One `?` for the whole block — both rows measure the same
|
||||
thing over different windows, so a second trigger on the
|
||||
long row would only add noise. */}
|
||||
<ComplianceInfoTip />
|
||||
</span>
|
||||
<span className="font-medium">{shortPct}%</span>
|
||||
</div>
|
||||
{/* aria-label so the bar has an accessible name. */}
|
||||
|
||||
@@ -25,6 +25,11 @@ export interface LedgerRow {
|
||||
/** The slot's "HH:mm" label, or null for an ad-hoc row. */
|
||||
timeOfDay: string | null;
|
||||
status: DoseHistoryStatus;
|
||||
/** v1.15.20 — slot served by a deliberate user pin ("zugeordnet"). */
|
||||
pinned?: boolean;
|
||||
/** v1.15.20 — due-context for an ad-hoc take: the nearest slot it could
|
||||
* belong to. `filled: false` unlocks the "Slot zuordnen" kebab action. */
|
||||
nearestSlot?: { at: string; timeOfDay: string; filled: boolean };
|
||||
intake: {
|
||||
id: string | null;
|
||||
scheduledFor: string;
|
||||
@@ -34,6 +39,24 @@ export interface LedgerRow {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.15.20 — human-compact signed offset between an ad-hoc take and the slot
|
||||
* it would have been due at ("+45 min", "-2 h", "+1 h 20 min"). Unit
|
||||
* abbreviations are locale-neutral (min / h); the sign reads "taken after
|
||||
* (+) / before (−) the slot". Sub-minute deltas collapse to "±0 min".
|
||||
*/
|
||||
export function formatSlotDelta(takenAtIso: string, slotAtIso: string): string {
|
||||
const deltaMs = new Date(takenAtIso).getTime() - new Date(slotAtIso).getTime();
|
||||
const sign = deltaMs < 0 ? "-" : "+";
|
||||
const totalMinutes = Math.round(Math.abs(deltaMs) / 60_000);
|
||||
if (totalMinutes < 60) return `${sign}${totalMinutes} min`;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return minutes === 0
|
||||
? `${sign}${hours} h`
|
||||
: `${sign}${hours} h ${minutes} min`;
|
||||
}
|
||||
|
||||
/** The full `data` envelope the endpoint returns. */
|
||||
export interface LedgerPayload {
|
||||
from: string;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
type MedicationPayload,
|
||||
@@ -55,6 +58,28 @@ export function Step8Summary({
|
||||
const topSummary = summariseCadence(payload, t);
|
||||
const canRemove = payload.schedules.length > 1;
|
||||
|
||||
// Reminders need at least one delivery channel. Fetch the channel
|
||||
// list only while the toggle is on; when the server reports zero
|
||||
// enabled channels, a small hint below the toggle links to
|
||||
// `/settings/notifications` so the user learns BEFORE saving that
|
||||
// the reminder would have nowhere to go. Query errors / loading
|
||||
// render no hint — a false "no channel" warning is worse than none.
|
||||
const { data: notificationChannels } = useQuery({
|
||||
queryKey: queryKeys.notificationsStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/notifications/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return ((await res.json()).data as { channels: Array<{ enabled: boolean }> })
|
||||
.channels;
|
||||
},
|
||||
enabled: payload.notificationsEnabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const showNoChannelHint =
|
||||
payload.notificationsEnabled &&
|
||||
Array.isArray(notificationChannels) &&
|
||||
!notificationChannels.some((channel) => channel.enabled);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-slot="wizard-step8">
|
||||
<div
|
||||
@@ -159,6 +184,21 @@ export function Step8Summary({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showNoChannelHint && (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="wizard-no-channel-hint"
|
||||
>
|
||||
{t("medications.wizard.steps.step8.noChannelHint")}{" "}
|
||||
<Link
|
||||
href="/settings/notifications"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("medications.wizard.steps.step8.noChannelHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<p
|
||||
className="text-destructive text-sm"
|
||||
|
||||
@@ -85,12 +85,9 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
describe("<SettingsShell>", () => {
|
||||
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
|
||||
const html = renderShell({ active: "account" });
|
||||
// v1.4.33 IW7 — the `about` slug is alive as a route
|
||||
// (`/settings/about` still resolves and `generateStaticParams()`
|
||||
// still emits the page) but it is hidden from the in-shell nav.
|
||||
// The user-card dropdown owns the link now. Iterate the visible
|
||||
// sections list, not the slug list, when asserting on rendered
|
||||
// markup.
|
||||
// Every slug — including `about`, which returned to the shell nav
|
||||
// as the last entry after living dropdown-only since v1.4.33 IW7 —
|
||||
// renders in both layouts.
|
||||
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
|
||||
for (const slug of navigableSlugs) {
|
||||
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
|
||||
@@ -100,8 +97,6 @@ describe("<SettingsShell>", () => {
|
||||
// before media queries resolve.
|
||||
expect(matches?.length ?? 0).toBe(2);
|
||||
}
|
||||
// The hidden `about` slug must NOT appear in the in-shell nav.
|
||||
expect(html).not.toContain('href="/settings/about"');
|
||||
});
|
||||
|
||||
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
|
||||
@@ -161,10 +156,9 @@ describe("<SettingsShell>", () => {
|
||||
// entry in the sidebar; the link must be present in both locales.
|
||||
expect(html).toContain('href="/settings/export"');
|
||||
expect(html).toContain("Advanced");
|
||||
// v1.4.33 IW7 — About is no longer in the settings nav; it lives
|
||||
// in the sidebar user-card dropdown. Route `/settings/about` is
|
||||
// still alive for direct links.
|
||||
expect(html).not.toContain('href="/settings/about"');
|
||||
// About is back in the settings nav as the last entry (it also
|
||||
// stays linked from the sidebar user-card dropdown).
|
||||
expect(html).toContain('href="/settings/about"');
|
||||
// v1.8.7.1 — Targets and Sources are two separate nav entries again.
|
||||
expect(html).toContain('href="/settings/thresholds"');
|
||||
expect(html).toContain('href="/settings/sources"');
|
||||
@@ -197,9 +191,9 @@ describe("<SettingsShell>", () => {
|
||||
// API & Tokens is identical in both locales (proper noun + ampersand)
|
||||
expect(html).toContain("API & Tokens");
|
||||
expect(html).toContain("Erweitert");
|
||||
// v1.4.33 IW7 — "Über" (About) section removed from the in-shell
|
||||
// nav, folded into the sidebar user-card dropdown ("Über HealthLog").
|
||||
expect(html).not.toContain('href="/settings/about"');
|
||||
// "Über diese App" (About) is back as the last in-shell nav entry
|
||||
// (the sidebar user-card dropdown keeps its own link too).
|
||||
expect(html).toContain('href="/settings/about"');
|
||||
// v1.8.7.1 — both Targets and Sources nav entries are present.
|
||||
expect(html).toContain('href="/settings/sources"');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
@@ -170,12 +173,78 @@ function IntegrationErrorMessage({ message }: { message: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Withings OAuth callback (`/api/withings/callback`) redirects back
|
||||
* here with `?withings=connected` or `?withings=error&reason=<tag>`.
|
||||
* Map every reason tag the callback emits onto a human-readable i18n
|
||||
* key (what went wrong + what to do next). Unknown tags fall back to
|
||||
* the generic entry so a future callback branch never strands the user
|
||||
* with silent params.
|
||||
*/
|
||||
const WITHINGS_OAUTH_ERROR_KEYS: Record<string, string> = {
|
||||
csrf1: "settings.withingsOauthError.csrf1",
|
||||
replay: "settings.withingsOauthError.replay",
|
||||
state: "settings.withingsOauthError.state",
|
||||
expired: "settings.withingsOauthError.expired",
|
||||
cross_user: "settings.withingsOauthError.cross_user",
|
||||
nocode: "settings.withingsOauthError.nocode",
|
||||
nocreds: "settings.withingsOauthError.nocreds",
|
||||
token: "settings.withingsOauthError.token",
|
||||
};
|
||||
|
||||
type WithingsOauthOutcome =
|
||||
| { kind: "connected" }
|
||||
| { kind: "error"; reason: string };
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
|
||||
|
||||
// OAuth callback handler — reads `?withings=connected|error&reason=…`
|
||||
// from the URL (lazy initialiser, same shape as the Codex handler in
|
||||
// `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the
|
||||
// callback set these params and nothing ever read them: a user came
|
||||
// back from Withings onto a silently unchanged settings page.
|
||||
const [withingsOauthOutcome] = useState<WithingsOauthOutcome | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const status = params.get("withings");
|
||||
if (status === "connected") return { kind: "connected" };
|
||||
if (status === "error") {
|
||||
return { kind: "error", reason: params.get("reason") ?? "unknown" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!withingsOauthOutcome) return;
|
||||
// Scrub the one-shot params so a reload / bookmark doesn't replay
|
||||
// the toast.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("withings");
|
||||
url.searchParams.delete("reason");
|
||||
router.replace(`${url.pathname}${url.search}`, { scroll: false });
|
||||
if (withingsOauthOutcome.kind === "connected") {
|
||||
toast.success(t("settings.withingsOauthConnected"));
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
const reasonKey =
|
||||
WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ??
|
||||
"settings.withingsOauthError.generic";
|
||||
toast.error(t("settings.withingsOauthFailed"), {
|
||||
description: t(reasonKey),
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
}, [withingsOauthOutcome, router, queryClient, t]);
|
||||
|
||||
const withingsViewModel = pickStatus(integrationStatus, "withings");
|
||||
const whoopViewModel = pickStatus(integrationStatus, "whoop");
|
||||
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
|
||||
@@ -195,6 +264,20 @@ export function IntegrationsSection() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.integrations.description")}
|
||||
</p>
|
||||
{/* Cross-link to Settings → Sources: when two integrations (or
|
||||
an integration + manual entry) report the same metric, the
|
||||
source-priority ladder decides which value counts — a fact
|
||||
newcomers otherwise discover only after a confusing chart. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationsSourcesHint")}{" "}
|
||||
<Link
|
||||
href="/settings/sources"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
data-slot="integrations-sources-cross-link"
|
||||
>
|
||||
{t("settings.integrationsSourcesHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChevronRight } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
|
||||
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
|
||||
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
|
||||
import { NtfyCard } from "@/components/settings/ntfy-card";
|
||||
@@ -110,6 +111,10 @@ export function NotificationsSection() {
|
||||
<div id="mood-reminder" className="scroll-mt-28">
|
||||
<MoodReminderCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
{/* v1.15.20 — proactive Coach nudge opt-out. */}
|
||||
<div id="coach-nudge" className="scroll-mt-28">
|
||||
<CoachNudgeCard isAuthenticated={isAuthenticated} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Bell,
|
||||
Download,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
@@ -66,15 +67,11 @@ interface SettingsSection {
|
||||
* in its sidebar + mobile chip-strip. Order matches the in-app
|
||||
* navigation order. Don't reorder without updating tests.
|
||||
*
|
||||
* v1.4.33 IW7 — "About" was folded into the sidebar user-card
|
||||
* dropdown (sidebar-nav.tsx -> "Über HealthLog" / "About HealthLog")
|
||||
* because its three small cards (identity / links / update check)
|
||||
* collectively read once or twice a year and didn't earn a top-level
|
||||
* settings slot. The route `/settings/about` is still alive — the
|
||||
* slug stays in `SETTINGS_SECTION_SLUGS` so `generateStaticParams()`
|
||||
* keeps emitting the page — but it no longer appears in the in-page
|
||||
* navigation. Direct links (the user-card dropdown, the public
|
||||
* `/about` permalink) continue to resolve to the same component.
|
||||
* "About" sits at the end of the list. v1.4.33 IW7 had folded it into
|
||||
* the sidebar user-card dropdown only, which left `/settings/about` an
|
||||
* orphaned route — reachable by URL but discoverable nowhere in the
|
||||
* settings navigation. It is now both a regular (last) shell entry and
|
||||
* a user-card dropdown item ("Über HealthLog" / "About HealthLog").
|
||||
*
|
||||
* v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
|
||||
* separate sidebar entries again (merged into a single "Targets &
|
||||
@@ -125,6 +122,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
titleKey: "settings.sections.advanced.title",
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
slug: "about",
|
||||
titleKey: "settings.sections.about.title",
|
||||
icon: Info,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export interface SettingsShellProps {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -307,6 +308,18 @@ export function SourcesSection() {
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sources.description")}
|
||||
</p>
|
||||
{/* Cross-link back to Settings → Integrations — the page that
|
||||
actually adds / removes the sources this ladder ranks. */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.sources.integrationsHint")}{" "}
|
||||
<Link
|
||||
href="/settings/integrations"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
data-slot="sources-integrations-cross-link"
|
||||
>
|
||||
{t("settings.sections.sources.integrationsHintLink")}
|
||||
</Link>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
|
||||
@@ -362,7 +362,7 @@ describe("i18n locale file integrity", () => {
|
||||
["insights.healthScore.componentBp", "Blutdruck"],
|
||||
["insights.healthScore.componentWeight", "Gewicht"],
|
||||
["insights.healthScore.componentMood", "Stimmung"],
|
||||
["insights.healthScore.componentCompliance", "Einnahmetreue"],
|
||||
["insights.healthScore.componentCompliance", "Therapietreue"],
|
||||
];
|
||||
|
||||
for (const [key, expectedDe] of EXPECTED) {
|
||||
|
||||
Reference in new issue
Block a user