From 0f0c63c0fc0a91883dfddef55fb26c772fbfc134 Mon Sep 17 00:00:00 2001 From: Helmi Date: Sun, 1 Mar 2026 22:41:40 +0100 Subject: [PATCH] fix(td): harden task detail payload + timestamp handling --- client/src/components/TaskDetailModal.test.ts | 95 ++++++++++- client/src/components/TaskDetailModal.tsx | 149 ++++++++++++++---- client/src/lib/taskDetailLayout.ts | 32 +++- client/src/lib/tdTimestamp.test.ts | 49 ++++++ client/src/lib/tdTimestamp.ts | 109 +++++++++++++ client/src/lib/types.ts | 13 ++ src/services/tdReader.test.ts | 43 ++++- src/services/tdReader.ts | 27 ++++ 8 files changed, 481 insertions(+), 36 deletions(-) create mode 100644 client/src/lib/tdTimestamp.test.ts create mode 100644 client/src/lib/tdTimestamp.ts diff --git a/client/src/components/TaskDetailModal.test.ts b/client/src/components/TaskDetailModal.test.ts index 314830e..b724bdd 100644 --- a/client/src/components/TaskDetailModal.test.ts +++ b/client/src/components/TaskDetailModal.test.ts @@ -3,6 +3,7 @@ import type { TdIssueWithChildren } from '../lib/types' import { getLinkedSessions, getTaskDetailLayoutCounts, + hasSchedulingDetails, parseAcceptanceCriteria, } from '../lib/taskDetailLayout' @@ -14,7 +15,7 @@ function makeIssue(overrides: Partial = {}): TdIssueWithChi status: 'open', type: 'task', priority: 'P1', - points: 1, + points: 0, labels: '', parent_id: '', acceptance: '', @@ -27,9 +28,14 @@ function makeIssue(overrides: Partial = {}): TdIssueWithChi minor: 0, created_branch: '', creator_session: '', + sprint: '', + defer_until: null, + due_date: null, + defer_count: 0, children: [], handoffs: [], files: [], + comments: [], ...overrides, } } @@ -60,6 +66,7 @@ describe('TaskDetailModal layout helpers', () => { description: 'Some description', acceptance: '- one\n- two', labels: 'webui, ux', + points: 3, implementer_session: 'ses_impl', reviewer_session: 'ses_rev', children: [ @@ -77,6 +84,15 @@ describe('TaskDetailModal layout helpers', () => { timestamp: '2026-02-20 08:30:10 +0000 UTC', }, ], + comments: [ + { + id: 'comment-1', + issue_id: 'td-1', + session_id: 'ses_impl', + text: 'Looks good.', + created_at: '2026-02-20 08:30:10 +0000 UTC', + }, + ], files: [ { id: 'file-1', @@ -89,8 +105,8 @@ describe('TaskDetailModal layout helpers', () => { expect(getTaskDetailLayoutCounts(issue)).toEqual({ overview: 4, - activity: 2, - details: 2, + activity: 3, + details: 3, }) }) @@ -102,4 +118,77 @@ describe('TaskDetailModal layout helpers', () => { details: 1, }) }) + + it('adds scheduling section count only when scheduling values exist', () => { + const withScheduling = makeIssue({ + due_date: '2026-02-25', + }) + const withoutScheduling = makeIssue() + + expect(hasSchedulingDetails(withScheduling)).toBe(true) + expect(hasSchedulingDetails(withoutScheduling)).toBe(false) + + expect(getTaskDetailLayoutCounts(withScheduling)).toEqual({ + overview: 0, + activity: 0, + details: 2, + }) + }) + + it('treats defer_count as scheduling detail', () => { + const issue = makeIssue({ + defer_count: 2, + }) + + expect(hasSchedulingDetails(issue)).toBe(true) + expect(getTaskDetailLayoutCounts(issue)).toEqual({ + overview: 0, + activity: 0, + details: 2, + }) + }) + + it('keeps acceptance criteria as a visible overview section without description', () => { + const issue = makeIssue({ + acceptance: '- must pass', + }) + + expect(getTaskDetailLayoutCounts(issue)).toEqual({ + overview: 1, + activity: 0, + details: 1, + }) + }) + + it('counts comments section as activity when comments exist', () => { + const issue = makeIssue({ + comments: [ + { + id: 'comment-1', + issue_id: 'td-1', + session_id: 'ses_reviewer', + text: 'Needs one more test.', + created_at: '2026-02-20 08:30:10 +0000 UTC', + }, + ], + }) + + expect(getTaskDetailLayoutCounts(issue)).toEqual({ + overview: 0, + activity: 1, + details: 1, + }) + }) + + it('does not throw when comments payload is missing', () => { + const legacy = makeIssue() + delete (legacy as { comments?: unknown }).comments + + expect(() => getTaskDetailLayoutCounts(legacy)).not.toThrow() + expect(getTaskDetailLayoutCounts(legacy)).toEqual({ + overview: 0, + activity: 0, + details: 1, + }) + }) }) diff --git a/client/src/components/TaskDetailModal.tsx b/client/src/components/TaskDetailModal.tsx index 1e5cdc8..da04245 100644 --- a/client/src/components/TaskDetailModal.tsx +++ b/client/src/components/TaskDetailModal.tsx @@ -4,10 +4,17 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { cn } from '@/lib/utils' import { useAppStore } from '@/lib/store' +import { + formatTdAbsolute, + formatTdDateValue, + formatTdRelative, + parseTdTimestamp, +} from '@/lib/tdTimestamp' import type { TdIssueWithChildren, TdHandoffParsed, TdIssue } from '@/lib/types' import { getLinkedSessions, getTaskDetailLayoutCounts, + hasSchedulingDetails, parseAcceptanceCriteria, } from '@/lib/taskDetailLayout' import type { LucideIcon } from 'lucide-react' @@ -32,17 +39,15 @@ import { type TaskDetailTab = 'overview' | 'activity' | 'details' -/** - * Parse Go-formatted timestamps like "2026-02-19 08:30:10.451538 +0100 CET m=+0.117390418" - * into a JavaScript Date. Strips the timezone abbreviation and monotonic clock suffix. - */ -function parseGoDate(s: string): Date | null { - if (!s) return null - // Extract: YYYY-MM-DD HH:MM:SS +ZZZZ (ignore fractional seconds, tz name, monotonic clock) - const match = s.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(?:\.\d+)?\s+([+-]\d{2})(\d{2})/) - if (!match) return null - const d = new Date(`${match[1]}T${match[2]}${match[3]}:${match[4]}`) - return isNaN(d.getTime()) ? null : d +function RelativeTime({ timestamp, nowMs }: { timestamp?: string | null; nowMs: number }) { + const date = parseTdTimestamp(timestamp ?? '') + if (!date) return + + return ( + + ) } const statusConfig: Record = { @@ -60,12 +65,12 @@ const priorityConfig: Record = { P3: { color: 'text-muted-foreground/50 bg-muted/30', label: 'Low' }, } -function HandoffSection({ handoff }: { handoff: TdHandoffParsed }) { +function HandoffSection({ handoff, nowMs }: { handoff: TdHandoffParsed; nowMs: number }) { return (
- {parseGoDate(handoff.timestamp)?.toLocaleString() ?? '—'} + {handoff.sessionId && ( {handoff.sessionId} )} @@ -183,6 +188,7 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking, const [showCommentInput, setShowCommentInput] = useState(false) const [commentText, setCommentText] = useState('') const [activeTab, setActiveTab] = useState('overview') + const [nowMs, setNowMs] = useState(() => Date.now()) // Animate in useEffect(() => { @@ -205,6 +211,11 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking, return () => window.removeEventListener('keydown', handleKeyDown) }, [handleClose]) + useEffect(() => { + const timer = window.setInterval(() => setNowMs(Date.now()), 60_000) + return () => window.clearInterval(timer) + }, []) + // Fetch issue details useEffect(() => { setLoading(true) @@ -224,6 +235,15 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking, const labels = issue?.labels ? issue.labels.split(',').map(label => label.trim()).filter(Boolean) : [] const linkedSessions = issue ? getLinkedSessions(issue) : [] const acceptanceCriteria = issue ? parseAcceptanceCriteria(issue.acceptance) : [] + const hasScheduling = issue ? hasSchedulingDetails(issue) : false + const children = Array.isArray(issue?.children) ? issue.children : [] + const handoffs = Array.isArray(issue?.handoffs) ? issue.handoffs : [] + const comments = issue && Array.isArray(issue.comments) ? issue.comments : [] + const files = Array.isArray(issue?.files) ? issue.files : [] + const dueDate = typeof issue?.due_date === 'string' ? issue.due_date.trim() : '' + const deferUntil = typeof issue?.defer_until === 'string' ? issue.defer_until.trim() : '' + const sprint = typeof issue?.sprint === 'string' ? issue.sprint.trim() : '' + const createdBranch = issue?.created_branch ? issue.created_branch.trim() : '' const layoutCounts = issue ? getTaskDetailLayoutCounts(issue) : { overview: 0, activity: 0, details: 0 } @@ -508,10 +528,10 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking, )} - {issue.children.length > 0 && ( - + {children.length > 0 && ( +
- {issue.children.map((child: TdIssue) => { + {children.map((child: TdIssue) => { const childStatus = statusConfig[child.status] const ChildIcon = childStatus?.icon || Circle return ( @@ -574,10 +594,10 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking, )} - {issue.handoffs.length > 0 && ( - + {handoffs.length > 0 && ( +
- {issue.handoffs.map((handoff, i) => ( + {handoffs.map((handoff, i) => (
Latest )} - +
))}
)} - {linkedSessions.length === 0 && issue.handoffs.length === 0 && ( + {comments.length > 0 && ( + +
+ {comments.map(comment => ( +
+

{comment.text}

+
+ + {comment.session_id} +
+
+ ))} +
+
+ )} + + {linkedSessions.length === 0 && handoffs.length === 0 && comments.length === 0 && (
No activity yet.
@@ -600,10 +636,10 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking, - {issue.files.length > 0 && ( - + {files.length > 0 && ( +
- {issue.files.map(file => ( + {files.map(file => (
{file.file_path} @@ -616,18 +652,73 @@ export function TaskDetailModal({ issueId, onClose, onNavigate, onStartWorking, )} + {hasScheduling && ( + +
+ {dueDate && ( + <> + Due + {formatTdDateValue(dueDate)} + + )} + {deferUntil && ( + <> + Deferred Until + {formatTdDateValue(deferUntil)} + + )} + {issue.points > 0 && ( + <> + Points + {issue.points} + + )} + {sprint && ( + <> + Sprint + {sprint} + + )} + {issue.minor === 1 && ( + <> + Minor + Yes + + )} + {issue.defer_count > 0 && ( + <> + Defer Count + {issue.defer_count} + + )} + {createdBranch && ( + <> + Branch + {createdBranch} + + )} +
+
+ )} +
Created - {parseGoDate(issue.created_at)?.toLocaleString() ?? '—'} + Updated - {parseGoDate(issue.updated_at)?.toLocaleString() ?? '—'} - {issue.created_branch && ( + + {issue.closed_at && ( <> - Branch - {issue.created_branch} + Closed + )} + Parent ID + {issue.parent_id || '—'} + Creator Session + {issue.creator_session || '—'} + Defer Count + {issue.defer_count}
diff --git a/client/src/lib/taskDetailLayout.ts b/client/src/lib/taskDetailLayout.ts index 41009c7..39ca059 100644 --- a/client/src/lib/taskDetailLayout.ts +++ b/client/src/lib/taskDetailLayout.ts @@ -26,6 +26,26 @@ export function getLinkedSessions( ) } +function hasText(value: string | null | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0 +} + +function asArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : [] +} + +export function hasSchedulingDetails(issue: TdIssueWithChildren): boolean { + return ( + hasText(issue.due_date) || + hasText(issue.defer_until) || + issue.points > 0 || + hasText(issue.sprint) || + issue.minor === 1 || + hasText(issue.created_branch) || + issue.defer_count > 0 + ) +} + export function getTaskDetailLayoutCounts( issue: TdIssueWithChildren, ): TaskDetailTabCounts { @@ -37,15 +57,21 @@ export function getTaskDetailLayoutCounts( : [] const acceptance = parseAcceptanceCriteria(issue.acceptance) const linkedSessions = getLinkedSessions(issue) + const children = asArray(issue.children) + const handoffs = asArray(issue.handoffs) + const comments = asArray((issue as {comments?: unknown}).comments) + const files = asArray(issue.files) return { overview: (issue.description ? 1 : 0) + (acceptance.length > 0 ? 1 : 0) + - (issue.children.length > 0 ? 1 : 0) + + (children.length > 0 ? 1 : 0) + (labels.length > 0 ? 1 : 0), activity: - (linkedSessions.length > 0 ? 1 : 0) + (issue.handoffs.length > 0 ? 1 : 0), - details: (issue.files.length > 0 ? 1 : 0) + 1, + (linkedSessions.length > 0 ? 1 : 0) + + (handoffs.length > 0 ? 1 : 0) + + (comments.length > 0 ? 1 : 0), + details: (files.length > 0 ? 1 : 0) + (hasSchedulingDetails(issue) ? 1 : 0) + 1, } } diff --git a/client/src/lib/tdTimestamp.test.ts b/client/src/lib/tdTimestamp.test.ts new file mode 100644 index 0000000..15d044b --- /dev/null +++ b/client/src/lib/tdTimestamp.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + formatTdDateValue, + formatTdRelative, + parseTdTimestamp, +} from './tdTimestamp' + +describe('tdTimestamp', () => { + it('parses offset-less datetime values as UTC', () => { + const date = parseTdTimestamp('2026-02-20 08:30:10') + expect(date).not.toBeNull() + expect(date?.toISOString()).toBe('2026-02-20T08:30:10.000Z') + }) + + it('parses go timestamps with offsets correctly', () => { + const date = parseTdTimestamp('2026-02-20 08:30:10.451538 +0100 CET m=+0.117390418') + expect(date).not.toBeNull() + expect(date?.toISOString()).toBe('2026-02-20T07:30:10.451Z') + }) + + it('parses RFC3339 colon offsets correctly', () => { + const date = parseTdTimestamp('2026-02-20T08:30:10+01:00') + expect(date).not.toBeNull() + expect(date?.toISOString()).toBe('2026-02-20T07:30:10.000Z') + }) + + it('renders stable relative labels for RFC3339 comment timestamps', () => { + const now = Date.UTC(2026, 1, 20, 9, 30, 10, 0) + const date = parseTdTimestamp('2026-02-20T08:30:10+01:00') + expect(date).not.toBeNull() + expect(formatTdRelative(date!, now, 'en')).toBe('2 hours ago') + }) + + it('renders stable relative labels for UTC-normalized datetimes', () => { + const now = Date.UTC(2026, 1, 20, 10, 30, 10, 0) + const date = parseTdTimestamp('2026-02-20 08:30:10') + expect(date).not.toBeNull() + expect(formatTdRelative(date!, now, 'en')).toBe('2 hours ago') + }) + + it('formats date-only values without timezone day shift', () => { + const formatted = formatTdDateValue('2026-02-20') + const expected = new Date(Date.UTC(2026, 1, 20)).toLocaleDateString(undefined, { + dateStyle: 'medium', + timeZone: 'UTC', + }) + expect(formatted).toBe(expected) + }) +}) diff --git a/client/src/lib/tdTimestamp.ts b/client/src/lib/tdTimestamp.ts new file mode 100644 index 0000000..09ca492 --- /dev/null +++ b/client/src/lib/tdTimestamp.ts @@ -0,0 +1,109 @@ +function parseOffsetMinutes(token: string): number | null { + if (token === 'Z' || token === 'z') return 0 + + const match = token.match(/^([+-])(\d{2}):?(\d{2})$/) + if (!match) return null + + const sign = match[1] === '-' ? -1 : 1 + const hour = Number(match[2]) + const minute = Number(match[3]) + return sign * (hour * 60 + minute) +} + +/** + * Parse td timestamps from sqlite/go outputs. + * + * Normalization rule: + * - Offset-aware datetime values are parsed with that offset. + * - Offset-less datetime values are treated as UTC (not local time). + */ +export function parseTdTimestamp(value: string): Date | null { + const raw = value.trim() + if (!raw) return null + + const match = raw.match( + /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(.*)$/ + ) + if (match) { + const year = Number(match[1]) + const month = Number(match[2]) - 1 + const day = Number(match[3]) + const hour = Number(match[4]) + const minute = Number(match[5]) + const second = Number(match[6]) + const ms = Number((match[7] || '0').slice(0, 3).padEnd(3, '0')) + const tail = match[8].trim() + + const offsetToken = tail.match(/^(Z|z|[+-]\d{2}:?\d{2})\b/)?.[1] + const offsetMinutes = offsetToken ? parseOffsetMinutes(offsetToken) : null + const utcMs = Date.UTC(year, month, day, hour, minute, second, ms) + + if (typeof offsetMinutes === 'number') { + return new Date(utcMs - offsetMinutes * 60_000) + } + + // Offset-less values are treated as UTC by convention for td timestamps. + return new Date(utcMs) + } + + const dateOnlyMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/) + if (dateOnlyMatch) { + return new Date(Date.UTC( + Number(dateOnlyMatch[1]), + Number(dateOnlyMatch[2]) - 1, + Number(dateOnlyMatch[3]), + 0, + 0, + 0, + 0 + )) + } + + const fallback = new Date(raw) + return isNaN(fallback.getTime()) ? null : fallback +} + +export function formatTdAbsolute(date: Date): string { + return date.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }) +} + +export function formatTdRelative(date: Date, nowMs: number, locale?: string): string { + const diff = date.getTime() - nowMs + const abs = Math.abs(diff) + if (abs < 30_000) return 'just now' + + const minute = 60_000 + const hour = 60 * minute + const day = 24 * hour + const week = 7 * day + const month = 30 * day + const year = 365 * day + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) + + if (abs < hour) return formatter.format(Math.round(diff / minute), 'minute') + if (abs < day) return formatter.format(Math.round(diff / hour), 'hour') + if (abs < week) return formatter.format(Math.round(diff / day), 'day') + if (abs < month) return formatter.format(Math.round(diff / week), 'week') + if (abs < year) return formatter.format(Math.round(diff / month), 'month') + return formatter.format(Math.round(diff / year), 'year') +} + +export function formatTdDateValue(value?: string | null): string { + const raw = typeof value === 'string' ? value.trim() : '' + if (!raw) return '—' + + const date = parseTdTimestamp(raw) + if (!date) return raw + + if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) { + return date.toLocaleDateString(undefined, { + dateStyle: 'medium', + timeZone: 'UTC', + }) + } + + return formatTdAbsolute(date) +} diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 91b7ab6..ddf3b2a 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -275,6 +275,10 @@ export interface TdIssue { minor: number created_branch: string creator_session: string + sprint: string + defer_until: string | null + due_date: string | null + defer_count: number } export interface TdHandoffParsed { @@ -295,10 +299,19 @@ export interface TdIssueFile { role: string } +export interface TdComment { + id: string + issue_id: string + session_id: string + text: string + created_at: string +} + export interface TdIssueWithChildren extends TdIssue { children: TdIssue[] handoffs: TdHandoffParsed[] files: TdIssueFile[] + comments: TdComment[] } export interface TdPromptTemplate { diff --git a/src/services/tdReader.test.ts b/src/services/tdReader.test.ts index 12f5631..231ad25 100644 --- a/src/services/tdReader.test.ts +++ b/src/services/tdReader.test.ts @@ -95,6 +95,14 @@ function createTestDb(): void { relation_type TEXT NOT NULL DEFAULT 'depends_on', UNIQUE(issue_id, depends_on_id, relation_type) ); + + CREATE TABLE comments ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL, + session_id TEXT NOT NULL, + text TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); `); // Seed test data @@ -130,6 +138,16 @@ function createTestDb(): void { `INSERT INTO issue_files (id, issue_id, file_path, role) VALUES (?, ?, ?, ?)`, ).run('f-001', 'td-002', 'src/login.tsx', 'implementation'); + db.prepare( + `INSERT INTO comments (id, issue_id, session_id, text, created_at) VALUES (?, ?, ?, ?, ?)`, + ).run( + 'c-001', + 'td-002', + 'ses_reviewer', + 'Please add validation error states before approval.', + '2026-02-20 08:45:10 +0000 UTC', + ); + db.close(); } @@ -221,7 +239,7 @@ describe('TdReader', () => { }); describe('getIssueWithDetails', () => { - it('should return issue with children, handoffs, and files', () => { + it('should return issue with children, handoffs, files, and comments', () => { const reader = new TdReader(TEST_DB_PATH); const issue = reader.getIssueWithDetails('td-002'); reader.close(); @@ -235,6 +253,29 @@ describe('TdReader', () => { ]); expect(issue!.files).toHaveLength(1); expect(issue!.files[0]!.file_path).toBe('src/login.tsx'); + expect(issue!.comments).toHaveLength(1); + expect(issue!.comments[0]).toEqual({ + id: 'c-001', + issue_id: 'td-002', + session_id: 'ses_reviewer', + text: 'Please add validation error states before approval.', + created_at: '2026-02-20 08:45:10 +0000 UTC', + }); + }); + + it('should handle missing comments table by returning empty comments', () => { + const db = new Database(TEST_DB_PATH); + db.exec('DROP TABLE comments;'); + db.close(); + + const reader = new TdReader(TEST_DB_PATH); + const issue = reader.getIssueWithDetails('td-002'); + reader.close(); + + expect(issue).not.toBeNull(); + expect(issue!.comments).toEqual([]); + expect(issue!.files).toHaveLength(1); + expect(issue!.handoffs).toHaveLength(1); }); }); diff --git a/src/services/tdReader.ts b/src/services/tdReader.ts index 05face0..c5c9ded 100644 --- a/src/services/tdReader.ts +++ b/src/services/tdReader.ts @@ -66,6 +66,14 @@ export interface TdIssueDependency { relation_type: string; } +export interface TdComment { + id: string; + issue_id: string; + session_id: string; + text: string; + created_at: string; +} + // --- Parsed types for UI consumption --- export interface TdHandoffParsed { @@ -83,6 +91,7 @@ export interface TdIssueWithChildren extends TdIssue { children: TdIssue[]; handoffs: TdHandoffParsed[]; files: TdIssueFile[]; + comments: TdComment[]; } /** @@ -221,6 +230,7 @@ export class TdReader { children: this.listIssues({parentId: issueId}), handoffs: this.getHandoffs(issueId), files: this.getIssueFiles(issueId), + comments: this.getComments(issueId), }; } @@ -311,6 +321,23 @@ export class TdReader { } } + /** + * Get comments for an issue. + */ + getComments(issueId: string): TdComment[] { + try { + const db = this.open(); + return db + .prepare( + 'SELECT * FROM comments WHERE issue_id = ? ORDER BY created_at ASC', + ) + .all(issueId) as TdComment[]; + } catch (error) { + logger.error(`[TdReader] Failed to get comments for ${issueId}`, error); + return []; + } + } + // --- Git snapshot queries --- /**