From e1e18282015692ab73d32e068c7f41310ced9461 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 24 Aug 2026 23:12:54 +0800 Subject: [PATCH 1/2] fix(reminder): refresh calendar location list after a reminder is confirmed confirm() only wrote the local SQLite disposition state; the calendar screen reads location schedules through a separate service that never learned about the change, so a confirmed location reminder stayed visible until the app restarted. LocalReminderApplication now emits an onScheduleConfirmed event that HomeScreen folds into its existing calendar refreshSignal. --- .../application/LocalReminderApplication.ts | 11 ++++ .../interfaces/ReminderApplicationPort.ts | 5 ++ frontend/src/screens/HomeScreen.tsx | 12 ++++- .../LocalReminderApplication.test.ts | 28 ++++++++++ .../tests/unit/screens/HomeScreen.test.tsx | 52 ++++++++++++++++++- 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/frontend/src/features/reminder/application/LocalReminderApplication.ts b/frontend/src/features/reminder/application/LocalReminderApplication.ts index b16039c5..294a3cba 100644 --- a/frontend/src/features/reminder/application/LocalReminderApplication.ts +++ b/frontend/src/features/reminder/application/LocalReminderApplication.ts @@ -68,6 +68,7 @@ export class LocalReminderApplication implements ReminderApplicationPort { private readonly permissionBlockedListeners = new Set< (event: ReminderPermissionBlockedEvent) => void >(); + private readonly scheduleConfirmedListeners = new Set<() => void>(); private opChain: Promise = Promise.resolve(); /** 每次 stop / 失败回滚自增;停机前开始的工作持有旧世代,重启后仍视为已取消。 */ private generation = 0; @@ -101,6 +102,13 @@ export class LocalReminderApplication implements ReminderApplicationPort { }; } + onScheduleConfirmed(listener: () => void): () => void { + this.scheduleConfirmedListeners.add(listener); + return () => { + this.scheduleConfirmedListeners.delete(listener); + }; + } + async handleTime(tick: { observed_at: string }): Promise { const generation = this.generation; return this.track(this.runHandleTime(tick, generation)); @@ -450,6 +458,9 @@ export class LocalReminderApplication implements ReminderApplicationPort { }); await this.dropRegistration(scheduleId); + for (const listener of this.scheduleConfirmedListeners) { + listener(); + } if (!this.isLive(generation)) { return { accepted: false, schedule_id: scheduleId, disposition: null }; diff --git a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts index a61dc45d..0f86f8a7 100644 --- a/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts +++ b/frontend/src/features/reminder/application/interfaces/ReminderApplicationPort.ts @@ -59,4 +59,9 @@ export interface ReminderApplicationPort { * 重弹一遍,见调用方 registerInternal/rebuildInternal 的路径区分)。 */ onPermissionBlocked(listener: (event: ReminderPermissionBlockedEvent) => void): () => void; + /** + * 订阅"某条日程被确认"事件——确认只写本地 SQLite,不经过日历页读取的 + * ScheduleClientService,日历页不知道要重取,地点提醒会一直挂着直到重启。 + */ + onScheduleConfirmed(listener: () => void): () => void; } diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index c04474b4..984531ce 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { StyleSheet, View } from 'react-native'; import type { AssistantApplicationPort } from '../features/assistant/application/AssistantApplication'; @@ -57,9 +57,17 @@ export function HomeScreen({ const [trackedPttCommand, setTrackedPttCommand] = useState(pttCommand); const [trackedCallCommand, setTrackedCallCommand] = useState(callCommand); const [focusTarget, setFocusTarget] = useState(null); + const [confirmRevision, setConfirmRevision] = useState(0); useReminderPermissionNudge(reminder, alertDialog, onRequestPermission); + // 提醒确认(闹钟响铃/App 内弹窗)只写本地库,不经过语音写日程那条 + // scheduleDataRevision 路径——地点提醒确认后要从下方地点列表里消失, + // 靠这里单独订阅触发重取,否则要等重启才会刷新。 + useEffect(() => reminder.onScheduleConfirmed(() => setConfirmRevision((value) => value + 1)), [ + reminder, + ]); + // command.result 写完本地库之后 lastAppliedCommand 才会更新(见 // AssistantConversationService.applyCommandResultLocally),所以这里发现它 // 变化时数据已经落地了,可以安全更新聚焦目标。日历重取由下方 revision 驱动, @@ -84,7 +92,7 @@ export function HomeScreen({ isSigningOut={isSigningOut} onOpenPermissions={() => onRequestPermission()} onSignOut={onSignOut} - refreshSignal={pttScheduleRevision + callScheduleRevision} + refreshSignal={pttScheduleRevision + callScheduleRevision + confirmRevision} focusTarget={focusTarget} service={scheduleService} timezone={timezone} diff --git a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts index 28b9e67c..149e467d 100644 --- a/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts +++ b/frontend/tests/unit/features/reminder/application/LocalReminderApplication.test.ts @@ -1079,6 +1079,34 @@ describe('LocalReminderApplication', () => { accepted: true, }); }); + + it('notifies onScheduleConfirmed after a confirm commits, but not on register()', async () => { + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + const listener = jest.fn(); + app.onScheduleConfirmed(listener); + + await app.register(schedule); + expect(listener).not.toHaveBeenCalled(); + + await app.confirm('s1', '2026-08-18T10:00:00.000Z'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('stops notifying onScheduleConfirmed after unsubscribing', async () => { + const schedule = fixtureLocationSchedule({ id: 's1' }); + const deps = createDeps({ schedules: new FakeScheduleReader([schedule]) }); + const app = new LocalReminderApplication(deps); + await app.start(); + const listener = jest.fn(); + const unsubscribe = app.onScheduleConfirmed(listener); + unsubscribe(); + + await app.confirm('s1', '2026-08-18T10:00:00.000Z'); + expect(listener).not.toHaveBeenCalled(); + }); }); describe('runDeliver edge receipts', () => { diff --git a/frontend/tests/unit/screens/HomeScreen.test.tsx b/frontend/tests/unit/screens/HomeScreen.test.tsx index 8eac1a3b..efdf83ec 100644 --- a/frontend/tests/unit/screens/HomeScreen.test.tsx +++ b/frontend/tests/unit/screens/HomeScreen.test.tsx @@ -73,7 +73,9 @@ describe('HomeScreen calendar refresh', () => { onRequestPermission={() => {}} onSignOut={async () => {}} pushToTalkApplication={pushToTalkApplication} - reminder={{ onPermissionBlocked: () => () => {} } as never} + reminder={ + { onPermissionBlocked: () => () => {}, onScheduleConfirmed: () => () => {} } as never + } scheduleService={scheduleService} timezone="Asia/Shanghai" username="Sarah" @@ -110,4 +112,52 @@ describe('HomeScreen calendar refresh', () => { accountId: 'account-a', }); }); + + it('reloads the calendar when a reminder is confirmed outside the voice-command path', async () => { + const pushToTalkApplication = new FakeAssistantApplication(); + const continuousApplication = new FakeAssistantApplication(); + const scheduleService: ScheduleCalendarReadService = { + getLocationSchedules: jest + .fn() + .mockResolvedValue([]), + getSchedulesByDay: jest + .fn() + .mockResolvedValue([]), + getSchedulesByRange: jest + .fn() + .mockResolvedValue([]), + }; + const confirmedListeners = new Set<() => void>(); + render( + {} }} + isSigningOut={false} + onRequestPermission={() => {}} + onSignOut={async () => {}} + pushToTalkApplication={pushToTalkApplication} + reminder={ + { + onPermissionBlocked: () => () => {}, + onScheduleConfirmed: (listener: () => void) => { + confirmedListeners.add(listener); + return () => confirmedListeners.delete(listener); + }, + } as never + } + scheduleService={scheduleService} + timezone="Asia/Shanghai" + username="Sarah" + />, + ); + + await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(1)); + + act(() => { + for (const listener of confirmedListeners) listener(); + }); + + await waitFor(() => expect(scheduleService.getLocationSchedules).toHaveBeenCalledTimes(2)); + }); }); From 7dc9e8777b7b92d4e6c8b779c532b25562482905 Mon Sep 17 00:00:00 2001 From: LUPENGHAN Date: Mon, 24 Aug 2026 23:18:55 +0800 Subject: [PATCH 2/2] style(reminder): run prettier on HomeScreen.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm run check caught this in CI after the previous commit — the useEffect arrow-function argument list didn't match Prettier's formatting. --- frontend/src/screens/HomeScreen.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index 984531ce..ccfd85c1 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -64,9 +64,10 @@ export function HomeScreen({ // 提醒确认(闹钟响铃/App 内弹窗)只写本地库,不经过语音写日程那条 // scheduleDataRevision 路径——地点提醒确认后要从下方地点列表里消失, // 靠这里单独订阅触发重取,否则要等重启才会刷新。 - useEffect(() => reminder.onScheduleConfirmed(() => setConfirmRevision((value) => value + 1)), [ - reminder, - ]); + useEffect( + () => reminder.onScheduleConfirmed(() => setConfirmRevision((value) => value + 1)), + [reminder], + ); // command.result 写完本地库之后 lastAppliedCommand 才会更新(见 // AssistantConversationService.applyCommandResultLocally),所以这里发现它