Add records, Elijah scores, per-session notes, and PWA update prompt

App features:
- Personal-best records per metric: manually settable in Settings and
  auto-updated when a session beats them; shown in the log modal and a
  new dashboard "Personal records" card.
- Juggling now counts by 1 instead of 5.
- 1-on-1 with Elijah gains Technical Skill and Effort scores (out of 10)
  as manual inputs, plus an optional per-session note.
- Service worker now uses a controlled update flow: an in-app
  "new version ready" banner activates the update on tap and reloads.

Data model:
- category_metrics gains track_record + record; entries gains note.
- Idempotent migrations bring existing databases up to date (juggling
  step/record, Elijah score metrics) alongside the updated seed.

StartOS package:
- Bump to 0.1.2:0 with release notes.
- Build x86_64 only (drop aarch64) per deployment target.
This commit is contained in:
Keysat
2026-06-03 08:46:27 -05:00
parent 0265699504
commit 5868852686
17 changed files with 441 additions and 121 deletions
+31
View File
@@ -136,13 +136,28 @@ input:focus, select:focus, textarea:focus { outline: none; border-color: var(--a
.stepper { display: flex; align-items: center; gap: 12px; } .stepper { display: flex; align-items: center; gap: 12px; }
.stepper button { width: 48px; height: 48px; border-radius: 50%; border: none; background: var(--arsenal-red); color: #fff; font-size: 1.6rem; font-weight: 700; } .stepper button { width: 48px; height: 48px; border-radius: 50%; border: none; background: var(--arsenal-red); color: #fff; font-size: 1.6rem; font-weight: 700; }
.stepper .val { font-size: 1.6rem; font-weight: 800; min-width: 70px; text-align: center; } .stepper .val { font-size: 1.6rem; font-weight: 800; min-width: 70px; text-align: center; }
.stepper input.val.score-input { width: 80px; min-width: 0; border: 2px solid var(--line); border-radius: 12px; height: 48px; background: var(--card); color: inherit; }
.stepper .unit { color: var(--muted); font-size: .9rem; } .stepper .unit { color: var(--muted); font-size: .9rem; }
.record-line { color: var(--arsenal-red); font-weight: 700; font-size: .9rem; margin-bottom: 8px; }
/* ---------- Metric / record editor (settings) ---------- */
.metric-edit { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid var(--line); }
.metric-edit-name { flex: 1 1 100%; font-weight: 600; }
.metric-edit .mini { display: flex; align-items: center; gap: 6px; font-size: .8rem; color: var(--muted); font-weight: 600; }
.metric-edit .mini input[type="number"] { height: 38px; }
/* ---------- Records (dashboard) ---------- */
.record-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.record-row:last-child { border-bottom: none; }
.record-row .emoji { font-size: 1.4rem; }
.record-row .record-val { font-size: 1.3rem; font-weight: 800; color: var(--arsenal-red); }
/* ---------- Logged entries ---------- */ /* ---------- Logged entries ---------- */
.entry { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line); } .entry { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.entry:last-child { border-bottom: none; } .entry:last-child { border-bottom: none; }
.entry .emoji { font-size: 1.4rem; } .entry .emoji { font-size: 1.4rem; }
.entry .vals { color: var(--muted); font-size: .9rem; } .entry .vals { color: var(--muted); font-size: .9rem; }
.entry .vals.note { font-style: italic; }
.badge { display: inline-block; background: var(--bg); border-radius: 999px; padding: 2px 10px; font-size: .8rem; font-weight: 600; } .badge { display: inline-block; background: var(--bg); border-radius: 999px; padding: 2px 10px; font-size: .8rem; font-weight: 600; }
/* ---------- Stats ---------- */ /* ---------- Stats ---------- */
@@ -190,6 +205,22 @@ canvas { max-width: 100%; }
.empty .big-emoji { font-size: 3rem; display: block; margin-bottom: 8px; } .empty .big-emoji { font-size: 3rem; display: block; margin-bottom: 8px; }
.toast { position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%); background: var(--ink); color: #fff; padding: 12px 20px; border-radius: 999px; font-weight: 600; z-index: 60; box-shadow: var(--shadow); } .toast { position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%); background: var(--ink); color: #fff; padding: 12px 20px; border-radius: 999px; font-weight: 600; z-index: 60; box-shadow: var(--shadow); }
/* ---------- Update banner ---------- */
.update-banner {
position: fixed; left: 12px; right: 12px;
bottom: calc(72px + var(--safe-bottom)); z-index: 50;
display: flex; align-items: center; justify-content: space-between; gap: 12px;
background: var(--arsenal-red); color: #fff;
padding: 12px 16px; border-radius: 14px; font-weight: 700;
box-shadow: var(--shadow);
animation: slide-up .25s ease;
}
.update-banner .btn-refresh {
flex: none; background: #fff; color: var(--arsenal-red);
border: none; border-radius: 999px; padding: 8px 18px; font-weight: 800;
}
@keyframes slide-up { from { transform: translateY(140%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (min-width: 620px) { @media (min-width: 620px) {
.stat-grid { grid-template-columns: repeat(4, 1fr); } .stat-grid { grid-template-columns: repeat(4, 1fr); }
} }
+1
View File
@@ -26,6 +26,7 @@ export const api = {
addCategory: (c) => req('POST', '/api/categories', c), addCategory: (c) => req('POST', '/api/categories', c),
updateCategory: (id, c) => req('PUT', `/api/categories/${id}`, c), updateCategory: (id, c) => req('PUT', `/api/categories/${id}`, c),
addMetric: (catId, m) => req('POST', `/api/categories/${catId}/metrics`, m), addMetric: (catId, m) => req('POST', `/api/categories/${catId}/metrics`, m),
updateMetric: (id, m) => req('PUT', `/api/metrics/${id}`, m),
deleteMetric: (id) => req('DELETE', `/api/metrics/${id}`), deleteMetric: (id) => req('DELETE', `/api/metrics/${id}`),
day: (day) => req('GET', `/api/day/${day}`), day: (day) => req('GET', `/api/day/${day}`),
+129 -21
View File
@@ -116,8 +116,11 @@ async function renderToday(view) {
}).join(' · '); }).join(' · ');
log.append(h('div', { class: 'entry' }, log.append(h('div', { class: 'entry' },
h('span', { class: 'emoji' }, c.emoji), h('span', { class: 'emoji' }, c.emoji),
h('div', { style: 'flex:1' }, h('div', {}, c.name), valStr && h('div', { class: 'vals' }, valStr)), h('div', { style: 'flex:1' },
h('button', { class: 'btn-danger', onclick: async () => { await api.deleteEntry(e.id); renderToday(view); refreshStatsIfActive(); } }, '🗑'))); h('div', {}, c.name),
valStr && h('div', { class: 'vals' }, valStr),
e.note && h('div', { class: 'vals note' }, '📝 ' + e.note)),
h('button', { class: 'btn-danger', onclick: async () => { await api.deleteEntry(e.id); await loadCategories(); renderToday(view); refreshStatsIfActive(); } }, '🗑')));
} }
} }
view.append(log); view.append(log);
@@ -138,25 +141,65 @@ function openLogModal(cat, view) {
body.append(h('p', { class: 'muted' }, `${cat.emoji} ${cat.name}`)); body.append(h('p', { class: 'muted' }, `${cat.emoji} ${cat.name}`));
if (!metrics.length) body.append(h('p', { class: 'muted small' }, 'No metrics — this just logs a session.')); if (!metrics.length) body.append(h('p', { class: 'muted small' }, 'No metrics — this just logs a session.'));
for (const m of metrics) { for (const m of metrics) {
values[m.id] = 0; const isScore = m.kind === 'score';
const valEl = h('span', { class: 'val' }, '0'); const maxV = isScore ? 10 : null;
const set = (n) => { values[m.id] = Math.max(0, n); valEl.textContent = values[m.id]; }; const start = isScore ? 5 : 0;
body.append(h('div', { class: 'metric' }, values[m.id] = start;
h('label', {}, m.name),
h('div', { class: 'stepper' }, const clamp = (n) => { let v = Math.max(0, n); if (maxV != null) v = Math.min(maxV, v); return v; };
h('button', { onclick: () => set(values[m.id] - (m.step || 1)) }, ''),
valEl, let valEl;
h('button', { onclick: () => set(values[m.id] + (m.step || 1)) }, '+'), if (isScore) {
h('span', { class: 'unit' }, m.unit || '')))); // Manual numeric entry for scores (e.g. 110), with stepper buttons too.
valEl = h('input', { class: 'val score-input', type: 'number', min: '0', max: '10',
inputmode: 'numeric', value: String(start) });
valEl.addEventListener('input', () => { values[m.id] = clamp(Number(valEl.value) || 0); });
valEl.addEventListener('blur', () => { valEl.value = String(values[m.id]); });
} else {
valEl = h('span', { class: 'val' }, '0');
}
const set = (n) => {
values[m.id] = clamp(n);
if (isScore) valEl.value = String(values[m.id]);
else valEl.textContent = String(values[m.id]);
};
const metricEl = h('div', { class: 'metric' }, h('label', {}, m.name));
if (m.track_record) {
metricEl.append(h('div', { class: 'record-line' },
m.record != null
? `🏆 Record: ${m.record}${m.unit ? ' ' + m.unit : ''}`
: '🏆 No record yet — set one in this session!'));
}
metricEl.append(h('div', { class: 'stepper' },
h('button', { type: 'button', onclick: () => set(values[m.id] - (m.step || 1)) }, ''),
valEl,
h('button', { type: 'button', onclick: () => set(values[m.id] + (m.step || 1)) }, '+'),
h('span', { class: 'unit' }, m.unit || '')));
body.append(metricEl);
} }
// Optional per-entry note (handy for coaching sessions like 1-on-1 with Elijah).
const noteEl = h('textarea', { rows: 2, placeholder: 'Notes for this session (optional)…' });
body.append(h('div', { class: 'metric' },
h('label', {}, '📝 Session note'),
noteEl));
body.append(h('div', { class: 'btn-row' }, body.append(h('div', { class: 'btn-row' },
h('button', { class: 'btn-primary big', onclick: async () => { h('button', { class: 'btn-primary big', onclick: async () => {
await api.logEntry({ const res = await api.logEntry({
day: state.day, category_id: cat.id, day: state.day, category_id: cat.id, note: noteEl.value,
values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })), values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })),
}); });
closeModal(); closeModal();
toast(`${cat.emoji} ${cat.name} logged!`); const recs = res?.newRecords || [];
if (recs.length) {
const r = recs[0];
toast(`🏆 NEW RECORD! ${r.value}${r.unit ? ' ' + r.unit : ''}${cat.name}!`);
} else {
toast(`${cat.emoji} ${cat.name} logged!`);
}
await loadCategories(); // pick up any updated records
renderToday(view); renderToday(view);
refreshStatsIfActive(); refreshStatsIfActive();
} }, 'Log it! 🎉'))); } }, 'Log it! 🎉')));
@@ -291,16 +334,41 @@ function openGoalModal(view) {
// ---------- SETTINGS ---------- // ---------- SETTINGS ----------
function openSettings() { function openSettings() {
const body = h('div', {}); const body = h('div', {});
body.append(h('h3', {}, 'Categories')); body.append(h('h3', {}, 'Categories & records'));
body.append(h('p', { class: 'muted small' }, 'Set how each metric counts, turn on 🏆 record tracking, and set the current record by hand. Records auto-update when a session beats them.'));
const list = h('div', {}); const list = h('div', {});
const renderList = () => { const renderList = () => {
list.innerHTML = ''; list.innerHTML = '';
for (const c of state.categories) { for (const c of state.categories) {
list.append(h('div', { class: 'entry' }, const card = h('div', { class: 'card', style: 'margin:10px 0' });
h('span', { class: 'emoji' }, c.emoji), card.append(h('div', { class: 'row spread' },
h('div', { style: 'flex:1' }, c.name, h('strong', {}, `${c.emoji} ${c.name}`),
h('div', { class: 'vals' }, (c.metrics || []).map((m) => m.name).join(', '))),
h('button', { class: 'btn-danger', onclick: async () => { await api.updateCategory(c.id, { archived: 1 }); await loadCategories(); renderList(); refreshActive(); } }, 'Archive'))); h('button', { class: 'btn-danger', onclick: async () => { await api.updateCategory(c.id, { archived: 1 }); await loadCategories(); renderList(); refreshActive(); } }, 'Archive')));
for (const m of (c.metrics || [])) {
const stepIn = h('input', { type: 'number', min: '1', value: String(m.step || 1), style: 'max-width:80px' });
const trackChk = h('input', { type: 'checkbox', ...(m.track_record ? { checked: true } : {}) });
const recordIn = h('input', { type: 'number', placeholder: 'record', value: m.record != null ? String(m.record) : '',
style: 'max-width:100px', ...(m.track_record ? {} : { disabled: true }) });
trackChk.addEventListener('change', () => { recordIn.disabled = !trackChk.checked; });
const save = h('button', { class: 'btn-ghost', onclick: async () => {
await api.updateMetric(m.id, {
step: Number(stepIn.value) || 1,
track_record: trackChk.checked,
record: trackChk.checked ? (recordIn.value === '' ? null : Number(recordIn.value)) : null,
});
await loadCategories(); renderList(); refreshActive(); toast('Saved');
} }, 'Save');
card.append(h('div', { class: 'metric-edit' },
h('div', { class: 'metric-edit-name' }, `${m.name}${m.unit ? ' (' + m.unit + ')' : ''}`),
h('label', { class: 'mini' }, 'Step', stepIn),
h('label', { class: 'mini' }, '🏆 Track', trackChk),
h('label', { class: 'mini' }, 'Record', recordIn),
save));
}
list.append(card);
} }
}; };
renderList(); renderList();
@@ -357,10 +425,50 @@ function switchTab(tab) {
document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab))); document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
document.getElementById('settings-btn').addEventListener('click', openSettings); document.getElementById('settings-btn').addEventListener('click', openSettings);
// ---------- service worker + update prompt ----------
function showUpdateBanner(worker) {
if (document.querySelector('.update-banner')) return;
const banner = h('div', { class: 'update-banner' },
h('span', {}, '⚽ A new version is ready!'),
h('button', { class: 'btn-refresh', onclick: () => {
banner.querySelector('button').textContent = 'Updating…';
worker.postMessage({ type: 'SKIP_WAITING' });
} }, 'Refresh'));
document.body.append(banner);
}
function setupServiceWorker() {
if (!('serviceWorker' in navigator)) return;
let refreshing = false;
// When the freshly-activated worker takes control, reload once to pick up new assets.
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return;
refreshing = true;
location.reload();
});
navigator.serviceWorker.register('/sw.js').then((reg) => {
// An update may already be waiting from a previous visit.
if (reg.waiting && navigator.serviceWorker.controller) showUpdateBanner(reg.waiting);
reg.addEventListener('updatefound', () => {
const nw = reg.installing;
if (!nw) return;
nw.addEventListener('statechange', () => {
// "installed" + an existing controller means this is an update, not first install.
if (nw.state === 'installed' && navigator.serviceWorker.controller) showUpdateBanner(nw);
});
});
// Proactively check for updates on launch and when the app regains focus.
reg.update().catch(() => {});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') reg.update().catch(() => {});
});
}).catch(() => {});
}
// ---------- boot ---------- // ---------- boot ----------
(async () => { (async () => {
try { await api.me(); } catch { return; } try { await api.me(); } catch { return; }
await loadCategories(); await loadCategories();
switchTab('today'); switchTab('today');
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); setupServiceWorker();
})(); })();
+14
View File
@@ -23,6 +23,20 @@ export async function renderStats(view, { catById }) {
const main = stats.goals.find((g) => g.is_main); const main = stats.goals.find((g) => g.is_main);
if (main) view.append(thermometer(main)); if (main) view.append(thermometer(main));
// ---- personal records ----
const records = (stats.records || []).length ? stats.records : null;
if (records) {
const card = h('div', { class: 'card' }, h('h2', {}, '🏆 Personal records'));
for (const r of records) {
card.append(h('div', { class: 'record-row' },
h('span', { class: 'emoji' }, r.emoji),
h('div', { style: 'flex:1' },
h('div', {}, `${r.category}${r.name}`)),
h('span', { class: 'record-val' }, r.record != null ? `${r.record}${r.unit ? ' ' + r.unit : ''}` : '—')));
}
view.append(card);
}
// ---- heatmap ---- // ---- heatmap ----
view.append(h('div', { class: 'card' }, view.append(h('div', { class: 'card' },
h('h2', {}, '🔥 Training calendar'), h('h2', {}, '🔥 Training calendar'),
+10 -2
View File
@@ -1,4 +1,4 @@
const CACHE = 'premier-gunner-v1'; const CACHE = 'premier-gunner-v3';
const SHELL = [ const SHELL = [
'/', '/index.html', '/login.html', '/', '/index.html', '/login.html',
'/css/styles.css', '/css/styles.css',
@@ -10,7 +10,10 @@ const SHELL = [
]; ];
self.addEventListener('install', (e) => { self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); // Pre-cache the new shell, but do NOT skipWaiting automatically — we wait
// until the page tells us to (via the "Refresh" banner) so the update is
// controlled and the user is never interrupted mid-action.
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
}); });
self.addEventListener('activate', (e) => { self.addEventListener('activate', (e) => {
@@ -20,6 +23,11 @@ self.addEventListener('activate', (e) => {
); );
}); });
// The page posts this when the user taps "Refresh" on the update banner.
self.addEventListener('message', (e) => {
if (e.data && e.data.type === 'SKIP_WAITING') self.skipWaiting();
});
self.addEventListener('fetch', (e) => { self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url); const url = new URL(e.request.url);
if (e.request.method !== 'GET') return; if (e.request.method !== 'GET') return;
+1 -1
View File
@@ -1,4 +1,4 @@
ARCHES := x86 arm ARCHES := x86
# overrides to s9pk.mk must precede the include statement # overrides to s9pk.mk must precede the include statement
# Build x86_64 + aarch64 only (matches manifest images.arch). # Build x86_64 + aarch64 only (matches manifest images.arch).
+68 -55
View File
@@ -1,16 +1,23 @@
<p align="center"> <p align="center">
<img src="icon.svg" alt="Hello World Logo" width="21%"> <img src="icon.svg" alt="Premier Gunner Logo" width="21%">
</p> </p>
# Hello World on StartOS # Premier Gunner on StartOS
> **Upstream repo:** <https://github.com/Start9Labs/hello-world> > **Upstream repo:** <https://github.com/ten31/premier-gunner>
A minimal reference service for StartOS. It displays a simple web page — nothing more. Use [this repository](https://github.com/Start9Labs/hello-world-startos) as a template when packaging a new service for StartOS. Premier Gunner is a kid-friendly, mobile-friendly soccer training tracker for a single player. It logs daily training across categories, plans future sessions, tracks goals, and shows progress toward a big reward. This repository packages the app as a StartOS `.s9pk` service for StartOS 0.4.0.x.
## Getting Started ## Getting Started
To learn how to use this template to create your own StartOS service package, see the [Packaging Guide](https://docs.start9.com/packaging). This package builds its own Docker image from the vendored Node app (it does **not** pull a prebuilt image from a registry). The build copies the app source into `./app` and installs/compiles its dependencies inside the image.
```sh
npm ci # install the start-sdk packaging deps
make # vendor the app, build images, and pack the .s9pk(s)
```
To learn the general workflow, see the [StartOS Packaging Guide](https://docs.start9.com/packaging/0.4.0.x/).
--- ---
@@ -25,79 +32,88 @@ To learn how to use this template to create your own StartOS service package, se
- [Backups and Restore](#backups-and-restore) - [Backups and Restore](#backups-and-restore)
- [Health Checks](#health-checks) - [Health Checks](#health-checks)
- [Dependencies](#dependencies) - [Dependencies](#dependencies)
- [Limitations and Differences](#limitations-and-differences) - [Build Layout](#build-layout)
- [What Is Unchanged from Upstream](#what-is-unchanged-from-upstream)
- [Contributing](#contributing)
- [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers) - [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers)
--- ---
## Image and Container Runtime ## Image and Container Runtime
| Property | Value | | Property | Value |
| ------------- | -------------------------------------- | | ------------- | ---------------------------------------------- |
| Image | `ghcr.io/start9labs/hello-world` | | Image | Built locally from `./Dockerfile` (Node 22) |
| Architectures | x86_64, aarch64, riscv64 | | Architectures | x86_64, aarch64 |
| Command | `hello-world` | | Command | `node src/server.js` (cwd `/app`) |
--- ---
## Volume and Data Layout ## Volume and Data Layout
| Volume | Mount Point | Purpose | | Volume | Mount Point | Purpose |
| ------ | ----------- | --------------- | | ------ | ----------- | ------------------------------------------------------------- |
| `main` | `/data` | Persistent data | | `main` | `/data` | SQLite database, sessions, and `store.json` (login password) |
The app reads `PG_DATA_DIR=/data` and writes its SQLite DB there. The package's `store.json` (managed by StartOS) lives in the same volume and holds the login password.
--- ---
## Installation and First-Run Flow ## Installation and First-Run Flow
No special setup. Install and start — the web page is immediately available. 1. On install, a 16-character random login password is generated and written to `store.json`.
2. On every start, `main` reads the password from `store.json` and injects it as `PG_PASSWORD`. The app treats this env var as authoritative and (re)hashes it, so the login password always matches what StartOS holds.
3. The user opens the **ui** interface, logs in, and (recommended) sets their own password via the **Set Login Password** action.
--- ---
## Configuration Management ## Configuration Management
No configurable settings. The service runs with no user-facing configuration. The only user-managed setting is the login password, handled by the **Set Login Password** action — not a config form. The action writes to `store.json`; the resulting change triggers a daemon restart (via a reactive `.const` read) so the new password takes effect immediately.
**StartOS-managed environment variables injected into the app:**
| Variable | Value | Source |
| ------------- | -------------- | ------------------------------- |
| `NODE_ENV` | `production` | static (enables `Secure` cookie)|
| `PG_HOST` | `0.0.0.0` | static |
| `PG_PORT` | `3000` | `utils.uiPort` |
| `PG_DATA_DIR` | `/data` | `main` volume mount |
| `PG_PASSWORD` | (random/user) | `store.json` → reactive read |
--- ---
## Network Access and Interfaces ## Network Access and Interfaces
| Interface | Port | Protocol | Purpose | | Interface | Port | Protocol | Purpose |
| --------- | ---- | -------- | -------------------- | | --------------- | ---- | -------- | ---------------------- |
| Web UI | 80 | HTTP | Hello World web page | | `ui` | 3000 | HTTP | Premier Gunner web app |
**Access methods:** **Access methods:** LAN IP, `<hostname>.local`, Tor `.onion`, and custom clearnet domains.
- LAN IP with unique port ### Clearnet via StartTunnel
- `<hostname>.local` with unique port
- Tor `.onion` address The app serves plain HTTP; StartOS/StartTunnel terminate TLS. Point a StartTunnel domain at the **ui** interface (see the [StartTunnel docs](https://docs.start9.com/start-tunnel/1.0.x/)). Because `NODE_ENV=production` sets the session cookie's `Secure` flag, the app must be reached over HTTPS — which StartTunnel provides.
- Custom domains (if configured)
--- ---
## Actions (StartOS UI) ## Actions (StartOS UI)
None. | Action | Input | Effect |
| -------------------- | ------------------- | ------------------------------------------------------------------- |
| Set Login Password | Password (≥4 chars) | Writes the password to `store.json`; service restarts; sessions cleared |
--- ---
## Backups and Restore ## Backups and Restore
**Included in backup:** **Included in backup:** the `main` volume (database + `store.json`). Restored fully before the service starts.
- `main` volume
**Restore behavior:** Volume is fully restored before the service starts.
--- ---
## Health Checks ## Health Checks
| Check | Method | Messages | | Check | Method | Messages |
| ------------- | ------------------- | ------------------------------------------------------------------ | | ------------- | ---------------------- | ------------------------------------------------------------------------------- |
| Web Interface | Port listening (80) | Success: "The web interface is ready" / Error: "The web interface is not ready" | | Web Interface | Port listening (3000) | Success: "The web interface is ready" / Error: "The web interface is not ready" |
--- ---
@@ -107,35 +123,32 @@ None.
--- ---
## Limitations and Differences ## Build Layout
1. **No meaningful functionality** — this is a reference/template package only - `Dockerfile` — multi-stage Node 22 build; compiles `better-sqlite3`, copies the app, runs `node src/server.js`.
- `Makefile``prep` vendors the app from the parent repo into `./app` (excluding `node_modules`/`data`), then delegates to `s9pk.mk` to build both arches.
--- - `startos/` — the start-sdk package definition (manifest, main, interfaces, actions, fileModels/store, versions, i18n).
- `./app` — generated, git-ignored vendor copy of the Node app used as the Docker build context.
## What Is Unchanged from Upstream
The service is identical to upstream. There are no modifications.
---
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for build instructions and development workflow.
--- ---
## Quick Reference for AI Consumers ## Quick Reference for AI Consumers
```yaml ```yaml
package_id: hello-world package_id: premier-gunner
image: ghcr.io/start9labs/hello-world image: built locally from ./Dockerfile (node:22-bookworm-slim)
architectures: [x86_64, aarch64, riscv64] architectures: [x86_64, aarch64]
volumes: volumes:
main: /data main: /data
ports: ports:
ui: 80 ui: 3000
dependencies: none dependencies: none
startos_managed_env_vars: none startos_managed_env_vars:
actions: none PG_PASSWORD: from store.json (login password, authoritative)
PG_PORT: 3000
PG_DATA_DIR: /data
PG_HOST: 0.0.0.0
NODE_ENV: production
actions:
- set-password (Set Login Password)
``` ```
+10
View File
@@ -1 +1,11 @@
# TODO # TODO
## Known follow-ups
- **In-app password change vs. StartOS action.** When deployed on StartOS, `PG_PASSWORD` (from `store.json`) is authoritative and re-applied on every restart. A password changed through the app's own Settings screen will be reverted on the next restart. Either hide the in-app password field under StartOS, or have the app write changes back to `store.json` so StartOS picks them up. For now, documented to use the **Set Login Password** action.
- **Vendored app is git-ignored.** `./app` is regenerated by `make prep` and excluded from git, so app source changes do not bump the package `gitHash`. Fine for single-user deployment; revisit if publishing.
- **riscv64 not built.** Manifest declares only x86_64 + aarch64 (Node + better-sqlite3 prebuilds). Add riscv64 only if a target host needs it.
## Phase 3 (deferred — not started)
- DGX Spark (Qwen3.6 35B, OpenAI-compatible endpoint) AI coach: login-time training suggestions and per-category drill ideas.
+31 -17
View File
@@ -1,25 +1,39 @@
# Hello World # Premier Gunner
You've installed Hello World — there's nothing to configure and nothing to set up. This page covers how to open the page it serves and where to read more. (If you're a developer, Hello World is also the recommended packaging template.) Premier Gunner is a kid-friendly, mobile-friendly soccer training tracker built for one player. Log daily training across categories (juggling, shooting, dribbling, soccer tennis, and more), plan upcoming sessions, set goals, and watch progress climb the thermometer toward the big reward.
## Documentation
- [Hello World upstream docs](https://github.com/Start9Labs/hello-world/blob/master/README.md) — the README for the web server this package runs.
- [StartOS Packaging Guide](https://docs.start9.com/packaging) — how to build a StartOS service package from that template.
## What you get on StartOS
- **A running web server** that serves a single static page.
- **Nothing to configure and no actions** — the service starts on its own and is immediately usable.
## Getting set up ## Getting set up
There's no setup wizard, no admin password, no first-run prompt — Hello World is usable the moment it starts. To view the page it serves: 1. Open Premier Gunner's **Dashboard** tab and click the **Premier Gunner** web interface to open the app.
2. You'll land on a **login screen** asking for a password. A strong random password is generated automatically when the service is installed.
3. To set your own password, run the **Set Login Password** action (Actions tab). Enter the password Gunner will type on the login screen (at least 4 characters) and save. The service restarts and the new password takes effect immediately. Any active logins are signed out.
1. Open Hello World's **Dashboard** tab. > The login password is managed entirely by the **Set Login Password** action. Changing it inside the app's own Settings screen will be overwritten on the next restart, so always use the action.
2. Click the **Web UI** interface to open the served page in your browser.
## What you get on StartOS
- **A single-user web app**, password protected, reachable over your StartOS networking (Tor, LAN, or a clearnet domain via StartTunnel).
- **Daily logging** by category with point-and-click steppers, plus optional per-session notes.
- **Personal-best records** — turn on 🏆 tracking for any metric (juggling is on by default). Set the current record by hand in Settings, and it updates automatically whenever a session beats it.
- **1-on-1 with Elijah scores** — log a Technical Skill score and an Effort score (out of 10) alongside the session note.
- **Planning, goals, and a dashboard** with a streak calendar, training-spread radar, improvement charts, a records list, and the main-goal thermometer.
- **Persistent data** stored in the package's `main` volume (`/data`), included in StartOS backups.
- **One action** — Set Login Password.
### Updates
When you install a new version of Premier Gunner, the installed web app (including a phone home-screen PWA) detects it automatically and shows a **"A new version is ready! — Refresh"** banner. Tap **Refresh** to update instantly. If you don't, it updates on its own the next time you fully close and reopen the app.
## Exposing on a clearnet domain (StartTunnel)
Premier Gunner serves plain HTTP on its interface; StartOS terminates TLS. To reach it from a normal browser on the open internet:
1. Install and configure **StartTunnel** and point a domain at this service's **ui** interface (see the [StartTunnel docs](https://docs.start9.com/start-tunnel/1.0.x/)).
2. Add the clearnet address to the **ui** interface so the app's session cookie is issued for that host.
The app sets its session cookie with the `Secure` flag in production, so it requires HTTPS — which StartTunnel provides.
## Limitations ## Limitations
- Hello World is intentionally minimal. It is not a useful service on its own; it exists to demonstrate the StartOS packaging system. - Premier Gunner is intentionally single-user. There are no separate accounts; everyone who has the password shares the same data.
- The page content is static and cannot be customized through the StartOS UI. - The login password must be changed via the **Set Login Password** action, not the in-app Settings screen, when running on StartOS.
+1 -1
View File
@@ -14,7 +14,7 @@ export const manifest = setupManifest({
images: { images: {
'premier-gunner': { 'premier-gunner': {
source: { dockerBuild: { dockerfile: 'Dockerfile', workdir: '.' } }, source: { dockerBuild: { dockerfile: 'Dockerfile', workdir: '.' } },
arch: ['x86_64', 'aarch64'], arch: ['x86_64'],
}, },
}, },
alerts: { alerts: {
+6 -6
View File
@@ -2,13 +2,13 @@ import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk'
import { store } from '../fileModels/store' import { store } from '../fileModels/store'
export const current = VersionInfo.of({ export const current = VersionInfo.of({
version: '0.1.0:0', version: '0.1.2:0',
releaseNotes: { releaseNotes: {
en_US: 'Initial release of Premier Gunner for StartOS.', en_US: 'Personal-best records (auto-update + manual set), juggling counts by 1, technical-skill/effort scores plus per-session notes for 1-on-1 with Elijah, and an in-app "new version ready" refresh prompt.',
es_ES: 'Versión inicial de Premier Gunner para StartOS.', es_ES: 'Récords personales (actualización automática + ajuste manual), los toques cuentan de 1 en 1, puntuaciones de técnica/esfuerzo con notas por sesión para el 1 contra 1 con Elijah, y un aviso de actualización dentro de la app.',
de_DE: 'Erste Veröffentlichung von Premier Gunner für StartOS.', de_DE: 'Persönliche Bestwerte (automatisch + manuell setzbar), Jonglieren zählt in 1er-Schritten, Technik-/Einsatz-Bewertungen mit Notizen pro Einheit für 1-gegen-1 mit Elijah und ein In-App-Hinweis „Neue Version verfügbar".',
pl_PL: 'Pierwsze wydanie Premier Gunner dla StartOS.', pl_PL: 'Rekordy życiowe (automatyczna aktualizacja + ręczne ustawianie), żonglerka liczona co 1, oceny techniki/zaangażowania z notatkami dla sesji 1 na 1 z Elijah oraz powiadomienie o aktualizacji w aplikacji.',
fr_FR: 'Première version de Premier Gunner pour StartOS.', fr_FR: "Records personnels (mise à jour automatique + réglage manuel), jonglages comptés par 1, notes de technique/d'effort avec commentaires par séance pour le 1-contre-1 avec Elijah, et une invite de mise à jour dans l'application.",
}, },
migrations: { migrations: {
up: async ({ effects }) => { up: async ({ effects }) => {
+59
View File
@@ -24,3 +24,62 @@ export function setSetting(key, value) {
'ON CONFLICT(key) DO UPDATE SET value = excluded.value' 'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, String(value)); ).run(key, String(value));
} }
// ---------- lightweight migrations for already-created databases ----------
function hasColumn(table, col) {
return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
}
function addColumn(table, col, def) {
if (!hasColumn(table, col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
}
// Columns added after the initial release (schema.sql covers fresh installs;
// these ALTERs bring existing databases up to date).
addColumn('category_metrics', 'track_record', 'INTEGER NOT NULL DEFAULT 0');
addColumn('category_metrics', 'record', 'REAL');
addColumn('entries', 'note', "TEXT NOT NULL DEFAULT ''");
// One-off data migration: turn on juggling records (step 1) and add the
// Elijah technical-skill / effort scores to databases seeded before these
// features existed. Guarded so it runs at most once.
if (getSetting('migr_records_scores') !== 'done') {
const tx = db.transaction(() => {
// Juggling: count by 1s and track a personal-best record.
const jugMetric = db.prepare(
`SELECT cm.id FROM category_metrics cm
JOIN categories c ON c.id = cm.category_id
WHERE c.name = 'Juggling' AND cm.name = 'Juggles'`
).get();
if (jugMetric) {
const best = db.prepare(
'SELECT MAX(value) AS v FROM entry_values WHERE metric_id = ?'
).get(jugMetric.id).v;
db.prepare(
'UPDATE category_metrics SET step = 1, track_record = 1, record = ? WHERE id = ?'
).run(best ?? null, jugMetric.id);
}
// 1-on-1 with Elijah: add Technical Skill + Effort score metrics if missing.
const elijah = db.prepare("SELECT id FROM categories WHERE name = '1-on-1 with Elijah'").get();
if (elijah) {
const addScore = (name) => {
const exists = db.prepare(
'SELECT 1 FROM category_metrics WHERE category_id = ? AND name = ?'
).get(elijah.id, name);
if (!exists) {
const maxOrder = db.prepare(
'SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_metrics WHERE category_id = ?'
).get(elijah.id).m;
db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' +
"VALUES (?, ?, '/10', 'score', 1, 1, ?)"
).run(elijah.id, name, maxOrder + 1);
}
};
addScore('Technical Skill');
addScore('Effort');
}
});
tx();
setSetting('migr_records_scores', 'done');
}
+35 -7
View File
@@ -34,9 +34,10 @@ export default async function categoryRoutes(app) {
: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }]; : [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }];
list.forEach((m, i) => { list.forEach((m, i) => {
db.prepare( db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' + 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)' 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(catId, m.name || 'Value', m.unit || '', m.kind || 'count', m.step || 1, m.higher_is_better ?? 1, i); ).run(catId, m.name || 'Value', m.unit || '', m.kind || 'count', m.step || 1,
m.higher_is_better ?? 1, m.track_record ? 1 : 0, i);
}); });
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === Number(catId)); return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === Number(catId));
}); });
@@ -58,17 +59,44 @@ export default async function categoryRoutes(app) {
const id = Number(req.params.id); const id = Number(req.params.id);
const cat = db.prepare('SELECT id FROM categories WHERE id = ?').get(id); const cat = db.prepare('SELECT id FROM categories WHERE id = ?').get(id);
if (!cat) return reply.code(404).send({ error: 'Not found' }); if (!cat) return reply.code(404).send({ error: 'Not found' });
const { name, unit, kind, step, higher_is_better } = req.body || {}; const { name, unit, kind, step, higher_is_better, track_record } = req.body || {};
const maxOrder = db.prepare( const maxOrder = db.prepare(
'SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_metrics WHERE category_id = ?' 'SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_metrics WHERE category_id = ?'
).get(id).m; ).get(id).m;
db.prepare( db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' + 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)' 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(id, name || 'Value', unit || '', kind || 'count', step || 1, higher_is_better ?? 1, maxOrder + 1); ).run(id, name || 'Value', unit || '', kind || 'count', step || 1,
higher_is_better ?? 1, track_record ? 1 : 0, maxOrder + 1);
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === id); return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === id);
}); });
// Update an existing metric (step, record tracking, manual record value, etc.).
app.put('/api/metrics/:id', async (req, reply) => {
const id = Number(req.params.id);
const m = db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id);
if (!m) return reply.code(404).send({ error: 'Not found' });
const b = req.body || {};
// `record` may be explicitly set to null to clear it.
const record = 'record' in b
? (b.record === null || b.record === '' ? null : Number(b.record))
: m.record;
db.prepare(
'UPDATE category_metrics SET name = ?, unit = ?, kind = ?, step = ?, ' +
'higher_is_better = ?, track_record = ?, record = ? WHERE id = ?'
).run(
b.name != null ? String(b.name) : m.name,
b.unit != null ? String(b.unit) : m.unit,
b.kind != null ? String(b.kind) : m.kind,
b.step != null ? Number(b.step) || 1 : m.step,
b.higher_is_better != null ? (b.higher_is_better ? 1 : 0) : m.higher_is_better,
b.track_record != null ? (b.track_record ? 1 : 0) : m.track_record,
record,
id,
);
return db.prepare('SELECT * FROM category_metrics WHERE id = ?').get(id);
});
app.delete('/api/metrics/:id', async (req, reply) => { app.delete('/api/metrics/:id', async (req, reply) => {
const id = Number(req.params.id); const id = Number(req.params.id);
db.prepare('DELETE FROM category_metrics WHERE id = ?').run(id); db.prepare('DELETE FROM category_metrics WHERE id = ?').run(id);
+23 -6
View File
@@ -10,7 +10,7 @@ function dayPayload(day) {
const td = db.prepare('SELECT day, notes FROM training_days WHERE day = ?').get(day) const td = db.prepare('SELECT day, notes FROM training_days WHERE day = ?').get(day)
|| { day, notes: '' }; || { day, notes: '' };
const entries = db.prepare( const entries = db.prepare(
'SELECT id, category_id, created_at FROM entries WHERE day = ? ORDER BY id' 'SELECT id, category_id, note, created_at FROM entries WHERE day = ? ORDER BY id'
).all(day); ).all(day);
const valuesByEntry = new Map(); const valuesByEntry = new Map();
if (entries.length) { if (entries.length) {
@@ -42,26 +42,43 @@ export default async function entryRoutes(app) {
}); });
app.post('/api/entries', async (req, reply) => { app.post('/api/entries', async (req, reply) => {
const { day, category_id, values } = req.body || {}; const { day, category_id, values, note } = req.body || {};
if (!ISO.test(day || '')) return reply.code(400).send({ error: 'Bad date' }); if (!ISO.test(day || '')) return reply.code(400).send({ error: 'Bad date' });
const cat = db.prepare('SELECT id FROM categories WHERE id = ?').get(Number(category_id)); const cat = db.prepare('SELECT id FROM categories WHERE id = ?').get(Number(category_id));
if (!cat) return reply.code(400).send({ error: 'Unknown category' }); if (!cat) return reply.code(400).send({ error: 'Unknown category' });
const newRecords = [];
const tx = db.transaction(() => { const tx = db.transaction(() => {
ensureDay(day); ensureDay(day);
const { lastInsertRowid: entryId } = db.prepare( const { lastInsertRowid: entryId } = db.prepare(
'INSERT INTO entries (day, category_id) VALUES (?, ?)' 'INSERT INTO entries (day, category_id, note) VALUES (?, ?, ?)'
).run(day, cat.id); ).run(day, cat.id, String(note || ''));
if (Array.isArray(values)) { if (Array.isArray(values)) {
const ins = db.prepare('INSERT INTO entry_values (entry_id, metric_id, value) VALUES (?, ?, ?)'); const ins = db.prepare('INSERT INTO entry_values (entry_id, metric_id, value) VALUES (?, ?, ?)');
for (const v of values) { for (const v of values) {
if (v && v.metric_id != null) ins.run(entryId, Number(v.metric_id), Number(v.value) || 0); if (!v || v.metric_id == null) continue;
const metricId = Number(v.metric_id);
const value = Number(v.value) || 0;
ins.run(entryId, metricId, value);
// Auto-update personal-best records.
const m = db.prepare(
'SELECT id, name, record, higher_is_better, unit FROM category_metrics WHERE id = ? AND track_record = 1'
).get(metricId);
if (m) {
const beats = m.record == null
|| (m.higher_is_better ? value > m.record : value < m.record);
if (beats) {
db.prepare('UPDATE category_metrics SET record = ? WHERE id = ?').run(value, metricId);
newRecords.push({ metric_id: m.id, name: m.name, unit: m.unit, value, previous: m.record });
}
}
} }
} }
return entryId; return entryId;
}); });
tx(); tx();
return dayPayload(day); return { ...dayPayload(day), newRecords };
}); });
app.delete('/api/entries/:id', async (req, reply) => { app.delete('/api/entries/:id', async (req, reply) => {
+10
View File
@@ -79,6 +79,15 @@ export default async function statsRoutes(app) {
const goals = db.prepare('SELECT * FROM goals ORDER BY is_main DESC, sort_order, id').all() const goals = db.prepare('SELECT * FROM goals ORDER BY is_main DESC, sort_order, id').all()
.map(goalProgress); .map(goalProgress);
// Personal-best records for metrics that track them.
const records = db.prepare(
`SELECT cm.id AS metric_id, cm.name, cm.unit, cm.record, cm.higher_is_better,
c.id AS category_id, c.name AS category, c.emoji, c.color
FROM category_metrics cm JOIN categories c ON c.id = cm.category_id
WHERE cm.track_record = 1 AND c.archived = 0
ORDER BY c.sort_order, c.id, cm.sort_order, cm.id`
).all();
return { return {
totalSessions, totalSessions,
totalDays, totalDays,
@@ -88,6 +97,7 @@ export default async function statsRoutes(app) {
radar, radar,
series, series,
goals, goals,
records,
}; };
}); });
} }
+3
View File
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS category_metrics (
kind TEXT NOT NULL DEFAULT 'count', -- count | duration | score kind TEXT NOT NULL DEFAULT 'count', -- count | duration | score
step INTEGER NOT NULL DEFAULT 1, step INTEGER NOT NULL DEFAULT 1,
higher_is_better INTEGER NOT NULL DEFAULT 1, higher_is_better INTEGER NOT NULL DEFAULT 1,
track_record INTEGER NOT NULL DEFAULT 0, -- 1 = keep a personal-best record
record REAL, -- current personal best (NULL = unset)
sort_order INTEGER NOT NULL DEFAULT 0 sort_order INTEGER NOT NULL DEFAULT 0
); );
@@ -34,6 +36,7 @@ CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
day TEXT NOT NULL, day TEXT NOT NULL,
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE INDEX IF NOT EXISTS idx_entries_day ON entries(day); CREATE INDEX IF NOT EXISTS idx_entries_day ON entries(day);
+9 -5
View File
@@ -3,7 +3,7 @@ import { db } from './db.js';
// Default categories Gunner starts with. He can edit/add/archive these later. // Default categories Gunner starts with. He can edit/add/archive these later.
const DEFAULTS = [ const DEFAULTS = [
{ name: 'Juggling', emoji: '🤹', color: '#EF0107', { name: 'Juggling', emoji: '🤹', color: '#EF0107',
metrics: [{ name: 'Juggles', unit: 'reps', kind: 'count', step: 5 }] }, metrics: [{ name: 'Juggles', unit: 'reps', kind: 'count', step: 1, track_record: 1 }] },
{ name: 'Left Foot', emoji: '🦶', color: '#0a58ca', { name: 'Left Foot', emoji: '🦶', color: '#0a58ca',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] }, metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
{ name: 'Shooting', emoji: '🥅', color: '#d63384', { name: 'Shooting', emoji: '🥅', color: '#d63384',
@@ -20,7 +20,11 @@ const DEFAULTS = [
{ name: 'Backyard with Dad', emoji: '🏡', color: '#20c997', { name: 'Backyard with Dad', emoji: '🏡', color: '#20c997',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] }, metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
{ name: '1-on-1 with Elijah', emoji: '👟', color: '#0dcaf0', { name: '1-on-1 with Elijah', emoji: '👟', color: '#0dcaf0',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] }, metrics: [
{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 },
{ name: 'Technical Skill', unit: '/10', kind: 'score', step: 1 },
{ name: 'Effort', unit: '/10', kind: 'score', step: 1 },
] },
{ name: 'EPA Agility & Speed', emoji: '⚡', color: '#ffc107', { name: 'EPA Agility & Speed', emoji: '⚡', color: '#ffc107',
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] }, metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
]; ];
@@ -33,8 +37,8 @@ export function seedIfEmpty() {
'INSERT INTO categories (name, emoji, color, sort_order) VALUES (?, ?, ?, ?)' 'INSERT INTO categories (name, emoji, color, sort_order) VALUES (?, ?, ?, ?)'
); );
const insMetric = db.prepare( const insMetric = db.prepare(
'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' + 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)' 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
); );
const seed = db.transaction(() => { const seed = db.transaction(() => {
@@ -42,7 +46,7 @@ export function seedIfEmpty() {
const { lastInsertRowid: catId } = insCat.run(cat.name, cat.emoji, cat.color, ci); const { lastInsertRowid: catId } = insCat.run(cat.name, cat.emoji, cat.color, ci);
cat.metrics.forEach((m, mi) => { cat.metrics.forEach((m, mi) => {
insMetric.run(catId, m.name, m.unit || '', m.kind || 'count', insMetric.run(catId, m.name, m.unit || '', m.kind || 'count',
m.step || 1, m.higher_is_better ?? 1, mi); m.step || 1, m.higher_is_better ?? 1, m.track_record ? 1 : 0, mi);
}); });
}); });