Commit graph

20807 commits

Author SHA1 Message Date
Johannes Millan
89d5fd39ba fix(android): pop to start destination on back per nav guidelines
The hardware back button replayed the SPA history stack — every bottom-nav
tab switch pushed a window.history entry, so back walked through every
previously visited tab instead of exiting (#7972).

Add AndroidBackButtonService, which main.ts delegates the backButton event to:
overlays still close via window.history.back(); a top-level destination pops
to the configured start destination (or exits if already there); deeper pages
keep normal up-navigation. The start destination reuses getStartPageUrlPath().

Covered by unit + real-Router integration specs. Native backButton/minimizeApp
wiring still needs on-device verification.
2026-06-03 12:41:43 +02:00
Johannes Millan
145ed02774 refactor(config): extract shared start-page URL resolver
Extract the default-start-page resolution from DefaultStartPageGuard into a
pure getStartPageUrlPath() helper so consumers cannot drift. The guard now
delegates to it; behavior is unchanged (guard spec still green).

Prep for #7972, where the Android back handler needs the same resolution.
2026-06-03 12:41:43 +02:00
Johannes Millan
524e827072
chore(log): suppress per-op action log lines during bulk replay (#7981)
A single `bulkApplyOperations` dispatch (startup hydration replay or
remote-sync apply) runs the reducer chain once per op, so the action
logger printed one `[a]<type>` line per op — hundreds of lines for one
dispatch, which reads as a flood of separate dispatches. Gate the per-op
console line behind a bulk-replay flag (intentionally distinct from
isApplyingRemoteOps, which spans the broader async apply window); callers
already log an "applying N ops" summary and the in-memory ring buffer is
still fed.
2026-06-03 12:41:12 +02:00
Johannes Millan
c971e74503
fix(sync): guard convertToMainTask against non-array parentTagIds (#7969)
A SuperSync fresh-client bulk-replay crashed with `TypeError: r is not
iterable` inside handleConvertToMainTask. The crashing value was the
captured op's `parentTagIds` — truthy but non-iterable, which bypassed
the reducer's `parentTagIds ?? parentTask.tagIds` fallback (?? only
catches null/undefined) and blew up the spread.

- Reducer: replace `??` with `Array.isArray` on both `parentTagIds` and
  `parentTask.tagIds` so any non-array shape falls back cleanly.
- convertOpToAction: when a `[Task Shared] convertToMainTask` op carries
  a non-array `parentTagIds`, strip the field on the read boundary so a
  single malformed op cannot poison the bulk-replay loop, and warn with
  opId/clientId/vectorClock/typeof so the historical producer can be
  identified next time it fires.
- Spec covers both the missing-tagIds path and the truthy-non-array path
  (the original `??` chain only handled the former).

The current dispatch sites all pass arrays or omit the field; the
producer is presumed to be a past code version whose ops still live in
the user's server-side op-log.
2026-06-03 12:04:43 +02:00
Johannes Millan
63460195b0 fix(simple-counter): stop habit chart popout sub-pixel jitter
The habit edit popout hard-overrode the chart canvas width/height in CSS
while Chart.js ran responsive + maintainAspectRatio. That fought the
inline size Chart.js writes on every resize, so its ResizeObserver kept
chasing a size it didn't set and the canvas/dialog height oscillated by
under 1px every frame.

Remove the override and let Chart.js own the canvas size. Add an e2e
regression guard that samples the steady-state dialog/canvas height and
asserts it no longer oscillates (was ~0.53px flipping every frame).

Fixes #7957
2026-06-03 10:45:51 +02:00
Johannes Millan
74722ce2d0 feat(config): show version in monospace with click-to-copy affordance
The Settings-footer version is rendered upright in a monospace font so
the distribution channel suffix (I / l / 1 / L) is unambiguous, and a
copy icon plus tooltip make the click-to-copy interaction discoverable.

Closes #7975
2026-06-03 10:45:51 +02:00
dependabot[bot]
b05c47ef04
chore(deps): bump jwt in the bundler group across 1 directory (#7970)
Bumps the bundler group with 1 update in the / directory: [jwt](https://github.com/jwt/ruby-jwt).


Updates `jwt` from 2.9.3 to 2.10.3
- [Release notes](https://github.com/jwt/ruby-jwt/releases)
- [Changelog](https://github.com/jwt/ruby-jwt/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jwt/ruby-jwt/compare/v2.9.3...v2.10.3)

---
updated-dependencies:
- dependency-name: jwt
  dependency-version: 2.10.3
  dependency-type: indirect
  dependency-group: bundler
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 10:03:23 +02:00
johannesjo
493d14238d fix(caldav): reuse shared WebDavHttp plugin registration
The CalDAV client re-registered the WebDavHttp Capacitor plugin under
the same name as the op-log webdav module, triggering "Capacitor plugin
WebDavHttp already registered" warnings on app start. Import the shared
registration instead.
2026-06-03 00:20:57 +02:00
johannesjo
4279d8e819 style(liquid-glass): align nav material with macOS Tahoe
Sidebar / mobile bottom nav now read closer to the Tahoe sidebar
material (Finder, App Store):

- Drop the asymmetric bright-top / dark-bottom inset rim in favor of
  a uniform 1px inner rim plus the existing outer hairline, so both
  navs read as softly-edged Tier A surfaces.
- Bump --lg-radius-lg from 22px to 20px to approximate macOS's
  continuous-squircle window/panel corner.
- Light sidebar: translucent white that picks up the wallpaper's
  primary-tinted radials via the existing vibrancy/blur, instead of
  a flat fill. Dark sidebar: a step darker than --card-bg, matching
  the App Store dark sidebar's tonal relationship to content.
- Selection cue inverted from a raised brighter pill to a depressed
  darker patch (--sidenav-item-active-bg overlay), and the elevation
  shadow on the active row is dropped.
- Active row label uses strong neutral text; only the icon carries
  the brand color (Tahoe's convention: color via icon, not text).
2026-06-02 23:09:47 +02:00
Johannes Millan
fc1da69560 fix(tasks): prevent stale auto-focus stealing focus after panel switch
The detail panel auto-focuses its first item after open via a deferred
setTimeout. A timer scheduled while showing one task could fire after
the user arrow-navigated the list to another task, pulling focus into
the panel (onto a task-detail-item) instead of leaving it on the task
row. Under load this made the #6578 e2e focus test flaky (retries: 0).

Schedule deferred focus through a _scheduleTaskGuardedFocus helper that
captures the task id at schedule time and no-ops if the panel has since
switched tasks. Also clears any pending timer before scheduling, fixing
a latent double-timer in _focusFirst.

Adds a fakeAsync regression spec that fails without the guard.
2026-06-02 20:34:27 +02:00
Johannes Millan
cb6b3d0e7a fix(config): remove duplicate Ctrl+S shortcut on unused saveNote
saveNote was assigned Ctrl+S in the default config but had no handler
and its config UI was commented out (TODO never implemented), leaving
Ctrl+S duplicated with triggerSync. Remove the dead saveNote field,
model entry, form stub, and translation key. Closes #7967
2026-06-02 20:34:27 +02:00
Johannes Millan
5377b4b375 build: package-lock.json 2026-06-02 20:17:51 +02:00
Johannes Millan
54671f87be
chore(deps): resolve 30 Dependabot security alerts (dev/build tooling) (#7960)
* chore(deps): bump plugin-dev dev tooling to patch security alerts

Update vite, vitest, postcss, glob, and minimatch across the
packages/plugin-dev/* sample plugins to clear 26 Dependabot alerts.
These are dev/build tooling for the example plugins and are not
shipped to end users.

- vite >=7.3.2, vitest 4.1.x, postcss 8.5.15, glob 10.5.0,
  minimatch 9.0.9/5.1.9 (all within declared semver ranges)
- clickup-issue-provider: vitest ^3.2.1 -> ^4.1.0 (only manifest change)

Verified test suites pass: clickup (28), automations (109).

* chore(deps): bump minimatch + webpack-dev-server overrides for security

Resolve the remaining root-lockfile Dependabot alerts in build/dev
tooling (not shipped to end users):

- minimatch: the existing `app-builder-lib` override pinned minimatch to
  10.1.1, which npm cascades across electron-builder's entire subtree
  (@electron/asar, @electron/universal, dir-compare, filelist, temp).
  Bump the override to 10.2.5 (within app-builder-lib's ^10.0.3); the
  vulnerable nested copies dedupe to the patched top-level. Clears
  Dependabot #341, #372, #373 (minimatch ReDoS).
- webpack-dev-server: add override to 5.2.4. Clears #593.

Note: electron-builder `npm run dist` packaging not run in this env;
the minimatch bump is a patch within range so impact is expected nil.
2026-06-02 19:44:21 +02:00
Johannes Millan
34d4aa0bbf
feat(tasks): allow dragging tasks into subtask lists (#7962)
* feat(tasks): allow dragging tasks into subtask lists

* fix(tasks): support childless subtask drop targets

* fix(tasks): support dragging subtasks back to main list

* fix(tasks): preserve nested subtask sorting

* fix(tasks): address subtask drag review feedback

* fix(tasks): address drag conversion review

* test: skip onboarding in migration fresh-start e2e

* fix(tasks): harden convertToSubTask guards and dedupe tag cleanup

Address review findings on the drag-to-subtask feature (#7905):

- Keep the section and crud meta-reducer guards in lock-step via a shared
  canApplyConvertToSubTask(). Previously the section reducer stripped a task
  from its section even when the crud reducer rejected the convert (missing
  target parent or self-target), leaving the task top-level yet dropped from
  section ordering on a replayed/concurrent op.
- Reject nesting under a target that is itself a subtask. The UI renders only
  two levels, so deeper nesting would orphan the task and leave the
  grandparent's time aggregation stale.
- Tighten op-log payload validation to require string taskId/targetParentId.
- Dedupe the "remove task ids from all tags" logic shared by
  convertToSubTask/deleteTask/deleteTasks into removeTasksFromAllTags().
- Collapse the tri-state afterTaskId positioning in handleConvertToMainTask
  (undefined and null both prepend via moveItemAfterAnchor).
- Name the DragPointer type in DropListService.

Adds reducer specs for the rejected-target-parent cases.

* fix(tasks): remove empty subtask drop-target for childless parents

The dashed empty sub-task drop zone that appeared under childless parent
tasks during a drag was unwanted UI. Remove it along with its supporting
machinery (the subTaskDropCandidate signal in ScheduleExternalDragService,
the pointerdown candidate-arming in TaskListComponent, the
isEmptySubTaskDropTargetMounted computed, and the related template/SCSS).

Consequence: a task can now be nested by dragging only onto a parent that
already has a subtask list. Dragging a subtask back out to a main list
(convertToMainTask) and nesting into an existing subtask list
(convertToSubTask) are unchanged.

* fix(tasks): reliably convert subtasks dragged to the top-level list

Two issues prevented dragging a subtask out to the top-level list to
convert it into a main task:

- CDK only caches a sibling drop-list's geometry when its enterPredicate
  passes at drag start (_startReceiving). The pointer is always over the
  source subtask list then, so the top-level list was never cached and
  conversion silently failed until an unrelated parent drag warmed it.
  Open a one-microtask accept window at subtask drag start so CDK caches
  the top-level lists' geometry; the pointer guard resumes afterwards.

- An expanded neighbour's subtask list "caught" the drag in the dead-band
  just above the next parent (sibling order resolves subtask lists before
  the top-level list), silently re-parenting the subtask instead of
  converting it, and growing/sticking once entered. Treat only an actual
  subtask row as "inside" a foreign subtask list; its trailing padding now
  falls through to the top-level list for conversion. The source list
  still blocks anywhere, so in-list sorting is unaffected.

* fix(tasks): use midpoint-crossing sort for drag preview placement

CDK's SingleAxisSortStrategy swaps siblings as soon as the cursor enters
any part of their clientRect. For task lists whose item rects span the
parent's full element (header + its expanded subtask list), that swap
displaces the drop preview far below the cursor — landing it past the
target's subtasks instead of where the user is pointing.

Monkey-patch the strategy's `_getItemIndexFromPointerPosition` with a
relative-position midpoint rule (the pattern used by dnd-kit and
react-beautiful-dnd): a sibling only swaps with the dragged item once
the cursor crosses into the half of the sibling that's on the dragged
item's approach side. `enter()` keeps CDK's first-inside semantics so
initial placement still lands somewhere sensible.

Also extend `_pointerSubTaskList()` with a `.sub-tasks` wrapper fallback
so the leading strip between a parent's header and its first subtask
routes the drag into that SUB list at index 0 instead of falling through
to the top-level list and converting at the parent's slot.

* test(tasks): align drag predicate spec data

* test(tasks): reconcile createMockDrop calls after combining drag PRs

Combining the two #7905 PR lineages crossed a stale signature:
the midpoint-sort commit added enterPredicate cases calling the old
3-arg createMockDrop(modelId, filteredTasks, listId), while keilogic's
spec-alignment commit narrowed it to (modelId, listId) since
enterPredicate never reads filteredTasks. Drop the vestigial arrays
from the three affected cases so they match the simplified helper.

* fix(tasks): widen first/last subtask drop targets

Dragging a task to the first/last position of a subtask list was hard:
the midpoint sort patch gives each slot a half-row trigger, and the
two end slots have no neighbouring row to borrow the other half from,
so they were only half of the edge row. The existing "easier dragging"
padding lived on :host, outside the `.task-list-inner` cdkDropList, so
it never grew the CDK hit-rect.

Move that padding inside the sub-task drop list so the strip above the
first and below the last subtask is part of the drop rect. CDK's
enter() then drops a task arriving there as the first/last child
(SingleAxisSortStrategy._shouldEnterAsFirstChild). The matching :host
padding is dropped for sub-lists so the visible gap stays compact.

* refactor(tasks): apply multi-review follow-ups to subtask drag

Hardening and fixes surfaced by the multi-agent review of the
drag-into-subtask feature:

- Scope the CDK midpoint sort patch to VERTICAL lists. It mutates the
  shared SingleAxisSortStrategy prototype app-wide, so horizontal lists
  (boards, issue panel) were silently swept into the new hit-test;
  gating to vertical keeps them on CDK's stock behaviour and sidesteps
  the right-to-left index-inversion question. Update the spec to assert
  the horizontal fall-back.
- Keep first-child re-parent in a sub-list's leading pad. The drop-target
  CSS fix moved the SUB list's top padding inside `.task-list-inner`, so
  part of the header→first-row strip now lives in the drop rect; report
  the leading pad as a row (re-parent) while the trailing pad stays a
  convert-to-main dead-band. Doc comment updated to match.
- Memoise the per-pointer subtask-list hit-test in DropListService so the
  several enterPredicate calls CDK fires per pointer move reuse one
  document.elementFromPoint.
- Tighten convertToSubTask op-log validation to isValidEntityId (rejects
  '' / 'undefined' / 'null') instead of a bare typeof string check.
- Log ids, not full task objects, in the drop handler (titles/notes must
  not reach the exportable log).
- Drop the dead _resetMidpointSortPatchForTests export; note why the
  seeded drag pointer is inert after a plain tap.
- Add direct unit tests for the canConvert/canApply guard pair.

* test(tasks): cover drop()-to-convert dispatch and the drag e2e

Close the two coverage gaps flagged in review:

- Unit: drive the public drop() handler with CdkDragDrop events and
  assert the resulting convertToSubTask / convertToMainTask dispatch,
  including the newIds→afterTaskId placement math (after-anchor,
  first-slot null anchor) and the isDone flag for the DONE list. This
  was previously untested — only _move() and the reducer were.
- E2E: a real CDK drag of a top-level task onto a subtask row, asserting
  it converts to a subtask. Uses the manual stepped-mouse gesture (CDK
  ignores HTML5 dragTo), matching work-view/sections.spec.ts. Verified
  green 3× in a row.

* fix(tasks): cut drop snap-back flicker by emitting default list sync

On drop, CDK tears down its preview/placeholder but never moves the real
DOM node (the list is NgRx-driven), so the un-moved task is briefly
revealed at its old slot until the store re-render lands. customizeUndone
Tasks() pushed *every* work-view list emission through
observeOn(animationFrameScheduler), adding a guaranteed extra frame to
that re-render and widening the visible snap-back.

Keep the frame-defer only for the customized (sort/group/filter) path —
it does heavier work and is driven by CD-bound signals, where a sync emit
can re-enter change detection. The default path is store-driven only, so
emit it on the same tick: the list re-renders without the extra frame and
the dropped task lands in place with far less (ideally no) snap-back.

task-view-customizer spec (43) green; drag-into-subtask e2e green.

* fix(tasks): scope CDK midpoint sort patch to task-list instances

Patch the dragged list's own SingleAxisSortStrategy instance instead of
the shared prototype. registerDropList is only called by task lists, so
the prototype mutation silently changed the hit-test of every other
vertical CDK list (planner, notes, boards, tree-dnd). Shadowing the
method per instance keeps the midpoint-crossing rule scoped to task
lists and leaves all other lists on CDK's stock behaviour.

The CDK-rename guard and 'this' binding are unchanged; the global
isPatched flag is dropped since each instance now patches independently.

* fix(sync): validate afterTaskId in convertToSubTask payload

The convertToSubTask op-log validation branch accepted any payload with
valid taskId/targetParentId, ignoring afterTaskId. Require it to be a
string or null (the action type's contract) so a crafted/malformed sync
payload is rejected at the validation boundary rather than treated as a
not-found anchor downstream.

* docs(tasks): clarify customizer frame-defer and drag-to-done intent

Comment-only. Correct the task-view-customizer note: the customized
path stays on the animation-frame scheduler to batch the signal-driven
emission burst on context switch (commit fddedf3fa6), not because a sync
emit re-enters change detection (the consumer is toSignal). Document
that dragging a subtask onto the DONE list intentionally converts it to
a main task done today, even outside the Today context.

* fix(tasks): keep midpoint sort patch alive across CDK strategy recreation

The previous commit scoped the patch by shadowing the method on the
strategy *instance*. That regressed subtask drag 100% of the time: CDK
rebuilds the SingleAxisSortStrategy on every drag start
(DropListRef.withOrientation runs in its beforeStarted hook), so the
instance shadow was discarded before the first pointer move and task
lists fell back to CDK's stock first-inside hit-test — the exact tall-
item preview misplacement the patch exists to prevent.

Patch the shared prototype instead (survives recreation) but apply the
midpoint rule only when the strategy's container is a registered task
list, delegating to CDK's original otherwise. This keeps the scoping
goal (planner/notes/boards/tree-dnd stay on stock behaviour) without the
recreation fragility. Extract the scoping dispatcher as a pure function
and unit-test it.

---------

Co-authored-by: kei <keletrh@gmail.com>
2026-06-02 19:43:41 +02:00
Johannes Millan
5c3067fd33
fix(sync): stop transient network snack flashing on Android resume (#7961)
The auto-sync fired when the app resumes from sleep/Doze can hit a
not-yet-ready network and throw a transient, self-healing
NetworkUnavailableSPError (or a generic connectivity error for
file-based providers). The retry machinery can't always outlast Doze
recovery, so the "temporary network problem" WARNING snack flashed and
vanished on its own.

Suppress this self-healing snack for automatic syncs (resume/focus/
interval/internal retries) via SyncWrapperService.sync(isUserTriggered);
user-initiated syncs (button, hotkey, config-save) still get immediate
feedback. Detection is provider-agnostic via isTransientNetworkError, so
Dropbox/WebDAV are covered too, without silencing real server errors
(500/503 stay visible). Sync timeouts get the same gate (also
self-healing) so a connectivity error phrased "timeout" can't slip
through the timeout branch and still flash.

To keep persistent problems discoverable without an intrusive snack, the
header sync button gains a state-aware tooltip (Offline / Sync problem /
Syncing / In sync) in place of a static label.
2026-06-02 19:42:50 +02:00
Johannes Millan
9e305510dd
feat(take-a-break): clearer break-reminder action with feedback (#7963)
The break reminder reused the focus-mode "Start break" label, but the
action only pauses tracking — there is no break timer or screen — so the
label over-promised and clicking it looked like it did nothing.

Relabel the action to "Pause & take a break" and show an encouraging
snack on click so the effect is visible.

(An earlier attempt to open the real focus break screen when a session
was running was dropped: Pomodoro never triggers this banner mid-session,
and for Countdown/Flowtime it would force an unwanted break and clobber
the running session.)
2026-06-02 19:42:18 +02:00
Johannes Millan
63d46f7a0b
feat(op-log): validate SQLite backend + IDB→SQLite migration + backend-aware init (#7931) (#7954)
* test(op-log): validate SqliteOpLogAdapter against a real sql.js engine

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The store-init half of B3 (call adapter.init() / skip the IDB open for
self-managing backends) is implemented + CI-tested; only the device-gated
native token flip + SqliteDb wrapper remain.
2026-06-02 17:26:23 +02:00
Johannes Millan
396ba6a7bb
fix(task-repeat-cfg): don't strand today's instance on edit (#7951) (#7955)
* feat(sync): strengthen WebDAV as-is/unsupported warning

* fix(calendar): use actual time spent for done time-block events

When a scheduled task was marked Done, the Google Calendar time-block
event's duration was recomputed with the app-wide remaining-estimate
formula `max(timeEstimate - timeSpent, 0)`. For a completed task that is
meaningless: it shrank the slot to a leftover sliver (estimate > spent)
or collapsed to 0 and hit the flat 30-min default (spent >= estimate).

Branch on `isDone`: done tasks now reflect the actual time spent
(falling back to the estimate, then the 30-min default), while active
tasks keep the remaining-estimate model that mirrors the schedule view.

Closes #7949

* fix(task-repeat-cfg): prevent crash editing recurring task with no start date (#7945)

A repeat config with no stored startDate (e.g. DAILY/MONDAY_TO_FRIDAY or
older configs) let Formly's `defaultValue: new Date()` put a raw Date into
the model — `parsers` don't run on defaultValue. That Date reached
`dateStrToUtcDate`, returned Invalid Date, made the weekOfMonth math NaN,
and `ORDINAL_KEYS[NaN-1]` was undefined, so `translate.instant(undefined)`
threw and broke the edit dialog.

- Default startDate to a 'YYYY-MM-DD' string so the model stays
  type-consistent and no Date leaks into the cfg.
- Guard `buildRepeatQuickSettingOptions` against an invalid Date so a bad
  value can never crash the dialog again.
- Add regression tests for both.

* fix(task-repeat-cfg): don't strand today's instance on edit (#7951)

Editing a schedule-affecting field of a recurring task relocated the
live instance via getNextRepeatOccurrence(), which is exclusive of today
by contract. A still-valid daily/weekly instance was therefore pushed to
tomorrow and lastTaskCreationDay advanced past today, leaving today empty
and never recreated (the reported "task not created on the new day").

Add an opt-in `inclusive` mode to getNextRepeatOccurrence() that scans
from today, with a floor so MONTHLY/YEARLY never resolve to an already
passed anchor, and use it in rescheduleTaskOnRepeatCfgUpdate$. The
default (exclusive) behaviour is unchanged for preview / scheduled-list /
heatmap.

For timed tasks (startTime + remindAt) the reschedule advances to the
next future occurrence when today's start time has already passed, so
relocating an instance never fires an immediate "missed reminder" (#7354).

Tests: unit repro + edge cases (repeatFromCompletionDate, repeatEvery>1
off-cycle, overdue relocation, MONTHLY/YEARLY today+floor, past-time timed
reschedule) and a fail-before/pass-after e2e.
2026-06-02 17:25:54 +02:00
Johannes Millan
a8a2917c5c test(calendar): remove stale duration tests superseded by #7949
The active-task time-block duration formula was intentionally changed in
95537b9dee (#7949) from max(estimate, spent) to max(estimate - spent, 0)
(remaining work), with a new 'event duration' describe block asserting the
corrected behavior. Three older top-level tests from #7950 were left behind:
two duplicated the new done-task cases, and one still asserted the removed
max(estimate, spent) formula, breaking the pre-push test gate.

Remove the superseded block; the 'event duration' tests fully cover it.
2026-06-02 16:11:14 +02:00
Johannes Millan
ded0240b8e docs(task-repeat-cfg): revise RRULE migration plan and consolidate to one doc
Rewrite the recurring-events plan after multi-axis review against the
codebase. Adopt a typed, RRULE-isomorphic recurrence model (RRULE string
at the export/CalDAV boundary only), keep the synchronous occurrence
engine, and route migration through the op-log schema system. Fold the
gap-analysis and industry-standards research docs into the plan as
appendices and remove them.
2026-06-02 16:09:35 +02:00
Johannes Millan
f76d467153 fix(task-repeat-cfg): prevent crash editing recurring task with no start date (#7945)
A repeat config with no stored startDate (e.g. DAILY/MONDAY_TO_FRIDAY or
older configs) let Formly's `defaultValue: new Date()` put a raw Date into
the model — `parsers` don't run on defaultValue. That Date reached
`dateStrToUtcDate`, returned Invalid Date, made the weekOfMonth math NaN,
and `ORDINAL_KEYS[NaN-1]` was undefined, so `translate.instant(undefined)`
threw and broke the edit dialog.

- Default startDate to a 'YYYY-MM-DD' string so the model stays
  type-consistent and no Date leaks into the cfg.
- Guard `buildRepeatQuickSettingOptions` against an invalid Date so a bad
  value can never crash the dialog again.
- Add regression tests for both.
2026-06-02 16:06:31 +02:00
Johannes Millan
95537b9dee fix(calendar): use actual time spent for done time-block events
When a scheduled task was marked Done, the Google Calendar time-block
event's duration was recomputed with the app-wide remaining-estimate
formula `max(timeEstimate - timeSpent, 0)`. For a completed task that is
meaningless: it shrank the slot to a leftover sliver (estimate > spent)
or collapsed to 0 and hit the flat 30-min default (spent >= estimate).

Branch on `isDone`: done tasks now reflect the actual time spent
(falling back to the estimate, then the 30-min default), while active
tasks keep the remaining-estimate model that mirrors the schedule view.

Closes #7949
2026-06-02 16:06:31 +02:00
Johannes Millan
9a7d1d488e feat(sync): strengthen WebDAV as-is/unsupported warning 2026-06-02 16:06:31 +02:00
Johannes Millan
d7e5be08b5
test(e2e): harden flaky auto-start-focus-on-play via hash nav (#7953)
Run #3925 (Build All & Release on master) failed solely on
auto-start-focus-on-play.spec.ts:53 — every other test passed and the
merged commit (local-backup) is unrelated to focus mode, so this is a
flake, not a regression.

The test enabled `autoStartFocusOnPlay` in Settings and then did a hard
`page.goto('/')` reload before pressing play. A full reload re-bootstraps
the app and re-reads the config from IndexedDB, which races the debounced
persistence of the toggle we just flipped: if the write hasn't flushed,
the reloaded app boots with the setting OFF, `syncTrackingStartToSession$`
early-returns, and the focus indicator never spawns — a flake no expect
timeout can fix.

Switch to a same-document hash navigation (`/#/tag/TODAY/tasks`, the
pattern already used by `navigateToMiscSettings`). This preserves the
in-memory NgRx config the toggle updated synchronously, so the test
exercises the auto-start behavior without depending on persistence timing.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 15:24:47 +02:00
Parman Mohammadalizadeh
c9de10a785
feat(projects): add project icons to show/hide projects visibility menu (#7926)
* feat(projects): add project icons to show/hide projects visibility menu #7894

* feat(projects): use checkbox + project icon pattern in visibility menu #7894

Mirrors the established toggle-list convention used in tag menus
(tag-toggle-menu-list, add-task-bar-actions): check_box /
check_box_outline_blank as the state icon followed by the item icon.
Removes the opacity dimming approach and its global SCSS rule.

* feat(projects): handle emoji icons and add a11y attrs in visibility menu #7894

Use single mat-icon with nav-icon/nav-icon-emoji conditional class
(mirrors nav-item sibling) instead of if/else branch. Copy nav-icon
styles into component SCSS since they are scoped to nav-item.
Add role=menuitemcheckbox and aria-checked to match schedule.component
toggle pattern.
2026-06-02 14:22:01 +02:00
dependabot[bot]
fc9321b7e3
chore(deps): bump @schematics/angular from 21.2.12 to 21.2.13 (#7936)
Bumps [@schematics/angular](https://github.com/angular/angular-cli) from 21.2.12 to 21.2.13.
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/v21.2.12...v21.2.13)

---
updated-dependencies:
- dependency-name: "@schematics/angular"
  dependency-version: 21.2.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 14:14:52 +02:00
dependabot[bot]
d96a543dd2
chore(deps): bump @material-symbols/font-400 from 0.44.9 to 0.44.10 (#7934)
Bumps [@material-symbols/font-400](https://github.com/marella/material-symbols/tree/HEAD/font/400) from 0.44.9 to 0.44.10.
- [Release notes](https://github.com/marella/material-symbols/releases)
- [Commits](https://github.com/marella/material-symbols/commits/v0.44.10/font/400)

---
updated-dependencies:
- dependency-name: "@material-symbols/font-400"
  dependency-version: 0.44.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 14:13:07 +02:00
dependabot[bot]
e050d0408c
chore(deps)(deps): bump anthropics/claude-code-action (#7943)
Bumps the github-actions-minor group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.132 to 1.0.133
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](bbfaf8e1ff...787c5a0ce9)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.133
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-02 14:10:12 +02:00
Johannes Millan
64d3219d3a
feat(local-backup): Track A safeguards for #7925 (#7932)
* fix(android): escape JS bridge args via JSONObject.quote (#7925)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No behavioural change. 21/21 local-backup specs + 18/18 startup specs green.
2026-06-02 11:58:36 +02:00
Maikel Hajiabadi
1e2260d0e2
fix(calendar): use actual time spent for calendar event duration on completion (#7950)
* fix(calendar): use actual time spent for calendar event duration on task completion

* test(calendar): add duration calculation tests for time-block sync

---------

Co-authored-by: hajiboy95 <193576843+hajiabadi-finbridge@users.noreply.github.com>
2026-06-02 11:54:42 +02:00
Johannes Millan
3663c8e81d
test(e2e): harden flaky recurring move-start-date-earlier #7724 spec (#7929)
The "moving startDate earlier still projects the new days" test
(recurring-move-start-date-earlier-no-instance-bug-7724) was ~50% flaky
in the scheduled E2E suite. All failures were in test setup, never in the
projection assertion the test actually verifies:

- The Material datepicker Start-date input intermittently dropped the
  first fill while the dialog was still binding (left empty + ng-invalid).
  Retry the fill until the value sticks before committing with Tab.
- page.goto to a SPA hash route was occasionally swallowed by the Angular
  router mid-bootstrap, leaving the work-view on "Today" instead of the
  Inbox project (and sometimes rewriting the fragment back), so a plain
  reload reloaded the wrong route. Add gotoHashRoute: when the expected
  view marker doesn't render, hop through about:blank so the retry is a
  cross-document load that bootstraps fresh on the target URL.
- The post-navigation right-click raced the still-settling task list
  ("element not stable"). Retry opening the context menu with a short
  per-attempt timeout.

Verified 30/30 consecutive passes locally (was 3/6 failing before).

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-01 22:05:11 +02:00
Johannes Millan
7483ba241b
feat(local-backup): harden mobile backups against eviction (#7901) (#7924)
* fix(local-backup): don't overwrite a good backup with empty state (#7901)

After a WebView IndexedDB eviction the live NgRx store can boot empty.
The 5-min local-backup timer would then clobber the last good copy in
durable storage (Android SQLite KeyValStore, iOS file, Electron file)
with nothing. Gate _backup() with hasMeaningfulStateData() so empty/
degraded state can never overwrite a good backup, mirroring the existing
snapshot/compaction empty-overwrite guard.

* feat(local-backup): keep a second backup generation on mobile (#7901)

A single overwritten row/file is fragile: one bad or corrupt write cycle
can erase the only copy. Add a two-generation ring on the single-slot
mobile platforms (Android KeyValStore key, iOS file) — promote the
current backup to a 'prev' slot before overwriting, and restore from the
newest usable slot, falling back to prev when the primary is corrupt or
empty. Electron already keeps rotated, timestamped backups, so it is left
unchanged.

Adds backup-ring.util (isUsableBackupStr / selectBestBackupStr) with full
unit coverage; the iOS write is split behind _writeIOSFile so the promote
ordering is testable without the Capacitor Filesystem proxy.

* fix(android): store a real timestamp in KeyValStore KEY_CREATED_AT (#7901)

ContentValues binds "time('now')" as a literal string rather than letting
SQLite evaluate it, so every row's KEY_CREATED_AT held a constant text
value instead of a write timestamp. Store System.currentTimeMillis() so
the column is actually usable for ordering/debugging.

* fix(local-backup): harden restore paths from multi-agent review (#7901)

- loadBackupIOS: return '' instead of throwing when no usable backup
  exists. askForFileStoreBackupIfAvailable runs from the fire-and-forget
  _initBackups() at startup, so a throw became an unhandled rejection; ''
  degrades to the existing import-error snack and mirrors loadBackupAndroid.
- Consolidate the Android newline escape into loadBackupAndroid (the single
  escape site) and drop the now-redundant re-escape + log line at the call
  site, removing a full-string pass on the restore path.

* feat(local-backup): restore fuller generation, show contents (#7901)

Two restore-path improvements (issue #7901 item 4):

- selectBestBackupStr now prefers the ring generation carrying MORE
  data when both slots are usable (tie -> primary). After an eviction
  the live store can boot near-empty and a 5-min backup may write that
  degraded state to the primary; the full copy survives in prev, and
  restore must surface the full one rather than the newer-but-smaller.

- The mobile restore prompt loads the backup first and names its task
  and project counts (new RESTORE_FILE_BACKUP_MOBILE string), so a user
  never blindly discards the only copy of their data. Falls back to the
  generic prompt for an unparseable blob.

Note: a 'defer backups until the restore decision resolves' guard was
considered but is already structurally guaranteed -- init() (the only
thing starting the 5-min timer) runs after the synchronous restore
confirm in _initBackups, so the timer cannot fire before the decision.

* fix(local-backup): restore the newest backup, not the larger one (#7901)

Multi-agent review of d1e03e550a flagged the restore-time "prefer the
fuller generation" heuristic as unsafe: a legitimate bulk-archive or
delete makes the newer generation smaller, producing the SAME ring shape
as a post-eviction degraded primary. Preferring the larger slot would
then silently restore the older generation and resurrect removed tasks --
a worse, more common failure than the narrow eviction gap it closed.

- Revert selectBestBackupStr to newest-usable-wins (drop backupWeight and
  the size comparison). The prev slot stays a fallback for an empty/corrupt
  newest slot only. The informed prompt (below) is what protects the
  eviction case now: the user sees a substantial backup and accepts it.
- Fix the prompt counts (summarizeBackupStr): include archived tasks so a
  heavily-archived backup doesn't read as empty, and exclude the
  always-present INBOX project so it matches hasMeaningfulStateData.
- Reword RESTORE_FILE_BACKUP_MOBILE: drop the self-contradictory "NO DATA
  ... but a backup with N tasks" framing, and use label-style counts
  ("tasks: N") so it reads correctly for a count of 1 (no ICU plurals).

Deferred follow-ups from the review: a write-time "suspicious shrink"
guard (needs threshold design) and a backup date in the prompt (needs an
Android bridge change to surface KEY_CREATED_AT; iOS has stat mtime).
2026-06-01 21:40:10 +02:00
GibaJr
af73eefc53
Add 'Memos Sync' plugin to community plugins list (#7928)
Added a new community plugin 'Memos Sync' with details including description, URL, author, and stars.
2026-06-01 21:20:47 +02:00
Johannes Millan
558e6a7664 test(e2e): drop dead onboarding preset-click fallback in fresh-start test
The 'app should handle fresh start correctly' test already suppresses the
first-run onboarding takeover via context.addInitScript(skipOnboardingForE2E),
which seeds SUP_ONBOARDING_PRESET_DONE so isShowOnboardingPresets stays false
and the side-nav is never hidden. The follow-up preset-card click was dead:
its isVisible({ timeout: 5000 }) guard is a no-op (Playwright ignores the
timeout on isVisible and returns immediately), and its comment wrongly claimed
the context skips the init script. Remove the fallback and the now-orphaned
[attr.e2e] preset-card hook (no other consumers). Verified test still passes.
2026-06-01 18:58:02 +02:00
Johannes Millan
cc63a566da test(e2e): handle onboarding takeover in fresh-start migration test
The 'app should handle fresh start correctly' test creates a genuinely
fresh context, so it skips the skipOnboardingForE2E init script the shared
page fixture injects and lands on the first-run onboarding preset screen.
Since the onboarding takeover hides magic-side-nav (visibility:hidden),
the side-nav visibility wait timed out. Dismiss onboarding by selecting a
preset before asserting the side-nav.

Add a stable [e2e] hook to the preset cards for the selector.
2026-06-01 18:24:58 +02:00
Johannes Millan
11e2eafcf3 test(e2e): skip onboarding in fresh-start migration test
The fresh-start test boots an unseeded browser context, so the first-run
onboarding preset screen took over. Since #7885 hides the side-nav during
onboarding (visibility:hidden), the test's wait for a visible magic-side-nav
timed out. Seed the onboarding-skip flag before boot like the shared fixture
does, keeping the fresh-empty-storage coverage while reaching the work view.
2026-06-01 18:24:58 +02:00
Johannes Millan
4c239e5691
refactor(op-log): extract a swappable persistence port + SQLite backend (groundwork for #7892) (#7902)
* docs(sync): add SQLite migration plan + Phase A adapter port skeleton

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

Not yet wired into OperationLogStoreService; additive scaffolding only.

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

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

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

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

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

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-01 18:16:16 +02:00
Johannes Millan
2065a3ccaa refactor(ui): prune dead progress-bar labels and tighten collapsible mixin
Post-review cleanup of dead code adjacent to the previous commit:
- Reduce PROGRESS_BAR_LABEL_MAP to the only labels still reachable from a
  countUp() caller (POLL, /issue/); drop the now-unused SYNC/ASSETS/DBX_*/
  WEB_DAV_* entries and their GPB.* strings in en.json (regenerated
  t.const.ts). Unknown URLs already fall back to T.GPB.UNKNOWN.
- Move the collapsible-only :host.isInline rules out of the shared mixin
  into collapsible.component.scss so the mixin is a true common base and
  formly-collapsible no longer ships rules it can never trigger.
2026-06-01 17:47:43 +02:00
Johannes Millan
209db4881a refactor(ui): drop dead progress-bar module and dedupe collapsible styles
- Remove unreferenced GlobalProgressBarModule and the interceptor it
  uniquely registered (dead since the 2024-12-30 standalone migration;
  progress is now driven by explicit countUp/countDown in jira/issue
  services). Component + service are kept.
- Extract shared collapsible SCSS into _collapsible-shared.scss mixin,
  used by both collapsible and formly-collapsible; keep only per-component
  overrides. Fix invalid 'transition: all var(--transition-standard)'
  (double 'all' -> dropped) so the expand icon animates again.
- Drop dead commented-out block from formly-collapsible template.
2026-06-01 17:47:43 +02:00
Johannes Millan
d04ebb5401 refactor(config): reduce duplicated form and selector boilerplate
Add a typed kbField()/subSectionHeading() builder to the keyboard form
and a generic createConfigSectionSelector() factory in the global-config
reducer. Collapses ~48 repeated keyboard field blocks and 16 identical
`cfg?.x ?? DEFAULT_GLOBAL_CONFIG.x` selectors into one-liners; the
builder now type-checks keys against keyof KeyboardConfig.

Field key set and order are unchanged; selectClipboardImagesConfig,
selectIsFocusModeEnabled and selectTimelineWorkStartEndHours stay
hand-written. Drops the dead commented goToFocusMode duplicate only.

Part of #7922.
2026-06-01 17:42:01 +02:00
Johannes Millan
4ab7d2b465 test(markdown): strengthen markdown-parser specs from review
- assert convertToMarkdownNotes preserves absolute indentation (base-indented
  input) instead of a column-0 case that proved nothing
- add tab + no-space-bracket round-trip coverage for convertToMarkdownNotes
- add the at-limit (length 800000) boundary case to the max-length test
- use an explicit \uFEFF escape for the BOM test (a literal BOM is invisible
  and a formatter could silently strip it)
- document normalizeIndentation's in-place mutation contract
2026-06-01 17:38:57 +02:00
Johannes Millan
118f077318 test(markdown): cover convertToMarkdownNotes and shared input handling
Add direct coverage for the previously-untested convertToMarkdownNotes
export, exercise the shared parseLines/splitMarkdownLines setup (CRLF,
BOM, max-length), and add a tripwire documenting the walker divergence
a future shared top-level walker must preserve.
2026-06-01 17:38:57 +02:00
Johannes Millan
089a99769a refactor(core): extract shared setup in markdown task parsing
Fold the duplicated input validation, line split/parse, empty-result
guard and min-indent normalization shared by parseMarkdownTasks,
parseMarkdownTasksWithStructure and convertToMarkdownNotes into
parseLines() + normalizeIndentation(). Extract the identical per-line
checkbox formatting into formatAsCheckboxLine(). The two top-level
walkers keep their distinct nested-item behavior untouched.

Refs #7918
2026-06-01 17:38:57 +02:00
Johannes Millan
52871f752e refactor: remove dead code (#7911)
Remove verified-dead code surfaced by the #7911 simplification sweep
(all confirmed to have zero production references):

- util/create-sha-1-hash.ts (legacy local-file sync; had a broken
  import + leftover console.time)
- util/omit.ts, util/watch-object.ts, util/numeric-converter.ts
- selectAllTasksDueAndOverdue, selectTasksWorkedOnOrDoneFlat selectors
  (plus their specs and now-unused imports)
- getTagOrUndefined helper
- stale commented-out blocks in the task components
- dangling watch-object.ts reference in remove-unused-log-imports.ts

unschedule() was intentionally kept (still called internally), despite
being listed as dead.
2026-06-01 17:38:57 +02:00
Johannes Millan
14a085a2ee refactor(tasks): close detail panel explicitly in hideDetailPanel
hideDetailPanel() previously called setSelectedId(this.task().id),
identical to showDetailPanel(). It only closed the panel by relying on
the setSelectedTask reducer toggle (id === selectedTaskId -> null) plus
the isSelected() guard in handleArrowLeft. Behavior is unchanged, but
setSelectedId(null) makes the intent explicit and drops the toggle
reliance.
2026-06-01 17:38:57 +02:00
Johannes Millan
4be7b5358f refactor: remove dead code and deduplicate platform/op-log helpers
- capacitor-platform: inject IS_ANDROID_WEB_VIEW_TOKEN instead of an
  inline window.SUPAndroid check
- task.selectors: extract isCalendarIssueTask helper (was duplicated)
- sync-import-filter: use isFullStateOpType helper for op-type checks
- take-a-break: drop unreachable BREAK branch in reduce (guarded above)
- plugin-api: remove unused header/menu/shortcut/side-panel tracking arrays
- task-list / task-hover-controls: remove unused drop() arg and inputs
- is-today: remove unused isYesterday
- migrate-to-droid-log: collapse repeated import checks into one regex
2026-06-01 17:38:57 +02:00
Johannes Millan
0396455841 fix(caldav): match getByIds$ issues by id value not array index
The filter used `task.id in ids`, which tests whether task.id is a
key/index of the ids array rather than a member — so it never matched
real issue ids. Use a Set membership check instead.
2026-06-01 17:38:57 +02:00
Johannes Millan
302d5fea12 refactor: address multi-review feedback
- Relocate the memory-efficiency rationale and no-early-exit NOTE from the
  now one-line getLatestFullStateOp wrapper onto getLatestFullStateOpEntry,
  which actually owns the cursor loop.
- getPointerPosition reuses the existing Safari-aware isTouchEvent guard
  instead of re-inlining the 'touches' in event check (free type-narrowing).
2026-06-01 16:56:59 +02:00
Johannes Millan
313625ac11 refactor(tasks): drop unused isIgnoreShortSyntax param from handleUpdateTask
The meta-reducer never read the param (short-syntax handling lives in
short-syntax.effects.ts). The action field is untouched.
2026-06-01 16:56:59 +02:00
Johannes Millan
418a66b756 refactor(util): drop redundant Array.isArray branch in distinctUntilChangedObject
Arrays satisfy isObject ([] === Object([])), so the Array.isArray clause
could only be true when the isObject clause already was.
2026-06-01 16:56:59 +02:00