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.
This commit is contained in:
Johannes Millan 2025-12-08 10:40:57 +01:00
parent 903dce197b
commit 78c65acf4d
19 changed files with 494 additions and 119 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
? {}
: {

View file

@ -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<TagState>(
// 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,
(

View file

@ -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(),

View file

@ -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', () => {

View file

@ -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');
}
}

View file

@ -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<TaskCopy> 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<TaskCopy> as TaskCopy;
const taskNotInTag = {
const taskNotForToday = {
id: 'task3',
tagIds: [], // Does NOT have TODAY tag
tagIds: [],
dueDay: '2000-01-01', // Not today
subTaskIds: [],
} as Partial<TaskCopy> 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<TaskCopy> as TaskCopy;
const M2 = {
id: 'M2',
subTaskIds: [],
tagIds: [TODAY_TAG.id],
tagIds: [],
dueDay: todayStr,
dueWithTime: 1234,
reminderId: 'asd',
} as Partial<TaskCopy> 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<TaskCopy> as TaskCopy;
const M2 = {
id: 'M2',
subTaskIds: [],
tagIds: [TODAY_TAG.id],
tagIds: [],
dueDay: todayStr,
dueWithTime: 1234,
} as Partial<TaskCopy> as TaskCopy;
@ -149,7 +169,8 @@ describe('workContext selectors', () => {
id: 'M2',
dueWithTime: 1234,
subTaskIds: [],
tagIds: ['TODAY'],
tagIds: [],
dueDay: todayStr,
},
] as Partial<TaskCopy>[] 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<TaskCopy> as TaskCopy;
const task2 = {
id: 'task2',
tagIds: [TODAY_TAG.id],
tagIds: [], // TODAY_TAG should NOT be in tagIds
dueDay: todayStr,
subTaskIds: [],
} as Partial<TaskCopy> 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<TaskCopy> as TaskCopy;
const task2 = {
id: 'task2',
tagIds: [], // Removed from TODAY tag
tagIds: [],
dueDay: '2000-01-01', // No longer today
subTaskIds: [],
} as Partial<TaskCopy> 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<TaskCopy> as TaskCopy;
const task2 = {
id: 'task2',
tagIds: [TODAY_TAG.id],
tagIds: [],
dueDay: todayStr,
subTaskIds: [],
} as Partial<TaskCopy> 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<TaskCopy> as TaskCopy;
const subtask = {
id: 'subtask1',
tagIds: [TODAY_TAG.id],
tagIds: [],
dueDay: todayStr,
subTaskIds: [],
parentId: 'parent',
} as Partial<TaskCopy> 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<TaskCopy> as TaskCopy;
const task2 = {
id: 'task2',
tagIds: [TODAY_TAG.id],
tagIds: [],
dueDay: todayStr,
subTaskIds: [],
isDone: true,
} as Partial<TaskCopy> as TaskCopy;
const task3 = {
id: 'task3',
tagIds: [TODAY_TAG.id],
tagIds: [],
dueDay: todayStr,
subTaskIds: [],
isDone: false,
} as Partial<TaskCopy> 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<TaskCopy> as TaskCopy;
const task2 = {
id: 'task2',
tagIds: [TODAY_TAG.id],
tagIds: [],
dueDay: todayStr,
subTaskIds: [],
isDone: true,
} as Partial<TaskCopy> 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<TaskCopy> as TaskCopy;

View file

@ -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<WorkContextState>(
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);
},
);

View file

@ -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<TaskWithSubTasks[]> = 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<string[]> = this._store$.select(

View file

@ -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');
});
});
});

View file

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

View file

@ -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']);

View file

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

View file

@ -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 || '',
};