Initial commit: Premier Gunner tracker + StartOS 0.4.0 s9pk package
This commit is contained in:
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
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;
|
||||
})
|
||||
);
|
||||
});
|
||||
Vendored
+20
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user