Commit graph

55 commits

Author SHA1 Message Date
Johannes Millan
c18055a341
fix(android): stop the widget labelling a stale list as Today (#9098) (#9118)
The widget renders a pre-computed snapshot Angular pushes into `widget_data`;
the blob carried no expiry, so a process that stayed dead across midnight kept
rendering yesterday's tasks under a hardcoded "Today" header, indefinitely.

Native cannot recompute the list. TODAY_TAG membership is virtual, today's
repeat instances do not exist as entities until TaskDueEffects materializes
them on a day change, and overdue carry-over runs there too — so there is no
persisted field a native filter could read that would give the right answer
even in principle. Only running the app can produce today's list.

So make the widget honest instead of wrong: Angular stamps the snapshot with
`validUntil`, the instant it stops being today (start-of-next-day offset
included), and `dayStr` for the label. Native's whole verdict is
`WidgetData.headerFor` → `now >= validUntil`; when stale it renders the
snapshot's own date instead of "Today". The list stays visible and useful, it
just stops claiming to be today's. A stale snapshot whose day cannot be read
says "Outdated" rather than falling back to the very lie this fixes.

Shipping the boundary rather than its inputs keeps the app's calendar rules in
one language. The iOS port (#8950) can consume `validUntil` unchanged instead
of mirroring getDbDateStr semantics into Swift, where a non-Gregorian default
locale would silently misread the day. The verdict is a pure function so it is
unit-testable: this project has no Robolectric, and a decision left inside the
provider could ship inverted and green.

Both refresh paths rebuild the header. A push can change the day the blob
describes; a tap cannot, but it re-renders at a later `now` than the last
verdict was computed at — and it is the one interaction that reaches our code
while the app is dead, so a tap on a new day must not redraw rows under a
"Today" header. Also fixed: onUpdate re-registered the adapter with an
unchanged intent, which does not re-invoke the factory's onDataSetChanged(),
so the periodic update rendered whatever rows the adapter last built.

`v` stays 1: the fields are additive, and parse() returns empty for any other
version, so a bump would blank the widget of every install until next opened.
A pre-#9098 blob has no `validUntil` and is never reported stale — unknown must
not read as expired.

Known bounds, documented in the plan rather than papered over: the label flips
only on a push, a tap, or the inexact Doze-deferred 30-min periodic update, and
the launcher paints cached views on unlock — so the lie is bounded, not
eliminated. Force-stop is not a gap: the system masks a stopped package's
widget entirely, so the reported symptom can only occur in the Doze band. An
exact alarm at `validUntil` would close the rest; deferred on cost/scope, not
because it would not work.

Verified: 25/25 Kotlin across 6 timezones incl. midnight-gap zones (Santiago,
Apia); 203/203 Angular in both timezones CI runs. Boundary math asserted
against the real getDbDateStr rollover. Sabotage-measured, not assumed:
inverting the verdict fails 5 tests, >=→> fails 4, dropping the validUntil
guard fails 3, Locale.US→getDefault fails 1, dropping the day round-trip
fails 1.
2026-07-17 16:06:06 +02:00
Johannes Millan
99f923ad6c
docs(sync): fix stale schema-compat docs, add bump policy, rescope Task 6 (#9119)
* docs(sync): fix stale schema docs, add bump policy, rescope Task 6

* docs(sync): add severity-triage and schema-bump rules to AGENTS.md

* docs(sync): fix review findings in schema compat docs
2026-07-17 15:37:06 +02:00
Johannes Millan
6756e9998c docs(sync): record fast-track simplification audit
Capture the read-only audit evidence and its verified and provisional risk/reward guides in one baseline-preserving changeset.
2026-07-17 14:26:16 +02:00
Johannes Millan
4519c5f4d3 docs: correct simplification audit findings
Replace stale placements for revised scopes, separate line-level LOC categories, and neutralize superseded implementation guidance.
2026-07-17 14:26:16 +02:00
Johannes Millan
0204210c5b docs: document non-sync simplification audit
Record the complete non-sync audit, two-pass candidate review, conservative reconciliation, global risk/reward order, and verified stashed implementation state.
2026-07-17 14:26:16 +02:00
Johannes Millan
d1ff7963d0
fix(sync): rebase repair op clocks on the durable clock (#8939) (#9080)
The REPAIR paths built their vector clock from the per-tab in-memory
clock cache and then REPLACED the durable clock with it. A stale cache
(another tab advanced the clock) regressed the durable clock, letting
subsequent captures reuse counters already shipped — silently corrupting
cross-device dominance comparisons.

- createRepairOperation: route through
  appendMixedSourceBatchSkipDuplicates; the in-transaction rebase makes
  regression unrepresentable. State cache stores the clock actually
  written.
- replaceRejectedRepair: rebase the replacement clock onto the durable
  clock inside its transaction (shared rebaseLocalClockOnDurable helper).
- Drop client-side clock pruning from repair op building: inert under
  the rebase, and it dropped client IDs the server still tracks (false
  CONCURRENT). Server prunes after conflict detection.
- Rename appendWithVectorClockUpdate -> appendWithVectorClockOverwrite
  and document the derivation invariant; the capture path (its only
  remaining production caller) already satisfies it.
2026-07-16 17:13:57 +02:00
Johannes Millan
71a9a4338a
fix(sync): freeze the conflict-review producers before the next release (#9061)
* fix(sync): freeze the conflict-review producers before the next release

The conflict-review feature (962c5bbeb1) is on master but in no release
tag, so a release cut would take it to the whole stable fleet while it is
still slated for removal. The Task 1 audit found it already ships to two
pre-release cohorts: Snap `edge` is public and built from every master
push (snapd auto-refreshes subscribers), and the Play `internal` track
auto-updates its testers. The data obligation therefore already exists —
the next tag would expand it, not create it.

Journal rows persist the discarded side of a conflict verbatim (entity
titles, arbitrary field values, raw action payloads). They are
device-local, absent from backups/exports, never uploaded, and expire at
14 days / 200 rows, so the recorded decision is: no export owed, and the
store, reader, UI and the SUP_CONFLICT_JOURNAL_CLEARED_BEFORE marker get
deleted together in the rollback.

Freeze both producers at the single production entry point into
autoResolveConflictsLWW:

- disableDisjointMerge: conflicts resolve by whole-entity LWW, which is
  the behaviour of every released version to date, so stable gains no
  merge behaviour it would later have to be migrated off.
- disableConflictJournal: no new rows are persisted. record() is the
  journal's only write and both call sites are now gated, so nothing
  produces rows behind the freeze.

Wired at the caller rather than as a global flag so ConflictResolution-
Service keeps the capability for its own tests and the freeze reverts by
deleting two lines. Covered by a spec that fails if either flag is
dropped, since that regression is otherwise silent.

Records the audit in docs/plans/2026-07-16-conflict-review-cohort-audit.md.

* fix(sync): don't offer conflict REVIEW when the journal is empty

The content-conflict banner (#8694) fires off the resolutions, not off the
journal, and its SPAP-15 REVIEW action was unconditional. With the producer
freeze the journal holds no rows, so REVIEW landed the user on the review
page's empty state — a dead end at the exact moment they are worried about a
discarded edit. v18.14.0 ships this banner WITHOUT the action (it has no
navigateToReview at all), so the next tag would have taken a permanently
broken affordance to the whole stable fleet — the opposite of what the freeze
is for.

Gate the action on the journal actually holding unreviewed rows. The count
signal reads fresh: the journal loop awaits record() (which refreshes it)
before the notification step. This also fixes a latent case that predates the
freeze — _journalResolution swallows record() failures by contract, so a
transient IndexedDB error already produced the same dead end. Mirrors
maybeShowSummaryBanner, which already stays silent on an empty journal.

Also cover the freeze's callee-side gates, which had none: the guard spec in
remote-ops-processing.service.spec.ts only asserts the production caller
PASSES the flags. Deleting `if (!options.disableConflictJournal)` in
autoResolveConflictsLWW left all 292 specs green while the fleet silently
resumed persisting the discarded side of every conflict. Both new gates are
falsification-verified: each mutation fails exactly its own spec.

* refactor(sync): simplify the conflict REVIEW action gate

Use a plain ternary to undefined instead of a conditional spread: the banner
component truthy-checks `@if (banner.action)` and exactOptionalPropertyTypes
is off, so `action: undefined` is identical to omitting the key — and reads
as the same thing. Trim the rationale comment to the load-bearing why.

No behaviour change; the gate's spec is unchanged and still passes.

* fix(sync): give the integration spec's journal mock its count signal

The content-conflict banner now reads `conflictJournal.unreviewedCount()` to
decide whether to offer REVIEW, but conflict-resolution-persistence.integration
.spec.ts mocks ConflictJournalService with `['record']` only, so resolution threw
`unreviewedCount is not a function` and took the #8997 project-recovery test down
with it.

Provide a real `signal(0)` via createSpyObj's properties arg rather than a spy:
unreviewedCount is a signal on the service, so this keeps the mock honest to the
type it stands in for.

Caught by CI, not locally: the earlier run covered only the four spec files the
change touched, and this mock lives in a fifth. Full suite now green, 12928 +
12914 across both TZ variants.

* test(sync): drop the redundant journal-on control test

'journals resolutions by default' duplicated coverage that already exists: the
#8956 multi-entity test asserts record() is called twice on the default path, so
it already fails if journaling breaks entirely — which was the only thing my
control guarded against (the freeze test passing vacuously).

Collapse the leftover one-test describe and its two single-use helpers back into
a flat spec.

Full suite green: 12927 + 12913 across both TZ variants.
2026-07-16 14:43:33 +02:00
Johannes Millan
011a92585d
docs(sync): add sync simplification roadmap (#9062)
* docs(sync): add simplification roadmap

* docs(sync): strengthen simplification roadmap

* docs(sync): simplify sync implementation plan

Prioritize deletion, atomic single-tab ownership, and full-sync trigger consolidation while deferring compatibility removals and speculative abstractions.

* docs(sync): make simplification roadmap compatibility-safe

Narrow deletion work behind persisted-data, provider-eligibility, delivery, and request-cost gates. Keep schema readers and separate unrelated startup and maintenance correctness projects.

* docs(sync): fold code-verified review findings into plan

* docs(sync): align simplification plan with latest changes

* docs(sync): correct trigger-branch claim and add footprint estimate

* docs: cap services at 1000 LOC

* chore(lint): warn when a service exceeds 1000 lines

* chore(lint): raise service size cap to 1200 lines

* docs(sync): re-align simplification plan to master 6fefd741c5

* docs(sync): fold #9054 footprint auth into plan contracts

Master advanced 3 commits past the plan's 6fefd741c5 baseline. Exactly
one touches a task surface: #9054 (authenticated LWW project-move
footprint, GHSA-8pxh) landed in conflict-resolution.service.ts — the
file Task 6 carves up.

Contract 14 documented only #9045's decrypt-path check and emphasised
the weaker half ("synthetic ops are intentionally exempt; that exemption
must survive the disjoint-merge removal"). Read alone, that could bless
an op.entityIds read while removing disjoint-merge, reopening the vector
one merge removed. #9045's exemption sentence is still accurate at
master, so 14 was incomplete rather than wrong.

Name the invariant #9054 established: getTaskProjectMoveEntityIds
re-derives footprints from the authenticated payload.projectMoveFootprint
(LWW) / actionPayload.projectMoveSubTaskIds + actionPayload.task.id (raw
TASK_SHARED_UPDATE), never the plaintext envelope, and that one choke
point serves the disjoint-merge, local-win and superseded-op callers.

Also fence #9054 in §4 and re-align the baseline to 7ef7e69e96 (#9052
orphan-task navigation and #9056 locale touch no task surface).

* chore(lint): make the service size cap enforce on new services

'warn' could not enforce what AGENTS.md asserts. `ng lint` runs the
@angular-eslint builder, whose maxWarnings defaults to -1 and is not
overridden in angular.json, so warnings never fail a build: a new
1300-line service merged green. The documented flip-to-'error' gate
("once every *.service.ts is under the cap") needed
conflict-resolution.service.ts to go 3934 -> 1200, i.e. the whole
7-task simplification plan, so it would realistically never flip.

Ratchet instead: 'error' on **/*.service.ts, with the 8 pre-existing
offenders grandfathered to non-failing warnings at their real size, so
the debt stays visible and the list can only shrink.

Verified: a new over-cap service errors (exit 1); all 8 grandfathered
warn (exit 0); full `npm run lint:ts` is exit 0 with exactly 8 warnings
and 0 errors, which proves the list is complete — a missed offender
would fail the build. Every >1200-line @Injectable in the repo is named
*.service.ts, so the glob has no coverage hole, and .service.spec.ts is
exempt (checked against the 7797-line conflict-resolution spec).

Also correct AGENTS.md "1200 LOC": max-lines defaults to
skipComments/skipBlankLines false, so the cap counts physical lines.
2026-07-16 13:42:38 +02:00
Johannes Millan
2864a39c85
fix(sync): file-based provider atomicity & conflict UX (#8960) (#9004)
* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(sync): prevent file-based sync data loss

Preserve post-snapshot operations and stage remote baselines until durable apply.

Use conditional writes and a resumable marker for provider concurrency and legacy migration. Report trustworthy remote timestamps and document transport limits.

Refs #8960

* fix(sync): avoid biased conflict recommendations

Highlight a side only when its timestamp or known change count is strictly greater, leaving unknown and tied metadata neutral.

* test(sync): assert options arg on split-file processRemoteOps

The split-file snapshot path now routes post-snapshot ops through
_processRemoteOpsWithStartupCleanup, which calls processRemoteOps with an
(empty) options object. Update the assertion to match the 2-arg call so the
unit suite passes.

* refactor(sync): dedupe strong-ETag regex, document snapshot-op boundary

- Extract the duplicated RFC 7232 strong-entity-tag pattern into a single
  STRONG_ETAG_RE constant with a comment noting why the char class is safe to
  interpolate into an If-Match header (no CR/LF header splitting).
- Explain why sv === undefined ops are classified as snapshot-included: they are
  legacy migration ops fully contained in the snapshot, and _validateSnapshotRef
  enforces a clock-EQUAL boundary. No behavior change.
2026-07-14 19:23:24 +02:00
Johannes Millan
762b3c5d8d
feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)
* feat(plugins): add Todoist import plugin with Import/Export launcher

* fix(plugins): fix cross-chunk subtask loss in todoist-import + hardening

* fix(plugins): clamp non-finite Todoist durations + review polish

* feat(plugins): add Eisenhower priority mapping to Todoist import

Replace the single "map priorities to p1-p3 tags" checkbox with one
mutually-exclusive control (Nothing / p1-p3 tags / Eisenhower matrix).

The new Eisenhower option reuses SP's built-in urgent/important tags by
title (p1 -> urgent+important, p2 -> important, p3 -> urgent, p4 -> none),
so imported tasks populate the Eisenhower Matrix board with no new tag
and no new plugin API. Collapsing Todoist's single priority axis onto the
2-D matrix is opinionated, so it stays opt-in and off by default.

Requested in the review of #8882.

* fix(plugins): harden Todoist import data integrity

* fix(plugins): improve Todoist import feedback
2026-07-10 13:30:03 +02:00
Johannes Millan
05f6bd27d1
fix(op-log): serialize SQLite adapter transactions on the shared connection (#8849)
* fix(op-log): serialize SQLite adapter transactions on shared connection

The SQLite op-log adapter issued raw BEGIN/COMMIT on the shared
connection with no serialization. Once both stores share one SqliteDb
(the staged native rollout), concurrent operations (capture append,
archive write, compaction) would interleave BEGINs: SQLite has no
nested transactions, so a second BEGIN throws and a bare statement
issued mid-transaction silently joins — and rolls back with — the
foreign transaction, corrupting op-log state.

Funnel every adapter entry point through an internal FIFO queue so a
transaction is exclusive on the connection for its whole BEGIN/COMMIT
and no bare operation interleaves. Transaction-internal work runs
directly on the connection (already holding the slot), so there is no
re-entrancy. Document the mutual-exclusion invariant in the port
contract and add a concurrent-transactions contract test that runs on
both the in-memory fake and real sql.js.

* docs: add complete architecture review report (2026-07-07)

Whole-app architecture review synthesized from eight parallel subsystem
reviews and an adversarial verification pass; findings filed as issues
#8832-#8843, with duplicates cross-referenced rather than re-filed.

* fix(op-log): key the SQLite transaction serializer to the connection

Multi-agent review found the serializer was keyed to the adapter
instance. The native rollout hands the op-log store and archive store
two separate SqliteOpLogAdapter instances over one shared SqliteDb, so
per-instance queues left an op-log BEGIN free to interleave with a
concurrent archive BEGIN on the shared connection — the exact hazard the
serializer targets.

Key the FIFO queue to the connection (WeakMap<SqliteDb>) so every adapter
over one SqliteDb shares one queue. Add a contract test that drives two
adapters over one connection concurrently (verified red with per-instance
keying, green with per-connection). Also: document the re-entrancy
precondition as unenforced (a lint rule, not a runtime flag, is the right
guard — a flag cannot distinguish a re-entrant call from a legal
concurrent one) and correct init/getLastSeq/port-contract doc drift.
2026-07-07 18:00:25 +02:00
Johannes Millan
3e56836a23 docs(plans): add iOS home screen widget port plan 2026-07-07 17:08:48 +02:00
Johannes Millan
bf710637d2
feat(android): add home screen widget for today's tasks (#8737)
* feat(android): add home screen widget for today's tasks (#3818)

Revives PR #7124 (POC by @ilvez) on current master with the review
punch-list addressed:

- today's tasks pushed as a widget_data KeyValStore snapshot (memoized
  selector, debounced, hydration-guarded, re-pushed on sync-window end
  and on pause); Angular is the single writer of the blob
- done-checkbox taps go through a SharedPreferences queue only; pending
  taps are overlaid natively at render time (no native blob write, no
  race with the serializer, no double setDone)
- row title tap opens the app via fill-in extras branching (single
  PendingIntent template); exported receiver no longer lists the custom
  actions in its intent-filter
- typed v:1 contract (android-widget.model.ts <-> WidgetData.kt) locked
  by golden-shape tests on both ends
- drain dedupes and skips missing/already-done tasks; aggregated
  translated snack
- KeyValStore: drop per-call db.close() (SQLiteOpenHelper caches the
  connection; access stays @Synchronized via the App singleton)

Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>

* feat(android): polish widget UI and support toggling done state

Visual polish to match the app:
- rounded surface card in the app's exact light/dark tokens
  (#f8f8f7 / #131314) with automatic day/night switching
- branded header (SP logo + 'Today') with separator, matching
  brand purple (#8b4a9d light / #a05db1 dark)
- app-style circle-check on the row end (Material check_circle in
  brand color when done), dimmed title for done tasks
- project dot tinted with the project color and hidden for
  project-less tasks; whole row and empty state open the app

Done-state toggle (was done-only):
- queue is now a last-wins map {taskId: targetIsDone}; tapping a
  done task queues setUnDone, and a done->undone round trip before
  the app runs collapses to a no-op
- checkbox target computed from the DISPLAYED state (incl. pending
  overlay) so repeated taps toggle back and forth while app is dead
- drain applies setDone/setUnDone, still skipping missing tasks and
  tasks already in the target state

---------

Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>
2026-07-03 18:32:34 +02:00
Johannes Millan
421dc353e8 docs(sync): add sync-engine extraction plan 2026-07-03 16:14:22 +02:00
Johannes Millan
c7d6131db8
feat(plainspace): Collaborate-on-Plainspace discovery + smoother connect (#8649)
* feat(plainspace): add Collaborate action to project context menu

Surface Plainspace sharing from the project context menu (active, non-Inbox,
not-yet-shared projects) so it can be discovered at the moment of collaboration
intent, reusing PlainspaceShareService. A new selectIsProjectSharedOnPlainspace
selector hides the action once a project is shared to avoid provisioning a
duplicate space on a repeat click.

* docs(plainspace): document collaboration in wiki

Add Plainspace to the issue-integration comparison (matrix + per-provider
section) and a how-to for sharing a project via the project menu, including the
network/privacy caveat.

* feat(plainspace): place Collaborate action below the share-list item

Group the two share/export actions and lift Collaborate higher for
discoverability. Gated to active, non-inbox, not-already-shared projects.

* fix(plainspace): smoother first-run connect and value-first dialog

Pre-check connectivity and revalidate a stored token before the space
picker, so a stale/foreign token routes to the connect dialog and an
offline state shows a calm message instead of the raw 'check your token'
picker error. Make the connect dialog value-first: lead with what you
get, drop the 4-step how-to and email hint in favor of one short pointer
(token-creation guidance moves to plainspace.org).

* docs(plans): dedicated from-Super-Productivity flow on plainspace.org

Open plan for a guided token/connect flow on plainspace.org when a user
arrives from SP (Model A manual token, Model B OAuth-style handoff).

* refactor(plainspace): drop redundant connect pre-check (multi-review)

The space picker already detects a stale token and offers a reconnect
(#8616), so the revalidate() pre-check duplicated that path, cost an
extra GET /me, and forced re-auth on a valid token during a transient
server blip. Keep only the one-line offline guard; drop revalidate(),
the discriminated union, and the unnecessary _isOnline() seam (navigator
.onLine is spyable in the runner). Fix stale doc comments and document
the disabled-provider trade-off in the selector.

* feat(plainspace): show brand icon in the connect dialog title

* feat(plainspace): deep-link connect dialog to the from-SP onboarding flow

Point the dialog's 'Open Plainspace' link at the dedicated
/connect/super-productivity entrypoint (which guides token creation)
instead of the bare marketing host, closing the connect-flow funnel leak.

* feat(plainspace): bounce back to the app after connecting (desktop)

Append a validated `?return=superproductivity://plainspace-connect` deep
link to the connect URL, gated on IS_ELECTRON (only desktop registers the
scheme — web/mobile would get dead buttons). Handle that action in the
Electron protocol handler by surfacing the window, so the connect page's
"Open Super Productivity" button re-focuses the app.

Desktop-only; needs an on-device check.
2026-07-01 16:39:34 +02:00
Johannes Millan
bb3ed74480
feat(android): migrate edge-to-edge to built-in SystemBars (#8543)
* feat(android): migrate edge-to-edge to built-in SystemBars

Replace @capawesome/capacitor-android-edge-to-edge-support with Capacitor 8's
built-in SystemBars (insetsHandling: 'css'). SystemBars handles edge-to-edge
insets + IME padding on WebView >= 140 / API >= 35; the WebView < 140 / API < 35
tail is covered by env() fallback plus a native keyboard shim
(adjustWebViewHeightForKeyboardBelowApi30), gated to WebView < 140 so it does
not fight SystemBars' own IME padding.

- config: drop EdgeToEdge plugin config + includePlugins entry + dep; remove the
  plugin from the generated gradle registration
- theme: stop JS-writing --safe-area-inset-* on Android (SystemBars owns them;
  the SCSS env() fallback preserves the #8283 top fix); bars are now transparent
  with the theme color painted behind them via setWebViewBackgroundColor, since
  SystemBars has no bar-color API
- StartupOverlayManager: derive the overlay inset from the system-bar insets
  instead of the removed plugin's WebView margin

Spike: TypeScript verified (tsc clean, lint clean). Kotlin and on-device
behavior NOT yet validated (gradle unavailable in this environment). Requires
`npm install` + `npx cap sync android` and the device matrix in
docs/plans/2026-06-22-android-systembars-migration-corrected.md before merge.

* build(deps): drop @capawesome edge-to-edge from lockfile

Reconcile package-lock.json with the package.json change in the edge-to-edge ->
SystemBars migration (removes only the @capawesome/capacitor-android-edge-to-edge-
support entry). Generated by `npm install`.

* docs(android): clarify SystemBars band model from implementation review

Comment-only + plan-doc follow-up to the multi-agent review of the migration:

- Correct the band model in the inset comments: SystemBars *injects*
  --safe-area-inset-* only on API >= 35; WebView >= 140 is native env()
  passthrough (no injection below API 35). The SCSS var(..., env()) fallback
  covers every band. (capacitor.config.ts, global-theme.service.ts)
- Fix a stale comment in StartupOverlayManager.show() that still described the
  removed @capawesome plugin insetting the WebView.
- Record the device-validation-only findings in the plan doc (API>=35/WebView<140
  double-count corner, env vs var consumer split, API 30-34/WebView<140 IME
  owner, CDK overlay top shift) so they are checked on the matrix, not blind-fixed
  (a blind shim extension would risk re-creating #8508).

No behavior change.
2026-06-22 16:07:06 +02:00
Johannes Millan
ad149e138c
docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause (#8528)
* docs(android): document SDK 28 (API 28) behind-keyboard add-task bar root cause

After #8508 (patch removed in 18.12.0) a user on Android 9 / API 28 reports the
global add-task bar sitting behind the soft keyboard — the device class open
item #4 predicted.

Capture the precise root cause (edge-to-edge forces no system resize; Type.ime()
unreliable < API 30; old WebView VisualViewport doesn't shrink, so
--keyboard-height stays 0) and why a web-side height fallback is the reverted
#8295 trap (obscured ≈ 0 in both the resized and non-resized cases). Record the
correct resize-detecting native fix recipe and the device-matrix gate it needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs

* fix(android): lift add-task bar above keyboard on API < 30 (#8508 follow-up)

On Android 9 / API 28 under enforced edge-to-edge (targetSdk 36) the system
does not resize the window for the IME and WindowInsetsCompat.Type.ime() is
unreliable, so the edge-to-edge plugin never insets the WebView and neither the
window nor VisualViewport shrinks. --keyboard-height stays 0 and the
position:fixed add-task bar sits behind the keyboard.

Add a resize-detecting native inset in CapacitorMainActivity, driven from the
existing keyboard OnGlobalLayoutListener: measure the keyboard via
getWindowVisibleDisplayFrame (reliable on every API level) and, only when the
WebView actually extends behind the keyboard, lift it by the exact overlap via
bottomMargin (matching the plugin), self-correcting as the keyboard height
changes and restoring the captured margin on hide. Gated Build.VERSION.SDK_INT
< 30 so it is a strict no-op on every device #8508/18.12.0 verified.

Web-side height fallback was rejected: obscured = innerHeight - vvHeight is ~0
in both the resized and non-resized cases, so disambiguating requires the
baseline-innerHeight + max(obscured, nativeKb - layoutShrink) math that was
reverted as #8295. Native has the unambiguous geometry.

Needs on-device validation across the matrix in docs/android-edge-to-edge-keyboard.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs

* fix(android): harden SDK < 30 keyboard inset after review

Address findings from a multi-agent review of adjustWebViewForKeyboardBelowApi30:

- Bound the feedback loop: clamp appliedKeyboardInsetPx to [0, keypadHeight]
  (the lift never needs to exceed the keyboard height) and skip redundant
  layoutParams writes, so a WebView height that doesn't respond to the margin
  (e.g. the edge-to-edge plugin rewriting it) can't drive an endless
  requestLayout loop.
- Ignore stale/pre-layout geometry while the keyboard is open (webView.height
  == 0) so an already-applied inset isn't collapsed to 0 for a frame; only the
  genuine keyboard-closed path restores the margin.
- Make the dead-band density-relative (8dp) instead of a fixed 16px so it's
  visually constant across densities.

The reviews confirmed the central risk is absent: shrinking the WebView via
bottomMargin shrinks both innerHeight and visualViewport.height, so the web
side's obscured stays under its 100px floor and does not add a second lift
(no #8295 double-count). Still pending on-device validation per the docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gqH2i456UwdTJXwW5YHPs

* fix(android): resize WebView via height for SDK<30 keyboard inset

On API < 30 under enforced edge-to-edge the system does not resize the
window for the IME, so the add-task bar sat behind the keyboard (#8508
follow-up, Android 9 / API 28).

The edge-to-edge plugin owns webView.bottomMargin and rewrites it to 0 on
every inset dispatch while the IME is visible, so correcting the margin
from a second writer flickers; WebView padding does not move the web
layout viewport; and fully replacing the plugin's inset listener stops it
re-painting its color overlays (white navbar gap).

Instead set an explicit WebView layout height to the keyboard top while
the IME is up and restore the resting height on hide. Height is a
different property than the margin the plugin manages and, for an
explicit-height view, the margin does not change the view size — so the
two never fight and the plugin keeps doing everything else (insets +
color overlays). Gated SDK_INT < 30, so API >= 30 is untouched.

Also documents the root cause, the approaches that failed, and the
upstream status, plus a handover/plan for migrating to Capacitor's
built-in SystemBars.

* fix(android): guard zero-height + reuse loc buffer in keyboard inset

Multi-review follow-ups for the SDK<30 keyboard WebView-height workaround:

- Skip the write when the computed keyboard-top height is <= 0 instead of
  coercing to 0. A 0 would collapse the WebView, and the existing
  height==0 guard would then latch and stop recomputing until the keyboard
  hides.
- Reuse a single IntArray for getLocationOnScreen instead of allocating
  one on every layout pass while the IME is up (hot path).
- Document the implicit coupling with StartupOverlayManager: both read/
  mutate webView.height from layout listeners and only coexist because the
  overlay early-returns once its input bar is visible. Cross-referenced in
  both files so the guard isn't removed later.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 13:27:45 +02:00
Miklos
67d1e32ffc
feat(focus-mode): focus screen UX overhaul (#7586)
* feat(focus-mode): focus screen UX overhaul

Major rework of the focus mode timer screens (#7349):

- Shared <focus-clock-face> drives Pomodoro / Flowtime / Countdown /
  Break with a single visual chrome; size tokens scale fluidly via
  clamp() + vmin/vh, no discrete breakpoints.
- Single source of truth: --clock-time-size and --control-offset
  derive from --clock-face-size.
- Countdown: click-to-edit duration, draggable handle on the ring,
  hybrid 5-min/15-min snap with 6h-per-rotation above 1h
  ([useFlexibleIncrement] on input-duration-slider, opt-in for other
  consumers).
- Pomodoro prep inherits the click-to-type input; drag handle hidden
  so dragging the ring can't silently shift the value.
- Pause keeps the selected task on screen (displayedTask falls back
  to the paused task while currentTask is null).
- Notes panel acts as a modal: clock stays put, backdrop dims and
  closes on outside-click.
- Session controls row below the circle (pause / complete / reset
  cycles); buttons fade via shared --revealed-opacity gated on host
  hover + document.hasFocus().
- Break screen mirrors focus-mode-main layout; cycle counter inside
  the circle on both focus and break; back-to-planning unified.
- Flowtime settings dialog: all fields render with proper
  disable/enable, stable dialog width when switching modes.
- arrow_backward -> arrow_back: fixes glitched glyph in repeat-type
  context menus (boards, simple counters, take-a-break, flowtime).

* fix(focus-mode): silence naming-convention lint on formly 'props.disabled'

Formly's expressionProperties path-string keys ('props.disabled') aren't
camelCase and the rule has no requiresQuotes exemption; matches the
existing pattern in src/app/features/issue/common-issue-form-stuff.const.ts.

* test(focus-mode): mock pomodoroConfig signal on FocusModeService

The component's initialization effect now reads
focusModeService.pomodoroConfig() in Pomodoro+Preparation mode, but the
three mocked FocusModeService instances in the spec didn't provide it,
causing TypeError in 47 tests on CI (somehow not surfaced locally).

* test(focus-mode): align specs with unified back-to-planning flow

- focus-mode-break.spec: exitBreakToPlanning -> cancelFocusSession
- focus-mode-session-done.spec: drop obsolete hideFocusOverlay assertion
  (cancelFocusSession now handles both clearing tracking and hiding the
  overlay, matching the production component)
- focus-mode-main.spec: storeSpy gains selectSignal returning a signal,
  needed by the displayedTask paused-task fallback

* fix(focus-mode): restore E2E selector hooks on refactored controls

The shared <focus-clock-face> refactor moved pause/complete buttons out
of the clock face into a new .circle-controls row but didn't carry the
class names forward; 23 E2E specs key off them. Also re-add
.task-title-placeholder on the no-task FAB so prep-state checks find it.

- .pause-resume-btn on pause/resume in focus-mode-main + focus-mode-break
- .complete-session-btn on the done_all button in focus-mode-main
- .task-title-placeholder added to .select-task-cta FAB

* fix(focus-mode): aria-label icon buttons; align break E2Es with new flow

- focus-mode-break: pause/resume/skip/reset icon buttons now carry
  [attr.aria-label] in addition to matTooltip — icon ligature alone
  isn't an accessible name and breaks getByRole locators.
- pomodoro-break-timing-bug-6044.spec: skipButton uses getByRole with
  accessible name rather than hasText (mat-icon ligature is "skip_next",
  not "skip break").
- focus-mode-break.spec: "exit break to planning and change timer mode"
  and "Back to Planning should NOT auto-start next session" now match
  the unified back-to-planning flow — overlay closes on click, user
  re-opens focus mode to change settings or verify prep state.

* fix(focus-mode): address PR #7586 review feedback

Maintainer review (johannesjo):
- Debounce the Pomodoro work-duration write so editing it emits one
  synced config op instead of one per keystroke; flush on session start
  so a value typed inside the debounce window is not lost. (A1)
- Replace the local ::ng-deep restyling of the shared input-duration-slider
  with opt-in [bareRing] and [hideHandle] inputs that own the chrome
  overrides in the slider's own styles; the four other consumers keep the
  default look. (A2)
- Add unit tests for the flexible drag math (_setValueFromRotationFlex):
  the A<->B boundary anchoring at 55/60 min and both +/-180 degree wrap
  branches. (A3)
- Delete the orphaned exitBreakToPlanning action, its
  stopTrackingOnExitBreakToPlanning$ effect, reducer case and specs;
  cancelFocusSession already unsets the current task. (B1)
- Drop the unreferenced CONTINUE_TO_NEXT_SESSION and BREAK_RELAX_MSG
  i18n keys. (B2)
- Collapse the duplicated clock-size clamp() into a single
  --clock-face-size-default token. (B4)

Smaller focus-screen fixes (beerkumquatpome):
- Hide the break task title when "pause tracking during breaks" is on. (C7)
- Commit and close the duration editor on Enter. (C9)
- Rename "Back to Planning" to "Exit focus session". (C11)
- Show Flowtime breaks as a neutral "Break". (C13)

Break-circle vertical alignment (C1) is only partially addressed here
(matched the top reservation); exact alignment is a follow-up.

* refactor(focus-mode): share a layout shell across timer screens and auto-start Flowtime breaks

Extract a presentational focus-mode-layout component (4-row content-projection
skeleton: [fmTop]/[fmTask]/[fmClock]/[fmBottom]) shared by the focus-session and
break screens, so both keep a stable clock baseline across the focus<->break and
prep<->in-progress transitions. focus-mode-main and focus-mode-break now consume
the shell instead of each maintaining their own absolute layout.

Replace the Flowtime "break offer" step with an auto-started break, mirroring
Pomodoro:
- Remove the BreakOffer UI state and the offerFlowtimeBreak action/reducer.
- endFlowtimeSession now dispatches completeFocusSession(isManual:false) +
  startBreak (unsetting the task first when tracking-pause-on-break is on), so
  the break starts automatically and the session is logged exactly once via
  logFocusSession$.

Also reorder the bottom controls (Back to Planning leftmost), restore the
BACK_TO_PLANNING label to "Back to Planning", and drop the now-orphaned
FLOWTIME_BREAK_TITLE / START_BREAK i18n keys.

* test(layout): restore document.activeElement after focus-restoration specs

The LayoutService "Focus restoration" tests override document.activeElement
with Object.defineProperty, which shadows the native (inherited) getter with an
own property on document. The afterEach only removed the mock DOM node, so the
override leaked into later specs: once it ran, document.activeElement was frozen
at the mock element and subsequent .focus() calls could no longer move it.

Depending on Karma's spec order this broke the task.service focusTaskById tests
(#7120), which then saw the stale activeElement instead of the element they
focused. Delete the shadowing own property in afterEach to restore native
behavior.

Repro: ng test --include layout.service.spec.ts --include task.service.spec.ts

* refactor(focus-mode): add interactive tracking widget and polish timer-screen layout

- Replace the read-only task-tracking-info with focus-mode-task-tracking
  (vertical time stack + play/pause), wired through the shared layout shell
- Center the task title and floor the task-row height so the clock baseline
  stays aligned across focus <-> break
- Spacing polish: task-title-row and layout gaps to --s2,
  segmented-button-group padding to 0
- Drop redundant safe-area-bottom padding on the action row (the overlay
  already reserves it for the fixed shell)
- Add "Take a moment to relax" break message and a clock-digit edit affordance

* refactor(focus-mode): share timer/break layout, drop dead tracking toggle

- Extract a shared <focus-mode-layout> skeleton and <focus-mode-task-row> used by both the focus session and the break.

- Remove the in-view tracking play/pause toggle (start/stop stays on the global header button); strip focus-mode-task-tracking to read-only and drop RESUME_TRACKING.

- Pin the mode selector out of flow and center the task·clock·bottom group; equal reserved task/bottom rows keep the clock vertically centered, with the selector kept on top.

- Tighten sizing: horizontal selector segments, settings cog matched to the in-session controls, and a fluid clock clamp.
2026-06-12 11:59:56 +02:00
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
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
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
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
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
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
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
4e1ace0786 refactor(sync): remove encryption test mocks 2026-05-13 17:26:55 +02:00
Johannes Millan
faa18c1638 docs(sync): add plan to extract encryption primitives to @sp/sync-core 2026-05-13 14:11:15 +02:00
johannesjo
c40feef6d2 refactor(sync-providers): move SuperSync provider into package
Move SuperSync's provider class, model, and spec from
`src/app/op-log/sync-providers/super-sync/` into
`packages/sync-providers/src/super-sync/` behind the ports
established by slices 5-6 (logger, platform-info, web-fetch,
native-http-executor, credential-store) plus two new ports for this
slice: `SuperSyncStorage` (narrow, three methods for `lastServerSeq`
persistence) and `SuperSyncResponseValidators` (host-side wrapper
that keeps `@sp/shared-schema` out of the package boundary).

Privacy fixes applied inline per multi-review consensus:

- `AuthFailSPError(reason, body)` now only takes the extracted
  reason — body is no longer retained on `additionalLog`.
- `_handleNativeRequestError` drops the parenthesised
  `(${errorMessage})` interpolation from the user-facing message
  to avoid leaking hostname/URL on native-stack errors.
- Timeout `Error.message` drops the `path` interpolation
  (`excludeClient` clientId was leaking into the thrown message).
- `_extractServerErrorReason` caps the extracted server reason at
  80 chars to defend against future server contract drift.
- Thrown non-2xx errors use a fixed `HTTP <status> <statusText> —
<reason?>` form (web) or `HTTP <status> — <reason?>` (native)
  instead of embedding the raw response body in `Error.message`.

JSDoc invariants pinned on `getEncryptKey`, `getWebSocketParams`,
`_cachedServerSeqKey`, and `deleteAllData` response shape.

Compression switched to direct `@sp/sync-core` import (no
`CompressError` wrapping at SuperSync's call sites; the existing
app-side compression-handler still wraps for
`EncryptAndCompressHandlerService`).

Spec converted Jasmine → Vitest. `TestableSuperSyncProvider`
subclass gone — native-platform routing now tested via injected
`platformInfo` + `nativeHttpExecutor` mocks. Adds a new
`privacy regression: no user content in error/log surface`
describe block driven by an HTTP-error path with a body containing
plausible user content (taskId/title/accessToken).

App-side `super-sync.ts` collapses to a 50-line
`createSuperSyncProvider()` factory (no `extraPath` arg —
SuperSync explicitly ignored it). The `SyncProviderId.SuperSync`
enum stays app-side; `AssertSuperSyncId` conditional pins
enum-vs-constant drift at compile time. `super-sync.model.ts`
becomes a re-export shim. `sync-providers.factory.ts` updated.

`super-sync-restore.service.ts` narrows the package's
`RestorePoint<string>` return through the consensus "narrow at the
app shim" decision (single cast at the call boundary).

Bundle: CJS 95.15 KB / ESM 92.34 KB (up from 76.78 / 74.18). Right
in the performance reviewer's 95-100 KB estimate; under the
original 110 KB projection. Tiered barrel split remains a
documented deferral.

Package spec count: 273 (was 197, added 76 SuperSync specs).
All targeted app specs green: sync-wrapper (107), op-log (2513
across the directory), imex/sync (381).
2026-05-13 00:30:19 +02:00
johannesjo
3d0ff2aec7 docs(sync-core-plan): record SuperSync slice multi-review consensus
Folds the six-Claude-lens (correctness, security/privacy, architecture,
alternatives, performance, simplicity) review pass into the SuperSync
slice design doc. Closes all 8 open questions in-doc, records new
blockers the original draft undercounted, and trims the body per the
simplicity reviewer's recommendations.

Key revisions:
- Storage port is narrow `SuperSyncStorage` (3 methods), not the
  generic `KeyValueStoragePort` originally proposed. Architecture
  and alternatives both pushed back.
- Promoted helper renamed to `isRetryableUploadError` (intent-
  anchored) to avoid naming collision with package's existing
  `isTransientNetworkError`.
- Factory drops `extraPath` — SuperSync explicitly ignores it.
- Privacy A1 fix uses extracted-reason form (via
  `_extractServerErrorReason`, capped at 80 chars), not blanket
  body-drop — preserves 5xx debug context.

Four new privacy blockers added that the original sweep missed:
- `AuthFailSPError(reason, body)` retains body via
  `AdditionalLogErrorBase.additionalLog` (security + correctness
  both flagged independently — PR 5b did NOT scrub this for
  SuperSync's call site).
- `_handleNativeRequestError` re-throw embeds raw `errorMessage`
  (can leak hostname/URL from native-stack `.message`).
- Timeout `Error.message` embeds `path` with `excludeClient` query
  param (pseudonymous clientId).
- `_extractServerErrorReason` returns server `error` field uncapped.

Dissents recorded: alternatives reviewer preferred pre-split spec
before move, moving CompressError/DecompressError into sync-core
for strict behavior preservation, and barrel split as PR 7d.
2026-05-12 23:58:58 +02:00
johannesjo
6a1f569a09 docs(sync-core-plan): draft SuperSync slice design for multi-review
Captures the SuperSync provider move (slice 7) design surface before
implementation: which files move into @sp/sync-providers, which stay
app-side, which ports get reused from prior slices, which new ports
this slice introduces, the privacy sweep checklist, the suggested
commit shape, and the open questions for parallel multi-review.

Key design calls flagged for review:
- response-validators.ts stays app-side (banned @sp/shared-schema
  import). New SuperSyncResponseValidators port.
- localStorage access becomes a KeyValueStoragePort.
- isTransientNetworkError promoted from app sync-error-utils to the
  package as isTransientErrorMessage (distinct from the package's
  existing native-error-code-aware version).
- Compression imported directly from @sp/sync-core; CompressError
  wrapping dropped at SuperSync call sites (no instanceof catches
  exist).
- Two privacy A1 sites identified: _doNativeFetch:646-648 and
  _doWebFetch:582 embed response body in thrown Error.message; fix
  via fixed status-only message.
- Spec migration kept monolithic for the move (1553 lines), split
  deferred to a follow-up.

Multi-review consensus block left blank for the reviewer pass.
2026-05-12 23:50:12 +02:00
Johannes Millan
cf4fbd22e3 docs(sync-core-plan): fold Gemini review dissent into consensus
Gemini's review eventually completed after a workspace-sandbox retry
and quota throttling. Its findings broadly affirm the Claude
consensus, with two dissents — both rejected with reasoning:

- Q1 (port reuse): Gemini wanted to keep the new WebDavNativeHttpExecutor
  port "for consistency with NativeHttpExecutor." Rejected — the
  architecture and simplicity reviewers verified by code reading that
  the existing NativeHttpExecutor already supports arbitrary methods,
  responseType: 'text', and maxRetries: 0. Naming consistency is a
  weak argument against actual port duplication.

- Q6 (retry policy): Gemini wanted a 2-attempt + explicit 423 Locked
  retry. Rejected — alternatives and simplicity flagged this as a
  behavior change masquerading as a refactor. Adapter has zero retries
  today; preserving that keeps the slice scope a move. Per-call-site
  maxRetries remains trivially available once we reuse the existing
  port.

Refresh the consensus header from "Claude-only consensus" to reflect
that Gemini did contribute.
2026-05-12 22:22:41 +02:00
Johannes Millan
9a00152c22 docs(sync-core-plan): record WebDAV slice multi-review consensus
Add a "Multi-review consensus (2026-05-12)" section between the goal
and the what-moves description, mirroring the Dropbox slice doc's
structure. Captures findings from four Claude reviewers (security,
architecture, alternatives, simplicity). Codex / Copilot / Gemini CLIs
were attempted but failed for environment reasons (sandbox, deny-tool
classifier, quota+workspace limits); noted in the consensus header.

Decisions revised:

- Open question 1: DROP the new WebDavNativeHttpExecutor port. Reuse
  NativeHttpExecutor — the existing port already supports responseType:
  'text', maxRetries: 0, and arbitrary methods (PROPFIND/MKCOL/MOVE).
  Auto-JSON-parse is a property of CapacitorHttp.request, not the port.
  The app injects a different adapter (wired to WebDavHttp plugin) of
  the same port. Commit 2 collapses to "wire app-side adapter."
- Open question 4: Drop inline registerPlugin in this slice — the
  canonical registration in capacitor-webdav-http/index.ts carries the
  web: () => import('./web') fallback; the inline one in
  webdav-http-adapter.ts:31 does not.
- Open question 5: Tighten CORS heuristic in this slice. Collapse the
  string-matching to a ~3-line "TypeError && message.includes('cors')"
  check and replace the ambiguous-error log with toSyncLogError +
  urlPathOnly meta. Closes a privacy leak (Firefox NetworkError embeds
  the URL) and removes 40 lines.
- Open question 6: Preserve no-retry behavior. Under open-question-1's
  port reuse, becomes a per-call-site maxRetries: 0 argument.
- Open question 8: Keep webdav-api.spec.ts monolithic. Match Dropbox
  precedent (~876 lines, single file move).
- Commit shape: Match Dropbox 5a/5b split. PR 6a = log helpers
  promotion; PR 6b = bulk move + adapter wiring + privacy sweep +
  Nextcloud generic widen + md5HashSync → hash-wasm + CORS tighten +
  spec migration.
- Factory shape: createWebdavProvider(extraPath?: string), not (deps).
  Deps are composed internally from app singletons, matching the
  Dropbox precedent at app-side dropbox.ts:31-43.

Decisions affirmed: md5HashSync → hash-wasm async (with one-line
benchmark in PR description), Nextcloud generic widening to union,
delete TestableWebDavHttpAdapter spec harness.

New privacy blockers surfaced (must fix in PR 6b): URL/basePath leak
via _buildFullPath at four error-construction call sites, PROPFIND
response body fed into HttpNotOkAPIError (contains user filenames),
testConnection returning raw e.message, _buildFullPath throwing
generic Error with raw path, A3 sweep undercount (10+ raw-error log
sites), new B3.4 invariant (FileMeta never enters a Log call site),
and a documented package-boundary invariant that response headers
are not logged or attached to errors.

Action items: PR 6a (helpers promotion) → PR 6b (bulk move). The
original "Open questions" section is preserved below the consensus
as a decision-log for review history, in the same style as the
Dropbox slice doc.
2026-05-12 22:22:41 +02:00
Johannes Millan
bd6863ac84 docs(sync-core-plan): draft WebDAV slice design for multi-review
Pre-implementation design doc for PR 5's WebDAV + Nextcloud slice,
mirroring the structure of the Dropbox slice design doc.

Captures the file-move surface (9 source files + specs), the new
WebDavNativeHttpExecutor port shape (callable type, divergent from
NativeHttpExecutor because of Capacitor WebDavHttp plugin transport
differences), the md5HashSync → hash-wasm migration choice, the
errorMeta / urlPathOnly log-helper promotion as a precursor commit,
the privacy A1/A3/B3.x sweep checklist, and the Nextcloud generic-
parameter / cast cleanup option.

Includes eight open questions framed for multi-review (port naming,
md5 strategy, generic parameter, registerPlugin cleanup scope, CORS
heuristic, retry policy, spec migration, file split). Suggested
three-commit shape (helpers promotion → port introduction → bulk
move) keeps each commit independently green.

No code changes; design doc only.
2026-05-12 22:22:41 +02:00
Johannes Millan
91e53bb488 refactor(sync-providers): move provider error classes
Lift 12 provider-shared error classes (AuthFailSPError, InvalidDataSPError,
HttpNotOkAPIError, NoRevAPIError, RemoteFileNotFoundAPIError,
MissingCredentialsSPError, MissingRefreshTokenAPIError,
TooManyRequestsAPIError, UploadRevToMatchMismatchAPIError,
PotentialCorsError, RemoteFileChangedUnexpectedly, EmptyRemoteBodySPError)
plus AdditionalLogErrorBase and extractErrorMessage into
@sp/sync-providers. App-side sync-errors.ts becomes a re-export shim
so existing call sites and instanceof checks keep working.

The moved AdditionalLogErrorBase drops its constructor-time
OP_LOG_SYNC_LOGGER.log side effect (Option A from the slice design):
privacy responsibility shifts entirely to catch-site logging via the
injected SyncLogger port. A new app-side identity spec asserts the
constructor identity is preserved across import paths so future bundler
or tsconfig drift can't silently break instanceof catches.

HttpNotOkAPIError splits its parsed body excerpt off .message onto a
new opt-in .detail field; getErrorTxt forwards .detail to UI surfaces
so user-visible toasts remain unchanged while privacy-aware logger
paths see only "HTTP <status> <statusText>".

TooManyRequestsAPIError's constructor is narrowed to accept only
{ status, retryAfter?, path? } — closing a latent bearer-token leak
where Dropbox's _handleErrorResponse passed the raw Authorization
header through additionalLog. Callers in dropbox-api and
webdav-http-adapter updated accordingly.

Package gains "sideEffects": false to unlock tree-shaking through the
barrel for consumers that import only error classes.

Slice design and round-2 multi-review findings documented in
docs/plans/2026-05-12-pr5-dropbox-slice.md.
2026-05-12 20:32:08 +02:00
Johannes Millan
b51bd2c9ca
New focus mode rework (#7411)
* fix(android): avoid false WebView version lockout

* fix(android): add WebView block recovery paths

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

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

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

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

Refs #7229

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

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

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

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

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

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

* fix(sync): warn before destructive SYNC_IMPORT actions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Updates:

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

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

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

* feat(focusMode): migrate legacy isSyncSessionWithTracking flag

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

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

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

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

* chore(focusMode): drop dead isStartInBackground setting

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

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

Issues caught in multi-agent review:

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

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

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

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

Two changes:

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

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

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

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

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

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

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

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

Re-add the import and the private readonly _focusModeService injection.
The need for it is now isolated to this PR's mobile-indicator computed,
not the reverted play-button logic.
2026-05-06 21:11:02 +02:00
Johannes Millan
28daf519fb feat(schedule): add week number and date range to nav header
Show "Week N · start – end" in week view and "Month YYYY" in month view,
right-aligned in the schedule nav bar. Move the week/month view toggle
out of main-header into the left of the schedule bar. Replace the "Now"
text button with a circle icon next to the chevrons, and disable prev
navigation when today is already in the displayed range.

- ISO 8601 week numbers via existing getWeekNumber util
- Parse day strings via parseDbDateStr to avoid UTC-midnight TZ skew
- Reuse existing T.F.WORKLOG.CMP.WEEK_NR / T.F.TODAY_TAG_TITLE keys
- Drop the duplicate month-title inside schedule-month; the shared nav
  now owns the label for both views
2026-04-21 21:50:52 +02:00
Johannes Millan
9eeded0845 feat(onboarding): add lightweight onboarding hints
Add onboarding hint UI with translations for all 27 languages.
2026-03-22 19:50:24 +01:00
Johannes Millan
c699fde21d chore: remove plan docs 2026-03-14 13:04:44 +01:00