#!/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.`);