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
+
+ - 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
+
+ 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:
-
- - Take five deep breaths
- - Drink some water
- - Step outside if you can
- - Text or call someone you trust
-
-
- "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()