Initial commit: Premier Gunner tracker + StartOS 0.4.0 s9pk package
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.claude/
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# ⚽ Premier Gunner
|
||||||
|
|
||||||
|
A kid-friendly, mobile-friendly soccer training tracker. Gunner logs what he trains each
|
||||||
|
day, plans his week, chases goals, and watches the **Road to London** thermometer fill up
|
||||||
|
toward the reward: a trip to see Arsenal play in person. Installable as a PWA on his phone.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Single-password login** — everything lives behind one password.
|
||||||
|
- **Daily logging** — tap a category pill, bump the metric steppers (juggles, minutes,
|
||||||
|
shots, …), add optional notes. Almost no typing.
|
||||||
|
- **Categories Gunner controls** — Juggling, Left Foot, Shooting, Soccer Tennis, Dribbling
|
||||||
|
Drills, Soccer Golf, Backyard with Dad, 1-on-1 with Elijah, EPA Agility & Speed — and he
|
||||||
|
can add/edit/archive his own in Settings.
|
||||||
|
- **Weekly planning** — set objectives for upcoming days; stick to them or change his mind.
|
||||||
|
- **Dashboard** — total sessions, training days, streaks, a GitHub-style calendar heatmap,
|
||||||
|
a radar chart of training spread, line charts of improvement over time, goal progress
|
||||||
|
bars, and the main-goal thermometer.
|
||||||
|
- **Goals** — overall or per-category, by number of sessions, personal best, or running
|
||||||
|
total. Flag one as the ⭐ main goal to drive the thermometer.
|
||||||
|
- **PWA** — installable, offline app shell, custom Arsenal-red cannon icon.
|
||||||
|
|
||||||
|
## Tech
|
||||||
|
|
||||||
|
Node.js + Fastify + better-sqlite3 on the backend; a no-build vanilla-JS PWA frontend with
|
||||||
|
Chart.js (vendored locally). All data in a single SQLite file. No external services.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
PG_PASSWORD=your-password npm start
|
||||||
|
# open http://localhost:3000 (default password is "gunner" if PG_PASSWORD is unset)
|
||||||
|
```
|
||||||
|
|
||||||
|
To start fresh, stop the server and delete the `data/` folder — categories and the starter
|
||||||
|
goals re-seed on next boot.
|
||||||
|
|
||||||
|
## Configuration (environment variables)
|
||||||
|
|
||||||
|
| Var | Default | Purpose |
|
||||||
|
| ------------------ | ------------------ | -------------------------------------------------- |
|
||||||
|
| `PG_PORT` | `3000` | HTTP port |
|
||||||
|
| `PG_HOST` | `0.0.0.0` | Bind address |
|
||||||
|
| `PG_DATA_DIR` | `./data` | Where the SQLite DB + WAL live (persist this!) |
|
||||||
|
| `PG_PASSWORD` | `gunner` (dev) | Plaintext password, hashed on first boot |
|
||||||
|
| `PG_PASSWORD_HASH` | — | Pre-computed bcrypt hash (preferred for prod) |
|
||||||
|
| `PG_COOKIE_SECRET` | auto-generated | Session cookie signing secret (persisted if unset) |
|
||||||
|
| `PG_SESSION_DAYS` | `30` | How long a login stays valid |
|
||||||
|
|
||||||
|
The password can also be changed in-app under ⚙️ Settings.
|
||||||
|
|
||||||
|
## Phase 2 — not yet built
|
||||||
|
|
||||||
|
These were intentionally deferred (see the plan in chat):
|
||||||
|
|
||||||
|
1. **StartOS 0.4.0 service package** — wrap this in an s9pk so it installs on Start9, with
|
||||||
|
`PG_DATA_DIR` mounted on a persistent volume and the password exposed as a service config
|
||||||
|
field. Serve it on the clearnet domain via Start9 pages + StartTunnel.
|
||||||
|
Docs: https://docs.start9.com/packaging/0.4.0.x/ · https://docs.start9.com/start-tunnel/1.0.x/
|
||||||
|
2. **DGX Spark (Qwen3.6) AI coach** — a server-side proxy to the OpenAI-compatible endpoint
|
||||||
|
that, on login, reviews Gunner's objectives + full history and suggests what to train and
|
||||||
|
how to plan ahead; plus on-demand drill ideas per category. Kept server-side so the
|
||||||
|
endpoint and any key never reach the browser.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
server.js Fastify app, auth gate, route wiring
|
||||||
|
config.js env-driven config + data dir
|
||||||
|
db.js SQLite connection + schema bootstrap
|
||||||
|
schema.sql tables
|
||||||
|
seed.js default categories + starter goals
|
||||||
|
auth.js password hashing, sessions
|
||||||
|
routes/ auth, categories, entries, plans, goals, stats
|
||||||
|
public/
|
||||||
|
index.html app shell (tabs: Today / Plan / Stats / Goals)
|
||||||
|
login.html
|
||||||
|
css/styles.css
|
||||||
|
js/app.js views: logging, planning, goals, settings
|
||||||
|
js/dashboard.js charts, heatmap, thermometer
|
||||||
|
js/api.js fetch wrapper
|
||||||
|
manifest.webmanifest, sw.js, icons/
|
||||||
|
```
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "premier-gunner",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Premier Gunner — kid-friendly soccer training tracker (PWA)",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
|
"seed": "node src/seed.js"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.0.2",
|
||||||
|
"@fastify/static": "^8.1.1",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"better-sqlite3": "^11.10.0",
|
||||||
|
"fastify": "^5.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
:root {
|
||||||
|
--arsenal-red: #EF0107;
|
||||||
|
--arsenal-red-dark: #c50006;
|
||||||
|
--arsenal-navy: #023474;
|
||||||
|
--gold: #DB0007;
|
||||||
|
--ink: #15181f;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--bg: #f4f6fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--line: #e6e9f0;
|
||||||
|
--good: #198754;
|
||||||
|
--radius: 18px;
|
||||||
|
--shadow: 0 4px 18px rgba(20, 24, 31, 0.08);
|
||||||
|
--tap: 52px;
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
h1, h2, h3 { margin: 0 0 .4em; }
|
||||||
|
button { font-family: inherit; cursor: pointer; }
|
||||||
|
|
||||||
|
/* ---------- Login ---------- */
|
||||||
|
.login-body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(160deg, var(--arsenal-red) 0%, var(--arsenal-red-dark) 55%, var(--arsenal-navy) 100%);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: 34px 28px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, .3);
|
||||||
|
}
|
||||||
|
.login-logo { margin-bottom: 8px; }
|
||||||
|
.login-card h1 { color: var(--arsenal-red); font-size: 1.7rem; }
|
||||||
|
.tagline { color: var(--muted); margin-top: -4px; }
|
||||||
|
.login-card form { display: flex; flex-direction: column; gap: 14px; margin-top: 18px; }
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: inherit; font-size: 1rem;
|
||||||
|
padding: 14px 16px; border: 2px solid var(--line); border-radius: 14px;
|
||||||
|
width: 100%; background: #fff; color: var(--ink);
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--arsenal-red); }
|
||||||
|
.error { color: var(--arsenal-red); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---------- App shell ---------- */
|
||||||
|
.app-header {
|
||||||
|
position: sticky; top: 0; z-index: 20;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 16px; padding-top: calc(12px + env(safe-area-inset-top, 0px));
|
||||||
|
background: var(--arsenal-red); color: #fff;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.app-header h1 { font-size: 1.2rem; margin: 0; flex: 1; }
|
||||||
|
.icon-btn {
|
||||||
|
background: rgba(255,255,255,.18); border: none; color: #fff;
|
||||||
|
width: 40px; height: 40px; border-radius: 12px; font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.view { padding: 16px 16px 96px; max-width: 760px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* ---------- Tab bar ---------- */
|
||||||
|
.tabbar {
|
||||||
|
position: fixed; bottom: 0; left: 0; right: 0; z-index: 20;
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||||
|
background: var(--card); border-top: 1px solid var(--line);
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
background: none; border: none; padding: 10px 4px 8px;
|
||||||
|
font-size: 1.5rem; color: var(--muted);
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||||
|
}
|
||||||
|
.tab span { font-size: .7rem; font-weight: 600; }
|
||||||
|
.tab.active { color: var(--arsenal-red); }
|
||||||
|
|
||||||
|
/* ---------- Cards & layout ---------- */
|
||||||
|
.card {
|
||||||
|
background: var(--card); border-radius: var(--radius);
|
||||||
|
padding: 16px; margin-bottom: 16px; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 1.05rem; }
|
||||||
|
.row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.spread { justify-content: space-between; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: .85rem; }
|
||||||
|
.center { text-align: center; }
|
||||||
|
.hidden, [hidden] { display: none !important; }
|
||||||
|
|
||||||
|
/* ---------- Date nav ---------- */
|
||||||
|
.datenav { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; }
|
||||||
|
.datenav .day-label { font-weight: 700; font-size: 1.05rem; text-align: center; flex: 1; }
|
||||||
|
.datenav button { width: 44px; height: 44px; border-radius: 12px; border: none; background: #fff; box-shadow: var(--shadow); font-size: 1.2rem; }
|
||||||
|
|
||||||
|
/* ---------- Pills ---------- */
|
||||||
|
.pill-grid { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.pill {
|
||||||
|
border: 2px solid var(--line); background: #fff; border-radius: 999px;
|
||||||
|
padding: 10px 16px; font-size: 1rem; font-weight: 600;
|
||||||
|
display: inline-flex; align-items: center; gap: 8px; min-height: var(--tap);
|
||||||
|
transition: transform .05s ease;
|
||||||
|
}
|
||||||
|
.pill:active { transform: scale(.96); }
|
||||||
|
.pill .emoji { font-size: 1.3rem; }
|
||||||
|
.pill.selected { color: #fff; border-color: transparent; }
|
||||||
|
.pill.done { box-shadow: inset 0 0 0 2px var(--good); }
|
||||||
|
.pill .check { color: #fff; }
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--arsenal-red); color: #fff; border: none;
|
||||||
|
border-radius: 14px; padding: 14px 18px; font-size: 1rem; font-weight: 700;
|
||||||
|
min-height: var(--tap);
|
||||||
|
}
|
||||||
|
.btn-primary.big { font-size: 1.1rem; padding: 16px; }
|
||||||
|
.btn-ghost { background: #fff; color: var(--ink); border: 2px solid var(--line); border-radius: 14px; padding: 12px 16px; font-weight: 600; }
|
||||||
|
.btn-danger { background: none; border: none; color: var(--arsenal-red); font-weight: 700; }
|
||||||
|
.btn-row { display: flex; gap: 10px; margin-top: 8px; }
|
||||||
|
.btn-row > * { flex: 1; }
|
||||||
|
|
||||||
|
/* ---------- Steppers ---------- */
|
||||||
|
.metric { margin: 14px 0; }
|
||||||
|
.metric label { display: block; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.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 .unit { color: var(--muted); font-size: .9rem; }
|
||||||
|
|
||||||
|
/* ---------- 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; }
|
||||||
|
.badge { display: inline-block; background: var(--bg); border-radius: 999px; padding: 2px 10px; font-size: .8rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ---------- Stats ---------- */
|
||||||
|
.stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 16px; }
|
||||||
|
.stat { background: var(--card); border-radius: var(--radius); padding: 16px; text-align: center; box-shadow: var(--shadow); }
|
||||||
|
.stat .num { font-size: 2rem; font-weight: 800; color: var(--arsenal-red); }
|
||||||
|
.stat .lbl { color: var(--muted); font-size: .8rem; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Thermometer */
|
||||||
|
.thermo-wrap { display: flex; align-items: center; gap: 18px; }
|
||||||
|
.thermo { position: relative; width: 46px; height: 200px; }
|
||||||
|
.thermo .reward { flex: 1; }
|
||||||
|
.thermo .reward .pct { font-size: 1.8rem; font-weight: 800; color: var(--arsenal-red); }
|
||||||
|
|
||||||
|
/* Heatmap */
|
||||||
|
.heatmap { overflow-x: auto; padding-bottom: 6px; }
|
||||||
|
.heat-cell { rx: 2; }
|
||||||
|
|
||||||
|
/* Progress bars */
|
||||||
|
.goal { margin-bottom: 16px; }
|
||||||
|
.goal .top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||||
|
.goal .label { font-weight: 700; }
|
||||||
|
.bar { height: 14px; background: var(--bg); border-radius: 999px; overflow: hidden; margin-top: 6px; }
|
||||||
|
.bar > span { display: block; height: 100%; background: linear-gradient(90deg, var(--arsenal-red), #ff5a5f); border-radius: 999px; transition: width .5s ease; }
|
||||||
|
.bar.done > span { background: linear-gradient(90deg, var(--good), #34d399); }
|
||||||
|
|
||||||
|
canvas { max-width: 100%; }
|
||||||
|
|
||||||
|
/* ---------- Modal ---------- */
|
||||||
|
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: grid; place-items: end center; z-index: 40; }
|
||||||
|
.modal {
|
||||||
|
background: var(--card); width: 100%; max-width: 760px;
|
||||||
|
border-radius: 24px 24px 0 0; padding: 20px 18px calc(20px + var(--safe-bottom));
|
||||||
|
max-height: 92vh; overflow-y: auto; box-shadow: 0 -10px 40px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
.modal h2 { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.modal .close { background: none; border: none; font-size: 1.6rem; color: var(--muted); }
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.field label { display: block; font-weight: 600; margin-bottom: 6px; }
|
||||||
|
.emoji-pick { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.emoji-pick button { font-size: 1.4rem; width: 44px; height: 44px; border-radius: 12px; border: 2px solid var(--line); background: #fff; }
|
||||||
|
.emoji-pick button.sel { border-color: var(--arsenal-red); background: #fff0f0; }
|
||||||
|
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 30px 10px; }
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
@media (min-width: 620px) {
|
||||||
|
.stat-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff1f25"/>
|
||||||
|
<stop offset="1" stop-color="#c50006"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||||
|
<!-- cannon barrel -->
|
||||||
|
<g transform="rotate(-18 256 300)">
|
||||||
|
<rect x="150" y="232" width="230" height="58" rx="14" fill="#fff"/>
|
||||||
|
<rect x="360" y="226" width="34" height="70" rx="8" fill="#fff"/>
|
||||||
|
<circle cx="168" cy="296" r="34" fill="#fff"/>
|
||||||
|
<rect x="150" y="296" width="70" height="40" rx="10" fill="#fff"/>
|
||||||
|
</g>
|
||||||
|
<!-- wheel -->
|
||||||
|
<circle cx="186" cy="350" r="46" fill="#023474" stroke="#fff" stroke-width="10"/>
|
||||||
|
<circle cx="186" cy="350" r="8" fill="#fff"/>
|
||||||
|
<!-- soccer ball as cannonball -->
|
||||||
|
<g transform="translate(392 196)">
|
||||||
|
<circle r="46" fill="#fff" stroke="#15181f" stroke-width="3"/>
|
||||||
|
<path d="M0 -22 L21 -7 L13 18 L-13 18 L-21 -7 Z" fill="#15181f"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 65 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff1f25"/>
|
||||||
|
<stop offset="1" stop-color="#c50006"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||||
|
<!-- cannon barrel -->
|
||||||
|
<g transform="rotate(-18 256 300)">
|
||||||
|
<rect x="150" y="232" width="230" height="58" rx="14" fill="#fff"/>
|
||||||
|
<rect x="360" y="226" width="34" height="70" rx="8" fill="#fff"/>
|
||||||
|
<circle cx="168" cy="296" r="34" fill="#fff"/>
|
||||||
|
<rect x="150" y="296" width="70" height="40" rx="10" fill="#fff"/>
|
||||||
|
</g>
|
||||||
|
<!-- wheel -->
|
||||||
|
<circle cx="186" cy="350" r="46" fill="#023474" stroke="#fff" stroke-width="10"/>
|
||||||
|
<circle cx="186" cy="350" r="8" fill="#fff"/>
|
||||||
|
<!-- soccer ball as cannonball -->
|
||||||
|
<g transform="translate(392 196)">
|
||||||
|
<circle r="46" fill="#fff" stroke="#15181f" stroke-width="3"/>
|
||||||
|
<path d="M0 -22 L21 -7 L13 18 L-13 18 L-21 -7 Z" fill="#15181f"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff1f25"/>
|
||||||
|
<stop offset="1" stop-color="#c50006"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(56 56) scale(0.78)">
|
||||||
|
<g transform="rotate(-18 256 300)">
|
||||||
|
<rect x="150" y="232" width="230" height="58" rx="14" fill="#fff"/>
|
||||||
|
<rect x="360" y="226" width="34" height="70" rx="8" fill="#fff"/>
|
||||||
|
<circle cx="168" cy="296" r="34" fill="#fff"/>
|
||||||
|
<rect x="150" y="296" width="70" height="40" rx="10" fill="#fff"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="186" cy="350" r="46" fill="#023474" stroke="#fff" stroke-width="10"/>
|
||||||
|
<circle cx="186" cy="350" r="8" fill="#fff"/>
|
||||||
|
<g transform="translate(392 196)">
|
||||||
|
<circle r="46" fill="#fff" stroke="#15181f" stroke-width="3"/>
|
||||||
|
<path d="M0 -22 L21 -7 L13 18 L-13 18 L-21 -7 Z" fill="#15181f"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#EF0107" />
|
||||||
|
<title>Premier Gunner</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<link rel="icon" href="/icons/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<img src="/icons/logo.svg" alt="" class="header-logo" width="34" height="34" />
|
||||||
|
<h1>Premier Gunner</h1>
|
||||||
|
<button id="settings-btn" class="icon-btn" title="Settings" aria-label="Settings">⚙️</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="view" class="view"><!-- active view rendered here --></main>
|
||||||
|
|
||||||
|
<nav class="tabbar">
|
||||||
|
<button class="tab" data-tab="today">📋<span>Today</span></button>
|
||||||
|
<button class="tab" data-tab="plan">🗓️<span>Plan</span></button>
|
||||||
|
<button class="tab" data-tab="stats">📊<span>Stats</span></button>
|
||||||
|
<button class="tab" data-tab="goals">🏆<span>Goals</span></button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="modal-root"></div>
|
||||||
|
|
||||||
|
<script src="/vendor/chart.umd.min.js"></script>
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Thin fetch wrapper. Redirects to login on 401.
|
||||||
|
async function req(method, url, body) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if (res.status === 401 && !url.endsWith('/api/login')) {
|
||||||
|
location.href = '/login.html';
|
||||||
|
throw new Error('Not authenticated');
|
||||||
|
}
|
||||||
|
const data = res.headers.get('content-type')?.includes('application/json')
|
||||||
|
? await res.json() : null;
|
||||||
|
if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (password) => req('POST', '/api/login', { password }),
|
||||||
|
logout: () => req('POST', '/api/logout'),
|
||||||
|
me: () => req('GET', '/api/me'),
|
||||||
|
setPassword: (current, next) => req('POST', '/api/password', { current, next }),
|
||||||
|
|
||||||
|
categories: (all = false) => req('GET', `/api/categories${all ? '?all=1' : ''}`),
|
||||||
|
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),
|
||||||
|
deleteMetric: (id) => req('DELETE', `/api/metrics/${id}`),
|
||||||
|
|
||||||
|
day: (day) => req('GET', `/api/day/${day}`),
|
||||||
|
logEntry: (e) => req('POST', '/api/entries', e),
|
||||||
|
deleteEntry: (id) => req('DELETE', `/api/entries/${id}`),
|
||||||
|
saveNotes: (day, notes) => req('PUT', `/api/day/${day}/notes`, { notes }),
|
||||||
|
|
||||||
|
plans: (from, to) => req('GET', `/api/plans?from=${from}&to=${to}`),
|
||||||
|
addPlan: (p) => req('POST', '/api/plans', p),
|
||||||
|
deletePlan: (id) => req('DELETE', `/api/plans/${id}`),
|
||||||
|
|
||||||
|
goals: () => req('GET', '/api/goals'),
|
||||||
|
addGoal: (g) => req('POST', '/api/goals', g),
|
||||||
|
updateGoal: (id, g) => req('PUT', `/api/goals/${id}`, g),
|
||||||
|
deleteGoal: (id) => req('DELETE', `/api/goals/${id}`),
|
||||||
|
|
||||||
|
stats: () => req('GET', '/api/stats'),
|
||||||
|
};
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { api } from '/js/api.js';
|
||||||
|
import { renderStats } from '/js/dashboard.js';
|
||||||
|
|
||||||
|
// ---------- tiny DOM + date helpers ----------
|
||||||
|
export const h = (tag, attrs = {}, ...kids) => {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === 'class') e.className = v;
|
||||||
|
else if (k === 'html') e.innerHTML = v;
|
||||||
|
else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
|
||||||
|
else if (v === true) e.setAttribute(k, '');
|
||||||
|
else if (v !== false && v != null) e.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const kid of kids.flat()) {
|
||||||
|
if (kid == null || kid === false) continue;
|
||||||
|
e.append(kid.nodeType ? kid : document.createTextNode(kid));
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
export const isoOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
export const todayISO = () => isoOf(new Date());
|
||||||
|
export const parseISO = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||||
|
export const addDays = (iso, n) => { const d = parseISO(iso); d.setDate(d.getDate() + n); return isoOf(d); };
|
||||||
|
const WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
export const prettyDay = (iso) => {
|
||||||
|
const d = parseISO(iso); const t = todayISO();
|
||||||
|
if (iso === t) return `Today · ${WEEK[d.getDay()]} ${MON[d.getMonth()]} ${d.getDate()}`;
|
||||||
|
if (iso === addDays(t, -1)) return `Yesterday · ${MON[d.getMonth()]} ${d.getDate()}`;
|
||||||
|
if (iso === addDays(t, 1)) return `Tomorrow · ${MON[d.getMonth()]} ${d.getDate()}`;
|
||||||
|
return `${WEEK[d.getDay()]} · ${MON[d.getMonth()]} ${d.getDate()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toast(msg) {
|
||||||
|
document.querySelector('.toast')?.remove();
|
||||||
|
const t = h('div', { class: 'toast' }, msg);
|
||||||
|
document.body.append(t);
|
||||||
|
setTimeout(() => t.remove(), 1900);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalRoot = document.getElementById('modal-root');
|
||||||
|
export function openModal(title, contentEl, { onClose } = {}) {
|
||||||
|
closeModal();
|
||||||
|
const close = () => { modalRoot.innerHTML = ''; onClose?.(); };
|
||||||
|
const backdrop = h('div', { class: 'modal-backdrop', onclick: (e) => { if (e.target === backdrop) close(); } },
|
||||||
|
h('div', { class: 'modal' },
|
||||||
|
h('h2', {}, title, h('button', { class: 'close', onclick: close }, '✕')),
|
||||||
|
contentEl));
|
||||||
|
modalRoot.append(backdrop);
|
||||||
|
return close;
|
||||||
|
}
|
||||||
|
export const closeModal = () => { modalRoot.innerHTML = ''; };
|
||||||
|
|
||||||
|
const textColorFor = (hex) => {
|
||||||
|
const c = hex.replace('#', '');
|
||||||
|
const r = parseInt(c.substr(0, 2), 16), g = parseInt(c.substr(2, 2), 16), b = parseInt(c.substr(4, 2), 16);
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#15181f' : '#fff';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- state ----------
|
||||||
|
const state = { categories: [], day: todayISO(), tab: 'today' };
|
||||||
|
const catById = (id) => state.categories.find((c) => c.id === id);
|
||||||
|
|
||||||
|
async function loadCategories() { state.categories = await api.categories(); }
|
||||||
|
|
||||||
|
// ---------- TODAY (logging) ----------
|
||||||
|
async function renderToday(view) {
|
||||||
|
const data = await api.day(state.day);
|
||||||
|
const loggedCatIds = new Set(data.entries.map((e) => e.category_id));
|
||||||
|
const plannedIds = new Set(data.plans.map((p) => p.category_id));
|
||||||
|
|
||||||
|
view.innerHTML = '';
|
||||||
|
view.append(
|
||||||
|
h('div', { class: 'datenav' },
|
||||||
|
h('button', { onclick: () => { state.day = addDays(state.day, -1); renderToday(view); } }, '‹'),
|
||||||
|
h('div', { class: 'day-label' }, prettyDay(state.day)),
|
||||||
|
h('button', { onclick: () => { state.day = addDays(state.day, 1); renderToday(view); } }, '›')));
|
||||||
|
|
||||||
|
if (plannedIds.size) {
|
||||||
|
view.append(h('div', { class: 'card' },
|
||||||
|
h('h2', {}, '🗓️ Today\'s plan'),
|
||||||
|
h('div', { class: 'pill-grid' },
|
||||||
|
[...plannedIds].map((id) => {
|
||||||
|
const c = catById(id); if (!c) return null;
|
||||||
|
return h('span', { class: 'pill' + (loggedCatIds.has(id) ? ' done' : '') },
|
||||||
|
h('span', { class: 'emoji' }, c.emoji), c.name, loggedCatIds.has(id) ? ' ✅' : '');
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
|
||||||
|
view.append(h('div', { class: 'card' },
|
||||||
|
h('h2', {}, '⚽ What did you train?'),
|
||||||
|
h('p', { class: 'muted small' }, 'Tap a category to log it.'),
|
||||||
|
h('div', { class: 'pill-grid' },
|
||||||
|
state.categories.map((c) => {
|
||||||
|
const done = loggedCatIds.has(c.id);
|
||||||
|
const style = `background:${c.color};border-color:${c.color};color:${textColorFor(c.color)}`;
|
||||||
|
return h('button', {
|
||||||
|
class: 'pill' + (done ? ' done' : ''),
|
||||||
|
style: done ? style : '',
|
||||||
|
onclick: () => openLogModal(c, view),
|
||||||
|
}, h('span', { class: 'emoji' }, c.emoji), c.name, done ? ' ✅' : '');
|
||||||
|
}))));
|
||||||
|
|
||||||
|
// Logged entries for the day
|
||||||
|
const log = h('div', { class: 'card' }, h('h2', {}, '✅ Logged'));
|
||||||
|
if (!data.entries.length) {
|
||||||
|
log.append(h('p', { class: 'muted' }, 'Nothing yet — tap a category above to start!'));
|
||||||
|
} else {
|
||||||
|
for (const e of data.entries) {
|
||||||
|
const c = catById(e.category_id) || { emoji: '⚽', name: 'Category', metrics: [] };
|
||||||
|
const valStr = e.values.map((v) => {
|
||||||
|
const m = c.metrics?.find((mm) => mm.id === v.metric_id);
|
||||||
|
return m ? `${v.value} ${m.unit || m.name}` : v.value;
|
||||||
|
}).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(); } }, '🗑')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.append(log);
|
||||||
|
|
||||||
|
// Daily notes
|
||||||
|
const ta = h('textarea', { rows: 3, placeholder: 'Notes, thoughts, things to remember…' }, data.notes || '');
|
||||||
|
view.append(h('div', { class: 'card' },
|
||||||
|
h('h2', {}, '📝 Notes for the day'),
|
||||||
|
ta,
|
||||||
|
h('div', { class: 'btn-row' },
|
||||||
|
h('button', { class: 'btn-primary', onclick: async () => { await api.saveNotes(state.day, ta.value); toast('Notes saved'); } }, 'Save notes'))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLogModal(cat, view) {
|
||||||
|
const metrics = cat.metrics || [];
|
||||||
|
const values = {};
|
||||||
|
const body = h('div', {});
|
||||||
|
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 || ''))));
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
values: metrics.map((m) => ({ metric_id: m.id, value: values[m.id] })),
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
toast(`${cat.emoji} ${cat.name} logged!`);
|
||||||
|
renderToday(view);
|
||||||
|
refreshStatsIfActive();
|
||||||
|
} }, 'Log it! 🎉')));
|
||||||
|
openModal(`Log ${cat.name}`, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- PLAN ----------
|
||||||
|
async function renderPlan(view) {
|
||||||
|
const from = todayISO(); const to = addDays(from, 13);
|
||||||
|
const plans = await api.plans(from, to);
|
||||||
|
const byDay = {};
|
||||||
|
for (const p of plans) (byDay[p.day] ||= []).push(p);
|
||||||
|
|
||||||
|
view.innerHTML = '';
|
||||||
|
view.append(h('div', { class: 'card' },
|
||||||
|
h('h2', {}, '🗓️ Plan your week'),
|
||||||
|
h('p', { class: 'muted small' }, 'Pick what to work on each day. You can always change your mind!')));
|
||||||
|
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const day = addDays(from, i);
|
||||||
|
const dayPlans = byDay[day] || [];
|
||||||
|
const plannedIds = new Set(dayPlans.map((p) => p.category_id));
|
||||||
|
view.append(h('div', { class: 'card' },
|
||||||
|
h('div', { class: 'row spread' },
|
||||||
|
h('strong', {}, prettyDay(day)),
|
||||||
|
h('span', { class: 'badge' }, `${plannedIds.size} planned`)),
|
||||||
|
h('div', { class: 'pill-grid', style: 'margin-top:10px' },
|
||||||
|
state.categories.map((c) => {
|
||||||
|
const on = plannedIds.has(c.id);
|
||||||
|
const style = on ? `background:${c.color};border-color:${c.color};color:${textColorFor(c.color)}` : '';
|
||||||
|
return h('button', { class: 'pill' + (on ? ' selected' : ''), style, onclick: async () => {
|
||||||
|
if (on) {
|
||||||
|
const p = dayPlans.find((x) => x.category_id === c.id);
|
||||||
|
await api.deletePlan(p.id);
|
||||||
|
} else {
|
||||||
|
await api.addPlan({ day, category_id: c.id });
|
||||||
|
}
|
||||||
|
renderPlan(view);
|
||||||
|
} }, h('span', { class: 'emoji' }, c.emoji), c.name);
|
||||||
|
}))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- GOALS ----------
|
||||||
|
async function renderGoals(view) {
|
||||||
|
const stats = await api.stats();
|
||||||
|
view.innerHTML = '';
|
||||||
|
view.append(h('div', { class: 'row spread' },
|
||||||
|
h('h2', {}, '🏆 Goals'),
|
||||||
|
h('button', { class: 'btn-primary', onclick: () => openGoalModal(view) }, '+ New goal')));
|
||||||
|
|
||||||
|
if (!stats.goals.length) {
|
||||||
|
view.append(h('div', { class: 'empty' }, h('span', { class: 'big-emoji' }, '🎯'), 'No goals yet. Add one to chase!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const g of stats.goals) {
|
||||||
|
view.append(goalCard(g, view));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goalCard(g, view) {
|
||||||
|
const done = g.pct >= 100;
|
||||||
|
const catName = g.category_id ? (catById(g.category_id)?.name || '') : 'Overall';
|
||||||
|
return h('div', { class: 'card goal' },
|
||||||
|
h('div', { class: 'top' },
|
||||||
|
h('span', { class: 'label' }, (g.is_main ? '⭐ ' : '') + (g.label || catName)),
|
||||||
|
h('span', { class: 'muted small' }, `${Math.round(g.current)} / ${g.target}`)),
|
||||||
|
h('div', { class: 'muted small' }, `${catName} · ${kindLabel(g.kind)}${g.reward ? ' · 🎁 ' + g.reward : ''}`),
|
||||||
|
h('div', { class: 'bar' + (done ? ' done' : '') }, h('span', { style: `width:${g.pct}%` })),
|
||||||
|
h('div', { class: 'btn-row' },
|
||||||
|
h('button', { class: 'btn-danger', onclick: async () => { if (confirm('Delete this goal?')) { await api.deleteGoal(g.id); renderGoals(view); } } }, 'Delete')));
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindLabel = (k) => ({ session_count: 'Times trained', metric_best: 'Personal best', metric_total: 'Total amount' }[k] || k);
|
||||||
|
|
||||||
|
function openGoalModal(view) {
|
||||||
|
const body = h('div', {});
|
||||||
|
const labelIn = h('input', { placeholder: 'Goal name (e.g. Juggle 100 in a row)' });
|
||||||
|
const scopeSel = h('select', {}, h('option', { value: 'category' }, 'A category'), h('option', { value: 'overall' }, 'Overall (all training)'));
|
||||||
|
const catSel = h('select', {}, state.categories.map((c) => h('option', { value: c.id }, `${c.emoji} ${c.name}`)));
|
||||||
|
const kindSel = h('select', {},
|
||||||
|
h('option', { value: 'session_count' }, 'Number of times trained'),
|
||||||
|
h('option', { value: 'metric_best' }, 'Personal best (one session)'),
|
||||||
|
h('option', { value: 'metric_total' }, 'Total added up'));
|
||||||
|
const metricSel = h('select', {});
|
||||||
|
const targetIn = h('input', { type: 'number', min: '1', placeholder: 'Target number' });
|
||||||
|
const rewardIn = h('input', { placeholder: 'Reward (optional)' });
|
||||||
|
const mainChk = h('input', { type: 'checkbox' });
|
||||||
|
|
||||||
|
const catField = h('div', { class: 'field' }, h('label', {}, 'Category'), catSel);
|
||||||
|
const metricField = h('div', { class: 'field' }, h('label', {}, 'Which metric?'), metricSel);
|
||||||
|
|
||||||
|
const refreshMetrics = () => {
|
||||||
|
const c = catById(Number(catSel.value));
|
||||||
|
metricSel.innerHTML = '';
|
||||||
|
(c?.metrics || []).forEach((m) => metricSel.append(h('option', { value: m.id }, `${m.name} (${m.unit || ''})`)));
|
||||||
|
};
|
||||||
|
const refreshVis = () => {
|
||||||
|
catField.style.display = scopeSel.value === 'category' ? '' : 'none';
|
||||||
|
const needMetric = kindSel.value !== 'session_count' && scopeSel.value === 'category';
|
||||||
|
metricField.style.display = needMetric ? '' : 'none';
|
||||||
|
if (scopeSel.value === 'overall') kindSel.value = 'session_count';
|
||||||
|
};
|
||||||
|
scopeSel.addEventListener('change', refreshVis);
|
||||||
|
kindSel.addEventListener('change', refreshVis);
|
||||||
|
catSel.addEventListener('change', refreshMetrics);
|
||||||
|
refreshMetrics(); refreshVis();
|
||||||
|
|
||||||
|
body.append(
|
||||||
|
h('div', { class: 'field' }, h('label', {}, 'Goal name'), labelIn),
|
||||||
|
h('div', { class: 'field' }, h('label', {}, 'Track…'), scopeSel),
|
||||||
|
catField,
|
||||||
|
h('div', { class: 'field' }, h('label', {}, 'Goal type'), kindSel),
|
||||||
|
metricField,
|
||||||
|
h('div', { class: 'field' }, h('label', {}, 'Target'), targetIn),
|
||||||
|
h('div', { class: 'field' }, h('label', {}, 'Reward'), rewardIn),
|
||||||
|
h('label', { class: 'row', style: 'gap:10px' }, mainChk, ' Make this the ⭐ main goal (thermometer)'),
|
||||||
|
h('div', { class: 'btn-row' },
|
||||||
|
h('button', { class: 'btn-primary big', onclick: async () => {
|
||||||
|
const payload = {
|
||||||
|
label: labelIn.value, scope: scopeSel.value, kind: kindSel.value,
|
||||||
|
target: Number(targetIn.value), reward: rewardIn.value, is_main: mainChk.checked,
|
||||||
|
};
|
||||||
|
if (scopeSel.value === 'category') payload.category_id = Number(catSel.value);
|
||||||
|
if (kindSel.value !== 'session_count') payload.metric_id = Number(metricSel.value);
|
||||||
|
try { await api.addGoal(payload); closeModal(); renderGoals(view); toast('Goal added 🏆'); }
|
||||||
|
catch (e) { alert(e.message); }
|
||||||
|
} }, 'Save goal')));
|
||||||
|
openModal('New goal', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- SETTINGS ----------
|
||||||
|
function openSettings() {
|
||||||
|
const body = h('div', {});
|
||||||
|
body.append(h('h3', {}, 'Categories'));
|
||||||
|
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(', '))),
|
||||||
|
h('button', { class: 'btn-danger', onclick: async () => { await api.updateCategory(c.id, { archived: 1 }); await loadCategories(); renderList(); refreshActive(); } }, 'Archive')));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderList();
|
||||||
|
body.append(list);
|
||||||
|
|
||||||
|
// Add category form
|
||||||
|
const nameIn = h('input', { placeholder: 'New category name' });
|
||||||
|
const emojiIn = h('input', { placeholder: 'Emoji', value: '⚽', maxlength: '4', style: 'max-width:90px' });
|
||||||
|
const colorIn = h('input', { type: 'color', value: '#EF0107', style: 'max-width:60px;height:48px;padding:4px' });
|
||||||
|
const metricName = h('input', { placeholder: 'Metric (e.g. Minutes)', value: 'Minutes' });
|
||||||
|
const metricKind = h('select', {}, h('option', { value: 'duration' }, 'Time'), h('option', { value: 'count' }, 'Count'), h('option', { value: 'score' }, 'Score'));
|
||||||
|
body.append(h('div', { class: 'card', style: 'margin-top:16px' },
|
||||||
|
h('h3', {}, '➕ Add a category'),
|
||||||
|
h('div', { class: 'field' }, h('label', {}, 'Name'), nameIn),
|
||||||
|
h('div', { class: 'row', style: 'gap:10px' }, emojiIn, colorIn),
|
||||||
|
h('div', { class: 'field', style: 'margin-top:10px' }, h('label', {}, 'First metric'), h('div', { class: 'row', style: 'gap:10px' }, metricName, metricKind)),
|
||||||
|
h('button', { class: 'btn-primary', onclick: async () => {
|
||||||
|
if (!nameIn.value.trim()) return alert('Enter a name');
|
||||||
|
await api.addCategory({ name: nameIn.value.trim(), emoji: emojiIn.value || '⚽', color: colorIn.value,
|
||||||
|
metrics: [{ name: metricName.value || 'Value', unit: metricKind.value === 'duration' ? 'min' : '', kind: metricKind.value, step: metricKind.value === 'duration' ? 5 : 1 }] });
|
||||||
|
await loadCategories(); renderList(); nameIn.value = ''; refreshActive(); toast('Category added');
|
||||||
|
} }, 'Add category')));
|
||||||
|
|
||||||
|
// Password change
|
||||||
|
const cur = h('input', { type: 'password', placeholder: 'Current password' });
|
||||||
|
const nxt = h('input', { type: 'password', placeholder: 'New password' });
|
||||||
|
body.append(h('div', { class: 'card' },
|
||||||
|
h('h3', {}, '🔒 Change password'),
|
||||||
|
h('div', { class: 'field' }, cur), h('div', { class: 'field' }, nxt),
|
||||||
|
h('button', { class: 'btn-ghost', onclick: async () => {
|
||||||
|
try { await api.setPassword(cur.value, nxt.value); toast('Password changed'); cur.value = nxt.value = ''; }
|
||||||
|
catch (e) { alert(e.message); }
|
||||||
|
} }, 'Update password')));
|
||||||
|
|
||||||
|
body.append(h('button', { class: 'btn-danger', style: 'margin-top:8px', onclick: async () => { await api.logout(); location.href = '/login.html'; } }, 'Log out'));
|
||||||
|
openModal('⚙️ Settings', body, { onClose: refreshActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- router ----------
|
||||||
|
function refreshActive() { switchTab(state.tab); }
|
||||||
|
function refreshStatsIfActive() { if (state.tab === 'stats') switchTab('stats'); }
|
||||||
|
|
||||||
|
const view = document.getElementById('view');
|
||||||
|
function switchTab(tab) {
|
||||||
|
state.tab = tab;
|
||||||
|
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
|
view.innerHTML = '<p class="muted center">Loading…</p>';
|
||||||
|
if (tab === 'today') renderToday(view);
|
||||||
|
else if (tab === 'plan') renderPlan(view);
|
||||||
|
else if (tab === 'stats') renderStats(view, { catById });
|
||||||
|
else if (tab === 'goals') renderGoals(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab').forEach((t) => t.addEventListener('click', () => switchTab(t.dataset.tab)));
|
||||||
|
document.getElementById('settings-btn').addEventListener('click', openSettings);
|
||||||
|
|
||||||
|
// ---------- boot ----------
|
||||||
|
(async () => {
|
||||||
|
try { await api.me(); } catch { return; }
|
||||||
|
await loadCategories();
|
||||||
|
switchTab('today');
|
||||||
|
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { api } from '/js/api.js';
|
||||||
|
import { h, parseISO, isoOf, todayISO } from '/js/app.js';
|
||||||
|
|
||||||
|
let radarChart = null;
|
||||||
|
let lineChart = null;
|
||||||
|
|
||||||
|
const RED = '#EF0107';
|
||||||
|
|
||||||
|
export async function renderStats(view, { catById }) {
|
||||||
|
const stats = await api.stats();
|
||||||
|
if (radarChart) { radarChart.destroy(); radarChart = null; }
|
||||||
|
if (lineChart) { lineChart.destroy(); lineChart = null; }
|
||||||
|
view.innerHTML = '';
|
||||||
|
|
||||||
|
// ---- top numbers ----
|
||||||
|
view.append(h('div', { class: 'stat-grid' },
|
||||||
|
stat(stats.totalSessions, 'Sessions'),
|
||||||
|
stat(stats.totalDays, 'Training days'),
|
||||||
|
stat(`${stats.current}🔥`, 'Day streak'),
|
||||||
|
stat(stats.longest, 'Best streak')));
|
||||||
|
|
||||||
|
// ---- main goal thermometer ----
|
||||||
|
const main = stats.goals.find((g) => g.is_main);
|
||||||
|
if (main) view.append(thermometer(main));
|
||||||
|
|
||||||
|
// ---- heatmap ----
|
||||||
|
view.append(h('div', { class: 'card' },
|
||||||
|
h('h2', {}, '🔥 Training calendar'),
|
||||||
|
h('div', { class: 'heatmap' }, heatmapSvg(stats.heatmap)),
|
||||||
|
h('p', { class: 'muted small' }, 'Each square is a day. Brighter red = more training!')));
|
||||||
|
|
||||||
|
// ---- radar ----
|
||||||
|
const radarData = stats.radar.filter((r) => true);
|
||||||
|
if (radarData.length >= 3) {
|
||||||
|
const canvas = h('canvas', { height: 280 });
|
||||||
|
view.append(h('div', { class: 'card' }, h('h2', {}, '🕸️ Training spread'), canvas));
|
||||||
|
radarChart = new Chart(canvas, {
|
||||||
|
type: 'radar',
|
||||||
|
data: {
|
||||||
|
labels: radarData.map((r) => r.name),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Sessions', data: radarData.map((r) => r.sessions),
|
||||||
|
backgroundColor: 'rgba(239,1,7,.18)', borderColor: RED, borderWidth: 2,
|
||||||
|
pointBackgroundColor: RED,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: { r: { beginAtZero: true, ticks: { precision: 0 }, pointLabels: { font: { size: 11 } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- line chart with metric picker ----
|
||||||
|
const metrics = [];
|
||||||
|
for (const c of stats.radar.map((r) => catById(r.category_id)).filter(Boolean)) {
|
||||||
|
for (const m of c.metrics || []) {
|
||||||
|
if (stats.series[m.id]?.length) metrics.push({ ...m, catName: c.name, emoji: c.emoji });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (metrics.length) {
|
||||||
|
const sel = h('select', {}, metrics.map((m) => h('option', { value: m.id }, `${m.emoji} ${m.catName} — ${m.name}`)));
|
||||||
|
const canvas = h('canvas', { height: 260 });
|
||||||
|
const draw = () => {
|
||||||
|
const m = metrics.find((x) => x.id === Number(sel.value));
|
||||||
|
const pts = stats.series[m.id] || [];
|
||||||
|
if (lineChart) lineChart.destroy();
|
||||||
|
lineChart = new Chart(canvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: pts.map((p) => p.day.slice(5)),
|
||||||
|
datasets: [{
|
||||||
|
label: `${m.name}${m.unit ? ' (' + m.unit + ')' : ''}`,
|
||||||
|
data: pts.map((p) => p.value),
|
||||||
|
borderColor: RED, backgroundColor: 'rgba(239,1,7,.12)',
|
||||||
|
fill: true, tension: .3, pointRadius: 4, pointBackgroundColor: RED,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: { plugins: { legend: { display: true } }, scales: { y: { beginAtZero: true } } },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
sel.addEventListener('change', draw);
|
||||||
|
view.append(h('div', { class: 'card' },
|
||||||
|
h('h2', {}, '📈 Improvement over time'),
|
||||||
|
h('div', { class: 'field' }, sel),
|
||||||
|
canvas));
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- all goals ----
|
||||||
|
if (stats.goals.length) {
|
||||||
|
const card = h('div', { class: 'card' }, h('h2', {}, '🏆 Goal progress'));
|
||||||
|
for (const g of stats.goals) {
|
||||||
|
const done = g.pct >= 100;
|
||||||
|
const catName = g.category_id ? (catById(g.category_id)?.name || '') : 'Overall';
|
||||||
|
card.append(h('div', { class: 'goal' },
|
||||||
|
h('div', { class: 'top' },
|
||||||
|
h('span', { class: 'label' }, (g.is_main ? '⭐ ' : '') + (g.label || catName)),
|
||||||
|
h('span', { class: 'muted small' }, `${Math.round(g.current)} / ${g.target} (${g.pct}%)`)),
|
||||||
|
h('div', { class: 'bar' + (done ? ' done' : '') }, h('span', { style: `width:${g.pct}%` }))));
|
||||||
|
}
|
||||||
|
view.append(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = (num, lbl) => h('div', { class: 'stat' }, h('div', { class: 'num' }, String(num)), h('div', { class: 'lbl' }, lbl));
|
||||||
|
|
||||||
|
function thermometer(g) {
|
||||||
|
const pct = g.pct;
|
||||||
|
const tubeH = 180, fillH = Math.round((tubeH - 8) * pct / 100);
|
||||||
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(svgNS, 'svg');
|
||||||
|
svg.setAttribute('viewBox', '0 0 46 200'); svg.setAttribute('width', '46'); svg.setAttribute('height', '200');
|
||||||
|
svg.innerHTML = `
|
||||||
|
<rect x="14" y="6" width="18" height="${tubeH}" rx="9" fill="#f0f0f0" stroke="#ddd"/>
|
||||||
|
<rect x="14" y="${6 + (tubeH - 4 - fillH)}" width="18" height="${fillH + 4}" rx="9" fill="${RED}"/>
|
||||||
|
<circle cx="23" cy="188" r="14" fill="${RED}"/>
|
||||||
|
<circle cx="23" cy="188" r="7" fill="#fff" opacity=".35"/>`;
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', {}, '✈️ ' + (g.label || 'Main goal')),
|
||||||
|
h('div', { class: 'thermo-wrap' },
|
||||||
|
svg,
|
||||||
|
h('div', { class: 'reward' },
|
||||||
|
h('div', { class: 'pct' }, `${pct}%`),
|
||||||
|
h('div', {}, `${Math.round(g.current)} of ${g.target}`),
|
||||||
|
g.reward && h('p', { class: 'muted', style: 'margin-top:8px' }, '🎁 ' + g.reward))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatmapSvg(rows) {
|
||||||
|
const counts = new Map(rows.map((r) => [r.day, r.count]));
|
||||||
|
const max = Math.max(1, ...rows.map((r) => r.count));
|
||||||
|
const weeks = 27, cell = 14, gap = 3;
|
||||||
|
const end = parseISO(todayISO());
|
||||||
|
const start = new Date(end); start.setDate(start.getDate() - weeks * 7);
|
||||||
|
// back up to Sunday
|
||||||
|
start.setDate(start.getDate() - start.getDay());
|
||||||
|
|
||||||
|
const shade = (n) => {
|
||||||
|
if (!n) return '#ebedf3';
|
||||||
|
const t = n / max; // 0..1
|
||||||
|
const light = 88 - Math.round(t * 50); // 88% -> 38%
|
||||||
|
return `hsl(2 90% ${light}%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const svgNS = 'http://www.w3.org/2000/svg';
|
||||||
|
const w = weeks * (cell + gap) + 24, hgt = 7 * (cell + gap) + 16;
|
||||||
|
const svg = document.createElementNS(svgNS, 'svg');
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${w} ${hgt}`);
|
||||||
|
svg.setAttribute('width', w); svg.setAttribute('height', hgt);
|
||||||
|
|
||||||
|
let parts = '';
|
||||||
|
const MON = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'];
|
||||||
|
const d = new Date(start);
|
||||||
|
let lastMonth = -1;
|
||||||
|
for (let col = 0; col <= weeks; col++) {
|
||||||
|
for (let r = 0; r < 7; r++) {
|
||||||
|
const iso = isoOf(d);
|
||||||
|
if (d <= end) {
|
||||||
|
const x = col * (cell + gap);
|
||||||
|
const y = r * (cell + gap) + 12;
|
||||||
|
parts += `<rect class="heat-cell" x="${x}" y="${y}" width="${cell}" height="${cell}" rx="3" fill="${shade(counts.get(iso) || 0)}"><title>${iso}: ${counts.get(iso) || 0}</title></rect>`;
|
||||||
|
if (r === 0 && d.getMonth() !== lastMonth) {
|
||||||
|
parts += `<text x="${x}" y="8" font-size="9" fill="#9aa3b2">${MON[d.getMonth()]}</text>`;
|
||||||
|
lastMonth = d.getMonth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svg.innerHTML = parts;
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#EF0107" />
|
||||||
|
<title>Premier Gunner — Login</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<link rel="icon" href="/icons/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="/css/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<main class="login-card">
|
||||||
|
<img src="/icons/logo.svg" alt="Premier Gunner" class="login-logo" width="120" height="120" />
|
||||||
|
<h1>Premier Gunner</h1>
|
||||||
|
<p class="tagline">Train. Track. Road to London. ✈️</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<input type="password" id="password" placeholder="Enter your password" autocomplete="current-password" required />
|
||||||
|
<button type="submit" class="btn-primary big">Let's go ⚽</button>
|
||||||
|
<p id="error" class="error" hidden></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script type="module">
|
||||||
|
import { api } from '/js/api.js';
|
||||||
|
const form = document.getElementById('login-form');
|
||||||
|
const err = document.getElementById('error');
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
err.hidden = true;
|
||||||
|
try {
|
||||||
|
await api.login(document.getElementById('password').value);
|
||||||
|
location.href = '/';
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Premier Gunner",
|
||||||
|
"short_name": "Gunner",
|
||||||
|
"description": "Soccer training tracker — train, track, and chase the Road to London.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#EF0107",
|
||||||
|
"theme_color": "#EF0107",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const CACHE = 'premier-gunner-v1';
|
||||||
|
const SHELL = [
|
||||||
|
'/', '/index.html', '/login.html',
|
||||||
|
'/css/styles.css',
|
||||||
|
'/js/app.js', '/js/api.js', '/js/dashboard.js',
|
||||||
|
'/vendor/chart.umd.min.js',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/icons/logo.svg', '/icons/favicon.svg',
|
||||||
|
'/icons/icon-192.png', '/icons/icon-512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (e) => {
|
||||||
|
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (e) => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const url = new URL(e.request.url);
|
||||||
|
if (e.request.method !== 'GET') return;
|
||||||
|
// Never cache the API — always go to network so data stays fresh and auth works.
|
||||||
|
if (url.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(e.request).then((cached) => {
|
||||||
|
const network = fetch(e.request).then((res) => {
|
||||||
|
if (res.ok && url.origin === location.origin) {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(e.request, copy));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}).catch(() => cached);
|
||||||
|
return cached || network;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitmodules
|
||||||
|
node_modules
|
||||||
|
startos
|
||||||
|
javascript
|
||||||
|
ncc-cache
|
||||||
|
assets
|
||||||
|
docker-images
|
||||||
|
*.s9pk
|
||||||
|
app/node_modules
|
||||||
|
app/data
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
*.s9pk
|
||||||
|
startos/*.js
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
docker-images
|
||||||
|
javascript
|
||||||
|
ncc-cache
|
||||||
|
app/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
You are working in a StartOS service-package repository — a repo that builds a `.s9pk` for installation on StartOS.
|
||||||
|
|
||||||
|
**Before doing anything in this repo, read [CONTRIBUTING.md](./CONTRIBUTING.md) and every document it links to.** That covers what this package is, how it's built, how it ships, and the conventions to follow. Do not begin work until you have read them all.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Keep these in sync
|
||||||
|
|
||||||
|
- **[`README.md`](./README.md)** — what this package is and how it's built (image, volumes, interfaces). Technical reference for developers and AI assistants.
|
||||||
|
- **[`instructions.md`](./instructions.md)** — the user-facing instructions packed into the `.s9pk` and shown on the **Instructions** tab in StartOS, for the person running the service.
|
||||||
|
- **[`TODO.md`](./TODO.md)** — pending work on this package.
|
||||||
|
|
||||||
|
**Read all three before starting any work.** Any code change that affects user-visible behavior must update `README.md` and `instructions.md` in the same change; add to `TODO.md` when you defer work, and remove items when complete. Content rules: [Writing READMEs](https://docs.start9.com/packaging/writing-readmes.html), [Writing Instructions](https://docs.start9.com/packaging/writing-instructions.html).
|
||||||
|
|
||||||
|
## Environment setup
|
||||||
|
|
||||||
|
See [Environment Setup](https://docs.start9.com/packaging/environment-setup.html)
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci # install dependencies
|
||||||
|
make # build the universal .s9pk
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of build options, see [Makefile](https://docs.start9.com/packaging/makefile.html).
|
||||||
|
|
||||||
|
## Updating the upstream version
|
||||||
|
|
||||||
|
1. Apply the upstream bump per [UPDATING.md](./UPDATING.md).
|
||||||
|
2. Update `version` and `releaseNotes` in `startos/versions/current.ts` — the latest version always lives in that file, so an in-place edit is all most bumps need. A new file is spun off only when the bump requires a migration — see [Versions](https://docs.start9.com/packaging/versions.html).
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Three workflows under `.github/workflows/` wrap reusable workflows in [`start9labs/shared-workflows`](https://github.com/Start9Labs/shared-workflows):
|
||||||
|
|
||||||
|
- **`build.yml`** — on PR, builds the `.s9pk` and uploads per-arch artifacts for sideload testing.
|
||||||
|
- **`release.yml`** — on `v*` tag, builds per arch and publishes to the test registry.
|
||||||
|
- **`tagAndRelease.yml`** — on push to `master`, tags `v<version>` and runs `release.yml`, skipping if already in production.
|
||||||
|
|
||||||
|
Promotion to `beta` and `prod` is a separate, manual step.
|
||||||
|
|
||||||
|
## How to contribute
|
||||||
|
|
||||||
|
1. Fork the repository and create a branch from `master`.
|
||||||
|
2. Make your changes — including the doc updates above.
|
||||||
|
3. Open a pull request to `master`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Build stage: compile native deps (better-sqlite3) with toolchain present.
|
||||||
|
FROM node:22-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY app/package.json app/package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY app/ ./
|
||||||
|
|
||||||
|
# Runtime stage: slim image with prebuilt node_modules copied in.
|
||||||
|
FROM node:22-bookworm-slim
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=build /app ./
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Start9 Labs
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
ARCHES := x86 arm
|
||||||
|
# overrides to s9pk.mk must precede the include statement
|
||||||
|
|
||||||
|
# Build x86_64 + aarch64 only (matches manifest images.arch).
|
||||||
|
# The default goal vendors the Node app into ./app (the Docker build context)
|
||||||
|
# before delegating to s9pk.mk's build matrix.
|
||||||
|
.DEFAULT_GOAL := default
|
||||||
|
.PHONY: default prep clean-app
|
||||||
|
|
||||||
|
# Root of the Premier Gunner Node app (this s9pk lives in ./s9pk).
|
||||||
|
APP_SRC := ..
|
||||||
|
|
||||||
|
default: prep
|
||||||
|
@$(MAKE) --no-print-directory all
|
||||||
|
|
||||||
|
# Sync app sources into ./app so the Dockerfile can COPY them. node_modules and
|
||||||
|
# runtime data are excluded; the Dockerfile reinstalls deps via `npm ci`.
|
||||||
|
prep:
|
||||||
|
@echo " Vendoring Node app into ./app ..."
|
||||||
|
@rm -rf app
|
||||||
|
@mkdir -p app
|
||||||
|
@rsync -a \
|
||||||
|
--exclude='node_modules/' \
|
||||||
|
--exclude='data/' \
|
||||||
|
--exclude='s9pk/' \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='.DS_Store' \
|
||||||
|
--exclude='.env' \
|
||||||
|
"$(APP_SRC)/package.json" \
|
||||||
|
"$(APP_SRC)/package-lock.json" \
|
||||||
|
"$(APP_SRC)/src" \
|
||||||
|
"$(APP_SRC)/public" \
|
||||||
|
app/
|
||||||
|
|
||||||
|
clean-app:
|
||||||
|
rm -rf app
|
||||||
|
|
||||||
|
include s9pk.mk
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="icon.svg" alt="Hello World Logo" width="21%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
# Hello World on StartOS
|
||||||
|
|
||||||
|
> **Upstream repo:** <https://github.com/Start9Labs/hello-world>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Image and Container Runtime](#image-and-container-runtime)
|
||||||
|
- [Volume and Data Layout](#volume-and-data-layout)
|
||||||
|
- [Installation and First-Run Flow](#installation-and-first-run-flow)
|
||||||
|
- [Configuration Management](#configuration-management)
|
||||||
|
- [Network Access and Interfaces](#network-access-and-interfaces)
|
||||||
|
- [Actions (StartOS UI)](#actions-startos-ui)
|
||||||
|
- [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)
|
||||||
|
- [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` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Volume and Data Layout
|
||||||
|
|
||||||
|
| Volume | Mount Point | Purpose |
|
||||||
|
| ------ | ----------- | --------------- |
|
||||||
|
| `main` | `/data` | Persistent data |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation and First-Run Flow
|
||||||
|
|
||||||
|
No special setup. Install and start — the web page is immediately available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Management
|
||||||
|
|
||||||
|
No configurable settings. The service runs with no user-facing configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Network Access and Interfaces
|
||||||
|
|
||||||
|
| Interface | Port | Protocol | Purpose |
|
||||||
|
| --------- | ---- | -------- | -------------------- |
|
||||||
|
| Web UI | 80 | HTTP | Hello World web page |
|
||||||
|
|
||||||
|
**Access methods:**
|
||||||
|
|
||||||
|
- LAN IP with unique port
|
||||||
|
- `<hostname>.local` with unique port
|
||||||
|
- Tor `.onion` address
|
||||||
|
- Custom domains (if configured)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Actions (StartOS UI)
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups and Restore
|
||||||
|
|
||||||
|
**Included in backup:**
|
||||||
|
|
||||||
|
- `main` volume
|
||||||
|
|
||||||
|
**Restore behavior:** Volume is fully restored 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" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Limitations and Differences
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference for AI Consumers
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
package_id: hello-world
|
||||||
|
image: ghcr.io/start9labs/hello-world
|
||||||
|
architectures: [x86_64, aarch64, riscv64]
|
||||||
|
volumes:
|
||||||
|
main: /data
|
||||||
|
ports:
|
||||||
|
ui: 80
|
||||||
|
dependencies: none
|
||||||
|
startos_managed_env_vars: none
|
||||||
|
actions: none
|
||||||
|
```
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# TODO
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Updating the upstream version
|
||||||
|
|
||||||
|
This package wraps Start9 Labs' own [hello-world](https://github.com/Start9Labs/hello-world) source, which we build and publish ourselves as `ghcr.io/start9labs/hello-world`. "Upstream" here means that source repo, not the image namespace.
|
||||||
|
|
||||||
|
## Determining the upstream version
|
||||||
|
|
||||||
|
- **hello-world** ([Start9Labs/hello-world](https://github.com/Start9Labs/hello-world)) — fetch the latest release tag:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gh release view -R Start9Labs/hello-world --json tagName -q .tagName
|
||||||
|
```
|
||||||
|
|
||||||
|
The current pin lives in `startos/manifest/index.ts` at `images['hello-world'].source.dockerTag` (the version after the `:` in `ghcr.io/start9labs/hello-world:<version>`).
|
||||||
|
|
||||||
|
## Applying the bump
|
||||||
|
|
||||||
|
- Bump `dockerTag` in `startos/manifest/index.ts` to `ghcr.io/start9labs/hello-world:<new version>` (drop the leading `v` from the release tag).
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ff1f25"/>
|
||||||
|
<stop offset="1" stop-color="#c50006"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="112" fill="url(#bg)"/>
|
||||||
|
<!-- cannon barrel -->
|
||||||
|
<g transform="rotate(-18 256 300)">
|
||||||
|
<rect x="150" y="232" width="230" height="58" rx="14" fill="#fff"/>
|
||||||
|
<rect x="360" y="226" width="34" height="70" rx="8" fill="#fff"/>
|
||||||
|
<circle cx="168" cy="296" r="34" fill="#fff"/>
|
||||||
|
<rect x="150" y="296" width="70" height="40" rx="10" fill="#fff"/>
|
||||||
|
</g>
|
||||||
|
<!-- wheel -->
|
||||||
|
<circle cx="186" cy="350" r="46" fill="#023474" stroke="#fff" stroke-width="10"/>
|
||||||
|
<circle cx="186" cy="350" r="8" fill="#fff"/>
|
||||||
|
<!-- soccer ball as cannonball -->
|
||||||
|
<g transform="translate(392 196)">
|
||||||
|
<circle r="46" fill="#fff" stroke="#15181f" stroke-width="3"/>
|
||||||
|
<path d="M0 -22 L21 -7 L13 18 L-13 18 L-21 -7 Z" fill="#15181f"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
# Hello World
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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 Hello World's **Dashboard** tab.
|
||||||
|
2. Click the **Web UI** interface to open the served page in your browser.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
{
|
||||||
|
"name": "hello-world-startos",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "hello-world-startos",
|
||||||
|
"dependencies": {
|
||||||
|
"@start9labs/start-sdk": "1.5.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.19.0",
|
||||||
|
"@vercel/ncc": "^0.38.4",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@iarna/toml": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@noble/curves": {
|
||||||
|
"version": "1.9.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||||
|
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "1.8.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "1.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
|
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.21.3 || >=16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nodable/entities": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@start9labs/start-sdk": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-OyHe9J6hMvyA5ZavcLkxdVQvZcuTH9J9kagV6NDI83eAG/YpJFIq62gP/n/2PPNdHWwNSXVQmSwnsvsV8Gyg+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@iarna/toml": "^3.0.0",
|
||||||
|
"@noble/curves": "^1.9.7",
|
||||||
|
"@noble/hashes": "^1.8.0",
|
||||||
|
"@types/ini": "^4.1.1",
|
||||||
|
"deep-equality-data-structures": "^2.0.0",
|
||||||
|
"fast-xml-parser": "~5.7.0",
|
||||||
|
"ini": "^5.0.0",
|
||||||
|
"isomorphic-fetch": "^3.0.0",
|
||||||
|
"mime": "^4.1.0",
|
||||||
|
"yaml": "^2.8.3",
|
||||||
|
"zod": "4.3.6",
|
||||||
|
"zod-deep-partial": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ini": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vercel/ncc": {
|
||||||
|
"version": "0.38.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
|
||||||
|
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"ncc": "dist/ncc/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/deep-equality-data-structures": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-hash": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-builder": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"xml-naming": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz",
|
||||||
|
"integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodable/entities": "^2.1.0",
|
||||||
|
"fast-xml-builder": "^1.1.7",
|
||||||
|
"path-expression-matcher": "^1.5.0",
|
||||||
|
"strnum": "^2.2.3"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ini": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isomorphic-fetch": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-fetch": "^2.6.1",
|
||||||
|
"whatwg-fetch": "^3.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-expression-matcher": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-fetch": {
|
||||||
|
"version": "3.6.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
||||||
|
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xml-naming": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod-deep-partial": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^4.1.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "hello-world-startos",
|
||||||
|
"scripts": {
|
||||||
|
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
|
||||||
|
"prettier": "prettier --write startos",
|
||||||
|
"check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@start9labs/start-sdk": "1.5.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.19.0",
|
||||||
|
"@vercel/ncc": "^0.38.4",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
},
|
||||||
|
"prettier": {
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# ** Plumbing. DO NOT EDIT **.
|
||||||
|
# This file is imported by ./Makefile. Make edits there
|
||||||
|
|
||||||
|
PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts)
|
||||||
|
INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null)
|
||||||
|
# Resolve the actual git dir so this works inside git worktrees, where .git
|
||||||
|
# is a file pointing at <main>/.git/worktrees/<name> rather than a directory.
|
||||||
|
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
|
||||||
|
GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index)
|
||||||
|
ARCHES ?= x86 arm riscv
|
||||||
|
# TARGETS is the list of leaf make-targets the build matrix fans out over.
|
||||||
|
# Defaults to the arches; variant packages override (e.g. immich, ollama, vllm
|
||||||
|
# set this to a list of variant or variant-arch leaf targets).
|
||||||
|
TARGETS ?= $(ARCHES)
|
||||||
|
ifdef VARIANT
|
||||||
|
BASE_NAME := $(PACKAGE_ID)_$(VARIANT)
|
||||||
|
else
|
||||||
|
BASE_NAME := $(PACKAGE_ID)
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
|
.SECONDARY:
|
||||||
|
|
||||||
|
define SUMMARY
|
||||||
|
@manifest=$$(start-cli s9pk inspect $(1) manifest); \
|
||||||
|
size=$$(du -h $(1) | awk '{print $$1}'); \
|
||||||
|
title=$$(printf '%s' "$$manifest" | jq -r .title); \
|
||||||
|
version=$$(printf '%s' "$$manifest" | jq -r .version); \
|
||||||
|
arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \
|
||||||
|
sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \
|
||||||
|
gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \
|
||||||
|
printf "\n"; \
|
||||||
|
printf "\033[1;32m✅ Build Complete!\033[0m\n"; \
|
||||||
|
printf "\n"; \
|
||||||
|
printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \
|
||||||
|
printf "───────────────────────────────\n"; \
|
||||||
|
printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \
|
||||||
|
printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \
|
||||||
|
printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \
|
||||||
|
printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \
|
||||||
|
printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \
|
||||||
|
echo ""
|
||||||
|
endef
|
||||||
|
|
||||||
|
all: $(TARGETS)
|
||||||
|
|
||||||
|
arches: $(ARCHES)
|
||||||
|
|
||||||
|
# Generic make-variable introspection. Used by the release workflow to
|
||||||
|
# read $(TARGETS) and fan out one matrix runner per target. `make -s
|
||||||
|
# print-TARGETS` echoes the list with no other output.
|
||||||
|
print-%:
|
||||||
|
@echo '$($*)'
|
||||||
|
|
||||||
|
universal: $(BASE_NAME).s9pk
|
||||||
|
$(call SUMMARY,$<)
|
||||||
|
|
||||||
|
arch/%: $(BASE_NAME)_%.s9pk
|
||||||
|
$(call SUMMARY,$<)
|
||||||
|
|
||||||
|
x86 x86_64: arch/x86_64
|
||||||
|
arm arm64 aarch64: arch/aarch64
|
||||||
|
riscv riscv64: arch/riscv64
|
||||||
|
|
||||||
|
$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS)
|
||||||
|
@$(MAKE) --no-print-directory ingredients
|
||||||
|
@echo " Packing '$@'..."
|
||||||
|
start-cli s9pk pack -o $@
|
||||||
|
|
||||||
|
$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS)
|
||||||
|
@$(MAKE) --no-print-directory ingredients
|
||||||
|
@echo " Packing '$@'..."
|
||||||
|
start-cli s9pk pack --arch=$* -o $@
|
||||||
|
|
||||||
|
ingredients: $(INGREDIENTS)
|
||||||
|
@echo " Re-evaluating ingredients..."
|
||||||
|
|
||||||
|
install: | check-deps check-init
|
||||||
|
@HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \
|
||||||
|
if [ -z "$$HOST" ]; then \
|
||||||
|
echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \
|
||||||
|
if [ -z "$$S9PK" ]; then \
|
||||||
|
echo "Error: No .s9pk file found. Run 'make' first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \
|
||||||
|
start-cli package install -s "$$S9PK"
|
||||||
|
|
||||||
|
publish: | all
|
||||||
|
@REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \
|
||||||
|
if [ -z "$$REGISTRY" ]; then \
|
||||||
|
echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \
|
||||||
|
if [ -z "$$S3BASE" ]; then \
|
||||||
|
echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
command -v s3cmd >/dev/null || \
|
||||||
|
(echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \
|
||||||
|
printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \
|
||||||
|
for s9pk in *.s9pk; do \
|
||||||
|
age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \
|
||||||
|
if [ "$$age" -gt 3600 ]; then \
|
||||||
|
printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \
|
||||||
|
read -r ans; \
|
||||||
|
case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \
|
||||||
|
fi; \
|
||||||
|
start-cli s9pk publish "$$s9pk"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
check-deps:
|
||||||
|
@command -v start-cli >/dev/null || \
|
||||||
|
(echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1)
|
||||||
|
@command -v npm >/dev/null || \
|
||||||
|
(echo "Error: npm not found. Please install Node.js and npm." && exit 1)
|
||||||
|
|
||||||
|
check-init:
|
||||||
|
@if [ ! -f ~/.startos/developer.key.pem ]; then \
|
||||||
|
echo "Initializing StartOS developer environment..."; \
|
||||||
|
start-cli init-key; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules
|
||||||
|
npm run check
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
node_modules: package-lock.json package.json
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning up build artifacts..."
|
||||||
|
@rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { store } from '../fileModels/store'
|
||||||
|
import { i18n } from '../i18n'
|
||||||
|
import { sdk } from '../sdk'
|
||||||
|
|
||||||
|
const { InputSpec, Value } = sdk
|
||||||
|
|
||||||
|
const inputSpec = InputSpec.of({
|
||||||
|
password: Value.text({
|
||||||
|
name: i18n('Password'),
|
||||||
|
description: i18n(
|
||||||
|
'The password Gunner types on the login screen (at least 4 characters)',
|
||||||
|
),
|
||||||
|
required: true,
|
||||||
|
default: null,
|
||||||
|
masked: true,
|
||||||
|
minLength: 4,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setPassword = sdk.Action.withInput(
|
||||||
|
'set-password',
|
||||||
|
|
||||||
|
async ({ effects }) => ({
|
||||||
|
name: i18n('Set Login Password'),
|
||||||
|
description: i18n('Set the password Gunner uses to log in to Premier Gunner'),
|
||||||
|
warning: null,
|
||||||
|
allowedStatuses: 'any',
|
||||||
|
group: null,
|
||||||
|
visibility: 'enabled',
|
||||||
|
}),
|
||||||
|
|
||||||
|
inputSpec,
|
||||||
|
|
||||||
|
async ({ effects }) => {
|
||||||
|
const password = await store.read((s) => s.password).const(effects)
|
||||||
|
return { password: password ?? undefined }
|
||||||
|
},
|
||||||
|
|
||||||
|
async ({ effects, input }) => {
|
||||||
|
await store.merge(effects, { password: input.password })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export const actions = sdk.Actions.of().addAction(setPassword)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { sdk } from './sdk'
|
||||||
|
|
||||||
|
export const { createBackup, restoreInit } = sdk.setupBackups(
|
||||||
|
async ({ effects }) => sdk.Backups.ofVolumes('main'),
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { sdk } from './sdk'
|
||||||
|
|
||||||
|
export const setDependencies = sdk.setupDependencies(
|
||||||
|
async ({ effects }) => ({}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Use the `/fileModels` directory to create separate `.ts` files to represent underlying files used by you package. The exported `FileModels` afford a convenient and type safe way to read amd write to the underlying files, as well as react to changes.
|
||||||
|
|
||||||
|
Supported file formats are `.yaml`, `.toml`, `.json`, `.env`, `.ini`, and `.txt`. For alternative file formats, you can use the `raw` method and provide custom serialization and parser functions.
|
||||||
|
|
||||||
|
It is common for packages to use a `store.json.ts` FileModel as a convenient place to persist arbitrary data that are needed by the package but are _not_ persisted by the upstream service. For example, you might use store.json to persist startup flags or login credentials.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { FileHelper, z } from '@start9labs/start-sdk'
|
||||||
|
import { sdk } from '../sdk'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists package data that the upstream app does not store itself.
|
||||||
|
* Here: the login password Premier Gunner injects into the app as PG_PASSWORD.
|
||||||
|
*/
|
||||||
|
export const store = FileHelper.json(
|
||||||
|
{ base: sdk.volumes.main, subpath: 'store.json' },
|
||||||
|
z.object({
|
||||||
|
password: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const DEFAULT_LANG = 'en_US'
|
||||||
|
|
||||||
|
const dict = {
|
||||||
|
// main.ts
|
||||||
|
'Starting Premier Gunner!': 0,
|
||||||
|
'Web Interface': 1,
|
||||||
|
'The web interface is ready': 2,
|
||||||
|
'The web interface is not ready': 3,
|
||||||
|
|
||||||
|
// interfaces.ts
|
||||||
|
'Premier Gunner': 4,
|
||||||
|
'The Premier Gunner training tracker web app': 5,
|
||||||
|
|
||||||
|
// actions
|
||||||
|
'Set Login Password': 6,
|
||||||
|
'Set the password Gunner uses to log in to Premier Gunner': 7,
|
||||||
|
'Password': 8,
|
||||||
|
'The password Gunner types on the login screen (at least 4 characters)': 9,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plumbing. DO NOT EDIT.
|
||||||
|
*/
|
||||||
|
export type I18nKey = keyof typeof dict
|
||||||
|
export type LangDict = Record<(typeof dict)[I18nKey], string>
|
||||||
|
export default dict
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { LangDict } from './default'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
es_ES: {
|
||||||
|
0: '¡Iniciando Premier Gunner!',
|
||||||
|
1: 'Interfaz web',
|
||||||
|
2: 'La interfaz web está lista',
|
||||||
|
3: 'La interfaz web no está lista',
|
||||||
|
4: 'Premier Gunner',
|
||||||
|
5: 'La aplicación web de seguimiento de entrenamiento Premier Gunner',
|
||||||
|
6: 'Establecer contraseña de acceso',
|
||||||
|
7: 'Establece la contraseña que Gunner usa para iniciar sesión en Premier Gunner',
|
||||||
|
8: 'Contraseña',
|
||||||
|
9: 'La contraseña que Gunner escribe en la pantalla de inicio de sesión (al menos 4 caracteres)',
|
||||||
|
},
|
||||||
|
de_DE: {
|
||||||
|
0: 'Starte Premier Gunner!',
|
||||||
|
1: 'Weboberfläche',
|
||||||
|
2: 'Die Weboberfläche ist bereit',
|
||||||
|
3: 'Die Weboberfläche ist nicht bereit',
|
||||||
|
4: 'Premier Gunner',
|
||||||
|
5: 'Die Premier Gunner Trainings-Tracker-Web-App',
|
||||||
|
6: 'Anmeldepasswort festlegen',
|
||||||
|
7: 'Lege das Passwort fest, mit dem Gunner sich bei Premier Gunner anmeldet',
|
||||||
|
8: 'Passwort',
|
||||||
|
9: 'Das Passwort, das Gunner auf dem Anmeldebildschirm eingibt (mindestens 4 Zeichen)',
|
||||||
|
},
|
||||||
|
pl_PL: {
|
||||||
|
0: 'Uruchamianie Premier Gunner!',
|
||||||
|
1: 'Interfejs webowy',
|
||||||
|
2: 'Interfejs webowy jest gotowy',
|
||||||
|
3: 'Interfejs webowy nie jest gotowy',
|
||||||
|
4: 'Premier Gunner',
|
||||||
|
5: 'Aplikacja webowa do śledzenia treningów Premier Gunner',
|
||||||
|
6: 'Ustaw hasło logowania',
|
||||||
|
7: 'Ustaw hasło, którego Gunner używa do logowania w Premier Gunner',
|
||||||
|
8: 'Hasło',
|
||||||
|
9: 'Hasło, które Gunner wpisuje na ekranie logowania (co najmniej 4 znaki)',
|
||||||
|
},
|
||||||
|
fr_FR: {
|
||||||
|
0: 'Démarrage de Premier Gunner !',
|
||||||
|
1: 'Interface web',
|
||||||
|
2: "L'interface web est prête",
|
||||||
|
3: "L'interface web n'est pas prête",
|
||||||
|
4: 'Premier Gunner',
|
||||||
|
5: "L'application web de suivi d'entraînement Premier Gunner",
|
||||||
|
6: 'Définir le mot de passe de connexion',
|
||||||
|
7: 'Définissez le mot de passe que Gunner utilise pour se connecter à Premier Gunner',
|
||||||
|
8: 'Mot de passe',
|
||||||
|
9: "Le mot de passe que Gunner saisit sur l'écran de connexion (au moins 4 caractères)",
|
||||||
|
},
|
||||||
|
} satisfies Record<string, LangDict>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Plumbing. DO NOT EDIT this file.
|
||||||
|
*/
|
||||||
|
import { setupI18n } from '@start9labs/start-sdk'
|
||||||
|
import defaultDict, { DEFAULT_LANG } from './dictionaries/default'
|
||||||
|
import translations from './dictionaries/translations'
|
||||||
|
|
||||||
|
export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Plumbing. DO NOT EDIT.
|
||||||
|
*/
|
||||||
|
export { createBackup } from './backups'
|
||||||
|
export { main } from './main'
|
||||||
|
export { init, uninit } from './init'
|
||||||
|
export { actions } from './actions'
|
||||||
|
import { buildManifest } from '@start9labs/start-sdk'
|
||||||
|
import { manifest as sdkManifest } from './manifest'
|
||||||
|
import { versionGraph } from './versions'
|
||||||
|
export const manifest = buildManifest(versionGraph, sdkManifest)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { sdk } from '../sdk'
|
||||||
|
import { setDependencies } from '../dependencies'
|
||||||
|
import { setInterfaces } from '../interfaces'
|
||||||
|
import { versionGraph } from '../versions'
|
||||||
|
import { actions } from '../actions'
|
||||||
|
import { restoreInit } from '../backups'
|
||||||
|
|
||||||
|
export const init = sdk.setupInit(
|
||||||
|
restoreInit,
|
||||||
|
versionGraph,
|
||||||
|
setInterfaces,
|
||||||
|
setDependencies,
|
||||||
|
actions,
|
||||||
|
)
|
||||||
|
|
||||||
|
export const uninit = sdk.setupUninit(versionGraph)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { i18n } from './i18n'
|
||||||
|
import { sdk } from './sdk'
|
||||||
|
import { uiPort } from './utils'
|
||||||
|
|
||||||
|
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||||
|
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
|
||||||
|
const uiMultiOrigin = await uiMulti.bindPort(uiPort, {
|
||||||
|
protocol: 'http',
|
||||||
|
})
|
||||||
|
const ui = sdk.createInterface(effects, {
|
||||||
|
name: i18n('Premier Gunner'),
|
||||||
|
id: 'ui',
|
||||||
|
description: i18n('The Premier Gunner training tracker web app'),
|
||||||
|
type: 'ui',
|
||||||
|
masked: false,
|
||||||
|
schemeOverride: null,
|
||||||
|
username: null,
|
||||||
|
path: '',
|
||||||
|
query: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const uiReceipt = await uiMultiOrigin.export([ui])
|
||||||
|
|
||||||
|
return [uiReceipt]
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { store } from './fileModels/store'
|
||||||
|
import { i18n } from './i18n'
|
||||||
|
import { sdk } from './sdk'
|
||||||
|
import { uiPort } from './utils'
|
||||||
|
|
||||||
|
export const main = sdk.setupMain(async ({ effects }) => {
|
||||||
|
console.info(i18n('Starting Premier Gunner!'))
|
||||||
|
|
||||||
|
// The login password lives in store.json. Reading it with `.const` makes the
|
||||||
|
// daemon restart whenever it changes (e.g. via the "Set Login Password" action),
|
||||||
|
// so PG_PASSWORD stays authoritative on every boot.
|
||||||
|
const password = await store.read((s) => s.password).const(effects)
|
||||||
|
|
||||||
|
return sdk.Daemons.of(effects).addDaemon('primary', {
|
||||||
|
subcontainer: await sdk.SubContainer.of(
|
||||||
|
effects,
|
||||||
|
{ imageId: 'premier-gunner' },
|
||||||
|
sdk.Mounts.of().mountVolume({
|
||||||
|
volumeId: 'main',
|
||||||
|
subpath: null,
|
||||||
|
mountpoint: '/data',
|
||||||
|
readonly: false,
|
||||||
|
}),
|
||||||
|
'premier-gunner-sub',
|
||||||
|
),
|
||||||
|
exec: {
|
||||||
|
command: ['node', 'src/server.js'],
|
||||||
|
cwd: '/app',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PG_HOST: '0.0.0.0',
|
||||||
|
PG_PORT: String(uiPort),
|
||||||
|
PG_DATA_DIR: '/data',
|
||||||
|
...(password ? { PG_PASSWORD: password } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
display: i18n('Web Interface'),
|
||||||
|
fn: () =>
|
||||||
|
sdk.healthCheck.checkPortListening(effects, uiPort, {
|
||||||
|
successMessage: i18n('The web interface is ready'),
|
||||||
|
errorMessage: i18n('The web interface is not ready'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
requires: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const short = {
|
||||||
|
en_US: 'A kid-friendly soccer training tracker',
|
||||||
|
es_ES: 'Un rastreador de entrenamiento de fútbol para niños',
|
||||||
|
de_DE: 'Ein kinderfreundlicher Fußball-Trainings-Tracker',
|
||||||
|
pl_PL: 'Przyjazny dzieciom tracker treningu piłkarskiego',
|
||||||
|
fr_FR: "Un suivi d'entraînement de football adapté aux enfants",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const long = {
|
||||||
|
en_US:
|
||||||
|
'Premier Gunner is a kid-friendly, mobile-friendly soccer training tracker. Log daily training across categories, plan future sessions, set goals, and watch progress climb toward the big reward.',
|
||||||
|
es_ES:
|
||||||
|
'Premier Gunner es un rastreador de entrenamiento de fútbol adaptado a niños y móviles. Registra el entrenamiento diario por categorías, planifica sesiones futuras, fija metas y observa el progreso hacia la gran recompensa.',
|
||||||
|
de_DE:
|
||||||
|
'Premier Gunner ist ein kinder- und mobilfreundlicher Fußball-Trainings-Tracker. Erfasse das tägliche Training nach Kategorien, plane zukünftige Einheiten, setze Ziele und verfolge den Fortschritt zur großen Belohnung.',
|
||||||
|
pl_PL:
|
||||||
|
'Premier Gunner to przyjazny dzieciom i urządzeniom mobilnym tracker treningu piłkarskiego. Zapisuj codzienny trening w kategoriach, planuj przyszłe sesje, ustawiaj cele i obserwuj postępy w drodze do wielkiej nagrody.',
|
||||||
|
fr_FR:
|
||||||
|
"Premier Gunner est un suivi d'entraînement de football adapté aux enfants et aux mobiles. Enregistrez l'entraînement quotidien par catégories, planifiez les séances futures, fixez des objectifs et suivez les progrès vers la grande récompense.",
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { setupManifest } from '@start9labs/start-sdk'
|
||||||
|
import { long, short } from './i18n'
|
||||||
|
|
||||||
|
export const manifest = setupManifest({
|
||||||
|
id: 'premier-gunner',
|
||||||
|
title: 'Premier Gunner',
|
||||||
|
license: 'MIT',
|
||||||
|
packageRepo: 'https://github.com/ten31/premier-gunner',
|
||||||
|
upstreamRepo: 'https://github.com/ten31/premier-gunner',
|
||||||
|
marketingUrl: 'https://github.com/ten31/premier-gunner',
|
||||||
|
donationUrl: null,
|
||||||
|
description: { short, long },
|
||||||
|
volumes: ['main'],
|
||||||
|
images: {
|
||||||
|
'premier-gunner': {
|
||||||
|
source: { dockerBuild: { dockerfile: 'Dockerfile', workdir: '.' } },
|
||||||
|
arch: ['x86_64', 'aarch64'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alerts: {
|
||||||
|
install: null,
|
||||||
|
update: null,
|
||||||
|
uninstall: null,
|
||||||
|
restore: null,
|
||||||
|
start: null,
|
||||||
|
stop: null,
|
||||||
|
},
|
||||||
|
dependencies: {},
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { StartSdk } from '@start9labs/start-sdk'
|
||||||
|
import { manifest } from './manifest'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plumbing. DO NOT EDIT.
|
||||||
|
*
|
||||||
|
* The exported "sdk" const is used throughout this package codebase.
|
||||||
|
*/
|
||||||
|
export const sdk = StartSdk.of().withManifest(manifest).build(true)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Here we define any constants or functions that are shared by multiple components
|
||||||
|
// throughout the package codebase.
|
||||||
|
|
||||||
|
// The port the Premier Gunner Node server listens on inside the container.
|
||||||
|
export const uiPort = 3000
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { IMPOSSIBLE, utils, VersionInfo } from '@start9labs/start-sdk'
|
||||||
|
import { store } from '../fileModels/store'
|
||||||
|
|
||||||
|
export const current = VersionInfo.of({
|
||||||
|
version: '0.1.0: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.',
|
||||||
|
},
|
||||||
|
migrations: {
|
||||||
|
up: async ({ effects }) => {
|
||||||
|
// Generate a random login password on first install so the app is never
|
||||||
|
// left on a known default. The user can change it via "Set Login Password".
|
||||||
|
const existing = await store.read().once()
|
||||||
|
if (!existing) {
|
||||||
|
const password = utils.getDefaultString({
|
||||||
|
charset: 'a-z,A-Z,2-9',
|
||||||
|
len: 16,
|
||||||
|
})
|
||||||
|
await store.write(effects, { password })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
down: IMPOSSIBLE,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { VersionGraph } from '@start9labs/start-sdk'
|
||||||
|
import { current } from './current'
|
||||||
|
|
||||||
|
export const versionGraph = VersionGraph.of({
|
||||||
|
current,
|
||||||
|
other: [],
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"include": ["startos/**/*.ts", "node_modules/**/startos"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2018",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { db, getSetting, setSetting } from './db.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export const COOKIE_NAME = 'pg_session';
|
||||||
|
|
||||||
|
// Resolve a stable cookie-signing secret (persisted so sessions survive restarts).
|
||||||
|
export function getCookieSecret() {
|
||||||
|
if (config.cookieSecret) return config.cookieSecret;
|
||||||
|
let secret = getSetting('cookie_secret');
|
||||||
|
if (!secret) {
|
||||||
|
secret = randomBytes(32).toString('hex');
|
||||||
|
setSetting('cookie_secret', secret);
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the bcrypt password hash. The environment (PG_PASSWORD / PG_PASSWORD_HASH)
|
||||||
|
// is authoritative: when set, it overwrites the stored hash on every boot so that
|
||||||
|
// platform-managed password changes (e.g. the StartOS "Set Login Password" action,
|
||||||
|
// which restarts the service with a new PG_PASSWORD) actually take effect.
|
||||||
|
export function initPassword() {
|
||||||
|
if (config.passwordHash) {
|
||||||
|
setSetting('password_hash', config.passwordHash);
|
||||||
|
return config.passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = getSetting('password_hash');
|
||||||
|
|
||||||
|
if (config.password) {
|
||||||
|
// Re-hash only when the password actually changed (bcrypt salts differ each run).
|
||||||
|
if (!stored || !bcrypt.compareSync(config.password, stored)) {
|
||||||
|
const hash = bcrypt.hashSync(config.password, 10);
|
||||||
|
setSetting('password_hash', hash);
|
||||||
|
// Password changed out from under existing sessions — invalidate them.
|
||||||
|
if (stored) db.prepare('DELETE FROM sessions').run();
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored) return stored;
|
||||||
|
|
||||||
|
const hash = bcrypt.hashSync('gunner', 10);
|
||||||
|
setSetting('password_hash', hash);
|
||||||
|
console.warn('\n⚠ No PG_PASSWORD set — using default password "gunner". Set PG_PASSWORD before deploying.\n');
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(plain) {
|
||||||
|
const hash = config.passwordHash || getSetting('password_hash');
|
||||||
|
if (!hash) return false;
|
||||||
|
return bcrypt.compareSync(String(plain || ''), hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPassword(plain) {
|
||||||
|
const hash = bcrypt.hashSync(String(plain), 10);
|
||||||
|
setSetting('password_hash', hash);
|
||||||
|
// Invalidate existing sessions when the password changes.
|
||||||
|
db.prepare('DELETE FROM sessions').run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSession() {
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
const expires = new Date(Date.now() + config.sessionDays * 86400_000).toISOString();
|
||||||
|
db.prepare('INSERT INTO sessions (token, expires_at) VALUES (?, ?)').run(token, expires);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSession(token) {
|
||||||
|
if (!token) return false;
|
||||||
|
const row = db.prepare('SELECT expires_at FROM sessions WHERE token = ?').get(token);
|
||||||
|
if (!row) return false;
|
||||||
|
if (new Date(row.expires_at).getTime() < Date.now()) {
|
||||||
|
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroySession(token) {
|
||||||
|
if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupExpiredSessions() {
|
||||||
|
db.prepare("DELETE FROM sessions WHERE expires_at < datetime('now')").run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { join, isAbsolute } from 'node:path';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
|
||||||
|
function resolveDir(p, fallback) {
|
||||||
|
const dir = p || fallback;
|
||||||
|
return isAbsolute(dir) ? dir : join(root, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
host: process.env.PG_HOST || '0.0.0.0',
|
||||||
|
port: Number(process.env.PG_PORT || 3000),
|
||||||
|
dataDir: resolveDir(process.env.PG_DATA_DIR, 'data'),
|
||||||
|
// Auth: prefer a pre-hashed value; otherwise hash PG_PASSWORD at boot.
|
||||||
|
// Defaults to "gunner" for local dev (a warning is logged).
|
||||||
|
passwordHash: process.env.PG_PASSWORD_HASH || '',
|
||||||
|
password: process.env.PG_PASSWORD || '',
|
||||||
|
cookieSecret: process.env.PG_COOKIE_SECRET || '',
|
||||||
|
sessionDays: Number(process.env.PG_SESSION_DAYS || 30),
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(config.dataDir, { recursive: true });
|
||||||
|
|
||||||
|
export const dbPath = join(config.dataDir, 'premier-gunner.db');
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { dbPath } from './config.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf8');
|
||||||
|
db.exec(schema);
|
||||||
|
|
||||||
|
export function getSetting(key, fallback = null) {
|
||||||
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||||
|
return row ? row.value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key, value) {
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||||
|
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||||
|
).run(key, String(value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
COOKIE_NAME, verifyPassword, createSession, destroySession, setPassword,
|
||||||
|
} from '../auth.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
|
const cookieOpts = {
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
signed: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: config.sessionDays * 86400,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function authRoutes(app) {
|
||||||
|
app.post('/api/login', async (req, reply) => {
|
||||||
|
const { password } = req.body || {};
|
||||||
|
if (!verifyPassword(password)) {
|
||||||
|
return reply.code(401).send({ error: 'Wrong password' });
|
||||||
|
}
|
||||||
|
const token = createSession();
|
||||||
|
reply.setCookie(COOKIE_NAME, token, cookieOpts);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/logout', async (req, reply) => {
|
||||||
|
const raw = req.cookies[COOKIE_NAME];
|
||||||
|
const unsigned = raw ? reply.unsignCookie(raw) : null;
|
||||||
|
if (unsigned && unsigned.valid) destroySession(unsigned.value);
|
||||||
|
reply.clearCookie(COOKIE_NAME, { path: '/' });
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/me', async () => ({ ok: true }));
|
||||||
|
|
||||||
|
app.post('/api/password', async (req, reply) => {
|
||||||
|
const { current, next } = req.body || {};
|
||||||
|
if (!verifyPassword(current)) return reply.code(401).send({ error: 'Wrong current password' });
|
||||||
|
if (!next || String(next).length < 4) return reply.code(400).send({ error: 'New password too short' });
|
||||||
|
setPassword(next);
|
||||||
|
const token = createSession();
|
||||||
|
reply.setCookie(COOKIE_NAME, token, cookieOpts);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { db } from '../db.js';
|
||||||
|
|
||||||
|
function categoriesWithMetrics({ includeArchived = false } = {}) {
|
||||||
|
const cats = db.prepare(
|
||||||
|
`SELECT * FROM categories ${includeArchived ? '' : 'WHERE archived = 0'} ORDER BY sort_order, id`
|
||||||
|
).all();
|
||||||
|
const metrics = db.prepare('SELECT * FROM category_metrics ORDER BY sort_order, id').all();
|
||||||
|
const byCat = new Map();
|
||||||
|
for (const m of metrics) {
|
||||||
|
if (!byCat.has(m.category_id)) byCat.set(m.category_id, []);
|
||||||
|
byCat.get(m.category_id).push(m);
|
||||||
|
}
|
||||||
|
return cats.map((c) => ({ ...c, metrics: byCat.get(c.id) || [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { categoriesWithMetrics };
|
||||||
|
|
||||||
|
export default async function categoryRoutes(app) {
|
||||||
|
app.get('/api/categories', async (req) => {
|
||||||
|
const includeArchived = req.query?.all === '1';
|
||||||
|
return categoriesWithMetrics({ includeArchived });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/categories', async (req, reply) => {
|
||||||
|
const { name, emoji, color, metrics } = req.body || {};
|
||||||
|
if (!name) return reply.code(400).send({ error: 'Name required' });
|
||||||
|
const maxOrder = db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM categories').get().m;
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO categories (name, emoji, color, sort_order) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(name, emoji || '⚽', color || '#EF0107', maxOrder + 1);
|
||||||
|
const catId = result.lastInsertRowid;
|
||||||
|
const list = Array.isArray(metrics) && metrics.length
|
||||||
|
? metrics
|
||||||
|
: [{ 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);
|
||||||
|
});
|
||||||
|
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === Number(catId));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/categories/:id', async (req, reply) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const cat = db.prepare('SELECT * FROM categories WHERE id = ?').get(id);
|
||||||
|
if (!cat) return reply.code(404).send({ error: 'Not found' });
|
||||||
|
const { name, emoji, color, archived } = req.body || {};
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE categories SET name = ?, emoji = ?, color = ?, archived = ? WHERE id = ?'
|
||||||
|
).run(name ?? cat.name, emoji ?? cat.emoji, color ?? cat.color,
|
||||||
|
archived !== undefined ? (archived ? 1 : 0) : cat.archived, id);
|
||||||
|
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a metric to an existing category.
|
||||||
|
app.post('/api/categories/:id/metrics', async (req, reply) => {
|
||||||
|
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 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);
|
||||||
|
return categoriesWithMetrics({ includeArchived: true }).find((c) => c.id === 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);
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { db } from '../db.js';
|
||||||
|
|
||||||
|
const ISO = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function ensureDay(day) {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO training_days (day) VALUES (?)').run(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
).all(day);
|
||||||
|
const valuesByEntry = new Map();
|
||||||
|
if (entries.length) {
|
||||||
|
const ids = entries.map((e) => e.id);
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT entry_id, metric_id, value FROM entry_values WHERE entry_id IN (${ids.map(() => '?').join(',')})`
|
||||||
|
).all(...ids);
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!valuesByEntry.has(r.entry_id)) valuesByEntry.set(r.entry_id, []);
|
||||||
|
valuesByEntry.get(r.entry_id).push({ metric_id: r.metric_id, value: r.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const plans = db.prepare(
|
||||||
|
'SELECT category_id, note FROM plans WHERE day = ?'
|
||||||
|
).all(day);
|
||||||
|
return {
|
||||||
|
day: td.day,
|
||||||
|
notes: td.notes,
|
||||||
|
entries: entries.map((e) => ({ ...e, values: valuesByEntry.get(e.id) || [] })),
|
||||||
|
plans,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function entryRoutes(app) {
|
||||||
|
app.get('/api/day/:day', async (req, reply) => {
|
||||||
|
const { day } = req.params;
|
||||||
|
if (!ISO.test(day)) return reply.code(400).send({ error: 'Bad date' });
|
||||||
|
return dayPayload(day);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/entries', async (req, reply) => {
|
||||||
|
const { day, category_id, values } = 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 tx = db.transaction(() => {
|
||||||
|
ensureDay(day);
|
||||||
|
const { lastInsertRowid: entryId } = db.prepare(
|
||||||
|
'INSERT INTO entries (day, category_id) VALUES (?, ?)'
|
||||||
|
).run(day, cat.id);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entryId;
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
return dayPayload(day);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/entries/:id', async (req, reply) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const row = db.prepare('SELECT day FROM entries WHERE id = ?').get(id);
|
||||||
|
db.prepare('DELETE FROM entries WHERE id = ?').run(id);
|
||||||
|
return reply.send(row ? dayPayload(row.day) : { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/day/:day/notes', async (req, reply) => {
|
||||||
|
const { day } = req.params;
|
||||||
|
if (!ISO.test(day)) return reply.code(400).send({ error: 'Bad date' });
|
||||||
|
const notes = String((req.body && req.body.notes) || '');
|
||||||
|
ensureDay(day);
|
||||||
|
db.prepare('UPDATE training_days SET notes = ? WHERE day = ?').run(notes, day);
|
||||||
|
return { ok: true, notes };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { db } from '../db.js';
|
||||||
|
|
||||||
|
const KINDS = new Set(['session_count', 'metric_best', 'metric_total']);
|
||||||
|
|
||||||
|
export default async function goalRoutes(app) {
|
||||||
|
app.get('/api/goals', async () => db.prepare('SELECT * FROM goals ORDER BY is_main DESC, sort_order, id').all());
|
||||||
|
|
||||||
|
app.post('/api/goals', async (req, reply) => {
|
||||||
|
const b = req.body || {};
|
||||||
|
const scope = b.scope === 'overall' ? 'overall' : 'category';
|
||||||
|
const kind = KINDS.has(b.kind) ? b.kind : 'session_count';
|
||||||
|
if (kind !== 'session_count' && !b.metric_id) {
|
||||||
|
return reply.code(400).send({ error: 'metric_id required for metric goals' });
|
||||||
|
}
|
||||||
|
if (scope === 'category' && !b.category_id) {
|
||||||
|
return reply.code(400).send({ error: 'category_id required for category goals' });
|
||||||
|
}
|
||||||
|
// Only one main goal at a time.
|
||||||
|
if (b.is_main) db.prepare('UPDATE goals SET is_main = 0').run();
|
||||||
|
const maxOrder = db.prepare('SELECT COALESCE(MAX(sort_order), -1) AS m FROM goals').get().m;
|
||||||
|
const result = db.prepare(
|
||||||
|
'INSERT INTO goals (scope, category_id, metric_id, kind, target, label, reward, is_main, sort_order) ' +
|
||||||
|
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
).run(scope, b.category_id || null, b.metric_id || null, kind, Number(b.target) || 0,
|
||||||
|
String(b.label || ''), String(b.reward || ''), b.is_main ? 1 : 0, maxOrder + 1);
|
||||||
|
return db.prepare('SELECT * FROM goals WHERE id = ?').get(result.lastInsertRowid);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/goals/:id', async (req, reply) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const g = db.prepare('SELECT * FROM goals WHERE id = ?').get(id);
|
||||||
|
if (!g) return reply.code(404).send({ error: 'Not found' });
|
||||||
|
const b = req.body || {};
|
||||||
|
if (b.is_main) db.prepare('UPDATE goals SET is_main = 0').run();
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE goals SET target = ?, label = ?, reward = ?, is_main = ? WHERE id = ?'
|
||||||
|
).run(b.target != null ? Number(b.target) : g.target,
|
||||||
|
b.label != null ? String(b.label) : g.label,
|
||||||
|
b.reward != null ? String(b.reward) : g.reward,
|
||||||
|
b.is_main != null ? (b.is_main ? 1 : 0) : g.is_main, id);
|
||||||
|
return db.prepare('SELECT * FROM goals WHERE id = ?').get(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/goals/:id', async (req, reply) => {
|
||||||
|
db.prepare('DELETE FROM goals WHERE id = ?').run(Number(req.params.id));
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { db } from '../db.js';
|
||||||
|
|
||||||
|
const ISO = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
export default async function planRoutes(app) {
|
||||||
|
// Plans within a date range (inclusive). Defaults to next 14 days handled client-side.
|
||||||
|
app.get('/api/plans', async (req) => {
|
||||||
|
const { from, to } = req.query || {};
|
||||||
|
if (ISO.test(from || '') && ISO.test(to || '')) {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT id, day, category_id, note FROM plans WHERE day BETWEEN ? AND ? ORDER BY day, id'
|
||||||
|
).all(from, to);
|
||||||
|
}
|
||||||
|
return db.prepare('SELECT id, day, category_id, note FROM plans ORDER BY day, id').all();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/plans', async (req, reply) => {
|
||||||
|
const { day, category_id, 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' });
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO plans (day, category_id, note) VALUES (?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(day, category_id) DO UPDATE SET note = excluded.note'
|
||||||
|
).run(day, cat.id, String(note || ''));
|
||||||
|
return db.prepare('SELECT id, day, category_id, note FROM plans WHERE day = ? AND category_id = ?')
|
||||||
|
.get(day, cat.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/plans/:id', async (req, reply) => {
|
||||||
|
db.prepare('DELETE FROM plans WHERE id = ?').run(Number(req.params.id));
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { db } from '../db.js';
|
||||||
|
|
||||||
|
function todayISO() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goalProgress(goal) {
|
||||||
|
let current = 0;
|
||||||
|
if (goal.kind === 'session_count') {
|
||||||
|
current = goal.scope === 'overall'
|
||||||
|
? db.prepare('SELECT COUNT(*) AS n FROM entries').get().n
|
||||||
|
: db.prepare('SELECT COUNT(*) AS n FROM entries WHERE category_id = ?').get(goal.category_id).n;
|
||||||
|
} else if (goal.kind === 'metric_best' && goal.metric_id) {
|
||||||
|
current = db.prepare('SELECT COALESCE(MAX(value), 0) AS v FROM entry_values WHERE metric_id = ?')
|
||||||
|
.get(goal.metric_id).v;
|
||||||
|
} else if (goal.kind === 'metric_total' && goal.metric_id) {
|
||||||
|
current = db.prepare('SELECT COALESCE(SUM(value), 0) AS v FROM entry_values WHERE metric_id = ?')
|
||||||
|
.get(goal.metric_id).v;
|
||||||
|
}
|
||||||
|
const pct = goal.target > 0 ? Math.min(100, Math.round((current / goal.target) * 100)) : 0;
|
||||||
|
return { ...goal, current, pct };
|
||||||
|
}
|
||||||
|
|
||||||
|
function streaks(days) {
|
||||||
|
// days: ISO strings sorted ascending, distinct
|
||||||
|
if (!days.length) return { current: 0, longest: 0 };
|
||||||
|
const set = new Set(days);
|
||||||
|
let longest = 0;
|
||||||
|
for (const d of days) {
|
||||||
|
const prev = new Date(d); prev.setDate(prev.getDate() - 1);
|
||||||
|
if (!set.has(prev.toISOString().slice(0, 10))) {
|
||||||
|
// start of a run
|
||||||
|
let len = 0; const cur = new Date(d);
|
||||||
|
while (set.has(cur.toISOString().slice(0, 10))) { len++; cur.setDate(cur.getDate() + 1); }
|
||||||
|
if (len > longest) longest = len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// current streak ending today or yesterday
|
||||||
|
let current = 0; const cur = new Date(todayISO());
|
||||||
|
if (!set.has(cur.toISOString().slice(0, 10))) cur.setDate(cur.getDate() - 1);
|
||||||
|
while (set.has(cur.toISOString().slice(0, 10))) { current++; cur.setDate(cur.getDate() - 1); }
|
||||||
|
return { current, longest };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function statsRoutes(app) {
|
||||||
|
app.get('/api/stats', async () => {
|
||||||
|
const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM entries').get().n;
|
||||||
|
const dayRows = db.prepare('SELECT DISTINCT day FROM entries ORDER BY day').all().map((r) => r.day);
|
||||||
|
const totalDays = dayRows.length;
|
||||||
|
|
||||||
|
// Heatmap: entries per day.
|
||||||
|
const heatmap = db.prepare(
|
||||||
|
'SELECT day, COUNT(*) AS count FROM entries GROUP BY day ORDER BY day'
|
||||||
|
).all();
|
||||||
|
|
||||||
|
// Radar: sessions per category (all-time + last 30 days).
|
||||||
|
const since = new Date(); since.setDate(since.getDate() - 30);
|
||||||
|
const since30 = since.toISOString().slice(0, 10);
|
||||||
|
const radar = db.prepare(
|
||||||
|
`SELECT c.id AS category_id, c.name, c.emoji, c.color,
|
||||||
|
COUNT(e.id) AS sessions,
|
||||||
|
SUM(CASE WHEN e.day >= ? THEN 1 ELSE 0 END) AS sessions30
|
||||||
|
FROM categories c LEFT JOIN entries e ON e.category_id = c.id
|
||||||
|
WHERE c.archived = 0
|
||||||
|
GROUP BY c.id ORDER BY c.sort_order, c.id`
|
||||||
|
).all(since30);
|
||||||
|
|
||||||
|
// Per-metric time series for line charts.
|
||||||
|
const seriesRows = db.prepare(
|
||||||
|
`SELECT ev.metric_id, e.day, ev.value, e.id AS entry_id
|
||||||
|
FROM entry_values ev JOIN entries e ON e.id = ev.entry_id
|
||||||
|
ORDER BY e.day, e.id`
|
||||||
|
).all();
|
||||||
|
const series = {};
|
||||||
|
for (const r of seriesRows) {
|
||||||
|
(series[r.metric_id] ||= []).push({ day: r.day, value: r.value, entry_id: r.entry_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const goals = db.prepare('SELECT * FROM goals ORDER BY is_main DESC, sort_order, id').all()
|
||||||
|
.map(goalProgress);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSessions,
|
||||||
|
totalDays,
|
||||||
|
...streaks(dayRows),
|
||||||
|
today: todayISO(),
|
||||||
|
heatmap,
|
||||||
|
radar,
|
||||||
|
series,
|
||||||
|
goals,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL DEFAULT '⚽',
|
||||||
|
color TEXT NOT NULL DEFAULT '#EF0107',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
archived INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- What gets measured for a category (juggles, minutes, shots, etc.)
|
||||||
|
CREATE TABLE IF NOT EXISTS category_metrics (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
unit TEXT NOT NULL DEFAULT '',
|
||||||
|
kind TEXT NOT NULL DEFAULT 'count', -- count | duration | score
|
||||||
|
step INTEGER NOT NULL DEFAULT 1,
|
||||||
|
higher_is_better INTEGER NOT NULL DEFAULT 1,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per calendar day Gunner trains; holds the daily notes.
|
||||||
|
CREATE TABLE IF NOT EXISTS training_days (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
day TEXT NOT NULL UNIQUE, -- ISO YYYY-MM-DD
|
||||||
|
notes TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A single logged practice of a category on a day.
|
||||||
|
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,
|
||||||
|
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_cat ON entries(category_id);
|
||||||
|
|
||||||
|
-- The metric readings captured for an entry.
|
||||||
|
CREATE TABLE IF NOT EXISTS entry_values (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
metric_id INTEGER NOT NULL REFERENCES category_metrics(id) ON DELETE CASCADE,
|
||||||
|
value REAL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_values_entry ON entry_values(entry_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entry_values_metric ON entry_values(metric_id);
|
||||||
|
|
||||||
|
-- Planned categories for a (usually future) day.
|
||||||
|
CREATE TABLE IF NOT EXISTS plans (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE(day, category_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plans_day ON plans(day);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS goals (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'category', -- overall | category
|
||||||
|
category_id INTEGER REFERENCES categories(id) ON DELETE CASCADE,
|
||||||
|
metric_id INTEGER REFERENCES category_metrics(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'session_count', -- session_count | metric_best | metric_total
|
||||||
|
target REAL NOT NULL DEFAULT 0,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
reward TEXT NOT NULL DEFAULT '',
|
||||||
|
is_main INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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 }] },
|
||||||
|
{ name: 'Left Foot', emoji: '🦶', color: '#0a58ca',
|
||||||
|
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
|
||||||
|
{ name: 'Shooting', emoji: '🥅', color: '#d63384',
|
||||||
|
metrics: [
|
||||||
|
{ name: 'Shots', unit: 'shots', kind: 'count', step: 5 },
|
||||||
|
{ name: 'Goals', unit: 'goals', kind: 'count', step: 1 },
|
||||||
|
] },
|
||||||
|
{ name: 'Soccer Tennis', emoji: '🎾', color: '#198754',
|
||||||
|
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
|
||||||
|
{ name: 'Dribbling Drills', emoji: '🌀', color: '#fd7e14',
|
||||||
|
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
|
||||||
|
{ name: 'Soccer Golf', emoji: '⛳', color: '#6f42c1',
|
||||||
|
metrics: [{ name: 'Strokes', unit: 'strokes', kind: 'count', step: 1, higher_is_better: 0 }] },
|
||||||
|
{ 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 }] },
|
||||||
|
{ name: 'EPA Agility & Speed', emoji: '⚡', color: '#ffc107',
|
||||||
|
metrics: [{ name: 'Minutes', unit: 'min', kind: 'duration', step: 5 }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function seedIfEmpty() {
|
||||||
|
const count = db.prepare('SELECT COUNT(*) AS n FROM categories').get().n;
|
||||||
|
if (count > 0) return false;
|
||||||
|
|
||||||
|
const insCat = db.prepare(
|
||||||
|
'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 (?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
|
||||||
|
const seed = db.transaction(() => {
|
||||||
|
DEFAULTS.forEach((cat, ci) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main goal: the London / Arsenal reward, tracked as overall sessions.
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO goals (scope, kind, target, label, reward, is_main, sort_order) ' +
|
||||||
|
"VALUES ('overall', 'session_count', 100, ?, ?, 1, 0)"
|
||||||
|
).run('Road to London', '✈️ Trip to London to see Arsenal play in person!');
|
||||||
|
|
||||||
|
// A starter juggling personal-best goal as an example.
|
||||||
|
const jug = db.prepare("SELECT id FROM categories WHERE name = 'Juggling'").get();
|
||||||
|
const jugMetric = db.prepare(
|
||||||
|
'SELECT id FROM category_metrics WHERE category_id = ? AND name = ?'
|
||||||
|
).get(jug.id, 'Juggles');
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO goals (scope, category_id, metric_id, kind, target, label, sort_order) ' +
|
||||||
|
"VALUES ('category', ?, ?, 'metric_best', 50, ?, 1)"
|
||||||
|
).run(jug.id, jugMetric.id, 'Juggle 50 in a row');
|
||||||
|
});
|
||||||
|
|
||||||
|
seed();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow `npm run seed` to run standalone.
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const seeded = seedIfEmpty();
|
||||||
|
console.log(seeded ? 'Seeded default categories and goals.' : 'Categories already exist — nothing to seed.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import fastifyStatic from '@fastify/static';
|
||||||
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { seedIfEmpty } from './seed.js';
|
||||||
|
import {
|
||||||
|
COOKIE_NAME, getCookieSecret, initPassword, isValidSession, cleanupExpiredSessions,
|
||||||
|
} from './auth.js';
|
||||||
|
|
||||||
|
import authRoutes from './routes/auth.js';
|
||||||
|
import categoryRoutes from './routes/categories.js';
|
||||||
|
import entryRoutes from './routes/entries.js';
|
||||||
|
import planRoutes from './routes/plans.js';
|
||||||
|
import goalRoutes from './routes/goals.js';
|
||||||
|
import statsRoutes from './routes/stats.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const publicDir = join(__dirname, '..', 'public');
|
||||||
|
|
||||||
|
// First-boot setup.
|
||||||
|
initPassword();
|
||||||
|
seedIfEmpty();
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
|
||||||
|
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || 'info' } });
|
||||||
|
|
||||||
|
await app.register(fastifyCookie, { secret: getCookieSecret() });
|
||||||
|
await app.register(fastifyStatic, { root: publicDir, index: ['index.html'] });
|
||||||
|
|
||||||
|
// Gate every /api route except login. Static assets stay public; the data is what's protected.
|
||||||
|
const OPEN = new Set(['/api/login']);
|
||||||
|
app.addHook('preHandler', async (req, reply) => {
|
||||||
|
if (!req.url.startsWith('/api/')) return;
|
||||||
|
const path = req.url.split('?')[0];
|
||||||
|
if (OPEN.has(path)) return;
|
||||||
|
const raw = req.cookies[COOKIE_NAME];
|
||||||
|
const unsigned = raw ? reply.unsignCookie(raw) : null;
|
||||||
|
if (!unsigned || !unsigned.valid || !isValidSession(unsigned.value)) {
|
||||||
|
return reply.code(401).send({ error: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(authRoutes);
|
||||||
|
await app.register(categoryRoutes);
|
||||||
|
await app.register(entryRoutes);
|
||||||
|
await app.register(planRoutes);
|
||||||
|
await app.register(goalRoutes);
|
||||||
|
await app.register(statsRoutes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
console.log(`⚽ Premier Gunner running at http://${config.host}:${config.port}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||