From d4ea3a6abd77c79885b8818acfc191d94eed71b1 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 13 Apr 2026 21:10:06 -0400 Subject: [PATCH] feat: cache offline crisis resources (closes #41) --- Makefile | 2 +- crisis-offline.html | 241 +++++++++++++++++++++++++++ pytest.ini | 4 +- sw.js | 219 ++++++++++++++---------- tests/test_service_worker_offline.py | 55 ++++++ 5 files changed, 426 insertions(+), 95 deletions(-) create mode 100644 crisis-offline.html create mode 100644 tests/test_service_worker_offline.py diff --git a/Makefile b/Makefile index f83d6cc..7eb101a 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ deploy-bash: push: rsync -avz --exclude='.git' --exclude='deploy' \ - index.html manifest.json sw.js about.html testimony.html system-prompt.txt \ + index.html manifest.json sw.js about.html crisis-offline.html testimony.html system-prompt.txt \ root@$(VPS):/var/www/the-door/ ssh root@$(VPS) "chown -R www-data:www-data /var/www/the-door" diff --git a/crisis-offline.html b/crisis-offline.html new file mode 100644 index 0000000..02f27e7 --- /dev/null +++ b/crisis-offline.html @@ -0,0 +1,241 @@ + + + + + + + +Offline Crisis Resources | The Door + + + +
+
+ + Offline crisis resources are ready on this device. +
+ +

You are not alone right now.

+

+ Your connection is weak or offline. These crisis resources are saved locally so you can still reach help. +

+ +
+

Immediate help

+

If you might act on suicidal thoughts, contact a real person now. Stay with another person if you can.

+ +

If you are in immediate danger or have already taken action, call emergency services now.

+
+ +
+
+

5-4-3-2-1 grounding

+
    +
  1. 5 things you can see
  2. +
  3. 4 things you can feel
  4. +
  5. 3 things you can hear
  6. +
  7. 2 things you can smell
  8. +
  9. 1 thing you can taste
  10. +
+

Say each one out loud if you can. Slow is okay.

+
+ +
+

Next small steps

+
    +
  • Put some distance between yourself and anything you could use to hurt yourself.
  • +
  • Move closer to another person, a front desk, or a public place if possible.
  • +
  • Drink water or hold something cold in your hand.
  • +
  • Breathe in for 4, hold for 4, out for 6. Repeat 5 times.
  • +
  • Text or call one safe person and say: “I need you with me right now.”
  • +
+
+
+ +
+

Stay through the next ten minutes

+

Do not solve your whole life right now. Stay for the next breath. Then the next one.

+

If the connection comes back, you can return to The Door chat. Until then, the fastest path to a real person is still 988.

+
+
+ + + + diff --git a/pytest.ini b/pytest.ini index daa6d22..594e230 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -testpaths = crisis -python_files = tests.py +testpaths = crisis tests +python_files = tests.py test_*.py python_classes = Test* python_functions = test_* diff --git a/sw.js b/sw.js index 75cfdfb..fcd5fe4 100644 --- a/sw.js +++ b/sw.js @@ -1,118 +1,153 @@ -const CACHE_NAME = 'the-door-v2'; -const ASSETS = [ +const CACHE_NAME = 'the-door-v3'; +const NAVIGATION_TIMEOUT_MS = 2500; +const OFFLINE_FALLBACK_PATH = '/crisis-offline.html'; +const PRECACHE_ASSETS = [ '/', '/index.html', - '/about', - '/manifest.json' + '/about.html', + '/manifest.json', + '/crisis-offline.html', + '/testimony.html' ]; -// Crisis resources to show when everything fails -const CRISIS_OFFLINE_RESPONSE = ` - - - - -You're Not Alone | The Door - - - -

You are not alone.

-

Your connection is down, but help is still available.

-
-

Call or text 988
Suicide & Crisis Lifeline
Free, 24/7, Confidential

- Call 988 Now -

Or text HOME to 741741
Crisis Text Line

-
-

When you're ready:

- -

- "The Lord is close to the brokenhearted and saves those who are crushed in spirit." — Psalm 34:18 -

-

- This page was created by The Door — a crisis intervention project.
- Connection will restore automatically. You don't have to go through this alone. -

- -`; +function isSameOrigin(request) { + return new URL(request.url).origin === self.location.origin; +} + +function canCache(response) { + return Boolean(response && response.ok && response.type !== 'opaque'); +} + +async function precache() { + const cache = await caches.open(CACHE_NAME); + await cache.addAll(PRECACHE_ASSETS); +} + +async function cleanupOldCaches() { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ); +} + +async function putInCache(request, response) { + if (!isSameOrigin(request) || !canCache(response)) { + return response; + } + + const cache = await caches.open(CACHE_NAME); + await cache.put(request, response.clone()); + return response; +} + +async function fetchWithTimeout(request, timeoutMs) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + return await fetch(request, { signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } +} + +async function offlineTextResponse() { + return new Response('Offline. Call 988 or text HOME to 741741 for immediate help.', { + status: 503, + statusText: 'Service Unavailable', + headers: new Headers({ 'Content-Type': 'text/plain; charset=utf-8' }) + }); +} + +async function handleNavigation(request) { + const cache = await caches.open(CACHE_NAME); + const cachedPage = await cache.match(request); + const offlineFallback = await cache.match(OFFLINE_FALLBACK_PATH); + + try { + const response = await fetchWithTimeout(request, NAVIGATION_TIMEOUT_MS); + return await putInCache(request, response); + } catch (error) { + if (cachedPage) { + return cachedPage; + } + + if (offlineFallback) { + return offlineFallback; + } + + return offlineTextResponse(); + } +} + +async function handleStaticRequest(request) { + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(request); + + if (cached) { + fetch(request) + .then((response) => putInCache(request, response)) + .catch(() => null); + return cached; + } + + try { + const response = await fetch(request); + return await putInCache(request, response); + } catch (error) { + return offlineTextResponse(); + } +} + +async function handleOtherRequest(request) { + try { + const response = await fetch(request); + return await putInCache(request, response); + } catch (error) { + const cached = await caches.match(request); + if (cached) { + return cached; + } + + return offlineTextResponse(); + } +} -// Install event - cache core assets self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(ASSETS); - }) + precache().then(() => self.skipWaiting()) ); - self.skipWaiting(); }); -// Activate event - cleanup old caches self.addEventListener('activate', (event) => { event.waitUntil( - caches.keys().then((keys) => { - return Promise.all( - keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)) - ); - }) + cleanupOldCaches().then(() => self.clients.claim()) ); - self.clients.claim(); }); -// Fetch event - network first, fallback to cache for static, -// but for the crisis front door, we want to ensure the shell is ALWAYS available. self.addEventListener('fetch', (event) => { - const url = new URL(event.request.url); + const request = event.request; + const url = new URL(request.url); - // Skip API calls - they should always go to network - if (url.pathname.startsWith('/api/')) { + if (request.method !== 'GET') { return; } - // Skip non-GET requests - if (event.request.method !== 'GET') { + if (!isSameOrigin(request) || url.pathname.startsWith('/api/')) { return; } - event.respondWith( - fetch(event.request) - .then((response) => { - // If we got a valid response, cache it for next time - if (response.ok && ASSETS.includes(url.pathname)) { - const copy = response.clone(); - caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy)); - } - return response; - }) - .catch(() => { - // If network fails, try cache - return caches.match(event.request).then((cached) => { - if (cached) return cached; + if (event.request.mode === 'navigate') { + event.respondWith(handleNavigation(request)); + return; + } - // If it's a navigation request and we're offline, show offline crisis page - if (event.request.mode === 'navigate') { - return new Response(CRISIS_OFFLINE_RESPONSE, { - status: 200, - headers: new Headers({ 'Content-Type': 'text/html' }) - }); - } + if (PRECACHE_ASSETS.includes(url.pathname)) { + event.respondWith(handleStaticRequest(request)); + return; + } - // For other requests, return a simple offline message - return new Response('Offline. Call 988 for immediate help.', { - status: 503, - statusText: 'Service Unavailable', - headers: new Headers({ 'Content-Type': 'text/plain' }) - }); - }); - }) - ); + event.respondWith(handleOtherRequest(request)); }); diff --git a/tests/test_service_worker_offline.py b/tests/test_service_worker_offline.py new file mode 100644 index 0000000..09b7adc --- /dev/null +++ b/tests/test_service_worker_offline.py @@ -0,0 +1,55 @@ +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SERVICE_WORKER = (ROOT / 'sw.js').read_text(encoding='utf-8') +CRISIS_OFFLINE_PAGE = ROOT / 'crisis-offline.html' +MAKEFILE = (ROOT / 'Makefile').read_text(encoding='utf-8') + + +class TestServiceWorkerOffline(unittest.TestCase): + def test_crisis_offline_page_exists(self): + self.assertTrue(CRISIS_OFFLINE_PAGE.exists(), 'crisis-offline.html should exist') + + def test_service_worker_precaches_crisis_offline_page(self): + self.assertIn('/crisis-offline.html', SERVICE_WORKER) + + def test_service_worker_has_navigation_timeout_for_intermittent_connections(self): + self.assertIn('NAVIGATION_TIMEOUT_MS', SERVICE_WORKER) + self.assertIn('AbortController', SERVICE_WORKER) + + def test_service_worker_uses_crisis_offline_fallback_for_navigation(self): + self.assertIn("event.request.mode === 'navigate'", SERVICE_WORKER) + self.assertIn("/crisis-offline.html", SERVICE_WORKER) + + def test_make_push_includes_crisis_offline_page(self): + self.assertIn('crisis-offline.html', MAKEFILE) + + +class TestCrisisOfflinePage(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.html = CRISIS_OFFLINE_PAGE.read_text(encoding='utf-8') if CRISIS_OFFLINE_PAGE.exists() else '' + cls.lower_html = cls.html.lower() + + def test_has_clickable_988_link(self): + self.assertIn('href="tel:988"', self.html) + + def test_has_crisis_text_line(self): + self.assertIn('Crisis Text Line', self.html) + self.assertIn('741741', self.html) + + def test_has_grounding_techniques(self): + required_phrases = [ + '5 things you can see', + '4 things you can feel', + '3 things you can hear', + '2 things you can smell', + '1 thing you can taste', + ] + for phrase in required_phrases: + self.assertIn(phrase, self.lower_html) + + +if __name__ == '__main__': + unittest.main()