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:
Marc-André Bombeck authored and GitHub committed 2026-05-16 21:25:26 +02:00
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");
});
});
+13 -35
View File
@@ -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)";
+30 -3
View File
@@ -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<");
});
});
+196 -42
View File
@@ -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");
});
});
+16 -3
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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,
+8 -1
View File
@@ -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",
+9 -6
View File
@@ -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 },
{
+46 -17
View File
@@ -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>
);
}
+43 -7
View File
@@ -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>
);
}