diff --git a/.gitignore b/.gitignore index 3b56f93..ecbec39 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ dmypy.json # Browser network captures (may contain session cookies / PII) *.har +.tokensave diff --git a/src/kudosy/app.py b/src/kudosy/app.py index 3c2ccdc..f0e66d1 100644 --- a/src/kudosy/app.py +++ b/src/kudosy/app.py @@ -24,6 +24,7 @@ from kudosy.settings import get_settings from kudosy.sport_types import ALL_SPORT_TYPES, fetch_sport_types, merge_sport_types from kudosy.store import ( + append_run_history, bootstrap, log_path, mark_kudoed, @@ -128,6 +129,19 @@ async def _run_job(dry_run: bool | None = None) -> Any: act["has_kudoed"] = True write_activity_cache(acts, result.started_at.isoformat()) log.debug("Activity cache updated (%d entries)", len(acts)) + # Persist a compact history entry for every completed run + if result: + append_run_history( + { + "started_at": result.started_at.isoformat(), + "finished_at": result.finished_at.isoformat(), + "dry_run": result.dry_run, + "total": result.total, + "would_give": result.would_give, + "given": result.given, + "success": result.success, + } + ) return result finally: await client.aclose() diff --git a/src/kudosy/routes.py b/src/kudosy/routes.py index 78bab62..c630253 100644 --- a/src/kudosy/routes.py +++ b/src/kudosy/routes.py @@ -29,6 +29,7 @@ read_athlete_avatars, read_athlete_labels, read_log, + read_run_history, read_settings, read_user_config, write_activity_cache, @@ -51,13 +52,31 @@ async def serve_index() -> HTMLResponse: """Serve index.html with versioned asset URLs for cache busting.""" v = __version__ - importmap = json.dumps({"imports": {"./i18n.js": f"./i18n.js?v={v}"}}) + # All local ES modules are mapped to their cache-busted ?v= variant so that + # a new release immediately invalidates every module in every browser. + _MODULES = [ + "./i18n.js", + "./state.js", + "./dom.js", + "./api.js", + "./format.js", + "./schedule-matrix.js", + "./athletes.js", + "./config.js", + "./settings.js", + "./feed.js", + "./status.js", + "./stats.js", + "./tabs.js", + "./main.js", + ] + importmap = json.dumps({"imports": {m: f"{m}?v={v}" for m in _MODULES}}) content = (_STATIC_DIR / "index.html").read_text() content = content.replace('href="styles.css"', f'href="styles.css?v={v}"') content = content.replace( - '', + '', f'\n ' - f'', + f'', ) return HTMLResponse(content=content, headers={"Cache-Control": "no-store"}) @@ -420,6 +439,19 @@ async def post_single_kudos(activity_id: str) -> dict[str, Any]: await client.aclose() +# ── History ─────────────────────────────────────────────────────────────────── + + +@router.get("/api/history") +async def get_history(limit: int = 100) -> list[dict[str, Any]]: + """Return the *limit* most-recent run-history entries (newest first). + + Each entry is a compact dict with: started_at, finished_at, dry_run, + total, would_give, given, success. + """ + return read_run_history(limit=max(1, min(limit, 500))) + + # ── Log ─────────────────────────────────────────────────────────────────────── diff --git a/src/kudosy/static/api.js b/src/kudosy/static/api.js new file mode 100644 index 0000000..e44abab --- /dev/null +++ b/src/kudosy/static/api.js @@ -0,0 +1,31 @@ +// ── Kudosy UI — api.js ─────────────────────────────────────────────────────── +// Thin fetch wrappers: structured-error handling + JSON serialisation. + +import { t } from './i18n.js'; + +export async function fetchJson(url, opts = {}) { + const res = await fetch(url, opts); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + // detail may be a structured {code, message} object or a plain string + const detail = body.detail; + if (detail && typeof detail === 'object' && detail.code) { + const key = `error.${detail.code}`; + const translated = t(key); + // if key not found, fall back to the message field + throw new Error(translated !== key ? translated : (detail.message || `HTTP ${res.status}`)); + } + throw new Error( + (typeof detail === 'string' ? detail : null) || body.error || `HTTP ${res.status}` + ); + } + return res.json(); +} + +export async function putJson(url, data) { + return fetchJson(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); +} diff --git a/src/kudosy/static/app.js b/src/kudosy/static/app.js deleted file mode 100644 index 278e38b..0000000 --- a/src/kudosy/static/app.js +++ /dev/null @@ -1,1448 +0,0 @@ -// ────────────────────────────────────────────────────────────────────────────── -// Kudosy UI — app.js -// ────────────────────────────────────────────────────────────────────────────── - -import { - SUPPORTED, - LANG_LABELS, - t, - getLang, - currentLang, - localeFor, - setLang, - applyStaticTranslations, -} from './i18n.js'; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -const $ = id => document.getElementById(id); - -function toast(msg, type = 'success') { - const el = document.createElement('div'); - el.className = `toast toast-${type}`; - const icon = type === 'success' ? '✓' : type === 'error' ? '✕' : 'ℹ'; - el.innerHTML = `${icon}${msg}`; - $('toast-container').appendChild(el); - setTimeout(() => { - el.classList.add('fade-out'); - setTimeout(() => el.remove(), 300); - }, 3500); -} - -async function fetchJson(url, opts = {}) { - const res = await fetch(url, opts); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - // detail may be a structured {code, message} object or a plain string - const detail = body.detail; - if (detail && typeof detail === 'object' && detail.code) { - const key = `error.${detail.code}`; - const translated = t(key); - // if key not found, fall back to the message field - throw new Error(translated !== key ? translated : (detail.message || `HTTP ${res.status}`)); - } - throw new Error( - (typeof detail === 'string' ? detail : null) || body.error || `HTTP ${res.status}` - ); - } - return res.json(); -} - -async function putJson(url, data) { - return fetchJson(url, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(data), - }); -} - -function setButtonLoading(btn, loading) { - if (!btn) return; - if (loading) { - btn._savedHTML = btn.innerHTML; - btn.innerHTML = ''; - btn.disabled = true; - btn.classList.add('is-loading'); - } else { - if (btn._savedHTML !== undefined) btn.innerHTML = btn._savedHTML; - btn.disabled = false; - btn.classList.remove('is-loading'); - delete btn._savedHTML; - } -} - -function formatRelative(isoString) { - if (!isoString) return '—'; - const d = new Date(isoString); - const diff = Date.now() - d.getTime(); - const timeStr = d.toLocaleTimeString(localeFor(currentLang()), { hour: '2-digit', minute: '2-digit' }); - if (diff < 0) { - const s = Math.round(-diff / 1000); - if (s < 60) return `${t('time.inSeconds', { n: s })} (${timeStr})`; - const m = Math.round(s / 60); - if (m < 60) return `${t('time.inMinutes', { n: m })} (${timeStr})`; - return `${t('time.inHours', { n: Math.round(m / 60) })} (${timeStr})`; - } - const s = Math.round(diff / 1000); - if (s < 60) return `${t('time.agoSeconds', { n: s })} (${timeStr})`; - const m = Math.round(s / 60); - if (m < 60) return `${t('time.agoMinutes', { n: m })} (${timeStr})`; - const h = Math.round(m / 60); - if (h < 24) return `${t('time.agoHours', { n: h })} (${timeStr})`; - return d.toLocaleString(localeFor(currentLang()), { dateStyle: 'short', timeStyle: 'short' }); -} - -function formatTime(isoString) { - if (!isoString) return '—'; - return new Date(isoString).toLocaleTimeString(localeFor(currentLang()), { - hour: '2-digit', - minute: '2-digit', - }); -} - -// "MountainBikeRide" → "Mountain Bike Ride" -function formatSportLabel(type) { - return type.replace(/([A-Z])/g, ' $1').trim(); -} - -// ── State ───────────────────────────────────────────────────────────────────── - -let sportTypes = []; -let sportCategories = {}; // { FootSports: [...], CycleSports: [...], … } -// The five canonical category names — used to distinguish a category-keyed row from -// a sport-keyed row when reading the rules tables back. -const CATEGORY_NAME_SET = new Set([ - 'FootSports', 'CycleSports', 'WaterSports', 'WinterSports', 'OtherSports', -]); -let athleteLabels = {}; -let athleteAvatars = {}; -let pollTimer = null; -let feedActivities = []; -let feedFetchedAt = null; -let feedLoaded = false; // true after the first successful feed fetch -let feedFilter = { status: 'all', text: '', sport: '' }; - -// ── Run-button spinner state ─────────────────────────────────────────────────── -// The button whose spinner is currently active (null when idle). -let runningButton = null; -// The lastRun.finished_at that was current when the user clicked Run/DryRun. -// pollStatus() clears the spinner once a *newer* finished_at appears. -let runStartStamp = null; -// Updated by pollStatus() every tick so startRun() can snapshot it. -let currentLastRunStamp = null; - -// ── Language selector ───────────────────────────────────────────────────────── - -function initLangSelect() { - const sel = $('lang-select'); - if (!sel) return; - for (const lang of SUPPORTED) { - const opt = document.createElement('option'); - opt.value = lang; - opt.textContent = LANG_LABELS[lang]; - if (lang === getLang()) opt.selected = true; - sel.appendChild(opt); - } - sel.addEventListener('change', () => { - setLang(sel.value, () => { - // Re-render dynamic areas after language change - pollStatus(); - const activeFeedPane = document.querySelector('#tab-feed.active'); - if (activeFeedPane) { - // Update sport dropdown "all sports" label if already populated - const sportSel = $('feed-filter-sport'); - if (sportSel && sportSel.options.length > 0) { - sportSel.options[0].textContent = t('feed.filter.allSports'); - } - if (feedActivities.length) renderFeed(); - else loadFeed(); - } - }); - // Keep select in sync (applyStaticTranslations won't touch it) - sel.value = sel.value; - }); -} - -// ── Auto-save ──────────────────────────────────────────────────────────────── -// Disabled during init so that loadConfig/loadSettings DOM mutations don't -// trigger premature API calls. Enabled by init() after both loads complete. - -let _autoSaveEnabled = false; -let _saveConfigTimer = null; -let _saveSettingsTimer = null; - -function debouncedSaveConfig() { - if (!_autoSaveEnabled) return; - clearTimeout(_saveConfigTimer); - _saveConfigTimer = setTimeout(saveConfig, 800); -} - -function debouncedSaveSettings() { - if (!_autoSaveEnabled) return; - clearTimeout(_saveSettingsTimer); - _saveSettingsTimer = setTimeout(saveSettings, 800); -} - -// ── Tabs ────────────────────────────────────────────────────────────────────── - -function activateTab(tabName) { - const btn = document.querySelector(`.tab[data-tab="${tabName}"]`); - if (!btn) return; - document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); - document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); - btn.classList.add('active'); - const pane = $(`tab-${tabName}`); - if (pane) pane.classList.add('active'); - location.hash = tabName; - if (tabName === 'log') startPolling(); - else if (tabName === 'feed') { - stopPolling(); pollStatus(); - // Only fetch from Strava on first visit; use cached data on tab switches. - // The Refresh button is the explicit way to reload. - if (feedLoaded) renderFeed(); else loadFeed(); - } - else { stopPolling(); pollStatus(); } -} - -function initTabs() { - document.querySelectorAll('.tab').forEach(btn => { - btn.addEventListener('click', () => activateTab(btn.dataset.tab)); - }); - - // Restore the last active tab from the URL hash, fall back to 'feed'. - const validTabs = new Set(['feed', 'config', 'log']); - const hashTab = location.hash.slice(1); - activateTab(validTabs.has(hashTab) ? hashTab : 'feed'); -} - -// ── Sport type ─────────────────────────────────────────────────────── + +export function buildSportTypeSelect(selectedType = '') { + const sel = document.createElement('select'); + sel.className = 'sport-type-select'; + + // Blank placeholder (always first, outside any optgroup) + const blank = document.createElement('option'); + blank.value = ''; + blank.textContent = t('table.sportType.placeholder'); + if (!selectedType) blank.selected = true; + sel.appendChild(blank); + + let found = !selectedType; + + // Prefer the grouped format when category data is available + const hasCats = Object.keys(state.sportCategories).length > 0; + const cats = hasCats ? state.sportCategories : { '': state.sportTypes }; + + for (const [cat, sports] of Object.entries(cats)) { + if (!sports.length) continue; + + let container; + if (hasCats) { + container = document.createElement('optgroup'); + const catLabel = t(`category.${cat}`); + container.label = (catLabel !== `category.${cat}`) ? catLabel : cat; + + // Selectable category option — selecting it applies the rule to all members + const catOpt = document.createElement('option'); + catOpt.value = cat; + catOpt.className = 'opt-category'; + const allLabel = t('table.category.all'); + catOpt.textContent = allLabel !== 'table.category.all' + ? allLabel.replace('{cat}', container.label) + : `★ ${container.label}`; + if (cat === selectedType) { catOpt.selected = true; found = true; } + container.appendChild(catOpt); + } else { + container = sel; + } + + for (const type of sports) { + const opt = document.createElement('option'); + opt.value = type; + opt.textContent = formatSportLabel(type); + if (type === selectedType) { opt.selected = true; found = true; } + container.appendChild(opt); + } + + if (hasCats) sel.appendChild(container); + } + + // Fallback: a saved value that is no longer in the active lists + if (!found && selectedType) { + const opt = document.createElement('option'); + opt.value = selectedType; + opt.textContent = `${formatSportLabel(selectedType)} ↑`; + opt.selected = true; + sel.insertBefore(opt, sel.children[1] || null); + } + + return sel; +} + +// ── Rules table helpers ─────────────────────────────────────────────────────── + +export function addRuleRow(tbody, sportType = '', value = '') { + const tr = document.createElement('tr'); + + const tdType = document.createElement('td'); + tdType.appendChild(buildSportTypeSelect(sportType)); + + const tdVal = document.createElement('td'); + const numInput = document.createElement('input'); + numInput.type = 'number'; + numInput.value = value !== '' ? value : ''; + numInput.min = '0'; + numInput.step = '0.1'; + numInput.placeholder = '—'; + numInput.style.maxWidth = '90px'; + tdVal.appendChild(numInput); + + const tdRemove = document.createElement('td'); + tdRemove.appendChild(makeRemoveBtn(() => tr.remove())); + + tr.appendChild(tdType); + tr.appendChild(tdVal); + tr.appendChild(tdRemove); + tbody.appendChild(tr); +} + +/** + * Read all rows from a rules table. + * @returns {{ sport: Object, category: Object }} + * A row is "category" when its select value is one of the five CATEGORY_NAME_SET names. + */ +export function getRulesFromTable(tbody) { + const sport = {}; + const category = {}; + tbody.querySelectorAll('tr').forEach(tr => { + const sel = tr.querySelector('select'); + const numIn = tr.querySelector('input[type="number"]'); + if (!sel || !numIn) return; + const key = sel.value.trim(); + const raw = numIn.value.trim(); + if (key && raw !== '') { + const val = parseFloat(raw); + if (!isNaN(val)) { + if (CATEGORY_NAME_SET.has(key)) category[key] = val; + else sport[key] = val; + } + } + }); + return { sport, category }; +} + +/** + * Populate a rules table from per-sport and per-category dicts. + * Both are loaded into the same table; the