diff --git a/public/css/styles.css b/public/css/styles.css index 0479d5c..de83ade 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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 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 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; } +.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 ---------- */ .entry { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line); } .entry:last-child { border-bottom: none; } .entry .emoji { font-size: 1.4rem; } .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; } /* ---------- Stats ---------- */ @@ -190,6 +205,22 @@ canvas { max-width: 100%; } .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); } +/* ---------- 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) { .stat-grid { grid-template-columns: repeat(4, 1fr); } } diff --git a/public/js/api.js b/public/js/api.js index 8ea9dad..75aaf89 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -26,6 +26,7 @@ export const api = { addCategory: (c) => req('POST', '/api/categories', c), updateCategory: (id, c) => req('PUT', `/api/categories/${id}`, c), 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}`), day: (day) => req('GET', `/api/day/${day}`), diff --git a/public/js/app.js b/public/js/app.js index 20ab3e8..a58d667 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -116,8 +116,11 @@ async function renderToday(view) { }).join(' · '); log.append(h('div', { class: 'entry' }, h('span', { class: 'emoji' }, c.emoji), - h('div', { style: 'flex:1' }, h('div', {}, c.name), valStr && h('div', { class: 'vals' }, valStr)), - h('button', { class: 'btn-danger', onclick: async () => { await api.deleteEntry(e.id); renderToday(view); refreshStatsIfActive(); } }, '🗑'))); + h('div', { style: 'flex:1' }, + 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); @@ -138,25 +141,65 @@ function openLogModal(cat, view) { 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.')); for (const m of metrics) { - values[m.id] = 0; - const valEl = h('span', { class: 'val' }, '0'); - const set = (n) => { values[m.id] = Math.max(0, n); valEl.textContent = values[m.id]; }; - body.append(h('div', { class: 'metric' }, - h('label', {}, m.name), - h('div', { class: 'stepper' }, - h('button', { onclick: () => set(values[m.id] - (m.step || 1)) }, '−'), - valEl, - h('button', { onclick: () => set(values[m.id] + (m.step || 1)) }, '+'), - h('span', { class: 'unit' }, m.unit || '')))); + const isScore = m.kind === 'score'; + const maxV = isScore ? 10 : null; + const start = isScore ? 5 : 0; + values[m.id] = start; + + const clamp = (n) => { let v = Math.max(0, n); if (maxV != null) v = Math.min(maxV, v); return v; }; + + let valEl; + if (isScore) { + // Manual numeric entry for scores (e.g. 1–10), 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' }, h('button', { class: 'btn-primary big', onclick: async () => { - await api.logEntry({ - day: state.day, category_id: cat.id, + const res = await api.logEntry({ + day: state.day, category_id: cat.id, note: noteEl.value, values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })), }); 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); refreshStatsIfActive(); } }, 'Log it! 🎉'))); @@ -291,16 +334,41 @@ function openGoalModal(view) { // ---------- SETTINGS ---------- function openSettings() { 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 renderList = () => { list.innerHTML = ''; for (const c of state.categories) { - list.append(h('div', { class: 'entry' }, - h('span', { class: 'emoji' }, c.emoji), - h('div', { style: 'flex:1' }, c.name, - h('div', { class: 'vals' }, (c.metrics || []).map((m) => m.name).join(', '))), + const card = h('div', { class: 'card', style: 'margin:10px 0' }); + card.append(h('div', { class: 'row spread' }, + h('strong', {}, `${c.emoji} ${c.name}`), 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(); @@ -357,10 +425,50 @@ function switchTab(tab) { document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab))); 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 ---------- (async () => { try { await api.me(); } catch { return; } await loadCategories(); switchTab('today'); - if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); + setupServiceWorker(); })(); diff --git a/public/js/dashboard.js b/public/js/dashboard.js index aaff6d3..3c96504 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -23,6 +23,20 @@ export async function renderStats(view, { catById }) { const main = stats.goals.find((g) => g.is_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 ---- view.append(h('div', { class: 'card' }, h('h2', {}, '🔥 Training calendar'), diff --git a/public/sw.js b/public/sw.js index b518338..dc250b7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'premier-gunner-v1'; +const CACHE = 'premier-gunner-v3'; const SHELL = [ '/', '/index.html', '/login.html', '/css/styles.css', @@ -10,7 +10,10 @@ const SHELL = [ ]; 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) => { @@ -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) => { const url = new URL(e.request.url); if (e.request.method !== 'GET') return; diff --git a/s9pk/Makefile b/s9pk/Makefile index 28dda48..b41ee79 100644 --- a/s9pk/Makefile +++ b/s9pk/Makefile @@ -1,4 +1,4 @@ -ARCHES := x86 arm +ARCHES := x86 # overrides to s9pk.mk must precede the include statement # Build x86_64 + aarch64 only (matches manifest images.arch). diff --git a/s9pk/README.md b/s9pk/README.md index a046a99..5127fcb 100644 --- a/s9pk/README.md +++ b/s9pk/README.md @@ -1,16 +1,23 @@

- Hello World Logo + Premier Gunner Logo

-# Hello World on StartOS +# Premier Gunner on StartOS -> **Upstream repo:** +> **Upstream repo:** -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 -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) - [Health Checks](#health-checks) - [Dependencies](#dependencies) -- [Limitations and Differences](#limitations-and-differences) -- [What Is Unchanged from Upstream](#what-is-unchanged-from-upstream) -- [Contributing](#contributing) +- [Build Layout](#build-layout) - [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers) --- ## Image and Container Runtime -| Property | Value | -| ------------- | -------------------------------------- | -| Image | `ghcr.io/start9labs/hello-world` | -| Architectures | x86_64, aarch64, riscv64 | -| Command | `hello-world` | +| Property | Value | +| ------------- | ---------------------------------------------- | +| Image | Built locally from `./Dockerfile` (Node 22) | +| Architectures | x86_64, aarch64 | +| Command | `node src/server.js` (cwd `/app`) | --- ## Volume and Data Layout -| Volume | Mount Point | Purpose | -| ------ | ----------- | --------------- | -| `main` | `/data` | Persistent data | +| Volume | Mount Point | Purpose | +| ------ | ----------- | ------------------------------------------------------------- | +| `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 -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 -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 -| Interface | Port | Protocol | Purpose | -| --------- | ---- | -------- | -------------------- | -| Web UI | 80 | HTTP | Hello World web page | +| Interface | Port | Protocol | Purpose | +| --------------- | ---- | -------- | ---------------------- | +| `ui` | 3000 | HTTP | Premier Gunner web app | -**Access methods:** +**Access methods:** LAN IP, `.local`, Tor `.onion`, and custom clearnet domains. -- LAN IP with unique port -- `.local` with unique port -- Tor `.onion` address -- Custom domains (if configured) +### Clearnet via StartTunnel + +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. --- ## 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 -**Included in backup:** - -- `main` volume - -**Restore behavior:** Volume is fully restored before the service starts. +**Included in backup:** the `main` volume (database + `store.json`). Restored fully before the service starts. --- ## Health Checks -| Check | Method | Messages | -| ------------- | ------------------- | ------------------------------------------------------------------ | -| Web Interface | Port listening (80) | Success: "The web interface is ready" / Error: "The web interface is not ready" | +| Check | Method | Messages | +| ------------- | ---------------------- | ------------------------------------------------------------------------------- | +| 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 - ---- - -## 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. +- `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. --- ## Quick Reference for AI Consumers ```yaml -package_id: hello-world -image: ghcr.io/start9labs/hello-world -architectures: [x86_64, aarch64, riscv64] +package_id: premier-gunner +image: built locally from ./Dockerfile (node:22-bookworm-slim) +architectures: [x86_64, aarch64] volumes: main: /data ports: - ui: 80 + ui: 3000 dependencies: none -startos_managed_env_vars: none -actions: none +startos_managed_env_vars: + 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) ``` diff --git a/s9pk/TODO.md b/s9pk/TODO.md index 4640904..8f01070 100644 --- a/s9pk/TODO.md +++ b/s9pk/TODO.md @@ -1 +1,11 @@ # 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. diff --git a/s9pk/instructions.md b/s9pk/instructions.md index b8ceddc..a4dfbfe 100644 --- a/s9pk/instructions.md +++ b/s9pk/instructions.md @@ -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.) - -## 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. +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. ## 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. -2. Click the **Web UI** interface to open the served page in your browser. +> 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. + +## 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 -- Hello World is intentionally minimal. It is not a useful service on its own; it exists to demonstrate the StartOS packaging system. -- The page content is static and cannot be customized through the StartOS UI. +- Premier Gunner is intentionally single-user. There are no separate accounts; everyone who has the password shares the same data. +- The login password must be changed via the **Set Login Password** action, not the in-app Settings screen, when running on StartOS. diff --git a/s9pk/startos/manifest/index.ts b/s9pk/startos/manifest/index.ts index 78b71a0..4d42074 100644 --- a/s9pk/startos/manifest/index.ts +++ b/s9pk/startos/manifest/index.ts @@ -14,7 +14,7 @@ export const manifest = setupManifest({ images: { 'premier-gunner': { source: { dockerBuild: { dockerfile: 'Dockerfile', workdir: '.' } }, - arch: ['x86_64', 'aarch64'], + arch: ['x86_64'], }, }, alerts: { diff --git a/s9pk/startos/versions/current.ts b/s9pk/startos/versions/current.ts index c1cd7e6..5e6745e 100644 --- a/s9pk/startos/versions/current.ts +++ b/s9pk/startos/versions/current.ts @@ -2,13 +2,13 @@ import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk' import { store } from '../fileModels/store' export const current = VersionInfo.of({ - version: '0.1.0:0', + version: '0.1.2:0', releaseNotes: { - en_US: 'Initial release of Premier Gunner for StartOS.', - es_ES: 'Versión inicial de Premier Gunner para StartOS.', - de_DE: 'Erste Veröffentlichung von Premier Gunner für StartOS.', - pl_PL: 'Pierwsze wydanie Premier Gunner dla StartOS.', - fr_FR: 'Première version de Premier Gunner pour 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: '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: '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: '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: "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: { up: async ({ effects }) => { diff --git a/src/db.js b/src/db.js index 94aedea..bdf0c3c 100644 --- a/src/db.js +++ b/src/db.js @@ -24,3 +24,62 @@ export function setSetting(key, value) { 'ON CONFLICT(key) DO UPDATE SET value = excluded.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'); +} diff --git a/src/routes/categories.js b/src/routes/categories.js index b83105f..58e8ab1 100644 --- a/src/routes/categories.js +++ b/src/routes/categories.js @@ -34,9 +34,10 @@ export default async function categoryRoutes(app) { : [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }]; list.forEach((m, i) => { db.prepare( - 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' + - 'VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(catId, m.name || 'Value', m.unit || '', m.kind || 'count', m.step || 1, m.higher_is_better ?? 1, i); + 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).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)); }); @@ -58,17 +59,44 @@ export default async function categoryRoutes(app) { const id = Number(req.params.id); const cat = db.prepare('SELECT id FROM categories WHERE id = ?').get(id); 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( 'SELECT COALESCE(MAX(sort_order), -1) AS m FROM category_metrics WHERE category_id = ?' ).get(id).m; db.prepare( - 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' + - 'VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(id, name || 'Value', unit || '', kind || 'count', step || 1, higher_is_better ?? 1, maxOrder + 1); + 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ).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); }); + // 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) => { const id = Number(req.params.id); db.prepare('DELETE FROM category_metrics WHERE id = ?').run(id); diff --git a/src/routes/entries.js b/src/routes/entries.js index f84270c..167689d 100644 --- a/src/routes/entries.js +++ b/src/routes/entries.js @@ -10,7 +10,7 @@ function dayPayload(day) { const td = db.prepare('SELECT day, notes FROM training_days WHERE day = ?').get(day) || { day, notes: '' }; 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); const valuesByEntry = new Map(); if (entries.length) { @@ -42,26 +42,43 @@ export default async function entryRoutes(app) { }); 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' }); const cat = db.prepare('SELECT id FROM categories WHERE id = ?').get(Number(category_id)); if (!cat) return reply.code(400).send({ error: 'Unknown category' }); + const newRecords = []; const tx = db.transaction(() => { ensureDay(day); const { lastInsertRowid: entryId } = db.prepare( - 'INSERT INTO entries (day, category_id) VALUES (?, ?)' - ).run(day, cat.id); + 'INSERT INTO entries (day, category_id, note) VALUES (?, ?, ?)' + ).run(day, cat.id, String(note || '')); if (Array.isArray(values)) { const ins = db.prepare('INSERT INTO entry_values (entry_id, metric_id, value) 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; }); tx(); - return dayPayload(day); + return { ...dayPayload(day), newRecords }; }); app.delete('/api/entries/:id', async (req, reply) => { diff --git a/src/routes/stats.js b/src/routes/stats.js index 790b1a7..a54a434 100644 --- a/src/routes/stats.js +++ b/src/routes/stats.js @@ -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() .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 { totalSessions, totalDays, @@ -88,6 +97,7 @@ export default async function statsRoutes(app) { radar, series, goals, + records, }; }); } diff --git a/src/schema.sql b/src/schema.sql index 01ba184..7be362c 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS category_metrics ( kind TEXT NOT NULL DEFAULT 'count', -- count | duration | score step 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 ); @@ -34,6 +36,7 @@ CREATE TABLE IF NOT EXISTS entries ( id INTEGER PRIMARY KEY, day TEXT NOT NULL, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + note TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_entries_day ON entries(day); diff --git a/src/seed.js b/src/seed.js index 6f0cc78..6034232 100644 --- a/src/seed.js +++ b/src/seed.js @@ -3,7 +3,7 @@ import { db } from './db.js'; // Default categories Gunner starts with. He can edit/add/archive these later. const DEFAULTS = [ { 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', metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] }, { name: 'Shooting', emoji: '🥅', color: '#d63384', @@ -20,7 +20,11 @@ const DEFAULTS = [ { name: 'Backyard with Dad', emoji: '🏡', color: '#20c997', metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] }, { 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', 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 (?, ?, ?, ?)' ); const insMetric = db.prepare( - 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, sort_order) ' + - 'VALUES (?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO category_metrics (category_id, name, unit, kind, step, higher_is_better, track_record, sort_order) ' + + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ); const seed = db.transaction(() => { @@ -42,7 +46,7 @@ export function seedIfEmpty() { const { lastInsertRowid: catId } = insCat.run(cat.name, cat.emoji, cat.color, ci); cat.metrics.forEach((m, mi) => { 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); }); });