* chore(deps): pin npm version to remove lockfile drift
Contributors on npm 10 (Node 22's bundled default) vs npm 11 reconcile the
app-builder-lib.minimatch override differently, rewriting package-lock.json on
npm install. Pin npm 11.7.0 via the standard packageManager field (Corepack)
and complete the existing volta block so volta users pick it up automatically.
Ref: discussion #8869
* chore: stop tracking .claude/skills symlink (runtime-managed)
The Claude Code runtime scaffolds .claude/ each session and materializes
.claude/skills as a real directory, clobbering the committed symlink
(-> ../.agents/skills). Git then reported a perpetual 'D .claude/skills' in
every worktree/session. Skill sources remain tracked under .agents/skills/;
let the runtime own .claude/skills and ignore it via the existing /.claude/*
rule.
* fix(config): restore day-start offset after operation replay
Reconcile DateService and app-state from config after incremental or full-state bulk replay without re-minting persistent task operations.
Fixes#8890
* fix(sync): replay full-state operations through reducers
Normalize canonical full-state operation types to LOAD_ALL_DATA during replay while preserving their original operation metadata. This lets repair and legacy backup operations install state through the existing reducers.
Regression test for the widened incoming-full-state conflict gate and the
piggyback pre-upload pending-snapshot fix: a pending SIMPLE_COUNTER change
(non-TASK/PROJECT/TAG/NOTE) must trigger the conflict dialog instead of
being silently discarded by an incoming SYNC_IMPORT from another client.
Not yet run (SuperSync docker stack is unavailable in this sandbox); run via
npm run e2e:supersync:file or the E2E Tests (Scheduled) workflow.
* feat(plugins): add Todoist import plugin with Import/Export launcher
* fix(plugins): fix cross-chunk subtask loss in todoist-import + hardening
* fix(plugins): clamp non-finite Todoist durations + review polish
* feat(plugins): add Eisenhower priority mapping to Todoist import
Replace the single "map priorities to p1-p3 tags" checkbox with one
mutually-exclusive control (Nothing / p1-p3 tags / Eisenhower matrix).
The new Eisenhower option reuses SP's built-in urgent/important tags by
title (p1 -> urgent+important, p2 -> important, p3 -> urgent, p4 -> none),
so imported tasks populate the Eisenhower Matrix board with no new tag
and no new plugin API. Collapsing Todoist's single priority axis onto the
2-D matrix is opinionated, so it stays opt-in and off by default.
Requested in the review of #8882.
* fix(plugins): harden Todoist import data integrity
* fix(plugins): improve Todoist import feedback
Address findings from a full sync-system review (blockers + high/medium):
- USE_REMOTE ("Use Server Data") is now a true download-first rebuild:
fetches the complete server history (incl. own/already-known ops via a
raw-download mode) and validates it before any local mutation; aborts
untouched on download failure, empty remote, or newer-schema ops.
- Widen the incoming full-state conflict gate to treat all user work as
meaningful (not just TASK/PROJECT/TAG/NOTE CUD); fix the piggyback race
by judging against a pre-upload pending snapshot.
- Stop advancing the server cursor past ops blocked by schema/migration
failures so they are retried after an app update; block any op from a
newer schema version (no forward-compat band).
- Retry of failed remote ops runs archive side effects only, never
re-dispatching reducers whose effects already committed (fixes additive
double-apply of time-tracking/counter deltas across hydration+retry).
- Use local monotonic seq (not lexical UUIDv7) to decide which ops are
covered by an uploaded snapshot, preventing dropped unsynced ops under
clock rollback.
- Server: include entityIds in duplicate-operation equality.
- Capture buffer no longer silently drops accepted actions at 100 (warns
to reload); drop only past a 5000 pathological cap.
Adds regression coverage for each fix. sync-core 209/209,
super-sync-server 817/817, and all touched Angular specs pass.
* fix(task): schedule overdue tasks for today via Shift+T and guard stale task focus (#8851)
* fix(planner): only bind data-task-id on planner-task when focusable
The unconditional data-task-id host binding broke the e2e done-confirmation
strategy in e2e/pages/task.page.ts, which selects its wait branch based on
the attribute's presence. Boards render planner-task cards without an inner
<task> element, so the id-based wait could never resolve (#8851).
* refactor(tasks): share overdue predicate across selector and util
The rebase onto #8858 re-inlined the overdue comparison into
selectOverdueTaskIds and dropped the delegation to isTaskOverdue, so the
two overdue definitions (the overdue list vs. the Shift+T "Add to Today"
path) were duplicated and free to drift — the exact footgun #8851 set out
to avoid, and the util's JSDoc still claimed the selector delegated to it.
Extract isTaskOverdueByThreshold as the single source of truth for the
comparison and getLogicalTodayStartMs for the boundary. isTaskOverdue and
selectOverdueTaskIds both route through it, so they cannot drift. The
selector still computes the threshold once per recompute (no per-task date
parsing), preserving #8858's perf posture. No behavior change.
* test(e2e): base markTaskAsDone strategy on <task> host, not attr
planner-task now carries data-task-id in the Planner overdue list (#8851),
so keying the done-confirmation strategy off data-task-id presence would send
a wrapper down the <task> path — document.querySelectorAll('task') finds no
match and the wait hangs 10s. Key it off the element actually being a <task>
instead. No behavior change for real <task> rows; every wrapper keeps the
300ms wrapper path. Verified: worklog-basic (real task) and boards #7498
(planner-task wrapper) both pass.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* fix(a11y): name icon-only buttons, add keyboard access, fix task tabindex
Addresses the three groups from #8826:
- Add translated aria-labels to unnamed icon-only buttons (search
clear, note/side-nav/feature more_vert triggers, focus-mode close,
board edit, track-time dialog suffix buttons); simple-counter
buttons use the counter title as their accessible name. Adds one
new global key G.MORE_ACTIONS.
- Make mouse-only controls keyboard-accessible (role=button,
tabindex=0, Enter/Space handlers, aria-expanded where they toggle):
formly-collapsible header, worklog week day rows, task detail panel
created/completed date editors, and the task time/estimate cell
(attributes conditional so parents with subtasks gain no tab stop).
- Change task host tabindex from 1 to 0 so tasks no longer jump ahead
of all other page content in tab order (WCAG 2.4.3).
Fixes#8826
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(a11y): import TranslatePipe in focus-mode-overlay
FocusModeOverlayComponent's template uses the translate pipe (added
aria-label) but the standalone component did not import TranslatePipe,
breaking the production AOT build (NG8004). Add it to imports, matching
every other component touched in this change.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* fix(project): carry sections forward when duplicating a project
Duplicating a project recreated its tasks and notes but never touched
the section store, so the copy came up with a flat, uncategorized task
list. Duplicate the template's sections too, remapping each section's
taskIds through an old->new task id map so tasks keep their section
membership in the copy.
Fixes#8293
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address PR feedback
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The Claude Code runtime scaffolds .claude/ each session and materializes
.claude/skills as a real directory, clobbering the committed symlink
(-> ../.agents/skills). Git then reported a perpetual 'D .claude/skills' in
every worktree/session. Skill sources remain tracked under .agents/skills/;
let the runtime own .claude/skills and ignore it via the existing /.claude/*
rule.
Contributors on npm 10 (Node 22's bundled default) vs npm 11 reconcile the
app-builder-lib.minimatch override differently, rewriting package-lock.json on
npm install. Pin npm 11.7.0 via the standard packageManager field (Corepack)
and complete the existing volta block so volta users pick it up automatically.
Ref: discussion #8869
* feat(eml): add EML file parsing and task creation on drop
Currently a simple skeleton, needs more work, but should be a good POC before refining.
Fixes#518
* fix(main-header): correct email message formatting and handle EML upload errors
while this commit looks bad, its a first commit before refining this solution and sending the final PR.
Fixes#518
* refactor(eml): move eml-parser to util and tighten isFileEml check
* feat(eml): on eml hover error parse error to log and show a snack message
* bugfix(eml-typecheck): tighten type for 'sender' to not have bugs
* refactor(eml-drop): updated file locations for each file drop
* bugfix(eml-drop): use stable npm package for parsing eml files
* bugfix(eml-drop): remove relevant "dev" flag
* refactor(eml-drop): drop empty emails with a snack instead of throwing
* test(eml-drop): add isFileEml and parseEml unit tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* rafactor(eml-service): refactor code to be consistent as written in issue number 3
* bugfix(eml-drop): remove file name when geting an error
as written in 1. inside the pr
* docs: update task creation instructions to include .eml file support
* refactor(eml-parser): update import statement for PostalMime to dynamic import
* refactor(eml-drop): enhance file drop handling and improve error logging
* test(eml-drop): add unit tests for EmlDropDirective and EmlDropService
* feat(tasks): save eml body to task notes and harden eml parsing
- capture the parsed plain-text email body into task notes (text/plain
only, never HTML, to avoid injecting untrusted markup into markdown notes)
- pass the File/Blob straight to postal-mime so it applies the message's
own charset/transfer-encoding instead of forcing UTF-8 via file.text()
- use relative import paths to match repo convention
- document why the drop handler relies on the global dragover preventDefault
- update wiki wording + tests for the new add() signature and notes behavior
* fix(tasks): harden eml-to-task against untrusted subject/size
Follow-up to the eml-drop feature from a multi-agent review:
- suppress short-syntax on imported email subjects: email content is
untrusted external input, but TaskService.add() dispatches addTask which
ShortSyntaxEffects parses for #tag/@date/+project/URL tokens — stripping
them from the intended literal "sender: subject" title and mutating
tags/scheduling/attachments. Thread isIgnoreShortSyntax through add()
(matching the existing plugin-bridge pattern) and set it here.
- guard against oversized .eml files (postal-mime parses synchronously on
the main thread and the body syncs to every device) with a 10MB cap +
EML_TOO_LARGE snack.
- trim sender/subject so whitespace-only headers don't bypass the empty
check or block the address fallback.
- drop the stale "add attachment" TODO.
* fix(tasks): log bounded eml parse reason, not the raw error
The source is untrusted email content and the log history is exportable
(rule #9). Log only e.message (postal-mime's throw messages are structural,
so no email content leaks) instead of the raw error object + stack.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Resolve the terminology collision where "Due" labeled both the deadline
and the planned-day concept. Standardize UI state labels on "Deadline"
(deadlineDay/deadlineWithTime) and "Planned" (dueDay/dueWithTime), and
move the deadline-passed label off "Overdue!" to "Past deadline!" so it
no longer clashes with the planned-past Overdue list.
en.json values only; keys unchanged (t.const.ts regenerates identically).
The "Schedule" action verb/keybinding and the issue-provider "Due Date"
field are intentionally left. The behavioral question of what "Overdue"
should mean is split out to #8877.
* feat(tasks): create "Check <url>" task from links dropped on empty app chrome
Dropping a web link anywhere on the app except onto a task, note, or panel
now creates a "Check <url>" task with a success snack, mirroring the Android
share-a-link flow. Task/note/panel drop handlers already stopPropagation, so
the document-level handler only sees links dropped on empty chrome.
- add pure getDroppedUrl() helper (http(s) only; rejects files, plain text,
non-web schemes, and whitespace/multiline selections)
- wire the existing global onDrop in AppComponent to create the task
- add APP.DROP_LINK translations
* fix(tasks): robustly extract dropped link URL; stop notes double-fire
The dropped-link → task feature could no-op for real link drags: getDroppedUrl
only read text/plain and rejected anything with inner whitespace, but browsers
often deliver the URL only in text/uri-list, or as "<url>\n<title>" in
text/plain (and Electron cross-app drops fill only one). Now scan both types
line by line and take the first exact http(s) URL.
Also fix a duplicate-task bug found in review: dropping a non-image link on the
Notes panel created BOTH a note and a "Check" task, because NoteService only
calls stopPropagation after an await (too late). Claim the event synchronously
in the notes drop handler.
* feat(tasks): give dropped-link tasks a readable title + clickable attachment
Mirror the Android share flow more closely: the dropped-link task now gets a
human-readable title ("Check example.com: some article") plus the raw URL as a
clickable LINK attachment, instead of a raw URL crammed into the title.
Extract the shared readableUrl() helper out of android.effects into
util/readable-url.ts (now used by both the Android share effect and the desktop
drop handler) with its own spec.
SHA-pinning the actions in wiki-sync.yml (#8868) pushed the
setup-python `uses:` line to 84 chars, tripping yamllint's default
80-char line-length rule and failing the Lint job on every wiki push
and every PR touching wiki paths.
Relax the yamllint step to accept the 40-char SHA pins and their
single-space `# vX.Y.Z` version comments. Tightening the comments to
two spaces instead would push the already-80-char pin lines over the
limit, so raising the ceiling is the correct fix.
task.component's `isTodayListActive` computed read the plain mutable
`WorkContextService.isTodayList` boolean, which is not a signal producer,
so the computed never invalidated and returned its first value for the
component's lifetime.
Expose `isTodayListSignal` (a toSignal mirror of isTodayList$) on the
service and read that from the computed. The plain boolean stays for the
synchronous reader in history.component.
#8843
* perf(tasks): content-stable scheduling snapshot for collection selectors
The collection selectors (overdue, due-time-sorted, deadline-sorted,
due-day, later-today, all-with-subtasks, and today-task-ids) all derive
from selectAllTasks, which returns a new array every second while a task
tracks time. None of them read timeSpent, yet each re-ran its O(n)
filter/sort and reallocated every tick.
Introduce selectTaskSchedulingSnapshot: a per-entity ref-cached, array-
ref-stable projection of the scheduling fields only (id, isDone,
dueDay/dueWithTime, deadlineDay/deadlineWithTime, parentId, subTaskIds),
so a timeSpent-only tick yields the identical snapshot ref. Each
collection selector now derives its filter/sort DECISION (an ordered id
list) from the snapshot — skipped by NgRx memoization when the snapshot
is stable — and a per-selector memoized re-map turns the stable id list
back into live task entities, keeping the existing output types and
returning the previous array ref unless a genuine member changed. No
consumer/API changes; task refs stay live (no staleness).
selectTimeConflictTaskIds is intentionally left on the live sorted
selector because getTimeLeftForTask reads timeSpent.
SPAP-20
* test(tasks): clear leaked selectAllTasksWithDueTimeSorted override between tests
dialog-schedule-task.* and time-block-sync.effects specs call
store.overrideSelector(selectAllTasksWithDueTimeSorted, ...). overrideSelector
uses setResult(), which sticks on the singleton selector across spec files and
is only reset by clearResult() (not release()). When one of those specs ran
before task.selectors.spec in the full karma bundle, the new
selectAllTasksWithDueTimeSorted(mockState) test got the leaked value instead of
computing from mockState — "Expected [ ] to contain 'task5'". Single-file runs
passed, so it only surfaced in CI's full run.
Add the selector to the existing beforeEach clearResult()/release() defensive
block (mirrors selectOverdueTasks etc.). Repro: dialog-schedule-task +
task.selectors together went 1 FAILED -> 90 SUCCESS.
SPAP-20
Make AGENTS.md the single source of guidance read natively by Claude
Code, OpenAI Codex, and GitHub Copilot. CLAUDE.md becomes a symlink to
it. Extract commit-message guidance into a reusable Agent Skill under
.agents/skills (read by Codex and Copilot); .claude/skills symlinks to
it for Claude Code, and .gitignore is narrowed to /.claude/* so only
that skills pointer is tracked. Drop the now-redundant
.github/copilot-instructions.md symlink since Copilot reads AGENTS.md.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* ci(actions): SHA-pin remaining workflow actions
Replace the mutable derekprior/add-autoresponse@master ref in
issue-open-auto-reply.yml with an inline actions/github-script snippet
to remove the third-party code-injection risk in CI. SHA-pin the 4
remaining tag-pinned actions in wiki-sync.yml (checkout, setup-python)
to match the rest of the workflows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* wrong sha for the github-script v9
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(task-repeat): default skip-overdue for new everyday recurring tasks
New recurring configs now default skipOverdue ("Don't let overdue instances
pile up") to ON only for a plain everyday schedule — the "Daily" preset or a
CUSTOM every-single-day cycle. That is the one case where the option is both
useful and provably safe: everyday tasks are the only schedule that actually
piles up (one empty overdue copy per missed day), and today is always
scheduled so a missed instance regenerates the same day and can never
silently vanish (it cannot even drop to zero).
Every other schedule stays OFF — workday/weekly/monthly/yearly and every-N-day
custom cycles keep their one missed occurrence visible, so a real obligation
(pay rent on the 1st, renew the domain) never disappears until its next
occurrence. Deriving the default purely from the effective schedule means the
"Daily" preset and a CUSTOM every-day cycle (the same schedule entered two
ways) get the same default — no "same schedule, different default" surprise.
The default is seeded from the chosen schedule in both config-creation paths:
the repeat dialog (re-derived from the final schedule on save, and skipped
when the user explicitly toggled the Advanced checkbox) and the inline
add-task-bar recurrence. Existing configs and DEFAULT_TASK_REPEAT_CFG
(skipOverdue: false) are unchanged; only newly created configs are affected.
Supersedes the broader daily+Mon-Fri variant, dropping the schedule-change
checkbox re-sync machinery: an Advanced checkbox opened on a Daily config and
then switched may briefly show a stale ON, but save always persists the safe
re-derived value.
* docs(task-repeat): clarify custom every-day default; signpost baseline
Multi-review follow-ups (no behavior change):
- Wiki 2.06 + 4.13: a CUSTOM every-single-day cycle also defaults skipOverdue
ON (it is the same schedule as the Daily preset); "off" now reads "custom
cycles longer than a day" so the docs match getDefaultSkipOverdue.
- Comment on DEFAULT_TASK_REPEAT_CFG.skipOverdue pointing to the schedule-aware
creation default, so the model's `false` baseline is not mistaken for the
effective default.
- Tighten the save() display-gap comment: the cosmetic seeded-ON/persist-OFF
gap applies to any new non-Daily config whose Advanced panel is opened
untouched, not only a Daily→switch.
* refactor(task-repeat): use type-only import in skip-overdue predicate
TaskRepeatCfgCopy is used only in type position; match the codebase's import type convention (58 other files). No behavior change.
compressArchive() persisted archiveYoung and archiveOld via two
independent writes (Promise.all). A crash between them left a
half-compressed archive, and because compression is op-replayed on
other clients, a torn local result diverged from replicas.
Route both writes through the existing saveArchivesAtomic API (one
IndexedDB transaction over both stores), matching the flush-young-to-old
path in archive-operation-handler.service.ts. Adds a regression spec.
#8843
Android/mobile soft keyboards hide the Enter key or deliver it as a composing
keydown that onKeydown filters out (to protect CJK candidate confirmation), so
the inline sub-task draft could not be committed at all: neither Enter, tapping
away, nor any on-screen control saved the text.
- Add an always-visible submit button beside the input as an accessible,
discoverable commit target (mouse-clickable, screen-reader labelled). Its
mousedown is preventDefaulted so a desktop click keeps input focus, commits,
and keeps the field open for rapid entry — mirroring the main add-task bar.
On touch the tap commits via the blur path below.
- Commit the draft on blur, but only on touch (isTouchActive): the natural
mobile "done" gesture is tapping away, and the soft-keyboard Enter is
unreliable there (#8791). Desktop keeps click-away-to-cancel — Enter and the
button are the reliable commit paths — so blur no longer silently creates a
task, and a user can move to the button without the draft being committed out
from under them. Escape still discards on all devices.
Both commit paths read the live input value, so IME/predictive-text buffering
no longer strands the text.
Closes#8856. Supersedes the standalone #8791 commit-on-blur branch.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Restore outlook: (reported in #8859) plus the same-class note/task apps
notion:, things:, omnifocus:, bear:, joplin: that the GHSA-hr87 fix also
silently broke. All low-risk registered-app deep-links, same class as the
already-allowed obsidian:/logseq:/zotero:.
* perf(ui): make date/keys pipes pure to stop per-CD formatting
localeDate and scheduledDateGroup were pure:false, so they re-ran (and
localeDate re-allocated a DatePipe + formatted via Intl) on EVERY change-
detection pass — 35-42 times per CD in the schedule month grid alone.
keys was pure:false too; to-array was impure with zero usages.
Make all four pure (delete to-array). Preserve locale reactivity: the
three hot grids (schedule-month/week, planner-day) precompute their day
labels in a computed() keyed on currentLocale(), binding plain strings
(removes the per-cell pipe entirely); the remaining localeDate call
sites pass the reactive locale as an explicit arg (:locale()) so change
detection re-formats only when the locale signal changes.
scheduledDateGroup takes today as an arg. keys becomes pure — and
dialog-time-estimate.deleteValue now replaces the map reference instead
of deleting in place so the pure keys pipe still updates.
SPAP-26
* test(pipes): stub DateTimeFormatService in specs broken by pure-pipe change
SPAP-26 added inject(DateTimeFormatService) to DialogSyncImportConflict and
IssuePanelCalendarAgenda so their templates can pass the reactive locale to
the now-pure localeDate pipe. Both component specs construct the component but
their TestBeds never provided the service, so DI failed at construction with
NG0201 (DateTimeFormatService -> GlobalConfigService -> Store) — 7 + 2 = 9
failing specs.
Provide the same { currentLocale, formatTime } stub already used in the
dialog-sync-import-conflict render block. Both spec files now pass
(9 + 2 SUCCESS locally).
SPAP-26
The E2E Sync PR gate ran the full SuperSync + WebDAV suites on PRs with
no sync changes. The detect job diffed `${BASE_SHA}...HEAD` where HEAD is
the refs/pull/N/merge commit (PR merged into current base) and BASE_SHA is
the event-time pull_request.base.sha. As the base branch advances while a
PR is open, that sha goes stale; since it stays an ancestor of the merge
commit, the three-dot range expands to every commit merged into the base
since the PR opened, tripping the sync patterns on unrelated churn.
Diff the PR head against its merge-base with the live base (HEAD^1) so the
range is exactly the PR's own changes, regardless of base movement. No
extra fetch needed: HEAD^1 and the PR head are both parents of the
checked-out merge commit, and their merge-base is reachable under
fetch-depth: 0. Verified on PR #8722: 250 leaked files -> the real 7.
* fix(sync): harden file-based .bak recovery and split gap detection
Post-merge review of the SPAP-8/9/10/11 series found five defects in the
file-based sync adapter; all fixed here with regression tests proven
red/green against the previous code. Design cross-checked by a 7-reviewer
multi-agent pass; its findings are folded in.
- Split gap detection suppressed a syncVersion reset when the remote
clock was EQUAL OR GREATER_THAN the last-seen clock — the exact bug the
SPAP-9 review follow-up removed from the single-file path. A dominating
client's snapshot reset (which compacts ops this client never saw) was
treated as cosmetic, skipping snapshot hydration and silently
diverging. Now EQUAL only, matching the single-file path.
- .bak recovery staged the CORRUPT primary's rev, which setLastServerSeq
then promoted to _lastSeenRevs: every later poll's SPAP-10 pre-check
read "unchanged" and skipped the re-download while the upload path (no
.bak recovery) kept failing on the corrupt primary — sync wedged until
another client rewrote the file. A recovery download now never
stages/promotes the rev (each poll re-recovers and re-seeds the heal
cache), and every site that rewrites _lastSeenRevs drops any stale
staged rev via the shared _commitLastSeenRev.
- Snapshot uploads (force-upload / "Use Local" / E2EE re-encryption) left
the pre-snapshot .bak behind. After a password rotation the stale
old-key .bak was silently "recovered" by a still-old-key client
(suppressing its wrong-password prompt) and heal-uploaded back over the
new-key primary, reverting the rotation. Snapshot uploads now write the
same payload to .bak FIRST, then the primary (_forceUploadWithBakFirst;
deliberately FATAL — aborting pre-primary leaves the remote consistent
for a retry). Applies to sync-data.json.bak and sync-ops.json.bak;
sync-state.json.bak is deliberately exempt: its adoption is
ref-validated (EQUAL clock vs snapshotRef), so a stale copy is inert,
and it must keep serving the compaction crash window it exists for.
- Recovery additionally refuses a PLAINTEXT .bak when encryption is
expected: decoding trusts the file's own prefix flags, so a plaintext
.bak decodes even under a wrong/rotated key — the same
wrong-password-suppression class via mode (rather than key) mismatch.
- The split migration wrote the v3 tombstone BEFORE neutralizing the
legacy .bak (best-effort): a crash between the writes left a live v2
.bak that an OFF client's recovery would resurrect over the tombstone,
forking the folder. Neutralize-first, fatal. Residual: a step-3 failure
after the migration's ops-file commit leaves a live v2 file until the
next snapshot upload (documented at the call site).
- sync-ops.json — the hot file, rewritten on every op-bearing sync — had
no backup at all, so a torn write wedged split sync until a manual
force-upload. It now gets the same backup-before-overwrite + recovery +
heal treatment as the single-file format. deleteAllData deletes the
split files BEFORE the tombstone and treats source-of-truth deletion
failures as errors (success:false) — it previously left every split
file behind and reported success.
The three backup/recover pairs are collapsed into shared _writeBakFile /
_readBakFile helpers (the EQUAL||GREATER_THAN divergence above is exactly
the copy-drift failure mode this prevents); .bak file names move into
FILE_BASED_SYNC_CONSTANTS as remote-format surface.
* fix(sync): show the exact pending-op count in the conflict dialog
Compaction can fold still-unsynced ops into the snapshot baseline clock,
so the dialog's vector-clock delta could report "0 changes" right next to
"N local changes pending" — and the false 0 skipped the secondary
USE_REMOTE overwrite confirmation. The dialog now prefers the EXACT
pending-op count carried on LocalDataConflictError (new optional
ConflictData.localUnsyncedOpsCount): it is precisely "what USE_REMOTE
would discard", so both the displayed count and the >= 20-difference
confirmation threshold work from a truthful figure; the clock delta
remains the fallback when no measured count is supplied.
Display/confirmation-only — no clock or op-log semantics touched. Also
asserts the explicit-null lastSyncedVectorClock contract at the
fresh-client conflict throw sites (test gap from SPAP-7).
* chore(lint): enforce tx-handle-only access in op-log transactions
The SQLite op-log adapter serializes every entry point through a
per-connection FIFO queue; awaiting an adapter method inside a
.transaction() callback enqueues behind the transaction's own slot and
silently deadlocks all op-log persistence. The port contract documents
the precondition and #8849 promised a lint rule — this adds it
(no-adapter-in-tx, scoped to src/app/op-log) with RuleTester specs.
Matching is rename-proof: it flags access on the SAME receiver the
transaction was opened on (plain identifiers and any `this.<field>`), so
it does not depend on the field being named `_adapter`; known heuristic
gaps (extracted callbacks, aliasing, method indirection) are documented.
Also corrects the port doc: IndexedDB serializes only overlapping-scope
transactions; SQLite provides the stronger whole-connection exclusion.
* feat(filetree): add option to add subfolders and items within folders #8525
* fix(menu-tree): prevent item duplication when creating inside folder
* test(op-log): update ActionType spec and compact code for MENU_TREE_ADD_ITEM
* refactor(filetree): dedupe folder-id prefix strip in context menu
* refactor(menu-tree): add items to folders via existing tree-update op
Rework "add project/tag inside folder" to compute the new tree in the
service and dispatch the existing updateProjectTree/updateTagTree ops,
matching how "add subfolder" and drag-reorder already persist.
Removes the newly-introduced MENU_TREE_ADD_ITEM op type (action, enum
member, compact code 'MA', reducer handler) and the on(addProject)
handler (scope creep). Benefits:
- No new synced op type (the highest-risk change class).
- Forward-compatible: old clients understand updateProjectTree/Tag, so
the item lands in the folder everywhere (the granular op no-op'd on
old clients, leaving the item at root).
- _insertNodeIntoFolder falls back to root-append when the target
folder is missing, so a placement is never silently dropped.
Behavior covered by new MenuTreeService specs (insert, null-folder,
missing-folder fallback, tag move-dedup).
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
* feat(sync): SPAP-11 opt-in split-file sync format for O(delta) syncs
Splits the single sync-data.json into a small always-read/written sync-ops.json
(the commit point) plus a sync-state.json snapshot rewritten only on
compaction / force-upload / gap-repair / migration, so a normal sync transfers
O(delta) instead of O(dataset). Opt-in via the isUseSplitSyncFiles setting; the
legacy file is left as a v3 tombstone so old clients hard-stop rather than
silently diverge.
Review follow-ups:
- Recompaction triggers at combinedOps > MAX_RECENT_OPS (2000) and trims to
SPLIT_COMPACTION_THRESHOLD (1000), so it no longer rebuilds the snapshot on
every op-bearing sync once the folder crosses 1000 (test b2).
- Split download stages the ops-file rev to _pendingRevs (not _lastSeenRevs),
promoted only in setLastServerSeq after durable apply — mirrors the single-file
crash-safety path (test g).
- Split rev-precheck is gated on sinceSeq > 0, so a forceFromSeq0 download
(USE_REMOTE / rehydrate) always fetches ops + snapshot (test h).
Stacked on SPAP-9 (#8787, merged) and SPAP-10 (#8795): the split path reuses
their per-provider causality (_lastSeenVectorClocks / compareVectorClocks) and
rev (_lastSeenRevs / _pendingRevs) infrastructure.
SPAP-11
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(sync): return whole file-based ops buffer per download page
The file-based download paths (single-file _downloadOps and split-format
_downloadOpsSplit) truncated recentOps to the caller's page size
(DOWNLOAD_PAGE_SIZE = 500) via slice(0, limit), but they return ops by array
index and ignore sinceSeq. The caller (operation-log-download.service) loops on
`hasMore`, advancing sinceSeq by the returned index-based serverSeq — so when the
buffer exceeds the page size the adapter kept re-returning the SAME oldest slice.
A behind client never received its newest ops and the loop spun until it bailed
(MAX_DOWNLOAD_ITERATIONS / in-memory cap), leaving the sync stuck (success:false)
and unable to converge.
This is the common case for the split format, whose ops buffer floor is
SPLIT_COMPACTION_THRESHOLD (1000) — always above DOWNLOAD_PAGE_SIZE. The
single-file path shares it, latent since MAX_RECENT_OPS was raised 500 -> 2000
(before, buffer == page, so hasMore never tripped).
File-based providers have no server-side cursor (they re-download the whole file
each call), so cross-call pagination cannot advance and truncation just drops the
newest ops. The recentOps buffer is already bounded by MAX_RECENT_OPS, so return
it WHOLE with hasMore=false and let the caller's appliedOpIds dedup decide what is
actually new. Returning everything (rather than slicing to a cap) also converges
safely if a future higher-MAX_RECENT_OPS client writes an over-cap buffer, instead
of re-entering the same stranding loop.
Documents the `limit` divergence on the OperationSyncCapable.downloadOps
interface. Regression tests cover a 600-op buffer with page size 500 on both the
split and single-file paths (newest ops delivered, hasMore=false).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(sync): drop redundant recentOps-length clause from gap detection (SPAP-33)
The file-based gap detector treated a trimming gap as real only when the ops
buffer was also at its cap:
oldestOpSyncVersion > sinceSeq + 1 && recentOps.length >= <cap>
The length clause is redundant for correctness — syncVersion is contiguous and
every bump carries at least one op, so oldestOpSyncVersion > sinceSeq + 1 already
proves the op at sinceSeq+1 existed and was trimmed away, and it never
false-positives. But the clause caused a false NEGATIVE: a buffer trimmed at a
SMALLER floor than the current cap — a legacy buffer written by an old client
with a lower MAX_RECENT_OPS, or (in the split format) a buffer trimmed to
SPLIT_COMPACTION_THRESHOLD — has a genuine gap while holding fewer ops than the
cap, so the gap was suppressed and the behind client applied ops without the
snapshot base and silently diverged.
Drop the clause in both the single-file (>= MAX_RECENT_OPS) and split
(>= SPLIT_COMPACTION_THRESHOLD) paths, so a behind client correctly falls back to
the snapshot. Updates the single-file test that asserted the suppressed behavior
and adds a split-path regression (short trimmed buffer -> gap + snapshot load).
Previously deferred as SPAP-33; pulled in as it prevents silent cross-client
divergence and sits in the same download-correctness path as the pagination fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Supersede the SuperSync-only gate with e2e-sync-pr.yml, which shares
one change-detection job and one frontend build across both suites and
exposes two independent required checks (SuperSync E2E Gate, WebDAV E2E
Gate). Each suite runs only when its own path set changed; sharing the
build avoids a third frontend build per sync PR.
The SuperSync client (packages/sync-providers/src/super-sync) drives
the @supersync suite but was absent from both the new PR gate's detect
pattern and the nightly push paths, so a PR editing only the client
could merge green without the suite ever running. Add the package to
both. Also switch the detect grep to a here-string to remove a
theoretical SIGPIPE under pipefail.
Runs the SuperSync E2E suite on PRs only when sync-affecting code
changed, via a detect/build/shard/gate job shape. The always-running
gate job is the required status check, so path-filtered runs never
leave a required check pending. Nightly scheduled run remains the
backstop for cross-cutting changes the path heuristic misses.
Adds a cheap getFileRev pre-check to the file-based sync download path: when the
provider reports the same rev we last confirmed, skip the full sync-data.json
download and return an empty "nothing new" result. Any getFileRev error falls
through to a full download, so the check never fails a sync.
Review follow-ups:
- Allowlist is [Dropbox, OneDrive] — the two providers whose getFileRev is a
cheap metadata-only call with a content-stable rev. WebDAV and LocalFile are
excluded: their getFileRev does a full-body read (no bandwidth saved, and a
changed rev would download the file twice) and can return a weak/coarse
validator. WebDAV re-add behind a strong-ETag/PROPFIND check: SPAP-29.
- The downloaded rev is staged as pending and promoted to last-seen (and
persisted) only in setLastServerSeq — after the caller durably applies the ops,
the same ordering as the seq cursor. A crash between download and apply can no
longer strand a rev ahead of un-applied ops and skip them on the next poll.
Also isolates the Sync Cycle Cache integration specs onto the LocalFile provider
so they exercise in-cycle cache invalidation without being short-circuited by the
rev-precheck.
SPAP-10
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(tasks): persist collapsed subtask state across restart #8781
Collapse/expand of a task's subtasks was written by non-persistent
actions, so the op-log never captured it and the state reset to
expanded on every reload.
Route collapse changes through the absolute, replay-safe updateTaskUi
op. toggleSubTaskMode now resolves the next _hideSubTasksMode at
dispatch time (extracted into a pure getNextHideSubTasksMode helper)
and persists that absolute value, so it is captured to the op-log and
restored on reload. Remove the old relative toggleTaskHideSubTasks
action and its reducer, which was non-deterministic on replay and threw
on a missing task. Register [Task] Update Task Ui in the ActionType enum
and compaction code map.
* test(tasks): add op-log round-trip test for _hideSubTasksMode #8781
The existing updateTaskUi test only asserted the action's persistence
metadata. Add a test that runs the value through the full op-log path
(dispatch -> capture -> JSON serialize -> convertOpToAction -> reducer
replay) and asserts the collapse value survives. The JSON hop is where a
dropped value would regress on the sync transport and SQLite op-log
backend.
Since #8785, a fresh client with no last-synced baseline gets an
OVERWRITE_WARNING_UNKNOWN confirmation after clicking Keep remote. The
test never accepted it, so the confirm backdrop blocked the later
triggerSync() click and timed out. Accept the confirmation, mirroring
the sibling webdav conflict tests.
* fix(op-log): serialize SQLite adapter transactions on shared connection
The SQLite op-log adapter issued raw BEGIN/COMMIT on the shared
connection with no serialization. Once both stores share one SqliteDb
(the staged native rollout), concurrent operations (capture append,
archive write, compaction) would interleave BEGINs: SQLite has no
nested transactions, so a second BEGIN throws and a bare statement
issued mid-transaction silently joins — and rolls back with — the
foreign transaction, corrupting op-log state.
Funnel every adapter entry point through an internal FIFO queue so a
transaction is exclusive on the connection for its whole BEGIN/COMMIT
and no bare operation interleaves. Transaction-internal work runs
directly on the connection (already holding the slot), so there is no
re-entrancy. Document the mutual-exclusion invariant in the port
contract and add a concurrent-transactions contract test that runs on
both the in-memory fake and real sql.js.
* docs: add complete architecture review report (2026-07-07)
Whole-app architecture review synthesized from eight parallel subsystem
reviews and an adversarial verification pass; findings filed as issues
#8832-#8843, with duplicates cross-referenced rather than re-filed.
* fix(op-log): key the SQLite transaction serializer to the connection
Multi-agent review found the serializer was keyed to the adapter
instance. The native rollout hands the op-log store and archive store
two separate SqliteOpLogAdapter instances over one shared SqliteDb, so
per-instance queues left an op-log BEGIN free to interleave with a
concurrent archive BEGIN on the shared connection — the exact hazard the
serializer targets.
Key the FIFO queue to the connection (WeakMap<SqliteDb>) so every adapter
over one SqliteDb shares one queue. Add a contract test that drives two
adapters over one connection concurrently (verified red with per-instance
keying, green with per-connection). Also: document the re-entrancy
precondition as unenforced (a lint rule, not a runtime flag, is the right
guard — a flag cannot distinguish a re-entrant call from a legal
concurrent one) and correct init/getLastSeq/port-contract doc drift.
The add-task-bar title became a cdkTextareaAutosize textarea in the #8812
redesign. The directive measures the content and pins an exact px height,
so the element must never scroll itself, but it had no overflow rule and
fell back to a textarea's UA default of overflow:auto. That paints a
spurious scrollbar on a single line whenever the rendered line box
(16px * 1.4 = 22.4px) doesn't land exactly on the pinned integer height
(font/zoom/platform dependent).
Set overflow:hidden on .main-input; the directive keeps the caret in view
via programmatic scroll, so nothing is lost.
Some iOS third-party keyboards (e.g. Sogou) report a near-full-screen
frame in `keyboardWillShow`. That value flowed unclamped into
`--keyboard-height`, and the global add-task bar is positioned
`bottom: calc(var(--keyboard-height) + var(--s2))`, so it was flung to
the top of the screen instead of sitting above the keyboard. Native
keyboards report a correct height and were unaffected.
Clamp the plugin-reported height to a plausible range, and — only for a
frame the clamp had to correct — override `--keyboard-height` with the
measured obscured area (`baseHeight - visualViewport.height`), the same
reliable signal the non-iOS path already uses. Well-behaved keyboards
skip the override entirely and keep their exact prior behaviour, so this
cannot regress them.
Adds a pure `sanitizeIosKeyboardHeight` util with unit tests.
* feat(tasks): optional setting to sort completed tasks by completion date
Adds an opt-in "Sort completed tasks by completion date (newest first)"
setting under Settings > Tasks. When enabled, the Done task list is ordered
by each task's doneOn timestamp descending; when off (the default) behaviour
is unchanged. Sorting is applied at the doneTasks$ choke point so it covers
the Today list and project/tag contexts alike.
* refactor(tasks): make completion-date sort the calm default, not a toggle
Builds on the previous commit's opt-in setting. Ordering the Done list by
completion date (newest first) is the expected default for reviewing just-
finished work (issue #2936), and no manual done-order is preserved anyway,
so drop the toggle and apply the sort unconditionally at doneTasks$. Removes
the config field + default, the settings-form checkbox, and the T/en.json
label; moves the pure helper to work-context.util.ts with its own spec
(stable-sort and empty-array cases included).
Also guards _move so a reorder WITHIN the now-auto-sorted Done list no longer
emits a spurious moveTaskInTodayList op that would sync to other devices;
dragging a task out of Done (DONE -> UNDONE) still works. Specs cover both.
* fix(tasks): skip keyboard reorder of auto-sorted done tasks
Complements the drag guard from the previous commit. The move up/down/top/
bottom shortcuts on a top-level done task in the main list dispatched a
taskIds-reorder that the completion-date-sorted Done list ignores, emitting
a spurious op that syncs to other devices. Guard _moveAndRefocus (the single
choke point for all four keyboard reorders) so it no-ops for done main-list
tasks. Done subtasks and backlog tasks stay reorderable — neither is
auto-sorted.
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Use vector clocks to resolve file-based sync conflicts by causality instead
of always surfacing the binary USE_LOCAL/USE_REMOTE dialog:
- keep-local when local strictly dominates the remote snapshot
- apply-snapshot (no dialog) when the snapshot strictly dominates local
- dialog when clocks are concurrent and can't be auto-resolved
Cosmetic syncVersion-reset suppression is gated on the last-seen vector
clock and treated as cosmetic ONLY when the clocks are EQUAL. A GREATER_THAN
remote clock proves the writer did more work, not that this client received
the intervening ops (a snapshot can compact ops we never saw), so it now
conservatively triggers a seq-0 resync rather than being silently skipped.
CONCURRENT-snapshot auto-merge defaults OFF and, when enabled, only merges
if the retained recent ops provably bridge the whole gap to the snapshot
clock (local ⊔ recentOps ⊒ snapshot); otherwise it falls back to the
user-recoverable dialog instead of replaying only recentOps and silently
dropping compacted-only entities. Re-enabling default-on is gated on
multi-client E2E validation (SPAP-34).
SPAP-9
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The breakpoint table in docs/styling-guide.md had four wrong values
(xxs at 450px, s at 800px, m at 1000px, l at 1200px) and three wrong
names (s instead of sm, m instead of md, l instead of lg) compared to
the actual SCSS variables in _media-queries.scss and CSS custom
properties in _css-variables.scss.
Co-authored-by: Debian <x@zak.linux>
The native foreground-service completion is bridged into the store via
handleNativeTimerComplete$. Its guard only checked purpose/isRunning, so a
stale/late/duplicate native work-completion could complete a *different*,
freshly-started work session — e.g. the one the user just advanced into from a
break with the blue "next session" arrow — and in Pomodoro completing a work
session auto-spawns a break, so the arrow appeared to start another break.
Require the session to have reached its scheduled end by the WALL CLOCK
(now - startedAt >= duration) rather than trusting the stored (and, while
backgrounded, frozen) timer.elapsed. This rejects a completion landing on a
just-started session while preserving the genuine over-run-on-resume case
(#7856), where now - startedAt is already >= duration. Applied symmetrically to
the break branch.
Adds 5 regression tests for the stale/duplicate completion guard.
* feat(tasks): move add-task-bar toggles into the actions row
The add-to-top/bottom and search toggles used to overlay the input's
top-right corner. They now sit at the start of the actions row (create
mode) and the search info row (search mode), prepended so they scroll
together with the rest of the row, separated by a dashed divider and
sized to match the neighbouring action chips.
A new ng-content slot on add-task-bar-actions lets the parent project
the toggles into the scrollable row; they are still rendered in the
search row when the action bar is not, so search stays toggleable.
* feat(tasks): wrap long add-task-bar titles onto multiple lines
The title field is now an auto-growing textarea (cdkTextareaAutosize) so a
long title wraps into view instead of scrolling off the right edge, which
is especially helpful on narrow screens. It stays single-line in meaning:
Enter still submits and pasted newlines are collapsed to spaces so no line
break ever reaches the task title.
cdkTextareaAutosize sizes the textarea to rows*line-height and ignores its
own padding, so the vertical breathing room lives on a wrapper element and
the field grows a line at a time. matInput is dropped (there is no
mat-form-field here and it broke the autosize height calc).
E2E selectors that targeted the title by the `input` tag now use the
tag-agnostic `.main-input` class.
* feat(tasks): refine add-task-bar toggles, note field and input autosize
- Move the note control from a labeled chip into an icon toggle in the
left group (search · note · add-to-top/bottom); hidden in search mode.
- Renumber the input shortcuts: Ctrl+1 add-to-bottom, Ctrl+2 search,
Ctrl+3 note, Ctrl+4-9 the action chips.
- Keep the submit (+) button's space reserved while empty via opacity
(visibility is re-asserted by mat-icon's anti-FOUC rule, so it stayed
visible).
- Animate the note field expand/collapse with the shared @expandFade.
- Fix a cdkTextareaAutosize height lag: the directive measures the
textarea with its horizontal padding stripped but its width unchanged,
so padding on the textarea made the measured wrap width wider than the
rendered one and the field grew a few chars after the text had already
wrapped (clipped line + scrollbar). Keep the title and note textareas
padding-free and move the gutters to their wrappers so measured and
rendered wrap widths match.
* feat(tasks): improve add-task-bar a11y, shortcut order and toggle emphasis
- Renumber the input shortcuts to match the on-screen left-to-right order:
Ctrl+1 search, Ctrl+2 note, Ctrl+3 add-to-top/bottom (chips stay Ctrl+4-9);
update the shortcut hints embedded in the tooltips accordingly.
- Add aria-label to every icon-only button and aria-pressed to the search
and note toggles so screen readers announce name and on/off state.
- Take the invisible submit (+) out of the tab order (disabled while empty).
- Guard the note toggle (and its Ctrl+2) to create mode; it is a no-op while
searching, where the note field does not render.
- Escape now dismisses an open task-suggestion list before closing the bar.
- Un-dim the toggles (0.6 + :focus-visible so keyboard focus isn't faint) and
give the submit + full-strength primary tint so the mobile CTA reads as the
primary action.
- Use the edit_note glyph for the note toggle (clearer than a chat bubble).
* refactor(tasks): apply add-task-bar multi-review cleanups
- De-duplicate the tooltip/aria-label expressions with @let (search,
backlog, add-to-bottom) so the two attributes can never drift.
- Split the Ctrl-shortcut map into local toggles (1-3) and action
shortcuts (4-9) so stopPropagation derives from structure instead of a
parallel key array that had to be kept in sync.
- Restore the "note attached" cue: the note toggle carries .has-value
(undimmed + tinted) when a collapsed note still holds text, distinct
from the .active pill shown while the note field is open.
- Remove dead code: the vestigial switch-add-to-btn class on the submit
button, the unused .search-input rule, a stale duplicated SCSS comment,
and the orphaned NOTE_BUTTON translation key.
- Document that the projected .inline-action-controls styles must stay in
the parent stylesheet (ng-content keeps the parent's encapsulation).
* feat(tasks): use notes bubble icon and restore expanded note draft
Match the notes icon used across the task feature (task list item,
detail panel, archived-task dialog) instead of the one-off edit_note
glyph. The glyph flips outline -> filled once the note field has text,
mirroring the detail panel's empty / has-notes states.
Also start the note field expanded when reopening the bar with a
persisted draft note, so it is visible rather than hidden behind the
collapsed toggle.
* fix(tasks): repair startup-overlay selector for add-task-bar textarea
The add-task-bar title field became a <textarea class="main-input">, but
StartupOverlayService still queried 'add-task-bar.global input'. A CSS input
type selector does not match a textarea, so on Android cold-start the partial-
text handoff (cursor position + focus) never ran and the overlay cleared only
via the 3s safety timeout. Target .main-input and retype to HTMLTextAreaElement.
Also address multi-review nits on the same branch:
- guard expandNote() in search mode so Ctrl+Enter cannot strand isNoteExpanded
(mirrors toggleNote()), with a spec covering it
- drop the unused fadeAnimation import and animations entry
- type the inputEl viewChild as ElementRef<HTMLTextAreaElement> (matches noteEl)
- repoint WorkViewPage.addBtn to .e2e-add-task-submit (.switch-add-to-btn now
belongs to the add-to-backlog toggle)
* test(tasks): clear add-task-bar sessionStorage between component specs
* docs(tasks): update add-task-bar keyboard shortcuts for new Ctrl mapping
Plugin issue-provider calendars (e.g. the Google Calendar plugin) register
asynchronously after the issue-provider store is hydrated. calendarEvents$
read getUseAgendaView() imperatively inside a store-stream map, so on first
load the filter ran before the plugin registered (returning false) and the
plugin's events stayed absent until a re-subscription (navigating away and
back) re-projected the stream.
Expose registration changes from the registry as a BehaviorSubject-backed
registrationChanges$ and fold it into the plugin-provider filter so it re-runs
when a plugin (un)registers. A BehaviorSubject (not toObservable(signal)) keeps
calendarEvents$ emitting synchronously on subscribe.