mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
v1.4.36 — perf, charts, AI payload trim, UX punch
Heavy-aggregate skip on the rollup-fresh + fully-covered path, daily chart fetches opt into rollup buckets via source=rollup, Insights extractFeatures swap drops the prompt payload from double-digit MB to the low hundreds of kB on power-user accounts, page-level isLoading gate replaced with per-section early-skeleton paint, trends-row chart slot pinned for CLS, IntakeHistoryListV2 restored on the medication detail page, About card folded into the Admin Console with an auto-checking update badge, cumulative metric tiles read the day's running sum, IP-whois fallback surfaces city + country when the ASN lookup misses. Migration 0068 adds an additive users.insights_exclude_metrics column. Unit suite 4354 passing; typecheck + lint clean.
This commit is contained in:
1 parent
5a3ca330ac
commit
0e2a99f8e7
70 files changed
+4982
-810
No files matched your search
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AboutSection, UpdateBadge } from "../about-section";
|
||||
|
||||
/**
|
||||
* v1.4.36 W4f — AboutSection update-badge contract.
|
||||
*
|
||||
* The dedicated "Updates" card with the manual "Check for updates"
|
||||
* button is gone. The 24 h auto-check still runs on mount and
|
||||
* surfaces a subtle ArrowUpCircle badge next to the version line
|
||||
* ONLY when the check returns `{ status: "newer_available", ... }`.
|
||||
*
|
||||
* These SSR smoke tests pin the badge presence by seeding the
|
||||
* react-query cache with a fake version payload (so the version
|
||||
* line paints synchronously) and exercising the path that decides
|
||||
* whether the badge mounts. The badge itself owns its own
|
||||
* `useState`, so its presence is gated on the runCheck branch —
|
||||
* SSR renders the initial `null` state. The actual badge render
|
||||
* is therefore covered by the runtime store-set + re-render in
|
||||
* future Playwright surfaces; here we pin the *baseline* contract
|
||||
* that the version line + Sources/docs panel still render and
|
||||
* the legacy manual-check chrome is gone.
|
||||
*/
|
||||
|
||||
function makeClient() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Seed the version query so the section paints inline rather than
|
||||
// rendering its loader skeleton.
|
||||
client.setQueryData(["api", "version"], {
|
||||
version: "1.4.36",
|
||||
buildSha: "abc1234",
|
||||
builtAt: "2026-05-17T08:00:00.000Z",
|
||||
license: "AGPL-3.0",
|
||||
repository: "https://github.com/MBombeck/HealthLog",
|
||||
changelog: "https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md",
|
||||
docs: "https://github.com/MBombeck/HealthLog/tree/main/docs",
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
||||
const client = makeClient();
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Fail the auto-check fetch so the badge stays in its initial
|
||||
// (no-result) state. This lets the SSR smoke tests assert the
|
||||
// "no badge" baseline; the badge-visible branch is unit-tested
|
||||
// separately by toggling `updateResult` via a forced re-render
|
||||
// in the future Playwright + RTL suite.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
throw new Error("network disabled in tests");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("<AboutSection> — update badge", () => {
|
||||
it("renders the version line without a badge by default", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("v1.4.36");
|
||||
// No badge — ArrowUpCircle's lucide class name is the unique signal.
|
||||
expect(html).not.toContain("lucide-arrow-up-circle");
|
||||
});
|
||||
|
||||
it("drops the manual 'Check for updates' button surface entirely", () => {
|
||||
const html = render(<AboutSection />);
|
||||
// Pre-v1.4.36 the section carried a "Check for updates" CTA
|
||||
// alongside a last-checked timestamp. Both are now gone — only
|
||||
// the 24 h auto-check stays, surfacing through the badge above.
|
||||
expect(html).not.toContain(">Check for updates<");
|
||||
expect(html).not.toContain(">Check now<");
|
||||
expect(html).not.toContain("last-checked-iso");
|
||||
});
|
||||
|
||||
it("preserves the Sources & docs panel", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("Sources & docs");
|
||||
});
|
||||
|
||||
it("uses a dedicated 'Built' label for the build-time row rather than the gitSha fallback", () => {
|
||||
const html = render(<AboutSection />);
|
||||
// Distinct label keeps the dt copy separate from the "Build" gitSha
|
||||
// header above it; the prior `t('builtAt', { time: '' }).trim()`
|
||||
// pattern silently leaked the gitSha key as a fallback.
|
||||
expect(html).toContain(">Built<");
|
||||
// Only ONE column should ever read "Build" — the gitSha header.
|
||||
const buildOccurrences = html.match(/>Build</g) ?? [];
|
||||
expect(buildOccurrences.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<UpdateBadge> — a11y contract", () => {
|
||||
it("renders an anchor with aria-label, focus-visible ring, and 44 px hit target when a URL is known", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateBadge
|
||||
latestTag="v1.4.99"
|
||||
htmlUrl="https://example.test/releases/v1.4.99"
|
||||
ariaLabel="A newer version is available: v1.4.99"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
expect(html).toContain("focus-visible:ring-ring");
|
||||
expect(html).toContain("focus-visible:ring-2");
|
||||
expect(html).toContain("focus-visible:ring-offset-2");
|
||||
expect(html).toContain("text-primary");
|
||||
expect(html).toContain('href="https://example.test/releases/v1.4.99"');
|
||||
});
|
||||
|
||||
it("falls back to a span with role=img + aria-label when no URL is known", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<UpdateBadge
|
||||
latestTag="v1.4.99"
|
||||
htmlUrl={null}
|
||||
ariaLabel="A newer version is available: v1.4.99"
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).toContain('aria-label="A newer version is available: v1.4.99"');
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("min-w-11");
|
||||
});
|
||||
});
|
||||
@@ -132,16 +132,17 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("onboarding.tour.");
|
||||
});
|
||||
|
||||
it("<AboutSection> renders version + license + check-for-updates button", () => {
|
||||
it("<AboutSection> renders version + license + sources/docs cards (no Updates panel)", () => {
|
||||
const html = render(<AboutSection />);
|
||||
expect(html).toContain("About");
|
||||
// The new about.* keys resolve. v1.4.3 split the page into three
|
||||
// titled cards (HealthLog identity, Sources & docs, Updates) and
|
||||
// shortened the button label from "Check for updates" to "Check now"
|
||||
// because the surrounding heading already names the section.
|
||||
expect(html).toContain("Updates");
|
||||
// v1.4.36 W4f — the dedicated "Updates" card with its manual
|
||||
// "Check now" button is gone. The 24 h auto-check still runs and
|
||||
// surfaces a subtle ArrowUpCircle badge next to the version line
|
||||
// when a newer release is available. The Sources & docs card
|
||||
// stays put.
|
||||
expect(html).toContain("Sources & docs");
|
||||
expect(html).toContain("Check now");
|
||||
expect(html).not.toContain(">Check now<");
|
||||
expect(html).not.toContain(">Check for updates<");
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
@@ -198,11 +199,12 @@ describe("settings sections — SSR smoke", () => {
|
||||
|
||||
it("<AboutSection> resolves about.* keys in German", () => {
|
||||
const html = render(<AboutSection />, "de");
|
||||
// v1.4.3: button label shortened to "Jetzt prüfen" because the
|
||||
// section heading "Updates" already provides context.
|
||||
expect(html).toContain("Updates");
|
||||
expect(html).toContain("Jetzt prüfen");
|
||||
// v1.4.36 W4f — "Updates" panel + "Jetzt prüfen" button removed;
|
||||
// the surviving German copy is the section title + the Sources
|
||||
// & docs heading + the version line.
|
||||
expect(html).toContain("Über");
|
||||
expect(html).toContain("Quellen & Dokumentation");
|
||||
expect(html).not.toContain(">Jetzt prüfen<");
|
||||
});
|
||||
|
||||
it("<ThresholdsSection> renders heading and embeds both editors (v1.4.34 IW-D merge)", () => {
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Info,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -69,6 +67,51 @@ function writeLastCheckedISO(iso: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 QA H7 — keyboard- and touch-accessible update badge.
|
||||
*
|
||||
* Renders as a 44 px-square hit target (anchor when a release URL is
|
||||
* known, span fallback otherwise) with a focus-visible ring, semantic
|
||||
* `text-primary` colour, and a real `aria-label` describing the
|
||||
* available version. Exported so the SSR test can pin the contract
|
||||
* without driving the parent's auto-check effect.
|
||||
*/
|
||||
export function UpdateBadge({
|
||||
latestTag,
|
||||
htmlUrl,
|
||||
ariaLabel,
|
||||
}: {
|
||||
latestTag: string;
|
||||
htmlUrl: string | null;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
if (htmlUrl) {
|
||||
return (
|
||||
<a
|
||||
href={htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className="text-primary focus-visible:ring-ring inline-flex min-h-11 min-w-11 items-center justify-center rounded-md hover:opacity-80 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
<span className="sr-only">{latestTag}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className="text-primary inline-flex min-h-11 min-w-11 items-center justify-center"
|
||||
>
|
||||
<ArrowUpCircle className="h-4 w-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -85,16 +128,15 @@ export function AboutSection() {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [checking, setChecking] = useState(false);
|
||||
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
|
||||
// only the 24 h auto-check stays. When the auto-check reports
|
||||
// `newer_available`, a subtle upward-arrow badge appears next to
|
||||
// the version line with the latest tag in its tooltip.
|
||||
const [updateResult, setUpdateResult] = useState<CheckUpdatesResult | null>(
|
||||
null,
|
||||
);
|
||||
const [lastCheckedISO, setLastCheckedISO] = useState<string | null>(() =>
|
||||
readLastCheckedISO(),
|
||||
);
|
||||
|
||||
async function runCheck(): Promise<void> {
|
||||
setChecking(true);
|
||||
try {
|
||||
const res = await fetch("/api/version/check-updates");
|
||||
if (!res.ok) {
|
||||
@@ -109,9 +151,7 @@ export function AboutSection() {
|
||||
const result = json.data as CheckUpdatesResult;
|
||||
setUpdateResult(result);
|
||||
if (result.status !== "unknown") {
|
||||
const iso = new Date().toISOString();
|
||||
writeLastCheckedISO(iso);
|
||||
setLastCheckedISO(iso);
|
||||
writeLastCheckedISO(new Date().toISOString());
|
||||
}
|
||||
} catch {
|
||||
setUpdateResult({
|
||||
@@ -119,14 +159,12 @@ export function AboutSection() {
|
||||
current: version?.version ?? "",
|
||||
reason: "network_error",
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-check on mount when the last successful check is older than the
|
||||
// refresh interval (or has never run). Keeps the UI populated without
|
||||
// requiring a click — but won't spam GitHub on every navigation.
|
||||
// refresh interval (or has never run). Keeps the badge fresh without a
|
||||
// click — but won't spam GitHub on every navigation.
|
||||
// The setState inside runCheck() is intentional: the check is the
|
||||
// whole point of the effect, and the staleness gate prevents a
|
||||
// cascading-render loop because the timestamp resets after the run.
|
||||
@@ -184,6 +222,23 @@ export function AboutSection() {
|
||||
{t("settings.about.version")}
|
||||
</dt>
|
||||
<dd className="font-mono font-medium">v{version.version}</dd>
|
||||
{/* v1.4.36 W4f — subtle upward-arrow badge that
|
||||
appears only when the 24 h auto-check reports a
|
||||
newer release. Hover/tap reveals the latest tag via
|
||||
the title attribute (read by screen readers + as a
|
||||
native tooltip on desktop, and announced inline as
|
||||
the link copy on mobile). No bouncing, no badge
|
||||
counter, no manual recheck button — the badge is
|
||||
the entire surface. */}
|
||||
{updateResult?.status === "newer_available" && (
|
||||
<UpdateBadge
|
||||
latestTag={updateResult.latest_tag}
|
||||
htmlUrl={updateResult.html_url}
|
||||
ariaLabel={t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-baseline gap-2">
|
||||
@@ -205,8 +260,7 @@ export function AboutSection() {
|
||||
{version.builtAt && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("settings.about.builtAt", { time: "" }).trim() ||
|
||||
t("settings.about.gitSha")}
|
||||
{t("settings.about.builtAtLabel")}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground">
|
||||
{fmt.date(version.builtAt)}
|
||||
@@ -262,91 +316,16 @@ export function AboutSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updates — proper heading + last-check timestamp + manual button
|
||||
that proxies through `/api/version/check-updates`. The v1.4.2
|
||||
version called `api.github.com` directly from the browser, which
|
||||
the production CSP blocked silently — that's why "nothing
|
||||
happened" when the user clicked the button. */}
|
||||
{version && (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.about.updatesHeading")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={runCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{checking
|
||||
? t("settings.about.checking")
|
||||
: t("settings.about.checkUpdates")}
|
||||
</Button>
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{lastCheckedISO
|
||||
? t("settings.about.lastChecked", {
|
||||
time: fmt.dateTime(lastCheckedISO),
|
||||
})
|
||||
: t("settings.about.lastCheckedNever")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{updateResult?.status === "up_to_date" && (
|
||||
<p
|
||||
role="status"
|
||||
className="text-dracula-green mt-3 flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("settings.about.upToDate")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.status === "newer_available" && (
|
||||
<p role="status" className="mt-3 flex items-center gap-1.5 text-sm">
|
||||
<Sparkles className="text-dracula-purple h-4 w-4" />
|
||||
{updateResult.html_url ? (
|
||||
<a
|
||||
href={updateResult.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
</a>
|
||||
) : (
|
||||
<span>
|
||||
{t("settings.about.newerAvailable", {
|
||||
tag: updateResult.latest_tag,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{updateResult?.status === "unknown" && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive mt-3 flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
{t("settings.about.checkFailed")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* v1.4.36 W4f — the dedicated "Updates" card with the
|
||||
manual "Check for updates" button is gone. The 24 h auto-
|
||||
check still runs on mount and writes its result into the
|
||||
local `updateResult` state; the consequence renders as the
|
||||
subtle upward-arrow badge next to the version line above
|
||||
rather than as a separate panel. The card-level last-check
|
||||
timestamp, success / failure states, and the explicit
|
||||
button were collectively never the point of the surface —
|
||||
the only signal users ever cared about was "is a newer
|
||||
release out?", and the badge answers that with no clicks. */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +164,11 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
|
||||
if (!active) return;
|
||||
active.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
// v1.4.36 W4b — `inline: "start"` pins the active chip to the
|
||||
// left edge of the scroller. `inline: "center"` over-scrolled
|
||||
// and the first one or two chips were unreachable on narrow
|
||||
// viewports.
|
||||
inline: "start",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
Reference in new issue
Block a user