Phase 5: Shared calendar polish — scoped polling, admin stats, dual panel fix, edge case handling

- Scope shared calendar polling to CalendarPage only (other consumers no longer poll)
- Add admin sharing stats card (owned/member/invites sent/received) in UserDetailSection
- Fix dual EventDetailPanel mount via JS media query breakpoint (replaces CSS hidden)
- Auto-close panel + toast when shared calendar is removed while viewing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-06 15:38:52 +08:00
parent f45b7a2115
commit 14fc085009
4 changed files with 80 additions and 23 deletions

View File

@ -1,10 +1,10 @@
import { X, User, ShieldCheck, Loader2 } from 'lucide-react';
import { X, User, ShieldCheck, Share2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
import { useAdminUserDetail, useAdminSharingStats, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
import { getRelativeTime } from '@/lib/date-utils';
import { cn } from '@/lib/utils';
import type { UserRole } from '@/types';
@ -57,6 +57,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
const { data: user, isLoading, error } = useAdminUserDetail(userId);
const updateRole = useUpdateRole();
const { data: sharingStats } = useAdminSharingStats(userId);
const handleRoleChange = async (newRole: UserRole) => {
if (!user || newRole === user.role) return;
@ -218,6 +219,24 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
/>
</CardContent>
</Card>
{/* Sharing Stats */}
<Card className="col-span-1">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<Share2 className="h-3.5 w-3.5 text-accent" />
</div>
<CardTitle className="text-sm">Sharing</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-0.5">
<DetailRow label="Calendars Shared" value={String(sharingStats?.shared_calendars_owned ?? 0)} />
<DetailRow label="Member Of" value={String(sharingStats?.calendars_member_of ?? 0)} />
<DetailRow label="Invites Sent" value={String(sharingStats?.pending_invites_sent ?? 0)} />
<DetailRow label="Invites Received" value={String(sharingStats?.pending_invites_received ?? 0)} />
</CardContent>
</Card>
</div>
);
}

View File

@ -42,7 +42,7 @@ export default function CalendarPage() {
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
const { settings } = useSettings();
const { data: calendars = [], sharedData } = useCalendars();
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
const calendarContainerRef = useRef<HTMLDivElement>(null);
@ -99,6 +99,15 @@ export default function CalendarPage() {
const panelOpen = panelMode !== 'closed';
// Track desktop breakpoint to prevent dual EventDetailPanel mount
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches);
useEffect(() => {
const mql = window.matchMedia('(min-width: 1024px)');
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
// Continuously resize calendar during panel open/close CSS transition
useEffect(() => {
let rafId: number;
@ -150,6 +159,15 @@ export default function CalendarPage() {
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
const selectedEventIsShared = selectedEvent ? permissionMap.has(selectedEvent.calendar_id) && permissionMap.get(selectedEvent.calendar_id) !== 'owner' : false;
// Close panel if shared calendar was removed while viewing
useEffect(() => {
if (!selectedEvent || allCalendarIds.size === 0) return;
if (!allCalendarIds.has(selectedEvent.calendar_id)) {
handlePanelClose();
toast.info('This calendar is no longer available');
}
}, [allCalendarIds, selectedEvent]);
// Escape key closes detail panel
useEffect(() => {
if (!panelOpen) return;
@ -497,11 +515,8 @@ export default function CalendarPage() {
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
{panelOpen && isDesktop && (
<div className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]">
<EventDetailPanel
event={panelMode === 'view' ? selectedEvent : null}
isCreating={panelMode === 'create'}
@ -513,13 +528,14 @@ export default function CalendarPage() {
isSharedEvent={selectedEventIsShared}
/>
</div>
)}
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
{panelOpen && !isDesktop && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div

View File

@ -61,6 +61,24 @@ export function useAdminUserDetail(userId: number | null) {
});
}
interface SharingStats {
shared_calendars_owned: number;
calendars_member_of: number;
pending_invites_sent: number;
pending_invites_received: number;
}
export function useAdminSharingStats(userId: number | null) {
return useQuery<SharingStats>({
queryKey: ['admin', 'users', userId, 'sharing-stats'],
queryFn: async () => {
const { data } = await api.get<SharingStats>(`/admin/users/${userId}/sharing-stats`);
return data;
},
enabled: userId !== null,
});
}
export function useAdminDashboard() {
return useQuery<AdminDashboardData>({
queryKey: ['admin', 'dashboard'],

View File

@ -3,7 +3,11 @@ import { useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import type { Calendar, SharedCalendarMembership } from '@/types';
export function useCalendars() {
interface UseCalendarsOptions {
pollingEnabled?: boolean;
}
export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {}) {
const ownedQuery = useQuery({
queryKey: ['calendars'],
queryFn: async () => {
@ -18,7 +22,7 @@ export function useCalendars() {
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
return data;
},
refetchInterval: 5_000,
refetchInterval: pollingEnabled ? 5_000 : false,
staleTime: 3_000,
});