Commit graph

449 commits

Author SHA1 Message Date
Johannes Millan
eff41c041d
feat(project): project completion experience (#8036)
* feat(project): add project completion with celebration + trophy view

Completing a project marks it done and archives it, with a celebration
dialog (confetti + live stats) and a prompt to resolve unfinished tasks
(move to Inbox / mark done). Completed projects show a trophy badge and
a Reopen action on the archived-projects page.

isDone stays distinct from isArchived; selectArchivedProjects is left
intact so completed projects' tasks stay filtered out of Today/Overdue.
Append/merge deferred to #8032. Plan: docs/plans/2026-06-05-project-completion.md

* refactor(project): drop Archive menu item; Complete is the retire path

Archive and Complete produced near-identical end states; collapse to one
user-facing action. Removes the 'Archive project' menu item and handler so
Complete is the single way to retire a project. The archiveProject action,
reducer and ProjectService.archive() stay (needed for op-log replay of
historical archive ops and the legacy unarchive/restore path). Wiki updated;
menu specs cover the Complete flow.

* fix(project): keep completion dialog open for the active project

MatDialog closeOnNavigation (default true) dismissed the celebration the
moment completing the currently-active project navigated to '/'. Navigate
first, then open the dialog.

* test(project): e2e for completion flow (complete, celebrate, reopen)

Covers the resolve-unfinished-tasks prompt, the celebration dialog with
stats, the trophy badge on the archived page, and reopening. Adds an
openProjectContextMenu page-object helper.

* feat(project): confirm project completion

* feat(project): show completion celebration fullscreen

* fix(project): harden completion celebration flow

* style(project): use spacing tokens in completion dialog

* fix(project): align completion screen with project context

* style(project): soften completion screen coloring

* style(project): reuse completion screen surfaces

* fix: refine project completion dialog actions

* fix: complete projects with atomic task resolution

* fix: restore archive path in project completion UI

* refactor(project): remove dead completion code

The project-level completeProject action (OpType.Update) was never
dispatched — completion goes exclusively through the atomic
TaskSharedActions.completeProject (OpType.Batch) meta-reducer. Drop the
dead action, its reducer case, the PROJECT_COMPLETE enum member and the
immutable 'PCO' op-log code (which would otherwise be permanently
reserved for an op that can never be produced); enum count 147->146.

Also remove the unused selectCompletedProjects / selectPlainArchivedProjects
selectors (no consumers) and the misspelled, unused 'angel' confetti
field, keeping the regression tests that guard selectArchivedProjects.

* fix(project): make project completion non-reversible

Completion resolves a project's open tasks (move-to-inbox / mark-done),
which reopen cannot truly restore, so the "Reopen"/"Undo" affordances on
the completion path were misleading. Drop the celebration dialog's Reopen
button and the post-complete undo snack; the fullscreen celebration is
the feedback and deliberate reactivation still lives on the
archived-projects page (Project.reopen kept for it).

Also restore the project title param on the archive confirm dialog (it
was rendering a raw {{title}} placeholder), remove the now-unused
moveTasksToInbox / markTasksDone resolution helpers (the meta-reducer
resolves tasks atomically), and drop the orphaned UNDO / S.COMPLETED
i18n keys. Updates the completion e2e to the close flow and asserts the
resolution props are forwarded to the atomic action.

* fix(tasks): cancel native reminders for project-completed tasks

Completing a project marks its unfinished tasks done inside the
meta-reducer (no per-task updateTask), so unscheduleDoneTask$'s
native-reminder cancellation is bypassed and an OS-scheduled Android
notification could still fire for a now-done task. Add a local-only
effect that cancels native reminders for the force-completed task ids.

Local-only by design: it dispatches no actions (the persistent
dismissReminderOnly/clearDeadlineReminder would each be an extra synced
op), and done tasks are already filtered from reminders$ on all
platforms — only the native Android notification needs explicit removal.

* test(project): drop TaskService spies orphaned by helper removal

getByIdWithSubTaskData$/moveToProject/setDone/setUnDone were only used
by the removed moveTasksToInbox/markTasksDone helpers and their deleted
tests.

* feat(sync): affectedEntities multi-entity conflict detection for atomic completion

WIP checkpoint of the atomic completeProject approach. The Batch op declares
every touched entity (PROJECT, INBOX, TASKs, TODAY_TAG) via a new
affectedEntities field threaded through op-log capture, conflict detection,
the sync server (+Prisma migration) and shared-schema. Per-effect
completeProject handlers (issue two-way-sync, time-block, repeat-cfg)
re-derive the task changes the atomic op bypasses.

* revert(sync): remove affectedEntities multi-entity conflict detection

Reverts 0893a86162. The affectedEntities feature existed solely to make the
atomic completeProject Batch op sync-correct (its only producer). Decoupling
project completion into normal per-task ops (next commit) makes the existing
per-entity conflict detection and effects fire naturally, so this entire
layer — sync-core, super-sync-server, shared-schema, op-log plumbing, the
Prisma migration, and the per-effect completeProject listeners — is no longer
needed. Preserved in history via the checkpoint commit.

* refactor(project): decouple completion from task resolution (Option C)

Completion was an atomic multi-entity Batch op (completeProject) that marked
tasks done / moved them to Inbox inside the project-shared meta-reducer.
Because it bypassed the normal per-task actions, every downstream consumer had
to be taught about it separately — conflict detection (the affectedEntities
feature, reverted in the previous commit), native-reminder cancellation, issue
two-way-sync, time-block and repeat-cfg effects.

Decouple instead: completion is now a plain single-entity PROJECT flag flip
(completeProject = OpType.Update, mirroring archiveProject). Unfinished-task
resolution runs first as the normal per-task actions (moveToOtherProject /
updateTask isDone) from the completion flow, so the existing effects and
per-entity conflict detection fire naturally — no special-casing anywhere.

- project.actions/reducer: restore plain completeProject action + on() handler
- project.service: complete() is a flag dispatch; restore moveTasksToInbox /
  markTasksDone (normal per-task dispatch + Rule #6 flush)
- work-context-menu: resolve unfinished work before the flag flip
- drop the completeProject meta-reducer block, the Batch action, the
  TASK_SHARED_COMPLETE_PROJECT op code, and the reminder-cancel effect
  (unscheduleDoneTask$ already cancels native reminders on the normal path);
  current-task clearing is covered by the existing task-internal effect

Net: ~190 LOC removed here on top of ~1565 (affectedEntities + a Prisma
migration) in the revert. Completion's task resolution is not undone either
way, so the atomic bundle never bought a clean reversal.

* docs(project): record decoupled-completion decision (ADR #5)

Document why project completion uses decoupled per-task resolution + a plain
single-entity flag flip instead of an atomic multi-entity op: the atomic op
forced a cross-stack affectedEntities conflict-detection feature and per-effect
listeners, for an undo guarantee it never delivered. Adds ARCHITECTURE-DECISIONS
#5 and a revision note + corrected undo/bulk-mechanic notes in the plan doc.

* test(project): pin completion ordering + resolution edge cases

Address multi-agent review of the decoupled-completion refactor:
- assert resolution (moveTasksToInbox / markTasksDone) runs BEFORE the
  completeProject flag flip (toHaveBeenCalledBefore) — the core invariant of
  the decoupled design that was previously not pinned
- cover the not-done branch of moveTasksToInbox (no setUnDone) and assert
  markTasksDone dispatches exactly the passed set
- add explicit PCO encode/decode round-trip assertions
- document the inbox-path current-task carry-forward nuance in ADR #5

Composition is covered end-to-end by e2e/tests/project/project-completion.spec.ts.

* refactor(project): tighten completion flow per review

- collapse 3x getCompletionInfo() to <=2: gate the resolve prompt once,
  recompute only after a resolution; each call now wrapped with an error
  snack so a failed archive load no longer aborts silently
- drop the dead post-confirm re-prompt branch (unreachable between two
  sequential single-user modals)
- reuse getDiffInDays + dateStrToUtcDate in completion-stats util instead
  of hand-rolled local-midnight/duration helpers
- use a Set for the top-level-task membership check (was O(n*m))
- drop redundant inline dialog sizing; the panelClass owns fullscreen
- remove dead --project-complete-accent test assertion

* refactor(project): finish review follow-ups for completion flow

- W3: reset the celebration confetti instance on dialog destroy so its
  rAF loop + window resize listener are torn down when the dialog closes
  before the animation ends (ConfettiService now returns the handle and
  fires without awaiting completion)
- S2: extract resolveBgImageToDataUrl() shared by app.component and the
  celebration dialog (was duplicated file://->data-url resolution)
- S3: split completeProject() into _getCompletionInfoOrNotify (dedupes the
  error handling), _promptResolveUnfinishedTasks and _confirmCompletion
- W2: keep prefers-reduced-motion gating app-wide (a11y) + document intent

* fix(project): close confetti teardown race on early dialog close

If the celebration dialog is dismissed while canvas-confetti is still
loading, the instance was assigned after ngOnDestroy ran, so reset() never
fired and the rAF loop + resize listener leaked. Guard with an _isDestroyed
flag and reset the instance immediately if it arrives post-destroy.

Also drop the now-dead CanvasConfetti type alias (superseded by
ConfettiInstance, zero references).

* docs(project): drop non-existent undo-snack from completion wiki

* refactor(project): extract completion task-tree and dialog helpers

* refactor(project): hide Archive menu item; Complete is the retire path

* refactor(project): drop dead archive(), reuse resolve-choice type

Multi-review follow-ups on the completion feature:
- Remove orphaned ProjectService.archive() (+ unused import, spec) — the
  menu collapsed Archive into Complete, leaving no caller. The
  archiveProject action/reducer stay for op-log decode of historical ops.
- Reuse the exported ResolveUnfinishedTasksChoice type instead of
  re-spelling the union three times in work-context-menu.
- Fix misleading moveTasksToInbox comment (setUnDone re-opens, not move).
- Note the as-shipped deviations (no extra selectors, no celebration
  effect) in the design plan so they aren't hunted for later.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-08 13:43:38 +02:00
Johannes Millan
0d1869263f docs: remove outdated and implemented plan docs
Delete 29 plan/design docs whose work has shipped or been superseded
(SuperSync slices, sync-core extraction, encryption-at-rest drafts,
document-mode/Stage-A persistence, calendar/CalDAV concepts, focus-mode
time-tracking sync, etc.).

Kept the still-forward-looking docs (e.g. supersync-encryption-at-rest,
sync-core-simplification-roadmap, calendar-two-way-sync-technical-analysis).

Source comments that cited deleted docs are rewritten into self-contained
inline rationale so no "see docs/..." reference dangles.
2026-06-08 12:38:51 +02:00
felix bear
71f4ca484c
fix(plugins): return dialog result #5239 (#8106)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 12:14:08 +02:00
felix bear
00cd665e1a
feat(backup): configure automatic backup file limit #6690 (#8094)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 12:09:49 +02:00
felix bear
376e068412
fix(electron): respect tray title settings #7823 (#8097)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 12:07:09 +02:00
felix bear
667e8c3aa0
fix(azure-devops): simplify host setup #7672 (#8086)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 11:40:19 +02:00
felix bear
c0146e2307
feat(help): add create task how-to #8015 (#8079)
Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-08 11:40:14 +02:00
摇摆熊
645797f0bd
fix(reminder): require action for reminder popup #8051 (#8057)
* fix(reminder): require action for reminder popup #8051

* test(reminder): update deadline reminder e2e expectation

---------

Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com>
2026-06-06 17:34:06 +02:00
Maikel Hajiabadi
2ac8cf03a2
feat(tasks): add keyboard shortcut to set task deadlines (#8042)
* feat(tasks): add keyboard shortcut to set task deadlines

* fix(shortcuts): gate togglePlay to focused tasks and fix delegation visibility

* revert(i18n): remove manual translation in de.json

* docs(wiki): align shortcut labels and precedence note wording

* test(shortcuts): update TaskShortcutService tests to align with new precedence and delegation logic

* fix(shortcut): consume events on match to prevent fall-through when task focused

* test(shortcut): add regression test for ShortcutService precedence

* docs(wiki): correct Schedule view shortcut label

* style(i18n): remove accidental blank line in de.json

* test(op-log): stabilize lock reentry regression tests with robust timeouts
2026-06-06 16:15:49 +02:00
Johannes Millan
7c03c84fad
feat(history): merge Quick History and Worklog into a unified History view (#8033)
* feat(history): merge Quick History and Worklog into one view

- single /history route; legacy worklog & quick-history redirect to it
- one History menu entry; navigate-to-task targets history
- show per-day work start-end as its own column in the day table
- show enabled habits/simple-counters (icon + tooltip) in the expanded day
- extract HistoryTaskRowComponent for archived task rows
- remove WorklogComponent and QuickHistoryComponent

* fix(history): restore quick history behavior

* refactor(history): collapse to a single unified view

- remove the Worklog/Quick History toggle; show one full all-time
  history tree (year -> month -> week -> day)
- drop the quick-history mode, significance filter and ?view= param
- extract shared HistoryDayMetaComponent; reuse HistoryTaskRowComponent
  in the Daily Summary "This Week" widget
- convert project colors + dateStr auto-expand to signals
- update e2e specs to the single-view selectors

* refactor(history): remove dead quick-history code and i18n keys

After merging Quick History into the unified History view, the
quick-history data pipeline had no consumers. Remove it:
- _quickHistoryData$, quickHistoryWeeks$, _loadQuickHistoryForWorkContext
- map-archive-to-worklog-weeks util (+ tz spec) and WorklogYearsWithWeeks
- orphaned F.QUICK_HISTORY, MH.QUICK_HISTORY, MH.WORKLOG translation keys

Keep WorklogWeekSimple (base of the still-used WorklogWeek).

* docs(history): reflect Quick History merge into unified History

The quick-history and worklog routes are now legacy aliases for the
single unified History view; drop the stale 'current-year mode/toggle'
framing. Concepts-note prose flagged for human review.

* feat(history): improve a11y and i18n of the unified history view

Accessibility:
- week table uses <thead> + <th scope=col>; icon-only work-times
  header gets a translated aria-label
- expandable day row: real <button> disclosure control carrying
  aria-expanded/aria-controls (keyboard + AT path) instead of a
  role=button <tr>; row keeps a pointer click
- archived-task title is now a <button> (was a non-focusable <span>)

i18n: translate previously hardcoded aria-labels (export/restore) and
the week-range tooltip; reuse VIEW_TASK_DETAILS/RESTORE_TASK_FROM_ARCHIVE,
add EXPORT_DATA + WEEK_RANGE_TOOLTIP keys.

Polish (carried in): convert worklogData$ to a toSignal signal (drop
AsyncPipe + dead animations), relocate the shared row to
features/worklog/worklog-task-row (WorklogTaskRowComponent) to break a
worklog<->history cycle, make template-unused services private, tighten
projectColor input type. SCSS focus rings use focus-ring tokens.

e2e: locate task titles via td.title button after the span->button change.
2026-06-05 18:10:56 +02:00
Johannes Millan
315c900391
fix(boards): show tasks from sidebar-hidden projects (#8021)
* fix(boards): show tasks from sidebar-hidden projects

* docs(projects): clarify sidebar hidden wording

* fix(nav): restore project visibility affordance
2026-06-05 12:10:14 +02:00
Johannes Millan
ede6b770c8
[codex] Hide global metric charts outside Today (#8019)
* fix(metric): hide global charts outside today

* refactor(metric): collapse showGlobalMetrics into isShowingAllTasks

Address review feedback on the global-charts gating:

- Drop the redundant showGlobalMetrics alias; it was a 1:1 pass-through of
  the existing computed. Make _isShowingAllTasks public as isShowingAllTasks
  so the template uses one source of truth (it already drives the metrics
  service selection and view title too).
- Collapse the three repeated metricService.hasData() guards in the template
  into a single @if/@else, evaluating the condition once.
- Fold the new Inbox coverage into the isShowingAllTasks spec and drop the
  now-duplicate showGlobalMetrics suite.
2026-06-05 10:53:57 +02:00
Maikel Hajiabadi
25896fd318
Fix frequency translations (#8000)
* fix(translation): fall back to translation language for date/time locale

* fix(translation): use correct dative ordinal inflection in German monthly nth weekday settings

* fix(translation): use correct dative ordinal inflection in German monthly nth weekday settings

* fix(date-time): reset date-time locale to active UI language when override is removed

* fix(tasks): correct monthly repeat display precedence and validity checks

* refactor(tasks): use UTC-anchored Sunday for deterministic weekday-name lookups

* fix(tasks): standardize quick settings translation keys

* test(tasks): improve getTaskRepeatInfoText test coverage for monthly nth-weekday and fallbacks

* docs(i18n): explain dative form suffix for ORD_*_NTH translation keys

* translation(de): translate all missing keys in de.json to German

* fix(date-time): prevent crashes when DateAdapter is not provided or mocked

* fix(tasks): use correct dative ordinal inflection in monthly nth weekday quick settings
2026-06-05 10:53:26 +02:00
Johannes Millan
524e505353 fix(onboarding): harden sync setup dismissal
Mark sync-based onboarding dismissal as fully complete so the hint tour does not restart on the next boot. Guard the sync setup CTA while the dialog is loading or open, cover the async races in tests, and move the onboarding z-index to a design token.
2026-06-03 19:54:14 +02:00
Johannes Millan
c41c18f247
fix(sync): guard fresh-client first sync against data-loss trap & decrypt-race (#7980)
* fix(sync): guard fresh-client first sync against data-loss trap & decrypt-race

A genuinely-fresh client seeds example tasks before its first sync, so on first
connect to a populated, encrypted SuperSync dataset it hit a SYNC_IMPORT conflict
dialog whose USE_LOCAL would overwrite the real remote, plus a red
DecryptNoPasswordError.

- A1: SyncImportConflictGateService flags isNeverSynced (\!hasSyncedOps) on the
  incoming-import dialog; the dialog guards the destructive USE_LOCAL with a
  confirm and focuses the scenario-appropriate safe button via cdkFocusInitial.
- B: OperationLogDownloadService logs 'encrypted ops, no key' quietly only for a
  genuinely-fresh client (never synced AND no local encryption flag); it stays
  loud (dropped-credential signature) for already-synced clients and for a wiped
  op store whose config still flags encryption on, via the new optional provider
  method SuperSync.isEncryptionEnabled().
- D: SuperSync.isReady() returns false while isEncryptionEnabled && \!encryptKey,
  keeping a self-inconsistent encrypted config out of auto-sync; shares the
  _isEncryptionHalfConfigured predicate with getEncryptKey().

Tests: super-sync isReady/isEncryptionEnabled, conflict-gate isNeverSynced,
dialog confirm-guard + focus-by-scenario, download severity branches, and a
sync-service end-to-end first-sync-trap guard.

Plan/rationale: docs/plans/2026-06-03-fresh-client-first-sync-data-loss-trap.md

* docs(sync): add fresh-client first-sync data-loss-trap plan

Documents the shared root cause (example tasks seeded pre-first-sync), the A1/B/D
fixes, and the accepted Fix C residual (example-task pollution onto non-encrypted
accounts) with the rationale for deferring an identity-based cleanup.

* fix(sync): capture never-synced guard pre-download for piggyback SYNC_IMPORT

The SYNC_IMPORT conflict gate's never-synced guard (which blocks a
fresh client from force-overwriting a populated remote via USE_LOCAL)
was computed from a live hasSyncedOps() read on the piggyback-upload
path. By the time that gate runs, the same sync has already persisted
downloaded ops with syncedAt and marked accepted uploads synced, so the
flag flips to "has synced" mid-cycle and the guard disarms itself —
re-opening the data-loss trap on the piggyback path.

Capture the never-synced snapshot once at sync-cycle start, before
download, and thread it through downloadRemoteOps()/uploadPendingOps()
into the gate. The gate falls back to a live read only for standalone
callers (download gate is safe live: nothing is persisted until
processRemoteOps).

Also stop the sync-wrapper catch from re-logging the expected
DecryptNoPasswordError at error level — the download/upload service
already logs it at the right severity, so re-logging undid the
fresh-client onboarding-prompt quieting.

Tests: gate honors a caller-provided isNeverSynced without consulting
live history; piggyback path flags isNeverSynced=true even when the
upload marks ops synced; wrapper threads the pre-download snapshot.
Refresh the plan doc's stale test counts and document the fix (§11).
2026-06-03 16:01:16 +02:00
Maikel Hajiabadi
e0645f57c3
Feat/task creation due date (#7966)
* feat(tasks): add deadline support to add-task-bar

* test(tasks): add unit tests for deadline creation

* docs(wiki): document deadline button and shortcuts

* feat(tasks): shift deadline creation to input short-syntax

* test(tasks): fix expectation in mentions spec for deadline trigger

* build(tasks): drop unused DialogDeadlineComponent import

* fix(tasks): prevent title corruption when combining schedule and deadline

* refactor(tasks): restrict preceding space guard to deadline trigger

* fix(tasks): prevent hasDeadlineTime leaking onto task during short syntax edits

* fix(tasks): auto-plan tasks when deadline is set to today via short syntax
2026-06-03 14:46:43 +02:00
Johannes Millan
63d46f7a0b
feat(op-log): validate SQLite backend + IDB→SQLite migration + backend-aware init (#7931) (#7954)
* test(op-log): validate SqliteOpLogAdapter against a real sql.js engine

The 23 adapter specs ran only against an in-memory regex stand-in that
models the SQL shapes the adapter emits — it validates the translation
layer, not SQLite itself. Add sql.js (dev-only; never in the app bundle)
served into Karma, and run the behavioral contract against BOTH the fake
and a real SQLite engine.

This exercises genuine-engine behavior the stand-in could only model:
the real UNIQUE-constraint message -> ConstraintError mapping,
AUTOINCREMENT never reusing seq after clear(), compound-index + NULL
range handling, and real BEGIN IMMEDIATE rollback. 51/51 green.

B2 (translation-layer pass) per docs/sync-and-op-log/sqlite-migration.md.
The integration-harness second pass and the on-device real-engine run
remain.

* test(op-log): run store-port integration against real sql.js (B2 stage 2)

Parameterize the RemoteOperationApplyStorePort integration scenarios to
run against BOTH the default IndexedDB backend and a sql.js-backed
SqliteOpLogAdapter, exercising the store's COMPOSED flows (apply/mark/
merge-clock, partial-failure persistence, full-state import clearing,
vector-clock persistence) on a real SQL engine — not just the adapter in
isolation. 6/6 green.

Surfaced a real B3 wiring gap: OperationLogStoreService.init() is
IDB-shaped (opens+adopts an IndexedDB connection, never calls the
adapter's own init()). For a self-managing backend like SQLite the
tables would not exist. The sql.js setup creates them once on the shared
db to mirror the store-init change B3 must make on native (call
adapter.init() / skip the IDB open when the backend is SQLite).

* feat(op-log): add verified IDB->SQLite backend migration (C1)

One-time copy of the entire op-log from a source adapter (legacy
IndexedDB) to a dest adapter (SQLite) in a single dest transaction with
verify-before-commit: a mismatch in op count, last seq, or vector clock
throws and rolls the dest back, leaving it empty and the source
untouched.

Adapter-agnostic (talks only to the OpLogDbAdapter port), so it is
validated in CI with a real Chrome IndexedDB source + a sql.js SQLite
dest; the native @capacitor-community/sqlite dest behaves identically
through the same port. The generic iterate->put copy preserves ops seq
(incl. gaps) via the put-honors-seq path and writes singletons at their
out-of-line key uniformly, with no per-store special-casing.

Not wired into startup — Phase B3/C2 decide WHEN to run it (SQLite empty
+ legacy SUP_OPS present) and retain the IDB copy >= 1 release. 5/5
green, incl. seq-fidelity, AUTOINCREMENT-continues-past-migrated,
empty-source, non-empty-dest guard, and verify-rollback.

* docs(sync): record sql.js validation, C1, and the B1/B3 findings

Update the SQLite migration plan + follow-up backlog to reflect what
landed this pass and hand off the device-gated remainder:
- B2: real-engine (sql.js) adapter contract + store-port second pass are
  done in CI; only the on-device run remains.
- C1: the backend-migration algorithm + verify-before-commit are done
  and tested (real IDB -> sql.js); only the startup wiring remains.
- B3 finding: OperationLogStoreService.init() is IDB-shaped (opens+adopts
  IDB, never calls adapter.init()); native must call adapter.init() and
  skip the IDB open on SQLite.
- B1 perf note: bridge round-trips dominate on native; return lastId from
  the plugin's run response and add a runBatch/executeSet bulk path so
  appendBatch is one crossing, with RETURNING-seq for per-op seq.

* feat(op-log): make store init backend-aware for self-managing backends (B3)

OperationLogStoreService.init() and ArchiveStoreService._init() were
IDB-shaped: they unconditionally opened+adopted a WebView IndexedDB
connection and never called the adapter's own init(). For a self-managing
backend (SQLite) that meant (a) the adapter's tables were never created
and (b) it still touched the evictable WebView store this migration
exists to escape.

Now: when the adapter exposes no adoptConnection (i.e. it self-manages,
like SQLite), call adapter.init() and skip the IndexedDB open. The
adopt-connection (IndexedDB) path is unchanged. The new branch is dead in
production until B3 flips the native token, so this is risk-free now and
unblocks that flip.

Tested: two unit tests cover both branches (self-managing -> adapter.init,
no IDB open; IDB -> open+adopt, no adapter.init). The store-port
integration spec now drives the store fully on SQLite with _db undefined,
so its earlier pre-init workaround is removed. 521 persistence + 6
integration green.

* docs(sync): mark the B3 backend-aware init fix as landed

The store-init half of B3 (call adapter.init() / skip the IDB open for
self-managing backends) is implemented + CI-tested; only the device-gated
native token flip + SqliteDb wrapper remain.
2026-06-02 17:26:23 +02:00
Johannes Millan
ded0240b8e docs(task-repeat-cfg): revise RRULE migration plan and consolidate to one doc
Rewrite the recurring-events plan after multi-axis review against the
codebase. Adopt a typed, RRULE-isomorphic recurrence model (RRULE string
at the export/CalDAV boundary only), keep the synchronous occurrence
engine, and route migration through the op-log schema system. Fold the
gap-analysis and industry-standards research docs into the plan as
appendices and remove them.
2026-06-02 16:09:35 +02:00
Johannes Millan
64d3219d3a
feat(local-backup): Track A safeguards for #7925 (#7932)
* fix(android): escape JS bridge args via JSONObject.quote (#7925)

`loadFromDb` interpolated the stored value into a single-quoted JS string
literal passed to `evaluateJavascript`. Beyond the security smell, this is
a real data-loss bug: `JSON.stringify` does not escape apostrophes, so a
backup blob containing one (e.g. a task titled "don't…") terminated the JS
literal and the load returned garbage — silently corrupting any restore
from `KeyValStore`.

Use `JSONObject.quote()` (already established in the file for
`emitForegroundServiceStartFailed`) for all three callback args, so values
containing `'`, `\`, newlines or `</script>` round-trip cleanly.

* feat(startup): log storage-persistence outcome on all branches (#7925)

`_requestPersistence()` was silent on native and on the `false` resolution
of `persist()`, so #7892-style "woke up blank" reports carried no signal
about whether the WebView store was actually persistent.

Always log `{persisted, granted, isNative, isElectron}` — including the
already-persisted branch, the persist-resolved branch (both true and false),
the error branch, and the no-`navigator.storage` branch. User-facing snack
gating is unchanged (still web-only, non-onboarding). Logging-only — no
behavioral change.

This is Track A1 in `docs/sync-and-op-log/sqlite-migration-followup.md`:
the diagnostics that decide whether the deeper protective steps
(near-empty write guard, SQLite migration) are worth the added complexity.

* docs(sync): refresh sqlite-migration followup after #7924 (#7925)

The followup backlog described the local-backup ring as TODO under A2,
but #7924 already shipped the periodic + app-private backup, two-generation
ring, empty-state write guard, and informed restore prompt. Bring the doc
in sync:

- Add the shipped local-backup work to "Where we are now".
- Replace the old A2 ("Periodic local auto-backup") with the narrower
  remaining gap: a debounced data-change backup trigger to complement the
  5-min timer.
- Add A3 (near-empty write-time overwrite guard) with a concrete starting
  threshold and a fail-safe rationale, sequenced after A1 so the
  diagnostics tune the threshold before it lands.
- New "Cross-cutting / hardening" section consolidating the items
  surfaced by the #7924 review (Kotlin JS-bridge escaping — now done;
  backup-date reader bridge for the restore prompt; robust restore on
  degraded boot; last-backup visibility; onboarding nudge for no-sync
  users).

* feat(local-backup): debounced on-data-change backup trigger (#7925)

The 5-min `interval()` was the only thing that drove `LocalBackupService._backup()`,
so a destructive event in the minutes before a WebView eviction could be lost
from the backup ring even though the live store had it.

Merge a `LOCAL_ACTIONS`-driven trigger into `_triggerBackupSave$` that fires
once after a 30s quiet period. Catches the typical "user made changes then
put phone down" pattern before the next periodic tick. `LOCAL_ACTIONS`
already filters out remote/hydration replays, and the existing empty-state
guard prevents writing a degraded post-eviction snapshot over a good
backup, so this strictly adds backup frequency — never spam.

Logic-level only — `_backup()` continues to early-return on non-target
platforms (web/PWA), so the trigger is safe to subscribe everywhere.

Closes A2 (remaining gap) in
`docs/sync-and-op-log/sqlite-migration-followup.md`.

* docs(sync): mark A1 + A2 shipped in sqlite-migration followup (#7925)

A1 (storage-persistence diagnostics) and A2 (debounced data-change backup
trigger) both landed this round. Update the suggested order and the A2
section so the doc accurately reflects what's left in Track A (just A3,
the near-empty write-time overwrite guard).

* feat(local-backup): near-empty write-time overwrite guard (A3, #7925)

The exact-empty guard in `_backup()` only catches a fully-degraded store.
The residual gap is a post-eviction boot that leaves the store near-empty,
the user adds 1-2 tasks before the 5-min timer fires, and the degraded
state then overwrites the good primary slot. The prev slot is still safe,
but the informed restore prompt only fires on a wholly fresh launch — so
without this guard the user has lost direct access to the better backup
until they uninstall/reinstall.

Add a per-platform near-empty guard in `_backupAndroid` / `_backupIOS`:
read the existing primary, compare task counts (active + young-archived +
old-archived via the new shared `countAllTasks` helper), and bail when a
< 3-task snapshot would clobber a >= 10-task existing backup. Electron is
unchanged — its rotated, timestamped backup chain isn't a single-slot
overwrite.

Threshold rationale: `summarizeBackupStr` counts archived tasks too, so
"near-empty" means the same thing on the read side (the restore prompt)
and the write side (this guard). Fail-safe — skipping a write only delays
capturing a real wipe, never loses data; the guard self-clears once the
store grows back past 3 tasks, so a legitimate bulk-delete is captured
on the next tick.

Marks Track A (#7925 / sqlite-migration-followup.md) complete.

* fix(android): JSONObject.quote() the sibling JS bridge callbacks (#7925)

`saveToDbCallback` / `removeFromDbCallback` / `clearDbCallback` still raw-
interpolated `requestId` into single-quoted JS string literals. The args are
nanoid strings today so it works — but only by caller hygiene. Mirror the
`loadFromDb` fix: quote all three so the bridge contract no longer depends
on what the caller happens to pass.

Compress the `loadFromDb` rationale comment in the process — the file-level
intent now lives on one line near the cluster.

* refactor(local-backup, startup): trim Track A code per multi-agent review

Two independent reviewers flagged the same set of cleanups on the Track A
commits (#7925). Applying the high-confidence ones:

- Drop `_escapeAndroidNewlines` + its two call sites. The Kotlin bridge fix
  (#7925, 663d747b4) now JSON-escapes newlines on the way out, so the JS-side
  workaround replaces nothing on any post-fix write. Removes the awkward
  "raw vs escaped" split in `_backupAndroid`.
- Hoist the A3 skip-and-log block into one private `_guardNearEmptyOverwrite`
  helper so the warn template can't drift between Android and iOS. Keep
  `_isNearEmptyOverwrite` as the pure predicate (the spec pins it).
- Tighten the A2 comment: `bulkApplyOperations` / `loadAllData` actually do
  transit `LOCAL_ACTIONS` (they aren't tagged `meta.isRemote`). Behaviour
  is still correct because the empty-state + A3 guards handle degraded data,
  but the previous comment overstated upstream filtering.
- Cut the multi-paragraph comment blocks around `DATA_CHANGE_BACKUP_DEBOUNCE`,
  the A3 constants, `_isNearEmptyOverwrite`, and the `_backup()` guard. Keep
  the issue refs; drop the prose that paraphrased the next line of code.
- Inline the `context` object spread in `_requestPersistence` — three log
  calls on adjacent lines didn't need a hoisted bag of fields.

No behavioural change. 21/21 local-backup specs + 18/18 startup specs green.
2026-06-02 11:58:36 +02:00
Johannes Millan
4c239e5691
refactor(op-log): extract a swappable persistence port + SQLite backend (groundwork for #7892) (#7902)
* docs(sync): add SQLite migration plan + Phase A adapter port skeleton

Documents the op-log persistence migration off WebView IndexedDB into
app-private SQLite on native (Capacitor), addressing the data-loss class
where Android can evict WebView storage when no sync is configured.

Phase A skeleton (no behavior change, not yet wired in):
- OpLogDbAdapter / OpLogTx: backend-agnostic persistence port with a
  callback-based transaction() as the atomicity seam both IndexedDB and
  SQLite map onto.
- OP_LOG_DB_SCHEMA: declarative SUP_OPS schema descriptor (mirrors
  db-upgrade.ts v6) that both backends can consume.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* feat(op-log): add IndexedDbOpLogAdapter implementing the persistence port

Phase A continuation of the SQLite migration (docs/sync-and-op-log/
sqlite-migration.md). Implements the IndexedDB backend behind the
OpLogDbAdapter port: open-retry with the existing budgets, versionchange/
close re-open handling, IndexedDBOpenError wrapping, index/range queries,
and a callback-based transaction() that commits on resolve and aborts on
throw — the atomicity seam both backends share.

Extends the port with cursor-style iterate() (continue/stop/delete/
delete-stop) to cover the latest-entry lookups and predicate pruning the
store does today, plus a close() teardown hook.

Spec exercises CRUD, the unique byId index, range queries, cursor
direction/stop/delete, and — critically — multi-store transaction commit
and rollback against fake-indexeddb. 10/10 pass.

Not yet wired into OperationLogStoreService; additive scaffolding only.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(op-log): harden persistence port after multi-agent review

Addresses blocking fidelity gaps found reviewing the adapter against the
real store's usage, so the upcoming store refactor can be behavior-
preserving:

- iterate() visitor is now synchronous and receives the primary key.
  An async visitor could await real I/O mid-cursor, letting the IDB
  transaction auto-commit and the next continue() throw
  TransactionInactiveError. Synchronous-only also lets a buffered SQLite
  backend honor it without materializing the whole result set.
- DbIterateOptions.query positions an index cursor at an exact key
  (clearFullStateOpsExcept's keyed delete).
- getAll()/count() take an optional primary-key range (getOpsAfterSeq and
  the getUnsynced/getAppliedOpIds incremental caches use
  getAll(OPS, lowerBound(seq))).
- getKeyFromIndex() for cheap existence probes
  (appendBatchSkipDuplicates' getKey, avoids deserializing the value).

Tests expanded 10 -> 26: destructive clear()+delete() rollback, abort on
inner-op rejection, transactional reads/index/cursor, readonly mode,
keyed index iteration, compound-index match, getAll/count ranges, the
close() re-open cliff, and the open-retry budgets via the _openDbOnce
seam.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(op-log): route import-backup methods through the persistence adapter

First method group of the Phase A store migration. Adds an
adoptConnection() seam so IndexedDbOpLogAdapter operates on the store's
existing connection rather than opening a second one to SUP_OPS (avoiding
versionchange deadlocks and doubled close/upgrade handling during the
transition). The store adopts/releases the connection alongside its own
_db in init()/close/versionchange.

saveImportBackup / loadImportBackup / clearImportBackup / hasImportBackup
now go through the adapter. Behavior is identical — same connection, same
store, same keys.

Verified: 170 store unit specs, 26 adapter specs, 3 archive specs, and
the import-sync integration spec all green.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(op-log): route state_cache + compaction methods through the adapter

Second method group of the Phase A store migration. saveStateCache,
loadStateCache, the migration-safety backup methods (save/load/clear/has/
restore), and the compaction counter (get/increment/reset) now go through
the shared adapter. The two atomic read-modify-write methods
(incrementCompactionCounter, resetCompactionCounter) use the adapter's
callback transaction(), preserving their single-transaction semantics.

Introduces a StateCacheEntry type; `id` is optional so the read-side
return types stay assignable from the looser snapshot shapes callers
construct (the pre-migration return types didn't surface `id`).

Verified: 170 store unit, 53 compaction unit, 27 vector-clock, 20
compaction integration specs all green; full tsc clean.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* docs(sync): track Phase A migration progress in sqlite-migration.md

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(op-log): route ops-table append + markApplied through the adapter

Third method group of the Phase A store migration — the higher-risk
write path. append, appendBatch, appendBatchSkipDuplicates and markApplied
now go through the shared adapter. The batch methods use the adapter's
callback transaction() (one atomic unit, same as before); the TOCTOU-free
duplicate guard uses tx.getKeyFromIndex (the byId unique index probe,
issue #6343). ConstraintError->DUPLICATE and QuotaExceededError->
StorageQuotaExceededError mappings are preserved — the adapter rethrows
the original DOMException so the store's catch blocks still fire.

Verified: 170 store unit + 367 op-log integration specs green; tsc clean.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(op-log): route ops-table reads + full-state clears through adapter

Fourth method group. getPendingRemoteOps (compound-index match expressed
as a degenerate [k,k] range, with the pre-v3 fallback scan preserved),
hasOp, getOpById, getOpsAfterSeq (primary-key range), the two reverse-
cursor latest-full-state lookups, and clearFullStateOps/
clearFullStateOpsExcept now go through the adapter's iterate()/getAll()/
getAllFromIndex(). The keyed-index-cursor delete is factored into a
_deleteOpsByIds() helper using iterate({index, query}) + delete-stop in a
single atomic transaction, matching the prior behavior (no-op + no cache
invalidation on empty list).

Verified: 170 store unit + 367 op-log integration specs green; tsc clean.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(op-log): route unsynced/applied caches + mark methods through adapter

Fifth method group. The getUnsynced/getAppliedOpIds incremental cache
builds (getAll with a primary-key range), getFailedRemoteOps (compound
index), markSynced/markRejected/clearUnsyncedOps/markFailed (transactional
get+put loops), deleteOpsWhere (predicate cursor delete) and getLastSeq
(reverse cursor reading the primary key via the iterate visitor's key arg)
now go through the adapter. markFailed keeps its original behavior of NOT
invalidating the unsynced cache.

Verified: 170 store unit + 367 op-log integration specs green; tsc clean.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(op-log): route remaining store methods through adapter

Final OperationLogStoreService group — every method now goes through the
persistence adapter; no direct `this.db` calls remain. Covers hasSyncedOps
(bySyncedAt index cursor), clearAllOperations, _clearAllDataForTesting
(multi-store clear in one transaction), the vector-clock accessors, and
the two flagship atomic flows:

- appendWithVectorClockUpdate (OPS + VECTOR_CLOCK in one transaction)
- runDestructiveStateReplacement (OPS + STATE_CACHE + VECTOR_CLOCK +
  CLIENT_ID + archive). The hand-rolled try/abort is replaced by the
  adapter's commit-on-resolve / abort-on-throw transaction(); success-only
  cache + clientId-cache invalidation now runs after the resolved
  transaction. The #7709 interrupt atomicity tests still pass — the
  adapter operates on the same adopted connection the tests spy on, so a
  poisoned opsStore.add still aborts and unwinds the queued clientId
  rotation.

Verified: 170 store unit + 367 op-log integration specs (incl. the 3
clean-slate-interrupt atomicity tests) green; tsc clean.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(op-log): route ArchiveStoreService through the persistence adapter

Completes the Phase A store/archive migration. ArchiveStoreService gets
its own IndexedDbOpLogAdapter that adopts its independent SUP_OPS
connection (released on close/versionchange and on the iOS
connection-closing retry path in _withRetryOnClose). All six accessors
plus saveArchivesAtomic/_clearAllDataForTesting now go through the
adapter; the dead `db` getter and its unused error constant are removed.
No direct `this.db` calls remain in either persistence service.

Verified: 3 archive unit + 170 store unit + 367 op-log integration specs
green; tsc clean.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* refactor(sync): fix readonly cursor regression + multi-review findings

Multi-agent review of the Phase A op-log adapter (post full store/cursor
migration). Addresses one live regression plus hardening; no functional
behavior change.

W1 (live regression fix): the migrated read-only cursor methods —
getLastSeq, hasSyncedOps, getLatestFullStateOp(Entry) — ran through
iterate(), which always opened a 'readwrite' transaction, so pure reads on
the hot ops store took an exclusive write lock and serialized against
appends (pre-migration they were 'readonly'). Add `mode` to DbIterateOptions
(default 'readwrite' so delete-walks keep working) and pass `mode:'readonly'`
from those four readers; clearFullStateOps* delete-walks stay readwrite.

W2: op-log-db-schema reuses DB_NAME/DB_VERSION from db-keys.const instead of
re-literaling 'SUP_OPS'/6 (no third source of truth), and a new
op-log-db-schema.spec.ts asserts the descriptor matches both DB_VERSION and
the stores/indexes runDbUpgrade actually creates (the contract Phase B builds
on).

W3: test the adoptConnection seam (both branches) — ops route onto an adopted
external connection, and adoptConnection(undefined) returns to the
not-initialized cliff (the store's close/versionchange path).

W4: convert the two open-retry specs from real ~8s backoff sleeps to
fakeAsync + tick (adapter spec ~0.04s vs ~8s) and assert exact attempt
budgets; add a full-lock-budget case.

Gates: adapter 30 + schema 2 + store 170 unit, and 59 op-log integration
specs (race-conditions, multi-entity-atomicity, compaction, server-migration,
clean-slate-interrupt, indexeddb-error-recovery) green; checkFile clean.

* refactor(op-log): inject the persistence adapter via DI token

Phase B step 1. Both persistence services now obtain their OpLogDbAdapter
from OP_LOG_DB_ADAPTER_FACTORY instead of constructing IndexedDbOpLogAdapter
directly. The token vends a factory (not a singleton) because each service
adopts its own connection into its own adapter instance. Defaults to
IndexedDB on all platforms; Phase B step 2 will override it to return a
SqliteOpLogAdapter when running native, with the stores untouched.

adoptConnection() becomes an optional bridge method on the OpLogDbAdapter
interface — documented as IDB-transition-only; a self-managing backend
(SQLite) leaves it undefined and callers guard with `?.()`.

Verified: 170 store unit + 3 archive unit + 367 op-log integration specs
green; tsc clean.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* feat(op-log): add SqliteOpLogAdapter skeleton (Phase B, no native dep)

Dependency-free skeleton of the SQLite backend behind the OpLogDbAdapter
port. Solves the hard schema-mapping question the reviewers flagged without
pulling in a native plugin or anything untestable in CI:

- planTables()/buildDdl(): derive the physical SQL layout from the shared
  OP_LOG_DB_SCHEMA. Each store -> a table with a JSON `value` column plus
  one extracted column per IDB index. ops gets `seq INTEGER PRIMARY KEY
  AUTOINCREMENT` (monotonic, never-reused — matches IDB + getLastSeq),
  `op_id TEXT UNIQUE` (byId), `synced_at` (bySyncedAt) and a composite
  (source, application_status) index. keyPath stores -> TEXT PK from the
  keyPath; keyless singletons -> caller-supplied TEXT key.
- A minimal SqliteDb port (run/query) the adapter talks to instead of
  importing @capacitor-community/sqlite, so this file has no native
  dependency and is unit-testable with a fake.
- init() applies the DDL (idempotent); query/tx methods throw a loud
  not-implemented error (fail loudly rather than silently lose data) with
  the intended SQL documented per method. adoptConnection is intentionally
  absent — SQLite self-manages its handle.

12 specs cover the plan/DDL derivation and that init() emits the expected
DDL. Doc updated with status + the deferred native-dependency decision.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* feat(op-log): fully implement SqliteOpLogAdapter (still no native dep)

Completes the SQLite backend behind the OpLogDbAdapter port. All query,
index, range, count, cursor-iterate and transaction methods are now
implemented against the minimal SqliteDb port:

- value→column extraction: each store row stores the JSON object in a
  `value` column plus extracted columns for the indexed paths
  (op_id/synced_at/source/application_status); writes populate them.
- transactions map to BEGIN IMMEDIATE/COMMIT/ROLLBACK with rollback-on-
  throw; readonly iterate/transaction use no write lock.
- SQLite errors map to the SAME DOMException names the store's existing
  catch blocks expect: UNIQUE→ConstraintError (→DUPLICATE_OPERATION_ERROR),
  disk-full→QuotaExceededError (→StorageQuotaExceededError).
- ops uses AUTOINCREMENT so seq is monotonic and never reused across
  clear() — matching IDB + getLastSeq.

Still imports no native plugin: a thin wrapper over
@capacitor-community/sqlite's SQLiteDBConnection will satisfy SqliteDb on
device. 23 specs validate the translation layer + transaction semantics
(commit/rollback/abort-on-unique) against an in-memory SQLite stand-in;
a real-engine on-device run is the remaining Phase B step (documented).

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* docs(sync): add SQLite migration follow-up backlog

Actionable, ordered backlog companion to sqlite-migration.md:
- Track A: ship the #7892 safeguards now (persist() diagnostics +
  native filesystem auto-backup) — independent of SQLite, recommended
  near-term fix.
- Track B: finish the native SQLite backend (plugin + SqliteDb wrapper,
  real-engine validation, DI flip behind a flag).
- Track C: one-time IDB→SQLite data migration, staged rollout.
- Track D: cleanup once SQLite is the native default.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

* fix(op-log): scan full-state ops read-only to drop the write lock

clearFullStateOps / clearFullStateOpsExcept iterate the ops store only to
collect ids (the delete runs in a separate transaction), but the migrated
iterate() defaulted to 'readwrite' — so these pure-read scans took an
exclusive write lock on the hot ops store and serialized against appends.
Pre-adapter (master) these scans used a readonly cursor. Pass
mode:'readonly' to restore parity. Same regression class the earlier W1
fix addressed for getLastSeq/hasSyncedOps/getLatestFullStateOp(Entry);
these two scans were missed because they are no longer delete-walks.

Verified: 170 store unit + server-migration/import-sync/remote-apply/
vector-clock-import integration specs green; checkFile + tsc clean.

* fix(op-log): correct SQLite seq round-trip + enforce tx scope and readonly

Hardens the dormant SQLiteOpLogAdapter against three multi-review findings
(translation-layer only; the backend is still wired to nothing):

- C1 (data duplication): the autoinc `ops` PK (`seq`) lived only in its own
  column, never the JSON value, and was never re-injected on read. So reads
  returned seq===undefined and put() emitted INSERT…ON CONFLICT(seq) with no
  seq bound — the conflict never fired and every mark*/clearUnsynced re-put
  inserted a duplicate row. Now buildInsert binds seq when the value carries
  one (re-put / explicit-seq add) and decodeRow injects the PK back from a
  `__pk` alias on every read, matching IDB's keyPath+autoIncrement store.
  ON CONFLICT no longer overwrites the PK column.
- W2 (atomicity scope): transaction() discarded its `stores` argument, so the
  OpLogTx could touch any store — silently passing where IDB throws. The tx
  now enforces the declared scope (and inherits the tx mode for iterate).
- W3 (readonly contract): a delete action under a readonly iterate executed
  the DELETE outside any transaction; it now rejects with ReadOnlyError,
  matching IDB.

Also makes the in-memory FakeSqliteDb faithfully model AUTOINCREMENT (honor
an explicit seq, upsert on PK conflict, advance the high-water mark) so the
spec actually catches C1-class bugs — verified: reverting the seq fix makes
the new "updates in place" test fail with the real UNIQUE violation.

Verified: 27 SQLite adapter specs (4 new) green; checkFile + tsc clean.

* refactor(op-log): strip the autoinc keyPath prefix via extractPath idiom

Follow-up review nit: decodeRow stripped the `$.` from the autoinc keyPath
with slice(2); use the same `.replace(/^\$\./, '')` idiom as extractPath for
consistency, and note that the autoinc keyPath is a top-level field. No
behavior change (keyJsonPath is always `$.seq` for the only autoinc store).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-01 18:16:16 +02:00
JongoDB
91a407346d
docs(caldav): mark VEVENT two-way as shipped via the bundled plugin (#7879)
The CalDAV VEVENT expansion design doc still says 'Status: Planned', but two-way
VEVENT sync + task->event time-blocking shipped via the bundled CalDAV Events
plugin (packages/plugin-dev/caldav-calendar-provider), not by extending the core
CalDAV provider as the doc proposed. The core provider remains VTODO-only.
Update the status banner + add an as-built note; retain the historical design.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:31:45 +02:00
Johannes Millan
dc3523b1c7
ci: auto-submit iOS and macOS App Store builds for review (#7857)
* ci: auto-submit iOS and macOS App Store builds for review

The iOS and Mac App Store workflows previously stopped after uploading the
build to App Store Connect via altool, leaving version creation, "What's New"
and submission as manual steps.

Add fastlane lanes (ios/mac release) that upload the prebuilt .ipa / MAS .pkg
using App Store Connect API key auth (reusing the existing notarization key
secrets), push release notes derived from build/release-notes.md, wait for
processing, submit for review and flag automatic release on approval.

Final version tags submit for review; pre-release tags (RC/beta/alpha) and
manual runs only upload the build. Listing metadata and screenshots remain
curated by hand in App Store Connect.

https://claude.ai/code/session_014c1W1mX7tfvFzpZ6wyWzsJ

* ci: wire iOS and Mac Store workflows to fastlane submit lane

The previous commit added the fastlane lanes but the workflow edits were not
applied. Replace the altool validate/upload steps in the iOS and Mac App Store
workflows with the fastlane submit lane: install fastlane, generate the App
Store "What's New" notes and run `fastlane <platform> release` with App Store
Connect API key auth.

Also extend the Mac workflow's harden-runner egress allowlist with the
rubygems and App Store Connect endpoints used by fastlane.

https://claude.ai/code/session_014c1W1mX7tfvFzpZ6wyWzsJ

* fix(ci): harden Apple App Store auto-submission after review

Address review findings on the iOS/macOS App Store automation:

- Tag gating: submit only when the tag has no "-" (final semver), instead of
  denylisting RC/beta/alpha. GitHub Actions contains() is case-sensitive and
  the repo's RC tags are mostly lowercase (-rc.N), so the old guard would have
  auto-submitted release candidates to production review.
- Fastfile: set skip_metadata so deliver no longer reads back and re-uploads
  curated listing fields; push only "What's New" via an inline release_notes
  hash. Warn against verbose mode (can dump the API key).
- Gemfile.lock: add arm64-darwin/x86_64-darwin platforms so bundle install
  works on the macOS runners.
- Workflows: install deps via pinned ruby/setup-ruby (bundler cache), and
  resolve the artifact path with a strict nullglob check (exactly one match)
  instead of ls | head.
- release-notes script: tighten emphasis regexes so stray * / _ (globs,
  snake_case) survive, anchor footer patterns so legitimate "download" lines
  are not dropped, and drop a duplicate mkdir.
- Docs: document the hyphen-based gate, single-use build numbers, automatic
  release behavior and inline validation.

https://claude.ai/code/session_014c1W1mX7tfvFzpZ6wyWzsJ

* fix(ci): correct deliver metadata + setup-ruby version (second review pass)

Two bugs introduced by the previous review-fix commit, both confirmed against
upstream source:

- Fastfile: skip_metadata: true makes deliver's upload_metadata return early
  (verified in fastlane 2.225.0 deliver/lib/deliver/upload_metadata.rb), so the
  "What's New" notes were never uploaded. Revert to metadata_path pointing at a
  dir that contains only <locale>/release_notes.txt; load_from_filesystem reads
  only that file (next unless File.exist?) with no remote read-back, so other
  listing fields stay untouched. Removed the now-unused inline release_notes
  helper.
- Workflows: ruby/setup-ruby throws when ruby-version is unset and no
  .ruby-version file exists (it does not fall back to system Ruby). Pin
  ruby-version: '3.3' in both workflows.

Docs updated to match the corrected metadata approach.

https://claude.ai/code/session_014c1W1mX7tfvFzpZ6wyWzsJ

* fix(ci): remove invalid wait_for_uploaded_build from deliver lanes

wait_for_uploaded_build is a pilot/upload_to_testflight option, not a deliver one. Passing it to upload_to_app_store makes fastlane raise on the unknown key and fail both iOS and macOS release lanes on every run. deliver already waits for the build to finish processing during submit (select_build -> wait_for_build_processing_to_be_complete), so no replacement is needed.

Also pin the ruby/setup-ruby comment to its resolved version (v1.310.0), and slice the App Store release notes by code point so a multi-byte character is never split at the 4000-char cap.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-01 11:42:16 +02:00
Johannes Millan
5fa1383bc5
fix(ios): reconcile time tracking and focus mode on app resume (#7837)
* fix(ios): reconcile time tracking and focus mode on app resume

iOS suspends the WKWebView WebContent process within seconds of
backgrounding, freezing JS timers so tracked time and the focus-mode
countdown stall. On resume we now credit the wall-clock gap (capped at
MOBILE_BACKGROUND_IDLE_CAP_MS) via a wake-up tick, reset the tracking
anchor, flush accumulated time, and nudge the focus reducer to recompute
elapsed. On pause we flush accumulated time and drain the op-log write
queue inside the existing BackgroundTask.beforeExit budget.

Effects are gated by IS_IOS_NATIVE and registered only on iOS; handler
bodies are exported as pure functions for unit coverage.

Refs #7824, #7826

* fix(ios): persist tracked time in background budget, drop duplicate flush

The flushOnPause$ effect duplicated the op-log drain that main.ts already
runs inside BackgroundTask.beforeExit, but executed outside that budget and
raced the main.ts listener — so accumulated time dispatched by the effect
could be lost on suspension. flushPendingWrites also has a 30s timeout, well
beyond the iOS budget, so the "drains within budget" premise was false.

Move the only needed pause work — flushAccumulatedTimeSpent() — into the
existing budgeted main.ts iOS handler before the drain, and remove the pause
effect, its OperationWriteFlushService dependency, and onPause$. onResume$
becomes a plain Subject since the iOS producer is a JS listener registered at
bootstrap (no cold-start replay race, unlike the native-fed Android shim).

Refs #7824, #7826

* test(ios): cover resume effect wiring via extracted factory

The reconcileOnResume$ effect field is false under Karma (IS_IOS_NATIVE
gate), so the onResume$ -> withLatestFrom(selectTimer) -> handler pipe was
untested. Extract the pipe into an exported reconcileOnResume factory and
add specs that drive it against a mock store, covering the selector read and
per-resume reconciliation that the pure-handler tests could not reach.

Refs #7824, #7826
2026-05-28 16:56:54 +02:00
Johannes Millan
f10d0e9d48
Android soft-keyboard: fix dark-theme white flash on resize (#7839)
* test(e2e): raise timeouts for two CI-flaky waits

Both timeouts repeatedly hit their limit on saturated scheduled runners
without indicating a real product regression:

- supersync.page.ts:781 — final sync-state icon waitFor went 10s → 30s.
  The race above it already consumed a 30s budget, so falling back to
  10s here is too tight when the runner is hot (e.g. SuperSync 5/6 in
  run 26514574130, "Client A can migrate multiple times" failure).
- repeat-task-day-change #6230 — post-midnight visibility went 30s → 60s.
  Even with the focus-event fallback added in 46ac873570, the 1s tick
  + debounce + sync chain can still exceed 30s under contention.

* fix(android): keep foreground services alive across task removal

The focus-mode and tracking foreground services overrode onTaskRemoved
to stop themselves when the app was swiped from recents, which defeats
the purpose of running them as foreground services. Remove the
overrides so the native countdown continues ticking and the notification
persists after task removal.

Refs #7818, #4513

* fix(theme): stabilize Android keyboard-height tracking

Per-event commits during the Android IME open animation sampled
partial keyboard amounts from `window.innerHeight - visualViewport.height`
(layout-viewport adjustResize and visual-viewport resize fire at slightly
different times), parking the global add-task bar mid-page. Debounce the
open path 200ms so only the final value lands; commit synchronously when
the value falls back to zero so the bar drops the moment the IME is gone.

* fix(theme): prevent white flash on Android keyboard resize

The Android WebView surface defaulted to white, so adjustResize keyboard
animations briefly exposed it before the page repainted at the new size —
jarring in dark theme. Paint the surface in the theme background: a
values/values-night color resource provides the cold-start default, and a
new NavigationBar.setWebViewBackgroundColor push keeps it in sync on live
theme switches (the activity is not recreated since uiMode is in
configChanges).

Also promote the full-viewport gradient backdrop to its own compositor
layer as a first-pass mitigation for resize choppiness, pending deeper work.

Not yet verified on-device.

* docs(android): plan to smooth soft-keyboard resize jank

Research + multi-reviewed plan for the remaining keyboard-resize choppiness
(the white-flash half is already fixed in 80b08f0e96). Root cause: adjustResize
resizes the WebView window per frame (WebView is excluded from Chrome 108's
visual-viewport fix). Recommends a KISS core — flip only CapacitorMainActivity
to adjustNothing + reuse the existing visualViewport/--keyboard-height model and
scroll-into-view — gated behind a baseline trace, with VirtualKeyboard API and
CSS containment kept as contingencies behind proven need.

* perf(theme): dedupe Android keyboard-visibility emissions

The native OnGlobalLayoutListener pushes isKeyboardShown$ on every layout pass
(every frame of the IME slide), so the subscriber rewrote <body> classes and
re-triggered change detection each frame. distinctUntilChanged collapses it to
actual show/hide transitions.

* docs(android): correct keyboard-resize plan for min-Chrome-107

Implementation review found MIN_CHROMIUM_VERSION=107, but WebView only
auto-resizes the visual viewport for the IME at ~Chrome 139. So a static
adjustNothing flip would leave Chrome 107-138 with no keyboard-height signal
(inputs silently covered). Corrected the plan: VirtualKeyboard API is required
(not optional), the switch must be runtime-gated via a native setSoftInputMode
method keeping adjustResize as the fallback, and the cheap containment route is
now Phase 1 (try first) with the bigger flip as Phase 2. distinctUntilChanged
win shipped (f486496b7b).

* style(theme): drop no-op keyboard backdrop compositing

The will-change/backface-visibility on body::before was added to reduce
keyboard-resize choppiness, but review showed it's a no-op: the backdrop
resizes every frame so it re-rasterizes regardless of layer promotion, while
the hint allocates an always-on compositor layer on every platform. The
white-flash fix (WebView background color) is unaffected.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-28 16:56:21 +02:00
Federico Simonetta
9a7a86c8ec
Feature plugins app state (#7803)
* chore(plugin-api): normalize index exports

* feat(plugin-api): expose app state types and getAppState

* feat(plugins): expose getAppState through PluginAPI

* feat(plugins): expose getAppState in plugin-bridge

* fix(plugins): restore tag selector import

* test(plugins): cover PluginAPI.getAppState

* fix(plugins): expose getAppState for iframe PluginAPI

* docs(plugin-api): add getAppState permission + example

* docs(plugins): concise getAppState entry

* fix(plugins): redact credentials from getAppState snapshot

`getAppState` previously returned `globalConfig` and `projects[]` verbatim,
exposing per-installation secrets to every loaded plugin. Strip the three
known credential surfaces before returning:

- `globalConfig.sync` — WebDAV/Nextcloud passwords, SuperSync access tokens,
  encryption keys
- `globalConfig.misc.unsplashApiKey` — user-supplied API key
- per-project `issueIntegrationCfgs` — Jira/CalDAV passwords, GitLab/Redmine
  tokens, OpenProject/Trello/Linear keys

Add a security-regression spec that seeds sentinel credential strings into
each surface and asserts none appear in `JSON.stringify(snapshot)`.

---------

Co-authored-by: 00sapo <00sapo@noreply.codeberg.org>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-27 17:02:29 +02:00
Johannes Millan
6d81dde043
feat(plugins): adopt PERSISTED_DATA_CHANGED in document-mode (#7752) (#7812)
* feat(plugins): adopt PERSISTED_DATA_CHANGED in document-mode (#7752)

Document-mode's iframe editor went stale when another device edited the
same context's doc — the editor kept typing on in-memory `storedState`
and the next throttled save merged-on-stale-base, partially clobbering
the remote edit. Background's `enabledIds` set had the same gap for
remote toggles.

Adopts the host-side `PERSISTED_DATA_CHANGED` hook (shipped in #7805,
made multi-handler-safe in #7811) in both surfaces:

- `background.ts` — on fire: re-read `enabledIds`, diff, and call
  `showInWorkContext`/`closeWorkContextView` only when the active
  context's membership flipped. Idempotent on no-op re-fires; the
  hook also fires for `doc:` writes which background ignores via the
  set-unchanged short-circuit.

- `ui/editor.ts` — on fire: load the current ctx's raw `doc:` bytes via
  the new `loadContextDoc` `{ raw, parsed }` shape, byte-compare to
  `lastSeenRemoteData`. Equal → noop (self-echo or another key's fire).
  Different → show a one-button "reload" banner with a distinct
  `doc-banner--remote-update` modifier class. Clicking Reload re-runs
  `setActiveContext` which already re-reads + setContent's.
  `isDocCorrupt === true` short-circuits to a direct reload (the
  corruption banner already promises saved data is untouched).

The load-bearing piece is `lastSeenRemoteData` — captured as the raw
`loadSyncedData()` string on both load (`setActiveContext`) and write
(`flushSave` / `flushSaveSync`). The editor's own `JSON.stringify(getJSON())`
is NOT byte-stable across the load path because `prepareStoredDoc`
reshapes chip content against the local task cache, so comparing
against editor output would mis-flag every load as remote. Raw-against-raw
keeps the host's deterministic encoding the only thing that matters.

Acceptance criteria from #7752: all met except the E2E append, which
needs iframe-level state injection that the existing spec helpers
don't cover; covered by manual verification + unit specs instead.

What this doesn't fix:
- No cross-context notification (editor catches up silently on switch).
- Same-context concurrent edits still resolve whole-doc LWW.
- No silent swap or selection preservation.
- No "Keep mine" force-flush — dismiss-and-keep-typing already wins LWW.

Tests: 84/84 plugin specs pass (+6 background hook membership branches,
+1 corrupt-entry raw-bytes case, +1 saveContextDoc-returns-raw case).
Plan v6 changelog documents the v5/v6 multi-review iterations and the
cuts (pre-reload backup, pure-fn file, coalesce timer, "Keep mine").

* refactor(plugins): apply doc-mode review findings (#7752)

Multi-review of the prior commit surfaced two real and one likely-real
bugs in the reconciler and a few cleanup wins. Apply all that survived
verification.

W1 — wrap getActiveWorkContext in try/catch. The prior handler only
caught loadEnabledCtxIds rejections; a getActiveWorkContext throw
would escape via the void onPersistedDataChanged() registration as an
unhandled rejection and leave enabledIds stale across every subsequent
fire.

W2 — snapshot enabledIds at entry. The prior handler read the closure
variable across multiple awaits before assigning at the end; two
interleaved fires could mis-compute wasActiveEnabled and drop a
close/show call. Pass-by-parameter to reconcileEnabledIds eliminates
the in-function race; concurrent fires now race only on the final
caller assignment, which is at least eventual-consistent.

W3 — extract reconcileEnabledIds to its own file and import the real
function from the spec (no more local copy). reconcile-enabled.ts
sidesteps the testability problem at its root: background.ts has
top-level PluginAPI side-effects (registerWorkContextHeaderButton,
registerHook) that crash in node, so the spec can't import from there.
The new file holds only the pure reconciler + try/catch hardening.

W4 — extract serializeContextDoc(doc) helper. flushSave and
flushSaveSync now produce byte-identical output via the same function,
so a future encoding change can't silently desync the sync path from
the async one.

W5 — fix the inline comment on showRemoteUpdateBanner re-entrancy to
match what the code actually does (early-return because text is
invariant, not "replace text in place" as the v6 plan implied).

S1 — rename lastSeenRemoteData → lastSeenDocBytes. The variable also
holds local-write bytes, not only "remote" ones; the new name follows
the existing lastSeenTaskIds convention.

S2 — drop the try/catch around loadSyncedData in
onRemotePersistedDataChanged. The hook dispatcher already catches +
logs handler rejections (PluginHooksService._invokeWithTimeout), and
loadContextDoc elsewhere in this file follows the no-wrap convention.

S3 — add primitive-JSON case to persistence.spec.ts. loadContextDoc's
new {raw, parsed} shape silently forwards parsed=123/null/"hi" to
callers; the editor's truthy guard is the safety net, but the
persistence contract is now locked.

S5 — explain why isDocCorrupt short-circuits to a direct reload
(auto-recovery without user click; alternative would leave the user
stuck on the corruption banner even when the remote already fixed
the entry).

Tests: 86/86 plugin specs pass (+7 reconciler error/membership cases,
+1 persistence primitive-JSON). Bundle redeployed.

* docs(plugins): update stale lastSeenRemoteData refs in JSDoc/comments

Two comments referenced the pre-rename variable name.
2026-05-27 11:46:50 +02:00
Johannes Millan
3c69aa961e
feat(plugins): fire PERSISTED_DATA_CHANGED hook on persisted-data changes (#7805)
* feat(plugins): fire PERSISTED_DATA_CHANGED hook on persisted-data changes

Wires the dead PluginHooks.PERSISTED_DATA_UPDATE enum into a fired hook,
renamed PERSISTED_DATA_CHANGED. Plugins are notified when their persisted
data changes for any reason after the host's initial boot load — local
writes, remote incremental sync via bulkApplyOperations, and post-boot
wholesale loadAllData paths (SYNC_IMPORT / BACKUP_IMPORT / validation
repair / recovery).

Selector-based effect on selectPluginUserDataFeatureState, gated on
SyncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$ so the
boot-time state seeds the pairwise baseline. Differ compares prev/next
by === on the encoded data blob (never decoded). Stage A composite
entityIds (pluginId:key) are normalized to owner pluginId and deduped
so a plugin with N keyed entries changing in one emission fires exactly
once. Per-pluginId dispatch via new PluginHooksService.dispatchHookToPlugin.

Effect is { dispatch: false } and creates no ops, so no sync-window
guard is needed — and adding one (skipDuringSyncWindow) would silently
suppress the very remote-sync deliveries the hook is designed to catch.

Closes #7754. Follow-up: doc-mode adoption tracked in #7752.

* test(plugins): tighten PERSISTED_DATA_CHANGED spec + docs

Multi-review pass 2 surfaced that the 5s timeout spec asserted only that
the dispatcher resolved — it would have silently passed if the timeout
race were removed entirely. Spy on PluginLog.err and assert the timeout
message actually reached the catch branch.

Also:
- Add `:` guard to PluginHooksService.registerHookHandler so the
  persistence-key grammar is enforced at both the persistence and
  hooks-registry endpoints (defense-in-depth; composeId already throws
  at the bridge).
- Add async-rejected-promise spec to cover the Promise.race branch that
  sync-throw didn't exercise.
- Carry the PERSISTED_DATA_CHANGED contract paragraph into
  docs/plugin-development.md and docs/wiki/3.01-API.md (previously only
  in packages/plugin-api/README.md).
- Clarify loadSyncedData(key?) in the README for keyed plugins.
2026-05-26 23:41:01 +02:00
Johannes Millan
8f274582e2
feat(plugins): Stage A keyed persistence with LWW (#7749) (#7763)
* feat(plugins): keyed persistence API for per-context LWW (Stage A Phase 1+3)

Add an optional `key` argument to `persistDataSynced` / `loadSyncedData`,
composed at the bridge transport boundary into `pluginId:key` entity ids.
Distinct keys now produce distinct ops that LWW-resolve per-entity,
enabling document-mode-style plugins to avoid cross-context blob
overwrites without changing existing keyless callers.

Phase 3: `removePluginUserData(pluginId)` now sweeps the full prefix
(legacy entry + every keyed entry), dispatching one delete per match
with the rule-6 setTimeout(0) trailer so remote replicas don't keep
keyed entries after uninstall. The reducer-only "smart prefix match"
shortcut is wrong (one op for the prefix only, remote keyed entries
leak) — see docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md
Phase 3.

Phase 4 (document-mode plugin-side migration of the legacy single-blob
entry) is left as a separate follow-up so the host change can be
reviewed in isolation.

Issue #7749

* feat(document-mode): migrate to keyed persistence (Stage A Phase 4)

Move from one synced blob under the bare plugin id to per-entity keyed
entries:
 - meta            — { enabledCtxIds: string[] }, owned by background.ts
 - doc:${ctxId}    — one entry per context, owned by the editor iframe
 - __meta__        — migration stamp

Each entry has its own LWW timestamp on the host, so a concurrent edit
in project A on Device 1 and project B on Device 2 no longer
whole-blob-collide.

The migration runs idempotently from both background.ts and editor.ts
(stamp-guarded), splits the legacy single blob into keyed entries, then
tombstones the legacy entry with an empty payload — giving LWW a
winning side against any offline device that still writes the old
shape.

flushSave / flushSaveSync no longer need to read+merge sibling state,
since each context's entry stands alone. The future-version blob guard
(isStorageUnreadable) is dropped — it referenced the wrapping blob's
version, which no longer exists; per-doc corruption still falls back
via isDocCorrupt.

Issue #7749

* fix(plugins): tighten Stage A keyspace at the boundaries

Multi-review surfaced three small gaps in the keyed-persistence rollout:

- The synchronous composeId throw covers the bridge's iframe and
  direct-API entry points, but three in-process callers
  (plugin-config.service, plugin.service, plugin-config-dialog) bypass
  the bridge and route directly into the persistence service. A
  user-installed plugin with `id: "evil:plugin"` passed manifest
  validation and would have collided with the legitimate `evil`
  plugin's keyed namespace — `removePluginUserData('evil')` would have
  over-matched the sweep. Reject the colon at install time in
  `validatePluginManifest`; keep the bridge throw as defense-in-depth.

- The new `key` arg at the bridge was typia-asserted on `data` but
  unchecked itself. A compromised iframe could pass a multi-megabyte
  string or a non-string value via postMessage. `data` is capped at
  1 MB, but the entity id composed from `key` would be stored verbatim
  in NgRx state, IndexedDB, the op-log, and on the sync wire — bypassing
  the data cap. Add `assertPluginPersistenceKey` with a 256-char cap.

- `_loadPersistedData` silently returned `null` when composeId threw,
  while `_persistDataSynced` rethrew. The asymmetry made a malformed
  pluginId look like "no data yet" on the load side, indistinguishable
  from a fresh install. Hoist composeId + key validation out of the
  load try/catch so it throws symmetrically.

Issue #7749

* fix(plugins): lower per-write cap to 256 KB

The pre-Stage-A 1 MB cap was sized for the old single-blob shape, where
one entry held every context's data. With the keyed split, each entity
gets its own write budget — 1 MB per write is wildly over-provisioned
for the realistic upper bound of plugin payloads (heavy document-mode
docs ~30–100 KB, configs and automations KB-scale).

256 KB keeps 2–5× headroom over realistic payloads while bounding the
per-plugin storage growth more tightly.

Document-mode's migration loop now skips oversized legacy docs instead
of aborting the whole run: a user whose legacy blob holds one ~500 KB
doc (legal under the old cap) keeps the other contexts migrated and
the original bytes preserved in the legacy entry. The success stamp
stays at migrated:0 in that case so a future build (or pruning of the
doc) can complete the migration without data loss.

Issue #7749

* test(plugins): e2e migration of legacy single-blob to keyed entries

The migration logic in document-mode is unit-tested against a mock
PluginAPI, which can't catch real-iframe quirks (postMessage handling
of undefined second args, commit-chain timing under the host's
per-entity rate limiter, hydration ordering against the op-log). Add
two end-to-end scenarios:

- Fresh install: enable the plugin, verify the __meta__ stamp lands
  at migrated:1 (the migration's final write — observing it implies
  every earlier step completed).
- Legacy blob: seed a pre-Stage-A single-blob entry via the e2e helper
  store, enable the plugin, verify the legacy entry is tombstoned,
  meta carries the enabledCtxIds, and each doc landed under its own
  doc:${ctxId} key.

Issue #7749

* chore(plugins): drop dead code and review-driven polish

Four small follow-ups from the multi-review pass:

- Don't log the plugin-supplied `key` value. Plugins may use user
  content (search queries, doc titles) as keys; the log history is
  exportable. Log `keyLen` instead, per CLAUDE.md rule 9.
- Delete `detectStaleLegacyWrite` and its 3 specs. Exported and
  fully tested, but zero non-test callers — banner UI is forbidden
  by project convention for transient-only messaging. If the need
  resurfaces, the implementation is four lines.
- Drop the `attemptedAt` field from `MigrationStamp`. It was written
  but never read; the success stamp is the only re-entry gate, and
  the resume path is just "re-run the loop" — re-writes are content-
  idempotent. Saves one rate-limited write per fresh migration.
- Update `docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md`
  with an implementation-status table referencing the shipping
  commits, so future readers don't have to dig through git.

Issue #7749
2026-05-23 22:23:17 +02:00
Johannes Millan
196e50b906
test: strengthen unit test assertions and revive disabled plugin specs (#7755)
* chore(plugins): re-bundle document-mode and document Stage A path

Reverts the unbundling from b0cae69ffe. Stage 0 (gzip + throttle, shipped
in 84625be849) handles size; document-mode remains opt-in per context, so
cross-context conflict risk is bounded. Stage A (keyed plugin-persistence
API, issue #7749) is the documented future path for closing the LWW gap
on different-context concurrent edits — picked up when conflicts are
observed in practice.

Design sketch with multi-reviewed phasing lives in
docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md. Predecessor
plan's "Future work" section now links to it.

* test(plugins): cover document-mode bundled load and PLUGIN_USER_DATA LWW

Follow-ups from the multi-review of 199e816479's re-bundling decision:

- E2E smoke test asserts document-mode appears in plugin management so
  a typo in BUNDLED_PLUGIN_PATHS fails loudly.
- Spec exercises PLUGIN_USER_DATA conflict resolution end-to-end, which
  previously relied on analogy to REMINDER (same array+null branch) but
  was never directly asserted after the migration off 'virtual'.
- Stage A plan risks: stale-editor-view gap surfaced by the review;
  PluginHooks.PERSISTED_DATA_UPDATE already exists in the API but is
  never dispatched host-side — wiring it is the path to a fix.
- background.ts: comment marks the known gap at the registerHook site.

* docs(plugins): plan for wiring PERSISTED_DATA_CHANGED hook

Designs the host-side wiring for the currently-dead
PluginHooks.PERSISTED_DATA_CHANGED so plugins can react to remote-driven
changes to their persisted data. Multi-reviewed twice; v4 trims scope to
host-only (no plugin adoption in this design) and preserves the
multi-review insights as seeds for the follow-up doc-mode adoption
tracked at issue #7752.

Implementation lands in a separate PR.

* test: strengthen unit test assertions and revive disabled plugin specs

Replace tautological assertions, setTimeout-without-expect patterns, and
placeholder `expect(true).toBe(true)` tests with real assertions across
~30 spec files. Revive 5 plugin spec files that were fully commented out
on master with live tests covering core behavior.

Production-side: extract pure helpers for testability:
- app.component: getBackgroundOverlayOpacity, getBackgroundImageBlur
- android-sync-bridge.effects: getSuperSyncCredentialBridgeCommand
- super-sync-server: export escapeHtml, SERVER_HELMET_CONFIG

Delete the always-skipped xdescribe placeholder
src/app/imex/sync/sync-fixes.spec.ts (412 LOC).
Rename operation-log-stress.spec.ts to .benchmark.ts to match its
header comment ("excluded from regular test runs").

No production behavior changes; no master commits reverted.
2026-05-23 18:31:53 +02:00
Johannes Millan
0c2420eeb3 feat(plugin): improve sync data size for plugins
- docs(plugins): remove delta-sync plan; track Stage A as GH issue
- fix(plugins): preserve teardown safety net + earlier base64 size gate
- fix(plugins): address second-round multi-review findings
- refactor(sync): drop unused isVirtualEntity host re-export
- fix(plugins): address multi-review findings in plugin persistence
- perf(plugins): delegate plugin-data codec to sync-core helpers, cap decompression
- docs(sync): mark delta-sync plan implementation status
- perf(plugins): gzip plugin user data at the persistence boundary
- perf(document-mode): raise save throttle and add focus-loss flush triggers
- fix(sync): resolve plugin-data LWW conflicts via array storagePattern
2026-05-23 16:36:04 +02:00
Johannes Millan
34af7b1036 feat(projects): polish archive flow per #7748
- Confirm dialog for archive (replaces snack+UNDO); navigation in
  work-context-menu now awaits confirmation.
- Archived-projects rows link to /project/<id>/tasks; project view
  shows an inline restore notice when the active project is archived.
- Unarchive snack split: when the project is still hidden from the
  menu, offer a "Show in menu" action; otherwise keep UNDO.
- Terminology: snack now reads "Project restored" to match the button.
- Tests: spec for archived-projects-page and project-task-page;
  defensive archive-filter coverage on selectAllTasksWithReminder /
  selectAllTasksWithDeadlineReminder / selectOverdueTasks; service
  spec covers confirm/cancel paths and the hidden-from-menu branch.
2026-05-23 16:36:04 +02:00
Alberto Avon
b945091b83
Archive project - Continuation of #7162 (#7639)
* feat(project): add archive project UI

Allow users to archive completed projects via the project context menu.
Archived projects are hidden from the sidebar and visibility menu, but
retained with all their tasks. They can be unarchived at any time from
the visibility menu (eye icon), where they appear below a divider with
an unarchive icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(project): add unit tests for archive/unarchive project feature

- projectReducer: verify archiveProject sets isArchived=true, unarchiveProject
  sets it back to false, and that other projects are not affected
- WorkContextMenuComponent: verify archiveProject() calls the service, shows
  a snack, navigates away when the archived project is currently active, and
  does not navigate otherwise

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(project): document archive/unarchive project feature

Add "Archiving Projects" section to the Project View wiki page explaining
how to archive a completed project and how to restore it via the visibility menu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(project): new archived projects page

* feat(project): improve archive UX with undo snack and hidden-menu warning
- Replace plain archive notification with icon + undo action so accidental archiving is recoverable without a confirmation dialog
- Add snack on unarchive; warn when project is still hidden from menu (isHiddenFromMenu=true) so users know why it won't appear in sidebar

* feat(reminder): suppress reminders for archived project tasks

* feat(tasks): hide archived project tasks from tags, today, and boards

* feat(tasks): hide archived project tasks from overdue tasks list

* fix(repeatTasks): exclude TaskRepeatCfg of archived projects when generating the next instance

* fix(tasks): hide Task and TaskRepeatCfg of archived project from the /scheduled-list page

This effectively hides the "Overlaps with another scheduled task" message if the other task belongs to an archived project.

* feat(docs): Add documentation for archived projects

* fix: revert changes to the it.json file

* feat: hide all task-repeat-cfg from planners and introduce a new task-repeat-cfg selector selectActiveTaskRepeatCfgs

* feat(tasks): add base selector for filtering tasks from active projects

* feat(ui): add count of archived project in project visibility menu and hide the archived projects page link when none exist

* feat(ui): improve accessibility for archived projects page by adding aria-labels and aria-hidden attributes

* perf(projects): use a sorted selector in archived projects page to avoid sorting during user's search

* refactor(tasks): derive selectAllUndoneTasksWithDueDay from the common shared selector

* refactor(tasks): introduced shared selectors to centralize active projects filtering

* fix: add mock selector for selectUndoneOverdueDeadlineTasks in plan view tests

* refactor(planner): use selectAllTasksInActiveProjects to exclude archived project tasks

Replaces direct task state access with the shared selectAllTasksInActiveProjects selector, which already filters archived project tasks. Fixes archived project tasks appearing in planner view.

Test mock stores updated to include tasks+projects slices (required by new selector chain). Removed "missing entity references" test: that guard now lives upstream in selectAllTasks.

* refactor(work-context): exclude archived project tasks from TAG/TODAY and SCHEDULE

selectActiveWorkContext and selectTodayTaskIds were using raw
taskState.entities, causing archived-project tasks to appear in tag boards and Today.

Fix by filtering entities in the selectors using selectArchivedProjectIds before calling computeOrderedTaskIdsForToday / computeOrderedTaskIdsForTag.

Apply the same logic to selectTimelineTasks and remove the
combineLatest workaround added in 9d6fe5e.

* fix(work-context): exclude archived project's tasks when selecting tasks in schedule page

* fix(tasks): exclude archived project tasks from unplanned deadline banner

* fix: fix tests after rebase onto master

* fix: stabilize selectArchivedProjectIds Set instance

Derive selectArchivedProjectIds from a base selectArrayOfArchivedProjectIds selector to avoid recreating the Set whenever unrelated project properties change

* perf(selectors): avoid unnecessary recomputations from archived project selectors

selectArchivedProjectIds was creating a new Set on every project-state update, causing unnecessary recomputation across downstream selectors even when archived projects did not change.

- Add selectArrayOfArchivedProjectIds as a stable intermediate selector
- Rebuild selectArchivedProjectIds only when archived IDs change
- Memoize active-project task entities instead of filtering inline
- Remove unused selectTaskFeatureState dependency from selectActiveWorkContext

* fix(project): prevent inbox project to be archived

* fix(task): add filters for non-archived projects and comment explaining why filter was not added in selectors

* fix: always call archive service before navigateByUrl when triggering archive action

* feat(ui): add undo action on project restored from archive snack

* fix: add guards against empty projectId in tasks

* fix(tasks): keep orphaned subtasks scheduled in selectLaterTodayTasksWithSubTasks

Restored selector to its original form, but reading tasks from selectAllTasksInActiveProjects instead of selectTaskFeatureState.

While categorizing tasks, group subtasks by their parentId in a single pass to avoid calling mapSubTasksToTask that requires TaskState.

* refactor(tasks): compose selectAllTasksWithoutHiddenProjects from selectAllTasksInActiveProjects + new selector for hidden projects

* test(tasks): restore coverage gaps from selector refactors

- selectAllUndoneTasksWithDueDay: add "should include subtasks with dueDay" (dropped when selectAllTasksWithDueDay was removed in 450855b)
- selectAllTasksDueToday: add "should handle missing entity references in planner days with empty task state gracefully" (replacing the taskState.ids variant removed in 9280b85 since upstream guard now owns that invariant)

* feat(tasks): extracted a new selector selectActiveTaskMap that returns a memoized map of active tasks indexed by id

* fix(tasks): removed unused selector

* refactor(projects): move archive/unarchive snacks inside project service and drop the UNARCHIVED_BUT_HIDDEN message

* refactor(projects): use only filtered sources in selectTimelineTasks

* refactor(tasks): rename selectActiveTaskMap into selectMapOfAllTasksInActiveProjects to better align with current naming conventions

* feat(a11y): add aria label to project archive elements

* chore: add comment to selectAllTasksInActiveProjects selector's fast path

* test: add expectation on operation order in archive project action

Navigation must happen after the ProjectService.archive() call

---------

Co-authored-by: Symon <peterbaikov12@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:24:14 +02:00
Johannes Millan
7a93265281 docs(sync): add document-mode sync data model and delta-sync plans
Two design docs for slimming the document-mode plugin's sync footprint: the sync-data-model plan covers the immediate fix (bare-atom chips) plus deferred per-context entities; the delta-sync plan analyses why true deltas need finer entity granularity or a commutative CRDT (Yjs) given the partially-ordered op-log.

Refs #7740.
2026-05-22 20:21:26 +02:00
Johannes Millan
713245e6f0 docs(sync): add SUP_OPS versionchange handler plan (#7735)
Rescopes #7735 from the original three-connection consolidation down to
its one genuine correctness fix: register versionchange handlers on the
OperationLogStoreService and ArchiveStoreService SUP_OPS connections so a
future schema bump cannot be stalled by a handler-less connection.

The connection consolidation is documented as not planned (no behavioral
benefit, net-additive LOC on a safety-critical path); the rejected
"OperationLogStoreService as connection owner" alternative is kept for
the record. Both decisions followed multi-agent review rounds.
2026-05-22 19:18:40 +02:00
Johannes Millan
50e2b53d90 Merge branch 'master' into feat/doc-mode4-2880bb 2026-05-22 18:05:03 +02:00
Johannes Millan
508998c6a1
Improve on sync (#7736)
* fix(android): restore share title derivation and dedupe shared tasks

Commit d32f7037a3 accidentally reverted the EXTRA_SUBJECT handling from
edb102534e, so the share handler stopped sending the page subject and
defaulted the title to the literal "Shared Content". The frontend's
subject -> title -> derived title chain then always fell through to that
placeholder, producing blank-looking shared tasks.

- Restore EXTRA_SUBJECT extraction; leave title/subject empty when absent
  so the frontend can derive a meaningful title from the URL or note.
- Ignore empty/blank shared text instead of creating a useless task.
- Skip handleIntent() on Activity recreation (config change) so the same
  share Intent isn't re-processed into a duplicate task.
- Extract buildTaskTitle/readableUrl as pure functions with unit tests
  and guard the effect against empty payloads.

* fix(snack): scale error/warning snack duration with message length

Long error messages (e.g. multi-sentence sync errors) were auto-dismissed
after a fixed 8s, too short to read. Error/warning snacks now stay visible
proportional to message length (~90ms/char), clamped to 10-30s.

* feat(tasks): skip undo snack when deleting a blank task

A sub task or parent task with an empty title and no data (notes, time,
estimate, attachments, issue link, reminder, repeat, scheduling,
deadline, non-blank sub tasks) no longer shows the undo-delete snack.

* fix(sync): include archive data in REPAIR operations

validateAndRepairCurrentState built the REPAIR op from the synchronous
getStateSnapshot(), which hardcodes empty archiveYoung/archiveOld
(archives live in IndexedDB, not NgRx state). The resulting REPAIR op
carried empty archives, so every other client that applied it
overwrote its archive with nothing — wiping archived tasks on all
devices except the one that ran the repair.

- Use getStateSnapshotAsync() so the REPAIR op carries real archives.
- Extend the empty-archive overwrite guard in
  ArchiveOperationHandler._handleLoadAllData() to also cover OpType.Repair
  (previously only SYNC_IMPORT/BACKUP_IMPORT), as defense in depth.

* test(sync): add archive REPAIR round-trip integration test

Wires the real StateSnapshotService, ArchiveDbAdapter, ArchiveStoreService
and ArchiveOperationHandler against real IndexedDB to verify archive data
survives the REPAIR-op round-trip:

- getStateSnapshotAsync() loads IndexedDB archives; getStateSnapshot() does not
- archive round-trips from client A's IndexedDB through a REPAIR op into a
  fresh client B's IndexedDB
- a REPAIR op carrying empty archives no longer wipes a client that has
  archive data (empty-archive guard regression)

* perf(sync): skip archive IndexedDB reads when post-sync state is valid

validateAndRepairCurrentState validated the full async snapshot (two
IndexedDB archive reads + structured-clone deserialization) on every
Checkpoint D, even when state was valid and no repair was needed. It now
validates the cheap synchronous snapshot first and only loads the async
snapshot (with archives) when a repair is actually required — the rare
path. The REPAIR op still carries archive data.

Also addresses multi-review follow-ups:
- archive-operation-handler: reword the empty-archive guard comment so it
  no longer over-promises reconciliation for REPAIR ops.
- archive-repair-roundtrip test: add isPersistent to the applied-op meta
  to match the real applier; scope the file docstring accurately.

* fix(task-repeat-cfg): schedule inbox task for today when made recurring

When an Inbox task (no dueDay) was made repeatable via the dialog with a
recurrence starting today, it stayed unscheduled. The TODAY-first-occurrence
branch of updateTaskAfterMakingItRepeatable$ derived currentDueDay from
task.created as a fallback, so a task created today looked already scheduled
and dueDay was never set.

Key the decision on task.dueDay directly. Skip timed tasks and tasks that
already have dueWithTime, since dueDay/dueWithTime are mutually exclusive and
timed scheduling is handled by addRepeatCfgToTaskUpdateTask$.

Closes #7725

* fix(tasks): correct monthly first/last-day recurrence anchoring

The "Every month on the first day" and "Every month on the last day"
quick settings scheduled the first task instance in the past. Both
presets produced a backdated startDate (1st of the current month;
hardcoded January 31), which getFirstRepeatOccurrence returns verbatim
for monthly recurrences.

- MONTHLY_FIRST_DAY now anchors startDate to the next 1st-of-month
  that is today or later.
- MONTHLY_LAST_DAY anchors startDate to the current month's last day
  and sets a new monthlyLastDay flag, so the occurrence engine clamps
  to month-end every month regardless of startDate's day-of-month.
- _normalizeMonthlyAnchor strips a stale monthlyLastDay flag when a
  config leaves the preset (CUSTOM mode has no control for it).

Closes #7726

* fix(task-repeat-cfg): re-anchor start date after instance deleted

When the user moved a repeat config's startDate earlier after deleting
its only live task instance, the stale lastTaskCreationDay anchor kept
suppressing every projected/created instance between the new startDate
and the old anchor.

rescheduleTaskOnRepeatCfgUpdate$ only re-anchored lastTaskCreationDay
when a live task instance existed (the #7423 fix) — it returned early
before the re-anchoring when there was none. Hoist the
isStartDateMovedEarlier detection above that early return: when no live
instance exists but startDate moved earlier, re-anchor to the day
before the new first occurrence so it and every following day is
created and projected fresh.

Closes #7724

* test(task-repeat-cfg): cover startDate re-anchor with no live instance

Add coverage for the #7724 fix beyond the effect unit test:

- Selector integration tests: feed a config re-anchored to the day
  before the new startDate through selectTaskRepeatCfgsForExactDay and
  assert it projects the new startDate and every following day, while
  still excluding the anchor day and earlier. Also documents that the
  stale anchor suppresses the gap days.
- E2E reproduction (recurring-move-start-date-earlier-no-instance):
  create a recurring task, delete its live instance, move startDate
  earlier via a transparent projection, and assert the new days appear
  in the planner. Verified to fail on pre-fix code.

* feat(sync): move clientId from pf into SUP_OPS for atomic rotation

Migrate the sync clientId out of the legacy `pf` IndexedDB database into
`SUP_OPS` (new `client_id` store, schema v6). The clientId write now joins
the atomic transaction in `runDestructiveStateReplacement`, so destructive
flows (clean-slate, backup-restore) rotate it atomically with
OPS/STATE_CACHE/VECTOR_CLOCK instead of a hand-rolled cross-database
two-phase commit.

- ClientIdService rewritten: SUP_OPS-backed via an independent connection,
  inline one-time pf->SUP_OPS migration, error-aware resolver. Read
  failures propagate (getOrGenerateClientId never mints a fresh id over a
  transient error — that would orphan the device's non-regenerable
  identity); loadClientId never throws.
- Delete withRotation, generateNewClientId and the CAS/rollback machinery.
- Extract pure generateClientId() + isValidClientIdFormat() into
  core/util/generate-client-id.ts.
- pf becomes a read-only, one-time migration source (never written/deleted).

Closes #7732

* fix(sync): dedup SUP_OPS connection open in ClientIdService

Address multi-agent review findings on the clientId migration:

- _getSupOpsDb() shares a single in-flight open via _supOpsDbPromise;
  concurrent cold-start callers previously each opened their own
  SUP_OPS connection, leaking all but the last.
- _putClientIdIfAbsent() collapsed to a single tx.done / exit point.
- db-upgrade.spec.ts: cover the v6 client_id store; the createObjectStore
  count assertions were stale and failing after the schema bump.
- operation-log-migration.service.ts: correct a misleading comment about
  the genesis-op clientId fallback.

* refactor(sync): align ClientIdService SUP_OPS open with in-house idiom

Re-review of the connection-leak fix recommended matching
OperationLogStoreService._ensureInit's pattern:

- _getSupOpsDb() clears the in-flight promise in .catch (failure only)
  instead of an unconditional finally — the resolved handle lives in
  _supOpsDb, so the promise field is pure in-flight coordination state.
- close/versionchange handlers now also null _supOpsDbPromise, so a
  stale (closed) connection is never re-handed-out.
- Add a regression test asserting _openSupOpsDb runs exactly once for
  concurrent cold-start callers.

* docs(sync): link the single-connection follow-up to #7735

Reference the tracked follow-up issue from the ClientIdService JSDoc
and the plan's out-of-scope section, so the deliberate trade-off (one
extra SUP_OPS connection) is traceable rather than forgotten.

* test(sync): update ClientIdService spies for getOrGenerateClientId

The op-log capture effect now resolves the clientId via
getOrGenerateClientId() (was loadClientId() ?? generateNewClientId()).
These two specs still mocked only loadClientId, so the effect called an
undefined method, captured no op, and 4 tests failed in the full suite.

- task-done-replay.integration.spec.ts
- operation-log-lock-reentry.regression.spec.ts

* test(sync): open SUP_OPS versionless in e2e read helpers

DB_VERSION was bumped 5->6; five e2e helpers still opened SUP_OPS at
the hardcoded old version 5 to read state after the app had already
upgraded it to v6, throwing VersionError. Open versionless instead —
matches the ~10 other e2e files that already do, and is future-proof
against the next schema bump.

- migration/legacy-data-migration.spec.ts (x2)
- sync/supersync-legacy-migration-sync.spec.ts
- sync/webdav-legacy-migration-sync.spec.ts
- recurring/invalid-clock-string-bug-7067.spec.ts
2026-05-22 17:49:25 +02:00
Johannes Millan
1c10ff67dd feat(document-mode): add TipTap-based document-mode plugin
A document-mode plugin under packages/plugin-dev/document-mode/ that
renders the active work context's tasks as an editable TipTap document.
Per-context doc state is persisted as a last-writer-wins JSON blob via
PluginAPI.persistDataSynced; task identity (title, done state, hierarchy)
stays in NgRx and is reached through PluginAPI.updateTask and the
ANY_TASK_UPDATE hook.

It registers a work-context header button and embeds itself into the
work-view embed slot added in the preceding commit. See
docs/plans/2026-05-21-document-mode-tiptap-plugin.md for the full design.

Squashed from the feat/doc-mode-v4 work; full per-step history is
preserved in the archive/doc-mode-v4-full-history branch and the
doc-mode-v4-pre-squash tag.
2026-05-22 17:33:22 +02:00
Johannes Millan
292f8b0e9a
refactor(sync): address multi-review findings on #7709 (#7712)
* docs(sync): plan for clean-slate upload data-loss prevention (#7709)

Revised after a 6-reviewer parallel pass. Splits into 4 PRs;
PR-A (atomicity + empty-snapshot dialog fix + pre-migration-backup
implementation + dead-code cleanup) closes the reported precondition
chain. Preflight gating deferred pending forensic log evidence.

* test(sync): reproduce #7709 precondition — interrupted createCleanSlate

Integration tests proving that an interrupt between
`clearAllOperations()` and `append()` on a device that has never reached
the compaction threshold leaves the device in
`isWhollyFreshClient===true && hasMeaningfulStoreData===true` — the
exact branch that throws `LocalDataConflictError(0, {})` and opens the
issue #7709 conflict dialog.

Also documents two adjacent findings:
- Interrupt at `setVectorClock` corrupts state_cache but does NOT trigger
  the #7709 chain (lastSeq stays > 0).
- A previously-compacted device is NOT vulnerable: state_cache survives
  the interrupt, so `isWhollyFreshClient` stays false.

These tests will need to be flipped (or deleted) when PR-A lands the
atomic destructive sequence: the bug post-condition should become
impossible.

* fix(sync): atomic destructive state replacement closes #7709 precondition

`CleanSlateService.createCleanSlate` and `BackupService.importComplete`
both ran a four-step destructive sequence as independent IndexedDB
transactions: `clearAllOperations` → `append(syncImportOp)` →
`setVectorClock` → `saveStateCache`. An interrupt between steps on a
device that had never reached COMPACTION_THRESHOLD = 500 ops left OPS
empty and state_cache still null — i.e. `isWhollyFreshClient()===true`
with meaningful in-memory data, the precondition that routes through
`operation-log-sync.service.ts:606` and throws
`LocalDataConflictError(0, {})` to open the conflict dialog. From there
a "Keep remote" click propagates the empty server snapshot to every
other device.

Replace both call sites with a new
`OperationLogStoreService.runDestructiveStateReplacement` helper that
uses snapshot-then-swap:
1. Stage the new state to STATE_CACHE under STATE_CACHE_STAGING_KEY
   (single-store write; large payload kept out of the destructive tx).
2. Round-trip verify the staged row.
3. One short multi-store readwrite transaction (OPS + STATE_CACHE +
   VECTOR_CLOCK) does small writes only: clear OPS, append the
   SYNC_IMPORT entry, write the vector clock, promote staging row to
   the singleton, delete the staging row.
4. On any error, explicitly `tx.abort()` so already-queued writes
   (notably the `clear()`) are rolled back. IDB does NOT auto-abort a
   transaction when JS throws between `await`ed requests — a mid-flight
   exception lets the tx commit partial state without the explicit
   abort. The integration test caught this before the helper landed.
5. `init()` sweeps any orphaned staging row left over from a previous
   interrupted destructive replacement.

`BackupService` additionally bails out cleanly if the pre-import backup
write failed — better to refuse the destructive import than overwrite
local state without a recovery point.

clean-slate-interrupt.integration.spec.ts inverts: it previously
demonstrated the corrupt post-condition; now it proves the device's
prior state is preserved through every injected failure mode (staging
read, destructive tx abort, never-compacted device).

* fix(sync): address review gaps in #7709 destructive replacement

- BackupService now throws (not silently returns) when the pre-import
  backup fails, so the caller doesn't fall through into a hybrid state
  (NgRx + archives + sync seqs replaced, op-log still old).
- Roll back clientId rotation in CleanSlate/Backup if the destructive
  tx aborts; pf and SUP_OPS now agree on the device's clientId after
  either success or failure.
- runDestructiveStateReplacement: patch the singleton's lastAppliedOpSeq
  to the assigned seq (was hardcoded 0), require snapshotEntityKeys from
  callers (was undefined → triggered an "old snapshot format" recompaction
  after every destructive replacement), drop the unused Promise<number>
  return, drop the dead-defensive verify-read, switch boot probe to
  getKey to skip multi-MB structured clone of an orphan staging row,
  and sanitize the raw Error in the reconciliation log.

* test(sync): cover clientId rollback-fails edge case (#7709)

When both the destructive call and the clientId rollback throw, the
caller must still see the ORIGINAL destructive failure (not the
rollback error), and the rollback failure must be logged at critical
level. Pins down behaviour the rollback path previously only reasoned
about.

* refactor(sync): tighten destructive replacement + log forensics (#7709)

Follow-up to round-2 multi-review findings.

- runDestructiveStateReplacement: write the new singleton row directly
  from opts.newState inside the destructive tx. Drops the in-tx
  stateCacheStore.get(STAGING_KEY) and the {...staged, ...} spread, which
  re-cloned the multi-MB payload while holding the multi-store tx open.
  Also restores the implicit "newState \!= null" precondition that the
  prior verify-read had enforced (the new "if (\!staged)" branch was
  weaker than the dropped check). Staging row remains as a
  crash-detection sentinel for boot-time reconciliation.
- _cacheLastSeq = 0 after the destructive write to match the pattern
  used by every other write site in the file (the previous "= seq" was
  unreadable because _appliedOpIdsCache=null forces a rebuild anyway).
- OpLog.critical on rollback failure now also carries originalError
  (name + message), so a forensic reader can correlate the pf/SUP_OPS
  divergence with the destructive failure that triggered it.
- Sanitise the pre-existing raw Error in [CleanSlate] pre-migration
  backup warn log to { name, message }, matching the rest of the
  module.
- Drop the brittle log-string regex in the rollback-fails tests;
  structural payload assertion (priorClientId + originalError) locks
  the contract without coupling to log wording.

* refactor(sync): delete unused PreMigrationBackupService placeholder (#7709)

The service was committed as a no-op placeholder before this branch
and never implemented. Its intended purpose — provide a recovery path
independent of IDB atomicity — is obsolete now that
runDestructiveStateReplacement makes the destructive sequence atomic
within SUP_OPS. The destructive tx either fully commits or fully rolls
back, so there is no partial-write state to recover from.

- Delete pre-migration-backup.service.ts and its placeholder spec.
- Remove the DI wiring, injected field, and dead try/catch from
  CleanSlateService.
- Rename PreMigrationReason → CleanSlateReason and define it locally;
  the type is internal to clean-slate.service.ts (single string-literal
  caller in encryption-password-change.service.ts).
- Remove the PreMigrationBackupService mock from the unit + integration
  specs and drop the "should continue if pre-migration backup fails"
  test (the code path it covered no longer exists).
- Update the plan doc: PR-A no longer ships pre-migration backup;
  Fix 3 and Fix 6 deferred to follow-up PRs.

* refactor(sync): address multi-review findings on #7709

Multi-review of the #7709 destructive-replacement branch surfaced one
privacy regression and several over-engineering smells that landed in
the earlier commits. This commit cleans them up.

- Drop priorClock from the "Starting clean slate" log payload — vector-
  clock keys are per-device clientIds and Log history is user-exportable.
  The branch's own plan explicitly forbids logging clock contents
  ("Security C2: size only, never the contents"); the implementation
  regressed against that and the test asserted the wrong shape. Spec
  now asserts the field must NOT appear.

- Drop the snapshot-then-swap staging row from runDestructiveStateReplacement.
  The staging row was supposed to keep the multi-MB payload outside the
  destructive multi-store tx, but the in-tx singleton put already wrote
  the same payload — so staging cost one extra full-state structured
  clone, one boot-time getKey round-trip per cold start, a catch-block
  cleanup, two integration tests, and ~30 lines of JSDoc, in exchange
  for a crash-detection sentinel nothing acts on. Single multi-store
  readwrite tx provides the same atomicity guarantee with none of the
  machinery. (Comment on the retained tx.abort() in the catch path
  corrected — it is unreachable in production but load-bearing for the
  spy-based fault-injection seam used by the interrupt integration test.)

- Extract ClientIdService.withRotation(logPrefix, fn) so the cross-DB
  clientId rotation+rollback dance is owned in one place. CleanSlateService
  and BackupService delegate; ~20 lines of byte-for-byte duplication
  removed. Rollback semantics (happy path, restore-on-failure, no-prior-id
  edge case, rollback-itself-fails) are now tested once against the helper
  instead of duplicated across the two consumer specs.

Net diff: 249 insertions, 401 deletions across 9 files.

Verified: full Karma suite passes in both Europe/Berlin and America/Los_Angeles
timezones (9444 / 9430 pre-existing pass; the 10 failures are in
immediate-upload.service.spec.ts and reproduce on master, unrelated to
this change). tsc --noEmit, ng lint, stylelint, and the custom lint
rules all clean. Public APIs of CleanSlateService and BackupService
unchanged; their three production callers (encryption-password-change,
user-profile import x2) require no updates.

Closes the post-implementation review for #7709.

* Fix destructive import race conditions

* refactor(sync): address round-3 multi-review findings on #7709

Six-reviewer parallel pass on the destructive-replacement branch surfaced
one privacy regression, one awkward control-flow pattern, and a stale
plan-doc section. This commit clears them.

- LockService.request: generic <T> return type. The Web Locks API and
  the fallback mutex both naturally return the callback's value;
  Promise<void> was just too narrow. Lets CleanSlateService.createCleanSlate()
  drop the `let result: ... | null = null` + null-check + cast + unreachable
  "completed without a replacement result" throw and use a single
  `const { syncImportId } = await lockService.request(...)`. Generic mock
  signatures threaded through 9 spec files (19 callFake sites).

- ClientIdService: stop logging plaintext clientIds. Per CLAUDE.md sync
  rule 9 (log history is user-exportable) the plan's own Fix-4 contract
  says "never log vector-clock contents; size only" — clientIds are the
  keyspace of the vector clock, so the same rule applies. Four log sites
  (loadClientId, generateNewClientId, persistClientId, invalid-format
  critical log) now omit the id; CleanSlateService logs a 3-char suffix
  where correlation is useful but the full value is not.

- BackupService: drop `message` from the pre-import-backup failure log.
  Matches the `name`-only pattern used by ClientIdService.withRotation
  rollback logging; future validator/IDB error types could otherwise
  interpolate user content into Error.message.

- runDestructiveStateReplacement: JSDoc-document the payload duplication
  trade-off (full state lives in OPS for the uploader and in STATE_CACHE
  for `isWhollyFreshClient`; eliminating either is unsafe — STATE_CACHE
  can be advanced past the SYNC_IMPORT op's seq by compaction).

- Plan doc: replace the snapshot-then-swap "Fix 2 Implementation"
  section with a description of the single multi-store readwrite tx
  that actually shipped. The plan's load-bearing premise ("every
  existing db.transaction() call is single-store") was wrong:
  appendWithVectorClockUpdate already runs a 2-store readwrite tx on
  every action. Revision-history bullet adjusted to record both the
  initial switch and the revert.

Verified: 788 tests across the touched sync surface pass
(clean-slate / backup / client-id / lock / write-flush / compaction /
upload / download / remote-ops / capture-effects / store / repair /
interrupt-integration / task-done-replay / migration-handling /
focus-mode-reducer / bug-7707 / focus-mode-effects / operation-log-sync /
superseded-operation-resolver). checkFile clean on every modified .ts file.

* test(sync): interleave-race coverage + boot-time partial-write detector (#7709)

Two additions raising end-to-end confidence on the #7709 fix:

1. Interleave race test (operation-log.effects.spec.ts):
   Asserts that a queued op acquiring the operation-log lock AFTER a
   concurrent destructive replacement reads the POST-rotation clientId.
   The existing test pinned the call ORDER (lock → clientId → append);
   this test pins the SEMANTICS: by the time the queued op's callback
   runs inside the lock, ClientIdService reflects the rotation, so the
   appended op carries 'newClient', not 'oldClient'.

2. Boot-time state_cache consistency check (operation-log-store.service.ts):
   Fire-and-forget on init: if state_cache.lastAppliedOpSeq references
   an op that doesn't exist in OPS, log a critical with counts-only
   forensics (referencedSeq, opsCount, vector-clock sizes). The atomic
   runDestructiveStateReplacement makes the invariant
   "state_cache.lastAppliedOpSeq always points to a real OPS entry"
   hold for any future write path that uses the helper. This check
   surfaces violations from either (a) pre-#7709 partial-state
   recoveries still on disk after upgrade, or (b) any future code path
   that bypasses the helper.

   Observability only — wrapped in try/catch, never blocks
   initialization. Counts-only payload (no entity IDs, no vector-clock
   contents) per CLAUDE.md sync rule 9.

Both changes verified: 34 effects-spec tests + 166 store-spec tests
pass. Full Karma run (9477 tests) has 10 pre-existing failures in
immediate-upload.service.spec.ts that reproduce on upstream/master
HEAD — unrelated to this branch.

* refactor(sync): drop boot consistency detector, derive replacement state from the op

Follow-up addressing multi-review findings on the #7709 atomicity work:

- Remove _verifyStateCacheConsistencyOnBoot. The atomicity fix makes the
  partial-write signature unreachable via runDestructiveStateReplacement,
  and "OPS empty + state_cache present" is also the valid state left by a
  full-log compaction, so the two are indistinguishable at boot and a
  critical log there is a false alarm. Forensic logging is deferred to a
  later PR per the design doc's staged plan.
- runDestructiveStateReplacement now derives newState / newVectorClock /
  schemaVersion from syncImportOp instead of taking them as separate opts.
  Nothing enforced that they agreed; a divergent value would silently
  desync OPS from state_cache, the exact bug class this work prevents.
- Restore the null-tolerant archive guard (was undefined-only) to preserve
  the prior defensive handling of backups with null archive fields.
2026-05-22 15:25:42 +02:00
Florian Bachmann
f3a2621ff3
Allows to use self-hosted Jira instance without web extension (#7628)
* feat(JiraIssueProvider): allow to use self-hosted Jira without web extension

* test(JiraIssueProvider): allow to use self-hosted Jira without web extension

* fix(JiraIssueProvider): fixes problems with fetch error handling

* fix(JiraIssueProvider): fixes problems with fetch error handling

* feat(JiraIssueProv): timeout no longer trips block-access; snack suppressed when block banner shows

* refactor(JiraIssueProvider): minor cleanups on direct-fetch path

- early-return direct-fetch in _sendRequestToExecutor$ so the
  promise / _requestsLog plumbing is only set up for the path
  that actually consumes it
- show global progress bar for direct-fetch requests (countUp /
  finalize countDown) so web users get the same UX as the
  extension path
- match wiki label to the actual i18n string in en.json
  ("Fetch Jira directly from the browser (bypass extension)")

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-21 12:45:26 +02:00
Johannes Millan
cdfbf640cb fix(backups): stop automatic backups when disabled 2026-05-20 17:24:34 +02:00
Het Savani
fab5d15cfa
feat(focus-mode): auto-start break after manual session completion (#7681)
* feat(focus-mode): auto-start break after manual session completion

* refactor(focus-mode): clean up manual break flow and update overtime semantics

* refactor(focus-mode): revert unintended Flowtime auto-break behavior

* docs(focus-mode): restore offerFlowtimeBreakOnSessionEnd$ rationale comment

The v3 revert removed the comment block documenting why this effect
listens to endFlowtimeSession rather than pauseFocusSession (which is
fired by sync-stop, idle, and the regular pause button). Restoring it
to match master.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-20 15:47:06 +02:00
johannesjo
64c622fab1 feat(tasks): copy focused task title with Ctrl/Cmd+C
Hardens the shortcut: blocks alt/shift modifiers, skips when the
event target is an input/textarea/contenteditable or text is
selected, and guards against the focus-recovery path copying a
stale title. Matches on `ev.code === 'KeyC'` so the shortcut still
fires on non-Latin layouts, mirroring the browser's native copy.
2026-05-19 22:29:41 +02:00
Johannes Millan
74ce49428f fix(sync): support separate Nextcloud login name
Refs #7617
2026-05-18 13:43:23 +02:00
Johannes Millan
eb381c187f fix(plugins): allow iframe-only plugin installs 2026-05-18 13:43:22 +02:00
Johannes Millan
e419fd6da5 docs(supersync): design for generic CONCURRENTLY migration recovery 2026-05-15 21:29:34 +02:00
Johannes Millan
b8be00d526 refactor(sync): decompose SuperSync server giants into cohesive modules
Split the three oversized SuperSync-server files into focused,
single-responsibility modules behind thin SyncService / SnapshotService
facades. Behavior, public API, HTTP/wire contract and DB schema are
unchanged (verbatim moves).

- sync.service.ts 2322 -> ~660, sync.routes.ts 1475 -> ~445,
  snapshot.service.ts 1215 -> ~390 LOC
- src/sync/op-replay.ts: pure replay engine (no Prisma)
- src/sync/conflict.ts: conflict detection + resolution
- src/sync/sync.routes.payload.ts / .quota.ts: HTTP helper modules
- src/sync/sync.routes.ops-handler.ts / .snapshot-handler.ts: POST handlers
- services/operation-upload.service.ts: upload pipeline (runs inside the
  caller's prisma.$transaction; tx threaded, never opens its own)
- services/snapshot-generation.service.ts: snapshot replay/generation
- StorageQuotaService gains quota-driven eviction; DeviceService gains
  stale-device cleanup; shared upload/conflict types -> sync.types
- EncryptedOpsNotSupportedError re-exported from snapshot.service for
  identity-stable instanceof in sync.routes
- new op-replay.spec.ts / conflict.spec.ts; sanctioned private-spy
  re-points only (no behavioral spec changes)
- includes the decomposition plan (docs/plans) and dead-weight cleanup

Verified: tsc --noEmit clean; 717 tests pass / 5 skipped (35 files);
checkFile + lint clean. Multi-reviewed (7 agents): behavior-faithful,
all sync-correctness invariants preserved.

Known gap: route-handler + multi-client integration specs are
config-excluded (stale legacy / Postgres-only) and were not executed
here — run integration in CI before merge.
2026-05-15 20:06:12 +02:00
Johannes Millan
2d9988dd73
perf(sync): SuperSync server speed + correctness hardening (#7621)
* docs(sync): add super sync server perf plan

* perf(sync): implement supersync server perf phases

* fix(sync): bracket auth cache invalidation

* fix(sync): avoid empty replay state stringify

* fix(sync): harden supersync batch uploads

* fix super sync review findings

* fix(sync): guard payload bytes backfill rollout

* perf(sync): speed up payload_bytes backfill and index its scan

Raise the backfill batch size (DEFAULT 5->500, MAX 25->1000) so a
100M-row operations table backfills in minutes rather than tens of
hours. Add a CONCURRENTLY partial index on (user_id, id) WHERE
payload_bytes = 0: it drains to empty post-backfill so the boot-time
backfill self-check and the BOOL_OR quota probe stop doing a full
sequential scan to prove absence, and it makes the backfill's per-user
keyset paging a true index seek. Wire the new concurrent-index
migration into both deploy scripts' P3018 recovery path. Add
migration-SQL guard tests for the ADD COLUMN (metadata-only fast path)
and the new partial index.

* fix(sync): bound auth cache invalidation map and bracket every delete

The auth verification cache's invalidationVersions map grew one entry
per lifetime-invalidated user with no eviction (unbounded heap on a
long-lived single replica). Cap it at the same 10k LRU bound as the
entries map, re-inserting the just-invalidated user at the MRU tail so
the CAS race protection still holds for the only window that matters
(one DB round trip). Bracket the passkey/magic-link registration
cleanup deletes with pre+post invalidate to match the documented
convention, and invalidate on verifyEmail so a freshly-verified user
isn't denied for up to the cache TTL.

* perf(sync): skip the redundant exact replay-state measurement

The delta accounting is a proven over-estimate of the serialized state
size, so when the running bound stays within the cap the true size is
too and the final exact JSON.stringify is provably redundant. Skip it
in that case (still measure-and-throw whenever the bound does not prove
safety). This collapses the common small/incremental replay back to
zero expensive full stringifications, matching the old per-op loop
instead of regressing it. Name the entity-key JSON overhead constant
and document that assertReplayStateSize's return value is load-bearing.

* refactor(sync): split processOperationBatch into pipeline stages

Extract the 297-line batch upload method into a thin orchestrator plus
six named single-responsibility stage helpers (validate+clamp, intra-
batch dedupe, classify existing duplicates, conflict-detect, reserve
seq + insert, full-state clock). Behavior-preserving: every stage
writes terminal rejections into the shared results array by index and
the two empty-set guards short-circuit exactly as before. Also share
the timestamp clamp, the duplicate-op SELECT, and the merged
full-state clock persistence between the batch and legacy paths so
they cannot silently diverge.

* test(sync): pin batch error-code divergence and aggregate-once

Strengthen the intra-batch duplicate test to assert same-id /
different-content yields DUPLICATE_OPERATION (deliberate divergence
from the legacy INVALID_OP_ID), and document the divergence in the
plan. Replace the single-full-state aggregate test with two
full-state ops + a spy asserting _aggregatePriorVectorClock runs
exactly once and last-write-wins — the old test could not catch a
per-op-aggregate regression. Add a makeOp fixture factory. Correct
the plan's overstated replay-stringification numbers.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: johannesjo <1456265+johannesjo@users.noreply.github.com>
2026-05-15 17:24:16 +02:00
Johannes Millan
42e6626b76 docs(sync): consolidate sync docs + enforce the contributor model
Collapse the sprawling, partly-stale docs/sync-and-op-log/ tree into a
small authoritative set and make the sync-correctness invariant
partly lint-enforced instead of convention-only.

Docs:
- Delete superseded/duplicate/provably-stale design, plan, and
  background-research docs (quick-reference, the architecture-diagrams
  monolith, the "Hybrid Manifest" docs describing code that does not
  exist, completed long-term plans, LLM-synthesis analyses).
- Salvage load-bearing decision history into the surviving docs before
  deletion: rejected-alternatives rationale -> operation-log-architecture
  ("Why this architecture"); vector-clock pruning incident history ->
  vector-clocks.md; archive-payload optimization -> architecture E.7.
- Add contributor-sync-model.md as the single-invariant entry point
  (one user intent = one op; replayed/remote ops must not re-trigger
  effects), with a decision table mapping to the enforcing linters.
- Repoint external/internal cross-refs; add CONTRIBUTING.md + CLAUDE.md
  pointers; record the migration in a dated docs/plans/ design doc.

Enforcement (new eslint-local-rules):
- no-actions-in-effects (error): effects must inject LOCAL_ACTIONS /
  ALL_ACTIONS, never the raw @ngrx/effects Actions stream.
- no-multi-entity-effect (warn, heuristic): flags a literal returned
  array of >=2 action-creator calls; docstring + valid-case specs pin
  exactly which shapes are and are not detected.
- run-specs.js runner wired into `npm run lint` via test:lint-rules;
  refuses to run under test-framework globals and counts RuleTester.run
  invocations so a spec that asserts nothing fails instead of passing.
- Correct the ALL_ACTIONS JSDoc in local-actions.token.ts to match
  reality (archive-operation-handler uses LOCAL_ACTIONS).

Reviewed via parallel multi-agent review; findings W1/W2/W4 and a
dangling doc anchor addressed.
2026-05-15 16:51:50 +02:00
Johannes Millan
d659ea9d4d fix(theme): apply velvet sidenav blur on inner element, not host
backdrop-filter on magic-side-nav establishes a new containing block for
its fixed-positioned children (.nav-sidenav drawer and .nav-backdrop-mobile
overlay). On mobile the host shrinks to width:0, collapsing both children
off-screen so the drawer never opens — same trap that was fixed for
liquid-glass. Move background + backdrop-filter to the inner .nav-sidenav.

Add a guard comment on the component :host and a "Authoring Themes"
section in docs/styling-guide.md so future themes don't reintroduce this.
2026-05-14 14:41:08 +02:00
Benjamin
07ec51b1a5
feat(plugins): add onReady() API with IPC ping + fix consent write delay (#7578)
* feat(plugins): add onReady() API with IPC ping + fix consent write delay #7326

- Remove setTimeout(5000) from _getNodeExecutionConsent; write consent immediately
- Add plugin.onReady(fn) to PluginAPI — fires after plugin.js evaluation and IPC bridge confirmation
- Add _pingNodeBridge() in plugin.service.ts with 3-attempt retry (1s, 2s delays)
- Add triggerReady() and pingNodeBridge() to PluginRunner
- Show snack + set error state if IPC bridge unavailable after retries
- Add NODE_EXECUTION_BRIDGE_UNAVAILABLE translation key
- Add focused tests for onReady, triggerReady, pingNodeBridge, consent persistence
- Update plugin-development.md with onReady usage and nodeExecution guidance

* test(plugins): fix unused variable lint errors in spec files

* fix(plugins): guard triggerReady on instance.loaded; fix doc numbering

* fix(plugins): remove _triggerReady from public API, route ping via bridge, add retry tests

* fix(electron): add paths for @sp/sync-providers subpath exports (node moduleResolution compat)

* fix(plugins): centralize onReady, tear down runtime on activation error, add iframe onReady

Address review on #7578:
- All plugin load paths (startup, upload, reload, lazy) now go through _fireOnReady,
  ensuring the IPC ping + onReady fire on every successful load — not just lazy.
- activatePlugin error path now unloads the plugin runtime (hooks, buttons, side
  effects) before setting status='error', preventing partially-running plugins.
- Iframe PluginAPI now exposes onReady (fires on next microtask after plugin.js
  evaluates), matching the host-side contract for typed iframe plugins.

* fix(plugins): clean up half-loaded plugins on onReady error, test real retry util

Self-review followups:
- _fireOnReadyWithCleanup wraps the 3 non-activatePlugin load paths and tears
  down the plugin (unloadPlugin + remove from list + status='error' + snack)
  if the IPC ping or onReady callback throws. Previously, those paths only
  logged and rethrew, leaving partially-running plugins.
- Extracted retry loop into pure pingWithRetry utility; spec now exercises
  production code instead of an inline-replicated stub. Removed the old
  plugin-ping-node-bridge.spec.ts which was just testing its own copy of
  the logic.
- Documented iframe onReady semantic (fires on microtask, no ping) in both
  the source comment and docs/plugin-development.md, since cold-boot is not
  a concern for iframe plugins (rendered on demand).

* ci(plugins): use npm i for root install to tolerate override drift

The root lockfile pins app-builder-lib's transitive minimatch via the
`overrides` field. npm 10.9.7 (bundled with Node 22 in setup-node@v6)
flags this as drift and fails `npm ci`, while npm 11 accepts it.
ci.yml's main test job uses `npm i`, which tolerates the drift without
mutating the lockfile on disk.

Plugin-Tests has been red on every PR since 2026-05-08 for this reason.
The inner `npm ci` for plugin-specific deps stays strict.

* fix(plugins): make onReady optional, assert callback isolation in spec

- packages/plugin-api/src/types.ts: mark onReady? optional on the public
  PluginAPI interface so existing plugin TypeScript typings (and any
  third-party PluginAPI implementations) remain assignable after upgrade.
  The host runtime already treats onReady as optional (no-op if no
  registration callback is provided), so this aligns the type with the
  actual contract.

- src/app/plugins/plugin-runner.spec.ts: the previous isolation test only
  asserted that triggerReady() resolved for both plugins; it would still
  pass if triggerReady fired every registered callback. The updated test
  wires per-plugin Jasmine spies through globalThis (the same context the
  plugin code's `new Function` runs in) and asserts call counts before
  and after each triggerReady, actually proving isolation.

* refactor(plugins): test real consent logic; scope startup snacks; tighten ping timeout

Address review feedback on PR #7578:

- Extract consent decision into pure `decideNodeExecutionConsent` util so the
  spec exercises real code instead of a reimplemented stub. Delete the
  stub-based plugin-consent.spec.ts and plugin-fire-on-ready.spec.ts (the
  latter was orchestration glue already covered by plugin-runner.spec.ts and
  ping-with-retry.util.spec.ts).
- Reduce per-ping timeout 5000ms -> 1500ms. Worst-case cold-boot bridge-down
  detection drops from ~17s to ~7.5s; in-process vm script returning true
  doesn't need 5s.
- Add PLUGIN_LOAD_FAILED translation wrapping plugin name + error. Strip the
  now-redundant pluginName from NODE_EXECUTION_BRIDGE_UNAVAILABLE.
- Scope activation-failure snack to manual activations only — startup
  auto-activation failures stay silent (plugin tile shows error state).
  _handleReadyFailure still snacks unconditionally since onReady failure
  leaves a partially-loaded runtime that the user needs to see.

---------

Co-authored-by: Benjamin <1159333+benjaminburzan@users.noreply.github.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-05-14 13:41:01 +02:00