diff --git a/scripts/audit-types.js b/scripts/audit-types.js new file mode 100644 index 0000000..58b5d26 --- /dev/null +++ b/scripts/audit-types.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// Audit activity types against their measured stats and fix contradictions. +// +// node scripts/audit-types.js # dry run: show proposed changes +// node scripts/audit-types.js --apply # apply + log to data/type-fixes.json +// +// Custom/curated types (skitour, vita, iceskate, …) are left alone; only +// clear physics contradictions in the generic foot/bike types plus the +// "outdoor" fallback are reclassified, using centroids computed from the +// database's own labelled activities. + +import Database from 'better-sqlite3'; +import fs from 'node:fs'; + +const APPLY = process.argv.includes('--apply'); +const dbPath = (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db'; +const db = new Database(dbPath); + +const rows = db + .prepare( + `SELECT id, name, type, distance_m, elev_gain_m, + coalesce(moving_s, duration_s) time_s + FROM activities` + ) + .all() + .map((r) => ({ + ...r, + kmh: r.time_s > 0 ? (r.distance_m / r.time_s) * 3.6 : null, + gainPerKm: r.distance_m > 0 ? r.elev_gain_m / (r.distance_m / 1000) : 0 + })); + +// generic classes we are willing to assign +const FOOT_SLOW = new Set(['hike', 'walk', 'walking']); +const FOOT_FAST = new Set(['run', 'running', 'trail run']); +const BIKE = new Set(['ride', 'cycling', 'bike', 'mtb', 'gravel']); +const GENERIC = new Set([...FOOT_SLOW, ...FOOT_FAST, ...BIKE, 'outdoor']); + +// centroids from the user's own labelled data (fall back to sensible defaults) +function centroid(types, defKmh, defGain, defKm) { + const sample = rows.filter((r) => types.has(r.type) && r.kmh !== null); + if (sample.length < 3) return { kmh: defKmh, gainPerKm: defGain, km: defKm }; + return { + kmh: sample.reduce((s, r) => s + r.kmh, 0) / sample.length, + gainPerKm: sample.reduce((s, r) => s + r.gainPerKm, 0) / sample.length, + km: sample.reduce((s, r) => s + r.distance_m / 1000, 0) / sample.length + }; +} +const CLASSES = [ + { name: 'hike', c: centroid(FOOT_SLOW, 4, 70, 10) }, + { name: 'run', c: centroid(FOOT_FAST, 11, 25, 8) }, + { name: 'ride', c: centroid(new Set(['ride', 'cycling', 'bike']), 21, 15, 40) }, + { name: 'mtb', c: centroid(new Set(['mtb']), 13.5, 45, 27) } +]; +function classify(r) { + if (r.kmh === null) return null; + let best = null; + let bestScore = Infinity; + for (const cls of CLASSES) { + // normalised distance in (speed, climb-rate, length) space + const ds = (r.kmh - cls.c.kmh) / 6; + const dg = (r.gainPerKm - cls.c.gainPerKm) / 40; + const dd = (r.distance_m / 1000 - cls.c.km) / 20; + const score = ds * ds + dg * dg + dd * dd; + if (score < bestScore) { + bestScore = score; + best = cls.name; + } + } + return best; +} + +function contradiction(r) { + if (r.kmh === null) return false; + if (r.type === 'outdoor') return true; + if (/^\d+$/.test(r.type)) return true; + if (FOOT_SLOW.has(r.type) && r.kmh > 9) return true; + if (FOOT_FAST.has(r.type) && (r.kmh < 4.5 || r.kmh > 16)) return true; + if (BIKE.has(r.type) && r.kmh < 5.5) return true; + return false; +} + +const changes = []; +for (const r of rows) { + if (!GENERIC.has(r.type) && !/^\d+$/.test(r.type)) continue; // curated type: hands off + if (!contradiction(r)) continue; + const suggestion = classify(r); + if (!suggestion || suggestion === r.type) continue; + changes.push({ + id: r.id, + name: r.name, + from: r.type, + to: suggestion, + kmh: r.kmh === null ? null : Math.round(r.kmh * 10) / 10, + gainPerKm: Math.round(r.gainPerKm) + }); +} + +// borderline cases worth a human look (reported, never auto-changed) +const review = rows.filter( + (r) => + (r.type === 'ride' && r.gainPerKm > 35 && r.kmh < 16) || + (r.type === 'mtb' && r.gainPerKm < 12 && r.kmh > 17) || + (FOOT_FAST.has(r.type) && r.kmh !== null && r.kmh < 6) || + (FOOT_SLOW.has(r.type) && r.kmh !== null && r.kmh > 7 && r.kmh <= 9) +); +if (review.length > 0) { + console.log('\nBorderline (left unchanged - review by hand if they look off):'); + console.table( + review.map((r) => ({ + id: r.id, + name: r.name, + type: r.type, + kmh: Math.round((r.kmh ?? 0) * 10) / 10, + gainPerKm: Math.round(r.gainPerKm), + km: Math.round(r.distance_m / 100) / 10 + })) + ); +} + +if (changes.length === 0) { + console.log('No contradictions found.'); + process.exit(0); +} +console.table(changes); +if (!APPLY) { + console.log(`Dry run - ${changes.length} change(s) proposed. Re-run with --apply.`); + process.exit(0); +} + +const update = db.prepare('UPDATE activities SET type = ? WHERE id = ?'); +db.transaction(() => { + for (const change of changes) update.run(change.to, change.id); +})(); +fs.writeFileSync( + (process.env.STREBA_DATA_DIR ?? 'data') + '/type-fixes.json', + JSON.stringify({ applied: new Date().toISOString(), changes }, null, 2) +); +console.log(`Applied ${changes.length} change(s); log written to data/type-fixes.json.`); diff --git a/src/lib/activity-colors.ts b/src/lib/activity-colors.ts index c9e28bb..0384b04 100644 --- a/src/lib/activity-colors.ts +++ b/src/lib/activity-colors.ts @@ -31,10 +31,21 @@ const TYPE_SLOT: Record = { 'via ferrata': 3, alpinism: 3, ski: 6, + alpineski: 6, + skitour: 6, + langlauf: 6, 'backcountry ski': 6, 'nordic ski': 6, snowboard: 6, snowshoe: 6, + mtb: 2, + gravel: 2, + vita: 1, + iceskate: 4, + inlineskate: 4, + inline: 4, + 'hike & fly': 3, + watersport: 5, segment: 7 }; diff --git a/src/lib/activity-rules.ts b/src/lib/activity-rules.ts index b5d4288..8260875 100644 --- a/src/lib/activity-rules.ts +++ b/src/lib/activity-rules.ts @@ -1,7 +1,13 @@ // Lift-assisted downhill types: their recorded elevation gain comes from // lifts, so it doesn't count toward ascent totals. Touring variants -// ("ski touring", "backcountry ski") earn their metres and do count. -export const LIFT_ASSISTED_TYPES = ['ski', 'alpine ski', 'downhill ski', 'snowboard']; +// ("skitour", "backcountry ski", "langlauf") earn their metres and do count. +export const LIFT_ASSISTED_TYPES = [ + 'ski', + 'alpine ski', + 'alpineski', + 'downhill ski', + 'snowboard' +]; export function countsTowardAscent(type: string): boolean { return !LIFT_ASSISTED_TYPES.includes(type.toLowerCase().trim());