Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
220 lines
7.6 KiB
TypeScript
220 lines
7.6 KiB
TypeScript
/**
|
|
* SyncStateToast — polled sync-state feedback toast (Plan 03-06).
|
|
*
|
|
* Shows after every write (create, edit, delete) to surface the outbox status:
|
|
* pending → "Syncing…" spinner (role="status", refetchInterval 3000ms)
|
|
* done → "Saved" Check icon, auto-dismiss after 2s, invalidates ['events']
|
|
* failed → "Didn't save. Try again." or conflict copy, persists until dismissed
|
|
* dead → "Not saved. Check your connection.", persists until dismissed
|
|
*
|
|
* No SSE — polling only (D-09).
|
|
*
|
|
* Position: bottom of screen (bottom-center). Above FAB on phone.
|
|
*
|
|
* Accessibility:
|
|
* - role="status" for pending/done (polite live region)
|
|
* - role="alert" for failed/dead (assertive live region)
|
|
* - dismiss button: aria-label="Dismiss sync notification", 44px touch target
|
|
*
|
|
* Security: T-03-19 — fetchSyncStatus is member-scoped server-side.
|
|
* T-03-18 — failed/dead toast persists; no silent loss.
|
|
*/
|
|
|
|
import { useEffect, useRef } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Loader2, Check, AlertCircle, X } from 'lucide-react';
|
|
import { useCalendarStore } from '../store/calendarStore.js';
|
|
import { fetchSyncStatus, type SyncStatus } from '../api/client.js';
|
|
|
|
// ── Component ──────────────────────────────────────────────────────────────────
|
|
|
|
export function SyncStateToast() {
|
|
const lastSyncedUid = useCalendarStore((s) => s.lastSyncedUid);
|
|
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid);
|
|
const queryClient = useQueryClient();
|
|
|
|
// Track whether we've already invalidated for this uid to avoid duplicate calls
|
|
const invalidatedRef = useRef<string | null>(null);
|
|
|
|
const { data, isLoading } = useQuery<SyncStatus>({
|
|
queryKey: ['syncStatus', lastSyncedUid],
|
|
queryFn: () => fetchSyncStatus(lastSyncedUid!),
|
|
enabled: lastSyncedUid !== null,
|
|
// refetchInterval: active (3000ms) only while pending; disabled once terminal
|
|
refetchInterval: (query) => {
|
|
const status = query.state.data?.status;
|
|
return status === 'pending' || status === undefined ? 3000 : false;
|
|
},
|
|
staleTime: 0,
|
|
gcTime: 0,
|
|
});
|
|
|
|
const status = data?.status;
|
|
// WR-06: an edit-as-move whose create hits 412 dead-ends with no retry path (the
|
|
// original event is preserved, but the new uid's only outbox row is failed). The
|
|
// worker tags that case with a 'move-failed:' lastError so we can show distinct copy
|
|
// guiding the user to re-open and re-save, rather than the etag-conflict copy.
|
|
const isMoveFailed = status === 'failed' && !!data?.error?.startsWith('move-failed');
|
|
const isConflict = status === 'failed' && !isMoveFailed && data?.error?.includes('412');
|
|
const isPersistent = status === 'failed' || status === 'dead';
|
|
|
|
// Invalidate events on done OR on conflict (D-06/D-08)
|
|
useEffect(() => {
|
|
if (!lastSyncedUid) return;
|
|
if (invalidatedRef.current === lastSyncedUid) return;
|
|
|
|
if (status === 'done' || isConflict) {
|
|
invalidatedRef.current = lastSyncedUid;
|
|
void queryClient.invalidateQueries({ queryKey: ['events'] });
|
|
}
|
|
}, [status, isConflict, lastSyncedUid, queryClient]);
|
|
|
|
// Auto-dismiss after 2s on done
|
|
useEffect(() => {
|
|
if (status !== 'done') return;
|
|
const timer = setTimeout(() => {
|
|
setLastSyncedUid(null);
|
|
}, 2000);
|
|
return () => clearTimeout(timer);
|
|
}, [status, setLastSyncedUid]);
|
|
|
|
// Reset invalidation guard when uid changes (new write)
|
|
useEffect(() => {
|
|
if (lastSyncedUid === null) {
|
|
invalidatedRef.current = null;
|
|
}
|
|
}, [lastSyncedUid]);
|
|
|
|
// Nothing to show
|
|
if (!lastSyncedUid || (!isLoading && !data)) return null;
|
|
|
|
// ── State-specific content ─────────────────────────────────────────────────
|
|
|
|
const dismiss = () => setLastSyncedUid(null);
|
|
|
|
// Determine ARIA role: status (polite) for pending/done; alert (assertive) for failed/dead
|
|
const ariaRole: 'status' | 'alert' =
|
|
status === 'failed' || status === 'dead' ? 'alert' : 'status';
|
|
|
|
// Toast copy (exact strings from UI-SPEC §Copywriting)
|
|
let copy: string;
|
|
let icon: React.ReactNode;
|
|
|
|
if (status === 'done') {
|
|
copy = 'Saved';
|
|
icon = <Check size={14} style={{ color: '#50C878', flexShrink: 0 }} aria-hidden="true" />;
|
|
} else if (isConflict) {
|
|
copy = 'This event changed elsewhere — review the latest version';
|
|
icon = (
|
|
<AlertCircle
|
|
size={14}
|
|
style={{ color: 'var(--color-destructive)', flexShrink: 0 }}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
} else if (isMoveFailed) {
|
|
// WR-06: the move could not be applied; the original event is unchanged.
|
|
copy = "Couldn't move the event. Open it and save again.";
|
|
icon = (
|
|
<AlertCircle
|
|
size={14}
|
|
style={{ color: 'var(--color-destructive)', flexShrink: 0 }}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
} else if (status === 'failed') {
|
|
copy = "Didn't save. Try again.";
|
|
icon = (
|
|
<AlertCircle
|
|
size={14}
|
|
style={{ color: 'var(--color-destructive)', flexShrink: 0 }}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
} else if (status === 'dead') {
|
|
copy = 'Not saved. Check your connection.';
|
|
icon = (
|
|
<AlertCircle
|
|
size={14}
|
|
style={{ color: 'var(--color-destructive)', flexShrink: 0 }}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
} else {
|
|
// pending (or loading)
|
|
copy = 'Syncing…';
|
|
icon = (
|
|
<Loader2
|
|
size={14}
|
|
style={{
|
|
color: 'var(--color-text-secondary)',
|
|
flexShrink: 0,
|
|
animation: 'spin 1s linear infinite',
|
|
}}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
role={ariaRole}
|
|
aria-live={ariaRole === 'alert' ? 'assertive' : 'polite'}
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: 'var(--space-12)',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
zIndex: 300,
|
|
background: 'var(--color-surface-raised)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--space-1)',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
|
padding: 'var(--space-2) var(--space-3)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-2)',
|
|
fontSize: 'var(--text-label-size)',
|
|
fontWeight: 'var(--text-label-weight)',
|
|
lineHeight: 'var(--text-label-line-height)',
|
|
fontFamily: 'var(--font-family-base)',
|
|
color:
|
|
status === 'done'
|
|
? '#50C878'
|
|
: status === 'failed' || status === 'dead'
|
|
? 'var(--color-destructive)'
|
|
: 'var(--color-text-secondary)',
|
|
whiteSpace: 'nowrap',
|
|
maxWidth: '90vw',
|
|
}}
|
|
>
|
|
{icon}
|
|
{/* Plain text — XSS guard */}
|
|
<span>{copy}</span>
|
|
{/* Dismiss button — only for persistent states (failed/dead) */}
|
|
{isPersistent && (
|
|
<button
|
|
aria-label="Dismiss sync notification"
|
|
onClick={dismiss}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
minWidth: '44px',
|
|
minHeight: '44px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'var(--color-text-secondary)',
|
|
borderRadius: 'var(--space-1)',
|
|
padding: 0,
|
|
marginLeft: 'var(--space-1)',
|
|
}}
|
|
>
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|