|
| 1 | +import { readFileSync } from "fs" |
| 2 | +import { join, dirname } from "path" |
| 3 | +import { fileURLToPath } from "url" |
| 4 | + |
| 5 | +const __dirname = dirname(fileURLToPath(import.meta.url)) |
| 6 | +const s = JSON.parse(readFileSync(join(__dirname, "..", "data", "sessions.json"), "utf8")) |
| 7 | +const m = JSON.parse(readFileSync(join(__dirname, "..", "data", "metcons.json"), "utf8")) |
| 8 | + |
| 9 | +const metconsByCode = new Map(m.metcons.map(mc => [mc.code, mc])) |
| 10 | +let issues = [] |
| 11 | + |
| 12 | +// === 1. No duplicate dates === |
| 13 | +const dateCounts = new Map() |
| 14 | +for (const sess of s.sessions) { |
| 15 | + dateCounts.set(sess.date, (dateCounts.get(sess.date) || 0) + 1) |
| 16 | +} |
| 17 | +for (const [date, count] of dateCounts) { |
| 18 | + if (count > 1) issues.push(`Duplicate date: ${date} appears ${count} times`) |
| 19 | +} |
| 20 | + |
| 21 | +// === 2. No gaps in date range === |
| 22 | +const dates = s.sessions.map(x => x.date).sort() |
| 23 | +if (dates.length > 0) { |
| 24 | + const start = new Date(dates[0]) |
| 25 | + const end = new Date(dates[dates.length - 1]) |
| 26 | + const dateSet = new Set(dates) |
| 27 | + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { |
| 28 | + const iso = d.toISOString().slice(0, 10) |
| 29 | + if (!dateSet.has(iso)) issues.push(`Missing date: ${iso} (gap in coverage)`) |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +// === 3. Metcon code references exist === |
| 34 | +for (const sess of s.sessions) { |
| 35 | + if (sess.metcon && !metconsByCode.has(sess.metcon)) { |
| 36 | + issues.push(`${sess.date}: metcon ${sess.metcon} not found in metcons.json`) |
| 37 | + } |
| 38 | +} |
| 39 | + |
| 40 | +// === 4. No orphaned metcons (warning only) === |
| 41 | +const usedCodes = new Set(s.sessions.map(x => x.metcon).filter(Boolean)) |
| 42 | +const orphaned = m.metcons.filter(mc => !usedCodes.has(mc.code)) |
| 43 | +if (orphaned.length > 0) { |
| 44 | + console.log(`INFO: ${orphaned.length} metcon(s) not referenced by any session: ${orphaned.map(x => x.code).join(", ")}`) |
| 45 | +} |
| 46 | + |
| 47 | +// === 5. Tuesday + Saturday must be team metcons === |
| 48 | +for (const sess of s.sessions) { |
| 49 | + if (!sess.metcon) continue |
| 50 | + const dow = new Date(sess.date).getDay() // 0=Sun, 2=Tue, 6=Sat |
| 51 | + const mc = metconsByCode.get(sess.metcon) |
| 52 | + if (!mc) continue |
| 53 | + |
| 54 | + const isTeamDay = dow === 2 || dow === 6 |
| 55 | + const isTeamMetcon = !!mc.team |
| 56 | + |
| 57 | + if (isTeamDay && !isTeamMetcon) { |
| 58 | + issues.push(`${sess.date}: team day (Tue/Sat) but ${sess.metcon} is not a team metcon`) |
| 59 | + } |
| 60 | + if (!isTeamDay && isTeamMetcon) { |
| 61 | + issues.push(`${sess.date}: non-team day but ${sess.metcon} is a team metcon`) |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +// === 6. Accessory only on Mon/Wed/Fri, and not when metcon TC > 20 === |
| 66 | +for (const sess of s.sessions) { |
| 67 | + const dow = new Date(sess.date).getDay() // 0=Sun, 1=Mon, ..., 6=Sat |
| 68 | + const hasAccessory = sess.accessory !== null && sess.accessory !== undefined |
| 69 | + const accessoryDays = [1, 3, 5] // Mon, Wed, Fri |
| 70 | + |
| 71 | + if (hasAccessory && !accessoryDays.includes(dow)) { |
| 72 | + issues.push(`${sess.date}: has accessory but is not Mon/Wed/Fri`) |
| 73 | + } |
| 74 | + |
| 75 | + if (hasAccessory && sess.metcon) { |
| 76 | + const mc = metconsByCode.get(sess.metcon) |
| 77 | + if (mc && mc.timeCap > 20) { |
| 78 | + issues.push(`${sess.date}: has accessory but metcon ${sess.metcon} timeCap is ${mc.timeCap} (>20 min)`) |
| 79 | + } |
| 80 | + } |
| 81 | +} |
| 82 | + |
| 83 | +// === 7. Time budget: estimatedMinutes must equal sum of parts === |
| 84 | +for (const sess of s.sessions) { |
| 85 | + const w = sess.warmup?.durationMinutes || 0 |
| 86 | + const st = sess.strength?.durationMinutes || 0 |
| 87 | + const mc = sess.metcon ? metconsByCode.get(sess.metcon) : null |
| 88 | + const tc = mc ? mc.timeCap : 0 |
| 89 | + const a = sess.accessory?.durationMinutes || 0 |
| 90 | + const sum = w + st + tc + a |
| 91 | + if (sum !== sess.estimatedMinutes) { |
| 92 | + issues.push(`${sess.date}: estimatedMinutes is ${sess.estimatedMinutes} but parts sum to ${sum} (warmup:${w} + strength:${st} + metcon:${tc} + accessory:${a})`) |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +// === 8. Session duration constraints === |
| 97 | +for (const sess of s.sessions) { |
| 98 | + if (sess.estimatedMinutes > 60) { |
| 99 | + issues.push(`${sess.date}: estimatedMinutes is ${sess.estimatedMinutes} (max 60)`) |
| 100 | + } |
| 101 | + if (sess.warmup && sess.warmup.durationMinutes > 12) { |
| 102 | + issues.push(`${sess.date}: warmup is ${sess.warmup.durationMinutes} min (max 12)`) |
| 103 | + } |
| 104 | + if (sess.strength && sess.strength.durationMinutes > 20) { |
| 105 | + issues.push(`${sess.date}: strength is ${sess.strength.durationMinutes} min (max 20)`) |
| 106 | + } |
| 107 | + if (sess.accessory && sess.accessory.durationMinutes > 15) { |
| 108 | + issues.push(`${sess.date}: accessory is ${sess.accessory.durationMinutes} min (max 15)`) |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +// === 9. Strength block single-focus rule === |
| 113 | +for (const sess of s.sessions) { |
| 114 | + if (sess.strength && sess.strength.movements && sess.strength.movements.length > 1) { |
| 115 | + issues.push(`${sess.date}: strength block has ${sess.strength.movements.length} movements (max 1)`) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +// === 10. Accessory max 3 exercises (heuristic: count \n-separated lines) === |
| 120 | +for (const sess of s.sessions) { |
| 121 | + if (sess.accessory && sess.accessory.notes) { |
| 122 | + const lines = sess.accessory.notes.split("\n").filter(l => l.trim().length > 0) |
| 123 | + if (lines.length > 3) { |
| 124 | + issues.push(`${sess.date}: accessory has ${lines.length} exercises (max 3)`) |
| 125 | + } |
| 126 | + } |
| 127 | +} |
| 128 | + |
| 129 | +// === 11. Long metcon + strength constraint === |
| 130 | +for (const sess of s.sessions) { |
| 131 | + if (!sess.metcon || !sess.strength) continue |
| 132 | + const mc = metconsByCode.get(sess.metcon) |
| 133 | + if (!mc) continue |
| 134 | + if (mc.timeCap >= 40) { |
| 135 | + issues.push(`${sess.date}: metcon ${sess.metcon} timeCap is ${mc.timeCap} but strength is not null`) |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +// === 12. No duplicate metcon usage on same date === |
| 140 | +// (already covered by duplicate dates, but check for duplicate codes across different dates) |
| 141 | +const codeUsage = new Map() |
| 142 | +for (const sess of s.sessions) { |
| 143 | + if (!sess.metcon) continue |
| 144 | + if (!codeUsage.has(sess.metcon)) codeUsage.set(sess.metcon, []) |
| 145 | + codeUsage.get(sess.metcon).push(sess.date) |
| 146 | +} |
| 147 | + |
| 148 | +// === 13. Required session fields === |
| 149 | +for (const sess of s.sessions) { |
| 150 | + if (!sess.date) issues.push("Session missing date") |
| 151 | + if (!sess.title) issues.push(`${sess.date || "?"}: missing title`) |
| 152 | + if (sess.estimatedMinutes === undefined) issues.push(`${sess.date}: missing estimatedMinutes`) |
| 153 | +} |
| 154 | + |
| 155 | +// === Summary === |
| 156 | +console.log(`Audited ${s.sessions.length} sessions (${dates[0]} to ${dates[dates.length - 1]})`) |
| 157 | +console.log(`Metcon library: ${m.metcons.length} metcons, ${usedCodes.size} used in sessions`) |
| 158 | +if (issues.length === 0) { |
| 159 | + console.log("ALL CHECKS PASSED") |
| 160 | +} else { |
| 161 | + console.log(`\n${issues.length} ISSUES:`) |
| 162 | + issues.forEach(i => console.log(` ${i}`)) |
| 163 | + process.exit(1) |
| 164 | +} |
0 commit comments