gabriel / musehub public
timeline.ts typescript
808 lines 31.0 KB
Raw
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, '&#39;')}')"
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, '&#39;')}')"
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