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,
+ };
+}