mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
903dce197b
commit
78c65acf4d
19 changed files with 494 additions and 119 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
123
docs/ai/today-tag-architecture.md
Normal file
123
docs/ai/today-tag-architecture.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {}
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 || '',
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue