timeline.ts
typescript
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * pages/timeline.ts |
| 3 | * |
| 4 | * Supercharged timeline visualisation for MuseHub. |
| 5 | * Renders a multi-lane SVG chart with: |
| 6 | * - Filled-area emotion lines (valence / energy / tension) |
| 7 | * - Commit rail with colour-coded dots |
| 8 | * - Section add/remove markers |
| 9 | * - Track add/remove markers |
| 10 | * - Session overlay (teal dashed lines) |
| 11 | * - Proposal merge markers (purple triangles) |
| 12 | * - Release markers (gold diamonds) |
| 13 | * |
| 14 | * Data flows: |
| 15 | * Server → #page-data JSON { page: "timeline", repoId, baseUrl, totalCommits } |
| 16 | * API → apiFetch('/repos/{id}/timeline') → tlData |
| 17 | * API → sessions, mergedProposals, releases → overlays |
| 18 | */ |
| 19 | |
| 20 | // --------------------------------------------------------------------------- |
| 21 | // Types |
| 22 | // --------------------------------------------------------------------------- |
| 23 | interface TimelineCfg { |
| 24 | repoId: string; |
| 25 | baseUrl: string; |
| 26 | totalCommits: number; |
| 27 | } |
| 28 | |
| 29 | interface CommitEvent { |
| 30 | commitId: string; |
| 31 | branch: string; |
| 32 | message: string; |
| 33 | author: string; |
| 34 | timestamp: string; |
| 35 | parentIds: string[]; |
| 36 | } |
| 37 | |
| 38 | interface EmotionEvent { |
| 39 | commitId: string; |
| 40 | timestamp: string; |
| 41 | valence: number; |
| 42 | energy: number; |
| 43 | tension: number; |
| 44 | } |
| 45 | |
| 46 | interface SectionEvent { |
| 47 | commitId: string; |
| 48 | timestamp: string; |
| 49 | sectionName: string; |
| 50 | action: 'added' | 'removed'; |
| 51 | } |
| 52 | |
| 53 | interface TrackEvent { |
| 54 | commitId: string; |
| 55 | timestamp: string; |
| 56 | trackName: string; |
| 57 | action: 'added' | 'removed'; |
| 58 | } |
| 59 | |
| 60 | interface TimelineData { |
| 61 | commits: CommitEvent[]; |
| 62 | emotion: EmotionEvent[]; |
| 63 | sections: SectionEvent[]; |
| 64 | tracks: TrackEvent[]; |
| 65 | totalCommits: number; |
| 66 | } |
| 67 | |
| 68 | interface SessionData { |
| 69 | sessionId: string; |
| 70 | startedAt: string; |
| 71 | endedAt?: string; |
| 72 | intent?: string; |
| 73 | participants?: string[]; |
| 74 | location?: string; |
| 75 | } |
| 76 | |
| 77 | interface ProposalData { |
| 78 | proposalId: string; |
| 79 | title: string; |
| 80 | createdAt: string; |
| 81 | mergedAt?: string; |
| 82 | } |
| 83 | |
| 84 | interface ReleaseData { |
| 85 | releaseId: string; |
| 86 | tag: string; |
| 87 | title: string; |
| 88 | createdAt: string; |
| 89 | } |
| 90 | |
| 91 | // --------------------------------------------------------------------------- |
| 92 | // Globals (injected from musehub.ts bundle) |
| 93 | // --------------------------------------------------------------------------- |
| 94 | declare const apiFetch: (path: string) => Promise<unknown>; |
| 95 | declare const escHtml: (s: string) => string; |
| 96 | declare const initRepoNav: (id: string) => void; |
| 97 | |
| 98 | // --------------------------------------------------------------------------- |
| 99 | // State |
| 100 | // --------------------------------------------------------------------------- |
| 101 | let tlData: TimelineData | null = null; |
| 102 | let sessions: SessionData[] = []; |
| 103 | let mergedProposals: ProposalData[] = []; |
| 104 | let releases: ReleaseData[] = []; |
| 105 | let zoom = 'all'; |
| 106 | let layers = { |
| 107 | commits: true, emotion: true, sections: true, tracks: true, |
| 108 | sessions: true, proposals: true, releases: true, |
| 109 | }; |
| 110 | let scrubPct = 1.0; |
| 111 | let cfg: TimelineCfg; |
| 112 | |
| 113 | // --------------------------------------------------------------------------- |
| 114 | // SVG layout — supercharged multi-lane design |
| 115 | // --------------------------------------------------------------------------- |
| 116 | const PAD_L = 52; |
| 117 | const PAD_R = 24; |
| 118 | const PAD_BOT = 36; // room for date axis |
| 119 | |
| 120 | // Lane 1: Emotion (Y 10 → 126) |
| 121 | const EMO_Y0 = 10; |
| 122 | const EMO_YH = 100; // 0→1 maps into this height |
| 123 | const EMO_Y1 = EMO_Y0 + EMO_YH; |
| 124 | |
| 125 | // Lane separator: 134 |
| 126 | // Lane 2: Commit rail (Y 138 → 172) |
| 127 | const COMMIT_LANE_Y0 = 138; |
| 128 | const COMMIT_Y = 155; |
| 129 | const COMMIT_LANE_Y1 = 172; |
| 130 | |
| 131 | // Lane separator: 178 |
| 132 | // Lane 3: Sections (Y 182 → 210) |
| 133 | const SECTION_Y = 196; |
| 134 | |
| 135 | // Lane 4: Tracks (Y 214 → 246) |
| 136 | const TRACK_Y = 230; |
| 137 | |
| 138 | // Lane separator: 252 |
| 139 | // Lane 5: Events (Y 256 → 334) |
| 140 | const SESSION_LINE_Y0 = 256; |
| 141 | const SESSION_LINE_Y1 = 330; |
| 142 | const RELEASE_Y = 272; |
| 143 | const PROPOSAL_Y = 304; |
| 144 | |
| 145 | const SVG_H = 370; |
| 146 | |
| 147 | // --------------------------------------------------------------------------- |
| 148 | // Helpers |
| 149 | // --------------------------------------------------------------------------- |
| 150 | function msForZoom(z: string): number { |
| 151 | switch (z) { |
| 152 | case 'day': return 24 * 3600 * 1000; |
| 153 | case 'week': return 7 * 24 * 3600 * 1000; |
| 154 | case 'month': return 30 * 24 * 3600 * 1000; |
| 155 | default: return Infinity; |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | function visibleCommits(): CommitEvent[] { |
| 160 | if (!tlData?.commits?.length) return []; |
| 161 | const all = tlData.commits; |
| 162 | const span = msForZoom(zoom); |
| 163 | if (span === Infinity) return all; |
| 164 | const newest = new Date(all[all.length - 1].timestamp).getTime(); |
| 165 | return all.filter(c => newest - new Date(c.timestamp).getTime() <= span); |
| 166 | } |
| 167 | |
| 168 | function tsToX(ts: number, tMin: number, tMax: number, svgW: number): number { |
| 169 | if (tMax === tMin) return PAD_L + (svgW - PAD_L - PAD_R) / 2; |
| 170 | return PAD_L + ((ts - tMin) / (tMax - tMin)) * (svgW - PAD_L - PAD_R); |
| 171 | } |
| 172 | |
| 173 | function filterByWindow<T>( |
| 174 | events: T[], dateField: keyof T, tMin: number, tMax: number, |
| 175 | ): T[] { |
| 176 | return events.filter(e => { |
| 177 | const t = new Date(e[dateField] as string).getTime(); |
| 178 | return t >= tMin && t <= tMax; |
| 179 | }); |
| 180 | } |
| 181 | |
| 182 | function fmtDate(ts: string): string { |
| 183 | return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); |
| 184 | } |
| 185 | |
| 186 | function fmtDateTime(ts: string): string { |
| 187 | return new Date(ts).toLocaleString(undefined, { |
| 188 | month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', |
| 189 | }); |
| 190 | } |
| 191 | |
| 192 | // Build a smooth area path that closes at the baseline y1. |
| 193 | function areaPath( |
| 194 | points: { x: number; y: number }[], baselineY: number, |
| 195 | ): string { |
| 196 | if (points.length < 2) return ''; |
| 197 | const pts = points.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' L'); |
| 198 | return `M${points[0].x.toFixed(1)},${baselineY} L${pts} L${points[points.length - 1].x.toFixed(1)},${baselineY} Z`; |
| 199 | } |
| 200 | |
| 201 | // --------------------------------------------------------------------------- |
| 202 | // Render |
| 203 | // --------------------------------------------------------------------------- |
| 204 | function renderTimeline(): void { |
| 205 | const container = document.getElementById('timeline-svg-container'); |
| 206 | if (!container) return; |
| 207 | if (!tlData) { |
| 208 | container.innerHTML = '<div class="tl-loading"><div class="tl-loading-inner">Loading timeline…</div></div>'; |
| 209 | return; |
| 210 | } |
| 211 | |
| 212 | const vcs = visibleCommits(); |
| 213 | if (vcs.length === 0) { |
| 214 | container.innerHTML = '<div class="tl-loading"><div class="tl-loading-inner">No commits in this time window.</div></div>'; |
| 215 | return; |
| 216 | } |
| 217 | |
| 218 | const svgW = Math.max((container as HTMLElement).clientWidth || 900, PAD_L + PAD_R + vcs.length * 22); |
| 219 | const timestamps = vcs.map(c => new Date(c.timestamp).getTime()); |
| 220 | const tMin = Math.min(...timestamps); |
| 221 | const tMax = Math.max(...timestamps); |
| 222 | const visIds = new Set(vcs.map(c => c.commitId)); |
| 223 | |
| 224 | let defs = ''; |
| 225 | let lanes = ''; |
| 226 | let paths = ''; |
| 227 | let events = ''; |
| 228 | let axis = ''; |
| 229 | |
| 230 | // --- Background lane bands --- |
| 231 | const laneAlpha = '0.03'; |
| 232 | lanes += ` |
| 233 | <rect x="0" y="${EMO_Y0}" width="${svgW}" height="${EMO_YH + 16}" fill="#58a6ff" fill-opacity="${laneAlpha}" rx="0"/> |
| 234 | <rect x="0" y="${COMMIT_LANE_Y0}" width="${svgW}" height="${COMMIT_LANE_Y1 - COMMIT_LANE_Y0}" fill="#ffffff" fill-opacity="0.015"/> |
| 235 | <rect x="0" y="${SESSION_LINE_Y0}" width="${svgW}" height="${SESSION_LINE_Y1 - SESSION_LINE_Y0 + 6}" fill="#2dd4bf" fill-opacity="0.025"/>`; |
| 236 | |
| 237 | // --- Lane dividers --- |
| 238 | const divStyle = `stroke="#30363d" stroke-width="1" stroke-dasharray="4 4"`; |
| 239 | lanes += ` |
| 240 | <line x1="${PAD_L}" y1="132" x2="${svgW - PAD_R}" y2="132" ${divStyle}/> |
| 241 | <line x1="${PAD_L}" y1="176" x2="${svgW - PAD_R}" y2="176" ${divStyle}/> |
| 242 | <line x1="${PAD_L}" y1="252" x2="${svgW - PAD_R}" y2="252" ${divStyle}/>`; |
| 243 | |
| 244 | // --- Lane labels (left edge) --- |
| 245 | const lblStyle = `font-size="9" fill="#8b949e" text-anchor="middle" font-family="system-ui,sans-serif"`; |
| 246 | lanes += ` |
| 247 | <text transform="rotate(-90, 18, ${EMO_Y0 + EMO_YH / 2})" x="18" y="${EMO_Y0 + EMO_YH / 2 + 3}" ${lblStyle}>EMOTION</text> |
| 248 | <text transform="rotate(-90, 18, ${(COMMIT_LANE_Y0 + COMMIT_LANE_Y1) / 2})" x="18" y="${(COMMIT_LANE_Y0 + COMMIT_LANE_Y1) / 2 + 3}" ${lblStyle}>COMMITS</text> |
| 249 | <text transform="rotate(-90, 18, ${(SESSION_LINE_Y0 + SESSION_LINE_Y1) / 2})" x="18" y="${(SESSION_LINE_Y0 + SESSION_LINE_Y1) / 2 + 3}" ${lblStyle}>EVENTS</text>`; |
| 250 | |
| 251 | // --- Date axis with gridlines --- |
| 252 | const labelCount = Math.min(8, vcs.length); |
| 253 | for (let i = 0; i < labelCount; i++) { |
| 254 | const idx = Math.round(i * (vcs.length - 1) / Math.max(1, labelCount - 1)); |
| 255 | const c = vcs[idx]; |
| 256 | const x = tsToX(new Date(c.timestamp).getTime(), tMin, tMax, svgW); |
| 257 | const lbl = fmtDate(c.timestamp); |
| 258 | axis += ` |
| 259 | <line x1="${x.toFixed(1)}" y1="${EMO_Y0}" x2="${x.toFixed(1)}" y2="${SVG_H - PAD_BOT}" stroke="#21262d" stroke-width="1"/> |
| 260 | <text x="${x.toFixed(1)}" y="${SVG_H - 10}" text-anchor="middle" font-size="10" fill="#6e7681" font-family="system-ui,sans-serif">${escHtml(lbl)}</text>`; |
| 261 | } |
| 262 | |
| 263 | // --- Gradient defs for emotion area fills --- |
| 264 | defs += ` |
| 265 | <linearGradient id="tl-grad-val" x1="0" y1="0" x2="0" y2="1"> |
| 266 | <stop offset="0%" stop-color="#58a6ff" stop-opacity="0.35"/> |
| 267 | <stop offset="100%" stop-color="#58a6ff" stop-opacity="0.03"/> |
| 268 | </linearGradient> |
| 269 | <linearGradient id="tl-grad-eng" x1="0" y1="0" x2="0" y2="1"> |
| 270 | <stop offset="0%" stop-color="#3fb950" stop-opacity="0.35"/> |
| 271 | <stop offset="100%" stop-color="#3fb950" stop-opacity="0.03"/> |
| 272 | </linearGradient> |
| 273 | <linearGradient id="tl-grad-ten" x1="0" y1="0" x2="0" y2="1"> |
| 274 | <stop offset="0%" stop-color="#f78166" stop-opacity="0.35"/> |
| 275 | <stop offset="100%" stop-color="#f78166" stop-opacity="0.03"/> |
| 276 | </linearGradient>`; |
| 277 | |
| 278 | // --- Emotion layer --- |
| 279 | if (layers.emotion && tlData.emotion) { |
| 280 | const visEmo = tlData.emotion.filter(e => visIds.has(e.commitId)); |
| 281 | if (visEmo.length >= 2) { |
| 282 | const buildPts = (field: 'valence' | 'energy' | 'tension') => |
| 283 | visEmo.map(e => ({ |
| 284 | x: tsToX(new Date(e.timestamp).getTime(), tMin, tMax, svgW), |
| 285 | y: EMO_Y0 + EMO_YH * (1 - e[field]), |
| 286 | })); |
| 287 | |
| 288 | // Horizontal gridlines at 25/50/75% |
| 289 | for (const pct of [0.25, 0.5, 0.75]) { |
| 290 | const gy = EMO_Y0 + EMO_YH * (1 - pct); |
| 291 | paths += `<line x1="${PAD_L}" y1="${gy.toFixed(1)}" x2="${(svgW - PAD_R).toFixed(1)}" y2="${gy.toFixed(1)}" stroke="#21262d" stroke-width="0.5" stroke-dasharray="2 4"/>`; |
| 292 | } |
| 293 | |
| 294 | const layers3: Array<['valence' | 'energy' | 'tension', string, string]> = [ |
| 295 | ['valence', '#58a6ff', 'url(#tl-grad-val)'], |
| 296 | ['energy', '#3fb950', 'url(#tl-grad-eng)'], |
| 297 | ['tension', '#f78166', 'url(#tl-grad-ten)'], |
| 298 | ]; |
| 299 | for (const [field, stroke, fill] of layers3) { |
| 300 | const pts = buildPts(field); |
| 301 | const polyPts = pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); |
| 302 | paths += `<path d="${areaPath(pts, EMO_Y1)}" fill="${fill}"/>`; |
| 303 | paths += `<polyline points="${polyPts}" fill="none" stroke="${stroke}" stroke-width="1.5" stroke-linejoin="round" opacity="0.9"/>`; |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // --- Commit rail + dots --- |
| 309 | if (layers.commits) { |
| 310 | // Timeline rail |
| 311 | if (vcs.length > 1) { |
| 312 | const x0 = tsToX(tMin, tMin, tMax, svgW); |
| 313 | const x1 = tsToX(tMax, tMin, tMax, svgW); |
| 314 | paths += `<line x1="${x0.toFixed(1)}" y1="${COMMIT_Y}" x2="${x1.toFixed(1)}" y2="${COMMIT_Y}" stroke="#30363d" stroke-width="2"/>`; |
| 315 | } |
| 316 | |
| 317 | // Colour commits by emotion valence (blue→green gradient) |
| 318 | const emoByCommit = new Map<string, EmotionEvent>(); |
| 319 | (tlData.emotion || []).forEach(e => emoByCommit.set(e.commitId, e)); |
| 320 | |
| 321 | vcs.forEach((c, i) => { |
| 322 | const x = tsToX(new Date(c.timestamp).getTime(), tMin, tMax, svgW); |
| 323 | const sha = c.commitId.substring(0, 8); |
| 324 | const msg = escHtml((c.message || '').substring(0, 60)); |
| 325 | const emo = emoByCommit.get(c.commitId); |
| 326 | |
| 327 | // Colour by valence: low=orange, mid=blue, high=green |
| 328 | let dotColour = '#58a6ff'; |
| 329 | if (emo) { |
| 330 | const v = emo.valence; |
| 331 | if (v < 0.33) dotColour = '#f78166'; |
| 332 | else if (v > 0.66) dotColour = '#3fb950'; |
| 333 | else dotColour = '#58a6ff'; |
| 334 | } |
| 335 | |
| 336 | const tipHtml = `${sha} · ${escHtml(c.branch)}<br>${msg}<br>${escHtml(c.author)} · ${fmtDateTime(c.timestamp)}`; |
| 337 | const cid = c.commitId; |
| 338 | |
| 339 | // Tick mark |
| 340 | paths += `<line x1="${x.toFixed(1)}" y1="${COMMIT_LANE_Y0}" x2="${x.toFixed(1)}" y2="${COMMIT_LANE_Y1}" stroke="#21262d" stroke-width="1"/>`; |
| 341 | |
| 342 | events += ` |
| 343 | <g class="tl-commit-dot" data-id="${cid}" |
| 344 | onclick="window.openAudioModal && window.openAudioModal('${cid}','${sha}')" |
| 345 | style="cursor:pointer" |
| 346 | onmouseenter="window.tlShowTip && window.tlShowTip(event,'${tipHtml.replace(/'/g, ''')}')" |
| 347 | onmouseleave="window.tlHideTip && window.tlHideTip()"> |
| 348 | <circle cx="${x.toFixed(1)}" cy="${COMMIT_Y}" r="7" fill="${dotColour}" stroke="#0d1117" stroke-width="2" opacity="0.92"/> |
| 349 | <circle cx="${x.toFixed(1)}" cy="${COMMIT_Y}" r="12" fill="transparent"/> |
| 350 | </g>`; |
| 351 | }); |
| 352 | } |
| 353 | |
| 354 | // --- Section markers --- |
| 355 | if (layers.sections && tlData.sections) { |
| 356 | const visSec = tlData.sections.filter(s => visIds.has(s.commitId)); |
| 357 | visSec.forEach(s => { |
| 358 | const x = tsToX(new Date(s.timestamp).getTime(), tMin, tMax, svgW); |
| 359 | const lbl = escHtml(s.sectionName); |
| 360 | const clr = s.action === 'removed' ? '#f78166' : '#3fb950'; |
| 361 | const sym = s.action === 'removed' ? '−' : '+'; |
| 362 | events += ` |
| 363 | <g onmouseenter="window.tlShowTip && window.tlShowTip(event,'${sym} ${lbl} section')" |
| 364 | onmouseleave="window.tlHideTip && window.tlHideTip()"> |
| 365 | <rect x="${(x - 5).toFixed(1)}" y="${SECTION_Y - 10}" width="10" height="10" |
| 366 | fill="${clr}" rx="2" opacity="0.9"/> |
| 367 | <text x="${x.toFixed(1)}" y="${SECTION_Y + 14}" text-anchor="middle" |
| 368 | font-size="8" fill="${clr}" font-family="system-ui,sans-serif">${lbl}</text> |
| 369 | </g>`; |
| 370 | }); |
| 371 | } |
| 372 | |
| 373 | // --- Track markers --- |
| 374 | if (layers.tracks && tlData.tracks) { |
| 375 | const visTrk = tlData.tracks.filter(t => visIds.has(t.commitId)); |
| 376 | visTrk.forEach((t, i) => { |
| 377 | const x = tsToX(new Date(t.timestamp).getTime(), tMin, tMax, svgW); |
| 378 | const lbl = escHtml(t.trackName); |
| 379 | const clr = t.action === 'removed' ? '#e3b341' : '#a371f7'; |
| 380 | const sym = t.action === 'removed' ? '−' : '+'; |
| 381 | const dy = (i % 2) * 14; |
| 382 | events += ` |
| 383 | <g onmouseenter="window.tlShowTip && window.tlShowTip(event,'${sym} ${lbl} track')" |
| 384 | onmouseleave="window.tlHideTip && window.tlHideTip()"> |
| 385 | <circle cx="${x.toFixed(1)}" cy="${TRACK_Y + dy}" r="4" fill="${clr}" opacity="0.88"/> |
| 386 | <text x="${(x + 7).toFixed(1)}" y="${TRACK_Y + dy + 3}" font-size="8" fill="${clr}" font-family="system-ui,sans-serif">${lbl}</text> |
| 387 | </g>`; |
| 388 | }); |
| 389 | } |
| 390 | |
| 391 | // --- Session overlays --- |
| 392 | if (layers.sessions && sessions.length > 0) { |
| 393 | const visSess = filterByWindow(sessions, 'startedAt', tMin, tMax); |
| 394 | visSess.forEach(s => { |
| 395 | const x = tsToX(new Date(s.startedAt).getTime(), tMin, tMax, svgW); |
| 396 | const intent = escHtml((s.intent || 'session').substring(0, 50)); |
| 397 | const pList = (s.participants || []).map(p => escHtml(p)).join(', ') || 'no participants'; |
| 398 | const tipHtml = `Session: ${intent}<br>${pList}<br>${fmtDateTime(s.startedAt)}`; |
| 399 | events += ` |
| 400 | <g onmouseenter="window.tlShowTip && window.tlShowTip(event,'${tipHtml.replace(/'/g, ''')}')" |
| 401 | onmouseleave="window.tlHideTip && window.tlHideTip()"> |
| 402 | <line x1="${x.toFixed(1)}" y1="${SESSION_LINE_Y0}" x2="${x.toFixed(1)}" y2="${SESSION_LINE_Y1}" |
| 403 | stroke="#2dd4bf" stroke-width="1.5" stroke-dasharray="5 3" opacity="0.65"/> |
| 404 | <circle cx="${x.toFixed(1)}" cy="${SESSION_LINE_Y0 + 8}" r="5" fill="#2dd4bf" opacity="0.9"/> |
| 405 | </g>`; |
| 406 | }); |
| 407 | } |
| 408 | |
| 409 | // --- Proposal merge markers --- |
| 410 | if (layers.proposals && mergedProposals.length > 0) { |
| 411 | const visProposals = filterByWindow(mergedProposals, 'createdAt', tMin, tMax); |
| 412 | visProposals.forEach(proposal => { |
| 413 | const mergeTs = proposal.mergedAt |
| 414 | ? new Date(proposal.mergedAt).getTime() |
| 415 | : new Date(proposal.createdAt).getTime(); |
| 416 | const x = tsToX(mergeTs, tMin, tMax, svgW); |
| 417 | const title = escHtml((proposal.title || 'Proposal').substring(0, 50)); |
| 418 | const ts = proposal.mergedAt ? proposal.mergedAt : proposal.createdAt; |
| 419 | const ty = PROPOSAL_Y; |
| 420 | events += ` |
| 421 | <g onmouseenter="window.tlShowTip && window.tlShowTip(event,'Proposal merge: ${title}<br>${fmtDateTime(ts)}')" |
| 422 | onmouseleave="window.tlHideTip && window.tlHideTip()"> |
| 423 | <line x1="${x.toFixed(1)}" y1="${SESSION_LINE_Y0 + 14}" x2="${x.toFixed(1)}" y2="${ty}" |
| 424 | stroke="#a371f7" stroke-width="1.5" opacity="0.6"/> |
| 425 | <polygon points="${x},${ty + 9} ${x - 6},${ty - 1} ${x + 6},${ty - 1}" |
| 426 | fill="#a371f7" opacity="0.92"/> |
| 427 | </g>`; |
| 428 | }); |
| 429 | } |
| 430 | |
| 431 | // --- Release markers --- |
| 432 | if (layers.releases && releases.length > 0) { |
| 433 | const visRel = filterByWindow(releases, 'createdAt', tMin, tMax); |
| 434 | visRel.forEach(rel => { |
| 435 | const x = tsToX(new Date(rel.createdAt).getTime(), tMin, tMax, svgW); |
| 436 | const tag = escHtml((rel.tag || '').substring(0, 16)); |
| 437 | const ry = RELEASE_Y; |
| 438 | events += ` |
| 439 | <g onmouseenter="window.tlShowTip && window.tlShowTip(event,'Release: ${tag}<br>${fmtDateTime(rel.createdAt)}')" |
| 440 | onmouseleave="window.tlHideTip && window.tlHideTip()"> |
| 441 | <polygon points="${x},${ry - 8} ${x + 6},${ry} ${x},${ry + 8} ${x - 6},${ry}" |
| 442 | fill="#e3b341" stroke="#0d1117" stroke-width="1" opacity="0.95"/> |
| 443 | <text x="${x.toFixed(1)}" y="${ry + 20}" text-anchor="middle" |
| 444 | font-size="8" fill="#e3b341" font-family="system-ui,sans-serif">${tag}</text> |
| 445 | </g>`; |
| 446 | }); |
| 447 | } |
| 448 | |
| 449 | // --- Compose SVG --- |
| 450 | container.innerHTML = ` |
| 451 | <svg id="timeline-svg" width="${svgW}" height="${SVG_H}" |
| 452 | xmlns="http://www.w3.org/2000/svg" |
| 453 | style="display:block;background:#0d1117"> |
| 454 | <defs>${defs}</defs> |
| 455 | ${lanes} |
| 456 | ${axis} |
| 457 | ${paths} |
| 458 | ${events} |
| 459 | </svg>`; |
| 460 | |
| 461 | // Sync scrubber thumb |
| 462 | const thumb = document.getElementById('scrubber-thumb'); |
| 463 | if (thumb) (thumb as HTMLElement).style.left = (scrubPct * 100) + '%'; |
| 464 | |
| 465 | // Update stats |
| 466 | const countEl = document.getElementById('tl-visible-count'); |
| 467 | if (countEl) countEl.textContent = `${vcs.length} commit${vcs.length !== 1 ? 's' : ''}`; |
| 468 | } |
| 469 | |
| 470 | // --------------------------------------------------------------------------- |
| 471 | // Tooltip |
| 472 | // --------------------------------------------------------------------------- |
| 473 | function setupTooltip(): void { |
| 474 | let tip = document.getElementById('tl-tooltip'); |
| 475 | if (!tip) { |
| 476 | tip = document.createElement('div'); |
| 477 | tip.id = 'tl-tooltip'; |
| 478 | tip.className = 'tl-tooltip'; |
| 479 | document.body.appendChild(tip); |
| 480 | } |
| 481 | const el = tip; |
| 482 | |
| 483 | window.tlShowTip = (evt: MouseEvent, html: string) => { |
| 484 | el.innerHTML = html; |
| 485 | el.style.display = 'block'; |
| 486 | el.style.left = (evt.clientX + 14) + 'px'; |
| 487 | el.style.top = (evt.clientY - 8) + 'px'; |
| 488 | }; |
| 489 | window.tlHideTip = () => { el.style.display = 'none'; }; |
| 490 | } |
| 491 | |
| 492 | // --------------------------------------------------------------------------- |
| 493 | // Audio modal helpers |
| 494 | // --------------------------------------------------------------------------- |
| 495 | |
| 496 | /** Relative timestamp label (no DOM dependency). */ |
| 497 | function relLabel(iso: string): string { |
| 498 | const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); |
| 499 | if (s < 60) return 'just now'; |
| 500 | const m = Math.floor(s / 60); |
| 501 | if (m < 60) return `${m}m ago`; |
| 502 | const h = Math.floor(m / 60); |
| 503 | if (h < 24) return `${h}h ago`; |
| 504 | return `${Math.floor(h / 24)}d ago`; |
| 505 | } |
| 506 | |
| 507 | /** Format seconds → "M:SS". */ |
| 508 | function fmtTime(sec: number): string { |
| 509 | if (!isFinite(sec) || sec < 0) return '—'; |
| 510 | const m = Math.floor(sec / 60); |
| 511 | const s = String(Math.floor(sec % 60)).padStart(2, '0'); |
| 512 | return `${m}:${s}`; |
| 513 | } |
| 514 | |
| 515 | interface MusicalBadge { cls: string; label: string; } |
| 516 | |
| 517 | /** Extract musical badges from a commit message (mirrors server-side Python). */ |
| 518 | function extractBadges(msg: string): MusicalBadge[] { |
| 519 | const badges: MusicalBadge[] = []; |
| 520 | const bpmM = /\b(\d{2,3})\s*(?:bpm|BPM)\b/.exec(msg); |
| 521 | if (bpmM) badges.push({ cls: 'am-bpm', label: `♩ ${bpmM[1]} BPM` }); |
| 522 | const keyM = /\b([A-G][b#]?(?:m(?:aj(?:or)?)?|min(?:or)?|M)?)\b/.exec(msg); |
| 523 | if (keyM) badges.push({ cls: 'am-key', label: `🎵 ${keyM[1]}` }); |
| 524 | const emoM = /emotion:([\w-]+)/i.exec(msg); |
| 525 | if (emoM) badges.push({ cls: '', label: `💜 ${emoM[1]}` }); |
| 526 | const instrRe = /\b(piano|bass|drums?|keys|strings?|guitar|synth|pad|lead|brass|horn|flute|cello|violin|organ|arp|vocals?|percussion|kick|snare|hihat|hi-hat|clap)\b/gi; |
| 527 | const instrs = [...new Set([...msg.matchAll(instrRe)].map(m => m[1].toLowerCase()))].slice(0, 3); |
| 528 | if (instrs.length) badges.push({ cls: 'am-instr', label: instrs.join(' · ') }); |
| 529 | return badges; |
| 530 | } |
| 531 | |
| 532 | // --------------------------------------------------------------------------- |
| 533 | // Audio modal |
| 534 | // --------------------------------------------------------------------------- |
| 535 | function setupAudioModal(): void { |
| 536 | window.openAudioModal = (commitId: string, sha: string) => { |
| 537 | document.getElementById('audio-modal')?.remove(); |
| 538 | |
| 539 | // Look up commit metadata from already-fetched timeline data |
| 540 | const commit = tlData?.commits?.find(c => c.commitId === commitId); |
| 541 | const message = commit?.message ?? sha; |
| 542 | const author = commit?.author ?? '?'; |
| 543 | const branch = commit?.branch ?? ''; |
| 544 | const tsIso = commit?.timestamp ?? ''; |
| 545 | const tsLabel = tsIso ? relLabel(tsIso) : ''; |
| 546 | const initial = author[0]?.toUpperCase() ?? '?'; |
| 547 | const badges = extractBadges(message); |
| 548 | const audioSrc = `/api/repos/${cfg.repoId}/commits/${commitId}/audio`; |
| 549 | const commitUrl = `${cfg.baseUrl}/commits/${commitId}`; |
| 550 | |
| 551 | // Build badge HTML |
| 552 | const badgeHTML = badges.map(b => |
| 553 | `<span class="am-badge ${escHtml(b.cls)}">${escHtml(b.label)}</span>` |
| 554 | ).join(''); |
| 555 | |
| 556 | const modal = document.createElement('div'); |
| 557 | modal.id = 'audio-modal'; |
| 558 | modal.className = 'audio-modal'; |
| 559 | modal.setAttribute('role', 'dialog'); |
| 560 | modal.setAttribute('aria-modal', 'true'); |
| 561 | modal.setAttribute('aria-label', `Audio preview — commit ${sha}`); |
| 562 | |
| 563 | modal.innerHTML = ` |
| 564 | <div class="am-box" id="am-box"> |
| 565 | |
| 566 | <div class="am-header"> |
| 567 | <span class="am-header-icon">🎧</span> |
| 568 | <span class="am-header-title">Audio Preview</span> |
| 569 | <span class="am-sha">${escHtml(sha)}</span> |
| 570 | <button class="am-close-btn" id="am-close-btn" title="Close (Esc)" aria-label="Close">✕</button> |
| 571 | </div> |
| 572 | |
| 573 | <div class="am-body"> |
| 574 | <div class="am-message">${escHtml(message)}</div> |
| 575 | |
| 576 | <div class="am-meta"> |
| 577 | <span class="am-avatar">${escHtml(initial)}</span> |
| 578 | <span class="am-author">${escHtml(author)}</span> |
| 579 | ${branch ? `<span class="am-branch">⑂ ${escHtml(branch)}</span>` : ''} |
| 580 | ${tsLabel ? `<span title="${escHtml(tsIso)}">${escHtml(tsLabel)}</span>` : ''} |
| 581 | </div> |
| 582 | |
| 583 | ${badgeHTML ? `<div class="am-badges">${badgeHTML}</div>` : ''} |
| 584 | |
| 585 | <div class="am-player" id="am-player"> |
| 586 | <div class="am-player-row"> |
| 587 | <button class="am-play-btn" id="am-play-btn" title="Play / Pause" disabled>▶</button> |
| 588 | <div class="am-progress-wrap" id="am-prog-wrap"> |
| 589 | <div class="am-progress-fill" id="am-prog-fill"></div> |
| 590 | </div> |
| 591 | <span class="am-time" id="am-time">Loading…</span> |
| 592 | </div> |
| 593 | </div> |
| 594 | </div> |
| 595 | |
| 596 | <div class="am-footer"> |
| 597 | <a href="${escHtml(commitUrl)}" class="btn btn-secondary btn-sm">View commit ↗</a> |
| 598 | <button class="btn btn-ghost btn-sm" id="am-close-btn-2">Close</button> |
| 599 | </div> |
| 600 | |
| 601 | </div> |
| 602 | <audio id="am-audio" preload="none" style="display:none"> |
| 603 | <source src="${escHtml(audioSrc)}" type="audio/mpeg"> |
| 604 | </audio>`; |
| 605 | |
| 606 | // Wire close events |
| 607 | const close = () => modal.remove(); |
| 608 | modal.addEventListener('click', e => { if (e.target === modal) close(); }); |
| 609 | modal.querySelector('#am-close-btn')?.addEventListener('click', close); |
| 610 | modal.querySelector('#am-close-btn-2')?.addEventListener('click', close); |
| 611 | |
| 612 | // ESC key |
| 613 | const onKey = (e: KeyboardEvent) => { |
| 614 | if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } |
| 615 | }; |
| 616 | document.addEventListener('keydown', onKey); |
| 617 | modal.addEventListener('remove', () => document.removeEventListener('keydown', onKey)); |
| 618 | |
| 619 | document.body.appendChild(modal); |
| 620 | |
| 621 | // Wire custom audio player |
| 622 | const audio = modal.querySelector<HTMLAudioElement>('#am-audio')!; |
| 623 | const playBtn = modal.querySelector<HTMLButtonElement>('#am-play-btn')!; |
| 624 | const progWrap = modal.querySelector<HTMLElement>('#am-prog-wrap')!; |
| 625 | const progFill = modal.querySelector<HTMLElement>('#am-prog-fill')!; |
| 626 | const timeEl = modal.querySelector<HTMLElement>('#am-time')!; |
| 627 | |
| 628 | audio.addEventListener('canplaythrough', () => { |
| 629 | playBtn.disabled = false; |
| 630 | timeEl.textContent = `0:00 / ${fmtTime(audio.duration)}`; |
| 631 | }); |
| 632 | audio.addEventListener('error', () => { |
| 633 | playBtn.disabled = true; |
| 634 | timeEl.textContent = 'No audio'; |
| 635 | modal.querySelector<HTMLElement>('#am-player')!.innerHTML = |
| 636 | `<div class="am-no-audio">🔇 No audio available for this commit.<br> |
| 637 | <a href="${escHtml(commitUrl)}" style="color:var(--color-accent)">View full commit →</a></div>`; |
| 638 | }); |
| 639 | audio.addEventListener('timeupdate', () => { |
| 640 | const pct = audio.duration ? (audio.currentTime / audio.duration) * 100 : 0; |
| 641 | progFill.style.width = `${pct}%`; |
| 642 | timeEl.textContent = `${fmtTime(audio.currentTime)} / ${fmtTime(audio.duration)}`; |
| 643 | }); |
| 644 | audio.addEventListener('ended', () => { playBtn.textContent = '▶'; }); |
| 645 | |
| 646 | playBtn.addEventListener('click', () => { |
| 647 | if (audio.paused) { |
| 648 | audio.play().catch(() => { timeEl.textContent = 'Playback error'; }); |
| 649 | playBtn.textContent = '⏸'; |
| 650 | } else { |
| 651 | audio.pause(); |
| 652 | playBtn.textContent = '▶'; |
| 653 | } |
| 654 | }); |
| 655 | |
| 656 | // Click progress bar to seek |
| 657 | progWrap.addEventListener('click', (e: MouseEvent) => { |
| 658 | if (!audio.duration) return; |
| 659 | const rect = progWrap.getBoundingClientRect(); |
| 660 | audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration; |
| 661 | }); |
| 662 | |
| 663 | audio.load(); |
| 664 | }; |
| 665 | } |
| 666 | |
| 667 | // --------------------------------------------------------------------------- |
| 668 | // Scrubber (functional — actually re-filters visible commits) |
| 669 | // --------------------------------------------------------------------------- |
| 670 | function setupScrubber(): void { |
| 671 | const bar = document.getElementById('scrubber-bar'); |
| 672 | if (!bar) return; |
| 673 | let dragging = false; |
| 674 | |
| 675 | function updateFromEvent(e: MouseEvent): void { |
| 676 | const rect = bar!.getBoundingClientRect(); |
| 677 | const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); |
| 678 | scrubPct = pct; |
| 679 | const thumb = document.getElementById('scrubber-thumb'); |
| 680 | if (thumb) (thumb as HTMLElement).style.left = (pct * 100) + '%'; |
| 681 | // Snap zoom to reflect scrubber position (coarse time filter) |
| 682 | // pct=1 = now/newest, pct=0 = oldest |
| 683 | renderTimeline(); |
| 684 | } |
| 685 | |
| 686 | bar.addEventListener('mousedown', e => { dragging = true; updateFromEvent(e as MouseEvent); }); |
| 687 | document.addEventListener('mousemove', e => { if (dragging) updateFromEvent(e as MouseEvent); }); |
| 688 | document.addEventListener('mouseup', () => { dragging = false; }); |
| 689 | } |
| 690 | |
| 691 | // --------------------------------------------------------------------------- |
| 692 | // Layer + zoom controls — bound via addEventListener, no inline handlers |
| 693 | // --------------------------------------------------------------------------- |
| 694 | function setupLayerAndZoomControls(): void { |
| 695 | // Layer toggle checkboxes: <input data-layer="commits"> etc. |
| 696 | document.querySelectorAll<HTMLInputElement>('[data-layer]').forEach(cb => { |
| 697 | cb.addEventListener('change', () => { |
| 698 | (layers as Record<string, boolean>)[cb.dataset.layer!] = cb.checked; |
| 699 | renderTimeline(); |
| 700 | }); |
| 701 | }); |
| 702 | |
| 703 | // Zoom buttons: <button data-zoom="day"> etc. |
| 704 | document.querySelectorAll<HTMLElement>('[data-zoom]').forEach(btn => { |
| 705 | btn.addEventListener('click', () => { |
| 706 | const z = btn.dataset.zoom!; |
| 707 | zoom = z; |
| 708 | document.querySelectorAll<HTMLElement>('[data-zoom]').forEach(b => { |
| 709 | b.classList.toggle('active', b.dataset.zoom === z); |
| 710 | }); |
| 711 | renderTimeline(); |
| 712 | }); |
| 713 | }); |
| 714 | } |
| 715 | |
| 716 | // Keep window globals as legacy shims so any cached HTML still works |
| 717 | window.toggleLayer = (name: string, checked: boolean): void => { |
| 718 | (layers as Record<string, boolean>)[name] = checked; |
| 719 | renderTimeline(); |
| 720 | }; |
| 721 | window.setZoom = (z: string): void => { |
| 722 | zoom = z; |
| 723 | document.querySelectorAll<HTMLElement>('[data-zoom]').forEach(b => { |
| 724 | b.classList.toggle('active', b.dataset.zoom === z); |
| 725 | }); |
| 726 | renderTimeline(); |
| 727 | }; |
| 728 | |
| 729 | // --------------------------------------------------------------------------- |
| 730 | // Data loading |
| 731 | // --------------------------------------------------------------------------- |
| 732 | async function loadOverlays(): Promise<void> { |
| 733 | const [sessData, prData, relData] = await Promise.allSettled([ |
| 734 | apiFetch('/repos/' + cfg.repoId + '/sessions?limit=200'), |
| 735 | apiFetch('/repos/' + cfg.repoId + '/proposals?state=merged'), |
| 736 | apiFetch('/repos/' + cfg.repoId + '/releases'), |
| 737 | ]); |
| 738 | |
| 739 | if (sessData.status === 'fulfilled' && sessData.value) { |
| 740 | const v = sessData.value as { sessions?: SessionData[] }; |
| 741 | sessions = v.sessions ?? []; |
| 742 | } |
| 743 | if (prData.status === 'fulfilled' && prData.value) { |
| 744 | const v = prData.value as { proposals?: ProposalData[] }; |
| 745 | mergedProposals = v.proposals ?? []; |
| 746 | } |
| 747 | if (relData.status === 'fulfilled' && relData.value) { |
| 748 | const v = relData.value as { releases?: ReleaseData[] }; |
| 749 | releases = v.releases ?? []; |
| 750 | } |
| 751 | } |
| 752 | |
| 753 | // --------------------------------------------------------------------------- |
| 754 | // Entry point |
| 755 | // --------------------------------------------------------------------------- |
| 756 | export function initTimeline(data: Record<string, unknown> = {}): void { |
| 757 | cfg = { |
| 758 | repoId: String(data['repoId'] ?? ''), |
| 759 | baseUrl: String(data['baseUrl'] ?? ''), |
| 760 | totalCommits: Number(data['totalCommits'] ?? 0), |
| 761 | }; |
| 762 | if (!cfg.repoId) return; |
| 763 | |
| 764 | initRepoNav(cfg.repoId); |
| 765 | setupTooltip(); |
| 766 | setupAudioModal(); |
| 767 | setupScrubber(); |
| 768 | setupLayerAndZoomControls(); |
| 769 | |
| 770 | (async () => { |
| 771 | try { |
| 772 | const [data] = await Promise.all([ |
| 773 | apiFetch('/repos/' + cfg.repoId + '/timeline?limit=200') as Promise<TimelineData>, |
| 774 | loadOverlays(), |
| 775 | ]); |
| 776 | tlData = data; |
| 777 | |
| 778 | // Update total commit count if available |
| 779 | const totalEl = document.getElementById('tl-total-count'); |
| 780 | if (totalEl && data.totalCommits) { |
| 781 | totalEl.textContent = String(data.totalCommits); |
| 782 | } |
| 783 | |
| 784 | renderTimeline(); |
| 785 | } catch (e) { |
| 786 | const err = e as Error; |
| 787 | if (err.message !== 'auth') { |
| 788 | const container = document.getElementById('timeline-svg-container'); |
| 789 | if (container) { |
| 790 | container.innerHTML = `<div class="tl-loading"><div class="tl-loading-inner error"> |
| 791 | ✕ ${escHtml(err.message)} |
| 792 | </div></div>`; |
| 793 | } |
| 794 | } |
| 795 | } |
| 796 | })(); |
| 797 | } |
| 798 | |
| 799 | // Augment window type for onclick handlers and module communication |
| 800 | declare global { |
| 801 | interface Window { |
| 802 | tlShowTip: (evt: MouseEvent, html: string) => void; |
| 803 | tlHideTip: () => void; |
| 804 | openAudioModal: (commitId: string, sha: string) => void; |
| 805 | toggleLayer: (name: string, checked: boolean) => void; |
| 806 | setZoom: (z: string) => void; |
| 807 | } |
| 808 | } |
File History
1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2
feat: add repair-commit wire endpoint (API parity with repa…
Opus 4.8
minor
⚠
1 day ago