step-overflow 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,437 @@
1
+ import { getRoute } from "./routes.js";
2
+ import { ACHIEVEMENTS } from "./achievements.js";
3
+ export function generateIndexHtml(config) {
4
+ const journey = config?.journey ?? null;
5
+ const route = journey ? getRoute(journey.route_id) ?? null : null;
6
+ const achievements = config?.achievements ?? {};
7
+ const allAchievements = ACHIEVEMENTS.map((a) => ({
8
+ id: a.id,
9
+ name: a.name,
10
+ description: a.description,
11
+ }));
12
+ const journeyJson = JSON.stringify(journey);
13
+ const routeJson = JSON.stringify(route);
14
+ const achievementsJson = JSON.stringify(achievements);
15
+ const allAchievementsJson = JSON.stringify(allAchievements);
16
+ return `<!DOCTYPE html>
17
+ <html lang="en">
18
+ <head>
19
+ <meta charset="UTF-8">
20
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
21
+ <title>Step Overflow</title>
22
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
23
+ <style>
24
+ * { margin: 0; padding: 0; box-sizing: border-box; }
25
+ body {
26
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
27
+ background: #fff;
28
+ color: #333;
29
+ }
30
+ .header {
31
+ padding: 24px 20px 0;
32
+ max-width: 960px;
33
+ margin: 0 auto;
34
+ }
35
+ .header h1 { font-size: 1.1rem; font-weight: 600; color: #333; }
36
+ .header p { font-size: 0.8rem; color: #aaa; margin-top: 2px; }
37
+ .container { max-width: 960px; margin: 0 auto; padding: 16px 20px; }
38
+
39
+ /* Main tabs */
40
+ .main-tab-bar {
41
+ display: flex;
42
+ gap: 0;
43
+ margin-bottom: 20px;
44
+ border-bottom: 2px solid #e5e7eb;
45
+ }
46
+ .main-tab-bar button {
47
+ padding: 10px 24px;
48
+ border: none;
49
+ background: none;
50
+ font-size: 0.9rem;
51
+ font-weight: 600;
52
+ color: #888;
53
+ cursor: pointer;
54
+ border-bottom: 2px solid transparent;
55
+ margin-bottom: -2px;
56
+ transition: color 0.2s, border-color 0.2s;
57
+ }
58
+ .main-tab-bar button.active { color: #333; border-bottom-color: #333; }
59
+ .main-tab-bar button:hover:not(.active) { color: #555; }
60
+ .tab-content { display: none; }
61
+ .tab-content.active { display: block; }
62
+
63
+ /* Summary cards */
64
+ .summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
65
+ .summary-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; }
66
+ .summary-card .value { font-size: 1.5rem; font-weight: 700; color: #333; }
67
+ .summary-card .label { font-size: 0.75rem; color: #999; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; }
68
+
69
+ /* Chart sub-tabs */
70
+ .sub-tab-bar { display: flex; gap: 0; margin-bottom: 16px; }
71
+ .sub-tab-bar button {
72
+ padding: 6px 16px;
73
+ border: 1px solid #e5e7eb;
74
+ background: #fff;
75
+ font-size: 0.8rem;
76
+ color: #888;
77
+ cursor: pointer;
78
+ transition: all 0.15s;
79
+ }
80
+ .sub-tab-bar button:first-child { border-radius: 6px 0 0 6px; }
81
+ .sub-tab-bar button:last-child { border-radius: 0 6px 6px 0; }
82
+ .sub-tab-bar button:not(:first-child) { border-left: none; }
83
+ .sub-tab-bar button.active { background: #3b82f6; color: #fff; border-color: #3b82f6; }
84
+ .sub-tab-bar button.active[data-tab*="calories"] { background: #e05080; border-color: #e05080; }
85
+
86
+ .chart-wrapper { border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
87
+ canvas { max-height: 300px; }
88
+
89
+ .card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
90
+ .card h2 { font-size: 1rem; font-weight: 600; margin-bottom: 12px; }
91
+
92
+ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
93
+ th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #f0f0f0; }
94
+ th { font-weight: 600; color: #666; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; }
95
+ tr:hover td { background: #fafafa; }
96
+ .empty { text-align: center; color: #999; padding: 40px; }
97
+
98
+ /* Journey */
99
+ .journey-header { text-align: center; margin-bottom: 24px; }
100
+ .journey-header .route-name { font-size: 1.3rem; font-weight: 700; }
101
+ .journey-header .route-sub { font-size: 0.85rem; color: #888; margin-top: 4px; }
102
+ .journey-progress { text-align: center; margin-bottom: 32px; }
103
+ .journey-progress .bar-bg {
104
+ width: 100%; height: 24px; background: #f0f0f0; border-radius: 12px; overflow: hidden; margin: 8px 0;
105
+ }
106
+ .journey-progress .bar-fill {
107
+ height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); border-radius: 12px;
108
+ transition: width 0.3s;
109
+ }
110
+ .journey-progress .percent { font-size: 1.8rem; font-weight: 700; color: #3b82f6; }
111
+ .journey-progress .km-text { font-size: 0.85rem; color: #888; }
112
+ .route-visual { position: relative; margin: 40px 20px 60px; }
113
+ .route-line { position: absolute; top: 50%; left: 0; right: 0; height: 3px; background: #e5e7eb; transform: translateY(-50%); }
114
+ .route-fill { position: absolute; top: 50%; left: 0; height: 3px; background: #3b82f6; transform: translateY(-50%); border-radius: 2px; }
115
+ .waypoint {
116
+ position: absolute; top: 50%; transform: translate(-50%, -50%);
117
+ width: 14px; height: 14px; border-radius: 50%; border: 2.5px solid #ccc; background: #fff; z-index: 1;
118
+ }
119
+ .waypoint.passed { border-color: #3b82f6; background: #3b82f6; }
120
+ .waypoint.next { border-color: #3b82f6; background: #fff; }
121
+ .waypoint-label {
122
+ position: absolute; top: calc(50% + 14px); transform: translateX(-50%);
123
+ font-size: 0.7rem; color: #888; white-space: nowrap;
124
+ }
125
+ .waypoint-label.passed { color: #3b82f6; font-weight: 600; }
126
+ .current-marker {
127
+ position: absolute; top: calc(50% - 22px); transform: translateX(-50%);
128
+ font-size: 1rem;
129
+ }
130
+
131
+ /* Achievements */
132
+ .achievement-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
133
+ .achievement-card {
134
+ display: flex; align-items: center; gap: 12px;
135
+ border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px 16px;
136
+ }
137
+ .achievement-card.locked { opacity: 0.45; }
138
+ .achievement-card .icon { font-size: 1.6rem; flex-shrink: 0; }
139
+ .achievement-card .info { flex: 1; min-width: 0; }
140
+ .achievement-card .name { font-weight: 600; font-size: 0.9rem; }
141
+ .achievement-card .desc { font-size: 0.75rem; color: #888; margin-top: 2px; }
142
+ .achievement-card .date { font-size: 0.7rem; color: #3b82f6; margin-top: 2px; }
143
+ .achievement-count { text-align: center; margin-bottom: 20px; font-size: 0.9rem; color: #888; }
144
+ .achievement-count span { font-weight: 700; color: #333; font-size: 1.1rem; }
145
+ </style>
146
+ </head>
147
+ <body>
148
+ <div class="header">
149
+ <h1>Step Overflow</h1>
150
+ <p>walking log dashboard</p>
151
+ </div>
152
+
153
+ <div class="container">
154
+ <div class="main-tab-bar">
155
+ <button class="active" data-main="walking">Walking</button>
156
+ <button data-main="journey">Journey</button>
157
+ <button data-main="achievements">Achievements</button>
158
+ </div>
159
+
160
+ <div id="walking-content" class="tab-content active">
161
+ <div class="summary" id="summary"></div>
162
+ <div class="sub-tab-bar">
163
+ <button class="active" data-tab="daily-distance">Distance (Daily)</button>
164
+ <button data-tab="cumulative-distance">Distance (Total)</button>
165
+ <button data-tab="daily-calories">Calories (Daily)</button>
166
+ <button data-tab="cumulative-calories">Calories (Total)</button>
167
+ </div>
168
+ <div class="chart-wrapper"><canvas id="chart"></canvas></div>
169
+ <div class="card"><h2>Records</h2><div id="table-container"></div></div>
170
+ </div>
171
+
172
+ <div id="journey-content" class="tab-content">
173
+ <div id="journey-container"></div>
174
+ </div>
175
+
176
+ <div id="achievements-content" class="tab-content">
177
+ <div id="achievements-container"></div>
178
+ </div>
179
+ </div>
180
+
181
+ <script>
182
+ // Embedded data (regenerated on each stp add)
183
+ const JOURNEY = ${journeyJson};
184
+ const ROUTE = ${routeJson};
185
+ const UNLOCKED = ${achievementsJson};
186
+ const ALL_ACHIEVEMENTS = ${allAchievementsJson};
187
+
188
+ // --- MET / Calorie calculation (mirrors src/lib/calories.ts) ---
189
+ const MET_TABLE = [[2.0,2.0],[3.2,2.8],[4.0,3.5],[4.8,4.3],[5.6,5.0],[6.4,7.0],[8.0,8.3]];
190
+ function getMets(speed) {
191
+ if (speed <= MET_TABLE[0][0]) return MET_TABLE[0][1];
192
+ if (speed >= MET_TABLE[MET_TABLE.length-1][0]) return MET_TABLE[MET_TABLE.length-1][1];
193
+ for (let i = 0; i < MET_TABLE.length-1; i++) {
194
+ const [s0,m0] = MET_TABLE[i], [s1,m1] = MET_TABLE[i+1];
195
+ if (speed >= s0 && speed <= s1) return m0 + (speed-s0)/(s1-s0) * (m1-m0);
196
+ }
197
+ return 3.5;
198
+ }
199
+ function calcCalories(speed, timeMin, weight) {
200
+ if (!weight) return 0;
201
+ return Math.round(getMets(speed) * weight * (timeMin / 60));
202
+ }
203
+
204
+ // --- Walking tab ---
205
+ let chart = null;
206
+ let dailyDistance = {};
207
+ let dailyCalories = {};
208
+ let sortedLabels = [];
209
+ let hasCalories = false;
210
+
211
+ function buildChart(mode) {
212
+ const ctx = document.getElementById('chart');
213
+ if (chart) chart.destroy();
214
+ const labels = sortedLabels;
215
+ const isCalories = mode.includes('calories');
216
+ const isCumulative = mode.includes('cumulative');
217
+ const color = isCalories ? '#e05080' : '#3b82f6';
218
+ const bgColor = isCalories ? 'rgba(224, 80, 128, 0.12)' : 'rgba(59, 130, 246, 0.12)';
219
+ const source = isCalories ? dailyCalories : dailyDistance;
220
+ const unit = isCalories ? 'kcal' : 'km';
221
+ let data, label;
222
+ if (isCumulative) {
223
+ let sum = 0;
224
+ data = labels.map(d => { sum += (source[d] ?? 0); return isCalories ? sum : parseFloat(sum.toFixed(2)); });
225
+ label = isCalories ? 'Total Calories' : 'Total Distance (km)';
226
+ } else {
227
+ data = labels.map(d => isCalories ? (source[d] ?? 0) : parseFloat((source[d] ?? 0).toFixed(2)));
228
+ label = isCalories ? 'Calories (kcal)' : 'Distance (km)';
229
+ }
230
+ chart = new Chart(ctx, {
231
+ type: 'line',
232
+ data: { labels, datasets: [{ label, data, borderColor: color, backgroundColor: bgColor, fill: true, tension: 0.3, pointRadius: 4, pointBackgroundColor: '#fff', pointBorderColor: color, pointBorderWidth: 2, pointHoverRadius: 6, borderWidth: 2 }] },
233
+ options: {
234
+ responsive: true,
235
+ interaction: { intersect: false, mode: 'index' },
236
+ plugins: {
237
+ legend: { display: false },
238
+ tooltip: { backgroundColor: '#333', titleColor: '#fff', bodyColor: '#fff', padding: 10, cornerRadius: 6, callbacks: { label: (c) => c.parsed.y.toLocaleString() + ' ' + unit } }
239
+ },
240
+ scales: {
241
+ y: { beginAtZero: true, title: { display: true, text: unit, color: '#888' }, grid: { color: '#f0f0f0' }, ticks: { color: '#888' } },
242
+ x: { grid: { display: false }, ticks: { color: '#888', maxRotation: 45 } }
243
+ }
244
+ }
245
+ });
246
+ }
247
+
248
+ // --- Journey tab ---
249
+ function renderJourney(totalKm) {
250
+ const el = document.getElementById('journey-container');
251
+ if (!JOURNEY || !ROUTE) {
252
+ el.innerHTML = '<p class="empty">No journey set. Run <code>stp config route</code> to choose a route.</p>';
253
+ return;
254
+ }
255
+ const journeyKm = Math.max(0, totalKm - JOURNEY.started_km);
256
+ const progress = Math.min(journeyKm / ROUTE.total_km * 100, 100);
257
+ const walkedClamped = Math.min(journeyKm, ROUTE.total_km);
258
+
259
+ let html = '<div class="journey-header">';
260
+ html += '<div class="route-name">' + ROUTE.emoji + ' ' + ROUTE.from + ' \\u2192 ' + ROUTE.to + '</div>';
261
+ html += '<div class="route-sub">' + ROUTE.name + ' \\u2014 ' + ROUTE.total_km.toLocaleString() + ' km</div>';
262
+ html += '</div>';
263
+
264
+ html += '<div class="journey-progress">';
265
+ html += '<div class="percent">' + progress.toFixed(1) + '%</div>';
266
+ html += '<div class="bar-bg"><div class="bar-fill" style="width:' + progress + '%"></div></div>';
267
+ html += '<div class="km-text">' + Math.floor(walkedClamped) + ' / ' + ROUTE.total_km.toLocaleString() + ' km</div>';
268
+ html += '</div>';
269
+
270
+ // Route visual
271
+ html += '<div class="route-visual" style="height:60px">';
272
+ html += '<div class="route-line"></div>';
273
+ const fillPct = Math.min(progress, 100);
274
+ html += '<div class="route-fill" style="width:' + fillPct + '%"></div>';
275
+ const nextWp = ROUTE.waypoints.find(w => journeyKm < w.km);
276
+ for (const wp of ROUTE.waypoints) {
277
+ const pct = (wp.km / ROUTE.total_km * 100).toFixed(2);
278
+ const passed = journeyKm >= wp.km;
279
+ const isNext = nextWp && nextWp.km === wp.km;
280
+ const cls = passed ? 'waypoint passed' : (isNext ? 'waypoint next' : 'waypoint');
281
+ html += '<div class="' + cls + '" style="left:' + pct + '%"></div>';
282
+ html += '<div class="waypoint-label' + (passed ? ' passed' : '') + '" style="left:' + pct + '%" title="' + (wp.description || '') + '">' + wp.name + '</div>';
283
+ }
284
+ // Current position marker
285
+ if (progress < 100) {
286
+ html += '<div class="current-marker" style="left:' + fillPct + '%">\\u{1F6B6}</div>';
287
+ }
288
+ html += '</div>';
289
+
290
+ el.innerHTML = html;
291
+ }
292
+
293
+ // --- Achievements tab ---
294
+ function renderAchievements() {
295
+ const el = document.getElementById('achievements-container');
296
+ const count = Object.keys(UNLOCKED).length;
297
+ let html = '<div class="achievement-count"><span>' + count + '</span> / ' + ALL_ACHIEVEMENTS.length + ' unlocked</div>';
298
+ html += '<div class="achievement-grid">';
299
+ for (const a of ALL_ACHIEVEMENTS) {
300
+ const isUnlocked = !!UNLOCKED[a.id];
301
+ html += '<div class="achievement-card' + (isUnlocked ? '' : ' locked') + '">';
302
+ html += '<div class="icon">' + (isUnlocked ? '\\u{1F3C5}' : '\\u{1F512}') + '</div>';
303
+ html += '<div class="info">';
304
+ html += '<div class="name">' + a.name + '</div>';
305
+ html += '<div class="desc">' + a.description + '</div>';
306
+ if (isUnlocked) {
307
+ html += '<div class="date">Unlocked: ' + UNLOCKED[a.id].split('T')[0] + '</div>';
308
+ }
309
+ html += '</div></div>';
310
+ }
311
+ html += '</div>';
312
+ el.innerHTML = html;
313
+ }
314
+
315
+ // --- Main ---
316
+ async function main() {
317
+ let text;
318
+ try {
319
+ const paths = ['../data/walking.csv', '/data/walking.csv', 'data/walking.csv'];
320
+ for (const p of paths) {
321
+ try {
322
+ const res = await fetch(p);
323
+ if (res.ok) { text = await res.text(); break; }
324
+ } catch {}
325
+ }
326
+ if (!text) throw new Error('not found');
327
+ } catch {
328
+ document.getElementById('table-container').innerHTML = '<p class="empty">No data found.</p>';
329
+ renderJourney(0);
330
+ renderAchievements();
331
+ return;
332
+ }
333
+
334
+ const lines = text.trim().split('\\n');
335
+ if (lines.length < 2) {
336
+ document.getElementById('table-container').innerHTML = '<p class="empty">No records yet.</p>';
337
+ renderJourney(0);
338
+ renderAchievements();
339
+ return;
340
+ }
341
+
342
+ const records = lines.slice(1).map(line => {
343
+ const [datetime, time_min, speed_kmh, distance_km, weight_kg] = line.split(',');
344
+ return { datetime, time_min: parseFloat(time_min), speed_kmh: parseFloat(speed_kmh), distance_km: parseFloat(distance_km), weight_kg: weight_kg ? parseFloat(weight_kg) : null };
345
+ });
346
+
347
+ let totalMin = 0, totalKm = 0, totalCal = 0;
348
+ for (const r of records) {
349
+ totalMin += r.time_min;
350
+ totalKm += r.distance_km;
351
+ const cal = calcCalories(r.speed_kmh, r.time_min, r.weight_kg);
352
+ totalCal += cal;
353
+ if (r.weight_kg) hasCalories = true;
354
+ }
355
+ const totalH = Math.floor(totalMin / 60);
356
+ const remainM = Math.round(totalMin % 60);
357
+
358
+ let summaryHtml =
359
+ '<div class="summary-card"><div class="value">' + totalKm.toFixed(1) + ' km</div><div class="label">Total Distance</div></div>' +
360
+ '<div class="summary-card"><div class="value">' + totalH + 'h ' + remainM + 'm</div><div class="label">Total Time</div></div>';
361
+ if (hasCalories) {
362
+ summaryHtml += '<div class="summary-card"><div class="value">' + totalCal.toLocaleString() + ' kcal</div><div class="label">Total Calories</div></div>';
363
+ }
364
+ document.getElementById('summary').innerHTML = summaryHtml;
365
+
366
+ const sorted = [...records].reverse();
367
+ let tableHtml = '<table><thead><tr><th>Date</th><th>Time (min)</th><th>Speed (km/h)</th><th>Distance (km)</th>' + (hasCalories ? '<th>Calories</th>' : '') + '</tr></thead><tbody>';
368
+ for (const r of sorted) {
369
+ const cal = calcCalories(r.speed_kmh, r.time_min, r.weight_kg);
370
+ const calStr = cal ? cal + ' kcal' : '-';
371
+ const dateStr = new Date(r.datetime).toLocaleString();
372
+ tableHtml += '<tr><td>' + dateStr + '</td><td>' + r.time_min + '</td><td>' + r.speed_kmh + '</td><td>' + r.distance_km + '</td>' + (hasCalories ? '<td>' + calStr + '</td>' : '') + '</tr>';
373
+ }
374
+ tableHtml += '</tbody></table>';
375
+ document.getElementById('table-container').innerHTML = tableHtml;
376
+
377
+ for (const r of records) {
378
+ const date = r.datetime.split('T')[0];
379
+ dailyDistance[date] = (dailyDistance[date] ?? 0) + r.distance_km;
380
+ dailyCalories[date] = (dailyCalories[date] ?? 0) + calcCalories(r.speed_kmh, r.time_min, r.weight_kg);
381
+ }
382
+ sortedLabels = Object.keys(dailyDistance).sort();
383
+
384
+ if (!hasCalories) {
385
+ document.querySelectorAll('[data-tab*="calories"]').forEach(el => el.style.display = 'none');
386
+ }
387
+
388
+ buildChart('daily-distance');
389
+ renderJourney(totalKm);
390
+ renderAchievements();
391
+
392
+ // Chart sub-tab switching
393
+ document.querySelectorAll('.sub-tab-bar button').forEach(btn => {
394
+ btn.addEventListener('click', () => {
395
+ document.querySelectorAll('.sub-tab-bar button').forEach(b => b.classList.remove('active'));
396
+ btn.classList.add('active');
397
+ buildChart(btn.dataset.tab);
398
+ });
399
+ });
400
+ }
401
+
402
+ // Main tab switching
403
+ document.querySelectorAll('.main-tab-bar button').forEach(btn => {
404
+ btn.addEventListener('click', () => {
405
+ document.querySelectorAll('.main-tab-bar button').forEach(b => b.classList.remove('active'));
406
+ btn.classList.add('active');
407
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
408
+ document.getElementById(btn.dataset.main + '-content').classList.add('active');
409
+ });
410
+ });
411
+
412
+ main();
413
+
414
+ // Auto-reload: poll CSV for changes every 3 seconds
415
+ if (location.protocol !== 'file:') {
416
+ let lastText = '';
417
+ setInterval(async () => {
418
+ try {
419
+ const paths = ['../data/walking.csv', '/data/walking.csv', 'data/walking.csv'];
420
+ for (const p of paths) {
421
+ try {
422
+ const res = await fetch(p, { cache: 'no-store' });
423
+ if (res.ok) {
424
+ const text = await res.text();
425
+ if (lastText && text !== lastText) location.reload();
426
+ lastText = text;
427
+ break;
428
+ }
429
+ } catch {}
430
+ }
431
+ } catch {}
432
+ }, 3000);
433
+ }
434
+ </script>
435
+ </body>
436
+ </html>`;
437
+ }
@@ -0,0 +1,15 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const exec = promisify(execFile);
4
+ export async function openInBrowser(url) {
5
+ const platform = process.platform;
6
+ if (platform === "darwin") {
7
+ await exec("open", [url]);
8
+ }
9
+ else if (platform === "win32") {
10
+ await exec("cmd", ["/c", "start", "", url]);
11
+ }
12
+ else {
13
+ await exec("xdg-open", [url]);
14
+ }
15
+ }
@@ -0,0 +1,19 @@
1
+ import { consola } from "consola";
2
+ export async function promptText(message, opts) {
3
+ const val = await consola.prompt(message, { type: "text", ...opts });
4
+ if (typeof val === "symbol")
5
+ process.exit(1);
6
+ return val;
7
+ }
8
+ export async function promptSelect(message, options, initial) {
9
+ const val = await consola.prompt(message, { type: "select", options, initial });
10
+ if (typeof val === "symbol")
11
+ process.exit(1);
12
+ return val;
13
+ }
14
+ export async function promptConfirm(message, initial) {
15
+ const val = await consola.prompt(message, { type: "confirm", initial });
16
+ if (typeof val === "symbol")
17
+ process.exit(1);
18
+ return val;
19
+ }