Skip to content
Agentmart
Submit a tool

local-notifications

Schedules on-device reminders with expo-notifications: rows as truth, one idempotent sync, DST-safe triggers, Android channels.

Skill by zakariaf

IndexedMIT★ 11
Write a review

Install

Works with Claude Code, Claude apps, Codex, Cursor, Gemini CLI, GitHub Copilot, VS Code, OpenCode, Goose, Windsurf. Read the repository before you give any tool access to your data.

npx agentmarket install skill/zakariaf-local-notifications

Asks which of your tools to add it to (Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI, Claude Desktop) and shows the change first. See https://agentmart.dev/cli.

Get the weekly diff

The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.

We use your email only for this newsletter. Privacy

About

Schedules on-device reminders with expo-notifications: rows as truth, one idempotent sync, DST-safe triggers, Android channels. Use when adding a reminder, snooze or local notification. Not for remote push (push-notifications).

No reviews yet. Installed it? Reviews come from verified installers only. How reviews work

Permissions

What this skill can do and what it asks for, from its SKILL.md and code. Checked Oct 26, 2026.

Tools
No tools found
Secrets
None asked for
Install scripts
None found
Runs as
Node.js package

Network hosts it talks to 1

  • codeload.github.com · found in install command

Files it touches

  • ~/.claude/skills/local-notifications

What's inside

Skill name
local-notifications
Description
Schedules on-device reminders with expo-notifications: rows as truth, one idempotent sync, DST-safe triggers, Android channels. Use when adding a reminder, snooze or local notification. Not for remote push (push-notifications).
License
MIT
Instructions
245 lines in SKILL.md · view on GitHub

Before installing

  • Ships executable scripts

Files (68)

  • SKILL.md
  • shared.json · assets/
  • create-root-kits.ts · examples/
  • migration-reminders.ts · examples/
  • sqlite-reminder-repository.ts · examples/
  • use-task-reminder-model.ts · examples/
  • checkers.md · references/
  • expo-notifications-api.md · references/
  • platform-behaviour.md · references/
  • scheduling-model.md · references/
  • taps-and-routing.md · references/
  • testing-and-devices.md · references/
  • check-lib.mjs · scripts/
  • check-reminder-code.mjs · scripts/
  • check-reminder-logic.mjs · scripts/
  • app-modules.mjs · scripts/lib/
  • reminder-checks.mjs · scripts/lib/
  • reminder-paths.mjs · scripts/lib/
  • source-scan.mjs · scripts/lib/
  • selftest.mjs · scripts/
  • date-key.test.ts · templates/src/core/dates/
  • date-key.ts · templates/src/core/dates/
  • plan-reminders.test.ts · templates/src/core/reminders/
  • plan-reminders.ts · templates/src/core/reminders/
  • reconcile-reminders.test.ts · templates/src/core/reminders/
  • reconcile-reminders.ts · templates/src/core/reminders/
  • reminder-tap.test.ts · templates/src/core/reminders/
  • reminder-tap.ts · templates/src/core/reminders/
  • reminder.test.ts · templates/src/core/reminders/
  • reminder.ts · templates/src/core/reminders/
  • wall-clock.test.ts · templates/src/core/reminders/
  • wall-clock.ts · templates/src/core/reminders/
  • use-reminder-sync.test.ts · templates/src/hooks/
  • use-reminder-sync.ts · templates/src/hooks/
  • use-reminder-taps.test.ts · templates/src/hooks/
  • use-reminder-taps.ts · templates/src/hooks/
  • ar.json · templates/src/i18n/catalogs/
  • en.json · templates/src/i18n/catalogs/
  • expo-local-notifications-adapter.test.ts · templates/src/services/local-notifications/
  • expo-local-notifications-adapter.ts · templates/src/services/local-notifications/
  • fake-local-notifications.test.ts · templates/src/services/local-notifications/
  • fake-local-notifications.ts · templates/src/services/local-notifications/
  • local-notifications-port.test.ts · templates/src/services/local-notifications/
  • local-notifications-port.ts · templates/src/services/local-notifications/
  • local-notifications-root-kit.test.tsx · templates/src/services/local-notifications/
  • local-notifications-root-kit.tsx · templates/src/services/local-notifications/
  • reminder-sync-context.test.tsx · templates/src/services/local-notifications/
  • reminder-sync-context.tsx · templates/src/services/local-notifications/
  • reminder-sync.test.ts · templates/src/services/local-notifications/
  • reminder-sync.ts · templates/src/services/local-notifications/
  • reminder-texts.test.ts · templates/src/services/local-notifications/
  • reminder-texts.ts · templates/src/services/local-notifications/
  • fake-local-zone.test.ts · templates/src/services/local-zone/
  • fake-local-zone.ts · templates/src/services/local-zone/
  • local-zone-port.ts · templates/src/services/local-zone/
  • system-local-zone-adapter.test.ts · templates/src/services/local-zone/
  • system-local-zone-adapter.ts · templates/src/services/local-zone/
  • reminder-rows.ts · templates/test/reminders/
  • rule-zone.ts · templates/test/reminders/
  • package.json · tests/bases/app/
  • …and 8 more

SKILL.md (first lines)

# Local notifications

Every on-device reminder in an Expo app is a database row; one idempotent sync turns the rows into
the OS's pending notifications (one-shot triggers at instants resolved per day through the local
zone, at most 50 nearest, on Android channels, with localized text), refills them on every return
to the foreground, and opens the row's current screen when one is tapped, on iOS and Android alike;
two checkers prove it on the app's own code.

## Rules that must hold

1. **The database is the source of truth; the OS pending set is a cache.** Reminders are rows
   (`Reminder`, `src/core/reminders/reminder.ts`); nothing reads `getAllScheduledNotificationsAsync`
   to decide what the person asked for. Why: iOS keeps only 64, OEM battery managers drop alarms, a
   restore or reinstall empties the set; only rows survive all of it. (`schedule-api`, `cancel-all`)
2. **One idempotent sync is the only writer.** `createReminderSync` (`reminder-sync.ts`): channels,
   permission, rows, `planReminders`, `reconcileReminders`, cancel, schedule; runs never overlap
   (single flight). Features write the row, then call `sync.request()`. Why: a second writer makes
   the diff wrong for both and cannot be made idempotent. (`sync-only-writer`, `reconcile-diff`)
3. **expo-notifications lives only in adapters.** The local adapter
   (`expo-local-notifications-adapter.ts`) is the only file that schedules, lists or cancels; tests
   drive it through `jest.mock` + `jest.requireMock`, never a value import (type-only imports are
   erased and allowed). Why: Jest never touches native code, and one file owns every platform
   difference. (`sdk-import`, `schedule-api`)
4. **A recurring time is a wall-clock minute plus a rule, resolved per day through the zone.**
   `instantOfWallClock(day, minuteOfDay, zone.offsetAt)`; days by `addDays` on date keys; no
   `Date.now()`, `new Date(` or `+ 86_400_000` in reminder code (time comes from `ClockPort` and
   `LocalZonePort`). Why: a local day lasts 23, 24 or 25 hours; 09:00 stored as an instant is 08:00
   all winter. (`wall-clock`, `wall-clock-dst`, `plan-dst`)
5. **Every notification is a one-shot DATE trigger on an explicit channel.** No DAILY, WEEKLY,
   CALENDAR, TIME_INTERVAL or `repeats: true`; `channelId` on every trigger. Why: a repeating trigger
   cannot skip a done day, keeps one text for ever, and hides its next instant from the diff; without
   a channel Android posts to an unnamed fallback channel. (`repeating-trigger`, `trigger-channel`)
6. **Identifiers encode reminder, instant and text, and the sync touches only its own.**
   `reminder:<id>:<instant>:<hash of channel, title, body>`; only `REMINDER_ID_PREFIX` identifiers are
   cancelled; never `cancelAllScheduledNotificationsAsync`. Why: the same input must be an empty diff,
   an edit must replace (iOS reports a pending date only as an interval), and other features'
   notifications are not this sync's. (`plan-idempotent`, `plan-edit`, `reconcile-diff`, `cancel-all`)
7. **Plan the nearest 50 (never above 64), nothing in the past, and refill on every foreground.**
   `maxPending` default 50, `IOS_PENDING_LIMIT` 64, a 5-second lead (a pending occurrence inside it is
   kept: `imminentIds`); `useReminderSync` runs on mount, on return to the foreground and on a
   language change. Why: iOS silently drops everything past the 64 soonest, rejects a past date
   (Android drops it silently), a sync in a reminder's last seconds must not cancel it, and a window
   runs out unless refilled. (`plan-max-pending`, `plan-past`, `reconcile-diff`, `sync-on-foreground`)
8. **A bad row is rejected and recorded, never fatal; skip days and weekdays are honoured.** Why: one
   corrupt row must not silence every reminder, and "done for today" must hold. (`plan-reject`,
   `plan-skip`)
9. **Notification text comes from `t()` at sync time, the `t()` the app shows.** `reminderTexts(t)`
   builds the channels and texts of a run from one `t()`, which `useReminderSync(sync, t)` hands over
   (`sync.request({ t })`); the person's label goes in a `*Text` placeholder (bidi-isolated). Why: a
   literal, or a template with words around its `${}`, is never translated; the text hash in each
   identifier re-plans every pending notification after a language switch; and a language read from
   module state in a child's effect is still the old one. (`literal-text`)
10. **The permission is asked from a person's action, channels first.** `sync.enable()` from "Remind
    me": channels, then the prompt only while the OS still shows one, then a sync; `blocked` offers
    the system Settings. Never in a layout, the root navigator, the entry or any effect (an effect
    runs on mount). Why: iOS asks once, an app targeting Android 12L or lower gets no prompt before a
    channel exists, and a launch prompt is refused more often. (`permission-at-launch`)
11. **Exactly one foreground handler.** One `setNotificationHandler` call: the local adapter's, or
    push-notifications' adapter's when push is adopted (the composition root then passes
    `installForegroundHandler: false` to the local adapter). Why: on SDK 57 a notification that fires
    while the app is open is not shown without it, and a second call silently replaces the first.
    (`foreground-handler`)
12. **A tap carries only `{ kind: 'reminder', reminderId }` and opens a typed route.**
    `parseReminderTap` treats data as untrusted; `useReminderTaps` reads the launch tap
    (`getLastNotificationResponse`) and the listener, handles each identifier once, clears it, and the
    app's `openReminder` reads the row's subject again and maps it to a typed href (missing → home);
    no row stores a path. Why: a notification outlives routes and rows, a cold-start tap reaches no
    listener, and a stored path needs an `as Href` cast. (`plan-data`, `tap-parse`, `tap-handling`)
13. **Android: inexact alarms by default, never `USE_EXACT_ALARM`.** `SCHEDULE_EXACT_ALARM` only as a
    deliberate opt-in with its denial handled. Why: Play allows `USE_EXACT_ALARM` only for alarm-clock,
    timer and calendar apps; Android 14 denies the exact-alarm grant by default. (`exact-alarm`)

## Workflow

1. **Read the reference for the task:**

   | Task | Read |
   |---|---|
   | a new reminder kind, the sync, the window, DST or travel, restore, "fired at the wrong time" | [references/scheduling-model.md](references/scheduling-model.md) |
   | an expo-notifications call, trigger, handler, permission or channel detail | [references/expo-notifications-api.md](references/expo-notifications-api.md) |
   | iOS vs Android delivery, the 64 limit, exact alarms, Doze, OEMs, lock-screen privacy | [references/platform-behaviour.md](references/platform-behaviour.md) |
   | tapping a reminder, cold start, coexisting with push, action buttons | [references/taps-and-routing.md](references/taps-and-routing.md) |
   | tests, rule zones, device probes | [references/testing-and-devices.md](references/testing-and-devices.md) |
   | a checker failure, configuration, NOT APPLICABLE | [references/checkers.md](references/checkers.md) |

2. **Check the prerequisites.** `ClockPort` with its fake and the composition root
   (architecture-and-boundaries), `Result` (error-handling-typed-results), `date-key.ts`
   (seeded-determinism-and-golden-vectors; a synced copy is in `templates/src/core/dates/`, copy it
   only when the app lacks it), `useIsAppActive` (react-components-and-hooks), `t()` and the
   catalogs (i18n-strings-and-catalogs). Install the SDK with `npx expo install expo-notifications`
   and add its config plugin with an Android `icon` and `color` (expo-prebuild-and-config-plugins).
3. **Classify the SDK.** Add `"expo-notifications": "LocalNotificationsPort"` to `vendorSdks` in
   `contracts/architecture.json` and `vendor('expo-notifications', 'LocalNotificationsPort')` to the
   ESLint `VENDOR_SDK_PATHS` (architecture-and-boundaries, typescript-and-lint-rules). When
   push-notifications is adopted too, both adapters import the SDK, so the label names both ports:
   `"LocalNotificationsPort/PushPort"` in both places.
4. **Store the rows.** Add the reminder tables in a forward-only migration and a repository whose
   `loadActiveReminders()` returns `Reminder[]` (recording malformed rows) and `findReminder(id)` the
   row's subject (`{ taskId }`, null when gone), created in `openDeviceData` as `reminders` on
   `AppData` over the one connection
   ([examples/migration-reminders.ts](examples/migration-reminders.ts),
   [examples/sqlite-reminder-repository.ts](examples/sqlite-reminder-repository.ts);
   persistence-and-migrations owns the format).
5. **Copy the templates** to the paths their first lines name, each with its test:
   `templates/src/core/reminders/` (the pure planner), `templates/src/services/local-zone/`,
   `templates/src/services/local-notifications/` (port, adapter, fake, texts, sync, the root kit and
   `reminder-sync-context.tsx`),
   `templates/src/hooks/` (sync and tap hooks), `templates/test/reminders/` (rule zones, rows) and the
   catalog keys in `templates/src/i18n/catalogs/` (merge into the app's catalogs; translate every
   locale).
6. **Register the root kit** in app-startup-and-bootstrap's registry `src/services/create-root-kits.ts`
   ([examples/create-root-kits.ts](examples/create-root-kits.ts)), which `startApp` runs once after
   the data layer opened: create the notification and zone adapters (if push-notifications is
   adopted, pass `installForegroundHandler: false` to the local adapter, rule 11), the one
   `reminderSync` over `data.reminders.loadActiveReminders`, and add
   `createLocalNotificationsKit({ notifications, sync, hrefForReminder })` to the list. The root files
   stay unchanged; the registry never opens the database.
7. **What the kit mounts:** its provider (`ReminderSyncProvider`, so models call
   `useReminderSyncService()`) and its effects beside the root Stack, inside the navigator:

From zakariaf/ReactNative-Skills, MIT.

More from zakariaf/ReactNative-Skills

Signals

Stars
11
Forks
2
Open issues
0
Last commit (this folder)
Oct 17, 2026 (11 days ago)

From GitHub (fetched Oct 24, 2026).

Details

License
MIT
First seen
Oct 24, 2026
Last checked
Oct 24, 2026

More from zakariaf

Cite this page

local-notifications skill. Agent Mart. https://agentmart.dev/skills/zakariaf-local-notifications (last updated 2026-10-24)