hearth-dash 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker.js CHANGED
@@ -1,1057 +1,1570 @@
1
- /**
2
- * Hearth Dash — Personal Dashboard
3
- * Cloudflare Worker with D1 Database + R2 Photos + MCP Endpoint
4
- *
5
- * Deploy: npx wrangler deploy
6
- * Docs: https://github.com/martusha89/hearth-dash
7
- */
8
-
9
- async function getConfig(env) {
10
- let password = env.DASHBOARD_PASSWORD || null;
11
- // If no env var password, check D1 for one set via the setup page
12
- if (!password) {
13
- try {
14
- const row = await env.DB.prepare("SELECT value FROM config WHERE key = 'password'").first();
15
- if (row) password = row.value;
16
- } catch(e) {}
17
- }
1
+ /**
2
+ * Hearth Dash — Personal Dashboard
3
+ * Cloudflare Worker with D1 Database + R2 Photos + MCP Endpoint
4
+ *
5
+ * Deploy: npx wrangler deploy
6
+ * Docs: https://github.com/martusha89/hearth-dash
7
+ */
8
+
9
+ const encoder = new TextEncoder();
10
+ const MCP_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26'];
11
+ const MAX_JSON_BODY_BYTES = 64 * 1024;
12
+
13
+ function getConfig(env) {
18
14
  return {
19
- PASSWORD: password,
20
- MCP_SECRET: env.MCP_SECRET || 'change-me-too',
15
+ PASSWORD: env.DASHBOARD_PASSWORD || null,
16
+ SESSION_SECRET: env.SESSION_SECRET || null,
17
+ MCP_ALLOWED_ORIGINS: (env.MCP_ALLOWED_ORIGINS || 'https://claude.ai')
18
+ .split(',').map(value => value.trim()).filter(Boolean),
21
19
  PARTNER_1: env.PARTNER_1 || 'Partner 1',
22
20
  PARTNER_2: env.PARTNER_2 || 'Partner 2',
23
21
  };
24
22
  }
25
23
 
26
- function getSetupPage(error) {
27
- return `<!DOCTYPE html>
28
- <html lang="en">
29
- <head>
30
- <meta charset="UTF-8">
31
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
32
- <title>Hearth Dash — Welcome</title>
33
- <style>
34
- * { box-sizing: border-box; margin: 0; padding: 0; }
35
- body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f0f1a; color: #fafafa; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
36
- .setup-box { background: #1a1a2e; padding: 3rem; border-radius: 16px; text-align: center; max-width: 440px; width: 90%; border: 1px solid rgba(192,132,252,0.2); }
37
- h1 { font-size: 2rem; margin-bottom: 0.5rem; font-weight: 600; background: linear-gradient(135deg, #c084fc, #2dd4bf); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
38
- .subtitle { color: #71717a; margin-bottom: 2rem; }
39
- input[type="password"] { width: 100%; padding: 1rem; border: 1px solid #2a2a3e; border-radius: 10px; background: #0f0f1a; color: #fafafa; font-family: inherit; font-size: 1rem; margin-bottom: 0.75rem; }
40
- input[type="password"]:focus { outline: none; border-color: #c084fc; box-shadow: 0 0 0 3px rgba(192,132,252,0.15); }
41
- button { width: 100%; padding: 1rem; border: none; border-radius: 10px; background: linear-gradient(135deg, #c084fc, #a855f7); color: white; font-family: inherit; font-size: 1rem; cursor: pointer; font-weight: 600; }
42
- button:hover { background: linear-gradient(135deg, #d8b4fe, #c084fc); }
43
- .error { color: #f87171; margin-bottom: 1rem; }
44
- .note { color: #52525b; font-size: 0.8rem; margin-top: 1.5rem; }
45
- </style>
46
- </head>
47
- <body>
48
- <div class="setup-box">
49
- <h1>Hearth Dash</h1>
50
- <p class="subtitle">Welcome! Create a password to get started.</p>
51
- ${error || ''}
52
- <form method="POST" action="/setup">
53
- <input type="password" name="password" placeholder="Choose a password" autofocus required minlength="4">
54
- <input type="password" name="confirm" placeholder="Confirm password" required minlength="4">
55
- <button type="submit">Create Dashboard</button>
56
- </form>
57
- <p class="note">This password protects your dashboard. Remember it!</p>
58
- </div>
59
- </body>
60
- </html>`;
24
+ function base64Url(bytes) {
25
+ let binary = '';
26
+ for (const byte of bytes) binary += String.fromCharCode(byte);
27
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
61
28
  }
62
29
 
63
- function getLoginPage(config) {
64
- return `<!DOCTYPE html>
65
- <html lang="en">
66
- <head>
67
- <meta charset="UTF-8">
68
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
69
- <title>Hearth Dash</title>
70
- <style>
71
- * { box-sizing: border-box; margin: 0; padding: 0; }
72
- body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f0f1a; color: #fafafa; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
73
- .login-box { background: #1a1a2e; padding: 3rem; border-radius: 16px; text-align: center; max-width: 400px; width: 90%; border: 1px solid rgba(192,132,252,0.2); }
74
- h1 { font-size: 2rem; margin-bottom: 0.5rem; font-weight: 600; background: linear-gradient(135deg, #c084fc, #2dd4bf); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
75
- .subtitle { color: #71717a; margin-bottom: 2rem; }
76
- input[type="password"] { width: 100%; padding: 1rem; border: 1px solid #2a2a3e; border-radius: 10px; background: #0f0f1a; color: #fafafa; font-family: inherit; font-size: 1rem; margin-bottom: 1rem; }
77
- input[type="password"]:focus { outline: none; border-color: #c084fc; box-shadow: 0 0 0 3px rgba(192,132,252,0.15); }
78
- button { width: 100%; padding: 1rem; border: none; border-radius: 10px; background: linear-gradient(135deg, #c084fc, #a855f7); color: white; font-family: inherit; font-size: 1rem; cursor: pointer; font-weight: 600; }
79
- button:hover { background: linear-gradient(135deg, #d8b4fe, #c084fc); }
80
- .error { color: #f87171; margin-bottom: 1rem; }
81
- </style>
82
- </head>
83
- <body>
84
- <div class="login-box">
85
- <h1>Hearth Dash</h1>
86
- <p class="subtitle">Your personal dashboard</p>
87
- {{ERROR}}
88
- <form method="POST" action="/login">
89
- <input type="password" name="password" placeholder="Password" autofocus required>
90
- <button type="submit">Enter</button>
91
- </form>
92
- </div>
93
- </body>
94
- </html>`;
30
+ function fromBase64Url(value) {
31
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
32
+ const binary = atob(padded);
33
+ return Uint8Array.from(binary, char => char.charCodeAt(0));
95
34
  }
96
35
 
97
- function getDashboardHTML(config) {
98
- const P1 = config.PARTNER_1;
99
- const P2 = config.PARTNER_2;
100
- return `<!DOCTYPE html>
101
- <html lang="en">
102
- <head>
103
- <meta charset="UTF-8">
104
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
105
- <title>Hearth Dash</title>
106
- <style>
107
- * { box-sizing: border-box; margin: 0; padding: 0; }
108
- body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f0f1a; color: #fafafa; min-height: 100vh; line-height: 1.6; }
109
- .container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
110
- header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #2a2a3e; flex-wrap: wrap; gap: 0.5rem; }
111
- h1 { font-size: 1.75rem; font-weight: 600; background: linear-gradient(135deg, #c084fc, #2dd4bf); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
112
- nav { display: flex; flex-wrap: wrap; gap: 0.25rem; }
113
- nav a { color: #71717a; text-decoration: none; padding: 0.25rem 0.75rem; border-radius: 6px; font-size: 0.9rem; transition: all 0.2s; }
114
- nav a:hover, nav a.active { color: #c084fc; background: rgba(192,132,252,0.1); }
115
- .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }
116
- .card { background: #1a1a2e; border-radius: 14px; padding: 1.5rem; border: 1px solid #2a2a3e; }
117
- .card h2 { font-size: 0.85rem; color: #71717a; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; font-weight: 600; }
118
- .mood-display { display: flex; gap: 2rem; }
119
- .mood-person { flex: 1; }
120
- .mood-person h3 { font-size: 0.875rem; color: #52525b; margin-bottom: 0.5rem; }
121
- .mood-value { font-size: 1.5rem; }
122
- .mood-time { font-size: 0.75rem; color: #52525b; margin-top: 0.25rem; }
123
- .note-card { background: rgba(192,132,252,0.08); color: #e5e7eb; padding: 1rem; border-radius: 10px; font-style: italic; margin-bottom: 0.75rem; border: 1px solid rgba(192,132,252,0.15); }
124
- .note-meta { font-size: 0.75rem; color: #71717a; margin-top: 0.5rem; font-style: normal; }
125
- .moment-item { padding: 0.75rem 0; border-bottom: 1px solid #2a2a3e; }
126
- .moment-item:last-child { border-bottom: none; }
127
- .moment-date { font-size: 0.75rem; color: #71717a; }
128
- .countdown { font-size: 2rem; color: #c084fc; font-weight: 700; }
129
- .countdown-label { font-size: 0.875rem; color: #71717a; }
130
- input, select, textarea { padding: 0.75rem; border: 1px solid #2a2a3e; border-radius: 10px; background: #0f0f1a; color: #fafafa; font-family: inherit; font-size: 0.875rem; width: 100%; }
131
- input:focus, select:focus, textarea:focus { outline: none; border-color: #c084fc; box-shadow: 0 0 0 3px rgba(192,132,252,0.15); }
132
- textarea { resize: vertical; min-height: 80px; }
133
- .btn { padding: 0.75rem 1.5rem; border: none; border-radius: 10px; background: linear-gradient(135deg, #c084fc, #a855f7); color: white; font-family: inherit; cursor: pointer; font-size: 0.875rem; font-weight: 600; }
134
- .btn:hover { background: linear-gradient(135deg, #d8b4fe, #c084fc); }
135
- .btn-ghost { background: transparent; border: 1px solid #2a2a3e; color: #71717a; }
136
- .btn-ghost:hover { border-color: #c084fc; color: #c084fc; }
137
- .empty { color: #52525b; font-style: italic; }
138
- .tab-content { display: none; }
139
- .tab-content.active { display: block; }
140
-
141
- /* Weather */
142
- .weather-main { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; }
143
- .weather-temp { font-size: 2rem; font-weight: bold; }
144
- .weather-desc { color: #71717a; text-transform: capitalize; }
145
- .weather-details { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; font-size: 0.85rem; color: #d4d4d8; }
146
- .weather-details span { display: flex; justify-content: space-between; }
147
- .weather-details .label { color: #71717a; }
148
- .migraine-alert { margin-top: 0.75rem; padding: 0.5rem 0.75rem; border-radius: 8px; font-size: 0.8rem; }
149
- .migraine-alert.low { background: rgba(45,212,191,0.1); border: 1px solid rgba(45,212,191,0.3); color: #5eead4; }
150
- .migraine-alert.moderate { background: rgba(234,179,8,0.1); border: 1px solid rgba(234,179,8,0.3); color: #facc15; }
151
- .migraine-alert.high { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; }
152
- .shift-alert { margin-top: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 8px; font-size: 0.8rem; display: flex; align-items: center; gap: 0.5rem; }
153
- .shift-alert.watch { background: rgba(234,179,8,0.1); border: 1px solid rgba(234,179,8,0.3); color: #facc15; }
154
- .shift-alert.warning { background: rgba(192,132,252,0.1); border: 1px solid rgba(192,132,252,0.3); color: #d8b4fe; }
155
- .shift-alert.critical { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.4); color: #fca5a5; }
156
- .shift-arrow { font-size: 1.2rem; font-weight: bold; }
157
-
158
- /* Pressure */
159
- .pressure-canvas { width: 100%; height: 280px; display: block; background: #0f0f1a; border: 1px solid #2a2a3e; border-radius: 10px; }
160
- .pressure-legend { display: flex; gap: 1.5rem; margin-top: 0.5rem; font-size: 0.75rem; color: #71717a; }
161
- .pressure-legend span::before { content: ''; display: inline-block; width: 12px; height: 3px; margin-right: 0.35rem; vertical-align: middle; border-radius: 2px; }
162
- .pressure-legend .leg-hist::before { background: #2dd4bf; }
163
- .pressure-legend .leg-forecast::before { background: #2dd4bf; opacity: 0.5; }
164
- .pressure-legend .leg-now::before { background: #c084fc; }
165
- .pressure-legend .leg-watch::before { background: rgba(234,179,8,0.35); width: 12px; height: 8px; }
166
- .pressure-legend .leg-warn::before { background: rgba(192,132,252,0.35); width: 12px; height: 8px; }
167
- .pressure-legend .leg-crit::before { background: rgba(239,68,68,0.35); width: 12px; height: 8px; }
168
- .pressure-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1rem; }
169
- @media (max-width: 600px) { .pressure-stats { grid-template-columns: 1fr; } }
170
- .pressure-stat { background: #0f0f1a; border: 1px solid #2a2a3e; border-radius: 10px; padding: 0.85rem; }
171
- .pressure-stat-label { font-size: 0.7rem; color: #71717a; text-transform: uppercase; letter-spacing: 0.04em; }
172
- .pressure-stat-value { font-size: 1.3rem; font-weight: bold; margin-top: 0.25rem; }
173
-
174
- /* Shopping */
175
- .shop-category { font-size: 0.8rem; color: #c084fc; text-transform: uppercase; letter-spacing: 0.05em; margin: 1rem 0 0.5rem; padding-bottom: 0.25rem; border-bottom: 1px solid #2a2a3e; }
176
- .shop-category:first-child { margin-top: 0; }
177
- .shop-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid rgba(42,42,62,0.6); }
178
- .shop-item:last-child { border-bottom: none; }
179
- .shop-item.checked { opacity: 0.4; }
180
- .shop-item.checked .shop-text { text-decoration: line-through; }
181
- .shop-check { appearance: none; width: 20px; height: 20px; border-radius: 50%; border: 2px solid #2a2a3e; cursor: pointer; flex-shrink: 0; position: relative; }
182
- .shop-check:checked { border-color: #2dd4bf; background: #2dd4bf; }
183
- .shop-check:checked::after { content: '\\2713'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); color: #0f0f1a; font-size: 0.7rem; font-weight: bold; }
184
- .shop-text { flex: 1; }
185
- .shop-by { font-size: 0.7rem; color: #71717a; }
186
- .shop-del { background: none; border: none; color: #52525b; cursor: pointer; font-size: 0.9rem; padding: 0.25rem; }
187
- .shop-del:hover { color: #f87171; }
188
- .shop-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
189
- .shop-count { font-size: 1.5rem; font-weight: bold; color: #c084fc; }
190
-
191
- /* Food Diary */
192
- .fd-grid { display: grid; grid-template-columns: 380px 1fr; gap: 1.5rem; }
193
- @media (max-width: 900px) { .fd-grid { grid-template-columns: 1fr; } }
194
- .fd-meal-card { background: #0f0f1a; border: 1px solid #2a2a3e; border-radius: 10px; padding: 1rem; margin-bottom: 0.75rem; display: flex; gap: 1rem; align-items: start; }
195
- .fd-meal-card img { width: 80px; height: 80px; object-fit: cover; border-radius: 8px; flex-shrink: 0; cursor: pointer; }
196
- .fd-meal-card img.fd-expanded { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); width: auto; height: auto; max-width: 90vw; max-height: 90vh; z-index: 1000; border-radius: 12px; box-shadow: 0 0 40px rgba(0,0,0,0.8); }
197
- .fd-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 999; display: none; }
198
- .fd-meal-info { flex: 1; }
199
- .fd-meal-type { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: #c084fc; font-weight: 600; }
200
- .fd-meal-time { font-size: 0.7rem; color: #71717a; }
201
- .fd-meal-note { font-size: 0.85rem; color: #d4d4d8; margin-top: 0.25rem; }
202
- .fd-water-bar { height: 24px; background: #2a2a3e; border-radius: 12px; overflow: hidden; margin: 0.75rem 0; position: relative; }
203
- .fd-water-fill { height: 100%; background: linear-gradient(90deg, #2dd4bf, #14b8a6); border-radius: 12px; transition: width 0.4s ease; }
204
- .fd-water-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 0.75rem; font-weight: 600; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
205
- .fd-water-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; }
206
- .fd-water-btn { padding: 0.5rem 1rem; border: 1px solid #2a2a3e; border-radius: 8px; background: #0f0f1a; color: #2dd4bf; cursor: pointer; font-family: inherit; font-size: 0.85rem; transition: all 0.15s; }
207
- .fd-water-btn:hover { border-color: #2dd4bf; background: rgba(45,212,191,0.1); }
208
- .fd-water-total { font-size: 1.3rem; font-weight: bold; color: #2dd4bf; }
209
- .fd-water-target { font-size: 0.8rem; color: #71717a; }
210
- .fd-review-card { background: rgba(192,132,252,0.06); border: 1px solid rgba(192,132,252,0.2); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
211
- .fd-review-card .fd-review-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
212
- .fd-review-card .fd-review-name { color: #c084fc; font-weight: 700; font-size: 0.95rem; }
213
- .fd-review-card .fd-review-label { font-size: 0.7rem; color: #71717a; text-transform: uppercase; letter-spacing: 0.04em; }
214
- .fd-review-card .fd-review-text { font-size: 0.9rem; color: #e5e7eb; line-height: 1.6; }
215
- .fd-review-card .fd-review-date { font-size: 0.7rem; color: #71717a; margin-top: 0.75rem; }
216
- .fd-review-card.empty-review { border-style: dashed; opacity: 0.6; }
217
- .fd-review-card.empty-review .fd-review-text { color: #71717a; font-style: italic; }
218
- .fd-patterns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1rem; }
219
- @media (max-width: 600px) { .fd-patterns { grid-template-columns: 1fr; } }
220
- .fd-pattern { background: #0f0f1a; border: 1px solid #2a2a3e; border-radius: 10px; padding: 0.85rem; }
221
- .fd-pattern-label { font-size: 0.7rem; color: #71717a; text-transform: uppercase; letter-spacing: 0.04em; }
222
- .fd-pattern-value { font-size: 1.3rem; font-weight: bold; margin-top: 0.25rem; }
223
- .fd-photo-preview { max-width: 100%; max-height: 200px; border-radius: 8px; margin-top: 0.5rem; display: none; }
224
- .fd-upload-area { border: 2px dashed #2a2a3e; border-radius: 8px; padding: 1rem; text-align: center; color: #71717a; font-size: 0.85rem; cursor: pointer; margin-bottom: 0.5rem; transition: border-color 0.2s; }
225
- .fd-upload-area:hover { border-color: #c084fc; }
226
- .fd-upload-area.has-file { border-color: #2dd4bf; border-style: solid; }
227
- .fd-week-day { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid rgba(42,42,62,0.6); font-size: 0.8rem; }
228
- .fd-week-day:last-child { border-bottom: none; }
229
- .fd-meal-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
230
- .fd-meal-dot.logged { background: #2dd4bf; }
231
- .fd-meal-dot.missed { background: #2a2a3e; }
232
- </style>
233
- </head>
234
- <body>
235
- <div class="container">
236
- <header>
237
- <h1>Hearth Dash</h1>
238
- <nav>
239
- <a href="#dashboard" class="active" data-tab="dashboard">Dashboard</a>
240
- <a href="#moods" data-tab="moods">Moods</a>
241
- <a href="#notes" data-tab="notes">Notes</a>
242
- <a href="#moments" data-tab="moments">Moments</a>
243
- <a href="#dates" data-tab="dates">Dates</a>
244
- <a href="#shopping" data-tab="shopping">Shopping</a>
245
- <a href="#pressure" data-tab="pressure">Pressure</a>
246
- <a href="#food" data-tab="food">Food</a>
247
- <a href="/logout">Logout</a>
248
- </nav>
249
- </header>
250
-
251
- <!-- DASHBOARD TAB -->
252
- <div id="dashboard" class="tab-content active">
253
- <div class="grid">
254
- <div class="card"><h2>Current Moods</h2><div class="mood-display" id="mood-display"><div class="mood-person"><h3>${P1}</h3><div class="mood-value" id="p1-mood">&mdash;</div><div class="mood-time" id="p1-mood-time"></div></div><div class="mood-person"><h3>${P2}</h3><div class="mood-value" id="p2-mood">&mdash;</div><div class="mood-time" id="p2-mood-time"></div></div></div></div>
255
- <div class="card"><h2>Latest Note</h2><div id="latest-note"><p class="empty">No notes yet</p></div></div>
256
- <div class="card"><h2>Next Date</h2><div id="next-date"><p class="empty">No upcoming dates</p></div></div>
257
- <div class="card"><h2>Weather</h2><div id="weather-display"><p class="empty">Loading weather...</p></div></div>
258
- <div class="card"><h2>Shopping</h2><div id="shop-dashboard"><p class="empty">Loading...</p></div></div>
259
- <div class="card"><h2>Food Diary</h2><div id="food-dashboard"><p class="empty">Loading...</p></div></div>
260
- </div>
261
- </div>
262
-
263
- <!-- MOODS TAB -->
264
- <div id="moods" class="tab-content">
265
- <div class="grid">
266
- <div class="card"><h2>Log Mood</h2><form id="mood-form"><select name="partner" required style="margin-bottom:0.5rem"><option value="">Who are you?</option><option value="${P1}">${P1}</option><option value="${P2}">${P2}</option></select><select name="mood" required style="margin-bottom:0.5rem"><option value="">How are you feeling?</option><option value="great">&#128522; Great</option><option value="good">&#128578; Good</option><option value="okay">&#128528; Okay</option><option value="tired">&#128564; Tired</option><option value="stressed">&#128560; Stressed</option><option value="low">&#128532; Low</option></select><textarea name="note" placeholder="Any notes? (optional)" style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Log Mood</button></form></div>
267
- <div class="card"><h2>Recent Moods</h2><div id="mood-history"><p class="empty">No mood entries yet</p></div></div>
268
- </div>
269
- </div>
270
-
271
- <!-- NOTES TAB -->
272
- <div id="notes" class="tab-content">
273
- <div class="grid">
274
- <div class="card"><h2>Leave a Note</h2><form id="note-form"><select name="from" required style="margin-bottom:0.5rem"><option value="">From</option><option value="${P1}">${P1}</option><option value="${P2}">${P2}</option></select><textarea name="content" placeholder="Your note..." required style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Post Note</button></form></div>
275
- <div class="card" style="grid-column:span 2"><h2>Fridge Notes</h2><div id="notes-display"><p class="empty">No notes on the fridge</p></div></div>
276
- </div>
277
- </div>
278
-
279
- <!-- MOMENTS TAB -->
280
- <div id="moments" class="tab-content">
281
- <div class="grid">
282
- <div class="card"><h2>Add Moment</h2><form id="moment-form"><input type="date" name="date" required style="margin-bottom:0.5rem"><input type="text" name="title" placeholder="What happened?" required style="margin-bottom:0.5rem"><textarea name="description" placeholder="Tell me more..." style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Save Moment</button></form></div>
283
- <div class="card" style="grid-column:span 2"><h2>Timeline</h2><div id="moments-timeline"><p class="empty">No moments recorded yet</p></div></div>
284
- </div>
285
- </div>
286
-
287
- <!-- DATES TAB -->
288
- <div id="dates" class="tab-content">
289
- <div class="grid">
290
- <div class="card">
291
- <h2>Add Date</h2>
292
- <form id="date-form">
293
- <input type="date" name="date" required style="margin-bottom:0.5rem">
294
- <input type="text" name="title" placeholder="What's the occasion?" required style="margin-bottom:0.5rem">
295
- <label style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;font-size:0.85rem;color:#71717a"><input type="checkbox" name="recurring"> Recurring yearly</label>
296
- <button type="submit" class="btn">Save Date</button>
297
- </form>
298
- </div>
299
- <div class="card" style="grid-column:span 2">
300
- <h2>Important Dates</h2>
301
- <div id="dates-list"><p class="empty">No dates saved yet</p></div>
302
- </div>
303
- </div>
304
- </div>
305
-
306
- <!-- SHOPPING TAB -->
307
- <div id="shopping" class="tab-content">
308
- <div class="grid">
309
- <div class="card">
310
- <h2>Add Item</h2>
311
- <form id="shop-form">
312
- <input type="text" name="item" placeholder="What do you need?" required style="margin-bottom:0.5rem">
313
- <select name="category" style="margin-bottom:0.5rem">
314
- <option value="Groceries">Groceries</option>
315
- <option value="Household">Household</option>
316
- <option value="Health">Health</option>
317
- <option value="Other">Other</option>
318
- </select>
319
- <select name="added_by" required style="margin-bottom:0.5rem">
320
- <option value="${P1}">${P1}</option>
321
- <option value="${P2}">${P2}</option>
322
- </select>
323
- <button type="submit" class="btn">Add</button>
324
- </form>
325
- </div>
326
- <div class="card" style="grid-column:span 2">
327
- <h2>Shopping List</h2>
328
- <div id="shop-list"><p class="empty">Nothing on the list</p></div>
329
- <div class="shop-actions">
330
- <button class="btn btn-ghost" id="shop-clear-checked">Clear Checked</button>
331
- </div>
332
- </div>
333
- </div>
334
- </div>
335
-
336
- <!-- PRESSURE TAB -->
337
- <div id="pressure" class="tab-content">
338
- <div class="grid">
339
- <div class="card" style="grid-column:1/-1">
340
- <h2>Pressure Overview</h2>
341
- <div class="pressure-stats">
342
- <div class="pressure-stat">
343
- <div class="pressure-stat-label">Current</div>
344
- <div class="pressure-stat-value" id="pr-current">&ndash;</div>
345
- </div>
346
- <div class="pressure-stat">
347
- <div class="pressure-stat-label">6h Change (past)</div>
348
- <div class="pressure-stat-value" id="pr-past-shift">&ndash;</div>
349
- </div>
350
- <div class="pressure-stat">
351
- <div class="pressure-stat-label">6h Change (forecast)</div>
352
- <div class="pressure-stat-value" id="pr-future-shift">&ndash;</div>
353
- </div>
354
- </div>
355
- </div>
356
- <div class="card" style="grid-column:1/-1">
357
- <h2>Pressure Trend</h2>
358
- <canvas class="pressure-canvas" id="pressure-chart"></canvas>
359
- <div class="pressure-legend">
360
- <span class="leg-hist">History</span>
361
- <span class="leg-forecast">Forecast</span>
362
- <span class="leg-now">Now</span>
363
- <span class="leg-watch">Watch</span>
364
- <span class="leg-warn">Warning</span>
365
- <span class="leg-crit">Critical</span>
366
- </div>
367
- </div>
368
- </div>
369
- </div>
370
-
371
- <!-- FOOD DIARY TAB -->
372
- <div id="food" class="tab-content">
373
- <div class="fd-grid">
374
- <section>
375
- <div class="card" style="margin-bottom:1rem;">
376
- <h2>Log Meal</h2>
377
- <form id="food-form">
378
- <div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;margin-bottom:0.5rem;">
379
- <select id="fd-meal-type" name="meal_type" required>
380
- <option value="">Meal type</option>
381
- <option value="breakfast">Breakfast</option>
382
- <option value="lunch">Lunch</option>
383
- <option value="dinner">Dinner</option>
384
- <option value="snack">Snack</option>
385
- </select>
386
- <input type="time" id="fd-time" name="time">
387
- </div>
388
- <textarea name="note" id="fd-note" placeholder="What did you eat?" style="margin-bottom:0.5rem"></textarea>
389
- <div class="fd-upload-area" id="fd-upload-area">
390
- <span id="fd-upload-text">Tap to add photo (optional)</span>
391
- <input type="file" id="fd-photo" accept="image/*" style="display:none">
392
- </div>
393
- <img id="fd-preview" class="fd-photo-preview" alt="Preview">
394
- <button type="submit" class="btn" style="margin-top:0.5rem">Log Meal</button>
395
- </form>
396
- </div>
397
- <div class="card">
398
- <h2>Water</h2>
399
- <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.5rem;">
400
- <span class="fd-water-total" id="fd-water-total">0 ml</span>
401
- <span class="fd-water-target">/ 2000 ml</span>
402
- </div>
403
- <div class="fd-water-bar">
404
- <div class="fd-water-fill" id="fd-water-fill" style="width:0%"></div>
405
- <div class="fd-water-label" id="fd-water-pct">0%</div>
406
- </div>
407
- <div class="fd-water-btns">
408
- <button class="fd-water-btn" data-ml="250">Glass (250ml)</button>
409
- <button class="fd-water-btn" data-ml="500">Bottle (500ml)</button>
410
- <button class="fd-water-btn" data-ml="150">Small (150ml)</button>
411
- <button class="fd-water-btn" data-ml="750">Large (750ml)</button>
412
- </div>
413
- </div>
414
- </section>
415
- <section>
416
- <div id="fd-review-area"></div>
417
- <div class="fd-patterns" id="fd-patterns">
418
- <div class="fd-pattern"><div class="fd-pattern-label">Today's Meals</div><div class="fd-pattern-value" id="fd-today-count">0</div></div>
419
- <div class="fd-pattern"><div class="fd-pattern-label">Water</div><div class="fd-pattern-value" id="fd-today-water" style="color:#2dd4bf">0 ml</div></div>
420
- <div class="fd-pattern"><div class="fd-pattern-label">This Week</div><div class="fd-pattern-value" id="fd-week-avg">--</div></div>
421
- </div>
422
- <div class="card" style="margin-bottom:1rem;">
423
- <h2>Today's Meals</h2>
424
- <div id="fd-today-meals"><p class="empty">Nothing logged yet today</p></div>
425
- </div>
426
- <div class="card" style="margin-bottom:1rem;">
427
- <h2>This Week</h2>
428
- <div id="fd-week-view"><p class="empty">Log meals to see weekly patterns</p></div>
429
- </div>
430
- <div class="card">
431
- <h2>Past Reviews</h2>
432
- <div id="fd-past-reviews"><p class="empty">No reviews yet</p></div>
433
- </div>
434
- </section>
435
- </div>
436
- <div class="fd-overlay" id="fd-overlay"></div>
437
- </div>
438
- </div>
439
-
440
- <script>
441
- const P1 = ${JSON.stringify(P1)};
442
- const P2 = ${JSON.stringify(P2)};
443
-
444
- /* === Tab Navigation === */
445
- document.querySelectorAll('nav a[data-tab]').forEach(link=>{link.addEventListener('click',e=>{e.preventDefault();const tab=e.target.dataset.tab;document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));document.querySelectorAll('nav a[data-tab]').forEach(l=>l.classList.remove('active'));document.getElementById(tab).classList.add('active');e.target.classList.add('active');window.location.hash=tab;if(tab==='pressure'&&_pressureGraphData)setTimeout(()=>drawPressureChart(_pressureGraphData),50)})});
446
- if(window.location.hash){const tab=window.location.hash.substring(1);const tabEl=document.getElementById(tab);const linkEl=document.querySelector('nav a[data-tab="'+tab+'"]');if(tabEl&&linkEl){document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));document.querySelectorAll('nav a[data-tab]').forEach(l=>l.classList.remove('active'));tabEl.classList.add('active');linkEl.classList.add('active');if(tab==='pressure')setTimeout(()=>{if(_pressureGraphData)drawPressureChart(_pressureGraphData)},500)}}
447
-
448
- /* === API Helper === */
449
- async function api(endpoint,method='GET',data=null){const opts={method,headers:{'Content-Type':'application/json'}};if(data)opts.body=JSON.stringify(data);const res=await fetch('/api'+endpoint,opts);return res.json()}
450
- function moodEmoji(m){return{great:'\\u{1F60A}',good:'\\u{1F642}',okay:'\\u{1F610}',tired:'\\u{1F634}',stressed:'\\u{1F630}',low:'\\u{1F614}'}[m]||'\\u2753'}
451
- function timeAgo(d){const s=Math.floor((new Date()-new Date(d+'Z'))/1000);if(s<60)return'just now';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';if(s<604800)return Math.floor(s/86400)+'d ago';return new Date(d).toLocaleDateString('en-GB',{day:'numeric',month:'short'})}
452
- function sanitize(s=''){return s.replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
453
-
454
- /* === Dashboard === */
455
- async function loadDashboard(){try{const data=await api('/dashboard');
456
- if(data.moods){
457
- if(data.moods[P1]){document.getElementById('p1-mood').textContent=moodEmoji(data.moods[P1].mood);document.getElementById('p1-mood-time').textContent=timeAgo(data.moods[P1].created_at)}
458
- if(data.moods[P2]){document.getElementById('p2-mood').textContent=moodEmoji(data.moods[P2].mood);document.getElementById('p2-mood-time').textContent=timeAgo(data.moods[P2].created_at)}
459
- }
460
- if(data.latestNote){document.getElementById('latest-note').innerHTML='<div class="note-card">"'+sanitize(data.latestNote.content)+'"<div class="note-meta">\\u2014 '+sanitize(data.latestNote.from_partner)+', '+timeAgo(data.latestNote.created_at)+'</div></div>'}
461
- if(data.nextDate){const daysUntil=Math.ceil((new Date(data.nextDate.date)-new Date())/(1000*60*60*24));document.getElementById('next-date').innerHTML='<div class="countdown">'+daysUntil+'</div><div class="countdown-label">days until '+sanitize(data.nextDate.title)+'</div>'}
462
- if(data.shoppingCount!==undefined){const sel=document.getElementById('shop-dashboard');sel.innerHTML=data.shoppingCount>0?'<div class="shop-count">'+data.shoppingCount+'</div><div style="color:#71717a;font-size:0.85rem">items to get</div>':'<p class="empty">List is clear</p>'}
463
- if(data.todayMeals!==undefined){const fdEl=document.getElementById('food-dashboard');const mc=data.todayMeals.length;const wm=data.waterTotal||0;const mt=data.todayMeals.map(m=>m.meal_type);const hB=mt.includes('breakfast'),hL=mt.includes('lunch'),hD=mt.includes('dinner');let fh='<div style="display:flex;gap:1.5rem;align-items:center"><div><div class="shop-count">'+mc+'</div><div style="color:#71717a;font-size:0.75rem">meals today</div></div><div><div style="font-size:0.8rem">'+(hB?'\\u2705':'\\u2B1C')+' Breakfast '+(hL?'\\u2705':'\\u2B1C')+' Lunch '+(hD?'\\u2705':'\\u2B1C')+' Dinner</div><div style="font-size:0.8rem;color:#2dd4bf;margin-top:0.25rem">\\u{1F4A7} '+wm+' / 2000 ml</div></div></div>';fdEl.innerHTML=fh}
464
- }catch(err){console.error(err)}}
465
-
466
- /* === Tab Loaders === */
467
- async function loadMoods(){try{const data=await api('/moods');const c=document.getElementById('mood-history');if(data.moods&&data.moods.length>0){c.innerHTML=data.moods.map(m=>'<div class="moment-item"><strong>'+sanitize(m.partner)+'</strong>: '+moodEmoji(m.mood)+' '+m.mood+(m.note?'<br><small style="color:#71717a">'+sanitize(m.note)+'</small>':'')+'<div class="moment-date">'+timeAgo(m.created_at)+'</div></div>').join('')}}catch(err){console.error(err)}}
468
- async function loadNotes(){try{const data=await api('/notes');const c=document.getElementById('notes-display');if(data.notes&&data.notes.length>0){c.innerHTML=data.notes.map(n=>'<div class="note-card">"'+sanitize(n.content)+'"<div class="note-meta">\\u2014 '+sanitize(n.from_partner)+', '+timeAgo(n.created_at)+'</div></div>').join('')}}catch(err){console.error(err)}}
469
- async function loadMoments(){try{const data=await api('/moments');const c=document.getElementById('moments-timeline');if(data.moments&&data.moments.length>0){c.innerHTML=data.moments.map(m=>'<div class="moment-item"><strong>'+sanitize(m.title)+'</strong>'+(m.description?'<br><small style="color:#71717a">'+sanitize(m.description)+'</small>':'')+'<div class="moment-date">'+new Date(m.date).toLocaleDateString('en-GB',{day:'numeric',month:'long',year:'numeric'})+'</div></div>').join('')}}catch(err){console.error(err)}}
470
-
471
- /* === Form Handlers === */
472
- document.getElementById('mood-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/moods','POST',{partner:f.get('partner'),mood:f.get('mood'),note:f.get('note')});e.target.reset();loadMoods();loadDashboard()});
473
- document.getElementById('note-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/notes','POST',{from:f.get('from'),content:f.get('content')});e.target.reset();loadNotes();loadDashboard()});
474
- document.getElementById('moment-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/moments','POST',{date:f.get('date'),title:f.get('title'),description:f.get('description')});e.target.reset();loadMoments()});
475
-
476
- /* === Dates === */
477
- async function loadDates(){try{const data=await api('/dates');const el=document.getElementById('dates-list');if(!data.dates||!data.dates.length){el.innerHTML='<p class="empty">No dates saved yet</p>';return;}const now=new Date();now.setHours(0,0,0,0);el.innerHTML=data.dates.map(d=>{const dt=new Date(d.date+'T00:00:00');const diff=Math.ceil((dt-now)/(1000*60*60*24));let countdown='';if(diff===0)countdown='<span style="color:#2dd4bf;font-weight:600">Today!</span>';else if(diff===1)countdown='<span style="color:#2dd4bf">Tomorrow</span>';else if(diff>0)countdown='<span style="color:#71717a">in '+diff+' days</span>';else countdown='<span style="color:#52525b">'+Math.abs(diff)+' days ago</span>';return'<div class="moment-item" style="display:flex;justify-content:space-between;align-items:center"><div><strong>'+sanitize(d.title)+'</strong>'+(d.recurring?' <span style="font-size:0.7rem;color:#c084fc">\\u{1F501} yearly</span>':'')+'<div class="moment-date">'+dt.toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'})+' \\u00B7 '+countdown+'</div></div><button class="shop-del" data-del="'+d.id+'">\\u2715</button></div>'}).join('');el.querySelectorAll('.shop-del').forEach(b=>b.onclick=async()=>{await api('/dates/'+b.dataset.del,'DELETE');loadDates()})}catch(err){console.error(err)}}
478
- document.getElementById('date-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/dates','POST',{date:f.get('date'),title:f.get('title'),recurring:f.get('recurring')==='on'});e.target.reset();loadDates();loadDashboard()});
479
-
480
- /* === Weather === */
481
- async function loadWeather(){try{const data=await fetch('/api/weather').then(r=>r.json());const el=document.getElementById('weather-display');if(data.error){el.innerHTML='<p class="empty">'+sanitize(data.error)+'</p>';return}let shiftHtml='';if(data.shift_alert&&data.shift_alert!=='none'){const arrow=data.pressure_trend==='falling'?'\\u2198':data.pressure_trend==='rising'?'\\u2197':'\\u2192';const s6=Math.abs(data.shift_6h_future||0);const s24=Math.abs(data.shift_24h_future||0);const use24h=s24>s6*2;const shiftVal=use24h?s24.toFixed(0):s6.toFixed(1);const window_=use24h?'24h':'6h';const dir=(data.shift_6h_future||0)<0||(data.shift_24h_future||0)<0?'dropping':'rising';shiftHtml='<div class="shift-alert '+data.shift_alert+'"><span class="shift-arrow">'+arrow+'</span><span>Pressure '+dir+' ~'+shiftVal+' hPa over next '+window_+'</span></div>'}el.innerHTML='<div class="weather-main"><img src="https://openweathermap.org/img/wn/'+data.icon+'@2x.png" width="50" height="50" alt="'+sanitize(data.description)+'" style="filter:brightness(1.2)"><div><div class="weather-temp">'+data.temp+'\\u00B0C</div><div class="weather-desc">'+sanitize(data.description)+'</div></div></div><div class="weather-details"><span><span class="label">Feels like</span> '+data.feels_like+'\\u00B0C</span><span><span class="label">Humidity</span> '+data.humidity+'%</span><span><span class="label">Pressure</span> '+data.pressure+' hPa</span><span><span class="label">Wind</span> '+data.wind+' mph</span></div><div class="migraine-alert '+data.migraine_risk+'">'+(data.migraine_risk==='low'?'\\u{1F7E2}':data.migraine_risk==='moderate'?'\\u{1F7E1}':'\\u{1F534}')+' '+sanitize(data.migraine_note)+'</div>'+shiftHtml+'<div style="font-size:0.65rem;color:#52525b;margin-top:0.5rem">'+sanitize(data.location)+' \\u00B7 Updated '+timeAgo(data.updated)+'</div>'}catch(err){document.getElementById('weather-display').innerHTML='<p class="empty">Could not load weather</p>';console.error(err)}}
482
-
483
- /* === Pressure Chart === */
484
- let _pressureGraphData=null;
485
- const $=id=>document.getElementById(id);
486
- async function loadPressure(){try{const[weatherData,pressureData]=await Promise.all([fetch('/api/weather').then(r=>r.json()),fetch('/api/pressure').then(r=>r.json())]);if(weatherData.pressure)$('pr-current').textContent=weatherData.pressure+' hPa';if(weatherData.shift_6h_past!==undefined){const v=weatherData.shift_6h_past;const el=$('pr-past-shift');el.textContent=(v>0?'+':'')+v.toFixed(1)+' hPa';el.style.color=Math.abs(v)>=8?'#fca5a5':Math.abs(v)>=4?'#facc15':'#5eead4'}if(weatherData.shift_6h_future!==undefined){const v=weatherData.shift_6h_future;const el=$('pr-future-shift');el.textContent=(v>0?'+':'')+v.toFixed(1)+' hPa';el.style.color=Math.abs(v)>=8?'#fca5a5':Math.abs(v)>=4?'#facc15':'#5eead4'}if(pressureData&&!pressureData.error){_pressureGraphData=pressureData;if(document.getElementById('pressure').classList.contains('active'))drawPressureChart(pressureData)}}catch(err){console.error('Pressure load error:',err)}}
487
-
488
- function drawPressureChart(data){const c=$('pressure-chart');const ctx=c.getContext('2d');const dpr=window.devicePixelRatio||1;const W=c.width=c.clientWidth*dpr;const H=c.height=280*dpr;ctx.clearRect(0,0,W,H);const history=data.history||[];const forecast=data.forecast||[];if(!history.length&&!forecast.length){ctx.fillStyle='#71717a';ctx.font=(12*dpr)+'px system-ui';ctx.fillText('No pressure data yet',20*dpr,30*dpr);return}const allPoints=[];history.forEach(h=>allPoints.push({ts:new Date(h.recorded_at+'Z').getTime(),pressure:h.pressure_hpa,type:'history'}));if(data.current)allPoints.push({ts:new Date(data.current.timestamp+'Z').getTime(),pressure:data.current.pressure,type:'current'});forecast.forEach(f=>allPoints.push({ts:new Date(f.dt_txt+'Z').getTime(),pressure:f.pressure,type:'forecast'}));if(!allPoints.length)return;allPoints.sort((a,b)=>a.ts-b.ts);const nowMs=Date.now();const windowStart=nowMs-12*60*60*1000;const windowEnd=nowMs+36*60*60*1000;const visible=allPoints.filter(p=>p.ts>=windowStart&&p.ts<=windowEnd);if(!visible.length)visible.push(...allPoints);const minT=visible[0].ts;const maxT=visible[visible.length-1].ts;const timeRange=maxT-minT||1;const pressures=visible.map(p=>p.pressure);let minP=Math.min(...pressures);let maxP=Math.max(...pressures);const pPad=Math.max(3,(maxP-minP)*0.1);minP-=pPad;maxP+=pPad;const pRange=maxP-minP||1;const pad={top:30*dpr,right:50*dpr,bottom:40*dpr,left:55*dpr};const plotW=W-pad.left-pad.right;const plotH=H-pad.top-pad.bottom;const toX=ts=>pad.left+((ts-minT)/timeRange)*plotW;const toY=p=>pad.top+plotH-((p-minP)/pRange)*plotH;ctx.strokeStyle='rgba(255,255,255,0.06)';ctx.lineWidth=dpr;const pStep=pRange>20?5:pRange>10?2:1;for(let p=Math.ceil(minP/pStep)*pStep;p<=maxP;p+=pStep){const y=toY(p);ctx.beginPath();ctx.moveTo(pad.left,y);ctx.lineTo(W-pad.right,y);ctx.stroke();ctx.fillStyle='#52525b';ctx.font=(10*dpr)+'px system-ui';ctx.textAlign='right';ctx.fillText(p.toFixed(0)+' hPa',pad.left-6*dpr,y+4*dpr)}ctx.textAlign='center';ctx.fillStyle='#52525b';ctx.font=(9*dpr)+'px system-ui';const totalHours=timeRange/(1000*60*60);const labelStep=totalHours>96?24:totalHours>48?12:6;const firstHour=new Date(minT);firstHour.setMinutes(0,0,0);firstHour.setHours(Math.ceil(firstHour.getHours()/labelStep)*labelStep);for(let t=firstHour.getTime();t<=maxT;t+=labelStep*60*60*1000){const x=toX(t);const d=new Date(t);ctx.fillText(d.getUTCDate()+'/'+(d.getUTCMonth()+1)+' '+d.getUTCHours()+':00',x,H-pad.bottom+16*dpr)}const nowTs=Date.now();if(nowTs>=minT&&nowTs<=maxT){const nx=toX(nowTs);ctx.strokeStyle='rgba(192,132,252,0.5)';ctx.lineWidth=2*dpr;ctx.setLineDash([4*dpr,4*dpr]);ctx.beginPath();ctx.moveTo(nx,pad.top);ctx.lineTo(nx,H-pad.bottom);ctx.stroke();ctx.setLineDash([]);ctx.fillStyle='#c084fc';ctx.font=(10*dpr)+'px system-ui';ctx.textAlign='center';ctx.fillText('Now',nx,pad.top-6*dpr)}const SIX_H=6*60*60*1000;for(let i=0;i<visible.length;i++){let j=i+1;while(j<visible.length&&(visible[j].ts-visible[i].ts)<SIX_H)j++;if(j>=visible.length)continue;const delta=Math.abs(visible[j].pressure-visible[i].pressure);let color=null;if(delta>=12)color='rgba(239,68,68,0.12)';else if(delta>=8)color='rgba(192,132,252,0.10)';else if(delta>=4)color='rgba(234,179,8,0.08)';if(color){ctx.fillStyle=color;ctx.fillRect(toX(visible[i].ts),pad.top,toX(visible[j].ts)-toX(visible[i].ts),plotH)}}const histPts=visible.filter(p=>p.type==='history'||p.type==='current');if(histPts.length>1){ctx.strokeStyle='#2dd4bf';ctx.lineWidth=2.5*dpr;ctx.beginPath();histPts.forEach((p,i)=>{const x=toX(p.ts),y=toY(p.pressure);if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)});ctx.stroke()}const fcPts=visible.filter(p=>p.type==='forecast');const bridgePoint=histPts.length?histPts[histPts.length-1]:null;if(fcPts.length>0){ctx.strokeStyle='rgba(45,212,191,0.45)';ctx.lineWidth=2*dpr;ctx.setLineDash([6*dpr,4*dpr]);ctx.beginPath();if(bridgePoint)ctx.moveTo(toX(bridgePoint.ts),toY(bridgePoint.pressure));fcPts.forEach((p,i)=>{const x=toX(p.ts),y=toY(p.pressure);if(!bridgePoint&&i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)});ctx.stroke();ctx.setLineDash([])}ctx.strokeStyle='rgba(255,255,255,.15)';ctx.lineWidth=dpr;ctx.beginPath();ctx.moveTo(pad.left,pad.top);ctx.lineTo(pad.left,H-pad.bottom);ctx.lineTo(W-pad.right,H-pad.bottom);ctx.stroke()}
489
-
490
- /* === Shopping === */
491
- async function loadShopping(){try{const data=await api('/shopping');const el=document.getElementById('shop-list');if(!data.items||!data.items.length){el.innerHTML='<p class="empty">Nothing on the list</p>';return}const groups={};data.items.forEach(i=>{if(!groups[i.category])groups[i.category]=[];groups[i.category].push(i)});let html='';for(const[cat,items]of Object.entries(groups)){html+='<div class="shop-category">'+sanitize(cat)+'</div>';items.forEach(i=>{html+='<div class="shop-item'+(i.checked?' checked':'')+'"><input type="checkbox" class="shop-check" data-id="'+i.id+'"'+(i.checked?' checked':'')+'><span class="shop-text">'+sanitize(i.item)+'</span><span class="shop-by">'+sanitize(i.added_by)+'</span><button class="shop-del" data-del="'+i.id+'">\\u2715</button></div>'})}el.innerHTML=html;el.querySelectorAll('.shop-check').forEach(c=>c.onchange=async()=>{await api('/shopping/'+c.dataset.id+'/check','POST');loadShopping();loadDashboard()});el.querySelectorAll('.shop-del').forEach(b=>b.onclick=async()=>{await api('/shopping/'+b.dataset.del,'DELETE');loadShopping();loadDashboard()})}catch(err){console.error(err)}}
492
- document.getElementById('shop-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/shopping','POST',{item:f.get('item'),category:f.get('category'),added_by:f.get('added_by')});e.target.reset();loadShopping();loadDashboard()});
493
- document.getElementById('shop-clear-checked').addEventListener('click',async()=>{await api('/shopping/checked','DELETE');loadShopping()});
36
+ async function hmac(secret, value) {
37
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
38
+ return new Uint8Array(await crypto.subtle.sign('HMAC', key, encoder.encode(value)));
39
+ }
494
40
 
495
- /* === Food Diary === */
496
- let fdPhotoFile=null;
497
- document.getElementById('fd-upload-area').addEventListener('click',()=>document.getElementById('fd-photo').click());
498
- document.getElementById('fd-photo').addEventListener('change',e=>{const file=e.target.files[0];if(!file)return;fdPhotoFile=file;document.getElementById('fd-upload-area').classList.add('has-file');document.getElementById('fd-upload-text').textContent=file.name;const reader=new FileReader();reader.onload=ev=>{const preview=document.getElementById('fd-preview');preview.src=ev.target.result;preview.style.display='block'};reader.readAsDataURL(file)});
499
- document.getElementById('food-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);let photoKey=null;if(fdPhotoFile){const fd=new FormData();fd.append('photo',fdPhotoFile);const uploadRes=await fetch('/api/food/photo',{method:'POST',body:fd});const uploadData=await uploadRes.json();if(uploadData.key)photoKey=uploadData.key}await api('/food','POST',{meal_type:f.get('meal_type'),time:f.get('time'),note:f.get('note'),photo_key:photoKey});e.target.reset();fdPhotoFile=null;document.getElementById('fd-upload-area').classList.remove('has-file');document.getElementById('fd-upload-text').textContent='Tap to add photo (optional)';document.getElementById('fd-preview').style.display='none';loadFood()});
41
+ async function safeEqual(left, right) {
42
+ const leftDigest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(String(left || ''))));
43
+ const rightDigest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(String(right || ''))));
44
+ let mismatch = 0;
45
+ for (let i = 0; i < leftDigest.length; i++) mismatch |= leftDigest[i] ^ rightDigest[i];
46
+ return mismatch === 0;
47
+ }
500
48
 
501
- async function loadFood(){try{const[todayData,waterData,reviewData,histData]=await Promise.all([api('/food'),api('/water'),api('/food/reviews?date='+new Date().toISOString().slice(0,10)),api('/food/history')]);const meals=todayData.entries||[];const todayEl=document.getElementById('fd-today-meals');if(meals.length){todayEl.innerHTML=meals.map(m=>'<div class="fd-meal-card">'+(m.photo_key?'<img src="/api/food/photo/'+encodeURIComponent(m.photo_key)+'" alt="meal" onclick="toggleFdPhoto(this)">':'')+'<div class="fd-meal-info"><div class="fd-meal-type">'+sanitize(m.meal_type)+'</div><div class="fd-meal-time">'+m.time+'</div>'+(m.note?'<div class="fd-meal-note">'+sanitize(m.note)+'</div>':'')+'</div></div>').join('')}else{todayEl.innerHTML='<p class="empty">Nothing logged yet today</p>'}document.getElementById('fd-today-count').textContent=meals.length;const waterTotal=waterData.total_ml||0;document.getElementById('fd-water-total').textContent=waterTotal+' ml';document.getElementById('fd-today-water').textContent=waterTotal+' ml';const pct=Math.min(100,Math.round((waterTotal/2000)*100));document.getElementById('fd-water-fill').style.width=pct+'%';document.getElementById('fd-water-pct').textContent=pct+'%';const reviewEl=document.getElementById('fd-review-area');if(reviewData.review){reviewEl.innerHTML='<div class="fd-review-card"><div class="fd-review-header"><span class="fd-review-name">'+sanitize(reviewData.review.reviewer)+'</span><span class="fd-review-label">Daily Review</span></div><div class="fd-review-text">'+sanitize(reviewData.review.review)+'</div><div class="fd-review-date">'+reviewData.review.date+'</div></div>'}else{reviewEl.innerHTML='<div class="fd-review-card empty-review"><div class="fd-review-text">No review yet for today</div></div>'}const histEntries=histData.entries||[];const weekEl=document.getElementById('fd-week-view');const dayMap={};histEntries.forEach(m=>{if(!dayMap[m.date])dayMap[m.date]=[];dayMap[m.date].push(m)});const days=Object.keys(dayMap).sort().reverse().slice(0,7);if(days.length){weekEl.innerHTML=days.map(d=>{const ms=dayMap[d];const types=ms.map(m=>m.meal_type);return'<div class="fd-week-day"><span style="width:60px;color:#71717a">'+new Date(d+'T00:00:00').toLocaleDateString('en-GB',{weekday:'short',day:'numeric'})+'</span><span class="fd-meal-dot '+(types.includes('breakfast')?'logged':'missed')+'" title="Breakfast"></span><span class="fd-meal-dot '+(types.includes('lunch')?'logged':'missed')+'" title="Lunch"></span><span class="fd-meal-dot '+(types.includes('dinner')?'logged':'missed')+'" title="Dinner"></span><span style="color:#71717a;font-size:0.7rem">'+ms.length+' meals</span></div>'}).join('')}else{weekEl.innerHTML='<p class="empty">Log meals to see weekly patterns</p>'}const reviewsData=await api('/food/reviews');const pastEl=document.getElementById('fd-past-reviews');if(reviewsData.reviews&&reviewsData.reviews.length){pastEl.innerHTML=reviewsData.reviews.map(r=>'<div class="fd-review-card"><div class="fd-review-header"><span class="fd-review-name">'+sanitize(r.reviewer)+'</span><span class="fd-review-label">'+r.date+'</span></div><div class="fd-review-text">'+sanitize(r.review)+'</div></div>').join('')}const weekAvg=days.length?Math.round(histEntries.length/days.length*10)/10:'--';document.getElementById('fd-week-avg').textContent=weekAvg+' meals/day'}catch(err){console.error(err)}}
49
+ async function createSession(secret) {
50
+ const payload = base64Url(encoder.encode(JSON.stringify({
51
+ exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60,
52
+ nonce: crypto.randomUUID(),
53
+ })));
54
+ return payload + '.' + base64Url(await hmac(secret, payload));
55
+ }
502
56
 
503
- document.querySelectorAll('.fd-water-btn').forEach(b=>b.addEventListener('click',async()=>{const ml=parseInt(b.dataset.ml);await api('/water','POST',{amount_ml:ml});loadFood()}));
57
+ async function verifySession(token, secret) {
58
+ if (!token || !secret) return false;
59
+ const parts = token.split('.');
60
+ if (parts.length !== 2) return false;
61
+ try {
62
+ const expected = base64Url(await hmac(secret, parts[0]));
63
+ if (!(await safeEqual(parts[1], expected))) return false;
64
+ const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(parts[0])));
65
+ return Number.isFinite(payload.exp) && payload.exp > Math.floor(Date.now() / 1000);
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
504
70
 
505
- function toggleFdPhoto(img){if(img.classList.contains('fd-expanded')){img.classList.remove('fd-expanded');document.getElementById('fd-overlay').style.display='none'}else{img.classList.add('fd-expanded');document.getElementById('fd-overlay').style.display='block';document.getElementById('fd-overlay').onclick=()=>{img.classList.remove('fd-expanded');document.getElementById('fd-overlay').style.display='none'}}}
71
+ function securityHeaders(extra = {}) {
72
+ return {
73
+ 'Content-Security-Policy': "default-src 'self'; img-src 'self' data: https://openweathermap.org; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
74
+ 'Referrer-Policy': 'no-referrer',
75
+ 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
76
+ 'X-Content-Type-Options': 'nosniff',
77
+ 'X-Frame-Options': 'DENY',
78
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
79
+ ...extra,
80
+ };
81
+ }
506
82
 
507
- /* === Init === */
508
- loadDashboard();loadMoods();loadNotes();loadMoments();loadDates();loadWeather();loadPressure();loadShopping();loadFood();
509
- </script>
510
- </body>
511
- </html>`;
83
+ function sessionCookie(value, maxAge = 604800) {
84
+ return `__Host-hearth_session=${value}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=${maxAge}`;
512
85
  }
513
86
 
514
- /* ========================= BACKEND ========================= */
87
+ function sameOrigin(request) {
88
+ const origin = request.headers.get('Origin');
89
+ return !origin || origin === new URL(request.url).origin;
90
+ }
515
91
 
516
- export default {
517
- async fetch(request, env, ctx) {
518
- const config = await getConfig(env);
519
- const url = new URL(request.url);
520
- const path = url.pathname;
92
+ export async function withinRateLimit(env, request, scope, limit, windowSeconds) {
93
+ const address = request.headers.get('CF-Connecting-IP') || 'unknown';
94
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(address)));
95
+ const windowStart = Math.floor(Date.now() / 1000 / windowSeconds) * windowSeconds;
96
+ const key = `${scope}:${windowStart}:${base64Url(digest).slice(0, 24)}`;
97
+ try {
98
+ await env.DB.prepare(
99
+ 'INSERT INTO rate_limits (bucket_key, count, expires_at) VALUES (?, 1, ?) ON CONFLICT(bucket_key) DO UPDATE SET count = count + 1'
100
+ ).bind(key, windowStart + windowSeconds * 2).run();
101
+ const row = await env.DB.prepare('SELECT count FROM rate_limits WHERE bucket_key = ?').bind(key).first();
102
+ if (Math.random() < 0.01) env.DB.prepare("DELETE FROM rate_limits WHERE expires_at < unixepoch('now')").run().catch(() => {});
103
+ return !row || row.count <= limit;
104
+ } catch {
105
+ // Authentication and authorization endpoints must not silently lose their
106
+ // abuse controls when D1 or the additive migration is unavailable.
107
+ return false;
108
+ }
109
+ }
110
+
111
+ function getSetupPage(error) {
112
+ return `<!DOCTYPE html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="UTF-8">
116
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
117
+ <title>Hearth Dash — Welcome</title>
118
+ <style>
119
+ * { box-sizing: border-box; margin: 0; padding: 0; }
120
+ body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f0f1a; color: #fafafa; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
121
+ .setup-box { background: #1a1a2e; padding: 3rem; border-radius: 16px; text-align: center; max-width: 440px; width: 90%; border: 1px solid rgba(192,132,252,0.2); }
122
+ h1 { font-size: 2rem; margin-bottom: 0.5rem; font-weight: 600; background: linear-gradient(135deg, #c084fc, #2dd4bf); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
123
+ .subtitle { color: #71717a; margin-bottom: 2rem; }
124
+ input[type="password"] { width: 100%; padding: 1rem; border: 1px solid #2a2a3e; border-radius: 10px; background: #0f0f1a; color: #fafafa; font-family: inherit; font-size: 1rem; margin-bottom: 0.75rem; }
125
+ input[type="password"]:focus { outline: none; border-color: #c084fc; box-shadow: 0 0 0 3px rgba(192,132,252,0.15); }
126
+ button { width: 100%; padding: 1rem; border: none; border-radius: 10px; background: linear-gradient(135deg, #c084fc, #a855f7); color: white; font-family: inherit; font-size: 1rem; cursor: pointer; font-weight: 600; }
127
+ button:hover { background: linear-gradient(135deg, #d8b4fe, #c084fc); }
128
+ .error { color: #f87171; margin-bottom: 1rem; }
129
+ .note { color: #52525b; font-size: 0.8rem; margin-top: 1.5rem; }
130
+ </style>
131
+ </head>
132
+ <body>
133
+ <div class="setup-box">
134
+ <h1>Hearth Dash</h1>
135
+ <p class="subtitle">Welcome! Create a password to get started.</p>
136
+ ${error || ''}
137
+ <form method="POST" action="/setup">
138
+ <input type="password" name="password" placeholder="Choose a password" autofocus required minlength="4">
139
+ <input type="password" name="confirm" placeholder="Confirm password" required minlength="4">
140
+ <button type="submit">Create Dashboard</button>
141
+ </form>
142
+ <p class="note">This password protects your dashboard. Remember it!</p>
143
+ </div>
144
+ </body>
145
+ </html>`;
146
+ }
147
+
148
+ function getLoginPage(config) {
149
+ return `<!DOCTYPE html>
150
+ <html lang="en">
151
+ <head>
152
+ <meta charset="UTF-8">
153
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
154
+ <title>Hearth Dash</title>
155
+ <style>
156
+ * { box-sizing: border-box; margin: 0; padding: 0; }
157
+ body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f0f1a; color: #fafafa; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
158
+ .login-box { background: #1a1a2e; padding: 3rem; border-radius: 16px; text-align: center; max-width: 400px; width: 90%; border: 1px solid rgba(192,132,252,0.2); }
159
+ h1 { font-size: 2rem; margin-bottom: 0.5rem; font-weight: 600; background: linear-gradient(135deg, #c084fc, #2dd4bf); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
160
+ .subtitle { color: #71717a; margin-bottom: 2rem; }
161
+ input[type="password"] { width: 100%; padding: 1rem; border: 1px solid #2a2a3e; border-radius: 10px; background: #0f0f1a; color: #fafafa; font-family: inherit; font-size: 1rem; margin-bottom: 1rem; }
162
+ input[type="password"]:focus { outline: none; border-color: #c084fc; box-shadow: 0 0 0 3px rgba(192,132,252,0.15); }
163
+ button { width: 100%; padding: 1rem; border: none; border-radius: 10px; background: linear-gradient(135deg, #c084fc, #a855f7); color: white; font-family: inherit; font-size: 1rem; cursor: pointer; font-weight: 600; }
164
+ button:hover { background: linear-gradient(135deg, #d8b4fe, #c084fc); }
165
+ .error { color: #f87171; margin-bottom: 1rem; }
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <div class="login-box">
170
+ <h1>Hearth Dash</h1>
171
+ <p class="subtitle">Your personal dashboard</p>
172
+ {{ERROR}}
173
+ <form method="POST" action="/login">
174
+ <input type="password" name="password" placeholder="Password" autofocus required>
175
+ <button type="submit">Enter</button>
176
+ </form>
177
+ </div>
178
+ </body>
179
+ </html>`;
180
+ }
181
+
182
+ function escapeHtml(value) {
183
+ return String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
184
+ }
521
185
 
522
- // MCP endpoint
523
- if (path.startsWith('/mcp/' + config.MCP_SECRET)) return handleMCP(request, env, config);
186
+ function jsonForInlineScript(value) {
187
+ return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
188
+ }
524
189
 
525
- // Setup — no password set yet, let them create one
526
- if (!config.PASSWORD) {
527
- if (path === '/setup' && request.method === 'POST') {
528
- const formData = await request.formData();
529
- const pw = formData.get('password');
530
- const confirm = formData.get('confirm');
531
- if (!pw || pw.length < 4) {
532
- return new Response(getSetupPage('<p class="error">Password must be at least 4 characters</p>'), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
533
- }
534
- if (pw !== confirm) {
535
- return new Response(getSetupPage('<p class="error">Passwords don\'t match</p>'), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
536
- }
537
- await env.DB.prepare("INSERT OR REPLACE INTO config (key, value) VALUES ('password', ?)").bind(pw).run();
538
- return new Response(null, { status: 302, headers: { 'Location': '/', 'Set-Cookie': `hearth_session=${pw}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800` } });
539
- }
540
- return new Response(getSetupPage(), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
190
+ function getDashboardHTML(config) {
191
+ const P1 = config.PARTNER_1;
192
+ const P2 = config.PARTNER_2;
193
+ const P1HTML = escapeHtml(P1);
194
+ const P2HTML = escapeHtml(P2);
195
+ return `<!DOCTYPE html>
196
+ <html lang="en">
197
+ <head>
198
+ <meta charset="UTF-8">
199
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
200
+ <title>Hearth Dash</title>
201
+ <style>
202
+ * { box-sizing: border-box; margin: 0; padding: 0; }
203
+ body { font-family: 'Segoe UI', system-ui, sans-serif; background: #0f0f1a; color: #fafafa; min-height: 100vh; line-height: 1.6; }
204
+ .container { max-width: 1200px; margin: 0 auto; padding: 2rem; }
205
+ header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #2a2a3e; flex-wrap: wrap; gap: 0.5rem; }
206
+ h1 { font-size: 1.75rem; font-weight: 600; background: linear-gradient(135deg, #c084fc, #2dd4bf); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
207
+ nav { display: flex; flex-wrap: wrap; gap: 0.25rem; }
208
+ nav a { color: #71717a; text-decoration: none; padding: 0.25rem 0.75rem; border-radius: 6px; font-size: 0.9rem; transition: all 0.2s; }
209
+ nav a:hover, nav a.active { color: #c084fc; background: rgba(192,132,252,0.1); }
210
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem; }
211
+ .card { background: #1a1a2e; border-radius: 14px; padding: 1.5rem; border: 1px solid #2a2a3e; }
212
+ .card h2 { font-size: 0.85rem; color: #71717a; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 1rem; font-weight: 600; }
213
+ .mood-display { display: flex; gap: 2rem; }
214
+ .mood-person { flex: 1; }
215
+ .mood-person h3 { font-size: 0.875rem; color: #52525b; margin-bottom: 0.5rem; }
216
+ .mood-value { font-size: 1.5rem; }
217
+ .mood-time { font-size: 0.75rem; color: #52525b; margin-top: 0.25rem; }
218
+ .note-card { background: rgba(192,132,252,0.08); color: #e5e7eb; padding: 1rem; border-radius: 10px; font-style: italic; margin-bottom: 0.75rem; border: 1px solid rgba(192,132,252,0.15); }
219
+ .note-meta { font-size: 0.75rem; color: #71717a; margin-top: 0.5rem; font-style: normal; }
220
+ .moment-item { padding: 0.75rem 0; border-bottom: 1px solid #2a2a3e; }
221
+ .moment-item:last-child { border-bottom: none; }
222
+ .moment-date { font-size: 0.75rem; color: #71717a; }
223
+ .countdown { font-size: 2rem; color: #c084fc; font-weight: 700; }
224
+ .countdown-label { font-size: 0.875rem; color: #71717a; }
225
+ input, select, textarea { padding: 0.75rem; border: 1px solid #2a2a3e; border-radius: 10px; background: #0f0f1a; color: #fafafa; font-family: inherit; font-size: 0.875rem; width: 100%; }
226
+ input:focus, select:focus, textarea:focus { outline: none; border-color: #c084fc; box-shadow: 0 0 0 3px rgba(192,132,252,0.15); }
227
+ textarea { resize: vertical; min-height: 80px; }
228
+ .btn { padding: 0.75rem 1.5rem; border: none; border-radius: 10px; background: linear-gradient(135deg, #c084fc, #a855f7); color: white; font-family: inherit; cursor: pointer; font-size: 0.875rem; font-weight: 600; }
229
+ .btn:hover { background: linear-gradient(135deg, #d8b4fe, #c084fc); }
230
+ .btn-ghost { background: transparent; border: 1px solid #2a2a3e; color: #71717a; }
231
+ .btn-ghost:hover { border-color: #c084fc; color: #c084fc; }
232
+ .empty { color: #52525b; font-style: italic; }
233
+ .tab-content { display: none; }
234
+ .tab-content.active { display: block; }
235
+
236
+ /* Weather */
237
+ .weather-main { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem; }
238
+ .weather-temp { font-size: 2rem; font-weight: bold; }
239
+ .weather-desc { color: #71717a; text-transform: capitalize; }
240
+ .weather-details { display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; font-size: 0.85rem; color: #d4d4d8; }
241
+ .weather-details span { display: flex; justify-content: space-between; }
242
+ .weather-details .label { color: #71717a; }
243
+ .migraine-alert { margin-top: 0.75rem; padding: 0.5rem 0.75rem; border-radius: 8px; font-size: 0.8rem; }
244
+ .migraine-alert.low { background: rgba(45,212,191,0.1); border: 1px solid rgba(45,212,191,0.3); color: #5eead4; }
245
+ .migraine-alert.moderate { background: rgba(234,179,8,0.1); border: 1px solid rgba(234,179,8,0.3); color: #facc15; }
246
+ .migraine-alert.high { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; }
247
+ .shift-alert { margin-top: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 8px; font-size: 0.8rem; display: flex; align-items: center; gap: 0.5rem; }
248
+ .shift-alert.watch { background: rgba(234,179,8,0.1); border: 1px solid rgba(234,179,8,0.3); color: #facc15; }
249
+ .shift-alert.warning { background: rgba(192,132,252,0.1); border: 1px solid rgba(192,132,252,0.3); color: #d8b4fe; }
250
+ .shift-alert.critical { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.4); color: #fca5a5; }
251
+ .shift-arrow { font-size: 1.2rem; font-weight: bold; }
252
+
253
+ /* Pressure */
254
+ .pressure-canvas { width: 100%; height: 280px; display: block; background: #0f0f1a; border: 1px solid #2a2a3e; border-radius: 10px; }
255
+ .pressure-legend { display: flex; gap: 1.5rem; margin-top: 0.5rem; font-size: 0.75rem; color: #71717a; }
256
+ .pressure-legend span::before { content: ''; display: inline-block; width: 12px; height: 3px; margin-right: 0.35rem; vertical-align: middle; border-radius: 2px; }
257
+ .pressure-legend .leg-hist::before { background: #2dd4bf; }
258
+ .pressure-legend .leg-forecast::before { background: #2dd4bf; opacity: 0.5; }
259
+ .pressure-legend .leg-now::before { background: #c084fc; }
260
+ .pressure-legend .leg-watch::before { background: rgba(234,179,8,0.35); width: 12px; height: 8px; }
261
+ .pressure-legend .leg-warn::before { background: rgba(192,132,252,0.35); width: 12px; height: 8px; }
262
+ .pressure-legend .leg-crit::before { background: rgba(239,68,68,0.35); width: 12px; height: 8px; }
263
+ .pressure-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1rem; }
264
+ @media (max-width: 600px) { .pressure-stats { grid-template-columns: 1fr; } }
265
+ .pressure-stat { background: #0f0f1a; border: 1px solid #2a2a3e; border-radius: 10px; padding: 0.85rem; }
266
+ .pressure-stat-label { font-size: 0.7rem; color: #71717a; text-transform: uppercase; letter-spacing: 0.04em; }
267
+ .pressure-stat-value { font-size: 1.3rem; font-weight: bold; margin-top: 0.25rem; }
268
+
269
+ /* Shopping */
270
+ .shop-category { font-size: 0.8rem; color: #c084fc; text-transform: uppercase; letter-spacing: 0.05em; margin: 1rem 0 0.5rem; padding-bottom: 0.25rem; border-bottom: 1px solid #2a2a3e; }
271
+ .shop-category:first-child { margin-top: 0; }
272
+ .shop-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid rgba(42,42,62,0.6); }
273
+ .shop-item:last-child { border-bottom: none; }
274
+ .shop-item.checked { opacity: 0.4; }
275
+ .shop-item.checked .shop-text { text-decoration: line-through; }
276
+ .shop-check { appearance: none; width: 20px; height: 20px; border-radius: 50%; border: 2px solid #2a2a3e; cursor: pointer; flex-shrink: 0; position: relative; }
277
+ .shop-check:checked { border-color: #2dd4bf; background: #2dd4bf; }
278
+ .shop-check:checked::after { content: '\\2713'; position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); color: #0f0f1a; font-size: 0.7rem; font-weight: bold; }
279
+ .shop-text { flex: 1; }
280
+ .shop-by { font-size: 0.7rem; color: #71717a; }
281
+ .shop-del { background: none; border: none; color: #52525b; cursor: pointer; font-size: 0.9rem; padding: 0.25rem; }
282
+ .shop-del:hover { color: #f87171; }
283
+ .shop-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
284
+ .shop-count { font-size: 1.5rem; font-weight: bold; color: #c084fc; }
285
+
286
+ /* Food Diary */
287
+ .fd-grid { display: grid; grid-template-columns: 380px 1fr; gap: 1.5rem; }
288
+ @media (max-width: 900px) { .fd-grid { grid-template-columns: 1fr; } }
289
+ .fd-meal-card { background: #0f0f1a; border: 1px solid #2a2a3e; border-radius: 10px; padding: 1rem; margin-bottom: 0.75rem; display: flex; gap: 1rem; align-items: start; }
290
+ .fd-meal-card img { width: 80px; height: 80px; object-fit: cover; border-radius: 8px; flex-shrink: 0; cursor: pointer; }
291
+ .fd-meal-card img.fd-expanded { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); width: auto; height: auto; max-width: 90vw; max-height: 90vh; z-index: 1000; border-radius: 12px; box-shadow: 0 0 40px rgba(0,0,0,0.8); }
292
+ .fd-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 999; display: none; }
293
+ .fd-meal-info { flex: 1; }
294
+ .fd-meal-type { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; color: #c084fc; font-weight: 600; }
295
+ .fd-meal-time { font-size: 0.7rem; color: #71717a; }
296
+ .fd-meal-note { font-size: 0.85rem; color: #d4d4d8; margin-top: 0.25rem; }
297
+ .fd-water-bar { height: 24px; background: #2a2a3e; border-radius: 12px; overflow: hidden; margin: 0.75rem 0; position: relative; }
298
+ .fd-water-fill { height: 100%; background: linear-gradient(90deg, #2dd4bf, #14b8a6); border-radius: 12px; transition: width 0.4s ease; }
299
+ .fd-water-label { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 0.75rem; font-weight: 600; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.5); }
300
+ .fd-water-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; }
301
+ .fd-water-btn { padding: 0.5rem 1rem; border: 1px solid #2a2a3e; border-radius: 8px; background: #0f0f1a; color: #2dd4bf; cursor: pointer; font-family: inherit; font-size: 0.85rem; transition: all 0.15s; }
302
+ .fd-water-btn:hover { border-color: #2dd4bf; background: rgba(45,212,191,0.1); }
303
+ .fd-water-total { font-size: 1.3rem; font-weight: bold; color: #2dd4bf; }
304
+ .fd-water-target { font-size: 0.8rem; color: #71717a; }
305
+ .fd-review-card { background: rgba(192,132,252,0.06); border: 1px solid rgba(192,132,252,0.2); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem; }
306
+ .fd-review-card .fd-review-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
307
+ .fd-review-card .fd-review-name { color: #c084fc; font-weight: 700; font-size: 0.95rem; }
308
+ .fd-review-card .fd-review-label { font-size: 0.7rem; color: #71717a; text-transform: uppercase; letter-spacing: 0.04em; }
309
+ .fd-review-card .fd-review-text { font-size: 0.9rem; color: #e5e7eb; line-height: 1.6; }
310
+ .fd-review-card .fd-review-date { font-size: 0.7rem; color: #71717a; margin-top: 0.75rem; }
311
+ .fd-review-card.empty-review { border-style: dashed; opacity: 0.6; }
312
+ .fd-review-card.empty-review .fd-review-text { color: #71717a; font-style: italic; }
313
+ .fd-patterns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; margin-bottom: 1rem; }
314
+ @media (max-width: 600px) { .fd-patterns { grid-template-columns: 1fr; } }
315
+ .fd-pattern { background: #0f0f1a; border: 1px solid #2a2a3e; border-radius: 10px; padding: 0.85rem; }
316
+ .fd-pattern-label { font-size: 0.7rem; color: #71717a; text-transform: uppercase; letter-spacing: 0.04em; }
317
+ .fd-pattern-value { font-size: 1.3rem; font-weight: bold; margin-top: 0.25rem; }
318
+ .fd-photo-preview { max-width: 100%; max-height: 200px; border-radius: 8px; margin-top: 0.5rem; display: none; }
319
+ .fd-upload-area { border: 2px dashed #2a2a3e; border-radius: 8px; padding: 1rem; text-align: center; color: #71717a; font-size: 0.85rem; cursor: pointer; margin-bottom: 0.5rem; transition: border-color 0.2s; }
320
+ .fd-upload-area:hover { border-color: #c084fc; }
321
+ .fd-upload-area.has-file { border-color: #2dd4bf; border-style: solid; }
322
+ .fd-week-day { display: flex; gap: 0.5rem; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid rgba(42,42,62,0.6); font-size: 0.8rem; }
323
+ .fd-week-day:last-child { border-bottom: none; }
324
+ .fd-meal-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
325
+ .fd-meal-dot.logged { background: #2dd4bf; }
326
+ .fd-meal-dot.missed { background: #2a2a3e; }
327
+ </style>
328
+ </head>
329
+ <body>
330
+ <div class="container">
331
+ <header>
332
+ <h1>Hearth Dash</h1>
333
+ <nav>
334
+ <a href="#dashboard" class="active" data-tab="dashboard">Dashboard</a>
335
+ <a href="#moods" data-tab="moods">Moods</a>
336
+ <a href="#notes" data-tab="notes">Notes</a>
337
+ <a href="#moments" data-tab="moments">Moments</a>
338
+ <a href="#dates" data-tab="dates">Dates</a>
339
+ <a href="#shopping" data-tab="shopping">Shopping</a>
340
+ <a href="#pressure" data-tab="pressure">Pressure</a>
341
+ <a href="#food" data-tab="food">Food</a>
342
+ <a href="/logout">Logout</a>
343
+ </nav>
344
+ </header>
345
+
346
+ <!-- DASHBOARD TAB -->
347
+ <div id="dashboard" class="tab-content active">
348
+ <div class="grid">
349
+ <div class="card"><h2>Current Moods</h2><div class="mood-display" id="mood-display"><div class="mood-person"><h3>${P1HTML}</h3><div class="mood-value" id="p1-mood">&mdash;</div><div class="mood-time" id="p1-mood-time"></div></div><div class="mood-person"><h3>${P2HTML}</h3><div class="mood-value" id="p2-mood">&mdash;</div><div class="mood-time" id="p2-mood-time"></div></div></div></div>
350
+ <div class="card"><h2>Latest Note</h2><div id="latest-note"><p class="empty">No notes yet</p></div></div>
351
+ <div class="card"><h2>Next Date</h2><div id="next-date"><p class="empty">No upcoming dates</p></div></div>
352
+ <div class="card"><h2>Weather</h2><div id="weather-display"><p class="empty">Loading weather...</p></div></div>
353
+ <div class="card"><h2>Shopping</h2><div id="shop-dashboard"><p class="empty">Loading...</p></div></div>
354
+ <div class="card"><h2>Food Diary</h2><div id="food-dashboard"><p class="empty">Loading...</p></div></div>
355
+ </div>
356
+ </div>
357
+
358
+ <!-- MOODS TAB -->
359
+ <div id="moods" class="tab-content">
360
+ <div class="grid">
361
+ <div class="card"><h2>Log Mood</h2><form id="mood-form"><select name="partner" required style="margin-bottom:0.5rem"><option value="">Who are you?</option><option value="${P1HTML}">${P1HTML}</option><option value="${P2HTML}">${P2HTML}</option></select><select name="mood" required style="margin-bottom:0.5rem"><option value="">How are you feeling?</option><option value="great">&#128522; Great</option><option value="good">&#128578; Good</option><option value="okay">&#128528; Okay</option><option value="tired">&#128564; Tired</option><option value="stressed">&#128560; Stressed</option><option value="low">&#128532; Low</option></select><textarea name="note" placeholder="Any notes? (optional)" style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Log Mood</button></form></div>
362
+ <div class="card"><h2>Recent Moods</h2><div id="mood-history"><p class="empty">No mood entries yet</p></div></div>
363
+ </div>
364
+ </div>
365
+
366
+ <!-- NOTES TAB -->
367
+ <div id="notes" class="tab-content">
368
+ <div class="grid">
369
+ <div class="card"><h2>Leave a Note</h2><form id="note-form"><select name="from" required style="margin-bottom:0.5rem"><option value="">From</option><option value="${P1HTML}">${P1HTML}</option><option value="${P2HTML}">${P2HTML}</option></select><textarea name="content" placeholder="Your note..." required style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Post Note</button></form></div>
370
+ <div class="card" style="grid-column:span 2"><h2>Fridge Notes</h2><div id="notes-display"><p class="empty">No notes on the fridge</p></div></div>
371
+ </div>
372
+ </div>
373
+
374
+ <!-- MOMENTS TAB -->
375
+ <div id="moments" class="tab-content">
376
+ <div class="grid">
377
+ <div class="card"><h2>Add Moment</h2><form id="moment-form"><input type="date" name="date" required style="margin-bottom:0.5rem"><input type="text" name="title" placeholder="What happened?" required style="margin-bottom:0.5rem"><textarea name="description" placeholder="Tell me more..." style="margin-bottom:0.5rem"></textarea><button type="submit" class="btn">Save Moment</button></form></div>
378
+ <div class="card" style="grid-column:span 2"><h2>Timeline</h2><div id="moments-timeline"><p class="empty">No moments recorded yet</p></div></div>
379
+ </div>
380
+ </div>
381
+
382
+ <!-- DATES TAB -->
383
+ <div id="dates" class="tab-content">
384
+ <div class="grid">
385
+ <div class="card">
386
+ <h2>Add Date</h2>
387
+ <form id="date-form">
388
+ <input type="date" name="date" required style="margin-bottom:0.5rem">
389
+ <input type="text" name="title" placeholder="What's the occasion?" required style="margin-bottom:0.5rem">
390
+ <label style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;font-size:0.85rem;color:#71717a"><input type="checkbox" name="recurring"> Recurring yearly</label>
391
+ <button type="submit" class="btn">Save Date</button>
392
+ </form>
393
+ </div>
394
+ <div class="card" style="grid-column:span 2">
395
+ <h2>Important Dates</h2>
396
+ <div id="dates-list"><p class="empty">No dates saved yet</p></div>
397
+ </div>
398
+ </div>
399
+ </div>
400
+
401
+ <!-- SHOPPING TAB -->
402
+ <div id="shopping" class="tab-content">
403
+ <div class="grid">
404
+ <div class="card">
405
+ <h2>Add Item</h2>
406
+ <form id="shop-form">
407
+ <input type="text" name="item" placeholder="What do you need?" required style="margin-bottom:0.5rem">
408
+ <select name="category" style="margin-bottom:0.5rem">
409
+ <option value="Groceries">Groceries</option>
410
+ <option value="Household">Household</option>
411
+ <option value="Health">Health</option>
412
+ <option value="Other">Other</option>
413
+ </select>
414
+ <select name="added_by" required style="margin-bottom:0.5rem">
415
+ <option value="${P1HTML}">${P1HTML}</option>
416
+ <option value="${P2HTML}">${P2HTML}</option>
417
+ </select>
418
+ <button type="submit" class="btn">Add</button>
419
+ </form>
420
+ </div>
421
+ <div class="card" style="grid-column:span 2">
422
+ <h2>Shopping List</h2>
423
+ <div id="shop-list"><p class="empty">Nothing on the list</p></div>
424
+ <div class="shop-actions">
425
+ <button class="btn btn-ghost" id="shop-clear-checked">Clear Checked</button>
426
+ </div>
427
+ </div>
428
+ </div>
429
+ </div>
430
+
431
+ <!-- PRESSURE TAB -->
432
+ <div id="pressure" class="tab-content">
433
+ <div class="grid">
434
+ <div class="card" style="grid-column:1/-1">
435
+ <h2>Pressure Overview</h2>
436
+ <div class="pressure-stats">
437
+ <div class="pressure-stat">
438
+ <div class="pressure-stat-label">Current</div>
439
+ <div class="pressure-stat-value" id="pr-current">&ndash;</div>
440
+ </div>
441
+ <div class="pressure-stat">
442
+ <div class="pressure-stat-label">6h Change (past)</div>
443
+ <div class="pressure-stat-value" id="pr-past-shift">&ndash;</div>
444
+ </div>
445
+ <div class="pressure-stat">
446
+ <div class="pressure-stat-label">6h Change (forecast)</div>
447
+ <div class="pressure-stat-value" id="pr-future-shift">&ndash;</div>
448
+ </div>
449
+ </div>
450
+ </div>
451
+ <div class="card" style="grid-column:1/-1">
452
+ <h2>Pressure Trend</h2>
453
+ <canvas class="pressure-canvas" id="pressure-chart"></canvas>
454
+ <div class="pressure-legend">
455
+ <span class="leg-hist">History</span>
456
+ <span class="leg-forecast">Forecast</span>
457
+ <span class="leg-now">Now</span>
458
+ <span class="leg-watch">Watch</span>
459
+ <span class="leg-warn">Warning</span>
460
+ <span class="leg-crit">Critical</span>
461
+ </div>
462
+ </div>
463
+ </div>
464
+ </div>
465
+
466
+ <!-- FOOD DIARY TAB -->
467
+ <div id="food" class="tab-content">
468
+ <div class="fd-grid">
469
+ <section>
470
+ <div class="card" style="margin-bottom:1rem;">
471
+ <h2>Log Meal</h2>
472
+ <form id="food-form">
473
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;margin-bottom:0.5rem;">
474
+ <select id="fd-meal-type" name="meal_type" required>
475
+ <option value="">Meal type</option>
476
+ <option value="breakfast">Breakfast</option>
477
+ <option value="lunch">Lunch</option>
478
+ <option value="dinner">Dinner</option>
479
+ <option value="snack">Snack</option>
480
+ </select>
481
+ <input type="time" id="fd-time" name="time">
482
+ </div>
483
+ <textarea name="note" id="fd-note" placeholder="What did you eat?" style="margin-bottom:0.5rem"></textarea>
484
+ <div class="fd-upload-area" id="fd-upload-area">
485
+ <span id="fd-upload-text">Tap to add photo (optional)</span>
486
+ <input type="file" id="fd-photo" accept="image/*" style="display:none">
487
+ </div>
488
+ <img id="fd-preview" class="fd-photo-preview" alt="Preview">
489
+ <button type="submit" class="btn" style="margin-top:0.5rem">Log Meal</button>
490
+ </form>
491
+ </div>
492
+ <div class="card">
493
+ <h2>Water</h2>
494
+ <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:0.5rem;">
495
+ <span class="fd-water-total" id="fd-water-total">0 ml</span>
496
+ <span class="fd-water-target">/ 2000 ml</span>
497
+ </div>
498
+ <div class="fd-water-bar">
499
+ <div class="fd-water-fill" id="fd-water-fill" style="width:0%"></div>
500
+ <div class="fd-water-label" id="fd-water-pct">0%</div>
501
+ </div>
502
+ <div class="fd-water-btns">
503
+ <button class="fd-water-btn" data-ml="250">Glass (250ml)</button>
504
+ <button class="fd-water-btn" data-ml="500">Bottle (500ml)</button>
505
+ <button class="fd-water-btn" data-ml="150">Small (150ml)</button>
506
+ <button class="fd-water-btn" data-ml="750">Large (750ml)</button>
507
+ </div>
508
+ </div>
509
+ </section>
510
+ <section>
511
+ <div id="fd-review-area"></div>
512
+ <div class="fd-patterns" id="fd-patterns">
513
+ <div class="fd-pattern"><div class="fd-pattern-label">Today's Meals</div><div class="fd-pattern-value" id="fd-today-count">0</div></div>
514
+ <div class="fd-pattern"><div class="fd-pattern-label">Water</div><div class="fd-pattern-value" id="fd-today-water" style="color:#2dd4bf">0 ml</div></div>
515
+ <div class="fd-pattern"><div class="fd-pattern-label">This Week</div><div class="fd-pattern-value" id="fd-week-avg">--</div></div>
516
+ </div>
517
+ <div class="card" style="margin-bottom:1rem;">
518
+ <h2>Today's Meals</h2>
519
+ <div id="fd-today-meals"><p class="empty">Nothing logged yet today</p></div>
520
+ </div>
521
+ <div class="card" style="margin-bottom:1rem;">
522
+ <h2>This Week</h2>
523
+ <div id="fd-week-view"><p class="empty">Log meals to see weekly patterns</p></div>
524
+ </div>
525
+ <div class="card">
526
+ <h2>Past Reviews</h2>
527
+ <div id="fd-past-reviews"><p class="empty">No reviews yet</p></div>
528
+ </div>
529
+ </section>
530
+ </div>
531
+ <div class="fd-overlay" id="fd-overlay"></div>
532
+ </div>
533
+ </div>
534
+
535
+ <script>
536
+ const P1 = ${jsonForInlineScript(P1)};
537
+ const P2 = ${jsonForInlineScript(P2)};
538
+
539
+ /* === Tab Navigation === */
540
+ document.querySelectorAll('nav a[data-tab]').forEach(link=>{link.addEventListener('click',e=>{e.preventDefault();const tab=e.target.dataset.tab;document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));document.querySelectorAll('nav a[data-tab]').forEach(l=>l.classList.remove('active'));document.getElementById(tab).classList.add('active');e.target.classList.add('active');window.location.hash=tab;if(tab==='pressure'&&_pressureGraphData)setTimeout(()=>drawPressureChart(_pressureGraphData),50)})});
541
+ if(window.location.hash){const tab=window.location.hash.substring(1);const tabEl=document.getElementById(tab);const linkEl=document.querySelector('nav a[data-tab="'+tab+'"]');if(tabEl&&linkEl){document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));document.querySelectorAll('nav a[data-tab]').forEach(l=>l.classList.remove('active'));tabEl.classList.add('active');linkEl.classList.add('active');if(tab==='pressure')setTimeout(()=>{if(_pressureGraphData)drawPressureChart(_pressureGraphData)},500)}}
542
+
543
+ /* === API Helper === */
544
+ async function api(endpoint,method='GET',data=null){const opts={method,headers:{'Content-Type':'application/json'}};if(data)opts.body=JSON.stringify(data);const res=await fetch('/api'+endpoint,opts);return res.json()}
545
+ function moodEmoji(m){return{great:'\\u{1F60A}',good:'\\u{1F642}',okay:'\\u{1F610}',tired:'\\u{1F634}',stressed:'\\u{1F630}',low:'\\u{1F614}'}[m]||'\\u2753'}
546
+ function timeAgo(d){const s=Math.floor((new Date()-new Date(d+'Z'))/1000);if(s<60)return'just now';if(s<3600)return Math.floor(s/60)+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';if(s<604800)return Math.floor(s/86400)+'d ago';return new Date(d).toLocaleDateString('en-GB',{day:'numeric',month:'short'})}
547
+ function sanitize(s=''){return s.replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
548
+
549
+ /* === Dashboard === */
550
+ async function loadDashboard(){try{const data=await api('/dashboard');
551
+ if(data.moods){
552
+ if(data.moods[P1]){document.getElementById('p1-mood').textContent=moodEmoji(data.moods[P1].mood);document.getElementById('p1-mood-time').textContent=timeAgo(data.moods[P1].created_at)}
553
+ if(data.moods[P2]){document.getElementById('p2-mood').textContent=moodEmoji(data.moods[P2].mood);document.getElementById('p2-mood-time').textContent=timeAgo(data.moods[P2].created_at)}
554
+ }
555
+ if(data.latestNote){document.getElementById('latest-note').innerHTML='<div class="note-card">"'+sanitize(data.latestNote.content)+'"<div class="note-meta">\\u2014 '+sanitize(data.latestNote.from_partner)+', '+timeAgo(data.latestNote.created_at)+'</div></div>'}
556
+ if(data.nextDate){const daysUntil=Math.ceil((new Date(data.nextDate.date)-new Date())/(1000*60*60*24));document.getElementById('next-date').innerHTML='<div class="countdown">'+daysUntil+'</div><div class="countdown-label">days until '+sanitize(data.nextDate.title)+'</div>'}
557
+ if(data.shoppingCount!==undefined){const sel=document.getElementById('shop-dashboard');sel.innerHTML=data.shoppingCount>0?'<div class="shop-count">'+data.shoppingCount+'</div><div style="color:#71717a;font-size:0.85rem">items to get</div>':'<p class="empty">List is clear</p>'}
558
+ if(data.todayMeals!==undefined){const fdEl=document.getElementById('food-dashboard');const mc=data.todayMeals.length;const wm=data.waterTotal||0;const mt=data.todayMeals.map(m=>m.meal_type);const hB=mt.includes('breakfast'),hL=mt.includes('lunch'),hD=mt.includes('dinner');let fh='<div style="display:flex;gap:1.5rem;align-items:center"><div><div class="shop-count">'+mc+'</div><div style="color:#71717a;font-size:0.75rem">meals today</div></div><div><div style="font-size:0.8rem">'+(hB?'\\u2705':'\\u2B1C')+' Breakfast '+(hL?'\\u2705':'\\u2B1C')+' Lunch '+(hD?'\\u2705':'\\u2B1C')+' Dinner</div><div style="font-size:0.8rem;color:#2dd4bf;margin-top:0.25rem">\\u{1F4A7} '+wm+' / 2000 ml</div></div></div>';fdEl.innerHTML=fh}
559
+ }catch(err){console.error(err)}}
560
+
561
+ /* === Tab Loaders === */
562
+ async function loadMoods(){try{const data=await api('/moods');const c=document.getElementById('mood-history');if(data.moods&&data.moods.length>0){c.innerHTML=data.moods.map(m=>'<div class="moment-item"><strong>'+sanitize(m.partner)+'</strong>: '+moodEmoji(m.mood)+' '+sanitize(m.mood)+(m.note?'<br><small style="color:#71717a">'+sanitize(m.note)+'</small>':'')+'<div class="moment-date">'+timeAgo(m.created_at)+'</div></div>').join('')}}catch(err){console.error(err)}}
563
+ async function loadNotes(){try{const data=await api('/notes');const c=document.getElementById('notes-display');if(data.notes&&data.notes.length>0){c.innerHTML=data.notes.map(n=>'<div class="note-card">"'+sanitize(n.content)+'"<div class="note-meta">\\u2014 '+sanitize(n.from_partner)+', '+timeAgo(n.created_at)+'</div></div>').join('')}}catch(err){console.error(err)}}
564
+ async function loadMoments(){try{const data=await api('/moments');const c=document.getElementById('moments-timeline');if(data.moments&&data.moments.length>0){c.innerHTML=data.moments.map(m=>'<div class="moment-item"><strong>'+sanitize(m.title)+'</strong>'+(m.description?'<br><small style="color:#71717a">'+sanitize(m.description)+'</small>':'')+'<div class="moment-date">'+new Date(m.date).toLocaleDateString('en-GB',{day:'numeric',month:'long',year:'numeric'})+'</div></div>').join('')}}catch(err){console.error(err)}}
565
+
566
+ /* === Form Handlers === */
567
+ document.getElementById('mood-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/moods','POST',{partner:f.get('partner'),mood:f.get('mood'),note:f.get('note')});e.target.reset();loadMoods();loadDashboard()});
568
+ document.getElementById('note-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/notes','POST',{from:f.get('from'),content:f.get('content')});e.target.reset();loadNotes();loadDashboard()});
569
+ document.getElementById('moment-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/moments','POST',{date:f.get('date'),title:f.get('title'),description:f.get('description')});e.target.reset();loadMoments()});
570
+
571
+ /* === Dates === */
572
+ async function loadDates(){try{const data=await api('/dates');const el=document.getElementById('dates-list');if(!data.dates||!data.dates.length){el.innerHTML='<p class="empty">No dates saved yet</p>';return;}const now=new Date();now.setHours(0,0,0,0);el.innerHTML=data.dates.map(d=>{const dt=new Date(d.date+'T00:00:00');const diff=Math.ceil((dt-now)/(1000*60*60*24));let countdown='';if(diff===0)countdown='<span style="color:#2dd4bf;font-weight:600">Today!</span>';else if(diff===1)countdown='<span style="color:#2dd4bf">Tomorrow</span>';else if(diff>0)countdown='<span style="color:#71717a">in '+diff+' days</span>';else countdown='<span style="color:#52525b">'+Math.abs(diff)+' days ago</span>';return'<div class="moment-item" style="display:flex;justify-content:space-between;align-items:center"><div><strong>'+sanitize(d.title)+'</strong>'+(d.recurring?' <span style="font-size:0.7rem;color:#c084fc">\\u{1F501} yearly</span>':'')+'<div class="moment-date">'+dt.toLocaleDateString('en-GB',{day:'numeric',month:'short',year:'numeric'})+' \\u00B7 '+countdown+'</div></div><button class="shop-del" data-del="'+d.id+'">\\u2715</button></div>'}).join('');el.querySelectorAll('.shop-del').forEach(b=>b.onclick=async()=>{await api('/dates/'+b.dataset.del,'DELETE');loadDates()})}catch(err){console.error(err)}}
573
+ document.getElementById('date-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/dates','POST',{date:f.get('date'),title:f.get('title'),recurring:f.get('recurring')==='on'});e.target.reset();loadDates();loadDashboard()});
574
+
575
+ /* === Weather === */
576
+ async function loadWeather(){try{const data=await fetch('/api/weather').then(r=>r.json());const el=document.getElementById('weather-display');if(data.error){el.innerHTML='<p class="empty">'+sanitize(data.error)+'</p>';return}let shiftHtml='';if(data.shift_alert&&data.shift_alert!=='none'){const arrow=data.pressure_trend==='falling'?'\\u2198':data.pressure_trend==='rising'?'\\u2197':'\\u2192';const s6=Math.abs(data.shift_6h_future||0);const s24=Math.abs(data.shift_24h_future||0);const use24h=s24>s6*2;const shiftVal=use24h?s24.toFixed(0):s6.toFixed(1);const window_=use24h?'24h':'6h';const dir=(data.shift_6h_future||0)<0||(data.shift_24h_future||0)<0?'dropping':'rising';shiftHtml='<div class="shift-alert '+data.shift_alert+'"><span class="shift-arrow">'+arrow+'</span><span>Pressure '+dir+' ~'+shiftVal+' hPa over next '+window_+'</span></div>'}el.innerHTML='<div class="weather-main"><img src="https://openweathermap.org/img/wn/'+data.icon+'@2x.png" width="50" height="50" alt="'+sanitize(data.description)+'" style="filter:brightness(1.2)"><div><div class="weather-temp">'+data.temp+'\\u00B0C</div><div class="weather-desc">'+sanitize(data.description)+'</div></div></div><div class="weather-details"><span><span class="label">Feels like</span> '+data.feels_like+'\\u00B0C</span><span><span class="label">Humidity</span> '+data.humidity+'%</span><span><span class="label">Pressure</span> '+data.pressure+' hPa</span><span><span class="label">Wind</span> '+data.wind+' mph</span></div><div class="migraine-alert '+data.migraine_risk+'">'+(data.migraine_risk==='low'?'\\u{1F7E2}':data.migraine_risk==='moderate'?'\\u{1F7E1}':'\\u{1F534}')+' '+sanitize(data.migraine_note)+'</div>'+shiftHtml+'<div style="font-size:0.65rem;color:#52525b;margin-top:0.5rem">'+sanitize(data.location)+' \\u00B7 Updated '+timeAgo(data.updated)+'</div>'}catch(err){document.getElementById('weather-display').innerHTML='<p class="empty">Could not load weather</p>';console.error(err)}}
577
+
578
+ /* === Pressure Chart === */
579
+ let _pressureGraphData=null;
580
+ const $=id=>document.getElementById(id);
581
+ async function loadPressure(){try{const[weatherData,pressureData]=await Promise.all([fetch('/api/weather').then(r=>r.json()),fetch('/api/pressure').then(r=>r.json())]);if(weatherData.pressure)$('pr-current').textContent=weatherData.pressure+' hPa';if(weatherData.shift_6h_past!==undefined){const v=weatherData.shift_6h_past;const el=$('pr-past-shift');el.textContent=(v>0?'+':'')+v.toFixed(1)+' hPa';el.style.color=Math.abs(v)>=8?'#fca5a5':Math.abs(v)>=4?'#facc15':'#5eead4'}if(weatherData.shift_6h_future!==undefined){const v=weatherData.shift_6h_future;const el=$('pr-future-shift');el.textContent=(v>0?'+':'')+v.toFixed(1)+' hPa';el.style.color=Math.abs(v)>=8?'#fca5a5':Math.abs(v)>=4?'#facc15':'#5eead4'}if(pressureData&&!pressureData.error){_pressureGraphData=pressureData;if(document.getElementById('pressure').classList.contains('active'))drawPressureChart(pressureData)}}catch(err){console.error('Pressure load error:',err)}}
582
+
583
+ function drawPressureChart(data){const c=$('pressure-chart');const ctx=c.getContext('2d');const dpr=window.devicePixelRatio||1;const W=c.width=c.clientWidth*dpr;const H=c.height=280*dpr;ctx.clearRect(0,0,W,H);const history=data.history||[];const forecast=data.forecast||[];if(!history.length&&!forecast.length){ctx.fillStyle='#71717a';ctx.font=(12*dpr)+'px system-ui';ctx.fillText('No pressure data yet',20*dpr,30*dpr);return}const allPoints=[];history.forEach(h=>allPoints.push({ts:new Date(h.recorded_at+'Z').getTime(),pressure:h.pressure_hpa,type:'history'}));if(data.current)allPoints.push({ts:new Date(data.current.timestamp+'Z').getTime(),pressure:data.current.pressure,type:'current'});forecast.forEach(f=>allPoints.push({ts:new Date(f.dt_txt+'Z').getTime(),pressure:f.pressure,type:'forecast'}));if(!allPoints.length)return;allPoints.sort((a,b)=>a.ts-b.ts);const nowMs=Date.now();const windowStart=nowMs-12*60*60*1000;const windowEnd=nowMs+36*60*60*1000;const visible=allPoints.filter(p=>p.ts>=windowStart&&p.ts<=windowEnd);if(!visible.length)visible.push(...allPoints);const minT=visible[0].ts;const maxT=visible[visible.length-1].ts;const timeRange=maxT-minT||1;const pressures=visible.map(p=>p.pressure);let minP=Math.min(...pressures);let maxP=Math.max(...pressures);const pPad=Math.max(3,(maxP-minP)*0.1);minP-=pPad;maxP+=pPad;const pRange=maxP-minP||1;const pad={top:30*dpr,right:50*dpr,bottom:40*dpr,left:55*dpr};const plotW=W-pad.left-pad.right;const plotH=H-pad.top-pad.bottom;const toX=ts=>pad.left+((ts-minT)/timeRange)*plotW;const toY=p=>pad.top+plotH-((p-minP)/pRange)*plotH;ctx.strokeStyle='rgba(255,255,255,0.06)';ctx.lineWidth=dpr;const pStep=pRange>20?5:pRange>10?2:1;for(let p=Math.ceil(minP/pStep)*pStep;p<=maxP;p+=pStep){const y=toY(p);ctx.beginPath();ctx.moveTo(pad.left,y);ctx.lineTo(W-pad.right,y);ctx.stroke();ctx.fillStyle='#52525b';ctx.font=(10*dpr)+'px system-ui';ctx.textAlign='right';ctx.fillText(p.toFixed(0)+' hPa',pad.left-6*dpr,y+4*dpr)}ctx.textAlign='center';ctx.fillStyle='#52525b';ctx.font=(9*dpr)+'px system-ui';const totalHours=timeRange/(1000*60*60);const labelStep=totalHours>96?24:totalHours>48?12:6;const firstHour=new Date(minT);firstHour.setMinutes(0,0,0);firstHour.setHours(Math.ceil(firstHour.getHours()/labelStep)*labelStep);for(let t=firstHour.getTime();t<=maxT;t+=labelStep*60*60*1000){const x=toX(t);const d=new Date(t);ctx.fillText(d.getUTCDate()+'/'+(d.getUTCMonth()+1)+' '+d.getUTCHours()+':00',x,H-pad.bottom+16*dpr)}const nowTs=Date.now();if(nowTs>=minT&&nowTs<=maxT){const nx=toX(nowTs);ctx.strokeStyle='rgba(192,132,252,0.5)';ctx.lineWidth=2*dpr;ctx.setLineDash([4*dpr,4*dpr]);ctx.beginPath();ctx.moveTo(nx,pad.top);ctx.lineTo(nx,H-pad.bottom);ctx.stroke();ctx.setLineDash([]);ctx.fillStyle='#c084fc';ctx.font=(10*dpr)+'px system-ui';ctx.textAlign='center';ctx.fillText('Now',nx,pad.top-6*dpr)}const SIX_H=6*60*60*1000;for(let i=0;i<visible.length;i++){let j=i+1;while(j<visible.length&&(visible[j].ts-visible[i].ts)<SIX_H)j++;if(j>=visible.length)continue;const delta=Math.abs(visible[j].pressure-visible[i].pressure);let color=null;if(delta>=12)color='rgba(239,68,68,0.12)';else if(delta>=8)color='rgba(192,132,252,0.10)';else if(delta>=4)color='rgba(234,179,8,0.08)';if(color){ctx.fillStyle=color;ctx.fillRect(toX(visible[i].ts),pad.top,toX(visible[j].ts)-toX(visible[i].ts),plotH)}}const histPts=visible.filter(p=>p.type==='history'||p.type==='current');if(histPts.length>1){ctx.strokeStyle='#2dd4bf';ctx.lineWidth=2.5*dpr;ctx.beginPath();histPts.forEach((p,i)=>{const x=toX(p.ts),y=toY(p.pressure);if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)});ctx.stroke()}const fcPts=visible.filter(p=>p.type==='forecast');const bridgePoint=histPts.length?histPts[histPts.length-1]:null;if(fcPts.length>0){ctx.strokeStyle='rgba(45,212,191,0.45)';ctx.lineWidth=2*dpr;ctx.setLineDash([6*dpr,4*dpr]);ctx.beginPath();if(bridgePoint)ctx.moveTo(toX(bridgePoint.ts),toY(bridgePoint.pressure));fcPts.forEach((p,i)=>{const x=toX(p.ts),y=toY(p.pressure);if(!bridgePoint&&i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y)});ctx.stroke();ctx.setLineDash([])}ctx.strokeStyle='rgba(255,255,255,.15)';ctx.lineWidth=dpr;ctx.beginPath();ctx.moveTo(pad.left,pad.top);ctx.lineTo(pad.left,H-pad.bottom);ctx.lineTo(W-pad.right,H-pad.bottom);ctx.stroke()}
584
+
585
+ /* === Shopping === */
586
+ async function loadShopping(){try{const data=await api('/shopping');const el=document.getElementById('shop-list');if(!data.items||!data.items.length){el.innerHTML='<p class="empty">Nothing on the list</p>';return}const groups={};data.items.forEach(i=>{if(!groups[i.category])groups[i.category]=[];groups[i.category].push(i)});let html='';for(const[cat,items]of Object.entries(groups)){html+='<div class="shop-category">'+sanitize(cat)+'</div>';items.forEach(i=>{html+='<div class="shop-item'+(i.checked?' checked':'')+'"><input type="checkbox" class="shop-check" data-id="'+i.id+'"'+(i.checked?' checked':'')+'><span class="shop-text">'+sanitize(i.item)+'</span><span class="shop-by">'+sanitize(i.added_by)+'</span><button class="shop-del" data-del="'+i.id+'">\\u2715</button></div>'})}el.innerHTML=html;el.querySelectorAll('.shop-check').forEach(c=>c.onchange=async()=>{await api('/shopping/'+c.dataset.id+'/check','POST');loadShopping();loadDashboard()});el.querySelectorAll('.shop-del').forEach(b=>b.onclick=async()=>{await api('/shopping/'+b.dataset.del,'DELETE');loadShopping();loadDashboard()})}catch(err){console.error(err)}}
587
+ document.getElementById('shop-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);await api('/shopping','POST',{item:f.get('item'),category:f.get('category'),added_by:f.get('added_by')});e.target.reset();loadShopping();loadDashboard()});
588
+ document.getElementById('shop-clear-checked').addEventListener('click',async()=>{await api('/shopping/checked','DELETE');loadShopping()});
589
+
590
+ /* === Food Diary === */
591
+ let fdPhotoFile=null;
592
+ document.getElementById('fd-upload-area').addEventListener('click',()=>document.getElementById('fd-photo').click());
593
+ document.getElementById('fd-photo').addEventListener('change',e=>{const file=e.target.files[0];if(!file)return;fdPhotoFile=file;document.getElementById('fd-upload-area').classList.add('has-file');document.getElementById('fd-upload-text').textContent=file.name;const reader=new FileReader();reader.onload=ev=>{const preview=document.getElementById('fd-preview');preview.src=ev.target.result;preview.style.display='block'};reader.readAsDataURL(file)});
594
+ document.getElementById('food-form').addEventListener('submit',async e=>{e.preventDefault();const f=new FormData(e.target);let photoKey=null;if(fdPhotoFile){const fd=new FormData();fd.append('photo',fdPhotoFile);const uploadRes=await fetch('/api/food/photo',{method:'POST',body:fd});const uploadData=await uploadRes.json();if(uploadData.key)photoKey=uploadData.key}await api('/food','POST',{meal_type:f.get('meal_type'),time:f.get('time'),note:f.get('note'),photo_key:photoKey});e.target.reset();fdPhotoFile=null;document.getElementById('fd-upload-area').classList.remove('has-file');document.getElementById('fd-upload-text').textContent='Tap to add photo (optional)';document.getElementById('fd-preview').style.display='none';loadFood()});
595
+
596
+ async function loadFood(){try{const[todayData,waterData,reviewData,histData]=await Promise.all([api('/food'),api('/water'),api('/food/reviews?date='+new Date().toISOString().slice(0,10)),api('/food/history')]);const meals=todayData.entries||[];const todayEl=document.getElementById('fd-today-meals');if(meals.length){todayEl.innerHTML=meals.map(m=>'<div class="fd-meal-card">'+(m.photo_key?'<img src="/api/food/photo/'+encodeURIComponent(m.photo_key)+'" alt="meal" onclick="toggleFdPhoto(this)">':'')+'<div class="fd-meal-info"><div class="fd-meal-type">'+sanitize(m.meal_type)+'</div><div class="fd-meal-time">'+m.time+'</div>'+(m.note?'<div class="fd-meal-note">'+sanitize(m.note)+'</div>':'')+'</div></div>').join('')}else{todayEl.innerHTML='<p class="empty">Nothing logged yet today</p>'}document.getElementById('fd-today-count').textContent=meals.length;const waterTotal=waterData.total_ml||0;document.getElementById('fd-water-total').textContent=waterTotal+' ml';document.getElementById('fd-today-water').textContent=waterTotal+' ml';const pct=Math.min(100,Math.round((waterTotal/2000)*100));document.getElementById('fd-water-fill').style.width=pct+'%';document.getElementById('fd-water-pct').textContent=pct+'%';const reviewEl=document.getElementById('fd-review-area');if(reviewData.review){reviewEl.innerHTML='<div class="fd-review-card"><div class="fd-review-header"><span class="fd-review-name">'+sanitize(reviewData.review.reviewer)+'</span><span class="fd-review-label">Daily Review</span></div><div class="fd-review-text">'+sanitize(reviewData.review.review)+'</div><div class="fd-review-date">'+reviewData.review.date+'</div></div>'}else{reviewEl.innerHTML='<div class="fd-review-card empty-review"><div class="fd-review-text">No review yet for today</div></div>'}const histEntries=histData.entries||[];const weekEl=document.getElementById('fd-week-view');const dayMap={};histEntries.forEach(m=>{if(!dayMap[m.date])dayMap[m.date]=[];dayMap[m.date].push(m)});const days=Object.keys(dayMap).sort().reverse().slice(0,7);if(days.length){weekEl.innerHTML=days.map(d=>{const ms=dayMap[d];const types=ms.map(m=>m.meal_type);return'<div class="fd-week-day"><span style="width:60px;color:#71717a">'+new Date(d+'T00:00:00').toLocaleDateString('en-GB',{weekday:'short',day:'numeric'})+'</span><span class="fd-meal-dot '+(types.includes('breakfast')?'logged':'missed')+'" title="Breakfast"></span><span class="fd-meal-dot '+(types.includes('lunch')?'logged':'missed')+'" title="Lunch"></span><span class="fd-meal-dot '+(types.includes('dinner')?'logged':'missed')+'" title="Dinner"></span><span style="color:#71717a;font-size:0.7rem">'+ms.length+' meals</span></div>'}).join('')}else{weekEl.innerHTML='<p class="empty">Log meals to see weekly patterns</p>'}const reviewsData=await api('/food/reviews');const pastEl=document.getElementById('fd-past-reviews');if(reviewsData.reviews&&reviewsData.reviews.length){pastEl.innerHTML=reviewsData.reviews.map(r=>'<div class="fd-review-card"><div class="fd-review-header"><span class="fd-review-name">'+sanitize(r.reviewer)+'</span><span class="fd-review-label">'+r.date+'</span></div><div class="fd-review-text">'+sanitize(r.review)+'</div></div>').join('')}const weekAvg=days.length?Math.round(histEntries.length/days.length*10)/10:'--';document.getElementById('fd-week-avg').textContent=weekAvg+' meals/day'}catch(err){console.error(err)}}
597
+
598
+ document.querySelectorAll('.fd-water-btn').forEach(b=>b.addEventListener('click',async()=>{const ml=parseInt(b.dataset.ml);await api('/water','POST',{amount_ml:ml});loadFood()}));
599
+
600
+ function toggleFdPhoto(img){if(img.classList.contains('fd-expanded')){img.classList.remove('fd-expanded');document.getElementById('fd-overlay').style.display='none'}else{img.classList.add('fd-expanded');document.getElementById('fd-overlay').style.display='block';document.getElementById('fd-overlay').onclick=()=>{img.classList.remove('fd-expanded');document.getElementById('fd-overlay').style.display='none'}}}
601
+
602
+ /* === Init === */
603
+ loadDashboard();loadMoods();loadNotes();loadMoments();loadDates();loadWeather();loadPressure();loadShopping();loadFood();
604
+ </script>
605
+ </body>
606
+ </html>`;
607
+ }
608
+
609
+ /* ========================= BACKEND ========================= */
610
+
611
+ export const applicationHandler = {
612
+ async fetch(request, env, ctx) {
613
+ const config = getConfig(env);
614
+ const url = new URL(request.url);
615
+ const path = url.pathname;
616
+
617
+ // MCP endpoint
618
+ if (!config.PASSWORD || !config.SESSION_SECRET) {
619
+ return new Response('Hearth Dash is not configured. Set DASHBOARD_PASSWORD and SESSION_SECRET as Cloudflare Worker secrets.', {
620
+ status: 503,
621
+ headers: securityHeaders({ 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }),
622
+ });
541
623
  }
542
-
624
+
543
625
  // Auth
544
- const sessionCookie = getCookie(request, 'hearth_session');
545
- const isAuthenticated = sessionCookie === config.PASSWORD;
546
-
626
+ const sessionToken = getCookie(request, '__Host-hearth_session');
627
+ const isAuthenticated = await verifySession(sessionToken, config.SESSION_SECRET);
628
+
547
629
  if (path === '/login') {
548
630
  if (request.method === 'POST') {
631
+ if (!sameOrigin(request)) return new Response('Forbidden', { status: 403 });
632
+ if (Number(request.headers.get('Content-Length') || 0) > 16 * 1024) return new Response('Request body is too large', { status: 413 });
633
+ if (!(await withinRateLimit(env, request, 'login', 10, 600))) {
634
+ return new Response('Too many login attempts. Try again later.', { status: 429, headers: securityHeaders({ 'Retry-After': '600' }) });
635
+ }
549
636
  const formData = await request.formData();
550
- if (formData.get('password') === config.PASSWORD) {
551
- return new Response(null, { status: 302, headers: { 'Location': '/', 'Set-Cookie': `hearth_session=${config.PASSWORD}; Path=/; HttpOnly; SameSite=Strict; Max-Age=604800` } });
637
+ if (await safeEqual(formData.get('password'), config.PASSWORD)) {
638
+ const session = await createSession(config.SESSION_SECRET);
639
+ return new Response(null, { status: 302, headers: securityHeaders({ 'Location': '/', 'Set-Cookie': sessionCookie(session), 'Cache-Control': 'no-store' }) });
552
640
  }
553
- return new Response(getLoginPage(config).replace('{{ERROR}}', '<p class="error">Wrong password</p>'), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
641
+ return new Response(getLoginPage(config).replace('{{ERROR}}', '<p class="error">Wrong password</p>'), { status: 401, headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
554
642
  }
555
- return new Response(getLoginPage(config).replace('{{ERROR}}', ''), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
643
+ if (!['GET', 'HEAD'].includes(request.method)) return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'GET, POST' }) });
644
+ return new Response(getLoginPage(config).replace('{{ERROR}}', ''), { headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
556
645
  }
557
- if (path === '/logout') return new Response(null, { status: 302, headers: { 'Location': '/login', 'Set-Cookie': 'hearth_session=; Path=/; Max-Age=0' } });
646
+ if (path === '/logout') return new Response(null, { status: 302, headers: securityHeaders({ 'Location': '/login', 'Set-Cookie': sessionCookie('', 0), 'Cache-Control': 'no-store' }) });
558
647
  if (!isAuthenticated) return Response.redirect(url.origin + '/login', 302);
559
648
 
560
- // API routes
561
- if (path === '/api/weather') return handleWeather(env);
562
- if (path === '/api/pressure') return handlePressure(env);
563
- if (path === '/api/food/photo' && request.method === 'POST') return handleFoodPhotoUpload(request, env);
564
- if (path.startsWith('/api/food/photo/') && request.method === 'GET') {
565
- const key = decodeURIComponent(path.substring('/api/food/photo/'.length));
566
- return handleFoodPhotoServe(key, env);
649
+ if (!['GET', 'HEAD'].includes(request.method) && !sameOrigin(request)) {
650
+ return new Response('Forbidden', { status: 403 });
567
651
  }
568
- if (path.startsWith('/api/')) return handleAPI(request, env, path.substring(4), config);
569
-
570
- return new Response(getDashboardHTML(config), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
652
+
653
+ // API routes
654
+ if (path === '/api/weather') return handleWeather(env);
655
+ if (path === '/api/pressure') return handlePressure(env);
656
+ if (path === '/api/food/photo' && request.method === 'POST') return handleFoodPhotoUpload(request, env);
657
+ if (path.startsWith('/api/food/photo/') && request.method === 'GET') {
658
+ const key = decodeURIComponent(path.substring('/api/food/photo/'.length));
659
+ return handleFoodPhotoServe(key, env);
660
+ }
661
+ if (path.startsWith('/api/')) return handleAPI(request, env, path.substring(4), config);
662
+
663
+ return new Response(getDashboardHTML(config), { headers: securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }) });
571
664
  }
572
665
  };
573
666
 
574
- /* ========================= REST API ========================= */
667
+ const OAUTH_SCOPES = ['hearth:read', 'hearth:write'];
668
+ const OAUTH_CSRF_COOKIE = '__Host-hearth_oauth_csrf';
575
669
 
576
- async function handleAPI(request, env, endpoint, config) {
577
- const method = request.method;
578
- try {
579
- /* Dashboard */
580
- if (endpoint === '/dashboard' && method === 'GET') {
581
- const [moodsP1, moodsP2, latestNote, nextDate, shoppingCount, todayMeals, todayWater] = await Promise.all([
582
- env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 1').bind(config.PARTNER_1).first(),
583
- env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 1').bind(config.PARTNER_2).first(),
584
- env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT 1').first(),
585
- env.DB.prepare('SELECT * FROM dates WHERE date >= date("now") ORDER BY date ASC LIMIT 1').first(),
586
- env.DB.prepare('SELECT COUNT(*) as cnt FROM shopping WHERE checked = 0').first(),
587
- env.DB.prepare("SELECT * FROM food_diary WHERE date = date('now') ORDER BY time ASC").all(),
588
- env.DB.prepare("SELECT SUM(amount_ml) as total FROM water_log WHERE date = date('now')").first()
589
- ]);
590
- const moods = {};
591
- moods[config.PARTNER_1] = moodsP1;
592
- moods[config.PARTNER_2] = moodsP2;
593
- return json({ moods, latestNote, nextDate, shoppingCount: shoppingCount ? shoppingCount.cnt : 0, todayMeals: todayMeals.results || [], waterTotal: todayWater ? todayWater.total || 0 : 0 });
670
+ export const oauthApiHandler = {
671
+ async fetch(request, env, ctx) {
672
+ const config = getConfig(env);
673
+ const remainder = new URL(request.url).pathname.substring('/mcp'.length);
674
+ const bearer = request.headers.get('Authorization')?.match(/^Bearer\s+(.+)$/i)?.[1];
675
+ const token = bearer ? await env.OAUTH_PROVIDER.unwrapToken(bearer) : null;
676
+ const scopes = Array.isArray(token?.scope) ? token.scope : [];
677
+ return handleMCP(request, env, config, remainder, scopes);
678
+ },
679
+ };
680
+
681
+ export const oauthDefaultHandler = {
682
+ async fetch(request, env, ctx) {
683
+ const url = new URL(request.url);
684
+ if (url.pathname !== '/authorize') return applicationHandler.fetch(request, env, ctx);
685
+
686
+ const config = getConfig(env);
687
+ if (!config.PASSWORD || !config.SESSION_SECRET) {
688
+ return new Response('Hearth Dash is not configured.', { status: 503, headers: securityHeaders() });
689
+ }
690
+ if (!['GET', 'POST'].includes(request.method)) {
691
+ return new Response(null, { status: 405, headers: securityHeaders({ Allow: 'GET, POST' }) });
594
692
  }
595
693
 
596
- /* Moods */
597
- if (endpoint === '/moods' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM moods ORDER BY created_at DESC LIMIT 20').all(); return json({ moods: r.results }); }
598
- if (endpoint === '/moods' && method === 'POST') { const b = await request.json(); await env.DB.prepare('INSERT INTO moods (partner, mood, note, created_at) VALUES (?, ?, ?, datetime("now"))').bind(b.partner, b.mood, b.note || null).run(); return json({ success: true }); }
694
+ let oauthRequest;
695
+ try {
696
+ oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
697
+ } catch (error) {
698
+ return renderOAuthAuthorizationError(error);
699
+ }
700
+ const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
701
+ if (!client) return new Response('Unknown OAuth client', { status: 400, headers: securityHeaders() });
599
702
 
600
- /* Notes */
601
- if (endpoint === '/notes' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT 20').all(); return json({ notes: r.results }); }
602
- if (endpoint === '/notes' && method === 'POST') { const b = await request.json(); await env.DB.prepare('INSERT INTO notes (from_partner, content, created_at) VALUES (?, ?, datetime("now"))').bind(b.from, b.content).run(); return json({ success: true }); }
703
+ const sessionToken = getCookie(request, '__Host-hearth_session');
704
+ const signedIn = await verifySession(sessionToken, config.SESSION_SECRET);
603
705
 
604
- /* Moments */
605
- if (endpoint === '/moments' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM moments ORDER BY date DESC').all(); return json({ moments: r.results }); }
606
- if (endpoint === '/moments' && method === 'POST') { const b = await request.json(); await env.DB.prepare('INSERT INTO moments (date, title, description, created_at) VALUES (?, ?, ?, datetime("now"))').bind(b.date, b.title, b.description || null).run(); return json({ success: true }); }
706
+ if (request.method === 'GET') {
707
+ const csrf = base64Url(crypto.getRandomValues(new Uint8Array(32)));
708
+ const headers = new Headers(securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
709
+ headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=${csrf}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
710
+ return new Response(renderOAuthConsentPage(request.url, oauthRequest, client, csrf, signedIn), { headers });
711
+ }
607
712
 
608
- /* Dates */
609
- if (endpoint === '/dates' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM dates ORDER BY date ASC').all(); return json({ dates: r.results }); }
610
- if (endpoint === '/dates' && method === 'POST') { const b = await request.json(); await env.DB.prepare('INSERT INTO dates (date, title, recurring, created_at) VALUES (?, ?, ?, datetime("now"))').bind(b.date, b.title, b.recurring ? 1 : 0).run(); return json({ success: true }); }
611
- const dateDelMatch = endpoint.match(/^\/dates\/(\d+)$/);
612
- if (dateDelMatch && method === 'DELETE') {
613
- await env.DB.prepare('DELETE FROM dates WHERE id = ?').bind(+dateDelMatch[1]).run();
614
- return json({ success: true });
713
+ if (!sameOrigin(request)) return new Response('Forbidden', { status: 403, headers: securityHeaders() });
714
+ if (Number(request.headers.get('Content-Length') || 0) > 16 * 1024) return new Response('Request body is too large', { status: 413 });
715
+ if (!(await withinRateLimit(env, request, 'oauth-consent', 10, 600))) {
716
+ return new Response('Too many authorization attempts. Try again later.', { status: 429, headers: securityHeaders({ 'Retry-After': '600' }) });
615
717
  }
616
718
 
617
- /* Shopping */
618
- if (endpoint === '/shopping' && method === 'GET') {
619
- const r = await env.DB.prepare('SELECT * FROM shopping ORDER BY checked ASC, created_at DESC').all();
620
- return json({ items: r.results });
719
+ const form = await request.formData();
720
+ const csrfCookie = getCookie(request, OAUTH_CSRF_COOKIE);
721
+ if (!csrfCookie || !(await safeEqual(csrfCookie, form.get('csrf_token')))) {
722
+ return new Response('Authorization form expired. Start the connection again.', { status: 400, headers: securityHeaders() });
621
723
  }
622
- if (endpoint === '/shopping' && method === 'POST') {
623
- const b = await request.json();
624
- await env.DB.prepare('INSERT INTO shopping (item, category, checked, added_by, created_at) VALUES (?, ?, 0, ?, datetime("now"))').bind(b.item, b.category || 'Other', b.added_by || config.PARTNER_1).run();
625
- return json({ success: true });
724
+ if (form.get('decision') === 'deny') return redirectOAuthDenial(oauthRequest);
725
+
726
+ let authenticated = signedIn;
727
+ let newSession = null;
728
+ if (!authenticated) {
729
+ authenticated = await safeEqual(form.get('password'), config.PASSWORD);
730
+ if (authenticated) newSession = await createSession(config.SESSION_SECRET);
626
731
  }
627
- if (endpoint === '/shopping/checked' && method === 'DELETE') {
628
- await env.DB.prepare('DELETE FROM shopping WHERE checked = 1').run();
629
- return json({ success: true });
732
+ if (!authenticated) {
733
+ const csrf = base64Url(crypto.getRandomValues(new Uint8Array(32)));
734
+ const headers = new Headers(securityHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
735
+ headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=${csrf}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
736
+ return new Response(renderOAuthConsentPage(request.url, oauthRequest, client, csrf, false, 'Wrong dashboard password'), { status: 401, headers });
630
737
  }
631
- const shopCheckMatch = endpoint.match(/^\/shopping\/(\d+)\/check$/);
632
- if (shopCheckMatch && method === 'POST') {
633
- await env.DB.prepare('UPDATE shopping SET checked = CASE WHEN checked = 0 THEN 1 ELSE 0 END WHERE id = ?').bind(+shopCheckMatch[1]).run();
738
+
739
+ const requestedScopes = oauthRequest.scope?.length ? oauthRequest.scope : ['hearth:read'];
740
+ const grantedScopes = requestedScopes.filter(scope => OAUTH_SCOPES.includes(scope));
741
+ const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
742
+ request: oauthRequest,
743
+ userId: 'hearth-owner',
744
+ metadata: { clientName: client.clientName || 'MCP client' },
745
+ scope: grantedScopes,
746
+ props: { userId: 'hearth-owner', scopes: grantedScopes },
747
+ });
748
+
749
+ const headers = new Headers(securityHeaders({ Location: redirectTo, 'Cache-Control': 'no-store' }));
750
+ headers.append('Set-Cookie', `${OAUTH_CSRF_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
751
+ if (newSession) headers.append('Set-Cookie', sessionCookie(newSession));
752
+ return new Response(null, { status: 302, headers });
753
+ },
754
+ };
755
+
756
+ function renderOAuthAuthorizationError(error) {
757
+ if (!error || typeof error.code !== 'string') return new Response('Invalid authorization request', { status: 400, headers: securityHeaders() });
758
+ if (!error.redirectUri) return new Response(error.description || 'Invalid authorization request', { status: 400, headers: securityHeaders() });
759
+ const redirect = new URL(error.redirectUri);
760
+ redirect.searchParams.set('error', error.code);
761
+ redirect.searchParams.set('error_description', error.description || 'Authorization failed');
762
+ if (error.state) redirect.searchParams.set('state', error.state);
763
+ if (error.issuer) redirect.searchParams.set('iss', error.issuer);
764
+ return Response.redirect(redirect, 302);
765
+ }
766
+
767
+ function redirectOAuthDenial(oauthRequest) {
768
+ const redirect = new URL(oauthRequest.redirectUri);
769
+ redirect.searchParams.set('error', 'access_denied');
770
+ redirect.searchParams.set('error_description', 'The Hearth owner denied access.');
771
+ if (oauthRequest.state) redirect.searchParams.set('state', oauthRequest.state);
772
+ if (oauthRequest.issuer) redirect.searchParams.set('iss', oauthRequest.issuer);
773
+ return Response.redirect(redirect, 302);
774
+ }
775
+
776
+ function renderOAuthConsentPage(action, oauthRequest, client, csrf, signedIn, error = '') {
777
+ const clientName = escapeHtml(client.clientName || 'An MCP client');
778
+ const redirectHost = escapeHtml(new URL(oauthRequest.redirectUri).host);
779
+ const scopes = (oauthRequest.scope?.length ? oauthRequest.scope : ['hearth:read'])
780
+ .filter(scope => OAUTH_SCOPES.includes(scope));
781
+ const permissions = scopes.map(scope => scope === 'hearth:write'
782
+ ? '<li><strong>Write:</strong> add moods, notes, moments, dates, shopping items and food reviews</li>'
783
+ : '<li><strong>Read:</strong> view the shared Hearth dashboard data</li>').join('');
784
+ return `<!doctype html>
785
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
786
+ <title>Authorize Hearth</title><style>
787
+ body{font-family:system-ui,sans-serif;background:#0f0f1a;color:#fafafa;min-height:100vh;display:grid;place-items:center;margin:0}.box{background:#1a1a2e;border:1px solid #34344a;border-radius:16px;padding:2rem;max-width:520px;width:calc(100% - 3rem)}h1{margin-top:0}.muted{color:#a1a1aa}.error{color:#f87171}input{box-sizing:border-box;width:100%;padding:.9rem;background:#0f0f1a;color:#fff;border:1px solid #45455f;border-radius:8px;margin:.5rem 0 1rem}.actions{display:flex;gap:.75rem}.actions button{padding:.8rem 1rem;border-radius:8px;border:0;font-weight:650;cursor:pointer}.approve{background:#a855f7;color:white}.deny{background:#34344a;color:white}code{word-break:break-all;color:#c4b5fd}
788
+ </style></head><body><main class="box"><h1>Authorize Hearth</h1>
789
+ <p><strong>${clientName}</strong> wants access to this private dashboard.</p><ul>${permissions}</ul>
790
+ <p class="muted">After approval you will return to <code>${redirectHost}</code>. Access is token-based and expires; remove or revoke the connector to end access.</p>
791
+ ${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
792
+ <form method="post" action="${escapeHtml(action)}"><input type="hidden" name="csrf_token" value="${escapeHtml(csrf)}">
793
+ ${signedIn ? '' : '<label>Dashboard password<input type="password" name="password" autocomplete="current-password" required></label>'}
794
+ <div class="actions"><button class="approve" type="submit" name="decision" value="approve">Approve</button><button class="deny" type="submit" name="decision" value="deny">Deny</button></div>
795
+ </form></main></body></html>`;
796
+ }
797
+
798
+ export default applicationHandler;
799
+
800
+ /* ========================= REST API ========================= */
801
+
802
+ async function handleAPI(request, env, endpoint, config) {
803
+ const method = request.method;
804
+ try {
805
+ /* Dashboard */
806
+ if (endpoint === '/dashboard' && method === 'GET') {
807
+ const [moodsP1, moodsP2, latestNote, nextDate, shoppingCount, todayMeals, todayWater] = await Promise.all([
808
+ env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 1').bind(config.PARTNER_1).first(),
809
+ env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 1').bind(config.PARTNER_2).first(),
810
+ env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT 1').first(),
811
+ env.DB.prepare('SELECT * FROM dates WHERE date >= date("now") ORDER BY date ASC LIMIT 1').first(),
812
+ env.DB.prepare('SELECT COUNT(*) as cnt FROM shopping WHERE checked = 0').first(),
813
+ env.DB.prepare("SELECT * FROM food_diary WHERE date = date('now') ORDER BY time ASC").all(),
814
+ env.DB.prepare("SELECT SUM(amount_ml) as total FROM water_log WHERE date = date('now')").first()
815
+ ]);
816
+ const moods = {};
817
+ moods[config.PARTNER_1] = moodsP1;
818
+ moods[config.PARTNER_2] = moodsP2;
819
+ return json({ moods, latestNote, nextDate, shoppingCount: shoppingCount ? shoppingCount.cnt : 0, todayMeals: todayMeals.results || [], waterTotal: todayWater ? todayWater.total || 0 : 0 });
820
+ }
821
+
822
+ /* Moods */
823
+ if (endpoint === '/moods' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM moods ORDER BY created_at DESC LIMIT 20').all(); return json({ moods: r.results }); }
824
+ if (endpoint === '/moods' && method === 'POST') {
825
+ const b = await readJsonObject(request);
826
+ const partner = requireText(b, 'partner', 80, [config.PARTNER_1, config.PARTNER_2]);
827
+ const mood = requireText(b, 'mood', 20, ['great', 'good', 'okay', 'tired', 'stressed', 'low']);
828
+ await env.DB.prepare('INSERT INTO moods (partner, mood, note, created_at) VALUES (?, ?, ?, datetime("now"))').bind(partner, mood, optionalText(b, 'note', 2000)).run();
634
829
  return json({ success: true });
635
830
  }
636
- const shopDelMatch = endpoint.match(/^\/shopping\/(\d+)$/);
637
- if (shopDelMatch && method === 'DELETE') {
638
- await env.DB.prepare('DELETE FROM shopping WHERE id = ?').bind(+shopDelMatch[1]).run();
831
+
832
+ /* Notes */
833
+ if (endpoint === '/notes' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT 20').all(); return json({ notes: r.results }); }
834
+ if (endpoint === '/notes' && method === 'POST') {
835
+ const b = await readJsonObject(request);
836
+ await env.DB.prepare('INSERT INTO notes (from_partner, content, created_at) VALUES (?, ?, datetime("now"))').bind(requireText(b, 'from', 80), requireText(b, 'content', 4000)).run();
639
837
  return json({ success: true });
640
838
  }
641
-
642
- /* Food Diary */
643
- if (endpoint === '/food' && method === 'GET') {
644
- const date = new URL(request.url).searchParams.get('date');
645
- const r = date
646
- ? await env.DB.prepare('SELECT * FROM food_diary WHERE date = ? ORDER BY time ASC').bind(date).all()
647
- : await env.DB.prepare("SELECT * FROM food_diary WHERE date = date('now') ORDER BY time ASC").all();
648
- return json({ entries: r.results });
649
- }
650
- if (endpoint === '/food' && method === 'POST') {
651
- const b = await request.json();
652
- const date = b.date || new Date().toISOString().slice(0, 10);
653
- const time = b.time || new Date().toISOString().slice(11, 16);
654
- await env.DB.prepare('INSERT INTO food_diary (date, time, meal_type, note, photo_key, created_at) VALUES (?, ?, ?, ?, ?, datetime("now"))').bind(date, time, b.meal_type, b.note || null, b.photo_key || null).run();
839
+
840
+ /* Moments */
841
+ if (endpoint === '/moments' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM moments ORDER BY date DESC').all(); return json({ moments: r.results }); }
842
+ if (endpoint === '/moments' && method === 'POST') {
843
+ const b = await readJsonObject(request);
844
+ await env.DB.prepare('INSERT INTO moments (date, title, description, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'title', 200), optionalText(b, 'description', 4000)).run();
655
845
  return json({ success: true });
656
846
  }
657
- const foodDelMatch = endpoint.match(/^\/food\/(\d+)$/);
658
- if (foodDelMatch && method === 'DELETE') {
659
- const entry = await env.DB.prepare('SELECT photo_key FROM food_diary WHERE id = ?').bind(+foodDelMatch[1]).first();
660
- if (entry && entry.photo_key) { try { await env.PHOTOS.delete(entry.photo_key); } catch(e){} }
661
- await env.DB.prepare('DELETE FROM food_diary WHERE id = ?').bind(+foodDelMatch[1]).run();
847
+
848
+ /* Dates */
849
+ if (endpoint === '/dates' && method === 'GET') { const r = await env.DB.prepare('SELECT * FROM dates ORDER BY date ASC').all(); return json({ dates: r.results }); }
850
+ if (endpoint === '/dates' && method === 'POST') {
851
+ const b = await readJsonObject(request);
852
+ await env.DB.prepare('INSERT INTO dates (date, title, recurring, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'title', 200), b.recurring === true ? 1 : 0).run();
662
853
  return json({ success: true });
663
854
  }
664
- if (endpoint === '/food/history' && method === 'GET') {
665
- const params = new URL(request.url).searchParams;
666
- const from = params.get('from') || new Date(Date.now() - 7*24*60*60*1000).toISOString().slice(0,10);
667
- const to = params.get('to') || new Date().toISOString().slice(0,10);
668
- const r = await env.DB.prepare('SELECT * FROM food_diary WHERE date >= ? AND date <= ? ORDER BY date DESC, time DESC').bind(from, to).all();
669
- return json({ entries: r.results });
670
- }
671
-
672
- /* Water Log */
673
- if (endpoint === '/water' && method === 'GET') {
674
- const date = new URL(request.url).searchParams.get('date') || null;
675
- const r = date
676
- ? await env.DB.prepare('SELECT * FROM water_log WHERE date = ? ORDER BY created_at ASC').bind(date).all()
677
- : await env.DB.prepare("SELECT * FROM water_log WHERE date = date('now') ORDER BY created_at ASC").all();
678
- const total = (r.results || []).reduce((sum, e) => sum + e.amount_ml, 0);
679
- return json({ entries: r.results, total_ml: total });
680
- }
855
+ const dateDelMatch = endpoint.match(/^\/dates\/(\d+)$/);
856
+ if (dateDelMatch && method === 'DELETE') {
857
+ await env.DB.prepare('DELETE FROM dates WHERE id = ?').bind(+dateDelMatch[1]).run();
858
+ return json({ success: true });
859
+ }
860
+
861
+ /* Shopping */
862
+ if (endpoint === '/shopping' && method === 'GET') {
863
+ const r = await env.DB.prepare('SELECT * FROM shopping ORDER BY checked ASC, created_at DESC').all();
864
+ return json({ items: r.results });
865
+ }
866
+ if (endpoint === '/shopping' && method === 'POST') {
867
+ const b = await readJsonObject(request);
868
+ const item = requireText(b, 'item', 300);
869
+ const category = b.category ? requireText(b, 'category', 100) : 'Other';
870
+ const addedBy = b.added_by ? requireText(b, 'added_by', 80) : config.PARTNER_1;
871
+ await env.DB.prepare('INSERT INTO shopping (item, category, checked, added_by, created_at) VALUES (?, ?, 0, ?, datetime("now"))').bind(item, category, addedBy).run();
872
+ return json({ success: true });
873
+ }
874
+ if (endpoint === '/shopping/checked' && method === 'DELETE') {
875
+ await env.DB.prepare('DELETE FROM shopping WHERE checked = 1').run();
876
+ return json({ success: true });
877
+ }
878
+ const shopCheckMatch = endpoint.match(/^\/shopping\/(\d+)\/check$/);
879
+ if (shopCheckMatch && method === 'POST') {
880
+ await env.DB.prepare('UPDATE shopping SET checked = CASE WHEN checked = 0 THEN 1 ELSE 0 END WHERE id = ?').bind(+shopCheckMatch[1]).run();
881
+ return json({ success: true });
882
+ }
883
+ const shopDelMatch = endpoint.match(/^\/shopping\/(\d+)$/);
884
+ if (shopDelMatch && method === 'DELETE') {
885
+ await env.DB.prepare('DELETE FROM shopping WHERE id = ?').bind(+shopDelMatch[1]).run();
886
+ return json({ success: true });
887
+ }
888
+
889
+ /* Food Diary */
890
+ if (endpoint === '/food' && method === 'GET') {
891
+ const date = new URL(request.url).searchParams.get('date');
892
+ const r = date
893
+ ? await env.DB.prepare('SELECT * FROM food_diary WHERE date = ? ORDER BY time ASC').bind(date).all()
894
+ : await env.DB.prepare("SELECT * FROM food_diary WHERE date = date('now') ORDER BY time ASC").all();
895
+ return json({ entries: r.results });
896
+ }
897
+ if (endpoint === '/food' && method === 'POST') {
898
+ const b = await readJsonObject(request);
899
+ const date = b.date ? requireDate(b) : new Date().toISOString().slice(0, 10);
900
+ const time = b.time ? requireText(b, 'time', 5) : new Date().toISOString().slice(11, 16);
901
+ if (!/^\d{2}:\d{2}$/.test(time)) throw Object.assign(new Error('time must use HH:MM'), { status: 400 });
902
+ const mealType = requireText(b, 'meal_type', 20, ['breakfast', 'lunch', 'dinner', 'snack']);
903
+ await env.DB.prepare('INSERT INTO food_diary (date, time, meal_type, note, photo_key, created_at) VALUES (?, ?, ?, ?, ?, datetime("now"))').bind(date, time, mealType, optionalText(b, 'note', 4000), optionalText(b, 'photo_key', 500)).run();
904
+ return json({ success: true });
905
+ }
906
+ const foodDelMatch = endpoint.match(/^\/food\/(\d+)$/);
907
+ if (foodDelMatch && method === 'DELETE') {
908
+ const entry = await env.DB.prepare('SELECT photo_key FROM food_diary WHERE id = ?').bind(+foodDelMatch[1]).first();
909
+ if (entry && entry.photo_key) { try { await env.PHOTOS.delete(entry.photo_key); } catch(e){} }
910
+ await env.DB.prepare('DELETE FROM food_diary WHERE id = ?').bind(+foodDelMatch[1]).run();
911
+ return json({ success: true });
912
+ }
913
+ if (endpoint === '/food/history' && method === 'GET') {
914
+ const params = new URL(request.url).searchParams;
915
+ const from = params.get('from') || new Date(Date.now() - 7*24*60*60*1000).toISOString().slice(0,10);
916
+ const to = params.get('to') || new Date().toISOString().slice(0,10);
917
+ const r = await env.DB.prepare('SELECT * FROM food_diary WHERE date >= ? AND date <= ? ORDER BY date DESC, time DESC').bind(from, to).all();
918
+ return json({ entries: r.results });
919
+ }
920
+
921
+ /* Water Log */
922
+ if (endpoint === '/water' && method === 'GET') {
923
+ const date = new URL(request.url).searchParams.get('date') || null;
924
+ const r = date
925
+ ? await env.DB.prepare('SELECT * FROM water_log WHERE date = ? ORDER BY created_at ASC').bind(date).all()
926
+ : await env.DB.prepare("SELECT * FROM water_log WHERE date = date('now') ORDER BY created_at ASC").all();
927
+ const total = (r.results || []).reduce((sum, e) => sum + e.amount_ml, 0);
928
+ return json({ entries: r.results, total_ml: total });
929
+ }
681
930
  if (endpoint === '/water' && method === 'POST') {
682
- const b = await request.json();
683
- const date = b.date || new Date().toISOString().slice(0, 10);
684
- const amount = b.amount_ml || 250;
685
- await env.DB.prepare('INSERT INTO water_log (date, amount_ml, created_at) VALUES (?, ?, datetime("now"))').bind(date, amount).run();
686
- const r = date
687
- ? await env.DB.prepare('SELECT SUM(amount_ml) as total FROM water_log WHERE date = ?').bind(date).first()
688
- : await env.DB.prepare("SELECT SUM(amount_ml) as total FROM water_log WHERE date = date('now')").first();
689
- return json({ success: true, total_ml: r ? r.total || amount : amount });
690
- }
691
- const waterDelMatch = endpoint.match(/^\/water\/(\d+)$/);
692
- if (waterDelMatch && method === 'DELETE') {
693
- await env.DB.prepare('DELETE FROM water_log WHERE id = ?').bind(+waterDelMatch[1]).run();
694
- return json({ success: true });
695
- }
696
-
697
- /* Food Reviews */
698
- if (endpoint === '/food/reviews' && method === 'GET') {
699
- const date = new URL(request.url).searchParams.get('date');
700
- if (date) {
701
- const r = await env.DB.prepare('SELECT * FROM food_reviews WHERE date = ?').bind(date).first();
702
- return json({ review: r });
703
- }
704
- const r = await env.DB.prepare('SELECT * FROM food_reviews ORDER BY date DESC LIMIT 7').all();
705
- return json({ reviews: r.results });
706
- }
931
+ const b = await readJsonObject(request);
932
+ const date = b.date ? requireDate(b) : new Date().toISOString().slice(0, 10);
933
+ const amount = b.amount_ml === undefined ? 250 : b.amount_ml;
934
+ if (!Number.isInteger(amount) || amount < 1 || amount > 5000) throw Object.assign(new Error('amount_ml must be an integer between 1 and 5000'), { status: 400 });
935
+ await env.DB.prepare('INSERT INTO water_log (date, amount_ml, created_at) VALUES (?, ?, datetime("now"))').bind(date, amount).run();
936
+ const r = date
937
+ ? await env.DB.prepare('SELECT SUM(amount_ml) as total FROM water_log WHERE date = ?').bind(date).first()
938
+ : await env.DB.prepare("SELECT SUM(amount_ml) as total FROM water_log WHERE date = date('now')").first();
939
+ return json({ success: true, total_ml: r ? r.total || amount : amount });
940
+ }
941
+ const waterDelMatch = endpoint.match(/^\/water\/(\d+)$/);
942
+ if (waterDelMatch && method === 'DELETE') {
943
+ await env.DB.prepare('DELETE FROM water_log WHERE id = ?').bind(+waterDelMatch[1]).run();
944
+ return json({ success: true });
945
+ }
946
+
947
+ /* Food Reviews */
948
+ if (endpoint === '/food/reviews' && method === 'GET') {
949
+ const date = new URL(request.url).searchParams.get('date');
950
+ if (date) {
951
+ const r = await env.DB.prepare('SELECT * FROM food_reviews WHERE date = ?').bind(date).first();
952
+ return json({ review: r });
953
+ }
954
+ const r = await env.DB.prepare('SELECT * FROM food_reviews ORDER BY date DESC LIMIT 7').all();
955
+ return json({ reviews: r.results });
956
+ }
707
957
  if (endpoint === '/food/reviews' && method === 'POST') {
708
- const b = await request.json();
709
- await env.DB.prepare('INSERT OR REPLACE INTO food_reviews (date, review, reviewer, created_at) VALUES (?, ?, ?, datetime("now"))').bind(b.date, b.review, b.reviewer || 'AI').run();
710
- return json({ success: true });
711
- }
712
-
713
- return json({ error: 'Not found' }, 404);
714
- } catch (err) { return json({ error: err.message }, 500); }
715
- }
716
-
717
- /* ========================= MCP ========================= */
718
-
719
- async function handleMCP(request, env, config) {
720
- const mcpPath = new URL(request.url).pathname.substring(('/mcp/' + config.MCP_SECRET).length);
721
- if (mcpPath.startsWith('/photo/') && request.method === 'GET') {
722
- const key = decodeURIComponent(mcpPath.substring('/photo/'.length));
723
- return handleFoodPhotoServe(key, env);
724
- }
725
- if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405);
726
- try {
727
- const body = await request.json();
728
- const { tool, params } = body;
729
- switch (tool) {
730
- case 'hearth_status': return await mcpStatus(env, config);
731
- case 'hearth_mood': return await mcpMood(env, params, config);
732
- case 'hearth_note': return await mcpNote(env, params);
733
- case 'hearth_moment': return await mcpMoment(env, params);
734
- case 'hearth_date': return await mcpDate(env, params);
735
- case 'hearth_shopping_list': return await mcpShoppingList(env);
736
- case 'hearth_shopping_add': return await mcpShoppingAdd(env, params, config);
737
- case 'hearth_pressure': return await mcpPressure(env, params);
738
- case 'hearth_food_diary_today': return await mcpFoodDiaryToday(env, config, new URL(request.url).origin);
739
- case 'hearth_food_diary_history': return await mcpFoodDiaryHistory(env, params);
740
- case 'hearth_food_review': return await mcpFoodReview(env, params);
741
- case 'hearth_water_status': return await mcpWaterStatus(env);
742
- default: return json({ error: 'Unknown tool', available: ['hearth_status','hearth_mood','hearth_note','hearth_moment','hearth_date','hearth_shopping_list','hearth_shopping_add','hearth_pressure','hearth_food_diary_today','hearth_food_diary_history','hearth_food_review','hearth_water_status'] });
743
- }
744
- } catch (err) { return json({ error: err.message }, 500); }
958
+ const b = await readJsonObject(request);
959
+ await env.DB.prepare('INSERT OR REPLACE INTO food_reviews (date, review, reviewer, created_at) VALUES (?, ?, ?, datetime("now"))').bind(requireDate(b), requireText(b, 'review', 6000), b.reviewer ? requireText(b, 'reviewer', 80) : 'AI').run();
960
+ return json({ success: true });
961
+ }
962
+
963
+ return json({ error: 'Not found' }, 404);
964
+ } catch (err) { return json({ error: err.status && err.status < 500 ? err.message : 'Request failed' }, err.status || 500); }
745
965
  }
746
-
747
- async function mcpStatus(env, config) {
748
- const [moodsP1, moodsP2, latestNote, nextDate] = await Promise.all([
749
- env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 1').bind(config.PARTNER_1).first(),
750
- env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 1').bind(config.PARTNER_2).first(),
751
- env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT 1').first(),
752
- env.DB.prepare('SELECT * FROM dates WHERE date >= date("now") ORDER BY date ASC LIMIT 1').first()
753
- ]);
754
- const moods = {};
755
- moods[config.PARTNER_1] = moodsP1;
756
- moods[config.PARTNER_2] = moodsP2;
757
- return json({ moods, latestNote, nextDate });
966
+
967
+ /* ========================= MCP ========================= */
968
+
969
+ const MCP_TOOLS = [
970
+ toolDefinition('hearth_status', 'Dashboard status', 'Read the current Hearth overview.', {}, true),
971
+ toolDefinition('hearth_mood', 'Mood log', 'Read recent moods or record a mood.', {
972
+ action: enumProperty(['get', 'set'], 'Whether to read or record a mood.'),
973
+ partner: stringProperty('Partner name.', 80),
974
+ mood: enumProperty(['great', 'good', 'okay', 'tired', 'stressed', 'low'], 'Mood to record.'),
975
+ note: stringProperty('Optional private note.', 2000),
976
+ }, false, ['action']),
977
+ toolDefinition('hearth_note', 'Fridge notes', 'Read or leave a shared fridge note.', {
978
+ action: enumProperty(['get', 'leave'], 'Whether to read or leave notes.'),
979
+ limit: integerProperty('Number of notes to read.', 1, 20),
980
+ from: stringProperty('Author name.', 80),
981
+ content: stringProperty('Note text.', 4000),
982
+ }, false, ['action']),
983
+ toolDefinition('hearth_moment', 'Shared moments', 'List or add a dated shared moment.', {
984
+ action: enumProperty(['list', 'add'], 'Whether to list or add moments.'),
985
+ limit: integerProperty('Number of moments to read.', 1, 50),
986
+ date: stringProperty('Date in YYYY-MM-DD format.', 10),
987
+ title: stringProperty('Moment title.', 200),
988
+ description: stringProperty('Optional description.', 4000),
989
+ }, false, ['action']),
990
+ toolDefinition('hearth_date', 'Important dates', 'Read upcoming dates or add one.', {
991
+ action: enumProperty(['upcoming', 'add'], 'Whether to read or add dates.'),
992
+ limit: integerProperty('Number of dates to read.', 1, 50),
993
+ date: stringProperty('Date in YYYY-MM-DD format.', 10),
994
+ title: stringProperty('Date title.', 200),
995
+ recurring: { type: 'boolean', description: 'Whether this repeats yearly.' },
996
+ }, false, ['action']),
997
+ toolDefinition('hearth_shopping_list', 'Shopping list', 'Read unchecked shopping-list items.', {}, true),
998
+ toolDefinition('hearth_shopping_add', 'Add shopping item', 'Add one item to the shopping list.', {
999
+ item: stringProperty('Item to add.', 300),
1000
+ category: stringProperty('Optional category.', 100),
1001
+ added_by: stringProperty('Name of the person adding it.', 80),
1002
+ }, false, ['item']),
1003
+ toolDefinition('hearth_pressure', 'Pressure and migraine risk', 'Read current, historical, or forecast barometric pressure.', {
1004
+ action: enumProperty(['status', 'history', 'forecast'], 'Pressure view to return.'),
1005
+ hours: integerProperty('History window in hours.', 1, 720),
1006
+ }, true),
1007
+ toolDefinition('hearth_food_diary_today', 'Today food diary', 'Read today\'s meals, water and review status.', {}, true),
1008
+ toolDefinition('hearth_food_diary_history', 'Food diary history', 'Read food and water history over a bounded date range.', {
1009
+ days: integerProperty('Days to include when dates are omitted.', 1, 90),
1010
+ from: stringProperty('Start date in YYYY-MM-DD format.', 10),
1011
+ to: stringProperty('End date in YYYY-MM-DD format.', 10),
1012
+ }, true),
1013
+ toolDefinition('hearth_food_review', 'Save food review', 'Save or replace an AI food-diary review for a date.', {
1014
+ date: stringProperty('Date in YYYY-MM-DD format.', 10),
1015
+ review: stringProperty('Review text.', 6000),
1016
+ reviewer: stringProperty('Reviewer name.', 80),
1017
+ }, false, ['date', 'review']),
1018
+ toolDefinition('hearth_water_status', 'Water status', 'Read today\'s and recent water totals.', {}, true),
1019
+ ];
1020
+
1021
+ function stringProperty(description, maxLength) {
1022
+ return { type: 'string', description, minLength: 1, maxLength };
758
1023
  }
759
1024
 
760
- async function mcpMood(env, params, config) {
761
- if (params.action === 'get') { const r = await env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 5').bind(params.partner || config.PARTNER_1).all(); return json({ moods: r.results }); }
762
- if (params.action === 'set') { await env.DB.prepare('INSERT INTO moods (partner, mood, note, created_at) VALUES (?, ?, ?, datetime("now"))').bind(params.partner, params.mood, params.note || null).run(); return json({ success: true, message: `Logged ${params.mood} mood for ${params.partner}` }); }
763
- return json({ error: 'Invalid action. Use: get, set' });
1025
+ function integerProperty(description, minimum, maximum) {
1026
+ return { type: 'integer', description, minimum, maximum };
764
1027
  }
765
1028
 
766
- async function mcpNote(env, params) {
767
- if (params.action === 'get') { const r = await env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT ?').bind(params.limit || 5).all(); return json({ notes: r.results }); }
768
- if (params.action === 'leave') { await env.DB.prepare('INSERT INTO notes (from_partner, content, created_at) VALUES (?, ?, datetime("now"))').bind(params.from, params.content).run(); return json({ success: true }); }
769
- return json({ error: 'Invalid action. Use: get, leave' });
1029
+ function enumProperty(values, description) {
1030
+ return { type: 'string', enum: values, description };
770
1031
  }
771
1032
 
772
- async function mcpMoment(env, params) {
773
- if (params.action === 'list') { const r = await env.DB.prepare('SELECT * FROM moments ORDER BY date DESC LIMIT ?').bind(params.limit || 10).all(); return json({ moments: r.results }); }
774
- if (params.action === 'add') { await env.DB.prepare('INSERT INTO moments (date, title, description, created_at) VALUES (?, ?, ?, datetime("now"))').bind(params.date, params.title, params.description || null).run(); return json({ success: true }); }
775
- return json({ error: 'Invalid action. Use: list, add' });
1033
+ function toolDefinition(name, title, description, properties, readOnlyHint, required = []) {
1034
+ return {
1035
+ name, title, description,
1036
+ inputSchema: { type: 'object', properties, required, additionalProperties: false },
1037
+ annotations: { title, readOnlyHint, destructiveHint: false, idempotentHint: readOnlyHint, openWorldHint: false },
1038
+ };
776
1039
  }
777
1040
 
778
- async function mcpDate(env, params) {
779
- if (params.action === 'upcoming') { const r = await env.DB.prepare('SELECT * FROM dates WHERE date >= date("now") ORDER BY date ASC LIMIT ?').bind(params.limit || 5).all(); return json({ dates: r.results }); }
780
- if (params.action === 'add') { await env.DB.prepare('INSERT INTO dates (date, title, recurring, created_at) VALUES (?, ?, ?, datetime("now"))').bind(params.date, params.title, params.recurring ? 1 : 0).run(); return json({ success: true }); }
781
- return json({ error: 'Invalid action. Use: upcoming, add' });
1041
+ function rpcResult(id, result, status = 200) {
1042
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), {
1043
+ status,
1044
+ headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1045
+ });
782
1046
  }
783
1047
 
784
- async function mcpShoppingList(env) {
785
- const r = await env.DB.prepare('SELECT * FROM shopping WHERE checked = 0 ORDER BY category, created_at DESC').all();
786
- const items = r.results || [];
787
- const groups = {};
788
- items.forEach(i => { if (!groups[i.category]) groups[i.category] = []; groups[i.category].push(i.item); });
789
- return json({ items, count: items.length, by_category: groups });
1048
+ function rpcError(id, code, message, status = 200, data) {
1049
+ const error = { code, message };
1050
+ if (data !== undefined) error.data = data;
1051
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id: id ?? null, error }), {
1052
+ status,
1053
+ headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1054
+ });
790
1055
  }
791
1056
 
792
- async function mcpShoppingAdd(env, params, config) {
793
- const item = params.item;
794
- if (!item) return json({ error: 'item is required' }, 400);
795
- const category = params.category || 'Other';
796
- const added_by = params.added_by || config.PARTNER_2;
797
- await env.DB.prepare('INSERT INTO shopping (item, category, checked, added_by, created_at) VALUES (?, ?, 0, ?, datetime("now"))').bind(item, category, added_by).run();
798
- return json({ success: true, message: `Added "${item}" to ${category}` });
1057
+ function validateMcpOrigin(request, config) {
1058
+ const origin = request.headers.get('Origin');
1059
+ if (!origin) return true;
1060
+ if (origin === new URL(request.url).origin) return true;
1061
+ return config.MCP_ALLOWED_ORIGINS.includes(origin);
799
1062
  }
800
1063
 
801
- async function mcpPressure(env, params) {
802
- const action = params.action || 'status';
803
- if (action === 'status') {
804
- const key = env.WEATHER_API_KEY;
805
- const lat = env.WEATHER_LAT;
806
- const lon = env.WEATHER_LON;
807
- if (!key) return json({ error: 'No weather API key configured' });
808
- const [currentRes, forecastRes, hist6h] = await Promise.all([
809
- fetch(`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${key}`),
810
- fetch(`https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&appid=${key}`),
811
- env.DB.prepare("SELECT pressure_hpa FROM pressure_log WHERE recorded_at <= datetime('now', '-5 hours') ORDER BY recorded_at DESC LIMIT 1").first().catch(() => null)
812
- ]);
813
- if (!currentRes.ok) return json({ error: 'Weather API error' });
814
- const w = await currentRes.json();
815
- const pressure = w.main.pressure;
816
- let shift6hPast = hist6h ? pressure - hist6h.pressure_hpa : 0;
817
- let shift6hFuture = 0, shift24hFuture = 0;
818
- if (forecastRes.ok) {
819
- const fc = await forecastRes.json();
820
- const now = Date.now();
821
- let c6 = null, c24 = null, m6 = Infinity, m24 = Infinity;
822
- (fc.list || []).forEach(f => {
823
- const fts = new Date(f.dt_txt + 'Z').getTime();
824
- if (Math.abs(fts - (now + 6*3600000)) < m6) { m6 = Math.abs(fts - (now + 6*3600000)); c6 = f; }
825
- if (Math.abs(fts - (now + 24*3600000)) < m24) { m24 = Math.abs(fts - (now + 24*3600000)); c24 = f; }
826
- });
827
- if (c6) shift6hFuture = c6.main.pressure - pressure;
828
- if (c24) shift24hFuture = c24.main.pressure - pressure;
1064
+ function validateArguments(tool, args) {
1065
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return 'arguments must be an object';
1066
+ const schema = tool.inputSchema;
1067
+ for (const required of schema.required || []) {
1068
+ if (args[required] === undefined || args[required] === null || args[required] === '') return required + ' is required';
1069
+ }
1070
+ for (const [key, value] of Object.entries(args)) {
1071
+ const property = schema.properties[key];
1072
+ if (!property) return 'unknown argument: ' + key;
1073
+ if (property.type === 'string') {
1074
+ if (typeof value !== 'string') return key + ' must be a string';
1075
+ if (property.minLength && value.length < property.minLength) return key + ' is too short';
1076
+ if (property.maxLength && value.length > property.maxLength) return key + ' is too long';
1077
+ if (property.enum && !property.enum.includes(value)) return key + ' must be one of: ' + property.enum.join(', ');
1078
+ } else if (property.type === 'integer') {
1079
+ if (!Number.isInteger(value) || value < property.minimum || value > property.maximum) return key + ' is out of range';
1080
+ } else if (property.type === 'boolean' && typeof value !== 'boolean') {
1081
+ return key + ' must be a boolean';
829
1082
  }
830
- const maxShift6h = Math.max(Math.abs(shift6hPast), Math.abs(shift6hFuture));
831
- const shift24hAbs = Math.abs(shift24hFuture);
832
- let alert = 'none';
833
- if (maxShift6h >= 12 || shift24hAbs >= 20) alert = 'critical';
834
- else if (maxShift6h >= 8 || shift24hAbs >= 15) alert = 'warning';
835
- else if (maxShift6h >= 4 || shift24hAbs >= 10) alert = 'watch';
836
- const trend = shift6hFuture < -2 ? 'falling' : shift6hFuture > 2 ? 'rising' : 'stable';
837
- return json({ pressure_hpa: pressure, trend, shift_6h_past: Math.round(shift6hPast * 10) / 10, shift_6h_future: Math.round(shift6hFuture * 10) / 10, shift_24h_future: Math.round(shift24hFuture * 10) / 10, alert, message: alert === 'none' ? 'Pressure stable.' : `Pressure ${trend} — ${alert} level shift detected.` });
838
1083
  }
839
- if (action === 'history') {
840
- const hours = params.hours || 72;
841
- const r = await env.DB.prepare(`SELECT pressure_hpa, temp, recorded_at FROM pressure_log WHERE recorded_at >= datetime('now', '-${Math.min(hours, 720)} hours') ORDER BY recorded_at ASC`).all();
842
- return json({ readings: r.results || [], count: r.results ? r.results.length : 0 });
1084
+ const requiredForAction = {
1085
+ hearth_mood: { set: ['partner', 'mood'] },
1086
+ hearth_note: { leave: ['from', 'content'] },
1087
+ hearth_moment: { add: ['date', 'title'] },
1088
+ hearth_date: { add: ['date', 'title'] },
1089
+ };
1090
+ for (const key of requiredForAction[tool.name]?.[args.action] || []) {
1091
+ if (args[key] === undefined || args[key] === null || args[key] === '') return key + ' is required for ' + args.action;
843
1092
  }
844
- if (action === 'forecast') {
845
- const key = env.WEATHER_API_KEY;
846
- if (!key) return json({ error: 'No weather API key configured' });
847
- const res = await fetch(`https://api.openweathermap.org/data/2.5/forecast?lat=${env.WEATHER_LAT}&lon=${env.WEATHER_LON}&units=metric&appid=${key}`);
848
- if (!res.ok) return json({ error: 'Forecast API error' });
849
- const fc = await res.json();
850
- const points = (fc.list || []).map(f => ({ pressure: f.main.pressure, temp: Math.round(f.main.temp), time: f.dt_txt }));
851
- return json({ forecast: points, count: points.length });
1093
+ for (const key of ['date', 'from', 'to']) {
1094
+ if (args[key] !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(args[key])) return key + ' must use YYYY-MM-DD';
852
1095
  }
853
- return json({ error: 'Invalid action. Use: status, history, forecast' });
1096
+ return null;
854
1097
  }
855
1098
 
856
- async function mcpFoodDiaryToday(env, config, origin) {
857
- const [meals, water, review] = await Promise.all([
858
- env.DB.prepare("SELECT * FROM food_diary WHERE date = date('now') ORDER BY time ASC").all(),
859
- env.DB.prepare("SELECT SUM(amount_ml) as total FROM water_log WHERE date = date('now')").first(),
860
- env.DB.prepare("SELECT * FROM food_reviews WHERE date = date('now')").first()
861
- ]);
862
- const baseUrl = origin + '/mcp/' + config.MCP_SECRET + '/photo/';
863
- const entries = (meals.results || []).map(m => ({ id: m.id, meal_type: m.meal_type, time: m.time, note: m.note, has_photo: !!m.photo_key, photo_url: m.photo_key ? baseUrl + encodeURIComponent(m.photo_key) : null }));
864
- const mealTypes = entries.map(e => e.meal_type);
865
- const logged = { breakfast: mealTypes.includes('breakfast'), lunch: mealTypes.includes('lunch'), dinner: mealTypes.includes('dinner') };
866
- const waterTotal = water ? water.total || 0 : 0;
867
- return json({ date: new Date().toISOString().slice(0, 10), meals: entries, meals_logged: entries.length, logged, water_ml: waterTotal, water_glasses: Math.floor(waterTotal / 250), water_target_ml: 2000, has_review: !!review, review: review ? review.review : null });
1099
+ async function executeMcpTool(name, params, env, config) {
1100
+ switch (name) {
1101
+ case 'hearth_status': return mcpStatus(env, config);
1102
+ case 'hearth_mood': return mcpMood(env, params, config);
1103
+ case 'hearth_note': return mcpNote(env, params);
1104
+ case 'hearth_moment': return mcpMoment(env, params);
1105
+ case 'hearth_date': return mcpDate(env, params);
1106
+ case 'hearth_shopping_list': return mcpShoppingList(env);
1107
+ case 'hearth_shopping_add': return mcpShoppingAdd(env, params, config);
1108
+ case 'hearth_pressure': return mcpPressure(env, params);
1109
+ case 'hearth_food_diary_today': return mcpFoodDiaryToday(env, config);
1110
+ case 'hearth_food_diary_history': return mcpFoodDiaryHistory(env, params);
1111
+ case 'hearth_food_review': return mcpFoodReview(env, params);
1112
+ case 'hearth_water_status': return mcpWaterStatus(env);
1113
+ default: return null;
1114
+ }
868
1115
  }
869
1116
 
870
- async function mcpFoodDiaryHistory(env, params) {
871
- const days = params.days || 7;
872
- const from = params.from || new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
873
- const to = params.to || new Date().toISOString().slice(0, 10);
874
- const [meals, water, reviews] = await Promise.all([
875
- env.DB.prepare('SELECT * FROM food_diary WHERE date >= ? AND date <= ? ORDER BY date DESC, time ASC').bind(from, to).all(),
876
- env.DB.prepare('SELECT date, SUM(amount_ml) as total FROM water_log WHERE date >= ? AND date <= ? GROUP BY date').bind(from, to).all(),
877
- env.DB.prepare('SELECT * FROM food_reviews WHERE date >= ? AND date <= ? ORDER BY date DESC').bind(from, to).all()
878
- ]);
879
- const dayMap = {};
880
- (meals.results || []).forEach(m => { if (!dayMap[m.date]) dayMap[m.date] = { meals: [], water_ml: 0 }; dayMap[m.date].meals.push({ meal_type: m.meal_type, time: m.time, note: m.note }); });
881
- (water.results || []).forEach(w => { if (!dayMap[w.date]) dayMap[w.date] = { meals: [], water_ml: 0 }; dayMap[w.date].water_ml = w.total; });
882
- const allDates = Object.keys(dayMap).sort();
883
- const skippedBreakfasts = allDates.filter(d => !dayMap[d].meals.some(m => m.meal_type === 'breakfast')).length;
884
- const avgWater = allDates.length ? allDates.reduce((s, d) => s + (dayMap[d].water_ml || 0), 0) / allDates.length : 0;
885
- return json({ from, to, days_logged: allDates.length, daily: dayMap, patterns: { skipped_breakfasts: skippedBreakfasts, avg_water_ml: Math.round(avgWater) }, reviews: (reviews.results || []).map(r => ({ date: r.date, review: r.review })) });
1117
+ function requiredScopeForCall(name, args) {
1118
+ if (name === 'hearth_shopping_add' || name === 'hearth_food_review') return 'hearth:write';
1119
+ if (name === 'hearth_mood' && args.action === 'set') return 'hearth:write';
1120
+ if (name === 'hearth_note' && args.action === 'leave') return 'hearth:write';
1121
+ if (name === 'hearth_moment' && args.action === 'add') return 'hearth:write';
1122
+ if (name === 'hearth_date' && args.action === 'add') return 'hearth:write';
1123
+ return 'hearth:read';
886
1124
  }
887
1125
 
888
- async function mcpFoodReview(env, params) {
889
- if (!params.date || !params.review) return json({ error: 'date and review are required' }, 400);
890
- await env.DB.prepare('INSERT OR REPLACE INTO food_reviews (date, review, reviewer, created_at) VALUES (?, ?, ?, datetime("now"))').bind(params.date, params.review, params.reviewer || 'AI').run();
891
- return json({ success: true, message: 'Review for ' + params.date + ' saved' });
1126
+ function visibleToolsForScopes(scopes) {
1127
+ if (scopes.includes('hearth:read') && scopes.includes('hearth:write')) return MCP_TOOLS;
1128
+ if (scopes.includes('hearth:read')) {
1129
+ return MCP_TOOLS.filter(tool => !['hearth_shopping_add', 'hearth_food_review'].includes(tool.name));
1130
+ }
1131
+ if (scopes.includes('hearth:write')) return MCP_TOOLS.filter(tool => !tool.annotations.readOnlyHint);
1132
+ return [];
892
1133
  }
893
1134
 
894
- async function mcpWaterStatus(env) {
895
- const [todayResult, weekResult] = await Promise.all([
896
- env.DB.prepare("SELECT SUM(amount_ml) as total, COUNT(*) as entries FROM water_log WHERE date = date('now')").first(),
897
- env.DB.prepare("SELECT date, SUM(amount_ml) as total FROM water_log WHERE date >= date('now', '-7 days') GROUP BY date ORDER BY date").all()
898
- ]);
899
- const todayTotal = todayResult ? todayResult.total || 0 : 0;
900
- const target = 2000;
901
- const weekDays = (weekResult.results || []);
902
- const avgWeek = weekDays.length ? weekDays.reduce((s, d) => s + d.total, 0) / weekDays.length : 0;
903
- let message = '';
904
- if (todayTotal === 0) message = 'No water logged today yet.';
905
- else if (todayTotal < 500) message = 'Barely any water today. Needs more.';
906
- else if (todayTotal < 1000) message = 'Under halfway. Needs more water.';
907
- else if (todayTotal < 1500) message = 'Getting there but still under target.';
908
- else if (todayTotal < 2000) message = 'Almost at target. One more glass.';
909
- else message = 'Hit the target today.';
910
- return json({ date: new Date().toISOString().slice(0, 10), today_ml: todayTotal, today_glasses: Math.floor(todayTotal / 250), target_ml: target, percent: Math.round((todayTotal / target) * 100), entries_today: todayResult ? todayResult.entries || 0 : 0, week_avg_ml: Math.round(avgWeek), week_days: weekDays, message });
911
- }
1135
+ async function handleMCP(request, env, config, remainder, scopes = []) {
1136
+ if (remainder && remainder !== '/') return json({ error: 'Not found' }, 404);
1137
+ if (!validateMcpOrigin(request, config)) return json({ error: 'Forbidden origin' }, 403);
1138
+ if (!(await withinRateLimit(env, request, 'mcp', 120, 60))) {
1139
+ return rpcError(null, -32000, 'Rate limit exceeded', 429);
1140
+ }
1141
+ if (request.method === 'GET') {
1142
+ return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'POST' }) });
1143
+ }
1144
+ if (request.method !== 'POST') return new Response(null, { status: 405, headers: securityHeaders({ 'Allow': 'POST' }) });
912
1145
 
913
- /* ========================= PHOTO HANDLERS ========================= */
1146
+ const contentType = request.headers.get('Content-Type') || '';
1147
+ if (!contentType.toLowerCase().startsWith('application/json')) return rpcError(null, -32600, 'Content-Type must be application/json', 415);
1148
+ const declaredLength = Number(request.headers.get('Content-Length') || 0);
1149
+ if (declaredLength > MAX_JSON_BODY_BYTES) return rpcError(null, -32600, 'Request body is too large', 413);
914
1150
 
915
- async function handleFoodPhotoUpload(request, env) {
1151
+ let message;
916
1152
  try {
917
- const formData = await request.formData();
918
- const file = formData.get('photo');
919
- if (!file || !file.size) return json({ error: 'No photo provided' }, 400);
920
- const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
921
- if (!allowed.includes(file.type)) return json({ error: 'Invalid file type' }, 400);
922
- if (file.size > 5 * 1024 * 1024) return json({ error: 'Photo too large (max 5MB)' }, 400);
923
- const date = new Date().toISOString().slice(0, 10);
924
- const id = crypto.randomUUID().slice(0, 8);
925
- const ext = file.type.split('/')[1] === 'jpeg' ? 'jpg' : file.type.split('/')[1];
926
- const key = 'food/' + date + '/' + id + '.' + ext;
927
- await env.PHOTOS.put(key, file.stream(), { httpMetadata: { contentType: file.type } });
928
- return json({ success: true, key });
929
- } catch (err) { return json({ error: 'Upload failed: ' + err.message }, 500); }
930
- }
1153
+ const text = await request.text();
1154
+ if (encoder.encode(text).byteLength > MAX_JSON_BODY_BYTES) return rpcError(null, -32600, 'Request body is too large', 413);
1155
+ message = JSON.parse(text);
1156
+ } catch {
1157
+ return rpcError(null, -32700, 'Parse error', 400);
1158
+ }
1159
+ if (!message || Array.isArray(message) || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
1160
+ return rpcError(message?.id, -32600, 'Invalid Request', 400);
1161
+ }
1162
+
1163
+ if (message.method !== 'initialize') {
1164
+ const protocolVersion = request.headers.get('MCP-Protocol-Version');
1165
+ if (!protocolVersion || !MCP_PROTOCOL_VERSIONS.includes(protocolVersion)) {
1166
+ return rpcError(message.id, -32600, 'Unsupported MCP protocol version', 400, { supported: MCP_PROTOCOL_VERSIONS });
1167
+ }
1168
+ }
1169
+
1170
+ const isNotification = message.id === undefined;
1171
+ if (message.method === 'notifications/initialized' || message.method === 'notifications/cancelled') {
1172
+ return new Response(null, { status: 202, headers: securityHeaders({ 'Cache-Control': 'no-store' }) });
1173
+ }
1174
+ if (isNotification) return new Response(null, { status: 202, headers: securityHeaders({ 'Cache-Control': 'no-store' }) });
1175
+
1176
+ if (message.method === 'initialize') {
1177
+ const requested = message.params?.protocolVersion;
1178
+ if (typeof requested !== 'string') return rpcError(message.id, -32602, 'protocolVersion is required', 400);
1179
+ const protocolVersion = MCP_PROTOCOL_VERSIONS.includes(requested) ? requested : MCP_PROTOCOL_VERSIONS[0];
1180
+ return rpcResult(message.id, {
1181
+ protocolVersion,
1182
+ capabilities: { tools: { listChanged: false } },
1183
+ serverInfo: { name: 'hearth-dash', version: '1.1.0' },
1184
+ instructions: 'Hearth is a private shared dashboard. Read tools do not change data; write tools change the shared household record.',
1185
+ });
1186
+ }
1187
+
1188
+ if (message.method === 'ping') return rpcResult(message.id, {});
1189
+ if (message.method === 'tools/list') return rpcResult(message.id, { tools: visibleToolsForScopes(scopes) });
1190
+ if (message.method !== 'tools/call') return rpcError(message.id, -32601, 'Method not found');
1191
+
1192
+ const name = message.params?.name;
1193
+ const args = message.params?.arguments || {};
1194
+ const tool = MCP_TOOLS.find(item => item.name === name);
1195
+ if (!tool) return rpcError(message.id, -32602, 'Unknown tool: ' + String(name));
1196
+ const validationError = validateArguments(tool, args);
1197
+ if (validationError) {
1198
+ return rpcResult(message.id, { content: [{ type: 'text', text: validationError }], isError: true });
1199
+ }
1200
+ const requiredScope = requiredScopeForCall(name, args);
1201
+ if (!scopes.includes(requiredScope)) {
1202
+ return rpcResult(message.id, {
1203
+ content: [{ type: 'text', text: `Permission denied: ${requiredScope} scope is required` }],
1204
+ isError: true,
1205
+ });
1206
+ }
931
1207
 
932
- async function handleFoodPhotoServe(key, env) {
933
1208
  try {
934
- const object = await env.PHOTOS.get(key);
935
- if (!object) return new Response('Not found', { status: 404 });
1209
+ const toolResponse = await executeMcpTool(name, args, env, config);
1210
+ const data = await toolResponse.json();
1211
+ const isError = !toolResponse.ok || !!data.error;
1212
+ return rpcResult(message.id, {
1213
+ content: [{ type: 'text', text: JSON.stringify(data) }],
1214
+ structuredContent: data,
1215
+ isError,
1216
+ });
1217
+ } catch (error) {
1218
+ return rpcResult(message.id, { content: [{ type: 'text', text: 'Tool execution failed' }], isError: true });
1219
+ }
1220
+ }
1221
+
1222
+ async function mcpStatus(env, config) {
1223
+ const [moodsP1, moodsP2, latestNote, nextDate] = await Promise.all([
1224
+ env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 1').bind(config.PARTNER_1).first(),
1225
+ env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 1').bind(config.PARTNER_2).first(),
1226
+ env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT 1').first(),
1227
+ env.DB.prepare('SELECT * FROM dates WHERE date >= date("now") ORDER BY date ASC LIMIT 1').first()
1228
+ ]);
1229
+ const moods = {};
1230
+ moods[config.PARTNER_1] = moodsP1;
1231
+ moods[config.PARTNER_2] = moodsP2;
1232
+ return json({ moods, latestNote, nextDate });
1233
+ }
1234
+
1235
+ async function mcpMood(env, params, config) {
1236
+ if (params.action === 'get') { const r = await env.DB.prepare('SELECT * FROM moods WHERE partner = ? ORDER BY created_at DESC LIMIT 5').bind(params.partner || config.PARTNER_1).all(); return json({ moods: r.results }); }
1237
+ if (params.action === 'set') { await env.DB.prepare('INSERT INTO moods (partner, mood, note, created_at) VALUES (?, ?, ?, datetime("now"))').bind(params.partner, params.mood, params.note || null).run(); return json({ success: true, message: `Logged ${params.mood} mood for ${params.partner}` }); }
1238
+ return json({ error: 'Invalid action. Use: get, set' });
1239
+ }
1240
+
1241
+ async function mcpNote(env, params) {
1242
+ if (params.action === 'get') { const r = await env.DB.prepare('SELECT * FROM notes ORDER BY created_at DESC LIMIT ?').bind(params.limit || 5).all(); return json({ notes: r.results }); }
1243
+ if (params.action === 'leave') { await env.DB.prepare('INSERT INTO notes (from_partner, content, created_at) VALUES (?, ?, datetime("now"))').bind(params.from, params.content).run(); return json({ success: true }); }
1244
+ return json({ error: 'Invalid action. Use: get, leave' });
1245
+ }
1246
+
1247
+ async function mcpMoment(env, params) {
1248
+ if (params.action === 'list') { const r = await env.DB.prepare('SELECT * FROM moments ORDER BY date DESC LIMIT ?').bind(params.limit || 10).all(); return json({ moments: r.results }); }
1249
+ if (params.action === 'add') { await env.DB.prepare('INSERT INTO moments (date, title, description, created_at) VALUES (?, ?, ?, datetime("now"))').bind(params.date, params.title, params.description || null).run(); return json({ success: true }); }
1250
+ return json({ error: 'Invalid action. Use: list, add' });
1251
+ }
1252
+
1253
+ async function mcpDate(env, params) {
1254
+ if (params.action === 'upcoming') { const r = await env.DB.prepare('SELECT * FROM dates WHERE date >= date("now") ORDER BY date ASC LIMIT ?').bind(params.limit || 5).all(); return json({ dates: r.results }); }
1255
+ if (params.action === 'add') { await env.DB.prepare('INSERT INTO dates (date, title, recurring, created_at) VALUES (?, ?, ?, datetime("now"))').bind(params.date, params.title, params.recurring ? 1 : 0).run(); return json({ success: true }); }
1256
+ return json({ error: 'Invalid action. Use: upcoming, add' });
1257
+ }
1258
+
1259
+ async function mcpShoppingList(env) {
1260
+ const r = await env.DB.prepare('SELECT * FROM shopping WHERE checked = 0 ORDER BY category, created_at DESC').all();
1261
+ const items = r.results || [];
1262
+ const groups = {};
1263
+ items.forEach(i => { if (!groups[i.category]) groups[i.category] = []; groups[i.category].push(i.item); });
1264
+ return json({ items, count: items.length, by_category: groups });
1265
+ }
1266
+
1267
+ async function mcpShoppingAdd(env, params, config) {
1268
+ const item = params.item;
1269
+ if (!item) return json({ error: 'item is required' }, 400);
1270
+ const category = params.category || 'Other';
1271
+ const added_by = params.added_by || config.PARTNER_2;
1272
+ await env.DB.prepare('INSERT INTO shopping (item, category, checked, added_by, created_at) VALUES (?, ?, 0, ?, datetime("now"))').bind(item, category, added_by).run();
1273
+ return json({ success: true, message: `Added "${item}" to ${category}` });
1274
+ }
1275
+
1276
+ async function mcpPressure(env, params) {
1277
+ const action = params.action || 'status';
1278
+ if (action === 'status') {
1279
+ const key = env.WEATHER_API_KEY;
1280
+ const lat = env.WEATHER_LAT;
1281
+ const lon = env.WEATHER_LON;
1282
+ if (!key) return json({ error: 'No weather API key configured' });
1283
+ const [currentRes, forecastRes, hist6h] = await Promise.all([
1284
+ fetch(`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${key}`),
1285
+ fetch(`https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&appid=${key}`),
1286
+ env.DB.prepare("SELECT pressure_hpa FROM pressure_log WHERE recorded_at <= datetime('now', '-5 hours') ORDER BY recorded_at DESC LIMIT 1").first().catch(() => null)
1287
+ ]);
1288
+ if (!currentRes.ok) return json({ error: 'Weather API error' });
1289
+ const w = await currentRes.json();
1290
+ const pressure = w.main.pressure;
1291
+ let shift6hPast = hist6h ? pressure - hist6h.pressure_hpa : 0;
1292
+ let shift6hFuture = 0, shift24hFuture = 0;
1293
+ if (forecastRes.ok) {
1294
+ const fc = await forecastRes.json();
1295
+ const now = Date.now();
1296
+ let c6 = null, c24 = null, m6 = Infinity, m24 = Infinity;
1297
+ (fc.list || []).forEach(f => {
1298
+ const fts = new Date(f.dt_txt + 'Z').getTime();
1299
+ if (Math.abs(fts - (now + 6*3600000)) < m6) { m6 = Math.abs(fts - (now + 6*3600000)); c6 = f; }
1300
+ if (Math.abs(fts - (now + 24*3600000)) < m24) { m24 = Math.abs(fts - (now + 24*3600000)); c24 = f; }
1301
+ });
1302
+ if (c6) shift6hFuture = c6.main.pressure - pressure;
1303
+ if (c24) shift24hFuture = c24.main.pressure - pressure;
1304
+ }
1305
+ const maxShift6h = Math.max(Math.abs(shift6hPast), Math.abs(shift6hFuture));
1306
+ const shift24hAbs = Math.abs(shift24hFuture);
1307
+ let alert = 'none';
1308
+ if (maxShift6h >= 12 || shift24hAbs >= 20) alert = 'critical';
1309
+ else if (maxShift6h >= 8 || shift24hAbs >= 15) alert = 'warning';
1310
+ else if (maxShift6h >= 4 || shift24hAbs >= 10) alert = 'watch';
1311
+ const trend = shift6hFuture < -2 ? 'falling' : shift6hFuture > 2 ? 'rising' : 'stable';
1312
+ return json({ pressure_hpa: pressure, trend, shift_6h_past: Math.round(shift6hPast * 10) / 10, shift_6h_future: Math.round(shift6hFuture * 10) / 10, shift_24h_future: Math.round(shift24hFuture * 10) / 10, alert, message: alert === 'none' ? 'Pressure stable.' : `Pressure ${trend} — ${alert} level shift detected.` });
1313
+ }
1314
+ if (action === 'history') {
1315
+ const hours = params.hours || 72;
1316
+ const r = await env.DB.prepare(`SELECT pressure_hpa, temp, recorded_at FROM pressure_log WHERE recorded_at >= datetime('now', '-${Math.min(hours, 720)} hours') ORDER BY recorded_at ASC`).all();
1317
+ return json({ readings: r.results || [], count: r.results ? r.results.length : 0 });
1318
+ }
1319
+ if (action === 'forecast') {
1320
+ const key = env.WEATHER_API_KEY;
1321
+ if (!key) return json({ error: 'No weather API key configured' });
1322
+ const res = await fetch(`https://api.openweathermap.org/data/2.5/forecast?lat=${env.WEATHER_LAT}&lon=${env.WEATHER_LON}&units=metric&appid=${key}`);
1323
+ if (!res.ok) return json({ error: 'Forecast API error' });
1324
+ const fc = await res.json();
1325
+ const points = (fc.list || []).map(f => ({ pressure: f.main.pressure, temp: Math.round(f.main.temp), time: f.dt_txt }));
1326
+ return json({ forecast: points, count: points.length });
1327
+ }
1328
+ return json({ error: 'Invalid action. Use: status, history, forecast' });
1329
+ }
1330
+
1331
+ async function mcpFoodDiaryToday(env, config) {
1332
+ const [meals, water, review] = await Promise.all([
1333
+ env.DB.prepare("SELECT * FROM food_diary WHERE date = date('now') ORDER BY time ASC").all(),
1334
+ env.DB.prepare("SELECT SUM(amount_ml) as total FROM water_log WHERE date = date('now')").first(),
1335
+ env.DB.prepare("SELECT * FROM food_reviews WHERE date = date('now')").first()
1336
+ ]);
1337
+ const entries = (meals.results || []).map(m => ({ id: m.id, meal_type: m.meal_type, time: m.time, note: m.note, has_photo: !!m.photo_key }));
1338
+ const mealTypes = entries.map(e => e.meal_type);
1339
+ const logged = { breakfast: mealTypes.includes('breakfast'), lunch: mealTypes.includes('lunch'), dinner: mealTypes.includes('dinner') };
1340
+ const waterTotal = water ? water.total || 0 : 0;
1341
+ return json({ date: new Date().toISOString().slice(0, 10), meals: entries, meals_logged: entries.length, logged, water_ml: waterTotal, water_glasses: Math.floor(waterTotal / 250), water_target_ml: 2000, has_review: !!review, review: review ? review.review : null });
1342
+ }
1343
+
1344
+ async function mcpFoodDiaryHistory(env, params) {
1345
+ const days = params.days || 7;
1346
+ const from = params.from || new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
1347
+ const to = params.to || new Date().toISOString().slice(0, 10);
1348
+ const [meals, water, reviews] = await Promise.all([
1349
+ env.DB.prepare('SELECT * FROM food_diary WHERE date >= ? AND date <= ? ORDER BY date DESC, time ASC').bind(from, to).all(),
1350
+ env.DB.prepare('SELECT date, SUM(amount_ml) as total FROM water_log WHERE date >= ? AND date <= ? GROUP BY date').bind(from, to).all(),
1351
+ env.DB.prepare('SELECT * FROM food_reviews WHERE date >= ? AND date <= ? ORDER BY date DESC').bind(from, to).all()
1352
+ ]);
1353
+ const dayMap = {};
1354
+ (meals.results || []).forEach(m => { if (!dayMap[m.date]) dayMap[m.date] = { meals: [], water_ml: 0 }; dayMap[m.date].meals.push({ meal_type: m.meal_type, time: m.time, note: m.note }); });
1355
+ (water.results || []).forEach(w => { if (!dayMap[w.date]) dayMap[w.date] = { meals: [], water_ml: 0 }; dayMap[w.date].water_ml = w.total; });
1356
+ const allDates = Object.keys(dayMap).sort();
1357
+ const skippedBreakfasts = allDates.filter(d => !dayMap[d].meals.some(m => m.meal_type === 'breakfast')).length;
1358
+ const avgWater = allDates.length ? allDates.reduce((s, d) => s + (dayMap[d].water_ml || 0), 0) / allDates.length : 0;
1359
+ return json({ from, to, days_logged: allDates.length, daily: dayMap, patterns: { skipped_breakfasts: skippedBreakfasts, avg_water_ml: Math.round(avgWater) }, reviews: (reviews.results || []).map(r => ({ date: r.date, review: r.review })) });
1360
+ }
1361
+
1362
+ async function mcpFoodReview(env, params) {
1363
+ if (!params.date || !params.review) return json({ error: 'date and review are required' }, 400);
1364
+ await env.DB.prepare('INSERT OR REPLACE INTO food_reviews (date, review, reviewer, created_at) VALUES (?, ?, ?, datetime("now"))').bind(params.date, params.review, params.reviewer || 'AI').run();
1365
+ return json({ success: true, message: 'Review for ' + params.date + ' saved' });
1366
+ }
1367
+
1368
+ async function mcpWaterStatus(env) {
1369
+ const [todayResult, weekResult] = await Promise.all([
1370
+ env.DB.prepare("SELECT SUM(amount_ml) as total, COUNT(*) as entries FROM water_log WHERE date = date('now')").first(),
1371
+ env.DB.prepare("SELECT date, SUM(amount_ml) as total FROM water_log WHERE date >= date('now', '-7 days') GROUP BY date ORDER BY date").all()
1372
+ ]);
1373
+ const todayTotal = todayResult ? todayResult.total || 0 : 0;
1374
+ const target = 2000;
1375
+ const weekDays = (weekResult.results || []);
1376
+ const avgWeek = weekDays.length ? weekDays.reduce((s, d) => s + d.total, 0) / weekDays.length : 0;
1377
+ let message = '';
1378
+ if (todayTotal === 0) message = 'No water logged today yet.';
1379
+ else if (todayTotal < 500) message = 'Barely any water today. Needs more.';
1380
+ else if (todayTotal < 1000) message = 'Under halfway. Needs more water.';
1381
+ else if (todayTotal < 1500) message = 'Getting there but still under target.';
1382
+ else if (todayTotal < 2000) message = 'Almost at target. One more glass.';
1383
+ else message = 'Hit the target today.';
1384
+ return json({ date: new Date().toISOString().slice(0, 10), today_ml: todayTotal, today_glasses: Math.floor(todayTotal / 250), target_ml: target, percent: Math.round((todayTotal / target) * 100), entries_today: todayResult ? todayResult.entries || 0 : 0, week_avg_ml: Math.round(avgWeek), week_days: weekDays, message });
1385
+ }
1386
+
1387
+ /* ========================= PHOTO HANDLERS ========================= */
1388
+
1389
+ async function handleFoodPhotoUpload(request, env) {
1390
+ try {
1391
+ const formData = await request.formData();
1392
+ const file = formData.get('photo');
1393
+ if (!file || !file.size) return json({ error: 'No photo provided' }, 400);
1394
+ const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/heic'];
1395
+ if (!allowed.includes(file.type)) return json({ error: 'Invalid file type' }, 400);
1396
+ if (file.size > 5 * 1024 * 1024) return json({ error: 'Photo too large (max 5MB)' }, 400);
1397
+ const date = new Date().toISOString().slice(0, 10);
1398
+ const id = crypto.randomUUID().slice(0, 8);
1399
+ const ext = file.type.split('/')[1] === 'jpeg' ? 'jpg' : file.type.split('/')[1];
1400
+ const key = 'food/' + date + '/' + id + '.' + ext;
1401
+ await env.PHOTOS.put(key, file.stream(), { httpMetadata: { contentType: file.type } });
1402
+ return json({ success: true, key });
1403
+ } catch (err) { return json({ error: 'Upload failed' }, 500); }
1404
+ }
1405
+
1406
+ async function handleFoodPhotoServe(key, env) {
1407
+ try {
1408
+ const object = await env.PHOTOS.get(key);
1409
+ if (!object) return new Response('Not found', { status: 404 });
936
1410
  const headers = new Headers();
937
1411
  headers.set('Content-Type', object.httpMetadata?.contentType || 'image/jpeg');
938
- headers.set('Cache-Control', 'public, max-age=31536000, immutable');
1412
+ headers.set('Cache-Control', 'private, no-store');
1413
+ headers.set('X-Content-Type-Options', 'nosniff');
1414
+ headers.set('Content-Security-Policy', "default-src 'none'");
939
1415
  return new Response(object.body, { headers });
940
- } catch (err) { return new Response('Error: ' + err.message, { status: 500 }); }
1416
+ } catch (err) { return new Response('Could not load photo', { status: 500 }); }
1417
+ }
1418
+
1419
+ /* ========================= WEATHER ========================= */
1420
+
1421
+ let weatherCache = { data: null, ts: 0 };
1422
+ const WEATHER_CACHE_MS = 30 * 60 * 1000;
1423
+
1424
+ async function handleWeather(env) {
1425
+ const now = Date.now();
1426
+ if (weatherCache.data && (now - weatherCache.ts) < WEATHER_CACHE_MS) {
1427
+ return json(weatherCache.data);
1428
+ }
1429
+ try {
1430
+ const key = env.WEATHER_API_KEY;
1431
+ const lat = env.WEATHER_LAT;
1432
+ const lon = env.WEATHER_LON;
1433
+ if (!key) return json({ error: 'No weather API key configured' }, 500);
1434
+ const [currentRes, forecastRes] = await Promise.all([
1435
+ fetch(`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${key}`),
1436
+ fetch(`https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&appid=${key}`)
1437
+ ]);
1438
+ if (!currentRes.ok) return json({ error: 'Weather API error', status: currentRes.status }, 502);
1439
+ const w = await currentRes.json();
1440
+ const fc = forecastRes.ok ? await forecastRes.json() : null;
1441
+ const pressure = w.main.pressure;
1442
+ const temp = Math.round(w.main.temp);
1443
+
1444
+ try { await env.DB.prepare('INSERT INTO pressure_log (pressure_hpa, temp, recorded_at) VALUES (?, ?, datetime("now"))').bind(pressure, w.main.temp).run(); } catch(e) {}
1445
+
1446
+ let shift6hPast = 0;
1447
+ try { const hist = await env.DB.prepare("SELECT pressure_hpa FROM pressure_log WHERE recorded_at <= datetime('now', '-5 hours') ORDER BY recorded_at DESC LIMIT 1").first(); if (hist) shift6hPast = pressure - hist.pressure_hpa; } catch(e) {}
1448
+
1449
+ let shift6hFuture = 0, shift24hFuture = 0, pressureTrend = 'stable';
1450
+ if (fc && fc.list) {
1451
+ const target6h = now + 6 * 3600000;
1452
+ const target24h = now + 24 * 3600000;
1453
+ let c6 = null, c24 = null, m6 = Infinity, m24 = Infinity;
1454
+ fc.list.forEach(f => {
1455
+ const fts = new Date(f.dt_txt + 'Z').getTime();
1456
+ if (Math.abs(fts - target6h) < m6) { m6 = Math.abs(fts - target6h); c6 = f; }
1457
+ if (Math.abs(fts - target24h) < m24) { m24 = Math.abs(fts - target24h); c24 = f; }
1458
+ });
1459
+ if (c6) shift6hFuture = c6.main.pressure - pressure;
1460
+ if (c24) shift24hFuture = c24.main.pressure - pressure;
1461
+ }
1462
+ if (shift6hFuture < -2) pressureTrend = 'falling';
1463
+ else if (shift6hFuture > 2) pressureTrend = 'rising';
1464
+
1465
+ const maxShift6h = Math.max(Math.abs(shift6hPast), Math.abs(shift6hFuture));
1466
+ const shift24hAbs = Math.abs(shift24hFuture);
1467
+ let shiftAlert = 'none';
1468
+ if (maxShift6h >= 12 || shift24hAbs >= 20) shiftAlert = 'critical';
1469
+ else if (maxShift6h >= 8 || shift24hAbs >= 15) shiftAlert = 'warning';
1470
+ else if (maxShift6h >= 4 || shift24hAbs >= 10) shiftAlert = 'watch';
1471
+
1472
+ let migraineRisk = 'low', migraineNote = 'Pressure stable';
1473
+ if (pressure < 1000) { migraineRisk = 'high'; migraineNote = 'Very low pressure — migraine risk elevated'; }
1474
+ else if (pressure < 1005) { migraineRisk = 'moderate'; migraineNote = 'Low pressure — watch for symptoms'; }
1475
+ else if (pressure > 1030) { migraineRisk = 'moderate'; migraineNote = 'High pressure — some sensitivity possible'; }
1476
+ if (shiftAlert === 'critical' || shiftAlert === 'warning') {
1477
+ migraineRisk = 'high';
1478
+ const dir = pressureTrend === 'falling' ? 'dropping' : pressureTrend === 'rising' ? 'rising' : 'shifting';
1479
+ migraineNote = 'Rapid pressure ' + dir + ' — migraine risk elevated';
1480
+ } else if (shiftAlert === 'watch' && migraineRisk === 'low') {
1481
+ migraineRisk = 'moderate'; migraineNote = 'Pressure shifting — monitor for symptoms';
1482
+ }
1483
+
1484
+ const data = {
1485
+ temp, feels_like: Math.round(w.main.feels_like), description: w.weather[0].description,
1486
+ icon: w.weather[0].icon, humidity: w.main.humidity, pressure,
1487
+ wind: Math.round(w.wind.speed * 2.237), migraine_risk: migraineRisk, migraine_note: migraineNote,
1488
+ pressure_trend: pressureTrend, shift_6h_past: Math.round(shift6hPast * 10) / 10,
1489
+ shift_6h_future: Math.round(shift6hFuture * 10) / 10, shift_24h_future: Math.round(shift24hFuture * 10) / 10,
1490
+ shift_alert: shiftAlert, location: w.name, updated: new Date().toISOString()
1491
+ };
1492
+ weatherCache = { data, ts: now };
1493
+ return json(data);
1494
+ } catch (err) { return json({ error: 'Failed to fetch weather' }, 500); }
1495
+ }
1496
+
1497
+ /* ========================= PRESSURE HISTORY ========================= */
1498
+
1499
+ async function handlePressure(env) {
1500
+ try {
1501
+ const key = env.WEATHER_API_KEY;
1502
+ const lat = env.WEATHER_LAT;
1503
+ const lon = env.WEATHER_LON;
1504
+ const [histResult, forecastRes] = await Promise.all([
1505
+ env.DB.prepare("SELECT pressure_hpa, temp, recorded_at FROM pressure_log WHERE recorded_at >= datetime('now', '-72 hours') ORDER BY recorded_at ASC").all(),
1506
+ key ? fetch(`https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&appid=${key}`) : Promise.resolve(null)
1507
+ ]);
1508
+ const history = histResult.results || [];
1509
+ let forecast = [];
1510
+ if (forecastRes && forecastRes.ok) {
1511
+ const fc = await forecastRes.json();
1512
+ forecast = (fc.list || []).map(f => ({ pressure: f.main.pressure, dt_txt: f.dt_txt }));
1513
+ }
1514
+ let current = null;
1515
+ if (history.length) {
1516
+ const last = history[history.length - 1];
1517
+ current = { pressure: last.pressure_hpa, timestamp: last.recorded_at };
1518
+ }
1519
+ return json({ history, forecast, current, migraine_days: [] });
1520
+ } catch (err) { return json({ error: 'Pressure data error' }, 500); }
1521
+ }
1522
+
1523
+ /* ========================= UTILITIES ========================= */
1524
+
1525
+ function getCookie(request, name) {
1526
+ const cookies = request.headers.get('Cookie') || '';
1527
+ const match = cookies.match(new RegExp(`${name}=([^;]+)`));
1528
+ return match ? match[1] : null;
941
1529
  }
942
1530
 
943
- /* ========================= WEATHER ========================= */
944
-
945
- let weatherCache = { data: null, ts: 0 };
946
- const WEATHER_CACHE_MS = 30 * 60 * 1000;
947
-
948
- async function handleWeather(env) {
949
- const now = Date.now();
950
- if (weatherCache.data && (now - weatherCache.ts) < WEATHER_CACHE_MS) {
951
- return json(weatherCache.data);
1531
+ async function readJsonObject(request) {
1532
+ const contentType = request.headers.get('Content-Type') || '';
1533
+ if (!contentType.toLowerCase().startsWith('application/json')) {
1534
+ throw Object.assign(new Error('Content-Type must be application/json'), { status: 415 });
952
1535
  }
953
- try {
954
- const key = env.WEATHER_API_KEY;
955
- const lat = env.WEATHER_LAT;
956
- const lon = env.WEATHER_LON;
957
- if (!key) return json({ error: 'No weather API key configured' }, 500);
958
- const [currentRes, forecastRes] = await Promise.all([
959
- fetch(`https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&units=metric&appid=${key}`),
960
- fetch(`https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&appid=${key}`)
961
- ]);
962
- if (!currentRes.ok) return json({ error: 'Weather API error', status: currentRes.status }, 502);
963
- const w = await currentRes.json();
964
- const fc = forecastRes.ok ? await forecastRes.json() : null;
965
- const pressure = w.main.pressure;
966
- const temp = Math.round(w.main.temp);
967
-
968
- try { await env.DB.prepare('INSERT INTO pressure_log (pressure_hpa, temp, recorded_at) VALUES (?, ?, datetime("now"))').bind(pressure, w.main.temp).run(); } catch(e) {}
969
-
970
- let shift6hPast = 0;
971
- try { const hist = await env.DB.prepare("SELECT pressure_hpa FROM pressure_log WHERE recorded_at <= datetime('now', '-5 hours') ORDER BY recorded_at DESC LIMIT 1").first(); if (hist) shift6hPast = pressure - hist.pressure_hpa; } catch(e) {}
972
-
973
- let shift6hFuture = 0, shift24hFuture = 0, pressureTrend = 'stable';
974
- if (fc && fc.list) {
975
- const target6h = now + 6 * 3600000;
976
- const target24h = now + 24 * 3600000;
977
- let c6 = null, c24 = null, m6 = Infinity, m24 = Infinity;
978
- fc.list.forEach(f => {
979
- const fts = new Date(f.dt_txt + 'Z').getTime();
980
- if (Math.abs(fts - target6h) < m6) { m6 = Math.abs(fts - target6h); c6 = f; }
981
- if (Math.abs(fts - target24h) < m24) { m24 = Math.abs(fts - target24h); c24 = f; }
982
- });
983
- if (c6) shift6hFuture = c6.main.pressure - pressure;
984
- if (c24) shift24hFuture = c24.main.pressure - pressure;
985
- }
986
- if (shift6hFuture < -2) pressureTrend = 'falling';
987
- else if (shift6hFuture > 2) pressureTrend = 'rising';
988
-
989
- const maxShift6h = Math.max(Math.abs(shift6hPast), Math.abs(shift6hFuture));
990
- const shift24hAbs = Math.abs(shift24hFuture);
991
- let shiftAlert = 'none';
992
- if (maxShift6h >= 12 || shift24hAbs >= 20) shiftAlert = 'critical';
993
- else if (maxShift6h >= 8 || shift24hAbs >= 15) shiftAlert = 'warning';
994
- else if (maxShift6h >= 4 || shift24hAbs >= 10) shiftAlert = 'watch';
995
-
996
- let migraineRisk = 'low', migraineNote = 'Pressure stable';
997
- if (pressure < 1000) { migraineRisk = 'high'; migraineNote = 'Very low pressure — migraine risk elevated'; }
998
- else if (pressure < 1005) { migraineRisk = 'moderate'; migraineNote = 'Low pressure — watch for symptoms'; }
999
- else if (pressure > 1030) { migraineRisk = 'moderate'; migraineNote = 'High pressure — some sensitivity possible'; }
1000
- if (shiftAlert === 'critical' || shiftAlert === 'warning') {
1001
- migraineRisk = 'high';
1002
- const dir = pressureTrend === 'falling' ? 'dropping' : pressureTrend === 'rising' ? 'rising' : 'shifting';
1003
- migraineNote = 'Rapid pressure ' + dir + ' — migraine risk elevated';
1004
- } else if (shiftAlert === 'watch' && migraineRisk === 'low') {
1005
- migraineRisk = 'moderate'; migraineNote = 'Pressure shifting — monitor for symptoms';
1006
- }
1007
-
1008
- const data = {
1009
- temp, feels_like: Math.round(w.main.feels_like), description: w.weather[0].description,
1010
- icon: w.weather[0].icon, humidity: w.main.humidity, pressure,
1011
- wind: Math.round(w.wind.speed * 2.237), migraine_risk: migraineRisk, migraine_note: migraineNote,
1012
- pressure_trend: pressureTrend, shift_6h_past: Math.round(shift6hPast * 10) / 10,
1013
- shift_6h_future: Math.round(shift6hFuture * 10) / 10, shift_24h_future: Math.round(shift24hFuture * 10) / 10,
1014
- shift_alert: shiftAlert, location: w.name, updated: new Date().toISOString()
1015
- };
1016
- weatherCache = { data, ts: now };
1017
- return json(data);
1018
- } catch (err) { return json({ error: 'Failed to fetch weather: ' + err.message }, 500); }
1536
+ const declaredLength = Number(request.headers.get('Content-Length') || 0);
1537
+ if (declaredLength > MAX_JSON_BODY_BYTES) throw Object.assign(new Error('Request body is too large'), { status: 413 });
1538
+ const text = await request.text();
1539
+ if (encoder.encode(text).byteLength > MAX_JSON_BODY_BYTES) throw Object.assign(new Error('Request body is too large'), { status: 413 });
1540
+ let value;
1541
+ try { value = JSON.parse(text); } catch { throw Object.assign(new Error('Invalid JSON'), { status: 400 }); }
1542
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw Object.assign(new Error('JSON body must be an object'), { status: 400 });
1543
+ return value;
1019
1544
  }
1020
1545
 
1021
- /* ========================= PRESSURE HISTORY ========================= */
1022
-
1023
- async function handlePressure(env) {
1024
- try {
1025
- const key = env.WEATHER_API_KEY;
1026
- const lat = env.WEATHER_LAT;
1027
- const lon = env.WEATHER_LON;
1028
- const [histResult, forecastRes] = await Promise.all([
1029
- env.DB.prepare("SELECT pressure_hpa, temp, recorded_at FROM pressure_log WHERE recorded_at >= datetime('now', '-72 hours') ORDER BY recorded_at ASC").all(),
1030
- key ? fetch(`https://api.openweathermap.org/data/2.5/forecast?lat=${lat}&lon=${lon}&units=metric&appid=${key}`) : Promise.resolve(null)
1031
- ]);
1032
- const history = histResult.results || [];
1033
- let forecast = [];
1034
- if (forecastRes && forecastRes.ok) {
1035
- const fc = await forecastRes.json();
1036
- forecast = (fc.list || []).map(f => ({ pressure: f.main.pressure, dt_txt: f.dt_txt }));
1037
- }
1038
- let current = null;
1039
- if (history.length) {
1040
- const last = history[history.length - 1];
1041
- current = { pressure: last.pressure_hpa, timestamp: last.recorded_at };
1042
- }
1043
- return json({ history, forecast, current, migraine_days: [] });
1044
- } catch (err) { return json({ error: 'Pressure data error: ' + err.message }, 500); }
1546
+ function requireText(body, key, maxLength, allowed) {
1547
+ const value = body[key];
1548
+ if (typeof value !== 'string' || !value.trim()) throw Object.assign(new Error(key + ' is required'), { status: 400 });
1549
+ if (value.length > maxLength) throw Object.assign(new Error(key + ' is too long'), { status: 400 });
1550
+ if (allowed && !allowed.includes(value)) throw Object.assign(new Error(key + ' is invalid'), { status: 400 });
1551
+ return value.trim();
1045
1552
  }
1046
1553
 
1047
- /* ========================= UTILITIES ========================= */
1554
+ function optionalText(body, key, maxLength) {
1555
+ if (body[key] === undefined || body[key] === null || body[key] === '') return null;
1556
+ return requireText(body, key, maxLength);
1557
+ }
1048
1558
 
1049
- function getCookie(request, name) {
1050
- const cookies = request.headers.get('Cookie') || '';
1051
- const match = cookies.match(new RegExp(`${name}=([^;]+)`));
1052
- return match ? match[1] : null;
1559
+ function requireDate(body, key = 'date') {
1560
+ const value = requireText(body, key, 10);
1561
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw Object.assign(new Error(key + ' must use YYYY-MM-DD'), { status: 400 });
1562
+ return value;
1053
1563
  }
1054
1564
 
1055
1565
  function json(data, status = 200) {
1056
- return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
1566
+ return new Response(JSON.stringify(data), {
1567
+ status,
1568
+ headers: securityHeaders({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }),
1569
+ });
1057
1570
  }