feat: Extract shared citation parser and formatTime utilities, add time…

- "frontend/src/utils/chatCitations.tsx"
- "frontend/src/utils/formatTime.ts"
- "frontend/src/api/chat.ts"
- "frontend/src/pages/ChatPage.tsx"
- "frontend/src/pages/ChatPage.module.css"
- "frontend/src/components/ChatWidget.tsx"
- "frontend/src/components/ChatWidget.module.css"

GSD-Task: S05/T02
This commit is contained in:
jlightner 2026-04-04 11:46:00 +00:00
parent f706afe8f6
commit 0eecee4271
11 changed files with 316 additions and 130 deletions

View file

@ -18,7 +18,7 @@
- Estimate: 30m
- Files: backend/search_service.py, backend/chat_service.py
- Verify: cd backend && python -c "from chat_service import _build_sources; print('OK')" && python -c "from search_service import SearchService; print('OK')"
- [ ] **T02: Timestamp links and enhanced source cards in ChatPage, ChatWidget, and shared citation utility** — Extend the frontend ChatSource type with video fields, update both ChatPage and ChatWidget source cards to show timestamp badges that link to the WatchPage, and consolidate the duplicated citation parser into a shared utility.
- [x] **T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget** — Extend the frontend ChatSource type with video fields, update both ChatPage and ChatWidget source cards to show timestamp badges that link to the WatchPage, and consolidate the duplicated citation parser into a shared utility.
## Steps

View file

@ -0,0 +1,16 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M024/S05/T01",
"timestamp": 1775302859036,
"passed": true,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd backend",
"exitCode": 0,
"durationMs": 9,
"verdict": "pass"
}
]
}

View file

@ -0,0 +1,88 @@
---
id: T02
parent: S05
milestone: M024
provides: []
requires: []
affects: []
key_files: ["frontend/src/utils/chatCitations.tsx", "frontend/src/utils/formatTime.ts", "frontend/src/api/chat.ts", "frontend/src/pages/ChatPage.tsx", "frontend/src/pages/ChatPage.module.css", "frontend/src/components/ChatWidget.tsx", "frontend/src/components/ChatWidget.module.css"]
key_decisions: ["Pass CSS module styles as Record<string, string> to shared citation parser to avoid CSSModuleClasses structural typing mismatch"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Frontend build passes with zero TypeScript and Vite errors. Confirmed local parse functions and CITATION_RE constants fully removed from both ChatPage.tsx and ChatWidget.tsx."
completed_at: 2026-04-04T11:45:48.138Z
blocker_discovered: false
---
# T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget
> Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget
## What Happened
---
id: T02
parent: S05
milestone: M024
key_files:
- frontend/src/utils/chatCitations.tsx
- frontend/src/utils/formatTime.ts
- frontend/src/api/chat.ts
- frontend/src/pages/ChatPage.tsx
- frontend/src/pages/ChatPage.module.css
- frontend/src/components/ChatWidget.tsx
- frontend/src/components/ChatWidget.module.css
key_decisions:
- Pass CSS module styles as Record<string, string> to shared citation parser to avoid CSSModuleClasses structural typing mismatch
duration: ""
verification_result: passed
completed_at: 2026-04-04T11:45:48.138Z
blocker_discovered: false
---
# T02: Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget
**Extract shared citation parser and formatTime utilities, add timestamp badge links and video metadata to chat source cards in ChatPage and ChatWidget**
## What Happened
Extended ChatSource interface with video fields (source_video_id, start_time, end_time, video_filename). Created shared parseChatCitations utility in utils/chatCitations.tsx, removing duplicate implementations from both ChatPage and ChatWidget. Created shared formatTime utility with hour-aware formatting. Updated source card rendering in both components to show timestamp badges linking to /watch/:id?t=N and video filename metadata. Added corresponding CSS classes to both module stylesheets.
## Verification
Frontend build passes with zero TypeScript and Vite errors. Confirmed local parse functions and CITATION_RE constants fully removed from both ChatPage.tsx and ChatWidget.tsx.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 6700ms |
| 2 | `grep -c CITATION_RE ChatPage.tsx ChatWidget.tsx` | 0 | ✅ pass | 100ms |
| 3 | `grep -c 'function parseCitations' ChatPage.tsx ChatWidget.tsx` | 0 | ✅ pass | 100ms |
## Deviations
Used Record<string, string> for styles param instead of strict interface due to CSSModuleClasses typing. Added .sourceContent wrapper div in ChatWidget for vertical layout.
## Known Issues
None.
## Files Created/Modified
- `frontend/src/utils/chatCitations.tsx`
- `frontend/src/utils/formatTime.ts`
- `frontend/src/api/chat.ts`
- `frontend/src/pages/ChatPage.tsx`
- `frontend/src/pages/ChatPage.module.css`
- `frontend/src/components/ChatWidget.tsx`
- `frontend/src/components/ChatWidget.module.css`
## Deviations
Used Record<string, string> for styles param instead of strict interface due to CSSModuleClasses typing. Added .sourceContent wrapper div in ChatWidget for vertical layout.
## Known Issues
None.

View file

@ -19,6 +19,10 @@ export interface ChatSource {
summary: string;
section_anchor: string;
section_heading: string;
source_video_id?: string;
start_time?: number;
end_time?: number;
video_filename?: string;
}
export interface ChatDoneMeta {

View file

@ -462,6 +462,49 @@
cursor: not-allowed;
}
/* ── Source card content wrapper ───────────────────────────── */
.sourceContent {
display: flex;
flex-direction: column;
gap: 0.125rem;
overflow: hidden;
min-width: 0;
}
/* ── Timestamp badge & video metadata ─────────────────────── */
.timestampBadge {
display: inline-flex;
align-items: center;
gap: 0.1875rem;
padding: 0.0625rem 0.25rem;
background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.12);
color: var(--color-accent);
border-radius: 3px;
font-size: 0.625rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-decoration: none;
transition: background 0.15s;
white-space: nowrap;
width: fit-content;
}
.timestampBadge:hover {
background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.22);
text-decoration: none;
}
.videoMeta {
color: var(--color-text-secondary);
font-size: 0.5625rem;
opacity: 0.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Responsive (mobile) ──────────────────────────────────── */
@media (max-width: 640px) {

View file

@ -9,11 +9,10 @@
import React, { useState, useRef, useCallback, useEffect, useMemo } from "react";
import { Link } from "react-router-dom";
import { streamChat, type ChatSource } from "../api/chat";
import { parseChatCitations } from "../utils/chatCitations";
import { formatTime } from "../utils/formatTime";
import styles from "./ChatWidget.module.css";
// Same citation regex as ChatPage / utils/citations.tsx
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
interface Technique {
title: string;
slug: string;
@ -60,61 +59,6 @@ function buildSuggestions(creatorName: string, techniques: Technique[]): string[
return suggestions.slice(0, 3);
}
/** Parse citation markers [N] into React nodes with superscript links. */
function parseCitations(text: string, sources: ChatSource[]): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(CITATION_RE)) {
const matchStart = match.index ?? 0;
if (matchStart > lastIndex) {
nodes.push(text.slice(lastIndex, matchStart));
}
const rawGroup = match[1];
if (!rawGroup) continue;
const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10));
const links: React.ReactNode[] = [];
for (let i = 0; i < indices.length; i++) {
const idx = indices[i]!;
const source = sources[idx - 1];
if (source) {
if (i > 0) links.push(", ");
const href = source.section_anchor
? `/techniques/${source.slug}#${source.section_anchor}`
: `/techniques/${source.slug}`;
links.push(
<Link
key={`${matchStart}-${idx}`}
to={href}
className={styles.citationLink}
title={`${source.title}${source.section_heading ? `${source.section_heading}` : ""}`}
>
{idx}
</Link>,
);
} else {
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className={styles.citationGroup}>
[{links}]
</sup>,
);
lastIndex = matchStart + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}
/** Map personality weight to a human-readable tier label. */
function getTierLabel(weight: number): string {
if (weight < 0.2) return "Encyclopedic";
@ -339,7 +283,7 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
<div className={styles.errorMsg}>{msg.error}</div>
) : (
<>
{parseCitations(msg.text, msg.sources)}
{parseChatCitations(msg.text, msg.sources, styles)}
{!msg.done && <span className={styles.cursor} />}
{msg.done && msg.sources.length > 0 && (
<div className={styles.sources}>
@ -352,10 +296,25 @@ export default function ChatWidget({ creatorName, techniques }: ChatWidgetProps)
return (
<li key={src.number} className={styles.sourceItem}>
<span className={styles.sourceNum}>{src.number}</span>
<Link to={href} className={styles.sourceLink}>
{src.title}
{src.section_heading ? `${src.section_heading}` : ""}
</Link>
<div className={styles.sourceContent}>
<Link to={href} className={styles.sourceLink}>
{src.title}
{src.section_heading ? `${src.section_heading}` : ""}
</Link>
{src.start_time != null && src.source_video_id && (
<Link
to={`/watch/${src.source_video_id}?t=${Math.floor(src.start_time)}`}
className={styles.timestampBadge}
title="Jump to video timestamp"
>
{formatTime(src.start_time)}
{src.end_time != null && `${formatTime(src.end_time)}`}
</Link>
)}
{src.video_filename && (
<span className={styles.videoMeta}>{src.video_filename}</span>
)}
</div>
</li>
);
})}

View file

@ -252,6 +252,36 @@
font-size: 0.75rem;
}
/* ── Timestamp badge & video metadata ─────────────────────── */
.timestampBadge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
padding: 0.125rem 0.375rem;
background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.12);
color: var(--color-accent);
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-decoration: none;
transition: background 0.15s;
white-space: nowrap;
}
.timestampBadge:hover {
background: rgba(var(--color-accent-rgb, 217, 186, 140), 0.22);
text-decoration: none;
}
.videoMeta {
color: var(--color-text-secondary);
font-size: 0.6875rem;
opacity: 0.7;
}
/* ── Citation superscript links in text ───────────────────── */
.citationGroup {

View file

@ -10,12 +10,11 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { Link } from "react-router-dom";
import { streamChat, type ChatSource } from "../api/chat";
import { parseChatCitations } from "../utils/chatCitations";
import { formatTime } from "../utils/formatTime";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./ChatPage.module.css";
// Matches [1], [2,3], [1,2,3] etc. — same regex as utils/citations.tsx
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
interface Message {
role: "user" | "assistant";
text: string;
@ -24,67 +23,6 @@ interface Message {
error?: string;
}
/**
* Parse text containing [N] markers into React nodes with citation links.
*/
function parseChatCitations(
text: string,
sources: ChatSource[],
): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(CITATION_RE)) {
const matchStart = match.index ?? 0;
if (matchStart > lastIndex) {
nodes.push(text.slice(lastIndex, matchStart));
}
const rawGroup = match[1];
if (!rawGroup) continue;
const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10));
const links: React.ReactNode[] = [];
for (let i = 0; i < indices.length; i++) {
const idx = indices[i]!;
const source = sources[idx - 1]; // 1-based
if (source) {
if (i > 0) links.push(", ");
const href = source.section_anchor
? `/techniques/${source.slug}#${source.section_anchor}`
: `/techniques/${source.slug}`;
links.push(
<Link
key={`${matchStart}-${idx}`}
to={href}
className={styles.citationLink}
title={`${source.title}${source.section_heading ? `${source.section_heading}` : ""}`}
>
{idx}
</Link>,
);
} else {
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className={styles.citationGroup}>
[{links}]
</sup>,
);
lastIndex = matchStart + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}
export default function ChatPage() {
useDocumentTitle("Chat — Chrysopedia");
@ -244,7 +182,7 @@ export default function ChatPage() {
) : (
<>
<div className={styles.responseText}>
{parseChatCitations(msg.text, msg.sources)}
{parseChatCitations(msg.text, msg.sources, styles)}
{!msg.done && <span className={styles.cursor} />}
</div>
{msg.done && msg.sources.length > 0 && (
@ -273,12 +211,28 @@ export default function ChatPage() {
? `${src.section_heading}`
: ""}
</Link>
{src.start_time != null && src.source_video_id && (
<Link
to={`/watch/${src.source_video_id}?t=${Math.floor(src.start_time)}`}
className={styles.timestampBadge}
title="Jump to video timestamp"
>
{formatTime(src.start_time)}
{src.end_time != null && `${formatTime(src.end_time)}`}
</Link>
)}
{src.creator_name && (
<span className={styles.sourceMeta}>
{" "}
· {src.creator_name}
</span>
)}
{src.video_filename && (
<span className={styles.videoMeta}>
{" "}
· {src.video_filename}
</span>
)}
</div>
</li>
);

View file

@ -0,0 +1,77 @@
/**
* Shared chat citation parser for ChatPage and ChatWidget.
*
* Parses [N] and [N,M] markers in streamed chat text into superscript
* <Link> elements pointing to technique pages with optional section anchors.
*/
import React from "react";
import { Link } from "react-router-dom";
import type { ChatSource } from "../api/chat";
/** Matches [1], [2,3], [1,2,3] etc. */
const CITATION_RE = /\[(\d+(?:,\s*\d+)*)\]/g;
/**
* Parse text containing [N] markers into React nodes with citation links.
* Takes a styles object so both ChatPage and ChatWidget can pass their own CSS modules.
* Expects `citationGroup` and `citationLink` keys in the styles object.
*/
export function parseChatCitations(
text: string,
sources: ChatSource[],
styles: Record<string, string>,
): React.ReactNode[] {
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of text.matchAll(CITATION_RE)) {
const matchStart = match.index ?? 0;
if (matchStart > lastIndex) {
nodes.push(text.slice(lastIndex, matchStart));
}
const rawGroup = match[1];
if (!rawGroup) continue;
const indices = rawGroup.split(",").map((s) => parseInt(s.trim(), 10));
const links: React.ReactNode[] = [];
for (let i = 0; i < indices.length; i++) {
const idx = indices[i]!;
const source = sources[idx - 1]; // 1-based
if (source) {
if (i > 0) links.push(", ");
const href = source.section_anchor
? `/techniques/${source.slug}#${source.section_anchor}`
: `/techniques/${source.slug}`;
links.push(
<Link
key={`${matchStart}-${idx}`}
to={href}
className={styles.citationLink}
title={`${source.title}${source.section_heading ? `${source.section_heading}` : ""}`}
>
{idx}
</Link>,
);
} else {
if (i > 0) links.push(", ");
links.push(String(idx));
}
}
nodes.push(
<sup key={`cite-${matchStart}`} className={styles.citationGroup}>
[{links}]
</sup>,
);
lastIndex = matchStart + match[0].length;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes.length > 0 ? nodes : [text];
}

View file

@ -0,0 +1,15 @@
/**
* Format a duration in seconds as M:SS (< 1 hour) or H:MM:SS (>= 1 hour).
* Used by player controls, chapter markers, key moment badges, and chat source cards.
*/
export function formatTime(seconds: number): string {
const total = Math.floor(seconds);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) {
return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
}
return `${m}:${s.toString().padStart(2, "0")}`;
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/templates.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/EmbedPlayer.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/ShortPlayer.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx","./src/utils/clipboard.ts"],"version":"5.6.3"}
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/templates.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/EmbedPlayer.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/ShortPlayer.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/chatCitations.tsx","./src/utils/citations.tsx","./src/utils/clipboard.ts","./src/utils/formatTime.ts"],"version":"5.6.3"}