From 78c65acf4d776bb0eb4a4788baba09bdf95cf940 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 8 Dec 2025 10:40:57 +0100 Subject: [PATCH] test(e2e): add sd:today to tasks for TODAY view visibility Tasks created without explicit dueDay don't appear in TODAY view due to recent selector changes. Add sd:today short syntax to ensure tasks have dueDay set and appear correctly in tests. --- CLAUDE.md | 1 + docs/ai/today-tag-architecture.md | 123 ++++++++++++++++++ .../app-features/focus-mode-feature.spec.ts | 3 +- .../time-tracking-feature.spec.ts | 3 +- .../autocomplete-dropdown.spec.ts | 3 +- .../planner/planner-today-sync.spec.ts | 24 +++- .../features/planner/store/planner.reducer.ts | 4 +- src/app/features/tag/store/tag.reducer.ts | 17 +++ src/app/features/tag/tag.const.ts | 12 ++ .../store/task.reducer.transferTask.spec.ts | 6 +- src/app/features/tasks/task/task.component.ts | 5 +- .../store/work-context.selectors.spec.ts | 104 ++++++++++----- .../store/work-context.selectors.ts | 90 ++++++++++--- .../work-context/work-context.service.ts | 20 +-- .../planner-shared.reducer.spec.ts | 34 +++-- .../planner-shared.reducer.ts | 79 +++++++++-- .../short-syntax-shared.reducer.spec.ts | 37 ++++-- .../short-syntax-shared.reducer.ts | 40 ++++-- .../task-shared-crud.reducer.ts | 8 +- 19 files changed, 494 insertions(+), 119 deletions(-) create mode 100644 docs/ai/today-tag-architecture.md diff --git a/CLAUDE.md b/CLAUDE.md index 286c03a18..4da29e4bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ The app uses NgRx (Redux pattern) for state management. Key state slices: 6. **Privacy**: No analytics or tracking. User data stays local unless explicitly synced. 7. **Effects & Remote Sync**: For NgRx effects that perform side effects (snackbars, external API calls, plugin hooks), use `inject(LOCAL_ACTIONS)` instead of `inject(Actions)`. This filters out remote sync operations where the side effect already happened on the original client. See `src/app/util/local-actions.token.ts` and the architecture docs at `src/app/core/persistence/operation-log/docs/operation-log-architecture.md` (section A.6). 8. **Atomic Multi-Entity Changes**: When one action affects multiple entities (e.g., deleting a tag removes it from tasks), use **meta-reducers** instead of effects to ensure all changes happen in a single reducer pass. This creates one operation in the sync log, preventing partial sync and state inconsistency. See `src/app/root-store/meta/task-shared-meta-reducers/` and Part F in the architecture docs. +9. **TODAY_TAG is a Virtual Tag**: TODAY_TAG (ID: `'TODAY'`) must **NEVER** be added to `task.tagIds`. It's a "virtual tag" where membership is determined by `task.dueDay`, and `TODAY_TAG.taskIds` only stores ordering. This keeps move operations uniform across all tags. See `docs/ai/today-tag-architecture.md`. ## 🚫 Known Anti-Patterns to Avoid diff --git a/docs/ai/today-tag-architecture.md b/docs/ai/today-tag-architecture.md new file mode 100644 index 000000000..e83a62cfa --- /dev/null +++ b/docs/ai/today-tag-architecture.md @@ -0,0 +1,123 @@ +# TODAY_TAG Architecture + +This document explains the dual-system architecture for task ordering in Super Productivity and why TODAY_TAG is a "virtual tag." + +## Overview + +Super Productivity uses two complementary systems for task ordering: + +| System | Purpose | Storage Location | +| ----------------------- | --------------------------- | ------------------------------- | +| `TODAY_TAG.taskIds` | Order tasks for today | `tag.entities['TODAY'].taskIds` | +| `planner.days[dateStr]` | Order tasks for future days | `planner.days` | + +This is intentional, not technical debt. The dual system keeps move operations (drag/drop, keyboard shortcuts) simple and uniform. + +## The "Virtual Tag" Pattern + +TODAY_TAG is special: + +1. **Should NOT be in `task.tagIds`** - Unlike regular tags, TODAY_TAG must never appear in a task's `tagIds` array +2. **Only exists in `tag.taskIds`** - TODAY_TAG.taskIds stores the ordered list of task IDs for today +3. **Acts as a work context** - Users can click TODAY in the sidebar to view today's tasks + +This pattern was enforced in commit `ca08724bd` ("fix(sync): remove TODAY_TAG from task.tagIds"). + +### Why This Pattern? + +Regular tags use a "board-style" pattern: + +- `task.tagIds` = source of truth for membership +- `tag.taskIds` = ordering only + +TODAY_TAG cannot follow this pattern because: + +- Tasks belong to "today" based on `task.dueDay`, not `task.tagIds` +- Adding TODAY_TAG to `task.tagIds` would create sync conflicts +- The planner already tracks day membership via `task.dueDay` + +## Code Paths + +### TODAY_TAG.taskIds is modified by: + +| File | Lines | Purpose | +| ----------------------------------- | ------- | --------------------------------------- | +| `task-shared-scheduling.reducer.ts` | 29-264 | scheduleTaskWithTime, planTasksForToday | +| `planner-shared.reducer.ts` | 91-151 | Transfer to/from today | +| `task-shared-crud.reducer.ts` | 120-156 | Add task with dueDay === today | +| `short-syntax-shared.reducer.ts` | 215-232 | @today syntax | +| `tag.reducer.ts` | 257-361 | Move actions (drag/drop, Ctrl+↑/↓) | + +### planner.days is modified by: + +| File | Lines | Purpose | +| ----------------------------------- | ------- | ------------------------------------------- | +| `planner.reducer.ts` | 155-183 | planTaskForDay (skips today) | +| `planner-shared.reducer.ts` | 47-82 | Transfer between days | +| `task-shared-scheduling.reducer.ts` | 206-226 | Remove from planner when planning for today | + +## Why Not Unify in Planner? + +We considered making `planner.days[todayStr]` the source of truth for today's ordering. However: + +1. **Move handlers would need special routing:** + + ```typescript + // tag.reducer.ts would need: + if (workContextId === TODAY_TAG.id) { + // Route to planner.days[todayStr] + } else { + // Update tag.taskIds + } + ``` + +2. **Current architecture is simpler:** + + ```typescript + // All tags (including TODAY) handled uniformly: + if (workContextType === 'TAG') { + // Update tag.taskIds - works for all tags + } + ``` + +3. **Cost/benefit doesn't favor refactoring:** + - 8+ files would need changes + - Migration required for existing data + - Conceptual benefit only (no functional improvement) + +## Guidance for New Features + +### When adding a task to today: + +1. Set `task.dueDay = getDbDateStr()` +2. Add task ID to `TODAY_TAG.taskIds` (meta-reducer handles this) +3. Do NOT add `TODAY_TAG.id` to `task.tagIds` + +### When moving a task away from today: + +1. Update `task.dueDay` to the new day +2. Remove task ID from `TODAY_TAG.taskIds` +3. Add to `planner.days[newDay]` if not today + +### When implementing move/reorder operations: + +- For TODAY context: operations go through `tag.reducer.ts` like any other tag +- The selector `selectTodayTaskIds` computes the ordered list + +## Key Selectors + +```typescript +// Get ordered today task IDs (uses board-style pattern internally) +selectTodayTaskIds; + +// Get active work context (TODAY_TAG or project) +selectActiveWorkContext; +``` + +## Related Files + +- `src/app/features/tag/tag.const.ts` - TODAY_TAG definition +- `src/app/features/planner/store/planner.reducer.ts` - Planner state +- `src/app/features/tag/store/tag.reducer.ts` - Tag state and move handlers +- `src/app/features/work-context/store/work-context.selectors.ts` - Work context selectors +- `src/app/root-store/meta/task-shared-meta-reducers/` - Meta-reducers for atomic updates diff --git a/e2e/tests/app-features/focus-mode-feature.spec.ts b/e2e/tests/app-features/focus-mode-feature.spec.ts index fb81848c3..eedb07a5d 100644 --- a/e2e/tests/app-features/focus-mode-feature.spec.ts +++ b/e2e/tests/app-features/focus-mode-feature.spec.ts @@ -24,8 +24,9 @@ test.describe('App Features - Focus Mode', () => { }); // Wait for task list and add a task + // Use sd:today to set dueDay so task appears in TODAY view await workViewPage.waitForTaskList(); - await workViewPage.addTask('TestTask'); + await workViewPage.addTask('TestTask sd:today'); await expect(firstTask).toBeVisible(); // Go to settings page diff --git a/e2e/tests/app-features/time-tracking-feature.spec.ts b/e2e/tests/app-features/time-tracking-feature.spec.ts index 0b8056cae..8e8d94a1d 100644 --- a/e2e/tests/app-features/time-tracking-feature.spec.ts +++ b/e2e/tests/app-features/time-tracking-feature.spec.ts @@ -19,8 +19,9 @@ test.describe('App Features - Time Tracking', () => { }); // Wait for task list and add a task + // Use sd:today to set dueDay so task appears in TODAY view await workViewPage.waitForTaskList(); - await workViewPage.addTask('TestTask'); + await workViewPage.addTask('TestTask sd:today'); await expect(firstTask).toBeVisible(); // Go to settings page diff --git a/e2e/tests/autocomplete/autocomplete-dropdown.spec.ts b/e2e/tests/autocomplete/autocomplete-dropdown.spec.ts index f32a446c9..490ace785 100644 --- a/e2e/tests/autocomplete/autocomplete-dropdown.spec.ts +++ b/e2e/tests/autocomplete/autocomplete-dropdown.spec.ts @@ -9,7 +9,8 @@ test.describe('Autocomplete Dropdown', () => { await workViewPage.waitForTaskList(); // Add task with tag syntax, skipClose=true to keep input open - await workViewPage.addTask('some task <3 #basicTag', true); + // Use sd:today to set dueDay so task appears in TODAY view + await workViewPage.addTask('some task <3 #basicTag sd:today', true); // Small delay to let the tag creation dialog appear await page.waitForTimeout(500); diff --git a/src/app/features/planner/planner-today-sync.spec.ts b/src/app/features/planner/planner-today-sync.spec.ts index fd6bbb677..97a516cef 100644 --- a/src/app/features/planner/planner-today-sync.spec.ts +++ b/src/app/features/planner/planner-today-sync.spec.ts @@ -187,10 +187,11 @@ describe('Planner Today Sync Integration', () => { }); it('should remove task from TODAY tag when planning for future day', (done) => { - const task = createMockTask(); + const todayStr = getDbDateStr(); + const task = createMockTask({ dueDay: todayStr }); // Task initially scheduled for today const futureDay = '2025-12-25'; - // Set initial state with task in TODAY + // Set initial state with task in TODAY (has dueDay = today) currentState = { ...initialState, tag: { @@ -211,6 +212,10 @@ describe('Planner Today Sync Integration', () => { }; store.setState(currentState); + // Override selector to return task in TODAY initially + store.overrideSelector(selectTodayTaskIds, [task.id]); + store.refreshState(); + // Dispatch planTaskForDay action for future day store.dispatch( PlannerActions.planTaskForDay({ @@ -221,6 +226,7 @@ describe('Planner Today Sync Integration', () => { ); // Simulate meta-reducer behavior: remove task from TODAY tag when planning for future day + // Also update task.dueDay to the future day currentState = { ...currentState, tag: { @@ -233,9 +239,23 @@ describe('Planner Today Sync Integration', () => { }, }, }, + task: { + ...currentState.task, + entities: { + ...currentState.task.entities, + [task.id]: { + ...task, + dueDay: futureDay, // Virtual tag pattern: membership determined by dueDay + }, + }, + }, }; store.setState(currentState); + // Override selector to return empty array after removal + store.overrideSelector(selectTodayTaskIds, []); + store.refreshState(); + // Check that task was removed from TODAY tag store .select(selectTodayTaskIds) diff --git a/src/app/features/planner/store/planner.reducer.ts b/src/app/features/planner/store/planner.reducer.ts index e6668c35a..508564b96 100644 --- a/src/app/features/planner/store/planner.reducer.ts +++ b/src/app/features/planner/store/planner.reducer.ts @@ -166,7 +166,9 @@ export const plannerReducer = createReducer( ...state, days: { ...daysCopy, - // Only add to planner days if NOT today (today is managed by today tag) + // Today's ordering is managed by TODAY_TAG.taskIds, not planner.days. + // This dual-system design keeps move operations (drag/drop, Ctrl+↑/↓) + // uniform for all tags. See: docs/ai/today-tag-architecture.md ...(isPlannedForToday ? {} : { diff --git a/src/app/features/tag/store/tag.reducer.ts b/src/app/features/tag/store/tag.reducer.ts index dafd83ee6..95cab4118 100644 --- a/src/app/features/tag/store/tag.reducer.ts +++ b/src/app/features/tag/store/tag.reducer.ts @@ -1,3 +1,16 @@ +/** + * Tag Reducer + * + * IMPORTANT: TODAY_TAG is a "Virtual Tag" + * ----------------------------------------- + * TODAY_TAG (ID: 'TODAY') is handled specially: + * - Should NEVER be in task.tagIds - membership is determined by task.dueDay + * - TODAY_TAG.taskIds only stores ordering for the today list + * - Move handlers below work uniformly for ALL tags including TODAY + * + * This pattern keeps move operations (drag/drop, Ctrl+↑/↓) simple. + * See: docs/ai/today-tag-architecture.md + */ import { createEntityAdapter, EntityAdapter } from '@ngrx/entity'; import { Tag, TagState } from '../tag.model'; import { createFeatureSelector, createReducer, createSelector, on } from '@ngrx/store'; @@ -254,6 +267,10 @@ export const tagReducer = createReducer( // REGULAR ACTIONS // -------------------- + // Move handlers work uniformly for ALL tags, including TODAY_TAG. + // This is why we keep TODAY_TAG.taskIds separate from planner.days - + // it allows drag/drop and keyboard shortcuts (Ctrl+↑/↓) to use the same + // code path for today as for any other tag. See: docs/ai/today-tag-architecture.md on( moveTaskInTodayList, ( diff --git a/src/app/features/tag/tag.const.ts b/src/app/features/tag/tag.const.ts index fdaaf6e6b..edcadc7a1 100644 --- a/src/app/features/tag/tag.const.ts +++ b/src/app/features/tag/tag.const.ts @@ -6,6 +6,18 @@ import { WORK_CONTEXT_DEFAULT_THEME, } from '../work-context/work-context.const'; +/** + * TODAY_TAG is a "virtual tag" - it behaves differently from regular tags: + * + * 1. Should NOT be in task.tagIds - membership is determined by task.dueDay + * 2. TODAY_TAG.taskIds stores the ordering of today's tasks + * 3. Move operations (drag/drop, Ctrl+↑/↓) work uniformly via tag.reducer.ts + * + * This pattern keeps the work context system simple while separating + * "today" ordering (TODAY_TAG.taskIds) from future days (planner.days). + * + * See: docs/ai/today-tag-architecture.md + */ export const TODAY_TAG: Tag = { color: null, created: Date.now(), diff --git a/src/app/features/tasks/store/task.reducer.transferTask.spec.ts b/src/app/features/tasks/store/task.reducer.transferTask.spec.ts index b6d8b3ed8..8f9e3fd00 100644 --- a/src/app/features/tasks/store/task.reducer.transferTask.spec.ts +++ b/src/app/features/tasks/store/task.reducer.transferTask.spec.ts @@ -167,13 +167,15 @@ describe('Task Reducer - transferTask action', () => { const result = reducerWithMetaReducers(rootState, action) as RootState; const taskState = result[TASK_FEATURE_NAME]; - // Board-style pattern: task.tagIds is updated to include TODAY when transferring to today + // Virtual tag pattern: TODAY_TAG should NOT be in task.tagIds + // Membership is determined by task.dueDay, not tagIds expect(taskState.entities.task1).toEqual({ ...task, dueDay: today, dueWithTime: undefined, - tagIds: [TODAY_TAG.id], + // tagIds should NOT contain TODAY (virtual tag pattern) }); + expect(taskState.entities.task1!.tagIds).not.toContain(TODAY_TAG.id); }); it('should not affect other tasks when transferring', () => { diff --git a/src/app/features/tasks/task/task.component.ts b/src/app/features/tasks/task/task.component.ts index a98e77c5c..d9dabc84d 100644 --- a/src/app/features/tasks/task/task.component.ts +++ b/src/app/features/tasks/task/task.component.ts @@ -299,10 +299,11 @@ export class TaskComponent implements OnDestroy, AfterViewInit { } ngAfterViewInit(): void { - // TODO remove + // Dev-time sanity check: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern) + // Membership is determined by task.dueDay. See: docs/ai/today-tag-architecture.md if (!environment.production) { if (this.task().tagIds.includes(TODAY_TAG.id)) { - throw new Error('Task should not have today tag'); + throw new Error('Task should not have TODAY_TAG in tagIds - it is a virtual tag'); } } diff --git a/src/app/features/work-context/store/work-context.selectors.spec.ts b/src/app/features/work-context/store/work-context.selectors.spec.ts index ca02d1bc8..59fa2183f 100644 --- a/src/app/features/work-context/store/work-context.selectors.spec.ts +++ b/src/app/features/work-context/store/work-context.selectors.spec.ts @@ -10,8 +10,19 @@ import { } from './work-context.selectors'; import { WorkContext, WorkContextType } from '../work-context.model'; import { TaskCopy } from '../../tasks/task.model'; +import { getDbDateStr } from '../../../util/get-db-date-str'; +/** + * Tests for work-context selectors. + * + * IMPORTANT: TODAY_TAG is a "virtual tag" - membership is determined by task.dueDay, + * NOT by task.tagIds. TODAY_TAG.taskIds only stores ordering. + * See: docs/ai/today-tag-architecture.md + */ describe('workContext selectors', () => { + // Get today's date string for tests + const todayStr = getDbDateStr(); + describe('selectActiveWorkContext', () => { it('should select today tag', () => { const result = selectActiveWorkContext.projector( @@ -55,27 +66,31 @@ describe('workContext selectors', () => { ); }); - it('should use board-style pattern for tags (task.tagIds as source of truth)', () => { + it('should use dueDay for TODAY_TAG membership (virtual tag pattern)', () => { + // TODAY_TAG uses dueDay for membership, not tagIds const task1 = { id: 'task1', - tagIds: [TODAY_TAG.id], + tagIds: [], // TODAY_TAG should NOT be in tagIds + dueDay: todayStr, // This determines TODAY membership subTaskIds: [], } as Partial as TaskCopy; const task2 = { id: 'task2', - tagIds: [TODAY_TAG.id], + tagIds: [], // TODAY_TAG should NOT be in tagIds + dueDay: todayStr, // This determines TODAY membership subTaskIds: [], } as Partial as TaskCopy; - const taskNotInTag = { + const taskNotForToday = { id: 'task3', - tagIds: [], // Does NOT have TODAY tag + tagIds: [], + dueDay: '2000-01-01', // Not today subTaskIds: [], } as Partial as TaskCopy; - // Tag has stale taskIds including task3 which doesn't have the tag + // Tag has stale taskIds including task3 which doesn't have dueDay === today const todayTagWithStaleIds = { ...TODAY_TAG, - taskIds: ['task3', 'task1'], // task3 is stale, task2 is missing + taskIds: ['task3', 'task1'], // task3 is stale (wrong dueDay), task2 is missing }; const result = selectActiveWorkContext.projector( @@ -85,7 +100,7 @@ describe('workContext selectors', () => { } as any, fakeEntityStateFromArray([]), fakeEntityStateFromArray([todayTagWithStaleIds]), - fakeEntityStateFromArray([task1, task2, taskNotInTag]) as any, + fakeEntityStateFromArray([task1, task2, taskNotForToday]) as any, [], ); @@ -97,13 +112,15 @@ describe('workContext selectors', () => { it('should select tasks for project', () => { const M1 = { id: 'M1', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], } as Partial as TaskCopy; const M2 = { id: 'M2', subTaskIds: [], - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, dueWithTime: 1234, reminderId: 'asd', } as Partial as TaskCopy; @@ -116,13 +133,14 @@ describe('workContext selectors', () => { fakeEntityStateFromArray([M2, M1]).entities, ); expect(result).toEqual([ - { id: 'M1', subTaskIds: [], tagIds: ['TODAY'] }, + { id: 'M1', subTaskIds: [], tagIds: [], dueDay: todayStr }, { id: 'M2', dueWithTime: 1234, reminderId: 'asd', subTaskIds: [], - tagIds: ['TODAY'], + tagIds: [], + dueDay: todayStr, }, ] as any[]); }); @@ -132,14 +150,16 @@ describe('workContext selectors', () => { it('should select tasks for project', () => { const M1 = { id: 'M1', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], isDone: true, } as Partial as TaskCopy; const M2 = { id: 'M2', subTaskIds: [], - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, dueWithTime: 1234, } as Partial as TaskCopy; @@ -149,7 +169,8 @@ describe('workContext selectors', () => { id: 'M2', dueWithTime: 1234, subTaskIds: [], - tagIds: ['TODAY'], + tagIds: [], + dueDay: todayStr, }, ] as Partial[] as TaskCopy[]); }); @@ -188,8 +209,8 @@ describe('workContext selectors', () => { }); }); - describe('selectTodayTaskIds (board-style pattern)', () => { - it('should return empty array when TODAY tag has no tasks', () => { + describe('selectTodayTaskIds (virtual tag pattern - uses dueDay)', () => { + it('should return empty array when no tasks have dueDay === today', () => { const tagState = fakeEntityStateFromArray([TODAY_TAG]); const taskState = fakeEntityStateFromArray([]) as any; @@ -197,15 +218,18 @@ describe('workContext selectors', () => { expect(result).toEqual([]); }); - it('should return tasks in stored order when all tasks have TODAY tag', () => { + it('should return tasks in stored order when all tasks have dueDay === today', () => { + // TODAY_TAG membership is determined by dueDay, not tagIds const task1 = { id: 'task1', - tagIds: [TODAY_TAG.id], + tagIds: [], // TODAY_TAG should NOT be in tagIds + dueDay: todayStr, subTaskIds: [], } as Partial as TaskCopy; const task2 = { id: 'task2', - tagIds: [TODAY_TAG.id], + tagIds: [], // TODAY_TAG should NOT be in tagIds + dueDay: todayStr, subTaskIds: [], } as Partial as TaskCopy; @@ -221,15 +245,17 @@ describe('workContext selectors', () => { expect(result).toEqual(['task1', 'task2']); }); - it('should filter out stale taskIds (tasks that no longer have TODAY tag)', () => { + it('should filter out stale taskIds (tasks that no longer have dueDay === today)', () => { const task1 = { id: 'task1', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], } as Partial as TaskCopy; const task2 = { id: 'task2', - tagIds: [], // Removed from TODAY tag + tagIds: [], + dueDay: '2000-01-01', // No longer today subTaskIds: [], } as Partial as TaskCopy; @@ -245,15 +271,17 @@ describe('workContext selectors', () => { expect(result).toEqual(['task1']); }); - it('should auto-add tasks with TODAY tag but not in stored order', () => { + it('should auto-add tasks with dueDay === today but not in stored order', () => { const task1 = { id: 'task1', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], } as Partial as TaskCopy; const task2 = { id: 'task2', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], } as Partial as TaskCopy; @@ -272,12 +300,14 @@ describe('workContext selectors', () => { it('should exclude subtasks', () => { const parentTask = { id: 'parent', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: ['subtask1'], } as Partial as TaskCopy; const subtask = { id: 'subtask1', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], parentId: 'parent', } as Partial as TaskCopy; @@ -299,19 +329,22 @@ describe('workContext selectors', () => { it('should filter out done tasks', () => { const task1 = { id: 'task1', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], isDone: false, } as Partial as TaskCopy; const task2 = { id: 'task2', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], isDone: true, } as Partial as TaskCopy; const task3 = { id: 'task3', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], isDone: false, } as Partial as TaskCopy; @@ -329,13 +362,15 @@ describe('workContext selectors', () => { it('should return empty array when all tasks are done', () => { const task1 = { id: 'task1', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], isDone: true, } as Partial as TaskCopy; const task2 = { id: 'task2', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], isDone: true, } as Partial as TaskCopy; @@ -349,7 +384,8 @@ describe('workContext selectors', () => { it('should handle tasks that do not exist in taskState', () => { const task1 = { id: 'task1', - tagIds: [TODAY_TAG.id], + tagIds: [], + dueDay: todayStr, subTaskIds: [], isDone: false, } as Partial as TaskCopy; diff --git a/src/app/features/work-context/store/work-context.selectors.ts b/src/app/features/work-context/store/work-context.selectors.ts index b5644e9d5..0a7a49d11 100644 --- a/src/app/features/work-context/store/work-context.selectors.ts +++ b/src/app/features/work-context/store/work-context.selectors.ts @@ -16,9 +16,64 @@ import { selectProjectFeatureState } from '../../project/store/project.selectors import { selectNoteTodayOrder } from '../../note/store/note.reducer'; import { TODAY_TAG } from '../../tag/tag.const'; import { Log } from '../../../core/log'; +import { getDbDateStr } from '../../../util/get-db-date-str'; +import { Tag } from '../../tag/tag.model'; export const WORK_CONTEXT_FEATURE_NAME = 'workContext'; +/** + * Computes ordered task IDs for TODAY_TAG using dueDay for membership. + * + * TODAY_TAG is a "virtual tag" - membership is determined by task.dueDay === today, + * NOT by task.tagIds. TODAY_TAG.taskIds only stores the ordering. + * + * See: docs/ai/today-tag-architecture.md + */ +const computeOrderedTaskIdsForToday = ( + todayTag: Tag | undefined, + taskEntities: Record< + string, + { id: string; dueDay?: string | null; parentId?: string | null } | undefined + >, +): string[] => { + const todayStr = getDbDateStr(); + const storedOrder = todayTag?.taskIds || []; + + // Find all tasks where dueDay === today (membership source of truth) + const tasksForToday: string[] = []; + for (const taskId of Object.keys(taskEntities)) { + const task = taskEntities[taskId]; + if (task && !task.parentId && task.dueDay === todayStr) { + tasksForToday.push(taskId); + } + } + + if (tasksForToday.length === 0) { + return []; + } + + // Order tasks according to TODAY_TAG.taskIds, with unordered tasks appended + const tasksForTodaySet = new Set(tasksForToday); + const orderedTasks: (string | undefined)[] = []; + const unorderedTasks: string[] = []; + + for (const taskId of tasksForToday) { + const orderIndex = storedOrder.indexOf(taskId); + if (orderIndex > -1) { + orderedTasks[orderIndex] = taskId; + } else { + unorderedTasks.push(taskId); + } + } + + return [ + ...orderedTasks.filter( + (id): id is string => id !== undefined && tasksForTodaySet.has(id), + ), + ...unorderedTasks, + ]; +}; + export const selectContextFeatureState = createFeatureSelector( WORK_CONTEXT_FEATURE_NAME, ); @@ -56,13 +111,14 @@ export const selectActiveWorkContext = createSelector( ): WorkContext => { if (activeType === WorkContextType.TAG) { const tag = selectTagById.projector(tagState, { id: activeId }); - // Use board-style pattern: task.tagIds is source of truth for membership, - // tag.taskIds is only for ordering. This provides atomic consistency and self-healing. - const orderedTaskIds = computeOrderedTaskIdsForTag( - activeId, - tag, - taskState.entities, - ); + + // TODAY_TAG uses dueDay for membership (virtual tag pattern) + // Regular tags use task.tagIds for membership (board-style pattern) + const orderedTaskIds = + activeId === TODAY_TAG.id + ? computeOrderedTaskIdsForToday(tag, taskState.entities) + : computeOrderedTaskIdsForTag(activeId, tag, taskState.entities); + return { ...tag, taskIds: orderedTaskIds, @@ -81,12 +137,8 @@ export const selectActiveWorkContext = createSelector( if (!tag) { throw new Error('Today tag not found'); } - // Fallback to TODAY tag with board-style pattern - const orderedTaskIds = computeOrderedTaskIdsForTag( - TODAY_TAG.id, - tag, - taskState.entities, - ); + // Fallback to TODAY tag - use dueDay for membership (virtual tag pattern) + const orderedTaskIds = computeOrderedTaskIdsForToday(tag, taskState.entities); return { ...tag, taskIds: orderedTaskIds, @@ -209,13 +261,21 @@ export const selectDoneBacklogTaskIdsForActiveContext = createSelector( }, ); +/** + * Selects ordered task IDs for the TODAY work context. + * + * TODAY_TAG is a "virtual tag" - membership is determined by task.dueDay, + * NOT by task.tagIds (TODAY_TAG should NEVER be in task.tagIds). + * TODAY_TAG.taskIds only stores the ordering. + * + * See: docs/ai/today-tag-architecture.md + */ export const selectTodayTaskIds = createSelector( selectTagFeatureState, selectTaskFeatureState, (tagState, taskState): string[] => { - // Use board-style pattern: task.tagIds is source of truth for membership const todayTag = tagState.entities[TODAY_TAG.id]; - return computeOrderedTaskIdsForTag(TODAY_TAG.id, todayTag, taskState.entities); + return computeOrderedTaskIdsForToday(todayTag, taskState.entities); }, ); diff --git a/src/app/features/work-context/work-context.service.ts b/src/app/features/work-context/work-context.service.ts index 4e9ba4313..91dd1fde8 100644 --- a/src/app/features/work-context/work-context.service.ts +++ b/src/app/features/work-context/work-context.service.ts @@ -275,21 +275,23 @@ export class WorkContextService { shareReplay(1), ); + // Filter project tasks to show only those scheduled for today + // TODAY_TAG is a virtual tag - membership is determined by task.dueDay, not task.tagIds + // See: docs/ai/today-tag-architecture.md mainListTasksInProject$: Observable = this.mainListTasks$.pipe( - map((tasks) => - tasks + map((tasks) => { + const todayStr = getDbDateStr(); + return tasks .filter( (task) => - task.tagIds.includes(TODAY_TAG.id) || - task.subTasks.some((subTask) => subTask.tagIds.includes(TODAY_TAG.id)), + task.dueDay === todayStr || + task.subTasks.some((subTask) => subTask.dueDay === todayStr), ) .map((task) => ({ ...task, - subTasks: task.subTasks.filter((subTask) => - subTask.tagIds.includes(TODAY_TAG.id), - ), - })), - ), + subTasks: task.subTasks.filter((subTask) => subTask.dueDay === todayStr), + })); + }), ); doneTaskIds$: Observable = this._store$.select( diff --git a/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts index 60f93fe46..38adc7cee 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.spec.ts @@ -210,9 +210,11 @@ describe('plannerSharedMetaReducer', () => { expect(mockReducer).toHaveBeenCalledWith(testState, action); }); - // Board-style pattern tests: verify task.tagIds is updated along with tag.taskIds - describe('board-style pattern: task.tagIds updates', () => { - it('should add TODAY to task.tagIds when planning for today', () => { + // Virtual tag pattern tests: TODAY_TAG membership is determined by task.dueDay, + // NOT by task.tagIds. TODAY_TAG should NEVER be in task.tagIds. + // See: docs/ai/today-tag-architecture.md + describe('virtual tag pattern: task.tagIds cleanup (TODAY should NEVER be in tagIds)', () => { + it('should NOT add TODAY to task.tagIds when planning for today (virtual tag pattern)', () => { const todayStr = getDbDateStr(); const testState = createStateWithExistingTasks([], [], [], []); // Add a task without TODAY in tagIds @@ -225,18 +227,15 @@ describe('plannerSharedMetaReducer', () => { const task = createMockTask({ id: 'task1', tagIds: ['other-tag'] }); const action = createPlanTaskForDayAction(task, todayStr, false); - metaReducer(testState, action); - expectStateUpdate( - expectTaskUpdate('task1', { tagIds: ['other-tag', 'TODAY'] }), - action, - mockReducer, - testState, - ); + const result = metaReducer(testState, action); + // Task.tagIds should NOT contain TODAY (virtual tag pattern) + expect(result.tasks.entities['task1'].tagIds).not.toContain('TODAY'); + expect(result.tasks.entities['task1'].tagIds).toEqual(['other-tag']); }); - it('should remove TODAY from task.tagIds when planning for different day', () => { + it('should remove TODAY from task.tagIds when present (cleanup legacy data)', () => { const testState = createStateWithExistingTasks([], [], [], ['task1']); - // Set task.tagIds to include TODAY + // Set task.tagIds to include TODAY (legacy data that needs cleanup) (testState as any).tasks.entities['task1'].tagIds = ['other-tag', 'TODAY']; const task = createMockTask({ id: 'task1', tagIds: ['other-tag', 'TODAY'] }); @@ -251,10 +250,10 @@ describe('plannerSharedMetaReducer', () => { ); }); - it('should not duplicate TODAY in task.tagIds if already present', () => { + it('should clean up TODAY from task.tagIds if present (legacy data cleanup)', () => { const todayStr = getDbDateStr(); const testState = createStateWithExistingTasks([], [], [], []); - // Add a task with TODAY already in tagIds + // Add a task with TODAY in tagIds (legacy data that needs cleanup) (testState as any).tasks.ids = ['task1']; (testState as any).tasks.entities['task1'] = createMockTask({ id: 'task1', @@ -265,11 +264,8 @@ describe('plannerSharedMetaReducer', () => { const action = createPlanTaskForDayAction(task, todayStr, false); const result = metaReducer(testState, action); - // Task should still have only one TODAY - expect( - result.tasks.entities['task1'].tagIds.filter((id: string) => id === 'TODAY') - .length, - ).toBe(1); + // TODAY should be removed from tagIds (virtual tag pattern) + expect(result.tasks.entities['task1'].tagIds).not.toContain('TODAY'); }); }); }); diff --git a/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts index c299bb744..e370d960b 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/planner-shared.reducer.ts @@ -83,10 +83,13 @@ const handleTransferTask = ( // Then handle today tag updates (from tag.reducer) const todayTag = getTag(state, TODAY_TAG.id); + // Get current task's tagIds from the updated state + const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task; + const currentTagIds = currentTask?.tagIds || []; + const hasTaskTodayTag = currentTagIds.includes(TODAY_TAG.id); if (prevDay === today && newDay !== today) { - // Moving away from today - remove from tag.taskIds - // Note: TODAY_TAG is a virtual tag and should NOT be in task.tagIds + // Moving away from today - update both tag.taskIds and task.tagIds (board-style pattern) state = updateTags(state, [ { id: TODAY_TAG.id, @@ -96,12 +99,27 @@ const handleTransferTask = ( }, ]); + // Remove TODAY from task.tagIds if present + if (hasTaskTodayTag) { + state = { + ...state, + [TASK_FEATURE_NAME]: taskAdapter.updateOne( + { + id: task.id, + changes: { tagIds: currentTagIds.filter((id) => id !== TODAY_TAG.id) }, + }, + state[TASK_FEATURE_NAME], + ), + }; + } + return state; } if (prevDay !== today && newDay === today) { - // Moving to today - add to tag.taskIds - // Note: TODAY_TAG is a virtual tag and should NOT be in task.tagIds + // Moving to today - update TODAY_TAG.taskIds for ordering + // IMPORTANT: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern) + // Membership is determined by task.dueDay. See: docs/ai/today-tag-architecture.md const taskIds = [...todayTag.taskIds]; const targetIndexToUse = targetTaskId ? todayTag.taskIds.findIndex((id) => id === targetTaskId) @@ -117,6 +135,20 @@ const handleTransferTask = ( }, ]); + // Ensure TODAY_TAG is NOT in task.tagIds (cleanup if present from legacy data) + if (hasTaskTodayTag) { + state = { + ...state, + [TASK_FEATURE_NAME]: taskAdapter.updateOne( + { + id: task.id, + changes: { tagIds: currentTagIds.filter((id) => id !== TODAY_TAG.id) }, + }, + state[TASK_FEATURE_NAME], + ), + }; + } + return state; } @@ -131,10 +163,14 @@ const handlePlanTaskForDay = ( ): RootState => { const todayStr = getDbDateStr(); const todayTag = getTag(state, TODAY_TAG.id); + const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task; + const currentTagIds = currentTask?.tagIds || []; + const hasTaskTodayTag = currentTagIds.includes(TODAY_TAG.id); if (day === todayStr) { - // Adding to today - update tag.taskIds only - // Note: TODAY_TAG is a virtual tag and should NOT be in task.tagIds + // Adding to today - update TODAY_TAG.taskIds for ordering + // IMPORTANT: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern) + // Membership is determined by task.dueDay. See: docs/ai/today-tag-architecture.md const newTagTaskIds = unique( isAddToTop ? [task.id, ...todayTag.taskIds.filter((tid) => tid !== task.id)] @@ -150,10 +186,23 @@ const handlePlanTaskForDay = ( }, ]); + // Ensure TODAY_TAG is NOT in task.tagIds (cleanup if present from legacy data) + if (hasTaskTodayTag) { + state = { + ...state, + [TASK_FEATURE_NAME]: taskAdapter.updateOne( + { + id: task.id, + changes: { tagIds: currentTagIds.filter((id) => id !== TODAY_TAG.id) }, + }, + state[TASK_FEATURE_NAME], + ), + }; + } + return state; } else if (todayTag.taskIds.includes(task.id)) { - // Moving away from today - remove from tag.taskIds only - // Note: TODAY_TAG is a virtual tag and should NOT be in task.tagIds + // Moving away from today - update both tag.taskIds and task.tagIds const newTagTaskIds = todayTag.taskIds.filter((id) => id !== task.id); state = updateTags(state, [ { @@ -164,6 +213,20 @@ const handlePlanTaskForDay = ( }, ]); + // Remove TODAY from task.tagIds if present + if (hasTaskTodayTag) { + state = { + ...state, + [TASK_FEATURE_NAME]: taskAdapter.updateOne( + { + id: task.id, + changes: { tagIds: currentTagIds.filter((id) => id !== TODAY_TAG.id) }, + }, + state[TASK_FEATURE_NAME], + ), + }; + } + return state; } diff --git a/src/app/root-store/meta/task-shared-meta-reducers/short-syntax-shared.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/short-syntax-shared.reducer.spec.ts index 4796a1101..211486e47 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/short-syntax-shared.reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/short-syntax-shared.reducer.spec.ts @@ -172,12 +172,19 @@ describe('shortSyntaxSharedMetaReducer', () => { mockReducer, stateWithTask, ); + // Virtual tag pattern: TODAY should NOT be in task.tagIds + // Membership is determined by task.dueDay, not tagIds expectStateUpdate( - expectTaskUpdate('task1', { dueDay: todayStr, tagIds: ['TODAY'] }), + expectTaskUpdate('task1', { dueDay: todayStr }), action, mockReducer, stateWithTask, ); + // Verify TODAY is NOT in tagIds + const calledState = mockReducer.calls.mostRecent().args[0] as RootState; + expect(calledState[TASK_FEATURE_NAME].entities.task1!.tagIds).not.toContain( + 'TODAY', + ); }); it('should add task to top of today when isAddToTop is true', () => { @@ -391,14 +398,18 @@ describe('shortSyntaxSharedMetaReducer', () => { const calledState = mockReducer.calls.mostRecent().args[0] as RootState; // Verify all changes happened atomically + // Virtual tag pattern: TODAY should NOT be in task.tagIds expect(calledState[TASK_FEATURE_NAME].entities.task1).toEqual( jasmine.objectContaining({ title: 'Updated Title', projectId: 'project2', dueDay: todayStr, - tagIds: ['TODAY'], }), ); + // Verify TODAY is NOT in tagIds (virtual tag pattern) + expect(calledState[TASK_FEATURE_NAME].entities.task1!.tagIds).not.toContain( + 'TODAY', + ); expect(calledState[PROJECT_FEATURE_NAME].entities.project1!.taskIds).toEqual([]); expect(calledState[PROJECT_FEATURE_NAME].entities.project2!.taskIds).toEqual([ 'task1', @@ -441,13 +452,18 @@ describe('shortSyntaxSharedMetaReducer', () => { expect(updatedTask.title).toBe('New Title'); expect(updatedTask.timeEstimate).toBe(7200000); - // Note: tagIds will be overwritten by scheduling logic to include TODAY - expect(updatedTask.tagIds).toContain('TODAY'); + // Virtual tag pattern: TODAY should NOT be in task.tagIds + // tagIds from taskChanges are preserved, but TODAY is never added + expect(updatedTask.tagIds).not.toContain('TODAY'); + expect(updatedTask.tagIds).toEqual(['tag1', 'tag2']); }); }); - describe('applyShortSyntax action - board-style consistency', () => { - it('should update both tag.taskIds and task.tagIds when adding to today', () => { + // Virtual tag pattern: TODAY_TAG membership is determined by task.dueDay, + // NOT by task.tagIds. TODAY_TAG.taskIds only stores ordering. + // See: docs/ai/today-tag-architecture.md + describe('applyShortSyntax action - virtual tag pattern consistency', () => { + it('should update tag.taskIds for ordering but NOT task.tagIds when adding to today', () => { const todayStr = getDbDateStr(); const testState = createStateWithExistingTasks(['task1'], [], [], []); @@ -476,14 +492,17 @@ describe('shortSyntaxSharedMetaReducer', () => { const calledState = mockReducer.calls.mostRecent().args[0] as RootState; - // Both sides should be updated - expect(calledState[TASK_FEATURE_NAME].entities.task1!.tagIds).toContain('TODAY'); + // Virtual tag pattern: TODAY_TAG.taskIds is updated for ordering, + // but task.tagIds should NOT contain TODAY + expect(calledState[TASK_FEATURE_NAME].entities.task1!.tagIds).not.toContain( + 'TODAY', + ); expect((calledState as any).tag.entities.TODAY.taskIds as string[]).toContain( 'task1', ); }); - it('should update both tag.taskIds and task.tagIds when removing from today', () => { + it('should clean up legacy TODAY in task.tagIds when removing from today', () => { const futureDay = '2099-12-31'; const testState = createStateWithExistingTasks(['task1'], [], [], ['task1']); diff --git a/src/app/root-store/meta/task-shared-meta-reducers/short-syntax-shared.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/short-syntax-shared.reducer.ts index 157c61fb1..089ed5da3 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/short-syntax-shared.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/short-syntax-shared.reducer.ts @@ -247,6 +247,7 @@ const handlePlanForDay = ( let updatedState = state; const todayStr = getDbDateStr(); const isForToday = day === todayStr; + const currentTask = state[TASK_FEATURE_NAME].entities[task.id] as Task; // Collect additional task changes const additionalChanges: MutableTaskChanges = { @@ -255,11 +256,14 @@ const handlePlanForDay = ( }; const todayTag = getTag(updatedState, TODAY_TAG.id); - // Note: TODAY_TAG should NOT be in task.tagIds - check tag.taskIds only const isCurrentlyInToday = todayTag.taskIds.includes(task.id); + const currentTagIds = currentTask?.tagIds || []; + const hasTaskTodayTag = currentTagIds.includes(TODAY_TAG.id); if (isForToday) { - // Adding to today - update tag.taskIds only + // Adding to today - update TODAY_TAG.taskIds for ordering + // IMPORTANT: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern) + // Membership is determined by task.dueDay. See: docs/ai/today-tag-architecture.md const newTagTaskIds = unique( isAddToTop ? [task.id, ...todayTag.taskIds.filter((tid) => tid !== task.id)] @@ -273,22 +277,32 @@ const handlePlanForDay = ( }, ]); + // Ensure TODAY_TAG is NOT in task.tagIds (cleanup if present from legacy data) + if (hasTaskTodayTag) { + additionalChanges.tagIds = currentTagIds.filter((id) => id !== TODAY_TAG.id); + } + // Remove from planner days if present updatedState = removeFromPlannerDays(updatedState, task.id); - } else if (isCurrentlyInToday) { - // Moving away from today - update tag.taskIds only - updatedState = updateTags(updatedState, [ - { - id: TODAY_TAG.id, - changes: { taskIds: todayTag.taskIds.filter((id) => id !== task.id) }, - }, - ]); + } else { + // Moving away from today or scheduling for future + if (isCurrentlyInToday) { + // Remove from TODAY_TAG.taskIds + updatedState = updateTags(updatedState, [ + { + id: TODAY_TAG.id, + changes: { taskIds: todayTag.taskIds.filter((id) => id !== task.id) }, + }, + ]); + } + + // Ensure TODAY_TAG is NOT in task.tagIds (cleanup if present from legacy data) + if (hasTaskTodayTag) { + additionalChanges.tagIds = currentTagIds.filter((id) => id !== TODAY_TAG.id); + } // Add to planner for the target day updatedState = addToPlannerDay(updatedState, task.id, day); - } else { - // Not in today, add to planner for target day - updatedState = addToPlannerDay(updatedState, task.id, day); } return { state: updatedState, additionalChanges }; diff --git a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts index 276adefd8..409c687f1 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer.ts @@ -121,11 +121,15 @@ const handleAddTask = ( const shouldAddToToday = task.dueDay === getDbDateStr(); // Add task to task state - // Note: TODAY_TAG should NOT be in task.tagIds - it's a virtual tag stored in tag.taskIds only + // IMPORTANT: TODAY_TAG should NEVER be in task.tagIds (virtual tag pattern) + // Membership is determined by task.dueDay, TODAY_TAG.taskIds only stores ordering + // See: docs/ai/today-tag-architecture.md + const taskTagIds = task.tagIds.filter((id) => id !== TODAY_TAG.id); + const newTask: Task = { ...DEFAULT_TASK, ...task, - tagIds: task.tagIds.filter((id) => id !== TODAY_TAG.id), // Ensure TODAY_TAG is not in tagIds + tagIds: taskTagIds, timeSpent: calcTotalTimeSpent(task.timeSpentOnDay || {}), projectId: task.projectId || '', };