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