mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
55 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
4519c5f4d3 |
docs: correct simplification audit findings
Replace stale placements for revised scopes, separate line-level LOC categories, and neutralize superseded implementation guidance. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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 (
|
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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. |
||
|
|
3e56836a23 | docs(plans): add iOS home screen widget port plan | ||
|
|
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> |
||
|
|
421dc353e8 | docs(sync): add sync-engine extraction plan | ||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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). |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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. |
||
|
|
50e2b53d90 | Merge branch 'master' into feat/doc-mode4-2880bb | ||
|
|
508998c6a1
|
Improve on sync (#7736)
* fix(android): restore share title derivation and dedupe shared tasks Commit |
||
|
|
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. |
||
|
|
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. |
||
|
|
e419fd6da5 | docs(supersync): design for generic CONCURRENTLY migration recovery | ||
|
|
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. |
||
|
|
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> |
||
|
|
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.
|
||
|
|
4e1ace0786 | refactor(sync): remove encryption test mocks | ||
|
|
faa18c1638 | docs(sync): add plan to extract encryption primitives to @sp/sync-core | ||
|
|
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).
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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, |
||
|
|
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 |
||
|
|
9eeded0845 |
feat(onboarding): add lightweight onboarding hints
Add onboarding hint UI with translations for all 27 languages. |
||
|
|
c699fde21d | chore: remove plan docs |