From f8f405c6237d90287d9ca3cd9c5338140cd2c944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Vel=C3=A1zquez?= Date: Wed, 29 Apr 2026 10:02:33 -0300 Subject: [PATCH] feat: add sections in projects (#6066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sections): Introduce core section state, model, and persistence * feat(sections): Implement SectionService and link sectionId to Task model * feat(sections): Add 'Add Section' functionality to project context menu * feat(sections): Implement cascading task deletion for sections * feat(sections): Enable drag & drop for tasks into sections * feat(sections): Display, manage, and reorder sections in Work View * refactor(tasks): Add guard for invalid subtask moves in reducer * feat(markdown): Add interfaces for markdown sections * feat(markdown): Implement markdown section parsing utility * feat(section): Add helper methods to SectionService * feat(markdown): Add i18n for markdown section paste confirmation * refactor(markdown): Prepare MarkdownPasteService for section support * feat(markdown): Implement markdown section paste handling in MarkdownPasteService * feat(markdown): Update paste detection to include markdown sections * test(markdown): Add simple test for markdown section parsing utility * fix(sections): post-merge type errors and stray file cleanup - model-config.ts: drop unsupported `validate` field on section ModelCfg; validators are looked up via validateAppDataProperty(key, data) instead. - app-data-mock.ts: add `section: createEmptyEntity()` so AppDataComplete is satisfied. - Remove test-sections.js (development artifact from the original PR). * refactor(sections): atomize section deletion via meta-reducer Replace the dispatch-chained section.effects.ts with a section-shared meta-reducer that removes the section entity and cascade-deletes its tasks (and subtasks) plus their references in projects and tags in a single reducer pass. Why: - The previous effect used inject(Actions), so it would re-fire during remote sync replay and double-delete tasks. - It dispatched a separate deleteTasks action, producing two operations in the sync log; a partial sync between them could leave a tree of orphaned tasks pointing at a vanished section. Changes: - Add src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.ts - Register sectionSharedMetaReducer in Phase 5 of meta-reducer-registry.ts - Remove src/app/features/section/store/section.effects.ts and its EffectsModule.forFeature registration in feature-stores.module.ts * refactor(sections): visual layer model + tag/today support Make sections a pure visual grouping (analogous to boards) rather than a property of tasks. This eliminates the cross-entity coupling that drove most of the original PR's sync-correctness issues. Section model: - Drop Task.sectionId entirely. Sections own their membership via Section.taskIds: string[]. - Generalize Section.projectId → contextId + contextType ('PROJECT' | 'TAG'). This unblocks sections in tag and TODAY views. - New addTaskToSection action atomically removes the task from any other section and inserts it into the target at the requested position via adapter.updateMany — one reducer pass, one sync operation. Replaces the old project.effects moveProjectTaskToSection effect, which dispatched two persistent actions and was non-atomic. Section deletion: - Pure entity removal; no task cascade. Tasks in the deleted section simply become "no section" tasks (DELETE_SECTION confirmation reflects this; the now-obsolete DELETE_SECTION_CASCADE string is removed). Section-shared meta-reducer (Phase 5): - Repurposed from the previous deleteSection cascade handler. Now listens for TaskSharedActions.deleteTask/deleteTasks and prunes deleted task IDs from any section.taskIds, keeping section state consistent without requiring an effect. Selector / perf: - selectSectionsByContextId is backed by a single memoized selectSectionsByContextIdMap. Replaces the per-call factory pattern that broke memoization across consumers. - WorkViewComponent.undoneTasksBySection now indexes section→task in O(n + m) using a Map keyed by taskId. WorkContextMenu: - Drop the @if (isForProject) gate on Add Section. The menu now offers it for tags (incl. TODAY) and propagates the appropriate contextType. Cleanup: - Add takeUntilDestroyed to all dialog .subscribe() chains in work-view.component.ts and work-context-menu.component.ts. - Drop unused MatMenu/MatMenuTrigger/MatMenuItem imports. - Yield the event loop every 10 sections during markdown imports. - Revert the es.json edits added by the original PR (en-only policy). * test(sections): cover reducer + meta-reducer behavior - sectionReducer: addSection (default empty taskIds), deleteSection (entity-only, no cascade), updateSection partial changes, updateSectionOrder scoped to one context, addTaskToSection (anchor positioning, uniqueness across sections, intra-section moves, ungrouping on null sectionId, no-op when target missing). - sectionSharedMetaReducer: deleteTask removes the id from any section, cascades through subtasks, deleteTasks bulk variant, passes through unrelated actions unchanged. * fix(sections): wire new entity through suite-wide invariants Fixes 11 unit-test failures introduced by adding the section model: - ActionType enum gains SECTION_ADD_TASK (the new atomic move action) and a corresponding ACTION_TYPE_TO_CODE entry; spec member-count expectation bumps from 136 → 141 to match. - extractEntityKeysFromState now emits SECTION: keys so OperationLogCompactionService recognizes section as a valid model. - task-list enterPredicate distinguishes section drop-lists from subtask drop-lists via drop.data.listId. Parent tasks may drop into PARENT_ALLOWED_LISTS or any parent-level (section) drop-list, but never into a subtask drop-list (listId === 'SUB'). - Add section: { ids: [], entities: {} } to the empty-state helper in validate-state.service.spec; without it Typia validation fails because section is now a required slice of AppDataComplete. - Add activeWorkContextId$: of(null) to the WorkContextService mock in work-view.component.spec; the section signal subscribes to that observable in the constructor. - Add 'section' → 'SECTION' to the modelToEntityType map in operation-log-compaction.service.spec. Five immediate-upload.service.spec.ts failures remain; they reproduce on master with identical files, so they're unrelated to this branch. * fix(sections): apply multi-review C1-C4 + harden taskIds reads C1: Move sectionSharedMetaReducer before taskSharedCrudMetaReducer. At Phase 5 it ran AFTER the task entity was already removed, so state.task.entities[id]?.subTaskIds was always undefined and section references to deleted subtasks leaked. The section spec passed only because its mock reducer didn't apply the cascade. C2: Tighten task-list enterPredicate. Subtasks may now drop only into another subtask drop-list (listId === 'SUB') or appear as top-level in DONE/UNDONE. Section drop-lists (listId === 'PARENT' with a section id) reject subtask drags so section.taskIds stays parent-only. C3: Add cdkDropListEnterPredicate to the sections-wrapper. Without it, a task drag could land on the section-reorder list and dropSection would treat a Task as a Section, corrupting ordering. The new acceptSectionDragOnly predicate brand-checks the drag's data. C4 + cleanup: drop the unused Section.isCollapsed field; remove the redundant `// 1.` `// 2.` comments in section.reducer; tighten the `(state as any).section` cast (AppStateSnapshot already declares the field); fix the misleading meta-reducer-registry comment. Plus a reported runtime crash: cleanupSectionTaskIds threw on undefined `s.taskIds` for sections persisted before this branch added the field. Defensive `?? []` everywhere that reads `taskIds`, and a normalizeLoadedSections pass on `loadAllData` so old persisted state is brought up to the current shape. * fix(sections): Add Section button — drop takeUntilDestroyed on dialog sub WorkContextMenuComponent lives inside a . The menu (and the component) is destroyed the moment the dialog opens, so takeUntilDestroyed(this._destroyRef) tore down the afterClosed() subscription before it could ever emit the typed title. Result: the dialog appeared, the user typed, clicked Save — but no section was ever dispatched. MatDialog cleans up its own subscription once the dialog closes, so the explicit teardown is unnecessary here. WorkViewComponent's similar dialog subscriptions are unaffected (that component is not inside a mat-menu). * style(sections): move Add Section above Settings in context menu * fix(sections): clean up orphan sections on project/tag deletion Closes the cleanup gap surfaced by user audit: until now, deleting a project or tag left behind its sections — they kept their stale contextId and contextType but no longer matched any context, piling up in state and the sync log. Section-level cleanup was only wired for task deletion. sectionSharedMetaReducer now also handles: - TaskSharedActions.deleteProject → remove sections where contextType='PROJECT' and contextId === projectId. - deleteTag (single) → remove sections where contextType='TAG' and contextId === id. - deleteTags (bulk) → remove sections where contextType='TAG' and contextId is in ids. contextType is matched explicitly so a project and a tag that happen to share the same id (theoretical, but possible) don't cross-pollute. Added 3 specs covering each path, including the same-id collision case. * fix(sections): clean section.taskIds on task move + tag removal Two more cleanup paths surfaced by the user audit: 1. Task moves to another project (TaskSharedActions.moveToOtherProject): the moved task and its subtasks were left lingering in any section owned by the previous project. Now the meta-reducer reads the task's old projectId from state (action runs before taskSharedCrud) and strips the task ids from project-scoped sections in that project. Tag-scoped sections are intentionally untouched because tag membership doesn't change on a project move. 2. Task tagIds change (TaskSharedActions.updateTask): when a tag is removed from a task, any section owned by that tag still kept the task id. Now we diff oldTagIds vs new tagIds, and for each removed tag, strip the task id from sections matching contextType='TAG' and contextId === removedTagId. Tag additions are not auto-joined to a section — that stays a user action. The new cleanupTaskIdsInContexts helper does both jobs (restricting edits to a specific contextType + a set of contextIds), keeping the logic narrow. Specs cover: project move strips only old-project sections (and subtasks), tag removal strips only the dropped tags' sections, and updateTask without a tagIds change is a no-op. * refactor(sections): replace SectionContextType with WorkContextType Drop the parallel `SectionContextType = 'PROJECT' | 'TAG'` union in favour of the existing `WorkContextType` enum, which has identical string values. Same on-disk shape (the Typia validator's literal-string serialization is unchanged), but the model now reuses the canonical work-context taxonomy and consumers can pass `activeWorkContextType` straight through without translation ternaries. Touches: section.model + section.service signature, the section-shared meta-reducer's helpers and handlers (`WorkContextType.PROJECT/TAG` instead of literals), the work-context-menu / work-view / markdown-paste call sites (drop the `=== WorkContextType.PROJECT ? 'PROJECT' : 'TAG'` ternary), and the spec fixtures. * refactor(sections): split addTaskToSection — drop the 'NONE' sentinel The 'NONE' string sentinel for ungrouped op-log entries had two problems: (1) two clients ungrouping different tasks concurrently both wrote ops keyed on entityId='NONE', so LWW conflict resolution collapsed them and one ungroup was discarded; (2) magic string with no constant or test pinning it. Split into two persistent actions: - addTaskToSection({ sectionId, taskId, afterTaskId? }) — sectionId is now non-nullable; entityId tracks the destination section. The reducer still atomically strips the task from any other section (uniqueness invariant), so a single op covers a full move. - removeTaskFromSection({ sectionId, taskId }) — entityId is the source section. Concurrent ungroups from different sources are now independent ops with distinct entityIds; LWW conflict resolution treats them correctly. The drag-drop call site already knows both source and target list ids (srcIsSection / targetIsSection), so the SectionService just exposes two narrow methods: `addTaskToSection(target, ...)` and `removeTaskFromSection(source, ...)`. The work-view caller uses addTaskToSection only (markdown paste never ungroups). Two new specs cover removeTaskFromSection: strips only the named section, no-op when the task isn't there, no-op when the section is missing. * fix(sections): apply round-2 review batch Addresses ten findings from the second multi-review pass. Security - Replace plain-object grouping caches with Map so a malicious sync peer can't poison Object.prototype via crafted contextIds like '__proto__'. Affects selectSectionsByContextIdMap; the per-call factory selector is removed (see Performance below). Correctness - normalizeLoadedSections defends against state.ids === undefined (very old persisted shapes wrote `section: {}`). - Section meta-reducer now also handles TaskSharedActions.updateTasks (bulk). Previously only updateTask fired the tag-removal cleanup; a future bulk dispatcher mutating tagIds would silently leak orphan task ids in tag-context sections. Spec covers it. Architecture - Phase 3.5 promoted to a named phase in the registry doc block. - validateMetaReducerOrdering pins sectionSharedMetaReducer to run before taskSharedCrudMetaReducer; any future reorder fails dev-mode validation. The handlers' pre-CRUD state read is now documented at the top of section-shared.reducer.ts. Alternatives + Performance - Drop the per-call selectSectionsByContextId factory. The service selects selectSectionsByContextIdMap and pipes map(...) — no fresh MemoizedSelector per call, no leak. - addTaskToSection reducer breaks out of the uniqueness scan after the first removal (a task is in at most one section at a time). - Pre-filter sectionSharedMetaReducer on a static HANDLED_ACTION_TYPES Set so the 99% of dispatches that don't match short-circuit before allocating the handler dispatch table. Simplicity - Collapse SectionService.{addSection, addSectionWithId, generateSectionId} into a single addSection that returns the new id synchronously. Markdown-paste consumes it directly. - Remove collectTaskAndSubtaskIds (single-task path is just collectAffectedTaskIds with a one-element array). - dropSection drops the object spread + extra map; reorder ids in place. - Tighten ExtendedState — SECTION_FEATURE_NAME is always registered, so the optional-typing dance and the four `if (\!sectionState)` early-returns inside helpers are removed. * fix(sections): apply round-3 review batch Fixes from a multi-reviewer pass (codex + claude + code-reviewer sub-agent) on the sections feature. Critical - task-list: subtask-to-subtask drag was being misidentified as a section move. _move() now takes srcListId/targetListId and only treats a non-reserved listModelId as a section when listId is PARENT. Subtask drop-lists ('SUB') fall through to moveSubTask. - parse-markdown-tasks: tasks before the first markdown header were silently dropped by parseMarkdownWithSections. They now flush into a top-of-list "No Section" entry. Also broaden the header regex from /^#\s+/ to /^#{1,6}\s+/ so H2-H6 are recognized. - section.actions/reducer/service: addTaskToSection now carries an explicit sourceSectionId. The reducer strips from that source rather than searching state, making replay deterministic. Action meta sets entityIds: [src, dest] when src is provided so vector- clock conflict detection covers both. Callers in task-list and markdown-paste pass the actual source (or null for new tasks). Warnings / cleanups - section-shared meta-reducer: handle removeTasksFromTodayTag and localRemoveOverdueFromToday — TODAY is virtual so the existing tagIds path doesn't catch them. Doc enumerates the residual gap (scheduling/planner/short-syntax/crud/lww) and proposes a future Phase 6.5 diff-based meta-reducer for full coverage. - section-shared reducer: typed as MetaReducer / ActionReducer instead of any. - markdown-paste: yield every 30 dispatches (was: every 10 sections) per CLAUDE.md rule #11. Type the reduce accumulator as number. - task-list: extract RESERVED_LIST_IDS constant; comment why it diverges from PARENT_ALLOWED_LISTS on LATER_TODAY. - en.json/es.json: restore trailing newlines (es.json now identical to master). Tests - parse-markdown-tasks.spec: 6 cases for parseMarkdownWithSections (H1, H2/H3, pre-header tasks, empty sections, null input). - section.reducer.spec: 4 cases for sourceSectionId behavior (explicit source, null, omitted, intra-section reorder). - section-shared.reducer.spec: 3 cases for TODAY removal cleanup. - task-list.component.spec: 5 cases for _move() routing (subtask vs section disambiguation, source propagation). * fix(sections): apply round-3 review nits - task-list: type RESERVED_LIST_IDS values via `satisfies DropListModelSource[]` so adding a new variant to the union surfaces a typo here without forcing casts at every `.has()`. - section.actions: tighten addTaskToSection doc — the `entityIds: [src, dest]` claim only holds when src is a non-null string different from dest; intra-section / null fall back to single-entity meta. - task-list spec: add two anchor cases for `_move` so `getAnchorFromDragDrop` is actually exercised on section drops (previous tests passed `[taskId]` only, which short-circuited to null). * fix(sections): apply round-4 review batch High-severity sync/UX fixes: - deleteProject now also strips cascaded task ids from tag-context sections (one extra cleanupSectionTaskIds pass in the meta-reducer); + unit test - Edit Section dialog prefill: val → txtValue - Markdown paste: drop yieldIfNeeded mid-loop (widened the sync interleave window); residual atomic-bulk-action gap documented - Add Section menu item guarded against TODAY_TAG - Paste hook switched from id-prefix scan to data-task-id attribute - New section referential validators + auto-repair (data-repair) - Section view bypasses customizer when filter/sort active - Parser hardening: input cap, CRLF/BOM normalization, reduce-based min-indent (avoids RangeError on huge spreads), JSDoc fix - moveSubTask guard now also rejects self-moves; console.warn → TaskLog.warn - Work-view styling: use --s/--s3 tokens, narrow \!important rules - Drop dead loadSections action + SectionService.sections$ - Trailing-space prettier nits in sync.model.ts + feature-stores.module * fix(sections): apply multi-review batch — moveToArchive + cleanup - moveToArchive: add handler in section-shared meta-reducer so archived tasks (and their subtasks) no longer leak as stale ids in section.taskIds until dataRepair clears them. - Collapse the dual HANDLED_ACTION_TYPES + handlers map into a single ACTION_HANDLERS record; the prior split is what allowed moveToArchive to drift out of sync. - Make addTaskToSection's sourceSectionId required (string | null) and delete the legacy defensive sweep branch in the reducer. - updateSectionOrder now keeps other-context sections in their slot in state.ids instead of pushing them to the front. - Sanitize section titles (trim + 200-char cap) on add/update. - Remove dead addSection() in work-view.component.ts (template only wires editSection/deleteSection; addSection lives on work-context-menu). - Remove dead translation keys WW.ADD_SECTION and WW.ADD_SOME_TASKS. - Drop hasHeaders field from MarkdownWithSections (null return already encodes header-less input). - Drop normalizeLoadedSections + ?? [] guards (Section is brand-new, no legacy persisted shape exists) and unused entity adapter exports. * perf(sections): apply perf review batch — drop-list coalesce + meta-reducer hot paths - DropListService: coalesce burst register/unregister calls via a microtask flush. Mounting 50 sections previously emitted 50 times through the BehaviorSubject; cdkDropListConnectedTo rebuilds its sibling graph per emission, so first paint cost was ~O(L²). Now one downstream emission per CD pass. - section-shared meta-reducer: replace Object.values(entities) sweeps with `for-of state.ids` (no intermediate array alloc on every matched task action) and collapse `.some + .filter` double-walk into a single pass that returns null when nothing changed. Halves walk count on affected sections under op-log replay. - updateTasks handler: aggregate (taskId → removedTagIds) across the batch into a `tagId → tasksToRemove` map, then sweep candidate sections once with a single adapter.updateMany. Drops a 50-task batch from O(N·S) to O(N + S). - section.service: stable EMPTY_SECTIONS constant for contexts with no sections, so OnPush downstream isn't broken by a fresh `[]` per emission. - work-view template: drop `|| []` on dict[section.id] — the dict is pre-populated per section, so the fallback was allocating a fresh array reference per CD pass for every empty section. - acceptSectionDragOnly: bind [cdkDragData]="section" and reduce the predicate to one property read; was running shape-validation on every dragOver tick. - markdown paste: cheap regex pre-screen in isMarkdownTaskList so plain-text Ctrl+V pastes bail before invoking the parser, and memoise the sectioned-parse result by string reference so the immediately-following handleMarkdownPaste doesn't re-parse. * feat(sections): project-only Add Section + empty main-list hint - work-context-menu: tighten Add Section gating from `isForProject || contextId \!== TODAY_TAG_ID` to `isForProject`. The menu item is now hidden for the today list and for tag contexts in general; sections in tag contexts can still arrive via markdown paste. - work-view: when the sectioned view's no-section bucket is empty (every task lives in a section), render a small italic hint ("All tasks are organized into sections") instead of leaving the area silently empty. * fix(sections): keep no-section drop target alive when empty Previously the no-section area swapped task-list for a

hint when empty, removing the cdkDropList. That blocked drops from sections back into the main list. Restore the always-rendered task-list and pass the hint via its built-in [noTasksMsg]: - task-list keeps its drop target so cross-section moves work. - The hint is muted via task-list's existing .no-tasks styling (var(--text-color-muted)). - The hint is hidden when undoneTasks() is empty so it doesn't duplicate the horizon "no tasks planned" empty state. * feat(sections): show Add Section in every work-context menu Drop the gate around the Add Section menu item so it shows for projects, regular tags, and the today list. Sections in TODAY are stored as TAG-context sections under the TODAY tag id, matching the existing tag-section flow. * fix(sections): apply round-6 multi-review batch Critical: - enterPredicate rejects backlog → section drops; previous path left the task in both backlog and section.taskIds. - editSection / addSection trim whitespace before the truthy check (sanitizeSectionTitle would otherwise produce empty titles). - TODAY_TAG cleanup: post-reducer diff in section-shared meta-reducer catches every flow that removes ids from TODAY (scheduling, planner, short-syntax, undo, lww), closing the documented residual gap. Bulk action handlers retained — handler + diff are idempotent. Sync-replay defense: - updateSectionOrder accepts partial / out-of-date payloads via dedup + append-missing instead of a fragile cursor walk; new tests cover shorter and stale payloads. - sanitizeSectionTitle moved to section.model and applied inside the reducer for addSection / updateSection so remote ops can't bypass the 200-char cap. Cleanup: - markdown-paste memo moved from module scope to instance state, with explicit clear after consumption (clipboard content no longer retained for the rest of the session). - undoneTasksBySection bound once via @let in the work-view template. - Factory selector selectSectionsForContext(contextId) replaces the whole-map mapping in SectionService. - Drop log-only validateSections (data-repair already cleans). Follow-ups documented inline (not in this PR): - task.sectionId membership model (W4) - removeTaskFromSection → addTaskToSection consolidation (W3) - marked-based parseMarkdownWithSections (S1) * fix(sections): guard TODAY-tag diff against undefined state slices `diffRemovedTodayTaskIds`, `applyTodayTagSectionCleanup`, and `collectAffectedTaskIds` all assumed their slices existed. During early boot / undo / hydration paths the tag, task, or section slice can be undefined, causing the meta-reducer to crash on `prevTagState.entities[...]`. The crash cascaded into selector errors (idleTime, activeType, isShowAddTaskBar) because subsequent reducer passes never ran. Bail out early when slices are absent — there's nothing to clean up yet. * test(sections): add e2e coverage for basic section flows Covers the core user-visible section behavior: - create section via project header context menu - reject whitespace-only titles (verifies the C2 trim fix) - edit section title via the per-section menu - delete section after dialog confirmation - drag a task into a section (Angular CDK drag-drop helper, since Playwright's `dragTo` uses HTML5 drag events that CDK ignores) - sections persist across a page reload (IndexedDB hydration) The reverse drag (section → no-section bucket) is fixme'd: forward drag passes, but the reverse target's bounding box collapses to a hint message when empty and the CDK drop won't register reliably in headless. Reducer-level coverage in section.reducer.spec.ts (`removeTaskFromSection`) substitutes for now. * fix(sections): apply round-7 multi-review batch Hardening: - Reducer-side `sanitizeSectionTitle` now coerces non-string input (null / undefined) to "" instead of throwing. A malformed remote op (`addSection({ section: { title: undefined } })`) was the threat model the cap was added for; previously it crashed the reducer. - `updateSection` now sanitizes on key-presence (`'title' in changes`) rather than `typeof === 'string'` so a peer shipping `title: null` cannot bypass the cap and corrupt the entity's typed contract. - Single dispatcher-level boot guard in `sectionSharedMetaReducer` catches every action handler that touches task / tag / section slices, replacing the asymmetric per-function guards from round 6. Cleanup: - Revert the round-6 `selectSectionsForContext` factory selector. It allocated a fresh `MemoizedSelector` per `getSectionsByContextId$` call, undermining the memoization the simplification claimed. The service's previous `.pipe(map(m => m.get(id) ?? EMPTY))` shape is one fewer layer with identical behavior — single consumer, no win. - Move `sanitizeSectionTitle` + `MAX_SECTION_TITLE_LENGTH` to `section.utils.ts` (model files in this repo are pure type files). - Inline `_clearSectionsCache()` (single caller). - Drop the redundant 5-step settle-move at the end of the e2e `cdkDragTo` helper. Tests: - Pin the actual ordering in the `updateSectionOrder` partial-payload test (was only checking dedupe count, missed the explicit shape). - New: empty-string title passes through (legitimate clear). - New: null/undefined title is coerced, not stored as null. - New: malformed remote op with undefined title doesn't crash addSection. - Replace `waitForTimeout(500)` in the e2e whitespace-rejection test with a polling `toHaveCount(0)` assertion (project rule violation). - Strengthen the `updateSection` cap test with leading-whitespace input. Deferred (tracked as follow-ups): - W2: explicit `handleRemoveFromTodayTag` redundant with the diff — keep both for now; soak the diff before unifying. * test(sections): drop unused eslint-disable directives Use `as unknown as string` casts instead of `as any` + eslint-disable in the malformed-input reducer tests. Same intent (force the type assertion to model what a malicious peer's payload looks like at runtime), no lint suppression needed. * style(sections): drop redundant message from Add Section dialog The Add Section dialog passed both `placeholder: T.G.TITLE` and `message: T.CONFIRM.ADD_SECTION` to DialogPromptComponent. The message was redundant — its text ("Add Section") duplicated the dialog's contextual purpose, and showing it forced the mat-dialog-content out of `.isNoMsg` mode, adding outer padding that the Add Tag dialog doesn't have. Drop the message so the Add Section dialog matches the Add Tag visual pattern (no outer padding, just the input field). Also remove the now-unused `T.CONFIRM.ADD_SECTION` translation key. * fix(sections): apply round-8 multi-review batch Hardening: - `sanitizeSectionTitle` swaps `String(title ?? '')` for a `typeof === 'string'` fast-path. Closes two real defense gaps: - `String(Symbol())` would throw and crash the reducer. - `String({toString: () => 'x'.repeat(2**27)})` would materialize a ~256 MB string before the slice. JSON op-log payloads can smuggle 2 MB+ literal strings that survive the wire. Same behavior for the documented `null` / `undefined` cases. - Reducer + service now share a `hasTitleChange()` helper using `Object.hasOwn` instead of `'title' in changes`. Strictly better semantics — `'in'` walks the prototype chain, so a peer payload with `Object.create({title: 'x'})` would have triggered sanitization on a key the entity doesn't carry. - Service-side `updateSection` now uses the same key-presence check as the reducer (was `typeof === 'string'`); removes a future reader's "why two checks?" smell. UX / a11y: - Add Section dialog now passes `placeholder: T.WW.ADD_SECTION_TITLE` ("Add Section") instead of generic `T.G.TITLE` ("Title"). The dialog has no title element and no message text, so the placeholder is the only context users (especially screen-reader users) get for what they're naming. Mirrors the Add Tag pattern ("Add new Tag" placeholder). Convention: - Rename `section.utils.ts` → `section.util.ts`. Repo uses singular `*.util.ts` (60+ files) vs plural `*.utils.ts` (was 2 outliers). * feat(sections): Add Section in work-view background context menu Right-click on the empty area of the work-view (project, tag, or Today view) now opens a small context menu with one item: "Add Section". This is a discoverability complement to the side-nav overflow button — the work-view is where the user is already focused when they decide they need a section, so the action should be reachable without navigating back to the side-nav. Implementation: - `(contextmenu)` listener on `.task-list-wrapper` calls `onBgContextMenu`, which skips when the click target is inside an interactive element (task, button, input, drag handle, …) so per-element context menus and native form behavior aren't shadowed. - A hidden `[matMenuTriggerFor]` div is positioned at the cursor via signals (`bgContextMenuX`, `bgContextMenuY`). - `addSection()` reuses the same DialogPromptComponent shape as the side-nav addSection (no `message`, descriptive placeholder), and reads `activeWorkContextId` / `activeWorkContextType` from `WorkContextService` so it works across project / tag / Today. E2E test: right-click → menu item → submit dialog → assert the section is rendered. * fix(sections): initialize section slice in dataRepair for legacy migrations Legacy 'pf' databases predate the sections feature and don't carry a section field. After Typia validation rejects the missing field, `dataRepair` was supposed to fix it — but `_repairSections` early- returned on missing state instead of initializing it, so re-validation failed and `OperationLogMigrationService._performMigration` aborted with "Migration failed." Match the existing init pattern (archiveYoung, archiveOld, reminders): populate `dataOut.section = { ids: [], entities: {} }` if absent before per-slice repair runs. Verified by running e2e/tests/migration/legacy-data-migration.spec.ts. * fix(sections): apply round-9 thorough review batch Critical: - Reject section → BACKLOG drag in `enterPredicate`. The handler was removing the task from `section.taskIds` but never dispatching `moveProjectTaskToBacklogList`, so the task vanished from the section without appearing in the backlog. Mirrors the existing BACKLOG → section guard. Warnings: - `section.reducer.ts: loadAllData` now falls back to `initialSectionState` when the payload omits `section`. Previous behavior preserved stale local state, violating SYNC_IMPORT / BACKUP_IMPORT semantics ("complete fresh start"). - `markdown-paste.service.ts` checks `sectionTitle?.trim()` before calling `addSection`, so headers like `## ​` (zero-width space) fall through to the noSection bucket instead of creating a titleless section. - `moveToArchive` section cleanup unions payload subtasks with the state-derived expansion. Robust under both threat models: replay where the parent entity is gone from state (payload carries the tree) AND callers passing `subTasks: []` (state lookup is the only signal). Suggestions: - Section overflow button gets an `aria-label="Section options"` (new `T.WW.SECTION_OPTIONS` translation key). - `_repairSections` now does `Array.isArray(taskIds)` instead of `?? []` — defends against malformed remote payloads where `taskIds` is a truthy non-array value. - `acceptSectionDragOnly` discriminates on `'contextType' in data` rather than `Array.isArray(data.taskIds)`. The latter would silently accept Task drags if Task ever gained a `taskIds` field. Documentation: - Added LWW conflict-resolution gap to the section-shared meta-reducer's KNOWN FOLLOW-UPs block. Project- and non-TODAY-tag- context section.taskIds can leak phantom references after LWW resolves a tagIds / projectId update; the diff catches TODAY but not other contexts. Visible impact bounded by render-time intersection in `undoneTasksBySection`. Cleaned by next `dataRepair` pass. False positive: `extract-entity-keys.ts` already guards via `entityState?.ids` optional chain — no change needed. Verified: 24 reducer tests, 17 meta-reducer tests, 51 data-repair tests, 35 task-list tests, 7 work-view tests, 7 active section e2e tests + migration e2e all pass. * refactor(sections): simplification pass — drop micro-helpers + comment cleanup A focused simplification review of the branch found small wins that the correctness-focused rounds had accreted around. Removed: - `hasTitleChange()` helper. Single-line `Object.hasOwn(c, 'title')` is clearer at the two call sites than a 3-line wrapper. - `_parseSectionsCached` instance memo + private fields in `MarkdownPasteService`. The `MARKDOWN_TASK_OR_HEADER_RE` pre-screen already gates plain-text pastes; for genuine sectioned input the parser walks once cheaply and the dialog wait dwarfs parse time. Removes mutable instance state and the `MarkdownWithSections` type import that only existed for the cache. Comment trimming (74 LOC removed across 5 sites, no behavior change): - `addTaskToSection`: 22 → 8 lines (the architectural follow-up was duplicated elsewhere; kept the essential meta-shape note). - `Phase 3.5 placement` block: 11 → 3 lines (runtime `validateMetaReducerOrdering()` already enforces it). - `moveToArchive` handler: tightened the union-rationale. - `acceptSectionDragOnly`: 8 → 2 lines. - `updateSection` reducer: 6 → 2 lines. - `updateSection` service: dropped redundant explainer. Things considered and intentionally kept (defended): - `section.util.ts` — file is clean (one constant + one normalizer); matches the codebase's per-feature util pattern. - `section.service.ts` — codebase has a service per feature. - `EMPTY_SECTIONS` frozen const — a fresh `[]` per emission would cause downstream computed signals to recompute even for empty contexts. - Reducer-side `sanitizeSectionTitle(unknown)` — defends the real schema-evolution case where a peer ships malformed payloads. - Boot-guard 3-slice check — needed because the diff path dereferences tag state. Verified: 24 reducer tests, 17 meta-reducer tests pass. * refactor(sections): restrict scope to projects + TODAY tag Custom-tag sections added per-tag cleanup machinery (deleteTag / deleteTags handlers, handleTaskTagsChange, handleBulkTaskTagsChange, removeTasksFromTodayTag/localRemoveOverdueFromToday explicit handlers) and broadened the validation surface for limited end-user value. - Add isValidSectionContext helper enforcing PROJECT or TODAY-only. - Guard at all entry points: SectionService.addSection (returns null), section.reducer (rejects invalid contextId/Type), data-repair drops invalid sections on import. - Hide "Add Section" in custom-tag context menu and suppress the work-view bg right-click menu there. - Drop tag-scope handlers from section-shared meta-reducer; rely on the existing diff-based TODAY_TAG.taskIds path for TODAY cleanup. - Update unit tests to match. Net: -243 LOC. * refactor(sections): drop markdown section paste The H1-grouped markdown paste path created sections + tasks atomically from a hand-rolled parser, but the feature is niche, the parser is duplicated logic next to the existing flat-list and structured paste paths, and the documented atomicity caveat (partial state on concurrent sync) was never resolved. Falls back to the existing flat-list / sub-task paste paths, which cover the dominant use case. - Remove parseMarkdownWithSections + MarkdownWithSections / SectionWithTasks types. - Drop the section-paste branch and SectionService / WorkContextService deps from MarkdownPasteService. - Drop CONFIRM_SECTIONS i18n key. - Drop spec coverage for the removed parser. Net: -251 LOC. * refactor(sections): post-review hardening + cleanups Apply five findings from the latest cross-reviewer pass. - loadAllData filters imported sections through isValidSectionContext. Typia validates structure but not invariants, so a backup from an older client could otherwise smuggle custom-tag sections back in. - updateSection rejects payloads that touch contextId/contextType. No legitimate flow rewrites a section's context — a malformed peer would otherwise morph a project section into a custom-tag one. - Add 'section' to ENTITY_STATE_KEYS so _resetEntityIdsFromObjects reconciles ids ↔ entities like every other entity slice. - Drop redundant service-side sanitize in updateSection — the reducer is authoritative. - Refresh stale addSection JSDoc (markdown-paste reference removed with the section paste path in 16a0011d5f). * refactor(sections): drop YAGNI defenses against non-existent older clients The Sections feature is brand new on this branch — no released version ever produced custom-tag sections, so loadAllData filtering and an updateSection contextId/contextType reject defend against a threat that cannot exist. Trust internal code per project guidelines. Reverts two of the five defenses added in d4c9680837. The other three (ENTITY_STATE_KEYS section parity, redundant service-side sanitize, stale JSDoc) stay — they're not threat-defensive. * refactor(sections): drop redundant addSection reducer context guard The service-level guard at section.service.ts:44 is the only dispatch path; layering a hot-path reducer guard on top defends against a "developer dispatches the action directly past the service" scenario that doesn't exist. Trust internal code per project guidelines. Data-repair keeps its custom-tag check — that module's design contract is defensive cleanup of impossible state, a different category. * refactor(sections): merge bg right-click menu with Settings menu Two background context menus existed: app-level "Change Settings" and work-view-level "Add Section". Combine them into the single existing app-level menu, gating "Add Section" by PROJECT or TODAY context. - Move addSection() to AppComponent (mirrors openSettings). - Add the gated "Add Section" item to the backgroundContextMenu template. - Remove the work-view bg trigger, signals, viewChild, handler, and matching template block. - Update the right-click section e2e to dispatchEvent on .task-list-wrapper (the merged menu uses target.matches, not target.closest, so the previous position-based click was fragile against the empty-state placeholder). --------- Co-authored-by: Johannes Millan --- e2e/tests/work-view/sections.spec.ts | 324 ++++++++++++ packages/shared-schema/src/entity-types.ts | 1 + src/app/app.component.html | 14 + src/app/app.component.ts | 35 +- .../core-ui/drop-list/drop-list.service.ts | 31 +- .../work-context-menu.component.html | 10 + .../work-context-menu.component.ts | 32 ++ .../compact/action-type-codes.ts | 8 + src/app/features/section/section.model.ts | 14 + src/app/features/section/section.service.ts | 102 ++++ src/app/features/section/section.util.ts | 26 + .../features/section/store/section.actions.ts | 114 +++++ .../section/store/section.reducer.spec.ts | 373 ++++++++++++++ .../features/section/store/section.reducer.ts | 151 ++++++ .../section/store/section.selectors.ts | 29 ++ .../features/tasks/markdown-paste.service.ts | 10 + src/app/features/tasks/store/task.reducer.ts | 18 + .../task-list/task-list.component.spec.ts | 147 +++++- .../tasks/task-list/task-list.component.ts | 89 +++- src/app/features/tasks/task/task.component.ts | 1 + .../work-view/work-view.component.html | 74 +++ .../work-view/work-view.component.scss | 37 ++ .../work-view/work-view.component.spec.ts | 1 + .../features/work-view/work-view.component.ts | 119 ++++- src/app/imex/sync/sync.const.ts | 2 + src/app/imex/sync/sync.model.ts | 2 + .../op-log/backup/state-snapshot.service.ts | 12 +- src/app/op-log/core/action-types.enum.spec.ts | 4 +- src/app/op-log/core/action-types.enum.ts | 8 + src/app/op-log/core/types/backup.types.ts | 1 + src/app/op-log/model/model-config.ts | 8 + .../op-log/persistence/extract-entity-keys.ts | 1 + .../operation-log-compaction.service.spec.ts | 1 + src/app/op-log/validation/data-repair.ts | 73 +++ .../validation/is-related-model-data-valid.ts | 3 + .../validation/state-validity-test-utils.ts | 4 + .../validation/validate-state.service.spec.ts | 1 + src/app/op-log/validation/validation-fn.ts | 3 + src/app/root-store/feature-stores.module.ts | 6 + .../root-store/meta/meta-reducer-registry.ts | 30 +- .../meta/task-shared-meta-reducers/index.ts | 1 + .../section-shared.reducer.spec.ts | 474 ++++++++++++++++++ .../section-shared.reducer.ts | 345 +++++++++++++ src/app/t.const.ts | 5 + .../ui/collapsible/collapsible.component.html | 2 + src/app/util/app-data-mock.ts | 1 + src/app/util/parse-markdown-tasks.ts | 33 +- src/assets/i18n/en.json | 5 + 48 files changed, 2750 insertions(+), 35 deletions(-) create mode 100644 e2e/tests/work-view/sections.spec.ts create mode 100644 src/app/features/section/section.model.ts create mode 100644 src/app/features/section/section.service.ts create mode 100644 src/app/features/section/section.util.ts create mode 100644 src/app/features/section/store/section.actions.ts create mode 100644 src/app/features/section/store/section.reducer.spec.ts create mode 100644 src/app/features/section/store/section.reducer.ts create mode 100644 src/app/features/section/store/section.selectors.ts create mode 100644 src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.spec.ts create mode 100644 src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.ts diff --git a/e2e/tests/work-view/sections.spec.ts b/e2e/tests/work-view/sections.spec.ts new file mode 100644 index 0000000000..abc1993210 --- /dev/null +++ b/e2e/tests/work-view/sections.spec.ts @@ -0,0 +1,324 @@ +import { expect, test } from '../../fixtures/test.fixture'; + +/** + * Basic e2e coverage for the Sections feature. + * + * Sections live inside a project (and tag/today, but project is the + * simpler isolated surface). Each test creates its own project so they + * stay independent and the testPrefix keeps names unique across workers. + */ +test.describe('Sections', () => { + /** + * Create a fresh project and navigate to it. Avoids + * `createAndGoToTestProject()` which hits a strict-mode `.nav-children` + * violation when both Projects and Tags trees are expanded. + */ + const setupTestProject = async ( + workViewPage: import('../../pages/work-view.page').WorkViewPage, + projectPage: import('../../pages/project.page').ProjectPage, + projectName: string = 'Test Project', + ): Promise => { + await workViewPage.waitForTaskList(); + await projectPage.createProject(projectName); + await projectPage.navigateToProjectByName(projectName); + }; + + /** + * Open the work-context menu via the page-title's `.project-settings-btn` + * (the more_vert icon next to the project title in the main header). + * This is the same menu the side-nav `additional-btn` opens, but the + * header trigger is always visible without hover and isn't sensitive to + * the tree's expand/collapse state. + */ + const openProjectContextMenu = async ( + page: import('@playwright/test').Page, + ): Promise => { + const trigger = page.locator('.project-settings-btn'); + await trigger.waitFor({ state: 'visible', timeout: 10000 }); + await trigger.click(); + await page + .locator('work-context-menu') + .first() + .waitFor({ state: 'visible', timeout: 5000 }); + }; + + /** Click the "Add Section" item in the open work-context menu. */ + const clickAddSection = async ( + page: import('@playwright/test').Page, + ): Promise => { + await page.getByRole('menuitem', { name: 'Add Section' }).click(); + }; + + /** Fill the dialog-prompt input and submit. */ + const submitPromptDialog = async ( + page: import('@playwright/test').Page, + title: string, + ): Promise => { + const dialog = page.locator('mat-dialog-container'); + await dialog.waitFor({ state: 'visible', timeout: 5000 }); + const input = dialog.locator('input[type="text"]').first(); + await input.fill(title); + await dialog.getByRole('button', { name: 'Save' }).click(); + await dialog.waitFor({ state: 'hidden', timeout: 5000 }); + }; + + /** Locator for a rendered section block in the work view by title. */ + const sectionByTitle = ( + page: import('@playwright/test').Page, + title: string, + ): ReturnType => + page.locator('.section-container').filter({ hasText: title }); + + /** + * CDK drag-drop is event-driven via Angular CDK's own pointer-event + * handling. Playwright's `dragTo` uses HTML5 drag-and-drop events, + * which CDK ignores. Drive the gesture manually with multi-step mouse + * moves so the CDK threshold + drag start fires. + */ + const cdkDragTo = async ( + page: import('@playwright/test').Page, + source: import('@playwright/test').Locator, + target: import('@playwright/test').Locator, + ): Promise => { + const sBox = await source.boundingBox(); + const tBox = await target.boundingBox(); + if (!sBox || !tBox) throw new Error('drag source/target has no bounding box'); + /* eslint-disable no-mixed-operators */ + const sx = sBox.x + sBox.width / 2; + const sy = sBox.y + sBox.height / 2; + const tx = tBox.x + tBox.width / 2; + const ty = tBox.y + tBox.height / 2; + /* eslint-enable no-mixed-operators */ + + await page.mouse.move(sx, sy); + await page.mouse.down(); + // Initial nudge past CDK's drag threshold (5px by default). + await page.mouse.move(sx + 10, sy + 10, { steps: 5 }); + // Smooth move to target so each `mousemove` re-evaluates drop target. + await page.mouse.move(tx, ty, { steps: 20 }); + await page.mouse.up(); + }; + + test('creates a section via the project context menu', async ({ + page, + workViewPage, + projectPage, + }) => { + await setupTestProject(workViewPage, projectPage); + + await openProjectContextMenu(page); + await clickAddSection(page); + await submitPromptDialog(page, 'My Section'); + + await expect(sectionByTitle(page, 'My Section')).toBeVisible(); + }); + + test('creates a section via right-click on the work-view background', async ({ + page, + workViewPage, + projectPage, + }) => { + await setupTestProject(workViewPage, projectPage); + + // The app-level bg context menu (shared with "Change Settings") only + // opens when the click target itself matches the allowed selectors, + // not on descendants. dispatchEvent fires the contextmenu directly + // on the wrapper so target.matches('.task-list-wrapper') holds. + const wrapper = page.locator('.task-list-wrapper').first(); + await wrapper.waitFor({ state: 'visible', timeout: 5000 }); + await wrapper.dispatchEvent('contextmenu'); + + await page.getByRole('menuitem', { name: 'Add Section' }).click(); + await submitPromptDialog(page, 'Right-Click Section'); + + await expect(sectionByTitle(page, 'Right-Click Section')).toBeVisible(); + }); + + test('rejects whitespace-only section titles', async ({ + page, + workViewPage, + projectPage, + }) => { + await setupTestProject(workViewPage, projectPage); + + await openProjectContextMenu(page); + await clickAddSection(page); + + const dialog = page.locator('mat-dialog-container'); + await dialog.waitFor({ state: 'visible', timeout: 5000 }); + const input = dialog.locator('input[type="text"]').first(); + await input.fill(' '); + // The form's `required` validator considers whitespace truthy as a + // string, so Save dispatches — but the component-side trim guard + // (work-context-menu.component.ts) drops the dispatch. Verify no + // section appears. + await dialog.getByRole('button', { name: 'Save' }).click(); + // Dialog may stay open (required validation) or close without effect. + // Either way the section list must remain empty — `toHaveCount` + // already polls so no separate wait is needed. + await expect(page.locator('.section-container')).toHaveCount(0, { timeout: 1500 }); + }); + + test('edits an existing section title', async ({ page, workViewPage, projectPage }) => { + await setupTestProject(workViewPage, projectPage); + + await openProjectContextMenu(page); + await clickAddSection(page); + await submitPromptDialog(page, 'Original'); + + const section = sectionByTitle(page, 'Original'); + await expect(section).toBeVisible(); + + // Open the section's per-section menu and click Edit. + await section.locator('button[mat-icon-button]').click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); + + const dialog = page.locator('mat-dialog-container'); + await dialog.waitFor({ state: 'visible', timeout: 5000 }); + const input = dialog.locator('input[type="text"]').first(); + await input.fill('Renamed'); + await dialog.getByRole('button', { name: 'Save' }).click(); + await dialog.waitFor({ state: 'hidden', timeout: 5000 }); + + await expect(sectionByTitle(page, 'Renamed')).toBeVisible(); + await expect(sectionByTitle(page, 'Original')).toHaveCount(0); + }); + + test('deletes a section after confirmation', async ({ + page, + workViewPage, + projectPage, + }) => { + await setupTestProject(workViewPage, projectPage); + + await openProjectContextMenu(page); + await clickAddSection(page); + await submitPromptDialog(page, 'Doomed'); + + const section = sectionByTitle(page, 'Doomed'); + await expect(section).toBeVisible(); + + await section.locator('button[mat-icon-button]').click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + + const confirm = page.locator('dialog-confirm'); + await confirm.waitFor({ state: 'visible', timeout: 5000 }); + // The confirmation has an OK / Confirm button — match either. + await confirm + .getByRole('button', { name: /^(OK|Confirm)$/i }) + .first() + .click(); + await confirm.waitFor({ state: 'hidden', timeout: 5000 }); + + await expect(sectionByTitle(page, 'Doomed')).toHaveCount(0); + }); + + test('drops a task into a section via drag and drop', async ({ + page, + workViewPage, + projectPage, + }) => { + await setupTestProject(workViewPage, projectPage); + + // Add task BEFORE creating the section so it lives in the no-section + // bucket initially. + await workViewPage.addTask('Movable'); + await page.waitForSelector('task', { state: 'visible' }); + + await openProjectContextMenu(page); + await clickAddSection(page); + await submitPromptDialog(page, 'Target'); + + const section = sectionByTitle(page, 'Target'); + await expect(section).toBeVisible(); + // The section's inner task-list is the drop target. + const sectionTaskList = section.locator('task-list').first(); + const noSectionTaskList = page.locator('.no-section task-list').first(); + + // Drag handle is `done-toggle` (per task-dragdrop.spec.ts). + const task = page.locator('task').filter({ hasText: 'Movable' }).first(); + const dragHandle = task.locator('done-toggle').first(); + + await cdkDragTo(page, dragHandle, sectionTaskList); + + // Task should now render inside the section, not in the no-section list. + await expect(section.locator('task').filter({ hasText: 'Movable' })).toBeVisible({ + timeout: 5000, + }); + await expect( + noSectionTaskList.locator('task').filter({ hasText: 'Movable' }), + ).toHaveCount(0); + }); + + // FIXME: round-trip drag (section → no-section) is flaky in headless CDK. + // The forward "into section" drag passes; the reverse fails to register the + // drop on the empty `.no-section` task-list whose bounding box collapses + // to its hint-message. Driving CDK pointer events deterministically here + // is non-trivial — track as a follow-up and exercise reverse moves via + // unit tests in section.reducer.spec.ts (`removeTaskFromSection`). + test.fixme('drags a task back out of a section into the main list', async ({ + page, + workViewPage, + projectPage, + }) => { + await setupTestProject(workViewPage, projectPage); + + await workViewPage.addTask('Roundtrip'); + await page.waitForSelector('task', { state: 'visible' }); + + await openProjectContextMenu(page); + await clickAddSection(page); + await submitPromptDialog(page, 'Holding'); + + const section = sectionByTitle(page, 'Holding'); + const sectionTaskList = section.locator('task-list').first(); + // Target the wrapper `.no-section` div, not the inner task-list — when + // the no-section bucket is empty the task-list collapses to its hint + // text and may have a too-small bounding box for a stable drop. + const noSection = page.locator('.no-section').first(); + + const task = page.locator('task').filter({ hasText: 'Roundtrip' }).first(); + let dragHandle = task.locator('done-toggle').first(); + + // Move into section. + await cdkDragTo(page, dragHandle, sectionTaskList); + const taskInSection = section + .locator('task') + .filter({ hasText: 'Roundtrip' }) + .first(); + await expect(taskInSection).toBeVisible(); + + // Move back out — re-acquire handle from the new DOM location. + dragHandle = taskInSection.locator('done-toggle').first(); + await cdkDragTo(page, dragHandle, noSection); + + await expect(noSection.locator('task').filter({ hasText: 'Roundtrip' })).toBeVisible({ + timeout: 5000, + }); + await expect(section.locator('task').filter({ hasText: 'Roundtrip' })).toHaveCount(0); + }); + + test('sections persist across a page reload', async ({ + page, + workViewPage, + projectPage, + }) => { + await setupTestProject(workViewPage, projectPage); + + await openProjectContextMenu(page); + await clickAddSection(page); + await submitPromptDialog(page, 'Persistent'); + + await expect(sectionByTitle(page, 'Persistent')).toBeVisible(); + + // Reload — the project view re-hydrates from IndexedDB. + await page.reload(); + await workViewPage.waitForTaskList(); + + // The reload may land on Today; navigate back to the project so the + // section list is what's rendered. + await projectPage.navigateToProjectByName('Test Project'); + + await expect(sectionByTitle(page, 'Persistent')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/packages/shared-schema/src/entity-types.ts b/packages/shared-schema/src/entity-types.ts index 2b959d95ac..a1ba59c04e 100644 --- a/packages/shared-schema/src/entity-types.ts +++ b/packages/shared-schema/src/entity-types.ts @@ -27,6 +27,7 @@ export const ENTITY_TYPES = [ 'MENU_TREE', 'METRIC', 'BOARD', + 'SECTION', 'REMINDER', 'PLUGIN_USER_DATA', 'PLUGIN_METADATA', diff --git a/src/app/app.component.html b/src/app/app.component.html index 9324db727b..6fb0b9d672 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -111,5 +111,19 @@ ) | translate }} + @if ( + workContextService.activeWorkContextType === 'PROJECT' || + workContextService.activeWorkContextId === TODAY_TAG_ID + ) { + + } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index d6b083315d..38731b35be 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -60,6 +60,9 @@ import { ProjectService } from './features/project/project.service'; import { TagService } from './features/tag/tag.service'; import { ContextMenuComponent } from './ui/context-menu/context-menu.component'; import { WorkContextType } from './features/work-context/work-context.model'; +import { SectionService } from './features/section/section.service'; +import { DialogPromptComponent } from './ui/dialog-prompt/dialog-prompt.component'; +import { TODAY_TAG } from './features/tag/tag.const'; import type { WorkContextSettingsDialogData } from './features/work-context/dialog-work-context-settings/dialog-work-context-settings.component'; import { isInputElement } from './util/dom-element'; import { MobileBottomNavComponent } from './core-ui/mobile-bottom-nav/mobile-bottom-nav.component'; @@ -143,7 +146,9 @@ export class AppComponent implements OnDestroy, AfterViewInit { readonly layoutService = inject(LayoutService); readonly globalThemeService = inject(GlobalThemeService); readonly _store = inject(Store); + private _sectionService = inject(SectionService); readonly T = T; + readonly TODAY_TAG_ID = TODAY_TAG.id; readonly isShowMobileButtonNav = this.layoutService.isShowMobileBottomNav; @ViewChild('routeWrapper', { read: ElementRef }) routeWrapper?: ElementRef; @@ -277,16 +282,16 @@ export class AppComponent implements OnDestroy, AfterViewInit { let taskTitle: string | null = null; let isSubTask = false; - // Find task element by traversing up the DOM tree - let element: HTMLElement | null = target; - while (element && !element.id.startsWith('t-')) { - element = element.parentElement; + // Find the nearest task element via the data-task-id attribute (set + // on the host). Avoids brittle id-prefix scans that could match + // unrelated elements whose id happens to start with "t-". + const taskEl = target.closest('[data-task-id]'); + + if (taskEl) { + taskId = taskEl.getAttribute('data-task-id'); } - if (element && element.id.startsWith('t-')) { - // Extract task ID from DOM id (format: "t-{taskId}") - taskId = element.id.substring(2); - + if (taskId) { // Get task data to determine if it's a sub-task this._taskService.getByIdOnce$(taskId).subscribe((task) => { if (task) { @@ -389,6 +394,20 @@ export class AppComponent implements OnDestroy, AfterViewInit { }); } + async addSection(): Promise { + const ctxId = this.workContextService.activeWorkContextId; + const ctxType = this.workContextService.activeWorkContextType; + if (!ctxId || !ctxType) return; + const title = await firstValueFrom( + this._matDialog + .open(DialogPromptComponent, { data: { placeholder: T.WW.ADD_SECTION_TITLE } }) + .afterClosed(), + ); + if (typeof title === 'string' && title.trim()) { + this._sectionService.addSection(title, ctxId, ctxType); + } + } + isAppEntrance = signal(!this.isShowOnboardingPresets()); onPresetSelected(): void { diff --git a/src/app/core-ui/drop-list/drop-list.service.ts b/src/app/core-ui/drop-list/drop-list.service.ts index f4f5525c7d..a1cf278712 100644 --- a/src/app/core-ui/drop-list/drop-list.service.ts +++ b/src/app/core-ui/drop-list/drop-list.service.ts @@ -7,23 +7,44 @@ import { map, startWith, switchMap } from 'rxjs/operators'; providedIn: 'root', }) export class DropListService { - dropLists = new BehaviorSubject([]); + // Coalesce burst register/unregister calls (e.g. dozens of sections + // mounting in one CD pass) into a single downstream emission via a + // microtask flush. `cdkDropListConnectedTo` rebuilds its sibling + // graph per emission, so without this every list mount would be + // O(L²) total. + readonly dropLists = new BehaviorSubject([]); + blockAniTrigger$ = new Subject(); isBlockAniAfterDrop$ = this.blockAniTrigger$.pipe( switchMap(() => merge(of(true), timer(1200).pipe(map(() => false)))), startWith(false), ); + private _list: CdkDropList[] = []; + private _flushScheduled = false; + registerDropList(dropList: CdkDropList, isSubTaskList = false): void { if (isSubTaskList) { - this.dropLists.next([dropList, ...this.dropLists.getValue()]); + this._list.unshift(dropList); } else { - this.dropLists.next([...this.dropLists.getValue(), dropList]); + this._list.push(dropList); } - // Log.log(this.dropLists.getValue()); + this._scheduleFlush(); } unregisterDropList(dropList: CdkDropList): void { - this.dropLists.next(this.dropLists.getValue().filter((dl) => dl !== dropList)); + const idx = this._list.indexOf(dropList); + if (idx === -1) return; + this._list.splice(idx, 1); + this._scheduleFlush(); + } + + private _scheduleFlush(): void { + if (this._flushScheduled) return; + this._flushScheduled = true; + queueMicrotask(() => { + this._flushScheduled = false; + this.dropLists.next(this._list.slice()); + }); } } diff --git a/src/app/core-ui/work-context-menu/work-context-menu.component.html b/src/app/core-ui/work-context-menu/work-context-menu.component.html index 19d61fd103..26a6c9a396 100644 --- a/src/app/core-ui/work-context-menu/work-context-menu.component.html +++ b/src/app/core-ui/work-context-menu/work-context-menu.component.html @@ -49,6 +49,16 @@ +@if (isForProject || contextId === TODAY_TAG_ID) { + +} + + + + + + + + + + + } + } @else { descendants. The collapsible projects content + // via `[actions]`, which lands inside `.collapsible-header`; the next + // selector matches an icon-button that's a direct child of that header. + ::ng-deep > collapsible > .collapsible-header > button[mat-icon-button] { + background: transparent; + box-shadow: none; + color: var(--text-color-secondary); + border: none; + } +} diff --git a/src/app/features/work-view/work-view.component.spec.ts b/src/app/features/work-view/work-view.component.spec.ts index e6ba48c6b3..f225bf9079 100644 --- a/src/app/features/work-view/work-view.component.spec.ts +++ b/src/app/features/work-view/work-view.component.spec.ts @@ -109,6 +109,7 @@ describe('WorkViewComponent', () => { estimateRemainingToday$: of(0), workingToday$: of(0), isTodayList$: of(false), + activeWorkContextId$: of(null), activeWorkContextTypeAndId$: of({ activeType: 'TAG', activeId: 'TODAY', diff --git a/src/app/features/work-view/work-view.component.ts b/src/app/features/work-view/work-view.component.ts index 9b91aca37e..e85155d497 100644 --- a/src/app/features/work-view/work-view.component.ts +++ b/src/app/features/work-view/work-view.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectorRef, Component, computed, + DestroyRef, effect, ElementRef, afterNextRender, @@ -13,7 +14,12 @@ import { signal, ViewChild, } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { MatMenuModule } from '@angular/material/menu'; + import { TaskService } from '../tasks/task.service'; +import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component'; +import { DialogPromptComponent } from '../../ui/dialog-prompt/dialog-prompt.component'; import { expandAnimation, expandFadeAnimation } from '../../ui/animations/expand.ani'; import { LayoutService } from '../../core-ui/layout/layout.service'; import { TakeABreakService } from '../take-a-break/take-a-break.service'; @@ -30,14 +36,24 @@ import { } from 'rxjs'; import { TaskWithSubTasks } from '../tasks/task.model'; import { delay, filter, map, observeOn, switchMap } from 'rxjs/operators'; +import { of } from 'rxjs'; import { fadeAnimation } from '../../ui/animations/fade.ani'; import { T } from '../../t.const'; import { workViewProjectChangeAnimation } from '../../ui/animations/work-view-project-change.ani'; import { WorkContextService } from '../work-context/work-context.service'; import { ProjectService } from '../project/project.service'; import { TaskViewCustomizerService } from '../task-view-customizer/task-view-customizer.service'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { CdkDropListGroup } from '@angular/cdk/drag-drop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { SectionService } from '../section/section.service'; +import { Section } from '../section/section.model'; +import { + CdkDrag, + CdkDragDrop, + CdkDragHandle, + CdkDropList, + CdkDropListGroup, + moveItemInArray, +} from '@angular/cdk/drag-drop'; import { CdkScrollable } from '@angular/cdk/scrolling'; import { MatTooltip } from '@angular/material/tooltip'; import { MatIcon } from '@angular/material/icon'; @@ -82,6 +98,9 @@ import { recordSearchNavDebug } from '../../util/search-nav-debug'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CdkDropListGroup, + CdkDropList, + CdkDrag, + CdkDragHandle, CdkScrollable, MatTooltip, MatIcon, @@ -95,6 +114,7 @@ import { recordSearchNavDebug } from '../../util/search-nav-debug'; TranslatePipe, CollapsibleComponent, CommonModule, + MatMenuModule, FinishDayBtnComponent, ScheduledDateGroupPipe, RepeatCfgPreviewComponent, @@ -107,6 +127,7 @@ export class WorkViewComponent implements OnInit, OnDestroy { taskService = inject(TaskService); takeABreakService = inject(TakeABreakService); layoutService = inject(LayoutService); + sectionService = inject(SectionService); customizerService = inject(TaskViewCustomizerService); workContextService = inject(WorkContextService); private _activatedRoute = inject(ActivatedRoute); @@ -115,6 +136,8 @@ export class WorkViewComponent implements OnInit, OnDestroy { private _store = inject(Store); private _snackService = inject(SnackService); private _globalConfigService = inject(GlobalConfigService); + private _matDialog = inject(MatDialog); + private _destroyRef = inject(DestroyRef); isFinishDayEnabled = computed( () => this._globalConfigService.appFeatures().isFinishDayEnabled, @@ -170,6 +193,48 @@ export class WorkViewComponent implements OnInit, OnDestroy { !this.customizerService.isCustomized() && this.repeatCfgsForContext().length > 0, ); + // Section Logic + sections = toSignal( + this.workContextService.activeWorkContextId$.pipe( + switchMap((id) => + id + ? this.sectionService.getSectionsByContextId$(id) + : of([] as readonly Section[]), + ), + ), + { initialValue: [] as readonly Section[] }, + ); + + undoneTasksBySection = computed(() => { + const tasks = this.undoneTasks(); + const sections = this.sections(); + + if (!sections.length) { + return { dict: {} as Record, noSection: tasks }; + } + + // Build sectionId-by-taskId in O(m) where m = total taskIds across sections. + const sectionByTaskId = new Map(); + for (const s of sections) { + for (const tId of s.taskIds ?? []) sectionByTaskId.set(tId, s.id); + } + + const dict: Record = {}; + for (const s of sections) dict[s.id] = []; + const noSection: TaskWithSubTasks[] = []; + + for (const task of tasks) { + const sId = sectionByTaskId.get(task.id); + if (sId) { + dict[sId].push(task); + } else { + noSection.push(task); + } + } + + return { dict, noSection }; + }); + isShowOverduePanel = computed( () => this.isOnTodayList() && this.overdueTasks().length > 0, ); @@ -324,6 +389,39 @@ export class WorkViewComponent implements OnInit, OnDestroy { this.layoutService.isWorkViewScrolled.set(false); } + deleteSection(id: string): void { + this._matDialog + .open(DialogConfirmComponent, { + data: { + message: T.CONFIRM.DELETE_SECTION, + }, + }) + .afterClosed() + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe((isConfirm: boolean) => { + if (isConfirm) { + this.sectionService.deleteSection(id); + } + }); + } + + editSection(id: string, title: string): void { + this._matDialog + .open(DialogPromptComponent, { + data: { + placeholder: T.WW.ADD_SECTION_TITLE, + txtValue: title, + }, + }) + .afterClosed() + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe((newTitle: string | undefined) => { + if (newTitle?.trim()) { + this.sectionService.updateSection(id, { title: newTitle }); + } + }); + } + resetBreakTimer(): void { this.takeABreakService.resetTimer(); } @@ -355,6 +453,23 @@ export class WorkViewComponent implements OnInit, OnDestroy { ); } + // Reject task drags into the section-reorder list (cdkDropListGroup + // shares targets). `contextType` is section-exclusive. + acceptSectionDragOnly = (drag: CdkDrag): boolean => { + const data = drag.data as Section | undefined; + return !!data && 'contextType' in data; + }; + + dropSection(event: CdkDragDrop): void { + if (event.previousIndex === event.currentIndex) return; + const contextId = this.workContextService.activeWorkContextId; + if (!contextId) return; + + const ids = this.sections().map((s) => s.id); + moveItemInArray(ids, event.previousIndex, event.currentIndex); + this.sectionService.updateSectionOrder(contextId, ids); + } + private _initScrollTracking(): void { this._subs.add( this.upperContainerScroll$.subscribe(({ target }) => { diff --git a/src/app/imex/sync/sync.const.ts b/src/app/imex/sync/sync.const.ts index 7713dd7ee0..718006c818 100644 --- a/src/app/imex/sync/sync.const.ts +++ b/src/app/imex/sync/sync.const.ts @@ -1,3 +1,4 @@ +import { initialSectionState } from '../../features/section/store/section.reducer'; import { initialProjectState } from '../../features/project/store/project.reducer'; import { initialTaskState } from '../../features/tasks/store/task.reducer'; import { initialTagState } from '../../features/tag/store/tag.reducer'; @@ -67,6 +68,7 @@ export const DEFAULT_APP_BASE_DATA: AppBaseData = { }, taskRepeatCfg: initialTaskRepeatCfgState, note: initialNoteState, + section: initialSectionState, metric: initialMetricState, }; diff --git a/src/app/imex/sync/sync.model.ts b/src/app/imex/sync/sync.model.ts index f872c20a63..45a7693e9b 100644 --- a/src/app/imex/sync/sync.model.ts +++ b/src/app/imex/sync/sync.model.ts @@ -13,6 +13,7 @@ import { PlannerState } from '../../features/planner/store/planner.reducer'; import { IssueProviderState } from '../../features/issue/issue.model'; import { BoardsState } from '../../features/boards/store/boards.reducer'; import { MenuTreeState } from '../../features/menu-tree/store/menu-tree.model'; +import { SectionState } from '../../features/section/section.model'; export interface AppBaseWithoutLastSyncModelChange { project: ProjectState; @@ -30,6 +31,7 @@ export interface AppBaseWithoutLastSyncModelChange { tag: TagState; simpleCounter: SimpleCounterState; taskRepeatCfg: TaskRepeatCfgState; + section: SectionState; } export interface AppMainFileNoRevsData extends AppBaseWithoutLastSyncModelChange { diff --git a/src/app/op-log/backup/state-snapshot.service.ts b/src/app/op-log/backup/state-snapshot.service.ts index 4a24c515c0..76bff2111e 100644 --- a/src/app/op-log/backup/state-snapshot.service.ts +++ b/src/app/op-log/backup/state-snapshot.service.ts @@ -18,6 +18,7 @@ import { selectSimpleCounterFeatureState } from '../../features/simple-counter/s import { selectTagFeatureState } from '../../features/tag/store/tag.reducer'; import { selectTaskFeatureState } from '../../features/tasks/store/task.selectors'; import { selectTaskRepeatCfgFeatureState } from '../../features/task-repeat-cfg/store/task-repeat-cfg.selectors'; +import { selectSectionFeatureState } from '../../features/section/store/section.selectors'; import { selectTimeTrackingState } from '../../features/time-tracking/store/time-tracking.selectors'; import { environment } from '../../../environments/environment'; import { ArchiveModel } from '../../features/time-tracking/time-tracking.model'; @@ -108,6 +109,7 @@ export class StateSnapshotService { this._store.select(selectPluginUserDataFeatureState), this._store.select(selectPluginMetadataFeatureState), this._store.select(selectReminderFeatureState), + this._store.select(selectSectionFeatureState), ]).pipe(first()), ); @@ -128,6 +130,7 @@ export class StateSnapshotService { pluginUserData, pluginMetadata, reminders, + section, ] = ngRxData; return { @@ -153,6 +156,7 @@ export class StateSnapshotService { pluginUserData, pluginMetadata, reminders, + section, archiveYoung, archiveOld, }; @@ -176,7 +180,8 @@ export class StateSnapshotService { let simpleCounter: unknown, taskRepeatCfg: unknown, menuTree: unknown, - timeTracking: unknown; + timeTracking: unknown, + section: unknown; let pluginUserData: unknown, pluginMetadata: unknown, reminders: unknown; // Subscribe synchronously to get current values @@ -244,6 +249,10 @@ export class StateSnapshotService { .select(selectReminderFeatureState) .pipe(first()) .subscribe((v) => (reminders = v)); + this._store + .select(selectSectionFeatureState) + .pipe(first()) + .subscribe((v) => (section = v)); return { task: { @@ -268,6 +277,7 @@ export class StateSnapshotService { pluginUserData, pluginMetadata, reminders, + section, }; } diff --git a/src/app/op-log/core/action-types.enum.spec.ts b/src/app/op-log/core/action-types.enum.spec.ts index c8696c8c0c..a96c73e050 100644 --- a/src/app/op-log/core/action-types.enum.spec.ts +++ b/src/app/op-log/core/action-types.enum.spec.ts @@ -12,8 +12,8 @@ describe('ActionType enum', () => { const enumValues = Object.values(ActionType) as string[]; const mappingKeys = Object.keys(ACTION_TYPE_TO_CODE); - it('should have exactly 136 members', () => { - expect(enumValues.length).toBe(136); + it('should have exactly 142 members', () => { + expect(enumValues.length).toBe(142); }); it('should have 1:1 correspondence with ACTION_TYPE_TO_CODE', () => { diff --git a/src/app/op-log/core/action-types.enum.ts b/src/app/op-log/core/action-types.enum.ts index df832efdb1..e08227fd8f 100644 --- a/src/app/op-log/core/action-types.enum.ts +++ b/src/app/op-log/core/action-types.enum.ts @@ -132,6 +132,14 @@ export enum ActionType { REPEAT_CFG_DELETE_INSTANCE = '[TaskRepeatCfg] Delete Single Instance', REPEAT_CFG_UPSERT = '[TaskRepeatCfg] Upsert TaskRepeatCfg', + // Section actions (S) + SECTION_ADD = '[Section] Add Section', + SECTION_DELETE = '[Section] Delete Section', + SECTION_UPDATE = '[Section] Update Section', + SECTION_UPDATE_ORDER = '[Section] Update Section Order', + SECTION_ADD_TASK = '[Section] Add Task to Section', + SECTION_REMOVE_TASK = '[Section] Remove Task from Section', + // SimpleCounter actions (S) COUNTER_ADD = '[SimpleCounter] Add SimpleCounter', COUNTER_UPDATE = '[SimpleCounter] Update SimpleCounter', diff --git a/src/app/op-log/core/types/backup.types.ts b/src/app/op-log/core/types/backup.types.ts index 46a3e2cacd..46b4f96638 100644 --- a/src/app/op-log/core/types/backup.types.ts +++ b/src/app/op-log/core/types/backup.types.ts @@ -21,6 +21,7 @@ export interface AppStateSnapshot { pluginUserData: unknown; pluginMetadata: unknown; reminders: unknown; + section: unknown; archiveYoung: ArchiveModel; archiveOld: ArchiveModel; } diff --git a/src/app/op-log/model/model-config.ts b/src/app/op-log/model/model-config.ts index a98c698ddf..abd2cfafa5 100644 --- a/src/app/op-log/model/model-config.ts +++ b/src/app/op-log/model/model-config.ts @@ -26,6 +26,8 @@ import { initialMetricState } from '../../features/metric/store/metric.reducer'; import { initialTaskState } from '../../features/tasks/store/task.reducer'; import { initialTagState } from '../../features/tag/store/tag.reducer'; import { initialSimpleCounterState } from '../../features/simple-counter/store/simple-counter.reducer'; +import { initialSectionState } from '../../features/section/store/section.reducer'; +import { SectionState } from '../../features/section/section.model'; import { initialTaskRepeatCfgState } from '../../features/task-repeat-cfg/store/task-repeat-cfg.reducer'; import { ArchiveModel, @@ -55,6 +57,7 @@ export type AllModelConfig = { task: ModelCfg; tag: ModelCfg; simpleCounter: ModelCfg; + section: ModelCfg; taskRepeatCfg: ModelCfg; reminders: ModelCfg; timeTracking: ModelCfg; @@ -91,6 +94,11 @@ export const MODEL_CONFIGS: AllModelConfig = { isMainFileModel: true, repair: fixEntityStateConsistency, }, + section: { + defaultData: initialSectionState, + isMainFileModel: true, + repair: fixEntityStateConsistency, + }, note: { defaultData: initialNoteState, isMainFileModel: true, diff --git a/src/app/op-log/persistence/extract-entity-keys.ts b/src/app/op-log/persistence/extract-entity-keys.ts index 5e0c120c86..006679a771 100644 --- a/src/app/op-log/persistence/extract-entity-keys.ts +++ b/src/app/op-log/persistence/extract-entity-keys.ts @@ -25,6 +25,7 @@ export const extractEntityKeysFromState = (state: AppStateSnapshot): string[] => { key: 'SIMPLE_COUNTER', state: state.simpleCounter as EntityState }, { key: 'TASK_REPEAT_CFG', state: state.taskRepeatCfg as EntityState }, { key: 'METRIC', state: state.metric as EntityState }, + { key: 'SECTION', state: state.section as EntityState }, ]; for (const { key, state: entityState } of entityStates) { diff --git a/src/app/op-log/persistence/operation-log-compaction.service.spec.ts b/src/app/op-log/persistence/operation-log-compaction.service.spec.ts index 7b5b2ef526..0d0fd65e61 100644 --- a/src/app/op-log/persistence/operation-log-compaction.service.spec.ts +++ b/src/app/op-log/persistence/operation-log-compaction.service.spec.ts @@ -718,6 +718,7 @@ describe('OperationLogCompactionService', () => { archiveOld: 'TASK', // Archives map to TASK pluginUserData: 'PLUGIN_USER_DATA', pluginMetadata: 'PLUGIN_METADATA', + section: 'SECTION', }; const missingModels: string[] = []; diff --git a/src/app/op-log/validation/data-repair.ts b/src/app/op-log/validation/data-repair.ts index 45474ce91e..67cd99c2b9 100644 --- a/src/app/op-log/validation/data-repair.ts +++ b/src/app/op-log/validation/data-repair.ts @@ -39,6 +39,7 @@ const ENTITY_STATE_KEYS: (keyof AppDataCompleteLegacy)[] = [ 'metric', 'task', 'taskRepeatCfg', + 'section', ]; export const dataRepair = ( @@ -93,6 +94,14 @@ export const dataRepair = ( dataOut.reminders = []; } + // Initialize section slice if missing — legacy pf databases (pre-section + // feature) don't carry one, and Typia's `DataToValidate` validator + // requires it. Without this, `validateFull` after `dataRepair` still + // fails and OperationLogMigrationService aborts the migration. + if (!dataOut.section) { + dataOut.section = { ids: [], entities: {} }; + } + // NOTE: We no longer merge archiveOld into archiveYoung during repair. // The dual-archive architecture keeps them separate for proper age-based archiving. @@ -122,6 +131,7 @@ export const dataRepair = ( dataOut = _removeNonExistentTagsFromTasks(dataOut, summary); dataOut = _addInboxProjectIdIfNecessary(dataOut, summary); dataOut = _repairMenuTree(dataOut, summary); + dataOut = _repairSections(dataOut, summary); dataOut = autoFixTypiaErrors(dataOut, errors); summary.typeErrorsFixed = errors.length; @@ -1356,3 +1366,66 @@ const _repairMenuTree = ( return data; }; + +const _repairSections = ( + data: AppDataComplete, + summary: RepairSummary, +): AppDataComplete => { + const sectionState = data.section; + if (!sectionState?.ids?.length) return data; + + const validProjectIds = new Set(data.project.ids as string[]); + const validTaskIds = new Set(data.task.ids as string[]); + + const keptIds: string[] = []; + const newEntities: typeof sectionState.entities = {}; + let droppedSections = 0; + let droppedTaskRefs = 0; + + for (const sid of sectionState.ids as string[]) { + const section = sectionState.entities[sid]; + if (!section) continue; + + // Sections are only valid in PROJECT contexts (with a live projectId) + // or the singleton TODAY tag. Custom-tag sections are rejected at + // dispatch boundaries; surviving ones in stored data are dropped. + const isValidProject = + section.contextType === 'PROJECT' && validProjectIds.has(section.contextId); + const isTodayTag = + section.contextType === 'TAG' && section.contextId === TODAY_TAG.id; + if (!isValidProject && !isTodayTag) { + droppedSections++; + continue; + } + + // Defend against malformed remote payloads where `taskIds` is not + // an array (truthy `?? []` would slip through and `.filter` would + // throw or yield garbage on a string / object value). + const taskIds = Array.isArray(section.taskIds) ? section.taskIds : []; + const filtered = taskIds.filter((tid) => validTaskIds.has(tid)); + if (filtered.length !== taskIds.length) { + droppedTaskRefs += taskIds.length - filtered.length; + newEntities[sid] = { ...section, taskIds: filtered }; + } else { + newEntities[sid] = section; + } + keptIds.push(sid); + } + + if (droppedSections === 0 && droppedTaskRefs === 0) return data; + + data.section = { ids: keptIds, entities: newEntities }; + if (droppedSections > 0) { + OpLog.warn( + `[data-repair] Removed ${droppedSections} section(s) with missing project/tag`, + ); + summary.invalidReferencesRemoved += droppedSections; + } + if (droppedTaskRefs > 0) { + OpLog.warn( + `[data-repair] Removed ${droppedTaskRefs} stale task reference(s) from sections`, + ); + summary.invalidReferencesRemoved += droppedTaskRefs; + } + return data; +}; diff --git a/src/app/op-log/validation/is-related-model-data-valid.ts b/src/app/op-log/validation/is-related-model-data-valid.ts index 2f578ea6ff..c06511ea0d 100644 --- a/src/app/op-log/validation/is-related-model-data-valid.ts +++ b/src/app/op-log/validation/is-related-model-data-valid.ts @@ -85,6 +85,9 @@ export const isRelatedModelDataValid = (d: AppDataComplete): boolean => { return false; } + // Section orphans (missing context, stale taskIds) are repaired by + // `repairSections` in data-repair.ts; no separate validation step. + return true; }; diff --git a/src/app/op-log/validation/state-validity-test-utils.ts b/src/app/op-log/validation/state-validity-test-utils.ts index 94cab56b68..faa5645aa5 100644 --- a/src/app/op-log/validation/state-validity-test-utils.ts +++ b/src/app/op-log/validation/state-validity-test-utils.ts @@ -41,6 +41,7 @@ import { plannerFeatureKey } from '../../features/planner/store/planner.reducer' import { TIME_TRACKING_FEATURE_KEY } from '../../features/time-tracking/store/time-tracking.reducer'; import { appStateFeatureKey } from '../../root-store/app-state/app-state.reducer'; import { getDbDateStr } from '../../util/get-db-date-str'; +import { initialSectionState } from '../../features/section/store/section.reducer'; /** * Creates a minimal valid AppDataComplete state. @@ -87,6 +88,7 @@ export const createValidAppData = ( entities: {}, todayOrder: [], }, + section: initialSectionState, menuTree: { ...menuTreeInitialState, projectTree: [{ k: MenuTreeKind.PROJECT, id: 'INBOX' } as MenuTreeProjectNode], @@ -332,6 +334,7 @@ export const rootStateToAppData = ( reminders?: AppDataComplete['reminders']; pluginUserData?: AppDataComplete['pluginUserData']; pluginMetadata?: AppDataComplete['pluginMetadata']; + section?: AppDataComplete['section']; } = {}, ): AppDataComplete => { return { @@ -344,6 +347,7 @@ export const rootStateToAppData = ( planner: state[plannerFeatureKey], boards: state[BOARDS_FEATURE_NAME], timeTracking: state[TIME_TRACKING_FEATURE_KEY], + section: additionalData.section || initialSectionState, // These are either from additional data or defaults simpleCounter: additionalData.simpleCounter || initialSimpleCounterState, taskRepeatCfg: additionalData.taskRepeatCfg || initialTaskRepeatCfgState, diff --git a/src/app/op-log/validation/validate-state.service.spec.ts b/src/app/op-log/validation/validate-state.service.spec.ts index c67e017e5f..8671899862 100644 --- a/src/app/op-log/validation/validate-state.service.spec.ts +++ b/src/app/op-log/validation/validate-state.service.spec.ts @@ -31,6 +31,7 @@ describe('ValidateStateService', () => { project: { ids: [], entities: {} }, tag: { ids: [], entities: {} }, note: { ids: [], entities: {}, todayOrder: [] }, + section: { ids: [], entities: {} }, simpleCounter: { ids: [], entities: {} }, issueProvider: { ids: [], entities: {} }, taskRepeatCfg: { ids: [], entities: {} }, diff --git a/src/app/op-log/validation/validation-fn.ts b/src/app/op-log/validation/validation-fn.ts index b93745b8ae..ad3fc1f85e 100644 --- a/src/app/op-log/validation/validation-fn.ts +++ b/src/app/op-log/validation/validation-fn.ts @@ -8,6 +8,7 @@ import { TimeTrackingState, } from '../../features/time-tracking/time-tracking.model'; import { ProjectState } from '../../features/project/project.model'; +import { SectionState } from '../../features/section/section.model'; import { MenuTreeState } from '../../features/menu-tree/store/menu-tree.model'; import { TaskState } from '../../features/tasks/task.model'; import { createValidate } from 'typia'; @@ -52,6 +53,7 @@ const _validateGlobalConfig = createValidate(); const _validateTimeTracking = createValidate(); const _validatePluginUserData = createValidate(); const _validatePluginMetadata = createValidate(); +const _validateSection = createValidate(); export const validateAllData = ( d: AppDataComplete | R, @@ -101,6 +103,7 @@ export const appDataValidators: { _wrapValidate(_validatePluginUserData(d)), pluginMetadata: (d: R | PluginMetaDataState) => _wrapValidate(_validatePluginMetadata(d)), + section: (d: R | SectionState) => _wrapValidate(_validateSection(d), d, true), } as const; const validateArchiveModel = (d: ArchiveModel | R): ValidationResult => { diff --git a/src/app/root-store/feature-stores.module.ts b/src/app/root-store/feature-stores.module.ts index e067bcfd63..8e3f14b06b 100644 --- a/src/app/root-store/feature-stores.module.ts +++ b/src/app/root-store/feature-stores.module.ts @@ -40,6 +40,10 @@ import { simpleCounterReducer, } from '../features/simple-counter/store/simple-counter.reducer'; import { SimpleCounterEffects } from '../features/simple-counter/store/simple-counter.effects'; +import { + SECTION_FEATURE_NAME, + sectionReducer, +} from '../features/section/store/section.reducer'; import { TAG_FEATURE_NAME, tagReducer } from '../features/tag/store/tag.reducer'; import { TagEffects } from '../features/tag/store/tag.effects'; import { @@ -137,6 +141,8 @@ import { StoreModule.forFeature(SIMPLE_COUNTER_FEATURE_NAME, simpleCounterReducer), EffectsModule.forFeature([SimpleCounterEffects]), + StoreModule.forFeature(SECTION_FEATURE_NAME, sectionReducer), + StoreModule.forFeature(TAG_FEATURE_NAME, tagReducer), EffectsModule.forFeature([TagEffects]), diff --git a/src/app/root-store/meta/meta-reducer-registry.ts b/src/app/root-store/meta/meta-reducer-registry.ts index b30ca1a6c3..0794fc4fde 100644 --- a/src/app/root-store/meta/meta-reducer-registry.ts +++ b/src/app/root-store/meta/meta-reducer-registry.ts @@ -10,6 +10,7 @@ import { taskSharedSchedulingMetaReducer } from './task-shared-meta-reducers/tas import { taskSharedDeadlineMetaReducer } from './task-shared-meta-reducers/task-shared-deadline.reducer'; import { projectSharedMetaReducer } from './task-shared-meta-reducers/project-shared.reducer'; import { tagSharedMetaReducer } from './task-shared-meta-reducers/tag-shared.reducer'; +import { sectionSharedMetaReducer } from './task-shared-meta-reducers/section-shared.reducer'; import { issueProviderSharedMetaReducer } from './task-shared-meta-reducers/issue-provider-shared.reducer'; import { taskRepeatCfgSharedMetaReducer } from './task-shared-meta-reducers/task-repeat-cfg-shared.reducer'; import { plannerSharedMetaReducer } from './task-shared-meta-reducers/planner-shared.reducer'; @@ -37,6 +38,14 @@ import { actionLoggerReducer } from './action-logger.reducer'; * Captures task context before deletion for undo functionality. * Must run before CRUD operations that actually delete. * + * ## Phase 3.5: Pre-CRUD Entity-Cascade Snapshots + * Meta-reducers that must read task/project/tag state BEFORE Phase 4 + * mutates it (e.g. resolving a task's old projectId on + * moveToOtherProject, or its old tagIds on updateTask). Once Phase 4 + * applies, those values are gone. + * - sectionShared: section.taskIds cleanup on task move/delete + tag + * removal; orphan-section removal on project/tag delete. + * * ## Phase 4: Core CRUD Operations * Task add/update/delete with project & tag cleanup. * These run in dependency order: @@ -88,6 +97,12 @@ export const META_REDUCERS: MetaReducer[] = [ // Captures task context before deletion for undo/restore. undoTaskDeleteMetaReducer, + // sectionSharedMetaReducer must run BEFORE taskSharedCrudMetaReducer because + // it needs to expand subTaskIds via state.task.entities for the deleteTasks + // bulk action — once Phase 4 strips the parent, the subtask references are + // gone and section.taskIds entries pointing at removed subtasks would leak. + sectionSharedMetaReducer, // Task deletion → prune section.taskIds (incl. subtasks) + // ═══════════════════════════════════════════════════════════════════════════ // PHASE 4: CORE CRUD OPERATIONS (Ordered by dependency) // ═══════════════════════════════════════════════════════════════════════════ @@ -129,7 +144,9 @@ export const META_REDUCERS: MetaReducer[] = [ * Critical constraints: * 1. operationCaptureMetaReducer MUST be at index 0 (captures state BEFORE any modifications) * 2. bulkOperationsMetaReducer MUST be at index 1 (unwraps bulk dispatches early) - * 3. actionLoggerReducer MUST be last (pure logging after all modifications) + * 3. sectionSharedMetaReducer (Phase 3.5) MUST run before taskSharedCrudMetaReducer + * (it reads pre-CRUD task state for moveToOtherProject and updateTask handlers) + * 4. actionLoggerReducer MUST be last (pure logging after all modifications) */ const validateMetaReducerOrdering = (): void => { if (!isDevMode()) { @@ -152,6 +169,17 @@ const validateMetaReducerOrdering = (): void => { ); } + // Phase 3.5: sectionSharedMetaReducer must precede taskSharedCrudMetaReducer. + // It needs to read state.task.entities for moveToOtherProject (oldProjectId) + // and updateTask (oldTagIds) — values that Phase 4 mutates away. + const sectionIdx = META_REDUCERS.indexOf(sectionSharedMetaReducer); + const crudIdx = META_REDUCERS.indexOf(taskSharedCrudMetaReducer); + if (sectionIdx === -1 || crudIdx === -1 || sectionIdx >= crudIdx) { + errors.push( + 'sectionSharedMetaReducer MUST run before taskSharedCrudMetaReducer (Phase 3.5 — pre-CRUD state read)', + ); + } + // Check actionLoggerReducer is last if (META_REDUCERS[META_REDUCERS.length - 1] !== actionLoggerReducer) { errors.push( diff --git a/src/app/root-store/meta/task-shared-meta-reducers/index.ts b/src/app/root-store/meta/task-shared-meta-reducers/index.ts index 6b3e48ee2b..3ee4772278 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/index.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/index.ts @@ -4,6 +4,7 @@ export { taskSharedSchedulingMetaReducer } from './task-shared-scheduling.reduce export { taskSharedDeadlineMetaReducer } from './task-shared-deadline.reducer'; export { projectSharedMetaReducer } from './project-shared.reducer'; export { tagSharedMetaReducer } from './tag-shared.reducer'; +export { sectionSharedMetaReducer } from './section-shared.reducer'; export { plannerSharedMetaReducer } from './planner-shared.reducer'; export { taskBatchUpdateMetaReducer } from './task-batch-update.reducer'; export { issueProviderSharedMetaReducer } from './issue-provider-shared.reducer'; diff --git a/src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.spec.ts new file mode 100644 index 0000000000..775be86a9c --- /dev/null +++ b/src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.spec.ts @@ -0,0 +1,474 @@ +import { Action, ActionReducer } from '@ngrx/store'; +import { sectionSharedMetaReducer } from './section-shared.reducer'; +import { TaskSharedActions } from '../task-shared.actions'; +import { RootState } from '../../root-state'; +import { TASK_FEATURE_NAME } from '../../../features/tasks/store/task.reducer'; +import { TAG_FEATURE_NAME } from '../../../features/tag/store/tag.reducer'; +import { SECTION_FEATURE_NAME } from '../../../features/section/store/section.reducer'; +import { Section, SectionState } from '../../../features/section/section.model'; +import { createBaseState, createMockTask } from './test-utils'; +import { Task } from '../../../features/tasks/task.model'; +import { WorkContextType } from '../../../features/work-context/work-context.model'; +import { TODAY_TAG } from '../../../features/tag/tag.const'; + +const sectionStateOf = (sections: Section[]): SectionState => ({ + ids: sections.map((s) => s.id), + entities: Object.fromEntries(sections.map((s) => [s.id, s])), +}); + +const stateWith = ( + tasks: Record>, + sections: Section[], +): RootState & { [SECTION_FEATURE_NAME]: SectionState } => { + const base = createBaseState(); + const taskIds = Object.keys(tasks); + const taskEntities: Record = {}; + for (const id of taskIds) { + taskEntities[id] = createMockTask({ id, ...tasks[id] }); + } + return { + ...base, + [TASK_FEATURE_NAME]: { + ...base[TASK_FEATURE_NAME], + ids: taskIds, + entities: taskEntities, + }, + [SECTION_FEATURE_NAME]: sectionStateOf(sections), + }; +}; + +describe('sectionSharedMetaReducer', () => { + let mockReducer: jasmine.Spy; + let metaReducer: ActionReducer; + + beforeEach(() => { + mockReducer = jasmine.createSpy('reducer').and.callFake((s) => s); + metaReducer = sectionSharedMetaReducer(mockReducer); + }); + + it('removes a deleted task id from any section that referenced it', () => { + const state = stateWith({ t1: {}, t2: {} }, [ + { + id: 's1', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'A', + taskIds: ['t1', 't2'], + }, + ]); + const action = TaskSharedActions.deleteTask({ + task: state[TASK_FEATURE_NAME].entities.t1 as Task, + } as any); + + metaReducer(state, action); + + const updated = (mockReducer.calls.mostRecent().args[0] as any)[ + SECTION_FEATURE_NAME + ] as SectionState; + expect(updated.entities['s1']?.taskIds).toEqual(['t2']); + }); + + it('cascades subtask removal alongside the parent', () => { + const state = stateWith( + { + parent: { subTaskIds: ['sub1', 'sub2'] }, + sub1: { parentId: 'parent' }, + sub2: { parentId: 'parent' }, + other: {}, + }, + [ + { + id: 's1', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'A', + // sections only hold parent task ids in the new model, but the + // meta-reducer must defensively scrub subtask ids too. + taskIds: ['parent', 'other'], + }, + { + id: 's2', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'B', + taskIds: ['sub1'], + }, + ], + ); + + metaReducer( + state, + TaskSharedActions.deleteTask({ + task: state[TASK_FEATURE_NAME].entities.parent as Task, + } as any), + ); + + const updated = (mockReducer.calls.mostRecent().args[0] as any)[ + SECTION_FEATURE_NAME + ] as SectionState; + expect(updated.entities['s1']?.taskIds).toEqual(['other']); + expect(updated.entities['s2']?.taskIds).toEqual([]); + }); + + it('strips archived tasks (and their subtasks) from sections on moveToArchive', () => { + const state = stateWith( + { + parent: { subTaskIds: ['sub1'] }, + sub1: { parentId: 'parent' }, + other: {}, + }, + [ + { + id: 's1', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'A', + taskIds: ['parent', 'other'], + }, + { + id: 's2', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'B', + taskIds: ['sub1'], + }, + ], + ); + + metaReducer( + state, + TaskSharedActions.moveToArchive({ + tasks: [{ ...state[TASK_FEATURE_NAME].entities.parent, subTasks: [] } as any], + } as any), + ); + + const updated = (mockReducer.calls.mostRecent().args[0] as any)[ + SECTION_FEATURE_NAME + ] as SectionState; + expect(updated.entities['s1']?.taskIds).toEqual(['other']); + expect(updated.entities['s2']?.taskIds).toEqual([]); + }); + + it('handles deleteTasks (bulk) across multiple sections', () => { + const state = stateWith({ t1: {}, t2: {}, t3: {}, t4: {} }, [ + { + id: 's1', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'A', + taskIds: ['t1', 't2'], + }, + { + id: 's2', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'B', + taskIds: ['t3', 't4'], + }, + ]); + + metaReducer(state, TaskSharedActions.deleteTasks({ taskIds: ['t1', 't3'] })); + + const updated = (mockReducer.calls.mostRecent().args[0] as any)[ + SECTION_FEATURE_NAME + ] as SectionState; + expect(updated.entities['s1']?.taskIds).toEqual(['t2']); + expect(updated.entities['s2']?.taskIds).toEqual(['t4']); + }); + + it('passes through unrelated actions unchanged', () => { + const state = stateWith({ t1: {} }, [ + { + id: 's1', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'A', + taskIds: ['t1'], + }, + ]); + + metaReducer(state, { type: '[Other] noop' } as Action); + expect(mockReducer.calls.mostRecent().args[0]).toBe(state); + }); + + it('is a no-op when no section references the deleted task', () => { + const state = stateWith({ t1: {}, t2: {} }, [ + { + id: 's1', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'A', + taskIds: ['t2'], + }, + ]); + + metaReducer( + state, + TaskSharedActions.deleteTask({ + task: state[TASK_FEATURE_NAME].entities.t1 as Task, + } as any), + ); + + expect(mockReducer.calls.mostRecent().args[0]).toBe(state); + }); + + describe('context deletion', () => { + it('removes sections owned by a deleted project but leaves other contexts alone', () => { + const state = stateWith({}, [ + { + id: 'sP', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'In project', + taskIds: [], + }, + { + id: 'sP2', + contextId: 'p2', + contextType: WorkContextType.PROJECT, + title: 'Other project', + taskIds: [], + }, + { + id: 'sT', + contextId: 'p1', + contextType: WorkContextType.TAG, + title: 'Tag with same id (no collision)', + taskIds: [], + }, + ]); + + metaReducer( + state, + TaskSharedActions.deleteProject({ + projectId: 'p1', + allTaskIds: [], + noteIds: [], + } as any), + ); + + const updated = (mockReducer.calls.mostRecent().args[0] as any)[ + SECTION_FEATURE_NAME + ] as SectionState; + expect(updated.entities['sP']).toBeUndefined(); + expect(updated.entities['sP2']).toBeDefined(); + // contextType='TAG' with the same id is intentionally not touched. + expect(updated.entities['sT']).toBeDefined(); + }); + + it('on deleteProject, also strips cascaded task ids from tag-context sections', () => { + // task t1 lives in project p1 AND in tag tA. Deleting p1 removes t1 + // (task.reducer cascades removeMany(allTaskIds)); the tag-context + // section sA must drop t1 from its taskIds in the same reducer pass. + const state = stateWith( + { + t1: { projectId: 'p1', tagIds: ['tA'] }, + t2: { projectId: 'p1' }, + }, + [ + { + id: 'sP', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'Project section', + taskIds: ['t1', 't2'], + }, + { + id: 'sA', + contextId: 'tA', + contextType: WorkContextType.TAG, + title: 'Tag section', + taskIds: ['t1'], + }, + ], + ); + + metaReducer( + state, + TaskSharedActions.deleteProject({ + projectId: 'p1', + allTaskIds: ['t1', 't2'], + noteIds: [], + } as any), + ); + + const updated = (mockReducer.calls.mostRecent().args[0] as any)[ + SECTION_FEATURE_NAME + ] as SectionState; + expect(updated.entities['sP']).toBeUndefined(); + expect(updated.entities['sA']).toBeDefined(); + expect(updated.entities['sA']?.taskIds).toEqual([]); + }); + + it('strips a moved task (and its subtasks) from sections in the old project only', () => { + const state = stateWith( + { + parent: { projectId: 'oldP', subTaskIds: ['sub1'] }, + sub1: { projectId: 'oldP', parentId: 'parent' }, + }, + [ + { + id: 'sOld', + contextId: 'oldP', + contextType: WorkContextType.PROJECT, + title: 'old project section', + taskIds: ['parent', 'sub1', 'unrelated'], + }, + { + id: 'sOther', + contextId: 'newP', + contextType: WorkContextType.PROJECT, + title: 'target project section', + taskIds: [], + }, + { + id: 'sTag', + contextId: 'oldP', + contextType: WorkContextType.TAG, + title: 'tag with same id as old project', + taskIds: ['parent'], + }, + ], + ); + + metaReducer( + state, + TaskSharedActions.moveToOtherProject({ + task: state[TASK_FEATURE_NAME].entities.parent as any, + targetProjectId: 'newP', + } as any), + ); + + const updated = (mockReducer.calls.mostRecent().args[0] as any)[ + SECTION_FEATURE_NAME + ] as SectionState; + expect(updated.entities['sOld']?.taskIds).toEqual(['unrelated']); + // Target project section is untouched. + expect(updated.entities['sOther']?.taskIds).toEqual([]); + // Tag-context section keeps the parent — tag membership didn't change. + expect(updated.entities['sTag']?.taskIds).toEqual(['parent']); + }); + + it('updateTask without tagIds change is a no-op for sections', () => { + const state = stateWith({ t1: { tagIds: ['a'] } }, [ + { + id: 'sa', + contextId: 'a', + contextType: WorkContextType.TAG, + title: 'tag a', + taskIds: ['t1'], + }, + ]); + + metaReducer( + state, + TaskSharedActions.updateTask({ + task: { id: 't1', changes: { title: 'renamed' } }, + } as any), + ); + + expect(mockReducer.calls.mostRecent().args[0]).toBe(state); + }); + }); + + describe('TODAY-tag removal cleanup (diff-based)', () => { + // The metaReducer compares pre/post TODAY_TAG.taskIds and strips any + // ids that left TODAY from TODAY-context sections. We simulate the + // tag reducer's effect by having the mock return a state where + // TODAY's taskIds shrank. + const stateWithTodayTaskIds = ( + todayTaskIds: string[], + tasks: Record>, + sections: Section[], + ): RootState & { [SECTION_FEATURE_NAME]: SectionState } => { + const base = stateWith(tasks, sections); + return { + ...base, + [TAG_FEATURE_NAME]: { + ...base[TAG_FEATURE_NAME], + entities: { + ...base[TAG_FEATURE_NAME].entities, + [TODAY_TAG.id]: { + ...base[TAG_FEATURE_NAME].entities[TODAY_TAG.id], + taskIds: todayTaskIds, + }, + }, + }, + } as any; + }; + + const mockTagReducerRemovesFromToday = (remainingTodayTaskIds: string[]): void => { + mockReducer.and.callFake((s: any) => ({ + ...s, + [TAG_FEATURE_NAME]: { + ...s[TAG_FEATURE_NAME], + entities: { + ...s[TAG_FEATURE_NAME].entities, + [TODAY_TAG.id]: { + ...s[TAG_FEATURE_NAME].entities[TODAY_TAG.id], + taskIds: remainingTodayTaskIds, + }, + }, + }, + })); + }; + + it('strips taskIds that left TODAY from TODAY-context sections, leaving non-TODAY contexts alone', () => { + const state = stateWithTodayTaskIds( + ['t1', 't2', 't3'], + { t1: {}, t2: {}, t3: {} }, + [ + { + id: 'today-sec', + contextId: TODAY_TAG.id, + contextType: WorkContextType.TAG, + title: 'Morning', + taskIds: ['t1', 't2', 't3'], + }, + { + id: 'project-sec', + contextId: 'p1', + contextType: WorkContextType.PROJECT, + title: 'Project', + taskIds: ['t1', 't2'], + }, + ], + ); + mockTagReducerRemovesFromToday(['t2']); + + const result = metaReducer(state, { type: '[Tag] simulated removal' } as Action); + + const updated = (result as any)[SECTION_FEATURE_NAME] as SectionState; + expect(updated.entities['today-sec']?.taskIds).toEqual(['t2']); + // Project-context sections are intentionally not touched. + expect(updated.entities['project-sec']?.taskIds).toEqual(['t1', 't2']); + }); + + it('cascades subtasks of a removed parent on TODAY removal', () => { + const state = stateWithTodayTaskIds( + ['parent', 'other'], + { + parent: { subTaskIds: ['sub1', 'sub2'] }, + sub1: { parentId: 'parent' }, + sub2: { parentId: 'parent' }, + other: {}, + }, + [ + { + id: 'today-sec', + contextId: TODAY_TAG.id, + contextType: WorkContextType.TAG, + title: 'Morning', + taskIds: ['parent', 'sub1', 'other'], + }, + ], + ); + mockTagReducerRemovesFromToday(['other']); + + const result = metaReducer(state, { type: '[Tag] simulated removal' } as Action); + + const updated = (result as any)[SECTION_FEATURE_NAME] as SectionState; + expect(updated.entities['today-sec']?.taskIds).toEqual(['other']); + }); + }); +}); diff --git a/src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.ts b/src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.ts new file mode 100644 index 0000000000..56c4ae5517 --- /dev/null +++ b/src/app/root-store/meta/task-shared-meta-reducers/section-shared.reducer.ts @@ -0,0 +1,345 @@ +import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; +import { Update } from '@ngrx/entity'; +import { RootState } from '../../root-state'; +import { + adapter as sectionAdapter, + SECTION_FEATURE_NAME, +} from '../../../features/section/store/section.reducer'; +import { Section, SectionState } from '../../../features/section/section.model'; +import { TaskSharedActions } from '../task-shared.actions'; +import { TASK_FEATURE_NAME } from '../../../features/tasks/store/task.reducer'; +import { TAG_FEATURE_NAME } from '../../../features/tag/store/tag.reducer'; +import { Task } from '../../../features/tasks/task.model'; +import { WorkContextType } from '../../../features/work-context/work-context.model'; +import { TODAY_TAG } from '../../../features/tag/tag.const'; + +// Must run before taskSharedCrudMetaReducer — handlers read pre-update +// task state to compute cleanups. Position pinned by +// `validateMetaReducerOrdering()` in meta-reducer-registry.ts. +interface ExtendedState extends RootState { + [SECTION_FEATURE_NAME]: SectionState; +} + +type Handler = (state: ExtendedState, action: Action) => ExtendedState; + +const collectAffectedTaskIds = ( + state: ExtendedState, + primaryTaskIds: string[], +): string[] => { + const taskState = state[TASK_FEATURE_NAME]; + const all = new Set(primaryTaskIds); + for (const id of primaryTaskIds) { + const t = taskState.entities[id]; + if (t?.subTaskIds?.length) { + for (const sub of t.subTaskIds) all.add(sub); + } + } + return Array.from(all); +}; + +/** + * Walk `taskIds` once removing entries in `removedSet`. Returns `null` + * when nothing was removed so callers can keep the original array + * reference, avoiding the `.some` + `.filter` double-walk. + */ +const filterRemovingTaskIds = ( + taskIds: string[], + removedSet: Set, +): string[] | null => { + let next: string[] | null = null; + for (let i = 0; i < taskIds.length; i++) { + const id = taskIds[i]; + if (removedSet.has(id)) { + if (next === null) next = taskIds.slice(0, i); + } else if (next !== null) { + next.push(id); + } + } + return next; +}; + +const cleanupSectionTaskIds = ( + sectionState: SectionState, + removedTaskIds: string[], +): SectionState => { + if (removedTaskIds.length === 0) return sectionState; + + const removedSet = new Set(removedTaskIds); + const updates: Update

[] = []; + + // Iterate `state.ids` directly — `Object.values(entities)` allocates + // a fresh array on every dispatch, which adds up under op-log replay. + for (const id of sectionState.ids) { + const s = sectionState.entities[id]; + if (!s) continue; + const filtered = filterRemovingTaskIds(s.taskIds, removedSet); + if (filtered !== null) { + updates.push({ id: s.id, changes: { taskIds: filtered } }); + } + } + + if (!updates.length) return sectionState; + return sectionAdapter.updateMany(updates, sectionState); +}; + +/** + * Drop project-scoped sections owned by the deleted project, and strip + * `taskIds` from TODAY-tag sections whose tasks are leaving the project. + */ +const removeProjectSections = ( + sectionState: SectionState, + projectId: string, +): SectionState => { + const idsToRemove: string[] = []; + for (const id of sectionState.ids) { + const s = sectionState.entities[id]; + if (!s) continue; + if (s.contextType === WorkContextType.PROJECT && s.contextId === projectId) { + idsToRemove.push(s.id); + } + } + if (!idsToRemove.length) return sectionState; + return sectionAdapter.removeMany(idsToRemove, sectionState); +}; + +/** + * Strip `taskIds` from sections owned by `projectId` (PROJECT context). + * Used when a task leaves a project (moveToOtherProject) — its section + * membership in the old project becomes stale. + */ +const removeTaskIdsFromProjectSections = ( + sectionState: SectionState, + taskIds: string[], + projectId: string, +): SectionState => { + if (taskIds.length === 0) return sectionState; + + const taskIdSet = new Set(taskIds); + const updates: Update
[] = []; + + for (const id of sectionState.ids) { + const s = sectionState.entities[id]; + if (!s) continue; + if (s.contextType !== WorkContextType.PROJECT) continue; + if (s.contextId !== projectId) continue; + const filtered = filterRemovingTaskIds(s.taskIds, taskIdSet); + if (filtered !== null) { + updates.push({ id: s.id, changes: { taskIds: filtered } }); + } + } + + if (!updates.length) return sectionState; + return sectionAdapter.updateMany(updates, sectionState); +}; + +/** + * Strip `taskIds` from the singleton TODAY-tag section bucket. + */ +const removeTaskIdsFromTodaySections = ( + sectionState: SectionState, + taskIds: string[], +): SectionState => { + if (taskIds.length === 0) return sectionState; + + const taskIdSet = new Set(taskIds); + const updates: Update
[] = []; + + for (const id of sectionState.ids) { + const s = sectionState.entities[id]; + if (!s) continue; + if (s.contextType !== WorkContextType.TAG) continue; + if (s.contextId !== TODAY_TAG.id) continue; + const filtered = filterRemovingTaskIds(s.taskIds, taskIdSet); + if (filtered !== null) { + updates.push({ id: s.id, changes: { taskIds: filtered } }); + } + } + + if (!updates.length) return sectionState; + return sectionAdapter.updateMany(updates, sectionState); +}; + +const withSectionStateUpdate = ( + state: ExtendedState, + next: SectionState, +): ExtendedState => + next === state[SECTION_FEATURE_NAME] + ? state + : ({ ...state, [SECTION_FEATURE_NAME]: next } as ExtendedState); + +const handleTaskRemoval = ( + state: ExtendedState, + primaryTaskIds: string[], +): ExtendedState => { + const affectedIds = collectAffectedTaskIds(state, primaryTaskIds); + return withSectionStateUpdate( + state, + cleanupSectionTaskIds(state[SECTION_FEATURE_NAME], affectedIds), + ); +}; + +/** + * Task is moving from its current project to `targetProjectId`. Strip + * the task (and its subtasks) from the old project's sections. + */ +const handleMoveToOtherProject = ( + state: ExtendedState, + taskId: string, + targetProjectId: string, +): ExtendedState => { + const t = state[TASK_FEATURE_NAME].entities[taskId] as Task | undefined; + const oldProjectId = t?.projectId; + if (!oldProjectId || oldProjectId === targetProjectId) return state; + + const affectedTaskIds = collectAffectedTaskIds(state, [taskId]); + return withSectionStateUpdate( + state, + removeTaskIdsFromProjectSections( + state[SECTION_FEATURE_NAME], + affectedTaskIds, + oldProjectId, + ), + ); +}; + +/** + * Diff-based TODAY_TAG.taskIds cleanup. TODAY is virtual — `task.tagIds` + * never contains `'TODAY'`, so the set of reducers that mutate + * `TODAY_TAG.taskIds` is too broad to enumerate by action type + * (scheduleTaskWithTime, planTaskForDay, unscheduleTask, short-syntax + * day moves, undo paths, lww conflict resolution, …). + * + * Compares pre/post `TODAY_TAG.taskIds` after the inner reducer ran; + * any id that left TODAY is stripped from TODAY-tag sections. + * + * Cheap path: short-circuits on tag-state reference equality, then on + * TODAY entity reference equality, then on taskIds reference equality. + * Only when all three changed do we walk the arrays. + */ +const diffRemovedTodayTaskIds = (prev: RootState, next: RootState): string[] | null => { + const prevTagState = prev[TAG_FEATURE_NAME]; + const nextTagState = next[TAG_FEATURE_NAME]; + if (prevTagState === nextTagState) return null; + const prevToday = prevTagState.entities[TODAY_TAG.id]; + const nextToday = nextTagState.entities[TODAY_TAG.id]; + if (prevToday === nextToday) return null; + const prevIds = prevToday?.taskIds; + const nextIds = nextToday?.taskIds; + if (prevIds === nextIds || !prevIds?.length) return null; + const nextSet = nextIds ? new Set(nextIds) : new Set(); + const removed: string[] = []; + for (const id of prevIds) { + if (!nextSet.has(id)) removed.push(id); + } + return removed.length ? removed : null; +}; + +const applyTodayTagSectionCleanup = ( + state: RootState, + removedTaskIds: string[], +): RootState => { + const extState = state as ExtendedState; + const sectionState = extState[SECTION_FEATURE_NAME]; + const cleaned = removeTaskIdsFromTodaySections(sectionState, removedTaskIds); + if (cleaned === sectionState) return state; + return { ...extState, [SECTION_FEATURE_NAME]: cleaned } as RootState; +}; + +/** + * Action-specific handlers. + * + * KNOWN FOLLOW-UPs: + * + * - `batchUpdateForProject` (plugin API) can update tagIds and delete + * tasks within its single-action transform. Sections it would + * otherwise affect aren't pruned. Handling it here requires walking + * the operations array, which is non-trivial. + * + * - LWW conflict-resolution can reassign a task's projectId; project- + * context section.taskIds can leak phantom references until the next + * `dataRepair` pass clears them. Visible impact bounded by render-time + * intersection in `undoneTasksBySection`. + */ +const ACTION_HANDLERS: Record = { + [TaskSharedActions.deleteTask.type]: (state, action) => { + const { task } = action as ReturnType; + return handleTaskRemoval(state, [task.id]); + }, + [TaskSharedActions.deleteTasks.type]: (state, action) => { + const { taskIds } = action as ReturnType; + return handleTaskRemoval(state, taskIds); + }, + [TaskSharedActions.moveToArchive.type]: (state, action) => { + // Strip archived task ids from sections. Restore is intentionally + // NOT a counterpart — the task comes back without a section, mirror + // of how restore drops missing tagIds. + // + // Union payload-subTasks with state-derived subtasks: payload covers + // the replay-with-missing-state case; state covers callers who pass + // an empty `subTasks` array. + const { tasks } = action as ReturnType; + const idSet = new Set(); + for (const t of tasks) { + idSet.add(t.id); + if (t.subTasks?.length) for (const st of t.subTasks) idSet.add(st.id); + } + const stateExpanded = collectAffectedTaskIds( + state, + tasks.map((t) => t.id), + ); + for (const id of stateExpanded) idSet.add(id); + return withSectionStateUpdate( + state, + cleanupSectionTaskIds(state[SECTION_FEATURE_NAME], Array.from(idSet)), + ); + }, + [TaskSharedActions.deleteProject.type]: (state, action) => { + const { projectId, allTaskIds } = action as ReturnType< + typeof TaskSharedActions.deleteProject + >; + // Two-step in a single reducer pass: + // 1. drop sections owned by the deleted project + // 2. strip the deleted task ids from any remaining (TODAY-tag) + // sections — task.reducer cascades removeMany(allTaskIds), so + // TODAY sections that held shared tasks would otherwise keep + // stale ids forever. + const next = withSectionStateUpdate( + state, + removeProjectSections(state[SECTION_FEATURE_NAME], projectId), + ); + if (!allTaskIds.length) return next; + return withSectionStateUpdate( + next, + cleanupSectionTaskIds(next[SECTION_FEATURE_NAME], allTaskIds), + ); + }, + [TaskSharedActions.moveToOtherProject.type]: (state, action) => { + const { task, targetProjectId } = action as ReturnType< + typeof TaskSharedActions.moveToOtherProject + >; + return handleMoveToOtherProject(state, task.id, targetProjectId); + }, +}; + +export const sectionSharedMetaReducer: MetaReducer = ( + reducer: ActionReducer, +) => { + return (state: RootState | undefined, action: Action): RootState => { + if (!state) return reducer(state, action); + // Boot/hydration guard: skip section-side cleanup until every slice + // it touches is hydrated. + const ext = state as ExtendedState; + if (!ext[TASK_FEATURE_NAME] || !ext[TAG_FEATURE_NAME] || !ext[SECTION_FEATURE_NAME]) { + return reducer(state, action); + } + const handler = ACTION_HANDLERS[action.type]; + const preState = handler ? handler(ext, action) : state; + const next = reducer(preState, action); + // Post-reducer TODAY_TAG.taskIds diff catches every flow that + // removes ids from TODAY without going through a known action. + const removedFromToday = diffRemovedTodayTaskIds(state, next); + if (!removedFromToday) return next; + const affected = collectAffectedTaskIds(next as ExtendedState, removedFromToday); + return applyTodayTagSectionCleanup(next, affected); + }; +}; diff --git a/src/app/t.const.ts b/src/app/t.const.ts index ff4ada537b..5edd74f105 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -26,6 +26,7 @@ const T = { RESTORE_FILE_BACKUP: 'CONFIRM.RESTORE_FILE_BACKUP', RESTORE_FILE_BACKUP_ANDROID: 'CONFIRM.RESTORE_FILE_BACKUP_ANDROID', RESTORE_STRAY_BACKUP: 'CONFIRM.RESTORE_STRAY_BACKUP', + DELETE_SECTION: 'CONFIRM.DELETE_SECTION', }, DATETIME_SCHEDULE: { PRESS_ENTER_AGAIN: 'DATETIME_SCHEDULE.PRESS_ENTER_AGAIN', @@ -2461,6 +2462,7 @@ const T = { }, MH: { ADD_NEW_TASK: 'MH.ADD_NEW_TASK', + ADD_SECTION: 'MH.ADD_SECTION', ALL_PLANNED_LIST: 'MH.ALL_PLANNED_LIST', BOARDS: 'MH.BOARDS', COPY_TASK_LIST_MARKDOWN: 'MH.COPY_TASK_LIST_MARKDOWN', @@ -2793,6 +2795,8 @@ const T = { WW: { ADD_MORE: 'WW.ADD_MORE', ADD_SCHEDULED_FOR_TOMORROW: 'WW.ADD_SCHEDULED_FOR_TOMORROW', + ADD_SECTION_TITLE: 'WW.ADD_SECTION_TITLE', + SECTION_OPTIONS: 'WW.SECTION_OPTIONS', DONE_TASKS: 'WW.DONE_TASKS', DONE_TASKS_IN_ARCHIVE: 'WW.DONE_TASKS_IN_ARCHIVE', ESTIMATE_REMAINING: 'WW.ESTIMATE_REMAINING', @@ -2803,6 +2807,7 @@ const T = { NO_DONE_TASKS: 'WW.NO_DONE_TASKS', NO_PLANNED_TASK_ALL_DONE: 'WW.NO_PLANNED_TASK_ALL_DONE', NO_PLANNED_TASKS: 'WW.NO_PLANNED_TASKS', + NO_TASKS_IN_MAIN_LIST: 'WW.NO_TASKS_IN_MAIN_LIST', RECURRING_TASKS: 'WW.RECURRING_TASKS', RESET_BREAK_TIMER: 'WW.RESET_BREAK_TIMER', TIME_ESTIMATED: 'WW.TIME_ESTIMATED', diff --git a/src/app/ui/collapsible/collapsible.component.html b/src/app/ui/collapsible/collapsible.component.html index 44db6c0828..a9fe353019 100644 --- a/src/app/ui/collapsible/collapsible.component.html +++ b/src/app/ui/collapsible/collapsible.component.html @@ -16,6 +16,8 @@
{{ title() }}
+ + @if (!isIconBefore()) { expand_more } diff --git a/src/app/util/app-data-mock.ts b/src/app/util/app-data-mock.ts index f5bc75314b..08bd1fb502 100644 --- a/src/app/util/app-data-mock.ts +++ b/src/app/util/app-data-mock.ts @@ -23,6 +23,7 @@ export const createAppDataCompleteMock = (): AppDataComplete => ({ isDataLoaded: false, }, tag: createEmptyEntity(), + section: createEmptyEntity(), simpleCounter: { ...createEmptyEntity(), ids: [], diff --git a/src/app/util/parse-markdown-tasks.ts b/src/app/util/parse-markdown-tasks.ts index 80fdf69d67..358baa95eb 100644 --- a/src/app/util/parse-markdown-tasks.ts +++ b/src/app/util/parse-markdown-tasks.ts @@ -1,3 +1,23 @@ +// Cap clipboard size to keep the parser from blocking the main thread on +// pathological inputs. Tracks roughly 10k task lines at ~80 chars each. +const MAX_INPUT_LENGTH = 800_000; + +const splitMarkdownLines = (text: string): string[] => { + // CRLF (Windows clipboard, GitHub render) → LF; strip BOM. + const normalized = text.replace(/^/, '').replace(/\r\n?/g, '\n'); + return normalized.split('\n').filter((line) => line.trim().length > 0); +}; + +const findMinIndentLevel = (parsedLines: { indentLevel: number }[]): number => { + // Reduce-based to avoid `Math.min(...arr)` which throws RangeError on + // very large arrays (V8 spread limit). + let min = Infinity; + for (const line of parsedLines) { + if (line.indentLevel < min) min = line.indentLevel; + } + return min === Infinity ? 0 : min; +}; + export interface ParsedMarkdownTask { title: string; isCompleted: boolean; @@ -88,7 +108,8 @@ export const convertToMarkdownNotes = (text: string): string | null => { return null; } - const lines = text.split('\n').filter((line) => line.trim().length > 0); + if (text.length > MAX_INPUT_LENGTH) return null; + const lines = splitMarkdownLines(text); const convertedLines: string[] = []; for (const line of lines) { @@ -118,7 +139,8 @@ export const parseMarkdownTasksWithStructure = ( return null; } - const lines = text.split('\n').filter((line) => line.trim().length > 0); + if (text.length > MAX_INPUT_LENGTH) return null; + const lines = splitMarkdownLines(text); const parsedLines: ParsedLine[] = []; // Parse all lines first @@ -137,7 +159,7 @@ export const parseMarkdownTasksWithStructure = ( } // Find the minimum indentation level to normalize - const minIndentLevel = Math.min(...parsedLines.map((line) => line.indentLevel)); + const minIndentLevel = findMinIndentLevel(parsedLines); // Normalize indentation levels by subtracting the minimum parsedLines.forEach((line) => { @@ -245,7 +267,8 @@ export const parseMarkdownTasks = (text: string): ParsedMarkdownTask[] | null => return null; } - const lines = text.split('\n').filter((line) => line.trim().length > 0); + if (text.length > MAX_INPUT_LENGTH) return null; + const lines = splitMarkdownLines(text); const parsedLines: ParsedLine[] = []; // Parse all lines first @@ -264,7 +287,7 @@ export const parseMarkdownTasks = (text: string): ParsedMarkdownTask[] | null => } // Find the minimum indentation level to normalize - const minIndentLevel = Math.min(...parsedLines.map((line) => line.indentLevel)); + const minIndentLevel = findMinIndentLevel(parsedLines); // Normalize indentation levels by subtracting the minimum parsedLines.forEach((line) => { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 1c3060221d..508c880625 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -22,6 +22,7 @@ }, "CONFIRM": { "AUTO_FIX": "Your data seems to be damaged (\"{{validityError}}\"). Do you want to try to automatically fix it? This may result in partial data loss.", + "DELETE_SECTION": "Are you sure you want to delete this section? Tasks in it will be moved to the task list.", "RELOAD_AFTER_IDB_ERROR": "Database Error - App Will Restart\n\nSuper Productivity cannot save data! This is usually caused by:\n• Low disk space (most common)\n• App update in background\n• Linux Snap users: run 'snap set core experimental.refresh-app-awareness=true'\n\nYour recent changes may not have been saved. Please free up disk space if low. The app will restart after you close this dialog.", "RESTORE_FILE_BACKUP": "There seems to be NO DATA, but there are backups available at \"{{dir}}\". Do you want to restore the latest backup from {{from}}?", "RESTORE_FILE_BACKUP_ANDROID": "There seems to be NO DATA, but there is a backup available. Do you want to load it?", @@ -2408,6 +2409,7 @@ }, "MH": { "ADD_NEW_TASK": "Add new Task", + "ADD_SECTION": "Add Section", "ALL_PLANNED_LIST": "Upcoming", "BOARDS": "Boards", "COPY_TASK_LIST_MARKDOWN": "Copy to clipboard", @@ -2740,6 +2742,8 @@ "WW": { "ADD_MORE": "Add more", "ADD_SCHEDULED_FOR_TOMORROW": "Add tasks planned for tomorrow ({{nr}})", + "ADD_SECTION_TITLE": "Add Section", + "SECTION_OPTIONS": "Section options", "DONE_TASKS": "Completed Tasks", "DONE_TASKS_IN_ARCHIVE": "There are currently no completed tasks here, but there are some already archived.", "ESTIMATE_REMAINING": "Estimate remaining:", @@ -2750,6 +2754,7 @@ "NO_DONE_TASKS": "There are currently no completed tasks", "NO_PLANNED_TASK_ALL_DONE": "all tasks completed", "NO_PLANNED_TASKS": "No tasks planned", + "NO_TASKS_IN_MAIN_LIST": "All tasks are organized into sections", "RECURRING_TASKS": "Recurring Tasks", "RESET_BREAK_TIMER": "Reset without break timer", "TIME_ESTIMATED": "Time estimated:",