super-productivity/docs/long-term-plans/location-based-reminders.md
Johannes Millan 01e30b9c7e
Feat/to me it looks like there are lots of 60dd04 (#7280)
* fix(issue): prevent crash from orphan issueProviderId (#7135)

The Jira image-headers effect in task-detail-panel subscribed to
selectIssueProviderById without an error handler, so a task with an
issueProviderId pointing at a deleted provider (e.g. after sync
convergence where taskIdsToUnlink didn't cover all local tasks)
propagated the selector throw to Zone.js as a crash dialog. Wrap the
inner selector observable in catchError that logs and falls back to
of(null); the downstream jiraCfg?.isEnabled guard handles the fallback.

Also drop IssueLog.log(issueProviderKey, issueProvider) from the
throwing variant of the selector: providers may carry credentials
(host, token, apiKey) and IssueLog history is exportable.

* fix(focus-mode): sync tray countdown with in-app timer during breaks

Tray title was rebuilt from a cached currentFocusSessionTime that only
refreshed when CURRENT_TASK_UPDATED fired. addTimeSpent is gated on an
active current task, so during focus-mode breaks or task-less focus
sessions the cache froze while the in-app timer kept ticking.

Add the tick action to taskChangeElectron$ so the cache refreshes every
second whenever the focus timer is running.

Fixes #7278

* fix(ci): restore GitHub Actions SHA pins undone by 0e9218bd68

Commit 0e9218bd68 silently reverted PR #7212 (github-actions-minor group
bump) along with its stated sync/client-id work. This restores the 15
workflow files to their pre-revert state.

Actions restored to newer pinned SHAs:
- actions/upload-artifact v7.0.0 -> v7.0.1
- step-security/harden-runner v2.16.1 -> v2.17.0
- softprops/action-gh-release v2.6.1 -> v3.0.0
- signpath/github-action-submit-signing-request v2.0 -> v2.1
- anthropics/claude-code-action v1.0.89 -> v1.0.93
- docker/build-push-action v7.0.0 -> v7.1.0
- easingthemes/ssh-deploy v5.1.1 -> v6.0.3

* fix: restore i18n, UI, and docs work undone by 0e9218bd68

Commit 0e9218bd68 silently reverted the following work alongside its stated
sync/client-id changes. Files where later master commits (fec7b25f23, etc.)
already re-applied the reverted work are intentionally left untouched.

Restored:
- #7232 docs/long-term-plans/location-based-reminders.md (513 lines)
- #7199 Romanian i18n phase 3 (ro.json + ro-md.json, ~1168 lines)
- #7049 Polish translation improvements
- #7143 planner component styling (4 scss files)
- #7211 add-task-bar preserve time estimate when typing title
- #7208 task.reducer roll-up estimates for added subtasks
- #6767 focus-mode pomodoro reset button
- #7205 plugin-dev github-issue-provider TOKEN description
- #7231 mobile-bottom-nav FAB fix
- a4fe03272 iOS keyboard accessory bar (global-theme + dialog-fullscreen-markdown)
- 309670db3 ShortSyntaxEffects undefined guard
- 667a7986f Dropbox PKCE auth comment/behavior

* fix(electron): restore electron + e2e work undone by 0e9218bd68

Commit 0e9218bd68 silently reverted the following electron/e2e work.
Files already re-fixed by later master commits are left as-is:
- e2e/tests/sync/supersync-archive-conflict.spec.ts (de33234976 + 191d129ff3)

Restored:
- e8a3e156eb fix(electron): Linux autostart IDB backing-store recovery
  (re-adds electron/clear-stale-idb-locks.ts + start-app.ts wiring)
- 5ce78a5b63 fix(electron): macOS Cmd+Q / Dock > Quit hang
  (setIsQuiting + before-quit delegate to close-handler)
- 46e0fa2d01 fix(sync): FILE_SYNC_LIST_FILES IPC contract
  (electronAPI.d.ts + local-file-sync + preload + ipc-events)
- ea1ef16307 fix(android): session-only SAF permissions on OEM devices
- 8865dc0a50 test(e2e): supersync parallel-worker stampede guard
  (SUPERSYNC_SERVER_HEALTHY env-var fallback + goto retry loop)
- af7c7687e2 test(e2e): block WS-triggered downloads in non-WS specs
- 265b44db5d test(e2e): premature waitForURL on daily-summary
  (this is literally the fix the bad commit's message claimed to add)

Conflict resolutions:
- e2e/utils/supersync-helpers.ts: kept the refined getDoneTaskElement
  checks from d64014d086 (later than c558bcab5e) while restoring the
  goto retry loop from 8865dc0a50.
- electron/start-app.ts: unioned imports (setIsQuiting +
  clearStaleLevelDbLocks from theirs, fs from ours).

* fix(sync): restore sync-core work undone by 0e9218bd68

Commit 0e9218bd68 silently reverted parts of several sync fixes. Most
sync-core reverts have already been re-addressed differently on master
by later commits (1f5184f6e7, 05cd875dd6, 09f5ced2c9, 7df43358ab,
d9158d6adb, 32dbc95ed9, 8c3b08e016, f89fe1ebc3) — those files are
intentionally left untouched to avoid reverting master's newer work.

This PR restores only the pieces that are genuinely still missing:

- e8a3e156eb fix(electron): IDB backing-store autoreload (in-app piece)
  operation-log-hydrator.service.ts + .spec.ts (the electron/clear-
  stale-idb-locks.ts piece was restored in the prior commit)

Plus three low-risk documentation/cleanup restorations:
- operation-sync.util.ts — add "Nextcloud" to isFileBasedProvider JSDoc
- dropbox.ts — restore improved _getRedirectUri JSDoc (667a7986fb)
- dialog-get-and-enter-auth-code.component.ts — restore isNativePlatform
  comment explaining why manual code entry flow is used (667a7986fb)
- file-adapter.interface.ts — remove stray "// NEW" comment (46e0fa2d01)

Intentionally NOT restored (master's newer work covers or supersedes):
- sync-trigger.service.ts / sync.effects.ts (05cd875dd6)
- sync-wrapper.service.ts (1f5184f6e7)
- sync-errors.ts (1f5184f6e7 re-added LegacySyncFormatDetectedError)
- file-based-sync-adapter.service.ts + spec (1f5184f6e7 + d9158d6adb)
- file-based-sync.types.ts (1f5184f6e7)
- operation-log.const.ts (32dbc95ed9 bumped IDB_OPEN_RETRIES to 5)
- dialog-sync-initial-cfg.component.ts (f89fe1ebc3)
2026-04-20 12:04:38 +02:00

19 KiB

Location-Based Reminders — Design Document

Status: Planned (Brainstorm)

Overview

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.

Decisions

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.

1. Data Model

1.1 SavedLocation Entity

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>;

1.2 Task Model Changes

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;

1.3 Config Changes

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,

1.4 Platform Capabilities

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

2. Sync & Persistence Registration

All registration steps required for the new entity, following existing patterns:

2.1 Entity Type

File: packages/shared-schema/src/entity-types.ts

Add 'SAVED_LOCATION' to the ENTITY_TYPES array.

2.2 Action Types Enum

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.

2.3 Entity Registry

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,
},

2.4 Model Config

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,
},

2.5 Root State

File: src/app/root-store/root-state.ts

[SAVED_LOCATION_FEATURE_NAME]: SavedLocationState;

2.6 Feature Store Registration

File: src/app/root-store/feature-stores.module.ts

StoreModule.forFeature(SAVED_LOCATION_FEATURE_NAME, savedLocationReducer),

2.7 Cascading Delete Meta-Reducer

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.


3. NgRx Store

3.1 Actions

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.

3.2 Reducer

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();

3.3 Selectors

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

3.4 Service

File: src/app/features/saved-location/saved-location.service.ts

Thin wrapper dispatching actions to the store. Methods: addSavedLocation(), updateSavedLocation(), deleteSavedLocation(), getById$().


4. Geofencing Service

4.1 Architecture

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

4.2 Why a Custom Capacitor Plugin

@capacitor/geolocation only provides one-time and continuous position reads — it does not support geofencing. Native geofencing requires:

  • Android: com.google.android.gms.location.GeofencingClient (Google Play Services). Supports up to 100 geofences. Fires BroadcastReceiver on enter/exit.
  • iOS: 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.

4.3 Lifecycle Rules

Register geofence when:

  • Feature is enabled + a task gets locationReminderId assigned
  • App starts with existing location-reminded tasks

Unregister geofence when:

  • Task is completed, deleted, or locationReminderId cleared
  • SavedLocation is deleted
  • No more undone tasks reference that location
  • Feature is disabled

iOS 20-region limit: Only register geofences for the 20 locations with the most active tasks. Re-evaluate when tasks change.

4.4 Effects

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.

4.5 Background Behavior

When geofence fires while app is in background:

  • Android: BroadcastReceiver shows native notification directly (same pattern as ReminderAlarmReceiver). Tapping opens app + reminder dialog.
  • iOS: CLLocationManager delegate fires local notification. Tapping opens app + reminder dialog.

4.6 Permission Flow

On first use:

  1. Check CapacitorPlatformService.hasCapability('geofencing')
  2. Request ACCESS_FINE_LOCATION + ACCESS_BACKGROUND_LOCATION (Android) or "Always" location access (iOS)
  3. If denied: feature degrades to display-only, show explanation

5. Reminder Integration

5.1 Integration Point

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.

5.2 Data Shape

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.

5.3 Dialog UX Changes

The existing DialogViewTaskRemindersComponent works as-is with minor additions:

  • Show location name/icon in header (e.g., "At: Grocery Store")
  • Snooze = suppress this location reminder for 1 hour (temporary geofence pause)
  • Dismiss = clear locationReminderId on the task
  • Done = mark task complete (same as today)
  • Hide "Edit Reminder" time picker for location-triggered reminders

5.4 Native Notifications (Background)

Reuse CapacitorReminderService patterns:

  • Android: native notification via ReminderNotificationHelper triggered from geofence BroadcastReceiver
  • iOS: LocalNotifications.schedule() triggered from CLLocationManager delegate
  • Both support Done/Snooze action buttons

6. UI Components

6.1 New Components

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")

6.2 Modified Components

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

6.3 Location Picker UX

┌─────────────────────────────────┐
│  Set Location Reminder          │
├─────────────────────────────────┤
│                                 │
│  📍 Use Current Location        │  ← gets GPS, prompts for label
│                                 │
│  ─── Saved Locations ────────── │
│                                 │
│  🏠 Home                        │
│  🏢 Office                      │
│  🛒 Grocery Store               │
│                                 │
│  [ Remove Location ] [ Cancel ] │
└─────────────────────────────────┘

6.4 Settings Section

┌─────────────────────────────────┐
│  Location Reminders             │
├─────────────────────────────────┤
│  [Toggle] Enable location       │
│          reminders              │
│                                 │
│  Saved Locations:               │
│  ┌─────────────────────────┐    │
│  │ 🏠 Home     200m  [✏️][🗑]│    │
│  │ 🏢 Office   150m  [✏️][🗑]│    │
│  │ 🛒 Grocery  200m  [✏️][🗑]│    │
│  └─────────────────────────┘    │
│  [ + Add Location ]            │
└─────────────────────────────────┘

7. Cross-Platform Behavior

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

8. File Structure

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

9. All Files to Touch

New Files

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

Modified Files

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

10. Future Enhancements (Out of Scope)

  • Map picker with OpenStreetMap tiles
  • Address search / geocoding (Nominatim)
  • Leave triggers ("remind when I leave the office")
  • Time + location combos ("at the store, but only after 5 PM")
  • Project default locations
  • Location-based task views ("show tasks for where I am now")
  • WiFi-based triggers (alternative to GPS, works indoors)
  • Bluetooth beacon triggers

11. Verification Plan

  1. Unit tests: SavedLocation reducer, selectors, service, cascading delete meta-reducer
  2. Manual mobile testing:
    • Create location from "Use current location"
    • Assign to a task, verify geofence registration
    • Move in/out of geofence, verify notification fires
    • Complete task, verify geofence unregistered
    • Delete location, verify locationReminderId cleared on tasks
  3. Desktop/web: Verify location label displays on tasks, no geofencing attempted
  4. Sync: Create location on device A, verify it appears on device B
  5. Lint/format: npm run lint, npm run prettier, npm test