diff --git a/frontend/src/app/AppRoot.tsx b/frontend/src/app/AppRoot.tsx index cc47cb03..8470a9df 100644 --- a/frontend/src/app/AppRoot.tsx +++ b/frontend/src/app/AppRoot.tsx @@ -9,6 +9,7 @@ import { ExpoAudioPlayback } from '../features/assistant/data/audio/ExpoAudioPla import { AuthenticatedVoiceTransport } from '../features/assistant/data/websocket/AuthenticatedVoiceTransport'; import { LocalScheduleWriter } from '../features/assistant/data/local/LocalScheduleWriter'; import { useAuth } from '../features/auth/presentation/AuthProvider'; +import type { SqliteLocalScheduleReader, SqliteReminderStateStore } from '../features/reminder'; import { SqliteScheduleClientService } from '../features/schedule/application'; import type { ScheduleLocalRepository } from '../features/schedule/data'; import { RNAppStateProvider } from '../infrastructure/appState/RNAppStateProvider'; @@ -34,6 +35,8 @@ export function AppRoot({ services: providedServices }: { services?: AppServices > @@ -42,9 +45,13 @@ export function AppRoot({ services: providedServices }: { services?: AppServices function AuthRoute({ protectedClient, + reminderState, + scheduleReader, webSocketClient, }: { readonly protectedClient: ApiRequest; + readonly reminderState: SqliteReminderStateStore; + readonly scheduleReader: SqliteLocalScheduleReader; readonly webSocketClient: AuthenticatedWebSocketClient; }) { const { retryInitialization, viewState } = useAuth(); @@ -78,6 +85,8 @@ function AuthRoute({ accountId={viewState.accountId} key={viewState.accountId} protectedClient={protectedClient} + reminderState={reminderState} + scheduleReader={scheduleReader} username={viewState.username} webSocketClient={webSocketClient} /> @@ -98,11 +107,15 @@ type ScheduleLoadState = function AuthenticatedScheduleRoute({ accountId, protectedClient, + reminderState, + scheduleReader, username, webSocketClient, }: { readonly accountId: string; readonly protectedClient: ApiRequest; + readonly reminderState: SqliteReminderStateStore; + readonly scheduleReader: SqliteLocalScheduleReader; readonly username: string; readonly webSocketClient: AuthenticatedWebSocketClient; }) { @@ -158,6 +171,21 @@ function AuthenticatedScheduleRoute({ [currentLoadState], ); + // The reminder runtime starts as soon as authentication succeeds, before SQLite may be ready. + // Bind its stable adapters to the active account/repository, then refresh the running engine. + useEffect(() => { + if (currentLoadState?.status !== 'ready') { + return; + } + reminderState.attach(currentLoadState.repository, accountId); + scheduleReader.attach(currentLoadState.repository, accountId); + void scheduleReader.refresh(); + return () => { + scheduleReader.detach(); + reminderState.detach(); + }; + }, [accountId, currentLoadState, reminderState, scheduleReader]); + // 语音这条连接复用应用唯一的 AuthenticatedWebSocketClient——握手、鉴权失效、 // 断线通知都由它统一处理,这里不再单独持有 access_token/device_id/wsUrl。 // 按住说话和免提通话共用同一批 capture/playback/location/appState 端口 @@ -174,11 +202,11 @@ function AuthenticatedScheduleRoute({ return { appState: new RNAppStateProvider(), capture: new ExpoAudioCapture(), - localScheduleWriter: new LocalScheduleWriter(currentLoadState.repository), + localScheduleWriter: new LocalScheduleWriter(currentLoadState.repository, scheduleReader), location: new ExpoLocationProvider(), playback: new ExpoAudioPlayback(), }; - }, [currentLoadState]); + }, [currentLoadState, scheduleReader]); const pushToTalkApplication = useMemo(() => { if (assistantDependencies === null) { diff --git a/frontend/src/app/composition/createAppServices.ts b/frontend/src/app/composition/createAppServices.ts index 4140dee7..4ea1065a 100644 --- a/frontend/src/app/composition/createAppServices.ts +++ b/frontend/src/app/composition/createAppServices.ts @@ -6,9 +6,9 @@ import type { } from '../../features/reminder/application/interfaces'; import { LocalReminderApplication } from '../../features/reminder/application'; import { - MockLocalScheduleReader, - MockReminderDispositionSync, - MockReminderStateStore, + LocalReminderDispositionSync, + SqliteLocalScheduleReader, + SqliteReminderStateStore, } from '../../features/reminder/data/local'; import { ExpoAudioPlayback } from '../../infrastructure/audio'; import { ExpoLocationMonitor } from '../../infrastructure/location'; @@ -31,7 +31,9 @@ export type AppServices = { runtime: AppRuntime; reminder: ReminderApplicationPort; reminderPorts: ReminderApplicationDependencies; + reminderState: SqliteReminderStateStore; scheduleView: ScheduleViewStore; + schedules: SqliteLocalScheduleReader; webSocketClient: AuthRuntime['webSocketClient']; }; @@ -42,8 +44,10 @@ export interface CreateAppServicesOptions { /** 应用唯一组合根:认证传输、功能服务、生命周期和账号内存清理在此接线。 */ export function createAppServices(options: CreateAppServicesOptions = {}): AppServices { const auth = createAuthRuntime(options.auth); + const schedules = new SqliteLocalScheduleReader(); + const reminderState = new SqliteReminderStateStore(); const reminderPorts: ReminderApplicationDependencies = { - schedules: new MockLocalScheduleReader(), + schedules, time: new IntervalTimeListener(), location: new ExpoLocationMonitor(), alarms: new NativeAlarmScheduler(), @@ -55,8 +59,8 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe popup: new MockPopup(), vibration: new MockVibration(), recovery: new MockReminderRecovery(), - state: new MockReminderStateStore(), - dispositionSync: new MockReminderDispositionSync(), + state: reminderState, + dispositionSync: new LocalReminderDispositionSync(), }; const reminder = new LocalReminderApplication(reminderPorts); const scheduleView = new ScheduleViewStore(); @@ -76,7 +80,9 @@ export function createAppServices(options: CreateAppServicesOptions = {}): AppSe runtime, reminder, reminderPorts, + reminderState, scheduleView, + schedules, webSocketClient: auth.webSocketClient, }; } diff --git a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts index 8bc22062..b7298b4a 100644 --- a/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts +++ b/frontend/src/features/assistant/data/local/LocalScheduleWriter.ts @@ -1,4 +1,5 @@ import { SCHEDULE_CATEGORIES, type ScheduleCategory } from '../../../../contracts/schedule'; +import type { SqliteLocalScheduleReader } from '../../../reminder'; import type { CloudScheduleRow, LocalScheduleOccurrenceOverrideRow, @@ -33,7 +34,10 @@ import type { AppliedCommand, AppliedOccurrenceOverride } from '../../domain/Con * message.ack,不会向服务端谎报已落库、也不会在本地留下半吊子状态。 */ export class LocalScheduleWriter implements LocalScheduleWriterPort { - public constructor(private readonly repository: ScheduleLocalRepository) {} + public constructor( + private readonly repository: ScheduleLocalRepository, + private readonly scheduleReader?: SqliteLocalScheduleReader, + ) {} public async applyCommandResult(accountId: string, command: AppliedCommand): Promise { if (command.status !== 'applied' || command.operation === 'list_schedules') { @@ -60,6 +64,9 @@ export class LocalScheduleWriter implements LocalScheduleWriterPort { } } }); + // 提醒引擎读的是 SqliteLocalScheduleReader 的投影,不是这个仓储本身;写完 + // 必须主动刷新一次,不然新建/改动的日程要等下次 rebuild 才会被提醒引擎看到。 + await this.scheduleReader?.refresh(); } public applyCategoryUpdate( diff --git a/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts b/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts new file mode 100644 index 00000000..0abf6a85 --- /dev/null +++ b/frontend/src/features/reminder/data/local/LocalReminderAdapters.ts @@ -0,0 +1,64 @@ +import type { + PopupPort, + PopupReceipt, + PopupRequest, + ReminderDeliveryPort, + ReminderDeliveryReceipt, + ReminderDeliveryRequest, + ReminderDispositionSyncPort, + ReminderDispositionSyncReceipt, + ReminderRecoveryPort, + ReminderRecoveryReceipt, + ReminderConfirmedDisposition, +} from '../../application/interfaces'; + +/** 送达记账:展示由 systemNotification / presenter 完成,此处只返回回执。 */ +export class LocalReminderDelivery implements ReminderDeliveryPort { + async deliver(request: ReminderDeliveryRequest): Promise { + return { + delivery_id: `delivery-${request.schedule_id}-${Date.now()}`, + schedule_id: request.schedule_id, + delivered_at: new Date().toISOString(), + channels: [], + used_fallback_audio: false, + }; + } + + async dismiss(_scheduleId: string): Promise { + return Promise.resolve(); + } +} + +/** 弹窗通道占位:提醒页由 ReminderPresenter 承接。 */ +export class NoopPopup implements PopupPort { + async show(request: PopupRequest): Promise { + return { popup_id: request.popup_id, visible: false }; + } + + async dismiss(_popupId: string): Promise { + return Promise.resolve(); + } +} + +/** 重启恢复占位:后续可接开机广播 / 精确闹钟重挂。 */ +export class LocalReminderRecovery implements ReminderRecoveryPort { + async registerForRestart(): Promise { + return { registered: true, recovery_id: `recovery-${Date.now()}` }; + } + + async restoreAfterRestart(): Promise { + return { registered: true, recovery_id: `recovery-${Date.now()}` }; + } +} + +/** 确认态本地受理:无网络时也返回 accepted,供后续 sync 替换。 */ +export class LocalReminderDispositionSync implements ReminderDispositionSyncPort { + async submitConfirmed( + disposition: ReminderConfirmedDisposition, + ): Promise { + return { + schedule_id: disposition.schedule_id, + accepted: true, + }; + } +} diff --git a/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts deleted file mode 100644 index 9b5942a4..00000000 --- a/frontend/src/features/reminder/data/local/MockLocalScheduleReader.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { LocalScheduleReader } from '../../application/interfaces'; -import type { LocalReminderSchedule } from '../../domain'; - -import { MOCK_REMINDER_SCHEDULES } from './mockReminderSchedules'; - -/** 代替本地数据库端口的固定只读适配器。 */ -export class MockLocalScheduleReader implements LocalScheduleReader { - readonly schedules = MOCK_REMINDER_SCHEDULES; - private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); - - async listReminderSchedules(): Promise { - return this.schedules; - } - - async getReminderSchedule(scheduleId: string): Promise { - return this.schedules.find((schedule) => schedule.id === scheduleId) ?? null; - } - - subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - /** 测试或本地写入后通知订阅方;subscribe 本身不重放,避免与 start/rebuild 重入。 */ - notify(): void { - for (const listener of this.listeners) { - listener(this.schedules); - } - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderApplication.ts b/frontend/src/features/reminder/data/local/MockReminderApplication.ts deleted file mode 100644 index bc717c72..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderApplication.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { - ReminderApplicationDependencies, - ReminderApplicationPort, - ReminderApplicationResult, - ReminderSnoozeRequest, -} from '../../application/interfaces'; -import type { - LocalReminderSchedule, - LocationSample, - ReminderDeliveryReceipt, - ReminderRegistration, - ReminderTrigger, -} from '../../domain'; - -const MOCK_DELIVERY: ReminderDeliveryReceipt = { - delivery_id: 'mock-delivery-001', - schedule_id: 'mock-schedule-time-001', - delivered_at: '2026-08-07T01:00:00.000Z', - channels: ['system_notification', 'popup', 'vibration'], - used_fallback_audio: false, -}; - -function registrationFor(schedule: LocalReminderSchedule): ReminderRegistration { - if (schedule.schedule_type === 'location') { - return { - schedule_id: schedule.id, - time_listener_id: null, - location_listener_id: `mock-location-listener-${schedule.id}`, - alarm_id: null, - }; - } - - return { - schedule_id: schedule.id, - time_listener_id: `mock-time-listener-${schedule.id}`, - location_listener_id: null, - alarm_id: `mock-alarm-${schedule.id}`, - }; -} - -/** 真实协调器完成前供应用外壳使用的应用门面。 */ -export class MockReminderApplication implements ReminderApplicationPort { - constructor(readonly dependencies: ReminderApplicationDependencies) {} - - async start(): Promise { - return Promise.resolve(); - } - - async stop(): Promise { - return Promise.resolve(); - } - - async register(schedule: LocalReminderSchedule): Promise { - return registrationFor(schedule); - } - - async rebuild(): Promise { - const schedules = await this.dependencies.schedules.listReminderSchedules(); - return schedules.map(registrationFor); - } - - async handleTime(_tick: { observed_at: string }): Promise { - return Promise.resolve(); - } - - async handleLocation(_sample: LocationSample): Promise { - return Promise.resolve(); - } - - async deliver(trigger: ReminderTrigger): Promise { - return { ...MOCK_DELIVERY, schedule_id: trigger.schedule_id }; - } - - async confirm(scheduleId: string, confirmedAt: string): Promise { - return { - accepted: true, - schedule_id: scheduleId, - disposition: { - schedule_id: scheduleId, - state: 'confirmed', - updated_at: confirmedAt, - snoozed_until: null, - sync_status: 'pending', - }, - }; - } - - async snooze(request: ReminderSnoozeRequest): Promise { - // 确定性 mock:绝对时间原样回传;相对分钟映射到固定 fixture 时刻。 - const snoozed_until = - 'snooze_minutes' in request ? '2026-08-07T01:10:00.000Z' : request.snooze_until; - - return { - accepted: true, - schedule_id: request.schedule_id, - disposition: { - schedule_id: request.schedule_id, - state: 'snoozed', - updated_at: '2026-08-07T01:00:00.000Z', - snoozed_until, - sync_status: 'pending', - }, - }; - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts b/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts deleted file mode 100644 index 5a7ef1c5..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderDispositionSync.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - ReminderConfirmedDisposition, - ReminderDispositionSyncPort, - ReminderDispositionSyncReceipt, -} from '../../application/interfaces'; - -/** 最终确认状态网络同步回调的模拟实现。 */ -export class MockReminderDispositionSync implements ReminderDispositionSyncPort { - async submitConfirmed( - disposition: ReminderConfirmedDisposition, - ): Promise { - return { - schedule_id: disposition.schedule_id, - accepted: true, - }; - } -} diff --git a/frontend/src/features/reminder/data/local/MockReminderStateStore.ts b/frontend/src/features/reminder/data/local/MockReminderStateStore.ts deleted file mode 100644 index 187d26c4..00000000 --- a/frontend/src/features/reminder/data/local/MockReminderStateStore.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ReminderStateStore } from '../../application/interfaces'; -import type { ReminderDisposition, ReminderRuntimeState } from '../../domain'; - -const MOCK_STATE: ReminderRuntimeState = { - reminder_disposition_state: null, - next_trigger_at: '2026-08-07T01:00:00.000Z', - snoozed_until: null, - geofence_armed: false, - disposition_updated_at: null, - sync_status: 'synced', - recorded_location: null, -}; - -/** 读取结果固定的本地状态空操作适配器,不打开本地数据库。 */ -export class MockReminderStateStore implements ReminderStateStore { - async read(_scheduleId: string): Promise { - return { ...MOCK_STATE }; - } - - async write(_scheduleId: string, _state: ReminderRuntimeState): Promise { - return Promise.resolve(); - } - - async setDisposition(_scheduleId: string, _disposition: ReminderDisposition): Promise { - return Promise.resolve(); - } -} diff --git a/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts b/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts new file mode 100644 index 00000000..b66ca3a6 --- /dev/null +++ b/frontend/src/features/reminder/data/local/SqliteLocalScheduleReader.ts @@ -0,0 +1,102 @@ +import type { ScheduleLocalRepository, LocalScheduleRow } from '../../../schedule/data'; +import type { LocalScheduleReader } from '../../application/interfaces'; +import type { LocalReminderSchedule, ReminderStrength } from '../../domain'; + +/** local_schedules 表没有这一列;地点提醒暂时统一按 200 米围栏处理。 */ +const DEFAULT_GEOFENCE_RADIUS_METERS = 200; + +type Target = { repository: ScheduleLocalRepository; accountId: string }; + +/** + * 真实本地日程数据源:读 SQLite `local_schedules`,供 LocalReminderApplication 使用。 + * + * createAppServices() 在认证/数据库就绪之前就要把这个类接进 reminderPorts,所以构造时 + * 不直接拿 repository/accountId——由调用方在数据库打开、账号确定后调用 attach(), + * 账号切换或登出时调用 detach()。整个应用生命周期内只有这一个实例, + * LocalReminderApplication.start() 对它的 subscribe() 只挂一次,重新绑定 target 不会 + * 让那次订阅失效。 + */ +export class SqliteLocalScheduleReader implements LocalScheduleReader { + private readonly listeners = new Set<(schedules: readonly LocalReminderSchedule[]) => void>(); + private target: Target | null = null; + + public attach(repository: ScheduleLocalRepository, accountId: string): void { + this.target = { repository, accountId }; + } + + public detach(): void { + this.target = null; + } + + public async listReminderSchedules(): Promise { + if (this.target === null) return []; + const rows = await this.target.repository.listSchedules(this.target.accountId); + return rows.map(toLocalReminderSchedule); + } + + public async getReminderSchedule(scheduleId: string): Promise { + if (this.target === null) return null; + const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId); + return row === null ? null : toLocalReminderSchedule(row); + } + + public subscribe(listener: (schedules: readonly LocalReminderSchedule[]) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + /** attach() 之后或本地写入提交后调用:重新读取 SQLite 并通知订阅方触发 rebuild。 */ + public async refresh(): Promise { + const schedules = await this.listReminderSchedules(); + for (const listener of this.listeners) { + listener(schedules); + } + } +} + +function toLocalReminderSchedule(row: LocalScheduleRow): LocalReminderSchedule { + return { + id: row.id, + account_id: row.account_id, + title: row.title, + schedule_type: row.schedule_type, + schedule_kind: row.schedule_kind, + is_all_day: row.is_all_day === 1, + start_time: row.start_time, + end_time: row.end_time, + timezone: row.timezone, + recurrence_rule: row.recurrence_rule, + location_name: row.location_name, + latitude: row.latitude, + longitude: row.longitude, + geofence_radius_meters: DEFAULT_GEOFENCE_RADIUS_METERS, + reminder: + row.reminder_type === null + ? null + : { + reminder_type: row.reminder_type, + reminder_trigger_at: row.reminder_trigger_at, + reminder_offset_minutes: row.reminder_offset_minutes, + // reminder_type 非空时 reminder_strength 一定非空,由建表 CHECK 约束保证。 + reminder_strength: row.reminder_strength as ReminderStrength, + }, + runtime: { + reminder_disposition_state: row.reminder_disposition_state, + next_trigger_at: row.next_trigger_at, + snoozed_until: row.snoozed_until, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: row.disposition_updated_at, + sync_status: row.sync_status, + // local_schedules 没有单独记录到达点位的列,围栏到达位置暂不持久化。 + recorded_location: null, + }, + status: row.status, + // 表里没有独立的设备端 revision 列,用 cloud_revision 兼当;这个字段在 + // reminder 领域逻辑里当前也没被读取。 + revision: row.cloud_revision, + cloud_revision: row.cloud_revision, + updated_at: row.updated_at, + }; +} diff --git a/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts new file mode 100644 index 00000000..5f678c9a --- /dev/null +++ b/frontend/src/features/reminder/data/local/SqliteReminderStateStore.ts @@ -0,0 +1,136 @@ +import type { + LocalReminderRuntimeUpdate, + LocalScheduleRow, + ScheduleLocalRepository, +} from '../../../schedule/data'; +import { + floatingDateToLocalParts, + instantToZonedParts, + isValidIanaTimezone, + localPartsToFloatingDate, + zonedPartsToInstant, +} from '../../../schedule/domain/scheduleDateTime'; +import { + normalizeUtcUntilForFloatingRrule, + parseScheduleRrule, +} from '../../../schedule/domain/scheduleRecurrence'; +import type { ReminderStateStore } from '../../application/interfaces'; +import type { ReminderDisposition, ReminderRuntimeState } from '../../domain'; + +type Target = { repository: ScheduleLocalRepository; accountId: string }; + +/** + * 提醒运行时状态(响没响、确认没确认)的真实持久化:写 SQLite `local_schedules` 的 + * 运行时字段。跟 SqliteLocalScheduleReader 一样用 attach()/detach() 延迟绑定—— + * createAppServices() 构造时账号和仓储都还没有,整个 App 生命周期只有这一个实例。 + * + * 没有这个类之前用的是 MemoryReminderStateStore(纯内存),App 进程一死状态就丢, + * 已经响过的到点提醒下次启动会被当成全新的重新弹一遍。 + */ +export class SqliteReminderStateStore implements ReminderStateStore { + private target: Target | null = null; + + public attach(repository: ScheduleLocalRepository, accountId: string): void { + this.target = { repository, accountId }; + } + + public detach(): void { + this.target = null; + } + + public async read(scheduleId: string): Promise { + if (this.target === null) return null; + const row = await this.target.repository.getSchedule(this.target.accountId, scheduleId); + if (row === null) return null; + + // 重复日程的 occurrence 光标为空:要么是云端刚同步下来的新记录(从没落过库), + // 要么是上一次 occurrence 被 confirmInternal 消费后显式清空的(它就是靠置空 + // next_trigger_at 触发"这条该往前挪一格了")。resolveTimeTriggerAt() 对重复 + // 日程光标为空时明确返回 null,不会回退到系列 start_time,所以这里必须补上 + // 下一次发生时间,不然这条提醒永远不会再触发第二次。同时把上一轮的 + // disposition 状态一起重置,否则 canDeliver() 会因为它还是 'confirmed' 继续 + // 拦住这条全新的 occurrence。 + if (row.schedule_kind === 'recurring' && row.next_trigger_at === null) { + const nextOccurrence = nextRecurringOccurrenceAtOrAfter(row, new Date()); + if (nextOccurrence !== null) { + return { + reminder_disposition_state: null, + next_trigger_at: nextOccurrence, + snoozed_until: null, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: null, + sync_status: row.sync_status, + recorded_location: null, + }; + } + // 系列已经结束(RRULE 的 UNTIL/COUNT 用完了)或规则本身有问题:没有下一次 + // 发生时间可算,透传原始(空)状态,让上层照常按"这条排不上"处理。 + } + + return { + reminder_disposition_state: row.reminder_disposition_state, + next_trigger_at: row.next_trigger_at, + snoozed_until: row.snoozed_until, + geofence_armed: row.geofence_armed === 1, + disposition_updated_at: row.disposition_updated_at, + sync_status: row.sync_status, + // local_schedules 没有单独记录到达点位的列,跟 SqliteLocalScheduleReader 一致处理。 + recorded_location: null, + }; + } + + public async write(scheduleId: string, state: ReminderRuntimeState): Promise { + if (this.target === null) return; + await this.target.repository.updateReminderRuntime( + this.target.accountId, + scheduleId, + toRuntimeUpdate(state), + ); + } + + public async setDisposition(scheduleId: string, disposition: ReminderDisposition): Promise { + if (this.target === null) return; + const current = await this.read(scheduleId); + await this.target.repository.updateReminderRuntime(this.target.accountId, scheduleId, { + reminder_disposition_state: disposition.state, + next_trigger_at: current?.next_trigger_at ?? null, + snoozed_until: disposition.snoozed_until, + geofence_armed: (current?.geofence_armed ?? false) ? 1 : 0, + disposition_updated_at: disposition.updated_at, + sync_status: disposition.sync_status, + }); + } +} + +/** 重复日程从 now 起(含 now 本身)的下一次发生时间;规则用完/无效时返回 null。 */ +function nextRecurringOccurrenceAtOrAfter(row: LocalScheduleRow, now: Date): string | null { + if (row.start_time === null || row.recurrence_rule === null) return null; + if (!isValidIanaTimezone(row.timezone)) return null; + + try { + const floatingStart = localPartsToFloatingDate( + instantToZonedParts(new Date(row.start_time), row.timezone), + ); + const rule = parseScheduleRrule( + normalizeUtcUntilForFloatingRrule(row.recurrence_rule, row.timezone), + floatingStart, + ); + const floatingNow = localPartsToFloatingDate(instantToZonedParts(now, row.timezone)); + const nextFloating = rule.after(floatingNow, true); + if (nextFloating === null) return null; + return zonedPartsToInstant(floatingDateToLocalParts(nextFloating), row.timezone).toISOString(); + } catch { + return null; + } +} + +function toRuntimeUpdate(state: ReminderRuntimeState): LocalReminderRuntimeUpdate { + return { + reminder_disposition_state: state.reminder_disposition_state, + next_trigger_at: state.next_trigger_at, + snoozed_until: state.snoozed_until, + geofence_armed: state.geofence_armed ? 1 : 0, + disposition_updated_at: state.disposition_updated_at, + sync_status: state.sync_status, + }; +} diff --git a/frontend/src/features/reminder/data/local/index.ts b/frontend/src/features/reminder/data/local/index.ts index 4f8b255e..9396c1f0 100644 --- a/frontend/src/features/reminder/data/local/index.ts +++ b/frontend/src/features/reminder/data/local/index.ts @@ -1,6 +1,9 @@ -export { MockLocalScheduleReader } from './MockLocalScheduleReader'; -export { MockReminderApplication } from './MockReminderApplication'; -export { MockReminderDispositionSync } from './MockReminderDispositionSync'; -export { MockReminderStateStore } from './MockReminderStateStore'; export { MemoryReminderStateStore } from './MemoryReminderStateStore'; -export { MOCK_REMINDER_SCHEDULES } from './mockReminderSchedules'; +export { SqliteLocalScheduleReader } from './SqliteLocalScheduleReader'; +export { SqliteReminderStateStore } from './SqliteReminderStateStore'; +export { + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, + NoopPopup, +} from './LocalReminderAdapters'; diff --git a/frontend/src/features/reminder/data/local/mockReminderSchedules.ts b/frontend/src/features/reminder/data/local/mockReminderSchedules.ts deleted file mode 100644 index cc5de05e..00000000 --- a/frontend/src/features/reminder/data/local/mockReminderSchedules.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { LocalReminderSchedule } from '../../domain'; - -const MOCK_RUNTIME = { - reminder_disposition_state: null, - next_trigger_at: '2026-08-07T01:00:00.000Z', - snoozed_until: null, - geofence_armed: false, - disposition_updated_at: null, - sync_status: 'synced' as const, - recorded_location: null, -}; - -/** 接入本地数据库读取器前使用的固定快照。 */ -export const MOCK_REMINDER_SCHEDULES: readonly LocalReminderSchedule[] = [ - { - id: 'mock-schedule-time-001', - account_id: 'mock-account-001', - title: '项目例会', - schedule_type: 'time', - schedule_kind: 'once', - is_all_day: false, - start_time: '2026-08-07T01:15:00.000Z', - end_time: null, - timezone: 'Asia/Shanghai', - recurrence_rule: null, - location_name: '203 会议室', - latitude: null, - longitude: null, - geofence_radius_meters: 100, - reminder: { - reminder_type: 'before_start', - reminder_trigger_at: null, - reminder_offset_minutes: 15, - reminder_strength: 'medium', - }, - runtime: { ...MOCK_RUNTIME }, - status: 'active', - revision: 1, - cloud_revision: 1, - updated_at: '2026-08-06T09:00:00.000Z', - }, - { - id: 'mock-schedule-location-001', - account_id: 'mock-account-001', - title: '取车提醒', - schedule_type: 'location', - schedule_kind: 'once', - is_all_day: false, - start_time: null, - end_time: null, - timezone: 'Asia/Shanghai', - recurrence_rule: null, - location_name: '停车场', - latitude: 31.2304, - longitude: 121.4737, - geofence_radius_meters: 100, - reminder: { - reminder_type: 'return_to_recorded_location', - reminder_trigger_at: null, - reminder_offset_minutes: null, - reminder_strength: 'high', - }, - runtime: { - ...MOCK_RUNTIME, - next_trigger_at: null, - recorded_location: { latitude: 31.2304, longitude: 121.4737 }, - }, - status: 'active', - revision: 1, - cloud_revision: 1, - updated_at: '2026-08-06T09:00:00.000Z', - }, -]; diff --git a/frontend/src/features/reminder/index.ts b/frontend/src/features/reminder/index.ts index 5548f3b5..698a7b5c 100644 --- a/frontend/src/features/reminder/index.ts +++ b/frontend/src/features/reminder/index.ts @@ -64,11 +64,12 @@ export type { } from './application'; export { LocalReminderApplication } from './application'; export { - MockLocalScheduleReader, - MockReminderApplication, - MockReminderDispositionSync, - MockReminderStateStore, - MOCK_REMINDER_SCHEDULES, + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, MemoryReminderStateStore, + NoopPopup, + SqliteLocalScheduleReader, + SqliteReminderStateStore, } from './data/local'; export { MockReminderPresenter, useReminderPermissionsOnLaunch } from './presentation'; diff --git a/frontend/tests/integration/localScheduleWriter.test.ts b/frontend/tests/integration/localScheduleWriter.test.ts index 4e07dd58..71f0b085 100644 --- a/frontend/tests/integration/localScheduleWriter.test.ts +++ b/frontend/tests/integration/localScheduleWriter.test.ts @@ -1,8 +1,9 @@ import initSqlJs, { type SqlJsStatic } from 'sql.js'; -import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { LocalScheduleWriter } from '../../src/features/assistant/data/local/LocalScheduleWriter'; import type { AppliedCommand } from '../../src/features/assistant/domain/ConversationTurn'; +import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader'; import { ScheduleLocalRepository } from '../../src/features/schedule/data'; import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; @@ -167,3 +168,57 @@ describe('LocalScheduleWriter', () => { expect(await repository.getSchedule('account-a', 'schedule-a')).toBeNull(); }); }); + +describe('LocalScheduleWriter refresh wiring', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + }); + + afterEach(() => { + database.close(); + }); + + it('refreshes the attached schedule reader after a successful write', async () => { + const reader = new SqliteLocalScheduleReader(); + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + const writer = new LocalScheduleWriter(repository, reader); + await writer.applyCommandResult('account-a', appliedCommand()); + + expect(listener).toHaveBeenCalledTimes(1); + expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ title: 'Team sync' }); + }); + + it('does not refresh when the command was not applied', async () => { + const reader = new SqliteLocalScheduleReader(); + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + const writer = new LocalScheduleWriter(repository, reader); + await writer.applyCommandResult('account-a', appliedCommand({ status: 'rejected' })); + + expect(listener).not.toHaveBeenCalled(); + expect(await reader.getReminderSchedule('schedule-a')).toBeNull(); + }); + + it('works without a schedule reader wired in', async () => { + const writer = new LocalScheduleWriter(repository); + await expect(writer.applyCommandResult('account-a', appliedCommand())).resolves.toBeUndefined(); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored?.title).toBe('Team sync'); + }); +}); diff --git a/frontend/tests/integration/sqliteLocalScheduleReader.test.ts b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts new file mode 100644 index 00000000..ec0933bb --- /dev/null +++ b/frontend/tests/integration/sqliteLocalScheduleReader.test.ts @@ -0,0 +1,157 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data'; +import { SqliteLocalScheduleReader } from '../../src/features/reminder/data/local/SqliteLocalScheduleReader'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +function cloudSchedule(overrides: Partial = {}): CloudScheduleRow { + return { + id: 'schedule-a', + account_id: 'account-a', + schedule_type: 'time', + schedule_kind: 'once', + category: null, + title: 'Original title', + is_all_day: 0, + start_time: '2026-08-12T07:00:00Z', + end_time: null, + timezone: 'Asia/Shanghai', + recurrence_rule: null, + location_name: null, + latitude: null, + longitude: null, + reminder_type: 'before_start', + reminder_trigger_at: null, + reminder_offset_minutes: 15, + reminder_strength: 'medium', + reminder_disposition_state: null, + status: 'active', + cloud_revision: 1, + updated_at: '2026-08-11T07:00:00Z', + ...overrides, + }; +} + +describe('SqliteLocalScheduleReader', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + let reader: SqliteLocalScheduleReader; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + reader = new SqliteLocalScheduleReader(); + }); + + afterEach(() => { + database.close(); + }); + + it('returns nothing before attach()', async () => { + expect(await reader.listReminderSchedules()).toEqual([]); + expect(await reader.getReminderSchedule('schedule-a')).toBeNull(); + }); + + it('maps a stored row onto the reminder domain shape once attached', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-a'); + + const schedules = await reader.listReminderSchedules(); + expect(schedules).toHaveLength(1); + expect(schedules[0]).toMatchObject({ + id: 'schedule-a', + account_id: 'account-a', + title: 'Original title', + geofence_radius_meters: 200, + reminder: { + reminder_type: 'before_start', + reminder_offset_minutes: 15, + reminder_strength: 'medium', + }, + runtime: { + geofence_armed: false, + recorded_location: null, + sync_status: 'synced', + }, + status: 'active', + }); + + expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ id: 'schedule-a' }); + expect(await reader.getReminderSchedule('missing')).toBeNull(); + }); + + it('maps a schedule without reminder configuration and preserves armed geofence state', async () => { + await repository.applyCloudSchedule( + cloudSchedule({ + reminder_type: null, + reminder_offset_minutes: null, + reminder_strength: null, + }), + ); + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 1, + disposition_updated_at: '2026-08-12T07:00:00Z', + sync_status: 'pending', + }); + reader.attach(repository, 'account-a'); + + expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ + reminder: null, + runtime: { geofence_armed: true }, + }); + }); + + it('scopes reads to the attached account', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-b'); + + expect(await reader.listReminderSchedules()).toEqual([]); + }); + + it('stops returning data after detach()', async () => { + await repository.applyCloudSchedule(cloudSchedule()); + reader.attach(repository, 'account-a'); + expect(await reader.listReminderSchedules()).toHaveLength(1); + + reader.detach(); + + expect(await reader.listReminderSchedules()).toEqual([]); + }); + + it('notifies subscribers with a fresh read on refresh()', async () => { + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + reader.subscribe(listener); + + await repository.applyCloudSchedule(cloudSchedule()); + await reader.refresh(); + + expect(listener).toHaveBeenCalledTimes(1); + const [notified] = listener.mock.calls[0] as [readonly { id: string }[]]; + expect(notified).toHaveLength(1); + expect(notified[0].id).toBe('schedule-a'); + }); + + it('stops notifying an unsubscribed listener', async () => { + reader.attach(repository, 'account-a'); + const listener = vi.fn(); + const unsubscribe = reader.subscribe(listener); + unsubscribe(); + + await repository.applyCloudSchedule(cloudSchedule()); + await reader.refresh(); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/integration/sqliteReminderStateStore.test.ts b/frontend/tests/integration/sqliteReminderStateStore.test.ts new file mode 100644 index 00000000..eee5f74a --- /dev/null +++ b/frontend/tests/integration/sqliteReminderStateStore.test.ts @@ -0,0 +1,225 @@ +import initSqlJs, { type SqlJsStatic } from 'sql.js'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ScheduleLocalRepository, type CloudScheduleRow } from '../../src/features/schedule/data'; +import { SqliteReminderStateStore } from '../../src/features/reminder/data/local/SqliteReminderStateStore'; +import { migrateScheduleDatabase } from '../../src/infrastructure/database/migrations'; +import { SqlJsExpoDatabase } from '../helpers/sqliteTestDatabase'; + +const START_TIME = '2026-08-10T07:00:00Z'; // 2026-08-10 15:00 Asia/Shanghai (Monday) +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1_000; + +function recurringSchedule(overrides: Partial = {}): CloudScheduleRow { + return { + id: 'schedule-a', + account_id: 'account-a', + schedule_type: 'time', + schedule_kind: 'recurring', + category: null, + title: '周会', + is_all_day: 0, + start_time: START_TIME, + end_time: null, + timezone: 'Asia/Shanghai', + // Asia/Shanghai 没有 DST,整周推进不用担心跨夏令时的偏差。 + recurrence_rule: 'FREQ=WEEKLY;COUNT=4', + location_name: null, + latitude: null, + longitude: null, + reminder_type: null, + reminder_trigger_at: null, + reminder_offset_minutes: null, + reminder_strength: null, + reminder_disposition_state: null, + status: 'active', + cloud_revision: 1, + updated_at: '2026-08-09T00:00:00Z', + ...overrides, + }; +} + +describe('SqliteReminderStateStore', () => { + let sql: SqlJsStatic; + let database: SqlJsExpoDatabase; + let repository: ScheduleLocalRepository; + let store: SqliteReminderStateStore; + + beforeAll(async () => { + sql = await initSqlJs(); + }); + + beforeEach(async () => { + database = new SqlJsExpoDatabase(new sql.Database()); + await migrateScheduleDatabase(database.asSQLiteDatabase()); + repository = new ScheduleLocalRepository(database.asSQLiteDatabase()); + store = new SqliteReminderStateStore(); + store.attach(repository, 'account-a'); + }); + + afterEach(() => { + database.close(); + vi.useRealTimers(); + }); + + it('returns null before attach()', async () => { + const detached = new SqliteReminderStateStore(); + expect(await detached.read('schedule-a')).toBeNull(); + }); + + it('ignores reads and writes after detach()', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + store.detach(); + + expect(await store.read('schedule-a')).toBeNull(); + await expect( + store.write('schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: START_TIME, + snoozed_until: null, + geofence_armed: true, + disposition_updated_at: START_TIME, + sync_status: 'pending', + recorded_location: null, + }), + ).resolves.toBeUndefined(); + await expect( + store.setDisposition('schedule-a', { + schedule_id: 'schedule-a', + state: 'confirmed', + updated_at: START_TIME, + snoozed_until: null, + sync_status: 'pending', + }), + ).resolves.toBeUndefined(); + + expect((await repository.getSchedule('account-a', 'schedule-a'))?.next_trigger_at).toBeNull(); + }); + + it('returns null for a missing schedule in the attached account', async () => { + expect(await store.read('missing')).toBeNull(); + }); + + it('passes through a once schedule unchanged, including a null next_trigger_at', async () => { + await repository.applyCloudSchedule( + recurringSchedule({ schedule_kind: 'once', recurrence_rule: null }), + ); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBeNull(); + expect(runtime?.reminder_disposition_state).toBeNull(); + }); + + it('leaves a recurring schedule alone once it already has an occurrence cursor', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: '2026-08-17T07:00:00Z', + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-17T07:00:00Z', + sync_status: 'synced', + }); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBe('2026-08-17T07:00:00Z'); + expect(runtime?.reminder_disposition_state).toBe('pending'); + }); + + it('computes the next occurrence and resets disposition when the cursor is null', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + // confirmInternal 的实际写法:确认之后把 disposition 设成 confirmed,同时把 + // occurrence 光标清空,这就是它触发"该往前挪一格了"的方式。 + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'confirmed', + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-10T07:00:00Z', + sync_status: 'synced', + }); + // "现在" 落在第 2 次和第 3 次发生之间:应该拿到第 3 次,不是回退到 start_time + // 或者停在第 2 次。 + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(START_TIME) + ONE_WEEK_MS * 1.5)); + + const runtime = await store.read('schedule-a'); + + const expectedThirdOccurrence = new Date( + Date.parse(START_TIME) + ONE_WEEK_MS * 2, + ).toISOString(); + expect(runtime?.next_trigger_at).toBe(expectedThirdOccurrence); + // 新 occurrence 的 disposition 必须重置,否则 canDeliver() 会因为上一次是 + // confirmed 就永远拦住这条日程,重复提醒响一次之后就再也不会响了。 + expect(runtime?.reminder_disposition_state).toBeNull(); + expect(runtime?.disposition_updated_at).toBeNull(); + }); + + it('leaves the cursor null once the recurring series has run out of occurrences', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await repository.updateReminderRuntime('account-a', 'schedule-a', { + reminder_disposition_state: 'confirmed', + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: '2026-08-31T07:00:00Z', + sync_status: 'synced', + }); + // COUNT=4:第 4 次之后系列结束,"现在"设在最后一次之后。 + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.parse(START_TIME) + ONE_WEEK_MS * 10)); + + const runtime = await store.read('schedule-a'); + + expect(runtime?.next_trigger_at).toBeNull(); + }); + + it('leaves the cursor null when the recurrence rule cannot be parsed', async () => { + await repository.applyCloudSchedule(recurringSchedule({ recurrence_rule: 'INVALID' })); + + expect((await store.read('schedule-a'))?.next_trigger_at).toBeNull(); + }); + + it('write() persists a runtime patch and setDisposition() preserves the current cursor', async () => { + await repository.applyCloudSchedule(recurringSchedule()); + await store.write('schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: '2026-08-17T07:00:00Z', + snoozed_until: null, + geofence_armed: true, + disposition_updated_at: '2026-08-17T07:00:00Z', + sync_status: 'pending', + recorded_location: null, + }); + + await store.setDisposition('schedule-a', { + schedule_id: 'schedule-a', + state: 'confirmed', + updated_at: '2026-08-17T07:05:00Z', + snoozed_until: null, + sync_status: 'pending', + }); + + const stored = await repository.getSchedule('account-a', 'schedule-a'); + expect(stored?.reminder_disposition_state).toBe('confirmed'); + // setDisposition 本身不该动 occurrence 光标,只有 confirmInternal 自己那次 + // 显式的 write() 才会把它清空。 + expect(stored?.next_trigger_at).toBe('2026-08-17T07:00:00Z'); + expect(stored?.geofence_armed).toBe(1); + }); + + it('sets a disposition for a missing schedule without fabricating runtime state', async () => { + await expect( + store.setDisposition('missing', { + schedule_id: 'missing', + state: 'confirmed', + updated_at: '2026-08-17T07:05:00Z', + snoozed_until: null, + sync_status: 'pending', + }), + ).resolves.toBeUndefined(); + + expect(await repository.getSchedule('account-a', 'missing')).toBeNull(); + }); +}); diff --git a/frontend/tests/unit/app/AppRoot.test.tsx b/frontend/tests/unit/app/AppRoot.test.tsx index ccc69c1f..11d1cf18 100644 --- a/frontend/tests/unit/app/AppRoot.test.tsx +++ b/frontend/tests/unit/app/AppRoot.test.tsx @@ -2,11 +2,12 @@ import { beforeEach, describe, expect, it, jest } from '@jest/globals'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { AppRoot } from '../../../src/app/AppRoot'; -import { createScheduleSnapshotPreparation } from '../../../src/app/composition/createScheduleSnapshotPreparation'; import { createAppServices, type AppServices, } from '../../../src/app/composition/createAppServices'; +import { createScheduleSnapshotPreparation } from '../../../src/app/composition/createScheduleSnapshotPreparation'; +import { LocalScheduleWriter } from '../../../src/features/assistant/data/local/LocalScheduleWriter'; import type { ScheduleSnapshotBootstrapResult, ScheduleSnapshotBootstrapService, @@ -20,10 +21,20 @@ jest.mock('../../../src/infrastructure/database', () => ({ jest.mock('../../../src/app/composition/createScheduleSnapshotPreparation', () => ({ createScheduleSnapshotPreparation: jest.fn(), })); -jest.mock('../../../src/features/schedule/data', () => ({ ScheduleLocalRepository: jest.fn() })); +jest.mock('../../../src/features/schedule/data', () => ({ + ScheduleLocalRepository: jest.fn().mockImplementation(() => ({ + getSchedule: jest.fn<() => Promise>().mockResolvedValue(null), + listSchedules: jest.fn<() => Promise>().mockResolvedValue([]), + })), +})); jest.mock('../../../src/features/schedule/application', () => ({ SqliteScheduleClientService: jest.fn(), })); +jest.mock('../../../src/features/assistant/data/local/LocalScheduleWriter', () => ({ + LocalScheduleWriter: jest.fn().mockImplementation(() => ({ + applyCommandResult: jest.fn<() => Promise>().mockResolvedValue(undefined), + })), +})); jest.mock('../../../src/features/schedule/presentation/ScheduleCalendarScreen', () => ({ ScheduleCalendarScreen: ({ isSigningOut, @@ -90,9 +101,16 @@ beforeEach(() => { ); mockedCreateScheduleSnapshotPreparation.mockReset(); mockedCreateScheduleSnapshotPreparation.mockReturnValue({ - repository: {}, + // scheduleReader.refresh() (wired in AppRoot's ready-state effect) calls through to + // these two on whatever repository the snapshot preparation hands back, so the stub + // needs real methods now, not just an opaque {} -- see SqliteLocalScheduleReader. + repository: { + getSchedule: jest.fn<() => Promise>().mockResolvedValue(null), + listSchedules: jest.fn<() => Promise>().mockResolvedValue([]), + }, bootstrap: { ensureLocalSnapshot: mockedEnsureLocalSnapshot }, } as never); + jest.mocked(LocalScheduleWriter).mockClear(); }); describe('AppRoot', () => { @@ -325,6 +343,34 @@ describe('AppRoot', () => { await waitFor(() => expect(screen.getByText('登录或注册')).toBeTruthy()); expect(controller.getViewState()).toEqual({ status: 'unauthenticated' }); }); + + it('binds the reminder SQLite adapters and detaches them on sign out', async () => { + const services = createController({ + accountId: 'acc_001', + accessToken: 'opaque-token', + expiresAt: 200_000, + username: 'timeflow_user', + }); + const attachSchedules = jest.spyOn(services.schedules, 'attach'); + const detachSchedules = jest.spyOn(services.schedules, 'detach'); + const refreshSchedules = jest.spyOn(services.schedules, 'refresh').mockResolvedValue(undefined); + const attachState = jest.spyOn(services.reminderState, 'attach'); + const detachState = jest.spyOn(services.reminderState, 'detach'); + + render(); + + await screen.findByText('日程日历'); + expect(attachSchedules).toHaveBeenCalledWith(expect.anything(), 'acc_001'); + expect(attachState).toHaveBeenCalledWith(expect.anything(), 'acc_001'); + expect(refreshSchedules).toHaveBeenCalledTimes(1); + expect(LocalScheduleWriter).toHaveBeenCalledWith(expect.anything(), services.schedules); + + fireEvent.press(screen.getByRole('button', { name: '退出登录' })); + + await waitFor(() => expect(screen.getByText('登录或注册')).toBeTruthy()); + expect(detachSchedules).toHaveBeenCalledTimes(1); + expect(detachState).toHaveBeenCalledTimes(1); + }); }); function createController( diff --git a/frontend/tests/unit/app/createAppServices.test.ts b/frontend/tests/unit/app/createAppServices.test.ts index 6608c367..171708c0 100644 --- a/frontend/tests/unit/app/createAppServices.test.ts +++ b/frontend/tests/unit/app/createAppServices.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, jest } from '@jest/globals'; import { createAppServices } from '../../../src/app/composition/createAppServices'; +import { + SqliteLocalScheduleReader, + SqliteReminderStateStore, +} from '../../../src/features/reminder'; import { FakeAuthSessionStore } from '../../fakes/FakeAuthSessionStore'; describe('createAppServices', () => { @@ -23,6 +27,10 @@ describe('createAppServices', () => { expect(services.protectedClient).toBe(services.auth.protectedClient); expect(services.webSocketClient).toBe(services.auth.webSocketClient); + expect(services.reminderPorts.schedules).toBe(services.schedules); + expect(services.reminderPorts.state).toBe(services.reminderState); + expect(services.schedules).toBeInstanceOf(SqliteLocalScheduleReader); + expect(services.reminderState).toBeInstanceOf(SqliteReminderStateStore); expect(services.scheduleView.getSnapshot()).toEqual({ accountId: null, occurrences: [], diff --git a/frontend/tests/unit/features/reminder/data/LocalReminderDataAdapters.test.ts b/frontend/tests/unit/features/reminder/data/LocalReminderDataAdapters.test.ts new file mode 100644 index 00000000..752125ce --- /dev/null +++ b/frontend/tests/unit/features/reminder/data/LocalReminderDataAdapters.test.ts @@ -0,0 +1,327 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; + +import { + LocalReminderDelivery, + LocalReminderDispositionSync, + LocalReminderRecovery, + NoopPopup, +} from '../../../../../src/features/reminder/data/local/LocalReminderAdapters'; +import { SqliteLocalScheduleReader } from '../../../../../src/features/reminder/data/local/SqliteLocalScheduleReader'; +import { SqliteReminderStateStore } from '../../../../../src/features/reminder/data/local/SqliteReminderStateStore'; +import type { + LocalReminderRuntimeUpdate, + LocalScheduleRow, + ScheduleLocalRepository, +} from '../../../../../src/features/schedule/data'; + +const START_TIME = '2026-08-10T07:00:00.000Z'; + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('local reminder adapters', () => { + it('returns local delivery, popup, recovery, and disposition receipts', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-19T08:00:00Z')); + const delivery = new LocalReminderDelivery(); + const popup = new NoopPopup(); + const recovery = new LocalReminderRecovery(); + const sync = new LocalReminderDispositionSync(); + + await expect( + delivery.deliver({ + reminder_id: 'reminder-a', + schedule_id: 'schedule-a', + title: 'Team sync', + strength: 'medium', + trigger: { + reminder_id: 'reminder-a', + schedule_id: 'schedule-a', + reason: 'at_time', + triggered_at: '2026-08-19T08:00:00Z', + }, + }), + ).resolves.toEqual({ + delivery_id: `delivery-schedule-a-${Date.now()}`, + schedule_id: 'schedule-a', + delivered_at: '2026-08-19T08:00:00.000Z', + channels: [], + used_fallback_audio: false, + }); + await expect(delivery.dismiss('schedule-a')).resolves.toBeUndefined(); + await expect( + popup.show({ popup_id: 'popup-a', title: 'Team sync', body: 'Starts now' }), + ).resolves.toEqual({ popup_id: 'popup-a', visible: false }); + await expect(popup.dismiss('popup-a')).resolves.toBeUndefined(); + + const recoveryReceipt = { registered: true, recovery_id: `recovery-${Date.now()}` }; + await expect(recovery.registerForRestart()).resolves.toEqual(recoveryReceipt); + await expect(recovery.restoreAfterRestart()).resolves.toEqual(recoveryReceipt); + await expect( + sync.submitConfirmed({ + schedule_id: 'schedule-a', + state: 'confirmed', + updated_at: '2026-08-19T08:00:00Z', + snoozed_until: null, + sync_status: 'pending', + }), + ).resolves.toEqual({ schedule_id: 'schedule-a', accepted: true }); + }); +}); + +describe('SqliteLocalScheduleReader', () => { + it('reads, maps, refreshes, unsubscribes, and detaches its repository target', async () => { + const reminderRow = scheduleRow(); + const noReminderRow = scheduleRow({ + id: 'schedule-b', + reminder_type: null, + reminder_offset_minutes: null, + reminder_strength: null, + geofence_armed: 1, + }); + const repository = repositoryStub({ rows: [reminderRow, noReminderRow] }); + const reader = new SqliteLocalScheduleReader(); + + expect(await reader.listReminderSchedules()).toEqual([]); + expect(await reader.getReminderSchedule('schedule-a')).toBeNull(); + + reader.attach(repository.value, 'account-a'); + const schedules = await reader.listReminderSchedules(); + expect(repository.listSchedules).toHaveBeenCalledWith('account-a'); + expect(schedules).toEqual([ + expect.objectContaining({ + id: 'schedule-a', + reminder: expect.objectContaining({ reminder_strength: 'medium' }), + runtime: expect.objectContaining({ geofence_armed: false }), + }), + expect.objectContaining({ + id: 'schedule-b', + reminder: null, + runtime: expect.objectContaining({ geofence_armed: true }), + }), + ]); + + repository.getSchedule.mockResolvedValueOnce(reminderRow).mockResolvedValueOnce(null); + expect(await reader.getReminderSchedule('schedule-a')).toMatchObject({ id: 'schedule-a' }); + expect(await reader.getReminderSchedule('missing')).toBeNull(); + + const listener = jest.fn(); + const unsubscribe = reader.subscribe(listener); + await reader.refresh(); + expect(listener).toHaveBeenCalledWith(schedules); + unsubscribe(); + await reader.refresh(); + expect(listener).toHaveBeenCalledTimes(1); + + reader.detach(); + expect(await reader.listReminderSchedules()).toEqual([]); + }); +}); + +describe('SqliteReminderStateStore', () => { + it('persists runtime and disposition state while attached', async () => { + const row = scheduleRow({ geofence_armed: 1, next_trigger_at: START_TIME }); + const repository = repositoryStub({ row }); + const store = new SqliteReminderStateStore(); + store.attach(repository.value, 'account-a'); + + expect(await store.read('schedule-a')).toEqual({ + reminder_disposition_state: null, + next_trigger_at: START_TIME, + snoozed_until: null, + geofence_armed: true, + disposition_updated_at: null, + sync_status: 'synced', + recorded_location: null, + }); + + await store.write('schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: START_TIME, + snoozed_until: null, + geofence_armed: false, + disposition_updated_at: START_TIME, + sync_status: 'pending', + recorded_location: null, + }); + expect(repository.updateReminderRuntime).toHaveBeenCalledWith('account-a', 'schedule-a', { + reminder_disposition_state: 'pending', + next_trigger_at: START_TIME, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: START_TIME, + sync_status: 'pending', + }); + + await store.setDisposition('schedule-a', { + schedule_id: 'schedule-a', + state: 'confirmed', + updated_at: '2026-08-10T07:05:00.000Z', + snoozed_until: null, + sync_status: 'pending', + }); + expect(repository.updateReminderRuntime).toHaveBeenLastCalledWith( + 'account-a', + 'schedule-a', + expect.objectContaining({ + reminder_disposition_state: 'confirmed', + next_trigger_at: START_TIME, + geofence_armed: 1, + }), + ); + }); + + it('handles detached and missing schedules without writing', async () => { + const repository = repositoryStub(); + const store = new SqliteReminderStateStore(); + const runtime = runtimeState(); + const disposition = { + schedule_id: 'missing', + state: 'confirmed' as const, + updated_at: START_TIME, + snoozed_until: null, + sync_status: 'pending' as const, + }; + + expect(await store.read('missing')).toBeNull(); + await store.write('missing', runtime); + await store.setDisposition('missing', disposition); + + store.attach(repository.value, 'account-a'); + expect(await store.read('missing')).toBeNull(); + await store.setDisposition('missing', disposition); + store.detach(); + + expect(repository.updateReminderRuntime).toHaveBeenCalledTimes(1); + expect(repository.updateReminderRuntime).toHaveBeenCalledWith( + 'account-a', + 'missing', + expect.objectContaining({ next_trigger_at: null, geofence_armed: 0 }), + ); + }); + + it('advances recurring schedules and tolerates exhausted or invalid rules', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-08-20T07:00:00.000Z')); + const repository = repositoryStub(); + const store = new SqliteReminderStateStore(); + store.attach(repository.value, 'account-a'); + + repository.getSchedule.mockResolvedValueOnce( + scheduleRow({ + schedule_kind: 'recurring', + recurrence_rule: 'FREQ=WEEKLY;COUNT=4', + start_time: START_TIME, + next_trigger_at: null, + reminder_disposition_state: 'confirmed', + }), + ); + expect(await store.read('schedule-a')).toMatchObject({ + reminder_disposition_state: null, + next_trigger_at: '2026-08-24T07:00:00.000Z', + }); + + for (const row of [ + scheduleRow({ schedule_kind: 'recurring', start_time: null, next_trigger_at: null }), + scheduleRow({ + schedule_kind: 'recurring', + recurrence_rule: null, + next_trigger_at: null, + }), + scheduleRow({ + schedule_kind: 'recurring', + timezone: 'Invalid/Timezone', + next_trigger_at: null, + }), + scheduleRow({ + schedule_kind: 'recurring', + recurrence_rule: 'FREQ=WEEKLY;COUNT=1', + next_trigger_at: null, + }), + scheduleRow({ + schedule_kind: 'recurring', + recurrence_rule: 'INVALID', + next_trigger_at: null, + }), + ]) { + repository.getSchedule.mockResolvedValueOnce(row); + expect((await store.read(row.id))?.next_trigger_at).toBeNull(); + } + }); +}); + +function repositoryStub({ + row = null, + rows = [], +}: { row?: LocalScheduleRow | null; rows?: LocalScheduleRow[] } = {}) { + const getSchedule = jest + .fn<(accountId: string, scheduleId: string) => Promise>() + .mockResolvedValue(row); + const listSchedules = jest + .fn<(accountId: string) => Promise>() + .mockResolvedValue(rows); + const updateReminderRuntime = jest + .fn< + ( + accountId: string, + scheduleId: string, + runtime: LocalReminderRuntimeUpdate, + ) => Promise + >() + .mockResolvedValue(true); + + return { + getSchedule, + listSchedules, + updateReminderRuntime, + value: { + getSchedule, + listSchedules, + updateReminderRuntime, + } as unknown as ScheduleLocalRepository, + }; +} + +function runtimeState() { + return { + reminder_disposition_state: null, + next_trigger_at: null, + snoozed_until: null, + geofence_armed: false, + disposition_updated_at: null, + sync_status: 'pending' as const, + recorded_location: null, + }; +} + +function scheduleRow(overrides: Partial = {}): LocalScheduleRow { + return { + id: 'schedule-a', + account_id: 'account-a', + schedule_type: 'time', + schedule_kind: 'once', + category: null, + title: 'Team sync', + is_all_day: 0, + start_time: START_TIME, + end_time: null, + timezone: 'UTC', + recurrence_rule: 'FREQ=WEEKLY;COUNT=4', + location_name: null, + latitude: null, + longitude: null, + reminder_type: 'before_start', + reminder_trigger_at: null, + reminder_offset_minutes: 15, + reminder_strength: 'medium', + reminder_disposition_state: null, + next_trigger_at: null, + snoozed_until: null, + geofence_armed: 0, + disposition_updated_at: null, + sync_status: 'synced', + status: 'active', + cloud_revision: 1, + updated_at: '2026-08-09T00:00:00.000Z', + ...overrides, + }; +}