docs/long-term-plans/location-based-reminders.md
Status: Planned (Brainstorm)
Add location-based reminders to Super Productivity. Users can attach a saved location to a task and receive a notification when they arrive at that place. This is primarily a mobile feature (Android/iOS via Capacitor), with passive location display on desktop/web.
| Decision | Choice | Rationale |
|---|---|---|
| Primary platform | Mobile (Android/iOS) | Geofencing requires GPS + background location. Desktop/web show location info but don't trigger. |
| Trigger type | Arrive only (v1) | Simplest. Leave triggers can be added later. |
| Location management | Saved locations entity | Users revisit the same places. Follows existing Tag entity pattern. |
| Location picker (v1) | "Use current location" + label | No map needed for v1. Map picker is a v2 enhancement. |
| Geofencing approach | Custom native Capacitor plugin | @capacitor/geolocation only does point-in-time reads, not geofencing. Need native GeofencingClient (Android) / CLLocationManager (iOS). |
| Sync | Sync by default | Location data treated like any other entity. Each device manages its own geofences locally after sync. |
| Feature toggle | isLocationRemindersEnabled in AppFeaturesConfig | Opt-in, default false. |
New file: src/app/features/saved-location/saved-location.model.ts
import { EntityState } from '@ngrx/entity';
export interface SavedLocationCopy {
id: string;
title: string; // "Office", "Grocery Store", "Gym"
lat: number; // latitude
lng: number; // longitude
radius: number; // geofence radius in meters (default 200)
icon?: string | null; // material icon name, e.g. 'home', 'work', 'shopping_cart'
created: number; // creation timestamp
modified?: number; // last update timestamp
}
export type SavedLocation = Readonly<SavedLocationCopy>;
export type SavedLocationState = EntityState<SavedLocation>;
File: src/app/features/tasks/task.model.ts — add to TaskCopy:
/** ID of a SavedLocation. When set, a geofence reminder is active for this task. */
locationReminderId?: string | null;
File: src/app/features/config/global-config.model.ts — add to AppFeaturesConfig:
isLocationRemindersEnabled: boolean; // default false
File: src/app/features/config/default-global-config.const.ts — set default:
isLocationRemindersEnabled: false,
File: src/app/core/platform/platform-capabilities.model.ts — add:
/** Whether the platform supports native geofencing (background location monitoring). */
readonly geofencing: boolean;
| Platform | geofencing |
|---|---|
Android (ANDROID_CAPABILITIES) | true |
iOS (IOS_CAPABILITIES) | true |
Electron (ELECTRON_CAPABILITIES) | false |
Web (WEB_CAPABILITIES) | false |
All registration steps required for the new entity, following existing patterns:
File: packages/shared-schema/src/entity-types.ts
Add 'SAVED_LOCATION' to the ENTITY_TYPES array.
File: src/app/op-log/core/action-types.enum.ts
// SavedLocation actions
SAVED_LOCATION_ADD = '[SavedLocation] Add SavedLocation',
SAVED_LOCATION_UPDATE = '[SavedLocation] Update SavedLocation',
SAVED_LOCATION_DELETE = '[SavedLocation] Delete SavedLocation',
These string values are immutable once deployed — they are used for encoding/decoding operations in IndexedDB and sync between clients.
File: src/app/op-log/core/entity-registry.ts
Add to ENTITY_CONFIGS:
SAVED_LOCATION: {
storagePattern: 'adapter',
featureName: SAVED_LOCATION_FEATURE_NAME,
payloadKey: 'savedLocation',
adapter: savedLocationAdapter,
selectEntities: createSelector(
selectSavedLocationFeatureState,
selectSavedLocationEntitiesFromAdapter,
),
selectById: selectSavedLocationById,
},
File: src/app/op-log/model/model-config.ts
Add to AllModelConfig type:
savedLocation: ModelCfg<SavedLocationState>;
Add to MODEL_CONFIGS:
savedLocation: {
defaultData: initialSavedLocationState,
isMainFileModel: true,
repair: fixEntityStateConsistency,
},
File: src/app/root-store/root-state.ts
[SAVED_LOCATION_FEATURE_NAME]: SavedLocationState;
File: src/app/root-store/feature-stores.module.ts
StoreModule.forFeature(SAVED_LOCATION_FEATURE_NAME, savedLocationReducer),
File: src/app/root-store/meta/task-shared-meta-reducers/saved-location-shared.reducer.ts
When a SavedLocation is deleted, clear locationReminderId on all tasks that reference it. This must be a meta-reducer (not an effect) to ensure atomicity — one operation in the sync log.
Register in src/app/root-store/meta/meta-reducer-registry.ts in Phase 5 (Entity-Specific Cascades), alongside tagSharedMetaReducer, projectSharedMetaReducer, etc.
File: src/app/features/saved-location/store/saved-location.actions.ts
Following the Tag action pattern with PersistentActionMeta:
| Action | OpType | Payload |
|---|---|---|
addSavedLocation | Create | { savedLocation: SavedLocation } |
updateSavedLocation | Update | { savedLocation: Update<SavedLocation> } |
deleteSavedLocation | Delete | { id: string } |
All include meta: { isPersistent: true, entityType: 'SAVED_LOCATION', entityId, opType } satisfies PersistentActionMeta.
File: src/app/features/saved-location/store/saved-location.reducer.ts
Standard @ngrx/entity adapter:
export const SAVED_LOCATION_FEATURE_NAME = 'savedLocation';
export const savedLocationAdapter = createEntityAdapter<SavedLocation>({
sortComparer: (a, b) => a.title.localeCompare(b.title),
});
export const initialSavedLocationState = savedLocationAdapter.getInitialState();
File: src/app/features/saved-location/store/saved-location.selectors.ts
| Selector | Returns |
|---|---|
selectSavedLocationFeatureState | Feature state |
selectAllSavedLocations | SavedLocation[] |
selectSavedLocationById | SavedLocation | undefined |
selectSavedLocationEntities | Dictionary<SavedLocation> |
Task-side selector (in task selectors or a cross-feature selector):
| Selector | Returns |
|---|---|
selectTasksWithLocationReminder | All undone tasks that have locationReminderId set |
File: src/app/features/saved-location/saved-location.service.ts
Thin wrapper dispatching actions to the store. Methods: addSavedLocation(), updateSavedLocation(), deleteSavedLocation(), getById$().
New file: src/app/features/saved-location/geofence.service.ts
Store (undone tasks with locationReminderId)
→ GeofenceService watches selector (distinctUntilChanged)
→ Computes which locations need active geofences
→ Registers/unregisters via custom Capacitor plugin
→ Receives geofence enter events
→ Emits to ReminderService for dialog/notification
@capacitor/geolocation only provides one-time and continuous position reads — it does not support geofencing. Native geofencing requires:
com.google.android.gms.location.GeofencingClient (Google Play Services). Supports up to 100 geofences. Fires BroadcastReceiver on enter/exit.CLLocationManager.startMonitoring(for: CLCircularRegion). Supports up to 20 monitored regions. Fires delegate callbacks on enter/exit.Implementation: Create GeofencePlugin.kt (Android) and GeofencePlugin.swift (iOS) extending Capacitor's Plugin class. Register in CapacitorMainActivity.onCreate() following the pattern of SafBridgePlugin, WebDavHttpPlugin, etc.
Register geofence when:
locationReminderId assignedUnregister geofence when:
locationReminderId clearediOS 20-region limit: Only register geofences for the 20 locations with the most active tasks. Re-evaluate when tasks change.
Effects MUST use inject(LOCAL_ACTIONS) — geofence registration should never happen during remote sync replay. Each device manages its own geofences based on local state after sync.
When geofence fires while app is in background:
BroadcastReceiver shows native notification directly (same pattern as ReminderAlarmReceiver). Tapping opens app + reminder dialog.CLLocationManager delegate fires local notification. Tapping opens app + reminder dialog.On first use:
CapacitorPlatformService.hasCapability('geofencing')ACCESS_FINE_LOCATION + ACCESS_BACKGROUND_LOCATION (Android) or "Always" location access (iOS)The existing ReminderService.onRemindersActive$ is a derived observable — external code cannot emit to it directly. Two approaches:
Option A (recommended): Add a new public subject on ReminderService:
// In ReminderService
private _onLocationReminders$ = new Subject<TaskWithReminderData[]>();
locationReminders$ = this._onLocationReminders$.asObservable();
Then merge in ReminderModule:
merge(
this._reminderService.onRemindersActive$,
this._reminderService.locationReminders$,
).subscribe(reminders => /* existing dialog handling */);
Option B: Add a public method emitLocationReminders() that pushes to the private _onRemindersActive$ subject.
Location-triggered reminders must satisfy TaskWithReminderData:
interface TaskWithReminderData extends Task {
readonly reminderData: { remindAt: number }; // use trigger timestamp
readonly parentData?: Task;
readonly isDeadlineReminder?: boolean; // false for location
}
Consider adding isLocationReminder?: boolean for UI differentiation.
The existing DialogViewTaskRemindersComponent works as-is with minor additions:
locationReminderId on the taskReuse CapacitorReminderService patterns:
ReminderNotificationHelper triggered from geofence BroadcastReceiverLocalNotifications.schedule() triggered from CLLocationManager delegate| Component | Path | Purpose |
|---|---|---|
saved-location-settings | src/app/features/saved-location/saved-location-settings/ | CRUD list in settings page |
location-picker-dialog | src/app/features/saved-location/location-picker-dialog/ | Assign location to task (dropdown + "Use current location") |
| Component | Change |
|---|---|
| Task detail panel | Add "Location" field showing assigned location |
| Task schedule dialog | Add location option alongside time-based reminder |
| Settings page | Add "Locations" section |
| Reminder dialog | Show location name when triggered by geofence |
┌─────────────────────────────────┐
│ Set Location Reminder │
├─────────────────────────────────┤
│ │
│ 📍 Use Current Location │ ← gets GPS, prompts for label
│ │
│ ─── Saved Locations ────────── │
│ │
│ 🏠 Home │
│ 🏢 Office │
│ 🛒 Grocery Store │
│ │
│ [ Remove Location ] [ Cancel ] │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Location Reminders │
├─────────────────────────────────┤
│ [Toggle] Enable location │
│ reminders │
│ │
│ Saved Locations: │
│ ┌─────────────────────────┐ │
│ │ 🏠 Home 200m [✏️][🗑]│ │
│ │ 🏢 Office 150m [✏️][🗑]│ │
│ │ 🛒 Grocery 200m [✏️][🗑]│ │
│ └─────────────────────────┘ │
│ [ + Add Location ] │
└─────────────────────────────────┘
| Platform | Geofencing | Location Display | Notifications |
|---|---|---|---|
| Android | Native GeofencingClient, up to 100 fences, background | Yes | Native via BroadcastReceiver |
| iOS | Native CLLocationManager, up to 20 regions, background | Yes | Native via LocalNotifications |
| Electron | None | Yes (label only) | None |
| Web | None | Yes (label only) | None |
src/app/features/saved-location/
├── saved-location.model.ts
├── saved-location.const.ts # DEFAULT_RADIUS = 200
├── saved-location.service.ts
├── geofence.service.ts # Capacitor geofencing bridge
├── store/
│ ├── saved-location.actions.ts
│ ├── saved-location.reducer.ts
│ └── saved-location.selectors.ts
├── saved-location-settings/
│ ├── saved-location-settings.component.ts
│ └── saved-location-settings.component.html
└── location-picker-dialog/
├── location-picker-dialog.component.ts
└── location-picker-dialog.component.html
src/app/root-store/meta/task-shared-meta-reducers/
└── saved-location-shared.reducer.ts # Cascading delete
android/app/src/main/java/.../
└── GeofencePlugin.kt # Native Android geofencing
GeofenceBroadcastReceiver.kt # Handles fence enter events
ios/App/App/
└── GeofencePlugin.swift # Native iOS geofencing
| File | Purpose |
|---|---|
src/app/features/saved-location/saved-location.model.ts | Entity interface |
src/app/features/saved-location/saved-location.const.ts | Defaults |
src/app/features/saved-location/saved-location.service.ts | Service |
src/app/features/saved-location/geofence.service.ts | Capacitor bridge |
src/app/features/saved-location/store/saved-location.actions.ts | Actions |
src/app/features/saved-location/store/saved-location.reducer.ts | Reducer + adapter |
src/app/features/saved-location/store/saved-location.selectors.ts | Selectors |
src/app/features/saved-location/saved-location-settings/* | Settings UI |
src/app/features/saved-location/location-picker-dialog/* | Picker dialog |
src/app/root-store/meta/task-shared-meta-reducers/saved-location-shared.reducer.ts | Cascading deletes |
android/.../GeofencePlugin.kt | Android native geofencing |
android/.../GeofenceBroadcastReceiver.kt | Android fence event handler |
ios/App/App/GeofencePlugin.swift | iOS native geofencing |
| File | Change |
|---|---|
packages/shared-schema/src/entity-types.ts | Add 'SAVED_LOCATION' |
src/app/op-log/core/action-types.enum.ts | Add SAVED_LOCATION_ADD/UPDATE/DELETE |
src/app/op-log/core/entity-registry.ts | Add SAVED_LOCATION config |
src/app/op-log/model/model-config.ts | Add to AllModelConfig + MODEL_CONFIGS |
src/app/root-store/root-state.ts | Add to RootState |
src/app/root-store/feature-stores.module.ts | Register feature store |
src/app/root-store/meta/meta-reducer-registry.ts | Register cascading delete in Phase 5 |
src/app/features/tasks/task.model.ts | Add locationReminderId field |
src/app/features/config/global-config.model.ts | Add isLocationRemindersEnabled |
src/app/features/config/default-global-config.const.ts | Set default false |
src/app/core/platform/platform-capabilities.model.ts | Add geofencing capability |
src/app/features/reminder/reminder.service.ts | Add locationReminders$ subject |
src/app/features/reminder/reminder.module.ts | Merge location reminders into dialog flow |
android/app/src/main/AndroidManifest.xml | Add location permissions |
android/.../CapacitorMainActivity.kt | Register GeofencePlugin |
locationReminderId cleared on tasksnpm run lint, npm run prettier, npm test