Commit graph

439 commits

Author SHA1 Message Date
Johannes Millan
dbafa8d0a0 test(e2e): stabilize task visibility specs 2026-05-13 16:27:31 +02:00
Miklos
79739a6f92
refactor(ui): replace custom SVG icons with Material font ligatures (#7575)
* refactor(ui): replace custom SVG icons with Material font ligatures

  Swap seven custom SVG icons for built-in Material font ligatures and
  delete the corresponding asset files plus one orphan backup:

  - repeat -> repeat (6 templates + 1 chip config)
  - next_week -> next_week (3 templates)
  - drag_handle -> drag_handle (1 commented site)
  - habit -> heart_check (side nav + features form)
  - tomorrow -> wb_twilight (3 dialogs/menus)
  - working_today -> timelapse (work-view header)
  - remove_today -> timer_off (task hover controls + note)

  Brand and protocol marks (jira, gitlab, caldav, calendar, trello, etc.)
  intentionally kept as SVG since Material has no equivalent.

* test(e2e): update recurring selectors for Material font ligature swap

The repeat icon switched from <mat-icon svgIcon="repeat"> to the font
ligature form <mat-icon>repeat</mat-icon>, which broke 10 selectors
across 8 recurring specs that filtered by the now-absent svgIcon
attribute. Replace with a locator that matches the ligature text
exactly (anchored regex prevents catching repeat_one or similar).
2026-05-13 14:19:45 +02:00
Johannes Millan
a7bb11c3ed fix(mobile): hide disabled issue panel option 2026-05-13 14:11:15 +02:00
Johannes Millan
86497d24de test(sync): cover incompatible supersync password change 2026-05-13 11:08:54 +02:00
Johannes Millan
5a924eeadd test(sync): scope keep-local validation assertion 2026-05-12 18:26:08 +02:00
Het Savani
38d0898228
feat(flowtime): add configurable dynamic breaks (#7402)
* feat(flowtime):Add-breaks-as-per-users-choice-in-flowtime

* Chore: Clean up dialog-flowtime-settings component

Removed commented-out hideExpression and props related to breakRules.

* fix(flowtime): correct break trigger, preserve work session, add proper break offer UI and validation

* fix(flowtime): align break strategy with effect, add tests and validation fixes

* fix(flowtime): address review feedback for break logic, validation, and state handling

* fix(flowtime): address final review feedback

* fix(flowtime): address review feedback for break offers, tracking, and rule handling

* fix(flowtime): align break offer flow with focus mode rework

* chore: restore package lockfile

* chore: revert unrelated lockfile changes

* fix(flowtime): address review feedback

* test(focus-mode): fix sync behavior and update E2E expectations

* test(e2e): align focus mode tests with current settings UI

* test(e2e): stabilize focus mode settings setup

* fix(theme): restore default css tokens

* test(focus-mode): cover flowtime completion logging

---------

Co-authored-by: johannesjo <johannes.millan@gmail.com>
2026-05-10 23:22:57 +02:00
David Vornholt
a7845acd81
fix(electron): retry Wayland idle helper startup (#7527)
* fix(electron): retry wayland idle helper startup

* build(electron): enforce wayland helper in CI

* fix(electron): avoid blocking wayland idle promotion

* test(e2e): make reminder option selector exact

* test(e2e): resolve reminder option selector conflict

* fix(electron): gate wayland helper redetection

* fix(electron): avoid stale Wayland helper packaging

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-10 22:35:33 +02:00
Johannes Millan
22dbfde12b test(sync): fix concurrent time tracking assertion 2026-05-09 19:28:19 +02:00
Johannes Millan
4b5fc3fb33 test: stabilize flaky e2e specs 2026-05-09 18:11:40 +02:00
David Vornholt
34704d82b2
fix(ui): improve task selector project filtering (#7529)
* fix(ui): improve task selector and focus notes controls

* fix(tasks): respect project short syntax setting

* test(e2e): expand focus mode settings robustly
2026-05-09 17:38:23 +02:00
johannesjo
989563dc79 test(e2e): scope reminder-default lookups to avoid theme tooltip match
The new theme upload button's matTooltip ("...never run code...") leaves its
text in the DOM via cdk-describedby, so case-insensitive getByText('never')
matched both the "Never" reminder option and the tooltip. Target the
mat-option by ARIA role and exact case, and scope the post-schedule
assertion to the dialog so only the mat-select trigger text qualifies.

Also prunes a stale nested vitest/esbuild tree from the caldav plugin's
package-lock.json.
2026-05-09 11:28:52 +02:00
Johannes Millan
44e0762fba
Feat/issue 7330 bc1a3f (#7522)
* fix(sync): strip same-batch-archived task IDs from TAG/PROJECT LWW payloads

Issue #7330. lwwUpdateMetaReducer's orphan filter only sees taskState as it
is when each op runs. A TAG LWW Update applied before its sibling archive
op in the same bulk batch escapes the filter, leaving TODAY_TAG (or any
tag/project) referencing a task the very next op removes — user-visible
as "archived tasks reappear in today's view" on hibernate-wake.

bulkOperationsMetaReducer already collects archivingOrDeletingEntityIds
for the wholesale TASK LWW Update skip; this commit reuses that set to
pre-clean taskIds (and PROJECT-only backlogTaskIds) on TAG/PROJECT LWW
Update payloads before convertOpToAction. Cross-batch ordering is not
addressed — see open issue.

* fix(sync): surface post-sync validation failure as ERROR, not IN_SYNC

Issue #7330. The reporter saw "State validation failed after sync. Some
data may be inconsistent." while sync simultaneously reported status
IN_SYNC. validateAfterSync's boolean was discarded above the snackbar
layer (#6571 only added the snackbar; nothing flowed up to the status
pipeline).

Plumb the boolean through:
- validateAfterSync: void -> boolean
- _validateAndRepairAfterResolution: void -> boolean
- autoResolveConflictsLWW + processRemoteOps: add validationFailed
- DownloadOutcome.ops_processed + UploadOutcome.completed: add
  validationFailed
- sync-wrapper: if either result.validationFailed, setSyncStatus('ERROR')
  and return HANDLED_ERROR before the IN_SYNC mark

Tests: assert validateAfterSync returns the boolean, assert
processRemoteOps surfaces validationFailed, assert sync-wrapper sets
ERROR (not IN_SYNC) when either download or upload reports
validationFailed.

* fix(sync): always set top-level id on LWW Update payloads

lwwUpdateMetaReducer drops LWW Updates whose payload lacks a top-level
id ("Entity data has no id"). Two creation paths could produce such
payloads:

- createLWWUpdateOp passed entityState through unchanged, so a malformed
  selector result silently produced an unusable LWW op.
- _convertToLWWUpdatesIfNeeded built mergedEntity by spreading baseEntity
  and updateChanges; when baseEntity lacked id (corrupt DELETE payload)
  and updateChanges had id stripped, the merge had no id either.

Both sites now force the canonical entityId onto the payload. (#7330)

* fix(sync): close 3 secondary gaps in #7330 fixes

Multi-agent review surfaced three additional code paths mirroring the
same defects we fixed at the primary path:

G1: bulkOperationsMetaReducer's pre-scan only saw op.entityIds /
op.entityId for archive/delete ops. moveToArchive declares only
top-level task IDs, but the reducer cascades to subtasks via
[t.id, ...t.subTasks.map(st => st.id)]. deleteTask cascades the same
way via taskToDelete.subTaskIds. Same-batch TAG/PROJECT LWW Updates
referencing those subtasks slipped through the strip. Add
collectCascadedSubTaskIds to harvest subtask IDs from archive/delete
payloads.

G2: SyncWrapperService's LWW re-upload retry loop captured only
reuploadResult.localWinOpsCreated, throwing away validationFailed.
A retry-pass piggybacked download with failing post-sync validation
still reported IN_SYNC. Track reuploadValidationFailed across
retries and OR into the gate before IN_SYNC.

G3: OperationLogSyncService's downloadCallback (used by the
rejected-op handler) converted the download outcome into
DownloadResultForRejection, which has no validationFailed field.
A nested download triggering validation failure was lost before
uploadPendingOps returned. Route the boolean via the existing
piggybackValidationFailed flag in the closure scope.

Also fixes one cosmetic logging field (reuploadValidationFailed
included alongside download/upload in the ERROR log).

* fix(sync): defense-in-depth id derivation in lwwUpdateMetaReducer

Multi-agent review surfaced two follow-up improvements for #7330:

1. Consumer-side id fallback: a future LWW Update producer that
   forgets to backfill payload.id would silently regress the
   "Entity data has no id" bail. Recover from action.meta.entityId,
   which convertOpToAction always populates from the canonical
   op.entityId. Now a single consumer guards the whole class of
   missing-id producer regressions.

2. Tighten 7 conflict-resolution.service.spec assertions from
   jasmine.objectContaining({ localWinOpsCreated: N }) back to
   strict toEqual({...}). The loosening (added when validationFailed
   was introduced) hid future stray-field regressions; the validation
   path now returns a deterministic shape per code branch, so we can
   assert it.

* fix(sync): close issues found in follow-up review (#7521)

Multi-agent review of the prior #7330 follow-up commits surfaced four
real issues. All addressed here:

CRITICAL — singleton id pollution at producer + consumer
  Singletons use entityId='*' as a sentinel. Both the producer-side
  payload-id backfill (createLWWUpdateOp + _convertToLWWUpdatesIfNeeded)
  and the consumer-side meta.entityId fallback in lwwUpdateMetaReducer
  injected `id: '*'` into payloads. For singletons (GLOBAL_CONFIG,
  app-state, time-tracking) this leaks a synthetic `id: '*'` field
  into singleton feature state when the reducer spreads entityData.
  Gate id-injection on entityId \!== '*' at all three sites and move
  the consumer fallback past the singleton branch.

WARNING — null-safety in collectCascadedSubTaskIds
  `'actionPayload' in payload` check could pass for malformed
  payload {actionPayload: null}, then crash on the next property
  access. Tighten to a typeof check.

WARNING — retries-exhausted path bypasses validationFailed gate
  When the LWW re-upload loop exits via MAX retries with pending
  ops still > 0, sync-wrapper used UNKNOWN_OR_CHANGED without
  consulting reuploadValidationFailed. Validation failure during
  a retry pass is now elevated to ERROR — a more honest signal
  than UNKNOWN_OR_CHANGED, and consistent with the user-visible
  "State validation failed" snackbar they already saw.

SUGGESTION — devError on consumer fallback
  Producer regressions should scream loudly in dev, not slip
  through silently. The fallback now emits devError + applies
  the recovery, so a future LWW producer that forgets payload.id
  surfaces in dev rather than only-when-the-bail-fires.

* fix(sync): close validationFailed gap in USE_REMOTE and DELETE_MULTIPLE cascade

Three issues found by multi-agent review of the #7330 sync fixes:

1. forceDownloadRemoteState() discarded the validationFailed result
   from processRemoteOps. A USE_REMOTE conflict-resolution path that
   applied corrupt remote state would still mark sync IN_SYNC despite
   the snackbar. Now returns { validationFailed } and propagates it
   through _handleSyncImportConflict and DownloadOutcome.no_new_ops
   to SyncWrapperService, plus the two direct callers (manual force
   download and first-sync conflict resolution).

2. sync-wrapper retry-exhaustion priority: when LWW retries exhausted
   AND the initial download/upload had reported validationFailed, the
   wrapper returned UNKNOWN_OR_CHANGED instead of ERROR. The hoisted
   downloadValidationFailed/uploadValidationFailed are now checked
   inside the retry-exhaustion branch alongside reuploadValidationFailed.

3. bulk-hydration pre-scan handled DELETE_MULTIPLE in the loop but the
   collectCascadedSubTaskIds helper early-returned for that action type.
   Since deleteTasks payload only carries flat taskIds, subtask cascade
   must be derived from initial batch state (mirroring what
   handleDeleteTasks does at apply time). Co-batched TAG/PROJECT LWW
   Updates referencing those subtasks could otherwise still leak.

Adds regression tests for each path.

* refactor(sync): centralize LWW id backfill, extract singleton sentinel constant

Follow-up simplifications from the multi-agent review of #7330 fixes.

- Extract SINGLETON_ENTITY_ID = '*' + isSingletonEntityId() helper in
  entity-registry.ts. Replaces the bare '*' literal across
  conflict-resolution.service.ts, lww-update.meta-reducer.ts,
  validate-operation-payload.ts, operation-log-recovery.service.ts,
  and operation-log-migration.service.ts. (S2)

- Add LWW Update payload.id backfill at the apply boundary in
  convertOpToAction. Every applied LWW op now has its top-level id set
  from op.entityId (excluding singletons). The redundant defense-in-depth
  block in lwwUpdateMetaReducer (which derived id from meta.entityId) is
  removed — the converter is now the single chokepoint, with producers
  (createLWWUpdateOp, _convertToLWWUpdatesIfNeeded) keeping their
  enforcement for an explicit on-disk shape. (S1)

- stripBatchArchivedTaskIdsFromLwwPayload: drop non-string entries
  rather than preserving them, and skip the cleaned[] allocation when
  no rewrite is needed (two-pass with early exit on first hit). (S5+S7)

* refactor(sync): extract bulk-archive util, unify orphan-task filters, type validation propagation

Continued review follow-ups for #7330. Behavior unchanged.

- Move payload-archaeology helpers (collectCascadedSubTaskIds,
  stripBatchArchivedTaskIdsFromLwwPayload) out of bulk-hydration.meta-reducer
  into a co-located bulk-archive-filter.util.ts. The meta-reducer body
  drops back to its dispatcher role; the helpers gain an independent
  test surface and clearer naming. (S3)

- Add a shared filterTaskIdArraysFromTagOrProjectPayload helper. Both
  filterOrphanedTaskIdsFromEntityData (lww-update.meta-reducer; predicate:
  not in live state) and stripBatchArchivedTaskIdsFromLwwPayload (bulk
  meta-reducer; predicate: in same-batch archive set) now wrap it. The
  two callers run at different layers because their predicates resolve
  at different times — the shared helper is the array-walking +
  payload-cloning + warn-logging core. (S4)

- Replace the closure-captured piggybackValidationFailed boolean in
  uploadPendingOps with typed plumbing: validationFailed flows through
  DownloadResultForRejection and RejectionHandlingResult. The closure
  smuggle was fragile; the typed return is grep-able and survives
  refactors. The wider session-latch refactor proposed in review is
  deferred — current typed plumbing is well-tested. (S6 minimal)

* refactor(sync): replace validationFailed plumbing with session latch + integration test

Final review follow-up for #7330. Behavior unchanged; surface area shrinks.

The validation-failed signal previously rode through 7 typed boundaries:
DownloadOutcome.{ops_processed,no_new_ops}.validationFailed,
UploadOutcome.completed.validationFailed, processRemoteOps return,
forceDownloadRemoteState return, _handleSyncImportConflict return,
DownloadResultForRejection.validationFailed, RejectionHandlingResult
.validationFailed, plus a closure-captured piggybackValidationFailed in
uploadPendingOps. Threading it correctly required every call site to
remember to forward the boolean — a new variant or path that forgot
would silently let IN_SYNC ride over corrupt state.

- Add SyncSessionValidationService — a no-dep singleton with reset() /
  setFailed() / hasFailed(). RemoteOpsProcessingService.validateAfterSync
  and ConflictResolutionService.autoResolveConflictsLWW flip the latch
  when validation reports corruption. SyncWrapperService resets it at
  every entry point (sync(), _forceDownload(), resolveSyncConflict
  USE_REMOTE) and reads it once before deciding IN_SYNC vs ERROR.

- Drop validationFailed from DownloadOutcome variants, UploadOutcome,
  DownloadResultForRejection, RejectionHandlingResult, processRemoteOps
  return, autoResolveConflictsLWW return, forceDownloadRemoteState
  return, and _handleSyncImportConflict return. ~120 LOC of plumbing
  collapsed to single latch reads.

- Add a focused integration test
  (post-sync-validation.integration.spec.ts) that wires real
  RemoteOpsProcessingService + ConflictResolutionService against a
  stubbed ValidateStateService. Asserts the latch flips on validation
  failure and survives discarded-boolean callers — catching plumbing
  regressions a future code path could otherwise sneak past.

Net change vs current branch: ~120 LOC deleted from production sources,
~270 LOC added in new service + tests (latch unit tests + integration).

* fix(sync): close 3 latch-bypass gaps from codex review

1. SyncHydrationService.hydrateFromRemoteSync runs validateAndRepair()
   directly and previously dropped the result on failure. Snapshot
   hydration (file-based providers, USE_REMOTE force-download) would
   silently accept corrupt remote data — the wrapper would see a clean
   latch and report IN_SYNC. Now flips
   SyncSessionValidationService.setFailed() when isValid is false.

2. WsTriggeredDownloadService called downloadRemoteOps() outside the
   wrapper session contract, so any validation failure during a realtime
   apply was either dropped (next sync()'s reset cleared it) or leaked
   into the next session. The service now resets the latch up-front and
   reads it after the download, surfacing failures as
   setSyncStatus('ERROR').

3. convertOpToAction only backfilled payload.id when missing. A
   malformed/older remote LWW op with payload.id \!= op.entityId would
   slip through and update the WRONG entity in lwwUpdateMetaReducer
   (which trusts entityData.id). Now forces id from op.entityId for
   non-singleton LWW ops, making "entityId is canonical" a hard
   invariant at the apply boundary.

Adds regression tests for each: integration test for the snapshot path,
two new tests on ws-triggered-download (latch flip → ERROR; latch reset
between sessions), and a converter test for the entityId-mismatch case.

* test(sync): unstick three failing supersync e2e tests

Three independent flakes surfaced in the same run; root-caused from
playwright traces:

- daily-summary: time-estimate row icon was renamed timer→hourglass_empty
  on master (eca8d211a) but the selector was never updated on this branch.
  Cherry-pick of master's 4be835017 selector update.

- multi-migration: 120s test budget is too tight for 3 sequential
  setupSuperSync + 4 syncAndWait cycles under parallel @supersync load.
  Trace shows test reaches the post-sync 1.5s grace wait — Client B has
  already received all 3 tasks — and times out there. Bumped just this
  test to 180s.

- lww-conflict notification: done-toggle dispatches updateTask
  asynchronously; without an explicit wait, sync runs as a no-op (latch
  trace shows uploaded=0, status=IN_SYNC at 2459705) and the queued
  updateTask lands ~13ms later, flipping hasNoPendingOps back and hiding
  the check icon → syncCheckIcon waitFor times out. Added the same
  expect(...).toHaveClass(/isDone/) wait that the file's other LWW tests
  (lines 83, 376, 389) already use.

* refactor(sync): self-enforcing session-validation contract via withSession()

The previous SyncSessionValidationService API was reset() / setFailed() /
hasFailed() with a doc-comment contract: "every sync entry point must call
reset() before doing work." A new entry point added later (e.g., a
background download path) that forgot the reset would inherit a
leaked-failed latch from a prior session, and #7330's IN_SYNC-vs-ERROR
decision would misfire silently. The maintenance hazard was the largest
reason this code wasn't self-protecting.

Replace with a callback API:

  withSession<T>(work: () => Promise<T>): Promise<T>

Resets the latch at entry, marks a session active for the duration of
work, clears the marker on completion or error. setFailed() and reset()
log a noisy SyncLog.err if called outside an active session — a runtime
guard for "validation fired without an entry point opening a session."
Nested withSession() calls are detected and run in the outer session's
context (no inner reset clobbers outer state).

Three production entry points migrated:
- SyncWrapperService._sync() — body extracted to _syncBody(providerId)
  to avoid a 400-line indent diff
- SyncWrapperService._forceDownload()
- WsTriggeredDownloadService._downloadOps()

The fourth caller of reset() — the USE_REMOTE branch in
_handleLocalDataConflict — keeps using reset(), now correctly within the
session opened by _sync()'s withSession. It's a sub-scope re-scoping
inside an existing session, not a top-level entry point. The new
reset() docs make this distinction explicit.

Test surface:
- sync-session-validation.service.spec.ts rewritten: 12 tests cover the
  callback semantics, nested-session guard, and outside-session warnings.
- post-sync-validation.integration.spec.ts wraps each scenario in
  withSession() (mirrors production); replaces the old "reset() between
  sessions" test with one that asserts withSession() entry resets state.
- sync-wrapper.service.spec.ts unchanged — its tests fire setFailed()
  inside mocked download/upload callbacks, which now run inside _sync()'s
  session. 107/107 still pass.
- WsTriggeredDownloadService spec: latch.reset() in setup → _resetForTest()
  to avoid the new outside-session warning; the "stale latch" test seeds
  internal state via the test-only helper.

reset() and setFailed() are still public — they're load-bearing for
the USE_REMOTE sub-scope and for validation services that need to flip
the latch from inside session-wrapped flows. The contract is now:
top-level entry points use withSession; validation sites use setFailed
unchanged; only USE_REMOTE recovery uses reset.

#7330

* fix(sync): warn when convertOpToAction rewrites a mismatched payload.id

The id-rewrite guard added in 41b47be4 silently fixes producer/wire bugs
(an LWW op whose payload.id disagrees with op.entityId would otherwise
update the wrong entity in lwwUpdateMetaReducer). Correct in direction,
but invisible — if the assumption that "no legitimate code path produces
mismatched ops" ever breaks, we'd only learn from a user-reported corrupt
entity.

Log a SyncLog.warn with ids only (actionType, entityType, entityId,
payloadId) when the rewrite fires. Never log payload content — op log is
exportable and #7330 already caused us to audit that surface for user
data leaks.

Two new tests: warning fires with the expected id pair on mismatch; no
warning on the happy path. Sanity assertion verifies payload content
(title) doesn't appear in the log call args.

#7330

* test(sync): static check enumerates withSession() entry-point allow-list

CI-time guard for the latch maintenance hazard: greps the eight production
sync sources for `.withSession(` callers and asserts the count matches an
explicit allow-list (3 today: _sync, _forceDownload, _downloadOps).

Brittle on purpose. A future contributor adding a 5th sync entry point
will see this fail and have to read the contract in
sync-session-validation.service.ts before updating ALLOWED_ENTRY_POINTS.
That's the friction the runtime guards (the new withSession callback
API + outside-session warnings) can't deliver on their own — they catch
forgotten resets, but not "added a new top-level entry point without
considering whether it's a session boundary at all."

Wired into `npm run lint` via a new lint:sync-sessions step. Tried a
Karma spec first, but Karma doesn't serve the workspace at /base/ paths
without explicit config — a Node script via existing tools/ infrastructure
is simpler and runs the same in CI.

Verified by appending a fake 4th caller to operation-log-sync.service.ts
and confirming the script exits 1 with a contributor-friendly message
pointing at the contract file. Reverted before this commit.

#7330

* fix(sync): wrap ImmediateUploadService._performUpload in withSession()

Fourth #7330 entry point. uploadPendingOps() processes piggybacked
remote ops, which run validateAfterSync(); on corruption, validation
flips the SyncSessionValidationService latch. Without an explicit
withSession() wrapper here, the latch flip would either fire outside
any session (logged as a contract violation, dropped by the next
normal sync's reset) or — worse — go unread while _performUpload
claimed IN_SYNC based purely on result.uploadedCount. That reproduces
the exact #7330 surface on the immediate-upload path.

Wrap _performUpload's body in latch.withSession(...) and read
hasFailed() before any IN_SYNC / deferred-checkmark decision. On
failure (including a thrown upload after the latch flipped) emit
ERROR; transient errors with no latch flip remain silent.

Allow-list: ImmediateUploadService._performUpload added to
tools/check-sync-session-entry-points.js (now 4 entry points).

Tests: 5 new specs cover failure during piggybacked-op processing,
clean upload, LWW re-upload pass, latch reset between sessions, and
upload-throws-after-latch-flip.

Plan: docs/plans/2026-05-08-sync-run-service-refactor.md proposes a
type-enforced runner to replace the contract+lint pair. Deferred —
this PR closes the user-visible bug; the runner refactor is
value-over-time.

* chore(sync): drop withSession() entry-point lint check + deferred runner plan

The static check enumerates withSession() *callers* and asserts the set
matches an allow-list. It catches "added a withSession call without
updating the list" but not the inverse — "added a sync entry point
that should call withSession() but doesn't." _performUpload() was the
existence proof that the lint was silent on the actual failure mode.
Net: false confidence + maintenance churn for a problem nobody has.
The 4-entry-point contract is small enough for code review.

The deferred runner-refactor plan doc proposes a SyncRunService that
mints a SyncRunContext to enforce the contract via types. Two reviews
converged on "don't do it":
  - net more code (re-introduces the typed plumbing the latch was
    chosen to avoid in b3cbdbd41),
  - 3 of 4 entry points keep their own status logic (UNKNOWN_OR_CHANGED,
    deferred-checkmark) so the runner shell adds little structural value,
  - the hazard it closes (forgot withSession on entry point #5) is
    theoretical until that contributor exists.
Drop the doc rather than leave a Status:Proposal file rotting in repo.

withSession() itself stays — reset-and-only-reset at session entry is
load-bearing against leaked-failed latches; cheap insurance.

#7330

* docs(sync): correct entry-point list in SyncSessionValidationService

The top-of-file docstring listed `resolveSyncConflict() (USE_REMOTE
branch)` as a 4th entry point and omitted `ImmediateUploadService
._performUpload()` (added in 22f68027). Both inaccurate after the
ImmediateUploadService wrap and contradict the inline `reset()`
docs at line 104, which correctly identify USE_REMOTE as a sub-scope
inside `_sync()`'s session, not a session boundary.

Replace with the actual four entry points and call out USE_REMOTE
separately as a sub-scope re-scope.

#7330
2026-05-09 00:12:58 +02:00
Johannes Millan
7dfc7a6efb test(e2e): match renamed time-estimate icon in daily-summary spec
The time-estimate row icon in task-detail-panel was renamed from
`timer` to `hourglass_empty` in eca8d211a; update the supersync
daily-summary selector so the click target resolves again.
2026-05-08 22:30:59 +02:00
Johannes Millan
2271d7c9d4 test(e2e): tighten boards #7498 regression specs
Multi-review feedback: drop waitForTimeout (project rule), use the
taskPage.markTaskAsDone helper instead of raw locator clicks, and scope
the Eisenhower assertion to the specific quadrant the task belongs in.
Also exact-match the Kanban "Create Tag" button to avoid matching the
Eisenhower tab's plural label when both tab bodies briefly co-exist.
2026-05-06 21:37:06 +02:00
Johannes Millan
6bec7fb500 fix(boards): keep tasks visible when done-toggle is clicked (#7498)
Eisenhower quadrants and the Kanban DONE column had filters that made a
task vanish when its done-toggle fired: Eisenhower has no Done panel and
all four quadrants filtered UnDone, while Kanban DONE excluded the
in-progress tag that the toggle path doesn't strip.

Relax the defaults and migrate existing user state on loadAllData,
narrowly scoped to panels still matching the original default IDs.
2026-05-06 21:37:06 +02:00
Johannes Millan
b51bd2c9ca
New focus mode rework (#7411)
* fix(android): avoid false WebView version lockout

* fix(android): add WebView block recovery paths

Builds on the prior authoritative-vs-fallback fix with three layered
recovery mechanisms so users hit by a false BLOCK are never locked out
of their data:

- Last-known-good auto-recovery: persist the highest WebView version
  that has ever loaded the app on this device. A later transient
  mis-read that drops below MIN_CHROMIUM_VERSION is downgraded to WARN.
- "Try anyway" override: third button on the block screen opens an
  AlertDialog with an explicit risk acknowledgment (crashes, render
  failures, possible data loss). Confirming persists an override and
  relaunches the app. Hardened against tapjacking via
  filterTouchesWhenObscured on both the activity and dialog window.
- Override auto-clears once a healthy version is detected, so a future
  genuine block is not silently bypassed.

Also tightens the UA regex (drops the misleading Safari Version/X
fallback that always reads "4.0" and would falsely block) and adds
diagnostic logging gated by Log.isLoggable for field debugging.

Tests: 12 unit tests covering statusForVersion branches, all
applyOverrides paths, and parseMajorVersion edge cases.

Refs #7229

* fix(android): recover tracking after WebView cold start (#7390)

When the WebView is killed in the background (e.g. profile switch on
GrapheneOS) the JS-side state is lost on the next cold start, but the
native foreground tracking service keeps accumulating elapsed time. The
app previously discarded that elapsed time on cold start, leading to
silent data loss for the user.

Recovery flow:
- syncTrackingToService$ detects "no current task + native is tracking"
  on the first emission after hydration and emits a recovery request.
- syncOnResume$ does the same on warm resume.
- processRecovery$ drains requests with exhaustMap, coalescing concurrent
  triggers onto a single in-flight recovery.
- _doRecover syncs the native elapsed time onto the task and dispatches
  setCurrentId, restoring the JS-side tracking state.
- The null→task re-emission in syncTrackingToService$ then calls
  updateTrackingService instead of startTrackingService when native is
  already tracking the same task, preserving the just-reconciled native
  counter (Kotlin's startTracking otherwise resets accumulatedMs).

Supporting changes:
- onResume$ is now a ReplaySubject(1) so cold-start emissions delivered
  before the JS subscriber attaches are still received. The 4 existing
  consumers are idempotent native-queue drains and verified safe.
- parseNativeTrackingData extracted as a top-level pure function with
  shape validation; warning logs use a length-only fingerprint to avoid
  burning user content into the exportable log if the native contract
  ever changes.
- Diagnostic 'source' label ('cold-start' | 'resume') in the recovery
  log line for field triage of any future re-reports.

Tests: 12 unit tests for parseNativeTrackingData against the real
production code, plus 4 helper-level tests for the null→task transition
logic. The pipeline-level tests follow the file's existing pattern of
re-implementing logic due to the IS_ANDROID_WEB_VIEW gate.

Not addressed (out of scope, separate Kotlin work): write-side flush
reliability under aggressive OS kills (flushOnPause$ may not complete
before WebView termination). The recovery covers most cases by reading
the native counter as the source of truth.

* fix(sync): warn before destructive SYNC_IMPORT actions

Previously the 'Server Already Has Data' dialog described a destructive
SYNC_IMPORT as a 'merge' with a primary-colored 'Upload Local Data'
button — leading users to clobber syncing devices' data. The decrypt-
error 'Overwrite Remote' button had similarly understated copy and no
final confirmation gate.

- Rewrite D_SERVER_MIGRATION_CONFIRM body to call out 'overwrite' /
  'replace' / 'other devices'; affirmative button is now 'Replace
  Server Data' with color=warn.
- Rewrite D_DECRYPT_ERROR P3 + button label to make cross-device
  blast radius explicit.
- Gate updatePWAndForceUpload behind a confirmDialog with a stronger
  warning string.
- Add component spec for the migration dialog as a regression guard.

* fix(infra): close db-startup race in supersync e2e stack

pg_isready -U supersync without -d returned OK as soon as postgres
accepted connections to the default database, but during first-run
initdb the server briefly bounced while POSTGRES_DB was created.
supersync's prisma db push then race-failed with P1001.

- Healthcheck now runs psql -d supersync_db -c 'SELECT 1' so it only
  passes once the app's db is queryable.
- Dockerfile.test entrypoint retries prisma db push up to 15x before
  giving up — defense in depth if anything else ever races.

* chore(sync): instrument destructive-recovery paths for next incident

Adds read-only diagnostic logs at the four sites a sync-stuck incident
flows through, so the next occurrence is debuggable from a single log
file without forensic recovery:

- clean-slate.service: snapshot prior vector clock, count + opType
  breakdown of unsynced ops, syncImportReason — captured before any
  mutation
- sync-wrapper.service: forceUpload(triggerSource) typed union stamps
  which error class drove the user into destructive recovery
- remote-ops-processing.service: incoming full-state op shape +
  receiver's prior clock and unsynced-op tally about to be wiped
- credential-store.service: encryptKey state on every fresh disk load,
  length-redacted ([length=N] / [empty]) — surfaces the
  isEncryptionEnabled=true + empty-key smoking-gun signature

No behaviour change. Hot sync paths are untouched (full-state branch is
gated; load() short-circuits on cache). Existing redaction patterns
preserved — keys never logged in plaintext.

* fix(sync): apply incoming SYNC_IMPORT silently with no pending ops

Receiving clients with only already-synced data (no unsynced pending
changes) used to see a conflict dialog when an incoming SYNC_IMPORT
arrived. If the user picked USE_LOCAL — a natural reaction to "your
data may be lost" — forceUploadLocalState() re-uploaded the pre-import
state as a new SYNC_IMPORT, rolling back the import (e.g. encryption
change) for every device.

The originating device already gates the SYNC_IMPORT behind a strong
warning (D_SERVER_MIGRATION_CONFIRM, b761efd8). The receiving-side
dialog is now scoped to the case where unsynced pending user changes
would actually be lost; already-synced store data is no longer treated
as a conflict.

Switches the gate from _hasAnyMeaningfulData (pending OR store) to
_hasMeaningfulPendingOps (pending only) in both the download and
piggyback paths. Drops the now-redundant isEncryptionOnlyChange
short-circuit — under the new gate, PASSWORD_CHANGED SYNC_IMPORTs
without pending ops fall through to silent acceptance for free.

- New unit tests for the silent-accept path on both code paths
- New e2e regression guard (supersync-import-conflict-dialog) — fails
  if the gate ever reverts to including store contents
- supersync-scenarios.md D.1 / D.6 and the flowchart gate updated

* fix(infra): repair supersync test Dockerfile retry CMD

Two bugs in the prisma db push retry loop introduced in 81634a17f2:

1. Shell form CMD wraps the command in /bin/sh -c, so $(seq 1 15) was
   expanded by the outer shell into a multi-line value. Busybox's ash
   refuses `for i in 1\n2\n...\n15; do` with "expected do" and the
   container exited immediately. Switched to exec form so the inner
   sh -c does the expansion in unquoted context where word-splitting
   flattens the newlines.

2. After 15 failed attempts the loop's final exit status was the
   status of `sleep 2` (zero), so `&& node` would still launch the
   server against an unmigrated DB and surface as confusing Prisma
   errors at request time. Replaced break/&& with `exec node
   dist/src/index.js` on success so the loop cannot fall through,
   followed by an explicit exit 1 if the loop ends.

* refactor(sync): tighten SYNC_IMPORT gate naming and inline single-use helper

Inline _hasAnyMeaningfulData at its remaining caller (the snapshot/provider-
switch path) and rename _hasMeaningfulLocalData to _hasMeaningfulStoreData
for parallel naming with _hasMeaningfulPendingOps. Strengthen the silent-
accept piggyback test to assert kind === 'completed' rather than
\!== 'cancelled', and clarify the originating-device cross-reference in the
piggyback (D.6) doc and the IMPORT_CONFLICT diamond in the flowchart.

* test(e2e): use spinner cycle for SYNC_IMPORT silent-accept completion signal

Replace the syncCheckIcon-based completion race with a spinner visible→hidden
cycle. The check icon may be stale from a prior sync, which forced the test
to add a "wait for the new sync to start" guard; the spinner toggles per-sync
and is unambiguous. The conflict dialog still races against completion, so
the test fails fast if a regression brings the dialog back.

* test(schedule): bound safeFormatDate coverage for #7405

Parameterize the existing NG0701 regression spec across every
DateTimeLocales value to prove safeFormatDate handles any locale
a user could configure, and assert that 'en-us' itself never
triggers NG0701 (which would refute the #7405#7383 duplicate
diagnosis if the reporter's dateTimeLocale is 'en-us').

* fix(sync): flush pending writes before SYNC_IMPORT silent-apply gate

Without flushing first, an op captured in OperationCaptureService but not
yet drained to IndexedDB is invisible to getUnsynced(); the gate silently
accepts the import and SyncImportFilterService then discards the
just-landed op as CONCURRENT. Mirrors the upload-path flush.

* docs(sync): align SYNC_IMPORT scenarios with current gate semantics

Rename stale _hasMeaningfulLocalData() refs to _hasMeaningfulStoreData()
and remove the dead Encryption-only flowchart node — PASSWORD_CHANGED
SYNC_IMPORTs without pending ops now fall through the standard gate.

* feat(focusMode): simplify clock styles and improve for #7403

* feat(focusMode): always sync with tracking, add autoStartFocusOnPlay

Lifecycles between focus session and time tracking are now always
linked (pause↔pause, stop↔stop, resume↔resume). The
isSyncSessionWithTracking toggle is removed, which fixes #6731 by
construction (pause-focus now always stops tracking). A new opt-in
flag autoStartFocusOnPlay (default off) lets pressing the play
button on a task also spawn a focus session quietly — the workflow
asked for in #5737.

The settings form gets a two-tier layout: primary controls
(autoStartFocusOnPlay, focusModeSound) and a collapsed Advanced
section for isPauseTrackingDuringBreak, isStartInBackground,
isSkipPreparation, isManualBreakStart. The missing default for
isManualBreakStart is filled in.

Driven by discussion #6781 (~100% of polled Pomodoro+tracking users
want them synced). Design notes:
docs/plans/2026-04-29-focus-mode-time-tracking-sync.md. The
banner→dedicated indicator UI is deferred to a follow-up after the
community picks an anchor.

* feat(focusMode): replace session banner with focus-button countdown indicator

When a focus session is in flight and the rich overlay is closed, the
header focus button shows the inline countdown (with a small `#cycle`
prefix in Pomodoro mode). Clicking the button opens the overlay for
all other actions — pause/resume falls out naturally from the
play-button tracking sync, and skip-break / end-session are one extra
click away via the overlay.

The banner-based surface is removed entirely:
- BannerId.FocusMode and its priority entry are deleted.
- updateBanner$, _getBannerActions, and the banner-action helper
  methods (_handleStartAfterBreak, _handleStartAfterSessionComplete,
  _handlePlayPauseToggle, _handleSkipBreak, _handleEndSession,
  _handleOpenOverlay) are removed from FocusModeEffects.
- closeOverlay() in the overlay no longer spawns a banner — the
  focus-button indicator surfaces automatically when isOverlayShown
  flips to false.

Also fixes the priority-conflict raised in #6781 — focus-session
controls are no longer pre-empted by higher-priority banners
(TakeABreak, CalendarEvent, etc.).

* style: drop stray blank line in focus-mode.bug-5995 spec

* test(e2e): align focus-mode specs with banner-removal + always-sync

The focus-mode rework on this PR introduced three behavior changes that
broke nine existing e2e tests:

- Play button is now disabled until a task is current (sync between
  focus session and tracking is always on).
- The session/break banner surface was removed in favor of the header
  focus-button countdown indicator.
- The isSyncSessionWithTracking toggle no longer exists.

Updates:

- focus-mode-break.spec.ts: beforeEach now starts tracking the seeded
  task so the focus-mode play button is enabled.
- pomodoro-timer-sync-bug-5954.spec.ts: the two "no valid task" tests
  now assert the play button is disabled and the "select task to focus"
  placeholder is shown — the new prevention path replaces the
  showFocusOverlay-on-empty-task fix the original bug shipped.
- pomodoro-timer-sync-bug-5974.spec.ts: the close-overlay assertions
  now check focus-button .focus-running-label instead of <banner>.
- bug-5995-break-resume.spec.ts: skipped — the test exclusively
  exercised banner pause/resume of the break, which no longer exists.
  Break pause/resume from the in-overlay component is covered by the
  48 reducer + 14 component unit tests called out in
  focus-mode-break.spec.ts's existing note.

* test(focusMode): regression for #6731 — pause stops time tracking

Locks in the always-sync behavior promised by the rework: pause in the
focus overlay must clear the current task, even after the overlay is
closed. Without `syncSessionPauseToTracking$` firing this would regress
silently.

* feat(focusMode): migrate legacy isSyncSessionWithTracking flag

Existing users with isSyncSessionWithTracking: true relied on the play
button auto-spawning a focus session. Without a migration, dropping the
old flag would silently turn auto-spawn off for them. Backfill the new
autoStartFocusOnPlay opt-in from the legacy value during loadAllData and
strip the deprecated key from the resulting state.

Also fix stale comments in the bug-6575 spec referencing the removed flag.

* feat(focusMode): show focus-button on mobile while session is active

The header focus-button now doubles as the running-session indicator
(replacing the removed BannerId.FocusMode banner). On mobile it was
hidden to save space, leaving users with no surface to see or open a
running session after the overlay was closed. Surface it on mobile too
when a session/break is in flight; resting state on mobile is unchanged.

* chore(focusMode): drop dead isStartInBackground setting

Its only consumer (autoShowOverlay$) was removed in this rework, so the
checkbox in Advanced no longer affected anything — a footgun for users
who would toggle it expecting an effect. Remove from the form, default
config, translation key index, and en.json. Keep the field on the type
as @deprecated so old persisted configs still deserialize.

* fix(focusMode): migration ordering, paused-state indicator, dead SCSS

Issues caught in multi-agent review:

1. migrateFocusModeConfig was being called AFTER the default-spread, so
   `autoStartFocusOnPlay` was already `false` (from defaults) when the
   `?? \!\!isSyncSessionWithTracking` ran — the legacy `true` was always
   short-circuited away. Real persisted JSON never carries the new key.
   Run migration on the raw incoming config first, then merge defaults
   to backfill missing fields. Update the test fixture so the legacy key
   shape matches real persisted data (no explicit `undefined`); add a
   sanity check and a prototype-pollution defensive case.
   Switch the `in` check to `hasOwnProperty.call` for the same reason.
   Tighten the boolean coerce to `=== true` so a tampered non-bool
   (e.g. string from a hand-edited JSON) cannot flip the migration.

2. The header focus-button `circleVisible` and the new mobile
   `isFocusSessionActive` only counted running sessions/breaks. Pausing
   a focus session made the button vanish on mobile and go blank on
   desktop — the very failure mode the indicator was meant to fix.
   Include `isSessionPaused()` in both gates.

3. The `.focus-btn-wrapper` / `.focus-label` block in
   `main-header.component.scss` is dead code: `focus-button` is its own
   encapsulated component and already styles those classes. Remove.

* test(e2e): assert focus-button countdown stays visible while paused

Two changes:

1. Extend issue-6731 e2e to assert the header focus-button countdown is
   still visible after the user pauses + closes the overlay. This is
   the exact regression the previous commit fixed (paused work session
   used to make `circleVisible` go false, hiding the countdown).

2. Drop the stale "Sync focus sessions with time tracking (plural)"
   typo-verification test from bug-5974 — the label was removed in the
   focus-mode rework, the test was silently passing without asserting
   anything because of an `if (count > 0)` guard.

* test(focusMode): cover cycleLabel + autoStartFocusOnPlay end-to-end

Two new test files closing the highest-risk gaps the multi-agent review
flagged:

1. focus-button.component.spec.ts (unit, 9 cases): pin the cycleLabel
   contract — null for non-Pomodoro, current cycle for work, cycle-1 for
   break (the cycle that just finished), floor at 1, treat 0 as 1. Also
   add a regression guard for circleVisible covering the paused state
   so refactors of selectIsSessionPaused can't silently hide the
   countdown again.

2. auto-start-focus-on-play.spec.ts (e2e, 2 cases): the headline feature
   of the rework had no e2e — verify play→spawn happens with the opt-in
   on (and the overlay stays closed) and does NOT happen with the opt-in
   off (the default). Without this, refactors of
   syncTrackingStartToSession$ could break auto-spawn silently.

* fix(focusMode): restore _focusModeService injection lost in master merge

Master commit a5fb3c4a (#7404, "restore play button on mobile") reverted
the FocusModeService injection along with the isPlayButtonVisible logic
it was originally added for. After merging master into this branch,
isFocusSessionActive (added here for the mobile focus-button indicator)
referenced the deleted property, breaking the CI build with three
TS2339 errors at main-header.component.ts:168-170.

Re-add the import and the private readonly _focusModeService injection.
The need for it is now isolated to this PR's mobile-indicator computed,
not the reverted play-button logic.
2026-05-06 21:11:02 +02:00
Johannes Millan
1e6fcb9a10 - fix(archive): also normalize malformed timeTracking field (#7487)
- test(e2e): regression test for malformed archive crash (#7487)
- fix(archive): normalize malformed task field on load (#7487)
2026-05-05 15:41:25 +02:00
Johannes Millan
414cd7f508 fix(tasks): recover focusedTaskId when shortcut fires on a focused task
After commit 2105bbc8 switched task focus tracking from `focus`/`blur` to
`focusin`/`focusout`, the focusout from a textarea blur (e.g. closing
inline title-edit on Enter) clears focusedTaskId before the paired
focusin from the subsequent host.focus() refocus rebinds it. When the
host was already document.activeElement, the refocus is a no-op and no
focusin fires — keystrokes then silently drop because the shortcut
handler bails on `\!focusedTaskId`.

Recover by deriving the id from `document.activeElement.closest('task')`
when the signal is null. Guard `_handleTaskShortcut` so the recovery
never delegates to a stale lastFocusedTaskComponent that points at a
different task than the active element.

E2E `markTaskDone` helper additionally blurs the active element before
.focus() so the focus call always fires events even when the host is
already active. Defense in depth — the production fix is sufficient on
its own, but this keeps the helper representative of real user input.

Fixes the deterministic CI failure of supersync-archive-subtasks.spec.ts
"Add subtask then immediately archive syncs correctly" since 2026-05-01.
2026-05-02 19:23:26 +02:00
Johannes Millan
340b75f1f0 fix(sections): drag-drop puts tasks at the right slot (#7452)
The display selector ordered in-section tasks by workContext.taskIds,
ignoring section.taskIds — so reorders inside or across sections never
moved on screen, and section→no-section drops snapped to the old slot.

- Make section.taskIds authoritative for in-section ordering by walking
  sections first in undoneTasksBySection.
- Fold the section→no-section reorder into a single atomic action:
  removeTaskFromSection now also repositions the task in the
  work-context's taskIds via sectionSharedMetaReducer (same pattern as
  TaskSharedActions.deleteTask et al), so one op covers both mutations
  and partial replay can't leave the task half-moved.
2026-05-02 15:58:19 +02:00
Johannes Millan
01e1776094 fix(task-repeat-cfg): re-anchor schedule when startDate moves earlier (#7423)
When editing a recurring task to move startDate earlier than the existing
lastTaskCreationDay, the live instance was rescheduled to the day after
the OLD startDate (getNextRepeatOccurrence advanced past the stale anchor)
instead of landing on the NEW startDate. Re-anchor via getFirstRepeatOccurrence
in that case. Skipped for repeatFromCompletionDate configs where startDate
is decoupled from scheduling.
2026-05-01 19:35:28 +02:00
Johannes Millan
d1a58124bf test(e2e): add regression for recurring missed-day bug #4559
Locks down the cold-start-after-miss path: configures a monthly
"first of month" recurring task on Jun 1, jumps to Jul 3 — skipping
Jul 1 entirely — and asserts the missed Jul 1 instance is materialised
on reload.

Uses setSystemTime rather than setFixedTime so the 1s debounceTime in
TaskDueEffects.createRepeatableTasksAndAddDueToday$ keeps ticking on
the freshly-reloaded page.
2026-05-01 19:35:28 +02:00
Johannes Millan
19f1cabda0 test(e2e): use spinner cycle for SYNC_IMPORT silent-accept completion signal
Replace the syncCheckIcon-based completion race with a spinner visible→hidden
cycle. The check icon may be stale from a prior sync, which forced the test
to add a "wait for the new sync to start" guard; the spinner toggles per-sync
and is unambiguous. The conflict dialog still races against completion, so
the test fails fast if a regression brings the dialog back.
2026-04-29 16:17:56 +02:00
Johannes Millan
9d3cf64986 fix(sync): apply incoming SYNC_IMPORT silently with no pending ops
Receiving clients with only already-synced data (no unsynced pending
changes) used to see a conflict dialog when an incoming SYNC_IMPORT
arrived. If the user picked USE_LOCAL — a natural reaction to "your
data may be lost" — forceUploadLocalState() re-uploaded the pre-import
state as a new SYNC_IMPORT, rolling back the import (e.g. encryption
change) for every device.

The originating device already gates the SYNC_IMPORT behind a strong
warning (D_SERVER_MIGRATION_CONFIRM, b761efd8). The receiving-side
dialog is now scoped to the case where unsynced pending user changes
would actually be lost; already-synced store data is no longer treated
as a conflict.

Switches the gate from _hasAnyMeaningfulData (pending OR store) to
_hasMeaningfulPendingOps (pending only) in both the download and
piggyback paths. Drops the now-redundant isEncryptionOnlyChange
short-circuit — under the new gate, PASSWORD_CHANGED SYNC_IMPORTs
without pending ops fall through to silent acceptance for free.

- New unit tests for the silent-accept path on both code paths
- New e2e regression guard (supersync-import-conflict-dialog) — fails
  if the gate ever reverts to including store contents
- supersync-scenarios.md D.1 / D.6 and the flowchart gate updated
2026-04-29 16:17:56 +02:00
Iván Velázquez
f8f405c623
feat: add sections in projects (#6066)
* 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>
2026-04-29 15:02:33 +02:00
Johannes Millan
fb61ed7d92 fix(boards): re-enable Save in Eisenhower Matrix edit dialog (#7380)
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.
2026-04-27 15:04:06 +02:00
Johannes Millan
654f4d80ee 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.
2026-04-25 22:36:14 +02:00
Johannes Millan
447ae68ff3 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.
2026-04-25 22:36:14 +02:00
Johannes Millan
b94b6c4e78 fix(task-repeat-cfg): preserve planned date when converting past-dated task to recurring (#7344)
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.
2026-04-24 17:57:38 +02:00
Johannes Millan
99b7dee74a fix(backup): move shared timestamp util into electron/ for snap packaging
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
2026-04-22 16:14:56 +02:00
Johannes Millan
ff523fd4dd refactor(reminders): address code review feedback for deadline fixes
- 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.
2026-04-21 21:50:52 +02:00
Johannes Millan
2db074d8e9 fix(reminders): clear unhandled deadline reminders on dialog close
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.
2026-04-21 21:50:52 +02:00
Johannes Millan
9aba039b59 test(e2e): tighten backup-export selector for renamed privacy button
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.
2026-04-21 21:50:52 +02:00
novikov1337danil
1abb15b804
refactor(backup): unify backup filename format (#7141)
* refactor(backup): implement consistent timestamp for backup filenames

* refactor(i18n): rename PRIVACY_EXPORT key to PRIVACY_EXPORT_DESCRIPTION

* refactor(i18n): add PRIVACY_EXPORT key for data export in multiple languages

* refactor(backup): standardize backup filename prefix

* refactor(backup): update backup filename format to use underscore separator

* refactor(i18n): update translations

* refactor(backup): move getBackupTimestamp utility to a shared location for consistency

* test(backup): add unit tests for getBackupTimestamp utility

* fix(e2e): move MIGRATION_BACKUP_PREFIX to migration.const

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor(tests): move jasmine.clock().install() to beforeEach for consistency

* refactor(backup): consolidate backup filename constants and update imports

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-21 14:09:09 +02:00
Johannes Millan
01e30b9c7e
Feat/to me it looks like there are lots of 60dd04 (#7280)
* 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)
2026-04-20 12:04:38 +02:00
Johannes Millan
de33234976 test(e2e): harden LWW archive and encryption password-change tests
- 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).
2026-04-19 19:20:34 +02:00
Johannes Millan
191d129ff3 test(e2e): restore title tolerance in LWW archive resurrection assertion 2026-04-18 23:24:19 +02:00
Johannes Millan
b9d6dc6d86
test(e2e): update Show/hide task panel button label (#7260)
* 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>
2026-04-17 22:19:22 +02:00
Johannes Millan
8e8a39b832 fix(note): linkify URLs in locked notes so markdown links stay clickable
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
2026-04-16 19:47:52 +02:00
Johannes Millan
0e9218bd68 fix(sync): handle data validation errors and improve client ID management
- 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)
2026-04-16 17:41:39 +02:00
Johannes Millan
23c729bd9c style(e2e): fix prettier formatting in supersync spec 2026-04-14 22:09:02 +02:00
Johannes Millan
c558bcab5e test(e2e): fix failing and flaky supersync archive conflict tests
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.
2026-04-14 22:09:02 +02:00
Johannes Millan
265b44db5d test(e2e): fix flaky supersync tests by preventing premature waitForURL resolution
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.
2026-04-14 22:09:02 +02:00
Johannes Millan
f528262f8d test(e2e): fix bug-7067 spec to work in production builds
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.
2026-04-01 17:57:50 +02:00
Johannes Millan
fc02693287 style: fix prettier formatting errors in e2e tests and nav-item scss
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-01 15:41:14 +02:00
Johannes Millan
de3e374396 fix(task-repeat-cfg): handle invalid startTime from sync/import gracefully (#7067)
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
2026-04-01 15:41:14 +02:00
Johannes Millan
d32f7037a3 fix(sync): preserve own vector clock counter across full-state op resets
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.
2026-04-01 15:41:13 +02:00
Johannes Millan
cb3f4d1ed8 Merge branch 'feat/issue-7032-034b15'
* 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)
2026-03-30 23:26:13 +02:00
Johannes Millan
2f2f861b9f test(e2e): block WebSocket in SuperSync tests to prevent sync race conditions
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.
2026-03-30 23:14:01 +02:00
Johannes Millan
08f452a1fe fix(tasks): handle markdown links in short syntax URL extraction (#7032)
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.
2026-03-30 23:01:48 +02:00
Thorsten Klein
7fa8f12132
feat(sync): add Helm chart and WebSocket push for SuperSync (#6971)
* 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>
2026-03-30 21:34:30 +02:00