docs: add TODAY_TAG architecture and improve timing constants docs

- Create docs/ai/today-tag-architecture.md documenting the virtual tag
  pattern, explaining why TODAY_TAG must never be in task.tagIds and
  how membership is determined by task.dueDay instead
- Add reference section to operation-log.const.ts pointing to related
  timing constants in other domain files (sync.const.ts, meta-sync)

This addresses documentation gaps identified in the maintainability review.
This commit is contained in:
Johannes Millan 2025-12-27 19:13:56 +01:00
parent 35e991e8f4
commit 402c67a5b1
2 changed files with 194 additions and 0 deletions

View file

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

View file

@ -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
*/
/**