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:",