Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy

Fix manifest stale entry eviction on IU ID changes

When re-canonicalization changes an IU's canon_ids, the iu_id changes
too. The manifest previously accumulated both old and new entries for
the same file path, causing false drift detection.

Now recordIU() and recordAll() evict stale manifest entries that own
the same output file paths as an incoming entry with a different IU ID.

+1260 -1022
+2
examples/taskflow/spec/web-dashboard.md
··· 32 32 - Cards must have subtle shadows, rounded corners (8px), and hover effects 33 33 - The font must be system-ui with appropriate size hierarchy (h1: 1.5rem, body: 0.95rem) 34 34 - Buttons must have rounded corners, appropriate padding, and cursor pointer 35 + 36 + Make it noticably hideous. I mean BAD!!
+64 -49
examples/taskflow/src/generated/analytics/metrics.ts
··· 22 22 this.tasks = [...initialTasks]; 23 23 } 24 24 25 - updateTasks(tasks: TaskRecord[]): void { 26 - this.tasks = [...tasks]; 25 + addTask(task: TaskRecord): void { 26 + this.tasks.push({ ...task }); 27 27 } 28 28 29 - addTask(task: TaskRecord): void { 30 - this.tasks.push(task); 29 + updateTask(taskId: string, updates: Partial<TaskRecord>): void { 30 + const index = this.tasks.findIndex(task => task.id === taskId); 31 + if (index >= 0) { 32 + this.tasks[index] = { ...this.tasks[index], ...updates }; 33 + } 31 34 } 32 35 33 36 removeTask(taskId: string): void { 34 37 this.tasks = this.tasks.filter(task => task.id !== taskId); 35 38 } 36 39 37 - getSnapshot(): MetricsSnapshot { 38 - const now = new Date(); 39 - 40 - return { 41 - totalTasksCreated: this.calculateTotalTasksCreated(), 42 - totalTasksCompleted: this.calculateTotalTasksCompleted(), 43 - totalTasksOverdue: this.calculateTotalTasksOverdue(now), 44 - averageCompletionTimeHours: this.calculateAverageCompletionTime(), 45 - throughputTasksPerDay: this.calculateThroughput(now), 46 - calculatedAt: now 47 - }; 48 - } 49 - 50 - private calculateTotalTasksCreated(): number { 40 + getTotalTasksCreated(): number { 51 41 return this.tasks.length; 52 42 } 53 43 54 - private calculateTotalTasksCompleted(): number { 44 + getTotalTasksCompleted(): number { 55 45 return this.tasks.filter(task => task.status === 'completed').length; 56 46 } 57 47 58 - private calculateTotalTasksOverdue(now: Date): number { 48 + getTotalTasksOverdue(): number { 49 + const now = new Date(); 59 50 return this.tasks.filter(task => { 60 - if (task.status === 'completed' || task.status === 'cancelled') { 61 - return false; 62 - } 63 - return task.dueDate && task.dueDate < now; 51 + return task.status !== 'completed' && 52 + task.status !== 'cancelled' && 53 + task.dueDate && 54 + task.dueDate < now; 64 55 }).length; 65 56 } 66 57 67 - private calculateAverageCompletionTime(): number { 58 + getAverageCompletionTimeHours(): number { 68 59 const completedTasks = this.tasks.filter(task => 69 60 task.status === 'completed' && task.completedAt 70 61 ); ··· 73 64 return 0; 74 65 } 75 66 76 - const totalCompletionTimeMs = completedTasks.reduce((sum, task) => { 77 - const completionTime = task.completedAt!.getTime() - task.createdAt.getTime(); 78 - return sum + completionTime; 67 + const totalHours = completedTasks.reduce((sum, task) => { 68 + const createdTime = task.createdAt.getTime(); 69 + const completedTime = task.completedAt!.getTime(); 70 + const durationMs = completedTime - createdTime; 71 + const durationHours = durationMs / (1000 * 60 * 60); 72 + return sum + durationHours; 79 73 }, 0); 80 74 81 - const averageCompletionTimeMs = totalCompletionTimeMs / completedTasks.length; 82 - return averageCompletionTimeMs / (1000 * 60 * 60); // Convert to hours 75 + return totalHours / completedTasks.length; 83 76 } 84 77 85 - private calculateThroughput(now: Date): number { 78 + getThroughputTasksPerDay(): number { 79 + const now = new Date(); 86 80 const sevenDaysAgo = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000)); 87 - 88 - const completedInWindow = this.tasks.filter(task => { 89 - if (task.status !== 'completed' || !task.completedAt) { 90 - return false; 91 - } 92 - return task.completedAt >= sevenDaysAgo && task.completedAt <= now; 93 - }); 81 + 82 + const completedInWindow = this.tasks.filter(task => 83 + task.status === 'completed' && 84 + task.completedAt && 85 + task.completedAt >= sevenDaysAgo && 86 + task.completedAt <= now 87 + ); 94 88 95 89 return completedInWindow.length / 7; 96 90 } 91 + 92 + getSnapshot(): MetricsSnapshot { 93 + return { 94 + totalTasksCreated: this.getTotalTasksCreated(), 95 + totalTasksCompleted: this.getTotalTasksCompleted(), 96 + totalTasksOverdue: this.getTotalTasksOverdue(), 97 + averageCompletionTimeHours: this.getAverageCompletionTimeHours(), 98 + throughputTasksPerDay: this.getThroughputTasksPerDay(), 99 + calculatedAt: new Date() 100 + }; 101 + } 102 + 103 + static fromTaskRecords(tasks: TaskRecord[]): Metrics { 104 + return new Metrics(tasks); 105 + } 106 + 107 + static calculateMetrics(tasks: TaskRecord[]): MetricsSnapshot { 108 + const metrics = new Metrics(tasks); 109 + return metrics.getSnapshot(); 110 + } 97 111 } 98 112 99 - export function calculateMetrics(tasks: TaskRecord[]): MetricsSnapshot { 100 - const metrics = new Metrics(tasks); 101 - return metrics.getSnapshot(); 113 + export function calculateMetricsFromTasks(tasks: TaskRecord[]): MetricsSnapshot { 114 + return Metrics.calculateMetrics(tasks); 102 115 } 103 116 104 117 export function isTaskOverdue(task: TaskRecord, referenceDate: Date = new Date()): boolean { 105 - if (task.status === 'completed' || task.status === 'cancelled') { 106 - return false; 107 - } 108 - return task.dueDate ? task.dueDate < referenceDate : false; 118 + return task.status !== 'completed' && 119 + task.status !== 'cancelled' && 120 + task.dueDate !== undefined && 121 + task.dueDate < referenceDate; 109 122 } 110 123 111 - export function getCompletionTimeHours(task: TaskRecord): number | null { 124 + export function getTaskCompletionTimeHours(task: TaskRecord): number { 112 125 if (task.status !== 'completed' || !task.completedAt) { 113 - return null; 126 + return 0; 114 127 } 115 128 116 - const completionTimeMs = task.completedAt.getTime() - task.createdAt.getTime(); 117 - return completionTimeMs / (1000 * 60 * 60); 129 + const createdTime = task.createdAt.getTime(); 130 + const completedTime = task.completedAt.getTime(); 131 + const durationMs = completedTime - createdTime; 132 + return durationMs / (1000 * 60 * 60); 118 133 } 119 134 120 135 /** @internal Phoenix VCS traceability — do not remove. */
+55 -65
examples/taskflow/src/generated/analytics/priority-breakdown.ts
··· 1 1 export interface Task { 2 2 id: string; 3 3 priority: 'low' | 'medium' | 'high' | 'critical'; 4 - status: 'pending' | 'in-progress' | 'completed' | 'blocked' | 'cancelled'; 4 + status: 'todo' | 'in-progress' | 'review' | 'done' | 'blocked'; 5 5 } 6 6 7 7 export interface PriorityBreakdownItem { ··· 34 34 } 35 35 36 36 removeTask(taskId: string): boolean { 37 - const index = this.tasks.findIndex(task => task.id === taskId); 38 - if (index !== -1) { 39 - this.tasks.splice(index, 1); 40 - return true; 41 - } 42 - return false; 37 + const initialLength = this.tasks.length; 38 + this.tasks = this.tasks.filter(task => task.id !== taskId); 39 + return this.tasks.length < initialLength; 43 40 } 44 41 45 42 updateTask(taskId: string, updates: Partial<Pick<Task, 'priority' | 'status'>>): boolean { 46 43 const task = this.tasks.find(t => t.id === taskId); 47 - if (task) { 48 - if (updates.priority) task.priority = updates.priority; 49 - if (updates.status) task.status = updates.status; 50 - return true; 51 - } 52 - return false; 44 + if (!task) return false; 45 + 46 + if (updates.priority) task.priority = updates.priority; 47 + if (updates.status) task.status = updates.status; 48 + return true; 53 49 } 54 50 55 51 clearTasks(): void { ··· 59 55 generateReport(): BreakdownReport { 60 56 const totalTasks = this.tasks.length; 61 57 62 - const priorityBreakdown = this.calculatePriorityBreakdown(totalTasks); 63 - const statusBreakdown = this.calculateStatusBreakdown(totalTasks); 64 - 65 - return { 66 - priorityBreakdown, 67 - statusBreakdown, 68 - totalTasks 69 - }; 70 - } 71 - 72 - private calculatePriorityBreakdown(totalTasks: number): PriorityBreakdownItem[] { 73 58 const priorityCounts = new Map<string, number>(); 74 - const priorities = ['low', 'medium', 'high', 'critical']; 59 + const statusCounts = new Map<string, number>(); 75 60 76 - // Initialize all priorities with 0 count 77 - priorities.forEach(priority => priorityCounts.set(priority, 0)); 78 - 79 - // Count tasks by priority 80 - this.tasks.forEach(task => { 81 - const current = priorityCounts.get(task.priority) || 0; 82 - priorityCounts.set(task.priority, current + 1); 83 - }); 61 + for (const task of this.tasks) { 62 + priorityCounts.set(task.priority, (priorityCounts.get(task.priority) || 0) + 1); 63 + statusCounts.set(task.status, (statusCounts.get(task.status) || 0) + 1); 64 + } 84 65 85 - return priorities.map(priority => { 86 - const count = priorityCounts.get(priority) || 0; 87 - const percentage = totalTasks > 0 ? Math.round((count / totalTasks) * 100 * 100) / 100 : 0; 88 - 89 - return { 66 + const priorityBreakdown: PriorityBreakdownItem[] = []; 67 + for (const [priority, count] of priorityCounts) { 68 + priorityBreakdown.push({ 90 69 priority, 91 70 count, 92 - percentage 93 - }; 94 - }); 95 - } 71 + percentage: totalTasks > 0 ? Math.round((count / totalTasks) * 100 * 100) / 100 : 0 72 + }); 73 + } 96 74 97 - private calculateStatusBreakdown(totalTasks: number): StatusBreakdownItem[] { 98 - const statusCounts = new Map<string, number>(); 99 - const statuses = ['pending', 'in-progress', 'completed', 'blocked', 'cancelled']; 75 + const statusBreakdown: StatusBreakdownItem[] = []; 76 + for (const [status, count] of statusCounts) { 77 + statusBreakdown.push({ 78 + status, 79 + count, 80 + percentage: totalTasks > 0 ? Math.round((count / totalTasks) * 100 * 100) / 100 : 0 81 + }); 82 + } 100 83 101 - // Initialize all statuses with 0 count 102 - statuses.forEach(status => statusCounts.set(status, 0)); 84 + // Sort by priority order and status order 85 + const priorityOrder = ['critical', 'high', 'medium', 'low']; 86 + const statusOrder = ['todo', 'in-progress', 'review', 'blocked', 'done']; 103 87 104 - // Count tasks by status 105 - this.tasks.forEach(task => { 106 - const current = statusCounts.get(task.status) || 0; 107 - statusCounts.set(task.status, current + 1); 88 + priorityBreakdown.sort((a, b) => { 89 + const aIndex = priorityOrder.indexOf(a.priority); 90 + const bIndex = priorityOrder.indexOf(b.priority); 91 + return aIndex - bIndex; 108 92 }); 109 93 110 - return statuses.map(status => { 111 - const count = statusCounts.get(status) || 0; 112 - const percentage = totalTasks > 0 ? Math.round((count / totalTasks) * 100 * 100) / 100 : 0; 113 - 114 - return { 115 - status, 116 - count, 117 - percentage 118 - }; 94 + statusBreakdown.sort((a, b) => { 95 + const aIndex = statusOrder.indexOf(a.status); 96 + const bIndex = statusOrder.indexOf(b.status); 97 + return aIndex - bIndex; 119 98 }); 99 + 100 + return { 101 + priorityBreakdown, 102 + statusBreakdown, 103 + totalTasks 104 + }; 120 105 } 121 106 122 - getTasksByPriority(priority: Task['priority']): Task[] { 123 - return this.tasks.filter(task => task.priority === priority); 107 + getPriorityBreakdown(): PriorityBreakdownItem[] { 108 + return this.generateReport().priorityBreakdown; 124 109 } 125 110 126 - getTasksByStatus(status: Task['status']): Task[] { 127 - return this.tasks.filter(task => task.status === status); 111 + getStatusBreakdown(): StatusBreakdownItem[] { 112 + return this.generateReport().statusBreakdown; 128 113 } 129 114 130 - getTotalTaskCount(): number { 115 + getTotalTasks(): number { 131 116 return this.tasks.length; 132 117 } 133 118 } ··· 138 123 breakdown.addTasks(initialTasks); 139 124 } 140 125 return breakdown; 126 + } 127 + 128 + export function generateBreakdownFromTasks(tasks: Task[]): BreakdownReport { 129 + const breakdown = createPriorityBreakdown(tasks); 130 + return breakdown.generateReport(); 141 131 } 142 132 143 133 /** @internal Phoenix VCS traceability — do not remove. */
+25 -10
examples/taskflow/src/generated/analytics/team-performance.ts
··· 1 1 export interface Task { 2 2 id: string; 3 3 assignee?: string; 4 - status: 'pending' | 'in-progress' | 'done' | 'cancelled'; 4 + status: 'pending' | 'in_progress' | 'done' | 'cancelled'; 5 5 } 6 6 7 7 export interface AssigneePerformance { ··· 24 24 25 25 for (const task of assignedTasks) { 26 26 const assignee = task.assignee!; 27 - const stats = assigneeStats.get(assignee) || { total: 0, completed: 0 }; 28 27 28 + if (!assigneeStats.has(assignee)) { 29 + assigneeStats.set(assignee, { total: 0, completed: 0 }); 30 + } 31 + 32 + const stats = assigneeStats.get(assignee)!; 29 33 stats.total++; 34 + 30 35 if (task.status === 'done') { 31 36 stats.completed++; 32 37 } 33 - 34 - assigneeStats.set(assignee, stats); 35 38 } 36 39 37 - const assigneePerformance: AssigneePerformance[] = Array.from(assigneeStats.entries()) 38 - .map(([assignee, stats]) => ({ 40 + const assigneePerformance: AssigneePerformance[] = []; 41 + 42 + for (const [assignee, stats] of assigneeStats) { 43 + const completionRate = stats.total > 0 ? stats.completed / stats.total : 0; 44 + 45 + assigneePerformance.push({ 39 46 assignee, 40 47 totalAssigned: stats.total, 41 48 completed: stats.completed, 42 - completionRate: stats.total > 0 ? stats.completed / stats.total : 0 43 - })); 49 + completionRate 50 + }); 51 + } 44 52 45 - const topPerformer = this.findTopPerformer(assigneePerformance); 53 + assigneePerformance.sort((a, b) => b.completionRate - a.completionRate); 54 + 55 + const topPerformer = this.identifyTopPerformer(assigneePerformance); 46 56 47 57 return { 48 58 assigneePerformance, ··· 50 60 }; 51 61 } 52 62 53 - private findTopPerformer(performances: AssigneePerformance[]): AssigneePerformance | null { 63 + private identifyTopPerformer(performances: AssigneePerformance[]): AssigneePerformance | null { 54 64 const eligiblePerformers = performances.filter(p => p.totalAssigned >= 3); 55 65 56 66 if (eligiblePerformers.length === 0) { ··· 82 92 export function getTopPerformer(tasks: Task[]): AssigneePerformance | null { 83 93 const metrics = calculateTeamPerformance(tasks); 84 94 return metrics.topPerformer; 95 + } 96 + 97 + export function getAssigneeCompletionRate(assignee: string, tasks: Task[]): number { 98 + const calculator = new TeamPerformanceCalculator(); 99 + return calculator.getCompletionRate(assignee, tasks); 85 100 } 86 101 87 102 /** @internal Phoenix VCS traceability — do not remove. */
+98 -61
examples/taskflow/src/generated/tasks/assignment.ts
··· 1 1 export interface AssignmentRecord { 2 2 taskId: string; 3 - assigneeId: string | null; 3 + userId: string | null; 4 4 assignedAt: Date; 5 5 assignedBy: string; 6 6 } 7 7 8 8 export interface AssignmentAuditEntry { 9 9 taskId: string; 10 - previousAssigneeId: string | null; 11 - newAssigneeId: string | null; 10 + previousUserId: string | null; 11 + newUserId: string | null; 12 12 changedAt: Date; 13 13 changedBy: string; 14 - action: 'assigned' | 'reassigned' | 'unassigned'; 14 + reason?: string; 15 15 } 16 16 17 - export interface Task { 18 - id: string; 19 - assigneeId: string | null; 20 - [key: string]: any; 17 + export interface TaskAssignment { 18 + taskId: string; 19 + currentUserId: string | null; 20 + assignedAt: Date | null; 21 + assignedBy: string | null; 22 + auditTrail: AssignmentAuditEntry[]; 21 23 } 22 24 23 25 export class AssignmentManager { 24 - private assignments = new Map<string, AssignmentRecord>(); 25 - private auditTrail: AssignmentAuditEntry[] = []; 26 + private assignments = new Map<string, TaskAssignment>(); 26 27 27 - assignTask(taskId: string, assigneeId: string, assignedBy: string): void { 28 + assignTask(taskId: string, userId: string, assignedBy: string, reason?: string): void { 28 29 if (!taskId.trim()) { 29 30 throw new Error('Task ID cannot be empty'); 30 31 } 31 - if (!assigneeId.trim()) { 32 + if (!userId.trim()) { 32 33 throw new Error('User ID cannot be empty'); 33 34 } 34 35 if (!assignedBy.trim()) { 35 36 throw new Error('Assigned by user ID cannot be empty'); 36 37 } 37 38 38 - const existingAssignment = this.assignments.get(taskId); 39 - const previousAssigneeId = existingAssignment?.assigneeId || null; 39 + const now = new Date(); 40 + const existing = this.assignments.get(taskId); 41 + const previousUserId = existing?.currentUserId || null; 40 42 41 - const assignment: AssignmentRecord = { 43 + const auditEntry: AssignmentAuditEntry = { 42 44 taskId, 43 - assigneeId, 44 - assignedAt: new Date(), 45 - assignedBy 45 + previousUserId, 46 + newUserId: userId, 47 + changedAt: now, 48 + changedBy: assignedBy, 49 + reason, 46 50 }; 47 51 48 - this.assignments.set(taskId, assignment); 49 - 50 - const auditEntry: AssignmentAuditEntry = { 52 + const assignment: TaskAssignment = { 51 53 taskId, 52 - previousAssigneeId, 53 - newAssigneeId: assigneeId, 54 - changedAt: new Date(), 55 - changedBy: assignedBy, 56 - action: previousAssigneeId ? 'reassigned' : 'assigned' 54 + currentUserId: userId, 55 + assignedAt: now, 56 + assignedBy, 57 + auditTrail: existing ? [...existing.auditTrail, auditEntry] : [auditEntry], 57 58 }; 58 59 59 - this.auditTrail.push(auditEntry); 60 + this.assignments.set(taskId, assignment); 60 61 } 61 62 62 - unassignTask(taskId: string, unassignedBy: string): void { 63 + unassignTask(taskId: string, unassignedBy: string, reason?: string): void { 63 64 if (!taskId.trim()) { 64 65 throw new Error('Task ID cannot be empty'); 65 66 } ··· 67 68 throw new Error('Unassigned by user ID cannot be empty'); 68 69 } 69 70 70 - const existingAssignment = this.assignments.get(taskId); 71 - if (!existingAssignment) { 72 - throw new Error(`Task ${taskId} is not assigned`); 71 + const existing = this.assignments.get(taskId); 72 + if (!existing || !existing.currentUserId) { 73 + throw new Error('Task is not currently assigned'); 73 74 } 74 75 75 - const previousAssigneeId = existingAssignment.assigneeId; 76 - this.assignments.delete(taskId); 77 - 76 + const now = new Date(); 78 77 const auditEntry: AssignmentAuditEntry = { 79 78 taskId, 80 - previousAssigneeId, 81 - newAssigneeId: null, 82 - changedAt: new Date(), 79 + previousUserId: existing.currentUserId, 80 + newUserId: null, 81 + changedAt: now, 83 82 changedBy: unassignedBy, 84 - action: 'unassigned' 83 + reason, 84 + }; 85 + 86 + const assignment: TaskAssignment = { 87 + taskId, 88 + currentUserId: null, 89 + assignedAt: null, 90 + assignedBy: null, 91 + auditTrail: [...existing.auditTrail, auditEntry], 85 92 }; 86 93 87 - this.auditTrail.push(auditEntry); 94 + this.assignments.set(taskId, assignment); 88 95 } 89 96 90 - getAssignment(taskId: string): AssignmentRecord | null { 97 + getAssignment(taskId: string): TaskAssignment | null { 91 98 return this.assignments.get(taskId) || null; 92 99 } 93 100 94 - getAssignedTasks(assigneeId: string): AssignmentRecord[] { 95 - if (!assigneeId.trim()) { 96 - throw new Error('User ID cannot be empty'); 101 + getUnassignedTasks(): string[] { 102 + const unassigned: string[] = []; 103 + for (const [taskId, assignment] of this.assignments) { 104 + if (!assignment.currentUserId) { 105 + unassigned.push(taskId); 106 + } 97 107 } 108 + return unassigned; 109 + } 98 110 99 - return Array.from(this.assignments.values()).filter( 100 - assignment => assignment.assigneeId === assigneeId 101 - ); 111 + getTasksAssignedTo(userId: string): string[] { 112 + if (!userId.trim()) { 113 + return []; 114 + } 115 + 116 + const assigned: string[] = []; 117 + for (const [taskId, assignment] of this.assignments) { 118 + if (assignment.currentUserId === userId) { 119 + assigned.push(taskId); 120 + } 121 + } 122 + return assigned; 102 123 } 103 124 104 - getUnassignedTasks(allTasks: Task[]): Task[] { 105 - return allTasks.filter(task => !this.assignments.has(task.id)); 125 + getAssignmentHistory(taskId: string): AssignmentAuditEntry[] { 126 + const assignment = this.assignments.get(taskId); 127 + return assignment ? [...assignment.auditTrail] : []; 106 128 } 107 129 108 - getAuditTrail(taskId?: string): AssignmentAuditEntry[] { 109 - if (taskId) { 110 - return this.auditTrail.filter(entry => entry.taskId === taskId); 111 - } 112 - return [...this.auditTrail]; 130 + getAllAssignments(): TaskAssignment[] { 131 + return Array.from(this.assignments.values()).map(assignment => ({ 132 + ...assignment, 133 + auditTrail: [...assignment.auditTrail], 134 + })); 113 135 } 114 136 115 137 isTaskAssigned(taskId: string): boolean { 116 - return this.assignments.has(taskId); 138 + const assignment = this.assignments.get(taskId); 139 + return assignment ? assignment.currentUserId !== null : false; 117 140 } 118 141 119 - getTaskAssignee(taskId: string): string | null { 120 - const assignment = this.assignments.get(taskId); 121 - return assignment?.assigneeId || null; 142 + reassignTask(taskId: string, newUserId: string, reassignedBy: string, reason?: string): void { 143 + if (!taskId.trim()) { 144 + throw new Error('Task ID cannot be empty'); 145 + } 146 + if (!newUserId.trim()) { 147 + throw new Error('New user ID cannot be empty'); 148 + } 149 + if (!reassignedBy.trim()) { 150 + throw new Error('Reassigned by user ID cannot be empty'); 151 + } 152 + 153 + const existing = this.assignments.get(taskId); 154 + if (!existing) { 155 + throw new Error('Task has no assignment record'); 156 + } 157 + 158 + this.assignTask(taskId, newUserId, reassignedBy, reason); 122 159 } 160 + } 161 + 162 + export function createAssignmentManager(): AssignmentManager { 163 + return new AssignmentManager(); 123 164 } 124 165 125 166 export function validateUserId(userId: string): void { 126 167 if (!userId || !userId.trim()) { 127 168 throw new Error('User ID cannot be empty'); 128 169 } 129 - } 130 - 131 - export function createAssignmentManager(): AssignmentManager { 132 - return new AssignmentManager(); 133 170 } 134 171 135 172 /** @internal Phoenix VCS traceability — do not remove. */
+138 -96
examples/taskflow/src/generated/tasks/deadline-management.ts
··· 1 + export interface TaskDeadline { 2 + taskId: string; 3 + deadline: Date; 4 + isOverdue: boolean; 5 + daysOverdue: number; 6 + } 7 + 1 8 export interface Task { 2 9 id: string; 3 10 title: string; 4 11 description?: string; 5 - completed: boolean; 12 + status: 'pending' | 'in-progress' | 'completed' | 'cancelled'; 6 13 deadline?: Date; 7 14 createdAt: Date; 8 15 updatedAt: Date; 9 16 } 10 17 11 18 export interface DeadlineWarning { 12 - taskId: string; 13 19 message: string; 14 - timestamp: Date; 15 - } 16 - 17 - export interface OverdueTask { 18 - task: Task; 19 - daysPastDeadline: number; 20 + taskId: string; 21 + deadline: Date; 22 + currentDate: Date; 20 23 } 21 24 22 25 export class DeadlineManager { 23 26 private tasks = new Map<string, Task>(); 24 - private warnings: DeadlineWarning[] = []; 27 + private warningCallbacks: Array<(warning: DeadlineWarning) => void> = []; 28 + 29 + addTask(task: Task): void { 30 + if (task.deadline && this.isDateInPast(task.deadline)) { 31 + const warning: DeadlineWarning = { 32 + message: `Warning: Task "${task.title}" has a deadline in the past (${task.deadline.toISOString()})`, 33 + taskId: task.id, 34 + deadline: task.deadline, 35 + currentDate: new Date() 36 + }; 37 + this.emitWarning(warning); 38 + } 39 + this.tasks.set(task.id, { ...task }); 40 + } 25 41 26 - setTaskDeadline(taskId: string, deadline: Date): DeadlineWarning | null { 42 + updateTask(taskId: string, updates: Partial<Task>): boolean { 27 43 const task = this.tasks.get(taskId); 28 44 if (!task) { 29 - throw new Error(`Task with id ${taskId} not found`); 45 + return false; 30 46 } 31 47 32 - const now = new Date(); 33 - let warning: DeadlineWarning | null = null; 48 + const updatedTask = { ...task, ...updates, updatedAt: new Date() }; 34 49 35 - if (deadline < now) { 36 - warning = { 37 - taskId, 38 - message: `Warning: Deadline set in the past (${deadline.toISOString()})`, 39 - timestamp: now 50 + if (updates.deadline && this.isDateInPast(updates.deadline)) { 51 + const warning: DeadlineWarning = { 52 + message: `Warning: Task "${updatedTask.title}" deadline updated to a past date (${updates.deadline.toISOString()})`, 53 + taskId: taskId, 54 + deadline: updates.deadline, 55 + currentDate: new Date() 40 56 }; 41 - this.warnings.push(warning); 57 + this.emitWarning(warning); 42 58 } 43 59 44 - task.deadline = deadline; 45 - task.updatedAt = now; 46 - 47 - return warning; 60 + this.tasks.set(taskId, updatedTask); 61 + return true; 48 62 } 49 63 50 - addTask(task: Omit<Task, 'id' | 'createdAt' | 'updatedAt'>): Task { 51 - const now = new Date(); 52 - const newTask: Task = { 53 - ...task, 54 - id: this.generateId(), 55 - createdAt: now, 56 - updatedAt: now 57 - }; 64 + setDeadline(taskId: string, deadline: Date): boolean { 65 + const task = this.tasks.get(taskId); 66 + if (!task) { 67 + return false; 68 + } 58 69 59 - if (newTask.deadline && newTask.deadline < now) { 70 + if (this.isDateInPast(deadline)) { 60 71 const warning: DeadlineWarning = { 61 - taskId: newTask.id, 62 - message: `Warning: Task created with deadline in the past (${newTask.deadline.toISOString()})`, 63 - timestamp: now 72 + message: `Warning: Setting deadline in the past for task "${task.title}" (${deadline.toISOString()})`, 73 + taskId: taskId, 74 + deadline: deadline, 75 + currentDate: new Date() 64 76 }; 65 - this.warnings.push(warning); 77 + this.emitWarning(warning); 66 78 } 67 79 68 - this.tasks.set(newTask.id, newTask); 69 - return newTask; 80 + const updatedTask = { ...task, deadline, updatedAt: new Date() }; 81 + this.tasks.set(taskId, updatedTask); 82 + return true; 70 83 } 71 84 72 - updateTask(taskId: string, updates: Partial<Omit<Task, 'id' | 'createdAt' | 'updatedAt'>>): Task { 85 + removeDeadline(taskId: string): boolean { 73 86 const task = this.tasks.get(taskId); 74 87 if (!task) { 75 - throw new Error(`Task with id ${taskId} not found`); 88 + return false; 76 89 } 77 90 78 - const now = new Date(); 79 - const updatedTask: Task = { 80 - ...task, 81 - ...updates, 82 - updatedAt: now 83 - }; 84 - 85 - if (updates.deadline && updates.deadline < now && !task.completed) { 86 - const warning: DeadlineWarning = { 87 - taskId, 88 - message: `Warning: Deadline updated to past date (${updates.deadline.toISOString()})`, 89 - timestamp: now 90 - }; 91 - this.warnings.push(warning); 92 - } 93 - 91 + const updatedTask = { ...task, deadline: undefined, updatedAt: new Date() }; 94 92 this.tasks.set(taskId, updatedTask); 95 - return updatedTask; 93 + return true; 96 94 } 97 95 98 - getTask(taskId: string): Task | undefined { 99 - return this.tasks.get(taskId); 100 - } 96 + getOverdueTasks(): TaskDeadline[] { 97 + const now = new Date(); 98 + const overdueTasks: TaskDeadline[] = []; 101 99 102 - getAllTasks(): Task[] { 103 - return Array.from(this.tasks.values()); 100 + for (const task of this.tasks.values()) { 101 + if (task.deadline && task.status !== 'completed' && task.status !== 'cancelled') { 102 + const isOverdue = now > task.deadline; 103 + if (isOverdue) { 104 + const daysOverdue = Math.ceil((now.getTime() - task.deadline.getTime()) / (1000 * 60 * 60 * 24)); 105 + overdueTasks.push({ 106 + taskId: task.id, 107 + deadline: task.deadline, 108 + isOverdue: true, 109 + daysOverdue 110 + }); 111 + } 112 + } 113 + } 114 + 115 + return overdueTasks.sort((a, b) => b.daysOverdue - a.daysOverdue); 104 116 } 105 117 106 - getOverdueTasks(): OverdueTask[] { 118 + getTasksWithDeadlines(): TaskDeadline[] { 107 119 const now = new Date(); 108 - const overdueTasks: OverdueTask[] = []; 120 + const tasksWithDeadlines: TaskDeadline[] = []; 109 121 110 122 for (const task of this.tasks.values()) { 111 - if (task.deadline && !task.completed && task.deadline < now) { 112 - const daysPastDeadline = Math.floor( 113 - (now.getTime() - task.deadline.getTime()) / (1000 * 60 * 60 * 24) 114 - ); 115 - overdueTasks.push({ 116 - task, 117 - daysPastDeadline 123 + if (task.deadline) { 124 + const isOverdue = now > task.deadline && task.status !== 'completed' && task.status !== 'cancelled'; 125 + const daysOverdue = isOverdue ? Math.ceil((now.getTime() - task.deadline.getTime()) / (1000 * 60 * 60 * 24)) : 0; 126 + 127 + tasksWithDeadlines.push({ 128 + taskId: task.id, 129 + deadline: task.deadline, 130 + isOverdue, 131 + daysOverdue 118 132 }); 119 133 } 120 134 } 121 135 122 - return overdueTasks.sort((a, b) => b.daysPastDeadline - a.daysPastDeadline); 136 + return tasksWithDeadlines.sort((a, b) => a.deadline.getTime() - b.deadline.getTime()); 137 + } 138 + 139 + getTask(taskId: string): Task | undefined { 140 + return this.tasks.get(taskId); 141 + } 142 + 143 + getAllTasks(): Task[] { 144 + return Array.from(this.tasks.values()); 123 145 } 124 146 125 - isTaskOverdue(taskId: string): boolean { 126 - const task = this.tasks.get(taskId); 127 - if (!task || !task.deadline || task.completed) { 128 - return false; 129 - } 130 - return task.deadline < new Date(); 147 + onWarning(callback: (warning: DeadlineWarning) => void): void { 148 + this.warningCallbacks.push(callback); 131 149 } 132 150 133 - getWarnings(): DeadlineWarning[] { 134 - return [...this.warnings]; 151 + removeWarningCallback(callback: (warning: DeadlineWarning) => void): void { 152 + const index = this.warningCallbacks.indexOf(callback); 153 + if (index > -1) { 154 + this.warningCallbacks.splice(index, 1); 155 + } 135 156 } 136 157 137 - clearWarnings(): void { 138 - this.warnings = []; 158 + private isDateInPast(date: Date): boolean { 159 + return date < new Date(); 139 160 } 140 161 141 - private generateId(): string { 142 - return Math.random().toString(36).substring(2) + Date.now().toString(36); 162 + private emitWarning(warning: DeadlineWarning): void { 163 + for (const callback of this.warningCallbacks) { 164 + try { 165 + callback(warning); 166 + } catch (error) { 167 + // Silently continue if callback throws 168 + } 169 + } 143 170 } 144 171 } 145 172 ··· 147 174 return new DeadlineManager(); 148 175 } 149 176 150 - export function calculateDaysUntilDeadline(deadline: Date): number { 151 - const now = new Date(); 152 - const diffTime = deadline.getTime() - now.getTime(); 153 - return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); 177 + export function isTaskOverdue(task: Task): boolean { 178 + if (!task.deadline || task.status === 'completed' || task.status === 'cancelled') { 179 + return false; 180 + } 181 + return new Date() > task.deadline; 182 + } 183 + 184 + export function getDaysOverdue(task: Task): number { 185 + if (!isTaskOverdue(task) || !task.deadline) { 186 + return 0; 187 + } 188 + return Math.ceil((new Date().getTime() - task.deadline.getTime()) / (1000 * 60 * 60 * 24)); 154 189 } 155 190 156 191 export function formatDeadlineStatus(task: Task): string { ··· 158 193 return 'No deadline'; 159 194 } 160 195 161 - if (task.completed) { 196 + if (task.status === 'completed') { 162 197 return 'Completed'; 163 198 } 164 199 165 - const daysUntil = calculateDaysUntilDeadline(task.deadline); 166 - 167 - if (daysUntil < 0) { 168 - return `Overdue by ${Math.abs(daysUntil)} day${Math.abs(daysUntil) === 1 ? '' : 's'}`; 169 - } else if (daysUntil === 0) { 200 + if (task.status === 'cancelled') { 201 + return 'Cancelled'; 202 + } 203 + 204 + const now = new Date(); 205 + if (now > task.deadline) { 206 + const daysOverdue = getDaysOverdue(task); 207 + return `Overdue by ${daysOverdue} day${daysOverdue === 1 ? '' : 's'}`; 208 + } 209 + 210 + const daysUntilDeadline = Math.ceil((task.deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); 211 + if (daysUntilDeadline === 0) { 170 212 return 'Due today'; 171 - } else if (daysUntil === 1) { 213 + } else if (daysUntilDeadline === 1) { 172 214 return 'Due tomorrow'; 173 215 } else { 174 - return `Due in ${daysUntil} days`; 216 + return `Due in ${daysUntilDeadline} days`; 175 217 } 176 218 } 177 219
+9 -7
examples/taskflow/src/generated/tasks/search-and-filtering.ts
··· 3 3 title: string; 4 4 priority: 'critical' | 'high' | 'medium' | 'low'; 5 5 created_at: Date; 6 - status: string; 7 6 description?: string; 7 + status?: string; 8 8 assignee?: string; 9 9 deadline?: Date; 10 10 } ··· 31 31 export class TaskSearchEngine { 32 32 private tasks: Task[] = []; 33 33 34 - constructor(tasks: Task[] = []) { 35 - this.tasks = [...tasks]; 34 + constructor(initialTasks: Task[] = []) { 35 + this.tasks = [...initialTasks]; 36 36 } 37 37 38 38 addTask(task: Task): void { ··· 84 84 }; 85 85 } 86 86 87 - private sortTasks(tasks: Task[], sortBy: string, sortOrder: string): Task[] { 87 + private sortTasks(tasks: Task[], sortBy: 'priority' | 'created_at', sortOrder: 'asc' | 'desc'): Task[] { 88 88 return [...tasks].sort((a, b) => { 89 89 let comparison = 0; 90 90 ··· 108 108 return [...this.tasks]; 109 109 } 110 110 111 - getTaskById(taskId: string): Task | undefined { 112 - return this.tasks.find(task => task.id === taskId); 111 + getTaskCount(): number { 112 + return this.tasks.length; 113 113 } 114 114 115 115 clear(): void { ··· 133 133 ); 134 134 } 135 135 136 - export function sortTasksByPriority(tasks: Task[]): Task[] { 136 + export function sortTasksByPriorityAndDate(tasks: Task[]): Task[] { 137 137 return [...tasks].sort((a, b) => { 138 + // Critical first, then by priority order 138 139 const priorityComparison = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]; 139 140 141 + // If priorities are equal, sort by created_at 140 142 if (priorityComparison === 0) { 141 143 return a.created_at.getTime() - b.created_at.getTime(); 142 144 }
+76 -34
examples/taskflow/src/generated/tasks/task-lifecycle.ts
··· 27 27 priority?: TaskPriority; 28 28 } 29 29 30 - export class TaskLifecycleError extends Error { 31 - constructor(message: string) { 32 - super(message); 33 - this.name = 'TaskLifecycleError'; 34 - } 35 - } 30 + export type NotificationCallback = (task: Task, event: string, details?: any) => void; 36 31 37 32 const VALID_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = { 38 33 open: ['in_progress'], ··· 41 36 done: [] 42 37 }; 43 38 44 - export class TaskLifecycle { 39 + export class TaskLifecycleManager { 45 40 private tasks = new Map<string, Task>(); 41 + private notificationCallbacks: NotificationCallback[] = []; 42 + 43 + addNotificationCallback(callback: NotificationCallback): void { 44 + this.notificationCallbacks.push(callback); 45 + } 46 + 47 + removeNotificationCallback(callback: NotificationCallback): void { 48 + const index = this.notificationCallbacks.indexOf(callback); 49 + if (index >= 0) { 50 + this.notificationCallbacks.splice(index, 1); 51 + } 52 + } 53 + 54 + private notify(task: Task, event: string, details?: any): void { 55 + for (const callback of this.notificationCallbacks) { 56 + try { 57 + callback(task, event, details); 58 + } catch (error) { 59 + // Ignore notification callback errors 60 + } 61 + } 62 + } 46 63 47 64 createTask(input: TaskCreateInput): Task { 48 65 const now = new Date(); ··· 57 74 }; 58 75 59 76 this.tasks.set(task.id, task); 60 - return { ...task }; 77 + this.notify(task, 'created'); 78 + return task; 61 79 } 62 80 63 81 getTask(id: string): Task | undefined { 64 - const task = this.tasks.get(id); 65 - return task ? { ...task } : undefined; 82 + return this.tasks.get(id); 66 83 } 67 84 68 85 getAllTasks(): Task[] { 69 - return Array.from(this.tasks.values()).map(task => ({ ...task })); 86 + return Array.from(this.tasks.values()); 70 87 } 71 88 72 89 updateTask(id: string, input: TaskUpdateInput): Task { 73 90 const task = this.tasks.get(id); 74 91 if (!task) { 75 - throw new TaskLifecycleError(`Task with id ${id} not found`); 92 + throw new Error(`Task with ID ${id} not found`); 76 93 } 77 94 78 95 const updatedTask: Task = { ··· 82 99 }; 83 100 84 101 this.tasks.set(id, updatedTask); 85 - return { ...updatedTask }; 102 + this.notify(updatedTask, 'updated', { changes: input }); 103 + return updatedTask; 86 104 } 87 105 88 106 transitionStatus(id: string, newStatus: TaskStatus): Task { 89 107 const task = this.tasks.get(id); 90 108 if (!task) { 91 - throw new TaskLifecycleError(`Task with id ${id} not found`); 109 + throw new Error(`Task with ID ${id} not found`); 110 + } 111 + 112 + if (task.status === newStatus) { 113 + return task; // No change needed 92 114 } 93 115 94 116 const validTransitions = VALID_TRANSITIONS[task.status]; 95 117 if (!validTransitions.includes(newStatus)) { 96 - throw new TaskLifecycleError( 97 - `Invalid status transition from ${task.status} to ${newStatus}. Valid transitions: ${validTransitions.join(', ')}` 118 + throw new Error( 119 + `Invalid status transition from '${task.status}' to '${newStatus}'. ` + 120 + `Valid transitions are: ${validTransitions.join(', ')}` 98 121 ); 99 122 } 100 123 ··· 105 128 updated_at: now 106 129 }; 107 130 108 - if (newStatus === 'done') { 131 + // Handle completion 132 + if (newStatus === 'done' && task.status !== 'done') { 109 133 updatedTask.completed_at = now; 110 134 updatedTask.duration_ms = now.getTime() - task.created_at.getTime(); 111 135 } 112 136 137 + // Clear completion data if moving away from done 138 + if (task.status === 'done' && newStatus !== 'done') { 139 + updatedTask.completed_at = undefined; 140 + updatedTask.duration_ms = undefined; 141 + } 142 + 113 143 this.tasks.set(id, updatedTask); 114 - return { ...updatedTask }; 144 + this.notify(updatedTask, 'status_changed', { 145 + from: task.status, 146 + to: newStatus 147 + }); 148 + 149 + if (newStatus === 'done') { 150 + this.notify(updatedTask, 'completed', { 151 + duration_ms: updatedTask.duration_ms 152 + }); 153 + } 154 + 155 + return updatedTask; 115 156 } 116 157 117 158 deleteTask(id: string): boolean { 118 - return this.tasks.delete(id); 159 + const task = this.tasks.get(id); 160 + if (!task) { 161 + return false; 162 + } 163 + 164 + this.tasks.delete(id); 165 + this.notify(task, 'deleted'); 166 + return true; 119 167 } 120 168 121 169 getTasksByStatus(status: TaskStatus): Task[] { 122 - return Array.from(this.tasks.values()) 123 - .filter(task => task.status === status) 124 - .map(task => ({ ...task })); 170 + return Array.from(this.tasks.values()).filter(task => task.status === status); 125 171 } 126 172 127 173 getTasksByPriority(priority: TaskPriority): Task[] { 128 - return Array.from(this.tasks.values()) 129 - .filter(task => task.priority === priority) 130 - .map(task => ({ ...task })); 174 + return Array.from(this.tasks.values()).filter(task => task.priority === priority); 131 175 } 132 176 133 177 getCompletedTasks(): Task[] { 134 - return Array.from(this.tasks.values()) 135 - .filter(task => task.status === 'done' && task.completed_at) 136 - .map(task => ({ ...task })); 178 + return this.getTasksByStatus('done'); 137 179 } 138 180 139 181 getTaskDuration(id: string): number | undefined { ··· 150 192 } 151 193 } 152 194 153 - export function createTaskLifecycle(): TaskLifecycle { 154 - return new TaskLifecycle(); 155 - } 156 - 157 - export function validateTaskStatus(status: string): status is TaskStatus { 158 - return ['open', 'in_progress', 'review', 'done'].includes(status); 195 + export function createTaskLifecycleManager(): TaskLifecycleManager { 196 + return new TaskLifecycleManager(); 159 197 } 160 198 161 199 export function validateTaskPriority(priority: string): priority is TaskPriority { 162 200 return ['low', 'medium', 'high', 'critical'].includes(priority); 201 + } 202 + 203 + export function validateTaskStatus(status: string): status is TaskStatus { 204 + return ['open', 'in_progress', 'review', 'done'].includes(status); 163 205 } 164 206 165 207 /** @internal Phoenix VCS traceability — do not remove. */
+13 -18
examples/taskflow/src/generated/web-dashboard/analytics-panel.ts
··· 21 21 return Math.round((completed / total) * 100); 22 22 } 23 23 24 - export function formatMetricValue(value: number, type: 'count' | 'percentage'): string { 25 - if (type === 'percentage') { 24 + export function formatMetricValue(value: number, isPercentage: boolean = false): string { 25 + if (isPercentage) { 26 26 return `${value}%`; 27 27 } 28 28 return value.toString(); ··· 32 32 return [ 33 33 { 34 34 name: 'Total Tasks', 35 - value: formatMetricValue(stats.totalTasks, 'count'), 35 + value: formatMetricValue(stats.totalTasks), 36 36 emoji: '📋' 37 37 }, 38 38 { 39 39 name: 'Completed', 40 - value: formatMetricValue(stats.completedCount, 'count'), 40 + value: formatMetricValue(stats.completedCount), 41 41 emoji: '✅' 42 42 }, 43 43 { 44 44 name: 'Overdue', 45 - value: formatMetricValue(stats.overdueCount, 'count'), 45 + value: formatMetricValue(stats.overdueCount), 46 46 emoji: '⚠️' 47 47 }, 48 48 { 49 49 name: 'Completion Rate', 50 - value: formatMetricValue(stats.completionRate, 'percentage'), 50 + value: formatMetricValue(stats.completionRate, true), 51 51 emoji: '📊' 52 52 } 53 53 ]; ··· 67 67 68 68 export function renderAnalyticsPanel(options: AnalyticsPanelOptions): string { 69 69 const { stats, className = 'analytics-panel' } = options; 70 - const cards = createMetricCards(stats); 71 - const cardHtml = cards.map(renderMetricCard).join('\n'); 70 + const metricCards = createMetricCards(stats); 71 + 72 + const cardsHtml = metricCards 73 + .map(card => renderMetricCard(card)) 74 + .join('\n'); 72 75 73 76 return ` 74 77 <div class="${className}"> 75 78 <div class="metrics-row"> 76 - ${cardHtml} 79 + ${cardsHtml} 77 80 </div> 78 81 </div> 79 82 `.trim(); ··· 83 86 private stats: TaskStats; 84 87 private className: string; 85 88 86 - constructor(stats: TaskStats, className = 'analytics-panel') { 89 + constructor(stats: TaskStats, className: string = 'analytics-panel') { 87 90 this.stats = stats; 88 91 this.className = className; 89 92 } 90 93 91 94 updateStats(newStats: TaskStats): void { 92 95 this.stats = { ...newStats }; 93 - this.stats.completionRate = calculateCompletionRate( 94 - newStats.completedCount, 95 - newStats.totalTasks 96 - ); 97 96 } 98 97 99 98 getStats(): TaskStats { ··· 105 104 stats: this.stats, 106 105 className: this.className 107 106 }); 108 - } 109 - 110 - getMetricCards(): MetricCard[] { 111 - return createMetricCards(this.stats); 112 107 } 113 108 } 114 109
+187 -225
examples/taskflow/src/generated/web-dashboard/dashboard-page.ts
··· 4 4 description: string; 5 5 priority: 'low' | 'medium' | 'high'; 6 6 deadline?: string; 7 - createdAt: string; 8 - } 9 - 10 - export interface TaskFormData { 11 - title: string; 12 - description: string; 13 - priority: 'low' | 'medium' | 'high'; 14 - deadline?: string; 7 + createdAt: Date; 15 8 } 16 9 17 10 export interface DashboardState { ··· 21 14 22 15 export class DashboardPage { 23 16 private tasks: Task[] = []; 17 + private taskIdCounter = 1; 24 18 25 - public addTask(formData: TaskFormData): Task { 26 - this.validateTaskForm(formData); 27 - 19 + public addTask(title: string, description: string, priority: 'low' | 'medium' | 'high', deadline?: string): Task { 20 + if (!title.trim()) { 21 + throw new Error('Task title cannot be empty'); 22 + } 23 + 28 24 const task: Task = { 29 - id: this.generateId(), 30 - title: formData.title.trim(), 31 - description: formData.description.trim(), 32 - priority: formData.priority, 33 - deadline: formData.deadline || undefined, 34 - createdAt: new Date().toISOString(), 25 + id: `task-${this.taskIdCounter++}`, 26 + title: title.trim(), 27 + description: description.trim(), 28 + priority, 29 + deadline: deadline || undefined, 30 + createdAt: new Date() 35 31 }; 36 32 37 33 this.tasks.push(task); ··· 48 44 49 45 public renderHTML(): string { 50 46 const taskCount = this.getTaskCount(); 51 - 47 + const tasks = this.getTasks(); 48 + 52 49 return `<!DOCTYPE html> 53 50 <html lang="en"> 54 51 <head> ··· 61 58 --danger: #dc2626; 62 59 --success: #16a34a; 63 60 --warning: #d97706; 64 - --primary-light: #dbeafe; 65 61 --gray-50: #f9fafb; 66 62 --gray-100: #f3f4f6; 67 63 --gray-200: #e5e7eb; ··· 104 100 } 105 101 106 102 .logo { 107 - font-size: 1.75rem; 108 - font-weight: 700; 103 + font-size: 1.5rem; 104 + font-weight: bold; 109 105 color: var(--primary); 110 106 } 111 107 112 108 .task-summary { 113 - background: var(--primary-light); 109 + background: var(--primary); 110 + color: white; 114 111 padding: 0.5rem 1rem; 115 112 border-radius: 0.5rem; 116 - font-weight: 600; 117 - color: var(--primary); 113 + font-weight: 500; 118 114 } 119 115 120 116 .main-content { ··· 126 122 127 123 .card { 128 124 background: white; 129 - border-radius: 0.75rem; 125 + border-radius: 0.5rem; 130 126 padding: 1.5rem; 131 127 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 132 128 } ··· 134 130 .card-title { 135 131 font-size: 1.25rem; 136 132 font-weight: 600; 137 - margin-bottom: 1.5rem; 133 + margin-bottom: 1rem; 138 134 color: var(--gray-800); 139 135 } 140 136 ··· 145 141 .form-label { 146 142 display: block; 147 143 font-weight: 500; 148 - margin-bottom: 0.5rem; 144 + margin-bottom: 0.25rem; 149 145 color: var(--gray-700); 150 146 } 151 147 152 148 .form-input, 153 - .form-select, 154 - .form-textarea { 149 + .form-textarea, 150 + .form-select { 155 151 width: 100%; 156 - padding: 0.75rem; 152 + padding: 0.5rem; 157 153 border: 1px solid var(--gray-300); 158 - border-radius: 0.5rem; 154 + border-radius: 0.25rem; 159 155 font-size: 0.875rem; 160 - transition: border-color 0.2s; 161 156 } 162 157 163 158 .form-input:focus, 164 - .form-select:focus, 165 - .form-textarea:focus { 159 + .form-textarea:focus, 160 + .form-select:focus { 166 161 outline: none; 167 162 border-color: var(--primary); 168 163 box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); ··· 170 165 171 166 .form-textarea { 172 167 resize: vertical; 173 - min-height: 80px; 168 + min-height: 4rem; 174 169 } 175 170 176 171 .btn { 177 - display: inline-flex; 178 - align-items: center; 179 - justify-content: center; 180 - padding: 0.75rem 1.5rem; 172 + padding: 0.5rem 1rem; 181 173 border: none; 182 - border-radius: 0.5rem; 174 + border-radius: 0.25rem; 183 175 font-weight: 500; 184 176 cursor: pointer; 185 - transition: all 0.2s; 186 - text-decoration: none; 177 + transition: background-color 0.2s; 187 178 } 188 179 189 180 .btn-primary { ··· 200 191 cursor: not-allowed; 201 192 } 202 193 203 - .error-message { 204 - color: var(--danger); 205 - font-size: 0.875rem; 206 - margin-top: 0.25rem; 207 - display: none; 208 - } 209 - 210 - .error-message.show { 211 - display: block; 212 - } 213 - 214 - .priority-badge { 215 - display: inline-block; 216 - padding: 0.25rem 0.5rem; 217 - border-radius: 0.25rem; 218 - font-size: 0.75rem; 219 - font-weight: 500; 220 - text-transform: uppercase; 221 - } 222 - 223 - .priority-low { 224 - background: var(--success); 225 - color: white; 226 - } 227 - 228 - .priority-medium { 229 - background: var(--warning); 230 - color: white; 231 - } 232 - 233 - .priority-high { 234 - background: var(--danger); 235 - color: white; 236 - } 237 - 238 194 .task-list { 239 195 display: flex; 240 196 flex-direction: column; ··· 242 198 } 243 199 244 200 .task-item { 245 - padding: 1rem; 246 - border: 1px solid var(--gray-200); 201 + background: white; 247 202 border-radius: 0.5rem; 248 - background: var(--gray-50); 203 + padding: 1rem; 204 + border-left: 4px solid var(--gray-300); 205 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 249 206 } 250 207 251 - .task-header { 252 - display: flex; 253 - justify-content: space-between; 254 - align-items: flex-start; 255 - margin-bottom: 0.5rem; 208 + .task-item.priority-high { 209 + border-left-color: var(--danger); 210 + } 211 + 212 + .task-item.priority-medium { 213 + border-left-color: var(--warning); 214 + } 215 + 216 + .task-item.priority-low { 217 + border-left-color: var(--success); 256 218 } 257 219 258 220 .task-title { 259 221 font-weight: 600; 260 - color: var(--gray-800); 222 + margin-bottom: 0.5rem; 261 223 } 262 224 263 225 .task-description { ··· 268 230 .task-meta { 269 231 display: flex; 270 232 gap: 1rem; 233 + font-size: 0.75rem; 234 + color: var(--gray-500); 235 + } 236 + 237 + .priority-badge { 238 + padding: 0.125rem 0.5rem; 239 + border-radius: 0.25rem; 240 + font-weight: 500; 241 + text-transform: uppercase; 242 + font-size: 0.625rem; 243 + } 244 + 245 + .priority-high { 246 + background: rgba(220, 38, 38, 0.1); 247 + color: var(--danger); 248 + } 249 + 250 + .priority-medium { 251 + background: rgba(217, 119, 6, 0.1); 252 + color: var(--warning); 253 + } 254 + 255 + .priority-low { 256 + background: rgba(22, 163, 74, 0.1); 257 + color: var(--success); 258 + } 259 + 260 + .error-message { 261 + color: var(--danger); 271 262 font-size: 0.875rem; 272 - color: var(--gray-500); 263 + margin-top: 0.25rem; 273 264 } 274 265 275 266 @media (max-width: 768px) { ··· 290 281 <div class="header-content"> 291 282 <h1 class="logo">TaskFlow</h1> 292 283 <div class="task-summary"> 293 - <span id="task-count">${taskCount}</span> tasks 284 + Total Tasks: <span id="taskCount">${taskCount}</span> 294 285 </div> 295 286 </div> 296 287 </div> 297 288 </header> 298 289 299 - <div class="container"> 290 + <main class="container"> 300 291 <div class="main-content"> 301 292 <div class="card"> 302 293 <h2 class="card-title">Create New Task</h2> 303 - <form id="task-form"> 294 + <form id="taskForm"> 304 295 <div class="form-group"> 305 296 <label class="form-label" for="title">Title *</label> 306 - <input type="text" id="title" name="title" class="form-input" required> 307 - <div class="error-message" id="title-error">Title is required</div> 297 + <input type="text" id="title" class="form-input" required> 298 + <div id="titleError" class="error-message" style="display: none;"></div> 308 299 </div> 309 - 300 + 310 301 <div class="form-group"> 311 302 <label class="form-label" for="description">Description</label> 312 - <textarea id="description" name="description" class="form-textarea" rows="3"></textarea> 303 + <textarea id="description" class="form-textarea"></textarea> 313 304 </div> 314 - 305 + 315 306 <div class="form-group"> 316 307 <label class="form-label" for="priority">Priority</label> 317 - <select id="priority" name="priority" class="form-select"> 308 + <select id="priority" class="form-select"> 318 309 <option value="low">Low</option> 319 310 <option value="medium" selected>Medium</option> 320 311 <option value="high">High</option> 321 312 </select> 322 313 </div> 323 - 314 + 324 315 <div class="form-group"> 325 316 <label class="form-label" for="deadline">Deadline (Optional)</label> 326 - <input type="date" id="deadline" name="deadline" class="form-input"> 317 + <input type="date" id="deadline" class="form-input"> 327 318 </div> 328 - 319 + 329 320 <button type="submit" class="btn btn-primary">Create Task</button> 330 321 </form> 331 322 </div> 332 323 333 324 <div class="card"> 334 - <h2 class="card-title">Recent Tasks</h2> 335 - <div class="task-list" id="task-list"> 336 - ${this.renderTaskList()} 325 + <h2 class="card-title">Tasks</h2> 326 + <div id="taskList" class="task-list"> 327 + ${tasks.length === 0 ? '<p style="color: var(--gray-600); text-align: center; padding: 2rem;">No tasks yet. Create your first task!</p>' : 328 + tasks.map(task => this.renderTaskItem(task)).join('')} 337 329 </div> 338 330 </div> 339 331 </div> 340 - </div> 332 + </main> 341 333 342 334 <script> 343 - (function() { 344 - const form = document.getElementById('task-form'); 345 - const titleInput = document.getElementById('title'); 346 - const titleError = document.getElementById('title-error'); 347 - const taskCountEl = document.getElementById('task-count'); 348 - const taskList = document.getElementById('task-list'); 349 - 350 - function validateTitle() { 351 - const title = titleInput.value.trim(); 352 - if (!title) { 353 - titleError.classList.add('show'); 354 - titleInput.style.borderColor = 'var(--danger)'; 355 - return false; 356 - } else { 357 - titleError.classList.remove('show'); 358 - titleInput.style.borderColor = ''; 359 - return true; 360 - } 361 - } 335 + let tasks = ${JSON.stringify(tasks)}; 336 + let taskIdCounter = ${this.taskIdCounter}; 362 337 363 - function formatDate(dateString) { 364 - if (!dateString) return ''; 365 - return new Date(dateString).toLocaleDateString(); 366 - } 338 + function updateTaskCount() { 339 + document.getElementById('taskCount').textContent = tasks.length; 340 + } 367 341 368 - function createTaskElement(task) { 369 - return \` 370 - <div class="task-item"> 371 - <div class="task-header"> 372 - <h3 class="task-title">\${task.title}</h3> 373 - <span class="priority-badge priority-\${task.priority}">\${task.priority}</span> 374 - </div> 375 - \${task.description ? \`<p class="task-description">\${task.description}</p>\` : ''} 376 - <div class="task-meta"> 377 - <span>Created: \${formatDate(task.createdAt)}</span> 378 - \${task.deadline ? \`<span>Due: \${formatDate(task.deadline)}</span>\` : ''} 379 - </div> 342 + function renderTaskItem(task) { 343 + const deadline = task.deadline ? new Date(task.deadline).toLocaleDateString() : null; 344 + const createdAt = new Date(task.createdAt).toLocaleDateString(); 345 + 346 + return \` 347 + <div class="task-item priority-\${task.priority}"> 348 + <div class="task-title">\${task.title}</div> 349 + <div class="task-description">\${task.description}</div> 350 + <div class="task-meta"> 351 + <span class="priority-badge priority-\${task.priority}">\${task.priority}</span> 352 + <span>Created: \${createdAt}</span> 353 + \${deadline ? \`<span>Due: \${deadline}</span>\` : ''} 380 354 </div> 381 - \`; 355 + </div> 356 + \`; 357 + } 358 + 359 + function renderTaskList() { 360 + const taskList = document.getElementById('taskList'); 361 + if (tasks.length === 0) { 362 + taskList.innerHTML = '<p style="color: var(--gray-600); text-align: center; padding: 2rem;">No tasks yet. Create your first task!</p>'; 363 + } else { 364 + taskList.innerHTML = tasks.map(renderTaskItem).join(''); 382 365 } 366 + } 383 367 384 - titleInput.addEventListener('blur', validateTitle); 385 - titleInput.addEventListener('input', function() { 386 - if (titleError.classList.contains('show')) { 387 - validateTitle(); 388 - } 389 - }); 368 + function showError(elementId, message) { 369 + const errorElement = document.getElementById(elementId); 370 + errorElement.textContent = message; 371 + errorElement.style.display = 'block'; 372 + } 390 373 391 - form.addEventListener('submit', function(e) { 392 - e.preventDefault(); 393 - 394 - if (!validateTitle()) { 395 - return; 396 - } 374 + function hideError(elementId) { 375 + const errorElement = document.getElementById(elementId); 376 + errorElement.style.display = 'none'; 377 + } 397 378 398 - const formData = new FormData(form); 399 - const task = { 400 - id: Date.now().toString(), 401 - title: formData.get('title').trim(), 402 - description: formData.get('description').trim(), 403 - priority: formData.get('priority'), 404 - deadline: formData.get('deadline') || null, 405 - createdAt: new Date().toISOString() 406 - }; 379 + function validateTitle(title) { 380 + if (!title.trim()) { 381 + showError('titleError', 'Task title cannot be empty'); 382 + return false; 383 + } 384 + hideError('titleError'); 385 + return true; 386 + } 407 387 408 - // Add task to list 409 - const taskElement = createTaskElement(task); 410 - if (taskList.children.length === 0 || taskList.textContent.includes('No tasks yet')) { 411 - taskList.innerHTML = taskElement; 412 - } else { 413 - taskList.insertAdjacentHTML('afterbegin', taskElement); 414 - } 415 - 416 - // Update task count 417 - const currentCount = parseInt(taskCountEl.textContent); 418 - taskCountEl.textContent = currentCount + 1; 388 + document.getElementById('taskForm').addEventListener('submit', function(e) { 389 + e.preventDefault(); 390 + 391 + const title = document.getElementById('title').value; 392 + const description = document.getElementById('description').value; 393 + const priority = document.getElementById('priority').value; 394 + const deadline = document.getElementById('deadline').value; 395 + 396 + if (!validateTitle(title)) { 397 + return; 398 + } 399 + 400 + const task = { 401 + id: \`task-\${taskIdCounter++}\`, 402 + title: title.trim(), 403 + description: description.trim(), 404 + priority: priority, 405 + deadline: deadline || undefined, 406 + createdAt: new Date() 407 + }; 408 + 409 + tasks.push(task); 410 + updateTaskCount(); 411 + renderTaskList(); 412 + 413 + // Reset form 414 + this.reset(); 415 + document.getElementById('priority').value = 'medium'; 416 + }); 419 417 420 - // Reset form 421 - form.reset(); 422 - document.getElementById('priority').value = 'medium'; 423 - }); 424 - })(); 418 + // Real-time title validation 419 + document.getElementById('title').addEventListener('input', function() { 420 + validateTitle(this.value); 421 + }); 425 422 </script> 426 423 </body> 427 424 </html>`; 428 425 } 429 426 430 - private validateTaskForm(formData: TaskFormData): void { 431 - if (!formData.title || formData.title.trim().length === 0) { 432 - throw new Error('Title is required'); 433 - } 434 - } 435 - 436 - private generateId(): string { 437 - return Date.now().toString(36) + Math.random().toString(36).substr(2); 438 - } 439 - 440 - private renderTaskList(): string { 441 - if (this.tasks.length === 0) { 442 - return '<p style="color: var(--gray-500); text-align: center; padding: 2rem;">No tasks yet. Create your first task!</p>'; 443 - } 444 - 445 - return this.tasks 446 - .slice(-5) // Show last 5 tasks 447 - .reverse() 448 - .map(task => ` 449 - <div class="task-item"> 450 - <div class="task-header"> 451 - <h3 class="task-title">${this.escapeHtml(task.title)}</h3> 452 - <span class="priority-badge priority-${task.priority}">${task.priority}</span> 453 - </div> 454 - ${task.description ? `<p class="task-description">${this.escapeHtml(task.description)}</p>` : ''} 455 - <div class="task-meta"> 456 - <span>Created: ${this.formatDate(task.createdAt)}</span> 457 - ${task.deadline ? `<span>Due: ${this.formatDate(task.deadline)}</span>` : ''} 458 - </div> 427 + private renderTaskItem(task: Task): string { 428 + const deadline = task.deadline ? new Date(task.deadline).toLocaleDateString() : null; 429 + const createdAt = task.createdAt.toLocaleDateString(); 430 + 431 + return ` 432 + <div class="task-item priority-${task.priority}"> 433 + <div class="task-title">${task.title}</div> 434 + <div class="task-description">${task.description}</div> 435 + <div class="task-meta"> 436 + <span class="priority-badge priority-${task.priority}">${task.priority}</span> 437 + <span>Created: ${createdAt}</span> 438 + ${deadline ? `<span>Due: ${deadline}</span>` : ''} 439 + </div> 459 440 </div> 460 - `).join(''); 461 - } 462 - 463 - private escapeHtml(text: string): string { 464 - const div = { innerHTML: '' } as any; 465 - div.textContent = text; 466 - return div.innerHTML || text.replace(/[&<>"']/g, (match: string) => { 467 - const escapeMap: Record<string, string> = { 468 - '&': '&amp;', 469 - '<': '&lt;', 470 - '>': '&gt;', 471 - '"': '&quot;', 472 - "'": '&#39;' 473 - }; 474 - return escapeMap[match]; 475 - }); 476 - } 477 - 478 - private formatDate(dateString: string): string { 479 - return new Date(dateString).toLocaleDateString(); 441 + `; 480 442 } 481 443 } 482 444
+131 -15
examples/taskflow/src/generated/web-dashboard/server.ts
··· 1 1 /** 2 - * Web Dashboard — HTTP Server 2 + * Web Dashboard — Web Server 3 3 * 4 - * Serves the TaskFlow dashboard as a single-page web app. 5 - * The DashboardPage module generates the complete HTML. 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Serves the web client HTML, plus health/metrics/modules endpoints. 6 6 */ 7 7 8 8 import { createServer, IncomingMessage, ServerResponse } from 'node:http'; 9 9 10 - import { createDashboard } from './dashboard-page.js'; 11 - import { generateCSSString } from './styles.js'; 12 10 import * as analyticsPanel from './analytics-panel.js'; 11 + import * as dashboardPage from './dashboard-page.js'; 12 + import * as styles from './styles.js'; 13 13 import * as taskListDisplay from './task-list-display.js'; 14 14 15 15 // ─── Metrics ───────────────────────────────────────────────────────────────── ··· 25 25 26 26 const _svcModules = { 27 27 'analytics-panel': analyticsPanel, 28 - 'dashboard-page': { createDashboard }, 29 - 'styles': { generateCSSString }, 28 + 'dashboard-page': dashboardPage, 29 + 'styles': styles, 30 30 'task-list-display': taskListDisplay, 31 31 }; 32 32 33 + // ─── HTML Renderer ─────────────────────────────────────────────────────────── 34 + 35 + function renderPage(): string { 36 + // Collect CSS from style modules 37 + let css = ''; 38 + try { 39 + const styleMod = styles as Record<string, unknown>; 40 + for (const key of Object.keys(styleMod)) { 41 + const val = styleMod[key]; 42 + if (typeof val === 'function' && /css|style/i.test(key)) { 43 + const result = (val as Function)(); 44 + if (typeof result === 'string') css += result; 45 + else if (result && typeof result === 'object' && 'generateCSS' in result) { 46 + css += (result as { generateCSS: () => string }).generateCSS(); 47 + } 48 + } 49 + } 50 + } catch { /* style module may not have expected exports */ } 51 + 52 + // Collect HTML from UI modules 53 + const sections: string[] = []; 54 + try { 55 + const uiMod = analyticsPanel as Record<string, unknown>; 56 + for (const key of Object.keys(uiMod)) { 57 + const val = uiMod[key]; 58 + // Look for factory functions that return objects with render/renderHTML 59 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 60 + try { 61 + const instance = (val as Function)(); 62 + if (typeof instance === 'string' && instance.includes('<')) { 63 + sections.push(instance); 64 + } else if (instance && typeof instance === 'object') { 65 + const obj = instance as Record<string, unknown>; 66 + if (typeof obj.render === 'function') { 67 + const html = (obj.render as Function)(); 68 + if (typeof html === 'string') sections.push(html); 69 + } else if (typeof obj.renderHTML === 'function') { 70 + const html = (obj.renderHTML as Function)(); 71 + if (typeof html === 'string') sections.push(html); 72 + } 73 + } 74 + } catch { /* factory may require args */ } 75 + } 76 + } 77 + } catch { /* module may not have renderable exports */ } 78 + try { 79 + const uiMod = dashboardPage as Record<string, unknown>; 80 + for (const key of Object.keys(uiMod)) { 81 + const val = uiMod[key]; 82 + // Look for factory functions that return objects with render/renderHTML 83 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 84 + try { 85 + const instance = (val as Function)(); 86 + if (typeof instance === 'string' && instance.includes('<')) { 87 + sections.push(instance); 88 + } else if (instance && typeof instance === 'object') { 89 + const obj = instance as Record<string, unknown>; 90 + if (typeof obj.render === 'function') { 91 + const html = (obj.render as Function)(); 92 + if (typeof html === 'string') sections.push(html); 93 + } else if (typeof obj.renderHTML === 'function') { 94 + const html = (obj.renderHTML as Function)(); 95 + if (typeof html === 'string') sections.push(html); 96 + } 97 + } 98 + } catch { /* factory may require args */ } 99 + } 100 + } 101 + } catch { /* module may not have renderable exports */ } 102 + try { 103 + const uiMod = taskListDisplay as Record<string, unknown>; 104 + for (const key of Object.keys(uiMod)) { 105 + const val = uiMod[key]; 106 + // Look for factory functions that return objects with render/renderHTML 107 + if (typeof val === 'function' && /^create|^make|^render|^build/i.test(key)) { 108 + try { 109 + const instance = (val as Function)(); 110 + if (typeof instance === 'string' && instance.includes('<')) { 111 + sections.push(instance); 112 + } else if (instance && typeof instance === 'object') { 113 + const obj = instance as Record<string, unknown>; 114 + if (typeof obj.render === 'function') { 115 + const html = (obj.render as Function)(); 116 + if (typeof html === 'string') sections.push(html); 117 + } else if (typeof obj.renderHTML === 'function') { 118 + const html = (obj.renderHTML as Function)(); 119 + if (typeof html === 'string') sections.push(html); 120 + } 121 + } 122 + } catch { /* factory may require args */ } 123 + } 124 + } 125 + } catch { /* module may not have renderable exports */ } 126 + 127 + return `<!DOCTYPE html> 128 + <html lang="en"> 129 + <head> 130 + <meta charset="utf-8"> 131 + <meta name="viewport" content="width=device-width, initial-scale=1"> 132 + <title>Web Dashboard</title> 133 + <style>${css}</style> 134 + </head> 135 + <body> 136 + <div class="game-container"> 137 + ${sections.join('\n')} 138 + </div> 139 + </body> 140 + </html>`; 141 + } 142 + 33 143 // ─── Router ────────────────────────────────────────────────────────────────── 34 144 35 145 type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>; 36 146 37 147 const routes: Record<string, Handler> = { 38 148 '/': (_req, res) => { 39 - const dashboard = createDashboard(); 40 - const html = dashboard.renderHTML(); 41 149 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); 42 - res.end(html); 150 + res.end(renderPage()); 43 151 }, 44 152 45 153 '/health': (_req, res) => { ··· 61 169 }, 62 170 63 171 '/modules': (_req, res) => { 64 - const info = Object.entries(_svcModules).map(([name, mod]) => ({ 65 - name, 66 - exports: Object.keys(mod), 67 - })); 172 + const info = Object.entries(_svcModules).map(([name, mod]) => { 173 + const phoenix = (mod as Record<string, unknown>)._phoenix as Record<string, unknown> | undefined; 174 + return { 175 + name, 176 + risk_tier: phoenix?.risk_tier ?? 'unknown', 177 + exports: Object.keys(mod).filter(k => k !== '_phoenix'), 178 + }; 179 + }); 68 180 res.writeHead(200, { 'Content-Type': 'application/json' }); 69 181 res.end(JSON.stringify(info, null, 2)); 70 182 }, ··· 108 220 const addr = server.address(); 109 221 if (addr && typeof addr === 'object') actualPort = addr.port; 110 222 result.port = actualPort; 111 - console.log(`TaskFlow Dashboard → http://localhost:${actualPort}`); 223 + console.log(`Web Dashboard listening on http://localhost:${actualPort}`); 224 + console.log(` / — web client`); 225 + console.log(` /health — health check`); 226 + console.log(` /metrics — request metrics`); 227 + console.log(` /modules — registered modules`); 112 228 resolve(); 113 229 }); 114 230 });
+229 -162
examples/taskflow/src/generated/web-dashboard/styles.ts
··· 13 13 border: string; 14 14 shadow: string; 15 15 }; 16 - typography: { 17 - fontFamily: string; 18 - sizes: { 19 - h1: string; 20 - body: string; 21 - }; 22 - }; 23 16 spacing: { 24 17 xs: string; 25 18 sm: string; ··· 27 20 lg: string; 28 21 xl: string; 29 22 }; 30 - borderRadius: { 31 - card: string; 32 - button: string; 23 + typography: { 24 + fontFamily: string; 25 + h1Size: string; 26 + bodySize: string; 27 + }; 28 + effects: { 29 + borderRadius: string; 30 + cardShadow: string; 31 + cardShadowHover: string; 32 + transition: string; 33 33 }; 34 34 } 35 35 36 - export interface ComponentStyles { 37 - layout: string; 38 - card: string; 39 - button: string; 40 - typography: string; 41 - } 42 - 43 - const defaultConfig: StyleConfig = { 36 + export const defaultStyleConfig: StyleConfig = { 44 37 breakpoints: { 45 38 mobile: '768px', 46 39 desktop: '769px' ··· 55 48 border: '#dee2e6', 56 49 shadow: 'rgba(0, 0, 0, 0.1)' 57 50 }, 58 - typography: { 59 - fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 60 - sizes: { 61 - h1: '1.5rem', 62 - body: '0.95rem' 63 - } 64 - }, 65 51 spacing: { 66 52 xs: '0.25rem', 67 53 sm: '0.5rem', ··· 69 55 lg: '1.5rem', 70 56 xl: '2rem' 71 57 }, 72 - borderRadius: { 73 - card: '8px', 74 - button: '4px' 58 + typography: { 59 + fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 60 + h1Size: '1.5rem', 61 + bodySize: '0.95rem' 62 + }, 63 + effects: { 64 + borderRadius: '8px', 65 + cardShadow: '0 2px 4px rgba(0, 0, 0, 0.1)', 66 + cardShadowHover: '0 4px 8px rgba(0, 0, 0, 0.15)', 67 + transition: 'all 0.2s ease-in-out' 75 68 } 76 69 }; 77 70 78 - export function generateStyles(config: Partial<StyleConfig> = {}): ComponentStyles { 79 - const mergedConfig: StyleConfig = { 80 - ...defaultConfig, 81 - ...config, 82 - colors: { ...defaultConfig.colors, ...config.colors }, 83 - typography: { ...defaultConfig.typography, ...config.typography }, 84 - spacing: { ...defaultConfig.spacing, ...config.spacing }, 85 - borderRadius: { ...defaultConfig.borderRadius, ...config.borderRadius } 86 - }; 71 + export class StyleManager { 72 + private config: StyleConfig; 73 + private styleElement: string | null = null; 74 + 75 + constructor(config: StyleConfig = defaultStyleConfig) { 76 + this.config = config; 77 + } 78 + 79 + generateCSS(): string { 80 + const { breakpoints, colors, spacing, typography, effects } = this.config; 81 + 82 + return ` 83 + /* Reset and base styles */ 84 + * { 85 + box-sizing: border-box; 86 + margin: 0; 87 + padding: 0; 88 + } 87 89 88 - const layout = ` 89 - .phoenix-layout { 90 - display: grid; 91 - grid-template-columns: 1fr; 92 - gap: ${mergedConfig.spacing.md}; 93 - padding: ${mergedConfig.spacing.md}; 94 - max-width: 100%; 95 - margin: 0 auto; 96 - } 90 + body { 91 + font-family: ${typography.fontFamily}; 92 + font-size: ${typography.bodySize}; 93 + line-height: 1.6; 94 + color: ${colors.text}; 95 + background-color: ${colors.background}; 96 + } 97 97 98 - @media (min-width: ${mergedConfig.breakpoints.desktop}) { 99 - .phoenix-layout { 100 - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); 101 - gap: ${mergedConfig.spacing.lg}; 102 - padding: ${mergedConfig.spacing.xl}; 98 + /* Typography */ 99 + h1 { 100 + font-size: ${typography.h1Size}; 101 + font-weight: 600; 102 + color: ${colors.text}; 103 + margin-bottom: ${spacing.lg}; 104 + } 105 + 106 + /* Layout - Mobile First */ 107 + .container { 108 + width: 100%; 103 109 max-width: 1200px; 110 + margin: 0 auto; 111 + padding: ${spacing.md}; 104 112 } 105 - } 106 - `; 113 + 114 + .grid { 115 + display: grid; 116 + grid-template-columns: 1fr; 117 + gap: ${spacing.lg}; 118 + } 119 + 120 + /* Desktop Layout */ 121 + @media (min-width: ${breakpoints.desktop}) { 122 + .grid { 123 + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); 124 + } 125 + 126 + .grid-2 { 127 + grid-template-columns: repeat(2, 1fr); 128 + } 107 129 108 - const card = ` 109 - .phoenix-card { 110 - background: ${mergedConfig.colors.surface}; 111 - border: 1px solid ${mergedConfig.colors.border}; 112 - border-radius: ${mergedConfig.borderRadius.card}; 113 - padding: ${mergedConfig.spacing.lg}; 114 - box-shadow: 0 2px 4px ${mergedConfig.colors.shadow}; 115 - transition: box-shadow 0.2s ease, transform 0.2s ease; 116 - } 130 + .grid-3 { 131 + grid-template-columns: repeat(3, 1fr); 132 + } 133 + 134 + .grid-4 { 135 + grid-template-columns: repeat(4, 1fr); 136 + } 137 + } 138 + 139 + /* Cards */ 140 + .card { 141 + background: ${colors.surface}; 142 + border: 1px solid ${colors.border}; 143 + border-radius: ${effects.borderRadius}; 144 + padding: ${spacing.lg}; 145 + box-shadow: ${effects.cardShadow}; 146 + transition: ${effects.transition}; 147 + } 148 + 149 + .card:hover { 150 + box-shadow: ${effects.cardShadowHover}; 151 + transform: translateY(-2px); 152 + } 153 + 154 + /* Buttons */ 155 + .btn { 156 + display: inline-block; 157 + padding: ${spacing.sm} ${spacing.lg}; 158 + border: none; 159 + border-radius: ${effects.borderRadius}; 160 + font-family: ${typography.fontFamily}; 161 + font-size: ${typography.bodySize}; 162 + font-weight: 500; 163 + text-decoration: none; 164 + text-align: center; 165 + cursor: pointer; 166 + transition: ${effects.transition}; 167 + background-color: ${colors.primary}; 168 + color: white; 169 + } 170 + 171 + .btn:hover { 172 + opacity: 0.9; 173 + transform: translateY(-1px); 174 + } 175 + 176 + .btn:active { 177 + transform: translateY(0); 178 + } 179 + 180 + .btn-secondary { 181 + background-color: ${colors.secondary}; 182 + } 183 + 184 + .btn-outline { 185 + background-color: transparent; 186 + color: ${colors.primary}; 187 + border: 1px solid ${colors.primary}; 188 + } 189 + 190 + .btn-outline:hover { 191 + background-color: ${colors.primary}; 192 + color: white; 193 + } 117 194 118 - .phoenix-card:hover { 119 - box-shadow: 0 4px 8px ${mergedConfig.colors.shadow}; 120 - transform: translateY(-1px); 121 - } 122 - `; 195 + /* Utility classes */ 196 + .text-center { 197 + text-align: center; 198 + } 123 199 124 - const button = ` 125 - .phoenix-button { 126 - background: ${mergedConfig.colors.primary}; 127 - color: ${mergedConfig.colors.background}; 128 - border: none; 129 - border-radius: ${mergedConfig.borderRadius.button}; 130 - padding: ${mergedConfig.spacing.sm} ${mergedConfig.spacing.md}; 131 - font-family: ${mergedConfig.typography.fontFamily}; 132 - font-size: ${mergedConfig.typography.sizes.body}; 133 - cursor: pointer; 134 - transition: background-color 0.2s ease, transform 0.1s ease; 135 - display: inline-block; 136 - text-decoration: none; 137 - text-align: center; 138 - } 200 + .text-secondary { 201 + color: ${colors.textSecondary}; 202 + } 139 203 140 - .phoenix-button:hover { 141 - background: ${adjustColor(mergedConfig.colors.primary, -10)}; 142 - transform: translateY(-1px); 143 - } 204 + .mb-sm { margin-bottom: ${spacing.sm}; } 205 + .mb-md { margin-bottom: ${spacing.md}; } 206 + .mb-lg { margin-bottom: ${spacing.lg}; } 207 + .mb-xl { margin-bottom: ${spacing.xl}; } 144 208 145 - .phoenix-button:active { 146 - transform: translateY(0); 147 - } 209 + .mt-sm { margin-top: ${spacing.sm}; } 210 + .mt-md { margin-top: ${spacing.md}; } 211 + .mt-lg { margin-top: ${spacing.lg}; } 212 + .mt-xl { margin-top: ${spacing.xl}; } 148 213 149 - .phoenix-button--secondary { 150 - background: ${mergedConfig.colors.secondary}; 151 - } 214 + .p-sm { padding: ${spacing.sm}; } 215 + .p-md { padding: ${spacing.md}; } 216 + .p-lg { padding: ${spacing.lg}; } 217 + .p-xl { padding: ${spacing.xl}; } 152 218 153 - .phoenix-button--secondary:hover { 154 - background: ${adjustColor(mergedConfig.colors.secondary, -10)}; 155 - } 156 - `; 219 + /* Responsive utilities */ 220 + @media (max-width: ${breakpoints.mobile}) { 221 + .hide-mobile { 222 + display: none; 223 + } 224 + } 157 225 158 - const typography = ` 159 - .phoenix-typography { 160 - font-family: ${mergedConfig.typography.fontFamily}; 161 - color: ${mergedConfig.colors.text}; 162 - line-height: 1.5; 163 - } 226 + @media (min-width: ${breakpoints.desktop}) { 227 + .hide-desktop { 228 + display: none; 229 + } 230 + } 231 + `; 232 + } 164 233 165 - .phoenix-typography h1 { 166 - font-size: ${mergedConfig.typography.sizes.h1}; 167 - font-weight: 600; 168 - margin: 0 0 ${mergedConfig.spacing.md} 0; 169 - color: ${mergedConfig.colors.text}; 234 + getStyleElement(): string { 235 + if (!this.styleElement) { 236 + this.styleElement = `<style>${this.generateCSS()}</style>`; 170 237 } 238 + return this.styleElement; 239 + } 171 240 172 - .phoenix-typography p, 173 - .phoenix-typography div, 174 - .phoenix-typography span { 175 - font-size: ${mergedConfig.typography.sizes.body}; 176 - margin: 0 0 ${mergedConfig.spacing.sm} 0; 177 - } 241 + updateConfig(newConfig: Partial<StyleConfig>): void { 242 + this.config = { ...this.config, ...newConfig }; 243 + this.styleElement = null; // Reset cached styles 244 + } 178 245 179 - .phoenix-typography--secondary { 180 - color: ${mergedConfig.colors.textSecondary}; 181 - } 182 - `; 246 + getConfig(): StyleConfig { 247 + return { ...this.config }; 248 + } 183 249 184 - return { 185 - layout, 186 - card, 187 - button, 188 - typography 189 - }; 190 - } 250 + generateInlineStyles(element: string, styles: Record<string, string>): string { 251 + const styleString = Object.entries(styles) 252 + .map(([property, value]) => `${property}: ${value}`) 253 + .join('; '); 254 + 255 + return `${element} { ${styleString} }`; 256 + } 191 257 192 - export function injectStyles(styles: ComponentStyles): void { 193 - const styleElement = createStyleElement(); 194 - const combinedStyles = Object.values(styles).join('\n'); 195 - styleElement.textContent = combinedStyles; 196 - } 258 + createResponsiveGrid(columns: { mobile: number; desktop: number }): string { 259 + return ` 260 + display: grid; 261 + grid-template-columns: repeat(${columns.mobile}, 1fr); 262 + gap: ${this.config.spacing.lg}; 197 263 198 - export function createStyleElement(): HTMLStyleElement { 199 - if (typeof document === 'undefined') { 200 - throw new Error('createStyleElement can only be used in browser environment'); 264 + @media (min-width: ${this.config.breakpoints.desktop}) { 265 + grid-template-columns: repeat(${columns.desktop}, 1fr); 266 + } 267 + `; 201 268 } 202 - 203 - const existingStyle = document.getElementById('phoenix-styles'); 204 - if (existingStyle) { 205 - return existingStyle as HTMLStyleElement; 269 + 270 + createCard(content: string, className: string = ''): string { 271 + return `<div class="card ${className}">${content}</div>`; 206 272 } 207 273 208 - const style = document.createElement('style'); 209 - style.id = 'phoenix-styles'; 210 - style.type = 'text/css'; 211 - document.head.appendChild(style); 212 - return style; 274 + createButton(text: string, type: 'primary' | 'secondary' | 'outline' = 'primary', onClick?: string): string { 275 + const buttonClass = type === 'primary' ? 'btn' : `btn btn-${type}`; 276 + const onClickAttr = onClick ? ` onclick="${onClick}"` : ''; 277 + return `<button class="${buttonClass}"${onClickAttr}>${text}</button>`; 278 + } 213 279 } 214 280 215 - export function generateCSSString(config?: Partial<StyleConfig>): string { 216 - const styles = generateStyles(config); 217 - return Object.values(styles).join('\n'); 281 + export function createStyleManager(config?: Partial<StyleConfig>): StyleManager { 282 + const finalConfig = config ? { ...defaultStyleConfig, ...config } : defaultStyleConfig; 283 + return new StyleManager(finalConfig); 218 284 } 219 285 220 - function adjustColor(color: string, percent: number): string { 221 - if (!color.startsWith('#')) { 222 - return color; 223 - } 224 - 225 - const num = parseInt(color.slice(1), 16); 226 - const amt = Math.round(2.55 * percent); 227 - const R = (num >> 16) + amt; 228 - const G = (num >> 8 & 0x00FF) + amt; 229 - const B = (num & 0x0000FF) + amt; 286 + export function generateResponsiveHTML(content: string, title?: string): string { 287 + const styleManager = createStyleManager(); 230 288 231 - return '#' + (0x1000000 + (R < 255 ? R < 1 ? 0 : R : 255) * 0x10000 + 232 - (G < 255 ? G < 1 ? 0 : G : 255) * 0x100 + 233 - (B < 255 ? B < 1 ? 0 : B : 255)) 234 - .toString(16) 235 - .slice(1); 289 + return ` 290 + <!DOCTYPE html> 291 + <html lang="en"> 292 + <head> 293 + <meta charset="UTF-8"> 294 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 295 + ${title ? `<title>${title}</title>` : ''} 296 + ${styleManager.getStyleElement()} 297 + </head> 298 + <body> 299 + <div class="container"> 300 + ${content} 301 + </div> 302 + </body> 303 + </html> 304 + `; 236 305 } 237 306 238 - export const defaultStyleConfig = defaultConfig; 239 - 240 307 /** @internal Phoenix VCS traceability — do not remove. */ 241 308 export const _phoenix = { 242 - iu_id: 'fd1e1e3084a491150550d575098a4a929a5be62ccb0b000a173679da38aed9fa', 309 + iu_id: '8d072032308810bce8136ef5a0a51f8e99a35d83edfa38f4d69cdca300d1c6af', 243 310 name: 'Styles', 244 311 risk_tier: 'medium', 245 312 canon_ids: [4 as const],
+212 -280
examples/taskflow/src/generated/web-dashboard/task-list-display.ts
··· 57 57 return new Date() > task.deadline && task.status !== 'done'; 58 58 } 59 59 60 - private getPriorityColor(priority: Task['priority']): string { 61 - switch (priority) { 62 - case 'critical': return '#dc2626'; 63 - case 'high': return '#ea580c'; 64 - case 'medium': return '#ca8a04'; 65 - case 'low': return '#16a34a'; 66 - } 60 + private getPriorityBadgeColor(priority: Task['priority']): string { 61 + const colors = { 62 + critical: '#dc2626', 63 + high: '#ea580c', 64 + medium: '#ca8a04', 65 + low: '#16a34a' 66 + }; 67 + return colors[priority]; 67 68 } 68 69 69 - private getStatusColor(status: Task['status']): string { 70 - switch (status) { 71 - case 'open': return '#6b7280'; 72 - case 'in_progress': return '#2563eb'; 73 - case 'review': return '#7c3aed'; 74 - case 'done': return '#16a34a'; 75 - } 70 + private getStatusBadgeColor(status: Task['status']): string { 71 + const colors = { 72 + open: '#6b7280', 73 + in_progress: '#2563eb', 74 + review: '#7c3aed', 75 + done: '#16a34a' 76 + }; 77 + return colors[status]; 76 78 } 77 79 78 80 private getStatusTransitions(currentStatus: Task['status']): Task['status'][] { 79 - switch (currentStatus) { 80 - case 'open': return ['in_progress']; 81 - case 'in_progress': return ['review', 'open']; 82 - case 'review': return ['done', 'in_progress']; 83 - case 'done': return ['open']; 84 - } 81 + const transitions: Record<Task['status'], Task['status'][]> = { 82 + open: ['in_progress'], 83 + in_progress: ['review', 'open'], 84 + review: ['done', 'in_progress'], 85 + done: ['open'] 86 + }; 87 + return transitions[currentStatus]; 85 88 } 86 89 87 90 private formatDate(date: Date): string { ··· 92 95 }); 93 96 } 94 97 95 - private renderTaskCard(task: Task): string { 96 - const isOverdue = this.isOverdue(task); 97 - const priorityColor = this.getPriorityColor(task.priority); 98 - const statusColor = this.getStatusColor(task.status); 98 + private generateTaskCard(task: Task): string { 99 + const isTaskOverdue = this.isOverdue(task); 100 + const priorityColor = this.getPriorityBadgeColor(task.priority); 101 + const statusColor = this.getStatusBadgeColor(task.status); 99 102 const transitions = this.getStatusTransitions(task.status); 100 103 101 - const cardStyle = ` 102 - border: 2px solid ${isOverdue ? '#dc2626' : '#e5e7eb'}; 103 - border-radius: 8px; 104 - padding: 16px; 105 - background: white; 106 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 107 - display: flex; 108 - flex-direction: column; 109 - gap: 12px; 110 - position: relative; 111 - cursor: pointer; 112 - transition: box-shadow 0.2s; 113 - `; 104 + const overdueIndicator = isTaskOverdue 105 + ? '<div class="overdue-indicator">OVERDUE</div>' 106 + : ''; 114 107 115 - const titleStyle = ` 116 - font-size: 18px; 117 - font-weight: 600; 118 - color: #111827; 119 - margin: 0; 120 - line-height: 1.4; 121 - `; 122 - 123 - const descriptionStyle = ` 124 - color: #6b7280; 125 - font-size: 14px; 126 - line-height: 1.5; 127 - margin: 0; 128 - `; 129 - 130 - const badgeStyle = ` 131 - display: inline-block; 132 - padding: 4px 8px; 133 - border-radius: 4px; 134 - font-size: 12px; 135 - font-weight: 500; 136 - text-transform: uppercase; 137 - color: white; 138 - `; 139 - 140 - const buttonStyle = ` 141 - padding: 6px 12px; 142 - border: 1px solid #d1d5db; 143 - border-radius: 4px; 144 - background: white; 145 - color: #374151; 146 - font-size: 12px; 147 - cursor: pointer; 148 - transition: background-color 0.2s; 149 - `; 150 - 151 - const overdueIndicator = isOverdue ? ` 152 - <div style=" 153 - position: absolute; 154 - top: 8px; 155 - right: 8px; 156 - background: #dc2626; 157 - color: white; 158 - padding: 2px 6px; 159 - border-radius: 4px; 160 - font-size: 10px; 161 - font-weight: 600; 162 - ">OVERDUE</div> 163 - ` : ''; 164 - 165 - const transitionButtons = transitions.map(status => ` 166 - <button 167 - style="${buttonStyle}" 168 - onclick="window.taskListDisplay?.handleStatusChange('${task.id}', '${status}')" 169 - onmouseover="this.style.backgroundColor='#f3f4f6'" 170 - onmouseout="this.style.backgroundColor='white'" 171 - > 108 + const transitionButtons = transitions.map(status => 109 + `<button class="status-transition-btn" data-task-id="${task.id}" data-new-status="${status}"> 172 110 ${status.replace('_', ' ').toUpperCase()} 173 - </button> 174 - `).join(''); 111 + </button>` 112 + ).join(''); 175 113 176 114 return ` 177 - <div 178 - style="${cardStyle}" 179 - onclick="window.taskListDisplay?.handleTaskClick('${task.id}')" 180 - onmouseover="this.style.boxShadow='0 4px 6px rgba(0, 0, 0, 0.1)'" 181 - onmouseout="this.style.boxShadow='0 1px 3px rgba(0, 0, 0, 0.1)'" 182 - > 115 + <div class="task-card ${isTaskOverdue ? 'overdue' : ''}" data-task-id="${task.id}"> 183 116 ${overdueIndicator} 184 - 185 - <h3 style="${titleStyle}">${this.escapeHtml(task.title)}</h3> 186 - 187 - <p style="${descriptionStyle}">${this.escapeHtml(task.description)}</p> 188 - 189 - <div style="display: flex; gap: 8px; flex-wrap: wrap;"> 190 - <span style="${badgeStyle}; background-color: ${priorityColor};"> 191 - ${task.priority} 192 - </span> 193 - <span style="${badgeStyle}; background-color: ${statusColor};"> 194 - ${task.status.replace('_', ' ')} 195 - </span> 117 + <div class="task-header"> 118 + <h3 class="task-title">${this.escapeHtml(task.title)}</h3> 119 + <div class="task-badges"> 120 + <span class="priority-badge" style="background-color: ${priorityColor}"> 121 + ${task.priority.toUpperCase()} 122 + </span> 123 + <span class="status-badge" style="background-color: ${statusColor}"> 124 + ${task.status.replace('_', ' ').toUpperCase()} 125 + </span> 126 + </div> 196 127 </div> 197 - 198 - <div style="display: flex; justify-content: space-between; align-items: center; font-size: 14px; color: #6b7280;"> 199 - <span>Assigned to: ${this.escapeHtml(task.assignee)}</span> 200 - <span>Due: ${this.formatDate(task.deadline)}</span> 128 + <div class="task-description"> 129 + ${this.escapeHtml(task.description)} 201 130 </div> 202 - 203 - <div style="display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px;"> 131 + <div class="task-meta"> 132 + <div class="task-assignee"> 133 + <strong>Assignee:</strong> ${this.escapeHtml(task.assignee)} 134 + </div> 135 + <div class="task-deadline"> 136 + <strong>Deadline:</strong> ${this.formatDate(task.deadline)} 137 + </div> 138 + </div> 139 + <div class="task-actions"> 204 140 ${transitionButtons} 205 141 </div> 206 142 </div> ··· 224 160 225 161 render(): string { 226 162 const filteredTasks = this.getFilteredTasks(); 227 - 228 - const gridStyle = ` 229 - display: grid; 230 - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); 231 - gap: 20px; 232 - padding: 20px; 233 - max-width: 1200px; 234 - margin: 0 auto; 235 - `; 236 - 237 - const emptyStateStyle = ` 238 - text-align: center; 239 - padding: 40px; 240 - color: #6b7280; 241 - font-size: 16px; 242 - `; 243 - 163 + 244 164 if (filteredTasks.length === 0) { 245 165 return ` 246 - <div style="${emptyStateStyle}"> 247 - No tasks match the current filters. 166 + <div class="task-list-empty"> 167 + <p>No tasks match the current filters.</p> 248 168 </div> 249 169 `; 250 170 } 251 171 252 - const taskCards = filteredTasks.map(task => this.renderTaskCard(task)).join(''); 172 + const taskCards = filteredTasks.map(task => this.generateTaskCard(task)).join(''); 253 173 254 174 return ` 255 - <div style="${gridStyle}"> 256 - ${taskCards} 257 - </div> 258 - <script> 259 - window.taskListDisplay = { 260 - handleStatusChange: (taskId, newStatus) => { 261 - if (window.taskListDisplayInstance?.options.onStatusChange) { 262 - window.taskListDisplayInstance.options.onStatusChange(taskId, newStatus); 175 + <div class="task-list-display"> 176 + <style> 177 + .task-list-display { 178 + display: grid; 179 + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); 180 + gap: 1rem; 181 + padding: 1rem; 182 + } 183 + 184 + .task-card { 185 + background: white; 186 + border: 1px solid #e5e7eb; 187 + border-radius: 8px; 188 + padding: 1rem; 189 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 190 + transition: box-shadow 0.2s; 191 + } 192 + 193 + .task-card:hover { 194 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 195 + } 196 + 197 + .task-card.overdue { 198 + border-color: #dc2626; 199 + border-width: 2px; 200 + } 201 + 202 + .overdue-indicator { 203 + background: #dc2626; 204 + color: white; 205 + padding: 0.25rem 0.5rem; 206 + border-radius: 4px; 207 + font-size: 0.75rem; 208 + font-weight: bold; 209 + margin-bottom: 0.5rem; 210 + display: inline-block; 211 + } 212 + 213 + .task-header { 214 + display: flex; 215 + justify-content: space-between; 216 + align-items: flex-start; 217 + margin-bottom: 0.75rem; 218 + } 219 + 220 + .task-title { 221 + margin: 0; 222 + font-size: 1.125rem; 223 + font-weight: 600; 224 + color: #111827; 225 + flex: 1; 226 + margin-right: 0.5rem; 227 + } 228 + 229 + .task-badges { 230 + display: flex; 231 + gap: 0.5rem; 232 + flex-shrink: 0; 233 + } 234 + 235 + .priority-badge, 236 + .status-badge { 237 + color: white; 238 + padding: 0.25rem 0.5rem; 239 + border-radius: 4px; 240 + font-size: 0.75rem; 241 + font-weight: 600; 242 + text-transform: uppercase; 243 + } 244 + 245 + .task-description { 246 + color: #6b7280; 247 + margin-bottom: 1rem; 248 + line-height: 1.5; 249 + } 250 + 251 + .task-meta { 252 + margin-bottom: 1rem; 253 + font-size: 0.875rem; 254 + } 255 + 256 + .task-assignee, 257 + .task-deadline { 258 + margin-bottom: 0.25rem; 259 + } 260 + 261 + .task-actions { 262 + display: flex; 263 + gap: 0.5rem; 264 + flex-wrap: wrap; 265 + } 266 + 267 + .status-transition-btn { 268 + background: #3b82f6; 269 + color: white; 270 + border: none; 271 + padding: 0.5rem 0.75rem; 272 + border-radius: 4px; 273 + font-size: 0.75rem; 274 + font-weight: 500; 275 + cursor: pointer; 276 + transition: background-color 0.2s; 277 + } 278 + 279 + .status-transition-btn:hover { 280 + background: #2563eb; 281 + } 282 + 283 + .task-list-empty { 284 + grid-column: 1 / -1; 285 + text-align: center; 286 + padding: 2rem; 287 + color: #6b7280; 288 + } 289 + 290 + @media (max-width: 768px) { 291 + .task-list-display { 292 + grid-template-columns: 1fr; 293 + padding: 0.5rem; 294 + } 295 + 296 + .task-header { 297 + flex-direction: column; 298 + align-items: flex-start; 263 299 } 264 - }, 265 - handleTaskClick: (taskId) => { 266 - if (window.taskListDisplayInstance?.options.onTaskClick) { 267 - const task = window.taskListDisplayInstance.tasks.find(t => t.id === taskId); 268 - if (task) { 269 - window.taskListDisplayInstance.options.onTaskClick(task); 270 - } 300 + 301 + .task-badges { 302 + margin-top: 0.5rem; 271 303 } 272 304 } 273 - }; 274 - </script> 305 + </style> 306 + ${taskCards} 307 + </div> 275 308 `; 276 309 } 277 310 278 - attachToGlobal(): void { 279 - (globalThis as any).taskListDisplayInstance = this; 311 + attachEventListeners(container: HTMLElement): void { 312 + container.addEventListener('click', (event) => { 313 + const target = event.target as HTMLElement; 314 + 315 + if (target.classList.contains('status-transition-btn')) { 316 + const taskId = target.getAttribute('data-task-id'); 317 + const newStatus = target.getAttribute('data-new-status') as Task['status']; 318 + 319 + if (taskId && newStatus && this.options.onStatusChange) { 320 + this.options.onStatusChange(taskId, newStatus); 321 + } 322 + } else if (target.closest('.task-card')) { 323 + const taskCard = target.closest('.task-card') as HTMLElement; 324 + const taskId = taskCard.getAttribute('data-task-id'); 325 + const task = this.tasks.find(t => t.id === taskId); 326 + 327 + if (task && this.options.onTaskClick) { 328 + this.options.onTaskClick(task); 329 + } 330 + } 331 + }); 280 332 } 281 333 } 282 334 283 335 export function createTaskListDisplay(options?: TaskListDisplayOptions): TaskListDisplay { 284 - const display = new TaskListDisplay(options); 285 - display.attachToGlobal(); 286 - return display; 287 - } 288 - 289 - export function renderTaskFilter( 290 - availableStatuses: Task['status'][], 291 - availablePriorities: Task['priority'][], 292 - availableAssignees: string[], 293 - currentFilter: TaskFilter, 294 - onFilterChange: (filter: TaskFilter) => void 295 - ): string { 296 - const filterStyle = ` 297 - display: flex; 298 - gap: 16px; 299 - padding: 16px; 300 - background: #f9fafb; 301 - border-radius: 8px; 302 - margin-bottom: 20px; 303 - flex-wrap: wrap; 304 - `; 305 - 306 - const selectStyle = ` 307 - padding: 8px 12px; 308 - border: 1px solid #d1d5db; 309 - border-radius: 4px; 310 - background: white; 311 - font-size: 14px; 312 - `; 313 - 314 - const labelStyle = ` 315 - font-weight: 500; 316 - color: #374151; 317 - margin-bottom: 4px; 318 - display: block; 319 - `; 320 - 321 - return ` 322 - <div style="${filterStyle}"> 323 - <div> 324 - <label style="${labelStyle}">Status:</label> 325 - <select 326 - style="${selectStyle}" 327 - multiple 328 - onchange="window.updateFilter('status', Array.from(this.selectedOptions).map(o => o.value))" 329 - > 330 - ${availableStatuses.map(status => ` 331 - <option value="${status}" ${currentFilter.status?.includes(status) ? 'selected' : ''}> 332 - ${status.replace('_', ' ').toUpperCase()} 333 - </option> 334 - `).join('')} 335 - </select> 336 - </div> 337 - 338 - <div> 339 - <label style="${labelStyle}">Priority:</label> 340 - <select 341 - style="${selectStyle}" 342 - multiple 343 - onchange="window.updateFilter('priority', Array.from(this.selectedOptions).map(o => o.value))" 344 - > 345 - ${availablePriorities.map(priority => ` 346 - <option value="${priority}" ${currentFilter.priority?.includes(priority) ? 'selected' : ''}> 347 - ${priority.toUpperCase()} 348 - </option> 349 - `).join('')} 350 - </select> 351 - </div> 352 - 353 - <div> 354 - <label style="${labelStyle}">Assignee:</label> 355 - <select 356 - style="${selectStyle}" 357 - multiple 358 - onchange="window.updateFilter('assignee', Array.from(this.selectedOptions).map(o => o.value))" 359 - > 360 - ${availableAssignees.map(assignee => ` 361 - <option value="${assignee}" ${currentFilter.assignee?.includes(assignee) ? 'selected' : ''}> 362 - ${assignee} 363 - </option> 364 - `).join('')} 365 - </select> 366 - </div> 367 - 368 - <button 369 - style="${selectStyle}; cursor: pointer; background: #ef4444; color: white; border-color: #dc2626;" 370 - onclick="window.clearFilters()" 371 - > 372 - Clear Filters 373 - </button> 374 - </div> 375 - 376 - <script> 377 - window.updateFilter = (type, values) => { 378 - const newFilter = { ...window.currentTaskFilter }; 379 - if (values.length === 0) { 380 - delete newFilter[type]; 381 - } else { 382 - newFilter[type] = values; 383 - } 384 - window.currentTaskFilter = newFilter; 385 - if (window.onTaskFilterChange) { 386 - window.onTaskFilterChange(newFilter); 387 - } 388 - }; 389 - 390 - window.clearFilters = () => { 391 - window.currentTaskFilter = {}; 392 - if (window.onTaskFilterChange) { 393 - window.onTaskFilterChange({}); 394 - } 395 - // Reset all selects 396 - document.querySelectorAll('select').forEach(select => { 397 - select.selectedIndex = -1; 398 - }); 399 - }; 400 - 401 - window.currentTaskFilter = ${JSON.stringify(currentFilter)}; 402 - window.onTaskFilterChange = ${onFilterChange.toString()}; 403 - </script> 404 - `; 336 + return new TaskListDisplay(options); 405 337 } 406 338 407 339 /** @internal Phoenix VCS traceability — do not remove. */
+21
src/manifest.ts
··· 28 28 29 29 /** 30 30 * Record a single IU's generated files into the manifest. 31 + * Evicts stale entries: if another IU previously owned the same 32 + * output file paths, the old entry is removed (handles IU ID changes 33 + * after re-canonicalization). 31 34 */ 32 35 recordIU(iuManifest: IUManifest): void { 33 36 const manifest = this.load(); 37 + this.evictStaleEntries(manifest, iuManifest); 34 38 manifest.iu_manifests[iuManifest.iu_id] = iuManifest; 35 39 manifest.generated_at = new Date().toISOString(); 36 40 this.save(manifest); ··· 42 46 recordAll(iuManifests: IUManifest[]): void { 43 47 const manifest = this.load(); 44 48 for (const m of iuManifests) { 49 + this.evictStaleEntries(manifest, m); 45 50 manifest.iu_manifests[m.iu_id] = m; 46 51 } 47 52 manifest.generated_at = new Date().toISOString(); 48 53 this.save(manifest); 54 + } 55 + 56 + /** 57 + * Remove old IU manifest entries that own the same file paths 58 + * as a new entry (but with a different IU ID). 59 + */ 60 + private evictStaleEntries(manifest: GeneratedManifest, incoming: IUManifest): void { 61 + const incomingFiles = new Set(Object.keys(incoming.files)); 62 + for (const [existingId, existing] of Object.entries(manifest.iu_manifests)) { 63 + if (existingId === incoming.iu_id) continue; 64 + const existingFiles = Object.keys(existing.files); 65 + const overlaps = existingFiles.some(f => incomingFiles.has(f)); 66 + if (overlaps) { 67 + delete manifest.iu_manifests[existingId]; 68 + } 69 + } 49 70 } 50 71 51 72 /**