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) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-17 04:09:07 +08:00
parent 61e48c3f14
commit f42175b3fe
5 changed files with 89 additions and 6 deletions

View File

@ -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

View File

@ -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<string, unknown> | undefined;
const projectId = data?.project_id as number | undefined;
const toastKey = `task-assigned-${notification.id}`;
toast.custom(
(id) => (
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
<div className="flex items-start gap-3">
<div className="h-9 w-9 rounded-full bg-blue-500/15 flex items-center justify-center shrink-0">
<ClipboardList className="h-4 w-4 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Task Assigned</p>
<p className="text-xs text-muted-foreground mt-0.5">
{notification.message || 'You were assigned to a task'}
</p>
<div className="flex items-center gap-2 mt-3">
{projectId && (
<button
onClick={() => {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
window.location.href = `/projects/${projectId}`;
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
>
View Project
</button>
)}
<button
onClick={() => {
toast.dismiss(id);
markReadRef.current([notification.id]).catch(() => {});
}}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
>
Dismiss
</button>
</div>
</div>
</div>
</div>
),
{ id: toastKey, duration: 15000 },
);
};
const showProjectInviteToast = (notification: AppNotification) => {
const data = notification.data as Record<string, unknown> | undefined;
const projectId = data?.project_id as number | undefined;

View File

@ -100,10 +100,15 @@ export default function ProjectCard({ project }: ProjectCardProps) {
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
</div>
)}
{isShared && (
{/* Sharing indicator — shows for both owner and shared users */}
{project.member_count > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-2">
<Users className="h-3.5 w-3.5" />
<span>Shared with you</span>
<span>
{isShared
? 'Shared with you'
: `${project.member_count} member${project.member_count !== 1 ? 's' : ''}`}
</span>
</div>
)}
</CardContent>

View File

@ -443,7 +443,7 @@ export default function ProjectDetail() {
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
className="shrink-0 text-muted-foreground relative"
onClick={() => setShowShareSheet(true)}
title="Project members"
>

View File

@ -144,6 +144,7 @@ export interface Project {
color?: string;
due_date?: string;
is_tracked: boolean;
member_count: number;
created_at: string;
updated_at: string;
tasks: ProjectTask[];