style(13-03): apply Prettier formatting across repo
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.
This commit is contained in:
@@ -29,32 +29,32 @@
|
||||
* success + events → ScheduleXCalendar
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ScheduleXCalendar, useCalendarApp } from '@schedule-x/react';
|
||||
import {
|
||||
createViewDay,
|
||||
createViewWeek,
|
||||
createViewMonthGrid,
|
||||
createViewMonthAgenda,
|
||||
type CalendarType,
|
||||
} from '@schedule-x/calendar'
|
||||
import { createEventsServicePlugin } from '@schedule-x/events-service'
|
||||
import { Plus } from 'lucide-react'
|
||||
} from '@schedule-x/calendar';
|
||||
import { createEventsServicePlugin } from '@schedule-x/events-service';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { fetchMe, fetchEvents } from '../api/client.js'
|
||||
import { hydrateEvents } from '../lib/hydrateEvents.js'
|
||||
import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js'
|
||||
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js'
|
||||
import { useCalendarStore } from '../store/calendarStore.js'
|
||||
import { AuthSplash } from './AuthSplash.js'
|
||||
import { EventDetailPopover } from './EventDetailPopover.js'
|
||||
import { EventForm } from './EventForm.js'
|
||||
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'
|
||||
import { SyncStateToast } from './SyncStateToast.js'
|
||||
import { ColorLegend } from './ColorLegend.js'
|
||||
import { SkeletonCalendar } from './SkeletonCalendar.js'
|
||||
import { InstallPrompt } from './InstallPrompt.js'
|
||||
import { fetchMe, fetchEvents } from '../api/client.js';
|
||||
import { hydrateEvents } from '../lib/hydrateEvents.js';
|
||||
import { maybeRedirectToLogin, clearLoginRedirect } from '../lib/loginRedirect.js';
|
||||
import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig.js';
|
||||
import { useCalendarStore } from '../store/calendarStore.js';
|
||||
import { AuthSplash } from './AuthSplash.js';
|
||||
import { EventDetailPopover } from './EventDetailPopover.js';
|
||||
import { EventForm } from './EventForm.js';
|
||||
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js';
|
||||
import { SyncStateToast } from './SyncStateToast.js';
|
||||
import { ColorLegend } from './ColorLegend.js';
|
||||
import { SkeletonCalendar } from './SkeletonCalendar.js';
|
||||
import { InstallPrompt } from './InstallPrompt.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -67,12 +67,12 @@ import { InstallPrompt } from './InstallPrompt.js'
|
||||
* view through unchanged. Do not expect breakpoint logic here.
|
||||
*/
|
||||
function resolveDefaultView(persistedView: string): string {
|
||||
if (typeof window === 'undefined') return 'month-grid'
|
||||
return persistedView
|
||||
if (typeof window === 'undefined') return 'month-grid';
|
||||
return persistedView;
|
||||
}
|
||||
|
||||
function isPhone(): boolean {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
|
||||
}
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
@@ -81,21 +81,21 @@ export function CalendarShell() {
|
||||
// Use per-field selectors so CalendarShell does NOT subscribe to openEventId.
|
||||
// Without selectors, any popover open/close triggers a full re-render here,
|
||||
// which rebuilds the Schedule-X config and causes a visible calendar flash (Bug B).
|
||||
const calendarRange = useCalendarStore((s) => s.calendarRange)
|
||||
const setCalendarRange = useCalendarStore((s) => s.setCalendarRange)
|
||||
const setOpenEventId = useCalendarStore((s) => s.setOpenEventId)
|
||||
const selectedView = useCalendarStore((s) => s.selectedView)
|
||||
const setEventForm = useCalendarStore((s) => s.setEventForm)
|
||||
const eventFormOpen = useCalendarStore((s) => s.eventFormOpen)
|
||||
const sessionExpired = useCalendarStore((s) => s.sessionExpired)
|
||||
const { start, end } = calendarRange
|
||||
const queryClient = useQueryClient()
|
||||
const calendarRange = useCalendarStore((s) => s.calendarRange);
|
||||
const setCalendarRange = useCalendarStore((s) => s.setCalendarRange);
|
||||
const setOpenEventId = useCalendarStore((s) => s.setOpenEventId);
|
||||
const selectedView = useCalendarStore((s) => s.selectedView);
|
||||
const setEventForm = useCalendarStore((s) => s.setEventForm);
|
||||
const eventFormOpen = useCalendarStore((s) => s.eventFormOpen);
|
||||
const sessionExpired = useCalendarStore((s) => s.sessionExpired);
|
||||
const { start, end } = calendarRange;
|
||||
const queryClient = useQueryClient();
|
||||
// Ref to ensure the session-expiry redirect timer fires only once per expiry
|
||||
const sessionExpiredRedirectFired = useRef(false)
|
||||
const sessionExpiredRedirectFired = useRef(false);
|
||||
// When the one-shot redirect guard is already exhausted (flag set from a prior
|
||||
// navigation), maybeRedirectToLogin() returns false — arm the dead-end state so
|
||||
// AuthSplash shows the tap-to-retry recovery instead of spinning forever (D-11).
|
||||
const [loginRedirectExhausted, setLoginRedirectExhausted] = useState(false)
|
||||
const [loginRedirectExhausted, setLoginRedirectExhausted] = useState(false);
|
||||
|
||||
// Fetch current user to build per-member color config.
|
||||
// Same query key (['me']) as App.tsx — TanStack Query deduplicates, no double fetch.
|
||||
@@ -104,7 +104,7 @@ export function CalendarShell() {
|
||||
queryFn: fetchMe,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch windowed occurrences — key includes start/end so navigation refetches.
|
||||
//
|
||||
@@ -124,30 +124,30 @@ export function CalendarShell() {
|
||||
enabled: meQuery.isSuccess,
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
});
|
||||
|
||||
// Create plugins once (stable across renders)
|
||||
const eventsService = useState(() => createEventsServicePlugin())[0]
|
||||
const eventsService = useState(() => createEventsServicePlugin())[0];
|
||||
|
||||
// Build members list from /api/me for ColorLegend (AppNav uses the same data from App.tsx)
|
||||
const members = useMemo(() => {
|
||||
if (!meQuery.data?.user) return []
|
||||
if (!meQuery.data?.user) return [];
|
||||
return [
|
||||
{
|
||||
id: String(meQuery.data.user.id),
|
||||
name: meQuery.data.user.displayName ?? 'Member',
|
||||
color: meQuery.data.user.color,
|
||||
},
|
||||
]
|
||||
}, [meQuery.data])
|
||||
];
|
||||
}, [meQuery.data]);
|
||||
|
||||
// Build calendars config whenever the authenticated user changes
|
||||
const calendarsConfig: Record<string, CalendarType> = useMemo(
|
||||
() => buildCalendarConfig(members).calendars,
|
||||
[members],
|
||||
)
|
||||
);
|
||||
|
||||
const defaultView = resolveDefaultView(selectedView)
|
||||
const defaultView = resolveDefaultView(selectedView);
|
||||
|
||||
// Display events in the VIEWER's local timezone. Schedule-X defaults to 'UTC', which made
|
||||
// a 17:45-04:00 event render at 21:45 (9:45 PM). Events arrive as zoned ISO strings in their
|
||||
@@ -155,18 +155,13 @@ export function CalendarShell() {
|
||||
// display zone, so the family sees every event in their own wall-clock time.
|
||||
// IANATimezone (the config's timezone type) is declared but not exported by @schedule-x/calendar,
|
||||
// so derive it from useCalendarApp's config parameter rather than importing it.
|
||||
type SxTimeZone = NonNullable<Parameters<typeof useCalendarApp>[0]['timezone']>
|
||||
const displayTimeZone: SxTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
type SxTimeZone = NonNullable<Parameters<typeof useCalendarApp>[0]['timezone']>;
|
||||
const displayTimeZone: SxTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// useCalendarApp — config is stable; plugins passed as second argument
|
||||
const calendar = useCalendarApp(
|
||||
{
|
||||
views: [
|
||||
createViewDay(),
|
||||
createViewWeek(),
|
||||
createViewMonthGrid(),
|
||||
createViewMonthAgenda(),
|
||||
],
|
||||
views: [createViewDay(), createViewWeek(), createViewMonthGrid(), createViewMonthAgenda()],
|
||||
defaultView,
|
||||
timezone: displayTimeZone,
|
||||
firstDayOfWeek: SX_FIRST_DAY_OF_WEEK,
|
||||
@@ -180,25 +175,25 @@ export function CalendarShell() {
|
||||
setCalendarRange({
|
||||
start: range.start.toPlainDate().toString(),
|
||||
end: range.end.toPlainDate().add({ days: 1 }).toString(),
|
||||
})
|
||||
});
|
||||
},
|
||||
onEventClick(event) {
|
||||
if (event.id != null) {
|
||||
setOpenEventId(String(event.id))
|
||||
setOpenEventId(String(event.id));
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
[eventsService],
|
||||
)
|
||||
);
|
||||
|
||||
// Sync TanStack Query result into Schedule-X eventsService (Pitfall 4 guard)
|
||||
// hydrateEvents converts ISO strings → Temporal before eventsService.set()
|
||||
useEffect(() => {
|
||||
if (!eventsQuery.data) return
|
||||
const sxEvents = hydrateEvents(eventsQuery.data.occurrences)
|
||||
eventsService.set(sxEvents)
|
||||
}, [eventsQuery.data, eventsService])
|
||||
if (!eventsQuery.data) return;
|
||||
const sxEvents = hydrateEvents(eventsQuery.data.occurrences);
|
||||
eventsService.set(sxEvents);
|
||||
}, [eventsQuery.data, eventsService]);
|
||||
|
||||
// Auth redirect — one-shot full-page nav to /api/login when /api/me fails.
|
||||
// If this is the first failure, maybeRedirectToLogin() sets a sessionStorage
|
||||
@@ -209,22 +204,22 @@ export function CalendarShell() {
|
||||
// instead of spinning indefinitely (D-11 dead-end recovery).
|
||||
useEffect(() => {
|
||||
if (meQuery.isError) {
|
||||
const willRedirect = maybeRedirectToLogin()
|
||||
const willRedirect = maybeRedirectToLogin();
|
||||
if (!willRedirect) {
|
||||
setLoginRedirectExhausted(true)
|
||||
setLoginRedirectExhausted(true);
|
||||
}
|
||||
}
|
||||
}, [meQuery.isError])
|
||||
}, [meQuery.isError]);
|
||||
|
||||
// Clear the one-shot flag on a successful /api/me load so a later session
|
||||
// expiry can trigger another redirect instead of showing "Sign-in required".
|
||||
// Also reset the dead-end state in case the component is reused after auth recovery.
|
||||
useEffect(() => {
|
||||
if (meQuery.isSuccess) {
|
||||
clearLoginRedirect()
|
||||
setLoginRedirectExhausted(false)
|
||||
clearLoginRedirect();
|
||||
setLoginRedirectExhausted(false);
|
||||
}
|
||||
}, [meQuery.isSuccess])
|
||||
}, [meQuery.isSuccess]);
|
||||
|
||||
// Session-expiry redirect (D-11) — fires when a mid-use 401 is detected by the
|
||||
// global QueryCache/MutationCache handler and arms the Zustand sessionExpired flag.
|
||||
@@ -232,29 +227,29 @@ export function CalendarShell() {
|
||||
// afresh, then schedules the top-level navigation after ~1.5s (UI-SPEC §Surface 2
|
||||
// "≤2s before redirect, no dismiss button").
|
||||
useEffect(() => {
|
||||
if (!sessionExpired) return
|
||||
if (sessionExpiredRedirectFired.current) return
|
||||
sessionExpiredRedirectFired.current = true
|
||||
if (!sessionExpired) return;
|
||||
if (sessionExpiredRedirectFired.current) return;
|
||||
sessionExpiredRedirectFired.current = true;
|
||||
|
||||
clearLoginRedirect()
|
||||
clearLoginRedirect();
|
||||
const timer = setTimeout(() => {
|
||||
maybeRedirectToLogin()
|
||||
}, 1500)
|
||||
maybeRedirectToLogin();
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [sessionExpired])
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [sessionExpired]);
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// Determine which content to show in the calendar area.
|
||||
// Note: meQuery.isLoading is handled above by an early AuthSplash return — it
|
||||
// will always be false when we reach this line (meQuery.isSuccess is guaranteed).
|
||||
const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data
|
||||
const isEventsError = eventsQuery.isError
|
||||
const isInitialLoading = eventsQuery.isLoading && !eventsQuery.data;
|
||||
const isEventsError = eventsQuery.isError;
|
||||
|
||||
const phone = isPhone()
|
||||
const phone = isPhone();
|
||||
|
||||
// ── Auth splash (D-10) ────────────────────────────────────────────────────
|
||||
// Gate the calendar render on auth state so no calendar shell, skeleton, or
|
||||
@@ -271,7 +266,7 @@ export function CalendarShell() {
|
||||
// returns false and this splash stays — the dead-end "Tap to try again" is
|
||||
// rendered by AuthSplash's dead-end state (wired in Task 3 via sessionExpired).
|
||||
if (meQuery.isLoading) {
|
||||
return <AuthSplash state="loading" overlay />
|
||||
return <AuthSplash state="loading" overlay />;
|
||||
}
|
||||
|
||||
if (meQuery.isError) {
|
||||
@@ -280,9 +275,9 @@ export function CalendarShell() {
|
||||
// Otherwise the useEffect above already fired maybeRedirectToLogin() and
|
||||
// the browser is navigating — show the redirecting splash while it does.
|
||||
if (loginRedirectExhausted) {
|
||||
return <AuthSplash state="dead-end" overlay />
|
||||
return <AuthSplash state="dead-end" overlay />;
|
||||
}
|
||||
return <AuthSplash state="redirecting" overlay />
|
||||
return <AuthSplash state="redirecting" overlay />;
|
||||
}
|
||||
|
||||
// ── Session-expiry interstitial (D-11) ─────────────────────────────────────
|
||||
@@ -297,7 +292,7 @@ export function CalendarShell() {
|
||||
body="Signing you back in…"
|
||||
overlay
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ── Calendar content ───────────────────────────────────────────────────────
|
||||
@@ -311,98 +306,100 @@ export function CalendarShell() {
|
||||
// write events refetch). That full remount is the "calendar flash" (Bug B). As
|
||||
// an element value it reconciles in place across re-renders: no remount, no flash.
|
||||
const calendarContent = (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Main calendar area */}
|
||||
<div style={{ flex: 1, minHeight: 0, height: '100%', position: 'relative' }}>
|
||||
{isInitialLoading ? (
|
||||
// Loading state: shimmer skeleton
|
||||
<div style={{ padding: 'var(--space-4)', flex: 1 }}>
|
||||
<SkeletonCalendar variant={phone ? 'agenda' : 'month'} />
|
||||
</div>
|
||||
) : isEventsError ? (
|
||||
// Error state: replace grid with heading + body + Retry button
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 'var(--space-12)',
|
||||
gap: 'var(--space-4)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
textAlign: 'center',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-heading-size)',
|
||||
fontWeight: 'var(--text-heading-weight)',
|
||||
lineHeight: 'var(--text-heading-line-height)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Couldn't load events
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-body-size)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Check your connection and try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { void queryClient.refetchQueries({ queryKey: ['events'] }) }}
|
||||
style={{
|
||||
background: 'var(--color-surface-dim)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1)',
|
||||
cursor: 'pointer',
|
||||
minHeight: '44px',
|
||||
padding: '0 var(--space-4)',
|
||||
fontSize: 'var(--text-label-size)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Normal: Schedule-X calendar (primary focal point).
|
||||
// ALWAYS render the calendar even when the window has no events — its built-in
|
||||
// header carries the navigation, so swapping in an empty-state would strand the
|
||||
// user with no way to navigate away from an empty day/week. An empty grid is clear.
|
||||
<ScheduleXCalendar calendarApp={calendar} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone: show ColorLegend below toolbar, collapsed */}
|
||||
{phone && !meQuery.isLoading && members.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Main calendar area */}
|
||||
<div style={{ flex: 1, minHeight: 0, height: '100%', position: 'relative' }}>
|
||||
{isInitialLoading ? (
|
||||
// Loading state: shimmer skeleton
|
||||
<div style={{ padding: 'var(--space-4)', flex: 1 }}>
|
||||
<SkeletonCalendar variant={phone ? 'agenda' : 'month'} />
|
||||
</div>
|
||||
) : isEventsError ? (
|
||||
// Error state: replace grid with heading + body + Retry button
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 'var(--space-12)',
|
||||
gap: 'var(--space-4)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
textAlign: 'center',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<ColorLegend members={members} />
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-heading-size)',
|
||||
fontWeight: 'var(--text-heading-weight)',
|
||||
lineHeight: 'var(--text-heading-line-height)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
Couldn't load events
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-body-size)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Check your connection and try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
void queryClient.refetchQueries({ queryKey: ['events'] });
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--color-surface-dim)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1)',
|
||||
cursor: 'pointer',
|
||||
minHeight: '44px',
|
||||
padding: '0 var(--space-4)',
|
||||
fontSize: 'var(--text-label-size)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Normal: Schedule-X calendar (primary focal point).
|
||||
// ALWAYS render the calendar even when the window has no events — its built-in
|
||||
// header carries the navigation, so swapping in an empty-state would strand the
|
||||
// user with no way to navigate away from an empty day/week. An empty grid is clear.
|
||||
<ScheduleXCalendar calendarApp={calendar} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
{/* Phone: show ColorLegend below toolbar, collapsed */}
|
||||
{phone && !meQuery.isLoading && members.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
}}
|
||||
>
|
||||
<ColorLegend members={members} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────────────────────────
|
||||
// CalendarShell fills its container (App.tsx provides the outer layout with AppNav).
|
||||
@@ -505,5 +502,5 @@ export function CalendarShell() {
|
||||
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
||||
<SyncStateToast />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user