mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #447 from MBombeck/release/v1.28.3
release/v1.28.3 — consistent spacing and alignment
This commit is contained in:
27 files changed
+276
-195
No files matched your search
@@ -2,6 +2,22 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.3] — 2026-07-09 — Consistent spacing and alignment
|
||||
|
||||
A consistency pass across the app, driven by a layout audit. Insight cards now
|
||||
share one left edge — headings and body text line up instead of drifting from
|
||||
card to card (worst on mobile). Settings and admin pages no longer scroll past
|
||||
their content into empty space. On mobile, Settings and Notifications live only
|
||||
in the account menu instead of being duplicated in the overflow menu. The
|
||||
dashboard greeting keeps its action beside the text rather than pushing it onto
|
||||
its own line. Preventive-care, cycle, and onboarding cards adopt the shared card
|
||||
anatomy. The spacing and alignment rules behind these are written down so they
|
||||
hold going forward.
|
||||
|
||||
Also folds in the document PDF rasterization runtime: the image renderer now
|
||||
loads in the container, so a scanned PDF is read on an image-only AI provider
|
||||
(previously it quietly fell back to text-only).
|
||||
|
||||
## [1.28.2] — 2026-07-09 — Automatic AI document reading (opt-in)
|
||||
|
||||
One opt-in in AI settings: read every uploaded document automatically with your
|
||||
|
||||
+14
@@ -81,6 +81,20 @@ COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
|
||||
# native binary's package into the standalone tree but NOT the pnpm symlink that
|
||||
# @napi-rs/canvas's loader resolves it through, so at runtime it fails with
|
||||
# "Failed to load native binding" and rasterization silently degrades. Copy the
|
||||
# prebuilt .node into the canvas package dir itself so the loader's built-in
|
||||
# local-file fallback (`require('./skia.<triple>.node')`) resolves it. Per-arch
|
||||
# buildx installs only the matching musl triple, so the find is arch-correct.
|
||||
RUN CANVAS_DIR="$(find ./node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \
|
||||
NODE_BIN="$(find ./node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \
|
||||
if [ -n "$CANVAS_DIR" ] && [ -n "$NODE_BIN" ]; then \
|
||||
cp "$NODE_BIN" "$CANVAS_DIR/" && chown nextjs:nodejs "$CANVAS_DIR/$(basename "$NODE_BIN")" && \
|
||||
echo "canvas native binding staged: $(basename "$NODE_BIN") -> $CANVAS_DIR"; \
|
||||
else echo "WARN: canvas native binding not found; PDF rasterization will degrade to local text"; fi
|
||||
|
||||
# Copy Prisma for migrations (schema, migration SQL, config, engines)
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.2
|
||||
version: 1.28.3
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -13,18 +13,28 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* the shell's own budget and reopens the dark-band-below-the-last-card
|
||||
* bug.
|
||||
*
|
||||
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest content
|
||||
* element + the shell-owned padding + tolerance. Runs across the
|
||||
* settings routes (short hub, long sortable-list subpages, long form
|
||||
* section) at desktop AND phone-shaped viewports, because the two shell
|
||||
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest real
|
||||
* content element + the shell-owned padding + tolerance. "Real content"
|
||||
* is measured against each element's CONTENT-box bottom, not its padding-
|
||||
* box: a sub-shell column that re-declares its own bottom gutter (`pb-*`)
|
||||
* no longer folds that gutter into `contentBottom`, so a redundant reserve
|
||||
* FAILS the assertion instead of being absorbed by it. Runs across both
|
||||
* two-column sub-shells — Settings AND Admin (they share the grid-floor
|
||||
* source) — at desktop AND phone-shaped viewports, because the two shell
|
||||
* paddings differ per breakpoint.
|
||||
*/
|
||||
const ROUTES = [
|
||||
// Settings sub-shell — short hub, long sortable-list subpages, long form.
|
||||
"/settings/layout",
|
||||
"/settings/layout/dashboard",
|
||||
"/settings/layout/insights",
|
||||
"/settings/account",
|
||||
"/settings/notifications",
|
||||
// Admin sub-shell — short overview + a longer list page. Guards that the
|
||||
// pre-#154 column reserve stays retired on every admin breakpoint.
|
||||
"/admin",
|
||||
"/admin/login-overview",
|
||||
"/admin/system-status",
|
||||
] as const;
|
||||
|
||||
const VIEWPORTS = [
|
||||
@@ -32,7 +42,7 @@ const VIEWPORTS = [
|
||||
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
|
||||
] as const;
|
||||
|
||||
test.describe("settings vertical over-scroll guard", () => {
|
||||
test.describe("settings + admin vertical over-scroll guard", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
@@ -56,11 +66,17 @@ test.describe("settings vertical over-scroll guard", () => {
|
||||
if (!main) return null;
|
||||
const wrapper = main.firstElementChild as HTMLElement | null;
|
||||
if (!wrapper) return null;
|
||||
// Lowest content edge: max bottom over the wrapper's visible,
|
||||
// non-fixed descendants, in the scroll container's coordinate
|
||||
// space. Elements inside nested scroll containers are clipped
|
||||
// by their own overflow and never add page scroll height, so
|
||||
// skip anything whose scrollable ancestor is not `main`.
|
||||
// Lowest content edge: max CONTENT-box bottom over the wrapper's
|
||||
// visible, non-fixed descendants, in the scroll container's
|
||||
// coordinate space. Using the content box (padding-box bottom
|
||||
// minus the element's own bottom padding + border) means a
|
||||
// layout column's own `pb-*` gutter is NOT counted — its last
|
||||
// real child (a card) is still measured through the column's
|
||||
// content box, so a redundant sub-shell bottom gutter shows up
|
||||
// as pure over-scroll instead of inflating the allowed budget.
|
||||
// Elements inside nested scroll containers are clipped by their
|
||||
// own overflow and never add page scroll height, so skip
|
||||
// anything whose scrollable ancestor is not `main`.
|
||||
let maxBottom = 0;
|
||||
const mainTop = main.getBoundingClientRect().top;
|
||||
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
|
||||
@@ -84,7 +100,10 @@ test.describe("settings vertical over-scroll guard", () => {
|
||||
p = p.parentElement;
|
||||
}
|
||||
if (inner) continue;
|
||||
const bottom = rect.bottom + main.scrollTop - mainTop;
|
||||
const padBottom = parseFloat(cs.paddingBottom) || 0;
|
||||
const borderBottom = parseFloat(cs.borderBottomWidth) || 0;
|
||||
const bottom =
|
||||
rect.bottom - padBottom - borderBottom + main.scrollTop - mainTop;
|
||||
if (bottom > maxBottom) maxBottom = bottom;
|
||||
}
|
||||
return {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.2",
|
||||
"version": "1.28.3",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.2";
|
||||
/* @sw-version-fallback */ "v1.28.3";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
|
||||
@@ -304,7 +305,12 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
col 1, cards in row 2 / col 2. The nav's first item starts the same
|
||||
grid row as the first card, so they align by construction across
|
||||
locales and however the heading wraps — no fixed-height spacer. */}
|
||||
<div className="grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]",
|
||||
SUB_SHELL_GRID_FLOOR,
|
||||
)}
|
||||
>
|
||||
{/* Heading — desktop only (mobile renders it above the strip). */}
|
||||
<div className="hidden md:col-start-2 md:row-start-1 md:block">
|
||||
{headingBlock}
|
||||
@@ -366,12 +372,14 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
{/* Main column (cards) — row 2 / col 2. Same
|
||||
`min-h-[calc(100dvh-12rem)]` reserve as `<SettingsShell>`
|
||||
so navigating between admin sub-pages (e.g. system-status
|
||||
→ login-overview) does not jump the page height while the
|
||||
new section is still fetching. */}
|
||||
<main className="min-h-[calc(100dvh-12rem)] min-w-0 md:col-start-2 md:row-start-2">
|
||||
{/* Main column (cards) — row 2 / col 2, the grid's `1fr` row, so it
|
||||
stretches to fill the shared `SUB_SHELL_GRID_FLOOR` on the grid
|
||||
(matches `<SettingsShell>`) without over-shooting. The loading-jump
|
||||
floor lives on the GRID, not here: a column-level
|
||||
`min-h-[calc(100dvh-12rem)]` reserve (the pre-#154 shape this used
|
||||
to carry) over-reserves on short sections and on mobile, scrolling
|
||||
the page into a dark band below the last card. */}
|
||||
<main className="min-w-0 md:col-start-2 md:row-start-2">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function CycleInsightSummaryCard() {
|
||||
data-slot="cycle-insight-summary"
|
||||
data-phase={phase ?? "none"}
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
|
||||
className="wellness-tile wellness-tile-rise rounded-xl p-4 md:p-6"
|
||||
>
|
||||
{/* Zone 1 — the current phase / cycle-day read. */}
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -109,7 +109,7 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
// per-phase hue arc inside the ring is the only saturated thing.
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className={cn(
|
||||
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-4 focus-visible:ring-2 focus-visible:outline-none md:p-6",
|
||||
play && "wellness-tile-rise",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -219,13 +219,13 @@ export function CycleView() {
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl px-5 py-5",
|
||||
"wellness-tile flex flex-col items-center gap-3 rounded-xl p-4 md:p-6",
|
||||
play && "wellness-tile-rise",
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-[220px] items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : calendarError ? (
|
||||
<div className="flex h-[220px] flex-col items-center justify-center gap-3 text-center">
|
||||
@@ -347,7 +347,7 @@ export function CycleView() {
|
||||
className="text-muted-foreground bg-muted/40 mb-3 flex items-start gap-2 rounded-lg p-3 text-sm"
|
||||
>
|
||||
<Sparkles
|
||||
className="text-primary mt-0.5 h-4 w-4 shrink-0"
|
||||
className="text-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{t("cycle.calendar.learning")}</span>
|
||||
@@ -429,7 +429,7 @@ export function CycleView() {
|
||||
</Card>
|
||||
) : profileQuery.isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : profileQuery.data ? (
|
||||
<CycleSettings
|
||||
|
||||
@@ -119,7 +119,7 @@ export function PhaseEducationCard({
|
||||
aria-label={t("cycle.phaseEducation.title")}
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className={cn(
|
||||
"wellness-tile rounded-xl px-5 py-5",
|
||||
"wellness-tile rounded-xl p-4 md:p-6",
|
||||
animate && "wellness-tile-rise",
|
||||
className,
|
||||
)}
|
||||
@@ -127,8 +127,7 @@ export function PhaseEducationCard({
|
||||
{/* Zone 1 — eyebrow + phase name with a hue dot (never colour-only). */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={{ color: hue }}
|
||||
className="text-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
|
||||
@@ -386,7 +386,7 @@ export function DashboardHero({
|
||||
drifting to the vertical centre of the taller ring column. */}
|
||||
<div className="flex h-full flex-col gap-4 md:gap-2">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
{/* Greeting leads the typographic hierarchy: larger + heavier
|
||||
than the verdict so the personalised line reads first.
|
||||
The verdict keeps its medium weight one step down — still
|
||||
@@ -399,13 +399,17 @@ export function DashboardHero({
|
||||
>
|
||||
{welcomeText}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{/* Text left, action right, one row — the PageHeader/TileHeader
|
||||
inline-action pattern. The sentence takes the available width and
|
||||
wraps internally (`min-w-0 flex-1`); the button never wraps to its
|
||||
own line (`shrink-0`), so the action stays beside the text. */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{sentenceHref ? (
|
||||
<Link
|
||||
href={sentenceHref}
|
||||
data-slot="dashboard-hero-verdict"
|
||||
data-verdict-variant={verdict.variant}
|
||||
className="text-foreground/90 hover:text-foreground text-base font-medium transition-colors"
|
||||
className="text-foreground/90 hover:text-foreground min-w-0 flex-1 text-base font-medium transition-colors"
|
||||
>
|
||||
{sentence}
|
||||
</Link>
|
||||
@@ -413,7 +417,7 @@ export function DashboardHero({
|
||||
<p
|
||||
data-slot="dashboard-hero-verdict"
|
||||
data-verdict-variant={verdict.variant}
|
||||
className="text-foreground/90 text-base font-medium"
|
||||
className="text-foreground/90 min-w-0 flex-1 text-base font-medium"
|
||||
>
|
||||
{sentence}
|
||||
</p>
|
||||
@@ -424,7 +428,7 @@ export function DashboardHero({
|
||||
asChild
|
||||
size="default"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="dashboard-hero-cta"
|
||||
>
|
||||
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
|
||||
@@ -432,7 +436,7 @@ export function DashboardHero({
|
||||
) : (
|
||||
<Button
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="dashboard-hero-cta"
|
||||
onClick={() => onQuickEntry(cta.target)}
|
||||
>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
DerivedCoverage,
|
||||
DerivedProvenance,
|
||||
} from "@/lib/insights/derived/types";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ScoreRing } from "./score-ring";
|
||||
import { CoverageMeter } from "./coverage-meter";
|
||||
import {
|
||||
@@ -126,20 +127,19 @@ export function ScoreAnatomyView({
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"flex flex-col gap-5 rounded-xl border p-5",
|
||||
"flex flex-col gap-4 rounded-xl border p-4 md:p-6",
|
||||
hue ? "wellness-detail-card" : "border-border bg-card",
|
||||
className,
|
||||
)}
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
>
|
||||
{/* Hero: title + ring + caption */}
|
||||
{/* Left-aligned canonical tile header — sits on the same reading edge as
|
||||
the sibling assessment card that stacks beneath it on a score
|
||||
sub-page, instead of the old centred uppercase title that floated
|
||||
toward the middle and shared no left line with any card. */}
|
||||
<TileHeader title={title} />
|
||||
{/* Hero: ring + caption — a centred identity block below the header. */}
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<h2
|
||||
data-slot="score-anatomy-title"
|
||||
className="text-foreground text-sm font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{/* v1.15.12 F2 — vertical breathing room so the ring's bloom/glow is
|
||||
never clipped top/bottom by the hero container. */}
|
||||
<div className="py-2">
|
||||
@@ -176,7 +176,7 @@ export function ScoreAnatomyView({
|
||||
/>
|
||||
) : (
|
||||
<div data-slot="score-anatomy-contributors" className="space-y-3">
|
||||
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
|
||||
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{t("insights.derived.anatomy.contributorsLabel")}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
sentimentColorClass,
|
||||
type TrendDirectionSentiment,
|
||||
} from "@/lib/insights/trend-sentiment";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
|
||||
/**
|
||||
* v1.10.0 — the Apple-Health-Highlights grid tile.
|
||||
@@ -138,27 +139,26 @@ export function SparklineDeltaTile({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Header row matches the canonical TileHeader language: a leading
|
||||
foreground icon + a foreground `CardTitle`-weight heading — never
|
||||
the old muted-uppercase caption. Kept on one line for the dense
|
||||
grid; provenance trails to the right edge. */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate text-base leading-none font-semibold whitespace-nowrap"
|
||||
data-slot="sparkline-delta-tile-label"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{provenance ? (
|
||||
<span
|
||||
data-slot="sparkline-delta-tile-provenance"
|
||||
className="ml-auto shrink-0"
|
||||
>
|
||||
{provenance}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{/* The canonical tile-header primitive (was a hand-rolled copy of it):
|
||||
leading foreground icon + foreground `CardTitle` heading, provenance
|
||||
in the `right` slot. Routing through the primitive keeps this dense
|
||||
grid tile from drifting out of the header contract. */}
|
||||
<TileHeader
|
||||
icon={Icon}
|
||||
title={label}
|
||||
className="min-w-0"
|
||||
titleClassName="min-w-0 truncate whitespace-nowrap"
|
||||
right={
|
||||
provenance ? (
|
||||
<span
|
||||
data-slot="sparkline-delta-tile-provenance"
|
||||
className="shrink-0"
|
||||
>
|
||||
{provenance}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="mt-2 flex flex-wrap items-baseline gap-x-1.5"
|
||||
|
||||
@@ -133,7 +133,7 @@ function RingTile({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-4 focus-visible:ring-2 focus-visible:outline-none md:p-6",
|
||||
animate && "wellness-tile-rise",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -6,16 +6,11 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
|
||||
/**
|
||||
* v1.17.1 — calm, data-gated readout for a device-native score that has no
|
||||
@@ -100,26 +95,31 @@ export function DeviceScoreTile({
|
||||
className={className}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<Icon
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{title}</span>
|
||||
</CardTitle>
|
||||
{latest != null ? (
|
||||
<CardAction
|
||||
data-slot="device-score-latest"
|
||||
className="text-foreground self-baseline text-lg font-semibold tabular-nums"
|
||||
>
|
||||
{fmt(latest)}
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
||||
{unit}
|
||||
{/* Canonical compact tile header — foreground `h-4` icon + `text-sm`
|
||||
title on the shared inset; the latest reading rides the `right`
|
||||
slot so it stays pinned to the row's right edge. */}
|
||||
<TileHeader
|
||||
size="sm"
|
||||
icon={Icon}
|
||||
title={title}
|
||||
className="min-w-0"
|
||||
titleClassName="min-w-0 truncate"
|
||||
right={
|
||||
latest != null ? (
|
||||
<span
|
||||
data-slot="device-score-latest"
|
||||
className="text-foreground text-lg font-semibold tabular-nums"
|
||||
>
|
||||
{fmt(latest)}
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground ml-1 text-xs font-normal">
|
||||
{unit}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</CardAction>
|
||||
) : null}
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{isLearning ? (
|
||||
|
||||
@@ -4,15 +4,10 @@ import { ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { LearnMoreLink } from "@/components/ui/learn-more-link";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
|
||||
/**
|
||||
* v1.19.2 — calm readout for Oura's daily resilience level.
|
||||
@@ -96,21 +91,25 @@ export function ResilienceTile() {
|
||||
return (
|
||||
<Card data-slot="resilience-tile" data-metric="RESILIENCE">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<ShieldCheck
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{t("measurements.typeResilience")}</span>
|
||||
</CardTitle>
|
||||
{bandKey != null ? (
|
||||
<CardAction
|
||||
data-slot="resilience-band"
|
||||
className="text-foreground self-baseline text-lg font-semibold"
|
||||
>
|
||||
{t(bandKey)}
|
||||
</CardAction>
|
||||
) : null}
|
||||
{/* Canonical compact tile header — foreground `h-4` icon + `text-sm`
|
||||
title; the current band rides the `right` slot. */}
|
||||
<TileHeader
|
||||
size="sm"
|
||||
icon={ShieldCheck}
|
||||
title={t("measurements.typeResilience")}
|
||||
className="min-w-0"
|
||||
titleClassName="min-w-0 truncate"
|
||||
right={
|
||||
bandKey != null ? (
|
||||
<span
|
||||
data-slot="resilience-band"
|
||||
className="text-foreground text-lg font-semibold"
|
||||
>
|
||||
{t(bandKey)}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-foreground text-sm leading-relaxed">
|
||||
|
||||
@@ -218,10 +218,13 @@ export function SubPageShell({
|
||||
<div data-slot="insights-subpage" className="space-y-3 md:space-y-4">
|
||||
{/* v1.8.7.1 — back-nav leads the page, above the heading. */}
|
||||
{backLink}
|
||||
{/* The heading + explainer indent to the cards' inner text edge
|
||||
(`px-6`, the Card content padding), so the page head reads as
|
||||
part of the card column instead of hanging outside it. */}
|
||||
<header className="space-y-1.5 px-6">
|
||||
{/* The heading + explainer indent to the cards' inner text edge so
|
||||
the page head reads as part of the card column instead of hanging
|
||||
outside it. Track the card slot inset EXACTLY (`px-4 md:px-6`) —
|
||||
never a flat `px-6`, which sat 8 px right of every card body on
|
||||
mobile and reopened the heading-vs-body step (insights left-edge
|
||||
rule: one reading edge across breakpoints). */}
|
||||
<header className="space-y-1.5 px-4 md:px-6">
|
||||
{/* v1.8.6 — the heading row pins the Coach icon top-right while the
|
||||
title / nudge / badge wrap together on the left.
|
||||
|
||||
|
||||
@@ -160,8 +160,8 @@ describe("visibleNavDestinations module gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => {
|
||||
describe("mobileMoreHubDestinations — the F-1 mobile invariant (feature-only hub, N-2)", () => {
|
||||
it("is exactly the visible feature destinations minus the primary slots (no utility tail)", () => {
|
||||
const opts = {
|
||||
modules: { cycle: false } as const,
|
||||
};
|
||||
@@ -170,9 +170,9 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
const expectedFeatures = visibleNavDestinations(opts.modules)
|
||||
.map((d) => d.href)
|
||||
.filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href));
|
||||
const expectedTail = visibleUtilityDestinations().map((d) => d.href);
|
||||
|
||||
expect(hub).toEqual([...expectedFeatures, ...expectedTail]);
|
||||
// Feature destinations only — the account utilities never ride the hub.
|
||||
expect(hub).toEqual(expectedFeatures);
|
||||
});
|
||||
|
||||
it("never contains a primary slot (Home / Meds / Insights)", () => {
|
||||
@@ -184,7 +184,7 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the feature destinations and the utility tail reachable in the hub", () => {
|
||||
it("keeps the feature destinations reachable but excludes the account utilities", () => {
|
||||
const hub = mobileMoreHubDestinations({
|
||||
modules: { cycle: false },
|
||||
}).map((d) => d.href);
|
||||
@@ -192,9 +192,10 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
|
||||
expect(hub).toContain("/measurements");
|
||||
expect(hub).toContain("/mood");
|
||||
expect(hub).toContain("/checkups");
|
||||
// The shared utility tail rides at the end.
|
||||
expect(hub).toContain("/settings/account");
|
||||
expect(hub).toContain("/notifications");
|
||||
// Settings + Notifications are account utilities — they live ONLY in the
|
||||
// user/avatar menu, never duplicated into the mobile More hub.
|
||||
expect(hub).not.toContain("/settings/account");
|
||||
expect(hub).not.toContain("/notifications");
|
||||
});
|
||||
|
||||
it("gates Cycle by the module map, same as the sidebar", () => {
|
||||
|
||||
@@ -300,26 +300,22 @@ export interface MobileMoreHubEntry {
|
||||
|
||||
/**
|
||||
* The ordered "More" hub for the mobile bottom-nav: every visible feature
|
||||
* destination that isn't a primary slot, in model order, followed by the
|
||||
* shared utility tail (Settings, Notifications). The desktop sidebar renders
|
||||
* the same feature list inline and consumes the same utility tail in its
|
||||
* footer + avatar menu, so the two bars cannot drift into two hand-curated
|
||||
* lists.
|
||||
* destination that isn't a primary slot, in model order. Feature destinations
|
||||
* only — the account utilities (Settings, Notifications) are NOT appended here.
|
||||
* They live solely in the user/avatar menu (mobile top-bar dropdown; desktop
|
||||
* sidebar avatar menu + footer), so surfacing them in the More hub too would
|
||||
* duplicate a utility across two menus reachable from the same screen. The
|
||||
* desktop sidebar renders the same feature list inline, so the two bars cannot
|
||||
* drift into two hand-curated feature lists.
|
||||
*/
|
||||
export function mobileMoreHubDestinations(opts: {
|
||||
modules: ModuleVisibilityMap | undefined;
|
||||
/** Hydration gate — see `isNavDestinationVisible`. Defaults to mounted. */
|
||||
mounted?: boolean;
|
||||
}): MobileMoreHubEntry[] {
|
||||
const features = visibleNavDestinations(opts.modules, opts.mounted ?? true)
|
||||
return visibleNavDestinations(opts.modules, opts.mounted ?? true)
|
||||
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
|
||||
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
|
||||
const tail = visibleUtilityDestinations().map((d) => ({
|
||||
href: d.href,
|
||||
tKey: d.tKey,
|
||||
icon: d.icon,
|
||||
}));
|
||||
return [...features, ...tail];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared scroll-floor for the two-column sub-shells (Settings, Admin).
|
||||
*
|
||||
* Scroll height is shell-owned (over-scroll class, #154 lineage): only
|
||||
* `AuthShell` owns the scroll container's height and bottom padding — the
|
||||
* `<main>` clears the mobile bottom-nav and its wrapper clears the Coach FAB.
|
||||
* A sub-shell must NEVER re-declare a bottom gutter (`pb-*`) or a viewport
|
||||
* reserve (`min-h-[calc(100dvh-…)]`) on its own content column: a nested
|
||||
* reserve stacks on the shell budget and scrolls past the last card.
|
||||
*
|
||||
* The one sanctioned reserve is this loading-jump floor, applied to the
|
||||
* sub-shell GRID (not to a column): it keeps the page height stable while a
|
||||
* freshly-navigated section is still fetching, without over-shooting the
|
||||
* viewport on a short section. The `<main>`/cards column is the grid's `1fr`
|
||||
* row, so it stretches to fill exactly and never over-scrolls.
|
||||
*
|
||||
* Both sub-shells derive the floor from THIS single constant so the two can
|
||||
* never drift — Admin previously carried a stale, all-breakpoint
|
||||
* `min-h-[calc(100dvh-12rem)]` column reserve that this de-duplication retires.
|
||||
*
|
||||
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + `pb-20`
|
||||
* (5rem) = 10.5rem. Retune here — and only here — if those shell paddings
|
||||
* change. (Latent follow-up: this constant assumes zero banners above `<main>`;
|
||||
* a locale/offline/demo banner or an iOS PWA safe-area inset makes it
|
||||
* over-reserve. A fully self-accounting `min-h-full` chain would need the
|
||||
* AuthShell content wrapper to pass a definite height through, which is out of
|
||||
* scope for the shell-only over-scroll fix.)
|
||||
*/
|
||||
export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]";
|
||||
@@ -81,7 +81,7 @@ export function AnamnesisCard({
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={panelId}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-5 text-left"
|
||||
className="flex w-full items-center gap-3 rounded-xl p-4 text-left md:p-6"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
@@ -107,7 +107,7 @@ export function AnamnesisCard({
|
||||
</button>
|
||||
|
||||
{expanded ? (
|
||||
<div id={panelId} className="space-y-5 px-5 pb-5">
|
||||
<div id={panelId} className="space-y-4 px-4 pb-4 md:px-6 md:pb-6">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t("onboarding.anamnesis.intro")}
|
||||
</p>
|
||||
|
||||
@@ -167,7 +167,7 @@ export function BaselineForm() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<fieldset className="bg-card border-border space-y-5 rounded-xl border p-5">
|
||||
<fieldset className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6">
|
||||
<legend className="sr-only">{t("onboarding.baseline.title")}</legend>
|
||||
|
||||
<FieldGroup
|
||||
|
||||
@@ -311,7 +311,7 @@ export function GettingStartedChecklist() {
|
||||
<section
|
||||
data-testid="onboarding-card"
|
||||
aria-labelledby="getting-started-title"
|
||||
className="bg-card border-border space-y-4 rounded-2xl border p-5 sm:p-6"
|
||||
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
{/* v1.4.27 MB7 / CF-44 — `flex-wrap` on the header lets the
|
||||
dismiss button drop to its own row when the German title +
|
||||
|
||||
@@ -643,8 +643,8 @@ export function OnboardingTour({
|
||||
// centred above the bottom edge on wide ones (`mx-auto` +
|
||||
// `max-w`). Used on EVERY viewport when no anchored
|
||||
// placement keeps the footer buttons inside the viewport.
|
||||
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 mx-auto max-h-[70vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
: "bg-card border-border pointer-events-auto absolute max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-5 shadow-2xl"
|
||||
"bg-card border-border pointer-events-auto absolute inset-x-2 bottom-2 mx-auto max-h-[70vh] max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-4 shadow-2xl md:p-6"
|
||||
: "bg-card border-border pointer-events-auto absolute max-w-[22rem] overflow-x-hidden overflow-y-auto rounded-xl border p-4 shadow-2xl md:p-6"
|
||||
}
|
||||
style={
|
||||
asSheet
|
||||
|
||||
@@ -18,13 +18,10 @@
|
||||
* record where it belongs.
|
||||
*/
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { HeartPulse, ShieldAlert, Users } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
|
||||
@@ -38,42 +35,33 @@ export function AnamnesisSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{coachEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.conditions.cardTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("records.conditions.cardDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ConditionsManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={HeartPulse}
|
||||
title={t("records.conditions.cardTitle")}
|
||||
description={t("records.conditions.cardDescription")}
|
||||
/>
|
||||
<ConditionsManager />
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.allergies.cardTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("records.allergies.cardDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AllergyManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={ShieldAlert}
|
||||
title={t("records.allergies.cardTitle")}
|
||||
description={t("records.allergies.cardDescription")}
|
||||
/>
|
||||
<AllergyManager />
|
||||
</SettingsCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("records.family.cardTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("records.family.cardDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FamilyHistoryManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("records.family.cardTitle")}
|
||||
description={t("records.family.cardDescription")}
|
||||
/>
|
||||
<FamilyHistoryManager />
|
||||
</SettingsCard>
|
||||
|
||||
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -506,7 +507,12 @@ export function SettingsShell({
|
||||
holds across locales and however the title or subtitle wraps. No
|
||||
fixed-height spacer to guess. The `gap-6` between rows reproduces
|
||||
the `space-y-6` the heading-to-first-card gap used to carry. */}
|
||||
<div className="grid gap-6 md:min-h-[calc(100dvh-10.5rem)] md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]">
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-6 md:grid-cols-[220px_1fr] md:grid-rows-[auto_1fr]",
|
||||
SUB_SHELL_GRID_FLOOR,
|
||||
)}
|
||||
>
|
||||
{/* Heading — desktop only here (mobile renders it above the strip). */}
|
||||
{headingBlock ? (
|
||||
<div className="hidden md:col-start-2 md:row-start-1 md:block">
|
||||
@@ -570,19 +576,18 @@ export function SettingsShell({
|
||||
still prevents the short-loading-state → long-list height jump
|
||||
the old reserve guarded, just measured against the real budget.
|
||||
|
||||
v1.4.33 F14 — `pb-24 md:pb-0` reserves a 96 px bottom gutter
|
||||
on `<md` so the last form field on
|
||||
`/settings/account` (Geschlecht / Gender) and
|
||||
`/settings/ai` (Aktiver Provider) is not eaten by the
|
||||
floating mobile bottom-nav. Desktop reverts to the parent's
|
||||
own padding because the bottom-nav doesn't render on `md+`.
|
||||
The column carries NO bottom gutter of its own. AuthShell's
|
||||
`<main>` already reserves `pb-[calc(4rem+safe-area)]` on `<md` to
|
||||
clear the floating bottom-nav, and its wrapper reserves `pb-20` for
|
||||
the Coach FAB. The old `pb-24 md:pb-0` (v1.4.33 F14) double-counted
|
||||
that same 64 px nav clearance and stacked ~96 px of dead scroll
|
||||
below the last card on mobile — removed so the shell owns all
|
||||
bottom-space (over-scroll class, #154 lineage).
|
||||
|
||||
v1.16.4 — a `<div>`, not `<main>`: the surrounding AuthShell
|
||||
already provides the page's single `<main>` landmark and a
|
||||
nested second one is an a11y violation. */}
|
||||
<div className="min-w-0 pb-24 md:col-start-2 md:row-start-2 md:pb-0">
|
||||
{children}
|
||||
</div>
|
||||
<div className="min-w-0 md:col-start-2 md:row-start-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user