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