mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
v1.4.34 — apple-health import + reliability + web-freeze
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.
### Added
- **Server-side aggregation cache.** A new `ServerCache<T>` primitive
(`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot
LRU shape into a reusable per-route layer with TTL expiry,
capacity-bounded LRU eviction, single-flight read coalescing, and
per-instance hit / miss / eviction / stampede counters. Wires the
three hottest dashboard reads — `/api/analytics` (slim + default),
`/api/gamification/achievements`, `/api/medications/intake?scope=compliance`
— and bolts per-user invalidation onto every measurement / mood /
medication / workout / dashboard-widget write endpoint so the next
read paints fresh data instead of waiting out the TTL. Every cache
hit / miss surfaces on the active wide event as
`cache.<name>.outcome` + `cache.<name>.key_hash` so production logs
carry the hit-ratio signal without leaking userIds.
- **Apple Health `export.zip` import.** New endpoint
`POST /api/import/apple-health-export` (synchronous multipart
upload, asynchronous ingest via a dedicated pg-boss
`apple-health-import` worker, per-`MeasurementType` ingestion
stats). Streams the upload straight to disk so a multi-gigabyte
export never lands in V8 heap, hashes the bytes inline for
content-based idempotency, and unpacks `apple_health_export/export.xml`
with a hand-rolled ZIP central-directory walker that handles Zip64
for archives past the 4 GB barrier. The parser folds every
`<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
into the existing `Measurement` and `Workout` row shapes — spot
rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
`sample:<sha256>` fallback), cumulative HK types collapsed into
one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
the iOS daily-aggregation convention. Live per-stage progress
surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
An admin variant at `POST /api/admin/import-apple-health-export`
imports on behalf of a target user (cookie-only `requireAdmin()`
gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
the per-upload state machine (`queued | unpacking | parsing |
upserting | done | failed`), the content-hash for idempotency
short-circuits, and the per-`MeasurementType` ingestion counters
the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
(`?slice=summaries`) and default slices return a per-type
`{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
mounts to a new `tileStaleDays()` helper so an "X days / weeks /
months ago" hint paints under any per-metric tile whose last
sample crosses the 7-day floor. Six locales gained additive
week / month plural keys; Polish uses the genitive-plural form
that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
existing "Hinzufügen" / "Add" launches the Coach drawer directly
from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
the insights layout to the auth shell so the drawer is reachable
from every authed route; the floating action button stays scoped
to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
centralises the three previous consumers — recent-achievements
card, the `/achievements` mother page, and the unlock notifier —
onto one TanStack queryKey + cache slot so dashboard cold mount
fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
`src/lib/http/cache-headers.ts` module exports
`NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
`SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
presets land at the framework level via a `next.config.ts`
`headers()` rule that stamps the bfcache-friendly directive on
every authed HTML response, restoring Chromium bfcache eligibility
for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
"top client chunks by parsed size" signal Turbopack dropped from
`next build`; reads `.next/analyze/client.json` (written by
`@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
and prints a sorted table plus totals.
### Changed
- **Settings sidebar: "Sources" folded into "Targets & Sources".**
Per-metric threshold ranges and per-metric source priority now
share one `/settings/thresholds` page so the same metric's
threshold and device-source preference sit together instead of
one sidebar entry away. `/settings/sources` keeps a
`permanentRedirect` so external bookmarks and docs links follow
through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
Temperature, Active Energy) hide behind one parent pill that
opens a popover sub-list. Parent-pill active state mirrors the
URL so spatial orientation survives the collapse; each sub-page
keeps its own URL and bookmark resolves unchanged. Strip
footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
inspection captured the section-1 / section-2 duplicates that
have accumulated under apps-01 since v1.3.1. Audit pinned at
`.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
deletes performed (operator action).
### Fixed
- **Compliance classifier: early intakes count as compliant.** The
classifier picked up a dedicated `early` bucket (window-start
minus three hours through window-start), the `on_time` post-window
tolerance widened from one hour to three hours, and the `late`
band sits between the new `on_time` ceiling and the configurable
`lateMinutes` knob. Heatmap consumers route the new bucket
through the compliant path; the dedicated `early` counter rides
on `DailyComplianceEntry` for downstream consumers that want to
distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
`looksClassifierBug` fallthrough is gone now that the underlying
classifier widening removes the every-dose-very-late mode the
fallback covered.
### Performance
- **`/api/gamification/achievements` consumer collapse.** Two of
three previous consumers shared the same TanStack queryKey
literal; the third carried a per-user discriminator so the cache
treated it as a fresh slot. Dashboard cold mount fired the
endpoint twice. Collapsing onto the shared hook trims that to a
single request; with the new server-side cache slot warm, the
duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
`docker-publish.yml` workflow lost its `push.branches: [main]`
trigger; the `:latest` raw-tag enable rule moved onto the tag
ref so each release produces exactly one multi-arch build that
refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
`outputFileTracingExcludes` block that narrows the Turbopack
tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
the trace-report no longer warns on every dependent route file.
### Refactor
- **Dashboard analytics consumers.** The two cards that previously
decided their own freshness copy now lean on the typed
`tileStaleDays()` helper and the additive `lastSeenByType` field
on the analytics response. Mood and BD-Zielbereich tiles keep the
default `null` because they have no underlying per-type freshness
signal.
### Accessibility
- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
The new "Ask the coach" pill carries `min-h-11` on mobile and
the matching `sm:min-h-9` desktop floor so the touch target
stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
tech.** Parent pill carries `aria-current="page"` whenever the
current URL matches one of its child vital sub-pages, plus a
`data-slot="insights-tab-strip-group"` hook for visual-regression
testing.
### Internal
- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
its 12-sample 50 ms poll loop for a single Playwright
auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
1-2 ms race window between the analytics-pending shell and
`useAuth().user` resolving collapses to a single retry slot.
`mobile-viewport` dropped `nav a[href]` from the touch-target
sweep (the bottom-nav owns its own WCAG spec) and gated the
44-px floor on `matchMedia('(min-width: 640px)').matches === false`
so the Pixel 5 viewport never tripped into the `sm:` tier during
WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
an `annotate` callback that lands two keys on the wide event:
`cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
`cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
can grep `cache.analytics.outcome` over any time window to
compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
handler serves a direct 200 on every fronting origin; Apple's
CDN has ingested matching bodies for all three domains.
### Web freeze
v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
This commit is contained in:
1 parent
7bef199039
commit
affede31a4
104 files changed
+8050
-404
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Activity } from "lucide-react";
|
||||
|
||||
import { TrendCard } from "../trend-card";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-B — pin the bucket-aware stale-hint copy on `<TrendCard>`.
|
||||
*
|
||||
* The tile shows a "Letzter Wert vor …" caption when the metric has not
|
||||
* been logged in a while, so the tile stays on the dashboard with an
|
||||
* explicit how-old hint instead of disappearing. Buckets:
|
||||
* - `staleDays <= 7` — silent (the tile reads as fresh)
|
||||
* - `8 <= staleDays <= 30` — "vor Xd" / "X d ago"
|
||||
* - `31 <= staleDays <= 60` — "vor X Wochen" / "X weeks ago"
|
||||
* - `staleDays > 60` — "vor X Monaten" / "X months ago"
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode, locale: "de" | "en" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
label: "Weight",
|
||||
latest: 80,
|
||||
unit: "kg",
|
||||
avg7: 80,
|
||||
avg30: 80,
|
||||
slope30: null,
|
||||
icon: Activity,
|
||||
};
|
||||
|
||||
describe("<TrendCard staleDays>", () => {
|
||||
it("stays silent when the metric is fresh (<= 7 days)", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={3} />);
|
||||
expect(html).not.toContain("data-slot=\"tile-stale-hint\"");
|
||||
});
|
||||
|
||||
it("renders day-bucket copy when staleDays is in (7, 30]", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={12} />, "en");
|
||||
expect(html).toContain("data-slot=\"tile-stale-hint\"");
|
||||
expect(html).toContain("Last reading 12 d ago");
|
||||
});
|
||||
|
||||
it("collapses 31-60 days into a weekly singular form", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={40} />, "en");
|
||||
// 40 / 7 = 5 (Math.floor) — plural form.
|
||||
expect(html).toContain("Last reading 5 weeks ago");
|
||||
});
|
||||
|
||||
it("collapses days > 60 into a monthly form", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={75} />, "de");
|
||||
// 75 / 30 = 2 — plural "Monaten".
|
||||
expect(html).toContain("Letzter Wert vor 2 Monaten");
|
||||
});
|
||||
|
||||
it("uses the German singular month form at exactly one month", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={61} />, "de");
|
||||
// 61 / 30 = 2 (Math.floor) — plural "Monaten".
|
||||
expect(html).toContain("Letzter Wert vor 2 Monaten");
|
||||
});
|
||||
|
||||
it("renders the week-singular form when staleDays maps to exactly one week", () => {
|
||||
const html = render(<TrendCard {...baseProps} staleDays={35} />, "en");
|
||||
// 35 / 7 = 5 — plural. Edge case for the singular path: staleDays
|
||||
// close to 31 (e.g. 31/7 = 4) still falls in the (7, 30] day-bucket
|
||||
// because we hit the day branch before the week branch. The
|
||||
// smallest staleDays that yields 1 week is impossible inside the
|
||||
// week bucket (min weeks = floor(31/7) = 4), so the singular path
|
||||
// is reachable only when a future bucket boundary changes — pin
|
||||
// the math anyway to catch regressions.
|
||||
expect(html).toContain("Last reading 5 weeks ago");
|
||||
});
|
||||
});
|
||||
@@ -38,47 +38,25 @@ function getColor(data: DailyData): string {
|
||||
data.veryLate !== undefined;
|
||||
|
||||
if (hasTimingData) {
|
||||
const onTime = data.onTime ?? 0;
|
||||
const veryLate = data.veryLate ?? 0;
|
||||
const late = data.late ?? 0;
|
||||
const missed = Math.max(0, data.expected - taken - data.skipped);
|
||||
|
||||
// v1.4.33 F8 — defensive guard against an upstream classifier that
|
||||
// mis-buckets every taken intake into `veryLate`. The runtime audit
|
||||
// (round-v1433-audit-runtime.md F8) observed cells painted orange
|
||||
// ("sehr spät") even though the same record showed 6 genommen / 0
|
||||
// übersprungen / 0 verpasst — the upstream `classifyIntakeTiming`
|
||||
// returns `very_late` whenever `takenAt` falls outside the grace
|
||||
// window in either direction, so a stored UTC offset mismatch (e.g.
|
||||
// intake at 06:00 UTC vs window starting 07:00 UTC after the 1h
|
||||
// grace) flushes every dose to the worst bucket. Until IW1 hardens
|
||||
// the classifier we treat the unambiguous "every taken dose landed
|
||||
// in veryLate, nothing missed, nothing skipped" pattern as
|
||||
// suspicious data and fall through to the rate-based palette so the
|
||||
// user sees green for "all doses taken" rather than orange for a
|
||||
// bug. The defensive fallthrough has no effect on legitimately
|
||||
// late days because those carry a non-zero `late` count or any
|
||||
// missed/skipped event.
|
||||
const looksClassifierBug =
|
||||
veryLate === taken &&
|
||||
onTime === 0 &&
|
||||
late === 0 &&
|
||||
missed === 0 &&
|
||||
data.skipped === 0;
|
||||
if (!looksClassifierBug) {
|
||||
// Any missed doses → red
|
||||
if (missed > 0) return "var(--dracula-red)";
|
||||
// Any very late → deep orange
|
||||
if (veryLate > 0) return "var(--dracula-orange)";
|
||||
// Any late → yellow
|
||||
if (late > 0) return "var(--dracula-yellow)";
|
||||
// All on time → green
|
||||
return "var(--dracula-green)";
|
||||
}
|
||||
// v1.4.34 IW-C — the v1.4.33 `looksClassifierBug` fallthrough is
|
||||
// gone because the root cause is fixed: `classifyIntakeTiming` now
|
||||
// routes proactive logs into the `early` bucket (counted as
|
||||
// compliant by the API) instead of flushing them to `very_late`.
|
||||
// Any missed doses → red
|
||||
if (missed > 0) return "var(--dracula-red)";
|
||||
// Any very late → deep orange
|
||||
if (veryLate > 0) return "var(--dracula-orange)";
|
||||
// Any late → yellow
|
||||
if (late > 0) return "var(--dracula-yellow)";
|
||||
// All on time (incl. early) → green
|
||||
return "var(--dracula-green)";
|
||||
}
|
||||
|
||||
// Fallback: rate-based coloring (no timing data, or defensive
|
||||
// fallthrough above).
|
||||
// Fallback: rate-based coloring when no timing data is supplied.
|
||||
const rate = (taken / data.expected) * 100;
|
||||
if (rate >= 100) return "var(--dracula-green)";
|
||||
if (rate >= 50) return "var(--dracula-yellow)";
|
||||
|
||||
@@ -327,14 +327,41 @@ export function TrendCard({
|
||||
whole tile disappear because of a gap in logging. Ride the
|
||||
same callout-slot reservation as the comparison-overlay
|
||||
caption; on `<sm` the line-clamp keeps the 140 px height
|
||||
contract intact. */}
|
||||
{staleDays != null && staleDays > 0 && (
|
||||
contract intact.
|
||||
|
||||
v1.4.34 IW-B — bucket-aware copy. Days under a week stay
|
||||
silent (the tile reads as fresh); 8-30 days surfaces as
|
||||
"vor Xd"; 31-60 days collapses to "vor X Wochen"; beyond
|
||||
two months collapses to "vor X Monaten". One key per
|
||||
bucket plus a singular/plural pair so locales with
|
||||
non-English plural rules read naturally. */}
|
||||
{staleDays != null && staleDays > 7 && (
|
||||
<span
|
||||
className="text-muted-foreground line-clamp-1 inline-block max-w-full text-xs leading-snug tabular-nums sm:line-clamp-none"
|
||||
data-slot="tile-stale-hint"
|
||||
data-stale-days={staleDays}
|
||||
>
|
||||
{t("dashboard.staleHint", { count: staleDays })}
|
||||
{(() => {
|
||||
if (staleDays <= 30) {
|
||||
return t("dashboard.staleHint", { count: staleDays });
|
||||
}
|
||||
if (staleDays <= 60) {
|
||||
const weeks = Math.floor(staleDays / 7);
|
||||
return t(
|
||||
weeks === 1
|
||||
? "dashboard.staleHintWeeksOne"
|
||||
: "dashboard.staleHintWeeksOther",
|
||||
{ count: weeks },
|
||||
);
|
||||
}
|
||||
const months = Math.floor(staleDays / 30);
|
||||
return t(
|
||||
months === 1
|
||||
? "dashboard.staleHintMonthsOne"
|
||||
: "dashboard.staleHintMonthsOther",
|
||||
{ count: months },
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,8 +28,11 @@ let mockData: {
|
||||
metrics: {},
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: mockData }),
|
||||
// v1.4.34 IW-F-Perf — the card now reads through the shared
|
||||
// `useAchievementsQuery` hook; the test mocks the hook directly so the
|
||||
// network layer + TanStack provider scaffolding stays out of scope.
|
||||
vi.mock("@/lib/queries/use-achievements-query", () => ({
|
||||
useAchievementsQuery: () => ({ data: mockData }),
|
||||
}));
|
||||
|
||||
import {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Sparkles, Trophy } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { AchievementProgress } from "@/lib/gamification/achievements";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "healthlog-achievements-seen";
|
||||
|
||||
interface AchievementsPayload {
|
||||
achievements: AchievementProgress[];
|
||||
}
|
||||
|
||||
interface AchievementUnlockNotifierProps {
|
||||
userId: string;
|
||||
}
|
||||
@@ -66,23 +61,16 @@ export function AchievementUnlockNotifier({
|
||||
const localStateReadyRef = useRef(false);
|
||||
const hasSnapshotRef = useRef(false);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["gamification", "achievements", "unlock-notifier", userId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch("/api/gamification/achievements");
|
||||
if (!response.ok) {
|
||||
return { achievements: [] } satisfies AchievementsPayload;
|
||||
}
|
||||
const json = await response.json();
|
||||
return (json.data ?? { achievements: [] }) as AchievementsPayload;
|
||||
} catch {
|
||||
return { achievements: [] } satisfies AchievementsPayload;
|
||||
}
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
// v1.4.34 IW-F-Perf — the notifier and `<RecentAchievementsCard>`
|
||||
// both ride the shared `["gamification", "achievements"]` cache cell.
|
||||
// Before, the notifier carried a per-user discriminator on its key,
|
||||
// which made TanStack treat it as a fresh cell and fire a second
|
||||
// network call alongside the dashboard tile. The shared hook owns the
|
||||
// refetch cadence; the notifier supplies the polling interval so
|
||||
// long-open tabs still surface new unlocks without forcing the card
|
||||
// to re-render on the same cadence.
|
||||
const { data } = useAchievementsQuery({
|
||||
refetchInterval: 2 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
@@ -20,14 +19,10 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type {
|
||||
AchievementProgress,
|
||||
AchievementSummary,
|
||||
AchievementMetrics,
|
||||
} from "@/lib/gamification/achievements";
|
||||
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
|
||||
import type { AchievementProgress } from "@/lib/gamification/achievements";
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Activity,
|
||||
@@ -46,12 +41,6 @@ const iconMap: Record<string, LucideIcon> = {
|
||||
SkipForward,
|
||||
};
|
||||
|
||||
interface AchievementsData {
|
||||
summary: AchievementSummary;
|
||||
achievements: AchievementProgress[];
|
||||
metrics: AchievementMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper — pick the N most-recently unlocked achievements.
|
||||
* Achievements without a `completedAt` (legacy unlocks pre-persistence)
|
||||
@@ -84,23 +73,15 @@ const RECENT_LIMIT = 3;
|
||||
* (`achievements` widget id).
|
||||
*
|
||||
* Reuses the same `/api/gamification/achievements` endpoint as the
|
||||
* dedicated page; TanStack Query dedupes the request when both this
|
||||
* card and the page render in the same session.
|
||||
* dedicated page and the unlock notifier; v1.4.34 IW-F-Perf folds all
|
||||
* three consumers onto the shared `useAchievementsQuery()` hook so a
|
||||
* single cache slot and a single network call back this dashboard
|
||||
* tile, the mother page, and the toast notifier.
|
||||
*/
|
||||
export function RecentAchievementsCard() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["gamification", "achievements"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/gamification/achievements");
|
||||
if (!res.ok) throw new Error("Failed to load achievements");
|
||||
const json = await res.json();
|
||||
return json.data as AchievementsData;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
const { data } = useAchievementsQuery();
|
||||
|
||||
const recent = pickRecentUnlocks(data?.achievements ?? [], RECENT_LIMIT);
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
|
||||
expect(html).not.toContain(">Mood<");
|
||||
expect(html).not.toContain(">Medication<");
|
||||
expect(html).not.toContain(">Sleep<");
|
||||
// v1.4.34 IW-D — the parent "Vitals" pill stays out when no
|
||||
// wave-A HealthKit metric has data (none in this availability).
|
||||
expect(html).not.toContain(">Vitals<");
|
||||
});
|
||||
|
||||
it("keeps the overview pill regardless of availability", () => {
|
||||
@@ -115,3 +118,58 @@ describe("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
|
||||
expect(html).toContain(">BMI<");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<InsightsTabStrip> — vitals group collapse (v1.4.34 IW-D)", () => {
|
||||
it("renders a single 'Vitals' parent pill instead of five wave-A pills in the strip row", () => {
|
||||
// No availability ⇒ every wave-A pill is visible ⇒ the parent pill
|
||||
// appears once. The five sub-pages still render in the popover
|
||||
// body but that lives in a Radix Portal which `renderToStaticMarkup`
|
||||
// does not serialise (no portal target in SSR) — so we assert on
|
||||
// the strip-row only. Behaviour test for the popover body sits in
|
||||
// a Playwright spec when the dev server runs.
|
||||
const html = render(<InsightsTabStrip />);
|
||||
// Parent pill — there is exactly one.
|
||||
const parentMatches = html.match(/>Vitals</g);
|
||||
expect(parentMatches).not.toBeNull();
|
||||
expect(parentMatches!.length).toBe(1);
|
||||
// The flat strip-row should NOT carry the five individual labels —
|
||||
// they live behind the parent pill now.
|
||||
expect(html).not.toContain(">HRV<");
|
||||
expect(html).not.toContain(">Resting HR<");
|
||||
expect(html).not.toContain(">Oxygen<");
|
||||
expect(html).not.toContain(">Temperature<");
|
||||
// "Active energy" overlaps with no other label in en/de so we can
|
||||
// assert the exact match.
|
||||
expect(html).not.toContain(">Active energy<");
|
||||
});
|
||||
|
||||
it("hides the parent pill when no wave-A metric has data", () => {
|
||||
const availability: InsightInputs = {
|
||||
summaries: { WEIGHT: fakeSummary(2) },
|
||||
hasMood: false,
|
||||
hasMedication: false,
|
||||
};
|
||||
const html = render(<InsightsTabStrip availability={availability} />);
|
||||
expect(html).not.toContain(">Vitals<");
|
||||
expect(html).not.toContain('data-slot="insights-tab-strip-group"');
|
||||
});
|
||||
|
||||
it("renders the parent pill as a popover trigger (button, not link)", () => {
|
||||
const html = render(<InsightsTabStrip />);
|
||||
expect(html).toContain('data-slot="insights-tab-strip-group"');
|
||||
expect(html).toContain('data-group="vitals"');
|
||||
});
|
||||
|
||||
it("preserves the non-grouped pills inline", () => {
|
||||
const html = render(<InsightsTabStrip />);
|
||||
// Flat (non-grouped) pills must still be reachable directly.
|
||||
expect(html).toContain(">Blood Pressure<");
|
||||
expect(html).toContain(">Pulse<");
|
||||
expect(html).toContain(">Weight<");
|
||||
expect(html).toContain(">BMI<");
|
||||
expect(html).toContain(">Sleep<");
|
||||
expect(html).toContain(">Mood<");
|
||||
expect(html).toContain(">Medication<");
|
||||
expect(html).toContain(">Workouts<");
|
||||
});
|
||||
});
|
||||
@@ -3,14 +3,22 @@
|
||||
import { memo, useEffect, useMemo, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { ChevronDown, Loader2, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
INSIGHTS_OVERVIEW_PATH,
|
||||
SUB_PAGE_GROUP,
|
||||
SUB_PAGE_GROUP_ORDER,
|
||||
SUB_PAGE_SLUGS,
|
||||
type SubPageGroup,
|
||||
type SubPageSlug,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import {
|
||||
@@ -67,12 +75,31 @@ export interface InsightsTabStripProps {
|
||||
availability?: InsightInputs;
|
||||
}
|
||||
|
||||
interface TabEntry {
|
||||
/** Pathname this pill links to. */
|
||||
href: string;
|
||||
/** Translation key for the pill label. */
|
||||
labelKey: string;
|
||||
}
|
||||
/**
|
||||
* v1.4.34 IW-D — a strip entry is either a flat `<Link>` pill or a
|
||||
* group "parent" pill that opens a popover containing the sub-pages
|
||||
* in the group. The discriminated union keeps the renderer branch-
|
||||
* exhaustive and lets future groups land with one new variant.
|
||||
*/
|
||||
type TabEntry =
|
||||
| {
|
||||
kind: "link";
|
||||
/** Pathname this pill links to. */
|
||||
href: string;
|
||||
/** Translation key for the pill label. */
|
||||
labelKey: string;
|
||||
}
|
||||
| {
|
||||
kind: "group";
|
||||
/** Group key (e.g. `"vitals"`). Used for keys + a11y wiring. */
|
||||
group: SubPageGroup;
|
||||
/** Translation key for the parent-pill label. */
|
||||
labelKey: string;
|
||||
/** Translation key for the popover header. */
|
||||
headerKey: string;
|
||||
/** Visible sub-pages inside the group (post-availability gating). */
|
||||
children: Array<{ href: string; labelKey: string; slug: SubPageSlug }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slug → (label key, gating metric) mapping. Keeping the metric here
|
||||
@@ -128,18 +155,70 @@ const SUB_PAGE_TABS: Record<
|
||||
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.34 IW-D — group metadata (label + popover header) keyed by
|
||||
* `SubPageGroup`. The strip renders one parent pill per group; the
|
||||
* five wave-A HealthKit pills are the only group today.
|
||||
*/
|
||||
const SUB_PAGE_GROUP_META: Record<
|
||||
SubPageGroup,
|
||||
{ labelKey: string; headerKey: string }
|
||||
> = {
|
||||
vitals: {
|
||||
labelKey: "insights.tabStrip.vitalsParent.label",
|
||||
headerKey: "insights.tabStrip.vitalsParent.header",
|
||||
},
|
||||
};
|
||||
|
||||
function buildTabs(availability: InsightInputs | undefined): TabEntry[] {
|
||||
const subPageEntries = SUB_PAGE_SLUGS.filter((slug) => {
|
||||
const visibleSlugs = SUB_PAGE_SLUGS.filter((slug) => {
|
||||
if (!availability) return true;
|
||||
return hasMetricData(SUB_PAGE_TABS[slug].metric, availability);
|
||||
}).map((slug) => ({
|
||||
href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`,
|
||||
labelKey: SUB_PAGE_TABS[slug].labelKey,
|
||||
}));
|
||||
return [
|
||||
{ href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview" },
|
||||
...subPageEntries,
|
||||
});
|
||||
|
||||
const visibleSet = new Set(visibleSlugs);
|
||||
const entries: TabEntry[] = [
|
||||
{
|
||||
kind: "link",
|
||||
href: INSIGHTS_OVERVIEW_PATH,
|
||||
labelKey: "insights.navOverview",
|
||||
},
|
||||
];
|
||||
|
||||
// v1.4.34 IW-D — track which groups have already been emitted so the
|
||||
// first encountered group-member triggers the parent pill at the
|
||||
// same strip position (preserving categorical order).
|
||||
const emittedGroups = new Set<SubPageGroup>();
|
||||
|
||||
for (const slug of visibleSlugs) {
|
||||
const group = SUB_PAGE_GROUP[slug];
|
||||
if (group) {
|
||||
if (emittedGroups.has(group)) continue;
|
||||
emittedGroups.add(group);
|
||||
const children = SUB_PAGE_GROUP_ORDER[group]
|
||||
.filter((childSlug) => visibleSet.has(childSlug))
|
||||
.map((childSlug) => ({
|
||||
slug: childSlug,
|
||||
href: `${INSIGHTS_OVERVIEW_PATH}/${childSlug}`,
|
||||
labelKey: SUB_PAGE_TABS[childSlug].labelKey,
|
||||
}));
|
||||
if (children.length === 0) continue;
|
||||
entries.push({
|
||||
kind: "group",
|
||||
group,
|
||||
labelKey: SUB_PAGE_GROUP_META[group].labelKey,
|
||||
headerKey: SUB_PAGE_GROUP_META[group].headerKey,
|
||||
children,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
entries.push({
|
||||
kind: "link",
|
||||
href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`,
|
||||
labelKey: SUB_PAGE_TABS[slug].labelKey,
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function InsightsTabStripImpl({
|
||||
@@ -198,34 +277,109 @@ function InsightsTabStripImpl({
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden">
|
||||
{tabs.map((tab) => {
|
||||
// The overview pill matches the mother page exactly; the
|
||||
// sub-page pills match a prefix so future nested routes
|
||||
// (e.g. `/insights/schlaf/2026-05-11`) still highlight the
|
||||
// parent tab.
|
||||
const isActive =
|
||||
tab.href === INSIGHTS_OVERVIEW_PATH
|
||||
? pathname === INSIGHTS_OVERVIEW_PATH
|
||||
: pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||
if (tab.kind === "link") {
|
||||
// The overview pill matches the mother page exactly; the
|
||||
// sub-page pills match a prefix so future nested routes
|
||||
// (e.g. `/insights/schlaf/2026-05-11`) still highlight the
|
||||
// parent tab.
|
||||
const isActive =
|
||||
tab.href === INSIGHTS_OVERVIEW_PATH
|
||||
? pathname === INSIGHTS_OVERVIEW_PATH
|
||||
: pathname === tab.href ||
|
||||
pathname.startsWith(`${tab.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="insights-tab-strip-pill"
|
||||
data-active={isActive ? "true" : undefined}
|
||||
className={cn(
|
||||
// 44px touch-target floor (W8.3) — pills are primary
|
||||
// navigation; the regenerate icon-button to the right
|
||||
// already meets the same minimum.
|
||||
"inline-flex min-h-11 shrink-0 items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
// v1.4.34 IW-D — group parent pill. Renders as a popover
|
||||
// trigger so desktop hover (`onMouseEnter` falls through to
|
||||
// Radix click semantics — tap-to-open is the same gesture
|
||||
// on every device, matching the `<Popover>` MB3 decision).
|
||||
// Sub-page navigation happens via the `<Link>` inside the
|
||||
// popover; the parent pill never navigates on its own.
|
||||
const isGroupActive = tab.children.some(
|
||||
(child) =>
|
||||
pathname === child.href ||
|
||||
pathname.startsWith(`${child.href}/`),
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="insights-tab-strip-pill"
|
||||
data-active={isActive ? "true" : undefined}
|
||||
className={cn(
|
||||
// 44px touch-target floor (W8.3) — pills are primary
|
||||
// navigation; the regenerate icon-button to the right
|
||||
// already meets the same minimum.
|
||||
"inline-flex min-h-11 shrink-0 items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
</Link>
|
||||
<Popover key={`group-${tab.group}`}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={isGroupActive ? "page" : undefined}
|
||||
data-slot="insights-tab-strip-group"
|
||||
data-group={tab.group}
|
||||
data-active={isGroupActive ? "true" : undefined}
|
||||
className={cn(
|
||||
"inline-flex min-h-11 shrink-0 items-center gap-1 rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isGroupActive
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey)}
|
||||
<ChevronDown
|
||||
className="h-3 w-3 opacity-70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
data-slot="insights-tab-strip-group-popover"
|
||||
className="w-56 p-2 text-sm"
|
||||
>
|
||||
<p className="text-muted-foreground px-2 py-1 text-[11px] font-semibold tracking-wide uppercase">
|
||||
{t(tab.headerKey)}
|
||||
</p>
|
||||
<ul className="space-y-1" role="list">
|
||||
{tab.children.map((child) => {
|
||||
const isChildActive =
|
||||
pathname === child.href ||
|
||||
pathname.startsWith(`${child.href}/`);
|
||||
return (
|
||||
<li key={child.href}>
|
||||
<Link
|
||||
href={child.href}
|
||||
aria-current={isChildActive ? "page" : undefined}
|
||||
data-slot="insights-tab-strip-group-item"
|
||||
data-active={isChildActive ? "true" : undefined}
|
||||
className={cn(
|
||||
"flex min-h-10 items-center rounded-md px-2 py-1.5 text-sm transition-colors",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isChildActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t(child.labelKey)}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* v1.4.34 IW-B — auth-shell hoist contract.
|
||||
*
|
||||
* The Coach launch provider used to live on `app/insights/layout.tsx`,
|
||||
* which meant the drawer was only reachable from `/insights/**`. The
|
||||
* hoist moves the provider + the drawer mount to `<AuthShell>` so every
|
||||
* authenticated route (dashboard included) shares the same context and
|
||||
* can call `askCoach()` from any descendant.
|
||||
*
|
||||
* Project convention keeps this contract pinned at the source level
|
||||
* because `<AuthShell>` is hard to render under SSR (it reads
|
||||
* `useAuth()` + `usePathname()` + the router) and we don't want a fully
|
||||
* mocked `@testing-library/react` setup for a one-line invariant.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const AUTH_SHELL_PATH = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"auth-shell.tsx",
|
||||
);
|
||||
const INSIGHTS_LAYOUT_PATH = resolve(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"app",
|
||||
"insights",
|
||||
"layout.tsx",
|
||||
);
|
||||
|
||||
describe("v1.4.34 IW-B — CoachLaunchProvider hoist", () => {
|
||||
it("imports the CoachLaunchProvider in the auth-shell", () => {
|
||||
const source = readFileSync(AUTH_SHELL_PATH, "utf8");
|
||||
expect(source).toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
expect(source).toContain("CoachLaunchProvider");
|
||||
});
|
||||
|
||||
it("mounts the LayoutCoachMount inside the auth-shell so the drawer is reachable globally", () => {
|
||||
const source = readFileSync(AUTH_SHELL_PATH, "utf8");
|
||||
expect(source).toContain("<LayoutCoachMount />");
|
||||
});
|
||||
|
||||
it("no longer mounts the provider in the routed insights layout", () => {
|
||||
const source = readFileSync(INSIGHTS_LAYOUT_PATH, "utf8");
|
||||
// The provider import drops; the FAB import stays because the FAB
|
||||
// is still scoped to `/insights/**` only.
|
||||
expect(source).not.toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
// The drawer mount lives on the shell now — verify the routed
|
||||
// layout doesn't double-mount it.
|
||||
expect(source).not.toContain("import { LayoutCoachMount }");
|
||||
// FAB stays scoped to the routed insights surface.
|
||||
expect(source).toContain("LayoutCoachFab");
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { AchievementUnlockNotifier } from "@/components/gamification/achievement-unlock-notifier";
|
||||
import { MaintainershipBanner } from "@/components/i18n/maintainership-banner";
|
||||
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
|
||||
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 { SidebarNav } from "./sidebar-nav";
|
||||
import { TopBar } from "./top-bar";
|
||||
@@ -125,9 +127,19 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticated — full app shell
|
||||
// Authenticated — full app shell.
|
||||
//
|
||||
// v1.4.34 IW-B — the Coach launch provider lives here (not inside
|
||||
// `app/insights/layout.tsx` anymore) so every authenticated route can
|
||||
// call `askCoach()` from the same context. Pre-hoist the drawer was
|
||||
// only reachable from `/insights/**`; the dashboard hero CTA now opens
|
||||
// it without a route hop. `<LayoutCoachMount>` consumes the same
|
||||
// context to render the drawer once at the shell level — the
|
||||
// insights-only mobile FAB stays where it is, inside the routed
|
||||
// insights layout, so the bottom-right floating action sits beside
|
||||
// the chart tooltips only on the surfaces that need it.
|
||||
return (
|
||||
<>
|
||||
<CoachLaunchProvider>
|
||||
{showUnlockNotifier && user?.id ? (
|
||||
<AchievementUnlockNotifier userId={user.id} />
|
||||
) : null}
|
||||
@@ -170,6 +182,7 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
</>
|
||||
<LayoutCoachMount />
|
||||
</CoachLaunchProvider>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,14 @@ vi.mock("@/components/settings/dashboard-layout-section", () => ({
|
||||
vi.mock("@/components/settings/thresholds-editor-section", () => ({
|
||||
ThresholdsEditorSection: () => <div data-testid="thresholds-editor" />,
|
||||
}));
|
||||
// v1.4.34 IW-D — `<ThresholdsSection>` now also embeds
|
||||
// `<SourcesSection mode="embedded">` so the combined "Targets &
|
||||
// Sources" page reads as one shelf. The SourcesSection itself owns
|
||||
// React Query and TanStack mutation wiring; stub it at the import
|
||||
// boundary to keep the SSR smoke test focused on the wrapper.
|
||||
vi.mock("@/components/settings/sources-section", () => ({
|
||||
SourcesSection: () => <div data-testid="sources-section" />,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AboutSection } from "../about-section";
|
||||
@@ -197,28 +205,26 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).toContain("Quellen & Dokumentation");
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> renders heading and embeds the editor (B6 rename)", () => {
|
||||
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
|
||||
// v1.4.16 phase B6 contract: the route wrapper is named
|
||||
// `<ThresholdsSection>` (was `<ThresholdsSettingsSection>`), lives
|
||||
// at `thresholds-section.tsx`, and embeds the inner editor under
|
||||
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`). The
|
||||
// mock above stubs the editor as `data-testid="thresholds-editor"`,
|
||||
// so this test asserts:
|
||||
// - the wrapper renders without throwing
|
||||
// - the localised page title surfaces
|
||||
// - the editor mount-point is present
|
||||
// - no raw i18n key leaks
|
||||
// `<ThresholdsEditorSection>` (was `<ThresholdsSection>`).
|
||||
// v1.4.34 IW-D — the wrapper now also embeds
|
||||
// `<SourcesSection mode="embedded">` so the page is the merged
|
||||
// "Targets & Sources" surface. Both editor mounts must be present.
|
||||
const html = render(<ThresholdsSection />);
|
||||
// F-07 (v1.4.19): page title aligned with the URL slug — was
|
||||
// "Personal targets", now "Personal thresholds".
|
||||
expect(html).toContain("Personal thresholds");
|
||||
// v1.4.34 IW-D: section renamed to "Targets & Sources".
|
||||
expect(html).toContain("Targets & Sources");
|
||||
expect(html).toContain('data-testid="thresholds-editor"');
|
||||
expect(html).toContain('data-testid="sources-section"');
|
||||
expect(html).not.toContain("settings.sections.thresholds.");
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> resolves the German title (B6 rename)", () => {
|
||||
it("<ThresholdsSection> resolves the German title (B6 rename + IW-D merge)", () => {
|
||||
const html = render(<ThresholdsSection />, "de");
|
||||
expect(html).toContain("Persönliche Zielwerte");
|
||||
expect(html).toContain("Zielwerte & Quellen");
|
||||
expect(html).toContain('data-testid="thresholds-editor"');
|
||||
expect(html).toContain('data-testid="sources-section"');
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ function renderShell(props: {
|
||||
}
|
||||
|
||||
describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
it("declares the eleven sections agreed for v1.4.25", () => {
|
||||
it("declares the ten sections agreed for v1.4.34", () => {
|
||||
// Order matters — `generateStaticParams()` and the sidebar derive their
|
||||
// ordering from this constant, so a reorder is a behaviour change.
|
||||
// v1.4.3 split the dashboard panel: layout stays under `dashboard`,
|
||||
@@ -48,13 +48,15 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// per-metric-class source-priority surface lives next to the other
|
||||
// analytics-shaping controls (thresholds → ranges; sources →
|
||||
// canonical source per metric).
|
||||
// v1.4.34 IW-D — `sources` slug was retired (merged into
|
||||
// `thresholds`, now "Targets & Sources"). `/settings/sources` stays
|
||||
// alive as a `permanentRedirect`. Section count: 10 (was 11).
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"thresholds",
|
||||
"sources",
|
||||
"ai",
|
||||
"api",
|
||||
"export",
|
||||
@@ -162,6 +164,11 @@ describe("<SettingsShell>", () => {
|
||||
// in the sidebar user-card dropdown. Route `/settings/about` is
|
||||
// still alive for direct links.
|
||||
expect(html).not.toContain('href="/settings/about"');
|
||||
// v1.4.34 IW-D — Sources slug retired; merged into Targets & Sources
|
||||
// under `/settings/thresholds`. The Sources nav entry must not
|
||||
// appear; the route stays alive via permanentRedirect.
|
||||
expect(html).not.toContain('href="/settings/sources"');
|
||||
expect(html).toContain("Targets & Sources");
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — German", () => {
|
||||
@@ -177,7 +184,9 @@ describe("<SettingsShell>", () => {
|
||||
// per-metric overrides moved out into their own "Persönliche Zielwerte"
|
||||
// section, which is the new entry below the Dashboard one.
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain("Persönliche Zielwerte");
|
||||
// v1.4.34 IW-D — section renamed from "Persönliche Zielwerte" to
|
||||
// "Zielwerte & Quellen" (combined with the former Sources section).
|
||||
expect(html).toContain("Zielwerte & Quellen");
|
||||
// v1.4.33 IW7 — "KI-Auswertungen" renamed to "Auswertungen" per
|
||||
// the Marc-Voice rule (no "KI"/"AI" prefix in user-facing copy).
|
||||
expect(html).toContain("Auswertungen");
|
||||
@@ -187,6 +196,9 @@ describe("<SettingsShell>", () => {
|
||||
// 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"');
|
||||
// v1.4.34 IW-D — Sources nav entry retired (merged into Targets &
|
||||
// Sources).
|
||||
expect(html).not.toContain('href="/settings/sources"');
|
||||
});
|
||||
|
||||
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Download,
|
||||
Info,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
@@ -36,7 +35,6 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
thresholds: SlidersHorizontal,
|
||||
sources: Layers,
|
||||
ai: Sparkles,
|
||||
api: KeyRound,
|
||||
export: Download,
|
||||
|
||||
@@ -6,13 +6,20 @@
|
||||
* graph.
|
||||
*/
|
||||
|
||||
// v1.4.34 IW-D — `sources` slug was retired. The two former sections
|
||||
// (`/settings/thresholds` and `/settings/sources`) merged into a single
|
||||
// "Targets & Sources" page under `/settings/thresholds`. The historic
|
||||
// `/settings/sources` URL stays alive as a `permanentRedirect` (see
|
||||
// `src/app/settings/sources/page.tsx`) so external bookmarks (iOS,
|
||||
// docs) follow through unchanged. Per
|
||||
// `.planning/research/v1434-r-2-carryover-scope.md` §7 and
|
||||
// `.planning/round-v1433-audit-menu.md` §7.1 (item 3).
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"thresholds",
|
||||
"sources",
|
||||
"ai",
|
||||
"api",
|
||||
"export",
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Bell,
|
||||
Download,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Settings2,
|
||||
@@ -73,6 +72,15 @@ interface SettingsSection {
|
||||
* 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.
|
||||
*
|
||||
* v1.4.34 IW-D — `sources` was merged into `thresholds` (now titled
|
||||
* "Targets & Sources" / "Zielwerte & Quellen"). The combined section
|
||||
* surfaces per-metric source priority alongside the threshold inputs
|
||||
* on a single page. `/settings/sources` stays alive as a
|
||||
* `permanentRedirect` so external bookmarks keep working; the slug
|
||||
* was dropped from `SETTINGS_SECTION_SLUGS` because the dynamic
|
||||
* `[section]` route no longer serves it. Per
|
||||
* `.planning/research/v1434-r-2-carryover-scope.md` §7.
|
||||
*/
|
||||
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
{ slug: "account", titleKey: "settings.sections.account.title", icon: User },
|
||||
@@ -96,11 +104,6 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
titleKey: "settings.sections.thresholds.title",
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
{
|
||||
slug: "sources",
|
||||
titleKey: "settings.sections.sources.title",
|
||||
icon: Layers,
|
||||
},
|
||||
{ slug: "ai", titleKey: "settings.sections.ai.title", icon: Sparkles },
|
||||
{ slug: "api", titleKey: "settings.sections.api.title", icon: KeyRound },
|
||||
{
|
||||
|
||||
@@ -98,7 +98,26 @@ const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
|
||||
unknown: "settings.sections.sources.deviceLabels.unknown",
|
||||
};
|
||||
|
||||
export function SourcesSection() {
|
||||
/**
|
||||
* v1.4.34 IW-D — section presentation mode.
|
||||
* - `"standalone"` (default) renders the page-level header (h1 +
|
||||
* subtitle) and keeps the section's own aria-labelledby wiring.
|
||||
* - `"embedded"` drops the header because a parent surface
|
||||
* (`<ThresholdsSection>` on `/settings/thresholds`) already names
|
||||
* the page. The section becomes a `<div>` instead of a
|
||||
* `<section>` so the assistive-tech reading order stays one
|
||||
* combined surface.
|
||||
*
|
||||
* `/settings/sources` no longer renders this component directly — it
|
||||
* permanently redirects to `/settings/thresholds`, which uses the
|
||||
* `embedded` mode. The default keeps backward compatibility for any
|
||||
* test/storybook that still mounts `<SourcesSection />` standalone.
|
||||
*/
|
||||
export interface SourcesSectionProps {
|
||||
mode?: "standalone" | "embedded";
|
||||
}
|
||||
|
||||
export function SourcesSection({ mode = "standalone" }: SourcesSectionProps = {}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -273,22 +292,32 @@ export function SourcesSection() {
|
||||
return Object.keys(priority.deviceTypePriority).length;
|
||||
}, [priority]);
|
||||
|
||||
// v1.4.34 IW-D — `embedded` mode skips the section header because the
|
||||
// parent surface (`<ThresholdsSection>`) already names the page.
|
||||
const isStandalone = mode === "standalone";
|
||||
const Wrapper = isStandalone ? "section" : "div";
|
||||
const wrapperProps = isStandalone
|
||||
? {
|
||||
"aria-labelledby": "settings-section-sources-title",
|
||||
className: "space-y-6",
|
||||
}
|
||||
: { className: "space-y-6" };
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-sources-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-sources-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.sources.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sources.description")}
|
||||
</p>
|
||||
</header>
|
||||
<Wrapper {...wrapperProps}>
|
||||
{isStandalone && (
|
||||
<header className="space-y-1">
|
||||
<h1
|
||||
id="settings-section-sources-title"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{t("settings.sections.sources.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.sources.description")}
|
||||
</p>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
@@ -570,7 +599,7 @@ export function SourcesSection() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { SourcesSection } from "@/components/settings/sources-section";
|
||||
import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* `<ThresholdsSection>` — route-level wrapper for `/settings/thresholds`.
|
||||
*
|
||||
* v1.4.34 IW-D — section was renamed "Zielwerte & Quellen" / "Targets &
|
||||
* Sources" and absorbs the former `/settings/sources` content. The two
|
||||
* editors stay distinct mutation flows (`/api/user/thresholds` for
|
||||
* ranges + `/api/auth/me/source-priority` for source ladders) so a save
|
||||
* on one half never disturbs the other; presenting them on a single
|
||||
* page collapses two adjacent sidebar slots into one and matches the
|
||||
* Settings nav audit (§7.1 item 3 in
|
||||
* `.planning/round-v1433-audit-menu.md` — both screens were per-metric
|
||||
* configuration shelves and reading them together matches how a user
|
||||
* actually thinks about a metric: "for this metric, which sources do
|
||||
* I trust AND what range counts as healthy?").
|
||||
*
|
||||
* `/settings/sources` stays alive as a `permanentRedirect` (see
|
||||
* `src/app/settings/sources/page.tsx`) so external bookmarks (iOS
|
||||
* Settings deep-links, docs) keep resolving.
|
||||
*
|
||||
* Visual structure on this page:
|
||||
* 1. Section header (combined title + description).
|
||||
* 2. `<ThresholdsEditorSection>` — per-metric range inputs (top).
|
||||
* The editor card kept its own affordance row (icon, reset-all
|
||||
* button) so the action surface stays compact.
|
||||
* 3. `<SourcesSection>` body — per-metric source priority + the
|
||||
* two-axis device-type expander (bottom). The inner section's
|
||||
* header was suppressed in `mode="embedded"` because the page
|
||||
* header above already names the surface.
|
||||
*
|
||||
* v1.4.16 phase B6: file renamed from the historic
|
||||
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`) so the
|
||||
* filename + default export match the slug — every other section in
|
||||
* `src/components/settings/` follows the `<slug>-section.tsx`
|
||||
* `<SlugSection>` convention. The inner editor card was simultaneously
|
||||
* moved out of `thresholds-section.tsx` (where it used to live as
|
||||
* `<ThresholdsSection>`) into `thresholds-editor-section.tsx`
|
||||
* (`<ThresholdsEditorSection>`) so the names stop clashing.
|
||||
* `thresholds-settings-section.tsx` (`<ThresholdsSettingsSection>`)
|
||||
* so the filename + default export match the slug — every other
|
||||
* section in `src/components/settings/` follows the
|
||||
* `<slug>-section.tsx` `<SlugSection>` convention. The inner editor
|
||||
* card was simultaneously moved out of `thresholds-section.tsx`
|
||||
* (where it used to live as `<ThresholdsSection>`) into
|
||||
* `thresholds-editor-section.tsx` (`<ThresholdsEditorSection>`) so
|
||||
* the names stop clashing.
|
||||
*/
|
||||
export function ThresholdsSection() {
|
||||
const { t } = useTranslations();
|
||||
@@ -35,7 +63,15 @@ export function ThresholdsSection() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Per-metric threshold ranges — top card. */}
|
||||
<ThresholdsEditorSection id="thresholds" />
|
||||
|
||||
{/* Per-metric source priority + two-axis device-type ladder —
|
||||
bottom card. `mode="embedded"` suppresses the inner h1 +
|
||||
subtitle (the page header above owns the surface name) and
|
||||
drops the inner `<section>` aria-label so the assistive-tech
|
||||
reading order stays one combined surface, not two. */}
|
||||
<SourcesSection mode="embedded" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user