* 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:<id> 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 <mat-menu>. 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<string, …> 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<RootState> /
ActionReducer<RootState, Action> 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 <p> 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 <johannes.millan@gmail.com>
The conditionally-required match-mode and sortDir radios placed
`defaultValue` inside `props`, which Formly ignores — its core extension
reads `field.defaultValue`. So opening any board with >=2 included or
excluded tags left those required fields undefined and the Save button
disabled. Even with the default lifted, Formly skips defaults for
fields hidden at init and on hidden→visible transitions unless
`resetOnHide: true` is set, so picking a sortBy re-locked the form.
Lift `defaultValue` to field level and add `resetOnHide: true` on
`includedTagsMatch`, `excludedTagsMatch`, and `sortDir`. Add a unit
spec exercising the panel fieldGroup and a Playwright e2e that drives
the dialog end-to-end.
* test(e2e): open attachment dialog via detail panel after #7314
The attach-dialog entry point moved out of the task context menu in
PR #7314 and now lives in the detail panel. Update the WebDAV sync
attachment test to use openTaskDetailPanel() and click the attachment
input-item instead of right-clicking and looking for an "Attach" menu
item that no longer exists.
* refactor(tasks): drop dead addAttachment from task context menu
Leftover from #7314, which moved the attach dialog into the detail
panel. The method, its TaskAttachmentService injection, and the
DialogEditTaskAttachmentComponent import are unreachable from the
template.
* fix(api): reject inherited fields when creating subtask via REST
POST /tasks with parentId silently dropped any supplied projectId/tagIds
(the reducer forces tagIds=[] and projectId=parent.projectId), so callers
got 201 with values different from what they sent. Reject the request
with 400 UNSUPPORTED_FIELD instead, symmetric with how subTaskIds and
parentId-on-PATCH are handled.
* refactor(simple-counter): inline countdown wrapper, document set invariant
Inline the single-call-site `_hasStartedRepeatedCountdown` private wrapper.
Promote the `setCountdownRemaining` invariant note to JSDoc so it surfaces
in IDE tooltips at every call site — paused-display is gated by
`hasStartedCountdown`, and writing through `setCountdownRemaining` without
a prior `startCountdown` call would silently leave the value invisible.
* ci(plugins): run unit tests when plugin code changes
New workflow runs each plugin's `npm test` in a parallel matrix when
its directory or a shared package (plugin-api, vite-plugin) changes
on a PR. Detects per-plugin changes via three-dot `git diff` against
the PR base.
* fix(sync): break iOS WebDAV conflict-dialog loop (#7339)
Two compounding bugs trapped iOS WebDAV users in a per-minute conflict
dialog that no button could resolve:
1. FileBasedSyncAdapter's snapshotReplacement heuristic re-fires
gapDetected on every sync from a non-writing client (clientId \!=
excludeClient is true forever), so the download keeps coming back
with snapshotState and OperationLogSyncService keeps throwing
LocalDataConflictError despite the local clock already dominating
the remote snapshot. Skip hydration and conflict when
compareVectorClocks(local, remote) is EQUAL or GREATER_THAN, gated
on both clocks being non-empty so a fresh client still hydrates a
legacy/clockless snapshot.
2. SyncWrapperService._openConflictDialog$ filtered undefined out of
the afterClosed() stream, so a programmatic close (iOS WebView
lifecycle, re-entry) collapsed the observable and firstValueFrom
threw EmptyError — the user's Use Local/Use Remote/Cancel click
never reached the resolution branches. Drop the filter so undefined
flows through to the existing cancellation path.
The dominate-skip deliberately does NOT append result.newOps to the
op log: VectorClockService.getEntityFrontier is last-write-wins by
seq, and writing historical remote ops at the current tail would
regress per-entity frontiers and let future LWW resolution overwrite
local data. Trade-off documented inline.
Adds an adapter-level integration reproducer that asserts gapDetected
re-fires forever for a non-writing client (the upstream loop trigger),
a 3-client WebDAV e2e that reproduces the loop end-to-end against a
real provider, plus service-level tests for the dominate-skip, the
empty-clock guard, the concurrent-clock conservative path, and
consecutive-sync loop prevention.
* fix(sync): improve Dropbox auth dialog UX on sandboxed Linux (#7139)
The "Get Authorization Code" button silently failed on Flatpak because
shell.openExternal rejects without renderer feedback when the
org.freedesktop.portal.Desktop talk-name isn't granted. After the user
gave up and reopened the dialog, the second attempt failed again with
`invalid_grant: invalid code verifier` because each call to
Dropbox.getAuthHelper() generated a fresh PKCE verifier that no longer
matched the originally-shown URL's challenge.
Three changes:
- Cache the in-flight PKCE Promise on the Dropbox provider so concurrent
callers and consecutive dialog opens share one verifier+URL pair.
Cleared on successful exchange, on clearAuthCredentials(), and on a
rejected generation (so a one-time crypto failure doesn't poison the
session). Five regression tests cover reuse, success-clear, explicit
clear, concurrent calls, and rejection-recovery.
- Render the auth URL as user-selectable text under a <details>
disclosure. Escape hatch when both shell.openExternal and the
clipboard portal are denied — the user can triple-click to select and
Ctrl+C the URL into a manually-opened browser. Adds
D_AUTH_CODE.MANUAL_URL_HINT translation key.
- Pipe shell.openExternal rejections through
errorHandlerWithFrontendInform so the existing IPC.ERROR snack
channel surfaces a "Could not open the link in your browser" message
instead of swallowing the failure to electron-log. Wrapped in a
try/catch since errorHandlerWithFrontendInform throws synchronously
if the renderer isn't ready.
The Flathub manifest also needs --talk-name=org.freedesktop.portal.Desktop
and --socket=wayland to fully fix the user-reported issue, but that
change lives in the flathub repo.
* refactor(sync): rename dialog-sync-initial-cfg to dialog-sync-cfg
Pure rename — no behavior change. The component is the canonical sync
config dialog, used both for first-time setup and editing. The "Initial"
qualifier was misleading once it became the only sync-config surface
(see #7362).
* refactor(sync): consolidate sync actions into the sync dialog
Move Re-authenticate, Force overwrite, and Restore from history into
DialogSyncCfgComponent so the dialog is the canonical surface for sync
configuration and management.
- Re-authenticate (OAuth providers, currently Dropbox) shown inline
when the active provider has getAuthHelper() and is already authed.
Bypasses the dirty-form gate so credentials can refresh without
losing in-progress edits.
- Force overwrite and Restore from history live in a new "Advanced"
section gated on isWasEnabled() (existing config) and disabled when
the form is dirty with a "Save changes first" tooltip. Restore stays
SuperSync-only.
- Force overwrite reuses the existing native confirm in
SyncWrapperService.forceUpload() — no extra confirmation dialog to
avoid double-confirm on the other 5 internal callers.
The legacy buttons on the settings page are removed in a follow-up
commit.
Refs #7362
* refactor(sync): move WebDAV Test Connection button into the sync dialog
Test Connection was previously injected into the inline sync form on
the settings page. It needs to live wherever the sync form is
rendered. The next commit removes the inline form, so move the
injection into DialogSyncCfgComponent first.
No user-visible change: the button still appears in the WebDAV
section of the sync form, just sourced from the dialog component now.
* refactor(config-page): replace inline sync form with status row + buttons
The Sync & Backup tab now hosts a compact status surface, not a full
form clone:
- When sync is disabled or unconfigured: empty-state hint + "Set up
sync" primary button.
- When configured: provider name, optional "Authentication required"
pill (OAuth providers) and "Encrypted" pill, then "Sync now" primary
button + "Configure" secondary button.
The dialog is the canonical sync config surface — reachable from this
tab, the header sync icon (right-click / long-press), or
SyncWrapperService when first-time setup is needed. Force overwrite
and Restore from history live inside the dialog now (see prior commit).
Removes _buildSyncFormConfig (~250 lines of formly button injection),
the empty authStatus placeholder field in SYNC_FORM, and the unused
tour-syncSection class.
Refs #7362
* refactor(sync): consolidate BTN_FORCE_OVERWRITE translation key
The SUPER_SYNC.BTN_FORCE_OVERWRITE key was a duplicate with no
consumers — its English copy ("Force Overwrite Server") even drifted
from the canonical F.SYNC.S.BTN_FORCE_OVERWRITE ("Force Overwrite").
The third occurrence under D_ENTER_PASSWORD ("Use Local Data") stays
— that's a different conflict-resolution action, not a duplicate.
Refs #7362
* refactor(sync): simplify sync dialog — collapse advanced fields, kebab menu
The previous "Advanced" zone (hr + heading + hint + button stack) plus
always-visible interval/manual-only/compression toggles made the dialog
visually noisy. Two changes:
- Move syncInterval and isManualSyncOnly into the existing
"Advanced Config" collapsible alongside compression. The collapsible
is hidden for SuperSync (which uses fixed settings) — most users
never need to expand it.
- Move Force overwrite + Restore from history out of the dialog body
into a kebab (more_vert) menu in the dialog title row. They act on
saved config — the kebab placement makes that scope clear and removes
the dirty-form gate / "Save changes first" ambiguity.
The kebab is hidden during first-time setup (gated on isWasEnabled).
The Re-authenticate button stays inline as before.
Drops three translation keys added in the prior commit (ADVANCED,
ADVANCED_HINT, SAVE_FIRST_HINT) — no longer referenced.
Refs #7362
* fix(sync): drop primary color from Cancel button in sync dialog
Cancel is a secondary action; only the affirmative button (Save)
should carry the primary color.
* fix(sync): clarify Dropbox info text for the new dialog flow
The previous copy said "Click the button below to authenticate" — but
in the consolidated dialog there is no inline Authenticate button for
first-time setup; OAuth runs as part of Save. Make the copy describe
the actual flow instead of pointing at a button that no longer exists.
* refactor(sync): move Force overwrite + Restore into the Advanced collapsible
The kebab menu introduced earlier hid Force overwrite and Restore from
history behind a small icon and required users to know that "more_vert"
in the dialog title contained sync actions. Fold them into the existing
top-level collapsible instead — the same one that already hosts sync
interval, manual-only, and compression toggles.
- Rename the collapsible's label from "Advanced Config" to "Advanced"
via a new sync-specific translation key (the global ADVANCED_CFG key
is shared with issue providers, so we don't touch it).
- The dialog component appends Force overwrite (warn) and Restore from
history (SuperSync only) to the collapsible's fieldGroup in edit
mode. First-time setup keeps the original SuperSync hide so the
collapsible never appears empty.
Re-authenticate stays as an inline button below the form because its
visibility depends on an async provider.isReady() check that doesn't
fit Formly's sync hideExpression.
Refs #7362
* refactor(sync): single Advanced collapsible per provider, all actions inside
Each provider now has exactly one "Advanced" collapsible:
- non-SuperSync: top-level (interval, manual-only, compression,
enable-encryption, plus injected Re-authenticate / Force overwrite
in edit mode)
- SuperSync: nested in the SuperSync section (server URL, plus injected
Force overwrite / Restore from history in edit mode)
The inner SuperSync collapsible's label switches from "Advanced Config"
to "Advanced" so both surfaces read the same. Re-authenticate moves
from a button below the form into the non-SuperSync Advanced collapsible
as a Formly button gated on `syncProvider === Dropbox`. Drops the async
provider.isReady() check — re-auth is shown for Dropbox in edit mode
regardless, since `force=true` works for both stale-token and switching
accounts.
Honors the rule that no buttons sit below the Advanced collapsible
inside the dialog.
Refs #7362
* refactor(sync): use stroked style for all dialog buttons + add Nextcloud test
- Every action button inside the sync dialog now uses btnStyle: 'stroked'
for a consistent outlined appearance: Re-authenticate, Force overwrite,
Restore from history, WebDAV/Nextcloud Test connection, file-based and
SuperSync Enable encryption, SuperSync Get token, LocalFile folder
pickers.
- Force overwrite keeps btnType: 'warn' so it stays warn-coloured but as
an outline rather than a filled warn button.
- The conditional stroked-when-token-set toggle on Get token is dropped
in favour of always-stroked.
- Re-authenticate and Restore previously misused btnType: 'stroked' (a
color slot) instead of btnStyle: 'stroked' (the actual style flag) —
the formly-btn template ignored the unrecognised color, so they
rendered as filled buttons. Fixed.
- Adds a Test Connection button to the Nextcloud section, matching the
WebDAV one — Nextcloud's serverUrl + userName are translated into the
WebDAV-shaped baseUrl client-side before invoking WebdavApi.testConnection.
Refs #7362
* refactor(sync): apply multi-review fixes — fragility, races, dedup, lifecycle
Cross-validated findings from the multi-agent review:
**Correctness / lifecycle**
- `fields` becomes a `computed` signal of `_getFields(isWasEnabled())`,
removing the manual `fields.set()` re-sync after the enabled flag
flips. Single source of truth.
- Reset `_tmpUpdatedCfg._isInitialSetup = false` when the dialog opens
in edit mode so SuperSync encryption-warning hideExpressions stop
treating returning users as first-timers.
- Replace ad-hoc `Subscription` aggregator with `takeUntilDestroyed` on
both subscriptions in the dialog component.
**Race conditions**
- `syncSettingsForm$` subscription on the settings page now goes through
`switchMap` so a fresh emission cancels any in-flight `provider.isReady()`
probe — late callbacks can no longer overwrite newer state.
- Drop the redundant `_cd.markForCheck()` after `signal.set()` — signals
integrate with OnPush automatically.
- Use `??` instead of `||` when preserving `globalCfg` flags during
provider switch, so an explicit `false` is honoured.
**De-duplication**
- Promote `NextcloudProvider._buildNextcloudBaseUrl` to a public static
`buildBaseUrl()`. Dialog's `_testNextcloudConnection` now imports it
rather than duplicating the URL-building logic.
- New `OAUTH_SYNC_PROVIDERS` set in `provider.const.ts`. Re-auth button's
hideExpression and the settings-page `requiresAuth` derivation can both
reference it instead of hard-coding `Dropbox`.
**Simplicity**
- Settings page `syncStatus` collapses from 5 fields to 3 (providerId,
needsAuth, isEncrypted) — empty state derives from `providerId === null`.
- Drop redundant `D_INITIAL_CFG.ADVANCED` translation key in favour of
the existing `T.G.ADVANCED_CFG`.
**Security / logging**
- Re-auth catch path now logs `{ name: e.name }` instead of the raw error
object — log history is exportable, no auth detail leakage.
Refs #7362
* refactor(sync): apply pass-2 review fixes — error paths, marker, factory
Cross-validated findings from the second multi-agent review:
**Correctness — keep the syncStatus stream alive on probe failure**
- Wrap the inner async block in try/catch. Previously, a rejected
`provider.isReady()` would propagate as an observable error through
switchMap, killing the outer subscription and freezing the status
pill. On failure, default to `needsAuth: true` so the row stays
meaningful.
- Replace the imperative `subscribe + .set` bridge with `toSignal()`.
**Security — finish the redaction**
- The first pass only redacted SyncLog.err; the snack `translateParams`
still passed raw `e.message` into a `[innerHtml]` sink. Snack copy
now uses the same `_redactErrorName(e)` helper so neither surface
carries token/URL/stack details. Helper handles `null`/`undefined`
thrown values explicitly.
**Architecture — structural marker decouples routing from i18n**
- Both Advanced collapsibles now carry `props.syncRole: 'advanced'`.
The dialog routes on this stable identifier instead of the shared
`T.G.ADVANCED_CFG` translation key (also used by Jira/Azure DevOps
forms — a global rename would have silently broken sync).
**Simplicity — collapse 5 button factories into one**
- New `_actionBtn({ text, onClick, btnType?, hideExpression?, className? })`
helper. Each per-action factory becomes a 3-4 line call site. ~60
lines net reduction in the dialog component.
- Drop the orphan `props: { dropboxAuth: true }` marker on the Dropbox
fieldGroup — its consumer (`_buildSyncFormConfig`) was deleted.
Refs #7362
* refactor(sync): apply pass-3 review fixes — auth label asymmetry, dead snack param
Pass-3 review surfaced two real findings on top of polish:
**Correctness — non-OAuth providers no longer mislabelled "needs auth"**
- The pass-2 try/catch returned `needsAuth: true` unconditionally on
probe failure, which would surface "Authentication required" for
WebDAV/Nextcloud/LocalFile — providers that don't have an auth
helper. Now we capture `requiresAuth` BEFORE the throwable
`isReady()` call and reuse it in the catch, so non-OAuth providers
fall through to `needsAuth: false` even on transient failures.
**Simplicity — drop the dead snack translateParams**
- The `INCOMPLETE_CFG` translation key has no `{{error}}` placeholder,
so the redacted error name was silently dropped by the translation
pipe. Removing the param documents the actual contract: the snack
shows static credentials-missing copy; the discriminator goes only
to the (redacted) log.
**Polish — cleaner types, tighter helper**
- `_redactErrorName` collapses to two-arm helper: `Error.name` for
Error instances, `'UnknownError'` for everything else. The
null/undefined/typeof branches were exporting internal jargon to
the user.
- The `props.syncRole` marker now uses a typed `SyncCollapsibleProps
extends FormlyFieldProps` interface (exported from sync-form.const)
instead of a `Record<string, unknown>` cast. Eliminates the cast at
both write and read sites.
Refs #7362
* refactor(sync): polish round — shareReplay, subscription cleanup, doc tightening
Three deferred items from prior reviews now applied:
- shareReplay({bufferSize:1, refCount:true}) on syncSettingsForm$. The
config-page subscribes long-term and the dialog re-subscribes per
open; previously the no-provider branch re-fetched the
sync-config-default-override.json asset on every fresh subscription.
Cold-start cost is now amortised across both consumers.
- Drop the manual Subscription aggregator on ConfigPageComponent. The
remaining cfg$ + queryParams subscriptions now use
takeUntilDestroyed(this._destroyRef); ngOnDestroy + OnDestroy go
away.
- Tighten dialog _isInitialSetup handling. Pass-3 review noted the
pre-set + Object.assign pattern reads as if order matters when it
doesn't. Move the flag into the spread payload so the intent is
obvious at the call site, and use \!v.isEnabled directly so the
semantics are explicit (true ⇔ first-time setup).
- JSDoc on forceOverwrite() documenting why no Material confirm is
added — SyncWrapperService.forceUpload already gates the action with
a native confirmDialog, and that gate also serves the 5 internal
snackbar callers (LockPresentError / EmptyRemoteBody / JsonParse /
LegacySyncFormatDetected / retry). Wrapping here would double-confirm.
Refs #7362
* refactor(sync): pass-4 polish — refCount:false, widen updateTmpCfg, trim docs
Cross-validated findings from the fourth review pass:
- shareReplay flips to refCount:false. Pass-4 reviewer noted that
refCount:true only deduplicated the rare case where the dialog
opens *while the settings page is mounted*. The far more common
path — header-icon → dialog with no settings page open — saw the
dialog become the sole subscriber, complete via .pipe(first()),
and drop refCount to 0, so the next open re-fetched the JSON
default-override anyway. With refCount:false the cached value is
retained for the lifetime of SyncConfigService, matching the
comment's stated intent at negligible memory cost.
- updateTmpCfg signature widens to `SyncConfig & { _isInitialSetup?:
boolean }`. Drops the call-site cast (a code smell that pretended
the type was wider than it was) and documents that _isInitialSetup
is a real, expected payload field.
- Trim the misleading comment around `_isInitialSetup: \!v.isEnabled`.
The "Spread last so Object.assign honours this" line wasn't true —
it's an object literal property, not a spread. New comment states
the actual semantics: first-time setup ⇔ sync was previously
disabled.
- Trim forceOverwrite() JSDoc from 6 lines to 2 — same content,
matches the project's preference for terse method comments.
Refs #7362
Two compounding bugs trapped iOS WebDAV users in a per-minute conflict
dialog that no button could resolve:
1. FileBasedSyncAdapter's snapshotReplacement heuristic re-fires
gapDetected on every sync from a non-writing client (clientId \!=
excludeClient is true forever), so the download keeps coming back
with snapshotState and OperationLogSyncService keeps throwing
LocalDataConflictError despite the local clock already dominating
the remote snapshot. Skip hydration and conflict when
compareVectorClocks(local, remote) is EQUAL or GREATER_THAN, gated
on both clocks being non-empty so a fresh client still hydrates a
legacy/clockless snapshot.
2. SyncWrapperService._openConflictDialog$ filtered undefined out of
the afterClosed() stream, so a programmatic close (iOS WebView
lifecycle, re-entry) collapsed the observable and firstValueFrom
threw EmptyError — the user's Use Local/Use Remote/Cancel click
never reached the resolution branches. Drop the filter so undefined
flows through to the existing cancellation path.
The dominate-skip deliberately does NOT append result.newOps to the
op log: VectorClockService.getEntityFrontier is last-write-wins by
seq, and writing historical remote ops at the current tail would
regress per-entity frontiers and let future LWW resolution overwrite
local data. Trade-off documented inline.
Adds an adapter-level integration reproducer that asserts gapDetected
re-fires forever for a non-writing client (the upstream loop trigger),
a 3-client WebDAV e2e that reproduces the loop end-to-end against a
real provider, plus service-level tests for the dominate-skip, the
empty-clock guard, the concurrent-clock conservative path, and
consecutive-sync loop prevention.
The attach-dialog entry point moved out of the task context menu in
PR #7314 and now lives in the detail panel. Update the WebDAV sync
attachment test to use openTaskDetailPanel() and click the attachment
input-item instead of right-clicking and looking for an "Attach" menu
item that no longer exists.
When an existing task with a past dueDay was converted to a recurring
task, both conversion effects called getFirstRepeatOccurrence with
`new Date()` as the anchor, which silently advanced the first occurrence
to the next future date matching the recurrence pattern — shifting the
task out of the user's planned date without consent.
Anchor on the task's existing dueDay only when it equals cfg.startDate
(the dialog default). If the user overrode startDate, fall back to
today — preserving existing behavior for all other flows.
Attach the console listener in the legacy-migration helper after
page.unroute('**/*.js') so the benign `Failed to load resource:
net::ERR_FAILED` messages from the intentional seeding-phase aborts
stop surfacing as console errors. pageerror stays attached early
since no JS runs while bundle loads are aborted.
electron/backup.ts imported getBackupTimestamp from src/app/util/, which
tsc compiled alongside the source. electron-builder only packages
electron/** and the Angular renderer bundle, so the compiled util was
missing from app.asar and the main process crashed on launch with
"Cannot find module '../src/app/util/get-backup-timestamp'".
Moved the util to electron/shared-with-frontend/ (existing convention
for code shared between main and renderer), updated all importers.
Fixes#7270
Refs #7315, #7323, #7265, #7320
- Document intent of editReminder cancel path: when user cancels the
inner schedule dialog, the outer dialog closes and the destroy handler
clears deadlineRemindAt (same as ESC). Prevents the worker from
re-firing the past-due reminder.
- Replace page.waitForTimeout with expect().not.toBeVisible({timeout}) in
the deadline ESC e2e test so it fails fast if the dialog reappears and
complies with the "no waitForTimeout" rule in e2e/CLAUDE.md.
- Capture realTodayStr per test in is-deadline-approaching spec to close
the (narrow) midnight-rollover flake window.
Without this, ESC/backdrop dismissal left deadlineRemindAt in the store,
so the reminder worker re-fired on its next 10s tick and the dialog
reopened indefinitely (reported in #4328 preview feedback).
ngOnDestroy now dispatches clearDeadlineReminder for any deadline
reminder shown in the session that was not explicitly handled. Bulk
handlers (markSingleAsDone, markAllTasksAsDone, addAllToToday) now
populate _dismissedReminderIds so destroy is a no-op for them.
Adds 7 unit tests and a Playwright e2e regression test that fails on
the unpatched component and passes with the fix.
The exportBackup helper used `file-imex button:has-text("Export")`, which
matched both the backup Export button and the newly-text-labeled
"Export Data (anonymized)" privacy button introduced in #7141. Playwright
strict mode on the subsequent click() threw, causing both non-skipped
tests in this file to fail. Switch to an exact name match so only the
intended button is targeted.
* fix(issue): prevent crash from orphan issueProviderId (#7135)
The Jira image-headers effect in task-detail-panel subscribed to
selectIssueProviderById without an error handler, so a task with an
issueProviderId pointing at a deleted provider (e.g. after sync
convergence where taskIdsToUnlink didn't cover all local tasks)
propagated the selector throw to Zone.js as a crash dialog. Wrap the
inner selector observable in catchError that logs and falls back to
of(null); the downstream jiraCfg?.isEnabled guard handles the fallback.
Also drop IssueLog.log(issueProviderKey, issueProvider) from the
throwing variant of the selector: providers may carry credentials
(host, token, apiKey) and IssueLog history is exportable.
* fix(focus-mode): sync tray countdown with in-app timer during breaks
Tray title was rebuilt from a cached currentFocusSessionTime that only
refreshed when CURRENT_TASK_UPDATED fired. addTimeSpent is gated on an
active current task, so during focus-mode breaks or task-less focus
sessions the cache froze while the in-app timer kept ticking.
Add the tick action to taskChangeElectron$ so the cache refreshes every
second whenever the focus timer is running.
Fixes#7278
* fix(ci): restore GitHub Actions SHA pins undone by 0e9218bd68
Commit 0e9218bd68 silently reverted PR #7212 (github-actions-minor group
bump) along with its stated sync/client-id work. This restores the 15
workflow files to their pre-revert state.
Actions restored to newer pinned SHAs:
- actions/upload-artifact v7.0.0 -> v7.0.1
- step-security/harden-runner v2.16.1 -> v2.17.0
- softprops/action-gh-release v2.6.1 -> v3.0.0
- signpath/github-action-submit-signing-request v2.0 -> v2.1
- anthropics/claude-code-action v1.0.89 -> v1.0.93
- docker/build-push-action v7.0.0 -> v7.1.0
- easingthemes/ssh-deploy v5.1.1 -> v6.0.3
* fix: restore i18n, UI, and docs work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted the following work alongside its stated
sync/client-id changes. Files where later master commits (fec7b25f23, etc.)
already re-applied the reverted work are intentionally left untouched.
Restored:
- #7232 docs/long-term-plans/location-based-reminders.md (513 lines)
- #7199 Romanian i18n phase 3 (ro.json + ro-md.json, ~1168 lines)
- #7049 Polish translation improvements
- #7143 planner component styling (4 scss files)
- #7211 add-task-bar preserve time estimate when typing title
- #7208 task.reducer roll-up estimates for added subtasks
- #6767 focus-mode pomodoro reset button
- #7205 plugin-dev github-issue-provider TOKEN description
- #7231 mobile-bottom-nav FAB fix
- a4fe03272 iOS keyboard accessory bar (global-theme + dialog-fullscreen-markdown)
- 309670db3 ShortSyntaxEffects undefined guard
- 667a7986f Dropbox PKCE auth comment/behavior
* fix(electron): restore electron + e2e work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted the following electron/e2e work.
Files already re-fixed by later master commits are left as-is:
- e2e/tests/sync/supersync-archive-conflict.spec.ts (de33234976 + 191d129ff3)
Restored:
- e8a3e156eb fix(electron): Linux autostart IDB backing-store recovery
(re-adds electron/clear-stale-idb-locks.ts + start-app.ts wiring)
- 5ce78a5b63 fix(electron): macOS Cmd+Q / Dock > Quit hang
(setIsQuiting + before-quit delegate to close-handler)
- 46e0fa2d01 fix(sync): FILE_SYNC_LIST_FILES IPC contract
(electronAPI.d.ts + local-file-sync + preload + ipc-events)
- ea1ef16307 fix(android): session-only SAF permissions on OEM devices
- 8865dc0a50 test(e2e): supersync parallel-worker stampede guard
(SUPERSYNC_SERVER_HEALTHY env-var fallback + goto retry loop)
- af7c7687e2 test(e2e): block WS-triggered downloads in non-WS specs
- 265b44db5d test(e2e): premature waitForURL on daily-summary
(this is literally the fix the bad commit's message claimed to add)
Conflict resolutions:
- e2e/utils/supersync-helpers.ts: kept the refined getDoneTaskElement
checks from d64014d086 (later than c558bcab5e) while restoring the
goto retry loop from 8865dc0a50.
- electron/start-app.ts: unioned imports (setIsQuiting +
clearStaleLevelDbLocks from theirs, fs from ours).
* fix(sync): restore sync-core work undone by 0e9218bd68
Commit 0e9218bd68 silently reverted parts of several sync fixes. Most
sync-core reverts have already been re-addressed differently on master
by later commits (1f5184f6e7, 05cd875dd6, 09f5ced2c9, 7df43358ab,
d9158d6adb, 32dbc95ed9, 8c3b08e016, f89fe1ebc3) — those files are
intentionally left untouched to avoid reverting master's newer work.
This PR restores only the pieces that are genuinely still missing:
- e8a3e156eb fix(electron): IDB backing-store autoreload (in-app piece)
operation-log-hydrator.service.ts + .spec.ts (the electron/clear-
stale-idb-locks.ts piece was restored in the prior commit)
Plus three low-risk documentation/cleanup restorations:
- operation-sync.util.ts — add "Nextcloud" to isFileBasedProvider JSDoc
- dropbox.ts — restore improved _getRedirectUri JSDoc (667a7986fb)
- dialog-get-and-enter-auth-code.component.ts — restore isNativePlatform
comment explaining why manual code entry flow is used (667a7986fb)
- file-adapter.interface.ts — remove stray "// NEW" comment (46e0fa2d01)
Intentionally NOT restored (master's newer work covers or supersedes):
- sync-trigger.service.ts / sync.effects.ts (05cd875dd6)
- sync-wrapper.service.ts (1f5184f6e7)
- sync-errors.ts (1f5184f6e7 re-added LegacySyncFormatDetectedError)
- file-based-sync-adapter.service.ts + spec (1f5184f6e7 + d9158d6adb)
- file-based-sync.types.ts (1f5184f6e7)
- operation-log.const.ts (32dbc95ed9 bumped IDB_OPEN_RETRIES to 5)
- dialog-sync-initial-cfg.component.ts (f89fe1ebc3)
- supersync-archive-conflict: add closing B sync to the convergence
rounds (A→B→A→B) so B is not one op behind A's final conflict-
resolution state; bump active-list assertion timeout to 15s to
cover the async reducer pipeline + sync-replay event-loop yield.
- supersync.page: fix race in changeEncryptionPassword where a stale
check icon from the previous syncAndWait() caused the method to
return before the server wipe + re-upload finished, leaving the
next client to race against partially-uploaded server state.
Now captures the check-icon state at entry and waits for it to
clear (same pattern syncAndWait uses).
* test(e2e): update Show/hide task panel button label
The TOGGLE_DETAIL_PANEL translation was renamed from "Show/Hide
additional info" to "Show/hide task panel" in dd789d2, but e2e
selectors still referenced the old string, causing every test that
opens the task detail panel to time out.
https://claude.ai/code/session_011mX4Pr24S42svmA5j7fRux
* 18.2.1
---------
Co-authored-by: Claude <noreply@anthropic.com>
TaskService.toggleDoneWithAnimation schedules the setDone dispatch via
setTimeout(200ms) to play the mark-done animation. markTaskDone and
markSubtaskDone returned immediately after clicking done-toggle, so a
following syncAndWait() could start before the late updateTask op was
captured. The op then landed during the sync's upload batch, leaving
hasPendingOps=true after sync completed and the .sync-state-ico check
mark never appeared — supersync.page.ts:1762 timed out.
Fixes flaky "4.1 Complex chain of actions syncs correctly" and
"Time tracking data persists after archive".
After #7004 made isLock actually disable markdown parsing, locked notes
showed `[text](url)` as raw text. Pipe the locked-path content through
RenderLinksPipe so URLs and markdown links remain clickable without
re-enabling full markdown rendering.
Fixes#7013
- Separate JsonParseError and SyncDataCorruptedError handlers in sync wrapper
- Handle corrupted remote data gracefully instead of throwing
- Add getOrGenerateClientId() as unified entry point, eliminating dual injection
of ClientIdService + CLIENT_ID_PROVIDER in snapshot-upload, file-based-encryption,
and sync-hydration services
- Use crypto.getRandomValues() instead of Math.random() for client ID generation
- Warn user when stored client ID is invalid and must be regenerated
- Fix flaky e2e supersync tests (premature waitForURL match on daily-summary URL)
Two sources of flakiness when running the full supersync suite:
1. Health check stampede: with many workers starting simultaneously,
all workers called isServerHealthy() concurrently (up to 24 HTTP
requests at once). This overloaded the server, causing false
negatives — workers got serverHealthyCache=false and skipped all
their tests (up to 192/198 tests). Fix: run the health check once
in globalSetup before workers are forked and store the result in
process.env.SUPERSYNC_SERVER_HEALTHY; workers read the env var
instead of making HTTP requests.
2. ERR_CONNECTION_REFUSED in createSimulatedClient: each supersync
test creates 2-3 browser contexts. Under parallel load the Angular
dev server temporarily refuses connections. Fix: retry page.goto('/')
up to 3 times with 1-2s backoff on ERR_CONNECTION_REFUSED.
routeWebSocket() can't prevent notifications that slip through in the
brief window before a connection is closed. __SP_E2E_BLOCK_WS_DOWNLOAD
was already checked by WsTriggeredDownloadService but never set by the
test infrastructure, leaving a race condition where background downloads
would race with explicit syncAndWait() calls and resurrect archived tasks.
Three separate root causes addressed:
1. markTaskDone/markSubtaskDone race with 200ms animation delay:
toggleDoneWithAnimation uses window.setTimeout(200ms) before
dispatching isDone:true. Tests proceeded before the state settled,
causing subsequent assertions to fail. Fix: wait for isDone CSS class
after clicking done-toggle. Also use .first() on the task locator to
avoid Playwright strict-mode violations during CDK drag animation,
where the same task briefly exists as two DOM elements.
2. Premature waitForURL resolution in supersync.spec.ts test 5.1:
waitForURL(/tag\/TODAY/) matched the current /tag/TODAY/daily-summary
URL immediately. Fix: add negative lookahead (?!\/daily-summary).
3. LWW worklog title nondeterminism in archive-conflict test:
After archive-wins conflict resolution and multiple sync rounds, the
archived task title on both clients depends on sync timing — it may be
the original name or the renamed name. Fix: accept either title using
hasTaskInWorklog OR, rather than asserting a specific title.
The URL pattern /(active\/tasks|tag\/TODAY)/ was matching the current
/daily-summary page URL (which contains "tag/TODAY"), causing waitForURL
to resolve immediately without waiting for navigation back to the work
view. This meant syncAndWait() fired before archive operations were
fully uploaded, so Client B received no archived tasks.
Fix: add negative lookahead (?!\/daily-summary) to all three waitForURL
calls (two inline in supersync-daily-summary.spec.ts, one in the shared
archiveDoneTasks() helper). Also updates the helper to accept tag/TODAY
without /tasks suffix, consistent with the rest of the test suite.
On slower/loaded CI machines (3 parallel workers), the task element still
carries the `ng-animating` class when Phase 4 tries to rename it. This
caused `dispatchEvent('click')` to timeout (15 s) waiting for task-title
to become interactable, failing all 3 test attempts.
Two-part fix:
1. After waiting for the task to be attached, wait for the `ng-animating`
class to be removed via a MutationObserver (max 2 s safety cap).
2. Add a 3-attempt retry loop for the click + textarea-visible check, so
any transient re-render right after animation removal is handled.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ng.getComponent is dev-mode only and is stripped in production builds,
causing the CI run (which uses ng build + http-server) to fail at the
store-corruption step. Add an IndexedDB fallback (Strategy B) that
injects a corrupt op directly into the SUP_OPS ops store and reloads
the page so the hydrator replays it as a tail op.
Guard all getDateTimeFromClockString() call sites that receive
repeatCfg.startTime with isValidSplitTime(), preventing "Invalid clock
string" crashes when corrupt data arrives via sync or import.
- get-task-repeat-info-text.util.ts: fall back to no-time display
- task-repeat-cfg.effects.ts: filter/skip invalid startTime in all effects
- create-sorted-blocker-blocks.ts: devError + skip instead of throw
- planner.selectors.ts: devError for corrupt case, silent skip for missing
- is-valid-split-time.ts: narrow return type to v is string type guard
When a full-state op (SYNC_IMPORT/BACKUP_IMPORT) arrived from another
client, mergeRemoteOpClocks reset the local clock to a "minimal" form
but only preserved the current client's counter from the incoming op's
clock. If the current client had issued ops (e.g. GLOBAL_CONFIG) not
reflected in the incoming full-state op's clock, its counter was dropped,
causing subsequent ops to reuse the same counter value. Downstream clients
then saw these ops as EQUAL (duplicate) and skipped them silently.
Fix: take max(mergedClock[clientId], currentClock[clientId]) when
rebuilding the clock after a full-state op reset.
Also add __SP_E2E_BLOCK_WS_DOWNLOAD flag to WsTriggeredDownloadService
to allow E2E tests to block automatic WS-triggered downloads during
concurrent conflict scenarios.
Fix archive conflict test by blocking WS downloads on Client A during
the concurrent edit phase so it doesn't auto-receive B's rename via
WebSocket before archiving (restoring the intended conflict scenario).
Fix LWW singleton test to assert convergence rather than specific winner.
Fix renameTask helper to avoid Playwright/Angular re-render races.
Fix shepherd.js import paths that broke the Angular dev server build.
* feat/issue-7032-034b15:
fix(tasks): add fast pre-check and edge case tests for markdown link extraction
fix(tasks): handle markdown links in short syntax URL extraction (#7032)
The WebSocket push feature (7fa8f12132) introduced background downloads
via WsTriggeredDownloadService that race with E2E test assertions.
Tests assume controlled, sequential sync via syncAndWait() but WS push
causes data to arrive before tests expect it, breaking conflict scenarios
and route interception.
Block the /api/sync/ws endpoint by default in setupSuperSync() and
opt-in only for the realtime-push test that specifically verifies WS.
The SHORT_SYNTAX_URL_REG_EX greedily matched the closing ')' from
markdown link syntax like [Website](https://example.com/), producing
malformed URLs and broken titles in extract mode. Fix by detecting
markdown links first, extracting their URLs correctly, then running
the plain URL regex on the remainder.
* feat(sync): add Helm chart for SuperSync Kubernetes deployment
Includes Deployment, StatefulSet (PostgreSQL), Service, Ingress,
ConfigMap, Secret, HPA, PDB, NetworkPolicy, and test templates.
Supports both bundled PostgreSQL and external database configurations.
* feat(sync): add WebSocket push notifications for near-realtime sync
Server: Fastify WebSocket plugin with connection manager, app-level
heartbeat (30s), debounced notifications, and per-user routing.
Client: WebSocket service with exponential backoff reconnection,
WS-triggered download service, and reduced polling when connected.
* fix(sync): improve WebSocket error handling and reactivity
Make syncInterval$ reactive to WS connection state, fix Set/Map
mutation during heartbeat iteration, add error handling to WS route
handler, separate JSON parsing from message handling, detect auth
failures in WS-triggered downloads, and add logging to all empty
catch blocks.
* fix(sync): address PR review findings for WebSocket and Helm
Fix race condition in WS-triggered download pipeline by moving
isSyncInProgress filter after debounce and adding guard in
_downloadOps. Add logging to remaining empty catch blocks, fix
missing $NODE_IP in Helm NOTES.txt, and correct inaccurate
comments in values.yaml and ws-triggered-download.service.ts.
* fix(sync): add rate limiting to WebSocket upgrade endpoint
Limit WS connection attempts to 10 per minute per IP to prevent
connection flooding, matching the rate-limit pattern used by other
sync endpoints.
* fix(sync): extract shared constants, fix debounce correctness, replace deprecated toPromise
Extract CLIENT_ID_REGEX and MAX_CLIENT_ID_LENGTH into sync.const.ts
to prevent drift between sync.routes.ts and websocket.routes.ts.
Fix debounce in notifyNewOps to accumulate excluded client IDs across
rapid calls from different clients, preventing self-notifications.
Replace deprecated toPromise() with firstValueFrom in connectWebSocket
and _sync methods. Add .catch() to reconnect path and logging to
silent early returns in _downloadOps.
* fix(build): restore correct package-lock.json and fix upstream lint errors
* revert: restore upstream HTML formatting to match CI prettier config
* fix(sync): use DownloadOutcome discriminated union in WsTriggeredDownloadService
* fix(sync): narrow TokenVerificationResult before accessing userId
* fix(sync): await async getProviderById in connectWebSocket
Missing await caused the Promise object to be cast to
SuperSyncProvider, making getWebSocketParams undefined.
* feat(sync): add SEED_USERS env var to create verified users on startup
For self-hosted single-user setups: set SEED_USERS=email1,email2 to
create verified users on boot and log their access tokens. Skips
existing users. Removes need for SMTP/magic link registration.
Also fix Dockerfile to use npm install instead of npm ci for lockfile
compatibility.
* fix(sync): address PR review feedback for Helm chart and server
Security:
- Remove seed-users.ts (logged full JWT tokens to stdout)
- Add fail assertions for missing jwtSecret and postgresql.password
- Add smtp.user/smtp.password values fields
- Add from: selector to NetworkPolicy ingress
- Add egress rule for external database when postgresql.enabled=false
Correctness:
- Fix PostgreSQL StatefulSet indentation for non-persistent mode
- Fix NOTES.txt panic on empty tls list (use len instead of index)
- Restore npm ci in Dockerfile by using node:24-alpine (ships npm 11)
- Add Recreate deployment strategy when using RWO PVC
Operational:
- Add fail guard preventing HPA maxReplicas > 1 (in-memory WS state)
- Fix PDB to use maxUnavailable instead of minAvailable
- Add WebSocket ingress timeout annotation examples
- Add Prisma db push init container for schema migrations
- Default serviceAccount.automount to false
- Add Chart.yaml maintainers, home, sources metadata
* feat(sync): add ALLOWED_EMAILS env var to restrict registration
Supports fully qualified emails (user@example.com) and domain
wildcards (*@example.com). When unset, all emails are allowed.
Applied to all three registration endpoints (passkey options,
passkey verify, magic link).
* fix(sync): exempt health endpoint from rate limiting
Kubernetes liveness/readiness probes hit /health every 5-15s,
exhausting the global rate limit (100 req/15min) and causing
429 responses that trigger container restarts.
* fix(sync): harden WebSocket, Helm chart, and sync services from multi-agent review
- Pin prisma@5.22.0 in init container and use migrate deploy instead of db push
- Add per-user WebSocket connection limit (max 10) to prevent resource exhaustion
- Add replicaCount > 1 fail guard in deployment template (in-memory WS state)
- Set maxPayload: 1024 on WebSocket plugin (only pong messages expected)
- Remove premature IN_SYNC status from download-only WS-triggered sync
- Fix double removeConnection on error+close events (let close handle cleanup)
- DRY debounce logic in notifyNewOps and store latestSeq on pending entry
- Remove dead fromClientId from NewOpsNotification and WsMessage interfaces
- Add takeUntilDestroyed to _wsProviderCleanup subscription
- Simplify email-allowlist.ts to eager init (eliminate mutable state)
- Guard connectWebSocket at call site for non-SuperSync providers
- Add distinctUntilChanged to syncInterval$ to prevent unnecessary resets
- Add container-level securityContext to PostgreSQL StatefulSet
- Fix HPA maxReplicas default to 1 (matches single-replica constraint)
* fix(sync): restore migration in Dockerfile CMD for non-Helm Docker users
Helm users get migration via init container (runs first, CMD becomes no-op).
Docker-compose/plain Docker users get automatic migration back on startup.
* fix(boards): add missing drag delay for touch and extract shared signal
Add cdkDragStartDelay to board-panel drag items, which was missing
entirely. Extract the repeated `isTouchActive() ? DRAG_DELAY_FOR_TOUCH : 0`
expression into a shared `dragDelayForTouch` computed signal and refactor
all 8 components to use it.
* test(sync): add comprehensive WebSocket test coverage
Add unit tests for the new WebSocket real-time sync notification pipeline:
- WebSocketConnectionService (server): connection lifecycle, max-per-user
limits, notification debouncing, heartbeat, graceful shutdown (13 tests)
- WebSocket routes validation: token/clientId validation, close codes,
error handling, validation ordering (18 tests)
- SuperSyncWebSocketService (frontend): reconnection with exponential
backoff, heartbeat, disconnect cleanup, URL conversion (6 tests)
- WsTriggeredDownloadService: auth error handling, pipeline resilience,
start() idempotency (3 tests)
- SyncWrapperService: connectWebSocket guards for non-SuperSync, null
params, and already-connected cases (3 tests)
- E2E realtime push: verifies WS-triggered download between two clients
Quality fixes:
- Replace waitForTimeout with syncAndWait in E2E
- Add explanatory comment for microtask flushing in sync-wrapper spec
- Use getter for isSyncInProgress mock in ws-triggered-download spec
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Replace HTTP-header-based conflict detection (If-Unmodified-Since, If-Match,
Last-Modified, ETag) with application-level content hashing (MD5), reducing
WebDAV server requirements to just GET, PUT, and MKCOL.
This mirrors the proven pattern from the LocalFile sync provider and eliminates
compatibility issues with WebDAV servers that don't properly support conditional
headers, strip response headers via reverse proxies, or return inconsistent
PROPFIND XML.
Changes:
- download() now computes MD5 hash of response body as rev
- upload() uses GET-compare-PUT instead of conditional PUT headers
- Remove testConditionalHeaders(), _cleanRev(), _getFileMetaViaHead()
- Remove WebdavServerCapabilities, WebdavServerType, basicCompatibilityMode
- Remove conditional header warning dialog from config page
- Remove legacyRev from provider interface
- Simplify webdav.const.ts (remove unused headers/methods/statuses)
- Add E2E test for near-simultaneous two-client sync
- Add unit test for rev stability across downloads
Verifies that creating a weekly Mon/Wed/Fri repeat on a Saturday
schedules the task for Monday (not today), and that daily repeats
correctly keep the task in Today.
- Fix SuperSync add-button click interception by hovering the group
header first (activates pointer-events) and targeting the button
element instead of mat-icon, with force-click fallback
- Fix WebDAV sync-expansion project lookup by using the proven
navigateToProjectByName helper instead of a fragile manual
sidebar locator
- Reduce flakiness in rapid-sync test by adding explicit timeouts
to post-loop verification assertions
Multiple error paths in the sync pipeline silently swallowed errors,
allowing sync to report IN_SYNC while clients had diverged state.
This caused permanent data loss — tasks missing on one device but
present on another, with no indication anything was wrong.
Fixes:
1. Propagate download failure: when OperationLogDownloadService
returns success=false, throw instead of treating as "no new ops".
Prevents lastServerSeq from advancing past failed downloads.
2. Throw on LWW conflict apply failure: autoResolveConflictsLWW
now throws when applyOperations fails, matching the behavior of
applyNonConflictingOps. Prevents lastServerSeq from advancing
past failed conflict resolutions.
3. Propagate rejected ops handler errors: rethrow instead of
swallowing in the uploadPendingOps catch block, so the
sync-wrapper can set ERROR status.
4. Surface validation failure: validateAfterSync now checks the
return value from validateAndRepairCurrentState and shows an
error snackbar when validation fails.
5. Set ERROR status in sync-wrapper catch-all: the generic error
handler now calls setSyncStatus('ERROR') so the sync icon shows
the red error state.
Fixes#6571
Add e2e test verifying the day-change trigger correctly creates new
repeat task instances when midnight passes. Uses Playwright clock
manipulation (setFixedTime) to control Date.now() while keeping real
timers running.
Document a known intra-day gap in TaskDueEffects: once addAllDueToday()
fires for today (via distinctUntilChanged on todayDateStr$), there is no
retry mechanism within the same day. Investigation verified day-change
trigger, sync buffering, and data loading guards all work correctly in
isolation — the exact real-world trigger remains unidentified.
Add diagnostic logging in addAllDueToday() to help users reproduce and
identify the failure point (repeat config count + IDs, sync window state).
The stale client reconnection test was flaky because syncAndWait()
was called immediately after clicking done-toggle, racing with the
200ms animation delay before isDone is dispatched to the store.
Use markTaskDone helper and wait for state to persist before syncing.
The done-toggle was refactored from an inline SVG with class="done-toggle"
into a standalone Angular component <done-toggle>. Update all e2e selectors
from '.done-toggle' (class) to 'done-toggle' (element) to match.
- moveItemAfterAnchor: preserve current position when anchor is
concurrently deleted instead of appending to end (which corrupted
ordering on remote clients). For cross-list moves, append to end
as fallback to prevent data loss.
- Prevent double-write of deferred actions: track buffered actions in
a WeakSet and filter them in the effect so they are only written
once by processDeferredActions().
- Propagate skipDequeue through handleQuotaExceeded retry path to
prevent queue desync when deferred actions hit storage quota.
- Add e2e tests for concurrent delete + reorder sync scenarios.
- Suppress onboarding overlay for fresh Client B in WebDAV legacy
migration test (onboarding-backdrop was blocking sync button clicks)
- Update play indicator selector from .play-icon-indicator to
.play-indicator after done-toggle circle refactor removed the mat-icon
- Use markTaskDoneByKey in archive conflict tests for reliable done
state verification (click-based markTaskDone was silently failing)
The previous fix (1cf7cff) added onboarding suppression via addInitScript
to test.fixture.ts, but WebDAV and SuperSync tests create their own
browser contexts via setupSyncClient() and createSimulatedClient() which
don't use that fixture. Without the suppression, onboarding overlays
interfere with sync tests causing timeouts and failures.
https://claude.ai/code/session_01AgeFLhcokw7y2tkTpeQMUz
Co-authored-by: Claude <noreply@anthropic.com>
Set localStorage flags (onboarding preset, hints, tour, example tasks)
via addInitScript before app boots, preventing onboarding overlays and
example tasks from interfering with e2e tests. Also switch archive
import/subtask specs to use the shared test fixture for proper isolation.
- Replace drag handle with SVG done-toggle circle with checkmark draw-on animation
- Improve mobile UX: cdkDragStartDelay for touch, bottom-sheet context menu
- Move issue/repeat indicators from drag handle to tag-list as chips
- Add isFinishDayEnabled config toggle
- Reorder context menu: add sub task before duplicate, deadline as own entry
- Fix context menu not opening on swipe left
- Fix race condition in done toggle animation timeout
Remove the full welcome tour (Welcome, AddTask, DeleteTask, Projects,
Sync, IssueProviders, ProductivityHelper, FinalCongrats, StartTourAgain)
and keep only the KeyboardNav tour, triggered from the Help menu.
- Delete ShepherdComponent (auto-start on load)
- Strip shepherd-steps.const.ts to KeyboardNav steps only
- Simplify ShepherdService with _initPromise guard and fresh tour on re-open
- Remove 3 tour menu items from Help menu, keep keyboard tour
- Remove waitForEl/waitForElObs$ helpers (unused by KeyboardNav)
- Delete e2e/utils/tour-helpers.ts and all tour dismissal calls
- Clean up stale tour comments across e2e tests
- Fix pre-existing build error: add isFinishDayEnabled to AppFeaturesConfig
type, defaults, form toggle, component signal, and translations
Planning mode added unnecessary UI complexity — the global add-task-bar
provides the same functionality and is always accessible via the header
button, keyboard shortcut, or mobile FAB.
- Remove PlanningModeService entirely
- Always show status bar and finish-day button
- 'Add more' button now opens the global add-task-bar
- Relocate 'add scheduled for tomorrow' button into empty state
- Remove E2E workarounds for planning mode exit
- Remove orphaned translation keys from t.const.ts and en.json
- Fix decorative image alt text for screen readers
Replace window.ng.getComponent() calls with UI-based interactions.
Angular debug APIs are unavailable in production builds (CI uses
ng build which defaults to production config with enableProdMode).
- Use calendar picker instead of signal manipulation for date setting
- Remove page.evaluate() model verification (input value checks suffice)
- Replace waitForTimeout with proper toBeVisible/toBeEnabled waits
- Fix date input focus issues with click + pressSequentially
- Use en-GB date format (DD/MM/YYYY) matching app default locale