<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"><title>Vinny</title><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="Vinny"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#111a1a"><link rel="manifest" id="manifest-link" href="#"><link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 180'%3E%3Crect width='180' height='180' rx='40' fill='%231a7a7a'/%3E%3Ctext x='90' y='116' font-family='Arial' font-weight='bold' font-size='72' text-anchor='middle' fill='white'%3EUT%3C/text%3E%3C/svg%3E"><script>window.addEventListener('DOMContentLoaded', function() { var manifestData = { name: "Vinny \u2014 Ultra Training", short_name: "Vinny", start_url: "./", display: "standalone", background_color: "#111a1a", theme_color: "#111a1a", icons: [{ src: "data:image/png;base64,iVBORw0KGgo=", sizes: "192x192", type: "image/png" }] }; var blob = new Blob([JSON.stringify(manifestData)], {type: 'application/json'}); document.getElementById('manifest-link').href = URL.createObjectURL(blob);});</script><link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet"><style> :root { --teal: #1a7a7a; --teal-light: #2a9d9d; --teal-dark: #0f5252; --teal-faint: #e8f5f5; --bone: #f5f0e8; --dark: #111a1a; --mid: #3d5252; --gray: #8a9e9e; --gray-light: #c5d5d5; --white: #ffffff; --accent: #e8622a; --accent-light: #fdf0ea; --green: #2a7a4a; --green-light: #e8f5ee; --shadow: 0 4px 24px rgba(26,122,122,0.12); --shadow-lg: 0 8px 40px rgba(26,122,122,0.18); } * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } body { font-family: 'Syne', sans-serif; background: var(--dark); color: var(--dark); min-height: 100vh; overflow-x: hidden; } /* ── HEADER ── */ .header { background: var(--dark); padding: 20px 20px 0; position: sticky; top: 0; z-index: 100; } .header-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .logo { font-size: 11px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--teal-light); font-family: 'DM Mono', monospace; } .header-date { font-size: 11px; color: var(--gray); font-family: 'DM Mono', monospace; letter-spacing: 0.05em; } .header-races { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 16px; -webkit-overflow-scrolling: touch; scrollbar-width: none; } .header-races::-webkit-scrollbar { display: none; } .race-chip { flex-shrink: 0; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 10px 14px; cursor: pointer; transition: all 0.2s; } .race-chip.a-race { background: rgba(26,122,122,0.25); border-color: var(--teal); } .race-chip-name { font-size: 11px; font-weight: 700; color: var(--white); letter-spacing: 0.05em; display: block; } .race-chip-date { font-size: 10px; color: var(--gray); font-family: 'DM Mono', monospace; margin-top: 2px; display: block; } .race-chip-days { font-size: 13px; font-weight: 800; color: var(--teal-light); margin-top: 4px; display: block; } .race-chip.a-race .race-chip-days { color: #7fd4d4; } /* ── NAV TABS ── */ .tabs { background: var(--dark); display: flex; border-bottom: 1px solid rgba(255,255,255,0.08); position: sticky; top: 0; z-index: 99; } .tab { flex: 1; padding: 14px 8px; text-align: center; font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--gray); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; } .tab.active { color: var(--teal-light); border-bottom-color: var(--teal-light); } /* ── CONTENT ── */ .content { background: var(--bone); min-height: calc(100vh - 160px); border-radius: 20px 20px 0 0; } .section { display: none; padding: 24px 20px; } .section.active { display: block; } /* ── BLOCK VIEW ── */ .block-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; } .block-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--teal); font-family: 'DM Mono', monospace; } .block-title { font-size: 26px; font-weight: 800; color: var(--dark); line-height: 1.1; margin-top: 4px; } .block-subtitle { font-size: 13px; color: var(--mid); margin-top: 4px; font-family: 'DM Mono', monospace; } .block-nav { display: flex; gap: 6px; } .block-btn { width: 36px; height: 36px; border-radius: 50%; border: 1.5px solid var(--gray-light); background: white; color: var(--dark); font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .block-btn:active { background: var(--teal); color: white; border-color: var(--teal); } /* ── DAYS GRID ── */ .week-label { font-size: 10px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: var(--gray); font-family: 'DM Mono', monospace; margin: 16px 0 8px; } .days-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 4px; } .day-col-header { text-align: center; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: var(--gray); font-family: 'DM Mono', monospace; padding-bottom: 4px; } .day-card { background: white; border-radius: 10px; padding: 8px 4px; text-align: center; cursor: pointer; border: 1.5px solid transparent; transition: all 0.15s; position: relative; min-height: 72px; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 2px; } .day-card:active { transform: scale(0.96); } .day-card.logged { background: var(--green-light); border-color: var(--green); } .day-card.today { border-color: var(--teal); background: var(--teal-faint); } .day-card.rest { background: #f8f4ee; } .day-card.active-day { border-color: var(--accent); background: var(--accent-light); } .day-num { font-size: 11px; font-weight: 700; color: var(--dark); font-family: 'DM Mono', monospace; } .day-type { font-size: 8px; color: var(--mid); line-height: 1.2; font-weight: 600; letter-spacing: 0.02em; padding: 0 2px; } .day-check { font-size: 14px; margin-top: 2px; } .day-rpe { font-size: 8px; font-family: 'DM Mono', monospace; color: var(--teal); font-weight: 700; background: var(--teal-faint); padding: 1px 4px; border-radius: 4px; } /* ── PHASE BANNER ── */ .phase-banner { background: var(--dark); border-radius: 12px; padding: 14px 16px; margin-bottom: 20px; display: flex; align-items: center; gap: 12px; } .phase-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--teal-light); flex-shrink: 0; } .phase-text { font-size: 12px; color: var(--gray-light); line-height: 1.4; } .phase-name { font-weight: 700; color: var(--white); display: block; } /* ── LOG MODAL ── */ .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(10,20,20,0.85); z-index: 200; align-items: flex-end; justify-content: center; } .modal-overlay.open { display: flex; } .modal { background: var(--white); border-radius: 24px 24px 0 0; padding: 28px 24px 40px; width: 100%; max-width: 500px; max-height: 88vh; overflow-y: auto; animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } .modal-handle { width: 40px; height: 4px; background: var(--gray-light); border-radius: 2px; margin: 0 auto 20px; } .modal-title { font-size: 22px; font-weight: 800; color: var(--dark); margin-bottom: 4px; } .modal-subtitle { font-size: 13px; color: var(--gray); font-family: 'DM Mono', monospace; margin-bottom: 24px; } .form-group { margin-bottom: 18px; } .form-label { font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--teal); display: block; margin-bottom: 8px; font-family: 'DM Mono', monospace; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 12px 14px; border: 1.5px solid var(--gray-light); border-radius: 10px; font-family: 'Syne', sans-serif; font-size: 15px; color: var(--dark); background: var(--bone); outline: none; transition: border-color 0.2s; -webkit-appearance: none; } input:focus, textarea:focus, select:focus { border-color: var(--teal); background: white; } textarea { resize: none; height: 80px; line-height: 1.5; } /* RPE slider */ .rpe-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 3px; background: var(--gray-light); outline: none; border: none; padding: 0; } .rpe-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; border-radius: 50%; background: var(--teal); cursor: pointer; box-shadow: 0 2px 8px rgba(26,122,122,0.4); } .rpe-labels { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10px; color: var(--gray); font-family: 'DM Mono', monospace; } .rpe-value { text-align: center; font-size: 32px; font-weight: 800; color: var(--teal); font-family: 'DM Mono', monospace; margin-top: 4px; } /* Knee toggle */ .knee-options { display: flex; gap: 8px; } .knee-opt { flex: 1; padding: 10px 8px; border: 1.5px solid var(--gray-light); border-radius: 10px; text-align: center; cursor: pointer; font-size: 12px; font-weight: 700; color: var(--mid); transition: all 0.15s; } .knee-opt.selected { border-color: var(--teal); background: var(--teal-faint); color: var(--teal); } .knee-opt.warn.selected { border-color: var(--accent); background: var(--accent-light); color: var(--accent); } .btn-primary { width: 100%; padding: 16px; background: var(--teal); color: white; border: none; border-radius: 12px; font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 700; cursor: pointer; margin-top: 8px; letter-spacing: 0.05em; transition: all 0.2s; } .btn-primary:active { background: var(--teal-dark); transform: scale(0.98); } .btn-ghost { width: 100%; padding: 14px; background: transparent; color: var(--gray); border: 1.5px solid var(--gray-light); border-radius: 12px; font-family: 'Syne', sans-serif; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 8px; } /* ── LOG LIST ── */ .log-entry { background: white; border-radius: 14px; padding: 16px; margin-bottom: 12px; border-left: 3px solid var(--teal); } .log-entry.flagged { border-left-color: var(--accent); } .log-entry-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } .log-entry-date { font-size: 11px; font-family: 'DM Mono', monospace; color: var(--gray); } .log-entry-title { font-size: 16px; font-weight: 700; color: var(--dark); } .log-entry-stats { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; } .stat-chip { font-size: 11px; font-family: 'DM Mono', monospace; color: var(--teal); background: var(--teal-faint); padding: 3px 8px; border-radius: 6px; font-weight: 700; } .log-note { font-size: 13px; color: var(--mid); margin-top: 8px; line-height: 1.5; font-family: 'Instrument Serif', serif; font-style: italic; } /* ── CHECKIN SECTION ── */ .checkin-card { background: white; border-radius: 14px; padding: 18px; margin-bottom: 12px; display: flex; align-items: center; gap: 14px; } .checkin-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--gray-light); flex-shrink: 0; } .checkin-dot.next { background: var(--teal); box-shadow: 0 0 0 4px var(--teal-faint); } .checkin-dot.done { background: var(--green); } .checkin-info { flex: 1; } .checkin-title { font-size: 15px; font-weight: 700; color: var(--dark); } .checkin-date { font-size: 11px; font-family: 'DM Mono', monospace; color: var(--gray); margin-top: 2px; } .checkin-status { font-size: 11px; font-weight: 700; color: var(--teal); } /* ── SUMMARY STATS ── */ .stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; } .stat-card { background: white; border-radius: 14px; padding: 16px; text-align: center; } .stat-card-value { font-size: 32px; font-weight: 800; color: var(--teal); font-family: 'DM Mono', monospace; display: block; } .stat-card-label { font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--gray); display: block; margin-top: 4px; } /* ── WEATHER WIDGET ── */ .weather-card { background: var(--dark); border-radius: 14px; padding: 18px; margin-bottom: 20px; color: white; } .weather-label { font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--gray); font-family: 'DM Mono', monospace; margin-bottom: 8px; } .weather-location { font-size: 14px; font-weight: 700; color: white; } .weather-note { font-size: 12px; color: var(--gray); margin-top: 6px; line-height: 1.5; } /* Toast */ .toast { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(80px); background: var(--dark); color: white; padding: 12px 24px; border-radius: 100px; font-size: 13px; font-weight: 700; z-index: 300; transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); white-space: nowrap; } .toast.show { transform: translateX(-50%) translateY(0); } .suunto-link { width: 100%; padding: 12px 14px; border: 1.5px dashed var(--teal-light); border-radius: 10px; background: var(--teal-faint); font-family: 'DM Mono', monospace; font-size: 13px; color: var(--teal); outline: none; } .gpx-upload-btn { display: block; width: 100%; padding: 12px 14px; background: var(--teal-faint); border: 1.5px dashed var(--teal-light); border-radius: 10px; font-family: 'Syne', sans-serif; font-size: 13px; font-weight: 600; color: var(--teal); cursor: pointer; text-align: center; transition: all 0.2s; } .gpx-upload-btn:active { border-style: solid; } .gpx-status { font-size: 12px; font-family: 'DM Mono', monospace; color: var(--green); margin-top: 6px; min-height: 16px; font-weight: 600; } .gpx-status.error { color: var(--accent); } .add-session-btn { width: 100%; padding: 10px; background: transparent; border: 1.5px dashed var(--gray-light); border-radius: 10px; font-family: 'Syne', sans-serif; font-size: 13px; font-weight: 600; color: var(--gray); cursor: pointer; text-align: center; transition: all 0.2s; margin-bottom: 4px; } .add-session-btn:active { border-color: var(--teal); color: var(--teal); } .session-divider { display: flex; align-items: center; gap: 10px; margin: 16px 0 12px; } .session-divider-line { flex: 1; height: 1px; background: var(--gray-light); } .session-divider-label { font-size: 10px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: var(--teal); font-family: 'DM Mono', monospace; } .session2-block { display: none; } .session2-block.open { display: block; } .empty-state { text-align: center; padding: 40px 20px; color: var(--gray); } .empty-state-icon { font-size: 48px; margin-bottom: 12px; } .empty-state-text { font-size: 15px; font-weight: 600; color: var(--mid); } .empty-state-sub { font-size: 13px; color: var(--gray); margin-top: 4px; }</style></head><body><div class="header"> <div class="header-top"> <div class="logo">Ultra Training 2026</div> <div class="header-date" id="todayDate"></div> </div> <div class="header-races" id="raceChips"></div></div><div class="tabs"> <div class="tab active" onclick="switchTab('block')">Block</div> <div class="tab" onclick="switchTab('log')">My Runs</div> <div class="tab" onclick="switchTab('schedule')">Schedule</div> <div class="tab" onclick="switchTab('checkin')">Check-Ins</div></div><div class="content"> <!-- BLOCK VIEW --> <div class="section active" id="section-block"> <div class="block-header"> <div> <div class="block-label" id="blockLabel">Block 1</div> <div class="block-title" id="blockTitle">Baseline</div> <div class="block-subtitle" id="blockDates">Feb 21 – Mar 6</div> </div> <div class="block-nav"> <button class="block-btn" onclick="prevBlock()">‹</button> <button class="block-btn" onclick="nextBlock()">›</button> </div> </div> <div class="phase-banner" id="phaseBanner"> <div class="phase-dot"></div> <div class="phase-text"> <span class="phase-name" id="phaseName">Phase 1: Base & Build</span> <span id="phaseDesc">Aerobic foundation, trail volume, strength</span> </div> </div> <div class="week-label">Week 1</div> <div class="days-grid" id="dayHeaders1"></div> <div class="days-grid" id="week1Grid"></div> <div class="week-label">Week 2</div> <div class="days-grid" id="dayHeaders2"></div> <div class="days-grid" id="week2Grid"></div> </div> <!-- LOG VIEW --> <div class="section" id="section-log"> <div class="stats-grid" id="statsGrid"></div> <div id="logList"></div> </div> <!-- SCHEDULE VIEW --> <div class="section" id="section-schedule"> <div class="weather-card"> <div class="weather-label">📍 Dryden, NY — 13068</div> <div class="weather-location">Weather checked at every 2-week check-in</div> <div class="weather-note">Snow forecast = run day moved. No heroics in heavy snow. Pull up weather at each check-in to adjust the upcoming block.</div> </div> <div id="fullSchedule"></div> </div> <!-- CHECKIN VIEW --> <div class="section" id="section-checkin"> <div id="checkinList"></div> </div></div><!-- LOG MODAL --><div class="modal-overlay" id="logModal"> <div class="modal"> <div class="modal-handle"></div> <div class="modal-title" id="modalTitle">Log Run</div> <div class="modal-subtitle" id="modalSubtitle"></div> <div class="form-group"> <label class="form-label">Upload GPX / FIT File</label> <label class="gpx-upload-btn" id="gpxUploadBtn" for="gpxFile"> <span id="gpxBtnText">📁 Upload Suunto export to auto-fill</span> <input type="file" id="gpxFile" accept=".gpx,.fit" style="display:none" onchange="parseActivityFile(this, 1)"> </label> <div class="gpx-status" id="gpxStatus"></div> </div> <div class="form-group"> <label class="form-label">Distance & Time</label> <div class="form-row"> <input type="number" id="inputDist" placeholder="Miles" step="0.1"> <input type="text" id="inputTime" placeholder="H:MM e.g. 2:30"> </div> </div> <div class="form-group"> <label class="form-label">Effort (RPE)</label> <input type="range" class="rpe-slider" id="rpeSlider" min="1" max="10" value="5" oninput="updateRPE(this.value)"> <div class="rpe-labels"><span>Easy</span><span>Moderate</span><span>Max</span></div> <div class="rpe-value" id="rpeVal">5</div> </div> <div class="form-group"> <label class="form-label">Knee Feel</label> <div class="knee-options"> <div class="knee-opt selected" data-val="great" onclick="selectKnee(this)">👍 Great</div> <div class="knee-opt" data-val="fine" onclick="selectKnee(this)">👌 Fine</div> <div class="knee-opt warn" data-val="grumpy" onclick="selectKnee(this)">⚠️ Grumpy</div> </div> </div> <div class="form-group"> <label class="form-label">Notes — how did it feel?</label> <textarea id="inputNote" placeholder="Legs felt good on the climb, HR stayed controlled, ate well at mile 10..."></textarea> </div> <button class="add-session-btn" id="addSession2Btn" onclick="toggleSession2()">+ Add 2nd session</button> <div class="session2-block" id="session2Block"> <div class="session-divider"> <div class="session-divider-line"></div> <div class="session-divider-label">Session 2</div> <div class="session-divider-line"></div> </div> <div class="form-group"> <label class="form-label">Upload GPX / FIT File</label> <label class="gpx-upload-btn" for="gpxFile2"> <span id="gpxBtnText2">📁 Upload Suunto export to auto-fill</span> <input type="file" id="gpxFile2" accept=".gpx,.fit" style="display:none" onchange="parseActivityFile(this, 2)"> </label> <div class="gpx-status" id="gpxStatus2"></div> </div> <div class="form-group"> <label class="form-label">Distance & Time</label> <div class="form-row"> <input type="number" id="inputDist2" placeholder="Miles" step="0.1"> <input type="text" id="inputTime2" placeholder="H:MM e.g. 0:45"> </div> </div> <div class="form-group"> <label class="form-label">Effort (RPE)</label> <input type="range" class="rpe-slider" id="rpeSlider2" min="1" max="10" value="5" oninput="updateRPE2(this.value)"> <div class="rpe-labels"><span>Easy</span><span>Moderate</span><span>Max</span></div> <div class="rpe-value" id="rpeVal2">5</div> </div> <div class="form-group"> <label class="form-label">Notes</label> <textarea id="inputNote2" placeholder="Second session notes..."></textarea> </div> </div> <button class="btn-primary" onclick="saveLog()">Save ✓</button> <button class="btn-ghost" onclick="closeModal()">Cancel</button> </div></div><div class="toast" id="toast"></div><script>// ── DATA ──────────────────────────────────────────────────────const RACES = [ { name: "Laurel 70", date: "2026-06-13", goal: "15-16 hrs", aRace: false }, { name: "Escarpment", date: "2026-07-26", goal: "Enjoy it", aRace: false }, { name: "Dark Sky 220", date: "2026-10-25", goal: "A-RACE", aRace: true },];const CHECKINS = [ { label: "Block 1 Review", date: "2026-03-07" }, { label: "Block 2 Review", date: "2026-03-21" }, { label: "Block 3 Review", date: "2026-04-04" }, { label: "Block 4 Review", date: "2026-04-18" }, { label: "Block 5 Review", date: "2026-05-02" }, { label: "Block 6 Review", date: "2026-05-16" }, { label: "Block 7 / Laurel Taper", date: "2026-05-30" }, { label: "Post-Laurel Recovery", date: "2026-06-27" }, { label: "Block 9 Review", date: "2026-07-11" }, { label: "Escarpment Week", date: "2026-07-26" }, { label: "Block 11 Review", date: "2026-08-09" }, { label: "Block 12 Review", date: "2026-08-23" }, { label: "Peak Build I", date: "2026-09-06" }, { label: "Peak Build II", date: "2026-09-20" }, { label: "Cut-back Review", date: "2026-10-04" }, { label: "Dark Sky Race Prep 🏁", date: "2026-10-18" },];const PHASES = [ { name: "Phase 1: Base & Build", desc: "Aerobic foundation, trail volume, strength", color: "#1a7a7a" }, { name: "Phase 2: Laurel Prep", desc: "Race-specific training & taper", color: "#2a7a4a" }, { name: "Phase 3: Recovery & Sharpen", desc: "Rebuild after Laurel, Escarpment prep", color: "#7a4a1a" }, { name: "Phase 4: Dark Sky 220 Build", desc: "Peak volume, night running, back-to-backs", color: "#1a1a7a" },];const BLOCKS = [ // Phase 1 { num: 1, label: "Baseline", dates: "Feb 21 – Mar 6", mileage: "45-50", phase: 0, week1: ["Easy 6-8mi", "Strength A", "Mod 8-10mi + strides", "Easy 5mi + Yoga", "Dog Walk", "Long 14-16mi", "Dog Walk + Mobility"], week2: ["Easy 6mi", "Strength A", "Tempo intervals 7mi", "Easy 5mi + Yoga", "Dog Walk", "Long 16-18mi", "Dog Walk + Mobility"] }, { num: 2, label: "Build — Climbing", dates: "Mar 7 – Mar 20", mileage: "48-54", phase: 0, week1: ["Easy 7mi", "Strength A", "Hill repeats 8mi", "Easy 6mi + Yoga", "Dog Walk", "Long trail 18-20mi", "Dog Walk"], week2: ["Easy 7mi", "Strength B", "Moderate 9mi", "Easy 5mi + Yoga", "Strides 5mi", "Long 20-22mi", "Dog Walk"] }, { num: 3, label: "Back-to-Backs", dates: "Mar 21 – Apr 3", mileage: "52-58", phase: 0, week1: ["Easy 7mi", "Strength A", "Moderate 10mi", "Easy 6mi + Yoga", "Dog Walk", "Long 22-24mi trail", "Moderate 12mi (B2B)"], week2: ["Easy 7mi", "Strength B", "Hill work 9mi", "Easy 6mi + Yoga", "Dog Walk", "Long 24-26mi", "Moderate 12mi (B2B)"] }, { num: 4, label: "Peak Base", dates: "Apr 4 – Apr 17", mileage: "55-60", phase: 0, week1: ["Easy 7mi", "Strength A", "Mod 10mi + surges", "Easy 6mi + Yoga", "Easy 5mi", "Long 26-28mi", "Moderate 14mi (B2B)"], week2: ["Easy 8mi", "Strength B", "Tempo 10mi", "Easy 6mi + Yoga", "Dog Walk", "Long 28-30mi", "Easy 12mi (B2B)"] }, { num: 5, label: "Cut-back & Sharpen", dates: "Apr 18 – May 2", mileage: "52-55", phase: 0, week1: ["Easy 6mi", "Strength A", "Moderate 9mi", "Easy 5mi + Yoga", "Dog Walk", "Long 22-24mi", "Easy 10mi"], week2: ["Easy 6mi", "Strength A", "Race-pace 8mi", "Easy 5mi + Yoga", "Easy 4mi", "Long 18-20mi", "Dog Walk + Yoga"] }, // Phase 2 { num: 6, label: "Laurel Volume", dates: "May 3 – May 16", mileage: "55-58", phase: 1, week1: ["Easy 7mi", "Strength A", "Long tempo 12mi", "Easy 6mi + Yoga", "Dog Walk", "Long 28-32mi trail", "Moderate 14mi (B2B)"], week2: ["Easy 7mi", "Strength A", "Pace work 10mi", "Easy 5mi + Yoga", "Easy 5mi", "Long 24-26mi", "Easy 10mi (B2B)"] }, { num: 7, label: "Taper Begins", dates: "May 17 – May 30", mileage: "48-52", phase: 1, week1: ["Easy 6mi", "Strength (light)", "Moderate 10mi", "Easy 5mi + Yoga", "Dog Walk", "Long 20-22mi", "Easy 8mi"], week2: ["Easy 5mi", "Mobility only", "Easy 8mi + pace", "Easy 4mi + Yoga", "Dog Walk", "Long 14-16mi", "Easy 6mi"] }, { num: 8, label: "Race Week — Laurel 🏁", dates: "May 31 – Jun 13", mileage: "30-35", phase: 1, week1: ["Easy 5mi", "Easy 4mi + strides", "Mobility + 3mi", "Rest / Dog Walk", "Easy 3mi shakeout", "Dog Walk", "Rest & Prep"], week2: ["LAUREL 70", "Jun 13 🏁", "15-16 hr goal", "Rest/recover", "", "", ""] }, // Phase 3 { num: 9, label: "Post-Laurel Recovery", dates: "Jun 14 – Jun 27", mileage: "25-35", phase: 2, week1: ["Dog Walk", "Yoga only", "Dog Walk", "Easy Mobility", "Dog Walk", "Easy Walk", "Rest"], week2: ["Easy run 30min", "Mobility", "Easy run 35min", "Easy 4mi + Yoga", "Dog Walk", "Easy trail 6mi", "Easy Walk"] }, { num: 10, label: "Rebuild + Technical", dates: "Jun 28 – Jul 11", mileage: "38-45", phase: 2, week1: ["Easy 6mi", "Strength A", "Technical trail 8mi", "Easy 5mi + Yoga", "Dog Walk", "Long hilly 16-18mi", "Easy 8mi"], week2: ["Easy 6mi", "Strength A", "Hill repeats 8mi", "Easy 5mi + Yoga", "Dog Walk", "Long trail 18-20mi", "Easy 8mi"] }, { num: 11, label: "Escarpment Prep 🏁", dates: "Jul 12 – Jul 26", mileage: "35-40", phase: 2, week1: ["Easy 6mi", "Strength (light)", "Technical climb 8mi", "Easy 5mi + Yoga", "Dog Walk", "Hilly long 16mi", "Easy 6mi"], week2: ["Easy 4mi", "Easy Mobility", "Easy 4mi shakeout", "Dog Walk", "Travel/Rest", "Easy 2mi", "ESCARPMENT 🏁"] }, // Phase 4 { num: 12, label: "DS220 Rebuild", dates: "Jul 27 – Aug 9", mileage: "45-52", phase: 3, week1: ["Easy 6mi", "Strength A", "Easy-mod 9mi", "Easy 6mi + Yoga", "Dog Walk", "Long 20-22mi", "Easy 10mi"], week2: ["Easy 7mi", "Strength B", "Trail 10mi", "Easy 6mi + Yoga", "Dog Walk", "Long 22-24mi", "Easy 10mi (B2B)"] }, { num: 13, label: "Volume Rising", dates: "Aug 10 – Aug 23", mileage: "52-58", phase: 3, week1: ["Easy 7mi", "Strength A", "Trail 10mi", "Easy 7mi + Yoga", "Dog Walk", "Long 26-28mi", "Moderate 14mi (B2B)"], week2: ["Easy 7mi", "Strength B", "Hill + surges 10mi", "Easy 6mi + Yoga", "Easy 5mi", "Long 28-32mi", "Moderate 15mi (B2B)"] }, { num: 14, label: "Peak Build I", dates: "Aug 24 – Sep 6", mileage: "55-62", phase: 3, week1: ["Easy 7mi", "Strength A", "Technical trail 10mi", "Easy 7mi + Yoga", "Dog Walk", "Long 30-35mi", "Moderate 16mi (B2B)"], week2: ["Easy 8mi", "Strength B", "Moderate 11mi", "Easy 7mi + Yoga", "Easy 5mi", "Long 32-36mi", "Moderate 16mi (B2B)"] }, { num: 15, label: "Peak Build II — Biggest", dates: "Sep 7 – Sep 20", mileage: "58-65", phase: 3, week1: ["Easy 8mi", "Strength A", "Trail 12mi", "Easy 7mi + Yoga", "Dog Walk", "Long 35-40mi", "Long 20mi (B2B)"], week2: ["Easy 8mi", "Strength B", "Pace work 10mi", "Easy 7mi + Yoga", "Easy 5mi", "Long 38-42mi", "Moderate 18mi (B2B)"] }, { num: 16, label: "Cut-back & Sharpen", dates: "Sep 21 – Oct 4", mileage: "48-54", phase: 3, week1: ["Easy 7mi", "Strength (light)", "Moderate trail 10mi", "Easy 6mi + Yoga", "Dog Walk", "Long 26-28mi", "Easy 12mi"], week2: ["Easy 7mi", "Strength (light)", "Trail 9mi", "Easy 5mi + Yoga", "Dog Walk", "Long 22-24mi", "Easy 10mi"] }, { num: 17, label: "Dark Sky Taper 🏁", dates: "Oct 5 – Oct 25", mileage: "35-42", phase: 3, week1: ["Easy 6mi", "Strength (light)", "Easy 8mi trail", "Easy 5mi + Yoga", "Dog Walk", "Long 18-20mi", "Easy 8mi"], week2: ["Easy 5mi", "Mobility only", "Easy 6mi + strides", "Easy 4mi + Yoga", "Dog Walk", "Easy 10mi", "DARK SKY 220 🏁"] },];const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];// ── STATE ─────────────────────────────────────────────────────let currentBlock = 0;let runLogs = [];let modalDay = null;let kneeVal = "great";// Storage — uses Claude's cross-device storage when available, falls back to localStorageasync function loadLogs() { try { if (window.storage) { const r = await window.storage.get('vinny-run-logs'); if (r) { runLogs = JSON.parse(r.value); return; } } } catch(e) {} try { const saved = localStorage.getItem('vinny-run-logs'); if (saved) runLogs = JSON.parse(saved); } catch(e) { runLogs = []; }}async function saveLogs() { const data = JSON.stringify(runLogs); try { if (window.storage) await window.storage.set('vinny-run-logs', data); } catch(e) {} try { localStorage.setItem('vinny-run-logs', data); } catch(e) {}}// ── INIT ──────────────────────────────────────────────────────async function init() { await loadLogs(); document.getElementById('todayDate').textContent = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); renderRaceChips(); findCurrentBlock(); renderCheckins(); renderFullSchedule();}function findCurrentBlock() { const today = new Date(); // Default to first block, but try to find the right one based on date // Block 1 starts Feb 21 2026 const blockStart = new Date('2026-02-21'); const diffDays = Math.floor((today - blockStart) / (1000 * 60 * 60 * 24)); if (diffDays >= 0) { const blockIdx = Math.min(Math.floor(diffDays / 14), BLOCKS.length - 1); currentBlock = Math.max(0, blockIdx); } renderBlock(currentBlock);}// ── RACE CHIPS ────────────────────────────────────────────────function renderRaceChips() { const today = new Date(); const container = document.getElementById('raceChips'); container.innerHTML = RACES.map(r => { const raceDate = new Date(r.date); const days = Math.ceil((raceDate - today) / (1000 * 60 * 60 * 24)); return `<div class="race-chip ${r.aRace ? 'a-race' : ''}"> <span class="race-chip-name">${r.name}</span> <span class="race-chip-date">${formatDate(r.date)}</span> <span class="race-chip-days">${days > 0 ? days + 'd away' : 'Race day!'}</span> </div>`; }).join('');}// ── BLOCK RENDER ──────────────────────────────────────────────function renderBlock(idx) { const b = BLOCKS[idx]; const phase = PHASES[b.phase]; document.getElementById('blockLabel').textContent = `Block ${b.num} of ${BLOCKS.length}`; document.getElementById('blockTitle').textContent = b.label; document.getElementById('blockDates').textContent = b.dates + ' · ~' + b.mileage + ' mi/wk'; document.getElementById('phaseName').textContent = phase.name; document.getElementById('phaseDesc').textContent = phase.desc; // Headers // Calculate block start date — Block 1 starts Feb 21 2026, each block is 14 days const blockStartDate = new Date('2026-02-21T00:00:00'); blockStartDate.setDate(blockStartDate.getDate() + (idx * 14)); // Remove static day headers — dates are now shown on cards ['dayHeaders1', 'dayHeaders2'].forEach(id => { document.getElementById(id).innerHTML = ''; }); renderWeek('week1Grid', b.week1, b.num, 1, blockStartDate, 0); renderWeek('week2Grid', b.week2, b.num, 2, blockStartDate, 7);}const FULL_DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];function renderWeek(gridId, days, blockNum, weekNum, blockStartDate, dayOffset) { const grid = document.getElementById(gridId); grid.innerHTML = days.map((desc, i) => { const dayKey = `b${blockNum}w${weekNum}d${i}`; const log = runLogs.find(l => l.dayKey === dayKey); const isRest = desc.toLowerCase().includes('dog walk') || desc.toLowerCase().includes('rest') || desc.toLowerCase().includes('yoga only') || desc.toLowerCase().includes('mobility only'); // Calculate actual date for this slot const d = new Date(blockStartDate); d.setDate(d.getDate() + dayOffset + i); const dayName = FULL_DAY_NAMES[d.getDay()]; const dateLabel = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const today = new Date(); const isToday = d.toDateString() === today.toDateString(); let cls = 'day-card'; if (log) cls += ' logged'; else if (isRest) cls += ' rest'; if (isToday) cls += ' today'; return `<div class="${cls}" onclick="openModal('${dayKey}', '${desc.replace(/'/g, "\\'")}', ${blockNum}, ${weekNum}, ${i}, '${dateLabel}')"> <span class="day-num">${dayName}</span> <span class="day-num" style="font-size:9px;color:var(--teal);margin-top:-1px">${dateLabel}</span> <span class="day-type">${desc}</span> ${log ? `<span class="day-check">✓</span><span class="day-rpe">RPE ${log.rpe}</span>` : ''} </div>`; }).join('');}function prevBlock() { if (currentBlock > 0) { currentBlock--; renderBlock(currentBlock); }}function nextBlock() { if (currentBlock < BLOCKS.length - 1) { currentBlock++; renderBlock(currentBlock); }}// ── MODAL ─────────────────────────────────────────────────────function openModal(dayKey, desc, blockNum, weekNum, dayIdx, dateLabel) { modalDay = { dayKey, desc }; document.getElementById('modalTitle').textContent = 'Log: ' + desc; document.getElementById('modalSubtitle').textContent = `Block ${blockNum} · Week ${weekNum} · ${DAY_NAMES[dayIdx]}${dateLabel ? ' · ' + dateLabel : ''}`; // Reset session 2 document.getElementById('session2Block').classList.remove('open'); document.getElementById('addSession2Btn').textContent = '+ Add 2nd session'; document.getElementById('inputDist2').value = ''; document.getElementById('inputTime2').value = ''; document.getElementById('rpeSlider2').value = 5; document.getElementById('rpeVal2').textContent = 5; document.getElementById('inputNote2').value = ''; document.getElementById('inputSuunto2').value = ''; // Pre-fill if existing const existing = runLogs.find(l => l.dayKey === dayKey); if (existing) { document.getElementById('inputDist').value = existing.dist || ''; document.getElementById('inputTime').value = existing.time || ''; document.getElementById('rpeSlider').value = existing.rpe || 5; document.getElementById('rpeVal').textContent = existing.rpe || 5; document.getElementById('inputNote').value = existing.note || ''; kneeVal = existing.knee || 'great'; if (existing.session2) { document.getElementById('session2Block').classList.add('open'); document.getElementById('addSession2Btn').textContent = '− Remove 2nd session'; document.getElementById('inputDist2').value = existing.session2.dist || ''; document.getElementById('inputTime2').value = existing.session2.time || ''; document.getElementById('rpeSlider2').value = existing.session2.rpe || 5; document.getElementById('rpeVal2').textContent = existing.session2.rpe || 5; document.getElementById('inputNote2').value = existing.session2.note || ''; } } else { document.getElementById('inputDist').value = ''; document.getElementById('inputTime').value = ''; document.getElementById('rpeSlider').value = 5; document.getElementById('rpeVal').textContent = 5; document.getElementById('inputNote').value = ''; kneeVal = 'great'; } document.querySelectorAll('.knee-opt').forEach(el => { el.classList.toggle('selected', el.dataset.val === kneeVal); }); document.getElementById('logModal').classList.add('open');}function closeModal() { document.getElementById('logModal').classList.remove('open');}// ── GPX / FIT PARSING ─────────────────────────────────────────function parseActivityFile(input, session) { const file = input.files[0]; if (!file) return; const suffix = session === 1 ? '' : '2'; const statusEl = document.getElementById('gpxStatus' + suffix); const btnText = document.getElementById('gpxBtnText' + suffix); statusEl.className = 'gpx-status'; statusEl.textContent = 'Parsing...'; const reader = new FileReader(); reader.onload = function(e) { try { const ext = file.name.split('.').pop().toLowerCase(); let distMiles = null, durationSecs = null; if (ext === 'gpx') { const parser = new DOMParser(); const xml = parser.parseFromString(e.target.result, 'text/xml'); const trkpts = xml.querySelectorAll('trkpt'); if (trkpts.length < 2) throw new Error('Not enough track points'); // Calculate distance from lat/lon points let totalMeters = 0; let pts = Array.from(trkpts).map(p => ({ lat: parseFloat(p.getAttribute('lat')), lon: parseFloat(p.getAttribute('lon')), time: p.querySelector('time') ? new Date(p.querySelector('time').textContent) : null })); for (let i = 1; i < pts.length; i++) { totalMeters += haversine(pts[i-1].lat, pts[i-1].lon, pts[i].lat, pts[i].lon); } distMiles = totalMeters / 1609.344; // Duration from first to last timestamp if (pts[0].time && pts[pts.length-1].time) { durationSecs = (pts[pts.length-1].time - pts[0].time) / 1000; } } else if (ext === 'fit') { // Parse FIT binary — look for key record messages const buf = new Uint8Array(e.target.result); const result = parseFitDistance(buf); if (result) { distMiles = result.distMiles; durationSecs = result.durationSecs; } } if (distMiles !== null) { document.getElementById('inputDist' + suffix).value = distMiles.toFixed(2); btnText.textContent = '✓ ' + file.name; } if (durationSecs !== null) { const h = Math.floor(durationSecs / 3600); const m = Math.floor((durationSecs % 3600) / 60); const timeStr = h > 0 ? `${h}:${String(m).padStart(2,'0')}` : `0:${String(m).padStart(2,'0')}`; document.getElementById('inputTime' + suffix).value = timeStr; } statusEl.textContent = distMiles ? `✓ ${distMiles.toFixed(2)} mi${durationSecs ? ' · ' + formatDuration(durationSecs) : ''}` : '✓ File loaded — check fields'; } catch(err) { statusEl.className = 'gpx-status error'; statusEl.textContent = 'Could not parse file — fill in manually'; console.warn('GPX parse error:', err); } }; if (file.name.endsWith('.fit')) reader.readAsArrayBuffer(file); else reader.readAsText(file);}function haversine(lat1, lon1, lat2, lon2) { const R = 6371000; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));}function parseFitDistance(buf) { // FIT files store total distance in session/lap messages // Look for session message (type 18) which has total_distance field try { // Skip 14-byte header let offset = 14; let localMsgDefs = {}; let distMeters = null, durationSecs = null; while (offset < buf.length - 2) { const recHdr = buf[offset]; const isDefn = (recHdr & 0x40) !== 0; const localNum = recHdr & 0x0F; if (isDefn) { // Definition message offset++; // skip header offset++; // reserved const arch = buf[offset++]; // architecture (0=little, 1=big) const globalMsgNum = arch === 0 ? (buf[offset] | buf[offset+1]<<8) : (buf[offset]<<8 | buf[offset+1]); offset += 2; const numFields = buf[offset++]; const fields = []; for (let i = 0; i < numFields; i++) { fields.push({ defNum: buf[offset], size: buf[offset+1], type: buf[offset+2] }); offset += 3; } localMsgDefs[localNum] = { globalMsgNum, arch, fields }; } else { // Data message const def = localMsgDefs[localNum]; if (!def) { offset++; continue; } offset++; // skip header const msgStart = offset; let msgSize = def.fields.reduce((s, f) => s + f.size, 0); // session message = global 18, total_distance = field def 9, timer_time = field def 7 if (def.globalMsgNum === 18) { let fieldOffset = msgStart; for (const f of def.fields) { if (f.defNum === 9 && f.size === 4) { const val = def.arch === 0 ? (buf[fieldOffset] | buf[fieldOffset+1]<<8 | buf[fieldOffset+2]<<16 | buf[fieldOffset+3]<<24) : (buf[fieldOffset]<<24 | buf[fieldOffset+1]<<16 | buf[fieldOffset+2]<<8 | buf[fieldOffset+3]); distMeters = val / 100; } if (f.defNum === 7 && f.size === 4) { const val = def.arch === 0 ? (buf[fieldOffset] | buf[fieldOffset+1]<<8 | buf[fieldOffset+2]<<16 | buf[fieldOffset+3]<<24) : (buf[fieldOffset]<<24 | buf[fieldOffset+1]<<16 | buf[fieldOffset+2]<<8 | buf[fieldOffset+3]); durationSecs = val / 1000; } fieldOffset += f.size; } } offset += msgSize; } } if (distMeters) return { distMiles: distMeters / 1609.344, durationSecs }; return null; } catch(e) { return null; }}function formatDuration(secs) { const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); return h > 0 ? `${h}h ${m}m` : `${m}m`;}function updateRPE(val) { document.getElementById('rpeVal').textContent = val;}function updateRPE2(val) { document.getElementById('rpeVal2').textContent = val;}function toggleSession2() { const block = document.getElementById('session2Block'); const btn = document.getElementById('addSession2Btn'); const isOpen = block.classList.toggle('open'); btn.textContent = isOpen ? '− Remove 2nd session' : '+ Add 2nd session';}function selectKnee(el) { kneeVal = el.dataset.val; document.querySelectorAll('.knee-opt').forEach(o => o.classList.remove('selected')); el.classList.add('selected');}async function saveLog() { if (!modalDay) return; const session2Open = document.getElementById('session2Block').classList.contains('open'); const entry = { dayKey: modalDay.dayKey, desc: modalDay.desc, dist: document.getElementById('inputDist').value, time: document.getElementById('inputTime').value, rpe: parseInt(document.getElementById('rpeSlider').value), knee: kneeVal, note: document.getElementById('inputNote').value, loggedAt: new Date().toISOString(), session2: session2Open ? { dist: document.getElementById('inputDist2').value, time: document.getElementById('inputTime2').value, rpe: parseInt(document.getElementById('rpeSlider2').value), note: document.getElementById('inputNote2').value, } : null, }; const idx = runLogs.findIndex(l => l.dayKey === entry.dayKey); if (idx >= 0) runLogs[idx] = entry; else runLogs.push(entry); saveLogs(); // fire and forget — async but we don't need to wait closeModal(); renderBlock(currentBlock); renderLogList(); renderStats(); showToast('Run logged ✓');}// ── LOG LIST ──────────────────────────────────────────────────function renderLogList() { const list = document.getElementById('logList'); if (!runLogs.length) { list.innerHTML = `<div class="empty-state"> <div class="empty-state-icon">🏃</div> <div class="empty-state-text">No runs logged yet</div> <div class="empty-state-sub">Tap any day in the Block view to log a run</div> </div>`; return; } const sorted = [...runLogs].sort((a, b) => b.loggedAt.localeCompare(a.loggedAt)); list.innerHTML = sorted.map(l => { const totalDist = (parseFloat(l.dist)||0) + (l.session2 ? (parseFloat(l.session2.dist)||0) : 0); return ` <div class="log-entry ${l.knee === 'grumpy' ? 'flagged' : ''}"> <div class="log-entry-header"> <div> <div class="log-entry-date">${new Date(l.loggedAt).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</div> <div class="log-entry-title">${l.desc}${l.session2 ? ' <span style="font-size:11px;color:var(--teal);font-family:\'DM Mono\',monospace">×2</span>' : ''}</div> </div> </div> <div class="log-entry-stats"> ${totalDist ? `<span class="stat-chip">${totalDist.toFixed(1)} mi total</span>` : ''} ${l.time ? `<span class="stat-chip">S1: ${l.time}</span>` : ''} ${l.rpe ? `<span class="stat-chip">RPE ${l.rpe}</span>` : ''} ${l.session2 && l.session2.time ? `<span class="stat-chip">S2: ${l.session2.time}</span>` : ''} ${l.session2 && l.session2.rpe ? `<span class="stat-chip">RPE ${l.session2.rpe}</span>` : ''} ${l.knee === 'grumpy' ? `<span class="stat-chip" style="background:#fdf0ea;color:#e8622a">⚠️ Knee</span>` : ''} ${l.suunto ? `<span class="stat-chip" style="cursor:pointer" onclick="window.open('${l.suunto}','_blank')">📡 S1</span>` : ''} ${l.session2 && l.session2.suunto ? `<span class="stat-chip" style="cursor:pointer" onclick="window.open('${l.session2.suunto}','_blank')">📡 S2</span>` : ''} </div> ${l.note ? `<div class="log-note">"${l.note}"</div>` : ''} ${l.session2 && l.session2.note ? `<div class="log-note" style="margin-top:4px;border-left:2px solid var(--teal-light);padding-left:8px">"${l.session2.note}"</div>` : ''} </div> `}).join('');}function renderStats() { const totalMiles = runLogs.reduce((s, l) => s + (parseFloat(l.dist) || 0) + (l.session2 ? (parseFloat(l.session2.dist)||0) : 0), 0); const totalRuns = runLogs.filter(l => l.dist).length; const avgRPE = runLogs.length ? (runLogs.reduce((s, l) => s + l.rpe, 0) / runLogs.length).toFixed(1) : '—'; const kneeFlags = runLogs.filter(l => l.knee === 'grumpy').length; document.getElementById('statsGrid').innerHTML = ` <div class="stat-card"> <span class="stat-card-value">${totalMiles.toFixed(0)}</span> <span class="stat-card-label">Miles Logged</span> </div> <div class="stat-card"> <span class="stat-card-value">${totalRuns}</span> <span class="stat-card-label">Runs Logged</span> </div> <div class="stat-card"> <span class="stat-card-value">${avgRPE}</span> <span class="stat-card-label">Avg RPE</span> </div> <div class="stat-card"> <span class="stat-card-value" style="color:${kneeFlags > 0 ? '#e8622a' : '#2a7a4a'}">${kneeFlags}</span> <span class="stat-card-label">Knee Flags</span> </div> `;}// ── SCHEDULE ──────────────────────────────────────────────────function renderFullSchedule() { const container = document.getElementById('fullSchedule'); container.innerHTML = BLOCKS.map((b, i) => { const phase = PHASES[b.phase]; return `<div style="margin-bottom:16px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> <div style="width:8px;height:8px;border-radius:50%;background:var(--teal);flex-shrink:0"></div> <div style="font-size:10px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--gray);font-family:'DM Mono',monospace">${b.dates}</div> </div> <div class="checkin-card" style="cursor:pointer" onclick="switchToBlock(${i})"> <div class="checkin-info"> <div class="checkin-title">Block ${b.num}: ${b.label}</div> <div class="checkin-date">${phase.name} · ~${b.mileage} mi/wk</div> </div> <div style="font-size:18px">›</div> </div> </div>`; }).join('');}function switchToBlock(idx) { currentBlock = idx; renderBlock(currentBlock); switchTab('block');}// ── CHECKINS ──────────────────────────────────────────────────function renderCheckins() { const today = new Date(); let nextFound = false; document.getElementById('checkinList').innerHTML = CHECKINS.map(c => { const d = new Date(c.date); const isPast = d < today; const isNext = !isPast && !nextFound; if (isNext) nextFound = true; return `<div class="checkin-card"> <div class="checkin-dot ${isNext ? 'next' : isPast ? 'done' : ''}"></div> <div class="checkin-info"> <div class="checkin-title">${c.label}</div> <div class="checkin-date">${formatDate(c.date)}</div> </div> <div class="checkin-status">${isPast ? '✓' : isNext ? 'NEXT' : ''}</div> </div>`; }).join('');}// ── TABS ──────────────────────────────────────────────────────function switchTab(tab) { document.querySelectorAll('.tab').forEach((t, i) => { const tabs = ['block', 'log', 'schedule', 'checkin']; t.classList.toggle('active', tabs[i] === tab); }); document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); document.getElementById('section-' + tab).classList.add('active'); if (tab === 'log') { renderLogList(); renderStats(); }}// ── UTILS ─────────────────────────────────────────────────────function formatDate(dateStr) { return new Date(dateStr + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });}function showToast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 2200);}// Close modal on overlay clickdocument.getElementById('logModal').addEventListener('click', function(e) { if (e.target === this) closeModal();});init().catch(console.error);</script></body></html><!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"><title>Vinny</title><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="Vinny"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#111a1a"><link rel="manifest" id="manifest-link" href="#"><link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 180'%3E%3Crect width='180' height='180' rx='40' fill='%231a7a7a'/%3E%3Ctext x='90' y='116' font-family='Arial' font-weight='bold' font-size='72' text-anchor='middle' fill='white'%3EUT%3C/text%3E%3C/svg%3E"><script>window.addEventListener('DOMContentLoaded', function() { var manifestData = { name: "Vinny \u2014 Ultra Training", short_name: "Vinny", start_url: "./", display: "standalone", background_color: "#111a1a", theme_color: "#111a1a", icons: [{ src: "data:image/png;base64,iVBORw0KGgo=", sizes: "192x192", type: "image/png" }] }; var blob = new Blob([JSON.stringify(manifestData)], {type: 'application/json'}); document.getElementById('manifest-link').href = URL.createObjectURL(blob);});</script><link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@400;600;700;800&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet"><style> :root { --teal: #1a7a7a; --teal-light: #2a9d9d; --teal-dark: #0f5252; --teal-faint: #e8f5f5; --bone: #f5f0e8; --dark: #111a1a; --mid: #3d5252; --gray: #8a9e9e; --gray-light: #c5d5d5; --white: #ffffff; --accent: #e8622a; --accent-light: #fdf0ea; --green: #2a7a4a; --green-light: #e8f5ee; --shadow: 0 4px 24px rgba(26,122,122,0.12); --shadow-lg: 0 8px 40px rgba(26,122,122,0.18); } * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } body { font-family: 'Syne', sans-serif; background: var(--dark); color: var(--dark); min-height: 100vh; overflow-x: hidden; } /* ── HEADER ── */ .header { background: var(--dark); padding: 20px 20px 0; position: sticky; top: 0; z-index: 100; } .header-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .logo { font-size: 11px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--teal-light); font-family: 'DM Mono', monospace; } .header-date { font-size: 11px; color: var(--gray); font-family: 'DM Mono', monospace; letter-spacing: 0.05em; } .header-races { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 16px; -webkit-overflow-scrolling: touch; scrollbar-width: none; } .header-races::-webkit-scrollbar { display: none; } .race-chip { flex-shrink: 0; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 10px 14px; cursor: pointer; transition: all 0.2s; } .race-chip.a-race { background: rgba(26,122,122,0.25); border-color: var(--teal); } .race-chip-name { font-size: 11px; font-weight: 700; color: var(--white); letter-spacing: 0.05em; display: block; } .race-chip-date { font-size: 10px; color: var(--gray); font-family: 'DM Mono', monospace; margin-top: 2px; display: block; } .race-chip-days { font-size: 13px; font-weight: 800; color: var(--teal-light); margin-top: 4px; display: block; } .race-chip.a-race .race-chip-days { color: #7fd4d4; } /* ── NAV TABS ── */ .tabs { background: var(--dark); display: flex; border-bottom: 1px solid rgba(255,255,255,0.08); position: sticky; top: 0; z-index: 99; } .tab { flex: 1; padding: 14px 8px; text-align: center; font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--gray); cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; } .tab.active { color: var(--teal-light); border-bottom-color: var(--teal-light); } /* ── CONTENT ── */ .content { background: var(--bone); min-height: calc(100vh - 160px); border-radius: 20px 20px 0 0; } .section { display: none; padding: 24px 20px; } .section.active { display: block; } /* ── BLOCK VIEW ── */ .block-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 20px; } .block-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--teal); font-family: 'DM Mono', monospace; } .block-title { font-size: 26px; font-weight: 800; color: var(--dark); line-height: 1.1; margin-top: 4px; } .block-subtitle { font-size: 13px; color: var(--mid); margin-top: 4px; font-family: 'DM Mono', monospace; } .block-nav { display: flex; gap: 6px; } .block-btn { width: 36px; height: 36px; border-radius: 50%; border: 1.5px solid var(--gray-light); background: white; color: var(--dark); font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .block-btn:active { background: var(--teal); color: white; border-color: var(--teal); } /* ── DAYS GRID ── */ .week-label { font-size: 10px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: var(--gray); font-family: 'DM Mono', monospace; margin: 16px 0 8px; } .days-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-bottom: 4px; } .day-col-header { text-align: center; font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: var(--gray); font-family: 'DM Mono', monospace; padding-bottom: 4px; } .day-card { background: white; border-radius: 10px; padding: 8px 4px; text-align: center; cursor: pointer; border: 1.5px solid transparent; transition: all 0.15s; position: relative; min-height: 72px; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 2px; } .day-card:active { transform: scale(0.96); } .day-card.logged { background: var(--green-light); border-color: var(--green); } .day-card.today { border-color: var(--teal); background: var(--teal-faint); } .day-card.rest { background: #f8f4ee; } .day-card.active-day { border-color: var(--accent); background: var(--accent-light); } .day-num { font-size: 11px; font-weight: 700; color: var(--dark); font-family: 'DM Mono', monospace; } .day-type { font-size: 8px; color: var(--mid); line-height: 1.2; font-weight: 600; letter-spacing: 0.02em; padding: 0 2px; } .day-check { font-size: 14px; margin-top: 2px; } .day-rpe { font-size: 8px; font-family: 'DM Mono', monospace; color: var(--teal); font-weight: 700; background: var(--teal-faint); padding: 1px 4px; border-radius: 4px; } /* ── PHASE BANNER ── */ .phase-banner { background: var(--dark); border-radius: 12px; padding: 14px 16px; margin-bottom: 20px; display: flex; align-items: center; gap: 12px; } .phase-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--teal-light); flex-shrink: 0; } .phase-text { font-size: 12px; color: var(--gray-light); line-height: 1.4; } .phase-name { font-weight: 700; color: var(--white); display: block; } /* ── LOG MODAL ── */ .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(10,20,20,0.85); z-index: 200; align-items: flex-end; justify-content: center; } .modal-overlay.open { display: flex; } .modal { background: var(--white); border-radius: 24px 24px 0 0; padding: 28px 24px 40px; width: 100%; max-width: 500px; max-height: 88vh; overflow-y: auto; animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); } @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } .modal-handle { width: 40px; height: 4px; background: var(--gray-light); border-radius: 2px; margin: 0 auto 20px; } .modal-title { font-size: 22px; font-weight: 800; color: var(--dark); margin-bottom: 4px; } .modal-subtitle { font-size: 13px; color: var(--gray); font-family: 'DM Mono', monospace; margin-bottom: 24px; } .form-group { margin-bottom: 18px; } .form-label { font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--teal); display: block; margin-bottom: 8px; font-family: 'DM Mono', monospace; } .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 12px 14px; border: 1.5px solid var(--gray-light); border-radius: 10px; font-family: 'Syne', sans-serif; font-size: 15px; color: var(--dark); background: var(--bone); outline: none; transition: border-color 0.2s; -webkit-appearance: none; } input:focus, textarea:focus, select:focus { border-color: var(--teal); background: white; } textarea { resize: none; height: 80px; line-height: 1.5; } /* RPE slider */ .rpe-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 3px; background: var(--gray-light); outline: none; border: none; padding: 0; } .rpe-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 24px; border-radius: 50%; background: var(--teal); cursor: pointer; box-shadow: 0 2px 8px rgba(26,122,122,0.4); } .rpe-labels { display: flex; justify-content: space-between; margin-top: 6px; font-size: 10px; color: var(--gray); font-family: 'DM Mono', monospace; } .rpe-value { text-align: center; font-size: 32px; font-weight: 800; color: var(--teal); font-family: 'DM Mono', monospace; margin-top: 4px; } /* Knee toggle */ .knee-options { display: flex; gap: 8px; } .knee-opt { flex: 1; padding: 10px 8px; border: 1.5px solid var(--gray-light); border-radius: 10px; text-align: center; cursor: pointer; font-size: 12px; font-weight: 700; color: var(--mid); transition: all 0.15s; } .knee-opt.selected { border-color: var(--teal); background: var(--teal-faint); color: var(--teal); } .knee-opt.warn.selected { border-color: var(--accent); background: var(--accent-light); color: var(--accent); } .btn-primary { width: 100%; padding: 16px; background: var(--teal); color: white; border: none; border-radius: 12px; font-family: 'Syne', sans-serif; font-size: 15px; font-weight: 700; cursor: pointer; margin-top: 8px; letter-spacing: 0.05em; transition: all 0.2s; } .btn-primary:active { background: var(--teal-dark); transform: scale(0.98); } .btn-ghost { width: 100%; padding: 14px; background: transparent; color: var(--gray); border: 1.5px solid var(--gray-light); border-radius: 12px; font-family: 'Syne', sans-serif; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 8px; } /* ── LOG LIST ── */ .log-entry { background: white; border-radius: 14px; padding: 16px; margin-bottom: 12px; border-left: 3px solid var(--teal); } .log-entry.flagged { border-left-color: var(--accent); } .log-entry-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; } .log-entry-date { font-size: 11px; font-family: 'DM Mono', monospace; color: var(--gray); } .log-entry-title { font-size: 16px; font-weight: 700; color: var(--dark); } .log-entry-stats { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; } .stat-chip { font-size: 11px; font-family: 'DM Mono', monospace; color: var(--teal); background: var(--teal-faint); padding: 3px 8px; border-radius: 6px; font-weight: 700; } .log-note { font-size: 13px; color: var(--mid); margin-top: 8px; line-height: 1.5; font-family: 'Instrument Serif', serif; font-style: italic; } /* ── CHECKIN SECTION ── */ .checkin-card { background: white; border-radius: 14px; padding: 18px; margin-bottom: 12px; display: flex; align-items: center; gap: 14px; } .checkin-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--gray-light); flex-shrink: 0; } .checkin-dot.next { background: var(--teal); box-shadow: 0 0 0 4px var(--teal-faint); } .checkin-dot.done { background: var(--green); } .checkin-info { flex: 1; } .checkin-title { font-size: 15px; font-weight: 700; color: var(--dark); } .checkin-date { font-size: 11px; font-family: 'DM Mono', monospace; color: var(--gray); margin-top: 2px; } .checkin-status { font-size: 11px; font-weight: 700; color: var(--teal); } /* ── SUMMARY STATS ── */ .stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; } .stat-card { background: white; border-radius: 14px; padding: 16px; text-align: center; } .stat-card-value { font-size: 32px; font-weight: 800; color: var(--teal); font-family: 'DM Mono', monospace; display: block; } .stat-card-label { font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--gray); display: block; margin-top: 4px; } /* ── WEATHER WIDGET ── */ .weather-card { background: var(--dark); border-radius: 14px; padding: 18px; margin-bottom: 20px; color: white; } .weather-label { font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--gray); font-family: 'DM Mono', monospace; margin-bottom: 8px; } .weather-location { font-size: 14px; font-weight: 700; color: white; } .weather-note { font-size: 12px; color: var(--gray); margin-top: 6px; line-height: 1.5; } /* Toast */ .toast { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(80px); background: var(--dark); color: white; padding: 12px 24px; border-radius: 100px; font-size: 13px; font-weight: 700; z-index: 300; transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); white-space: nowrap; } .toast.show { transform: translateX(-50%) translateY(0); } .suunto-link { width: 100%; padding: 12px 14px; border: 1.5px dashed var(--teal-light); border-radius: 10px; background: var(--teal-faint); font-family: 'DM Mono', monospace; font-size: 13px; color: var(--teal); outline: none; } .gpx-upload-btn { display: block; width: 100%; padding: 12px 14px; background: var(--teal-faint); border: 1.5px dashed var(--teal-light); border-radius: 10px; font-family: 'Syne', sans-serif; font-size: 13px; font-weight: 600; color: var(--teal); cursor: pointer; text-align: center; transition: all 0.2s; } .gpx-upload-btn:active { border-style: solid; } .gpx-status { font-size: 12px; font-family: 'DM Mono', monospace; color: var(--green); margin-top: 6px; min-height: 16px; font-weight: 600; } .gpx-status.error { color: var(--accent); } .add-session-btn { width: 100%; padding: 10px; background: transparent; border: 1.5px dashed var(--gray-light); border-radius: 10px; font-family: 'Syne', sans-serif; font-size: 13px; font-weight: 600; color: var(--gray); cursor: pointer; text-align: center; transition: all 0.2s; margin-bottom: 4px; } .add-session-btn:active { border-color: var(--teal); color: var(--teal); } .session-divider { display: flex; align-items: center; gap: 10px; margin: 16px 0 12px; } .session-divider-line { flex: 1; height: 1px; background: var(--gray-light); } .session-divider-label { font-size: 10px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: var(--teal); font-family: 'DM Mono', monospace; } .session2-block { display: none; } .session2-block.open { display: block; } .empty-state { text-align: center; padding: 40px 20px; color: var(--gray); } .empty-state-icon { font-size: 48px; margin-bottom: 12px; } .empty-state-text { font-size: 15px; font-weight: 600; color: var(--mid); } .empty-state-sub { font-size: 13px; color: var(--gray); margin-top: 4px; }</style></head><body><div class="header"> <div class="header-top"> <div class="logo">Ultra Training 2026</div> <div class="header-date" id="todayDate"></div> </div> <div class="header-races" id="raceChips"></div></div><div class="tabs"> <div class="tab active" onclick="switchTab('block')">Block</div> <div class="tab" onclick="switchTab('log')">My Runs</div> <div class="tab" onclick="switchTab('schedule')">Schedule</div> <div class="tab" onclick="switchTab('checkin')">Check-Ins</div></div><div class="content"> <!-- BLOCK VIEW --> <div class="section active" id="section-block"> <div class="block-header"> <div> <div class="block-label" id="blockLabel">Block 1</div> <div class="block-title" id="blockTitle">Baseline</div> <div class="block-subtitle" id="blockDates">Feb 21 – Mar 6</div> </div> <div class="block-nav"> <button class="block-btn" onclick="prevBlock()">‹</button> <button class="block-btn" onclick="nextBlock()">›</button> </div> </div> <div class="phase-banner" id="phaseBanner"> <div class="phase-dot"></div> <div class="phase-text"> <span class="phase-name" id="phaseName">Phase 1: Base & Build</span> <span id="phaseDesc">Aerobic foundation, trail volume, strength</span> </div> </div> <div class="week-label">Week 1</div> <div class="days-grid" id="dayHeaders1"></div> <div class="days-grid" id="week1Grid"></div> <div class="week-label">Week 2</div> <div class="days-grid" id="dayHeaders2"></div> <div class="days-grid" id="week2Grid"></div> </div> <!-- LOG VIEW --> <div class="section" id="section-log"> <div class="stats-grid" id="statsGrid"></div> <div id="logList"></div> </div> <!-- SCHEDULE VIEW --> <div class="section" id="section-schedule"> <div class="weather-card"> <div class="weather-label">📍 Dryden, NY — 13068</div> <div class="weather-location">Weather checked at every 2-week check-in</div> <div class="weather-note">Snow forecast = run day moved. No heroics in heavy snow. Pull up weather at each check-in to adjust the upcoming block.</div> </div> <div id="fullSchedule"></div> </div> <!-- CHECKIN VIEW --> <div class="section" id="section-checkin"> <div id="checkinList"></div> </div></div><!-- LOG MODAL --><div class="modal-overlay" id="logModal"> <div class="modal"> <div class="modal-handle"></div> <div class="modal-title" id="modalTitle">Log Run</div> <div class="modal-subtitle" id="modalSubtitle"></div> <div class="form-group"> <label class="form-label">Upload GPX / FIT File</label> <label class="gpx-upload-btn" id="gpxUploadBtn" for="gpxFile"> <span id="gpxBtnText">📁 Upload Suunto export to auto-fill</span> <input type="file" id="gpxFile" accept=".gpx,.fit" style="display:none" onchange="parseActivityFile(this, 1)"> </label> <div class="gpx-status" id="gpxStatus"></div> </div> <div class="form-group"> <label class="form-label">Distance & Time</label> <div class="form-row"> <input type="number" id="inputDist" placeholder="Miles" step="0.1"> <input type="text" id="inputTime" placeholder="H:MM e.g. 2:30"> </div> </div> <div class="form-group"> <label class="form-label">Effort (RPE)</label> <input type="range" class="rpe-slider" id="rpeSlider" min="1" max="10" value="5" oninput="updateRPE(this.value)"> <div class="rpe-labels"><span>Easy</span><span>Moderate</span><span>Max</span></div> <div class="rpe-value" id="rpeVal">5</div> </div> <div class="form-group"> <label class="form-label">Knee Feel</label> <div class="knee-options"> <div class="knee-opt selected" data-val="great" onclick="selectKnee(this)">👍 Great</div> <div class="knee-opt" data-val="fine" onclick="selectKnee(this)">👌 Fine</div> <div class="knee-opt warn" data-val="grumpy" onclick="selectKnee(this)">⚠️ Grumpy</div> </div> </div> <div class="form-group"> <label class="form-label">Notes — how did it feel?</label> <textarea id="inputNote" placeholder="Legs felt good on the climb, HR stayed controlled, ate well at mile 10..."></textarea> </div> <button class="add-session-btn" id="addSession2Btn" onclick="toggleSession2()">+ Add 2nd session</button> <div class="session2-block" id="session2Block"> <div class="session-divider"> <div class="session-divider-line"></div> <div class="session-divider-label">Session 2</div> <div class="session-divider-line"></div> </div> <div class="form-group"> <label class="form-label">Upload GPX / FIT File</label> <label class="gpx-upload-btn" for="gpxFile2"> <span id="gpxBtnText2">📁 Upload Suunto export to auto-fill</span> <input type="file" id="gpxFile2" accept=".gpx,.fit" style="display:none" onchange="parseActivityFile(this, 2)"> </label> <div class="gpx-status" id="gpxStatus2"></div> </div> <div class="form-group"> <label class="form-label">Distance & Time</label> <div class="form-row"> <input type="number" id="inputDist2" placeholder="Miles" step="0.1"> <input type="text" id="inputTime2" placeholder="H:MM e.g. 0:45"> </div> </div> <div class="form-group"> <label class="form-label">Effort (RPE)</label> <input type="range" class="rpe-slider" id="rpeSlider2" min="1" max="10" value="5" oninput="updateRPE2(this.value)"> <div class="rpe-labels"><span>Easy</span><span>Moderate</span><span>Max</span></div> <div class="rpe-value" id="rpeVal2">5</div> </div> <div class="form-group"> <label class="form-label">Notes</label> <textarea id="inputNote2" placeholder="Second session notes..."></textarea> </div> </div> <button class="btn-primary" onclick="saveLog()">Save ✓</button> <button class="btn-ghost" onclick="closeModal()">Cancel</button> </div></div><div class="toast" id="toast"></div><script>// ── DATA ──────────────────────────────────────────────────────const RACES = [ { name: "Laurel 70", date: "2026-06-13", goal: "15-16 hrs", aRace: false }, { name: "Escarpment", date: "2026-07-26", goal: "Enjoy it", aRace: false }, { name: "Dark Sky 220", date: "2026-10-25", goal: "A-RACE", aRace: true },];const CHECKINS = [ { label: "Block 1 Review", date: "2026-03-07" }, { label: "Block 2 Review", date: "2026-03-21" }, { label: "Block 3 Review", date: "2026-04-04" }, { label: "Block 4 Review", date: "2026-04-18" }, { label: "Block 5 Review", date: "2026-05-02" }, { label: "Block 6 Review", date: "2026-05-16" }, { label: "Block 7 / Laurel Taper", date: "2026-05-30" }, { label: "Post-Laurel Recovery", date: "2026-06-27" }, { label: "Block 9 Review", date: "2026-07-11" }, { label: "Escarpment Week", date: "2026-07-26" }, { label: "Block 11 Review", date: "2026-08-09" }, { label: "Block 12 Review", date: "2026-08-23" }, { label: "Peak Build I", date: "2026-09-06" }, { label: "Peak Build II", date: "2026-09-20" }, { label: "Cut-back Review", date: "2026-10-04" }, { label: "Dark Sky Race Prep 🏁", date: "2026-10-18" },];const PHASES = [ { name: "Phase 1: Base & Build", desc: "Aerobic foundation, trail volume, strength", color: "#1a7a7a" }, { name: "Phase 2: Laurel Prep", desc: "Race-specific training & taper", color: "#2a7a4a" }, { name: "Phase 3: Recovery & Sharpen", desc: "Rebuild after Laurel, Escarpment prep", color: "#7a4a1a" }, { name: "Phase 4: Dark Sky 220 Build", desc: "Peak volume, night running, back-to-backs", color: "#1a1a7a" },];const BLOCKS = [ // Phase 1 { num: 1, label: "Baseline", dates: "Feb 21 – Mar 6", mileage: "45-50", phase: 0, week1: ["Easy 6-8mi", "Strength A", "Mod 8-10mi + strides", "Easy 5mi + Yoga", "Dog Walk", "Long 14-16mi", "Dog Walk + Mobility"], week2: ["Easy 6mi", "Strength A", "Tempo intervals 7mi", "Easy 5mi + Yoga", "Dog Walk", "Long 16-18mi", "Dog Walk + Mobility"] }, { num: 2, label: "Build — Climbing", dates: "Mar 7 – Mar 20", mileage: "48-54", phase: 0, week1: ["Easy 7mi", "Strength A", "Hill repeats 8mi", "Easy 6mi + Yoga", "Dog Walk", "Long trail 18-20mi", "Dog Walk"], week2: ["Easy 7mi", "Strength B", "Moderate 9mi", "Easy 5mi + Yoga", "Strides 5mi", "Long 20-22mi", "Dog Walk"] }, { num: 3, label: "Back-to-Backs", dates: "Mar 21 – Apr 3", mileage: "52-58", phase: 0, week1: ["Easy 7mi", "Strength A", "Moderate 10mi", "Easy 6mi + Yoga", "Dog Walk", "Long 22-24mi trail", "Moderate 12mi (B2B)"], week2: ["Easy 7mi", "Strength B", "Hill work 9mi", "Easy 6mi + Yoga", "Dog Walk", "Long 24-26mi", "Moderate 12mi (B2B)"] }, { num: 4, label: "Peak Base", dates: "Apr 4 – Apr 17", mileage: "55-60", phase: 0, week1: ["Easy 7mi", "Strength A", "Mod 10mi + surges", "Easy 6mi + Yoga", "Easy 5mi", "Long 26-28mi", "Moderate 14mi (B2B)"], week2: ["Easy 8mi", "Strength B", "Tempo 10mi", "Easy 6mi + Yoga", "Dog Walk", "Long 28-30mi", "Easy 12mi (B2B)"] }, { num: 5, label: "Cut-back & Sharpen", dates: "Apr 18 – May 2", mileage: "52-55", phase: 0, week1: ["Easy 6mi", "Strength A", "Moderate 9mi", "Easy 5mi + Yoga", "Dog Walk", "Long 22-24mi", "Easy 10mi"], week2: ["Easy 6mi", "Strength A", "Race-pace 8mi", "Easy 5mi + Yoga", "Easy 4mi", "Long 18-20mi", "Dog Walk + Yoga"] }, // Phase 2 { num: 6, label: "Laurel Volume", dates: "May 3 – May 16", mileage: "55-58", phase: 1, week1: ["Easy 7mi", "Strength A", "Long tempo 12mi", "Easy 6mi + Yoga", "Dog Walk", "Long 28-32mi trail", "Moderate 14mi (B2B)"], week2: ["Easy 7mi", "Strength A", "Pace work 10mi", "Easy 5mi + Yoga", "Easy 5mi", "Long 24-26mi", "Easy 10mi (B2B)"] }, { num: 7, label: "Taper Begins", dates: "May 17 – May 30", mileage: "48-52", phase: 1, week1: ["Easy 6mi", "Strength (light)", "Moderate 10mi", "Easy 5mi + Yoga", "Dog Walk", "Long 20-22mi", "Easy 8mi"], week2: ["Easy 5mi", "Mobility only", "Easy 8mi + pace", "Easy 4mi + Yoga", "Dog Walk", "Long 14-16mi", "Easy 6mi"] }, { num: 8, label: "Race Week — Laurel 🏁", dates: "May 31 – Jun 13", mileage: "30-35", phase: 1, week1: ["Easy 5mi", "Easy 4mi + strides", "Mobility + 3mi", "Rest / Dog Walk", "Easy 3mi shakeout", "Dog Walk", "Rest & Prep"], week2: ["LAUREL 70", "Jun 13 🏁", "15-16 hr goal", "Rest/recover", "", "", ""] }, // Phase 3 { num: 9, label: "Post-Laurel Recovery", dates: "Jun 14 – Jun 27", mileage: "25-35", phase: 2, week1: ["Dog Walk", "Yoga only", "Dog Walk", "Easy Mobility", "Dog Walk", "Easy Walk", "Rest"], week2: ["Easy run 30min", "Mobility", "Easy run 35min", "Easy 4mi + Yoga", "Dog Walk", "Easy trail 6mi", "Easy Walk"] }, { num: 10, label: "Rebuild + Technical", dates: "Jun 28 – Jul 11", mileage: "38-45", phase: 2, week1: ["Easy 6mi", "Strength A", "Technical trail 8mi", "Easy 5mi + Yoga", "Dog Walk", "Long hilly 16-18mi", "Easy 8mi"], week2: ["Easy 6mi", "Strength A", "Hill repeats 8mi", "Easy 5mi + Yoga", "Dog Walk", "Long trail 18-20mi", "Easy 8mi"] }, { num: 11, label: "Escarpment Prep 🏁", dates: "Jul 12 – Jul 26", mileage: "35-40", phase: 2, week1: ["Easy 6mi", "Strength (light)", "Technical climb 8mi", "Easy 5mi + Yoga", "Dog Walk", "Hilly long 16mi", "Easy 6mi"], week2: ["Easy 4mi", "Easy Mobility", "Easy 4mi shakeout", "Dog Walk", "Travel/Rest", "Easy 2mi", "ESCARPMENT 🏁"] }, // Phase 4 { num: 12, label: "DS220 Rebuild", dates: "Jul 27 – Aug 9", mileage: "45-52", phase: 3, week1: ["Easy 6mi", "Strength A", "Easy-mod 9mi", "Easy 6mi + Yoga", "Dog Walk", "Long 20-22mi", "Easy 10mi"], week2: ["Easy 7mi", "Strength B", "Trail 10mi", "Easy 6mi + Yoga", "Dog Walk", "Long 22-24mi", "Easy 10mi (B2B)"] }, { num: 13, label: "Volume Rising", dates: "Aug 10 – Aug 23", mileage: "52-58", phase: 3, week1: ["Easy 7mi", "Strength A", "Trail 10mi", "Easy 7mi + Yoga", "Dog Walk", "Long 26-28mi", "Moderate 14mi (B2B)"], week2: ["Easy 7mi", "Strength B", "Hill + surges 10mi", "Easy 6mi + Yoga", "Easy 5mi", "Long 28-32mi", "Moderate 15mi (B2B)"] }, { num: 14, label: "Peak Build I", dates: "Aug 24 – Sep 6", mileage: "55-62", phase: 3, week1: ["Easy 7mi", "Strength A", "Technical trail 10mi", "Easy 7mi + Yoga", "Dog Walk", "Long 30-35mi", "Moderate 16mi (B2B)"], week2: ["Easy 8mi", "Strength B", "Moderate 11mi", "Easy 7mi + Yoga", "Easy 5mi", "Long 32-36mi", "Moderate 16mi (B2B)"] }, { num: 15, label: "Peak Build II — Biggest", dates: "Sep 7 – Sep 20", mileage: "58-65", phase: 3, week1: ["Easy 8mi", "Strength A", "Trail 12mi", "Easy 7mi + Yoga", "Dog Walk", "Long 35-40mi", "Long 20mi (B2B)"], week2: ["Easy 8mi", "Strength B", "Pace work 10mi", "Easy 7mi + Yoga", "Easy 5mi", "Long 38-42mi", "Moderate 18mi (B2B)"] }, { num: 16, label: "Cut-back & Sharpen", dates: "Sep 21 – Oct 4", mileage: "48-54", phase: 3, week1: ["Easy 7mi", "Strength (light)", "Moderate trail 10mi", "Easy 6mi + Yoga", "Dog Walk", "Long 26-28mi", "Easy 12mi"], week2: ["Easy 7mi", "Strength (light)", "Trail 9mi", "Easy 5mi + Yoga", "Dog Walk", "Long 22-24mi", "Easy 10mi"] }, { num: 17, label: "Dark Sky Taper 🏁", dates: "Oct 5 – Oct 25", mileage: "35-42", phase: 3, week1: ["Easy 6mi", "Strength (light)", "Easy 8mi trail", "Easy 5mi + Yoga", "Dog Walk", "Long 18-20mi", "Easy 8mi"], week2: ["Easy 5mi", "Mobility only", "Easy 6mi + strides", "Easy 4mi + Yoga", "Dog Walk", "Easy 10mi", "DARK SKY 220 🏁"] },];const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];// ── STATE ─────────────────────────────────────────────────────let currentBlock = 0;let runLogs = [];let modalDay = null;let kneeVal = "great";// Storage — uses Claude's cross-device storage when available, falls back to localStorageasync function loadLogs() { try { if (window.storage) { const r = await window.storage.get('vinny-run-logs'); if (r) { runLogs = JSON.parse(r.value); return; } } } catch(e) {} try { const saved = localStorage.getItem('vinny-run-logs'); if (saved) runLogs = JSON.parse(saved); } catch(e) { runLogs = []; }}async function saveLogs() { const data = JSON.stringify(runLogs); try { if (window.storage) await window.storage.set('vinny-run-logs', data); } catch(e) {} try { localStorage.setItem('vinny-run-logs', data); } catch(e) {}}// ── INIT ──────────────────────────────────────────────────────async function init() { await loadLogs(); document.getElementById('todayDate').textContent = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); renderRaceChips(); findCurrentBlock(); renderCheckins(); renderFullSchedule();}function findCurrentBlock() { const today = new Date(); // Default to first block, but try to find the right one based on date // Block 1 starts Feb 21 2026 const blockStart = new Date('2026-02-21'); const diffDays = Math.floor((today - blockStart) / (1000 * 60 * 60 * 24)); if (diffDays >= 0) { const blockIdx = Math.min(Math.floor(diffDays / 14), BLOCKS.length - 1); currentBlock = Math.max(0, blockIdx); } renderBlock(currentBlock);}// ── RACE CHIPS ────────────────────────────────────────────────function renderRaceChips() { const today = new Date(); const container = document.getElementById('raceChips'); container.innerHTML = RACES.map(r => { const raceDate = new Date(r.date); const days = Math.ceil((raceDate - today) / (1000 * 60 * 60 * 24)); return `<div class="race-chip ${r.aRace ? 'a-race' : ''}"> <span class="race-chip-name">${r.name}</span> <span class="race-chip-date">${formatDate(r.date)}</span> <span class="race-chip-days">${days > 0 ? days + 'd away' : 'Race day!'}</span> </div>`; }).join('');}// ── BLOCK RENDER ──────────────────────────────────────────────function renderBlock(idx) { const b = BLOCKS[idx]; const phase = PHASES[b.phase]; document.getElementById('blockLabel').textContent = `Block ${b.num} of ${BLOCKS.length}`; document.getElementById('blockTitle').textContent = b.label; document.getElementById('blockDates').textContent = b.dates + ' · ~' + b.mileage + ' mi/wk'; document.getElementById('phaseName').textContent = phase.name; document.getElementById('phaseDesc').textContent = phase.desc; // Headers // Calculate block start date — Block 1 starts Feb 21 2026, each block is 14 days const blockStartDate = new Date('2026-02-21T00:00:00'); blockStartDate.setDate(blockStartDate.getDate() + (idx * 14)); // Remove static day headers — dates are now shown on cards ['dayHeaders1', 'dayHeaders2'].forEach(id => { document.getElementById(id).innerHTML = ''; }); renderWeek('week1Grid', b.week1, b.num, 1, blockStartDate, 0); renderWeek('week2Grid', b.week2, b.num, 2, blockStartDate, 7);}const FULL_DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];function renderWeek(gridId, days, blockNum, weekNum, blockStartDate, dayOffset) { const grid = document.getElementById(gridId); grid.innerHTML = days.map((desc, i) => { const dayKey = `b${blockNum}w${weekNum}d${i}`; const log = runLogs.find(l => l.dayKey === dayKey); const isRest = desc.toLowerCase().includes('dog walk') || desc.toLowerCase().includes('rest') || desc.toLowerCase().includes('yoga only') || desc.toLowerCase().includes('mobility only'); // Calculate actual date for this slot const d = new Date(blockStartDate); d.setDate(d.getDate() + dayOffset + i); const dayName = FULL_DAY_NAMES[d.getDay()]; const dateLabel = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const today = new Date(); const isToday = d.toDateString() === today.toDateString(); let cls = 'day-card'; if (log) cls += ' logged'; else if (isRest) cls += ' rest'; if (isToday) cls += ' today'; return `<div class="${cls}" onclick="openModal('${dayKey}', '${desc.replace(/'/g, "\\'")}', ${blockNum}, ${weekNum}, ${i}, '${dateLabel}')"> <span class="day-num">${dayName}</span> <span class="day-num" style="font-size:9px;color:var(--teal);margin-top:-1px">${dateLabel}</span> <span class="day-type">${desc}</span> ${log ? `<span class="day-check">✓</span><span class="day-rpe">RPE ${log.rpe}</span>` : ''} </div>`; }).join('');}function prevBlock() { if (currentBlock > 0) { currentBlock--; renderBlock(currentBlock); }}function nextBlock() { if (currentBlock < BLOCKS.length - 1) { currentBlock++; renderBlock(currentBlock); }}// ── MODAL ─────────────────────────────────────────────────────function openModal(dayKey, desc, blockNum, weekNum, dayIdx, dateLabel) { modalDay = { dayKey, desc }; document.getElementById('modalTitle').textContent = 'Log: ' + desc; document.getElementById('modalSubtitle').textContent = `Block ${blockNum} · Week ${weekNum} · ${DAY_NAMES[dayIdx]}${dateLabel ? ' · ' + dateLabel : ''}`; // Reset session 2 document.getElementById('session2Block').classList.remove('open'); document.getElementById('addSession2Btn').textContent = '+ Add 2nd session'; document.getElementById('inputDist2').value = ''; document.getElementById('inputTime2').value = ''; document.getElementById('rpeSlider2').value = 5; document.getElementById('rpeVal2').textContent = 5; document.getElementById('inputNote2').value = ''; document.getElementById('inputSuunto2').value = ''; // Pre-fill if existing const existing = runLogs.find(l => l.dayKey === dayKey); if (existing) { document.getElementById('inputDist').value = existing.dist || ''; document.getElementById('inputTime').value = existing.time || ''; document.getElementById('rpeSlider').value = existing.rpe || 5; document.getElementById('rpeVal').textContent = existing.rpe || 5; document.getElementById('inputNote').value = existing.note || ''; kneeVal = existing.knee || 'great'; if (existing.session2) { document.getElementById('session2Block').classList.add('open'); document.getElementById('addSession2Btn').textContent = '− Remove 2nd session'; document.getElementById('inputDist2').value = existing.session2.dist || ''; document.getElementById('inputTime2').value = existing.session2.time || ''; document.getElementById('rpeSlider2').value = existing.session2.rpe || 5; document.getElementById('rpeVal2').textContent = existing.session2.rpe || 5; document.getElementById('inputNote2').value = existing.session2.note || ''; } } else { document.getElementById('inputDist').value = ''; document.getElementById('inputTime').value = ''; document.getElementById('rpeSlider').value = 5; document.getElementById('rpeVal').textContent = 5; document.getElementById('inputNote').value = ''; kneeVal = 'great'; } document.querySelectorAll('.knee-opt').forEach(el => { el.classList.toggle('selected', el.dataset.val === kneeVal); }); document.getElementById('logModal').classList.add('open');}function closeModal() { document.getElementById('logModal').classList.remove('open');}// ── GPX / FIT PARSING ─────────────────────────────────────────function parseActivityFile(input, session) { const file = input.files[0]; if (!file) return; const suffix = session === 1 ? '' : '2'; const statusEl = document.getElementById('gpxStatus' + suffix); const btnText = document.getElementById('gpxBtnText' + suffix); statusEl.className = 'gpx-status'; statusEl.textContent = 'Parsing...'; const reader = new FileReader(); reader.onload = function(e) { try { const ext = file.name.split('.').pop().toLowerCase(); let distMiles = null, durationSecs = null; if (ext === 'gpx') { const parser = new DOMParser(); const xml = parser.parseFromString(e.target.result, 'text/xml'); const trkpts = xml.querySelectorAll('trkpt'); if (trkpts.length < 2) throw new Error('Not enough track points'); // Calculate distance from lat/lon points let totalMeters = 0; let pts = Array.from(trkpts).map(p => ({ lat: parseFloat(p.getAttribute('lat')), lon: parseFloat(p.getAttribute('lon')), time: p.querySelector('time') ? new Date(p.querySelector('time').textContent) : null })); for (let i = 1; i < pts.length; i++) { totalMeters += haversine(pts[i-1].lat, pts[i-1].lon, pts[i].lat, pts[i].lon); } distMiles = totalMeters / 1609.344; // Duration from first to last timestamp if (pts[0].time && pts[pts.length-1].time) { durationSecs = (pts[pts.length-1].time - pts[0].time) / 1000; } } else if (ext === 'fit') { // Parse FIT binary — look for key record messages const buf = new Uint8Array(e.target.result); const result = parseFitDistance(buf); if (result) { distMiles = result.distMiles; durationSecs = result.durationSecs; } } if (distMiles !== null) { document.getElementById('inputDist' + suffix).value = distMiles.toFixed(2); btnText.textContent = '✓ ' + file.name; } if (durationSecs !== null) { const h = Math.floor(durationSecs / 3600); const m = Math.floor((durationSecs % 3600) / 60); const timeStr = h > 0 ? `${h}:${String(m).padStart(2,'0')}` : `0:${String(m).padStart(2,'0')}`; document.getElementById('inputTime' + suffix).value = timeStr; } statusEl.textContent = distMiles ? `✓ ${distMiles.toFixed(2)} mi${durationSecs ? ' · ' + formatDuration(durationSecs) : ''}` : '✓ File loaded — check fields'; } catch(err) { statusEl.className = 'gpx-status error'; statusEl.textContent = 'Could not parse file — fill in manually'; console.warn('GPX parse error:', err); } }; if (file.name.endsWith('.fit')) reader.readAsArrayBuffer(file); else reader.readAsText(file);}function haversine(lat1, lon1, lat2, lon2) { const R = 6371000; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));}function parseFitDistance(buf) { // FIT files store total distance in session/lap messages // Look for session message (type 18) which has total_distance field try { // Skip 14-byte header let offset = 14; let localMsgDefs = {}; let distMeters = null, durationSecs = null; while (offset < buf.length - 2) { const recHdr = buf[offset]; const isDefn = (recHdr & 0x40) !== 0; const localNum = recHdr & 0x0F; if (isDefn) { // Definition message offset++; // skip header offset++; // reserved const arch = buf[offset++]; // architecture (0=little, 1=big) const globalMsgNum = arch === 0 ? (buf[offset] | buf[offset+1]<<8) : (buf[offset]<<8 | buf[offset+1]); offset += 2; const numFields = buf[offset++]; const fields = []; for (let i = 0; i < numFields; i++) { fields.push({ defNum: buf[offset], size: buf[offset+1], type: buf[offset+2] }); offset += 3; } localMsgDefs[localNum] = { globalMsgNum, arch, fields }; } else { // Data message const def = localMsgDefs[localNum]; if (!def) { offset++; continue; } offset++; // skip header const msgStart = offset; let msgSize = def.fields.reduce((s, f) => s + f.size, 0); // session message = global 18, total_distance = field def 9, timer_time = field def 7 if (def.globalMsgNum === 18) { let fieldOffset = msgStart; for (const f of def.fields) { if (f.defNum === 9 && f.size === 4) { const val = def.arch === 0 ? (buf[fieldOffset] | buf[fieldOffset+1]<<8 | buf[fieldOffset+2]<<16 | buf[fieldOffset+3]<<24) : (buf[fieldOffset]<<24 | buf[fieldOffset+1]<<16 | buf[fieldOffset+2]<<8 | buf[fieldOffset+3]); distMeters = val / 100; } if (f.defNum === 7 && f.size === 4) { const val = def.arch === 0 ? (buf[fieldOffset] | buf[fieldOffset+1]<<8 | buf[fieldOffset+2]<<16 | buf[fieldOffset+3]<<24) : (buf[fieldOffset]<<24 | buf[fieldOffset+1]<<16 | buf[fieldOffset+2]<<8 | buf[fieldOffset+3]); durationSecs = val / 1000; } fieldOffset += f.size; } } offset += msgSize; } } if (distMeters) return { distMiles: distMeters / 1609.344, durationSecs }; return null; } catch(e) { return null; }}function formatDuration(secs) { const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); return h > 0 ? `${h}h ${m}m` : `${m}m`;}function updateRPE(val) { document.getElementById('rpeVal').textContent = val;}function updateRPE2(val) { document.getElementById('rpeVal2').textContent = val;}function toggleSession2() { const block = document.getElementById('session2Block'); const btn = document.getElementById('addSession2Btn'); const isOpen = block.classList.toggle('open'); btn.textContent = isOpen ? '− Remove 2nd session' : '+ Add 2nd session';}function selectKnee(el) { kneeVal = el.dataset.val; document.querySelectorAll('.knee-opt').forEach(o => o.classList.remove('selected')); el.classList.add('selected');}async function saveLog() { if (!modalDay) return; const session2Open = document.getElementById('session2Block').classList.contains('open'); const entry = { dayKey: modalDay.dayKey, desc: modalDay.desc, dist: document.getElementById('inputDist').value, time: document.getElementById('inputTime').value, rpe: parseInt(document.getElementById('rpeSlider').value), knee: kneeVal, note: document.getElementById('inputNote').value, loggedAt: new Date().toISOString(), session2: session2Open ? { dist: document.getElementById('inputDist2').value, time: document.getElementById('inputTime2').value, rpe: parseInt(document.getElementById('rpeSlider2').value), note: document.getElementById('inputNote2').value, } : null, }; const idx = runLogs.findIndex(l => l.dayKey === entry.dayKey); if (idx >= 0) runLogs[idx] = entry; else runLogs.push(entry); saveLogs(); // fire and forget — async but we don't need to wait closeModal(); renderBlock(currentBlock); renderLogList(); renderStats(); showToast('Run logged ✓');}// ── LOG LIST ──────────────────────────────────────────────────function renderLogList() { const list = document.getElementById('logList'); if (!runLogs.length) { list.innerHTML = `<div class="empty-state"> <div class="empty-state-icon">🏃</div> <div class="empty-state-text">No runs logged yet</div> <div class="empty-state-sub">Tap any day in the Block view to log a run</div> </div>`; return; } const sorted = [...runLogs].sort((a, b) => b.loggedAt.localeCompare(a.loggedAt)); list.innerHTML = sorted.map(l => { const totalDist = (parseFloat(l.dist)||0) + (l.session2 ? (parseFloat(l.session2.dist)||0) : 0); return ` <div class="log-entry ${l.knee === 'grumpy' ? 'flagged' : ''}"> <div class="log-entry-header"> <div> <div class="log-entry-date">${new Date(l.loggedAt).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</div> <div class="log-entry-title">${l.desc}${l.session2 ? ' <span style="font-size:11px;color:var(--teal);font-family:\'DM Mono\',monospace">×2</span>' : ''}</div> </div> </div> <div class="log-entry-stats"> ${totalDist ? `<span class="stat-chip">${totalDist.toFixed(1)} mi total</span>` : ''} ${l.time ? `<span class="stat-chip">S1: ${l.time}</span>` : ''} ${l.rpe ? `<span class="stat-chip">RPE ${l.rpe}</span>` : ''} ${l.session2 && l.session2.time ? `<span class="stat-chip">S2: ${l.session2.time}</span>` : ''} ${l.session2 && l.session2.rpe ? `<span class="stat-chip">RPE ${l.session2.rpe}</span>` : ''} ${l.knee === 'grumpy' ? `<span class="stat-chip" style="background:#fdf0ea;color:#e8622a">⚠️ Knee</span>` : ''} ${l.suunto ? `<span class="stat-chip" style="cursor:pointer" onclick="window.open('${l.suunto}','_blank')">📡 S1</span>` : ''} ${l.session2 && l.session2.suunto ? `<span class="stat-chip" style="cursor:pointer" onclick="window.open('${l.session2.suunto}','_blank')">📡 S2</span>` : ''} </div> ${l.note ? `<div class="log-note">"${l.note}"</div>` : ''} ${l.session2 && l.session2.note ? `<div class="log-note" style="margin-top:4px;border-left:2px solid var(--teal-light);padding-left:8px">"${l.session2.note}"</div>` : ''} </div> `}).join('');}function renderStats() { const totalMiles = runLogs.reduce((s, l) => s + (parseFloat(l.dist) || 0) + (l.session2 ? (parseFloat(l.session2.dist)||0) : 0), 0); const totalRuns = runLogs.filter(l => l.dist).length; const avgRPE = runLogs.length ? (runLogs.reduce((s, l) => s + l.rpe, 0) / runLogs.length).toFixed(1) : '—'; const kneeFlags = runLogs.filter(l => l.knee === 'grumpy').length; document.getElementById('statsGrid').innerHTML = ` <div class="stat-card"> <span class="stat-card-value">${totalMiles.toFixed(0)}</span> <span class="stat-card-label">Miles Logged</span> </div> <div class="stat-card"> <span class="stat-card-value">${totalRuns}</span> <span class="stat-card-label">Runs Logged</span> </div> <div class="stat-card"> <span class="stat-card-value">${avgRPE}</span> <span class="stat-card-label">Avg RPE</span> </div> <div class="stat-card"> <span class="stat-card-value" style="color:${kneeFlags > 0 ? '#e8622a' : '#2a7a4a'}">${kneeFlags}</span> <span class="stat-card-label">Knee Flags</span> </div> `;}// ── SCHEDULE ──────────────────────────────────────────────────function renderFullSchedule() { const container = document.getElementById('fullSchedule'); container.innerHTML = BLOCKS.map((b, i) => { const phase = PHASES[b.phase]; return `<div style="margin-bottom:16px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> <div style="width:8px;height:8px;border-radius:50%;background:var(--teal);flex-shrink:0"></div> <div style="font-size:10px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--gray);font-family:'DM Mono',monospace">${b.dates}</div> </div> <div class="checkin-card" style="cursor:pointer" onclick="switchToBlock(${i})"> <div class="checkin-info"> <div class="checkin-title">Block ${b.num}: ${b.label}</div> <div class="checkin-date">${phase.name} · ~${b.mileage} mi/wk</div> </div> <div style="font-size:18px">›</div> </div> </div>`; }).join('');}function switchToBlock(idx) { currentBlock = idx; renderBlock(currentBlock); switchTab('block');}// ── CHECKINS ──────────────────────────────────────────────────function renderCheckins() { const today = new Date(); let nextFound = false; document.getElementById('checkinList').innerHTML = CHECKINS.map(c => { const d = new Date(c.date); const isPast = d < today; const isNext = !isPast && !nextFound; if (isNext) nextFound = true; return `<div class="checkin-card"> <div class="checkin-dot ${isNext ? 'next' : isPast ? 'done' : ''}"></div> <div class="checkin-info"> <div class="checkin-title">${c.label}</div> <div class="checkin-date">${formatDate(c.date)}</div> </div> <div class="checkin-status">${isPast ? '✓' : isNext ? 'NEXT' : ''}</div> </div>`; }).join('');}// ── TABS ──────────────────────────────────────────────────────function switchTab(tab) { document.querySelectorAll('.tab').forEach((t, i) => { const tabs = ['block', 'log', 'schedule', 'checkin']; t.classList.toggle('active', tabs[i] === tab); }); document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); document.getElementById('section-' + tab).classList.add('active'); if (tab === 'log') { renderLogList(); renderStats(); }}// ── UTILS ─────────────────────────────────────────────────────function formatDate(dateStr) { return new Date(dateStr + 'T12:00:00').toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' });}function showToast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 2200);}// Close modal on overlay clickdocument.getElementById('logModal').addEventListener('click', function(e) { if (e.target === this) closeModal();});init().catch(console.error);</script></body></html>