Feature Request: Average Grade / Difficulty Score in Tick Breakdown & Pitches by Year
|
|
I built a working demo of this on my own tick list so you can see exactly what it'd look like, not just describe it: https://claude.ai/code/artifact/b93e6783-e2c6-4611-89cc-970cd084d251 Example: The idea: add a calculated average difficulty score to personal profiles, based on ticked routes. Not just a flat average either -- a route you onsight-led counts more than one you top-roped, and (optionally) a route from this season counts more than one from a decade ago. Simple example: 5.7 + 5.9 doesn't have to just be "5.8 avg" -- it can weight for how it was climbed and when. Where it'd slot in naturally: Tick Breakdown -- same four windows you already have (Last 90 Days / Last Year / Last 5 Years / All Time), each showing an average grade alongside the existing pitch/day counts. Pitches by Year -- same idea, plotted as a trend across the x-axis, so climbers can see their average grade progress year over year instead of just volume. Why it's useful: it gives climbers a real way to track progression and set goals, and it'd make Partner Finder matches more accurate than self-reported grades, which everyone knows run optimistic. I put together a small, dependency-free JS reference implementation showing the math end-to-end (grade parsing, the weighting, and both the Tick Breakdown and Pitches-by-Year groupings) -- pasted below if anyone wants to see it work or build on it directly. [insert into JS or your AI model of choice] /** * Average Climbing Grade — reference implementation * Proof-of-concept for a Mountain Project feature request: a weighted average difficulty score per climber, computable over any span of ticks (all time, last 5 years, last year, last 90 days — matching the existing "Tick Breakdown" windows — or grouped by year, matching "Pitches by Year"). * Why not a plain average? A plain mean treats a top-roped 5.7 from a decade ago the same as an onsight lead last week. This weights each tick by (a) how it was climbed and (b) optionally, how recently — before averaging. * No dependencies. Runs as-is in Node or a browser console. YDS (5.x) grades only — ice (WI), aid (A/C), and boulder (V) grades live on different scales and would need their own averageGrade() pass rather than being mixed into this one./ // ---- 1. Grade <-> numeric scale ------------------------------------------ // 5.0-5.9 map straight to 0-9 (+/- shifts by 0.3). 5.10 and up move to a // continuous integer scale so a/b/c/d sort correctly across number grades: // 5.10a=10, 5.10b=11, 5.10c=12, 5.10d=13, 5.11a=14 ... const LETTER_OFFSET = { a: 0, b: 1, c: 2, d: 3 }; function gradeToNumber(grade) { const g = grade.trim(); if (g.includes('/')) { // slash grades ("5.10a/b") average their two endpoints const [first, ...rest] = g.split('/'); const base = first.match(/^5\.(\d+)/)[1]; const vals = [first, ...rest.map(p => (/^[a-d][+-]?$/.test(p) ? `5.${base}${p}` : p))] .map(gradeToNumber) .filter(v => v !== null); return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; } const m = g.match(/^5\.(\d+)([+-]?)([a-d]?)$/); if (!m) return null; // not a YDS grade const [, numStr, mod, letter] = m; const num = parseInt(numStr, 10); if (num <= 9) { return num + (mod === '+' ? 0.3 : mod === '-' ? -0.3 : 0); } const base = 10 + (num - 10) * 4; if (letter) return base + LETTER_OFFSET[letter]; return base + (mod === '+' ? 3 : mod === '-' ? 0 : 1.5); // bare "5.10" -> midpoint } function numberToGrade(v) { if (v <= 9.5) { const whole = Math.round(v); const frac = v - whole; return frac > 0.15 ? `5.${whole}+` : frac < -0.15 ? `5.${whole}-` : `5.${whole}`; } const rem = v - 10; const num = 10 + Math.floor(rem / 4); const letter = ['a', 'b', 'c', 'd'][Math.min(3, Math.max(0, Math.round(rem % 4)))]; return `5.${num}${letter}`; } // ---- 2. Weights ------------------------------------------------------------ // Style: how the route was climbed. A clean lead demonstrates the grade; // a top-rope or an attempt demonstrates less (or nothing) of it. const STYLE_WEIGHT = { onsight: 1.0, flash: 1.0, redpoint: 1.0, pinkpoint: 1.0, lead: 1.0, solo: 1.0, 'fell/hung': 0.7, // worked/rehearsed, not clean, but completed tr: 0.6, // top-rope — didn't lead it follow: 0.5, // seconding a multi-pitch attempt: 0, dnf: 0, // excluded — route wasn't finished }; // Recency: optional exponential decay so a current tick counts more than // an old one. Off by default — turn it on for a single "your current // grade" headline number; leave it off when you're already bucketing by // time window (Tick Breakdown) or by year (Pitches by Year), since the // bucket itself is doing the recency filtering. function recencyWeight(tickDate, referenceDate = new Date(), halfLifeYears = 3) { const yearsAgo = (referenceDate - tickDate) / (1000 * 60 * 60 * 24 * 365.25); return Math.pow(0.5, yearsAgo / halfLifeYears); } // ---- 3. Core equation ------------------------------------------------------- // D = Σ(wᵢ · gᵢ) / Σ(wᵢ), wᵢ = styleWeight(i) · recencyWeight(i) // // tick shape: { grade: '5.10a', style: 'onsight', date: '2024-06-01' } function averageGrade(ticks, { useRecency = false, halfLifeYears = 3, referenceDate = new Date() } = {}) { let num = 0, den = 0, counted = 0; for (const t of ticks) { const g = gradeToNumber(t.grade); if (g === null) continue; // not a YDS grade — skip (ice/boulder need their own pass) const s = STYLE_WEIGHT[(t.style || '').toLowerCase()] ?? 0.5; if (s === 0) continue; // attempt / DNF const r = useRecency ? recencyWeight(new Date(t.date), referenceDate, halfLifeYears) : 1; const w = s * r; num += g * w; den += w; counted++; } if (den === 0) return null; return { numeric: +(num / den).toFixed(2), grade: numberToGrade(num / den), ticksCounted: counted, ticksTotal: ticks.length }; } // ---- 4. "Tick Breakdown" x-axis: bucket by MP's existing windows ----------- function averageGradeByWindow(ticks, referenceDate = new Date()) { const DAY = 1000 * 60 * 60 * 24; const windows = { 'Last 90 Days': 90 * DAY, 'Last Year': 365 * DAY, 'Last 5 Years': 5 * 365 * DAY, 'All Time': Infinity, }; const out = {}; for (const [label, span] of Object.entries(windows)) { const subset = ticks.filter(t => referenceDate - new Date(t.date) <= span); out[label] = averageGrade(subset); } return out; } // ---- 5. "Pitches by Year" x-axis: same idea, grouped by calendar year ------ function averageGradeByYear(ticks) { const byYear = {}; for (const t of ticks) { const y = new Date(t.date).getFullYear(); (byYear[y] ??= []).push(t); } const out = {}; for (const [year, subset] of Object.entries(byYear).sort()) { out[year] = averageGrade(subset); } return out; } // ---- Example ----------------------------------------------------------- const sampleTicks = [ { grade: '5.7', style: 'onsight', date: '2022-04-04' }, { grade: '5.9', style: 'lead', date: '2023-05-16' }, { grade: '5.10a', style: 'redpoint', date: '2025-06-01' }, { grade: '5.8', style: 'tr', date: '2021-01-10' }, { grade: '5.9', style: 'attempt', date: '2025-08-02' }, // excluded — not finished ]; console.log('Overall (unweighted for recency):', averageGrade(sampleTicks)); // -> { numeric: 8.13, grade: '5.8', ticksCounted: 5 } console.log('Overall, current-form headline (recency-weighted):', averageGrade(sampleTicks, { useRecency: true })); console.log('Tick Breakdown:', averageGradeByWindow(sampleTicks)); console.log('Pitches by Year:', averageGradeByYear(sampleTicks)); module.exports = { gradeToNumber, numberToGrade, averageGrade, averageGradeByWindow, averageGradeByYear }; ] Happy to answer questions or adjust the approach -- just wanted to make this concrete instead of hand-wavy. Would be great to see added to the site! -Hunter |








