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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user