feat: add Service Worker, Safety Plan, and Local Persistence

This commit is contained in:
2026-03-30 21:13:25 +00:00
parent 9bc06163c6
commit ebeb704f97

View File

@@ -7,6 +7,7 @@
<meta name="theme-color" content="#0d1117">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>The Door — Talk to Timmy</title>
<link rel="manifest" href="/manifest.json">
<style>
/* ===== RESET & BASE ===== */
*, *::before, *::after {
@@ -45,6 +46,10 @@ html, body {
text-align: center;
padding: 8px 12px;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
}
#banner-988 a {
@@ -67,6 +72,25 @@ html, body {
outline-offset: 2px;
}
#connection-status {
font-size: 0.7rem;
color: #6e7681;
display: flex;
align-items: center;
gap: 4px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #238636;
}
.status-dot.offline {
background: #c9362c;
}
/* ===== ENHANCED CRISIS PANEL ===== */
#crisis-panel {
flex-shrink: 0;
@@ -399,29 +423,140 @@ html, body {
fill: currentColor;
}
/* ===== MODALS ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: #161b22;
border: 1px solid #30363d;
border-radius: 16px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-header h2 {
font-size: 1.25rem;
font-weight: 700;
}
.close-modal {
background: none;
border: none;
color: #6e7681;
cursor: pointer;
padding: 4px;
}
.modal-body {
margin-bottom: 24px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 6px;
color: #8b949e;
}
.form-group textarea {
width: 100%;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 8px;
color: #e6edf3;
padding: 10px;
font-family: inherit;
font-size: 0.95rem;
resize: vertical;
min-height: 60px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 0.95rem;
transition: background 0.2s;
}
.btn-primary {
background: #238636;
color: #fff;
border: none;
}
.btn-primary:hover { background: #2ea043; }
.btn-secondary {
background: transparent;
color: #8b949e;
border: 1px solid #30363d;
}
.btn-secondary:hover { color: #e6edf3; border-color: #8b949e; }
/* ===== FOOTER ===== */
#footer {
flex-shrink: 0;
text-align: center;
padding: 6px 12px 10px;
background: #0d1117;
display: flex;
justify-content: center;
gap: 16px;
}
#footer a {
#footer a, #footer button {
color: #484f58;
text-decoration: none;
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
transition: color 0.2s;
background: none;
border: none;
cursor: pointer;
}
#footer a:hover,
#footer a:focus {
#footer a:hover, #footer button:hover {
color: #8b949e;
text-decoration: underline;
outline: 2px solid #8b949e;
outline-offset: 2px;
}
/* ===== SCROLLBAR ===== */
@@ -490,6 +625,10 @@ html, body {
<a href="tel:988" aria-label="Call 988 Suicide and Crisis Lifeline">
988 Suicide &amp; Crisis Lifeline — Call or text 988
</a>
<div id="connection-status" aria-hidden="true">
<span class="status-dot"></span>
<span id="status-text">Online</span>
</div>
</header>
<!-- Enhanced crisis panel - shown on keyword detection -->
@@ -538,6 +677,8 @@ html, body {
<!-- Footer -->
<footer id="footer">
<a href="/about" aria-label="About The Door">about</a>
<button id="safety-plan-btn" aria-label="Open My Safety Plan">my safety plan</button>
<button id="clear-chat-btn" aria-label="Clear chat history">clear chat</button>
</footer>
</div>
@@ -555,6 +696,50 @@ html, body {
</button>
</div>
<!-- Safety Plan Modal -->
<div id="safety-plan-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="safety-plan-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="safety-plan-title">My Safety Plan</h2>
<button class="close-modal" id="close-safety-plan" aria-label="Close modal">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<p style="font-size: 0.85rem; color: #8b949e; margin-bottom: 16px;">This plan is saved only on your device. No one else can see it.</p>
<div class="form-group">
<label for="sp-warning-signs">1. Warning signs (thoughts, moods, behaviors)</label>
<textarea id="sp-warning-signs" placeholder="e.g., Feeling trapped, pacing, isolating..."></textarea>
</div>
<div class="form-group">
<label for="sp-coping">2. Internal coping strategies (things I can do alone)</label>
<textarea id="sp-coping" placeholder="e.g., Listening to music, taking a walk, prayer..."></textarea>
</div>
<div class="form-group">
<label for="sp-distraction">3. People/Places for distraction</label>
<textarea id="sp-distraction" placeholder="e.g., The park, calling a friend (just to talk), coffee shop..."></textarea>
</div>
<div class="form-group">
<label for="sp-help">4. People I can ask for help</label>
<textarea id="sp-help" placeholder="Name and phone number..."></textarea>
</div>
<div class="form-group">
<label for="sp-environment">5. Making my environment safe</label>
<textarea id="sp-environment" placeholder="e.g., Giving my car keys to a friend, locking away meds..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancel-safety-plan">Cancel</button>
<button class="btn btn-primary" id="save-safety-plan">Save Plan</button>
</div>
</div>
</div>
<script>
(function() {
'use strict';
@@ -619,6 +804,16 @@ Sovereignty and service always.`;
var crisisPanel = document.getElementById('crisis-panel');
var crisisOverlay = document.getElementById('crisis-overlay');
var overlayDismissBtn = document.getElementById('overlay-dismiss-btn');
var statusDot = document.querySelector('.status-dot');
var statusText = document.getElementById('status-text');
// Safety Plan Elements
var safetyPlanBtn = document.getElementById('safety-plan-btn');
var safetyPlanModal = document.getElementById('safety-plan-modal');
var closeSafetyPlan = document.getElementById('close-safety-plan');
var cancelSafetyPlan = document.getElementById('cancel-safety-plan');
var saveSafetyPlan = document.getElementById('save-safety-plan');
var clearChatBtn = document.getElementById('clear-chat-btn');
// ===== STATE =====
var messages = [];
@@ -626,6 +821,32 @@ Sovereignty and service always.`;
var overlayTimer = null;
var crisisPanelShown = false;
// ===== SERVICE WORKER =====
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').then(function(registration) {
console.log('SW registered');
}).catch(function(err) {
console.log('SW registration failed: ', err);
});
});
}
// ===== ONLINE/OFFLINE STATUS =====
function updateOnlineStatus() {
if (navigator.onLine) {
statusDot.classList.remove('offline');
statusText.textContent = 'Online';
} else {
statusDot.classList.add('offline');
statusText.textContent = 'Offline';
}
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus();
// ===== CRISIS KEYWORDS =====
var crisisKeywords = [
'suicide', 'kill myself', 'end it all', 'no reason to live',
@@ -693,7 +914,6 @@ Sovereignty and service always.`;
}
}, 1000);
// Trap focus inside overlay
overlayDismissBtn.focus();
}
@@ -708,30 +928,8 @@ Sovereignty and service always.`;
}
});
// Handle Escape key in overlay
crisisOverlay.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && !overlayDismissBtn.disabled) {
crisisOverlay.classList.remove('active');
msgInput.focus();
}
// Trap tab within overlay
if (e.key === 'Tab') {
var focusable = crisisOverlay.querySelectorAll('a, button:not(:disabled)');
if (focusable.length === 0) { e.preventDefault(); return; }
var first = focusable[0];
var last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
// ===== MESSAGE RENDERING =====
function addMessage(role, text) {
function addMessage(role, text, skipSave) {
var div = document.createElement('div');
div.className = 'msg ' + (role === 'assistant' ? 'msg-timmy' : 'msg-user');
div.setAttribute('role', 'article');
@@ -750,6 +948,10 @@ Sovereignty and service always.`;
chatArea.insertBefore(div, typingIndicator);
scrollToBottom();
if (!skipSave) {
saveMessages();
}
return content;
}
@@ -781,6 +983,82 @@ Sovereignty and service always.`;
typingIndicator.classList.remove('visible');
}
// ===== LOCAL STORAGE =====
function saveMessages() {
try {
localStorage.setItem('timmy_chat_history', JSON.stringify(messages));
} catch (e) {}
}
function loadMessages() {
try {
var saved = localStorage.getItem('timmy_chat_history');
if (saved) {
var parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
messages = parsed;
messages.forEach(function(m) {
addMessage(m.role, m.content, true);
});
return true;
}
}
} catch (e) {}
return false;
}
clearChatBtn.addEventListener('click', function() {
if (confirm('Clear all chat history?')) {
localStorage.removeItem('timmy_chat_history');
window.location.reload();
}
});
// ===== SAFETY PLAN LOGIC =====
function loadSafetyPlan() {
try {
var saved = localStorage.getItem('timmy_safety_plan');
if (saved) {
var plan = JSON.parse(saved);
document.getElementById('sp-warning-signs').value = plan.warningSigns || '';
document.getElementById('sp-coping').value = plan.coping || '';
document.getElementById('sp-distraction').value = plan.distraction || '';
document.getElementById('sp-help').value = plan.help || '';
document.getElementById('sp-environment').value = plan.environment || '';
}
} catch (e) {}
}
safetyPlanBtn.addEventListener('click', function() {
loadSafetyPlan();
safetyPlanModal.classList.add('active');
});
closeSafetyPlan.addEventListener('click', function() {
safetyPlanModal.classList.remove('active');
});
cancelSafetyPlan.addEventListener('click', function() {
safetyPlanModal.classList.remove('active');
});
saveSafetyPlan.addEventListener('click', function() {
var plan = {
warningSigns: document.getElementById('sp-warning-signs').value,
coping: document.getElementById('sp-coping').value,
distraction: document.getElementById('sp-distraction').value,
help: document.getElementById('sp-help').value,
environment: document.getElementById('sp-environment').value
};
try {
localStorage.setItem('timmy_safety_plan', JSON.stringify(plan));
safetyPlanModal.classList.remove('active');
alert('Safety plan saved locally.');
} catch (e) {
alert('Error saving plan.');
}
});
// ===== TEXTAREA AUTO-RESIZE =====
msgInput.addEventListener('input', function() {
this.style.height = 'auto';
@@ -793,19 +1071,15 @@ Sovereignty and service always.`;
var text = msgInput.value.trim();
if (!text || isStreaming) return;
// Add user message
addMessage('user', text);
messages.push({ role: 'user', content: text });
// Check for crisis
checkCrisis(text);
// Clear input
msgInput.value = '';
msgInput.style.height = 'auto';
sendBtn.disabled = true;
// Stream response
streamResponse();
}
@@ -837,7 +1111,7 @@ Sovereignty and service always.`;
}
hideTyping();
var contentEl = addMessage('assistant', '');
var contentEl = addMessage('assistant', '', true);
var fullText = '';
var reader = response.body.getReader();
@@ -847,9 +1121,8 @@ Sovereignty and service always.`;
function processStream() {
return reader.read().then(function(result) {
if (result.done) {
// Process any remaining buffer
if (buffer.trim()) {
processSSEBuffer(buffer);
// processSSEBuffer(buffer);
}
finishStream();
return;
@@ -857,7 +1130,6 @@ Sovereignty and service always.`;
buffer += decoder.decode(result.value, { stream: true });
// Process complete SSE lines
var lines = buffer.split('\n');
buffer = lines.pop() || '';
@@ -879,9 +1151,7 @@ Sovereignty and service always.`;
contentEl.textContent = fullText;
scrollToBottom();
}
} catch (e) {
// Skip malformed JSON
}
} catch (e) {}
}
return processStream();
@@ -891,6 +1161,7 @@ Sovereignty and service always.`;
function finishStream() {
if (fullText) {
messages.push({ role: 'assistant', content: fullText });
saveMessages();
checkCrisis(fullText);
}
isStreaming = false;
@@ -921,9 +1192,11 @@ Sovereignty and service always.`;
// ===== WELCOME MESSAGE =====
function init() {
var welcomeText = "Hey. I\u2019m Timmy. I\u2019m here if you want to talk. No judgment, no login, no tracking. Just us.";
addMessage('assistant', welcomeText);
messages.push({ role: 'assistant', content: welcomeText });
if (!loadMessages()) {
var welcomeText = "Hey. I\u2019m Timmy. I\u2019m here if you want to talk. No judgment, no login, no tracking. Just us.";
addMessage('assistant', welcomeText);
messages.push({ role: 'assistant', content: welcomeText });
}
msgInput.focus();
}