Skip to content

fix(boards): add existing tasks to tagged columns - #9295

Merged
johannesjo merged 2 commits into
masterfrom
fix/board-column-add-existing
Jul 24, 2026
Merged

fix(boards): add existing tasks to tagged columns#9295
johannesjo merged 2 commits into
masterfrom
fix/board-column-add-existing

Conversation

@johannesjo

@johannesjo johannesjo commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

Selecting an existing task suggestion from a tag-filtered board column's add bar did not make the task appear in that column.

Board membership is derived from task.tagIds, but the existing-task suggestion path never applied the destination panel's tag policy. It could also apply active work-context defaults and show a movement snackbar that did not describe the board operation.

Solution

  • Distinguish newly created tasks from selected existing suggestions in the add-task event.
  • Keep the existing new-task ordering, schedule, and backlog behavior unchanged.
  • For selected existing tasks, apply the destination column's tag policy with the established rewriteTagIdsForPanel() helper.
  • Never persist the virtual TODAY tag.
  • Persist at most one Task update and return before Boards ordering, scheduling, or backlog operations.
  • Skip work-context defaults and movement snackbars for board add bars.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring
  • Documentation
  • Other (please describe)

Verification

  • npm run checkFile passed for all five changed TypeScript files.
  • add-task-bar.component.spec.ts: 51/51 tests passed.
  • board-panel.component.spec.ts: 24/24 tests passed.
  • Angular production build passed.
  • Repository-wide commit-hook lint, lint-rule tests, and theme-asset tests passed.

Checklist

  • Documentation changes are not required for this focused bug fix.
  • I have run npm run checkFile on changed .ts/.scss files
  • I have added tests for my changes (if applicable)
  • Existing tests still pass
  • My commit messages follow the Angular format (type(scope): description)

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Preview Deployment

Status URL
Deployed https://e3cd840f.super-productivity-preview.pages.dev

Branch: fix/board-column-add-existing
Commit: 9e8fa2a


Deployed with Cloudflare Pages

@johannesjo
johannesjo force-pushed the fix/board-column-add-existing branch from 2f72e6d to 852bb01 Compare July 24, 2026 18:56
The board tag pickers offer My Day (isShowMyDayTag), but TODAY_TAG must
never land in task.tagIds - membership derives from dueDay/dueWithTime
(ARCHITECTURE-DECISIONS #2). rewriteTagIdsForPanel concat'd includedTagIds
verbatim, so a cross-panel drop into such a column persisted 'TODAY' via
updateTags -> updateTask, which (unlike handleAddTask) has no strip, and
synced the corruption to every device.

Pre-filtering the panel cfg at the call site fixed one path but broke
another: dropping TODAY from excludedTagIds let an AND-exclude fire in the
rewrite that doesTaskMatchPanel can never hit (it only ever sees real
tags), so adding an existing task removed a real user tag to satisfy a
filter that was already inert.

Teach the shared util instead, so it and the match predicate agree: ignore
TODAY_TAG on the include side, keep it on the exclude side, and heal a
legacy TODAY_TAG already carried in a task's tags. Both callers - drop()
and afterTaskAdd - now go through the raw panelCfg.

Also finish the TaskAddEvent migration in app.component.
@johannesjo

Copy link
Copy Markdown
Collaborator Author

Follow-up: 9e8fa2a136

Re-reviewing the tag rewrite turned up two defects. The PR body's claim "Never persist the virtual TODAY tag" held only for the new afterTaskAdd path — it is accurate for the whole feature as of this commit.

1. drop() persisted the virtual TODAY tag

rewriteTagIdsForPanel concat'd includedTagIds verbatim, and the board tag pickers do offer My Day (isShowMyDayTag="true" in formly-tag-selection.component.html). Dragging a task into such a column wrote 'TODAY' into task.tagIds, violating ARCHITECTURE-DECISIONS #2 — and it syncs to every device.

The reason nothing caught it: handleAddTask strips TODAY_TAG from tagIds with an explicit comment, but handleUpdateTask has no such guard — it writes changes.tagIds verbatim, and handleTagUpdates only protects the Tag entity's taskIds. So updateTagsupdateTask is the one unguarded path, and that is exactly the one boards uses.

2. Pre-filtering the panel cfg deleted a real user tag

Stripping TODAY out of excludedTagIds before the rewrite let an AND-exclude fire that doesTaskMatchPanel can never hit: it only ever sees real tags, so excludedTagIds.every(...) with TODAY in the list is always false.

Panel excludes ['TODAY','x','y'] with match all, task has ['x','y','keep'] → the exclusion was already inert, but the rewrite still removed 'x'.

Fix

Teach rewriteTagIdsForPanel rather than the call site, so it and doesTaskMatchPanel agree: ignore TODAY_TAG on the include side, keep it on the exclude side, and heal a legacy TODAY_TAG already carried in a task's tags. drop() and afterTaskAdd both pass the raw panelCfg now — board-panel.component.ts is a net deletion.

For any panel without My Day in its tag lists the function is byte-identical to before; the only other difference is that a task carrying a legacy 'TODAY' now gets it stripped (and emits one extra updateTags op) on its next drop.

Consequence worth flagging

A board column that requires My Day only ever "worked" because drop() wrote the tag. Such columns now stay empty, and an already-corrupt task drops out of one on its next drop. Making them work properly means teaching doesTaskMatchPanel about dueDay/dueWithTime — a feature, not a fix.

Verification

7 tests cover it: 5 new boards.util.spec.ts cases, 2 new component cases (one per call site), and 1 updated — the previous expectation ['y','keep','need'] encoded the tag loss and is now ['x','y','keep','need']. All 7 fail when the util change is reverted. Full unit suite green in both timezones (13507 Berlin / 13493 Los Angeles), AOT build clean, npm run checkFile clean on every touched file.

Deliberately not in this PR

  • Adding an existing task applies only the tag dimension. drop() also handles done-state, project move, _checkToScheduledTask and _checkBacklogState — so on a column filtered by any of those, picking an existing task is a silent no-op.
  • Existing tasks land at the bottom of a manual-order column while the add row sits at the top (no updatePanelCfgTaskIds dispatch). New tasks go to the top.
  • The systemic fix would be stripping TODAY_TAG in handleUpdateTask, closing the class of bug for every caller. That changes replay semantics for already-recorded ops, so per the sync rules it needs a reproducible failure first.

@johannesjo
johannesjo marked this pull request as ready for review July 24, 2026 20:32
@johannesjo
johannesjo merged commit 8a32038 into master Jul 24, 2026
16 checks passed
@johannesjo
johannesjo deleted the fix/board-column-add-existing branch July 24, 2026 20:53
johannesjo added a commit that referenced this pull request Aug 4, 2026
* fix(caldav): pull completion state from server todos #9278

Two defects kept a server-side STATUS:COMPLETED from ever ticking the
SP task done:

- getFreshDataForIssueTasks queried and matched server todos by SP's
  internal task id instead of the VTODO UID (task.issueId), so the
  batch update poll never detected any remote change at all.
- getAddTaskData never mapped the issue's completed state to isDone,
  so even the single-task refresh path updated everything except the
  done state (Plainspace and Nextcloud Deck already map this).

Specs mirror the Nextcloud Deck completion-pull guards: without the
fix, the UID-matching test and the three isDone mapping tests fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: close critical E2E and CI coverage gaps (#9274)

* test(worklog): verify archived duration corrections persist

* ci(android): run JVM tests on pull requests

Run the Play and F-Droid debug JVM suites for Android-affecting changes while keeping a stable required-check status on unrelated PRs.\n\nCloses #8734

* test(supersync): run repair causality integration test

Include the PostgreSQL repair-causality regression in the CI integration script's explicit test list.\n\nCloses #8773

* ci(sync): close provider E2E path gaps

Select both real-provider suites for every persistent action owner and the client clock, date, and replay guards. Mirror the same coverage on master and release pushes.\n\nRefs #8733\nRefs #9262

* ci(android): run instrumentation tests on emulator

Use the AndroidX test runner and execute the Play debug instrumentation suite on an API 35 emulator for Android-affecting pull requests. This gates the on-device CursorWindow data-loss regression.\n\nRefs #8401\nRefs #9262

* ci(shared-schema): run package tests

* ci(plugins): discover test suites from manifests

Closes #8733

* test(boards): verify board creation persists

* test(migration): require legacy error recovery flow

* test(navigation): remove redundant route smoke

* test(keyboard): verify default shortcut behavior

* test(migration): verify error acknowledgement cleanup

* test(worklog): verify corrected time CSV export

* test(keyboard): verify remapped shortcut persistence

* test(recurring): verify occurrence and series removal

* test(mobile): add WebKit touch smoke

* test(pwa): verify persisted offline reload

* test(pwa): verify update activation

* test(performance): verify large-list row reuse

* test(webdav): cover split migration recovery

Exercise a real v2-to-v3 migration through WebDAV when the primary tombstone commits but its response is lost. Verify restart recovery, backup neutralization, pending-data survival, and the split-disabled no-write guard.

* fix(add-task-bar): keep global bar above the keyboard on mobile web (#9277)

* fix(add-task-bar): keep global bar above the keyboard on mobile web

The floating global add-task bar (position: fixed; bottom) sits nicely
above the on-screen keyboard on the native mobile builds but not on the
mobile web app.

Root cause is that the native builds resize the WebView when the keyboard
opens (Android `adjustResize`, iOS Keyboard `resize: 'native'`), so the
fixed bar re-anchors above the keyboard for free and `--keyboard-height`
stays ~0. On mobile web the default `interactive-widget=resizes-visual`
leaves the *layout* viewport full-height when the keyboard appears, so the
layout-viewport-anchored fixed bar depends entirely on the JS-computed
`--keyboard-height` offset, which is subject to visual-viewport panning
and cross-browser inconsistencies — hence the poor positioning.

Add `interactive-widget=resizes-content` to the viewport meta so the
virtual keyboard shrinks the layout viewport on mobile web too, matching
the native `adjustResize` behavior. Degrades gracefully everywhere it
doesn't apply: native WebViews handle the keyboard natively, iOS Safari
ignores the property and keeps the existing visualViewport tracking, and
desktop has no virtual keyboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyqioXWJEgh5KSvsbj7P5N

* fix(add-task-bar): anchor global bar to bottom on hybrid touch devices

On mobile web (Chrome on Android) the global add-task bar stayed pinned to
the top instead of the bottom, unlike the native Android app where it
correctly sits at the bottom.

Android Chrome advertises a fine pointer, so detect-it classifies phones as
`deviceType === 'hybrid'` rather than `touchOnly`. As a result `body.isTouchOnly`
is never set on mobile web, and the bar's bottom-anchoring rule — which keyed
on `.isTouchOnly` — never applied, leaving the bar at the top. The native
Android app is detected as touch-only, so it got the bottom layout; hence the
divergence.

Key the bottom-anchoring (and its iOS overlay-offset variant) on
`.isTouchPrimary` instead. That class is set for pure-touch devices and for
hybrid devices while touch is the active input (InputIntentService keeps it in
sync), so it is a superset of the previous `.isTouchOnly` condition and matches
the autocomplete panel rule in the same stylesheet. The mobile keyboard
positioning specs are updated to drive the layout via `.isTouchPrimary`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CyqioXWJEgh5KSvsbj7P5N

---------

Co-authored-by: Claude <noreply@anthropic.com>

* test(migration): wait for migrated state persistence

* fix(sync): hide inactive encryption actions (#9284)

Hide encryption controls until the selected sync provider is active. This removes the dead-end during initial setup and prevents unsaved provider switches from acting on the previous provider.

Fixes #9268

* fix(ui): improve responsive dialog actions (#9285)

* fix(onboarding): improve productivity preset and mobile first-task flow (#9288)

* fix(onboarding): enable finish day and advance mobile tour

Enable Finish Day for the Productivity Suite preset. Close the first-task composer only during touch mobile onboarding so the contextual tour can continue without changing desktop rapid-add behavior.

* fix(onboarding): scope composer auto-close to first task

* fix(tasks): delete sub-tasks via keyboard shortcut (#9280) (#9289)

remove() threw a TypeError on task.subTasks.forEach when passed a raw Task
entity (sub-tasks are rendered from raw entities without a subTasks array),
so the deleteTask dispatch never fired. The keyboard path hit this; the
context-menu path re-maps to TaskWithSubTasks first, so it worked.

Clear pending time via subTaskIds (a required base-Task field, always present)
instead of the runtime-optional subTasks array, which structurally avoids the
crash rather than merely guarding it.

* feat(planner): integrate recurring config into schedule dialog (#9286)

* feat(planner): add Repeat button to schedule dialog

Surface recurrence directly from the schedule dialog via a "Repeat"
button that opens the existing repeat-config dialog, so setting a task
to recur no longer requires the separate detail-panel row.

- Button mirrors the repeat dialog's own start-date button styling and
  shows the live recurrence label ("Daily", "Mon–Fri", ...) or
  "Does not repeat", with an edit/chevron affordance.
- Gated to top-level, non-issue tasks (matching the panel's Repeat row)
  and hidden in isSelectDueOnly mode to avoid a circular picker.
- Recurrence start is seeded from the date currently selected in the
  dialog, falling back to the task's due day / creation date.
- Label reads the repeat cfg via the store selector (not the service)
  to keep the dialog's dependency surface minimal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC

* feat(tasks): merge recurring into the schedule item in task detail panel

Fold the standalone "Repeat" row into the schedule/due item so a task's
timing lives in one place. The schedule item now also shows the
recurrence label (e.g. "Daily") beside the due date, and recurrence is
edited via the Repeat button now hosted in the schedule dialog.

- Remove the separate Repeat row and the now-unused editTaskRepeatCfg()
  opener; recurrence editing goes through the schedule dialog.
- Show a repeat chip in the schedule item's value when repeatCfgId is
  set, and treat repeatCfgId as "has a value" for the add/edit icon.
- showScheduleIcon() falls back to 'repeat' for a recurring task with no
  due date instead of the misleading 'alarm'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC

* style(datetime-picker): tighten spacing in the schedule picker

The date/time picker left noticeable dead space between the calendar and
the time input, and between the stacked time and reminder inputs.

- Shrink the fixed calendar height 400px -> 360px. A 6-row month (the
  structural max) needs ~353px, so the old value left ~47px of empty
  space below the grid; 360px fits every month with no clipping.
- Drop the calendar-to-inputs top margin and the reserved Material
  form-field subscript row (these fields never show hints and always
  carry a valid value), so the time and reminder inputs sit closer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC

* style(tasks): stack recurrence under the due date in the schedule item

When a task was both planned and recurring, the schedule item crammed
the date ("Today") and the recurrence ("Every day") side by side in the
narrow value column, squeezing both and truncating the recurrence label.

Present them as a hierarchy instead: the due date is the primary value
and the recurrence is a muted secondary line stacked beneath it. Reads
clearly, avoids truncation, and gives the row label room to breathe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC

* style(datetime-picker): unify vertical spacing below the inputs

The form-ctrl-wrapper carried a bottom margin on top of each field's own
~16px trailing space, so whatever followed the inputs sat 32px away —
twice the gap between the stacked fields themselves. In the schedule
dialog this left the Repeat button visibly detached; the deadline dialog
had the same doubled gap before its actions.

Drop the redundant bottom margin so the inputs, the Repeat button, and
the dialog actions all share one consistent 16px rhythm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC

* fix(planner): harden schedule-dialog recurrence + polish merged item

Review follow-ups across the recurring/planned consolidation:

- Fix a duplicate-cfg bug: openRepeatDialog seeded the repeat dialog from
  the frozen MAT_DIALOG_DATA snapshot, so re-opening after a repeat was
  just created routed to "create" again and orphaned the first cfg. Seed
  from the live store task instead, so the second open edits.
- Drop the ineffective "seed recurrence start from the selected date"
  logic and its misleading comment: the repeat dialog derives the start
  from the task's due date; targetDate only drives the skip-instance UI.
  Behaviour now matches task.component's opener exactly.
- Break the schedule-dialog <-> repeat-dialog module cycle with a lazy
  import, matching task.component / add-task-bar.
- Panel: give the recurrence line a screen-reader "Repeat:" prefix
  (cdk-visually-hidden) and let long labels ellipsis within the flex row.
- Align terminology: ADDITIONAL_INFO.REPEAT "Recur" -> "Repeat" (the
  add-task-bar and schedule dialog already say "Repeat").
- Use --bar-height token instead of a raw 48px in .repeat-btn.
- Add coverage: canRepeat gating (incl. isSelectDueOnly), the live-task
  seeding regression, and showScheduleIcon's new 'repeat' branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176AgeeX2Tzv3KjmwZ7zWXC

* test(recurring): follow schedule dialog repeat flow

Update shared Playwright helpers and direct callers after the standalone Repeat row moved into the schedule dialog.

* style(planner): remove one-off Material overrides

Use stock button presentation and the supported dynamic subscript API instead of local Material internals.

* style(planner): distinguish configured repeat values

Emphasize active recurrence while keeping the non-repeating state visibly muted.

* fix(recurring): avoid warning for newly added config

Require removal confirmation only when recurrence already existed before the enclosing schedule dialog opened.

* fix(recurring): preserve schedule context in repeat dialog

Seed new repeat configs from the current Schedule selection and suppress removal confirmation only for the exact config created in that dialog session.

Update the repeating-task guide and add regression coverage for date and config provenance.

* test(recurring): target combined schedule value

---------

Co-authored-by: Claude <noreply@anthropic.com>

* style(tasks): shrink add-task bar action icons

* fix(tasks): disable deadline short syntax by default (#9291)

* fix(tasks): restore archived tasks to Today (#9263) (#9293)

* fix(tasks): restore archived tasks to Today (#9263)

* fix(tasks): capture restore date after archive load

* fix(sync): preserve local restore after delete conflict

Re-emit the semantic restore operation when a sole local restore wins over a sole remote delete, preserving hierarchy, Today placement, and archive cleanup on replay. Covers the symmetric local-wins path for #9263.

* refactor(tasks): share dueWithTime-for-today normalization

Extract shouldClearDueTimeForToday, used by both the restoreTask action
creator and handlePlanTasksForToday, removing the duplicated clear-time
logic that had already drifted (the action creator guarded finite/positive
timestamps, the reducer did not).

The helper is throw-safe: a finite but out-of-JS-Date-range dueWithTime no
longer reaches isTodayWithOffset's throwing guard - it is cleared instead.

Also document, at the local-win restore guard, the intentional archive-
cleanup degradation for bulk/multi-op delete-vs-restore conflicts (the
generic snapshot path forgoes cleanup); broadening it is tracked in #9290.

Add unit tests for the helper.

* fix(plainspace): fail-closed completion sync; title/schedule pull-only (#9296)

* test(migration): wait for migrated state persistence

* fix(plainspace): make task synchronization fail closed

Restrict linked-task updates to completion and require PATCH responses to confirm the requested task and state.

Validate assigned-task snapshots and binding provenance, but keep removal candidates disconnected until an exact local-change baseline can authorize ordinary deletion.

* fix(plainspace): simplify synchronization safeguards

Accept the minimal completion response the adapter actually needs and remove dormant reassignment-candidate plumbing until deletion can be authorized by an exact unchanged baseline.

* refactor(issue): remove dead orphan-removal path

Plainspace was the sole implementor of getRemovedRemoteTasks; its removal
in the prior commit left the generic orphan-removal machinery unreachable
in production. Remove the optional interface method, the guarded caller
(_removeOrphanedRemoteTasks / _hasLocalContent) in IssueService, and the
specs that only passed by monkey-patching a getRemovedRemoteTasks spy onto
the provider mock (false coverage for dead code).

No behavior change. Verified: full project tsc --noEmit passes (no caller
breaks from dropping the interface method); issue.service.spec 21/21 and
plainspace-common-interfaces.service.spec 12/12 green.

* fix(sync): accept legacy singleton LWW ops rejected as tampering (#9256) (#9294)

* test(migration): wait for migrated state persistence

* fix(sync): accept legacy singleton LWW operations

Treat payload IDs as canonical only for adapter-backed LWW targets so legacy time-tracking operations can replay without weakening task retarget protection.

* fix(sync): harden singleton LWW replay

Preserve compatibility payload IDs for composite singleton conflicts while keeping adapter IDs canonical. Ignore malformed non-record singleton payloads before they can overwrite state.

* fix(sync): harden malformed LWW payload handling

* fix(sync): scope malformed LWW array handling

Normalize legacy numeric-key singleton payloads while preserving valid array-backed map values such as planner days.

* fix(sync): drop unreproduced LWW array-spread guard; add lockstep test

Review follow-up for the singleton LWW replay fix (#9256).

The #9256 repro (issue log: op 019d1e73, TIME_TRACKING, entityId
PROJECT:eP8tBLmm0tBgJThAZOxcT:2026-03-24) shows the failing payload is a
well-formed { project, tag } record with payloadId "<undefined>" — the
core isLwwPayloadIdCanonical fix already handles it. There is NO
array-spread or non-record payload anywhere in the repro, so the
numeric-key "legacy-array-spread-record" detector had no reproduced
failure behind it and carried a latent footgun (it would zero any future
singleton with all-numeric top-level keys). Remove it, per the repo rule
"start from a reproducible problem":

- Drop isLegacyArraySpreadRecord / isCanonicalArrayIndexKey /
  MAX_ARRAY_INDEX from the converter; keep the footgun-free !isRecord
  normalization (a bare string/array payload still no-ops).
- Drop the producer's isMalformedSingletonState; basePayload again
  includes arrays, which preserves PLANNER (map) day-array spreading and
  normal singletons alike.
- Remove the two tests that only exercised the deleted guard.

Also (no behavior change):
- Add a converter test pinning that an op whose plaintext actionType is
  swapped to a singleton type lands as a whole-slice singleton replace
  (id stripped), never a TASK retarget — gate/converter/reducer branch
  on the same actionType in lockstep.
- Document the integrity gate's adapter-only scope, the compat-id
  sunset, and the un-migrated entityId==='*' check in sync-core.

* test(sync): add end-to-end recovery, back-compat & classification coverage

Confidence-raising tests for #9256 (no production-code change):

- operation-encryption: weld the full seam in one test — real AES-GCM
  decrypt -> convertOpToAction -> lwwUpdateMetaReducer — proving the
  reporter's legacy TIME_TRACKING op actually restores { project, tag }
  state, not merely that it "wasn't rejected".
- conflict-resolution: simulate the shipped v18.15.1 metadata-integrity
  gate (faithful copy, verified via git show) and assert the new
  producer's compatibility id makes old clients accept the op — and,
  non-vacuously, that stripping it reproduces the #9256 rejection.
- entity-registry: exhaustive table asserting isLwwPayloadIdCanonical is
  true for exactly the adapter entities across all 18 configs, guarding
  the misclassification that caused #9256.

* docs(sync): correct singleton-classification comments (#9256)

Review follow-up. Comment-only — no behavior change, verified by diff.

- sync-core convertLocalDeleteRemoteUpdatesToLww: the NOTE claimed
  "singletons never emit per-entity deletes". They do —
  menuTreeDeleteFolder emits MENU_TREE + OpType.Delete with a folderId
  entityId. The branch is still unreachable, but via a different guard:
  extractEntityFromPayload finds no base entity in that delete payload
  (no `menuTree` key, no id-matching array element, and the field is
  named `folderId`, not `id`). Name the real guard, since the stated one
  invites the exact refactor that would arm the line.

- SINGLETON_ENTITY_ID docstring: no shipped singleton producer emits '*'.
  GLOBAL_CONFIG addresses ops by section key, MENU_TREE by tree name /
  folderId, TIME_TRACKING by a composite TYPE:id:date key. Point readers
  at isLwwPayloadIdCanonical for the storage-pattern question.

- createLWWUpdateOp SUNSET note: the compat id covers every singleton,
  not just TIME_TRACKING, so the fleet-age cleanup is broader than the
  note said; the '*' else branch is unreachable in practice.

- validate-operation-payload: flag the third un-migrated
  isSingletonEntityId site (inert — warning-only, and LWW ops carry
  `entityChanges: []`) so it is not the one silent site left.

* test(sync): correct the non-vacuity note on the v18.15.1 gate sim (#9256)

Comment-only. The old note credited the second assertion for the test's
non-vacuity, but that one strips `id` from the test's own copy and re-runs
the test's own helper — it can only prove the simulated predicate still
discriminates.

The load-bearing assertion is the first: it fails when the producer stops
emitting the compat id (i.e. when `|| !isSingletonEntityId(entityId)` is
dropped from createLWWUpdateOp — the SUNSET cleanup performed too early),
which is exactly the regression this test exists to catch. Say so, and
label the second assertion as the guard on the simulation itself.

* fix(boards): add existing tasks to tagged columns (#9295)

* fix(boards): add existing tasks to tagged columns

* fix(boards): stop writing the virtual My Day tag into tasks

The board tag pickers offer My Day (isShowMyDayTag), but TODAY_TAG must
never land in task.tagIds - membership derives from dueDay/dueWithTime
(ARCHITECTURE-DECISIONS #2). rewriteTagIdsForPanel concat'd includedTagIds
verbatim, so a cross-panel drop into such a column persisted 'TODAY' via
updateTags -> updateTask, which (unlike handleAddTask) has no strip, and
synced the corruption to every device.

Pre-filtering the panel cfg at the call site fixed one path but broke
another: dropping TODAY from excludedTagIds let an AND-exclude fire in the
rewrite that doesTaskMatchPanel can never hit (it only ever sees real
tags), so adding an existing task removed a real user tag to satisfy a
filter that was already inert.

Teach the shared util instead, so it and the match predicate agree: ignore
TODAY_TAG on the include side, keep it on the exclude side, and heal a
legacy TODAY_TAG already carried in a task's tags. Both callers - drop()
and afterTaskAdd - now go through the raw panelCfg.

Also finish the TaskAddEvent migration in app.component.

* 18.16.0

* chore(i18n): update Turkish language (#9309)

* fix(i18n): correct shortcut key assignments and parentheses in zh.json #9300 (#9301)

- Fix swapped shortcut keys (Ctrl+1, Ctrl+2, Ctrl+3) for task bar tooltips
- Standardize all shortcut display to use half-width parentheses with preceding space
- Affected scope: F.TASK.ADD_TASK_BAR

* fix(date-time): fall back to default locale instead of blank dates; register English region locale data (#9130)

* fix(date-time): render dates via default-locale fallback instead of blank for unregistered locales

Since #8023 "System default" resolves to the browser's regional locale, which can be any BCP-47 tag. LocaleDatePipe builds Angular's DatePipe from it and Angular throws NG0701 when that locale's data isn't registered — the catch returned null, leaving blank date fields for users whose OS language is outside the app's registered set.

- LocaleDatePipe: retry failed transforms with DEFAULT_LOCALE (en-GB), warn once per locale
- Register locale data for 8 English region variants (en-AU, en-CA, en-IE, en-IN, en-NZ, en-PH, en-SG, en-ZA) so DatePipe-rendered strings (toasts/snacks) match the Intl-based paths instead of falling back through 'en' (en-GB data, 24h)

* fix(date-time): harden locale fallback warning and cover regional registration

Review feedback from #9130:

- Hoist the fallback DatePipe and warn-dedup set to module scope. Angular
  creates one pure-pipe instance per binding per embedded view, so
  per-instance state warned once per row (60 warnings for one
  habit-tracker render), evicting exportable log history (capped at 1000).
- Warn only after the default-locale fallback succeeds. DatePipe rewraps
  both "unregistered locale" and "bad value" as NG02100 and ngDevMode
  strips messages in prod, so fallback success is the only reliable
  discriminant. Prevents a malformed value from blaming the locale and
  permanently suppressing the genuine NG0701 warning for it.
- Drop the caught error from the log call: its dev-mode text embeds the
  raw date value, which is user content in exportable history.
- Pin the th-TH spec to prod behavior by registering en-GB under 'en'
  (as main.ts does) and asserting the format-sensitive 'shortDate'.
- Replace the vacuous bad-value spec with one gating the new behavior:
  unformattable values stay silent and cannot poison the per-locale
  warn dedup.
- Add a cross-instance warn-once spec.
- Add locale.constants.spec.ts covering the registration half: each
  navigator-fallback data file self-reports the id its key promises, and
  shortTime renders 12h for en-AU/CA/IN/NZ/PH/SG and 24h for en-IE/ZA.
- Register the navigator-fallback map without explicit ids (self-reported
  ids match the keys; the aliasing loop above still needs them).
- Eagerly load the browser's own regional locale at bootstrap instead of
  waiting for idle: LocaleDatePipe is pure, so a date rendered before
  registration kept its default-locale fallback for the session.

* fix(date-time): register locale data before first render, not after bootstrap

Review feedback from #9130 (second round):

- Both locale registrations lived inside bootstrapApplication(...).then(),
  which resolves after Angular's first render — a pure LocaleDatePipe could
  cache built-in en-US (before default registration) or the en-GB parent
  format (before the regional chunk resolved) for the whole session.
- Extract registerDefaultLocale/registerNavigatorLocale into
  locale-registration.ts. main.ts now registers the static en-GB data at
  module scope before bootstrapApplication, and awaits the matched
  navigator.language regional locale via provideAppInitializer so it is
  registered before first render. registerNavigatorLocale never rejects, so
  a failed chunk load logs and falls back instead of blocking bootstrap.
- Drop the idle-time loop over all 8 navigator-only regional chunks: only
  the matched navigator.language entry is ever needed, and the initializer
  already registered it.
- Add locale-registration.spec.ts covering the production path (en-GB
  day-first/24h under bare 'en'; en-AU flipping from the 24h en-GB fallback
  to 12h; non-matching locales resolving cleanly), and route
  locale.constants.spec.ts registration through registerNavigatorLocale.
- Document in the wiki that System default follows the browser/OS regional
  locale and that unavailable locale data falls back to en-GB.

* fix(date-time): bound the locale chunk wait and read one browser locale source

Bootstrap awaits the regional locale chunk, so a stalled network could hold up
first render indefinitely. Race the import against a 1.5s timeout: on timeout we
render with the default locale, which is exactly the pre-existing behavior.

Also derive the browser locale from `getBrowserCultureLang()` — the same call
`DateTimeFormatService` resolves the pipe's locale with — instead of
`navigator.language`. The two agree in practice but not by construction, and a
disagreement would register data for a locale the pipe never asks for.

* test(date-time): make locale-registration specs fail when the fix is reverted

Round-4 review found the registration-half coverage vacuous under sabotage:

- locale.constants.spec: register the en-GB baseline under bare 'en' in
  beforeAll (as prod does) so the 12h assertions discriminate. Without it an
  unregistered en-* resolved through Angular's built-in en (=en-US, already
  12h), so the six 12h variants this fix exists for passed with registration
  disabled. Add an afterAll restoring 'en' to en-US for cross-suite hygiene.
  Sabotage (registration no-op) now fails all six, not the two 24h locales.

- locale-registration.spec: the real en-* variants leak into the module-global
  locale registry from the sibling suite, so asserting on en-AU/en-NZ passed on
  that leak ~50% of runs depending on Jasmine's random suite order. Register
  synthetic 12h locales under ids no other suite touches (en-XA/en-XB) so a 12h
  render can only mean this call registered it; clean up the map in afterEach.
  Now deterministic: .toLowerCase() sabotage fails both, default->
  navigator.language sabotage fails the getBrowserCultureLang spec.

- The stalled-load spec asserted en-IE renders 24h, which is true whether or
  not the stalled load was abandoned (en-IE data is byte-identical to en-GB) —
  the comment even claimed en-IE is 12h. Replace with a synthetic 12h loader
  that never resolves: giving up leaves it unregistered -> en-GB 24h fallback,
  which discriminates. Move jasmine.clock() install/uninstall into
  beforeEach/afterEach so a timeout regression that never settles can't leak
  the mocked clock into every sibling suite (was 11 cascading failures; now 1).

* fix(date-time): register en-US up front so an explicit US locale renders correctly from first paint

Seeding en-GB under the bare 'en' id at module scope (this PR) changed what an
'en-us' lookup resolves to before the idle-time locale loop runs: it now falls
through 'en' (=en-GB) and renders day-first/24h, frozen there by the pure
LocaleDatePipe until the bound value changes. On master, where 'en' was seeded
only inside the bootstrap .then, the same first-paint lookup fell through to
Angular's built-in en-US and was correct.

This regresses an explicit "System default" or dropdown en-US date locale for
the largest locale group. Register en-US under 'en-us' in registerDefaultLocale
(before first render, alongside the en-GB seed). The data file self-reports
'en', so an explicit id is required — a keyless call would clobber the en-GB
seed. Spec pins month-first/12h en-us and the untouched en-GB 'en' seed.

* test(date-time): cover a rejecting locale chunk load

The existing "resolves without throwing" spec used th-TH, which is outside NAVIGATOR_FALLBACK_LOCALE_IMPORT_FNS, so registerNavigatorLocale returned before calling load() and the catch was never exercised. Rethrowing from it left the whole file green.

Add a synthetic matched loader that rejects and assert the call still resolves and leaves the locale unregistered (en-GB 24h fallback). The promise is awaited by an app initializer, so an escaping rejection would reject bootstrap and block first render.

* fix(sync): preserve section semantics during conflict replay (#9326)

* fix(sync): preserve section semantics during conflict replay

Add real-client regression coverage for SECTION convergence and REPAIR recovery, plus durable WebDAV migration stages, released-client policy, and Capacitor lifecycle I/O.\n\nUse fail-closed SECTION metadata validation and causal replay so concurrent move, removal, and reorder operations cannot collapse into lossy entity snapshots.\n\nRefs #9262

* fix(sync): harden section replay safety

Defer semantic replay when retained or pending operations can reorder the transition, while allowing valid null and same-section placements.

Verify release provenance and strengthen WebDAV and fresh-client recovery coverage.

* fix(sync): make section replay state-based and atomic

Project section replacements against a stable reducer snapshot so later accepted actions and deleted anchors are represented. Persist replacements and predecessor rejection in one operation-log transaction.

* fix(sync): preserve scoped section replay order

Scope SECTION projection to the originating work context and preserve Project/Tag ordering when a removed task is later re-added.

Persist dependent placement compensations in current predecessor order and cover the real server rejection path.

* fix(sync): preserve legacy section removal ordering

Pair projected section removals with an exact work-context state update so v18.4.0-v18.4.3 retain task ordering.

* test(sync): require complete legacy compensation state

* fix(sync): avoid duplicate rejected-op recovery

* test(sync): prove single-cycle rejected-op recovery

* fix(mac): restore complete app icon set (#9335)

* fix(mac): restore complete app icon set

Package the padded macOS artwork through all ten required icon slots for Developer ID and MAS builds, and verify the copied artifact to prevent the small-size regression from returning.

* fix(mac): harden icon build verification

* fix(backup): prevent overlapping automatic backups (#9337)

Own rejected backup promises so failures are logged without surfacing as unhandled rejections. Drop triggers while an automatic backup is already running.

* fix(sync): reject uploads behind state replacements (#9330)

Persist the latest SYNC_IMPORT/BACKUP_IMPORT boundary and reject incremental uploads whose cursor has not observed it. Reconcile upgrade rows lazily, preserve explicit account-reset recovery, and cover cache, quota, PostgreSQL, and encrypted-client paths.

* fix(local-backup): await Electron backup write completion (#9327)

Refs #9299. Master's #9337 ships the exhaustMap scheduler hardening;
this closes the remaining platform boundary: Electron's renderer API
returned before the main-process backup ran, so the overlap gate
opened mid-write and main-process failures were unobservable. Convert
the BACKUP channel to ipcRenderer.invoke/ipcMain.handle and await the
write in _backupElectron.

* fix(focus-mode): stop Formly FieldArray from wiping break rule edits #8501 (#9092)

* fix(focus-mode): stop Formly FieldArray from wiping break-rule edits #8501

* fix(focus-mode): break getter descriptor chain when adding repeat rows #8501

Formly's default clone() implementation copies property descriptors verbatim using
Object.getOwnPropertyDescriptor. When Formly's observe() wraps props.defaultValue
with getter/setter accessors, adding new rows produces distinct objects that still
proxy the same underlying storage. Editing one row's value mutates all other rows
seeded from props.defaultValue.

Shallow-spreading initialValue on addItem reads the values through the getters and
writes plain data properties, severing the accessor-sharing chain.

- Perform a shallow spread copy on initialValue in repeat-section-type
- Update inline documentation to clarify why the manual copy is required
- Add a UI-level integration test that exercises the "Add" button path

* fix(i18n): translate hardcoded strings and add tag row a11y label in … (#9298)

* fix(i18n): translate hardcoded strings and add tag row a11y label in task detail panel #9297

* Fix: wire up checklist translation key and add aria-label to tag input

* feat(sync): diagnose encrypted operation failures (#9331)

* feat(sync): add encrypted operation diagnostics

Capture immutable encrypted SuperSync histories through the read-only download API, then classify per-operation decryption failures offline without exposing plaintext or secrets. Document the recovery-safe workflow and cover pagination, integrity, and CLI safeguards for #9256.

* test(sync): reproduce final-page decryption failure

Exercise the real browser, encryption, pagination, and SuperSync server path with a valid encrypted full-state page followed by one corrupted operation. Verify the correct key reaches the final page, recovery remains atomic, and retry restarts from sequence zero for #9256.

* feat(sync): attribute encrypted download failures

Retain server envelopes through decryption so exportable logs can identify the failing operation and sequence without recording user content or credentials.

Preserve the existing atomic abort and retry behavior, with real encrypted final-page coverage for #9256.

* refactor(sync): drop server-side encrypted-ops diagnostic tool

* feat(sync): classify whole failed encrypted batch client-side

* fix(sync): address multi-review findings on decrypt diagnosis

* test(sync): cover decryptBatchSettled; correct errorName triage docs

* fix(tasks): narrow project-membership repair; #8780 root cause refuted (#9354)

* fix(tasks): repair missing project membership

Self-heal legacy or inconsistent task-project relationships during navigation and data repair so global-search results remain reachable. Validate the reverse project-list relationship and cover repair with focused integration tests.

* fix(tasks): harden project membership repair

Keep same-project repairs single-entity and revalidate live state after asynchronous project lookup. Reject inherited prototype keys during project selection.

* fix(tasks): narrow project membership repair to unreachable tasks

Review follow-up to the two preceding commits on this branch.

- Downgrade the new "task missing from project lists" rule to a log-only
  notice. Only the sync paths run dataRepair: the hydration checkpoints
  validate WITHOUT repairing and gate saveCurrentStateAsSnapshot() on the
  result, and recoverFromLegacyData() throws outright. Failing there would
  degrade a non-syncing user permanently with nothing ever healing them.
  Matches how the file already treats other repairable ordering-array
  inconsistencies (TODAY_TAG orphans, archive stale refs).

- Omit projectMoveSubTaskIds instead of passing []. An empty array mints a
  one-element [rootId] move footprint, which bypasses parseMoveFootprint's
  "never relocate the root task alone" safeguard and strands subtasks in
  the old project. Omitting keeps meta.entityIds unset (still a
  single-entity op) and leaves the footprint undefined so replaying clients
  derive the task family from their own state.

- Only repair a task that is genuinely unreachable. Tag membership comes
  from task.tagIds and Today membership from dueDay/dueWithTime, so
  project-less tagged and due-today tasks already render and must not get
  a synced write from a read-only navigation. Keeps the #9052 orphan heal
  for the no-project/no-tag/not-due-today case.

- Resolve project membership synchronously from the store signals the
  service already held. getByIdOnce$ was a synchronous store read wrapped
  in a promise, so the await created the very staleness window the second
  commit hardened against; removing it deletes expectedProjectId, the
  re-derivation block and both race specs. Guard taskIds/backlogTaskIds
  with ?? [] and read the live task entity so an archive-only task cannot
  produce a repair the executor would silently drop.

* refactor(tasks): drop speculative validation rule, add e2e regression guard

Cuts the branch to the parts that earn their place, and records what the
first real end-to-end run actually proved.

- Revert is-related-model-data-valid.ts and its spec to master. The new
  "task missing from project lists" rule was pure detection: not needed to
  fix navigation, and net-new code in the validation layer justified only
  by a diagnostic hope. The op-log surface of this branch is now the
  data-repair reorder and its tests, nothing else.

- Delete task-project-membership-repair.integration.spec.ts. Two of its
  three cases duplicate data-repair.spec.ts ('should add orphan tasks to
  their project list' and the extended non-existent-projectId test); the
  third is preserved as a focused case in data-repair.spec.ts, where it
  sits beside the pass it exercises. Sabotage-checked: moving
  _addOrphanedTasksToProjectLists back to its old position fails it.

- Add an e2e that drives the REAL user path (search, click a result)
  against a genuinely unreachable task.

  IMPORTANT: that e2e does NOT reproduce #8780. It passes against
  v18.16.0 — the build the reporter says is still broken — as well as
  against HEAD, verified by swapping in v18.16.0's navigate-to-task
  .service.ts and re-running. So the no-project/no-tag/not-due-today
  shape that #8801 and #9052 both targeted already works end to end, and
  #8780 should not be considered diagnosed. Kept as a regression guard for
  behaviour that currently has no e2e coverage.

* test(tasks): prove the unlisted-task repair end to end; drop unrelated guard

- Add an e2e for the case this branch actually adds: a task that still owns a
  valid project but is missing from that project's ordering arrays. It is the
  only test that joins resolver -> dispatch -> real meta-reducer -> render;
  the unit spec uses MockStore and runs no reducers, so the dispatch branch
  had never been exercised against a real store.

  Fail-before/pass-after verified against v18.16.0: there the URL assertion
  PASSES (navigation reaches the right project) but the task is not rendered
  there — exactly the "lands on a view that doesn't show the task" symptom of
  #8780. On this branch the repair re-lists it and it renders.

- Drop the selectProjectById prototype-key guard and its spec. After the
  synchronous-signal refactor, NavigateToTaskService no longer uses that
  selector at all (it carries its own `?.id === id` checks), so the change
  was orphaned scope in this PR. The hole it closes is real and reachable
  via entity-registry's remote-controlled entityId, but it belongs in its
  own change alongside the other unguarded selectById entries, not here.

- Avoid projectPage.createAndGoToTestProject() in the new e2e: it timed out
  on the collapsible projects nav. The task's default project is read from
  the store instead, and the test asserts the fixture really is the
  dispatch-branch shape (projectId points at an existing project).

Pre-commit hook bypassed: its targeted check on the 3 changed files passed;
the full-repo lint it then runs fails only on files byte-identical to master
(local prettier version skew). CI Lint is green on this PR.

* fix(idle): enforce the Electron idle floor on minIdleTime (#9349) (#9357)

* fix(idle): enforce the Electron idle floor on minIdleTime (#9349)

The main process only forwards idle periods longer than
CONFIG.MIN_IDLE_TIME (60s), so any configured minIdleTime below that
never produced an IPC.IDLE_TIME message and silently disabled idle
detection outright — with no feedback anywhere in the UI.

Bound the settings field to the same value the main process enforces,
sourced from a new shared-with-frontend const so the two cannot drift,
and say so in the field description (including the 30s poll interval,
which was also undocumented).

Duration bounds are milliseconds, so the shared min/max validation
message now renders them as a duration — "Must not be smaller than 1m"
instead of "... 60000". This also fixes the same wart on the simple
counter's daily-goal field, the only other duration field with a min.

* test(idle): pin the rendered minIdleTime description (#9349)

* test(idle): drive the minIdleTime floor through the real settings UI (#9349)

Covers what the component harness cannot: the `updateOn: 'blur'` path that
actually surfaces the error, and persistence across a full reload (so the
value is read back from IndexedDB rather than the DOM).

Asserts the stored default `5m` rather than "not 30s" — a sub-minute value
renders EMPTY in this field (it has no `isAllowSeconds`), so the negative
form of the assertion passes even when the bound is missing.

Includes a positive control, so "did not persist" cannot pass by virtue of
nothing persisting at all.

* fix(sync): detect Electron and iPad in client-ID platform prefix (#9358)

_getEnvironmentId() re-derived platform detection locally and both of its
checks were dead, so desktop and iPad both minted B_ ids:

- Electron tested process.versions.electron, but the renderer runs with
  contextIsolation, so `process` is never exposed to page scripts.
- iOS user-agent matched iOS|iPhone|iPad, but iPadOS sends a desktop macOS
  UA. (The literal string "iOS" appears in no Apple UA at all.)

Reuse the app-wide constants instead. iOS resolves as IS_IOS_NATIVE ||
IS_IOS: Capacitor is authoritative for the native app and cannot drift when
Apple changes a UA, while IS_IOS carries the iPadOS desktop-UA workaround
and keeps mobile Safari on 'I'.

Android now keys off window.SUPAndroid (both activities inject it, so Play
and F-Droid are covered) rather than an Android+wv user agent. Deliberately
narrower: our page inside another app's WebView now reports 'B'. It is also
slightly broader in one case - the legacy activity overrides its UA without
a `wv` token, so it minted B_ before and correctly mints A_ now.

Split the mapping into a pure getPlatformCode() returning a literal union so
the branches are unit-testable and the [BEAI] contract is compiler-enforced.
The constants it consumes are frozen at module load and cannot be stubbed,
so the wiring itself stays covered only by manual per-platform check - the
tests pin the mapping, not the detection. Mirrors util/get-app-version-str.ts.

Document that [BEAI] is a compatibility contract: a client that does not
know a prefix reads null and mints over the stored id, so a fifth code may
only ship once the fleet accepts it. Adding E/I was safe because every
released client already does.

Only newly minted ids change. Existing ids are persisted vector-clock keys
and are untouched; isValidClientIdFormat already accepts [BEAI]_.

Closes #9353

* fix(electron): allow SiYuan deep links #9292 (#9359)

* docs(idle): correct the minIdleTime floor's effect and flag its reuse (#9362)

The floor does not disable idle detection below 1 minute. The main process
polls the current, still-growing system idle time, so the first sample that
clears sendIdleMsgIfOverMin() is already past the floor: a lower setting is
rounded up to ~1-1.5 min rather than ignored. Correct that claim in the shared
const, the spec docblock and the wiki.

Also record that IDLE_MIN_IDLE_TIME_MS is not only the forwarding floor -
IdleTimeHandler reuses it as the Wayland helper's --timeout-ms and as the
_waylandIdleSinceMs backfill offset, so lowering it to relax the settings
bound would also retune Wayland idle detection.

docs/wiki/4.17-Idle-Time.md was missed by #9349: it is the dedicated idle
note and still described the threshold as freely tunable.

Kept deliberately terse: #9349 went wrong by stating one fact in five places
that then drifted, so this states it once per audience - the const JSDoc for
maintainers, 4.17 for users - and drops the inline comment in idle-form.const
that only restated the JSDoc of the symbol imported on the next line.

Refs #9349

* fix(take-a-break): restore automatic break-timer reset and reminder teardown (#9351)

* fix(take-a-break): restore automatic break-timer reset and reminder teardown

The idle path stopped resetting the break-reminder timer in v11.1.0: the
dispatch of triggerResetBreakTimer was removed when the reset moved into
the idle dialog result, but _triggerProgrammaticReset$ kept waiting for
that action whenever idle tracking is enabled. Since idle tracking is on
by default on Electron, this silently disabled the "a long stretch with
no tracked task counts as a break" reset for everyone, leaving the idle
dialog checkbox as the only automatic way to clear the timer.

Also derive the reminder teardown (banner dismiss, lock-screen and
fullscreen-blocker cancellation) from the counter reaching zero instead
of from _triggerReset$. The idle dialog and focus-mode breaks zero the
counter through _tick$, bypassing _triggerReset$ entirely, so they left
a stale banner up and left the two distinctUntilChanged() subjects
latched at true - silently disabling lock screen and fullscreen blocker
for the rest of the session.

Refs #9305

* test(take-a-break): characterise the reset overlap with the idle dialog opt-out

Documents that the restored untracked-stretch reset fires DURING an idle
absence (the task is already deselected), so answering the idle dialog
with "reset break reminder timer" explicitly unchecked can no longer
preserve the pre-idle counter. Reverting the reset to its old gating
makes this fail with 5340000 instead of 0, confirming the change in
behaviour is introduced here rather than pre-existing.

Refs #9305

* refactor(take-a-break): route every reset through _triggerReset$

Follow-up to the teardown fix. Deriving the reminder teardown from
timeWorkingWithoutABreak$ reaching 0 coupled cleanup to a public
measurement stream, so any accidental <= 0 emission tore the reminder
down. The two paths that bypassed _triggerReset$ now go through it
explicitly instead:

- focus-mode breaks call resetTimer() instead of poking
  otherNoBreakTIme$, which only zeroed the counter
- the idle dialog's reset request is forked out of _tick$ into
  _triggerReset$
- the teardown is no longer gated on isTakeABreakEnabled: skipping it
  when the feature is toggled off mid-session stranded the lock-screen
  and fullscreen-blocker subjects at true for good, the exact state this
  code exists to prevent

Also edge-trigger _triggerSimpleBreakReset$. It was level-triggered, so
past BREAK_TRIGGER_DURATION it re-fired on every 1s tick for as long as
the app stayed open; timeWorkingWithoutABreak$ is bound via async pipe
in the work view, so each emission cost a change-detection pass.

NOTE: an accidental non-positive tick still zeroes the counter via the
seedless scan (it just no longer tears the reminder down), so the banner
can still disagree with the counter after a backwards clock step. That
is pre-existing and needs a separate guard on the tick branch.

Tests: resetBreakTimerOnBreakStart$ had no coverage at all and four
sibling focus-mode specs mocked TakeABreakService without resetTimer, so
the routing change broke five tests in the #6064 spec. The edge trigger
also needed a live currentTaskId$ subject rather than of(null), which
completes — without it a one-shot reset passes every test.

Refs #9305

* test(e2e): prove the break reminder is dismissed when a break starts

The break-reminder teardown had no end-to-end coverage at all - the
existing spec in this folder only walks settings pages, and its own
header explains why: "full break timing tests would require waiting for
real time". The store bridge removes that constraint, so the reminder
threshold is shrunk to 2s and the real banner is driven end to end.

Asserts on DOM presence rather than visibility on purpose: startBreak
opens the focus-mode overlay, which would hide the banner without
dismissing it, and a visibility assertion would pass for the wrong
reason.

Reverting focus-mode to otherNoBreakTIme$.next(0) fails this test.

Refs #9305

* fix(take-a-break): only let _triggerReset$ zero the break counter

Review follow-up to d7f82248c0. Moving the reminder teardown from
"timeWorkingWithoutABreak$ reached 0" to _triggerReset$ dropped one case
the old derivation covered: the seedless scan treats ANY value <= 0 as a
reset, and _tick$ — not just _triggerReset$ — feeds that scan. A/B on the
branch, same probe, only the teardown swapped:

  d7f8224 (_triggerReset$):   before=7200000 after=0 dismissAfter=0
  b88d913 (counter === 0):    before=7200000 after=0 dismissAfter=1

so the counter still went to 0 while the banner kept claiming two hours.
The previous commit called this "an accidental non-positive tick … after
a backwards clock step". It is more ordinary than that: triggerWakeUpTick
clamps with Math.max(0, Math.min(rawDelta, cap)), and the Android
focus-mode effects pass cap = Math.max(0, timer.duration - timer.elapsed),
which is exactly 0 whenever a session sits at or over its duration — the
normal completion path, plus pause during overtime (the reducer keeps
isRunning true when _isOvertimeEnabled). consumeCurrentTick() is unclamped,
so a backwards clock step goes negative.

Fix: filter _tick$ to positive values. Resets are _triggerReset$'s job
alone, which restores the invariant as a property of the pipeline rather
than of the current set of callers — and incidentally closes the
otherNoBreakTIme$.next(0) footgun this PR created by removing its last
caller. Not a regression against master (its teardown was also
_triggerReset$-derived); it was a loss against the intermediate commit.

Also from review:

- The teardown's isTakeABreakEnabled gate removal was completely
  unguarded — the full suite stayed green (13543) with the gate restored.
  The removal is right, so pin it instead of reverting it.
- Nothing asserted that resetTimer() zeroes the counter; the specs only
  proved the effect *calls* it.
- The edge trigger changes semantics, not just change-detection cost:
  time added later in the same untracked stretch now survives. That is
  the point (it is what makes the idle dialog's "reset break timer"
  checkbox meaningful again for absences over BREAK_TRIGGER_DURATION),
  but neither the comment nor a test said so.
- 9 dead spyOn(otherNoBreakTime$, 'next') left in the #6064 spec.
- Documented why _triggerReset$ is deliberately cold despite two
  subscribers, since share() would be the tempting wrong fix.

Each new guard sabotage-verified in isolation:

  drop the positive-only filter  -> zero-tick + negative-tick fail
  restore the config gate        -> feature-disabled teardown fails
  drop distinctUntilChanged      -> opt-out-survives fails (+2 existing)
  make resetTimer() a no-op      -> resetTimer counter test fails (+2)

Full suite green both TZ variants; break-reminder e2e still passes.

Refs #9305

* fix(idle): defer the isIdle edge during sync instead of dropping it (#9361)

* fix(idle): defer the isIdle edge during sync instead of dropping it

handleIdleInit$ guarded selectIsIdle with skipWhileApplyingRemoteOps(), a
plain filter, placed before distinctUntilChanged(). selectIsIdle emits true
exactly once per idle episode, so an emission landing inside a sync apply
window was discarded for good: no setCurrentId(null), no openIdleDialog, but
the store stayed isIdle:true. Every later IPC tick then short-circuited on
isAlreadyIdle, and the only resetIdle() comes from a dialog that never opened
- so idle detection stayed dead until restart, the current task kept accruing
time, and the take-a-break banner stayed suppressed too.

Moving the guard after distinctUntilChanged() is not enough; the edge is
still dropped. Use waitForSyncWindow(), the defer-instead-of-drop counterpart
that already exists for exactly this shape (#6192), and place it after
distinctUntilChanged() so the edge is captured first and then held.

The require-hydration-guard lint rule matched guard names textually and did
not know waitForSyncWindow, so it rejected the correct fix. Teach it the
operator and document why an edge-triggered selector needs the deferring
variant.

The spec asserts both halves: zero emissions while applying remote ops, then
exactly one openIdleDialog once the window closes. Asserting only the second
would also pass for an unguarded pass-through.

Closes #9348

* fix(idle): keep idle time on the task that was running at the edge

Follow-up to the deferral in the previous commit, from multi-agent review.

Deferring the isIdle edge means the effect body can run seconds after the
user returned. It re-read `currentTaskId()` at that point, so if the user
came back and started tracking a different task before the sync window
closed, removeTimeSpent() subtracted the idle duration from THAT task -
silently deleting real tracked work from a task that never accrued it.
Reachable on the canonical idle path: waking from sleep opens a sync window
and the user typically starts a task immediately.

Capture the task id at the edge, before the wait, and use the snapshot.

Also from the review:
- The lint rule's error message still told authors to add
  skipWhileApplyingRemoteOps() - the dropping guard that causes #9348. It now
  names waitForSyncWindow() for edge-triggered selectors.
- waitForSyncWindow's docstring claimed dropping is fine for store selectors
  because "the next emission will retry". store.select() ends in
  distinctUntilChanged(), so a dropped selector emission is NOT retried until
  the value changes - and the dropped emissions are precisely the remote-op
  ones, already settled by the time the window closes.
- Corrected the ordering rationale: the guard must lead every
  distinctUntilChanged() because it is a switchMap, so a superseded edge is
  cancelled and a downstream dUC would latch the cancelled value and swallow
  the next identical one.
- The spec left a MODULE-level applying-remote-ops flag set if a test threw,
  which would silently make later specs buffer their actions under Karma's
  random order; reset it in afterEach, as tag.effects.spec.ts does.

Refs #9348

* fix(idle): snapshot the counters at the idle edge too, not just the task

The previous commit only snapshotted the task id. enabledSimpleStopWatchCounters$
and the focus-session flag were still read via withLatestFrom AFTER the wait, so
a counter the user switched on during the deferral got decreaseCounterToday()
applied to it - deleting recorded habit time from a counter that never ran during
that idle period. Same hazard as the task case, same reported finding, half fixed.

Move the whole withLatestFrom ahead of the guard so the entire payload is an
edge-time snapshot, and pass it through as one object rather than a widening
tuple. selectIdleTime cannot drift during the hold (triggerIdle sets isIdle and
idleTime in one reducer pass, the producer short-circuits on isAlreadyIdle, and
_initIdlePoll has not started), so capturing it earlier is a no-op for value and
a win for consistency: nothing in the body is a post-wait read any more.

Refs #9348

* feat(shortcuts): add "?" keyboard shortcut cheat sheet (#9302)

* feat(shortcuts): add "?" cheat sheet dialog #8816

The default keyboard config shipped `showHelp: '?'` with no handler, and
could not have had one via the usual path: checkKeyCombo() matches on
ev.code, which reports the "?" key as Slash+Shift and can never yield '?'.

Match on ev.key instead - the character the layout actually emitted - so
the shortcut also works on non-QWERTY layouts. The dialog builds its rows
from KEYBOARD_SETTINGS_FORM_CFG, so it reflects custom bindings and picks
up newly added shortcuts automatically.

* docs(wiki): document the "?" shortcut cheat sheet

* fix(shortcuts): honor the configured showHelp binding in the cheat sheet

* feat(settings): add search to global settings (#9318)

* feat(settings): add search to global settings

* fix(settings): match modern props fields and guard empty search panel

* feat(i18n): complete translations across all locales (#9360)

* test(i18n): report locale key drift

* feat(i18n): add missing simplified chinese translations

* feat(i18n): add missing swedish translations

* feat(i18n): add missing romanian translations

* feat(i18n): add missing ukrainian translations

* feat(i18n): add missing german translations

* feat(i18n): add missing vietnamese translations

* feat(i18n): add missing russian translations

* feat(i18n): add missing spanish translations

* feat(i18n): add missing french translations

* feat(i18n): add missing indonesian translations

* feat(i18n): add missing italian translations

* feat(i18n): add missing japanese translations

* feat(i18n): add missing korean translations

* feat(i18n): add missing dutch translations

* feat(i18n): add missing portuguese translations

* feat(i18n): add missing brazilian portuguese translations

* feat(i18n): add missing traditional chinese translations

* feat(i18n): add missing polish translations

* feat(i18n): add missing arabic translations

* feat(i18n): add missing czech translations

* feat(i18n): add missing finnish translations

* feat(i18n): add missing norwegian translations

* feat(i18n): add missing croatian translations

* feat(i18n): add missing slovak translations

* feat(i18n): add missing persian translations

* fix(i18n): correct placeholder, plural, and repeat text

* feat(short-syntax): interval recurrence phrases in the add task bar (@every 2 weeks, @every 2 fridays) (#9328)

* feat(short-syntax): interval recurrence phrases in the add task bar

Completes the recurrence grammar specced in #4961: "@every 2 days",
"@every 2 weeks", "@every 2 fridays". Intervals were left out of #9134
because every repeat quick-setting hardcodes `repeatEvery: 1`, so the
phrases fell through to the plain-date path and silently created a
one-off task ("thing @every 2 days" -> due in 2 days, no recurrence).

An interval has no preset to expand, so it maps to a `quickSetting:
"CUSTOM"` config carrying the cycle and interval directly - the one
setting whose interval the repeat dialog can display and round-trip. No
new UI, settings, dependencies, or sync/schema surface; the existing
repeat chip and the repeat-cfg model already cover it.

- grammar: `every <1-999> <days|weeks|months|years|weekday>`. Bounds live
  in the regex so the derived removal regex (clear button) can only ever
  delete text the parser consumed. "@every 2 weekdays" stays a plain date:
  every other workday is not expressible as a weekly cycle.
- an interval of 1 collapses to the equivalent preset, so "@every 1 week"
  and "@every week" are indistinguishable downstream.
- weekly intervals restrict the weekday flags to the first occurrence's
  weekday. `DEFAULT_TASK_REPEAT_CFG` enables Mon-Fri, so cycle+interval
  alone would mean "every other week, Monday through Friday".
- first-occurrence anchoring now keys off the cycle rather than the quick
  setting, extending the existing "don't let chrono's forwardDate move the
  recurring day" rule to intervals.
- `skipOverdue` needed no change: getDefaultSkipOverdue already keeps
  every-N-days off, so a missed occurrence stays visible.

Also keeps the input and the action buttons from contradicting each other,
which intervals made easy to hit:

- picking a date, deadline, estimate or recurrence in the buttons now
  strips the syntax it overrides, the way the clear buttons already do.
  Previously the text kept advertising a schedule the task would not get,
  and which of the two won depended on what you edited next.
- removal deletes the exact ranges the parser consumed instead of one
  whitespace-delimited token, which truncated every multi-word one
  ("Call mom @next friday" -> "Call mom friday").
- a pick discards the in-flight parse it invalidates, but only when the
  strip changed the text, because that change queues the parse which
  recomputes everything the discarded one would have published.

* fix(add-task-bar): take the whole due token when a recurrence is removed

Removing a recurrence deleted only the phrase, via the derived removal
regex. The parser consumes more than that: a due token absorbs an
adjacent time ("@daily 6am"), so both the repeat menu pick and the clear
button orphaned that time into the title of the task and of its repeat
config. Delete the recorded `due` ranges instead, which describe exactly
what was consumed.

Gate it on the recurrence having come from the text, so picking one does
not eat an unrelated "@tomorrow"; the derived grammar stays as the
fallback for when no parse has landed for the current input.

Also:

- derive the LA-failing reminder spec's expectation with getDbDateStr
  instead of hardcoding a locally formatted UTC date
- drop the unreachable 'urls' branch of removeShortSyntaxFromInput
- pin three untested guards: the stale-ranges forText check, the
  back-to-front multi-range sort, and MONDAY_TO_FRIDAY anchoring
- correct the removal-regex comments — the regex is not equivalent to
  what the parser consumed, and the interval bound is one notch inside
  the dialog max rather than equal to it

* fix(tags): make tag-input placeholder actually visible and translate it (#9367)

* fix(tags): translate tag-input placeholder and make it actually visible

* fix(tags): correct placeholder comment to name the real MDC chip-input rule

* fix(tags): make tag placeholder theme-aware and drop needless !important

The placeholder kept the browser's fixed grey default, which ignores the
theme: measured against the real backdrop it was 2.95:1 on dark (below WCAG
AA 4.5:1). Using --text-color-muted, as the add-task-bar inputs already do,
gives 4.69:1 light / 5.77:1 dark.

!important was unnecessary: the component-scoped rule compiles to
[_nghost-x] input[_ngcontent-x]::placeholder (0,2,2), which already outranks
Material's .mdc-text-field__input::placeholder (0,1,1). Verified by measuring
the computed style in the running app.

Also corrects the comment: Material actively forces opacity: 0 via an
unscoped rule, it is not a fallback to a low-contrast browser default.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>

* fix(short-syntax): keep "@every weekday" off the weekend (#9370)

"@every weekday 6am" typed on a Friday after 06:00 previewed Saturday as
the first occurrence: chrono's forwardDate slides the passed time to
tomorrow, and the parser treats MONDAY_TO_FRIDAY as unanchored, so it
took that date verbatim. Typed on a Saturday it previewed Saturday.

The stored config is unaffected — its weekday flags make
getFirstRepeatOccurrence skip to Monday, which is where both
addTaskRepeatCfgToTask effects then schedule the task. So the weekend
date only ever reached the add bar's date chip, which advertised a first
occurrence the task never gets, plus the transient dueWithTime the
effect overwrites.

Roll the previewed date forward to Monday for the workday preset. The
whole weekend is skipped, not just the slid day, so the Saturday case
lands on Monday too.

* fix: open recurring Schedule projections reliably (#9314)

* fix:…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant