diff --git a/DECISIONS.md b/DECISIONS.md
index dd9607f..586aed7 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -1398,3 +1398,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **"Start your company" keeps its command name** (`createGuest`): the start screen, its tests and the route catalog stay unchanged; the command now builds the save on the device instead of an account and a database row.
- **The test pins the whole starting state** with fixed ids and a fixed seed, so any drift from CR-001's start shows up as a diff, not as a vague mismatch.
+
+## Step 14.9
+
+- **The schedule is derived, not tracked**: after every saved change the engine works out every future event from the save and replaces the platform's whole schedule. Nothing remembers notification ids, so a moved arrival, a sold ship or a new company can't leave a stale notification behind. Ids are just 1..n per schedule.
+- **A combined notification fires at its minute's last event**, so "2 updates" never announces something that hasn't happened yet.
+- **Permission is asked by the engine client right after `acceptDeal` succeeds** (decision 0.5.91: not at first launch). The tutorial's loaner runs don't need notifications; owning the business is when waiting starts to matter. Without permission the native scheduler just schedules nothing.
+- **The toggle is a device preference** (default on), read by the engine through a callback; changing it resyncs at once. The section shows on the web too, labelled "iOS and Android apps only", so the setting carries over if the same preferences ever matter there.
diff --git a/apps/web/package.json b/apps/web/package.json
index 6dbb98d..224282d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,6 +14,10 @@
"build:e2e": "node scripts/map-art.mjs --if-available && vite build --mode e2e"
},
"dependencies": {
+ "@capacitor/app": "^8.1.2",
+ "@capacitor/core": "^8.5.3",
+ "@capacitor/filesystem": "^8.1.4",
+ "@capacitor/local-notifications": "^8.3.1",
"@deadhead/content": "workspace:*",
"@deadhead/engine": "workspace:*",
"@deadhead/schema": "workspace:*",
diff --git a/apps/web/src/api/engine-client.ts b/apps/web/src/api/engine-client.ts
index a37350e..e53b381 100644
--- a/apps/web/src/api/engine-client.ts
+++ b/apps/web/src/api/engine-client.ts
@@ -17,7 +17,10 @@ export function createEngineClient(engine: GameEngine): ApiClient {
return {
async call(name, input) {
try {
- return (await engine.call(name as RouteName as CommandName, input as never)) as never;
+ const result = await engine.call(name as RouteName as CommandName, input as never);
+ // Notifications are asked for once the player owns the business (decision 0.5.91).
+ if (name === "acceptDeal") void engine.requestNotificationPermission().catch(() => {});
+ return result as never;
} catch (error) {
if (error instanceof CommandError) {
throw new ApiError(statusFor(error.code), error.code, error.message);
diff --git a/apps/web/src/platform/game.ts b/apps/web/src/platform/game.ts
index 3458c70..e5b95a3 100644
--- a/apps/web/src/platform/game.ts
+++ b/apps/web/src/platform/game.ts
@@ -3,17 +3,20 @@ import {
GameEngine,
IndexedDbBackend,
MemoryBackend,
+ NotificationSync,
ResolutionLoop,
SaveStorage,
TabLock,
type LockManagerLike,
type LoopHost,
+ type NotificationScheduler,
} from "@deadhead/engine";
+import { usePreferences } from "../state/preferences";
+import { platformNotifications } from "./notifications";
/**
* The game on this device (CR-014): save storage, the device clock, the tab
- * lock and the engine, wired to the browser. Native storage and notifications
- * join in Step 14.9.
+ * lock, local notifications and the engine, wired to the browser.
*/
export interface Game {
engine: GameEngine;
@@ -37,7 +40,7 @@ function savedDevOffset(): number {
}
}
-export function createGame(): Game {
+export function createGame(scheduler: NotificationScheduler = platformNotifications()): Game {
const storage = new SaveStorage(
typeof indexedDB === "undefined" ? new MemoryBackend() : new IndexedDbBackend(indexedDB),
);
@@ -63,6 +66,11 @@ export function createGame(): Game {
devTools: DEV_TOOLS,
// Responses are checked in development, as the server did outside production.
validateResponses: !import.meta.env.PROD,
+ notifications: new NotificationSync(scheduler, () => usePreferences.getState().notifications),
+ });
+ // The Settings toggle reschedules (or clears) at once.
+ usePreferences.subscribe((prefs, before) => {
+ if (prefs.notifications !== before.notifications) void engine.resyncNotifications();
});
return { engine, lock, storage, clock, devTools: DEV_TOOLS };
}
diff --git a/apps/web/src/platform/notifications.ts b/apps/web/src/platform/notifications.ts
new file mode 100644
index 0000000..30c28e3
--- /dev/null
+++ b/apps/web/src/platform/notifications.ts
@@ -0,0 +1,39 @@
+import { Capacitor } from "@capacitor/core";
+import { LocalNotifications } from "@capacitor/local-notifications";
+import { noNotifications, type NotificationScheduler } from "@deadhead/engine";
+
+/**
+ * Local notifications on iOS and Android through Capacitor (decision 0.5.91).
+ * The web build has none: there, the no-op scheduler is used.
+ */
+export const capacitorNotifications: NotificationScheduler = {
+ async replaceAll(list) {
+ const { notifications: pending } = await LocalNotifications.getPending();
+ if (pending.length > 0) {
+ await LocalNotifications.cancel({ notifications: pending.map((n) => ({ id: n.id })) });
+ }
+ if (list.length === 0) return;
+ // Without permission scheduling fails; the player said no, so stay quiet.
+ const { display } = await LocalNotifications.checkPermissions();
+ if (display !== "granted") return;
+ await LocalNotifications.schedule({
+ notifications: list.map((n) => ({
+ id: n.id,
+ title: n.title,
+ body: n.body,
+ schedule: { at: new Date(n.at), allowWhileIdle: true },
+ })),
+ });
+ },
+ async requestPermission() {
+ const { display } = await LocalNotifications.checkPermissions();
+ if (display === "prompt" || display === "prompt-with-rationale") {
+ await LocalNotifications.requestPermissions();
+ }
+ },
+};
+
+/** The scheduler for this platform. */
+export function platformNotifications(): NotificationScheduler {
+ return Capacitor.isNativePlatform() ? capacitorNotifications : noNotifications;
+}
diff --git a/apps/web/src/screens/Settings.tsx b/apps/web/src/screens/Settings.tsx
index 6d22292..286e231 100644
--- a/apps/web/src/screens/Settings.tsx
+++ b/apps/web/src/screens/Settings.tsx
@@ -64,6 +64,19 @@ export function Settings() {
/>
+
+
+ label="Arrivals, rest and upgrades"
+ value={prefs.notifications ? "on" : "off"}
+ options={[
+ ["on", "On"],
+ ["off", "Off"],
+ ]}
+ onChange={(v) => prefs.set({ notifications: v === "on" })}
+ />
+