diff --git a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx index 1f59a36..1c4ee97 100644 --- a/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx +++ b/frontend/src/features/assistant/presentation/AssistantVoiceOverlay.tsx @@ -118,20 +118,10 @@ export function AssistantVoiceOverlay({ } return ( - - {/* 铺满全屏的蒙层:点气泡和长条以外的任何地方(包括上面的日历区域)都会 - 命中这层,因为它渲染在长条之前、又是 box-none 容器唯一会拦截"空白处" - 点击的兜底。 */} - {ptt.replyText ? ( - - ) : null} + {ptt.replyText ? ( - {}} style={styles.bubble}> + {ptt.replyText} ) : null} @@ -174,6 +164,15 @@ const styles = StyleSheet.create({ gap: spacing.sm, width: '100%', }, + container: { + backgroundColor: colors.surface, + borderTopColor: colors.border, + borderTopWidth: StyleSheet.hairlineWidth, + paddingBottom: spacing.md, + paddingHorizontal: spacing.lg, + paddingTop: spacing.sm, + width: '100%', + }, bubble: { backgroundColor: colors.surface, borderColor: colors.border, @@ -203,10 +202,6 @@ const styles = StyleSheet.create({ }, overlay: { alignItems: 'center', - bottom: spacing.xl, - left: 0, - paddingHorizontal: spacing.lg, - position: 'absolute', - right: 0, + width: '100%', }, }); diff --git a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx index 16a570c..5d898c4 100644 --- a/frontend/src/features/assistant/presentation/PushToTalkBar.tsx +++ b/frontend/src/features/assistant/presentation/PushToTalkBar.tsx @@ -101,8 +101,10 @@ function normalizeLevel(dbfs: number | null): number { const styles = StyleSheet.create({ bar: { alignItems: 'center', - backgroundColor: colors.text, - borderRadius: 999, + backgroundColor: colors.input, + borderColor: colors.border, + borderRadius: 14, + borderWidth: 1, flex: 1, height: 52, justifyContent: 'center', @@ -117,12 +119,12 @@ const styles = StyleSheet.create({ opacity: 0.86, }, label: { - color: colors.onPrimary, + color: colors.text, fontSize: 14, fontWeight: '600', }, labelDisabled: { - color: colors.onPrimary, + color: colors.mutedText, }, wave: { alignItems: 'center', diff --git a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx index 30ad8ef..84f81a2 100644 --- a/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx +++ b/frontend/src/features/schedule/presentation/ScheduleCalendarScreen.tsx @@ -14,6 +14,7 @@ import { MonthCalendar } from './MonthCalendar'; import { ScheduleOccurrenceDetailSheet } from './ScheduleOccurrenceDetailSheet'; import { ScheduleOccurrenceRow } from './ScheduleOccurrenceRow'; import { useScheduleCalendar } from './useScheduleCalendar'; +import type { CalendarFocusTarget } from './calendarFocus'; const SELECTED_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', { day: 'numeric', @@ -30,6 +31,7 @@ interface ScheduleCalendarScreenProps { isSigningOut?: boolean; /** 外部触发刷新用(比如语音写完一条日程);变化即重取,不用管具体数值。 */ refreshSignal?: number; + focusTarget?: CalendarFocusTarget | null; } export function ScheduleCalendarScreen({ @@ -40,8 +42,16 @@ export function ScheduleCalendarScreen({ onSignOut, isSigningOut = false, refreshSignal, + focusTarget, }: ScheduleCalendarScreenProps) { - const calendar = useScheduleCalendar(service, accountId, timezone, undefined, refreshSignal); + const calendar = useScheduleCalendar( + service, + accountId, + timezone, + undefined, + refreshSignal, + focusTarget, + ); const [selectedOccurrence, setSelectedOccurrence] = useState(null); const [selectedLocation, setSelectedLocation] = useState(null); const selectedLabel = SELECTED_DATE_FORMATTER.format(calendar.selectedDate); @@ -276,7 +286,7 @@ const styles = StyleSheet.create({ }, retryText: { color: colors.onPrimary, fontWeight: '700' }, screen: { backgroundColor: colors.background, flex: 1 }, - scrollContent: { paddingBottom: spacing.xxl * 3 }, + scrollContent: { paddingBottom: spacing.lg }, sectionCount: { color: colors.mutedText, fontSize: 12, fontWeight: '600' }, sectionEyebrow: { color: colors.mutedText, fontSize: 12, fontWeight: '600', marginBottom: 3 }, sectionHeader: { diff --git a/frontend/src/features/schedule/presentation/calendarFocus.ts b/frontend/src/features/schedule/presentation/calendarFocus.ts new file mode 100644 index 0000000..2041419 --- /dev/null +++ b/frontend/src/features/schedule/presentation/calendarFocus.ts @@ -0,0 +1,34 @@ +import type { AppliedCommand } from '../../assistant/domain/ConversationTurn'; + +export interface CalendarFocusTarget { + scheduleId: string; + kind: 'time' | 'location'; + recurrenceMode: 'once' | 'recurring' | null; + recurrenceRule: string | null; + startTime: string | null; + timezone: string | null; +} + +export function calendarFocusTargetFromCommand( + command: AppliedCommand | null, +): CalendarFocusTarget | null { + if (command === null || command.status !== 'applied' || command.operation !== 'create_schedule') { + return null; + } + const raw = command.schedule; + if (raw === undefined || typeof raw.id !== 'string' || raw.id.length === 0) return null; + + const kind = + raw.schedule_type === 'location' || typeof raw.start_time !== 'string' ? 'location' : 'time'; + const recurrenceMode = + raw.schedule_kind === 'recurring' ? 'recurring' : raw.schedule_kind === 'once' ? 'once' : null; + + return { + kind, + recurrenceMode, + recurrenceRule: typeof raw.recurrence_rule === 'string' ? raw.recurrence_rule : null, + scheduleId: raw.id, + startTime: typeof raw.start_time === 'string' ? raw.start_time : null, + timezone: typeof raw.timezone === 'string' ? raw.timezone : null, + }; +} diff --git a/frontend/src/features/schedule/presentation/useScheduleCalendar.ts b/frontend/src/features/schedule/presentation/useScheduleCalendar.ts index 3d0ac05..4bb1ea6 100644 --- a/frontend/src/features/schedule/presentation/useScheduleCalendar.ts +++ b/frontend/src/features/schedule/presentation/useScheduleCalendar.ts @@ -6,7 +6,18 @@ import type { ScheduleCalendarReadService, ScheduleOccurrenceView, } from '../application'; +import { + floatingDateToLocalParts, + instantToZonedParts, + localPartsToFloatingDate, + zonedPartsToInstant, +} from '../domain/scheduleDateTime'; +import { + normalizeUtcUntilForFloatingRrule, + parseScheduleRrule, +} from '../domain/scheduleRecurrence'; import { addDays, dateKey, dateKeyInTimezone, startOfMonth } from './scheduleDisplay'; +import type { CalendarFocusTarget } from './calendarFocus'; export interface ScheduleCalendarState { selectedDate: Date; @@ -34,6 +45,7 @@ export function useScheduleCalendar( initialDate = new Date(), /** 外部触发刷新用(比如语音写完一条日程);变化时跟 retry() 走同一条重取路径。 */ refreshSignal = 0, + focusTarget?: CalendarFocusTarget | null, ): ScheduleCalendarState { const [selectedDate, setSelectedDate] = useState(() => initialDate); const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(initialDate)); @@ -47,6 +59,45 @@ export function useScheduleCalendar( const [locationsError, setLocationsError] = useState(null); const [reloadToken, setReloadToken] = useState(0); + const focusKey = focusTarget + ? `${focusTarget.scheduleId}:${focusTarget.kind}:${focusTarget.recurrenceMode}:${focusTarget.startTime ?? ''}` + : null; + + useEffect(() => { + if (focusTarget === undefined || focusTarget === null || focusTarget.kind === 'location') { + return; + } + + let cancelled = false; + const applyDate = (date: Date) => { + if (cancelled) return; + setSelectedDate(date); + setVisibleMonth(startOfMonth(date)); + }; + + if (focusTarget.recurrenceMode !== 'recurring') { + const dateKeyForTarget = focusTarget.startTime + ? dateKeyInTimezone(focusTarget.startTime, timezone) + : null; + const date = dateKeyForTarget ? parseDateKey(dateKeyForTarget) : null; + if (date !== null) applyDate(date); + return () => { + cancelled = true; + }; + } + + void findNextOccurrenceDate(service, accountId, timezone, focusTarget) + .then((date) => { + if (date !== null) applyDate(date); + }) + .catch(() => { + if (!cancelled) setOccurrencesError('日程加载失败,请重试'); + }); + return () => { + cancelled = true; + }; + }, [accountId, focusKey, focusTarget, refreshSignal, service, timezone]); + useEffect(() => { let cancelled = false; const dates = monthGridDates(visibleMonth); @@ -162,3 +213,56 @@ export function useScheduleCalendar( retry, }; } + +function parseDateKey(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + return dateKey(date) === value ? date : null; +} + +async function findNextOccurrenceDate( + service: ScheduleCalendarReadService, + accountId: string, + timezone: string, + target: CalendarFocusTarget, +): Promise { + const now = new Date(); + const scheduleTimezone = target.timezone ?? timezone; + if (target.startTime === null || target.recurrenceRule === null) return null; + const scheduleStart = new Date(target.startTime); + if (Number.isNaN(scheduleStart.getTime())) return null; + const floatingStart = localPartsToFloatingDate( + instantToZonedParts(scheduleStart, scheduleTimezone), + ); + const floatingNow = localPartsToFloatingDate(instantToZonedParts(now, scheduleTimezone)); + const rule = parseScheduleRrule( + normalizeUtcUntilForFloatingRrule(target.recurrenceRule, scheduleTimezone), + floatingStart, + ); + const nextFloating = rule.after(floatingNow, false); + if (nextFloating === null) return null; + const nextInstant = zonedPartsToInstant(floatingDateToLocalParts(nextFloating), scheduleTimezone); + const startKey = dateKeyInTimezone(nextInstant.toISOString(), timezone); + const startDate = startKey ? parseDateKey(startKey) : null; + if (startDate === null || startKey === null) return null; + const occurrences = await service.getSchedulesByRange({ + accountId, + endDate: dateKey(addDays(startDate, 1)), + startDate: startKey, + timezone, + }); + const next = occurrences + .filter( + (occurrence) => + occurrence.scheduleId === target.scheduleId && + occurrence.occurrenceStart !== null && + new Date(occurrence.occurrenceStart).getTime() >= now.getTime(), + ) + .sort( + (left, right) => + new Date(left.occurrenceStart!).getTime() - new Date(right.occurrenceStart!).getTime(), + )[0]; + const nextKey = next?.occurrenceStart ? dateKeyInTimezone(next.occurrenceStart, timezone) : null; + return nextKey ? parseDateKey(nextKey) : null; +} diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index 018344b..649997e 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -6,6 +6,10 @@ import { AssistantVoiceOverlay } from '../features/assistant/presentation/Assist import { useAssistantConversation } from '../features/assistant/presentation/useAssistantConversation'; import type { ScheduleCalendarReadService } from '../features/schedule/application'; import { ScheduleCalendarScreen } from '../features/schedule/presentation/ScheduleCalendarScreen'; +import { + calendarFocusTargetFromCommand, + type CalendarFocusTarget, +} from '../features/schedule/presentation/calendarFocus'; interface HomeScreenProps { pushToTalkApplication: AssistantApplicationPort; @@ -38,6 +42,7 @@ export function HomeScreen({ const [trackedPttCommand, setTrackedPttCommand] = useState(pttCommand); const [trackedCallCommand, setTrackedCallCommand] = useState(callCommand); const [refreshSignal, setRefreshSignal] = useState(0); + const [focusTarget, setFocusTarget] = useState(null); // command.result 写完本地库之后 lastAppliedCommand 才会更新(见 // AssistantConversationService.applyCommandResultLocally),所以这里发现它 @@ -46,12 +51,14 @@ export function HomeScreen({ if (trackedPttCommand !== pttCommand) { setTrackedPttCommand(pttCommand); if (pttCommand !== null) { + setFocusTarget(calendarFocusTargetFromCommand(pttCommand)); setRefreshSignal((value) => value + 1); } } if (trackedCallCommand !== callCommand) { setTrackedCallCommand(callCommand); if (callCommand !== null) { + setFocusTarget(calendarFocusTargetFromCommand(callCommand)); setRefreshSignal((value) => value + 1); } } @@ -63,6 +70,7 @@ export function HomeScreen({ isSigningOut={isSigningOut} onSignOut={onSignOut} refreshSignal={refreshSignal} + focusTarget={focusTarget} service={scheduleService} timezone={timezone} username={username} diff --git a/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx new file mode 100644 index 0000000..2a3287f --- /dev/null +++ b/frontend/tests/unit/features/assistant/presentation/AssistantVoiceOverlay.test.tsx @@ -0,0 +1,65 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import type { AssistantApplicationPort } from '../../../../../src/features/assistant/application/AssistantApplication'; +import { AssistantVoiceOverlay } from '../../../../../src/features/assistant/presentation/AssistantVoiceOverlay'; + +jest.mock('../../../../../src/features/assistant/presentation/useAssistantConversation', () => ({ + useAssistantConversation: (application: AssistantApplicationPort) => ({ + dismissReply: mockDismissReply, + endTurn: application.endTurn, + lastAppliedCommand: null, + replyText: application === mockPttApplication ? '已创建' : null, + soundLevel: null, + startTurn: application.startTurn, + state: { phase: 'idle' as const }, + togglePause: () => {}, + }), +})); + +let mockPttApplication: AssistantApplicationPort; +const mockDismissReply = jest.fn(); + +function createApplication(): AssistantApplicationPort { + return { + dismissReply: async () => {}, + dispose: () => {}, + endTurn: async () => {}, + getLastAppliedCommand: () => null, + getReplyText: () => null, + getSoundLevel: () => null, + getState: () => ({ phase: 'idle' }), + startTurn: async () => {}, + subscribe: () => () => {}, + }; +} + +describe('AssistantVoiceOverlay layout', () => { + it('does not add a fullscreen reply dismiss target over the calendar', () => { + mockPttApplication = createApplication(); + const continuousApplication = createApplication(); + render( + , + ); + + expect(screen.queryByLabelText('关闭回复')).toBeNull(); + expect(screen.getByText('按住说话')).toBeTruthy(); + }); + + it('dismisses the reply when the bubble itself is pressed', () => { + mockPttApplication = createApplication(); + const continuousApplication = createApplication(); + render( + , + ); + + fireEvent.press(screen.getByText('已创建')); + expect(mockDismissReply).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx b/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx new file mode 100644 index 0000000..a6b64cd --- /dev/null +++ b/frontend/tests/unit/features/assistant/presentation/PushToTalkBarLayout.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen } from '@testing-library/react-native'; +import { StyleSheet } from 'react-native'; + +import { PushToTalkBar } from '../../../../../src/features/assistant/presentation/PushToTalkBar'; + +describe('PushToTalkBar layout', () => { + it('uses a light bordered input treatment in the idle state', () => { + render( + {}} + onPressOut={() => {}} + soundLevel={null} + />, + ); + + const button = screen.getByRole('button'); + expect(StyleSheet.flatten(button.props.style)).toMatchObject({ + backgroundColor: '#F0F2EE', + borderWidth: 1, + }); + }); +}); diff --git a/frontend/tests/unit/features/schedule/presentation/calendarFocus.test.ts b/frontend/tests/unit/features/schedule/presentation/calendarFocus.test.ts new file mode 100644 index 0000000..d34952e --- /dev/null +++ b/frontend/tests/unit/features/schedule/presentation/calendarFocus.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from '@jest/globals'; + +import { calendarFocusTargetFromCommand } from '../../../../../src/features/schedule/presentation/calendarFocus'; + +const schedule = { + id: 'schedule-a', + schedule_type: 'time', + schedule_kind: 'once', + start_time: '2026-08-24T07:00:00Z', + timezone: 'Asia/Shanghai', +}; + +describe('calendarFocusTargetFromCommand', () => { + it('creates a focus target only for an applied create_schedule result', () => { + expect( + calendarFocusTargetFromCommand({ operation: 'create_schedule', status: 'applied', schedule }), + ).toMatchObject({ scheduleId: 'schedule-a', kind: 'time', recurrenceMode: 'once' }); + }); + + it.each(['list_schedules', 'update_schedule', 'delete_schedule'])( + 'does not focus after %s even when the result contains a schedule', + (operation) => { + expect(calendarFocusTargetFromCommand({ operation, status: 'applied', schedule })).toBeNull(); + }, + ); + + it('does not use the plural schedules field for a read result', () => { + expect( + calendarFocusTargetFromCommand({ + operation: 'list_schedules', + status: 'applied', + schedules: [schedule], + }), + ).toBeNull(); + }); +}); diff --git a/frontend/tests/unit/features/schedule/presentation/useScheduleCalendar.test.ts b/frontend/tests/unit/features/schedule/presentation/useScheduleCalendar.test.ts index f40cd2f..5a87e16 100644 --- a/frontend/tests/unit/features/schedule/presentation/useScheduleCalendar.test.ts +++ b/frontend/tests/unit/features/schedule/presentation/useScheduleCalendar.test.ts @@ -2,9 +2,192 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; import { describe, expect, it, jest } from '@jest/globals'; import type { ScheduleCalendarReadService } from '../../../../../src/features/schedule/application'; +import type { CalendarFocusTarget } from '../../../../../src/features/schedule/presentation/calendarFocus'; import { useScheduleCalendar } from '../../../../../src/features/schedule/presentation/useScheduleCalendar'; describe('useScheduleCalendar', () => { + it('focuses a newly created one-time schedule and refreshes the new month', async () => { + const getSchedulesByRange = jest + .fn() + .mockResolvedValue([]); + const service = { + getSchedulesByRange, + getSchedulesByDay: jest.fn(), + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + } as ScheduleCalendarReadService; + const target: CalendarFocusTarget = { + kind: 'time', + recurrenceMode: 'once', + recurrenceRule: null, + scheduleId: 'new-schedule', + startTime: '2026-08-24T07:00:00Z', + timezone: 'Asia/Shanghai', + }; + const { result, rerender } = renderHook( + ({ focusTarget }: { focusTarget: CalendarFocusTarget | null }) => + useScheduleCalendar( + service, + 'account-a', + 'Asia/Shanghai', + new Date(2026, 7, 14), + 0, + focusTarget, + ), + { initialProps: { focusTarget: null } }, + ); + + await waitFor(() => expect(getSchedulesByRange).toHaveBeenCalledTimes(1)); + rerender({ focusTarget: target }); + await waitFor(() => expect(result.current.selectedDate).toEqual(new Date(2026, 7, 24))); + expect(result.current.visibleMonth).toEqual(new Date(2026, 7, 1)); + await waitFor(() => expect(getSchedulesByRange).toHaveBeenCalledTimes(2)); + }); + + it('uses the nearest future occurrence for a recurring focus target', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-14T00:00:00Z')); + const occurrence = { + scheduleId: 'weekly', + scheduleCategory: 'time' as const, + recurrenceMode: 'recurring' as const, + title: 'Weekly meeting', + isAllDay: false, + timezone: 'Asia/Shanghai', + locationName: null, + reminderType: null, + reminderStrength: null, + occurrenceStart: '2026-08-17T02:00:00Z', + occurrenceEnd: null, + }; + const getSchedulesByRange = jest + .fn() + .mockResolvedValue([occurrence]); + const service = { + getSchedulesByRange, + getSchedulesByDay: jest.fn(), + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + } as ScheduleCalendarReadService; + const target: CalendarFocusTarget = { + kind: 'time', + recurrenceMode: 'recurring', + recurrenceRule: 'FREQ=WEEKLY;BYDAY=MO', + scheduleId: 'weekly', + startTime: '2026-08-03T02:00:00Z', + timezone: 'Asia/Shanghai', + }; + const { result } = renderHook(() => + useScheduleCalendar(service, 'account-a', 'Asia/Shanghai', new Date(2026, 7, 14), 1, target), + ); + + await waitFor(() => expect(result.current.selectedDate).toEqual(new Date(2026, 7, 17))); + expect(result.current.visibleMonth).toEqual(new Date(2026, 7, 1)); + expect(getSchedulesByRange).toHaveBeenCalledWith( + expect.objectContaining({ startDate: '2026-08-17', endDate: '2026-08-18' }), + ); + jest.useRealTimers(); + }); + + it('does not change the selected date for a location focus target', async () => { + const service = { + getSchedulesByRange: jest + .fn() + .mockResolvedValue([]), + getSchedulesByDay: jest.fn(), + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + } as ScheduleCalendarReadService; + const target: CalendarFocusTarget = { + kind: 'location', + recurrenceMode: null, + recurrenceRule: null, + scheduleId: 'location-a', + startTime: null, + timezone: 'Asia/Shanghai', + }; + const { result } = renderHook(() => + useScheduleCalendar(service, 'account-a', 'Asia/Shanghai', new Date(2026, 7, 14), 1, target), + ); + + await waitFor(() => expect(service.getSchedulesByRange).toHaveBeenCalledTimes(1)); + expect(result.current.selectedDate).toEqual(new Date(2026, 7, 14)); + }); + + it('keeps the current date and surfaces an error when recurring focus lookup fails', async () => { + const service = { + getSchedulesByRange: jest + .fn() + .mockRejectedValue(new Error('sqlite unavailable')), + getSchedulesByDay: jest.fn(), + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + } as ScheduleCalendarReadService; + const target: CalendarFocusTarget = { + kind: 'time', + recurrenceMode: 'recurring', + recurrenceRule: 'FREQ=WEEKLY;BYDAY=MO', + scheduleId: 'weekly', + startTime: '2026-08-03T02:00:00Z', + timezone: 'Asia/Shanghai', + }; + const { result } = renderHook(() => + useScheduleCalendar(service, 'account-a', 'Asia/Shanghai', new Date(2026, 7, 14), 1, target), + ); + + await waitFor(() => expect(result.current.error).toBe('日程加载失败,请重试')); + expect(result.current.selectedDate).toEqual(new Date(2026, 7, 14)); + }); + + it('resolves a multi-year recurring interval without a one-year search cap', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-14T00:00:00Z')); + const occurrence = { + scheduleId: 'biennial', + scheduleCategory: 'time' as const, + recurrenceMode: 'recurring' as const, + title: 'Biennial event', + isAllDay: false, + timezone: 'Asia/Shanghai', + locationName: null, + reminderType: null, + reminderStrength: null, + occurrenceStart: '2027-08-24T02:00:00Z', + occurrenceEnd: null, + }; + const getSchedulesByRange = jest + .fn() + .mockResolvedValue([occurrence]); + const service = { + getSchedulesByRange, + getSchedulesByDay: jest.fn(), + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + } as ScheduleCalendarReadService; + const target: CalendarFocusTarget = { + kind: 'time', + recurrenceMode: 'recurring', + recurrenceRule: 'FREQ=YEARLY;INTERVAL=2', + scheduleId: 'biennial', + startTime: '2025-08-24T02:00:00Z', + timezone: 'Asia/Shanghai', + }; + const { result } = renderHook(() => + useScheduleCalendar(service, 'account-a', 'Asia/Shanghai', new Date(2026, 7, 14), 1, target), + ); + + await waitFor(() => expect(result.current.selectedDate).toEqual(new Date(2027, 7, 24))); + expect(getSchedulesByRange).toHaveBeenCalledWith( + expect.objectContaining({ startDate: '2027-08-24', endDate: '2027-08-25' }), + ); + jest.useRealTimers(); + }); + it('loads a 42-day grid through one range query and selects the first day when changing month', async () => { const getSchedulesByRange = jest .fn()