Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,39 @@ function triggerReauth(): void {
}
}

/**
* Can an authenticated request still reach the API?
*
* Used to tell an expired Access session apart from a dropped connection when
* fetch() throws. The cache-busting param matters: the service worker answers
* /api/ GETs network-first and would otherwise hand back a cached 200 from
* before the session expired.
*/
async function sessionIsAlive(): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.onLine === false) return true;
try {
const res = await fetch(`${API_BASE}/auth/me?probe=${Date.now()}`, {
credentials: "include",
});
return res.ok;
} catch {
return false;
}
}

async function doFetch(path: string, options: RequestInit): Promise<Response> {
try {
return await fetch(`${API_BASE}${path}`, options);
} catch {
// fetch() throws (surfaced by the browser as a CORS error) when Cloudflare
// Access redirects an unauthenticated request to its login domain instead
// of reaching this API — treat it the same as an expired session.
// fetch() throws for two very different reasons: Cloudflare Access
// redirecting an unauthenticated request to its login domain (surfaced as
// a CORS error), and an ordinary dropped connection — routine on a phone.
// Re-authing navigates away from whatever is on screen, so only do it once
// we know the session is actually gone. Retrying the original request
// isn't an option: a POST that failed on the way back would double-log.
if (await sessionIsAlive()) {
throw new Error("Network error — check your connection and try again.");
}
triggerReauth();
throw new Error("Unauthorized");
}
Expand Down
50 changes: 44 additions & 6 deletions client/src/components/QuickLogDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
Box,
Button,
Expand All @@ -18,6 +18,7 @@ import { api } from "../api/client";
import { useChildren } from "../hooks/useChildren";
import { useDataRefresh } from "../hooks/useDataRefresh";
import { useNotification } from "../hooks/useNotification";
import { clearDraft, loadDraft, saveDraft } from "../utils/formDraft";
import NowButton from "./NowButton";

export type QuickLogCategory = "feed" | "diaper" | "sleep" | "pump" | "tummy" | "note";
Expand Down Expand Up @@ -87,6 +88,11 @@ interface FormState {
content: string;
}

/** Has anything been entered since the dialog opened? */
function isUnchanged(form: FormState, opened: FormState): boolean {
return (Object.keys(opened) as (keyof FormState)[]).every((k) => form[k] === opened[k]);
}

function emptyForm(): FormState {
return {
time: nowLocal(),
Expand All @@ -111,13 +117,45 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog
const { refreshData } = useDataRefresh();
const [form, setForm] = useState<FormState>(emptyForm);
const [saving, setSaving] = useState(false);
const childId = selectedChild?.id ?? null;

// What the form looked like when it opened — anything else means the user
// has typed something worth keeping.
const openedWith = useRef<FormState>(form);

useEffect(() => {
if (!category) return;
const fresh = emptyForm();
openedWith.current = fresh;

const draft = loadDraft<FormState>(category, childId);
if (draft) {
setForm({ ...fresh, ...draft });
notify("Picked up where you left off.", "info");
return;
}
setForm(fresh);
}, [category, childId]);

// Persist on every edit: whatever interrupts the page (an expired Access
// session navigating to re-auth, iOS evicting the PWA) gives no warning.
useEffect(() => {
if (category) setForm(emptyForm());
}, [category]);
if (!category) return;
if (isUnchanged(form, openedWith.current)) {
clearDraft(category, childId);
return;
}
saveDraft(category, childId, form);
}, [category, childId, form]);

if (!category) return null;

/** Dismissing the form is an explicit discard — don't offer it back later. */
const handleClose = () => {
clearDraft(category, childId);
onClose();
};

const handleSave = async () => {
if (!selectedChild) {
notify("Select a child first.", "warning");
Expand Down Expand Up @@ -188,7 +226,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog
notify("Logged.", "success");
refreshData();
onLogged?.(category);
onClose();
handleClose();
} catch (err) {
notify(err instanceof Error ? err.message : "Failed to save.", "error");
} finally {
Expand Down Expand Up @@ -239,7 +277,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog
);

return (
<Dialog open onClose={onClose} fullWidth maxWidth="sm" fullScreen={fullScreen}>
<Dialog open onClose={handleClose} fullWidth maxWidth="sm" fullScreen={fullScreen}>
<DialogTitle>{TITLES[category]}</DialogTitle>
<DialogContent sx={{ p: 2 }}>
{!selectedChild && (
Expand Down Expand Up @@ -395,7 +433,7 @@ export default function QuickLogDialog({ category, onClose, onLogged }: QuickLog
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleSave} variant="contained" disabled={saving || !selectedChild}>
{saving ? "Saving…" : "Save"}
</Button>
Expand Down
36 changes: 32 additions & 4 deletions client/src/hooks/useDataRefresh.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { isUserBusy } from "../utils/interruptions";

interface DataRefreshContextType {
/** Bumped whenever tracked data changes — use as a `useEffect` dependency to refetch. */
Expand All @@ -25,19 +26,46 @@ export function DataRefreshProvider({ children }: { children: ReactNode }) {
// again. `visibilitychange` and `focus` both fire when returning to a tab,
// hence the throttle.
const lastFocusRefresh = useRef(0);
const refreshHeld = useRef(false);
useEffect(() => {
const runRefresh = () => {
lastFocusRefresh.current = Date.now();
refreshHeld.current = false;
refreshData();
};

const onVisible = () => {
if (document.visibilityState !== "visible") return;
const now = Date.now();
if (now - lastFocusRefresh.current < FOCUS_REFRESH_THROTTLE_MS) return;
lastFocusRefresh.current = now;
refreshData();
if (Date.now() - lastFocusRefresh.current < FOCUS_REFRESH_THROTTLE_MS) return;
// On a phone the on-screen keyboard and the native date picker both blur
// and re-focus the window, so this fires repeatedly while a form is open.
// Refetching then rebuilds every list under the dialog for no benefit,
// and a request that fails mid-form can bounce the whole app through
// re-auth — taking the half-filled form with it. Hold it until the form
// is closed.
if (isUserBusy()) {
refreshHeld.current = true;
return;
}
runRefresh();
};

// Closing the dialog (or just leaving a field) is when a held-back refresh
// becomes safe. `focusout` fires before focus lands, so re-check next tick.
const onFocusOut = () => {
if (!refreshHeld.current) return;
setTimeout(() => {
if (refreshHeld.current && !isUserBusy()) runRefresh();
}, 0);
};

document.addEventListener("visibilitychange", onVisible);
window.addEventListener("focus", onVisible);
document.addEventListener("focusout", onFocusOut);
return () => {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("focus", onVisible);
document.removeEventListener("focusout", onFocusOut);
};
}, [refreshData]);

Expand Down
21 changes: 15 additions & 6 deletions client/src/pwa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,29 @@
// Vite config but don't ship a service worker) can safely skip registration
// without bundling the module.

import { createDeferredReload } from "./utils/deferredReload";

export function registerServiceWorker(): void {
if (typeof window === "undefined") return;
if (!("serviceWorker" in navigator)) return;

const pendingUpdate = createDeferredReload(() => window.location.reload());

void import("virtual:pwa-register")
.then(({ registerSW }) => {
// With registerType: 'autoUpdate', the new SW activates as soon as it's
// ready. We reload once on update so the user picks up fresh assets
// without any prompt — this app is single-user, so a brief refresh is
// friendlier than a banner.
registerSW({
immediate: true,
onNeedRefresh() {
window.location.reload();
// With registerType: 'autoUpdate' the new service worker activates as
// soon as it's ready, and vite-plugin-pwa reloads the page right then
// unless we take over via `onNeedReload`. That reload lands seconds
// after the app is opened following a deploy — often mid-form — and
// wipes out whatever has been typed. Picking up fresh assets still
// needs a reload, so hold it until the app is in the background.
//
// (`onNeedRefresh` is never called in autoUpdate mode; `onNeedReload`
// is the hook that suppresses the plugin's built-in reload.)
onNeedReload() {
pendingUpdate.request();
},
});
})
Expand Down
58 changes: 58 additions & 0 deletions client/src/utils/deferredReload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { isUserBusy } from "./interruptions";

/**
* A background stint at least this long means the user walked away, so coming
* back is a natural moment to start on the new build.
*/
export const STALE_BACKGROUND_MS = 30 * 60 * 1000;

export interface DeferredReload {
/** A new build is ready — reload at the next moment that won't cost the user anything. */
request: () => void;
/** Detach listeners. */
dispose: () => void;
}

/**
* Holds a page reload until it's safe to perform.
*
* "Safe" means nothing is open that a reload would destroy, and the app isn't
* in front of the user: either it's been backgrounded, or it's just come back
* from a long stint in the background where a fresh start is expected anyway.
*/
export function createDeferredReload(reload: () => void): DeferredReload {
let requested = false;
let hiddenAt = 0;

const attempt = () => {
if (!requested) return;
if (isUserBusy()) return;

const backgrounded = document.visibilityState === "hidden";
const returningFromLongAbsence = hiddenAt > 0 && Date.now() - hiddenAt >= STALE_BACKGROUND_MS;
if (!backgrounded && !returningFromLongAbsence) return;

requested = false;
reload();
};

const onVisibilityChange = () => {
if (document.visibilityState === "hidden") {
hiddenAt = Date.now();
attempt();
return;
}
attempt();
hiddenAt = 0;
};

document.addEventListener("visibilitychange", onVisibilityChange);

return {
request: () => {
requested = true;
attempt();
},
dispose: () => document.removeEventListener("visibilitychange", onVisibilityChange),
};
}
72 changes: 72 additions & 0 deletions client/src/utils/formDraft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Keeps an in-progress form around when something takes the page out from
* under it.
*
* Most interruptions are now held off until the user is done (see
* `deferredReload`), but a couple can't be: an expired Cloudflare Access
* session has to navigate to re-auth, and iOS will evict a backgrounded PWA
* whenever it likes. A draft costs nothing and makes those non-destructive.
*
* localStorage rather than sessionStorage, so an evicted app still has it on
* relaunch. Drafts are per child, expire on their own, and are cleared the
* moment the form is saved or dismissed — the only way one survives is if the
* user never got to finish with it.
*/

const KEY_PREFIX = "babytracker.draft.";

/** Drop a draft older than this rather than restoring something forgotten. */
export const DRAFT_TTL_MS = 6 * 60 * 60 * 1000;

interface StoredDraft<T> {
savedAt: number;
form: T;
}

function keyFor(name: string, childId: number | null): string {
return `${KEY_PREFIX}${name}.${childId ?? "none"}`;
}

export function saveDraft<T>(name: string, childId: number | null, form: T): void {
try {
const draft: StoredDraft<T> = { savedAt: Date.now(), form };
localStorage.setItem(keyFor(name, childId), JSON.stringify(draft));
} catch {
// Storage can be full or blocked (private browsing) — a draft is a bonus,
// never a reason to break the form.
}
}

export function loadDraft<T>(name: string, childId: number | null): T | null {
const key = keyFor(name, childId);
try {
const raw = localStorage.getItem(key);
if (!raw) return null;

const draft = JSON.parse(raw) as StoredDraft<T>;
if (!draft?.form || typeof draft.savedAt !== "number") {
localStorage.removeItem(key);
return null;
}
if (Date.now() - draft.savedAt > DRAFT_TTL_MS) {
localStorage.removeItem(key);
return null;
}
return draft.form;
} catch {
try {
localStorage.removeItem(key);
} catch {
// Ignore — nothing to recover.
}
return null;
}
}

export function clearDraft(name: string, childId: number | null): void {
try {
localStorage.removeItem(keyFor(name, childId));
} catch {
// Ignore.
}
}
29 changes: 29 additions & 0 deletions client/src/utils/interruptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Is the user in the middle of something that must not be interrupted?
*
* Reloading the page — or churning every list underneath an open dialog —
* throws away whatever has been typed. This app gets used one-handed, mid-feed,
* so losing a half-filled form is a real cost. Both the service-worker update
* reload and the on-focus refetch check this before doing anything disruptive.
*
* It's deliberately a DOM-level question rather than a flag each dialog has to
* register: any open modal or focused field counts, so pages added later are
* covered without opting in.
*/
export function isUserBusy(): boolean {
if (typeof document === "undefined") return false;

// MUI's Dialog puts role="dialog" on its paper; Layout's bottom "Log" sheet
// sets the same role.
if (document.querySelector('[role="dialog"]')) return true;

const active = document.activeElement as HTMLElement | null;
if (!active) return false;
if (active.isContentEditable) return true;

// A focused field still counts when the window itself is blurred: tapping a
// datetime input on iOS hands focus to the native picker, which is exactly
// the moment we must not pull the rug out.
const tag = active.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}
Loading