mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
Merge remote-tracking branch 'origin/release/v1.42.0' into v142/ui-audit
This commit is contained in:
commit
492c2a4131
41 files changed
+1268
-151
No files matched your search
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
KEYBOARD_MIN_PX,
|
||||
keyboardInset,
|
||||
trackKeyboardInset,
|
||||
} from "@/lib/pwa/keyboard-inset";
|
||||
|
||||
describe("keyboardInset", () => {
|
||||
it("is the layout viewport left below the visual one", () => {
|
||||
// iPhone 15, portrait: 844 tall, keyboard 336.
|
||||
expect(keyboardInset(844, { height: 508, offsetTop: 0, scale: 1 })).toBe(
|
||||
336,
|
||||
);
|
||||
});
|
||||
|
||||
it("counts a visual viewport iOS panned to keep the field in view", () => {
|
||||
expect(keyboardInset(844, { height: 508, offsetTop: 120, scale: 1 })).toBe(
|
||||
216,
|
||||
);
|
||||
});
|
||||
|
||||
it("is zero without a keyboard", () => {
|
||||
expect(keyboardInset(844, { height: 844, offsetTop: 0, scale: 1 })).toBe(0);
|
||||
});
|
||||
|
||||
it("does not read a collapsing toolbar as a keyboard", () => {
|
||||
expect(
|
||||
keyboardInset(844, {
|
||||
height: 844 - (KEYBOARD_MIN_PX - 1),
|
||||
offsetTop: 0,
|
||||
scale: 1,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("does not read pinch-zoom as a keyboard", () => {
|
||||
expect(keyboardInset(844, { height: 422, offsetTop: 0, scale: 2 })).toBe(0);
|
||||
});
|
||||
|
||||
it("is zero where the browser has no visual viewport", () => {
|
||||
expect(keyboardInset(844, null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeViewport extends EventTarget {
|
||||
height = 844;
|
||||
offsetTop = 0;
|
||||
scale = 1;
|
||||
}
|
||||
|
||||
function fakeRoot() {
|
||||
const props = new Map<string, string>();
|
||||
const attrs = new Map<string, string>();
|
||||
return {
|
||||
props,
|
||||
attrs,
|
||||
el: {
|
||||
style: {
|
||||
setProperty: (k: string, v: string) => props.set(k, v),
|
||||
removeProperty: (k: string) => props.delete(k),
|
||||
},
|
||||
setAttribute: (k: string, v: string) => attrs.set(k, v),
|
||||
removeAttribute: (k: string) => attrs.delete(k),
|
||||
} as unknown as HTMLElement,
|
||||
};
|
||||
}
|
||||
|
||||
describe("trackKeyboardInset", () => {
|
||||
it("publishes the inset while the keyboard is open and clears it after", () => {
|
||||
const viewport = new FakeViewport();
|
||||
const root = fakeRoot();
|
||||
const stop = trackKeyboardInset(
|
||||
{
|
||||
innerHeight: 844,
|
||||
visualViewport: viewport as unknown as VisualViewport,
|
||||
},
|
||||
root.el,
|
||||
);
|
||||
expect(root.attrs.has("data-keyboard")).toBe(false);
|
||||
|
||||
viewport.height = 508;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
expect(root.props.get("--keyboard-inset")).toBe("336px");
|
||||
expect(root.attrs.get("data-keyboard")).toBe("open");
|
||||
|
||||
viewport.offsetTop = 100;
|
||||
viewport.dispatchEvent(new Event("scroll"));
|
||||
expect(root.props.get("--keyboard-inset")).toBe("236px");
|
||||
|
||||
viewport.height = 844;
|
||||
viewport.offsetTop = 0;
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
expect(root.props.has("--keyboard-inset")).toBe(false);
|
||||
expect(root.attrs.has("data-keyboard")).toBe(false);
|
||||
|
||||
viewport.height = 508;
|
||||
stop();
|
||||
viewport.dispatchEvent(new Event("resize"));
|
||||
expect(root.attrs.has("data-keyboard")).toBe(false);
|
||||
});
|
||||
|
||||
it("does nothing without a visual viewport", () => {
|
||||
const root = fakeRoot();
|
||||
const stop = trackKeyboardInset(
|
||||
{ innerHeight: 844, visualViewport: null },
|
||||
root.el,
|
||||
);
|
||||
stop();
|
||||
expect(root.attrs.size).toBe(0);
|
||||
expect(root.props.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { applyThemeColor, THEME_COLOR } from "@/lib/pwa/theme-color";
|
||||
|
||||
function fakeMeta(content: string) {
|
||||
const attrs = new Map<string, string>([["content", content]]);
|
||||
return {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => attrs.set(k, v),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyThemeColor", () => {
|
||||
it("rewrites both media-keyed tags to the theme the page painted", () => {
|
||||
// A phone in light mode, the app in its dark default: the light tag is
|
||||
// the one the browser applies, and it must carry the dark colour.
|
||||
const light = fakeMeta(THEME_COLOR.light);
|
||||
const dark = fakeMeta(THEME_COLOR.dark);
|
||||
const doc = {
|
||||
querySelectorAll: (selector: string) => {
|
||||
expect(selector).toBe('meta[name="theme-color"]');
|
||||
return [light, dark];
|
||||
},
|
||||
} as unknown as Document;
|
||||
|
||||
applyThemeColor(doc, "dark");
|
||||
expect(light.attrs.get("content")).toBe(THEME_COLOR.dark);
|
||||
expect(dark.attrs.get("content")).toBe(THEME_COLOR.dark);
|
||||
|
||||
applyThemeColor(doc, "light");
|
||||
expect(light.attrs.get("content")).toBe(THEME_COLOR.light);
|
||||
expect(dark.attrs.get("content")).toBe(THEME_COLOR.light);
|
||||
});
|
||||
|
||||
it("matches each theme's --background in globals.css", () => {
|
||||
const css = readFileSync(
|
||||
resolve(__dirname, "../../../app/globals.css"),
|
||||
"utf8",
|
||||
);
|
||||
// The dark palette is declared in hex; the bar colour must be it exactly.
|
||||
expect(css).toMatch(
|
||||
new RegExp(`--background:\\s*${THEME_COLOR.dark};`, "i"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* One reload after a stale-shell chunk failure.
|
||||
*
|
||||
* After a deploy, a page that was open across it still references the
|
||||
* previous build's chunk files; they 404 and the lazy load fails. A client
|
||||
* navigation already recovers on its own (Next turns a failed chunk during a
|
||||
* route change into a full page load), but a chunk that fails inside a page
|
||||
* (a `next/dynamic` panel, a lazily imported sheet) surfaces in an error
|
||||
* boundary instead. Both boundaries, the route one (`app/error.tsx`) and the
|
||||
* root one (`app/global-error.tsx`), call `reloadOnceForChunkError`, which
|
||||
* loads the page once so it arrives with the new build's chunk graph.
|
||||
*
|
||||
* The guard is keyed on the running shell's version: it suppresses a loop
|
||||
* within ONE broken shell, and re-arms after every deploy (the pre-v1.16.8
|
||||
* once-per-session key exhausted itself on multi-deploy days and stranded the
|
||||
* user on the error page).
|
||||
*/
|
||||
|
||||
const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted";
|
||||
|
||||
/**
|
||||
* A failed chunk, in the words of each loader and engine: webpack's
|
||||
* `ChunkLoadError` / "Loading chunk", its CSS twin, Turbopack's "Failed to
|
||||
* load chunk", and a native dynamic import as Chromium, WebKit and Firefox
|
||||
* report it.
|
||||
*/
|
||||
export function isChunkLoadError(err: { name?: string; message?: string }) {
|
||||
if (err.name === "ChunkLoadError") return true;
|
||||
const msg = err.message ?? "";
|
||||
return (
|
||||
msg.includes("Loading chunk") ||
|
||||
msg.includes("Loading CSS chunk") ||
|
||||
msg.includes("Failed to load chunk") ||
|
||||
msg.includes("Failed to fetch dynamically imported module") ||
|
||||
msg.includes("Importing a module script failed") ||
|
||||
msg.includes("error loading dynamically imported module")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard value recorded per auto-reload attempt — the running shell's build
|
||||
* version, so the suppression is scoped to ONE shell. After a successful
|
||||
* heal the reloaded page carries the NEW version and the guard re-arms by
|
||||
* construction; only a shell that reloaded and is STILL broken (the new
|
||||
* build is also missing the chunk) stays suppressed. Builds without an
|
||||
* injected version degrade to once-per-session via the "unversioned"
|
||||
* sentinel.
|
||||
*/
|
||||
export function chunkReloadGuardValue(shellVersion: string): string {
|
||||
return shellVersion || "unversioned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure reload decision. `stored` is the sessionStorage value from the last
|
||||
* attempt (`null` when none happened); legacy guard values (the pre-v1.16.8
|
||||
* timestamp) never match a version string, so a session stuck from before
|
||||
* the change heals on its next chunk error.
|
||||
*/
|
||||
export function shouldAttemptChunkReload(
|
||||
stored: string | null,
|
||||
shellVersion: string,
|
||||
): boolean {
|
||||
return stored !== chunkReloadGuardValue(shellVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload once if `error` is a chunk failure and this shell has not tried
|
||||
* yet. Returns whether a reload was started.
|
||||
*/
|
||||
export function reloadOnceForChunkError(
|
||||
error: { name?: string; message?: string },
|
||||
shellVersion: string = process.env.NEXT_PUBLIC_APP_VERSION || "",
|
||||
): boolean {
|
||||
if (typeof window === "undefined" || !isChunkLoadError(error)) return false;
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY);
|
||||
if (!shouldAttemptChunkReload(stored, shellVersion)) return false;
|
||||
window.sessionStorage.setItem(
|
||||
CHUNK_RELOAD_KEY,
|
||||
chunkReloadGuardValue(shellVersion),
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage can throw under strict privacy modes; without a guard
|
||||
// a reload could loop, so fall through to the error UI instead.
|
||||
return false;
|
||||
}
|
||||
window.location.reload();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* How much of the layout viewport the on-screen keyboard covers.
|
||||
*
|
||||
* iOS Safari, and Chrome on Android by default (`interactive-widget` left at
|
||||
* `resizes-visual`), do not shrink the layout viewport when the keyboard
|
||||
* opens; only the visual viewport shrinks. Everything `position: fixed` stays
|
||||
* anchored to the layout viewport, so a bottom sheet keeps its footer (Save,
|
||||
* the Coach composer) behind the keyboard. The covered height is what is left
|
||||
* of the layout viewport below the visual one:
|
||||
*
|
||||
* innerHeight - (visualViewport.offsetTop + visualViewport.height)
|
||||
*
|
||||
* Two things shrink the visual viewport without a keyboard, and neither may
|
||||
* read as one: pinch-zoom (the scale moves off 1) and the few dozen pixels a
|
||||
* collapsing toolbar can account for. Hence the scale check and the floor.
|
||||
*
|
||||
* Published as the `--keyboard-inset` custom property and `data-keyboard` on
|
||||
* `<html>`; the rules that use them live in `globals.css`.
|
||||
*/
|
||||
|
||||
/** Below this, the difference is browser chrome, not a keyboard. */
|
||||
export const KEYBOARD_MIN_PX = 120;
|
||||
|
||||
export interface VisualViewportLike {
|
||||
height: number;
|
||||
offsetTop: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export function keyboardInset(
|
||||
innerHeight: number,
|
||||
viewport: VisualViewportLike | null | undefined,
|
||||
): number {
|
||||
if (!viewport) return 0;
|
||||
if (Math.abs(viewport.scale - 1) > 0.01) return 0;
|
||||
const covered = innerHeight - viewport.offsetTop - viewport.height;
|
||||
return covered >= KEYBOARD_MIN_PX ? Math.round(covered) : 0;
|
||||
}
|
||||
|
||||
export const KEYBOARD_INSET_PROPERTY = "--keyboard-inset";
|
||||
export const KEYBOARD_ATTRIBUTE = "data-keyboard";
|
||||
|
||||
/**
|
||||
* Track the keyboard on `win` and publish it on `root`. Returns the cleanup.
|
||||
* A browser without `visualViewport` gets nothing published, which leaves
|
||||
* every rule at its keyboard-closed default.
|
||||
*/
|
||||
export function trackKeyboardInset(
|
||||
win: Pick<Window, "innerHeight" | "visualViewport">,
|
||||
root: HTMLElement,
|
||||
): () => void {
|
||||
const viewport = win.visualViewport;
|
||||
if (!viewport) return () => {};
|
||||
|
||||
let current = -1;
|
||||
const update = () => {
|
||||
const inset = keyboardInset(win.innerHeight, viewport);
|
||||
if (inset === current) return;
|
||||
current = inset;
|
||||
if (inset > 0) {
|
||||
root.style.setProperty(KEYBOARD_INSET_PROPERTY, `${inset}px`);
|
||||
root.setAttribute(KEYBOARD_ATTRIBUTE, "open");
|
||||
} else {
|
||||
root.style.removeProperty(KEYBOARD_INSET_PROPERTY);
|
||||
root.removeAttribute(KEYBOARD_ATTRIBUTE);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
viewport.addEventListener("resize", update);
|
||||
// iOS pans the visual viewport to keep the focused field in view, which
|
||||
// moves `offsetTop` without a resize.
|
||||
viewport.addEventListener("scroll", update);
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", update);
|
||||
viewport.removeEventListener("scroll", update);
|
||||
root.style.removeProperty(KEYBOARD_INSET_PROPERTY);
|
||||
root.removeAttribute(KEYBOARD_ATTRIBUTE);
|
||||
};
|
||||
}
|
||||
@@ -35,16 +35,16 @@ import {
|
||||
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { isReadingSharedRecord } from "@/lib/query-keys/record-scope";
|
||||
// Session-end cleanup removes every current/legacy HealthLog cache family
|
||||
// while leaving unrelated CacheStorage namespaces on the same origin
|
||||
// untouched; the name contract is shared with `public/sw.js`.
|
||||
import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy";
|
||||
|
||||
const DB_NAME = "healthlog-query-cache";
|
||||
const STORE = "kv";
|
||||
const KEY = "react-query";
|
||||
const VERSION_KEY = "react-query-version";
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day
|
||||
// Keep this ownership contract aligned with `public/sw.js`. Session-end
|
||||
// cleanup removes every current/legacy HealthLog cache family while leaving
|
||||
// unrelated CacheStorage namespaces on the same origin untouched.
|
||||
const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
|
||||
|
||||
/**
|
||||
* Upper bound on the serialized snapshot. The allowlist already caps WHAT is
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
* allowlisted by mistake — mirroring the idempotency cache's refusal.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The worker's own cache names: `healthlog-{static,pages,data}-<version>`.
|
||||
* Everything the app deletes from CacheStorage (session end, the "new
|
||||
* version" reload) matches against this and nothing else, so another app's
|
||||
* caches on the same origin are left alone. Keep in lockstep with
|
||||
* `HEALTHLOG_CACHE_NAME_RE` in `public/sw.js`.
|
||||
*/
|
||||
export const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/;
|
||||
|
||||
/**
|
||||
* Path prefixes whose safe GET reads are eligible for the offline data cache.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* The browser-chrome colour of each theme: the Android address bar, the
|
||||
* installed app's title bar, and the status bar of an installed iOS app.
|
||||
*
|
||||
* Each value is the resolved `--background` of its theme in
|
||||
* `app/globals.css`, so the bar never seams against the page on a cold paint.
|
||||
* The root layout declares both as `<meta name="theme-color" media=…>` keyed on
|
||||
* the operating system's scheme, which is all a server render can know.
|
||||
*
|
||||
* The app's own theme is not the operating system's, though: with nothing
|
||||
* stored it defaults to dark, and a person can pick light or dark by hand. A
|
||||
* phone in light mode running HealthLog in dark mode then showed a light bar
|
||||
* over a dark page. `applyThemeColor` rewrites both tags to the theme the page
|
||||
* actually painted, whichever scheme the media query would pick.
|
||||
*/
|
||||
export const THEME_COLOR = {
|
||||
light: "#f3f2f5",
|
||||
dark: "#282a36",
|
||||
} as const;
|
||||
|
||||
export function applyThemeColor(
|
||||
doc: Pick<Document, "querySelectorAll">,
|
||||
resolved: keyof typeof THEME_COLOR,
|
||||
): void {
|
||||
for (const meta of doc.querySelectorAll<HTMLMetaElement>(
|
||||
'meta[name="theme-color"]',
|
||||
)) {
|
||||
meta.setAttribute("content", THEME_COLOR[resolved]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user