Add service worker implementation and tests for PWA caching strategy

- Implemented a service worker (sw.js) that handles installation, activation, and fetch events.
- Added caching logic for public assets and a fallback for offline navigation.
- Created unit tests (sw.test.ts) to validate service worker behavior, including asset caching, navigation handling, and activation logic.
- Ensured that private API routes and third-party requests are excluded from caching.
This commit is contained in:
syntaxbullet
2026-09-08 17:19:12 +02:00
parent e081e9acbf
commit ccd998ab04
29 changed files with 795 additions and 13 deletions

View File

@@ -1,4 +1,5 @@
import { deferred } from "../components/design-system/deferred";
import { useOnline } from "../pwa/client";
import { CalendarCacheProvider } from "../components/CalendarCache";
import { Archive, Plus, Settings2 } from "lucide-react";
const ArchivedHabits = deferred(() => import("../components/ArchivedHabits").then(module => module.ArchivedHabits));
@@ -30,22 +31,24 @@ import type { Schedule } from "../habits/contracts";
const ShareProgress = deferred(() => import("../components/ShareProgress").then(module => module.ShareProgress));
export function Home() {
const { user, loading, busy, error, accountError, signIn, signOut, retry } = useAuth();
const online = useOnline();
const { user, loading, busy, error, accountError, connectionError, signIn, signOut, retry } = useAuth();
const disconnected = !online || connectionError;
return (
<PageLayout
header={
loading ? (
<span className="ds-library-label">Loading account…</span>
) : user ? (
<Button variant="text" disabled={busy} onClick={() => void signOut()}>
<Button variant="text" disabled={busy || !online} onClick={() => void signOut()}>
{busy ? "Signing out…" : "Sign out"}
</Button>
) : (
!accountError && <DiscordSignInButton variant="secondary" onClick={signIn} />
!accountError && <DiscordSignInButton disabled={!online} variant="secondary" onClick={signIn} />
)
}
>
{error && (
{error && !disconnected && (
<div className="ds-form-feedback" role="alert">
{error}
</div>
@@ -56,9 +59,9 @@ export function Home() {
</section>
) : accountError ? (
<section className="ds-section">
<h1 className="type-section">Let’s try that again.</h1>
<Card headingLevel={2} heading="Account unavailable">
<p>Your account couldn’t be loaded.</p>
<h1 className="type-section">{disconnected ? "A moment to reconnect." : "Let’s try that again."}</h1>
<Card headingLevel={2} heading={disconnected ? "Your habits will be here." : "Account unavailable"}>
<p>{disconnected ? "We can’t connect to Mina Habits. Check your internet connection to load your account and check in. We’ll try again automatically." : "Your account couldn’t be loaded."}</p>
<div className="ds-actions">
<Button onClick={retry}>Try again</Button>
</div>
@@ -74,6 +77,7 @@ export function Home() {
}
function Welcome({ onSignIn }: { onSignIn: () => void }) {
const online = useOnline();
const [water, setWater] = useState(3);
const [read, setRead] = useState(false);
return (
@@ -93,7 +97,7 @@ function Welcome({ onSignIn }: { onSignIn: () => void }) {
day at a time.
</p>
<div className="ds-actions">
<DiscordSignInButton onClick={onSignIn} />
<DiscordSignInButton disabled={!online} onClick={onSignIn} />
<ButtonLink href="#try-it">Try it below ↓</ButtonLink>
</div>
<p className="type-small">Sign in with your Discord account to save your habits.</p>
@@ -230,14 +234,16 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
// Refresh after returning to the page and across the account's local midnight.
useEffect(() => {
const refresh = () => {
if (!saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
if (navigator.onLine !== false && !saving.current && progressSaves.current.size === 0 && document.visibilityState === "visible")
setAttempt((value) => value + 1);
};
const timer = window.setInterval(refresh, 60_000);
window.addEventListener("focus", refresh);
window.addEventListener("online", refresh);
return () => {
window.clearInterval(timer);
window.removeEventListener("focus", refresh);
window.removeEventListener("online", refresh);
};
}, []);