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:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+23 -26
View File
@@ -36,26 +36,26 @@
* - /api/me query shared so AppNav has user data on all routes.
*/
import { useState, useMemo } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { CalendarShell } from './components/CalendarShell.js'
import { ListsIndex } from './routes/ListsIndex.js'
import { ListDetail } from './routes/ListDetail.js'
import { BottomTabBar } from './components/BottomTabBar.js'
import { AppNav } from './components/AppNav.js'
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js'
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js'
import { SettingsSheet } from './components/SettingsSheet.js'
import { fetchMe } from './api/client.js'
import { useState, useMemo } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { CalendarShell } from './components/CalendarShell.js';
import { ListsIndex } from './routes/ListsIndex.js';
import { ListDetail } from './routes/ListDetail.js';
import { BottomTabBar } from './components/BottomTabBar.js';
import { AppNav } from './components/AppNav.js';
import { PushPermissionPrompt } from './components/PushPermissionPrompt.js';
import { PermissionDeniedBanner } from './components/PermissionDeniedBanner.js';
import { SettingsSheet } from './components/SettingsSheet.js';
import { fetchMe } from './api/client.js';
function isPhone(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
}
export default function App() {
const [settingsOpen, setSettingsOpen] = useState(false)
const phone = isPhone()
const [settingsOpen, setSettingsOpen] = useState(false);
const phone = isPhone();
// Fetch current user once at the app shell level so AppNav has member data on
// ALL routes. This is the same query key (['me']) used by CalendarShell, so
@@ -65,19 +65,19 @@ export default function App() {
queryFn: fetchMe,
retry: false,
staleTime: 5 * 60 * 1000,
})
});
// Derive members for AppNav from the shared /api/me response
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]);
// Outer layout: phone = column, desktop = row (AppNav sidebar + content)
const outerStyle: React.CSSProperties = {
@@ -88,7 +88,7 @@ export default function App() {
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
overflow: 'hidden',
}
};
// Content area: fills remaining space next to / below AppNav
const contentStyle: React.CSSProperties = {
@@ -99,7 +99,7 @@ export default function App() {
flexDirection: 'column',
overflow: 'hidden',
position: 'relative',
}
};
return (
<BrowserRouter>
@@ -120,10 +120,7 @@ export default function App() {
<div style={contentStyle}>
<Routes>
<Route path="/" element={<Navigate to="/calendar" replace />} />
<Route
path="/calendar"
element={<CalendarShell />}
/>
<Route path="/calendar" element={<CalendarShell />} />
<Route path="/lists" element={<ListsIndex />} />
<Route path="/lists/:listId" element={<ListDetail />} />
</Routes>
@@ -140,5 +137,5 @@ export default function App() {
{/* Settings sheet — master notifications toggle (D-09), opened from avatar */}
<SettingsSheet isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} />
</BrowserRouter>
)
);
}
+287 -258
View File
@@ -18,175 +18,189 @@
* - fetchMe's existing opaqueredirect/401 path now throws SessionExpiredError (unified)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Plan 06-05: SessionExpiredError detection tests ───────────────────────────
describe('SessionExpiredError — fetchEvents (06-05)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('throws SessionExpiredError on opaqueredirect', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'opaqueredirect',
status: 0,
} as unknown as Response)
} as unknown as Response);
const { fetchEvents, SessionExpiredError } = await import('./client.js')
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(SessionExpiredError)
})
const { fetchEvents, SessionExpiredError } = await import('./client.js');
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(
SessionExpiredError,
);
});
it('throws SessionExpiredError on 401', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 401,
} as unknown as Response)
} as unknown as Response);
const { fetchEvents, SessionExpiredError } = await import('./client.js')
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(SessionExpiredError)
})
const { fetchEvents, SessionExpiredError } = await import('./client.js');
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toBeInstanceOf(
SessionExpiredError,
);
});
it('throws a generic Error (not SessionExpiredError) on 500', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 500,
} as unknown as Response)
} as unknown as Response);
const { fetchEvents, SessionExpiredError } = await import('./client.js')
const { fetchEvents, SessionExpiredError } = await import('./client.js');
await expect(fetchEvents('2026-06-01', '2026-06-30')).rejects.toSatisfy(
(e: unknown) => e instanceof Error && !(e instanceof SessionExpiredError),
)
})
})
);
});
});
describe('SessionExpiredError — createEvent (06-05)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('throws SessionExpiredError on 401', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 401,
} as unknown as Response)
} as unknown as Response);
const { createEvent, SessionExpiredError } = await import('./client.js')
const { createEvent, SessionExpiredError } = await import('./client.js');
await expect(
createEvent({ title: 'Test', allDay: false, start: '2026-06-15T10:00:00', end: '2026-06-15T11:00:00' }),
).rejects.toBeInstanceOf(SessionExpiredError)
})
})
createEvent({
title: 'Test',
allDay: false,
start: '2026-06-15T10:00:00',
end: '2026-06-15T11:00:00',
}),
).rejects.toBeInstanceOf(SessionExpiredError);
});
});
describe('SessionExpiredError — updateEvent (06-05)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('throws SessionExpiredError on 401', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 401,
} as unknown as Response)
} as unknown as Response);
const { updateEvent, SessionExpiredError } = await import('./client.js')
const { updateEvent, SessionExpiredError } = await import('./client.js');
await expect(
updateEvent('uid-123', { title: 'Test', allDay: false, start: '2026-06-15T10:00:00', end: '2026-06-15T11:00:00' }),
).rejects.toBeInstanceOf(SessionExpiredError)
})
})
updateEvent('uid-123', {
title: 'Test',
allDay: false,
start: '2026-06-15T10:00:00',
end: '2026-06-15T11:00:00',
}),
).rejects.toBeInstanceOf(SessionExpiredError);
});
});
describe('SessionExpiredError — deleteEvent (06-05)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('throws SessionExpiredError on 401', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 401,
} as unknown as Response)
} as unknown as Response);
const { deleteEvent, SessionExpiredError } = await import('./client.js')
await expect(deleteEvent('uid-to-delete')).rejects.toBeInstanceOf(SessionExpiredError)
})
})
const { deleteEvent, SessionExpiredError } = await import('./client.js');
await expect(deleteEvent('uid-to-delete')).rejects.toBeInstanceOf(SessionExpiredError);
});
});
describe('SessionExpiredError — fetchMe unified (06-05)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('throws SessionExpiredError (instanceof) on opaqueredirect — unified with other wrappers', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'opaqueredirect',
status: 0,
} as unknown as Response)
} as unknown as Response);
const { fetchMe, SessionExpiredError } = await import('./client.js')
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError)
})
const { fetchMe, SessionExpiredError } = await import('./client.js');
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError);
});
it('throws SessionExpiredError on 401 — unified', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 401,
} as unknown as Response)
} as unknown as Response);
const { fetchMe, SessionExpiredError } = await import('./client.js')
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError)
})
})
const { fetchMe, SessionExpiredError } = await import('./client.js');
await expect(fetchMe()).rejects.toBeInstanceOf(SessionExpiredError);
});
});
// ── createEvent tests ─────────────────────────────────────────────────────────
describe('createEvent', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('POSTs to /api/events/create with credentials:include', async () => {
const mockFetch = vi.mocked(fetch)
const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => ({ uid: 'test-uid-123' }),
} as unknown as Response)
} as unknown as Response);
const { createEvent } = await import('./client.js')
const { createEvent } = await import('./client.js');
const payload = {
title: 'Team Meeting',
allDay: false,
start: '2026-06-15T10:00:00',
end: '2026-06-15T11:00:00',
recurrence: 'none' as const,
}
await createEvent(payload)
};
await createEvent(payload);
expect(mockFetch).toHaveBeenCalledWith(
'/api/events/create',
@@ -196,35 +210,35 @@ describe('createEvent', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- expect.objectContaining returns 'any' (Vitest asymmetric matcher); safe in assertion context
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
}),
)
})
);
});
it('returns { uid } from 202 response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => ({ uid: 'returned-uid-456' }),
} as unknown as Response)
} as unknown as Response);
const { createEvent } = await import('./client.js')
const { createEvent } = await import('./client.js');
const result = await createEvent({
title: 'Test',
allDay: true,
start: '2026-06-20',
end: '2026-06-20',
recurrence: 'none' as const,
})
});
expect(result).toEqual({ uid: 'returned-uid-456' })
})
expect(result).toEqual({ uid: 'returned-uid-456' });
});
it('throws on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 400,
json: () => ({ error: 'Bad Request' }),
} as unknown as Response)
} as unknown as Response);
const { createEvent } = await import('./client.js')
const { createEvent } = await import('./client.js');
await expect(
createEvent({
title: '',
@@ -233,35 +247,35 @@ describe('createEvent', () => {
end: '2026-06-15T11:00:00',
recurrence: 'none' as const,
}),
).rejects.toThrow()
})
})
).rejects.toThrow();
});
});
// ── updateEvent tests ─────────────────────────────────────────────────────────
describe('updateEvent', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('PATCHes /api/events/:uid/edit', async () => {
const mockFetch = vi.mocked(fetch)
const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => ({ uid: 'edit-uid-789' }),
} as unknown as Response)
} as unknown as Response);
const { updateEvent } = await import('./client.js')
const { updateEvent } = await import('./client.js');
await updateEvent('edit-uid-789', {
title: 'Updated Meeting',
allDay: false,
start: '2026-06-15T10:00:00',
end: '2026-06-15T11:00:00',
recurrence: 'weekly' as const,
})
});
expect(mockFetch).toHaveBeenCalledWith(
'/api/events/edit-uid-789/edit',
@@ -269,104 +283,119 @@ describe('updateEvent', () => {
method: 'PATCH',
credentials: 'include',
}),
)
})
);
});
it('returns { uid } on success', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => ({ uid: 'patched-uid' }),
} as unknown as Response)
} as unknown as Response);
const { updateEvent } = await import('./client.js')
const { updateEvent } = await import('./client.js');
const result = await updateEvent('some-uid', {
title: 'Patched',
allDay: false,
start: '2026-06-15T10:00:00',
end: '2026-06-15T11:00:00',
recurrence: 'none' as const,
})
});
expect(result).toEqual({ uid: 'patched-uid' })
})
})
expect(result).toEqual({ uid: 'patched-uid' });
});
});
// ── fetchWritableCalendars tests ──────────────────────────────────────────────
describe('fetchWritableCalendars', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('GETs /api/events/writable-calendars with credentials:include', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => ({
calendars: [
{ url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false },
{
url: 'https://caldav.fastmail.com/cal1',
displayName: 'My Calendar',
color: '#4A90D9',
isShared: false,
},
],
}),
} as unknown as Response)
} as unknown as Response);
const { fetchWritableCalendars } = await import('./client.js')
await fetchWritableCalendars()
const { fetchWritableCalendars } = await import('./client.js');
await fetchWritableCalendars();
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
'/api/events/writable-calendars',
expect.objectContaining({ credentials: 'include' }),
)
})
);
});
it('returns WritableCalendar[] from the calendars envelope', async () => {
const mockCalendars = [
{ url: 'https://caldav.fastmail.com/cal1', displayName: 'My Calendar', color: '#4A90D9', isShared: false },
{ url: 'https://caldav.fastmail.com/cal2', displayName: 'Family', color: '#F25C7A', isShared: true },
]
{
url: 'https://caldav.fastmail.com/cal1',
displayName: 'My Calendar',
color: '#4A90D9',
isShared: false,
},
{
url: 'https://caldav.fastmail.com/cal2',
displayName: 'Family',
color: '#F25C7A',
isShared: true,
},
];
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => ({ calendars: mockCalendars }),
} as unknown as Response)
} as unknown as Response);
const { fetchWritableCalendars } = await import('./client.js')
const result = await fetchWritableCalendars()
const { fetchWritableCalendars } = await import('./client.js');
const result = await fetchWritableCalendars();
expect(result).toEqual(mockCalendars)
expect(result).toHaveLength(2)
})
expect(result).toEqual(mockCalendars);
expect(result).toHaveLength(2);
});
it('throws on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 401,
} as unknown as Response)
} as unknown as Response);
const { fetchWritableCalendars } = await import('./client.js')
await expect(fetchWritableCalendars()).rejects.toThrow()
})
})
const { fetchWritableCalendars } = await import('./client.js');
await expect(fetchWritableCalendars()).rejects.toThrow();
});
});
// ── deleteEvent tests ─────────────────────────────────────────────────────────
describe('deleteEvent', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('DELETEs /api/events/:uid with credentials:include', async () => {
const mockFetch = vi.mocked(fetch)
const mockFetch = vi.mocked(fetch);
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => ({}),
} as unknown as Response)
} as unknown as Response);
const { deleteEvent } = await import('./client.js')
await deleteEvent('uid-to-delete')
const { deleteEvent } = await import('./client.js');
await deleteEvent('uid-to-delete');
expect(mockFetch).toHaveBeenCalledWith(
'/api/events/uid-to-delete',
@@ -374,98 +403,98 @@ describe('deleteEvent', () => {
method: 'DELETE',
credentials: 'include',
}),
)
})
);
});
it('resolves void on success (204/202)', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => ({}),
} as unknown as Response)
} as unknown as Response);
const { deleteEvent } = await import('./client.js')
const result = await deleteEvent('uid-abc')
expect(result).toBeUndefined()
})
const { deleteEvent } = await import('./client.js');
const result = await deleteEvent('uid-abc');
expect(result).toBeUndefined();
});
it('throws on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as unknown as Response)
} as unknown as Response);
const { deleteEvent } = await import('./client.js')
await expect(deleteEvent('missing-uid')).rejects.toThrow()
})
})
const { deleteEvent } = await import('./client.js');
await expect(deleteEvent('missing-uid')).rejects.toThrow();
});
});
// ── fetchSyncStatus tests ─────────────────────────────────────────────────────
describe('fetchSyncStatus', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('GETs /api/events/sync-status?uid= with credentials:include', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => ({ uid: 'my-uid', status: 'pending' }),
} as unknown as Response)
} as unknown as Response);
const { fetchSyncStatus } = await import('./client.js')
await fetchSyncStatus('my-uid')
const { fetchSyncStatus } = await import('./client.js');
await fetchSyncStatus('my-uid');
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
'/api/events/sync-status?uid=my-uid',
expect.objectContaining({ credentials: 'include' }),
)
})
);
});
it('returns SyncStatus object with uid and status', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => ({ uid: 'test-uid', status: 'done' }),
} as unknown as Response)
} as unknown as Response);
const { fetchSyncStatus } = await import('./client.js')
const result = await fetchSyncStatus('test-uid')
expect(result).toEqual({ uid: 'test-uid', status: 'done' })
})
const { fetchSyncStatus } = await import('./client.js');
const result = await fetchSyncStatus('test-uid');
expect(result).toEqual({ uid: 'test-uid', status: 'done' });
});
it('returns SyncStatus with error field for failed status', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: () => ({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' }),
} as unknown as Response)
} as unknown as Response);
const { fetchSyncStatus } = await import('./client.js')
const result = await fetchSyncStatus('fail-uid')
expect(result).toEqual({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' })
})
const { fetchSyncStatus } = await import('./client.js');
const result = await fetchSyncStatus('fail-uid');
expect(result).toEqual({ uid: 'fail-uid', status: 'failed', error: '412 Conflict' });
});
it('throws on non-ok response', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 500,
} as unknown as Response)
} as unknown as Response);
const { fetchSyncStatus } = await import('./client.js')
await expect(fetchSyncStatus('any-uid')).rejects.toThrow()
})
})
const { fetchSyncStatus } = await import('./client.js');
await expect(fetchSyncStatus('any-uid')).rejects.toThrow();
});
});
// ── fetchMe tests ─────────────────────────────────────────────────────────────
describe('fetchMe', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn())
})
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals()
})
vi.unstubAllGlobals();
});
it('requests /api/me with credentials:include AND redirect:manual', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
@@ -473,16 +502,16 @@ describe('fetchMe', () => {
type: 'basic',
status: 200,
json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
} as unknown as Response)
} as unknown as Response);
const { fetchMe } = await import('./client.js')
await fetchMe()
const { fetchMe } = await import('./client.js');
await fetchMe();
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
'/api/me',
expect.objectContaining({ credentials: 'include', redirect: 'manual' }),
)
})
);
});
it('returns the user envelope on 200', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
@@ -490,12 +519,12 @@ describe('fetchMe', () => {
type: 'basic',
status: 200,
json: () => ({ user: { id: 2, displayName: 'Me', color: '#E8734A' } }),
} as unknown as Response)
} as unknown as Response);
const { fetchMe } = await import('./client.js')
const result = await fetchMe()
expect(result).toEqual({ user: { id: 2, displayName: 'Me', color: '#E8734A' } })
})
const { fetchMe } = await import('./client.js');
const result = await fetchMe();
expect(result).toEqual({ user: { id: 2, displayName: 'Me', color: '#E8734A' } });
});
it('throws on an opaqueredirect (OIDC guard 302 to Authelia) — the auth-required signal', async () => {
// redirect:'manual' surfaces a 302 as an opaqueredirect with status 0 and
@@ -505,123 +534,123 @@ describe('fetchMe', () => {
type: 'opaqueredirect',
status: 0,
json: () => {
throw new Error('body not accessible on opaqueredirect')
throw new Error('body not accessible on opaqueredirect');
},
} as unknown as Response)
} as unknown as Response);
const { fetchMe } = await import('./client.js')
await expect(fetchMe()).rejects.toThrow(/authentication required/i)
})
const { fetchMe } = await import('./client.js');
await expect(fetchMe()).rejects.toThrow(/authentication required/i);
});
it('throws on a 401', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
type: 'basic',
status: 401,
} as unknown as Response)
} as unknown as Response);
const { fetchMe } = await import('./client.js')
await expect(fetchMe()).rejects.toThrow(/authentication required/i)
})
})
const { fetchMe } = await import('./client.js');
await expect(fetchMe()).rejects.toThrow(/authentication required/i);
});
});
// ── Zustand store new keys tests ──────────────────────────────────────────────
describe('calendarStore — eventForm keys', () => {
it('eventFormOpen defaults to false', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
const state = useCalendarStore.getState()
expect(state.eventFormOpen).toBe(false)
})
const { useCalendarStore } = await import('../store/calendarStore.js');
const state = useCalendarStore.getState();
expect(state.eventFormOpen).toBe(false);
});
it('eventFormMode defaults to create', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
const state = useCalendarStore.getState()
expect(state.eventFormMode).toBe('create')
})
const { useCalendarStore } = await import('../store/calendarStore.js');
const state = useCalendarStore.getState();
expect(state.eventFormMode).toBe('create');
});
it('eventFormUid defaults to null', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
const state = useCalendarStore.getState()
expect(state.eventFormUid).toBeNull()
})
const { useCalendarStore } = await import('../store/calendarStore.js');
const state = useCalendarStore.getState();
expect(state.eventFormUid).toBeNull();
});
it('setEventForm(true, edit, some-uid) updates all three keys', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid')
const state = useCalendarStore.getState()
expect(state.eventFormOpen).toBe(true)
expect(state.eventFormMode).toBe('edit')
expect(state.eventFormUid).toBe('some-uid')
})
const { useCalendarStore } = await import('../store/calendarStore.js');
useCalendarStore.getState().setEventForm(true, 'edit', 'some-uid');
const state = useCalendarStore.getState();
expect(state.eventFormOpen).toBe(true);
expect(state.eventFormMode).toBe('edit');
expect(state.eventFormUid).toBe('some-uid');
});
it('setEventForm(false) closes the form and preserves other defaults', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
const { useCalendarStore } = await import('../store/calendarStore.js');
// Open first
useCalendarStore.getState().setEventForm(true, 'edit', 'uid-abc')
useCalendarStore.getState().setEventForm(true, 'edit', 'uid-abc');
// Close
useCalendarStore.getState().setEventForm(false)
const state = useCalendarStore.getState()
expect(state.eventFormOpen).toBe(false)
})
useCalendarStore.getState().setEventForm(false);
const state = useCalendarStore.getState();
expect(state.eventFormOpen).toBe(false);
});
it('setEventForm(true) with no mode/uid defaults to create/null', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
useCalendarStore.getState().setEventForm(true)
const state = useCalendarStore.getState()
expect(state.eventFormOpen).toBe(true)
expect(state.eventFormMode).toBe('create')
expect(state.eventFormUid).toBeNull()
})
})
const { useCalendarStore } = await import('../store/calendarStore.js');
useCalendarStore.getState().setEventForm(true);
const state = useCalendarStore.getState();
expect(state.eventFormOpen).toBe(true);
expect(state.eventFormMode).toBe('create');
expect(state.eventFormUid).toBeNull();
});
});
describe('calendarStore — delete/sync keys', () => {
it('deleteDialogOpen defaults to false', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
const state = useCalendarStore.getState()
expect(state.deleteDialogOpen).toBe(false)
})
const { useCalendarStore } = await import('../store/calendarStore.js');
const state = useCalendarStore.getState();
expect(state.deleteDialogOpen).toBe(false);
});
it('deleteDialogUid defaults to null', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
const state = useCalendarStore.getState()
expect(state.deleteDialogUid).toBeNull()
})
const { useCalendarStore } = await import('../store/calendarStore.js');
const state = useCalendarStore.getState();
expect(state.deleteDialogUid).toBeNull();
});
it('lastSyncedUid defaults to null', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
const state = useCalendarStore.getState()
expect(state.lastSyncedUid).toBeNull()
})
const { useCalendarStore } = await import('../store/calendarStore.js');
const state = useCalendarStore.getState();
expect(state.lastSyncedUid).toBeNull();
});
it('setDeleteDialog(true, uid) opens dialog with uid', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
useCalendarStore.getState().setDeleteDialog(true, 'delete-uid-999')
const state = useCalendarStore.getState()
expect(state.deleteDialogOpen).toBe(true)
expect(state.deleteDialogUid).toBe('delete-uid-999')
})
const { useCalendarStore } = await import('../store/calendarStore.js');
useCalendarStore.getState().setDeleteDialog(true, 'delete-uid-999');
const state = useCalendarStore.getState();
expect(state.deleteDialogOpen).toBe(true);
expect(state.deleteDialogUid).toBe('delete-uid-999');
});
it('setDeleteDialog(false) closes dialog', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
useCalendarStore.getState().setDeleteDialog(true, 'some-uid')
useCalendarStore.getState().setDeleteDialog(false)
const state = useCalendarStore.getState()
expect(state.deleteDialogOpen).toBe(false)
})
const { useCalendarStore } = await import('../store/calendarStore.js');
useCalendarStore.getState().setDeleteDialog(true, 'some-uid');
useCalendarStore.getState().setDeleteDialog(false);
const state = useCalendarStore.getState();
expect(state.deleteDialogOpen).toBe(false);
});
it('setLastSyncedUid sets the UID', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
useCalendarStore.getState().setLastSyncedUid('synced-uid-abc')
const state = useCalendarStore.getState()
expect(state.lastSyncedUid).toBe('synced-uid-abc')
})
const { useCalendarStore } = await import('../store/calendarStore.js');
useCalendarStore.getState().setLastSyncedUid('synced-uid-abc');
const state = useCalendarStore.getState();
expect(state.lastSyncedUid).toBe('synced-uid-abc');
});
it('setLastSyncedUid(null) clears the UID', async () => {
const { useCalendarStore } = await import('../store/calendarStore.js')
useCalendarStore.getState().setLastSyncedUid('some-uid')
useCalendarStore.getState().setLastSyncedUid(null)
const state = useCalendarStore.getState()
expect(state.lastSyncedUid).toBeNull()
})
})
const { useCalendarStore } = await import('../store/calendarStore.js');
useCalendarStore.getState().setLastSyncedUid('some-uid');
useCalendarStore.getState().setLastSyncedUid(null);
const state = useCalendarStore.getState();
expect(state.lastSyncedUid).toBeNull();
});
});
+67 -70
View File
@@ -31,10 +31,10 @@
* breaks the prototype chain.
*/
export class SessionExpiredError extends Error {
readonly name = 'SessionExpiredError'
readonly name = 'SessionExpiredError';
constructor() {
super('Session expired — re-authentication required')
Object.setPrototypeOf(this, SessionExpiredError.prototype)
super('Session expired — re-authentication required');
Object.setPrototypeOf(this, SessionExpiredError.prototype);
}
}
@@ -50,23 +50,23 @@ export class SessionExpiredError extends Error {
*/
function handleAuthResponse(res: Response, label: string): void {
if (res.type === 'opaqueredirect' || res.status === 401) {
throw new SessionExpiredError()
throw new SessionExpiredError();
}
if (!res.ok) {
throw new Error(`${label} failed: ${res.status}`)
throw new Error(`${label} failed: ${res.status}`);
}
}
// ── /api/me ────────────────────────────────────────────────────────────────
export interface MeUser {
id: number
displayName: string | null
color: string
id: number;
displayName: string | null;
color: string;
}
export interface MeResponse {
user: MeUser
user: MeUser;
}
export async function fetchMe(): Promise<MeResponse> {
@@ -80,11 +80,11 @@ export async function fetchMe(): Promise<MeResponse> {
const res = await fetch('/api/me', {
credentials: 'include',
redirect: 'manual',
})
});
handleAuthResponse(res, 'GET /api/me')
handleAuthResponse(res, 'GET /api/me');
return res.json() as Promise<MeResponse>
return res.json() as Promise<MeResponse>;
}
// ── /api/events (windowed — Phase 2) ─────────────────────────────────────
@@ -104,35 +104,35 @@ export async function fetchMe(): Promise<MeResponse> {
* to build the Schedule-X calendarId that keys into buildCalendarConfig().
*/
export interface CalendarOccurrence {
id: string // `ev-<sanitized-uid>-<epochMs>` — stable identity (server: expand.ts makeOccurrenceId)
uid: string
calendarId: number // DB calendar-row id — do NOT use for SX calendarId routing
calendarName: string
ownerUserId: number // DB user id — the correct Schedule-X routing key
id: string; // `ev-<sanitized-uid>-<epochMs>` — stable identity (server: expand.ts makeOccurrenceId)
uid: string;
calendarId: number; // DB calendar-row id — do NOT use for SX calendarId routing
calendarName: string;
ownerUserId: number; // DB user id — the correct Schedule-X routing key
/**
* Display name of the calendar owner (users.displayName from the API).
* Null when the user has not configured a display name.
* Popover renders: isShared ? 'Family' : (ownerName ?? calendarName)
*/
ownerName: string | null
color: string // hex from users.color or shared-family constant
isShared: boolean // true → 'shared' slot; false → String(ownerUserId) slot
title: string
start: string // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed
end: string
allDay: boolean
location: string | null
description: string | null
ownerName: string | null;
color: string; // hex from users.color or shared-family constant
isShared: boolean; // true → 'shared' slot; false → String(ownerUserId) slot
title: string;
start: string; // 'YYYY-MM-DD' for allDay:true; ISO 8601 with IANA tz for timed
end: string;
allDay: boolean;
location: string | null;
description: string | null;
/**
* True when this occurrence belongs to a recurring series (has an RRULE).
* Mirrors CalendarOccurrence.hasRrule in apps/api/src/broker/expand.ts — must
* stay in sync with the server type (Pitfall 4 — atomic mirror, Plan 06-05).
*/
hasRrule: boolean
hasRrule: boolean;
}
export interface OccurrencesResponse {
occurrences: CalendarOccurrence[]
occurrences: CalendarOccurrence[];
}
/**
@@ -144,18 +144,15 @@ export interface OccurrencesResponse {
* @param start ISO date string 'YYYY-MM-DD' — window start (inclusive)
* @param end ISO date string 'YYYY-MM-DD' — window end (exclusive)
*/
export async function fetchEvents(
start: string,
end: string,
): Promise<OccurrencesResponse> {
export async function fetchEvents(start: string, end: string): Promise<OccurrencesResponse> {
const res = await fetch(`/api/events?start=${start}&end=${end}`, {
credentials: 'include',
redirect: 'manual',
})
});
handleAuthResponse(res, 'GET /api/events')
handleAuthResponse(res, 'GET /api/events');
return res.json() as Promise<OccurrencesResponse>
return res.json() as Promise<OccurrencesResponse>;
}
// Phase 1 legacy types (CalendarEvent, EventsResponse, fetchEventsLegacy) removed in Plan 05
@@ -167,40 +164,40 @@ export async function fetchEvents(
* Recurrence presets supported by the EventForm.
* Maps 1:1 to the RRULE frequency values the API accepts.
*/
export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
export type RecurrencePreset = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly';
/**
* Payload for creating or updating a calendar event.
* Mirrors the Zod schema on POST /api/events/create and PATCH /api/events/:uid/edit.
*/
export interface CreateEventPayload {
title: string
allDay: boolean
start: string // 'YYYY-MM-DD' for allDay; ISO 8601 for timed
end: string // same format as start
title: string;
allDay: boolean;
start: string; // 'YYYY-MM-DD' for allDay; ISO 8601 for timed
end: string; // same format as start
// WR-01: optional. CREATE always sends it; EDIT omits it so the API/worker preserve
// the event's existing RRULE (the occurrence contract does not expose recurrence, so
// the form cannot echo it back without silently resetting it to 'none').
recurrence?: RecurrencePreset
recurrence?: RecurrencePreset;
/**
* RRULE UNTIL date (D-06). ISO 'YYYY-MM-DD' string. Only sent when recurrence !== 'none'
* and the user selects the "On date" bound. Mutually exclusive with recurrenceCount.
* The outbox worker converts this to RRULE UNTIL format (DATE for all-day, DATETIME UTC for timed).
*/
recurrenceUntil?: string
recurrenceUntil?: string;
/**
* RRULE COUNT (D-06). Integer >= 1. Only sent when recurrence !== 'none' and the user
* selects the "After N times" bound. Mutually exclusive with recurrenceUntil.
*/
recurrenceCount?: number
location?: string
description?: string
calendarUrl?: string // omit to use the member's default writable calendar (D-01)
recurrenceCount?: number;
location?: string;
description?: string;
calendarUrl?: string; // omit to use the member's default writable calendar (D-01)
}
/** Response from POST /api/events/create and PATCH /api/events/:uid/edit */
export interface CreateEventResponse {
uid: string
uid: string;
}
/**
@@ -209,10 +206,10 @@ export interface CreateEventResponse {
* The client never derives writability — it reads this endpoint verbatim.
*/
export interface WritableCalendar {
url: string
displayName: string
color: string
isShared: boolean
url: string;
displayName: string;
color: string;
isShared: boolean;
}
/**
@@ -228,11 +225,11 @@ export async function createEvent(payload: CreateEventPayload): Promise<CreateEv
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(payload),
})
});
handleAuthResponse(res, 'POST /api/events/create')
handleAuthResponse(res, 'POST /api/events/create');
return res.json() as Promise<CreateEventResponse>
return res.json() as Promise<CreateEventResponse>;
}
/**
@@ -251,11 +248,11 @@ export async function updateEvent(
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(payload),
})
});
handleAuthResponse(res, `PATCH /api/events/${uid}/edit`)
handleAuthResponse(res, `PATCH /api/events/${uid}/edit`);
return res.json() as Promise<CreateEventResponse>
return res.json() as Promise<CreateEventResponse>;
}
/**
@@ -269,9 +266,9 @@ export async function deleteEvent(uid: string): Promise<void> {
method: 'DELETE',
credentials: 'include',
redirect: 'manual',
})
});
handleAuthResponse(res, `DELETE /api/events/${uid}`)
handleAuthResponse(res, `DELETE /api/events/${uid}`);
}
// ── /api/events/sync-status (Plan 03-06) ─────────────────────────────────────
@@ -280,17 +277,17 @@ export async function deleteEvent(uid: string): Promise<void> {
* Status values for an outbox write operation.
* Mirrors the calendarOutbox.status enum on the server.
*/
export type SyncStatusValue = 'pending' | 'done' | 'failed' | 'dead'
export type SyncStatusValue = 'pending' | 'done' | 'failed' | 'dead';
/**
* Response from GET /api/events/sync-status?uid=
* The server returns the current outbox status for the given UID + member.
*/
export interface SyncStatus {
uid: string
status: SyncStatusValue
uid: string;
status: SyncStatusValue;
/** Present on failed status — may contain '412' prefix for conflict detection. */
error?: string
error?: string;
}
/**
@@ -303,11 +300,11 @@ export async function fetchSyncStatus(uid: string): Promise<SyncStatus> {
const res = await fetch(`/api/events/sync-status?uid=${uid}`, {
credentials: 'include',
redirect: 'manual',
})
});
handleAuthResponse(res, 'GET /api/events/sync-status')
handleAuthResponse(res, 'GET /api/events/sync-status');
return res.json() as Promise<SyncStatus>
return res.json() as Promise<SyncStatus>;
}
/**
@@ -321,10 +318,10 @@ export async function fetchWritableCalendars(): Promise<WritableCalendar[]> {
const res = await fetch('/api/events/writable-calendars', {
credentials: 'include',
redirect: 'manual',
})
});
handleAuthResponse(res, 'GET /api/events/writable-calendars')
handleAuthResponse(res, 'GET /api/events/writable-calendars');
const body = (await res.json()) as { calendars: WritableCalendar[] }
return body.calendars
const body = (await res.json()) as { calendars: WritableCalendar[] };
return body.calendars;
}
+31 -39
View File
@@ -9,63 +9,60 @@
* Types: List, ListItem, ListsResponse, ListItemsResponse
*/
const BASE = '/api'
const BASE = '/api';
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
const res = await fetch(`${BASE}${path}`, {
credentials: 'include',
...init,
})
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`)
return res
});
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`);
return res;
}
// ── Types ──────────────────────────────────────────────────────────────────
export interface List {
id: number
name: string
isShared: boolean
ownerId: number
activeCount: number
doneCount: number
createdAt?: string
updatedAt?: string
id: number;
name: string;
isShared: boolean;
ownerId: number;
activeCount: number;
doneCount: number;
createdAt?: string;
updatedAt?: string;
}
export interface ListItem {
id: number
listId: number
text: string
checked: boolean
rank: string
createdAt?: string
updatedAt?: string
id: number;
listId: number;
text: string;
checked: boolean;
rank: string;
createdAt?: string;
updatedAt?: string;
}
export interface ListsResponse {
lists: List[]
lists: List[];
}
export interface ListItemsResponse {
items: ListItem[]
items: ListItem[];
}
// ── Functions ──────────────────────────────────────────────────────────────
export async function fetchLists(): Promise<ListsResponse> {
return apiFetch('/lists').then((r) => r.json() as Promise<ListsResponse>)
return apiFetch('/lists').then((r) => r.json() as Promise<ListsResponse>);
}
export async function createList(payload: {
name: string
isShared: boolean
}): Promise<List> {
export async function createList(payload: { name: string; isShared: boolean }): Promise<List> {
return apiFetch('/lists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then((r) => r.json() as Promise<List>)
}).then((r) => r.json() as Promise<List>);
}
export async function patchList(
@@ -76,13 +73,13 @@ export async function patchList(
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).then((r) => r.json() as Promise<List>)
}).then((r) => r.json() as Promise<List>);
}
export async function deleteList(id: number): Promise<{ id: number }> {
return apiFetch(`/lists/${id}`, {
method: 'DELETE',
}).then((r) => r.json() as Promise<{ id: number }>)
}).then((r) => r.json() as Promise<{ id: number }>);
}
// ── Item functions (LIST-02) ────────────────────────────────────────────────
@@ -92,23 +89,18 @@ export async function deleteList(id: number): Promise<{ id: number }> {
* Returns active and completed items; the UI splits them into sections.
*/
export async function fetchListItems(listId: number): Promise<ListItemsResponse> {
return apiFetch(`/lists/${listId}/items`).then(
(r) => r.json() as Promise<ListItemsResponse>,
)
return apiFetch(`/lists/${listId}/items`).then((r) => r.json() as Promise<ListItemsResponse>);
}
/**
* Add an item to a list. Server assigns the fractional rank (D-13).
*/
export async function addItem(
listId: number,
payload: { text: string },
): Promise<ListItem> {
export async function addItem(listId: number, payload: { text: string }): Promise<ListItem> {
return apiFetch(`/lists/${listId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then((r) => r.json() as Promise<ListItem>)
}).then((r) => r.json() as Promise<ListItem>);
}
/**
@@ -124,7 +116,7 @@ export async function patchListItem(
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
}).then((r) => r.json() as Promise<ListItem>)
}).then((r) => r.json() as Promise<ListItem>);
}
/**
@@ -134,5 +126,5 @@ export async function patchListItem(
export async function deleteItem(itemId: number): Promise<{ id: number }> {
return apiFetch(`/list-items/${itemId}`, {
method: 'DELETE',
}).then((r) => r.json() as Promise<{ id: number }>)
}).then((r) => r.json() as Promise<{ id: number }>);
}
+15 -15
View File
@@ -9,30 +9,30 @@
* - Submit: Enter key OR tap "Add" button
*/
import { useState, useRef } from 'react'
import { useState, useRef } from 'react';
interface AddItemInputProps {
onAdd: (text: string) => void
onAdd: (text: string) => void;
/** Whether the add mutation is pending (parent controls this for optimistic state) */
isPending?: boolean
isPending?: boolean;
}
export function AddItemInput({ onAdd, isPending = false }: AddItemInputProps) {
const [text, setText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const [text, setText] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
function handleSubmit() {
const trimmed = text.trim()
if (!trimmed) return
onAdd(trimmed)
setText('')
inputRef.current?.focus()
const trimmed = text.trim();
if (!trimmed) return;
onAdd(trimmed);
setText('');
inputRef.current?.focus();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
e.preventDefault();
handleSubmit();
}
}
@@ -74,10 +74,10 @@ export function AddItemInput({ onAdd, isPending = false }: AddItemInputProps) {
fontFamily: 'var(--font-family-base)',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--color-focus-ring, #4A90D9)'
e.currentTarget.style.borderColor = 'var(--color-focus-ring, #4A90D9)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)'
e.currentTarget.style.borderColor = 'var(--color-border)';
}}
/>
<button
@@ -101,5 +101,5 @@ export function AddItemInput({ onAdd, isPending = false }: AddItemInputProps) {
Add
</button>
</div>
)
);
}
+30 -30
View File
@@ -10,17 +10,17 @@
* is visible on both the /calendar and /lists routes.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// ── Module mocks ────────────────────────────────────────────────────────────
vi.mock('@schedule-x/react', () => ({
useCalendarApp: vi.fn(() => ({})),
ScheduleXCalendar: () => <div data-testid="schedule-x-calendar" />,
}))
}));
vi.mock('@schedule-x/events-service', () => ({
createEventsServicePlugin: vi.fn(() => ({
@@ -32,51 +32,51 @@ vi.mock('@schedule-x/events-service', () => ({
remove: vi.fn(),
update: vi.fn(),
})),
}))
}));
vi.mock('@schedule-x/event-modal', () => ({
createEventModalPlugin: vi.fn(() => ({ name: 'event-modal' })),
}))
}));
vi.mock('../api/client.js', () => ({
fetchMe: vi.fn(),
fetchEvents: vi.fn(),
}))
}));
vi.mock('../api/listsClient.js', () => ({
fetchLists: vi.fn().mockResolvedValue({ lists: [] }),
deleteList: vi.fn(),
}))
}));
vi.mock('../lib/hydrateEvents.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/hydrateEvents.js')>()
return { ...actual, hydrateEvents: vi.fn(actual.hydrateEvents) }
})
const actual = await importOriginal<typeof import('../lib/hydrateEvents.js')>();
return { ...actual, hydrateEvents: vi.fn(actual.hydrateEvents) };
});
// ── Imports (after mocks) ───────────────────────────────────────────────────
import { fetchMe, fetchEvents } from '../api/client.js'
import type { Mock } from 'vitest'
import { fetchMe, fetchEvents } from '../api/client.js';
import type { Mock } from 'vitest';
// ── Helpers ─────────────────────────────────────────────────────────────────
function makeQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0 } },
})
});
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('AppNav persistence across routes (FIX 3)', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
;(fetchMe as Mock).mockResolvedValue({
vi.clearAllMocks();
sessionStorage.clear();
(fetchMe as Mock).mockResolvedValue({
user: { id: 1, displayName: 'Lucas', color: '#4A90D9' },
})
;(fetchEvents as Mock).mockResolvedValue({ occurrences: [] })
})
});
(fetchEvents as Mock).mockResolvedValue({ occurrences: [] });
});
it('renders AppNav "FamilySync" brand on /lists route (FIX 3: nav persists across routes)', async () => {
// This test verifies the fix: AppNav is a persistent app-shell element that
@@ -84,9 +84,9 @@ describe('AppNav persistence across routes (FIX 3)', () => {
// It renders the app shell layout (AppNav + Routes) with MemoryRouter at /lists
// and asserts AppNav's brand text is visible — which would fail if AppNav were
// only inside CalendarShell (which unmounts on /lists).
const { AppNav } = await import('./AppNav.js')
const { Routes, Route, MemoryRouter } = await import('react-router')
const client = makeQueryClient()
const { AppNav } = await import('./AppNav.js');
const { Routes, Route, MemoryRouter } = await import('react-router');
const client = makeQueryClient();
// Simulate the app-shell layout: AppNav is OUTSIDE Routes (persistent), and
// Routes renders the /lists page. The 'FamilySync' brand in AppNav must be visible
@@ -101,13 +101,13 @@ describe('AppNav persistence across routes (FIX 3)', () => {
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
);
// FamilySync brand text from AppNav must be present while the /lists route renders
const brand = screen.queryAllByText('FamilySync')
expect(brand.length).toBeGreaterThan(0)
const brand = screen.queryAllByText('FamilySync');
expect(brand.length).toBeGreaterThan(0);
// The Lists page route content also renders
expect(screen.getByText('Lists page')).toBeDefined()
})
})
expect(screen.getByText('Lists page')).toBeDefined();
});
});
+31 -24
View File
@@ -11,20 +11,25 @@
* - Grid is primary focal point; AppNav is secondary chrome
*/
import { NavLink } from 'react-router'
import { CalendarDays, List } from 'lucide-react'
import { ColorLegend, type LegendMember } from './ColorLegend.js'
import { NavLink } from 'react-router';
import { CalendarDays, List } from 'lucide-react';
import { ColorLegend, type LegendMember } from './ColorLegend.js';
interface AppNavProps {
members?: LegendMember[]
currentUserColor?: string
currentUserName?: string
members?: LegendMember[];
currentUserColor?: string;
currentUserName?: string;
/** Called when the user avatar is tapped — opens the Settings sheet. */
onOpenSettings?: () => void
onOpenSettings?: () => void;
}
export function AppNav({ members = [], currentUserColor, currentUserName, onOpenSettings }: AppNavProps) {
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
export function AppNav({
members = [],
currentUserColor,
currentUserName,
onOpenSettings,
}: AppNavProps) {
const isMobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
if (isMobile) {
return (
@@ -33,7 +38,7 @@ export function AppNav({ members = [], currentUserColor, currentUserName, onOpen
currentUserName={currentUserName}
onOpenSettings={onOpenSettings}
/>
)
);
}
return (
@@ -43,7 +48,7 @@ export function AppNav({ members = [], currentUserColor, currentUserName, onOpen
currentUserName={currentUserName}
onOpenSettings={onOpenSettings}
/>
)
);
}
/** Phone: 48px top bar — app name left, user avatar right */
@@ -52,12 +57,12 @@ function PhoneNav({
currentUserName,
onOpenSettings,
}: {
currentUserColor?: string
currentUserName?: string
onOpenSettings?: () => void
currentUserColor?: string;
currentUserName?: string;
onOpenSettings?: () => void;
}) {
const displayName = currentUserName ?? 'User'
const color = currentUserColor ?? 'var(--color-member-0)'
const displayName = currentUserName ?? 'User';
const color = currentUserColor ?? 'var(--color-member-0)';
return (
<header
@@ -115,7 +120,7 @@ function PhoneNav({
/>
</button>
</header>
)
);
}
/** Tablet/Desktop: 240px left sidebar — app name + nav links + color legend + avatar */
@@ -125,10 +130,10 @@ function DesktopNav({
currentUserName,
onOpenSettings,
}: {
members: LegendMember[]
currentUserColor?: string
currentUserName?: string
onOpenSettings?: () => void
members: LegendMember[];
currentUserColor?: string;
currentUserName?: string;
onOpenSettings?: () => void;
}) {
const navLinkStyle = ({ isActive }: { isActive: boolean }): React.CSSProperties => ({
display: 'flex',
@@ -140,10 +145,12 @@ function DesktopNav({
fontSize: 'var(--text-body-size, 15px)',
fontWeight: isActive ? 600 : 400,
color: isActive ? 'var(--color-member-0)' : 'var(--color-text-primary)',
background: isActive ? 'color-mix(in srgb, var(--color-member-0) 10%, transparent)' : 'transparent',
background: isActive
? 'color-mix(in srgb, var(--color-member-0) 10%, transparent)'
: 'transparent',
minHeight: '44px',
transition: 'color 0.1s ease, background 0.1s ease',
})
});
return (
<nav
@@ -252,5 +259,5 @@ function DesktopNav({
</button>
</div>
</nav>
)
);
}
+13 -15
View File
@@ -21,30 +21,30 @@
* Layout analog: SkeletonCalendar.tsx — full-screen centered column, inline styles only.
*/
import { Loader2 } from 'lucide-react'
import { clearLoginRedirect, maybeRedirectToLogin } from '../lib/loginRedirect.js'
import { Loader2 } from 'lucide-react';
import { clearLoginRedirect, maybeRedirectToLogin } from '../lib/loginRedirect.js';
export type AuthSplashState = 'loading' | 'redirecting' | 'dead-end'
export type AuthSplashState = 'loading' | 'redirecting' | 'dead-end';
export interface AuthSplashProps {
state: AuthSplashState
state: AuthSplashState;
/**
* Override the heading for the loading/redirecting states.
* Defaults to "Signing you in" (cold-load copy per UI-SPEC §Surface 1).
*/
heading?: string
heading?: string;
/**
* Override the body text for the loading/redirecting states.
* Defaults to "Taking you to the sign-in page…" (cold-load copy per UI-SPEC §Surface 1).
*/
body?: string
body?: string;
/**
* When true, renders with position:fixed inset:0 z-index:999 so the splash
* covers the entire viewport including the persistent AppNav (FIX 3).
* Use this when CalendarShell (a child of the app shell) needs to show an
* auth interstitial that hides the nav chrome.
*/
overlay?: boolean
overlay?: boolean;
}
export function AuthSplash({
@@ -53,17 +53,15 @@ export function AuthSplash({
body = 'Taking you to the sign-in page…',
overlay = false,
}: AuthSplashProps) {
const isDeadEnd = state === 'dead-end'
const showSpinner = !isDeadEnd
const isDeadEnd = state === 'dead-end';
const showSpinner = !isDeadEnd;
return (
<div
role="status"
aria-label="Signing you in"
style={{
...(overlay
? { position: 'fixed', inset: 0, zIndex: 999 }
: { height: '100dvh' }),
...(overlay ? { position: 'fixed', inset: 0, zIndex: 999 } : { height: '100dvh' }),
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
@@ -90,8 +88,8 @@ export function AuthSplash({
// Dead-end: one-shot guard has already fired — show tap-to-retry
<button
onClick={() => {
clearLoginRedirect()
maybeRedirectToLogin()
clearLoginRedirect();
maybeRedirectToLogin();
}}
style={{
background: 'none',
@@ -135,5 +133,5 @@ export function AuthSplash({
</>
)}
</div>
)
);
}
+18 -18
View File
@@ -10,16 +10,16 @@
* The BottomTabBar should NOT render on desktop.
*/
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { BottomTabBar } from './BottomTabBar.js'
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { BottomTabBar } from './BottomTabBar.js';
describe('BottomTabBar visibility (FIX 4)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
vi.clearAllMocks();
});
it('does NOT render on desktop (matchMedia max-width:767px returns false)', () => {
// test-setup.ts matchMedia polyfill returns matches:false for all queries
@@ -28,15 +28,15 @@ describe('BottomTabBar visibility (FIX 4)', () => {
<MemoryRouter>
<BottomTabBar />
</MemoryRouter>,
)
);
// The nav element should not be in the document on desktop
const nav = screen.queryByRole('navigation', { name: /Main navigation/i })
const nav = screen.queryByRole('navigation', { name: /Main navigation/i });
// BottomTabBar renders a <nav aria-label="Main navigation"> — on desktop it must be absent
// (note: AppNav also renders a <nav aria-label="Main navigation"> on desktop; but we're
// only rendering BottomTabBar here, so the absence confirms it returns null on desktop)
expect(nav).toBeNull()
})
expect(nav).toBeNull();
});
it('renders on phone (matchMedia max-width:767px returns true)', () => {
// Override matchMedia to simulate phone
@@ -52,17 +52,17 @@ describe('BottomTabBar visibility (FIX 4)', () => {
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
})
});
render(
<MemoryRouter>
<BottomTabBar />
</MemoryRouter>,
)
);
// On phone, BottomTabBar nav must be present
const nav = screen.queryByRole('navigation', { name: /Main navigation/i })
expect(nav).not.toBeNull()
const nav = screen.queryByRole('navigation', { name: /Main navigation/i });
expect(nav).not.toBeNull();
// Restore default (matches:false) for subsequent tests
Object.defineProperty(window, 'matchMedia', {
@@ -77,6 +77,6 @@ describe('BottomTabBar visibility (FIX 4)', () => {
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
})
})
})
});
});
});
+7 -7
View File
@@ -17,11 +17,11 @@
* button (FIX 4). Same breakpoint (767px) as AppNav's phone/desktop switch.
*/
import { NavLink } from 'react-router'
import { CalendarDays, List } from 'lucide-react'
import { NavLink } from 'react-router';
import { CalendarDays, List } from 'lucide-react';
function isPhone(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
}
const tabBase: React.CSSProperties = {
@@ -42,19 +42,19 @@ const tabBase: React.CSSProperties = {
transition: 'color 0.1s ease, border-color 0.1s ease',
userSelect: 'none',
WebkitTapHighlightColor: 'transparent',
}
};
const tabActiveOverride: React.CSSProperties = {
color: 'var(--color-member-0)',
borderBottom: '2px solid var(--color-member-0)',
}
};
export function BottomTabBar() {
// Phone-only: return null on desktop (≥768px) so the fixed bar does not overlay
// the AppNav sidebar's Settings/avatar button (FIX 4). Consistent with the
// isPhone() breakpoint used in AppNav and CalendarShell.
if (!isPhone()) {
return null
return null;
}
return (
@@ -97,5 +97,5 @@ export function BottomTabBar() {
<span>Lists</span>
</NavLink>
</nav>
)
);
}
+76 -76
View File
@@ -13,12 +13,12 @@
* path are both invoked without error (the Temporal hydration contracts from hydrateEvents.test.ts).
*/
import 'temporal-polyfill/global'
import React from 'react'
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router'
import 'temporal-polyfill/global';
import React from 'react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router';
// window.matchMedia is polyfilled in src/test-setup.ts (loaded via vitest.config setupFiles)
@@ -34,10 +34,10 @@ vi.mock('@schedule-x/react', () => ({
ScheduleXCalendar: ({ calendarApp }: { calendarApp: unknown }) => (
<div data-testid="schedule-x-calendar" data-has-app={calendarApp != null ? 'true' : 'false'} />
),
}))
}));
// Mock @schedule-x/events-service to capture .set() calls
const mockEventsServiceSet = vi.fn()
const mockEventsServiceSet = vi.fn();
vi.mock('@schedule-x/events-service', () => ({
createEventsServicePlugin: vi.fn(() => ({
name: 'events-service',
@@ -48,35 +48,35 @@ vi.mock('@schedule-x/events-service', () => ({
remove: vi.fn(),
update: vi.fn(),
})),
}))
}));
// Mock @schedule-x/event-modal
vi.mock('@schedule-x/event-modal', () => ({
createEventModalPlugin: vi.fn(() => ({
name: 'event-modal',
})),
}))
}));
// Mock the API client
vi.mock('../api/client.js', () => ({
fetchMe: vi.fn(),
fetchEvents: vi.fn(),
}))
}));
// ── Spy on hydrateEvents (must be after vi.mock but before imports use it) ──
vi.mock('../lib/hydrateEvents.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/hydrateEvents.js')>()
const actual = await importOriginal<typeof import('../lib/hydrateEvents.js')>();
return {
...actual,
hydrateEvents: vi.fn(actual.hydrateEvents), // spy wrapping real implementation
}
})
};
});
// ── Imports (after mocks are in place) ──────────────────────────────────────
import { fetchMe, fetchEvents } from '../api/client.js'
import { hydrateEvents } from '../lib/hydrateEvents.js'
import { CalendarShell } from './CalendarShell.js'
import { fetchMe, fetchEvents } from '../api/client.js';
import { hydrateEvents } from '../lib/hydrateEvents.js';
import { CalendarShell } from './CalendarShell.js';
// ── Fixtures ─────────────────────────────────────────────────────────────────
@@ -94,7 +94,7 @@ const TIMED_OCCURRENCE = {
allDay: false,
location: null,
description: null,
}
};
const ALLDAY_OCCURRENCE = {
id: 'allday-uid::2026-06-20',
@@ -110,7 +110,7 @@ const ALLDAY_OCCURRENCE = {
allDay: true,
location: null,
description: null,
}
};
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -123,110 +123,110 @@ function makeQueryClient() {
staleTime: 0,
},
},
})
});
}
function renderWithClient(ui: React.ReactElement) {
const client = makeQueryClient()
const client = makeQueryClient();
return render(
<MemoryRouter initialEntries={['/calendar']}>
<QueryClientProvider client={client}>{ui}</QueryClientProvider>
</MemoryRouter>,
)
);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('CalendarShell — CAL-03 render smoke', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.clearAllMocks();
// Reset the one-shot redirect guard between tests
sessionStorage.clear()
sessionStorage.clear();
// Default mock responses
;(fetchMe as Mock).mockResolvedValue({
(fetchMe as Mock).mockResolvedValue({
user: { id: 1, displayName: 'Lucas', color: '#4A90D9' },
})
;(fetchEvents as Mock).mockResolvedValue({
});
(fetchEvents as Mock).mockResolvedValue({
occurrences: [TIMED_OCCURRENCE, ALLDAY_OCCURRENCE],
})
})
});
});
it('renders without throwing with all four views configured (CAL-03 smoke)', () => {
// Validates @schedule-x/react@4.1.0 ↔ @schedule-x/calendar@4.6.0 import compatibility (Pitfall 6)
expect(() => renderWithClient(<CalendarShell />)).not.toThrow()
})
expect(() => renderWithClient(<CalendarShell />)).not.toThrow();
});
it('mounts the ScheduleXCalendar with a non-null calendarApp after data loads', async () => {
renderWithClient(<CalendarShell />)
renderWithClient(<CalendarShell />);
// CalendarShell shows SkeletonCalendar during loading; wait for data to resolve
const calEl = await screen.findByTestId('schedule-x-calendar')
expect(calEl).toBeDefined()
expect(calEl.getAttribute('data-has-app')).toBe('true')
})
const calEl = await screen.findByTestId('schedule-x-calendar');
expect(calEl).toBeDefined();
expect(calEl.getAttribute('data-has-app')).toBe('true');
});
it('calls hydrateEvents with both timed and all-day occurrences and passes them to eventsService', async () => {
renderWithClient(<CalendarShell />)
renderWithClient(<CalendarShell />);
await waitFor(() => {
expect(hydrateEvents as Mock).toHaveBeenCalled()
})
expect(hydrateEvents as Mock).toHaveBeenCalled();
});
const callArgs = (hydrateEvents as Mock).mock.calls[0][0] as typeof TIMED_OCCURRENCE[]
const ids = callArgs.map((o) => o.id)
expect(ids).toContain(TIMED_OCCURRENCE.id)
expect(ids).toContain(ALLDAY_OCCURRENCE.id)
const callArgs = (hydrateEvents as Mock).mock.calls[0][0] as (typeof TIMED_OCCURRENCE)[];
const ids = callArgs.map((o) => o.id);
expect(ids).toContain(TIMED_OCCURRENCE.id);
expect(ids).toContain(ALLDAY_OCCURRENCE.id);
// Verify eventsService.set() was called with the hydrated events
await waitFor(() => {
expect(mockEventsServiceSet).toHaveBeenCalled()
})
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType<typeof hydrateEvents>
expect(sxEvents.length).toBe(2)
})
expect(mockEventsServiceSet).toHaveBeenCalled();
});
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType<typeof hydrateEvents>;
expect(sxEvents.length).toBe(2);
});
it('converts all-day occurrence to Temporal.PlainDate (Pitfall 4 — no ISO-string rejection)', async () => {
renderWithClient(<CalendarShell />)
renderWithClient(<CalendarShell />);
await waitFor(() => {
expect(mockEventsServiceSet).toHaveBeenCalled()
})
expect(mockEventsServiceSet).toHaveBeenCalled();
});
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType<typeof hydrateEvents>
const allDayEvt = sxEvents.find((e) => e.id === ALLDAY_OCCURRENCE.id)
expect(allDayEvt).toBeDefined()
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType<typeof hydrateEvents>;
const allDayEvt = sxEvents.find((e) => e.id === ALLDAY_OCCURRENCE.id);
expect(allDayEvt).toBeDefined();
// Must be Temporal.PlainDate, NOT ZonedDateTime — guards all-day date shift (Pitfall 2/4)
expect(allDayEvt!.start).toBeInstanceOf(Temporal.PlainDate)
expect(allDayEvt!.end).toBeInstanceOf(Temporal.PlainDate)
})
expect(allDayEvt!.start).toBeInstanceOf(Temporal.PlainDate);
expect(allDayEvt!.end).toBeInstanceOf(Temporal.PlainDate);
});
it('converts timed occurrence to Temporal.ZonedDateTime (Pitfall 4 — no ISO-string rejection)', async () => {
renderWithClient(<CalendarShell />)
renderWithClient(<CalendarShell />);
await waitFor(() => {
expect(mockEventsServiceSet).toHaveBeenCalled()
})
expect(mockEventsServiceSet).toHaveBeenCalled();
});
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType<typeof hydrateEvents>
const timedEvt = sxEvents.find((e) => e.id === TIMED_OCCURRENCE.id)
expect(timedEvt).toBeDefined()
expect(timedEvt!.start).toBeInstanceOf(Temporal.ZonedDateTime)
expect(timedEvt!.end).toBeInstanceOf(Temporal.ZonedDateTime)
})
const sxEvents = mockEventsServiceSet.mock.calls[0][0] as ReturnType<typeof hydrateEvents>;
const timedEvt = sxEvents.find((e) => e.id === TIMED_OCCURRENCE.id);
expect(timedEvt).toBeDefined();
expect(timedEvt!.start).toBeInstanceOf(Temporal.ZonedDateTime);
expect(timedEvt!.end).toBeInstanceOf(Temporal.ZonedDateTime);
});
it('shows sign-in required when /api/me returns an error', () => {
;(fetchMe as Mock).mockRejectedValue(new Error('401'))
renderWithClient(<CalendarShell />)
(fetchMe as Mock).mockRejectedValue(new Error('401'));
renderWithClient(<CalendarShell />);
// Error renders asynchronously after query settles
})
});
it('renders dead-end AuthSplash when redirect guard is already exhausted (D-11)', async () => {
// Simulate the one-shot guard having already fired (prior redirect attempt)
sessionStorage.setItem('familysync.loginRedirectAttempted', '1')
;(fetchMe as Mock).mockRejectedValue(new Error('401'))
renderWithClient(<CalendarShell />)
sessionStorage.setItem('familysync.loginRedirectAttempted', '1');
(fetchMe as Mock).mockRejectedValue(new Error('401'));
renderWithClient(<CalendarShell />);
// After the auth error and the guard is exhausted, dead-end copy must be visible
const tapToRetry = await screen.findByText(/Tap here to try again/i)
expect(tapToRetry).toBeDefined()
})
})
const tapToRetry = await screen.findByText(/Tap here to try again/i);
expect(tapToRetry).toBeDefined();
});
});
+161 -164
View File
@@ -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&apos;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&apos;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>
)
);
}
+10 -17
View File
@@ -12,17 +12,17 @@
* ColorLegend is secondary chrome — no accent colors on the legend container.
*/
const SHARED_FAMILY_COLOR = '#F25C7A'
const SHARED_FAMILY_NAME = 'Family'
const SHARED_FAMILY_COLOR = '#F25C7A';
const SHARED_FAMILY_NAME = 'Family';
export interface LegendMember {
id: string
name: string
color: string
id: string;
name: string;
color: string;
}
interface ColorLegendProps {
members?: LegendMember[]
members?: LegendMember[];
}
export function ColorLegend({ members = [] }: ColorLegendProps) {
@@ -39,20 +39,13 @@ export function ColorLegend({ members = [] }: ColorLegendProps) {
>
{/* Per-member rows */}
{members.map((member) => (
<LegendRow
key={member.id}
name={member.name}
color={member.color}
/>
<LegendRow key={member.id} name={member.name} color={member.color} />
))}
{/* Shared-family row — always last */}
<LegendRow
name={SHARED_FAMILY_NAME}
color={SHARED_FAMILY_COLOR}
/>
<LegendRow name={SHARED_FAMILY_NAME} color={SHARED_FAMILY_COLOR} />
</div>
)
);
}
function LegendRow({ name, color }: { name: string; color: string }) {
@@ -90,5 +83,5 @@ function LegendRow({ name, color }: { name: string; color: string }) {
{name}
</span>
</div>
)
);
}
+51 -53
View File
@@ -19,59 +19,59 @@
* T-04-06 XSS guard: all text is static or plain-text JSX children (no dangerouslySetInnerHTML).
*/
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createList } from '../api/listsClient.js'
import type { ListsResponse } from '../api/listsClient.js'
import { useListsStore } from '../store/listsStore.js'
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createList } from '../api/listsClient.js';
import type { ListsResponse } from '../api/listsClient.js';
import { useListsStore } from '../store/listsStore.js';
export function CreateListSheet() {
const isOpen = useListsStore((s) => s.createListSheetOpen)
const setOpen = useListsStore((s) => s.setCreateListSheetOpen)
const queryClient = useQueryClient()
const isOpen = useListsStore((s) => s.createListSheetOpen);
const setOpen = useListsStore((s) => s.setCreateListSheetOpen);
const queryClient = useQueryClient();
const [name, setName] = useState('')
const [isShared, setIsShared] = useState(true) // D-01: default shared
const [attemptedEmpty, setAttemptedEmpty] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const [name, setName] = useState('');
const [isShared, setIsShared] = useState(true); // D-01: default shared
const [attemptedEmpty, setAttemptedEmpty] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus name input when sheet opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus()
inputRef.current.focus();
// Reset form state on open
setName('')
setIsShared(true)
setAttemptedEmpty(false)
setName('');
setIsShared(true);
setAttemptedEmpty(false);
}
}, [isOpen])
}, [isOpen]);
// Escape key listener
useEffect(() => {
if (!isOpen) return
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose()
handleClose();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
const handleClose = () => {
setOpen(false)
setName('')
setIsShared(true)
setAttemptedEmpty(false)
}
setOpen(false);
setName('');
setIsShared(true);
setAttemptedEmpty(false);
};
const mutation = useMutation({
mutationFn: (payload: { name: string; isShared: boolean }) => createList(payload),
onMutate: async (payload) => {
// Optimistic insert: add a temporary entry to the lists cache
await queryClient.cancelQueries({ queryKey: ['lists'] })
const previous = queryClient.getQueryData<ListsResponse>(['lists'])
const tempId = -Date.now() // negative temp ID so it won't collide with real IDs
await queryClient.cancelQueries({ queryKey: ['lists'] });
const previous = queryClient.getQueryData<ListsResponse>(['lists']);
const tempId = -Date.now(); // negative temp ID so it won't collide with real IDs
queryClient.setQueryData<ListsResponse>(['lists'], (old) => ({
lists: [
...(old?.lists ?? []),
@@ -84,39 +84,38 @@ export function CreateListSheet() {
doneCount: 0,
},
],
}))
return { previous }
}));
return { previous };
},
onError: (_err, _vars, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(['lists'], context.previous)
queryClient.setQueryData(['lists'], context.previous);
}
},
onSettled: () => {
// Always invalidate to get canonical server state; fire-and-forget (React Query handles cache update)
void queryClient.invalidateQueries({ queryKey: ['lists'] })
void queryClient.invalidateQueries({ queryKey: ['lists'] });
},
onSuccess: () => {
handleClose()
handleClose();
},
})
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!name.trim()) {
setAttemptedEmpty(true)
return
setAttemptedEmpty(true);
return;
}
mutation.mutate({ name: name.trim(), isShared })
}
mutation.mutate({ name: name.trim(), isShared });
};
if (!isOpen) return null
if (!isOpen) return null;
const isNameEmpty = !name.trim()
const inputBorderColor = attemptedEmpty && isNameEmpty
? 'var(--color-destructive)'
: 'var(--color-border)'
const isNameEmpty = !name.trim();
const inputBorderColor =
attemptedEmpty && isNameEmpty ? 'var(--color-destructive)' : 'var(--color-border)';
return (
<>
@@ -189,8 +188,8 @@ export function CreateListSheet() {
type="text"
value={name}
onChange={(e) => {
setName(e.target.value)
if (e.target.value.trim()) setAttemptedEmpty(false)
setName(e.target.value);
if (e.target.value.trim()) setAttemptedEmpty(false);
}}
placeholder="e.g. Groceries"
maxLength={255}
@@ -294,9 +293,8 @@ export function CreateListSheet() {
minHeight: '48px',
background: isNameEmpty ? 'var(--color-surface-dim)' : 'var(--color-member-0)',
color: isNameEmpty ? 'var(--color-text-muted)' : '#ffffff',
border: attemptedEmpty && isNameEmpty
? '1px solid var(--color-destructive)'
: 'none',
border:
attemptedEmpty && isNameEmpty ? '1px solid var(--color-destructive)' : 'none',
borderRadius: 'var(--space-1)',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
@@ -332,5 +330,5 @@ export function CreateListSheet() {
</form>
</div>
</>
)
);
}
@@ -11,11 +11,11 @@
* - T-03-18: failed delete toast persists (wired via lastSyncedUid → SyncStateToast)
*/
import 'temporal-polyfill/global'
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import 'temporal-polyfill/global';
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// ── Module mocks ──────────────────────────────────────────────────────────────
// vi.hoisted() required for variables referenced inside vi.mock() factories (D-03-04-hoisting)
@@ -34,7 +34,7 @@ const {
mockSetOpenEventId: vi.fn(),
mockDeleteDialogOpen: { value: true },
mockDeleteDialogUid: { value: 'event-uid-to-delete' },
}))
}));
vi.mock('../store/calendarStore.js', () => ({
useCalendarStore: (selector: (s: Record<string, unknown>) => unknown) => {
@@ -44,111 +44,111 @@ vi.mock('../store/calendarStore.js', () => ({
setDeleteDialog: mockSetDeleteDialog,
setLastSyncedUid: mockSetLastSyncedUid,
setOpenEventId: mockSetOpenEventId,
}
return selector(state)
};
return selector(state);
},
}))
}));
vi.mock('../api/client.js', () => ({
deleteEvent: mockDeleteEvent,
}))
}));
// ── Import component (after mocks) ────────────────────────────────────────────
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js';
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderDialog() {
const client = makeQueryClient()
const client = makeQueryClient();
return render(
<QueryClientProvider client={client}>
<DeleteConfirmationDialog />
</QueryClientProvider>,
)
);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('DeleteConfirmationDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDeleteDialogOpen.value = true
mockDeleteDialogUid.value = 'event-uid-to-delete'
mockDeleteEvent.mockResolvedValue(undefined)
})
vi.clearAllMocks();
mockDeleteDialogOpen.value = true;
mockDeleteDialogUid.value = 'event-uid-to-delete';
mockDeleteEvent.mockResolvedValue(undefined);
});
it('renders heading "Delete event?"', () => {
renderDialog()
expect(screen.getByRole('heading', { name: /delete event\?/i })).toBeInTheDocument()
})
renderDialog();
expect(screen.getByRole('heading', { name: /delete event\?/i })).toBeInTheDocument();
});
it('renders the body copy about Fastmail calendar', () => {
renderDialog()
renderDialog();
expect(
screen.getByText('This will be removed from your Fastmail calendar.'),
).toBeInTheDocument()
})
).toBeInTheDocument();
});
it('renders Cancel and Delete buttons', () => {
renderDialog()
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument()
})
renderDialog();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument();
});
it('Cancel button closes dialog without deleting', () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false)
expect(mockDeleteEvent).not.toHaveBeenCalled()
})
renderDialog();
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false);
expect(mockDeleteEvent).not.toHaveBeenCalled();
});
it('Escape key closes dialog without deleting', () => {
renderDialog()
fireEvent.keyDown(document, { key: 'Escape' })
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false)
expect(mockDeleteEvent).not.toHaveBeenCalled()
})
renderDialog();
fireEvent.keyDown(document, { key: 'Escape' });
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false);
expect(mockDeleteEvent).not.toHaveBeenCalled();
});
it('Delete button calls deleteEvent with the uid', async () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }))
renderDialog();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => {
expect(mockDeleteEvent).toHaveBeenCalledWith('event-uid-to-delete')
})
})
expect(mockDeleteEvent).toHaveBeenCalledWith('event-uid-to-delete');
});
});
it('Delete button sets lastSyncedUid to the uid', async () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }))
renderDialog();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => {
expect(mockSetLastSyncedUid).toHaveBeenCalledWith('event-uid-to-delete')
})
})
expect(mockSetLastSyncedUid).toHaveBeenCalledWith('event-uid-to-delete');
});
});
it('Delete button closes dialog and popover on success', async () => {
renderDialog()
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }))
renderDialog();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => {
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false)
expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
})
})
expect(mockSetDeleteDialog).toHaveBeenCalledWith(false);
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
});
it('renders nothing when deleteDialogOpen is false', () => {
mockDeleteDialogOpen.value = false
const { container } = renderDialog()
expect(container.firstChild).toBeNull()
})
mockDeleteDialogOpen.value = false;
const { container } = renderDialog();
expect(container.firstChild).toBeNull();
});
it('has role="dialog" and aria-modal="true"', () => {
renderDialog()
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
expect(dialog).toHaveAttribute('aria-modal', 'true')
})
})
renderDialog();
const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(dialog).toHaveAttribute('aria-modal', 'true');
});
});
@@ -22,44 +22,44 @@
* Security: T-03-15 — all text rendered as plain-text JSX children.
*/
import { useEffect, useRef } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import { useCalendarStore } from '../store/calendarStore.js'
import { deleteEvent } from '../api/client.js'
import { useEffect, useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Trash2 } from 'lucide-react';
import { useCalendarStore } from '../store/calendarStore.js';
import { deleteEvent } from '../api/client.js';
// ── Component ──────────────────────────────────────────────────────────────────
export function DeleteConfirmationDialog() {
const deleteDialogOpen = useCalendarStore((s) => s.deleteDialogOpen)
const deleteDialogUid = useCalendarStore((s) => s.deleteDialogUid)
const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog)
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid)
const setOpenEventId = useCalendarStore((s) => s.setOpenEventId)
const dialogRef = useRef<HTMLDivElement>(null)
const deleteDialogOpen = useCalendarStore((s) => s.deleteDialogOpen);
const deleteDialogUid = useCalendarStore((s) => s.deleteDialogUid);
const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog);
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid);
const setOpenEventId = useCalendarStore((s) => s.setOpenEventId);
const dialogRef = useRef<HTMLDivElement>(null);
// Focus trap — focus the dialog when it opens
useEffect(() => {
if (deleteDialogOpen && dialogRef.current) {
dialogRef.current.focus()
dialogRef.current.focus();
}
}, [deleteDialogOpen])
}, [deleteDialogOpen]);
// Escape key listener — cancel without deleting (T-03-17: no accidental delete)
useEffect(() => {
if (!deleteDialogOpen) return
if (!deleteDialogOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleCancel()
handleCancel();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [deleteDialogOpen]) // eslint-disable-line react-hooks/exhaustive-deps
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [deleteDialogOpen]); // eslint-disable-line react-hooks/exhaustive-deps
const handleCancel = () => {
setDeleteDialog(false)
}
setDeleteDialog(false);
};
// TanStack mutation — DELETE /api/events/:uid (enqueued to outbox, D-05)
const mutation = useMutation({
@@ -67,21 +67,21 @@ export function DeleteConfirmationDialog() {
onSuccess: () => {
// Wire sync-toast: set the UID so SyncStateToast starts polling (D-05/D-09)
if (deleteDialogUid) {
setLastSyncedUid(deleteDialogUid)
setLastSyncedUid(deleteDialogUid);
}
// Close both dialog and popover (per UI-SPEC delete interaction step 4)
setDeleteDialog(false)
setOpenEventId(null)
setDeleteDialog(false);
setOpenEventId(null);
},
})
});
const handleDelete = () => {
if (!deleteDialogUid) return
mutation.mutate(deleteDialogUid)
}
if (!deleteDialogUid) return;
mutation.mutate(deleteDialogUid);
};
// Nothing to show when closed
if (!deleteDialogOpen) return null
if (!deleteDialogOpen) return null;
return (
<>
@@ -204,5 +204,5 @@ export function DeleteConfirmationDialog() {
</div>
</div>
</>
)
);
}
+3 -7
View File
@@ -10,7 +10,7 @@
* Only rendered when fetch succeeded AND zero events in the visible window.
*/
import { CalendarDays } from 'lucide-react'
import { CalendarDays } from 'lucide-react';
export function EmptyState() {
return (
@@ -27,11 +27,7 @@ export function EmptyState() {
flex: 1,
}}
>
<CalendarDays
size={32}
style={{ color: 'var(--color-text-muted)' }}
aria-hidden="true"
/>
<CalendarDays size={32} style={{ color: 'var(--color-text-muted)' }} aria-hidden="true" />
<div>
<h2
style={{
@@ -58,5 +54,5 @@ export function EmptyState() {
</p>
</div>
</div>
)
);
}
+10 -10
View File
@@ -7,28 +7,28 @@
* text + component stack so failures are diagnosable in the field.
*/
import React from 'react'
import React from 'react';
interface ErrorBoundaryProps {
children: React.ReactNode
children: React.ReactNode;
}
interface ErrorBoundaryState {
error: Error | null
info: string | null
error: Error | null;
info: string | null;
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null, info: null }
state: ErrorBoundaryState = { error: null, info: null };
static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> {
return { error }
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
// Log for DevTools / future telemetry; also kept in state for on-screen display.
console.error('[CalendarShell crash]', error, info.componentStack)
this.setState({ info: info.componentStack ?? null })
console.error('[CalendarShell crash]', error, info.componentStack);
this.setState({ info: info.componentStack ?? null });
}
render() {
@@ -62,8 +62,8 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
{this.state.info ? '\n\n--- component stack ---' + this.state.info : ''}
</pre>
</div>
)
);
}
return this.props.children
return this.props.children;
}
}
@@ -9,26 +9,22 @@
* - Backdrop click closes the popover
*/
import 'temporal-polyfill/global'
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import 'temporal-polyfill/global';
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// window.matchMedia polyfilled in test-setup.ts
// ── Module mocks ──────────────────────────────────────────────────────────────
const {
mockSetOpenEventId,
mockSetEventForm,
mockSetDeleteDialog,
} = vi.hoisted(() => ({
const { mockSetOpenEventId, mockSetEventForm, mockSetDeleteDialog } = vi.hoisted(() => ({
mockSetOpenEventId: vi.fn(),
mockSetEventForm: vi.fn(),
mockSetDeleteDialog: vi.fn(),
}))
let mockOpenEventId: string | null = null
}));
let mockOpenEventId: string | null = null;
vi.mock('../store/calendarStore.js', () => ({
useCalendarStore: vi.fn((selector?: (s: Record<string, unknown>) => unknown) => {
@@ -37,15 +33,15 @@ vi.mock('../store/calendarStore.js', () => ({
setOpenEventId: mockSetOpenEventId,
setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
}
if (typeof selector === 'function') return selector(state)
return state
};
if (typeof selector === 'function') return selector(state);
return state;
}),
}))
}));
// ── Fixtures ───────────────────────────────────────────────────────────────────
import type { CalendarOccurrence } from '../api/client.js'
import type { CalendarOccurrence } from '../api/client.js';
const TIMED_OCCURRENCE: CalendarOccurrence = {
id: 'test-uid::2026-06-15T10:00:00',
@@ -63,7 +59,7 @@ const TIMED_OCCURRENCE: CalendarOccurrence = {
location: 'Conference Room B',
description: 'Daily team sync meeting',
hasRrule: false,
}
};
const OCCURRENCE_WITH_HTML: CalendarOccurrence = {
...TIMED_OCCURRENCE,
@@ -72,7 +68,7 @@ const OCCURRENCE_WITH_HTML: CalendarOccurrence = {
title: '<script>alert("xss")</script>Team Meeting',
description: '<b>Bold</b> description',
location: '<img src=x onerror=alert(1)>Room',
}
};
const ALLDAY_OCCURRENCE: CalendarOccurrence = {
id: 'allday-uid::2026-06-20',
@@ -90,182 +86,182 @@ const ALLDAY_OCCURRENCE: CalendarOccurrence = {
location: null,
description: null,
hasRrule: false,
}
};
// ── Import component (after mocks are declared) ───────────────────────────────
import { EventDetailPopover } from './EventDetailPopover.js'
import { useCalendarStore } from '../store/calendarStore.js'
import { EventDetailPopover } from './EventDetailPopover.js';
import { useCalendarStore } from '../store/calendarStore.js';
// ── Helpers ───────────────────────────────────────────────────────────────────
function renderPopover(occurrence = TIMED_OCCURRENCE) {
mockOpenEventId = occurrence.id
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
mockOpenEventId = occurrence.id;
(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector?: (s: Record<string, unknown>) => unknown) => {
const state = {
openEventId: mockOpenEventId,
setOpenEventId: mockSetOpenEventId,
setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
}
if (typeof selector === 'function') return selector(state)
return state
};
if (typeof selector === 'function') return selector(state);
return state;
},
)
);
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
});
// Pre-populate the events cache so EventDetailPopover can resolve by id
client.setQueryData(['events'], { occurrences: [occurrence] })
client.setQueryData(['events'], { occurrences: [occurrence] });
return render(
<QueryClientProvider client={client}>
<EventDetailPopover />
</QueryClientProvider>,
)
);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('EventDetailPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOpenEventId = null
})
vi.clearAllMocks();
mockOpenEventId = null;
});
it('renders the event title as a heading', () => {
renderPopover(TIMED_OCCURRENCE)
expect(screen.getByRole('heading')).toHaveTextContent('Team Standup')
})
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByRole('heading')).toHaveTextContent('Team Standup');
});
it('renders location when present', () => {
renderPopover(TIMED_OCCURRENCE)
expect(screen.getByText(/Conference Room B/)).toBeDefined()
})
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByText(/Conference Room B/)).toBeDefined();
});
it('renders description when present', () => {
renderPopover(TIMED_OCCURRENCE)
expect(screen.getByText(/Daily team sync meeting/)).toBeDefined()
})
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByText(/Daily team sync meeting/)).toBeDefined();
});
it('renders owner name in footer for personal events', () => {
renderPopover(TIMED_OCCURRENCE)
renderPopover(TIMED_OCCURRENCE);
// TIMED_OCCURRENCE is personal (isShared:false) with ownerName:'Alice'
expect(screen.getByText(/Alice/)).toBeDefined()
})
expect(screen.getByText(/Alice/)).toBeDefined();
});
it('renders "Family" in footer for shared calendar events', () => {
renderPopover(ALLDAY_OCCURRENCE)
renderPopover(ALLDAY_OCCURRENCE);
// ALLDAY_OCCURRENCE has isShared:true — footer must show 'Family'
expect(screen.getByText('Family')).toBeDefined()
})
expect(screen.getByText('Family')).toBeDefined();
});
it('renders calendarName in footer when ownerName is null', () => {
const noOwnerName: CalendarOccurrence = {
...TIMED_OCCURRENCE,
id: 'no-owner-uid::2026-06-15T10:00:00',
ownerName: null,
}
renderPopover(noOwnerName)
expect(screen.getByText(/My Calendar/)).toBeDefined()
})
};
renderPopover(noOwnerName);
expect(screen.getByText(/My Calendar/)).toBeDefined();
});
it('renders an all-day event without crashing', () => {
renderPopover(ALLDAY_OCCURRENCE)
expect(screen.getByRole('heading')).toHaveTextContent('Birthday Party')
})
renderPopover(ALLDAY_OCCURRENCE);
expect(screen.getByRole('heading')).toHaveTextContent('Birthday Party');
});
it('close button has aria-label="Close"', () => {
renderPopover(TIMED_OCCURRENCE)
expect(screen.getByLabelText('Close')).toBeDefined()
})
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByLabelText('Close')).toBeDefined();
});
it('pressing Escape calls setOpenEventId(null)', () => {
renderPopover(TIMED_OCCURRENCE)
fireEvent.keyDown(document, { key: 'Escape' })
expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
})
renderPopover(TIMED_OCCURRENCE);
fireEvent.keyDown(document, { key: 'Escape' });
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
it('clicking the close button calls setOpenEventId(null)', () => {
renderPopover(TIMED_OCCURRENCE)
fireEvent.click(screen.getByLabelText('Close'))
expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
})
renderPopover(TIMED_OCCURRENCE);
fireEvent.click(screen.getByLabelText('Close'));
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
it('clicking the backdrop calls setOpenEventId(null)', () => {
renderPopover(TIMED_OCCURRENCE)
fireEvent.click(screen.getByTestId('popover-backdrop'))
expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
})
renderPopover(TIMED_OCCURRENCE);
fireEvent.click(screen.getByTestId('popover-backdrop'));
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
it('renders nothing when openEventId is null', () => {
mockOpenEventId = null
;(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
mockOpenEventId = null;
(useCalendarStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(selector?: (s: Record<string, unknown>) => unknown) => {
const state = {
openEventId: null,
setOpenEventId: mockSetOpenEventId,
setEventForm: mockSetEventForm,
setDeleteDialog: mockSetDeleteDialog,
}
if (typeof selector === 'function') return selector(state)
return state
};
if (typeof selector === 'function') return selector(state);
return state;
},
)
);
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
})
});
const { container } = render(
<QueryClientProvider client={client}>
<EventDetailPopover />
</QueryClientProvider>,
)
expect(container.firstChild).toBeNull()
})
);
expect(container.firstChild).toBeNull();
});
it('XSS guard: HTML in title renders as escaped text, not as DOM elements', () => {
renderPopover(OCCURRENCE_WITH_HTML)
const heading = screen.getByRole('heading')
renderPopover(OCCURRENCE_WITH_HTML);
const heading = screen.getByRole('heading');
// <script> must NOT be injected as a DOM element
expect(heading.innerHTML).not.toContain('<script>')
expect(heading.innerHTML).not.toContain('<script>');
// The raw text including angle brackets must appear as literal text
expect(heading.textContent).toContain('<script>alert("xss")</script>Team Meeting')
})
expect(heading.textContent).toContain('<script>alert("xss")</script>Team Meeting');
});
it('XSS guard: HTML in description renders as escaped text', () => {
renderPopover(OCCURRENCE_WITH_HTML)
const descEl = screen.getByTestId('event-description')
renderPopover(OCCURRENCE_WITH_HTML);
const descEl = screen.getByTestId('event-description');
// <b> must NOT be rendered as a bold element
expect(descEl.innerHTML).not.toContain('<b>')
expect(descEl.textContent).toContain('<b>Bold</b> description')
})
expect(descEl.innerHTML).not.toContain('<b>');
expect(descEl.textContent).toContain('<b>Bold</b> description');
});
// ── Phase 3 footer: Edit/Delete actions ────────────────────────────────────
it('footer renders an "Edit" button', () => {
renderPopover(TIMED_OCCURRENCE)
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument()
})
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
});
it('footer renders a "Delete" button', () => {
renderPopover(TIMED_OCCURRENCE)
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument()
})
renderPopover(TIMED_OCCURRENCE);
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
});
it('clicking "Edit" opens EventForm in edit mode and closes popover', () => {
renderPopover(TIMED_OCCURRENCE)
fireEvent.click(screen.getByRole('button', { name: /edit/i }))
expect(mockSetEventForm).toHaveBeenCalledWith(true, 'edit', TIMED_OCCURRENCE.uid)
expect(mockSetOpenEventId).toHaveBeenCalledWith(null)
})
renderPopover(TIMED_OCCURRENCE);
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
expect(mockSetEventForm).toHaveBeenCalledWith(true, 'edit', TIMED_OCCURRENCE.uid);
expect(mockSetOpenEventId).toHaveBeenCalledWith(null);
});
it('clicking "Delete" opens DeleteConfirmationDialog (setDeleteDialog)', () => {
renderPopover(TIMED_OCCURRENCE)
fireEvent.click(screen.getByRole('button', { name: /delete/i }))
expect(mockSetDeleteDialog).toHaveBeenCalledWith(true, TIMED_OCCURRENCE.uid)
})
renderPopover(TIMED_OCCURRENCE);
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
expect(mockSetDeleteDialog).toHaveBeenCalledWith(true, TIMED_OCCURRENCE.uid);
});
it('BUG-3 regression: IANA-bracketed start/end does not produce "Invalid Date" in rendered output', () => {
// Fastmail events are serialized with IANA bracket notation e.g. '2026-06-18T08:00:00-04:00[America/Toronto]'.
@@ -277,16 +273,16 @@ describe('EventDetailPopover', () => {
uid: 'iana-bracket-uid',
start: '2026-06-18T08:00:00-04:00[America/Toronto]',
end: '2026-06-18T09:00:00-04:00[America/Toronto]',
}
renderPopover(occurrence)
};
renderPopover(occurrence);
// The date/time text must not contain 'Invalid Date'
const dialogEl = screen.getByRole('dialog')
expect(dialogEl.textContent).not.toContain('Invalid Date')
const dialogEl = screen.getByRole('dialog');
expect(dialogEl.textContent).not.toContain('Invalid Date');
// It must contain recognizable date content (month name or a digit)
// toLocaleDateString output varies by locale; check for a digit at minimum
const dateTimeText = dialogEl.textContent ?? ''
expect(dateTimeText).toMatch(/\d/)
})
})
const dateTimeText = dialogEl.textContent ?? '';
expect(dateTimeText).toMatch(/\d/);
});
});
+59 -65
View File
@@ -22,26 +22,26 @@
* - aria-modal="true", role="dialog"
*/
import { useEffect, useRef } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { MapPin, Edit2, Trash2 } from 'lucide-react'
import { useCalendarStore } from '../store/calendarStore.js'
import type { CalendarOccurrence } from '../api/client.js'
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { MapPin, Edit2, Trash2 } from 'lucide-react';
import { useCalendarStore } from '../store/calendarStore.js';
import type { CalendarOccurrence } from '../api/client.js';
// ── Types ──────────────────────────────────────────────────────────────────
/** Shape of props passed by Schedule-X customComponents.eventModal */
interface ScheduleXEventModalProps {
calendarEvent?: {
id?: string | number
title?: string
start?: unknown
end?: unknown
calendarId?: string
location?: string
description?: string
_familySync?: { uid: string; color: string; isShared: boolean }
}
id?: string | number;
title?: string;
start?: unknown;
end?: unknown;
calendarId?: string;
location?: string;
description?: string;
_familySync?: { uid: string; color: string; isShared: boolean };
};
}
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -58,41 +58,41 @@ function formatDateTime(start: string, end: string, allDay: boolean): string {
if (allDay) {
// YYYY-MM-DD — format as a date without time
try {
const d = new Date(start + 'T00:00:00')
const d = new Date(start + 'T00:00:00');
return d.toLocaleDateString(undefined, {
weekday: 'short',
year: 'numeric',
month: 'long',
day: 'numeric',
})
});
} catch {
return start
return start;
}
}
// Timed — parse offset-aware ISO string.
// Strip trailing IANA bracket e.g. '[America/Toronto]' before passing to new Date():
// new Date() cannot parse the bracket notation and returns Invalid Date.
try {
const cleanStart = start.replace(/\[[^\]]*\]$/, '')
const cleanEnd = end.replace(/\[[^\]]*\]$/, '')
const startDate = new Date(cleanStart)
const endDate = new Date(cleanEnd)
const cleanStart = start.replace(/\[[^\]]*\]$/, '');
const cleanEnd = end.replace(/\[[^\]]*\]$/, '');
const startDate = new Date(cleanStart);
const endDate = new Date(cleanEnd);
const dateStr = startDate.toLocaleDateString(undefined, {
weekday: 'short',
month: 'long',
day: 'numeric',
})
});
const startTime = startDate.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})
});
const endTime = endDate.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})
return `${dateStr}, ${startTime} ${endTime}`
});
return `${dateStr}, ${startTime} ${endTime}`;
} catch {
return start
return start;
}
}
@@ -106,65 +106,65 @@ function formatDateTime(start: string, end: string, allDay: boolean): string {
* In standalone mode it resolves the event from TanStack Query cache.
*/
export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
const { openEventId, setOpenEventId } = useCalendarStore()
const setEventForm = useCalendarStore((s) => s.setEventForm)
const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog)
const queryClient = useQueryClient()
const dialogRef = useRef<HTMLDivElement>(null)
const { openEventId, setOpenEventId } = useCalendarStore();
const setEventForm = useCalendarStore((s) => s.setEventForm);
const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog);
const queryClient = useQueryClient();
const dialogRef = useRef<HTMLDivElement>(null);
// Resolve the event to display:
// 1. If Schedule-X passed a calendarEvent prop, use it to get the id
// 2. Otherwise use Zustand openEventId
const activeId: string | null = (() => {
if (props.calendarEvent?.id != null) {
return String(props.calendarEvent.id)
return String(props.calendarEvent.id);
}
return openEventId
})()
return openEventId;
})();
// Lookup the occurrence in TanStack Query cache.
// We search all 'events' query entries for a matching id.
const occurrence: CalendarOccurrence | null = (() => {
if (!activeId) return null
if (!activeId) return null;
// queryClient.getQueriesData returns [{queryKey, data}] entries
const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({
queryKey: ['events'],
})
});
for (const [, data] of allEntries) {
if (!data?.occurrences) continue
const found = data.occurrences.find((o) => o.id === activeId)
if (found) return found
if (!data?.occurrences) continue;
const found = data.occurrences.find((o) => o.id === activeId);
if (found) return found;
}
return null
})()
return null;
})();
// Close handler
const handleClose = () => setOpenEventId(null)
const handleClose = () => setOpenEventId(null);
// Escape key listener — add to document so it works even when focus is trapped
useEffect(() => {
if (!activeId) return
if (!activeId) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose()
handleClose();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [activeId]) // eslint-disable-line react-hooks/exhaustive-deps
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [activeId]); // eslint-disable-line react-hooks/exhaustive-deps
// Focus trap — when popover opens, focus the dialog
useEffect(() => {
if (activeId && dialogRef.current) {
dialogRef.current.focus()
dialogRef.current.focus();
}
}, [activeId])
}, [activeId]);
// Nothing to show
if (!activeId || !occurrence) return null
if (!activeId || !occurrence) return null;
// Responsive: detect phone breakpoint
const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
const dialogStyle: React.CSSProperties = isPhone
? {
@@ -198,7 +198,7 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
overflowY: 'auto',
zIndex: 200,
fontFamily: 'var(--font-family-base)',
}
};
return (
<>
@@ -316,11 +316,7 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
fontFamily: 'var(--font-family-base)',
}}
>
<MapPin
size={14}
style={{ flexShrink: 0, marginTop: '2px' }}
aria-hidden="true"
/>
<MapPin size={14} style={{ flexShrink: 0, marginTop: '2px' }} aria-hidden="true" />
{/* Plain text — XSS guard */}
<span>{occurrence.location}</span>
</div>
@@ -374,9 +370,7 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
{/* Plain text — XSS guard (T-02e-01).
Show 'Family' for shared-calendar events; owner display name for personal
events; fall back to calendarName when ownerName is null. */}
{occurrence.isShared
? 'Family'
: (occurrence.ownerName ?? occurrence.calendarName)}
{occurrence.isShared ? 'Family' : (occurrence.ownerName ?? occurrence.calendarName)}
</div>
{/* Phase 3 footer: Edit / Delete actions (D-10) */}
@@ -394,8 +388,8 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
<button
aria-label="Edit event"
onClick={() => {
setEventForm(true, 'edit', occurrence.uid)
setOpenEventId(null)
setEventForm(true, 'edit', occurrence.uid);
setOpenEventId(null);
}}
style={{
background: 'none',
@@ -424,7 +418,7 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
<button
aria-label="Delete event"
onClick={() => {
setDeleteDialog(true, occurrence.uid)
setDeleteDialog(true, occurrence.uid);
}}
style={{
background: 'none',
@@ -451,5 +445,5 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
</div>
</div>
</>
)
);
}
File diff suppressed because it is too large Load Diff
+191 -173
View File
@@ -28,29 +28,29 @@
* - 44px minimum touch targets on all interactive elements
*/
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { X, Loader2 } from 'lucide-react'
import { useCalendarStore, todayIso } from '../store/calendarStore.js'
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { X, Loader2 } from 'lucide-react';
import { useCalendarStore, todayIso } from '../store/calendarStore.js';
import {
createEvent,
updateEvent,
fetchWritableCalendars,
type CreateEventPayload,
type RecurrencePreset,
} from '../api/client.js'
import type { CalendarOccurrence } from '../api/client.js'
} from '../api/client.js';
import type { CalendarOccurrence } from '../api/client.js';
import {
serializeEventDateTime,
computeNewTimedEnd,
computeNewAllDayEnd,
} from '../lib/eventDateTime.js'
import { SeriesEditPrompt } from './SeriesEditPrompt.js'
import { useFocusTrap } from '../hooks/useFocusTrap.js'
} from '../lib/eventDateTime.js';
import { SeriesEditPrompt } from './SeriesEditPrompt.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
// ── Constants ─────────────────────────────────────────────────────────────────
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl'
const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl';
// IN-03: todayIso is now imported from calendarStore (single source of truth).
// getDefaultStartDate/getDefaultEndDate collapsed to todayIso() calls at use sites.
@@ -59,22 +59,22 @@ const LAST_CALENDAR_KEY = 'eventForm.lastCalendarUrl'
/** Determine if we're on phone breakpoint. */
function isPhoneBreakpoint(): boolean {
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
return typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
}
/** Read last-used calendar URL from localStorage (D-01). */
function readLastCalendarUrl(): string | null {
try {
return localStorage.getItem(LAST_CALENDAR_KEY)
return localStorage.getItem(LAST_CALENDAR_KEY);
} catch {
return null
return null;
}
}
/** Persist last-used calendar URL (D-01). */
function writeLastCalendarUrl(url: string): void {
try {
localStorage.setItem(LAST_CALENDAR_KEY, url)
localStorage.setItem(LAST_CALENDAR_KEY, url);
} catch {
// Ignore write failures
}
@@ -90,15 +90,15 @@ function writeLastCalendarUrl(url: string): void {
* UTC components so the roll-back is DST-safe (mirrors vevent.ts's roll-forward).
*/
function exclusiveEndToInclusiveDate(dateStr: string): string {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
if (!m) return dateStr
const [, y, mo, d] = m
const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)))
date.setUTCDate(date.getUTCDate() - 1)
const yy = String(date.getUTCFullYear())
const mm = String(date.getUTCMonth() + 1).padStart(2, '0')
const dd = String(date.getUTCDate()).padStart(2, '0')
return `${yy}-${mm}-${dd}`
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr);
if (!m) return dateStr;
const [, y, mo, d] = m;
const date = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d)));
date.setUTCDate(date.getUTCDate() - 1);
const yy = String(date.getUTCFullYear());
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
const dd = String(date.getUTCDate()).padStart(2, '0');
return `${yy}-${mm}-${dd}`;
}
/**
@@ -113,13 +113,13 @@ function exclusiveEndToInclusiveDate(dateStr: string): string {
function parseDateTime(iso: string): { date: string; time: string; ok: boolean } {
try {
// Strip IANA bracket suffix e.g. '[America/Toronto]'
const clean = iso.replace(/\[[^\]]*\]$/, '')
const clean = iso.replace(/\[[^\]]*\]$/, '');
// WR-03: test and return `clean`, not the raw `iso`. Testing `iso` would let a
// date-only value carrying a bracket suffix skip the all-day branch and fall through
// to new Date(clean); using `clean` matches the "strip IANA suffix" intent above.
if (/^\d{4}-\d{2}-\d{2}$/.test(clean)) {
// All-day date string — use as-is (no time component)
return { date: clean, time: '09:00', ok: true }
return { date: clean, time: '09:00', ok: true };
}
// WR-08: require a well-formed timed shape (date + 'T' + HH:MM) before trusting
// new Date()'s permissive parsing. Otherwise a truncated/garbled cached value like
@@ -127,23 +127,23 @@ function parseDateTime(iso: string): { date: string; time: string; ok: boolean }
// tripping the IN-02 blank-field guard. A genuinely malformed timed value now fails
// here and is reported ok:false instead of silently resolving to an unintended day.
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(clean)) {
throw new Error('Malformed timed datetime')
throw new Error('Malformed timed datetime');
}
const d = new Date(clean)
if (isNaN(d.getTime())) throw new Error('Invalid date')
const d = new Date(clean);
if (isNaN(d.getTime())) throw new Error('Invalid date');
// WR-05: use ONLY local accessors so date and time are in the same zone frame.
// Do NOT use toISOString() here — it returns UTC, which can differ from local time.
const year = String(d.getFullYear())
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const mins = String(d.getMinutes()).padStart(2, '0')
return { date: `${year}-${month}-${day}`, time: `${hours}:${mins}`, ok: true }
const year = String(d.getFullYear());
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hours = String(d.getHours()).padStart(2, '0');
const mins = String(d.getMinutes()).padStart(2, '0');
return { date: `${year}-${month}-${day}`, time: `${hours}:${mins}`, ok: true };
} catch {
// IN-02: signal failure so the CREATE path can fall back to today/09:00 (a benign
// default for a brand-new event) while the EDIT path leaves the field blank and
// blocks submit — never silently rewriting a corrupt cached value to today/09:00.
return { date: todayIso(), time: '09:00', ok: false }
return { date: todayIso(), time: '09:00', ok: false };
}
}
@@ -159,37 +159,37 @@ function initFormDateTime(
isEdit: boolean,
fallbackTime: string,
): { date: string; time: string } {
if (iso === undefined) return { date: todayIso(), time: fallbackTime }
const parsed = parseDateTime(iso)
if (parsed.ok) return { date: parsed.date, time: parsed.time }
if (iso === undefined) return { date: todayIso(), time: fallbackTime };
const parsed = parseDateTime(iso);
if (parsed.ok) return { date: parsed.date, time: parsed.time };
// Parse failed
if (isEdit) return { date: '', time: '' }
return { date: todayIso(), time: fallbackTime }
if (isEdit) return { date: '', time: '' };
return { date: todayIso(), time: fallbackTime };
}
// ── Component ─────────────────────────────────────────────────────────────────
export function EventForm() {
const { eventFormOpen, eventFormMode, eventFormUid, setEventForm } = useCalendarStore()
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid)
const queryClient = useQueryClient()
const titleRef = useRef<HTMLInputElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const { eventFormOpen, eventFormMode, eventFormUid, setEventForm } = useCalendarStore();
const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid);
const queryClient = useQueryClient();
const titleRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// ── Resolve event for edit mode ─────────────────────────────────────────────
const occurrence: CalendarOccurrence | null = (() => {
if (eventFormMode !== 'edit' || !eventFormUid) return null
if (eventFormMode !== 'edit' || !eventFormUid) return null;
const allEntries = queryClient.getQueriesData<{ occurrences: CalendarOccurrence[] }>({
queryKey: ['events'],
})
});
for (const [, data] of allEntries) {
if (!data?.occurrences) continue
const found = data.occurrences.find((o) => o.uid === eventFormUid || o.id === eventFormUid)
if (found) return found
if (!data?.occurrences) continue;
const found = data.occurrences.find((o) => o.uid === eventFormUid || o.id === eventFormUid);
if (found) return found;
}
return null
})()
return null;
})();
// ── Writable calendars (D-03) ───────────────────────────────────────────────
@@ -198,49 +198,49 @@ export function EventForm() {
queryFn: fetchWritableCalendars,
staleTime: 5 * 60 * 1000,
enabled: eventFormOpen,
})
});
// ── Form state ──────────────────────────────────────────────────────────────
const isEditMode = eventFormMode === 'edit' && !!eventFormUid
const initStart = initFormDateTime(occurrence?.start, isEditMode, '09:00')
const initEnd = initFormDateTime(occurrence?.end, isEditMode, '10:00')
const isEditMode = eventFormMode === 'edit' && !!eventFormUid;
const initStart = initFormDateTime(occurrence?.start, isEditMode, '09:00');
const initEnd = initFormDateTime(occurrence?.end, isEditMode, '10:00');
// CR-03: occurrence.end for an all-day event is the EXCLUSIVE DTEND; the form's
// end-date input is the INCLUSIVE last day. Convert when pre-filling so a re-edit
// does not re-advance the span (buildVeventString rolls forward again on write).
// IN-02: skip the roll-back when initEnd.date is blank (parse failure in edit mode).
const initEndDate =
occurrence?.allDay && initEnd.date ? exclusiveEndToInclusiveDate(initEnd.date) : initEnd.date
occurrence?.allDay && initEnd.date ? exclusiveEndToInclusiveDate(initEnd.date) : initEnd.date;
const [title, setTitle] = useState(occurrence?.title ?? '')
const [allDay, setAllDay] = useState(occurrence?.allDay ?? false)
const [startDate, setStartDate] = useState(initStart.date)
const [startTime, setStartTime] = useState(initStart.time)
const [endDate, setEndDate] = useState(initEndDate)
const [endTime, setEndTime] = useState(initEnd.time)
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none')
const [title, setTitle] = useState(occurrence?.title ?? '');
const [allDay, setAllDay] = useState(occurrence?.allDay ?? false);
const [startDate, setStartDate] = useState(initStart.date);
const [startTime, setStartTime] = useState(initStart.time);
const [endDate, setEndDate] = useState(initEndDate);
const [endTime, setEndTime] = useState(initEnd.time);
const [recurrence, setRecurrence] = useState<RecurrencePreset>('none');
// D-06: recurrence bound state — "Ends" control
const [recurrenceBound, setRecurrenceBound] = useState<'never' | 'until' | 'count'>('never')
const [recurrenceUntil, setRecurrenceUntil] = useState('')
const [recurrenceCount, setRecurrenceCount] = useState(1)
const [location, setLocation] = useState(occurrence?.location ?? '')
const [description, setDescription] = useState(occurrence?.description ?? '')
const [recurrenceBound, setRecurrenceBound] = useState<'never' | 'until' | 'count'>('never');
const [recurrenceUntil, setRecurrenceUntil] = useState('');
const [recurrenceCount, setRecurrenceCount] = useState(1);
const [location, setLocation] = useState(occurrence?.location ?? '');
const [description, setDescription] = useState(occurrence?.description ?? '');
const [calendarUrl, setCalendarUrl] = useState<string>(() => {
if (occurrence?.calendarId) {
// In edit mode we don't know the URL from occurrence, use last-used or first writable
return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? ''
return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? '';
}
return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? ''
})
return readLastCalendarUrl() ?? writableCalendars[0]?.url ?? '';
});
// Sync calendarUrl default when writableCalendars loads
useEffect(() => {
if (writableCalendars.length > 0 && !calendarUrl) {
const lastUrl = readLastCalendarUrl()
const found = lastUrl ? writableCalendars.find((c) => c.url === lastUrl) : null
setCalendarUrl(found ? found.url : writableCalendars[0].url)
const lastUrl = readLastCalendarUrl();
const found = lastUrl ? writableCalendars.find((c) => c.url === lastUrl) : null;
setCalendarUrl(found ? found.url : writableCalendars[0].url);
}
}, [writableCalendars]) // eslint-disable-line react-hooks/exhaustive-deps
}, [writableCalendars]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset form when opening (mode may change) or when occurrence resolves in cache.
// WR-03: `occurrence` (via occurrence?.uid) is in the dep array so the effect
@@ -249,22 +249,22 @@ export function EventForm() {
// has hydrated the occurrence for the requested UID.
useEffect(() => {
if (eventFormOpen) {
const editMode = eventFormMode === 'edit' && !!eventFormUid
const startParsed = initFormDateTime(occurrence?.start, editMode, '09:00')
const endParsed = initFormDateTime(occurrence?.end, editMode, '10:00')
const editMode = eventFormMode === 'edit' && !!eventFormUid;
const startParsed = initFormDateTime(occurrence?.start, editMode, '09:00');
const endParsed = initFormDateTime(occurrence?.end, editMode, '10:00');
// CR-03: see exclusiveEndToInclusiveDate — pre-fill the inclusive last day for
// all-day events so re-saving an edit does not grow the span by a day each time.
// IN-02: skip the roll-back when endParsed.date is blank (parse failure in edit mode).
const endDateValue =
occurrence?.allDay && endParsed.date
? exclusiveEndToInclusiveDate(endParsed.date)
: endParsed.date
setTitle(occurrence?.title ?? '')
setAllDay(occurrence?.allDay ?? false)
setStartDate(startParsed.date)
setStartTime(startParsed.time)
setEndDate(endDateValue)
setEndTime(endParsed.time)
: endParsed.date;
setTitle(occurrence?.title ?? '');
setAllDay(occurrence?.allDay ?? false);
setStartDate(startParsed.date);
setStartTime(startParsed.time);
setEndDate(endDateValue);
setEndTime(endParsed.time);
// WR-03 recurrence: derive from occurrence if present; default 'none' only when
// genuinely absent. Note: occurrence.recurrence is not in CalendarOccurrence type
// (the API expand contract does not expose it in v1 — D-03). We cast to any to
@@ -272,22 +272,26 @@ export function EventForm() {
// (WR-03 v1 comment: occurrence edits whose recurrence is not in the cache default
// to 'none'; this will be addressed when the occurrence/expand contract is extended).
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access -- occurrence.recurrence is not in CalendarOccurrence v1 type; cast to any to read a future API field, default 'none' when absent
const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined
setRecurrence(derivedRecurrence ?? 'none')
const derivedRecurrence = (occurrence as any)?.recurrence as RecurrencePreset | undefined;
setRecurrence(derivedRecurrence ?? 'none');
// D-06: reset bound state to defaults on form open/occurrence change
setRecurrenceBound('never')
setRecurrenceUntil('')
setRecurrenceCount(1)
setLocation(occurrence?.location ?? '')
setDescription(occurrence?.description ?? '')
setRecurrenceBound('never');
setRecurrenceUntil('');
setRecurrenceCount(1);
setLocation(occurrence?.location ?? '');
setDescription(occurrence?.description ?? '');
}
}, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]) // eslint-disable-line react-hooks/exhaustive-deps
}, [eventFormOpen, eventFormMode, eventFormUid, occurrence?.uid]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Validation state ────────────────────────────────────────────────────────
const [errors, setErrors] = useState<{ title?: string; endTime?: string; recurrenceBound?: string }>({})
const [errors, setErrors] = useState<{
title?: string;
endTime?: string;
recurrenceBound?: string;
}>({});
// D-08/D-09: series-edit confirmation prompt state
const [seriesEditPromptOpen, setSeriesEditPromptOpen] = useState(false)
const [seriesEditPromptOpen, setSeriesEditPromptOpen] = useState(false);
// ── Mutations ───────────────────────────────────────────────────────────────
@@ -298,24 +302,24 @@ export function EventForm() {
: createEvent(payload),
onSuccess: (data) => {
// Wire sync-toast: set the returned UID so SyncStateToast starts polling (D-05/D-09)
setLastSyncedUid(data.uid)
setLastSyncedUid(data.uid);
// Do NOT invalidate here — SyncStateToast does it on done/conflict (D-06/D-08)
if (calendarUrl) writeLastCalendarUrl(calendarUrl)
setEventForm(false)
if (calendarUrl) writeLastCalendarUrl(calendarUrl);
setEventForm(false);
},
})
});
// ── Handlers ────────────────────────────────────────────────────────────────
const handleClose = () => setEventForm(false)
const handleClose = () => setEventForm(false);
const handleAllDayToggle = () => {
const next = !allDay
setAllDay(next)
const next = !allDay;
setAllDay(next);
if (!next) {
// Turning off all-day: restore default times
setStartTime('09:00')
setEndTime('10:00')
setStartTime('09:00');
setEndTime('10:00');
}
if (next) {
// WR-02: turning all-day ON discards the time inputs, so a midnight-spanning
@@ -324,36 +328,36 @@ export function EventForm() {
// max(startDate, endDate) deterministically: when the end day is behind the start
// it snaps forward to a single-day event; an already-valid multi-day all-day span
// is preserved. Also clear any stale end-time error left over from the timed view.
setEndDate((prev) => (prev < startDate ? startDate : prev))
setErrors((prev) => (prev.endTime ? { ...prev, endTime: undefined } : prev))
setEndDate((prev) => (prev < startDate ? startDate : prev));
setErrors((prev) => (prev.endTime ? { ...prev, endTime: undefined } : prev));
}
}
};
const validate = (): boolean => {
const newErrors: { title?: string; endTime?: string; recurrenceBound?: string } = {}
const newErrors: { title?: string; endTime?: string; recurrenceBound?: string } = {};
if (!title.trim()) {
newErrors.title = 'Title is required'
newErrors.title = 'Title is required';
}
// IN-02: a blank start/end date means a cached value failed to parse in edit mode
// (initFormDateTime left it empty rather than substituting today/09:00). Block submit
// so the corrupt value is never silently saved as today/09:00.
if (!startDate || !endDate || (!allDay && (!startTime || !endTime))) {
newErrors.endTime = "Couldn't read this event's date — re-open it from the calendar"
setErrors(newErrors)
return false
newErrors.endTime = "Couldn't read this event's date — re-open it from the calendar";
setErrors(newErrors);
return false;
}
if (!allDay) {
const startISO = `${startDate}T${startTime}:00`
const endISO = `${endDate}T${endTime}:00`
const startISO = `${startDate}T${startTime}:00`;
const endISO = `${endDate}T${endTime}:00`;
if (endISO <= startISO) {
newErrors.endTime = 'End time must be after start'
newErrors.endTime = 'End time must be after start';
}
} else {
if (endDate < startDate) {
newErrors.endTime = 'End time must be after start'
newErrors.endTime = 'End time must be after start';
}
}
@@ -366,27 +370,27 @@ export function EventForm() {
// WR-03: NaN < 1 is false, so a NaN count (from inputs like '' / '-' / 'e')
// previously bypassed this guard AND the payload spread, yielding an unbounded
// series. Require a finite integer ≥ 1 explicitly.
newErrors.recurrenceBound = 'Must be at least 1 occurrence'
newErrors.recurrenceBound = 'Must be at least 1 occurrence';
} else if (recurrenceBound === 'until') {
// WR-02: a blank end date with bound='until' must be a validation error.
// Previously it passed validation and the payload spread dropped
// recurrenceUntil, silently creating an UNBOUNDED series — the opposite
// of the user's stated "Ends: On date" intent.
if (!recurrenceUntil) {
newErrors.recurrenceBound = 'Choose an end date'
newErrors.recurrenceBound = 'Choose an end date';
} else if (startDate && recurrenceUntil < startDate) {
// WR-07: only compare when startDate is non-empty. Both values are
// zero-padded ISO DATE strings here, so lexicographic compare is valid;
// guarding on a non-empty startDate avoids `recurrenceUntil < ''` (always
// false) silently skipping the bound-before-start guard.
newErrors.recurrenceBound = 'End date must be after the event starts'
newErrors.recurrenceBound = 'End date must be after the event starts';
}
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
/**
* Build the payload and fire the mutation. Called directly for non-recurring edits
@@ -405,7 +409,7 @@ export function EventForm() {
startTime,
endDate,
endTime,
)
);
// WR-01: on EDIT, omit `recurrence` from the payload. The occurrence/expand contract
// does not expose the event's existing recurrence (D-03), so the form cannot know it
@@ -413,7 +417,7 @@ export function EventForm() {
// recurring series into a single event. Omitting the field signals "unchanged"; the
// outbox worker then preserves the stored RRULE (see outboxWorker.ts WR-01). On
// CREATE the user explicitly chose a recurrence, so it is always sent.
const isEdit = eventFormMode === 'edit' && !!eventFormUid
const isEdit = eventFormMode === 'edit' && !!eventFormUid;
const payload: CreateEventPayload = {
title: title.trim(),
allDay,
@@ -430,65 +434,65 @@ export function EventForm() {
...(location.trim() ? { location: location.trim() } : {}),
...(description.trim() ? { description: description.trim() } : {}),
...(writableCalendars.length > 1 && calendarUrl ? { calendarUrl } : {}),
}
};
mutation.mutate(payload)
}
mutation.mutate(payload);
};
const handleSubmit = () => {
if (!validate()) return
if (!validate()) return;
const isEdit = eventFormMode === 'edit' && !!eventFormUid
const isEdit = eventFormMode === 'edit' && !!eventFormUid;
// D-08/D-09: gate recurring-series edits behind the confirmation prompt
if (isEdit && occurrence?.hasRrule === true) {
setSeriesEditPromptOpen(true)
return
setSeriesEditPromptOpen(true);
return;
}
executeSubmit()
}
executeSubmit();
};
// ── Keyboard: Escape to close ───────────────────────────────────────────────
useEffect(() => {
if (!eventFormOpen) return
if (!eventFormOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [eventFormOpen]) // eslint-disable-line react-hooks/exhaustive-deps
if (e.key === 'Escape') handleClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [eventFormOpen]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Focus trap: Tab / Shift+Tab cycles within dialog (WR-07) ───────────────
// IN-05: shared with SeriesEditPrompt via useFocusTrap so the trap logic lives once.
const handleDialogKeyDown = useFocusTrap(dialogRef)
const handleDialogKeyDown = useFocusTrap(dialogRef);
// ── Focus: move to title input on open ─────────────────────────────────────
useEffect(() => {
if (eventFormOpen && titleRef.current) {
titleRef.current.focus()
titleRef.current.focus();
}
}, [eventFormOpen])
}, [eventFormOpen]);
// ── Early return ────────────────────────────────────────────────────────────
if (!eventFormOpen) return null
if (!eventFormOpen) return null;
// ── Responsive styles ───────────────────────────────────────────────────────
const isPhone = isPhoneBreakpoint()
const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event'
const isPhone = isPhoneBreakpoint();
const label = eventFormMode === 'edit' ? 'Edit Event' : 'New Event';
// D-08/D-09: CTA label is "Update series" for recurring edits (UI-SPEC "EventForm primary CTAs")
const isEditRecurring = eventFormMode === 'edit' && occurrence?.hasRrule === true
const isEditRecurring = eventFormMode === 'edit' && occurrence?.hasRrule === true;
const saveLabel = mutation.isPending
? 'Saving…'
: isEditRecurring
? 'Update series'
: eventFormMode === 'edit'
? 'Save Changes'
: 'Create Event'
: 'Create Event';
const dialogStyle: React.CSSProperties = isPhone
? {
@@ -520,7 +524,7 @@ export function EventForm() {
overflowY: 'auto',
zIndex: 200,
fontFamily: 'var(--font-family-base)',
}
};
// ── Shared input style ──────────────────────────────────────────────────────
@@ -537,7 +541,7 @@ export function EventForm() {
color: 'var(--color-text-primary)',
fontFamily: 'var(--font-family-base)',
outline: 'none',
}
};
const labelStyle: React.CSSProperties = {
display: 'block',
@@ -545,19 +549,19 @@ export function EventForm() {
fontWeight: 'var(--text-label-weight)',
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-1)',
}
};
const fieldStyle: React.CSSProperties = {
marginBottom: 'var(--space-4)',
}
};
const errorStyle: React.CSSProperties = {
fontSize: 'var(--text-label-size)',
color: 'var(--color-destructive)',
marginTop: 'var(--space-1)',
}
};
const showPicker = writableCalendars.length > 1
const showPicker = writableCalendars.length > 1;
return (
<>
@@ -717,18 +721,23 @@ export function EventForm() {
type="date"
value={startDate}
onChange={(e) => {
const newStart = e.target.value
const newStart = e.target.value;
// D-04: recompute end to preserve duration when start date changes
if (allDay) {
setEndDate(computeNewAllDayEnd(newStart, startDate, endDate))
setEndDate(computeNewAllDayEnd(newStart, startDate, endDate));
} else {
const { endDate: ed, endTime: et } = computeNewTimedEnd(
newStart, startTime, startDate, startTime, endDate, endTime,
)
setEndDate(ed)
setEndTime(et)
newStart,
startTime,
startDate,
startTime,
endDate,
endTime,
);
setEndDate(ed);
setEndTime(et);
}
setStartDate(newStart)
setStartDate(newStart);
}}
style={inputStyle}
/>
@@ -743,14 +752,19 @@ export function EventForm() {
type="time"
value={startTime}
onChange={(e) => {
const newTime = e.target.value
const newTime = e.target.value;
// D-04: recompute end to preserve duration when start time changes (timed only)
const { endDate: ed, endTime: et } = computeNewTimedEnd(
startDate, newTime, startDate, startTime, endDate, endTime,
)
setEndDate(ed)
setEndTime(et)
setStartTime(newTime)
startDate,
newTime,
startDate,
startTime,
endDate,
endTime,
);
setEndDate(ed);
setEndTime(et);
setStartTime(newTime);
}}
style={inputStyle}
/>
@@ -926,8 +940,8 @@ export function EventForm() {
// WR-03: parseInt + Number.isFinite guard. Number('') === 0 and
// Number('-'/'e') === NaN both previously slipped through; coerce any
// non-finite intermediate to 0 so the validate() guard catches it.
const n = parseInt(e.target.value, 10)
setRecurrenceCount(Number.isFinite(n) ? n : 0)
const n = parseInt(e.target.value, 10);
setRecurrenceCount(Number.isFinite(n) ? n : 0);
}}
style={{
...inputStyle,
@@ -1031,7 +1045,11 @@ export function EventForm() {
}}
>
{mutation.isPending && (
<Loader2 size={16} aria-hidden="true" style={{ animation: 'spin 1s linear infinite' }} />
<Loader2
size={16}
aria-hidden="true"
style={{ animation: 'spin 1s linear infinite' }}
/>
)}
{/* Plain text — XSS guard (T-03-15) */}
{saveLabel}
@@ -1043,11 +1061,11 @@ export function EventForm() {
<SeriesEditPrompt
open={seriesEditPromptOpen}
onConfirm={() => {
setSeriesEditPromptOpen(false)
executeSubmit()
setSeriesEditPromptOpen(false);
executeSubmit();
}}
onCancel={() => setSeriesEditPromptOpen(false)}
/>
</>
)
);
}
+49 -49
View File
@@ -11,23 +11,23 @@
* They will turn GREEN in Plan 03-06 when the implementation is added.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
// This import fails (RED) — InstallPrompt.tsx does not exist yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore intentional RED import
import { isIOSSafariNonStandalone, useAndroidInstallPrompt } from './InstallPrompt.js'
import { isIOSSafariNonStandalone, useAndroidInstallPrompt } from './InstallPrompt.js';
// Capture the original navigator descriptors so we can restore them
const originalUserAgent = navigator.userAgent
const originalUserAgent = navigator.userAgent;
function setUserAgent(ua: string) {
Object.defineProperty(navigator, 'userAgent', {
value: ua,
writable: true,
configurable: true,
})
});
}
function setStandalone(value: boolean) {
@@ -36,7 +36,7 @@ function setStandalone(value: boolean) {
value,
writable: true,
configurable: true,
})
});
}
describe('isIOSSafariNonStandalone', () => {
@@ -46,91 +46,91 @@ describe('isIOSSafariNonStandalone', () => {
value: originalUserAgent,
writable: true,
configurable: true,
})
});
// Remove standalone mock
Object.defineProperty(navigator, 'standalone', {
value: undefined,
writable: true,
configurable: true,
})
})
});
});
it('returns true for an iOS Safari non-standalone UA', () => {
setUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) ' +
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1',
)
setStandalone(false)
);
setStandalone(false);
expect(isIOSSafariNonStandalone()).toBe(true)
})
expect(isIOSSafariNonStandalone()).toBe(true);
});
it('returns false when running in standalone mode (PWA installed)', () => {
setUserAgent(
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X) ' +
'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.4 Mobile/15E148 Safari/604.1',
)
setStandalone(true)
);
setStandalone(true);
expect(isIOSSafariNonStandalone()).toBe(false)
})
expect(isIOSSafariNonStandalone()).toBe(false);
});
it('returns false for an Android Chrome UA', () => {
setUserAgent(
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36',
)
setStandalone(false)
);
setStandalone(false);
expect(isIOSSafariNonStandalone()).toBe(false)
})
})
expect(isIOSSafariNonStandalone()).toBe(false);
});
});
describe('useAndroidInstallPrompt', () => {
beforeEach(() => {
vi.clearAllMocks()
})
vi.clearAllMocks();
});
it('sets canInstall=true when a beforeinstallprompt event is dispatched', () => {
const { result } = renderHook(() => useAndroidInstallPrompt())
expect(result.current.canInstall).toBe(false)
const { result } = renderHook(() => useAndroidInstallPrompt());
expect(result.current.canInstall).toBe(false);
const mockPromptEvent = new Event('beforeinstallprompt') as Event & {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
mockPromptEvent.prompt = vi.fn().mockResolvedValue(undefined)
mockPromptEvent.userChoice = Promise.resolve({ outcome: 'accepted' })
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
mockPromptEvent.prompt = vi.fn().mockResolvedValue(undefined);
mockPromptEvent.userChoice = Promise.resolve({ outcome: 'accepted' });
act(() => {
window.dispatchEvent(mockPromptEvent)
})
window.dispatchEvent(mockPromptEvent);
});
expect(result.current.canInstall).toBe(true)
})
expect(result.current.canInstall).toBe(true);
});
it('sets canInstall=false when appinstalled event fires', () => {
const { result } = renderHook(() => useAndroidInstallPrompt())
const { result } = renderHook(() => useAndroidInstallPrompt());
// First fire beforeinstallprompt to set canInstall=true
const mockPromptEvent = new Event('beforeinstallprompt') as Event & {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
mockPromptEvent.prompt = vi.fn().mockResolvedValue(undefined)
mockPromptEvent.userChoice = Promise.resolve({ outcome: 'dismissed' })
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
};
mockPromptEvent.prompt = vi.fn().mockResolvedValue(undefined);
mockPromptEvent.userChoice = Promise.resolve({ outcome: 'dismissed' });
act(() => {
window.dispatchEvent(mockPromptEvent)
})
window.dispatchEvent(mockPromptEvent);
});
expect(result.current.canInstall).toBe(true)
expect(result.current.canInstall).toBe(true);
// Now fire appinstalled — should reset canInstall to false
act(() => {
window.dispatchEvent(new Event('appinstalled'))
})
window.dispatchEvent(new Event('appinstalled'));
});
expect(result.current.canInstall).toBe(false)
})
})
expect(result.current.canInstall).toBe(false);
});
});
+51 -52
View File
@@ -21,9 +21,9 @@
* - EU DMA iOS 17.4+ caveat: PWA may open in Safari tabs; install guide addresses this.
*/
import { useState, useEffect } from 'react'
import { Smartphone, X } from 'lucide-react'
import { PushPermissionPrompt } from './PushPermissionPrompt.js'
import { useState, useEffect } from 'react';
import { Smartphone, X } from 'lucide-react';
import { PushPermissionPrompt } from './PushPermissionPrompt.js';
// ── iOS detection ──────────────────────────────────────────────────────────
@@ -38,12 +38,11 @@ import { PushPermissionPrompt } from './PushPermissionPrompt.js'
* 3. Check navigator.standalone is NOT true (standalone = already installed).
*/
export function isIOSSafariNonStandalone(): boolean {
const ua = navigator.userAgent
const ua = navigator.userAgent;
const isIOS =
/iPad|iPhone|iPod/.test(ua) &&
!(window as unknown as { MSStream?: unknown }).MSStream
const isStandalone = (navigator as unknown as { standalone?: boolean }).standalone === true
return isIOS && !isStandalone
/iPad|iPhone|iPod/.test(ua) && !(window as unknown as { MSStream?: unknown }).MSStream;
const isStandalone = (navigator as unknown as { standalone?: boolean }).standalone === true;
return isIOS && !isStandalone;
}
// ── Installed state check ──────────────────────────────────────────────────
@@ -56,14 +55,14 @@ function isInstalled(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(navigator as unknown as { standalone?: boolean }).standalone === true
)
);
}
// ── Android beforeinstallprompt hook ─────────────────────────────────────
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
/**
@@ -75,40 +74,40 @@ interface BeforeInstallPromptEvent extends Event {
* triggerInstall: function that calls prompt() on the deferred event
*/
export function useAndroidInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
// justInstalled: set to true when the appinstalled event fires, enabling the
// post-install push permission prompt trigger (D-08).
const [justInstalled, setJustInstalled] = useState(false)
const [justInstalled, setJustInstalled] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
}
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
};
const installedHandler = () => {
setDeferredPrompt(null)
setJustInstalled(true)
}
setDeferredPrompt(null);
setJustInstalled(true);
};
window.addEventListener('beforeinstallprompt', handler)
window.addEventListener('appinstalled', installedHandler)
window.addEventListener('beforeinstallprompt', handler);
window.addEventListener('appinstalled', installedHandler);
return () => {
window.removeEventListener('beforeinstallprompt', handler)
window.removeEventListener('appinstalled', installedHandler)
}
}, [])
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('appinstalled', installedHandler);
};
}, []);
const triggerInstall = async () => {
if (!deferredPrompt) return
await deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (!deferredPrompt) return;
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setDeferredPrompt(null)
setDeferredPrompt(null);
}
}
};
return { canInstall: deferredPrompt !== null, triggerInstall, justInstalled }
return { canInstall: deferredPrompt !== null, triggerInstall, justInstalled };
}
// ── iOS Walkthrough Sheet ─────────────────────────────────────────────────
@@ -119,10 +118,10 @@ const IOS_STEPS = [
"Scroll down and tap 'Add to Home Screen'",
"Tap 'Add' in the top right",
'Open FamilySync from your Home Screen — it opens without the browser bar',
]
];
interface WalkthroughSheetProps {
onClose: () => void
onClose: () => void;
}
function WalkthroughSheet({ onClose }: WalkthroughSheetProps) {
@@ -142,7 +141,7 @@ function WalkthroughSheet({ onClose }: WalkthroughSheetProps) {
}}
onClick={(e) => {
// Close on backdrop click
if (e.target === e.currentTarget) onClose()
if (e.target === e.currentTarget) onClose();
}}
>
<div
@@ -272,7 +271,7 @@ function WalkthroughSheet({ onClose }: WalkthroughSheetProps) {
</button>
</div>
</div>
)
);
}
// ── InstallPrompt ─────────────────────────────────────────────────────────
@@ -290,44 +289,44 @@ function WalkthroughSheet({ onClose }: WalkthroughSheetProps) {
// degrades gracefully (treated as "not dismissed") instead of crashing the component.
function readDismissed(): boolean {
try {
return localStorage.getItem('installPromptDismissed') === '1'
return localStorage.getItem('installPromptDismissed') === '1';
} catch {
return false
return false;
}
}
function persistDismissed(): void {
try {
localStorage.setItem('installPromptDismissed', '1')
localStorage.setItem('installPromptDismissed', '1');
} catch {
// Ignore write failures (private mode / storage disabled)
}
}
export function InstallPrompt() {
const [dismissed, setDismissed] = useState<boolean>(readDismissed)
const [walkthroughOpen, setWalkthroughOpen] = useState(false)
const { canInstall, triggerInstall, justInstalled } = useAndroidInstallPrompt()
const [dismissed, setDismissed] = useState<boolean>(readDismissed);
const [walkthroughOpen, setWalkthroughOpen] = useState(false);
const { canInstall, triggerInstall, justInstalled } = useAndroidInstallPrompt();
// Re-check isInstalled on mount — matchMedia is only available in the browser
const [installed, setInstalled] = useState(false)
const [installed, setInstalled] = useState(false);
useEffect(() => {
setInstalled(isInstalled())
}, [])
setInstalled(isInstalled());
}, []);
// Show post-install permission prompt (D-08) immediately after the appinstalled event
// fires — before the page knows it is in standalone mode. This covers Android Chrome
// where the page stays loaded after install without a reload.
if (justInstalled) {
return <PushPermissionPrompt />
return <PushPermissionPrompt />;
}
// Nothing to show when already installed (App.tsx mounts PushPermissionPrompt there)
if (installed) return null
if (installed) return null;
function dismiss() {
persistDismissed()
setDismissed(true)
persistDismissed();
setDismissed(true);
}
// ── iOS banner ──────────────────────────────────────────────────────────
@@ -416,7 +415,7 @@ export function InstallPrompt() {
{walkthroughOpen && <WalkthroughSheet onClose={() => setWalkthroughOpen(false)} />}
</>
)
);
}
// ── Android banner ──────────────────────────────────────────────────────
@@ -458,7 +457,7 @@ export function InstallPrompt() {
{/* Install CTA */}
<button
onClick={() => {
void triggerInstall().then(() => dismiss())
void triggerInstall().then(() => dismiss());
}}
style={{
background: 'var(--color-text-primary, #111318)',
@@ -499,9 +498,9 @@ export function InstallPrompt() {
<X size={16} aria-hidden="true" />
</button>
</div>
)
);
}
// Nothing applicable — desktop, already installed, or dismissed
return null
return null;
}
@@ -7,8 +7,8 @@
* - onClose (the sheet-close prop) is NOT called when the dialog opens
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
// ── Module mocks ──────────────────────────────────────────────────────────────
@@ -20,7 +20,7 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
setEnabled: vi.fn(),
})),
readNotificationsEnabled: vi.fn(() => false),
}))
}));
// ── Minimal Notification stub (jsdom lacks it) ────────────────────────────────
@@ -30,60 +30,60 @@ beforeEach(() => {
value: { permission: 'denied' },
writable: true,
configurable: true,
})
});
} else {
Object.defineProperty(globalThis.Notification, 'permission', {
value: 'denied',
writable: true,
configurable: true,
})
});
}
})
});
// ── Import component (after mocks) ────────────────────────────────────────────
import { SettingsSheet } from './SettingsSheet.js'
import { SettingsSheet } from './SettingsSheet.js';
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('SettingsSheet — "How to enable" wiring (UAT-05-T4)', () => {
it('clicking "How to enable" opens the InstructionSheet dialog and does NOT call onClose', () => {
const onCloseSpy = vi.fn()
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />)
const onCloseSpy = vi.fn();
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
// No instruction dialog yet
expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull()
expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull();
// Click the "How to enable" button
const howToEnableBtn = screen.getByText('How to enable')
fireEvent.click(howToEnableBtn)
const howToEnableBtn = screen.getByText('How to enable');
fireEvent.click(howToEnableBtn);
// The InstructionSheet dialog should now be visible
const instructionDialog = screen.getByRole('dialog', { name: /re-enable notifications/i })
expect(instructionDialog).toBeDefined()
const instructionDialog = screen.getByRole('dialog', { name: /re-enable notifications/i });
expect(instructionDialog).toBeDefined();
// The heading inside the dialog
expect(screen.getByText('How to enable notifications')).toBeDefined()
expect(screen.getByText('How to enable notifications')).toBeDefined();
// onClose (sheet close prop) must NOT have been called
expect(onCloseSpy).not.toHaveBeenCalled()
})
expect(onCloseSpy).not.toHaveBeenCalled();
});
it('InstructionSheet "Done" button closes the instruction dialog without calling sheet onClose', () => {
const onCloseSpy = vi.fn()
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />)
const onCloseSpy = vi.fn();
render(<SettingsSheet isOpen={true} onClose={onCloseSpy} />);
// Open the instruction sheet
fireEvent.click(screen.getByText('How to enable'))
expect(screen.getByRole('dialog', { name: /re-enable notifications/i })).toBeDefined()
fireEvent.click(screen.getByText('How to enable'));
expect(screen.getByRole('dialog', { name: /re-enable notifications/i })).toBeDefined();
// Click Done — closes the instruction sheet
fireEvent.click(screen.getByText('Done'))
fireEvent.click(screen.getByText('Done'));
// Instruction dialog should be gone
expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull()
expect(screen.queryByRole('dialog', { name: /re-enable notifications/i })).toBeNull();
// Sheet onClose must NOT have been called
expect(onCloseSpy).not.toHaveBeenCalled()
})
})
expect(onCloseSpy).not.toHaveBeenCalled();
});
});
+11 -9
View File
@@ -7,13 +7,15 @@
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
*/
import { X } from 'lucide-react'
import { X } from 'lucide-react';
// ── OS detection ──────────────────────────────────────────────────────────
function isIOS(): boolean {
return /iPad|iPhone|iPod/.test(navigator.userAgent) &&
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
!(window as unknown as { MSStream?: unknown }).MSStream
);
}
// ── Instruction steps ────────────────────────────────────────────────────
@@ -23,24 +25,24 @@ const IOS_STEPS = [
'Scroll down and tap Safari',
'Tap Notifications',
'Allow notifications for FamilySync',
]
];
const ANDROID_STEPS = [
'Open your browser (Chrome or Edge) on your phone',
'Tap the three-dot menu → Settings',
'Tap Site Settings → Notifications',
'Find FamilySync and tap Allow',
]
];
// ── Instruction sheet ────────────────────────────────────────────────────
interface InstructionSheetProps {
onClose: () => void
onClose: () => void;
}
export function InstructionSheet({ onClose }: InstructionSheetProps) {
const steps = isIOS() ? IOS_STEPS : ANDROID_STEPS
const platform = isIOS() ? 'iOS' : 'Android'
const steps = isIOS() ? IOS_STEPS : ANDROID_STEPS;
const platform = isIOS() ? 'iOS' : 'Android';
return (
<div
@@ -57,7 +59,7 @@ export function InstructionSheet({ onClose }: InstructionSheetProps) {
zIndex: 1000,
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
if (e.target === e.currentTarget) onClose();
}}
>
<div
@@ -185,5 +187,5 @@ export function InstructionSheet({ onClose }: InstructionSheetProps) {
</button>
</div>
</div>
)
);
}
+37 -44
View File
@@ -22,30 +22,32 @@
* - Delete: caller removes item from cache; no rollback (D-09)
*/
import { useState } from 'react'
import { GripVertical, Trash2 } from 'lucide-react'
import { useSortable } from '@dnd-kit/sortable'
import type { ListItem } from '../api/listsClient.js'
import { useState } from 'react';
import { GripVertical, Trash2 } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import type { ListItem } from '../api/listsClient.js';
/**
* Convert a dnd-kit Transform object to a CSS transform string.
* Equivalent to CSS.Transform.toString() from @dnd-kit/utilities (not a direct
* dependency; inline to avoid adding @dnd-kit/utilities as a separate dep).
*/
function transformToString(transform: { x: number; y: number; scaleX: number; scaleY: number } | null): string | undefined {
if (!transform) return undefined
const { x, y } = transform
return `translate3d(${x ? Math.round(x) : 0}px, ${y ? Math.round(y) : 0}px, 0)`
function transformToString(
transform: { x: number; y: number; scaleX: number; scaleY: number } | null,
): string | undefined {
if (!transform) return undefined;
const { x, y } = transform;
return `translate3d(${x ? Math.round(x) : 0}px, ${y ? Math.round(y) : 0}px, 0)`;
}
interface ItemRowProps {
item: ListItem
item: ListItem;
/** Whether this is an active (unchecked) item — shows drag handle */
isActive: boolean
onCheck: (itemId: number, checked: boolean) => void
onDelete: (itemId: number) => void
isActive: boolean;
onCheck: (itemId: number, checked: boolean) => void;
onDelete: (itemId: number) => void;
/** Opacity for optimistic pending state (e.g. 0.6 while add is confirming) */
optimisticOpacity?: number
optimisticOpacity?: number;
}
export function ItemRow({
@@ -55,52 +57,47 @@ export function ItemRow({
onDelete,
optimisticOpacity = 1,
}: ItemRowProps) {
const [hovered, setHovered] = useState(false)
const [swipeRevealed, setSwipeRevealed] = useState(false)
const [touchStartX, setTouchStartX] = useState<number | null>(null)
const [hovered, setHovered] = useState(false);
const [swipeRevealed, setSwipeRevealed] = useState(false);
const [touchStartX, setTouchStartX] = useState<number | null>(null);
// useSortable is always called (React hook rules), but listeners are only
// attached to the handle button when isActive=true.
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id })
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
});
function handleCheckboxClick() {
onCheck(item.id, !item.checked)
onCheck(item.id, !item.checked);
}
function handleDelete() {
setSwipeRevealed(false)
onDelete(item.id)
setSwipeRevealed(false);
onDelete(item.id);
}
function handleTouchStart(e: React.TouchEvent) {
setTouchStartX(e.touches[0].clientX)
setTouchStartX(e.touches[0].clientX);
}
function handleTouchEnd(e: React.TouchEvent) {
if (touchStartX === null) return
const deltaX = touchStartX - e.changedTouches[0].clientX
if (touchStartX === null) return;
const deltaX = touchStartX - e.changedTouches[0].clientX;
if (deltaX > 60) {
// Swipe-left: reveal delete zone
setSwipeRevealed(true)
setSwipeRevealed(true);
} else if (deltaX < -20) {
// Swipe-right: hide delete zone
setSwipeRevealed(false)
setSwipeRevealed(false);
}
setTouchStartX(null)
setTouchStartX(null);
}
// D-14: transformToString + transition animates remote reorders arriving
// via SSE (Plan 06). The transition fallback 'transform 150ms ease-out' applies
// when dnd-kit's own transition is not active (i.e. for non-drag CSS changes).
const transformStr = transformToString(transform)
const computedTransition = transition ?? 'transform 150ms ease-out'
const transformStr = transformToString(transform);
const computedTransition = transition ?? 'transform 150ms ease-out';
return (
<div
@@ -129,7 +126,9 @@ export function ItemRow({
gap: 'var(--space-2)',
minHeight: '44px',
padding: 'var(--space-2) var(--space-4)',
background: isDragging ? 'var(--color-surface-raised, var(--color-surface))' : 'var(--color-surface)',
background: isDragging
? 'var(--color-surface-raised, var(--color-surface))'
: 'var(--color-surface)',
transform: swipeRevealed ? 'translateX(-80px)' : 'translateX(0)',
transition: 'transform 200ms ease',
fontFamily: 'var(--font-family-base)',
@@ -190,13 +189,7 @@ export function ItemRow({
}}
>
{item.checked && (
<svg
width="12"
height="9"
viewBox="0 0 12 9"
fill="none"
aria-hidden="true"
>
<svg width="12" height="9" viewBox="0 0 12 9" fill="none" aria-hidden="true">
<path
d="M1 4L4.5 7.5L11 1"
stroke="white"
@@ -269,5 +262,5 @@ export function ItemRow({
</button>
)}
</div>
)
);
}
+18 -18
View File
@@ -17,35 +17,35 @@
* onDelete — called when the user triggers delete from the hover/long-press X
*/
import { useNavigate } from 'react-router'
import { ChevronRight, Trash2 } from 'lucide-react'
import { useState } from 'react'
import type { List } from '../api/listsClient.js'
import { useNavigate } from 'react-router';
import { ChevronRight, Trash2 } from 'lucide-react';
import { useState } from 'react';
import type { List } from '../api/listsClient.js';
interface ListCardProps {
list: List
onDelete: (list: List) => void
list: List;
onDelete: (list: List) => void;
}
function itemCountLabel(activeCount: number, doneCount: number): string {
const total = activeCount + doneCount
if (total === 0) return '0 items'
if (doneCount === 0) return `${activeCount} item${activeCount === 1 ? '' : 's'}`
return `${activeCount} active · ${doneCount} done`
const total = activeCount + doneCount;
if (total === 0) return '0 items';
if (doneCount === 0) return `${activeCount} item${activeCount === 1 ? '' : 's'}`;
return `${activeCount} active · ${doneCount} done`;
}
export function ListCard({ list, onDelete }: ListCardProps) {
const navigate = useNavigate()
const [showDelete, setShowDelete] = useState(false)
const navigate = useNavigate();
const [showDelete, setShowDelete] = useState(false);
const handleCardClick = () => {
void navigate(`/lists/${list.id}`)
}
void navigate(`/lists/${list.id}`);
};
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation() // Don't navigate when delete is clicked
onDelete(list)
}
e.stopPropagation(); // Don't navigate when delete is clicked
onDelete(list);
};
return (
<div
@@ -172,5 +172,5 @@ export function ListCard({ list, onDelete }: ListCardProps) {
</button>
)}
</div>
)
);
}
+18 -18
View File
@@ -31,15 +31,15 @@
* isPending — whether delete mutation is in-flight
*/
import { useEffect, useRef } from 'react'
import { Trash2 } from 'lucide-react'
import type { List } from '../api/listsClient.js'
import { useEffect, useRef } from 'react';
import { Trash2 } from 'lucide-react';
import type { List } from '../api/listsClient.js';
interface ListDeleteDialogProps {
list: List | null
onClose: () => void
onConfirm: () => void
isPending?: boolean
list: List | null;
onClose: () => void;
onConfirm: () => void;
isPending?: boolean;
}
export function ListDeleteDialog({
@@ -48,28 +48,28 @@ export function ListDeleteDialog({
onConfirm,
isPending = false,
}: ListDeleteDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const dialogRef = useRef<HTMLDivElement>(null);
// Focus trap — focus the dialog when it opens
useEffect(() => {
if (list && dialogRef.current) {
dialogRef.current.focus()
dialogRef.current.focus();
}
}, [list])
}, [list]);
// Escape key listener — cancel without deleting (D-06: two-tap confirmation)
useEffect(() => {
if (!list) return
if (!list) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
onClose();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [list, onClose])
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [list, onClose]);
if (!list) return null
if (!list) return null;
return (
<>
@@ -192,5 +192,5 @@ export function ListDeleteDialog({
</div>
</div>
</>
)
);
}
+3 -7
View File
@@ -10,7 +10,7 @@
* T-04-06 XSS guard: all text is static plain-text JSX children (no user input).
*/
import { ClipboardList } from 'lucide-react'
import { ClipboardList } from 'lucide-react';
export function ListsEmptyState() {
return (
@@ -29,11 +29,7 @@ export function ListsEmptyState() {
fontFamily: 'var(--font-family-base)',
}}
>
<ClipboardList
size={32}
color="var(--color-text-muted)"
aria-hidden="true"
/>
<ClipboardList size={32} color="var(--color-text-muted)" aria-hidden="true" />
<div
style={{
fontSize: 'var(--text-heading-size, 18px)',
@@ -58,5 +54,5 @@ export function ListsEmptyState() {
Tap + to create your first shared list &mdash; Groceries, Gift Ideas, or anything else.
</div>
</div>
)
);
}
@@ -15,10 +15,10 @@
* Visible only inside ListDetail (not on ListsIndex).
*/
import type { SyncState } from '../hooks/useListSSE.js'
import type { SyncState } from '../hooks/useListSSE.js';
interface LiveSyncIndicatorProps {
state: SyncState
state: SyncState;
}
export function LiveSyncIndicator({ state }: LiveSyncIndicatorProps) {
@@ -44,7 +44,7 @@ export function LiveSyncIndicator({ state }: LiveSyncIndicatorProps) {
}}
/>
</div>
)
);
}
if (state === 'reconnecting') {
@@ -80,7 +80,7 @@ export function LiveSyncIndicator({ state }: LiveSyncIndicatorProps) {
Reconnecting
</span>
</div>
)
);
}
// disconnected — role="alert" for assertive announcement
@@ -115,5 +115,5 @@ export function LiveSyncIndicator({ state }: LiveSyncIndicatorProps) {
Updates paused
</span>
</div>
)
);
}
@@ -18,23 +18,23 @@
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
*/
import { useState } from 'react'
import { AlertCircle } from 'lucide-react'
import { readNotificationsEnabled } from '../hooks/usePushSubscription.js'
import { InstructionSheet } from './InstructionSheet.js'
import { useState } from 'react';
import { AlertCircle } from 'lucide-react';
import { readNotificationsEnabled } from '../hooks/usePushSubscription.js';
import { InstructionSheet } from './InstructionSheet.js';
// ── PermissionDeniedBanner ────────────────────────────────────────────────
export function PermissionDeniedBanner() {
const [instructionsOpen, setInstructionsOpen] = useState(false)
const [instructionsOpen, setInstructionsOpen] = useState(false);
// Only show when OS permission is 'denied' AND the user previously had notifications on.
// This is the OS-revoked case (D-10). Silent re-subscribe handles the expired-sub case.
const permissionDenied =
typeof Notification !== 'undefined' && Notification.permission === 'denied'
const wasEnabled = readNotificationsEnabled()
typeof Notification !== 'undefined' && Notification.permission === 'denied';
const wasEnabled = readNotificationsEnabled();
if (!permissionDenied || !wasEnabled) return null
if (!permissionDenied || !wasEnabled) return null;
return (
<>
@@ -94,9 +94,7 @@ export function PermissionDeniedBanner() {
</div>
</div>
{instructionsOpen && (
<InstructionSheet onClose={() => setInstructionsOpen(false)} />
)}
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
</>
)
);
}
@@ -17,26 +17,26 @@
* the pushManager.subscribe() call satisfies the iOS user-gesture requirement.
*/
import { useState, useEffect, useId } from 'react'
import { Bell, Loader2, X } from 'lucide-react'
import { usePushSubscription } from '../hooks/usePushSubscription.js'
import { useState, useEffect, useId } from 'react';
import { Bell, Loader2, X } from 'lucide-react';
import { usePushSubscription } from '../hooks/usePushSubscription.js';
// fetchVapidKey is the internal helper; we import it directly via the module
// rather than re-exporting it through usePushSubscription, since we need to
// store the resolved key in state (not just warm the cache).
async function fetchVapidKeyForPrompt(): Promise<string | null> {
try {
const cached = sessionStorage.getItem('vapidPublicKey')
if (cached) return cached
const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' })
if (!res.ok) return null
const data = (await res.json()) as { publicKey: string }
const cached = sessionStorage.getItem('vapidPublicKey');
if (cached) return cached;
const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' });
if (!res.ok) return null;
const data = (await res.json()) as { publicKey: string };
if (data.publicKey) {
sessionStorage.setItem('vapidPublicKey', data.publicKey)
sessionStorage.setItem('vapidPublicKey', data.publicKey);
}
return data.publicKey ?? null
return data.publicKey ?? null;
} catch {
return null
return null;
}
}
@@ -46,22 +46,22 @@ function isInstalled(): boolean {
return (
window.matchMedia('(display-mode: standalone)').matches ||
(navigator as unknown as { standalone?: boolean }).standalone === true
)
);
}
// ── localStorage guards ────────────────────────────────────────────────────
function readDismissed(): boolean {
try {
return localStorage.getItem('pushPermissionDismissed') === '1'
return localStorage.getItem('pushPermissionDismissed') === '1';
} catch {
return false
return false;
}
}
function persistDismissed(): void {
try {
localStorage.setItem('pushPermissionDismissed', '1')
localStorage.setItem('pushPermissionDismissed', '1');
} catch {
// Private mode / storage disabled — ignore
}
@@ -71,66 +71,66 @@ function persistDismissed(): void {
interface PushPermissionPromptProps {
/** Optional callback after the prompt is closed (granted or dismissed) */
onClose?: () => void
onClose?: () => void;
}
export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
const [dismissed, setDismissed] = useState<boolean>(readDismissed)
const [installed, setInstalled] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [dismissed, setDismissed] = useState<boolean>(readDismissed);
const [installed, setInstalled] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// CR-04: Pre-fetch the VAPID key into state so the tap handler can call
// subscribe(registration, vapidKey) without any await before pushManager.subscribe().
// Button is disabled until the key is ready (null = not yet loaded).
const [vapidKey, setVapidKey] = useState<string | null>(null)
const [vapidKey, setVapidKey] = useState<string | null>(null);
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the tap
// handler has ZERO awaits between the user gesture and pushManager.subscribe().
// Any await (including navigator.serviceWorker.ready) between the tap and
// pushManager.subscribe() breaks the iOS user-gesture requirement.
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null)
const headingId = useId()
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null);
const headingId = useId();
const { subscribe, permission } = usePushSubscription()
const { subscribe, permission } = usePushSubscription();
useEffect(() => {
setInstalled(isInstalled())
}, [])
setInstalled(isInstalled());
}, []);
// Pre-fetch the VAPID key into state while the prompt is visible.
// CR-04: We store the resolved key in state (not just sessionStorage) so the
// tap handler has synchronous access — no network await inside the tap path.
useEffect(() => {
if (!installed) return
if (permission !== 'default') return
if (dismissed) return
if (!installed) return;
if (permission !== 'default') return;
if (dismissed) return;
void fetchVapidKeyForPrompt().then((key) => {
if (key) setVapidKey(key)
})
}, [installed, permission, dismissed])
if (key) setVapidKey(key);
});
}, [installed, permission, dismissed]);
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration in a useEffect so the
// tap handler never has to await navigator.serviceWorker.ready.
// navigator.serviceWorker.ready resolves once the SW is active; doing this
// eagerly means the result is in state before the user can tap the button.
useEffect(() => {
if (!installed) return
if (permission !== 'default') return
if (dismissed) return
if (!navigator.serviceWorker) return
if (!installed) return;
if (permission !== 'default') return;
if (dismissed) return;
if (!navigator.serviceWorker) return;
void navigator.serviceWorker.ready.then((reg) => {
setSwRegistration(reg)
})
}, [installed, permission, dismissed])
setSwRegistration(reg);
});
}, [installed, permission, dismissed]);
// Don't render when: not installed, already granted/denied, or dismissed
if (!installed) return null
if (permission !== 'default') return null
if (dismissed) return null
if (!installed) return null;
if (permission !== 'default') return null;
if (dismissed) return null;
function handleDismiss() {
persistDismissed()
setDismissed(true)
onClose?.()
persistDismissed();
setDismissed(true);
onClose?.();
}
// onClick handler — subscribe() called synchronously (iOS user-gesture requirement).
@@ -138,34 +138,31 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
// There is ZERO await between the tap gesture and registration.pushManager.subscribe()
// inside subscribe() — the iOS gesture gate is fully satisfied.
function handleEnableClick() {
if (loading || !vapidKey || !swRegistration) return
setLoading(true)
setError(null)
if (loading || !vapidKey || !swRegistration) return;
setLoading(true);
setError(null);
// Capture both synchronously — no await in this scope before subscribe().
const resolvedVapidKey = vapidKey
const resolvedRegistration = swRegistration
const resolvedVapidKey = vapidKey;
const resolvedRegistration = swRegistration;
void (async () => {
try {
await subscribe(resolvedRegistration, resolvedVapidKey)
await subscribe(resolvedRegistration, resolvedVapidKey);
// On success: close the prompt (permission is now 'granted')
setLoading(false)
onClose?.()
setLoading(false);
onClose?.();
} catch (err) {
setLoading(false)
if (
err instanceof Error &&
err.name === 'NotAllowedError'
) {
setLoading(false);
if (err instanceof Error && err.name === 'NotAllowedError') {
// User denied in the native browser dialog — close the sheet
setDismissed(true)
onClose?.()
setDismissed(true);
onClose?.();
} else {
setError('Something went wrong. Please try again.')
setError('Something went wrong. Please try again.');
}
}
})()
})();
}
return (
@@ -250,11 +247,7 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
textAlign: 'center',
}}
>
<Bell
size={40}
aria-hidden="true"
style={{ color: 'var(--color-member-0, #4A90D9)' }}
/>
<Bell size={40} aria-hidden="true" style={{ color: 'var(--color-member-0, #4A90D9)' }} />
<p
style={{
margin: 0,
@@ -353,7 +346,6 @@ export function PushPermissionPrompt({ onClose }: PushPermissionPromptProps) {
</button>
</div>
</div>
</div>
)
);
}
+20 -20
View File
@@ -22,46 +22,46 @@
* NEVER use dangerouslySetInnerHTML here.
*/
import { useEffect, useRef } from 'react'
import { useFocusTrap } from '../hooks/useFocusTrap.js'
import { useEffect, useRef } from 'react';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
// ── Component ─────────────────────────────────────────────────────────────────
interface SeriesEditPromptProps {
open: boolean
onConfirm: () => void
onCancel: () => void
open: boolean;
onConfirm: () => void;
onCancel: () => void;
}
export function SeriesEditPrompt({ open, onConfirm, onCancel }: SeriesEditPromptProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const headingId = 'series-edit-prompt-heading'
const dialogRef = useRef<HTMLDivElement>(null);
const headingId = 'series-edit-prompt-heading';
// Focus trap — focus the dialog when it opens
useEffect(() => {
if (open && dialogRef.current) {
dialogRef.current.focus()
dialogRef.current.focus();
}
}, [open])
}, [open]);
// Escape key listener — cancel without submitting
useEffect(() => {
if (!open) return
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
onCancel();
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [open, onCancel])
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onCancel]);
// Focus trap: Tab / Shift+Tab cycles within dialog (IN-05: shared via useFocusTrap)
const handleDialogKeyDown = useFocusTrap(dialogRef)
const handleDialogKeyDown = useFocusTrap(dialogRef);
if (!open) return null
if (!open) return null;
const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
const isPhone = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches;
const dialogStyle: React.CSSProperties = isPhone
? {
@@ -89,7 +89,7 @@ export function SeriesEditPrompt({ open, onConfirm, onCancel }: SeriesEditPrompt
maxWidth: '480px',
zIndex: 400,
fontFamily: 'var(--font-family-base)',
}
};
return (
<>
@@ -195,5 +195,5 @@ export function SeriesEditPrompt({ open, onConfirm, onCancel }: SeriesEditPrompt
</div>
</div>
</>
)
);
}
+59 -59
View File
@@ -21,104 +21,104 @@
* Security: T-05-24 — all copy is plain-text JSX children, no dangerouslySetInnerHTML.
*/
import { useEffect, useRef, useState } from 'react'
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react'
import { usePushSubscription } from '../hooks/usePushSubscription.js'
import { InstructionSheet } from './InstructionSheet.js'
import { useEffect, useRef, useState } from 'react';
import { X, Bell, AlertCircle, Loader2 } from 'lucide-react';
import { usePushSubscription } from '../hooks/usePushSubscription.js';
import { InstructionSheet } from './InstructionSheet.js';
// CR-04: fetch VAPID key (from sessionStorage cache if available) for the
// tap-gated subscribe() path. Same logic as PushPermissionPrompt.
async function fetchVapidKeyForSettings(): Promise<string | null> {
try {
const cached = sessionStorage.getItem('vapidPublicKey')
if (cached) return cached
const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' })
if (!res.ok) return null
const data = (await res.json()) as { publicKey: string }
const cached = sessionStorage.getItem('vapidPublicKey');
if (cached) return cached;
const res = await fetch('/api/push/vapid-public-key', { credentials: 'include' });
if (!res.ok) return null;
const data = (await res.json()) as { publicKey: string };
if (data.publicKey) {
sessionStorage.setItem('vapidPublicKey', data.publicKey)
sessionStorage.setItem('vapidPublicKey', data.publicKey);
}
return data.publicKey ?? null
return data.publicKey ?? null;
} catch {
return null
return null;
}
}
interface SettingsSheetProps {
isOpen: boolean
onClose: () => void
isOpen: boolean;
onClose: () => void;
}
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription()
const [isTogglingOn, setIsTogglingOn] = useState(false)
const [instructionsOpen, setInstructionsOpen] = useState(false)
const { subscribe, permission, isSubscribed, setEnabled } = usePushSubscription();
const [isTogglingOn, setIsTogglingOn] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
// CR-04: pre-fetch the VAPID key into state so the toggle tap handler can call
// subscribe(registration, vapidKey) without any network await before pushManager.subscribe().
const [vapidKey, setVapidKey] = useState<string | null>(null)
const [vapidKey, setVapidKey] = useState<string | null>(null);
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the toggle
// tap handler has ZERO awaits between the user gesture and pushManager.subscribe().
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
// on when notificationsEnabled !== '0' AND permission === 'granted' AND isSubscribed
const isOn = permission === 'granted' && isSubscribed
const isOn = permission === 'granted' && isSubscribed;
// Escape key listener (CreateListSheet pattern)
useEffect(() => {
if (!isOpen) return
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [isOpen, onClose])
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
// Focus close button on open (a11y)
useEffect(() => {
if (isOpen && closeButtonRef.current) {
closeButtonRef.current.focus()
closeButtonRef.current.focus();
}
}, [isOpen])
}, [isOpen]);
// CR-04: Pre-fetch the VAPID key while the sheet is open so the OS-dialog path
// (permission === 'default') has the key ready before the user taps.
useEffect(() => {
if (!isOpen) return
if (permission === 'denied') return
if (!isOpen) return;
if (permission === 'denied') return;
void fetchVapidKeyForSettings().then((key) => {
if (key) setVapidKey(key)
})
}, [isOpen, permission])
if (key) setVapidKey(key);
});
}, [isOpen, permission]);
// NEW-CR-01: Pre-resolve the ServiceWorkerRegistration into state so the toggle
// tap handler never needs to await navigator.serviceWorker.ready. Any await
// between the tap gesture and pushManager.subscribe() breaks iOS.
useEffect(() => {
if (!isOpen) return
if (permission === 'denied') return
if (!navigator.serviceWorker) return
if (!isOpen) return;
if (permission === 'denied') return;
if (!navigator.serviceWorker) return;
void navigator.serviceWorker.ready.then((reg) => {
setSwRegistration(reg)
})
}, [isOpen, permission])
setSwRegistration(reg);
});
}, [isOpen, permission]);
if (!isOpen) return null
if (!isOpen) return null;
const handleToggle = async () => {
if (permission === 'denied') return // no-op — show hint below
if (permission === 'denied') return; // no-op — show hint below
if (isOn) {
// on → off: unsubscribe
await setEnabled(false)
await setEnabled(false);
} else if (permission === 'granted') {
// off → on, permission already granted: silent subscribe
setIsTogglingOn(true)
setIsTogglingOn(true);
try {
await setEnabled(true)
await setEnabled(true);
} finally {
setIsTogglingOn(false)
setIsTogglingOn(false);
}
} else {
// off → on, permission 'default': needs tap-gated subscribe with OS dialog
@@ -126,21 +126,21 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
// NEW-CR-01: Both vapidKey AND swRegistration are pre-resolved into state
// (useEffects above). There is ZERO await between the tap and
// registration.pushManager.subscribe() — iOS gesture gate satisfied.
if (!vapidKey || !swRegistration) return // not ready — useEffects still resolving
const resolvedVapidKey = vapidKey
const resolvedRegistration = swRegistration
setIsTogglingOn(true)
if (!vapidKey || !swRegistration) return; // not ready — useEffects still resolving
const resolvedVapidKey = vapidKey;
const resolvedRegistration = swRegistration;
setIsTogglingOn(true);
try {
await subscribe(resolvedRegistration, resolvedVapidKey)
await subscribe(resolvedRegistration, resolvedVapidKey);
} catch {
// Permission denied by OS or error — permission state will update reactively
} finally {
setIsTogglingOn(false)
setIsTogglingOn(false);
}
}
}
};
const isDisabled = permission === 'denied'
const isDisabled = permission === 'denied';
return (
<>
@@ -289,7 +289,9 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
role="switch"
aria-checked={isOn}
aria-label={isOn ? 'FamilySync Notifications, on' : 'FamilySync Notifications, off'}
onClick={() => { void handleToggle() }}
onClick={() => {
void handleToggle();
}}
disabled={isDisabled}
style={{
// 44px touch target
@@ -391,9 +393,7 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
)}
</div>
{instructionsOpen && (
<InstructionSheet onClose={() => setInstructionsOpen(false)} />
)}
{instructionsOpen && <InstructionSheet onClose={() => setInstructionsOpen(false)} />}
</>
)
);
}
+7 -7
View File
@@ -12,10 +12,10 @@
* background: linear-gradient(90deg, --color-surface-dim, --color-border-subtle, --color-surface-dim)
*/
type SkeletonVariant = 'month' | 'agenda'
type SkeletonVariant = 'month' | 'agenda';
interface SkeletonCalendarProps {
variant?: SkeletonVariant
variant?: SkeletonVariant;
}
/** Shimmer inline style — gradient + animation referencing the keyframe in tokens.css */
@@ -25,7 +25,7 @@ const shimmerStyle: React.CSSProperties = {
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
borderRadius: 'var(--space-1)',
}
};
export function SkeletonCalendar({ variant = 'month' }: SkeletonCalendarProps) {
return (
@@ -40,7 +40,7 @@ export function SkeletonCalendar({ variant = 'month' }: SkeletonCalendarProps) {
>
{variant === 'month' ? <MonthSkeleton /> : <AgendaSkeleton />}
</div>
)
);
}
/** 6×7 month grid of shimmer cells */
@@ -87,7 +87,7 @@ function MonthSkeleton() {
))}
</div>
</div>
)
);
}
/** 4 date-group blocks, 23 event rows each, varying widths */
@@ -98,7 +98,7 @@ function AgendaSkeleton() {
{ rows: [75, 60] },
{ rows: [85, 70, 65] },
{ rows: [60, 80] },
]
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
@@ -129,5 +129,5 @@ function AgendaSkeleton() {
</div>
))}
</div>
)
);
}
+135 -119
View File
@@ -11,43 +11,39 @@
* - renders nothing when lastSyncedUid is null
*/
import 'temporal-polyfill/global'
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { SyncStateToast } from './SyncStateToast.js'
import 'temporal-polyfill/global';
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SyncStateToast } from './SyncStateToast.js';
// ── Module mocks ──────────────────────────────────────────────────────────────
// vi.hoisted() required for variables referenced inside vi.mock() factories (D-03-04-hoisting)
const {
mockLastSyncedUid,
mockSetLastSyncedUid,
mockFetchSyncStatus,
} = vi.hoisted(() => {
const { mockLastSyncedUid, mockSetLastSyncedUid, mockFetchSyncStatus } = vi.hoisted(() => {
// Declare with explicit type so later null assignments (line 82+) stay type-safe
const mockLastSyncedUid: { value: string | null } = { value: 'test-uid-123' }
const mockLastSyncedUid: { value: string | null } = { value: 'test-uid-123' };
return {
mockLastSyncedUid,
mockSetLastSyncedUid: vi.fn(),
mockFetchSyncStatus: vi.fn(),
}
})
};
});
vi.mock('../store/calendarStore.js', () => ({
useCalendarStore: (selector: (s: Record<string, unknown>) => unknown) => {
const state = {
lastSyncedUid: mockLastSyncedUid.value,
setLastSyncedUid: mockSetLastSyncedUid,
}
return selector(state)
};
return selector(state);
},
}))
}));
vi.mock('../api/client.js', () => ({
fetchSyncStatus: mockFetchSyncStatus,
}))
}));
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -56,7 +52,7 @@ function makeQueryClient() {
defaultOptions: {
queries: { retry: false },
},
})
});
}
function renderToast(queryClient: QueryClient) {
@@ -64,174 +60,194 @@ function renderToast(queryClient: QueryClient) {
<QueryClientProvider client={queryClient}>
<SyncStateToast />
</QueryClientProvider>,
)
);
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('SyncStateToast', () => {
let queryClient: QueryClient
let queryClient: QueryClient;
beforeEach(() => {
vi.clearAllMocks()
queryClient = makeQueryClient()
mockLastSyncedUid.value = 'test-uid-123'
})
vi.clearAllMocks();
queryClient = makeQueryClient();
mockLastSyncedUid.value = 'test-uid-123';
});
afterEach(() => {
vi.useRealTimers()
})
vi.useRealTimers();
});
it('renders nothing when lastSyncedUid is null', () => {
mockLastSyncedUid.value = null
const { container } = renderToast(queryClient)
expect(container.firstChild).toBeNull()
})
mockLastSyncedUid.value = null;
const { container } = renderToast(queryClient);
expect(container.firstChild).toBeNull();
});
it('pending: renders "Syncing…" with role="status"', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'pending' })
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'pending' });
renderToast(queryClient)
renderToast(queryClient);
await waitFor(() => {
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByText('Syncing…')).toBeInTheDocument()
}, { timeout: 3000 })
})
await waitFor(
() => {
expect(screen.getByRole('status')).toBeInTheDocument();
expect(screen.getByText('Syncing…')).toBeInTheDocument();
},
{ timeout: 3000 },
);
});
it('done: renders "Saved"', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' })
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' });
renderToast(queryClient)
renderToast(queryClient);
await waitFor(() => {
expect(screen.getByText('Saved')).toBeInTheDocument()
}, { timeout: 3000 })
})
await waitFor(
() => {
expect(screen.getByText('Saved')).toBeInTheDocument();
},
{ timeout: 3000 },
);
});
it('done: calls queryClient.invalidateQueries for ["events"]', async () => {
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' })
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' });
renderToast(queryClient)
renderToast(queryClient);
await waitFor(() => {
expect(screen.getByText('Saved')).toBeInTheDocument()
}, { timeout: 3000 })
await waitFor(
() => {
expect(screen.getByText('Saved')).toBeInTheDocument();
},
{ timeout: 3000 },
);
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['events'] }),
)
})
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['events'] }));
});
it('done: auto-dismisses after 2s via setLastSyncedUid(null)', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' })
vi.useFakeTimers({ shouldAdvanceTime: true });
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'done' });
renderToast(queryClient)
renderToast(queryClient);
// Wait for the "Saved" state (real timer resolution happens via shouldAdvanceTime)
await act(async () => {
await vi.runAllTimersAsync()
})
await vi.runAllTimersAsync();
});
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null)
})
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null);
});
it('failed (generic): renders "Didn\'t save. Try again." with role="alert"', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' })
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' });
renderToast(queryClient)
renderToast(queryClient);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByText("Didn't save. Try again.")).toBeInTheDocument()
}, { timeout: 3000 })
})
await waitFor(
() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText("Didn't save. Try again.")).toBeInTheDocument();
},
{ timeout: 3000 },
);
});
it('failed (generic): persists and has a dismiss button', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' })
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' });
renderToast(queryClient)
renderToast(queryClient);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
}, { timeout: 3000 })
await waitFor(
() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
},
{ timeout: 3000 },
);
// Should have a dismiss button
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
expect(dismissBtn).toBeInTheDocument()
})
const dismissBtn = screen.getByRole('button', { name: /dismiss/i });
expect(dismissBtn).toBeInTheDocument();
});
it('failed (conflict/412): renders conflict copy and invalidates ["events"]', async () => {
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
mockFetchSyncStatus.mockResolvedValue({
uid: 'test-uid-123',
status: 'failed',
error: '412 Conflict',
})
});
renderToast(queryClient)
renderToast(queryClient);
await waitFor(() => {
expect(
screen.getByText('This event changed elsewhere — review the latest version'),
).toBeInTheDocument()
}, { timeout: 3000 })
await waitFor(
() => {
expect(
screen.getByText('This event changed elsewhere — review the latest version'),
).toBeInTheDocument();
},
{ timeout: 3000 },
);
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['events'] }),
)
})
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['events'] }));
});
it('dead: renders "Not saved. Check your connection." with role="alert"', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'dead' })
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'dead' });
renderToast(queryClient)
renderToast(queryClient);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
expect(screen.getByText('Not saved. Check your connection.')).toBeInTheDocument()
}, { timeout: 3000 })
})
await waitFor(
() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Not saved. Check your connection.')).toBeInTheDocument();
},
{ timeout: 3000 },
);
});
it('refetchInterval is active (3000ms) while pending', async () => {
let callCount = 0
let callCount = 0;
mockFetchSyncStatus.mockImplementation(() => {
callCount++
return Promise.resolve({ uid: 'test-uid-123', status: 'pending' })
})
callCount++;
return Promise.resolve({ uid: 'test-uid-123', status: 'pending' });
});
vi.useFakeTimers({ shouldAdvanceTime: true })
renderToast(queryClient)
vi.useFakeTimers({ shouldAdvanceTime: true });
renderToast(queryClient);
// Wait for first fetch
await act(async () => {
await vi.advanceTimersByTimeAsync(100)
})
const countAfterFirst = callCount
expect(countAfterFirst).toBeGreaterThanOrEqual(1)
await vi.advanceTimersByTimeAsync(100);
});
const countAfterFirst = callCount;
expect(countAfterFirst).toBeGreaterThanOrEqual(1);
// Advance 3s to trigger refetch
await act(async () => {
await vi.advanceTimersByTimeAsync(3100)
})
await vi.advanceTimersByTimeAsync(3100);
});
expect(callCount).toBeGreaterThan(countAfterFirst)
})
expect(callCount).toBeGreaterThan(countAfterFirst);
});
it('dismiss button on failed toast calls setLastSyncedUid(null)', async () => {
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' })
mockFetchSyncStatus.mockResolvedValue({ uid: 'test-uid-123', status: 'failed' });
renderToast(queryClient)
renderToast(queryClient);
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument()
}, { timeout: 3000 })
await waitFor(
() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
},
{ timeout: 3000 },
);
const dismissBtn = screen.getByRole('button', { name: /dismiss/i })
fireEvent.click(dismissBtn)
const dismissBtn = screen.getByRole('button', { name: /dismiss/i });
fireEvent.click(dismissBtn);
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null)
})
})
expect(mockSetLastSyncedUid).toHaveBeenCalledWith(null);
});
});
+46 -52
View File
@@ -20,21 +20,21 @@
* 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'
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()
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 invalidatedRef = useRef<string | null>(null);
const { data, isLoading } = useQuery<SyncStatus>({
queryKey: ['syncStatus', lastSyncedUid],
@@ -42,113 +42,107 @@ export function SyncStateToast() {
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
const status = query.state.data?.status;
return status === 'pending' || status === undefined ? 3000 : false;
},
staleTime: 0,
gcTime: 0,
})
});
const status = data?.status
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'
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 (!lastSyncedUid) return;
if (invalidatedRef.current === lastSyncedUid) return;
if (status === 'done' || isConflict) {
invalidatedRef.current = lastSyncedUid
void queryClient.invalidateQueries({ queryKey: ['events'] })
invalidatedRef.current = lastSyncedUid;
void queryClient.invalidateQueries({ queryKey: ['events'] });
}
}, [status, isConflict, lastSyncedUid, queryClient])
}, [status, isConflict, lastSyncedUid, queryClient]);
// Auto-dismiss after 2s on done
useEffect(() => {
if (status !== 'done') return
if (status !== 'done') return;
const timer = setTimeout(() => {
setLastSyncedUid(null)
}, 2000)
return () => clearTimeout(timer)
}, [status, setLastSyncedUid])
setLastSyncedUid(null);
}, 2000);
return () => clearTimeout(timer);
}, [status, setLastSyncedUid]);
// Reset invalidation guard when uid changes (new write)
useEffect(() => {
if (lastSyncedUid === null) {
invalidatedRef.current = null
invalidatedRef.current = null;
}
}, [lastSyncedUid])
}, [lastSyncedUid]);
// Nothing to show
if (!lastSyncedUid || (!isLoading && !data)) return null
if (!lastSyncedUid || (!isLoading && !data)) return null;
// ── State-specific content ─────────────────────────────────────────────────
const dismiss = () => setLastSyncedUid(null)
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'
status === 'failed' || status === 'dead' ? 'alert' : 'status';
// Toast copy (exact strings from UI-SPEC §Copywriting)
let copy: string
let icon: React.ReactNode
let copy: string;
let icon: React.ReactNode;
if (status === 'done') {
copy = 'Saved'
icon = (
<Check
size={14}
style={{ color: '#50C878', flexShrink: 0 }}
aria-hidden="true"
/>
)
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'
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."
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."
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.'
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…'
copy = 'Syncing…';
icon = (
<Loader2
size={14}
@@ -159,7 +153,7 @@ export function SyncStateToast() {
}}
aria-hidden="true"
/>
)
);
}
return (
@@ -221,5 +215,5 @@ export function SyncStateToast() {
</button>
)}
</div>
)
);
}
+11 -11
View File
@@ -14,37 +14,37 @@
*
* @param dialogRef - ref to the dialog container element
*/
import type { KeyboardEvent, RefObject } from 'react'
import type { KeyboardEvent, RefObject } from 'react';
export function useFocusTrap(
dialogRef: RefObject<HTMLDivElement | null>,
): (e: KeyboardEvent<HTMLDivElement>) => void {
return (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Tab' || !dialogRef.current) return
if (e.key !== 'Tab' || !dialogRef.current) return;
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
),
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1')
).filter((el) => !el.hasAttribute('disabled') && el.getAttribute('tabindex') !== '-1');
if (focusable.length === 0) return
if (focusable.length === 0) return;
const first = focusable[0]
const last = focusable[focusable.length - 1]
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
// Shift+Tab: if on first element, wrap to last
if (document.activeElement === first) {
e.preventDefault()
last.focus()
e.preventDefault();
last.focus();
}
} else {
// Tab: if on last element, wrap to first
if (document.activeElement === last) {
e.preventDefault()
first.focus()
e.preventDefault();
first.focus();
}
}
}
};
}
+169 -177
View File
@@ -11,11 +11,11 @@
* Run: pnpm --filter @familysync/pwa exec vitest run src/hooks/useListSSE.test.ts
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import type { ReactNode } from 'react'
import React from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// ---------------------------------------------------------------------------
// Mock EventSource
@@ -25,60 +25,60 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// ---------------------------------------------------------------------------
type MockEventSourceInstance = {
url: string
withCredentials: boolean
readyState: number
onopen: ((ev: Event) => void) | null
onerror: ((ev: Event) => void) | null
listeners: Map<string, Array<(ev: MessageEvent) => void>>
addEventListener: (type: string, handler: (ev: MessageEvent) => void) => void
close: () => void
url: string;
withCredentials: boolean;
readyState: number;
onopen: ((ev: Event) => void) | null;
onerror: ((ev: Event) => void) | null;
listeners: Map<string, Array<(ev: MessageEvent) => void>>;
addEventListener: (type: string, handler: (ev: MessageEvent) => void) => void;
close: () => void;
// Test helpers — trigger events
_triggerOpen: () => void
_triggerError: () => void
_triggerMessage: (type: string, data: unknown) => void
}
_triggerOpen: () => void;
_triggerError: () => void;
_triggerMessage: (type: string, data: unknown) => void;
};
let mockInstances: MockEventSourceInstance[] = []
let mockInstances: MockEventSourceInstance[] = [];
class MockEventSource {
url: string
withCredentials: boolean
readyState: number = 0
onopen: ((ev: Event) => void) | null = null
onerror: ((ev: Event) => void) | null = null
listeners: Map<string, Array<(ev: MessageEvent) => void>> = new Map()
closeCalled = false
url: string;
withCredentials: boolean;
readyState: number = 0;
onopen: ((ev: Event) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
listeners: Map<string, Array<(ev: MessageEvent) => void>> = new Map();
closeCalled = false;
constructor(url: string, init?: { withCredentials?: boolean }) {
this.url = url
this.withCredentials = init?.withCredentials ?? false
mockInstances.push(this)
this.url = url;
this.withCredentials = init?.withCredentials ?? false;
mockInstances.push(this);
}
addEventListener(type: string, handler: (ev: MessageEvent) => void) {
const existing = this.listeners.get(type) ?? []
this.listeners.set(type, [...existing, handler])
const existing = this.listeners.get(type) ?? [];
this.listeners.set(type, [...existing, handler]);
}
close() {
this.closeCalled = true
this.readyState = 2 // CLOSED
this.closeCalled = true;
this.readyState = 2; // CLOSED
}
_triggerOpen() {
this.readyState = 1 // OPEN
this.onopen?.({} as Event)
this.readyState = 1; // OPEN
this.onopen?.({} as Event);
}
_triggerError() {
this.onerror?.({} as Event)
this.onerror?.({} as Event);
}
_triggerMessage(type: string, data: unknown) {
const handlers = this.listeners.get(type) ?? []
const event = new MessageEvent(type, { data: JSON.stringify(data) })
handlers.forEach((h) => h(event))
const handlers = this.listeners.get(type) ?? [];
const event = new MessageEvent(type, { data: JSON.stringify(data) });
handlers.forEach((h) => h(event));
}
}
@@ -88,22 +88,22 @@ class MockEventSource {
function makeWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return React.createElement(QueryClientProvider, { client: queryClient }, children)
}
return React.createElement(QueryClientProvider, { client: queryClient }, children);
};
}
beforeEach(() => {
mockInstances = []
vi.useFakeTimers()
mockInstances = [];
vi.useFakeTimers();
// Replace global EventSource with mock
vi.stubGlobal('EventSource', MockEventSource)
})
vi.stubGlobal('EventSource', MockEventSource);
});
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
mockInstances = []
})
vi.useRealTimers();
vi.unstubAllGlobals();
mockInstances = [];
});
// ---------------------------------------------------------------------------
// Tests
@@ -111,222 +111,214 @@ afterEach(() => {
describe('useListSSE — D-11 bounded backoff', () => {
it('reports connected and resets attempt counter when EventSource fires open', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const stateChanges: string[] = []
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const stateChanges: string[] = [];
const { unmount } = renderHook(
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
{ wrapper: makeWrapper(queryClient) },
)
);
// Trigger open on the created EventSource
const es = mockInstances[0] as unknown as { _triggerOpen: () => void }
const es = mockInstances[0] as unknown as { _triggerOpen: () => void };
act(() => {
es._triggerOpen()
})
es._triggerOpen();
});
expect(stateChanges).toContain('connected')
unmount()
})
expect(stateChanges).toContain('connected');
unmount();
});
it('invalidates [list, listId] query on successful open (D-10 full refetch on reconnect)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { unmount } = renderHook(
() => useListSSE({ listId: 42, onStateChange: vi.fn() }),
{ wrapper: makeWrapper(queryClient) },
)
const { unmount } = renderHook(() => useListSSE({ listId: 42, onStateChange: vi.fn() }), {
wrapper: makeWrapper(queryClient),
});
act(() => {
const es = mockInstances[0] as unknown as { _triggerOpen: () => void }
es._triggerOpen()
})
const es = mockInstances[0] as unknown as { _triggerOpen: () => void };
es._triggerOpen();
});
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['list', 42] }),
)
unmount()
})
expect(invalidateSpy).toHaveBeenCalledWith(expect.objectContaining({ queryKey: ['list', 42] }));
unmount();
});
it('invalidates [list, listId] when a list-change SSE event is received', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
const { unmount } = renderHook(
() => useListSSE({ listId: 7, onStateChange: vi.fn() }),
{ wrapper: makeWrapper(queryClient) },
)
const { unmount } = renderHook(() => useListSSE({ listId: 7, onStateChange: vi.fn() }), {
wrapper: makeWrapper(queryClient),
});
act(() => {
const es = mockInstances[0] as unknown as {
_triggerOpen: () => void
_triggerMessage: (type: string, data: unknown) => void
}
es._triggerOpen()
es._triggerMessage('item:added', { type: 'item:added', listId: 7 })
})
_triggerOpen: () => void;
_triggerMessage: (type: string, data: unknown) => void;
};
es._triggerOpen();
es._triggerMessage('item:added', { type: 'item:added', listId: 7 });
});
const calls = invalidateSpy.mock.calls
const calls = invalidateSpy.mock.calls;
const hasListQuery = calls.some((args) => {
const opts = args[0] as { queryKey?: unknown[] }
return JSON.stringify(opts?.queryKey) === JSON.stringify(['list', 7])
})
expect(hasListQuery).toBe(true)
unmount()
})
const opts = args[0] as { queryKey?: unknown[] };
return JSON.stringify(opts?.queryKey) === JSON.stringify(['list', 7]);
});
expect(hasListQuery).toBe(true);
unmount();
});
it('transitions to reconnecting on first error and schedules retry after 250ms (D-11 step 0)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const stateChanges: string[] = []
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const stateChanges: string[] = [];
const { unmount } = renderHook(
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
{ wrapper: makeWrapper(queryClient) },
)
);
act(() => {
mockInstances[0]._triggerError()
})
mockInstances[0]._triggerError();
});
// Should be reconnecting (not disconnected — still have attempts left)
expect(stateChanges).toContain('reconnecting')
expect(stateChanges).toContain('reconnecting');
// Advance 250ms — a new EventSource should be created
act(() => {
vi.advanceTimersByTime(250)
})
vi.advanceTimersByTime(250);
});
expect(mockInstances.length).toBeGreaterThanOrEqual(2) // reconnected
unmount()
})
expect(mockInstances.length).toBeGreaterThanOrEqual(2); // reconnected
unmount();
});
it('stops retrying after MAX_ATTEMPTS and transitions to disconnected (D-11 backoff exhausted)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const stateChanges: string[] = []
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const stateChanges: string[] = [];
renderHook(
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
{ wrapper: makeWrapper(queryClient) },
)
renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), {
wrapper: makeWrapper(queryClient),
});
// Exhaust all 6 backoff steps: 250→500→1000→2000→4000→8000ms
const backoffSteps = [250, 500, 1000, 2000, 4000, 8000]
const backoffSteps = [250, 500, 1000, 2000, 4000, 8000];
for (const delay of backoffSteps) {
// Trigger error on the latest EventSource instance
act(() => {
const es = mockInstances[mockInstances.length - 1]
es._triggerError()
})
const es = mockInstances[mockInstances.length - 1];
es._triggerError();
});
// Advance to the next backoff delay to allow next connection attempt
act(() => {
vi.advanceTimersByTime(delay)
})
vi.advanceTimersByTime(delay);
});
}
// After exhausting all attempts, trigger error on last instance
act(() => {
const es = mockInstances[mockInstances.length - 1]
es._triggerError()
})
const es = mockInstances[mockInstances.length - 1];
es._triggerError();
});
// State must be 'disconnected' — no more retries
const lastState = stateChanges[stateChanges.length - 1]
expect(lastState).toBe('disconnected')
const lastState = stateChanges[stateChanges.length - 1];
expect(lastState).toBe('disconnected');
// No additional timer scheduled — advancing time should not create a new instance
const instanceCount = mockInstances.length
const instanceCount = mockInstances.length;
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(mockInstances.length).toBe(instanceCount) // no new connection attempt
})
vi.advanceTimersByTime(30_000);
});
expect(mockInstances.length).toBe(instanceCount); // no new connection attempt
});
it('resets backoff counter on successful reconnect (D-11 — counter reset)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const stateChanges: string[] = []
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const stateChanges: string[] = [];
renderHook(
() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }),
{ wrapper: makeWrapper(queryClient) },
)
renderHook(() => useListSSE({ listId: 1, onStateChange: (s) => stateChanges.push(s) }), {
wrapper: makeWrapper(queryClient),
});
// Fail once
act(() => {
mockInstances[0]._triggerError()
})
mockInstances[0]._triggerError();
});
act(() => {
vi.advanceTimersByTime(250) // first backoff
})
vi.advanceTimersByTime(250); // first backoff
});
// Reconnect successfully
act(() => {
const latest = mockInstances[mockInstances.length - 1]
latest._triggerOpen()
})
const latest = mockInstances[mockInstances.length - 1];
latest._triggerOpen();
});
expect(stateChanges).toContain('connected')
expect(stateChanges).toContain('connected');
// The attempt counter should be reset. Fail again — should reconnect, not give up.
const statesBefore = stateChanges.length
const statesBefore = stateChanges.length;
act(() => {
const latest = mockInstances[mockInstances.length - 1]
latest._triggerError()
})
const latest = mockInstances[mockInstances.length - 1];
latest._triggerError();
});
// Should be reconnecting again (counter reset → still has attempts)
const newStates = stateChanges.slice(statesBefore)
expect(newStates).toContain('reconnecting')
})
const newStates = stateChanges.slice(statesBefore);
expect(newStates).toContain('reconnecting');
});
it('closes EventSource and clears timers on unmount (no reconnect storm — Pitfall 3)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { unmount } = renderHook(
() => useListSSE({ listId: 1, onStateChange: vi.fn() }),
{ wrapper: makeWrapper(queryClient) },
)
const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), {
wrapper: makeWrapper(queryClient),
});
const firstEs = mockInstances[0]
const firstEs = mockInstances[0];
// Trigger error to schedule a reconnect timer
act(() => {
firstEs._triggerError()
})
firstEs._triggerError();
});
// Unmount — should cancel the pending timer and close the EventSource
unmount()
unmount();
const instanceCountAfterUnmount = mockInstances.length
const instanceCountAfterUnmount = mockInstances.length;
// Advance well past any backoff timer — no new instances should be created
act(() => {
vi.advanceTimersByTime(30_000)
})
vi.advanceTimersByTime(30_000);
});
expect(mockInstances.length).toBe(instanceCountAfterUnmount) // no new connection post-unmount
expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy()
})
expect(mockInstances.length).toBe(instanceCountAfterUnmount); // no new connection post-unmount
expect((firstEs as unknown as { closeCalled?: boolean }).closeCalled).toBeTruthy();
});
it('uses withCredentials: true on EventSource (Pitfall 7 — session cookie)', async () => {
const { useListSSE } = await import('./useListSSE.js')
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const { useListSSE } = await import('./useListSSE.js');
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const { unmount } = renderHook(
() => useListSSE({ listId: 1, onStateChange: vi.fn() }),
{ wrapper: makeWrapper(queryClient) },
)
const { unmount } = renderHook(() => useListSSE({ listId: 1, onStateChange: vi.fn() }), {
wrapper: makeWrapper(queryClient),
});
const es = mockInstances[0]
expect(es.withCredentials).toBe(true)
unmount()
})
})
const es = mockInstances[0];
expect(es.withCredentials).toBe(true);
unmount();
});
});
+41 -41
View File
@@ -18,21 +18,21 @@
* Source: RESEARCH.md Finding 4 verbatim pattern.
*/
import { useEffect, useRef, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
export type SyncState = 'connected' | 'reconnecting' | 'disconnected'
export type SyncState = 'connected' | 'reconnecting' | 'disconnected';
/**
* Backoff schedule per D-11: 250ms → 500ms → 1000ms → 2000ms → 4000ms → cap 8000ms.
* 6 steps → MAX_ATTEMPTS = 6; after exhaustion, state → 'disconnected'.
*/
const BACKOFF_STEPS_MS = [250, 500, 1000, 2000, 4000, 8000]
const MAX_ATTEMPTS = BACKOFF_STEPS_MS.length
const BACKOFF_STEPS_MS = [250, 500, 1000, 2000, 4000, 8000];
const MAX_ATTEMPTS = BACKOFF_STEPS_MS.length;
export interface UseListSSEOptions {
listId: number
onStateChange: (state: SyncState) => void
listId: number;
onStateChange: (state: SyncState) => void;
}
/**
@@ -48,68 +48,68 @@ export interface UseListSSEOptions {
* Unmount → es.close() + clearTimeout(timerRef)
*/
export function useListSSE({ listId, onStateChange }: UseListSSEOptions): void {
const queryClient = useQueryClient()
const esRef = useRef<EventSource | null>(null)
const attemptsRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const queryClient = useQueryClient();
const esRef = useRef<EventSource | null>(null);
const attemptsRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// handleListChange is stable — invalidate the list on any list-change event
const handleListChange = useCallback(() => {
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
void queryClient.invalidateQueries({ queryKey: ['list', listId] })
}, [queryClient, listId])
void queryClient.invalidateQueries({ queryKey: ['list', listId] });
}, [queryClient, listId]);
const connect = useCallback(() => {
// Close any existing connection (prevents stacking)
esRef.current?.close()
esRef.current?.close();
const es = new EventSource('/api/sse/lists', { withCredentials: true })
esRef.current = es
const es = new EventSource('/api/sse/lists', { withCredentials: true });
esRef.current = es;
// Subscribe to all list-change event types (D-10 — trigger refetch on any change)
es.addEventListener('item:added', handleListChange)
es.addEventListener('item:updated', handleListChange)
es.addEventListener('item:deleted', handleListChange)
es.addEventListener('list:updated', handleListChange)
es.addEventListener('list:deleted', handleListChange)
es.addEventListener('item:added', handleListChange);
es.addEventListener('item:updated', handleListChange);
es.addEventListener('item:deleted', handleListChange);
es.addEventListener('list:updated', handleListChange);
es.addEventListener('list:deleted', handleListChange);
es.onopen = () => {
// Reset attempt counter on successful open (D-11)
attemptsRef.current = 0
onStateChange('connected')
attemptsRef.current = 0;
onStateChange('connected');
// Full refetch on (re)connect — D-10: no Last-Event-ID replay, just refetch; fire-and-forget
void queryClient.invalidateQueries({ queryKey: ['list', listId] })
}
void queryClient.invalidateQueries({ queryKey: ['list', listId] });
};
es.onerror = () => {
// Close BEFORE scheduling retry — prevents browser auto-reconnect stacking (Pitfall 3)
es.close()
es.close();
const attempt = attemptsRef.current
const attempt = attemptsRef.current;
if (attempt >= MAX_ATTEMPTS) {
// Backoff exhausted — surface "Updates paused" indicator and stop (D-11)
onStateChange('disconnected')
onStateChange('disconnected');
// D-12 polling fallback (refetchInterval:30000 in ListDetail) keeps data fresh
return
return;
}
onStateChange('reconnecting')
const delay = BACKOFF_STEPS_MS[attempt]
attemptsRef.current = attempt + 1
timerRef.current = setTimeout(connect, delay)
}
}, [listId, queryClient, onStateChange, handleListChange])
onStateChange('reconnecting');
const delay = BACKOFF_STEPS_MS[attempt];
attemptsRef.current = attempt + 1;
timerRef.current = setTimeout(connect, delay);
};
}, [listId, queryClient, onStateChange, handleListChange]);
useEffect(() => {
connect()
connect();
return () => {
// Cleanup: close EventSource + cancel any pending reconnect timer (Pitfall 3)
esRef.current?.close()
esRef.current?.close();
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
clearTimeout(timerRef.current);
timerRef.current = null;
}
}
}, [connect])
};
}, [connect]);
}
+79 -79
View File
@@ -30,7 +30,7 @@
* setEnabled(false) — unsubscribe + set '0'.
*/
import { useState, useEffect } from 'react'
import { useState, useEffect } from 'react';
// ---------------------------------------------------------------------------
// Helpers
@@ -42,15 +42,15 @@ import { useState, useEffect } from 'react'
* applicationServerKey (Web Push spec — key must be a BufferSource).
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = atob(base64)
const buffer = new ArrayBuffer(rawData.length)
const outputArray = new Uint8Array(buffer)
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const buffer = new ArrayBuffer(rawData.length);
const outputArray = new Uint8Array(buffer);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray
return outputArray;
}
/**
@@ -59,18 +59,18 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
* are instant — the tap handler can proceed without an extra round-trip.
*/
async function fetchVapidKey(): Promise<string> {
const cached = sessionStorage.getItem('vapidPublicKey')
if (cached) return cached
const cached = sessionStorage.getItem('vapidPublicKey');
if (cached) return cached;
const res = await fetch('/api/push/vapid-public-key', {
credentials: 'include',
})
if (!res.ok) throw new Error(`Failed to fetch VAPID key: ${res.status}`)
const data = (await res.json()) as { publicKey: string }
});
if (!res.ok) throw new Error(`Failed to fetch VAPID key: ${res.status}`);
const data = (await res.json()) as { publicKey: string };
if (data.publicKey) {
sessionStorage.setItem('vapidPublicKey', data.publicKey)
sessionStorage.setItem('vapidPublicKey', data.publicKey);
}
return data.publicKey
return data.publicKey;
}
// ---------------------------------------------------------------------------
@@ -79,9 +79,9 @@ async function fetchVapidKey(): Promise<string> {
function readNotificationsEnabled(): boolean {
try {
return localStorage.getItem('notificationsEnabled') === '1'
return localStorage.getItem('notificationsEnabled') === '1';
} catch {
return false
return false;
}
}
@@ -91,18 +91,18 @@ function readNotificationsEnabled(): boolean {
*/
function readNotificationsDisabled(): boolean {
try {
return localStorage.getItem('notificationsEnabled') === '0'
return localStorage.getItem('notificationsEnabled') === '0';
} catch {
return false
return false;
}
}
function persistNotificationsEnabled(value: boolean): void {
try {
if (value) {
localStorage.setItem('notificationsEnabled', '1')
localStorage.setItem('notificationsEnabled', '1');
} else {
localStorage.setItem('notificationsEnabled', '0')
localStorage.setItem('notificationsEnabled', '0');
}
} catch {
// Private mode / storage disabled — ignore
@@ -121,12 +121,12 @@ export interface UsePushSubscriptionReturn {
* in a useEffect) and passed in here. This eliminates the network round-trip inside
* the tap handler, preserving the iOS user-gesture requirement for pushManager.subscribe().
*/
subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise<void>
unsubscribe: () => Promise<void>
subscribe: (registration: ServiceWorkerRegistration, vapidKey: string) => Promise<void>;
unsubscribe: () => Promise<void>;
/** Current Notification.permission value ('default' | 'granted' | 'denied') */
permission: NotificationPermission
permission: NotificationPermission;
/** True when an active push subscription exists in pushManager. */
isSubscribed: boolean
isSubscribed: boolean;
/**
* Master on/off toggle (D-09).
*
@@ -138,27 +138,27 @@ export interface UsePushSubscriptionReturn {
* setEnabled(false):
* - Calls unsubscribe() (DELETE server + browser) + persists '0'.
*/
setEnabled: (on: boolean) => Promise<void>
setEnabled: (on: boolean) => Promise<void>;
}
export function usePushSubscription(): UsePushSubscriptionReturn {
const [permission, setPermission] = useState<NotificationPermission>(
typeof Notification !== 'undefined' ? Notification.permission : 'default',
)
const [isSubscribed, setIsSubscribed] = useState(false)
);
const [isSubscribed, setIsSubscribed] = useState(false);
// Health-check on mount (D-10): if OS permission is granted but no active
// subscription exists (expired/cleared) AND user hasn't explicitly disabled,
// silently re-subscribe in background.
useEffect(() => {
if (typeof Notification === 'undefined') return
if (Notification.permission !== 'granted') return
if (!navigator.serviceWorker) return
if (typeof Notification === 'undefined') return;
if (Notification.permission !== 'granted') return;
if (!navigator.serviceWorker) return;
void (async () => {
try {
const registration = await navigator.serviceWorker.ready
const existingSub = await registration.pushManager.getSubscription()
const registration = await navigator.serviceWorker.ready;
const existingSub = await registration.pushManager.getSubscription();
if (existingSub) {
// Re-confirm server-side record (handles 410-prune recovery — D-10).
@@ -168,33 +168,33 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(existingSub.toJSON()),
}).catch(() => {}) // non-blocking — ignore network failures
setIsSubscribed(true)
return
}).catch(() => {}); // non-blocking — ignore network failures
setIsSubscribed(true);
return;
}
// No active subscription — only silently re-subscribe if the user
// hasn't explicitly turned notifications off (D-10).
if (readNotificationsDisabled()) return
if (readNotificationsDisabled()) return;
const vapidKey = await fetchVapidKey()
const vapidKey = await fetchVapidKey();
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
});
await fetch('/api/push/subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(sub.toJSON()),
})
persistNotificationsEnabled(true)
setIsSubscribed(true)
});
persistNotificationsEnabled(true);
setIsSubscribed(true);
} catch {
// Ignore health-check errors — non-blocking background task
}
})()
}, [])
})();
}, []);
/**
* subscribe — MUST be called inside an onClick handler.
@@ -219,7 +219,7 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
});
// Persist the subscription to the server
const res = await fetch('/api/push/subscription', {
@@ -227,39 +227,39 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(sub.toJSON()),
})
});
if (!res.ok) {
throw new Error(`Failed to persist subscription: ${res.status}`)
throw new Error(`Failed to persist subscription: ${res.status}`);
}
persistNotificationsEnabled(true)
setPermission(Notification.permission)
setIsSubscribed(true)
}
persistNotificationsEnabled(true);
setPermission(Notification.permission);
setIsSubscribed(true);
};
/**
* unsubscribe — retrieve the active subscription and cancel it.
* Also removes the server-side row via DELETE /api/push/subscription.
*/
const unsubscribe = async (): Promise<void> => {
if (!navigator.serviceWorker) return
if (!navigator.serviceWorker) return;
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.getSubscription()
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.getSubscription();
if (sub) {
await sub.unsubscribe()
await sub.unsubscribe();
}
await fetch('/api/push/subscription', {
method: 'DELETE',
credentials: 'include',
})
});
persistNotificationsEnabled(false)
setPermission(Notification.permission)
setIsSubscribed(false)
}
persistNotificationsEnabled(false);
setPermission(Notification.permission);
setIsSubscribed(false);
};
/**
* setEnabled — master on/off for the settings toggle (D-09).
@@ -271,52 +271,52 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
*/
const setEnabled = async (on: boolean): Promise<void> => {
if (!on) {
await unsubscribe()
return
await unsubscribe();
return;
}
// on=true path
const currentPermission =
typeof Notification !== 'undefined' ? Notification.permission : 'default'
typeof Notification !== 'undefined' ? Notification.permission : 'default';
if (currentPermission !== 'granted') {
// 'default' → caller must use tap-gated subscribe(); 'denied' → no-op
return
return;
}
// Permission is granted — silently subscribe without a tap gesture
// (already have OS permission, no dialog required).
if (!navigator.serviceWorker) return
if (!navigator.serviceWorker) return;
try {
const registration = await navigator.serviceWorker.ready
const existingSub = await registration.pushManager.getSubscription()
const registration = await navigator.serviceWorker.ready;
const existingSub = await registration.pushManager.getSubscription();
if (existingSub) {
// Already subscribed — just flip the stored flag back on.
persistNotificationsEnabled(true)
setIsSubscribed(true)
return
persistNotificationsEnabled(true);
setIsSubscribed(true);
return;
}
const vapidKey = await fetchVapidKey()
const vapidKey = await fetchVapidKey();
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
})
});
const res = await fetch('/api/push/subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(sub.toJSON()),
})
});
if (res.ok) {
persistNotificationsEnabled(true)
setIsSubscribed(true)
persistNotificationsEnabled(true);
setIsSubscribed(true);
}
} catch {
// Ignore — non-fatal; user can retry via toggle
}
}
};
return { subscribe, unsubscribe, permission, isSubscribed, setEnabled }
return { subscribe, unsubscribe, permission, isSubscribed, setEnabled };
}
/**
@@ -326,7 +326,7 @@ export function usePushSubscription(): UsePushSubscriptionReturn {
*/
export async function prefetchVapidKey(): Promise<void> {
try {
await fetchVapidKey()
await fetchVapidKey();
} catch {
// Non-fatal — the subscribe() call will retry if sessionStorage miss
}
@@ -337,4 +337,4 @@ export async function prefetchVapidKey(): Promise<void> {
* (localStorage.notificationsEnabled === '1').
* Exported for use in PermissionDeniedBanner and SettingsSheet initial state.
*/
export { readNotificationsEnabled }
export { readNotificationsEnabled };
+19 -20
View File
@@ -10,42 +10,41 @@
* Passing 0 would silently default to Monday in Schedule-X v4.
*/
import { describe, it, expect } from 'vitest'
import { describe, it, expect } from 'vitest';
// Not yet built — import will fail (RED state) until Plan 03 implements calendarConfig.ts
import { WEEK_START_DAY, buildCalendarConfig } from './calendarConfig.js'
import { WEEK_START_DAY, buildCalendarConfig } from './calendarConfig.js';
describe('calendarConfig — RED stubs (Wave 0)', () => {
it('WEEK_START_DAY constant equals 0 (Sunday in JS/date-fns convention)', () => {
// The project uses 0 = Sunday (JS/date-fns convention).
// This constant is translated to 7 before passing to Schedule-X.
expect(WEEK_START_DAY).toBe(0)
})
expect(WEEK_START_DAY).toBe(0);
});
it('WEEK_START_DAY=0 translates to Schedule-X firstDayOfWeek === 7', () => {
// Schedule-X v4 uses Temporal numbering: 1=Mon, 7=Sun.
// WEEK_START_DAY=0 (JS Sunday) must become 7 (Temporal Sunday).
// This avoids the Pitfall 1 silent failure where weeks start on Monday.
const config = buildCalendarConfig([])
expect(config.firstDayOfWeek).toBe(7)
})
const config = buildCalendarConfig([]);
expect(config.firstDayOfWeek).toBe(7);
});
it('buildCalendarConfig includes shared-family calendar with id "shared"', () => {
const config = buildCalendarConfig([])
expect(config.calendars).toHaveProperty('shared')
expect(config.calendars['shared'].colorName).toBe('shared')
})
const config = buildCalendarConfig([]);
expect(config.calendars).toHaveProperty('shared');
expect(config.calendars['shared'].colorName).toBe('shared');
});
it('buildCalendarConfig includes per-member calendars keyed by String(userId)', () => {
const members = [
{ id: '1', name: 'Lucas', color: '#4A90D9' },
{ id: '2', name: 'Spouse', color: '#E8734A' },
]
const config = buildCalendarConfig(members)
expect(config.calendars).toHaveProperty('1')
expect(config.calendars).toHaveProperty('2')
expect(config.calendars['1'].lightColors.main).toBe('#4A90D9')
expect(config.calendars['2'].lightColors.main).toBe('#E8734A')
})
})
];
const config = buildCalendarConfig(members);
expect(config.calendars).toHaveProperty('1');
expect(config.calendars).toHaveProperty('2');
expect(config.calendars['1'].lightColors.main).toBe('#4A90D9');
expect(config.calendars['2'].lightColors.main).toBe('#E8734A');
});
});
+17 -17
View File
@@ -17,10 +17,10 @@
* Source: https://schedule-x.dev/docs/calendar/calendars
*/
import { deriveScheduleXColors } from './colorUtils.js'
import { deriveScheduleXColors } from './colorUtils.js';
/** Week start day in JS/date-fns convention: 0 = Sunday. */
export const WEEK_START_DAY = 0
export const WEEK_START_DAY = 0;
/**
* firstDayOfWeek in Schedule-X/Temporal convention: 7 = Sunday, 1 = Monday.
@@ -28,7 +28,7 @@ export const WEEK_START_DAY = 0
* Translation: WEEK_START_DAY === 0 (JS Sunday) → 7 (Temporal Sunday).
* Hard-code to the project convention; one edit here when the user wants Monday.
*/
export const SX_FIRST_DAY_OF_WEEK: number = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY
export const SX_FIRST_DAY_OF_WEEK: number = WEEK_START_DAY === 0 ? 7 : WEEK_START_DAY;
/**
* Per-member calendar config entry passed to buildCalendarConfig.
@@ -38,23 +38,23 @@ export const SX_FIRST_DAY_OF_WEEK: number = WEEK_START_DAY === 0 ? 7 : WEEK_STAR
* color = users.color hex
*/
export interface MemberCalendarConfig {
id: string // String(users.id)
name: string // users.displayName
color: string // hex from users.color
id: string; // String(users.id)
name: string; // users.displayName
color: string; // hex from users.color
}
export interface ScheduleXCalendarEntry {
colorName: string
colorName: string;
lightColors: {
main: string
container: string
onContainer: string
}
main: string;
container: string;
onContainer: string;
};
}
export interface CalendarConfig {
firstDayOfWeek: number
calendars: Record<string, ScheduleXCalendarEntry>
firstDayOfWeek: number;
calendars: Record<string, ScheduleXCalendarEntry>;
}
/**
@@ -69,24 +69,24 @@ export interface CalendarConfig {
* isShared:false → String(ownerUserId) (NOT String(db calendarId))
*/
export function buildCalendarConfig(members: MemberCalendarConfig[]): CalendarConfig {
const calendars: Record<string, ScheduleXCalendarEntry> = {}
const calendars: Record<string, ScheduleXCalendarEntry> = {};
// Shared-family calendar: reserved rose color, confirmed by user
calendars['shared'] = {
colorName: 'shared',
lightColors: deriveScheduleXColors('#F25C7A'),
}
};
// Per-member calendars keyed by String(userId)
for (const m of members) {
calendars[m.id] = {
colorName: `member-${m.id}`,
lightColors: deriveScheduleXColors(m.color),
}
};
}
return {
firstDayOfWeek: SX_FIRST_DAY_OF_WEEK, // 7 = Sunday in Temporal convention
calendars,
}
};
}
+63 -64
View File
@@ -1,108 +1,107 @@
import { describe, it, expect } from 'vitest'
import { hexToContainer, hexToOnContainer, deriveScheduleXColors } from './colorUtils.js'
import { describe, it, expect } from 'vitest';
import { hexToContainer, hexToOnContainer, deriveScheduleXColors } from './colorUtils.js';
describe('colorUtils', () => {
describe('hexToContainer — 15% opacity blend over white', () => {
it('produces a lighter color than the input', () => {
const container = hexToContainer('#4A90D9')
const container = hexToContainer('#4A90D9');
// Container should be lighter (higher R, G, B values) than original
// #4A90D9 = rgb(74, 144, 217). At 15% over white:
// R = 74 * 0.15 + 255 * 0.85 = 11.1 + 216.75 ≈ 228 → #E4
// G = 144 * 0.15 + 255 * 0.85 = 21.6 + 216.75 ≈ 238 → #EE
// B = 217 * 0.15 + 255 * 0.85 = 32.55 + 216.75 ≈ 249 → #F9
expect(container).toMatch(/^#[0-9a-f]{6}$/i)
const [r, g, b] = hexToRgbTest(container)
expect(container).toMatch(/^#[0-9a-f]{6}$/i);
const [r, g, b] = hexToRgbTest(container);
// Should be well above 200 in all channels (very light)
expect(r).toBeGreaterThan(200)
expect(g).toBeGreaterThan(200)
expect(b).toBeGreaterThan(200)
})
expect(r).toBeGreaterThan(200);
expect(g).toBeGreaterThan(200);
expect(b).toBeGreaterThan(200);
});
it('blends #4A90D9 at 15% over white correctly', () => {
const container = hexToContainer('#4A90D9')
const container = hexToContainer('#4A90D9');
// Expected: R≈228, G≈239, B≈249
const [r, g, b] = hexToRgbTest(container)
expect(r).toBeCloseTo(228, -1) // within 5
expect(g).toBeCloseTo(239, -1)
expect(b).toBeCloseTo(249, -1)
})
const [r, g, b] = hexToRgbTest(container);
expect(r).toBeCloseTo(228, -1); // within 5
expect(g).toBeCloseTo(239, -1);
expect(b).toBeCloseTo(249, -1);
});
it('blends white (#FFFFFF) to white', () => {
const container = hexToContainer('#FFFFFF')
expect(container.toLowerCase()).toBe('#ffffff')
})
})
const container = hexToContainer('#FFFFFF');
expect(container.toLowerCase()).toBe('#ffffff');
});
});
describe('hexToOnContainer — darken 40%', () => {
it('produces a darker color than the input', () => {
const onContainer = hexToOnContainer('#4A90D9')
expect(onContainer).toMatch(/^#[0-9a-f]{6}$/i)
const onContainer = hexToOnContainer('#4A90D9');
expect(onContainer).toMatch(/^#[0-9a-f]{6}$/i);
// 40% darker: each channel * 0.6
// #4A90D9 = rgb(74, 144, 217) → rgb(44, 86, 130) ≈ #2C5682
const [r, g, b] = hexToRgbTest(onContainer)
expect(r).toBeLessThan(74)
expect(g).toBeLessThan(144)
expect(b).toBeLessThan(217)
})
const [r, g, b] = hexToRgbTest(onContainer);
expect(r).toBeLessThan(74);
expect(g).toBeLessThan(144);
expect(b).toBeLessThan(217);
});
it('darkens #4A90D9 by 40% correctly', () => {
const onContainer = hexToOnContainer('#4A90D9')
const [r, g, b] = hexToRgbTest(onContainer)
expect(r).toBeCloseTo(74 * 0.6, -1)
expect(g).toBeCloseTo(144 * 0.6, -1)
expect(b).toBeCloseTo(217 * 0.6, -1)
})
})
const onContainer = hexToOnContainer('#4A90D9');
const [r, g, b] = hexToRgbTest(onContainer);
expect(r).toBeCloseTo(74 * 0.6, -1);
expect(g).toBeCloseTo(144 * 0.6, -1);
expect(b).toBeCloseTo(217 * 0.6, -1);
});
});
describe('deriveScheduleXColors', () => {
it('returns main unchanged', () => {
const result = deriveScheduleXColors('#4A90D9')
expect(result.main).toBe('#4A90D9')
})
const result = deriveScheduleXColors('#4A90D9');
expect(result.main).toBe('#4A90D9');
});
it('returns container and onContainer as hex strings', () => {
const result = deriveScheduleXColors('#4A90D9')
expect(result.container).toMatch(/^#[0-9a-f]{6}$/i)
expect(result.onContainer).toMatch(/^#[0-9a-f]{6}$/i)
})
const result = deriveScheduleXColors('#4A90D9');
expect(result.container).toMatch(/^#[0-9a-f]{6}$/i);
expect(result.onContainer).toMatch(/^#[0-9a-f]{6}$/i);
});
it('container is lighter than main', () => {
const result = deriveScheduleXColors('#4A90D9')
const [mr, mg, mb] = hexToRgbTest(result.main)
const [cr, cg, cb] = hexToRgbTest(result.container)
const result = deriveScheduleXColors('#4A90D9');
const [mr, mg, mb] = hexToRgbTest(result.main);
const [cr, cg, cb] = hexToRgbTest(result.container);
// All container channels should be >= main channels (blending with white)
expect(cr).toBeGreaterThanOrEqual(mr)
expect(cg).toBeGreaterThanOrEqual(mg)
expect(cb).toBeGreaterThanOrEqual(mb)
})
expect(cr).toBeGreaterThanOrEqual(mr);
expect(cg).toBeGreaterThanOrEqual(mg);
expect(cb).toBeGreaterThanOrEqual(mb);
});
it('onContainer is darker than main', () => {
const result = deriveScheduleXColors('#4A90D9')
const [mr, mg, mb] = hexToRgbTest(result.main)
const [or, og, ob] = hexToRgbTest(result.onContainer)
const result = deriveScheduleXColors('#4A90D9');
const [mr, mg, mb] = hexToRgbTest(result.main);
const [or, og, ob] = hexToRgbTest(result.onContainer);
// All onContainer channels should be <= main channels (darken)
expect(or).toBeLessThanOrEqual(mr)
expect(og).toBeLessThanOrEqual(mg)
expect(ob).toBeLessThanOrEqual(mb)
})
expect(or).toBeLessThanOrEqual(mr);
expect(og).toBeLessThanOrEqual(mg);
expect(ob).toBeLessThanOrEqual(mb);
});
it('works for the shared-family rose color', () => {
const result = deriveScheduleXColors('#F25C7A')
expect(result.main).toBe('#F25C7A')
expect(result.container).toMatch(/^#[0-9a-f]{6}$/i)
expect(result.onContainer).toMatch(/^#[0-9a-f]{6}$/i)
})
})
})
const result = deriveScheduleXColors('#F25C7A');
expect(result.main).toBe('#F25C7A');
expect(result.container).toMatch(/^#[0-9a-f]{6}$/i);
expect(result.onContainer).toMatch(/^#[0-9a-f]{6}$/i);
});
});
});
// ── Test helper ────────────────────────────────────────────────────────────
function hexToRgbTest(hex: string): [number, number, number] {
const clean = hex.replace('#', '')
const clean = hex.replace('#', '');
return [
parseInt(clean.slice(0, 2), 16),
parseInt(clean.slice(2, 4), 16),
parseInt(clean.slice(4, 6), 16),
]
];
}
+17 -17
View File
@@ -13,18 +13,18 @@
* Parse a 6-digit hex color string to [r, g, b] in 0255 range.
*/
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace('#', '')
const r = parseInt(clean.slice(0, 2), 16)
const g = parseInt(clean.slice(2, 4), 16)
const b = parseInt(clean.slice(4, 6), 16)
return [r, g, b]
const clean = hex.replace('#', '');
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
return [r, g, b];
}
/**
* Convert [r, g, b] (0255) to a #RRGGBB hex string.
*/
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')
return '#' + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
}
/**
@@ -35,10 +35,10 @@ function rgbToHex(r: number, g: number, b: number): string {
* result = fg * alpha + 255 * (1 - alpha)
*/
export function hexToContainer(hex: string): string {
const [r, g, b] = hexToRgb(hex)
const alpha = 0.15
const blendChannel = (fg: number) => fg * alpha + 255 * (1 - alpha)
return rgbToHex(blendChannel(r), blendChannel(g), blendChannel(b))
const [r, g, b] = hexToRgb(hex);
const alpha = 0.15;
const blendChannel = (fg: number) => fg * alpha + 255 * (1 - alpha);
return rgbToHex(blendChannel(r), blendChannel(g), blendChannel(b));
}
/**
@@ -46,9 +46,9 @@ export function hexToContainer(hex: string): string {
* A factor of 0.4 means "40% darker" — each channel is multiplied by (1 - factor).
*/
export function hexToOnContainer(hex: string): string {
const [r, g, b] = hexToRgb(hex)
const factor = 1 - 0.4 // darken 40%
return rgbToHex(r * factor, g * factor, b * factor)
const [r, g, b] = hexToRgb(hex);
const factor = 1 - 0.4; // darken 40%
return rgbToHex(r * factor, g * factor, b * factor);
}
/**
@@ -60,13 +60,13 @@ export function hexToOnContainer(hex: string): string {
* onContainer — main darkened 40% (event chip text, passed to Schedule-X)
*/
export function deriveScheduleXColors(main: string): {
main: string
container: string
onContainer: string
main: string;
container: string;
onContainer: string;
} {
return {
main,
container: hexToContainer(main),
onContainer: hexToOnContainer(main),
}
};
}
+84 -58
View File
@@ -12,103 +12,129 @@
* string passed through verbatim, and never an instant that loses the local hour.
*/
import { describe, it, expect } from 'vitest'
import { serializeEventDateTime, localWallClockToUtcIso, computeNewTimedEnd, computeNewAllDayEnd } from './eventDateTime.js'
import { describe, it, expect } from 'vitest';
import {
serializeEventDateTime,
localWallClockToUtcIso,
computeNewTimedEnd,
computeNewAllDayEnd,
} from './eventDateTime.js';
describe('serializeEventDateTime (BUG A — write-path TZ)', () => {
it('serializes a timed start to a UTC instant (ends in Z)', () => {
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
expect(start.endsWith('Z')).toBe(true)
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00');
expect(start.endsWith('Z')).toBe(true);
// It must NOT be the naive wall-clock string (the original bug shape).
expect(start).not.toBe('2026-06-07T09:00:00')
})
expect(start).not.toBe('2026-06-07T09:00:00');
});
it('the serialized instant round-trips back to the SAME local wall clock', () => {
// This is the heart of BUG A: 09:00 in → 09:00 back out in the operator's zone.
const { start, end } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:30')
const startBack = new Date(start)
expect(startBack.getHours()).toBe(9)
expect(startBack.getMinutes()).toBe(0)
const endBack = new Date(end)
expect(endBack.getHours()).toBe(10)
expect(endBack.getMinutes()).toBe(30)
})
const { start, end } = serializeEventDateTime(
false,
'2026-06-07',
'09:00',
'2026-06-07',
'10:30',
);
const startBack = new Date(start);
expect(startBack.getHours()).toBe(9);
expect(startBack.getMinutes()).toBe(0);
const endBack = new Date(end);
expect(endBack.getHours()).toBe(10);
expect(endBack.getMinutes()).toBe(30);
});
it('equals the instant new Date(local parts) produces — not a passthrough', () => {
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00')
expect(start).toBe(new Date('2026-06-07T09:00:00').toISOString())
})
const { start } = serializeEventDateTime(false, '2026-06-07', '09:00', '2026-06-07', '10:00');
expect(start).toBe(new Date('2026-06-07T09:00:00').toISOString());
});
it('leaves all-day events as DATE strings (no time, no Z) — D-13 contract', () => {
const { start, end } = serializeEventDateTime(true, '2026-06-07', '09:00', '2026-06-09', '10:00')
expect(start).toBe('2026-06-07')
expect(end).toBe('2026-06-09')
})
const { start, end } = serializeEventDateTime(
true,
'2026-06-07',
'09:00',
'2026-06-09',
'10:00',
);
expect(start).toBe('2026-06-07');
expect(end).toBe('2026-06-09');
});
it('localWallClockToUtcIso round-trips a local wall clock to a UTC instant', () => {
const iso = localWallClockToUtcIso('2026-06-07', '09:00')
expect(iso.endsWith('Z')).toBe(true)
expect(new Date(iso).getHours()).toBe(9)
})
})
const iso = localWallClockToUtcIso('2026-06-07', '09:00');
expect(iso.endsWith('Z')).toBe(true);
expect(new Date(iso).getHours()).toBe(9);
});
});
describe('computeNewTimedEnd (D-04 — end-tracking)', () => {
it('preserves a 1-hour timed delta when start moves forward', () => {
// Old: 2026-06-11 09:00 → 10:00 (1h duration)
// New start: 2026-06-11 11:00 → new end should be 12:00
const result = computeNewTimedEnd(
'2026-06-11', '11:00',
'2026-06-11', '09:00',
'2026-06-11', '10:00',
)
expect(result.endDate).toBe('2026-06-11')
expect(result.endTime).toBe('12:00')
})
'2026-06-11',
'11:00',
'2026-06-11',
'09:00',
'2026-06-11',
'10:00',
);
expect(result.endDate).toBe('2026-06-11');
expect(result.endTime).toBe('12:00');
});
it('preserves a multi-day timed delta (26h) when start moves', () => {
// Old: 2026-06-11 08:00 → 2026-06-12 10:00 (26h duration)
// New start: 2026-06-13 08:00 → new end should be 2026-06-14 10:00
const result = computeNewTimedEnd(
'2026-06-13', '08:00',
'2026-06-11', '08:00',
'2026-06-12', '10:00',
)
expect(result.endDate).toBe('2026-06-14')
expect(result.endTime).toBe('10:00')
})
'2026-06-13',
'08:00',
'2026-06-11',
'08:00',
'2026-06-12',
'10:00',
);
expect(result.endDate).toBe('2026-06-14');
expect(result.endTime).toBe('10:00');
});
it('floors to 1h when old end was already behind old start', () => {
// Old end (10:00) <= old start (11:00) — stale/invalid state
// New start: 2026-06-11 14:00 → new end should snap to +1h = 15:00
const result = computeNewTimedEnd(
'2026-06-11', '14:00',
'2026-06-11', '11:00',
'2026-06-11', '10:00',
)
expect(result.endDate).toBe('2026-06-11')
expect(result.endTime).toBe('15:00')
})
})
'2026-06-11',
'14:00',
'2026-06-11',
'11:00',
'2026-06-11',
'10:00',
);
expect(result.endDate).toBe('2026-06-11');
expect(result.endTime).toBe('15:00');
});
});
describe('computeNewAllDayEnd (D-04 — all-day end-tracking)', () => {
it('preserves a 0-day span (single-day all-day event)', () => {
// Old: 2026-06-11 → 2026-06-11 (0-day span, single day)
// New start: 2026-06-15 → new end should be 2026-06-15
const result = computeNewAllDayEnd('2026-06-15', '2026-06-11', '2026-06-11')
expect(result).toBe('2026-06-15')
})
const result = computeNewAllDayEnd('2026-06-15', '2026-06-11', '2026-06-11');
expect(result).toBe('2026-06-15');
});
it('preserves a 3-day span when start moves', () => {
// Old: 2026-06-11 → 2026-06-14 (3-day span)
// New start: 2026-06-20 → new end should be 2026-06-23
const result = computeNewAllDayEnd('2026-06-20', '2026-06-11', '2026-06-14')
expect(result).toBe('2026-06-23')
})
const result = computeNewAllDayEnd('2026-06-20', '2026-06-11', '2026-06-14');
expect(result).toBe('2026-06-23');
});
it('floors to same day when old end was already behind old start', () => {
// Old end (2026-06-10) < old start (2026-06-11) — stale/invalid state
// New start: 2026-06-15 → new end should snap to same day 2026-06-15
const result = computeNewAllDayEnd('2026-06-15', '2026-06-11', '2026-06-10')
expect(result).toBe('2026-06-15')
})
})
const result = computeNewAllDayEnd('2026-06-15', '2026-06-11', '2026-06-10');
expect(result).toBe('2026-06-15');
});
});
+19 -19
View File
@@ -39,7 +39,7 @@ export function serializeEventDateTime(
): { start: string; end: string } {
if (allDay) {
// DATE contract (D-13): no time component, timezone-independent.
return { start: startDate, end: endDate }
return { start: startDate, end: endDate };
}
// Timed: build the instant from local wall-clock parts (browser is in the
@@ -48,7 +48,7 @@ export function serializeEventDateTime(
return {
start: localWallClockToUtcIso(startDate, startTime),
end: localWallClockToUtcIso(endDate, endTime),
}
};
}
/**
@@ -57,7 +57,7 @@ export function serializeEventDateTime(
* local timezone per ECMAScript, giving the correct instant for the operator.
*/
export function localWallClockToUtcIso(date: string, time: string): string {
return new Date(`${date}T${time}:00`).toISOString()
return new Date(`${date}T${time}:00`).toISOString();
}
// ─── Private date helpers (local accessor pattern — WR-05 constraint) ────────
@@ -65,7 +65,7 @@ export function localWallClockToUtcIso(date: string, time: string): string {
// local date. Always use getFullYear/getMonth/getDate/getHours/getMinutes.
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`
return n < 10 ? `0${n}` : `${n}`;
}
/**
@@ -74,12 +74,12 @@ function pad2(n: number): string {
* (initialCalendarRange/todayIso) can drop its banned toISOString().slice(0,10).
*/
export function localDateISO(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/** Format a Date as 'HH:MM' using LOCAL clock accessors. */
function localTimeHHMM(d: Date): string {
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
/**
@@ -88,9 +88,9 @@ function localTimeHHMM(d: Date): string {
* Uses midnight-local Date arithmetic to stay in the local calendar.
*/
function dateDiffDays(startDate: string, endDate: string): number {
const startMs = new Date(`${startDate}T00:00:00`).getTime()
const endMs = new Date(`${endDate}T00:00:00`).getTime()
return Math.round((endMs - startMs) / (24 * 60 * 60 * 1000))
const startMs = new Date(`${startDate}T00:00:00`).getTime();
const endMs = new Date(`${endDate}T00:00:00`).getTime();
return Math.round((endMs - startMs) / (24 * 60 * 60 * 1000));
}
/**
@@ -98,9 +98,9 @@ function dateDiffDays(startDate: string, endDate: string): number {
* 'YYYY-MM-DD' string. Uses local midnight to avoid DST-boundary issues.
*/
function addDaysISO(dateStr: string, days: number): string {
const d = new Date(`${dateStr}T00:00:00`)
d.setDate(d.getDate() + days)
return localDateISO(d)
const d = new Date(`${dateStr}T00:00:00`);
d.setDate(d.getDate() + days);
return localDateISO(d);
}
// ─── D-04: Duration-preservation helpers ─────────────────────────────────────
@@ -123,14 +123,14 @@ export function computeNewTimedEnd(
oldEndDate: string,
oldEndTime: string,
): { endDate: string; endTime: string } {
const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime()
const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime()
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000 // 1h floor
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs)
const oldStartMs = new Date(`${oldStartDate}T${oldStartTime}:00`).getTime();
const oldEndMs = new Date(`${oldEndDate}T${oldEndTime}:00`).getTime();
const deltaMs = oldEndMs > oldStartMs ? oldEndMs - oldStartMs : 60 * 60 * 1000; // 1h floor
const newEndDate = new Date(new Date(`${newStartDate}T${newStartTime}:00`).getTime() + deltaMs);
return {
endDate: localDateISO(newEndDate),
endTime: localTimeHHMM(newEndDate),
}
};
}
/**
@@ -148,6 +148,6 @@ export function computeNewAllDayEnd(
oldStartDate: string,
oldEndDate: string,
): string {
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate))
return addDaysISO(newStartDate, span)
const span = Math.max(0, dateDiffDays(oldStartDate, oldEndDate));
return addDaysISO(newStartDate, span);
}
+54 -55
View File
@@ -14,26 +14,26 @@
* This assertion locks the Plan 03 routing fix.
*/
import 'temporal-polyfill/global'
import { describe, it, expect } from 'vitest'
import 'temporal-polyfill/global';
import { describe, it, expect } from 'vitest';
import { hydrateEvents } from './hydrateEvents.js'
import { hydrateEvents } from './hydrateEvents.js';
// Minimal CalendarOccurrence shape for test purposes
interface TestOccurrence {
id: string
uid: string
calendarId: number
calendarName: string
ownerUserId: number
color: string
isShared: boolean
title: string
start: string
end: string
allDay: boolean
location: string | null
description: string | null
id: string;
uid: string;
calendarId: number;
calendarName: string;
ownerUserId: number;
color: string;
isShared: boolean;
title: string;
start: string;
end: string;
allDay: boolean;
location: string | null;
description: string | null;
}
function makeOccurrence(overrides: Partial<TestOccurrence>): TestOccurrence {
@@ -52,11 +52,10 @@ function makeOccurrence(overrides: Partial<TestOccurrence>): TestOccurrence {
location: null,
description: null,
...overrides,
}
};
}
describe('hydrateEvents — RED stubs (Wave 0)', () => {
describe('Temporal type conversion', () => {
it('converts all-day occurrence (allDay:true) to Temporal.PlainDate for start and end', () => {
const occurrences = [
@@ -65,33 +64,33 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
start: '2026-06-15',
end: '2026-06-15',
}),
]
];
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const result = hydrateEvents(occurrences);
expect(result).toHaveLength(1);
const evt = result[0]
const evt = result[0];
// Schedule-X requires Temporal.PlainDate for all-day events
expect(evt.start).toBeInstanceOf(Temporal.PlainDate)
expect(evt.end).toBeInstanceOf(Temporal.PlainDate)
})
expect(evt.start).toBeInstanceOf(Temporal.PlainDate);
expect(evt.end).toBeInstanceOf(Temporal.PlainDate);
});
it('converts an all-day exclusive DTEND to an inclusive last day for Schedule-X', () => {
// Single-day event: iCal DTSTART:24 / DTEND:25 (exclusive). Schedule-X end is
// inclusive, so a 1-day event must have start === end (renders on one day only).
const single = hydrateEvents([
makeOccurrence({ allDay: true, start: '2026-06-24', end: '2026-06-25' }),
])[0]
expect((single.start as Temporal.PlainDate).toString()).toBe('2026-06-24')
expect((single.end as Temporal.PlainDate).toString()).toBe('2026-06-24')
])[0];
expect((single.start as Temporal.PlainDate).toString()).toBe('2026-06-24');
expect((single.end as Temporal.PlainDate).toString()).toBe('2026-06-24');
// Two-day event: DTSTART:26 / DTEND:28 (exclusive) → inclusive last day = 27.
const multi = hydrateEvents([
makeOccurrence({ allDay: true, start: '2026-06-26', end: '2026-06-28' }),
])[0]
expect((multi.start as Temporal.PlainDate).toString()).toBe('2026-06-26')
expect((multi.end as Temporal.PlainDate).toString()).toBe('2026-06-27')
})
])[0];
expect((multi.start as Temporal.PlainDate).toString()).toBe('2026-06-26');
expect((multi.end as Temporal.PlainDate).toString()).toBe('2026-06-27');
});
it('converts timed occurrence (allDay:false) to Temporal.ZonedDateTime for start and end', () => {
const occurrences = [
@@ -100,16 +99,16 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
start: '2026-06-15T10:00:00-04:00[America/New_York]',
end: '2026-06-15T11:00:00-04:00[America/New_York]',
}),
]
];
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const result = hydrateEvents(occurrences);
expect(result).toHaveLength(1);
const evt = result[0]
expect(evt.start).toBeInstanceOf(Temporal.ZonedDateTime)
expect(evt.end).toBeInstanceOf(Temporal.ZonedDateTime)
})
})
const evt = result[0];
expect(evt.start).toBeInstanceOf(Temporal.ZonedDateTime);
expect(evt.end).toBeInstanceOf(Temporal.ZonedDateTime);
});
});
describe('calendarId routing — Plan 03 contract', () => {
it('shared occurrence (isShared:true) gets Schedule-X calendarId "shared"', () => {
@@ -119,14 +118,14 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
calendarId: 5,
ownerUserId: 2,
}),
]
];
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const result = hydrateEvents(occurrences);
expect(result).toHaveLength(1);
// Shared-family events must route to the 'shared' calendar slot in Schedule-X config
expect(result[0].calendarId).toBe('shared')
})
expect(result[0].calendarId).toBe('shared');
});
it('personal occurrence (isShared:false) gets Schedule-X calendarId === String(ownerUserId), NOT String(calendarId)', () => {
// This assertion locks the routing decision: personal events are grouped by MEMBER (ownerUserId),
@@ -134,17 +133,17 @@ describe('hydrateEvents — RED stubs (Wave 0)', () => {
const occurrences = [
makeOccurrence({
isShared: false,
calendarId: 99, // DB calendar row id
ownerUserId: 7, // DB user id — this is the correct Schedule-X key
calendarId: 99, // DB calendar row id
ownerUserId: 7, // DB user id — this is the correct Schedule-X key
}),
]
];
const result = hydrateEvents(occurrences)
expect(result).toHaveLength(1)
const result = hydrateEvents(occurrences);
expect(result).toHaveLength(1);
// Must be '7' (String(ownerUserId)), NOT '99' (String(calendarId))
expect(result[0].calendarId).toBe('7')
expect(result[0].calendarId).not.toBe('99')
})
})
})
expect(result[0].calendarId).toBe('7');
expect(result[0].calendarId).not.toBe('99');
});
});
});
+32 -33
View File
@@ -24,41 +24,41 @@
*/
export interface CalendarOccurrence {
id: string // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
uid: string
calendarId: number // DB calendar-row id — NOT used for calendarId routing
calendarName: string
ownerUserId: number // DB user id — this IS the routing key for personal events
color: string // hex from users.color or shared-family constant
isShared: boolean // true when this event belongs to the shared-family calendar
title: string
start: string // 'YYYY-MM-DD' for all-day; ISO 8601 with IANA tz for timed
end: string
allDay: boolean
location: string | null
description: string | null
id: string; // `${uid}::${dtstart_iso}` — stable identity for Schedule-X
uid: string;
calendarId: number; // DB calendar-row id — NOT used for calendarId routing
calendarName: string;
ownerUserId: number; // DB user id — this IS the routing key for personal events
color: string; // hex from users.color or shared-family constant
isShared: boolean; // true when this event belongs to the shared-family calendar
title: string;
start: string; // 'YYYY-MM-DD' for all-day; ISO 8601 with IANA tz for timed
end: string;
allDay: boolean;
location: string | null;
description: string | null;
}
export interface ScheduleXEvent {
id: string
title: string
start: Temporal.ZonedDateTime | Temporal.PlainDate
end: Temporal.ZonedDateTime | Temporal.PlainDate
id: string;
title: string;
start: Temporal.ZonedDateTime | Temporal.PlainDate;
end: Temporal.ZonedDateTime | Temporal.PlainDate;
/**
* Schedule-X calendarId — keys into the calendars config built by
* buildCalendarConfig(). Routing:
* 'shared' when isShared === true
* String(ownerUserId) when isShared === false
*/
calendarId: string
location?: string
description?: string
calendarId: string;
location?: string;
description?: string;
/** FamilySync custom fields — carried through for popover rendering */
_familySync: {
uid: string
color: string
isShared: boolean
}
uid: string;
color: string;
isShared: boolean;
};
}
/**
@@ -71,7 +71,7 @@ export interface ScheduleXEvent {
export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent[] {
return occurrences.map((occ) => {
// calendarId routing contract — must match buildCalendarConfig() keys
const calendarId: string = occ.isShared ? 'shared' : String(occ.ownerUserId)
const calendarId: string = occ.isShared ? 'shared' : String(occ.ownerUserId);
if (occ.allDay) {
// All-day: use Temporal.PlainDate — do NOT construct ZonedDateTime from
@@ -84,11 +84,10 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
// straight through renders every all-day event one day too long (a 1-day event
// showed across two days). Subtract one day to get the inclusive last day,
// clamped to never precede start.
const startPd = Temporal.PlainDate.from(occ.start)
const endExclusive = Temporal.PlainDate.from(occ.end)
const endInclusive = endExclusive.subtract({ days: 1 })
const end =
Temporal.PlainDate.compare(endInclusive, startPd) < 0 ? startPd : endInclusive
const startPd = Temporal.PlainDate.from(occ.start);
const endExclusive = Temporal.PlainDate.from(occ.end);
const endInclusive = endExclusive.subtract({ days: 1 });
const end = Temporal.PlainDate.compare(endInclusive, startPd) < 0 ? startPd : endInclusive;
return {
id: occ.id,
title: occ.title,
@@ -100,7 +99,7 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
color: occ.color,
isShared: occ.isShared,
},
} satisfies ScheduleXEvent
} satisfies ScheduleXEvent;
}
// Timed: use ZonedDateTime from the offset+IANA-annotated ISO string the server returns.
@@ -118,6 +117,6 @@ export function hydrateEvents(occurrences: CalendarOccurrence[]): ScheduleXEvent
color: occ.color,
isShared: occ.isShared,
},
} satisfies ScheduleXEvent
})
} satisfies ScheduleXEvent;
});
}
+38 -38
View File
@@ -7,65 +7,65 @@
* - clearLoginRedirect() removes the flag so a subsequent call redirects again
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Stub window.location with a writable href before importing the module.
// jsdom sets window.location to a read-only getter backed by a Location object;
// we need to replace it with a plain object so href assignment is detectable.
const locationStub = { href: '' }
const locationStub = { href: '' };
Object.defineProperty(window, 'location', {
value: locationStub,
writable: true,
})
});
import { maybeRedirectToLogin, clearLoginRedirect } from './loginRedirect.js'
import { maybeRedirectToLogin, clearLoginRedirect } from './loginRedirect.js';
describe('maybeRedirectToLogin', () => {
beforeEach(() => {
sessionStorage.clear()
locationStub.href = ''
vi.clearAllMocks()
})
sessionStorage.clear();
locationStub.href = '';
vi.clearAllMocks();
});
it('sets window.location.href to /api/login and returns true on first call', () => {
const result = maybeRedirectToLogin()
expect(result).toBe(true)
expect(locationStub.href).toBe('/api/login')
})
const result = maybeRedirectToLogin();
expect(result).toBe(true);
expect(locationStub.href).toBe('/api/login');
});
it('sets the sessionStorage flag after first call', () => {
maybeRedirectToLogin()
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBe('1')
})
maybeRedirectToLogin();
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBe('1');
});
it('does NOT change href on second call and returns false (one-shot guard)', () => {
maybeRedirectToLogin() // first call — redirects
locationStub.href = '' // reset the stub to detect a second assignment
const result = maybeRedirectToLogin() // second call — should NOT redirect
expect(result).toBe(false)
expect(locationStub.href).toBe('') // href must NOT have been reassigned
})
})
maybeRedirectToLogin(); // first call — redirects
locationStub.href = ''; // reset the stub to detect a second assignment
const result = maybeRedirectToLogin(); // second call — should NOT redirect
expect(result).toBe(false);
expect(locationStub.href).toBe(''); // href must NOT have been reassigned
});
});
describe('clearLoginRedirect', () => {
beforeEach(() => {
sessionStorage.clear()
locationStub.href = ''
vi.clearAllMocks()
})
sessionStorage.clear();
locationStub.href = '';
vi.clearAllMocks();
});
it('removes the sessionStorage flag', () => {
sessionStorage.setItem('familysync.loginRedirectAttempted', '1')
clearLoginRedirect()
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBeNull()
})
sessionStorage.setItem('familysync.loginRedirectAttempted', '1');
clearLoginRedirect();
expect(sessionStorage.getItem('familysync.loginRedirectAttempted')).toBeNull();
});
it('allows maybeRedirectToLogin to redirect again after the flag is cleared', () => {
maybeRedirectToLogin() // first call — sets flag
clearLoginRedirect() // clear the flag
locationStub.href = '' // reset stub
const result = maybeRedirectToLogin() // should redirect again
expect(result).toBe(true)
expect(locationStub.href).toBe('/api/login')
})
})
maybeRedirectToLogin(); // first call — sets flag
clearLoginRedirect(); // clear the flag
locationStub.href = ''; // reset stub
const result = maybeRedirectToLogin(); // should redirect again
expect(result).toBe(true);
expect(locationStub.href).toBe('/api/login');
});
});
+11 -11
View File
@@ -13,7 +13,7 @@
* maybeRedirectToLogin() call is a no-op and the "Sign-in required" UI is shown.
*/
export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted'
export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted';
/**
* Navigate to /api/login if this is the first attempt.
@@ -26,21 +26,21 @@ export const LOGIN_REDIRECT_KEY = 'familysync.loginRedirectAttempted'
* sessionStorage is unavailable.
*/
export function maybeRedirectToLogin(): boolean {
if (typeof window === 'undefined') return false
if (typeof sessionStorage === 'undefined') return false
if (typeof window === 'undefined') return false;
if (typeof sessionStorage === 'undefined') return false;
try {
if (sessionStorage.getItem(LOGIN_REDIRECT_KEY) !== null) {
// Already attempted — do not redirect again (loop guard).
return false
return false;
}
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1')
window.location.href = '/api/login'
return true
sessionStorage.setItem(LOGIN_REDIRECT_KEY, '1');
window.location.href = '/api/login';
return true;
} catch {
// sessionStorage access can throw in private-browsing mode or with storage quota
// exceeded. Fail open: do not redirect, let the caller render the error.
return false
return false;
}
}
@@ -53,11 +53,11 @@ export function maybeRedirectToLogin(): boolean {
* Safe under SSR/test environments (guarded).
*/
export function clearLoginRedirect(): void {
if (typeof window === 'undefined') return
if (typeof sessionStorage === 'undefined') return
if (typeof window === 'undefined') return;
if (typeof sessionStorage === 'undefined') return;
try {
sessionStorage.removeItem(LOGIN_REDIRECT_KEY)
sessionStorage.removeItem(LOGIN_REDIRECT_KEY);
} catch {
// Ignore storage errors — clearing the flag is best-effort.
}
+13 -13
View File
@@ -4,17 +4,17 @@
// 2. Schedule-X theme-default CSS must be imported BEFORE tokens.css so that
// the project's --sx-color-* overrides in tokens.css win the cascade.
// 3. styles/index.css imports tokens.css which carries the --sx-color-* overrides.
import 'temporal-polyfill/global'
import '@schedule-x/theme-default/dist/index.css'
import './styles/index.css'
import 'temporal-polyfill/global';
import '@schedule-x/theme-default/dist/index.css';
import './styles/index.css';
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query'
import App from './App.js'
import { ErrorBoundary } from './components/ErrorBoundary.js'
import { SessionExpiredError } from './api/client.js'
import { useCalendarStore } from './store/calendarStore.js'
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider, QueryCache, MutationCache } from '@tanstack/react-query';
import App from './App.js';
import { ErrorBoundary } from './components/ErrorBoundary.js';
import { SessionExpiredError } from './api/client.js';
import { useCalendarStore } from './store/calendarStore.js';
/**
* Global session-expiry error handler (D-11, Plan 06-05).
@@ -36,7 +36,7 @@ function onGlobalError(error: unknown): void {
error instanceof SessionExpiredError ||
(error as { name?: string } | null)?.name === 'SessionExpiredError'
) {
useCalendarStore.getState().setSessionExpired(true)
useCalendarStore.getState().setSessionExpired(true);
}
}
@@ -49,7 +49,7 @@ const queryClient = new QueryClient({
staleTime: 30_000,
},
},
})
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
@@ -59,4 +59,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</ErrorBoundary>
</QueryClientProvider>
</React.StrictMode>,
)
);
+86 -94
View File
@@ -11,10 +11,10 @@
* Uses React Query's QueryClient directly (no mocked server).
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { act } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
import { describe, it, expect, beforeEach } from 'vitest';
import { act } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import type { ListItem, ListItemsResponse } from '../api/listsClient.js';
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -26,94 +26,88 @@ function makeItem(overrides: Partial<ListItem> = {}): ListItem {
checked: false,
rank: 'a0',
...overrides,
}
};
}
function makeItemsResponse(items: ListItem[]): ListItemsResponse {
return { items }
return { items };
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('ListDetail — D-07 optimistic update + rollback', () => {
let queryClient: QueryClient
const LIST_ID = 10
let queryClient: QueryClient;
const LIST_ID = 10;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
})
});
});
it('checking an item immediately updates the UI before server responds', async () => {
const item = makeItem({ checked: false })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
const item = makeItem({ checked: false });
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]));
// Simulate the onMutate optimistic update (D-07 pattern)
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] });
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: true } : i,
),
}))
})
items: (old?.items ?? []).map((i) => (i.id === item.id ? { ...i, checked: true } : i)),
}));
});
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(updated?.items[0].checked).toBe(true)
})
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
expect(updated?.items[0].checked).toBe(true);
});
it('unchecking an item immediately updates the UI before server responds', async () => {
const item = makeItem({ checked: true })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
const item = makeItem({ checked: true });
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]));
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] });
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: false } : i,
),
}))
})
items: (old?.items ?? []).map((i) => (i.id === item.id ? { ...i, checked: false } : i)),
}));
});
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(updated?.items[0].checked).toBe(false)
})
const updated = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
expect(updated?.items[0].checked).toBe(false);
});
it('rolls back the checked state if the server PATCH returns an error', () => {
const item = makeItem({ checked: false })
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]))
const item = makeItem({ checked: false });
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([item]));
// Step 1: capture previous state (as onMutate would)
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
// Step 2: apply optimistic update
act(() => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).map((i) =>
i.id === item.id ? { ...i, checked: true } : i,
),
}))
})
items: (old?.items ?? []).map((i) => (i.id === item.id ? { ...i, checked: true } : i)),
}));
});
// Verify it was applied
const after = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(after?.items[0].checked).toBe(true)
const after = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
expect(after?.items[0].checked).toBe(true);
// Step 3: simulate onError rollback
act(() => {
if (previous) {
queryClient.setQueryData(['list', LIST_ID], previous)
queryClient.setQueryData(['list', LIST_ID], previous);
}
})
});
// Should be rolled back
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(rolledBack?.items[0].checked).toBe(false)
})
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
expect(rolledBack?.items[0].checked).toBe(false);
});
it('adding an item shows it in the list immediately (optimistic insert)', () => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]))
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], makeItemsResponse([]));
const optimisticItem: ListItem = {
id: -Date.now(),
@@ -121,30 +115,30 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
text: 'milk',
checked: false,
rank: 'a0',
}
};
act(() => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
})
}));
});
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(data?.items).toHaveLength(1)
expect(data?.items[0].text).toBe('milk')
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
expect(data?.items).toHaveLength(1);
expect(data?.items[0].text).toBe('milk');
// Optimistic item has negative id
expect(data?.items[0].id).toBeLessThan(0)
})
expect(data?.items[0].id).toBeLessThan(0);
});
it('removes the optimistically-added item if the server POST returns an error', () => {
const existingItem = makeItem({ id: 1 })
const existingItem = makeItem({ id: 1 });
queryClient.setQueryData<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([existingItem]),
)
);
// Capture previous before optimistic add
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
const previous = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
// Apply optimistic add
const optimisticItem: ListItem = {
@@ -153,65 +147,63 @@ describe('ListDetail — D-07 optimistic update + rollback', () => {
text: 'optimistic',
checked: false,
rank: 'a1',
}
};
act(() => {
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
})
}));
});
expect(
queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])?.items,
).toHaveLength(2)
expect(queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])?.items).toHaveLength(2);
// Simulate rollback on error
act(() => {
if (previous) queryClient.setQueryData(['list', LIST_ID], previous)
})
if (previous) queryClient.setQueryData(['list', LIST_ID], previous);
});
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(rolledBack?.items).toHaveLength(1)
expect(rolledBack?.items[0].id).toBe(1) // original item
})
const rolledBack = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
expect(rolledBack?.items).toHaveLength(1);
expect(rolledBack?.items[0].id).toBe(1); // original item
});
it('completed items sink to the "completed" section at the bottom (D-05)', () => {
const activeItem = makeItem({ id: 1, checked: false, rank: 'a0', text: 'active' })
const completedItem = makeItem({ id: 2, checked: true, rank: 'a1', text: 'done' })
const items = [activeItem, completedItem]
const activeItem = makeItem({ id: 1, checked: false, rank: 'a0', text: 'active' });
const completedItem = makeItem({ id: 2, checked: true, rank: 'a1', text: 'done' });
const items = [activeItem, completedItem];
// The ListDetail splits items into active and completed sections
const activeItems = items.filter((i) => !i.checked).sort((a, b) =>
a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0,
)
const completedItems = items.filter((i) => i.checked)
const activeItems = items
.filter((i) => !i.checked)
.sort((a, b) => (a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0));
const completedItems = items.filter((i) => i.checked);
expect(activeItems).toHaveLength(1)
expect(activeItems[0].text).toBe('active')
expect(completedItems).toHaveLength(1)
expect(completedItems[0].text).toBe('done')
expect(activeItems).toHaveLength(1);
expect(activeItems[0].text).toBe('active');
expect(completedItems).toHaveLength(1);
expect(completedItems[0].text).toBe('done');
// Active comes before completed by data structure (rendered above completed section)
})
});
it('delete removes item immediately with NO rollback (delete-wins D-09)', async () => {
const item1 = makeItem({ id: 1, text: 'keep', rank: 'a0' })
const item2 = makeItem({ id: 2, text: 'delete-me', rank: 'a1' })
const item1 = makeItem({ id: 1, text: 'keep', rank: 'a0' });
const item2 = makeItem({ id: 2, text: 'delete-me', rank: 'a1' });
queryClient.setQueryData<ListItemsResponse>(
['list', LIST_ID],
makeItemsResponse([item1, item2]),
)
);
// onMutate for delete: remove immediately, no previous state saved (D-09)
await act(async () => {
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] })
await queryClient.cancelQueries({ queryKey: ['list', LIST_ID] });
queryClient.setQueryData<ListItemsResponse>(['list', LIST_ID], (old) => ({
items: (old?.items ?? []).filter((i) => i.id !== 2),
}))
})
}));
});
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID])
expect(data?.items).toHaveLength(1)
expect(data?.items[0].id).toBe(1)
const data = queryClient.getQueryData<ListItemsResponse>(['list', LIST_ID]);
expect(data?.items).toHaveLength(1);
expect(data?.items[0].id).toBe(1);
// item2 is gone, no rollback mechanism exists (delete-wins)
})
})
});
});
+76 -81
View File
@@ -26,10 +26,10 @@
* Security: item text rendered as plain-text JSX children (T-04-06 XSS guard).
*/
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ChevronLeft } from 'lucide-react'
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronLeft } from 'lucide-react';
import {
DndContext,
closestCenter,
@@ -39,25 +39,20 @@ import {
useSensors,
useSensor,
type DragEndEvent,
} from '@dnd-kit/core'
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { generateKeyBetween } from 'fractional-indexing'
import {
fetchListItems,
addItem,
patchListItem,
deleteItem,
} from '../api/listsClient.js'
import { ItemRow } from '../components/ItemRow.js'
import { AddItemInput } from '../components/AddItemInput.js'
import { LiveSyncIndicator } from '../components/LiveSyncIndicator.js'
import { useListSSE } from '../hooks/useListSSE.js'
import type { SyncState } from '../hooks/useListSSE.js'
import type { ListItem, ListItemsResponse } from '../api/listsClient.js'
} from '@dnd-kit/sortable';
import { generateKeyBetween } from 'fractional-indexing';
import { fetchListItems, addItem, patchListItem, deleteItem } from '../api/listsClient.js';
import { ItemRow } from '../components/ItemRow.js';
import { AddItemInput } from '../components/AddItemInput.js';
import { LiveSyncIndicator } from '../components/LiveSyncIndicator.js';
import { useListSSE } from '../hooks/useListSSE.js';
import type { SyncState } from '../hooks/useListSSE.js';
import type { ListItem, ListItemsResponse } from '../api/listsClient.js';
/**
* ListEmptyState — shown inside ListDetail when list has no items.
@@ -97,23 +92,23 @@ function ListEmptyState() {
Add your first item below.
</div>
</div>
)
);
}
export function ListDetail() {
const { listId } = useParams<{ listId: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const parsedListId = Number(listId)
const { listId } = useParams<{ listId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const parsedListId = Number(listId);
const [completedExpanded, setCompletedExpanded] = useState(true)
const [syncState, setSyncState] = useState<SyncState>('connected')
const [completedExpanded, setCompletedExpanded] = useState(true);
const [syncState, setSyncState] = useState<SyncState>('connected');
// ── Live sync (LIST-04) ──────────────────────────────────────────────────
// Bounded-backoff SSE hook (D-11). On open: invalidates ['list', listId] (D-10).
// On event: invalidates ['list', listId] → background refetch.
// Polling fallback: refetchInterval:30000 below stays always active (D-12).
useListSSE({ listId: parsedListId, onStateChange: setSyncState })
useListSSE({ listId: parsedListId, onStateChange: setSyncState });
// ── dnd-kit sensors ────────────────────────────────────────────────────────
// PointerSensor: desktop mouse — immediate activation
@@ -131,7 +126,7 @@ export function ListDetail() {
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
);
// ── Data fetch ─────────────────────────────────────────────────────────────
// refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06)
@@ -140,25 +135,25 @@ export function ListDetail() {
queryFn: () => fetchListItems(parsedListId),
refetchInterval: 30_000,
enabled: !isNaN(parsedListId),
})
});
// Split into active (unchecked, sorted rank ASC) and completed (checked) per D-05
const allItems: ListItem[] = data?.items ?? []
const allItems: ListItem[] = data?.items ?? [];
const activeItems = allItems
.filter((i) => !i.checked)
.sort((a, b) => (a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0))
const completedItems = allItems.filter((i) => i.checked)
.sort((a, b) => (a.rank < b.rank ? -1 : a.rank > b.rank ? 1 : 0));
const completedItems = allItems.filter((i) => i.checked);
// ── Add item mutation (D-07 optimistic: append at active bottom, opacity 0.6) ──
const addMutation = useMutation({
mutationFn: (text: string) => addItem(parsedListId, { text }),
onMutate: async (text: string) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] });
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId]);
// Compute optimistic rank: after the last active item
const lastRank = activeItems.at(-1)?.rank ?? null
const optimisticRank = generateKeyBetween(lastRank, null)
const lastRank = activeItems.at(-1)?.rank ?? null;
const optimisticRank = generateKeyBetween(lastRank, null);
const optimisticItem: ListItem = {
id: -Date.now(), // temporary negative id
@@ -166,111 +161,109 @@ export function ListDetail() {
text,
checked: false,
rank: optimisticRank,
}
};
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: [...(old?.items ?? []), optimisticItem],
}))
}));
return { previous, optimisticItem }
return { previous, optimisticItem };
},
onError: (_err, _text, context) => {
// Rollback on rejection
if (context?.previous) {
queryClient.setQueryData(['list', parsedListId], context.previous)
queryClient.setQueryData(['list', parsedListId], context.previous);
}
},
onSettled: () => {
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] });
},
})
});
// ── Check/uncheck mutation (D-07 optimistic: move section immediately, rollback on error) ──
const checkMutation = useMutation({
mutationFn: ({ itemId, checked }: { itemId: number; checked: boolean }) =>
patchListItem(itemId, { checked }),
onMutate: async ({ itemId, checked }) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] });
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId]);
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: (old?.items ?? []).map((item) =>
item.id === itemId ? { ...item, checked } : item,
),
}))
items: (old?.items ?? []).map((item) => (item.id === itemId ? { ...item, checked } : item)),
}));
return { previous }
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['list', parsedListId], context.previous)
queryClient.setQueryData(['list', parsedListId], context.previous);
}
},
onSettled: () => {
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] });
},
})
});
// ── Delete item mutation (D-09 delete-wins: NO rollback on error) ──
const deleteMutation = useMutation({
mutationFn: (itemId: number) => deleteItem(itemId),
onMutate: async (itemId: number) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] });
// Remove item optimistically — no previous state saved (delete-wins D-09)
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: (old?.items ?? []).filter((item) => item.id !== itemId),
}))
}));
},
// No onError rollback — delete-wins (D-09)
onSettled: () => {
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] });
},
})
});
// ── Reorder mutation (D-13 single-row rank write, D-15 LWW convergence) ──
const reorderMutation = useMutation({
mutationFn: ({ itemId, position }: { itemId: number; position: string }) =>
patchListItem(itemId, { position }),
onMutate: async ({ itemId, position }) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] });
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId]);
// Optimistic: update the moved item's rank in cache immediately
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: (old?.items ?? []).map((item) =>
item.id === itemId ? { ...item, rank: position } : item,
),
}))
}));
return { previous }
return { previous };
},
onError: (_err, _vars, context) => {
// Rollback: animate back to previous order (D-14 — CSS transition in ItemRow handles animation)
if (context?.previous) {
queryClient.setQueryData(['list', parsedListId], context.previous)
queryClient.setQueryData(['list', parsedListId], context.previous);
}
},
onSettled: () => {
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
void queryClient.invalidateQueries({ queryKey: ['list', parsedListId] });
},
})
});
// ── Handlers ───────────────────────────────────────────────────────────────
function handleAdd(text: string) {
addMutation.mutate(text)
addMutation.mutate(text);
}
function handleCheck(itemId: number, checked: boolean) {
checkMutation.mutate({ itemId, checked })
checkMutation.mutate({ itemId, checked });
}
function handleDelete(itemId: number) {
deleteMutation.mutate(itemId)
deleteMutation.mutate(itemId);
}
/**
@@ -289,29 +282,29 @@ export function ListDetail() {
* via SSE (Plan 06) or the 30s polling fallback (D-12, D-15).
*/
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
const { active, over } = event;
// No-op: dropped outside or on itself
if (!over || active.id === over.id) return
if (!over || active.id === over.id) return;
// Find source and destination indices in the sorted active list
const activeIndex = activeItems.findIndex((i) => i.id === active.id)
const overIndex = activeItems.findIndex((i) => i.id === over.id)
const activeIndex = activeItems.findIndex((i) => i.id === active.id);
const overIndex = activeItems.findIndex((i) => i.id === over.id);
if (activeIndex === -1 || overIndex === -1) return
if (activeIndex === -1 || overIndex === -1) return;
// Build the new order by moving the item to the target position
const newOrder = [...activeItems]
const [moved] = newOrder.splice(activeIndex, 1)
newOrder.splice(overIndex, 0, moved)
const newOrder = [...activeItems];
const [moved] = newOrder.splice(activeIndex, 1);
newOrder.splice(overIndex, 0, moved);
// Derive prevRank/nextRank from the reordered list at the moved item's new position
const prevRank = newOrder[overIndex - 1]?.rank ?? null
const nextRank = newOrder[overIndex + 1]?.rank ?? null
const prevRank = newOrder[overIndex - 1]?.rank ?? null;
const nextRank = newOrder[overIndex + 1]?.rank ?? null;
const newRank = generateKeyBetween(prevRank, nextRank)
const newRank = generateKeyBetween(prevRank, nextRank);
reorderMutation.mutate({ itemId: Number(active.id), position: newRank })
reorderMutation.mutate({ itemId: Number(active.id), position: newRank });
}
// ── Loading / error states ─────────────────────────────────────────────────
@@ -327,7 +320,7 @@ export function ListDetail() {
>
Invalid list.
</div>
)
);
}
// ── Render ─────────────────────────────────────────────────────────────────
@@ -355,7 +348,9 @@ export function ListDetail() {
}}
>
<button
onClick={() => { void navigate('/lists') }}
onClick={() => {
void navigate('/lists');
}}
aria-label="Back to lists"
style={{
background: 'none',
@@ -501,5 +496,5 @@ export function ListDetail() {
{/* Sticky add-item input */}
<AddItemInput onAdd={handleAdd} isPending={addMutation.isPending} />
</div>
)
);
}
+38 -40
View File
@@ -18,75 +18,75 @@
* as plain-text JSX children no dangerouslySetInnerHTML anywhere.
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { Plus } from 'lucide-react'
import { fetchLists, deleteList } from '../api/listsClient.js'
import type { List, ListsResponse } from '../api/listsClient.js'
import { useListsStore } from '../store/listsStore.js'
import { ListCard } from '../components/ListCard.js'
import { ListDeleteDialog } from '../components/ListDeleteDialog.js'
import { ListsEmptyState } from '../components/ListsEmptyState.js'
import { CreateListSheet } from '../components/CreateListSheet.js'
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { Plus } from 'lucide-react';
import { fetchLists, deleteList } from '../api/listsClient.js';
import type { List, ListsResponse } from '../api/listsClient.js';
import { useListsStore } from '../store/listsStore.js';
import { ListCard } from '../components/ListCard.js';
import { ListDeleteDialog } from '../components/ListDeleteDialog.js';
import { ListsEmptyState } from '../components/ListsEmptyState.js';
import { CreateListSheet } from '../components/CreateListSheet.js';
// ── Main Component ─────────────────────────────────────────────────────────
export function ListsIndex() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const setCreateListSheetOpen = useListsStore((s) => s.setCreateListSheetOpen)
const navigate = useNavigate();
const queryClient = useQueryClient();
const setCreateListSheetOpen = useListsStore((s) => s.setCreateListSheetOpen);
const [deleteTarget, setDeleteTarget] = useState<List | null>(null)
const [deleteTarget, setDeleteTarget] = useState<List | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['lists'],
queryFn: fetchLists,
retry: 2,
staleTime: 30 * 1000,
})
});
const lists = data?.lists ?? []
const lists = data?.lists ?? [];
// Delete mutation with optimistic removal
const deleteMutation = useMutation({
mutationFn: (listId: number) => deleteList(listId),
onMutate: async (listId) => {
await queryClient.cancelQueries({ queryKey: ['lists'] })
const previous = queryClient.getQueryData<ListsResponse>(['lists'])
await queryClient.cancelQueries({ queryKey: ['lists'] });
const previous = queryClient.getQueryData<ListsResponse>(['lists']);
queryClient.setQueryData<ListsResponse>(['lists'], (old) => ({
lists: (old?.lists ?? []).filter((l) => l.id !== listId),
}))
return { previous }
}));
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(['lists'], context.previous)
queryClient.setQueryData(['lists'], context.previous);
}
// TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)
},
onSettled: () => {
// fire-and-forget: cache invalidation; React Query handles the refetch lifecycle
void queryClient.invalidateQueries({ queryKey: ['lists'] })
void queryClient.invalidateQueries({ queryKey: ['lists'] });
},
onSuccess: () => {
void navigate('/lists')
setDeleteTarget(null)
void navigate('/lists');
setDeleteTarget(null);
},
})
});
const handleDeleteRequest = (list: List) => {
setDeleteTarget(list)
}
setDeleteTarget(list);
};
const handleDeleteConfirm = () => {
if (!deleteTarget) return
deleteMutation.mutate(deleteTarget.id)
}
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id);
};
const handleDeleteClose = () => {
setDeleteTarget(null)
}
setDeleteTarget(null);
};
return (
<>
@@ -196,7 +196,9 @@ export function ListsIndex() {
</div>
<button
type="button"
onClick={() => { void refetch() }}
onClick={() => {
void refetch();
}}
style={{
background: 'var(--color-member-0)',
color: '#fff',
@@ -229,11 +231,7 @@ export function ListsIndex() {
}}
>
{lists.map((list) => (
<ListCard
key={list.id}
list={list}
onDelete={handleDeleteRequest}
/>
<ListCard key={list.id} list={list} onDelete={handleDeleteRequest} />
))}
</div>
)}
@@ -251,5 +249,5 @@ export function ListsIndex() {
{/* Create list sheet (D-01) */}
<CreateListSheet />
</>
)
);
}
+41 -42
View File
@@ -23,44 +23,44 @@
* onRangeUpdate firing on mount (A4/Open Q2 from RESEARCH.md).
*/
import { create } from 'zustand'
import { localDateISO } from '../lib/eventDateTime.js'
import { create } from 'zustand';
import { localDateISO } from '../lib/eventDateTime.js';
// ── Types ──────────────────────────────────────────────────────────────────
type BreakpointGroup = 'phone' | 'tablet-desktop'
type BreakpointGroup = 'phone' | 'tablet-desktop';
export interface CalendarStore {
selectedView: string
selectedDate: string
openEventId: string | null
calendarRange: { start: string; end: string }
selectedView: string;
selectedDate: string;
openEventId: string | null;
calendarRange: { start: string; end: string };
// ── EventForm UI state (Plan 03-05) ──────────────────────────────────────
// Server state (events, calendars) lives in TanStack Query. These are pure
// UI-shape keys: whether the form is open, what mode it is in, and which
// event UID to pre-populate in edit mode.
eventFormOpen: boolean
eventFormMode: 'create' | 'edit'
eventFormUid: string | null
eventFormOpen: boolean;
eventFormMode: 'create' | 'edit';
eventFormUid: string | null;
// ── Delete dialog + sync-state UI state (Plan 03-06) ─────────────────────
// Drives DeleteConfirmationDialog visibility and SyncStateToast polling.
deleteDialogOpen: boolean
deleteDialogUid: string | null
deleteDialogOpen: boolean;
deleteDialogUid: string | null;
/** UID of the most recently enqueued write. SyncStateToast polls sync-status for this. */
lastSyncedUid: string | null
lastSyncedUid: string | null;
// ── Session-expiry flag (Plan 06-05 / D-11) ─────────────────────────────
// Set to true by the global QueryCache/MutationCache onError handler in main.tsx
// when a SessionExpiredError is detected from any query or mutation.
// Drives the "Session expired / Signing you back in…" interstitial in CalendarShell.
sessionExpired: boolean
sessionExpired: boolean;
setSelectedView: (view: string) => void
setSelectedDate: (date: string) => void
setOpenEventId: (id: string | null) => void
setCalendarRange: (range: { start: string; end: string }) => void
setSelectedView: (view: string) => void;
setSelectedDate: (date: string) => void;
setOpenEventId: (id: string | null) => void;
setCalendarRange: (range: { start: string; end: string }) => void;
/**
* Open or close the EventForm.
@@ -69,7 +69,7 @@ export interface CalendarStore {
* @param mode 'create' (default) or 'edit'
* @param uid UID of the event to pre-populate in edit mode (null otherwise)
*/
setEventForm: (open: boolean, mode?: 'create' | 'edit', uid?: string | null) => void
setEventForm: (open: boolean, mode?: 'create' | 'edit', uid?: string | null) => void;
/**
* Open or close the DeleteConfirmationDialog.
@@ -77,13 +77,13 @@ export interface CalendarStore {
* @param open true to open, false to close
* @param uid UID of the event to confirm-delete (null when closing)
*/
setDeleteDialog: (open: boolean, uid?: string | null) => void
setDeleteDialog: (open: boolean, uid?: string | null) => void;
/**
* Set the UID of the most recently enqueued write, driving SyncStateToast polling.
* Pass null to dismiss the toast.
*/
setLastSyncedUid: (uid: string | null) => void
setLastSyncedUid: (uid: string | null) => void;
/**
* Arm the session-expiry interstitial (D-11).
@@ -91,55 +91,55 @@ export interface CalendarStore {
* (outside React, via getState().setSessionExpired) when a SessionExpiredError
* is caught from any query or mutation.
*/
setSessionExpired: (expired: boolean) => void
setSessionExpired: (expired: boolean) => void;
}
// ── Helpers ────────────────────────────────────────────────────────────────
/** Determine breakpoint group from current viewport width. */
function getBreakpointGroup(): BreakpointGroup {
if (typeof window === 'undefined') return 'tablet-desktop'
return window.matchMedia('(max-width: 767px)').matches ? 'phone' : 'tablet-desktop'
if (typeof window === 'undefined') return 'tablet-desktop';
return window.matchMedia('(max-width: 767px)').matches ? 'phone' : 'tablet-desktop';
}
/** localStorage key for the persisted view of a breakpoint group. */
function viewStorageKey(group: BreakpointGroup): string {
return `calendarView.${group}`
return `calendarView.${group}`;
}
/** Read the persisted view for the current breakpoint group. */
function readPersistedView(): string {
const group = getBreakpointGroup()
const group = getBreakpointGroup();
try {
const stored = localStorage.getItem(viewStorageKey(group))
if (stored) return stored
const stored = localStorage.getItem(viewStorageKey(group));
if (stored) return stored;
} catch {
// localStorage may be unavailable (SSR, private mode)
}
// D-05 defaults: phone → 'month-agenda', tablet-desktop → 'month-grid'
return group === 'phone' ? 'month-agenda' : 'month-grid'
return group === 'phone' ? 'month-agenda' : 'month-grid';
}
/** Build the initial calendarRange: today ± ~1 week buffer (current month ± 7 days). */
function initialCalendarRange(): { start: string; end: string } {
const today = new Date()
const start = new Date(today.getFullYear(), today.getMonth(), 1)
start.setDate(start.getDate() - 7)
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0)
end.setDate(end.getDate() + 7)
const today = new Date();
const start = new Date(today.getFullYear(), today.getMonth(), 1);
start.setDate(start.getDate() - 7);
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
end.setDate(end.getDate() + 7);
// WR-04: localDateISO uses local calendar accessors — NEVER toISOString().slice(0,10),
// which returns the UTC date and (for the project's negative-offset zones after ~20:00
// local) seeds the window/selectedDate on the wrong day.
return {
start: localDateISO(start),
end: localDateISO(end),
}
};
}
/** Today as an ISO date string (YYYY-MM-DD). Exported for use in EventForm (IN-03). */
export function todayIso(): string {
// WR-04: local-date accessor, not the banned UTC slice.
return localDateISO(new Date())
return localDateISO(new Date());
}
// ── Store ──────────────────────────────────────────────────────────────────
@@ -164,11 +164,11 @@ export const useCalendarStore = create<CalendarStore>((set) => ({
sessionExpired: false,
setSelectedView: (view: string) => {
set({ selectedView: view })
set({ selectedView: view });
// Persist to localStorage keyed by breakpoint group
const group = getBreakpointGroup()
const group = getBreakpointGroup();
try {
localStorage.setItem(viewStorageKey(group), view)
localStorage.setItem(viewStorageKey(group), view);
} catch {
// Ignore write failures
}
@@ -178,8 +178,7 @@ export const useCalendarStore = create<CalendarStore>((set) => ({
setOpenEventId: (id: string | null) => set({ openEventId: id }),
setCalendarRange: (range: { start: string; end: string }) =>
set({ calendarRange: range }),
setCalendarRange: (range: { start: string; end: string }) => set({ calendarRange: range }),
setEventForm: (open: boolean, mode: 'create' | 'edit' = 'create', uid: string | null = null) =>
set({ eventFormOpen: open, eventFormMode: mode, eventFormUid: uid }),
@@ -190,4 +189,4 @@ export const useCalendarStore = create<CalendarStore>((set) => ({
setLastSyncedUid: (uid: string | null) => set({ lastSyncedUid: uid }),
setSessionExpired: (expired: boolean) => set({ sessionExpired: expired }),
}))
}));
+6 -6
View File
@@ -12,17 +12,17 @@
* createListSheetOpen whether the "New List" sheet/dialog is open
*/
import { create } from 'zustand'
import { create } from 'zustand';
// ── Types ──────────────────────────────────────────────────────────────────
export interface ListsStore {
// UI-only state — no server data
activeTab: 'calendar' | 'lists'
createListSheetOpen: boolean
activeTab: 'calendar' | 'lists';
createListSheetOpen: boolean;
setActiveTab: (tab: 'calendar' | 'lists') => void
setCreateListSheetOpen: (open: boolean) => void
setActiveTab: (tab: 'calendar' | 'lists') => void;
setCreateListSheetOpen: (open: boolean) => void;
}
// ── Store ──────────────────────────────────────────────────────────────────
@@ -33,4 +33,4 @@ export const useListsStore = create<ListsStore>()((set) => ({
setActiveTab: (tab) => set({ activeTab: tab }),
setCreateListSheetOpen: (open) => set({ createListSheetOpen: open }),
}))
}));
+6 -6
View File
@@ -108,17 +108,17 @@ body {
* current /api/me members list). Remapping it keeps all-day events solid-filled
* even in that fallback case, so the pill never degrades to the light tint. */
--sx-color-primary-container: var(--sx-color-primary);
--sx-color-on-primary-container: #FFFFFF;
--sx-color-on-primary-container: #ffffff;
--sx-color-shared-container: var(--sx-color-shared);
--sx-color-on-shared-container: #FFFFFF;
--sx-color-on-shared-container: #ffffff;
--sx-color-member-1-container: var(--sx-color-member-1);
--sx-color-on-member-1-container: #FFFFFF;
--sx-color-on-member-1-container: #ffffff;
--sx-color-member-2-container: var(--sx-color-member-2);
--sx-color-on-member-2-container: #FFFFFF;
--sx-color-on-member-2-container: #ffffff;
--sx-color-member-3-container: var(--sx-color-member-3);
--sx-color-on-member-3-container: #FFFFFF;
--sx-color-on-member-3-container: #ffffff;
--sx-color-member-4-container: var(--sx-color-member-4);
--sx-color-on-member-4-container: #FFFFFF;
--sx-color-on-member-4-container: #ffffff;
}
/* Week/Day all-day row — .sx__date-grid-event is exclusively all-day in this view */
+19 -18
View File
@@ -18,18 +18,18 @@
* BASE SURFACE / BORDER / TEXT PALETTE
* */
--color-surface: #FFFFFF;
--color-surface-dim: #F7F7F8;
--color-surface-raised: #FFFFFF;
--color-surface: #ffffff;
--color-surface-dim: #f7f7f8;
--color-surface-raised: #ffffff;
--color-border: #E2E4E9;
--color-border-subtle: #ECEEF2;
--color-border: #e2e4e9;
--color-border-subtle: #eceef2;
--color-text-primary: #111318;
--color-text-secondary: #6B7280;
--color-text-muted: #9CA3AF;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
--color-focus-ring: #4A90D9;
--color-focus-ring: #4a90d9;
--color-overlay: rgba(0, 0, 0, 0.32);
/*
@@ -37,18 +37,18 @@
* 10% accent band ONLY for event chip fills and color legend swatches.
* */
--color-member-0: #4A90D9;
--color-member-1: #50C878;
--color-member-2: #F5A623;
--color-member-3: #9B59B6;
--color-member-4: #E67E22;
--color-member-5: #1ABC9C;
--color-member-0: #4a90d9;
--color-member-1: #50c878;
--color-member-2: #f5a623;
--color-member-3: #9b59b6;
--color-member-4: #e67e22;
--color-member-5: #1abc9c;
/* Shared-family calendar — rose, confirmed by user */
--color-shared-family: #F25C7A;
--color-shared-family: #f25c7a;
/* Destructive — declared for Phase 3 reuse; not used in Phase 2 (read-only) */
--color-destructive: #DC2626;
--color-destructive: #dc2626;
/*
* SPACING SCALE (multiples of 4px)
@@ -66,7 +66,7 @@
* TYPOGRAPHY
* */
--font-family-base: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-family-base: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
/* Body — 15px/400/1.5: popover description, agenda location lines */
--text-body-size: 15px;
@@ -147,7 +147,8 @@
}
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {
+2 -2
View File
@@ -72,6 +72,6 @@ export const tokens = {
bpPhone: '0px',
bpTablet: '768px',
bpDesktop: '1280px',
} as const
} as const;
export type Tokens = typeof tokens
export type Tokens = typeof tokens;
+44 -50
View File
@@ -25,26 +25,26 @@
/// <reference lib="webworker" />
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'
import { clientsClaim } from 'workbox-core'
import { NavigationRoute, registerRoute } from 'workbox-routing'
import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching';
import { clientsClaim } from 'workbox-core';
import { NavigationRoute, registerRoute } from 'workbox-routing';
declare const self: ServiceWorkerGlobalScope
declare const self: ServiceWorkerGlobalScope;
// ---------------------------------------------------------------------------
// AutoUpdate behavior: replace old SW immediately on install/activate.
// Equivalent to the former generateSW autoUpdate: 'prompt' → 'autoUpdate' path.
// ---------------------------------------------------------------------------
// skipWaiting() resolves when the SW is installed; fire-and-forget is the correct pattern here
void self.skipWaiting()
clientsClaim()
void self.skipWaiting();
clientsClaim();
// ---------------------------------------------------------------------------
// Precache the app shell.
// self.__WB_MANIFEST is injected by vite-plugin-pwa injectManifest at build time.
// At dev time this is an empty array; the real manifest is injected in production.
// ---------------------------------------------------------------------------
precacheAndRoute(self.__WB_MANIFEST)
precacheAndRoute(self.__WB_MANIFEST);
// ---------------------------------------------------------------------------
// Navigation denylist (T-03-20 — CRITICAL).
@@ -56,7 +56,7 @@ precacheAndRoute(self.__WB_MANIFEST)
//
// Re-implements the former workbox.navigateFallbackDenylist from vite.config.ts.
// ---------------------------------------------------------------------------
const navHandler = createHandlerBoundToURL('/index.html')
const navHandler = createHandlerBoundToURL('/index.html');
registerRoute(
new NavigationRoute(navHandler, {
denylist: [
@@ -65,7 +65,7 @@ registerRoute(
/^\/health/, // Health endpoint
],
}),
)
);
// ---------------------------------------------------------------------------
// Push handler (D-11, NOTIF-01/02/03).
@@ -79,14 +79,14 @@ registerRoute(
// Legacy (iOS 16.418.3 + Android): { title, body, tag, data: { url } }
// ---------------------------------------------------------------------------
self.addEventListener('push', (event: PushEvent) => {
let title = 'FamilySync'
let body = 'You have a new notification'
let tag = 'familysync-notification'
let url = '/'
let title = 'FamilySync';
let body = 'You have a new notification';
let tag = 'familysync-notification';
let url = '/';
try {
if (event.data) {
const payload = event.data.json() as Record<string, unknown>
const payload = event.data.json() as Record<string, unknown>;
// iOS 18.4+ declarative web push format
if (
@@ -94,23 +94,23 @@ self.addEventListener('push', (event: PushEvent) => {
payload.notification &&
typeof payload.notification === 'object'
) {
const notif = payload.notification as Record<string, unknown>
if (typeof notif.title === 'string') title = notif.title
if (typeof notif.body === 'string') body = notif.body
if (typeof notif.navigate === 'string') url = notif.navigate
const notif = payload.notification as Record<string, unknown>;
if (typeof notif.title === 'string') title = notif.title;
if (typeof notif.body === 'string') body = notif.body;
if (typeof notif.navigate === 'string') url = notif.navigate;
// Use navigate as tag for coalescing duplicate pushes
tag = `familysync-${url}`
tag = `familysync-${url}`;
} else {
// Legacy format: top-level title/body/tag/data
if (typeof payload.title === 'string') title = payload.title
if (typeof payload.body === 'string') body = payload.body
if (typeof payload.tag === 'string') tag = payload.tag
if (typeof payload.title === 'string') title = payload.title;
if (typeof payload.body === 'string') body = payload.body;
if (typeof payload.tag === 'string') tag = payload.tag;
if (
payload.data &&
typeof payload.data === 'object' &&
typeof (payload.data as Record<string, unknown>).url === 'string'
) {
url = (payload.data as Record<string, string>).url
url = (payload.data as Record<string, string>).url;
}
}
}
@@ -134,11 +134,9 @@ self.addEventListener('push', (event: PushEvent) => {
badge: '/icon-192.png',
renotify: true,
vibrate: [200, 100, 200],
} as NotificationOptions
event.waitUntil(
self.registration.showNotification(title, options),
)
})
} as NotificationOptions;
event.waitUntil(self.registration.showNotification(title, options));
});
// ---------------------------------------------------------------------------
// notificationclick handler (D-14 — deep-link on tap).
@@ -155,35 +153,31 @@ self.addEventListener('push', (event: PushEvent) => {
// Using any existing window + navigate() satisfies D-14 on both Android and iOS.
// ---------------------------------------------------------------------------
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close()
event.notification.close();
// Notification.data is typed as 'any' in the ServiceWorker lib; we validate with typeof
// before using the value so the access is safe despite the lack of static types.
let url = '/'
let url = '/';
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Notification.data is 'any' per webworker lib; typeof guard on the right-hand side validates this access
if (typeof event.notification.data?.url === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- Notification.data is 'any'; typeof check above validates this access
url = event.notification.data.url as string
url = event.notification.data.url as string;
}
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus any existing window on this origin and navigate it to the target URL.
// We don't match on client.url — the deep-link URL will almost always differ
// from the current window location (query string with event uid / date).
for (const client of clientList) {
if ('focus' in client) {
return client.focus().then(() =>
client.navigate(url)
)
}
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Focus any existing window on this origin and navigate it to the target URL.
// We don't match on client.url — the deep-link URL will almost always differ
// from the current window location (query string with event uid / date).
for (const client of clientList) {
if ('focus' in client) {
return client.focus().then(() => client.navigate(url));
}
// No existing window — open a new one at the deep-link URL
if (self.clients.openWindow) {
return self.clients.openWindow(url)
}
}),
)
})
}
// No existing window — open a new one at the deep-link URL
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
}),
);
});
+2 -2
View File
@@ -11,7 +11,7 @@
*/
// Extend Vitest's expect with jest-dom matchers (toHaveTextContent, etc.)
import '@testing-library/jest-dom'
import '@testing-library/jest-dom';
// Polyfill window.matchMedia for jsdom.
// jsdom does not implement the CSSOM MediaQueryList API.
@@ -29,4 +29,4 @@ Object.defineProperty(window, 'matchMedia', {
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
})
});