hearth-dash 1.0.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 ADDED
@@ -0,0 +1,1057 @@
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
+ }
18
+ return {
19
+ PASSWORD: password,
20
+ MCP_SECRET: env.MCP_SECRET || 'change-me-too',
21
+ PARTNER_1: env.PARTNER_1 || 'Partner 1',
22
+ PARTNER_2: env.PARTNER_2 || 'Partner 2',
23
+ };
24
+ }
25
+
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>`;
61
+ }
62
+
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>`;
95
+ }
96
+
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()});
494
+
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()});
500
+
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)}}
502
+
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()}));
504
+
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'}}}
506
+
507
+ /* === Init === */
508
+ loadDashboard();loadMoods();loadNotes();loadMoments();loadDates();loadWeather();loadPressure();loadShopping();loadFood();
509
+ </script>
510
+ </body>
511
+ </html>`;
512
+ }
513
+
514
+ /* ========================= BACKEND ========================= */
515
+
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;
521
+
522
+ // MCP endpoint
523
+ if (path.startsWith('/mcp/' + config.MCP_SECRET)) return handleMCP(request, env, config);
524
+
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' } });
541
+ }
542
+
543
+ // Auth
544
+ const sessionCookie = getCookie(request, 'hearth_session');
545
+ const isAuthenticated = sessionCookie === config.PASSWORD;
546
+
547
+ if (path === '/login') {
548
+ if (request.method === 'POST') {
549
+ 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` } });
552
+ }
553
+ return new Response(getLoginPage(config).replace('{{ERROR}}', '<p class="error">Wrong password</p>'), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
554
+ }
555
+ return new Response(getLoginPage(config).replace('{{ERROR}}', ''), { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
556
+ }
557
+ if (path === '/logout') return new Response(null, { status: 302, headers: { 'Location': '/login', 'Set-Cookie': 'hearth_session=; Path=/; Max-Age=0' } });
558
+ if (!isAuthenticated) return Response.redirect(url.origin + '/login', 302);
559
+
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);
567
+ }
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' } });
571
+ }
572
+ };
573
+
574
+ /* ========================= REST API ========================= */
575
+
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 });
594
+ }
595
+
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 }); }
599
+
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 }); }
603
+
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 }); }
607
+
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 });
615
+ }
616
+
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 });
621
+ }
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 });
626
+ }
627
+ if (endpoint === '/shopping/checked' && method === 'DELETE') {
628
+ await env.DB.prepare('DELETE FROM shopping WHERE checked = 1').run();
629
+ return json({ success: true });
630
+ }
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();
634
+ return json({ success: true });
635
+ }
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();
639
+ return json({ success: true });
640
+ }
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();
655
+ return json({ success: true });
656
+ }
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();
662
+ return json({ success: true });
663
+ }
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
+ }
681
+ 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
+ }
707
+ 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); }
745
+ }
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 });
758
+ }
759
+
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' });
764
+ }
765
+
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' });
770
+ }
771
+
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' });
776
+ }
777
+
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' });
782
+ }
783
+
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 });
790
+ }
791
+
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}` });
799
+ }
800
+
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;
829
+ }
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
+ }
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 });
843
+ }
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 });
852
+ }
853
+ return json({ error: 'Invalid action. Use: status, history, forecast' });
854
+ }
855
+
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 });
868
+ }
869
+
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 })) });
886
+ }
887
+
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' });
892
+ }
893
+
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
+ }
912
+
913
+ /* ========================= PHOTO HANDLERS ========================= */
914
+
915
+ async function handleFoodPhotoUpload(request, env) {
916
+ 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
+ }
931
+
932
+ async function handleFoodPhotoServe(key, env) {
933
+ try {
934
+ const object = await env.PHOTOS.get(key);
935
+ if (!object) return new Response('Not found', { status: 404 });
936
+ const headers = new Headers();
937
+ headers.set('Content-Type', object.httpMetadata?.contentType || 'image/jpeg');
938
+ headers.set('Cache-Control', 'public, max-age=31536000, immutable');
939
+ return new Response(object.body, { headers });
940
+ } catch (err) { return new Response('Error: ' + err.message, { status: 500 }); }
941
+ }
942
+
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);
952
+ }
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); }
1019
+ }
1020
+
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); }
1045
+ }
1046
+
1047
+ /* ========================= UTILITIES ========================= */
1048
+
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;
1053
+ }
1054
+
1055
+ function json(data, status = 200) {
1056
+ return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
1057
+ }