Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> = Promise.resolve();
/** 每次 stop / 失败回滚自增;停机前开始的工作持有旧世代,重启后仍视为已取消。 */
private generation = 0;
Expand Down Expand Up @@ -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<void> {
const generation = this.generation;
return this.track(this.runHandleTime(tick, generation));
Expand Down Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,9 @@ export interface ReminderApplicationPort {
* 重弹一遍,见调用方 registerInternal/rebuildInternal 的路径区分)。
*/
onPermissionBlocked(listener: (event: ReminderPermissionBlockedEvent) => void): () => void;
/**
* 订阅"某条日程被确认"事件——确认只写本地 SQLite,不经过日历页读取的
* ScheduleClientService,日历页不知道要重取,地点提醒会一直挂着直到重启。
*/
onScheduleConfirmed(listener: () => void): () => void;
}
13 changes: 11 additions & 2 deletions frontend/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -57,9 +57,18 @@ export function HomeScreen({
const [trackedPttCommand, setTrackedPttCommand] = useState(pttCommand);
const [trackedCallCommand, setTrackedCallCommand] = useState(callCommand);
const [focusTarget, setFocusTarget] = useState<CalendarFocusTarget | null>(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 驱动,
Expand All @@ -84,7 +93,7 @@ export function HomeScreen({
isSigningOut={isSigningOut}
onOpenPermissions={() => onRequestPermission()}
onSignOut={onSignOut}
refreshSignal={pttScheduleRevision + callScheduleRevision}
refreshSignal={pttScheduleRevision + callScheduleRevision + confirmRevision}
focusTarget={focusTarget}
service={scheduleService}
timezone={timezone}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
52 changes: 51 additions & 1 deletion frontend/tests/unit/screens/HomeScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<ScheduleCalendarReadService['getLocationSchedules']>()
.mockResolvedValue([]),
getSchedulesByDay: jest
.fn<ScheduleCalendarReadService['getSchedulesByDay']>()
.mockResolvedValue([]),
getSchedulesByRange: jest
.fn<ScheduleCalendarReadService['getSchedulesByRange']>()
.mockResolvedValue([]),
};
const confirmedListeners = new Set<() => void>();
render(
<HomeScreen
accountId="account-a"
continuousApplication={continuousApplication}
alertDialog={{ show: async () => {} }}
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));
});
});
Loading