diff --git a/docs/ai/today-tag-architecture.md b/docs/ai/today-tag-architecture.md new file mode 100644 index 000000000..0f4c9cce7 --- /dev/null +++ b/docs/ai/today-tag-architecture.md @@ -0,0 +1,184 @@ +# TODAY_TAG Architecture + +## Overview + +TODAY_TAG is a **virtual tag** that behaves differently from regular tags. Understanding this distinction is critical for correct implementation of features involving today's tasks. + +## Key Invariant + +**TODAY_TAG (ID: `'TODAY'`) must NEVER be added to `task.tagIds`.** + +Membership in TODAY_TAG is determined by `task.dueDay`, not by `task.tagIds`. The `TODAY_TAG.taskIds` field stores only the **ordering** of today's tasks, not membership. + +## Virtual Tag vs. Board-Style Pattern + +| Aspect | TODAY_TAG (Virtual) | Regular Tags (Board-Style) | +| ----------------------- | ----------------------- | ----------------------------- | +| **Membership source** | `task.dueDay === today` | `task.tagIds.includes(tagId)` | +| **Stored on task** | `task.dueDay` | `task.tagIds` | +| **In task.tagIds?** | NO (invariant) | YES (required) | +| **tag.taskIds purpose** | Ordering only | Ordering only | +| **Use case** | Time-based grouping | Category/label grouping | + +## Why This Pattern? + +1. **Uniform move operations**: Drag/drop and keyboard shortcuts (Ctrl+↑/↓) work identically for TODAY_TAG and regular tags because all tags store ordering in `tag.taskIds`. + +2. **Single source of truth**: `task.dueDay` is the canonical field for "is this task scheduled for today?" - no dual bookkeeping between `dueDay` and a hypothetical `tagIds` entry. + +3. **Planner integration**: The planner view uses `task.dueDay` to organize tasks by day. TODAY_TAG naturally aligns with this. + +4. **Self-healing**: Stale ordering entries are gracefully filtered out by the selector. No manual cleanup needed when a task's `dueDay` changes. + +## Implementation Details + +### Definition + +**File:** `src/app/features/tag/tag.const.ts` + +```typescript +export const TODAY_TAG: Tag = { + id: 'TODAY', + title: 'Today', + icon: 'wb_sunny', + // ... theme, colors +}; +``` + +### Membership Computation + +**File:** `src/app/features/work-context/store/work-context.selectors.ts` + +The `computeOrderedTaskIdsForToday()` function: + +1. Finds all tasks where `dueDay === today` (membership) +2. Orders them according to `TODAY_TAG.taskIds` (ordering) +3. Appends any unordered tasks at the end (self-healing) +4. Filters out stale entries from `TODAY_TAG.taskIds` (self-healing) + +```typescript +const computeOrderedTaskIdsForToday = (todayTag, taskEntities) => { + const todayStr = getDbDateStr(); + + // Membership: tasks where dueDay === today + const tasksForToday = Object.values(taskEntities) + .filter((t) => t && !t.parentId && t.dueDay === todayStr) + .map((t) => t.id); + + // Ordering: use TODAY_TAG.taskIds, filter stale, append missing + const storedOrder = todayTag?.taskIds || []; + const tasksForTodaySet = new Set(tasksForToday); + + const orderedTasks = storedOrder.filter((id) => tasksForTodaySet.has(id)); + const unorderedTasks = tasksForToday.filter((id) => !storedOrder.includes(id)); + + return [...orderedTasks, ...unorderedTasks]; +}; +``` + +### Move Operations + +**File:** `src/app/features/tag/store/tag.reducer.ts` + +All move operations (`moveTaskInTodayList`, `moveTaskUpInTodayList`, etc.) update `tag.taskIds` uniformly for ALL tags, including TODAY_TAG: + +```typescript +on(moveTaskInTodayList, (state, { taskId, afterTaskId, workContextId }) => { + const tag = state.entities[workContextId]; + const taskIds = moveItemAfterAnchor(taskId, afterTaskId, tag.taskIds); + return tagAdapter.updateOne({ id: workContextId, changes: { taskIds } }, state); +}), +``` + +### Ensuring the Invariant + +**File:** `src/app/root-store/meta/task-shared-meta-reducers/task-shared-helpers.ts` + +Helper functions enforce the invariant: + +```typescript +export const filterOutTodayTag = (tagIds: string[]): string[] => + tagIds.filter((id) => id !== TODAY_TAG.id); + +export const hasInvalidTodayTag = (tagIds: string[]): boolean => + tagIds.includes(TODAY_TAG.id); +``` + +**File:** `src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts` + +The planner meta-reducer: + +- Updates `TODAY_TAG.taskIds` when moving tasks to/from today +- Removes TODAY_TAG from `task.tagIds` if present (cleanup legacy data) +- Never adds TODAY_TAG to `task.tagIds` + +### Consistency Repair + +**File:** `src/app/features/tag/store/tag.effects.ts` + +The `repairTodayTagConsistency$` effect detects and repairs inconsistencies after sync: + +```typescript +repairTodayTagConsistency$ = createEffect(() => + this._store$.select(selectTodayTagRepair).pipe( + skipDuringSyncWindow(), // Don't fire during sync replay + filter((repair) => repair?.needsRepair), + map((repair) => + updateTag({ + tag: { id: TODAY_TAG.id, changes: { taskIds: repair.repairedTaskIds } }, + isSkipSnack: true, + }), + ), + ), +); +``` + +This handles state divergence from per-entity conflict resolution during sync. + +## Common Mistakes to Avoid + +### Wrong: Adding TODAY_TAG to task.tagIds + +```typescript +// WRONG - Never do this +task.tagIds = [...task.tagIds, TODAY_TAG.id]; +``` + +### Correct: Set task.dueDay + +```typescript +// CORRECT - Set dueDay to add to today +task.dueDay = getDbDateStr(); // Today's date string +``` + +### Wrong: Checking tagIds for TODAY membership + +```typescript +// WRONG - This will always be false (or indicates legacy bug) +const isToday = task.tagIds.includes(TODAY_TAG.id); +``` + +### Correct: Check dueDay + +```typescript +// CORRECT - Check dueDay for TODAY membership +const isToday = task.dueDay === getDbDateStr(); +``` + +## Key Files Reference + +| File | Purpose | +| ----------------------------------------------------------------------------- | --------------------------------------------- | +| `src/app/features/tag/tag.const.ts` | TODAY_TAG definition | +| `src/app/features/work-context/store/work-context.selectors.ts` | Membership computation, repair selector | +| `src/app/features/tag/store/tag.reducer.ts` | Move operations (uniform for all tags) | +| `src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts` | Multi-entity updates for planner actions | +| `src/app/root-store/meta/task-shared-meta-reducers/task-shared-helpers.ts` | `filterOutTodayTag()`, `hasInvalidTodayTag()` | +| `src/app/features/tag/store/tag.effects.ts` | `repairTodayTagConsistency$` effect | + +## Testing + +Tests demonstrating the virtual tag pattern: + +- `src/app/features/work-context/store/work-context.selectors.spec.ts` - Membership computation tests +- `src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts` - Move action tests diff --git a/src/app/op-log/core/operation-log.const.ts b/src/app/op-log/core/operation-log.const.ts index dc4da6b6b..a8e89a5ab 100644 --- a/src/app/op-log/core/operation-log.const.ts +++ b/src/app/op-log/core/operation-log.const.ts @@ -3,6 +3,16 @@ import { InjectionToken } from '@angular/core'; /** * Configuration constants for the Operation Log system. * Centralizes all tunable parameters for easier maintenance and documentation. + * + * ## Related Timing Constants in Other Files + * + * **Sync Configuration** (`src/app/imex/sync/sync.const.ts`): + * - SYNC_MIN_INTERVAL (5s) - Minimum interval between sync operations + * - SYNC_WAIT_TIMEOUT_MS (40s) - Max wait for ongoing sync to complete + * - INITIAL_SYNC_DELAY_MS (500ms) - Delay before triggering initial sync + * + * **Meta Sync Lock** (`src/app/pfapi/api/sync/meta-sync.service.ts`): + * - LOCK_TTL_MS (5 min) - Lock TTL for distributed lock on remote metadata */ /**