feat: enhance weekly report and realtime notifications

Weekly Report (fix-weekly-report):
- Remove 5-task limit, show all tasks per category
- Add blocked tasks with blocker_reason and blocked_since
- Add next week tasks (due in coming week)
- Add assignee_name, completed_at, days_overdue to task details
- Frontend collapsible sections for each task category
- 8 new tests for enhanced report content

Realtime Notifications (fix-realtime-notifications):
- SQLAlchemy event-based notification publishing
- Redis Pub/Sub for multi-process broadcast
- Fix soft rollback handler stacking issue
- Fix ping scheduling drift (send immediately when interval expires)
- Frontend NotificationContext with WebSocket reconnection

Spec Fixes:
- Add missing ## Purpose sections to 5 specs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-12-30 20:52:08 +08:00
parent 10db2c9d1f
commit 64874d5425
25 changed files with 1034 additions and 140 deletions

View File

@@ -1,5 +1,145 @@
import { useState, useEffect } from 'react'
import { reportsApi, WeeklyReportContent } from '../services/reports'
import { reportsApi, WeeklyReportContent, ProjectSummary } from '../services/reports'
interface CollapsibleSectionProps {
title: string
count: number
colorClass: string
defaultOpen?: boolean
children: React.ReactNode
}
function CollapsibleSection({ title, count, colorClass, defaultOpen = false, children }: CollapsibleSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen)
if (count === 0) return null
return (
<div className="mt-3">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-between w-full text-left text-sm font-medium hover:bg-gray-50 p-2 rounded"
>
<span className={colorClass}>
{title} ({count})
</span>
<span className="text-gray-400">{isOpen ? '▼' : '▶'}</span>
</button>
{isOpen && <div className="pl-2 mt-1 space-y-1">{children}</div>}
</div>
)
}
interface TaskItemProps {
title: string
subtitle?: string
highlight?: 'overdue' | 'blocked'
}
function TaskItem({ title, subtitle, highlight }: TaskItemProps) {
const bgClass = highlight === 'overdue'
? 'bg-red-50 border-l-2 border-red-400'
: highlight === 'blocked'
? 'bg-orange-50 border-l-2 border-orange-400'
: 'bg-gray-50'
return (
<div className={`p-2 rounded text-sm ${bgClass}`}>
<div className="font-medium text-gray-800">{title}</div>
{subtitle && <div className="text-xs text-gray-500 mt-0.5">{subtitle}</div>}
</div>
)
}
function ProjectCard({ project }: { project: ProjectSummary }) {
const formatDate = (dateStr: string | null) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' })
}
return (
<div className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h5 className="font-medium">{project.project_title}</h5>
<span className="text-sm text-gray-500">
{project.completed_count}/{project.total_tasks} completed
</span>
</div>
{/* Summary row */}
<div className="flex flex-wrap gap-3 text-sm mb-2">
<span className="text-green-600">{project.completed_count} done</span>
<span className="text-blue-600">{project.in_progress_count} in progress</span>
{project.overdue_count > 0 && (
<span className="text-red-600">{project.overdue_count} overdue</span>
)}
{project.blocked_count > 0 && (
<span className="text-orange-600">{project.blocked_count} blocked</span>
)}
{project.next_week_count > 0 && (
<span className="text-purple-600">{project.next_week_count} next week</span>
)}
</div>
{/* Completed Tasks */}
<CollapsibleSection title="Completed" count={project.completed_count} colorClass="text-green-700">
{project.completed_tasks.map(task => (
<TaskItem
key={task.id}
title={task.title}
subtitle={`${task.assignee_name || 'Unassigned'}${formatDate(task.completed_at)}`}
/>
))}
</CollapsibleSection>
{/* In Progress Tasks */}
<CollapsibleSection title="In Progress" count={project.in_progress_count} colorClass="text-blue-700">
{project.in_progress_tasks.map(task => (
<TaskItem
key={task.id}
title={task.title}
subtitle={`${task.assignee_name || 'Unassigned'}${task.due_date ? ` • Due ${formatDate(task.due_date)}` : ''}`}
/>
))}
</CollapsibleSection>
{/* Overdue Tasks */}
<CollapsibleSection title="Overdue" count={project.overdue_count} colorClass="text-red-700" defaultOpen>
{project.overdue_tasks.map(task => (
<TaskItem
key={task.id}
title={task.title}
subtitle={`${task.assignee_name || 'Unassigned'}${task.days_overdue} days overdue`}
highlight="overdue"
/>
))}
</CollapsibleSection>
{/* Blocked Tasks */}
<CollapsibleSection title="Blocked" count={project.blocked_count} colorClass="text-orange-700" defaultOpen>
{project.blocked_tasks.map(task => (
<TaskItem
key={task.id}
title={task.title}
subtitle={`${task.blocker_reason || 'No reason provided'}${task.blocked_since ? ` • Since ${formatDate(task.blocked_since)}` : ''}`}
highlight="blocked"
/>
))}
</CollapsibleSection>
{/* Next Week Tasks */}
<CollapsibleSection title="Next Week" count={project.next_week_count} colorClass="text-purple-700">
{project.next_week_tasks.map(task => (
<TaskItem
key={task.id}
title={task.title}
subtitle={`${task.assignee_name || 'Unassigned'} • Due ${formatDate(task.due_date)}`}
/>
))}
</CollapsibleSection>
</div>
)
}
export function WeeklyReportPreview() {
const [report, setReport] = useState<WeeklyReportContent | null>(null)
@@ -82,22 +222,30 @@ export function WeeklyReportPreview() {
</div>
{/* Summary Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-3xl font-bold text-green-600">{report.summary.completed_count}</p>
<p className="text-sm text-green-800">Completed</p>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-2xl font-bold text-green-600">{report.summary.completed_count}</p>
<p className="text-xs text-green-800">Completed</p>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-3xl font-bold text-blue-600">{report.summary.in_progress_count}</p>
<p className="text-sm text-blue-800">In Progress</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-2xl font-bold text-blue-600">{report.summary.in_progress_count}</p>
<p className="text-xs text-blue-800">In Progress</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-3xl font-bold text-red-600">{report.summary.overdue_count}</p>
<p className="text-sm text-red-800">Overdue</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-2xl font-bold text-red-600">{report.summary.overdue_count}</p>
<p className="text-xs text-red-800">Overdue</p>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-3xl font-bold text-gray-600">{report.summary.total_tasks}</p>
<p className="text-sm text-gray-800">Total Tasks</p>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<p className="text-2xl font-bold text-orange-600">{report.summary.blocked_count}</p>
<p className="text-xs text-orange-800">Blocked</p>
</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3">
<p className="text-2xl font-bold text-purple-600">{report.summary.next_week_count}</p>
<p className="text-xs text-purple-800">Next Week</p>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<p className="text-2xl font-bold text-gray-600">{report.summary.total_tasks}</p>
<p className="text-xs text-gray-800">Total</p>
</div>
</div>
@@ -106,21 +254,7 @@ export function WeeklyReportPreview() {
<div className="space-y-3">
<h4 className="font-medium">Projects</h4>
{report.projects.map(project => (
<div key={project.project_id} className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h5 className="font-medium">{project.project_title}</h5>
<span className="text-sm text-gray-500">
{project.completed_count}/{project.total_tasks} completed
</span>
</div>
<div className="flex gap-4 text-sm">
<span className="text-green-600">{project.completed_count} done</span>
<span className="text-blue-600">{project.in_progress_count} in progress</span>
{project.overdue_count > 0 && (
<span className="text-red-600">{project.overdue_count} overdue</span>
)}
</div>
</div>
<ProjectCard key={project.project_id} project={project} />
))}
</div>
) : (

View File

@@ -23,8 +23,8 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const refreshUnreadCount = useCallback(async () => {
try {
@@ -79,8 +79,15 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
const token = localStorage.getItem('token')
if (!token) return
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${wsProtocol}//${window.location.host}/ws/notifications?token=${token}`
// Use env var if available, otherwise derive from current location
let wsUrl: string
const envWsUrl = import.meta.env.VITE_WS_URL
if (envWsUrl) {
wsUrl = `${envWsUrl}/ws/notifications?token=${token}`
} else {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl = `${wsProtocol}//${window.location.host}/ws/notifications?token=${token}`
}
try {
const ws = new WebSocket(wsUrl)
@@ -101,6 +108,32 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
const message = JSON.parse(event.data)
switch (message.type) {
case 'connected':
console.log('WebSocket authenticated:', message.data.message)
break
case 'unread_sync':
// Merge unread notifications without removing already-loaded notifications
setNotifications(prev => {
const unreadNotifications = message.data.notifications || []
const existingIds = new Set(prev.map(n => n.id))
// Add new unread notifications that don't exist in current list
const newNotifications = unreadNotifications.filter(
(n: Notification) => !existingIds.has(n.id)
)
// Update existing unread notifications and prepend new ones
const updated = prev.map(existing => {
const fromSync = unreadNotifications.find((n: Notification) => n.id === existing.id)
return fromSync || existing
})
return [...newNotifications, ...updated]
})
setUnreadCount(message.data.unread_count || 0)
break
case 'notification':
// Add new notification to the top
setNotifications(prev => [message.data, ...prev])
@@ -111,6 +144,13 @@ export function NotificationProvider({ children }: { children: ReactNode }) {
setUnreadCount(message.data.unread_count)
break
case 'ping':
// Server ping - respond with pong
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'pong' }))
}
break
case 'pong':
// Pong received, connection is alive
break

View File

@@ -4,18 +4,62 @@ export interface ReportSummary {
completed_count: number
in_progress_count: number
overdue_count: number
blocked_count: number
next_week_count: number
total_tasks: number
}
export interface CompletedTask {
id: string
title: string
completed_at: string | null
assignee_name: string | null
}
export interface InProgressTask {
id: string
title: string
assignee_name: string | null
due_date: string | null
}
export interface OverdueTask {
id: string
title: string
due_date: string | null
days_overdue: number
assignee_name: string | null
}
export interface BlockedTask {
id: string
title: string
blocker_reason: string | null
blocked_since: string | null
assignee_name: string | null
}
export interface NextWeekTask {
id: string
title: string
due_date: string | null
assignee_name: string | null
}
export interface ProjectSummary {
project_id: string
project_title: string
completed_count: number
in_progress_count: number
overdue_count: number
blocked_count: number
next_week_count: number
total_tasks: number
completed_tasks: Array<{ id: string; title: string }>
overdue_tasks: Array<{ id: string; title: string; due_date: string | null }>
completed_tasks: CompletedTask[]
in_progress_tasks: InProgressTask[]
overdue_tasks: OverdueTask[]
blocked_tasks: BlockedTask[]
next_week_tasks: NextWeekTask[]
}
export interface WeeklyReportContent {