mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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.
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
// `<SettingsShell>` reads the active route from `usePathname()`. We stub
|
|
// next/navigation so the SSR test render works without an App-Router
|
|
// runtime. The mock reads `mockPathnameRef.value` at call-time (not at
|
|
// module-load) so each test can mutate the ref before rendering and the
|
|
// shell will see the updated path.
|
|
const mockPathnameRef = { value: "/settings/account" };
|
|
vi.mock("next/navigation", () => ({
|
|
usePathname: () => mockPathnameRef.value,
|
|
}));
|
|
|
|
import { I18nProvider } from "@/lib/i18n/context";
|
|
import {
|
|
SETTINGS_SECTION_SLUGS,
|
|
SETTINGS_SECTIONS,
|
|
SettingsShell,
|
|
isSettingsSectionSlug,
|
|
} from "../settings-shell";
|
|
|
|
function renderShell(props: {
|
|
active?: (typeof SETTINGS_SECTION_SLUGS)[number];
|
|
pathname?: string;
|
|
locale?: "en" | "de";
|
|
}) {
|
|
mockPathnameRef.value = props.pathname ?? "/settings/account";
|
|
return renderToStaticMarkup(
|
|
<I18nProvider initialLocale={props.locale ?? "en"}>
|
|
<SettingsShell active={props.active}>
|
|
<div>section body</div>
|
|
</SettingsShell>
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
describe("SETTINGS_SECTION_SLUGS", () => {
|
|
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`,
|
|
// per-metric overrides moved to their own `thresholds` slug so it's
|
|
// a top-level entry in the settings nav.
|
|
// v1.4.16 phase B7 added the consolidated `export` section between
|
|
// `api` and `advanced` so every "give me my data out" path lives in
|
|
// one place.
|
|
// v1.4.25 W5e added `sources` between `thresholds` and `ai` so the
|
|
// 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",
|
|
"ai",
|
|
"api",
|
|
"export",
|
|
"advanced",
|
|
"about",
|
|
]);
|
|
});
|
|
|
|
it("`isSettingsSectionSlug` accepts known slugs and rejects others", () => {
|
|
for (const slug of SETTINGS_SECTION_SLUGS) {
|
|
expect(isSettingsSectionSlug(slug)).toBe(true);
|
|
}
|
|
expect(isSettingsSectionSlug("nope")).toBe(false);
|
|
expect(isSettingsSectionSlug("")).toBe(false);
|
|
expect(isSettingsSectionSlug("Account")).toBe(false); // case-sensitive
|
|
});
|
|
|
|
it("each section has a non-empty title key under settings.sections.<slug>", () => {
|
|
for (const section of SETTINGS_SECTIONS) {
|
|
expect(section.titleKey).toBe(`settings.sections.${section.slug}.title`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("<SettingsShell>", () => {
|
|
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
|
|
const html = renderShell({ active: "account" });
|
|
// v1.4.33 IW7 — the `about` slug is alive as a route
|
|
// (`/settings/about` still resolves and `generateStaticParams()`
|
|
// still emits the page) but it is hidden from the in-shell nav.
|
|
// The user-card dropdown owns the link now. Iterate the visible
|
|
// sections list, not the slug list, when asserting on rendered
|
|
// markup.
|
|
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
|
|
for (const slug of navigableSlugs) {
|
|
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
|
|
// Two renders — mobile strip + desktop sidebar — guarantee the link
|
|
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
|
|
// but the markup is always present so it's keyboard-discoverable
|
|
// before media queries resolve.
|
|
expect(matches?.length ?? 0).toBe(2);
|
|
}
|
|
// The hidden `about` slug must NOT appear in the in-shell nav.
|
|
expect(html).not.toContain('href="/settings/about"');
|
|
});
|
|
|
|
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
|
|
const html = renderShell({ active: "account" });
|
|
// Spot-check a couple of slugs that have historic anchor aliases (e.g.
|
|
// `/settings#withings` → `/settings/integrations`) — they must NOT
|
|
// sneak back in.
|
|
expect(html).toContain('href="/settings/integrations"');
|
|
expect(html).toContain('href="/settings/ai"');
|
|
expect(html).not.toContain('href="/settings#');
|
|
expect(html).not.toContain('href="/settings"');
|
|
});
|
|
|
|
it('marks the active section with aria-current="page" (and only that one)', () => {
|
|
const html = renderShell({ active: "notifications" });
|
|
// Both layouts emit the active link, so two `aria-current="page"`.
|
|
const occurrences = html.match(/aria-current="page"/g);
|
|
expect(occurrences?.length ?? 0).toBe(2);
|
|
|
|
// And the active link is on the notifications href. Attribute order
|
|
// in React SSR is alphabetic, so `aria-current` precedes `href` —
|
|
// the regex deliberately doesn't pin order to stay robust against
|
|
// future React renderer changes.
|
|
const activeLinkRegex =
|
|
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
|
|
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
|
});
|
|
|
|
it("derives the active slug from the current pathname when `active` is omitted", () => {
|
|
const html = renderShell({ pathname: "/settings/api" });
|
|
const activeLinkRegex =
|
|
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
|
|
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
|
});
|
|
|
|
it("falls back to `account` when the pathname doesn't match a known slug", () => {
|
|
const html = renderShell({ pathname: "/settings/totally-bogus" });
|
|
const activeLinkRegex =
|
|
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
|
|
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
|
|
});
|
|
|
|
it("resolves every section title via the i18n provider — English", () => {
|
|
const html = renderShell({ active: "account", locale: "en" });
|
|
expect(html).toContain("Account");
|
|
expect(html).toContain("Integrations");
|
|
// v1.4.33 IW7 — section renamed from "Notifications" to
|
|
// "Notification channels" so it doesn't collide with the inbox at
|
|
// `/notifications` ("Notification Center").
|
|
expect(html).toContain("Notification channels");
|
|
expect(html).toContain("Dashboard");
|
|
expect(html).toContain("Insights");
|
|
// The ampersand is HTML-escaped by React SSR — assert on the encoded
|
|
// form so we don't accidentally match a parser that double-escapes.
|
|
expect(html).toContain("API & Tokens");
|
|
// v1.4.16 phase B7: the consolidated Export section is a top-level
|
|
// entry in the sidebar; the link must be present in both locales.
|
|
expect(html).toContain('href="/settings/export"');
|
|
expect(html).toContain("Advanced");
|
|
// v1.4.33 IW7 — About is no longer in the settings nav; it lives
|
|
// in the sidebar user-card dropdown. Route `/settings/about` is
|
|
// still alive for direct links.
|
|
expect(html).not.toContain('href="/settings/about"');
|
|
// 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", () => {
|
|
const html = renderShell({ active: "account", locale: "de" });
|
|
expect(html).toContain("Konto");
|
|
expect(html).toContain("Integrationen");
|
|
// v1.4.33 IW7 — section renamed from "Benachrichtigungen" to
|
|
// "Benachrichtigungs-Kanäle" so it doesn't collide with the inbox at
|
|
// `/notifications` ("Benachrichtigungs-Center").
|
|
expect(html).toContain("Benachrichtigungs-Kanäle");
|
|
// v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now
|
|
// "Dashboard" (matching the term users see in the main nav). The
|
|
// 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");
|
|
// 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");
|
|
// API & Tokens is identical in both locales (proper noun + ampersand)
|
|
expect(html).toContain("API & Tokens");
|
|
expect(html).toContain("Erweitert");
|
|
// v1.4.33 IW7 — "Über" (About) section removed from the in-shell
|
|
// nav, folded into the sidebar user-card dropdown ("Über HealthLog").
|
|
expect(html).not.toContain('href="/settings/about"');
|
|
// 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", () => {
|
|
const html = renderShell({ active: "account", locale: "en" });
|
|
expect(html).not.toContain("settings.sections.");
|
|
});
|
|
|
|
it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
|
|
// The 10-section strip is wider than 393 CSS px, so without
|
|
// `no-scrollbar` Chromium/WebKit paints an always-on horizontal
|
|
// scrollbar at the top of every settings page. The class is
|
|
// defined in `globals.css` and combines `scrollbar-width: none`
|
|
// (Firefox) with `::-webkit-scrollbar { display: none }` (everyone
|
|
// else) — scroll behaviour is preserved.
|
|
const html = renderShell({ active: "account" });
|
|
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>/);
|
|
expect(nav).not.toBeNull();
|
|
expect(nav![0]).toMatch(/\bno-scrollbar\b/);
|
|
expect(nav![0]).toMatch(/\boverflow-x-auto\b/);
|
|
});
|
|
});
|