feat(02-05): ColorLegend, AppNav, ViewToolbar, SkeletonCalendar, EmptyState; retire EventProof

- ColorLegend: per-member color swatches (12px circle, label) + always-visible Family row
- AppNav: phone 48px top bar (avatar with aria-label/title) + tablet/desktop 240px sidebar with ColorLegend
- ViewToolbar: Today/prev/next + Day/Week/Month/Agenda view switcher; 44px min-height; active state uses surface tint not accent
- SkeletonCalendar: shimmer month (6x7 grid) and agenda (4 date-group blocks) variants; aria-busy=true
- EmptyState: CalendarDays icon + 'Nothing here' heading + body copy per UI-SPEC
- CalendarShell: full phone/desktop layout with AppNav + ViewToolbar + ColorLegend chrome
- CalendarShell: state branches — loading→SkeletonCalendar, empty→EmptyState, error→'Couldn't load events' + Retry button (refetchQueries)
- EventProof.tsx deleted; legacy types removed from client.ts
- CalendarShell.test.tsx: updated to waitFor ScheduleXCalendar after data loads
- All 36 tests pass, tsc clean, vite build clean (490kB)
This commit is contained in:
Lucas Berger
2026-06-05 10:58:06 -04:00
parent 3eebfbff42
commit 216ddcedf4
9 changed files with 840 additions and 230 deletions
@@ -0,0 +1,133 @@
/**
* SkeletonCalendar — shimmer loading placeholder.
*
* UI-SPEC §SkeletonCalendar:
* - Month variant: 6×7 grid of rounded rect placeholders, animated shimmer
* - Agenda variant: 4 date-group blocks, 23 rows each, varying widths (6090%)
* - No spinner; shimmer only (matches Fantastical-style)
* - aria-busy="true", aria-label="Loading calendar" on root
*
* Shimmer keyframe declared in tokens.css (@keyframes shimmer).
* The shimmer animation uses the gradient from tokens.css:
* background: linear-gradient(90deg, --color-surface-dim, --color-border-subtle, --color-surface-dim)
*/
type SkeletonVariant = 'month' | 'agenda'
interface SkeletonCalendarProps {
variant?: SkeletonVariant
}
/** Shimmer inline style — gradient + animation referencing the keyframe in tokens.css */
const shimmerStyle: React.CSSProperties = {
background:
'linear-gradient(90deg, var(--color-surface-dim), var(--color-border-subtle), var(--color-surface-dim))',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
borderRadius: 'var(--space-1)',
}
export function SkeletonCalendar({ variant = 'month' }: SkeletonCalendarProps) {
return (
<div
aria-busy="true"
aria-label="Loading calendar"
style={{
padding: 'var(--space-4)',
fontFamily: 'var(--font-family-base)',
width: '100%',
}}
>
{variant === 'month' ? <MonthSkeleton /> : <AgendaSkeleton />}
</div>
)
}
/** 6×7 month grid of shimmer cells */
function MonthSkeleton() {
return (
<div>
{/* Day-of-week header row */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 'var(--space-1)',
marginBottom: 'var(--space-2)',
}}
>
{Array.from({ length: 7 }).map((_, i) => (
<div
key={i}
style={{
...shimmerStyle,
height: '16px',
opacity: 0.5,
}}
/>
))}
</div>
{/* 6 rows × 7 columns */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 'var(--space-1)',
}}
>
{Array.from({ length: 42 }).map((_, i) => (
<div
key={i}
style={{
height: '80px',
...shimmerStyle,
}}
/>
))}
</div>
</div>
)
}
/** 4 date-group blocks, 23 event rows each, varying widths */
function AgendaSkeleton() {
// Each group has: a date header + 23 event rows with varying widths
const groups = [
{ rows: [80, 65, 90] },
{ rows: [75, 60] },
{ rows: [85, 70, 65] },
{ rows: [60, 80] },
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
{groups.map((group, gi) => (
<div key={gi}>
{/* Date group header placeholder */}
<div
style={{
...shimmerStyle,
height: '20px',
width: '40%',
marginBottom: 'var(--space-3)',
}}
/>
{/* Event rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{group.rows.map((widthPct, ri) => (
<div
key={ri}
style={{
...shimmerStyle,
height: '44px',
width: `${widthPct}%`,
}}
/>
))}
</div>
</div>
))}
</div>
)
}