Skip to content

feat(reminder): add audio playback and interval time adapters - #267

Merged
Wintercom merged 7 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-adapters-audio
Aug 19, 2026
Merged

feat(reminder): add audio playback and interval time adapters#267
Wintercom merged 7 commits into
1024XEngineer:mainfrom
LUPENGHAN:feature/reminder-adapters-audio

Conversation

@LUPENGHAN

@LUPENGHAN LUPENGHAN commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

关联 Issue

Part of #263

依赖 #266(已合并);

改动

  • ExpoAudioPlayback(实现 ReminderDeliveryPort 的音频部分)+ audioDataUri 工具函数;构造函数留了一个 loadExpoAudioModule 注入口子,测试用假实现绕开 expo-audio 这个原生模块
  • IntervalTimeListener(用 setInterval 实现时间端口)
  • 删除 MockAudioPlayback
  • 组合根接线:createAppServices()time/audio 换成这个 PR 提供的真实实现,reminder 换成真的 LocalReminderApplication#266),不再是全空操作的 MockReminderApplication——否则接进 reminderPorts 的这两个适配器不会被任何东西调用。其它不属于这个 PR 的端口(schedules/location/通知/state/dispositionSync)仍然是 Mock,见下面"验收边界"

修复(review 中发现)

  • ensureAudioMode() 原来在 setAudioModeAsync() 失败时用 .catch() 把 rejected promise 变成 resolved,调用方完全不知道 mode 设置失败了,照样播放并报 played: true——静音模式/后台播放没配置成功时会误报成功,LocalReminderApplication 不会触发 fallback,提醒可能静音。现在 ensureAudioMode() 返回 boolean,失败时上报 played: false;失败仍然只影响这一次,下次播放会重新尝试 mode setup

验证

  • npx tsc --noEmitnpx eslint .npx prettier --check . 全绿(#266 已合并,组合根接线补完后这个 PR 自己能独立跑通,不用等 #271
  • npm run test:vitest 62/62、jest 369/369
  • 新增单测覆盖 ExpoAudioPlayback(含 mode 设置失败的 fallback 路径)、audioDataUriIntervalTimeListener,以及一个组合根集成测试断言 runtime.start() 真的会调用到 reminderPorts.time.start()

验收边界

这个 PR 保证的是:音频/时间两个适配器本身逻辑正确,且组合根用的是会真正消费它们的引擎(LocalReminderApplication),不是死代码。不保证生产提醒流程完整闭环——schedules/location/通知渠道/状态存储/disposition 同步这几个端口还是 Mock,日程数据、地理围栏、系统通知这几条链路要等 #268/#269/#270/#271 落地才会全部接上真实实现。

本轮不含(见 #263 Out of Scope)

  • 定位、通知、数据层适配器(见 #268/#269/#270
  • 全端口真实接线、Mock 全部替换(见 #271

@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
timeflow Ready Ready Preview Aug 17, 2026 8:54am

@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-audio branch from 26115c8 to d7c75ae Compare August 17, 2026 05:22
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-audio branch from d7c75ae to cb2e422 Compare August 17, 2026 07:00
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-audio branch from cb2e422 to e3ffc03 Compare August 17, 2026 07:25
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-audio branch from e3ffc03 to b714e49 Compare August 17, 2026 07:51
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 17, 2026
Code review (PR 1024XEngineer#267): ensureAudioMode() cached setAudioModeAsync()'s
promise unconditionally, including on rejection (the .catch swallowed
it into a resolved-undefined promise). A transient failure on the
first reminder -- e.g. called before the native audio module finishes
initializing after app launch -- would permanently skip
playsInSilentMode/shouldPlayInBackground configuration for every
reminder afterward, for the rest of the app session. Reset modeReady
to null in the catch so the next call retries instead.
Part of 1024XEngineer#263.

ExpoAudioPlayback (implements ReminderDeliveryPort's audio side) +
audioDataUri helper, and IntervalTimeListener (implements the time
port with a plain setInterval). Both only import from application
interfaces already on main -- no dependency on the other adapter PRs
in this stack (notifications/location/data layer), can be reviewed
and merged independently of them.

Removes MockAudioPlayback.
Code review (PR 1024XEngineer#267): ensureAudioMode() cached setAudioModeAsync()'s
promise unconditionally, including on rejection (the .catch swallowed
it into a resolved-undefined promise). A transient failure on the
first reminder -- e.g. called before the native audio module finishes
initializing after app launch -- would permanently skip
playsInSilentMode/shouldPlayInBackground configuration for every
reminder afterward, for the rest of the app session. Reset modeReady
to null in the catch so the next call retries instead.
@LUPENGHAN
LUPENGHAN force-pushed the feature/reminder-adapters-audio branch from 1102497 to d973f40 Compare August 19, 2026 06:14
@LUPENGHAN
LUPENGHAN marked this pull request as ready for review August 19, 2026 06:15
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.13084% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...tend/src/infrastructure/audio/ExpoAudioPlayback.ts 97.05% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审查结论

音频播放适配器、Data URI 编码和 expo-audio@57.0.3 的调用契约已核对;时间适配器本身也能创建和清理周期计时器。但生产组合根仍注入不会产生 tick 的 Mock,因此前台运行时的时间观测链路尚未真正接通。

验证:检查固定范围 0e378c5...d973f40 的完整 diff、AppRoot/AppProviders 生命周期调用和关联 Issue #263 的接线要求;未修改代码。

View job run

Comment thread frontend/src/infrastructure/time/IntervalTimeListener.ts
createAppServices() still constructed MockTimeListener, whose start()
explicitly never fires the listener. LocalReminderApplication.handleTime()
therefore never received periodic ticks, so foreground due/overdue
reminders were never triggered via the JS time channel even though this
PR's own IntervalTimeListener was sitting right there, exported but unused.

Code review (PR 1024XEngineer#267, fennoai bot).
Patch coverage was 6.86% on this PR (Codecov, 95 lines missing) --
ExpoAudioPlayback.ts, audioDataUri.ts, and IntervalTimeListener.ts had no
tests at all.

audioDataUri.ts and IntervalTimeListener.ts are straightforward to test
directly (fake timers for the interval, known base64 vectors for the
encoder). ExpoAudioPlayback.ts was not: its dynamic import('expo-audio')
throws in this Jest environment without --experimental-vm-modules, so
jest.mock('expo-audio', ...) can never be reached -- the try/catch around
the import swallows that TypeError the same way it would swallow a real
"native module unavailable" failure, making the class's actual play/pause/
mode-setup logic structurally unreachable from a test.

Added a constructor seam (loadExpoAudioModule, defaulting to the real
loadExpoAudio) so tests can inject a fake module and exercise the real
logic instead of only ever hitting the fallback branch. The sole
production call site (createAppServices.ts) still does `new
ExpoAudioPlayback()` unchanged.

Patch coverage on the three files is now 94-100%.
Comment thread frontend/src/infrastructure/audio/ExpoAudioPlayback.ts Outdated
@Wintercom

Copy link
Copy Markdown
Collaborator

【Issue 验收与生产接线 / 阻塞合并】PR 已将 IntervalTimeListener 接入组合根,但 createAppServices() 仍创建 MockReminderApplication;这个 Mock 的 start/handleTime/deliver 不执行真实提醒引擎,因此时间适配器和 ExpoAudioPlayback 仍不会被生产提醒流程调用。#263 的验收要求替换全 Mock 桩并接入 App 组合根,本 PR 目前只是把端口绑到一个不会消费它们的 Mock。请与 #266LocalReminderApplication 接线保持明确依赖并补组合层集成测试,或不要在本 PR 宣称生产适配器已接通。

ensureAudioMode()'s .catch(() => { this.modeReady = null; }) turned a
rejected setAudioModeAsync() into a resolved promise -- the rejection never
propagated past ensureAudioMode(), so playBytes()/playBundledAlarm() always
proceeded to call player.play() and returned played: true regardless of
whether silent-mode/background playback was actually configured.
LocalReminderApplication trusts played: true and won't fall back to another
delivery channel, so a mode setup failure could make a reminder silent with
no fallback ever triggered.

ensureAudioMode() now returns whether the mode is actually ready; the two
callers bail out with played: false when it isn't, instead of proceeding to
a play() that's unlikely to be heard. Retry-on-next-attempt behavior is
unchanged: a failure still clears modeReady so the next play tries again.

Code review (PR 1024XEngineer#267, Wintercom).
createAppServices() still constructed MockReminderApplication, whose
start()/handleTime()/deliver() are all no-ops. Wiring IntervalTimeListener
and ExpoAudioPlayback into reminderPorts (earlier commits on this PR)
therefore had no effect in production: nothing ever called time.start() or
audio.playTts(), because the engine that's supposed to call them was a
stub that never touches its dependencies.

Swapped in the real LocalReminderApplication (1024XEngineer#266). The other ports this
PR doesn't own (schedules, location, notifications, state, disposition
sync) stay on their existing Mocks -- LocalReminderApplication works
against the ReminderApplicationDependencies interface regardless of which
side of each port is real, same as the equivalent swap already done for
1024XEngineer#270.

Added an assertion to the existing createAppServices test that starting
the runtime actually calls through to reminderPorts.time.start(), proving
the composition root wires an engine that consumes its ports instead of
one that ignores them.

Code review (PR 1024XEngineer#267, Wintercom).
@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

组合根换成了真的 LocalReminderApplication(#266)。这个 PR 自己不拥有的端口(schedules/location/通知/state/dispositionSync)还是原来的 Mock,没动——LocalReminderApplication 是照着 ReminderApplicationDependencies 这个接口工作的,哪边端口是真的哪边是 Mock 不影响它,跟 #270 那边同样的换法一致。补了一个断言:services.runtime.start() 之后 reminderPorts.time.start() 真的被调用了,证明现在接的是会消费这些端口的引擎,不是一个 start()/handleTime() 全是空操作的桩。

@Wintercom

Copy link
Copy Markdown
Collaborator

【Milestone 过程管理 / 阻塞合并】PR 使用 Part of #263,但 PR 本身没有 Milestone;关联 Issue #263 在 MS3。git_rules.txt 第 5–9 行要求本轮所有 Issue/交付挂入 Milestone,以追踪完成度和最终发布清单。请将 PR #267 归入 MS3,并明确依赖 #266 的合并状态和本 PR 的独立验收边界。

@Wintercom Wintercom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

按照 git_rules.txt 的合并标准,本轮需要先处理以下阻塞项:

  1. setAudioModeAsync() 失败时本次音频仍返回 played: true,导致提醒应用不会触发 fallback,可能静音。
  2. 虽然 IntervalTimeListener 已接入组合根,但 createAppServices() 仍构造 MockReminderApplication,生产提醒流程不会消费该时间适配器或 ExpoAudioPlayback#263 的真实引擎接线仍未完成。
  3. PR 未设置 Milestone,关联 #263 在 MS3;且依赖 #266 的合并状态与独立验收边界需要在 PR 中明确。

具体问题已使用 gh 逐条评论。

@LUPENGHAN

Copy link
Copy Markdown
Contributor Author

三条都处理了:

  1. 237a169 修:ensureAudioMode() 现在返回 boolean,setAudioModeAsync() 失败时 playBytes/playBundledAlarm 直接 played: false,不再假装播放成功;失败仍然只影响这一次,下次播放会重试 mode setup。
  2. b385082 修:createAppServices() 换成真的 LocalReminderApplication(#266,已合并),补了断言证明 runtime.start() 真的会调用到 reminderPorts.time.start()——现在接的是会消费 time/audio 这两个端口的真实引擎,不是空操作的 Mock。
  3. Milestone 已经挂到 MS3;PR 描述加了"验收边界"一节,写清楚依赖 #266(已合并)、这个 PR 自己保证的范围(音频/时间适配器本身 + 组合根接的是真实引擎),以及不保证的部分(schedules/location/通知/state/dispositionSync 还是 Mock,完整生产闭环要等 #268/#269/#270/#271)。

@Wintercom Wintercom left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

@Wintercom
Wintercom merged commit 612955d into 1024XEngineer:main Aug 19, 2026
4 checks passed
LUPENGHAN added a commit to LUPENGHAN/timeflow that referenced this pull request Aug 20, 2026
…neer#268), fix conflicts

1024XEngineer#270 was still based on 1024XEngineer#266's merge point; 1024XEngineer#267 and 1024XEngineer#268 merged since
then and both touched createAppServices.ts (real port swaps) and, for
1024XEngineer#267, the same reminder = new LocalReminderApplication(...) line 1024XEngineer#270
had already changed independently. Resolved by keeping every adapter's
real implementation (both sides had already done their own swap for
different ports) instead of picking one branch's version.

AppRoot.tsx/AppRoot.test.tsx conflicts were two unrelated sets of new
props (protectedClient from main, reminderState/scheduleReader from
this branch) threaded through the same component chain -- combined
both, dropped one genuinely unused import (AuthController) picked up
along the way.

Also fixed two real bugs the rebase surfaced, not introduced by it:
- The branch's own "binds the reminder SQLite adapters" test passed
  authController={controller} to AppRoot, a prop it hasn't accepted
  since the pre-1024XEngineer#266 composition root shape; controller was otherwise
  unused. Collapsed to the one authenticated `services` instance every
  other test in the file already uses.
- mockedCreateScheduleSnapshotPreparation's repository stub was a bare
  {}, fine before this branch existed. AppRoot's ready-state effect now
  calls scheduleReader.refresh() unconditionally, which calls through
  to repository.listSchedules() -- gave the stub real getSchedule/
  listSchedules methods matching the ScheduleLocalRepository mock
  already used elsewhere in the same file.

Verified: tsc/eslint/prettier clean, jest 464/464, vitest 87/87.
MeteorsLiu pushed a commit that referenced this pull request Aug 20, 2026
* feat(reminder): add SQLite-backed reminder data layer

Part of #263.

SqliteLocalScheduleReader / SqliteReminderStateStore read and persist
against the real local database (ScheduleLocalRepository) instead of
in-memory fixtures; geofence_radius_meters is hardcoded to 200m for
now (known simplification, see Issue #263 Out of Scope).
InMemoryLocalScheduleReader is kept as a non-persisted alternative
implementation of the same port. LocalScheduleWriter's post-write hook
refreshes the new reader after a voice-driven schedule mutation lands.

Only depends on application interfaces already on main and the
existing ScheduleLocalRepository -- independent of the audio/location/
notifications adapter PRs in this stack.

Removes MockLocalScheduleReader, MockReminderApplication,
MockReminderDispositionSync, MockReminderStateStore, mockReminderSchedules.

* chore(reminder): remove unused LocalSystemNotification placeholder

Code review (PR #270): LocalSystemNotification was a no-op
SystemNotificationPort placeholder, exported but never imported
anywhere -- fully superseded by the real ExpoSystemNotification
adapter from the notifications PR, which is what createAppServices.ts
on the wiring branch actually uses. Dead code, removed.

* fix(reminder): wire SQLite adapters into app lifecycle

* test(reminder): cover local data adapters

* test(reminder): cover data adapters in Jest

* fix(reminder): rebase onto upstream/main (#266-#268), fix conflicts

#270 was still based on #266's merge point; #267 and #268 merged since
then and both touched createAppServices.ts (real port swaps) and, for
#267, the same reminder = new LocalReminderApplication(...) line #270
had already changed independently. Resolved by keeping every adapter's
real implementation (both sides had already done their own swap for
different ports) instead of picking one branch's version.

AppRoot.tsx/AppRoot.test.tsx conflicts were two unrelated sets of new
props (protectedClient from main, reminderState/scheduleReader from
this branch) threaded through the same component chain -- combined
both, dropped one genuinely unused import (AuthController) picked up
along the way.

Also fixed two real bugs the rebase surfaced, not introduced by it:
- The branch's own "binds the reminder SQLite adapters" test passed
  authController={controller} to AppRoot, a prop it hasn't accepted
  since the pre-#266 composition root shape; controller was otherwise
  unused. Collapsed to the one authenticated `services` instance every
  other test in the file already uses.
- mockedCreateScheduleSnapshotPreparation's repository stub was a bare
  {}, fine before this branch existed. AppRoot's ready-state effect now
  calls scheduleReader.refresh() unconditionally, which calls through
  to repository.listSchedules() -- gave the stub real getSchedule/
  listSchedules methods matching the ScheduleLocalRepository mock
  already used elsewhere in the same file.

Verified: tsc/eslint/prettier clean, jest 464/464, vitest 87/87.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants