From f42175b3fe6480355b56698e01ec403ee6728f92 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 04:09:07 +0800 Subject: [PATCH] Improve sharing visibility: member count on cards, task assignment toast - Add member_count to ProjectResponse via model_validator (computed from eagerly loaded members relationship). Shows on ProjectCard for both owners ("2 members") and shared users ("Shared with you"). - Fix share button badge positioning (add relative class). - Add dedicated showTaskAssignedToast with blue ClipboardList icon, "View Project" action button, and 15s duration. - Wire task_assigned into both initial-load and new-notification toast dispatch flows in NotificationToaster. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/schemas/project.py | 27 ++++++++- .../notifications/NotificationToaster.tsx | 56 ++++++++++++++++++- .../src/components/projects/ProjectCard.tsx | 9 ++- .../src/components/projects/ProjectDetail.tsx | 2 +- frontend/src/types/index.ts | 1 + 5 files changed, 89 insertions(+), 6 deletions(-) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 74a0bc0..8e03e36 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from datetime import datetime, date from typing import Optional, List, Literal from app.schemas.project_task import ProjectTaskResponse @@ -37,12 +37,37 @@ class ProjectResponse(BaseModel): color: Optional[str] due_date: Optional[date] is_tracked: bool + member_count: int = 0 created_at: datetime updated_at: datetime tasks: List[ProjectTaskResponse] = [] model_config = ConfigDict(from_attributes=True) + @model_validator(mode="before") + @classmethod + def compute_member_count(cls, data): # type: ignore[override] + """Compute member_count from eagerly loaded members relationship.""" + if hasattr(data, "members"): + try: + data = dict( + id=data.id, + user_id=data.user_id, + name=data.name, + description=data.description, + status=data.status, + color=data.color, + due_date=data.due_date, + is_tracked=data.is_tracked, + member_count=len([m for m in data.members if m.status == "accepted"]), + created_at=data.created_at, + updated_at=data.updated_at, + tasks=data.tasks, + ) + except Exception: + pass # If members aren't loaded, default to 0 + return data + class TrackedTaskResponse(BaseModel): id: int diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx index 9396200..2062ffd 100644 --- a/frontend/src/components/notifications/NotificationToaster.tsx +++ b/frontend/src/components/notifications/NotificationToaster.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useCallback } from 'react'; import { toast } from 'sonner'; -import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban } from 'lucide-react'; +import { Check, X, Bell, UserPlus, Calendar, Clock, FolderKanban, ClipboardList } from 'lucide-react'; import { useQueryClient } from '@tanstack/react-query'; import { useNotifications } from '@/hooks/useNotifications'; import { useConnections } from '@/hooks/useConnections'; @@ -173,7 +173,7 @@ export default function NotificationToaster() { initializedRef.current = true; // Toast actionable unread notifications on login so the user can act immediately - const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite', 'project_invite']); + const actionableTypes = new Set(['connection_request', 'calendar_invite', 'event_invite', 'project_invite', 'task_assigned']); const actionable = notifications.filter( (n) => !n.is_read && actionableTypes.has(n.type), ); @@ -189,6 +189,8 @@ export default function NotificationToaster() { showEventInviteToast(notification); } else if (notification.type === 'project_invite' && notification.data) { showProjectInviteToast(notification); + } else if (notification.type === 'task_assigned' && notification.data) { + showTaskAssignedToast(notification); } }); return; @@ -231,6 +233,8 @@ export default function NotificationToaster() { showEventInviteToast(notification); } else if (notification.type === 'project_invite' && notification.data) { showProjectInviteToast(notification); + } else if (notification.type === 'task_assigned' && notification.data) { + showTaskAssignedToast(notification); } else { toast(notification.title || 'New Notification', { description: notification.message || undefined, @@ -399,6 +403,54 @@ export default function NotificationToaster() { ); }; + const showTaskAssignedToast = (notification: AppNotification) => { + const data = notification.data as Record | undefined; + const projectId = data?.project_id as number | undefined; + const toastKey = `task-assigned-${notification.id}`; + + toast.custom( + (id) => ( +
+
+
+ +
+
+

Task Assigned

+

+ {notification.message || 'You were assigned to a task'} +

+
+ {projectId && ( + + )} + +
+
+
+
+ ), + { id: toastKey, duration: 15000 }, + ); + }; + const showProjectInviteToast = (notification: AppNotification) => { const data = notification.data as Record | undefined; const projectId = data?.project_id as number | undefined; diff --git a/frontend/src/components/projects/ProjectCard.tsx b/frontend/src/components/projects/ProjectCard.tsx index dc48aca..6ab3021 100644 --- a/frontend/src/components/projects/ProjectCard.tsx +++ b/frontend/src/components/projects/ProjectCard.tsx @@ -100,10 +100,15 @@ export default function ProjectCard({ project }: ProjectCardProps) { Due {format(parseISO(project.due_date), 'MMM d, yyyy')} )} - {isShared && ( + {/* Sharing indicator — shows for both owner and shared users */} + {project.member_count > 0 && (
- Shared with you + + {isShared + ? 'Shared with you' + : `${project.member_count} member${project.member_count !== 1 ? 's' : ''}`} +
)} diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index a9fe578..bf37fa7 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -443,7 +443,7 @@ export default function ProjectDetail() {