Commit graph

21348 commits

Author SHA1 Message Date
Johannes Millan
ada565c651 fix(android): push header below status bar on API 30-34 with old WebView (#8283)
The native status-bar overlap fallback (pushStatusBarOverlap) was gated to
SDK < 30, but the band that needs it is WebView < 140 AND API < 35: there
SystemBars injects nothing and env(safe-area-inset-top) resolves to 0, so
the header draws behind the status bar. API 30-34 devices on an un-updated
WebView (e.g. de-Googled LineageOS/microG, which doesn't refresh System
WebView via Play) were uncovered.

Broaden the gate SDK < 30 -> SDK < 35 and rename the method accordingly.
The WebView >= 140 early-return still skips the passthrough band, and the
max(env(), overlap) fold self-corrects to 0 when the WebView is natively
inset, so there is no double-count and the worst case is no change.
2026-07-09 15:21:48 +02:00
Johannes Millan
6236dc7458
fix(tasks): add sub-task submit button and commit-on-blur on touch (#8860)
Android/mobile soft keyboards hide the Enter key or deliver it as a composing
keydown that onKeydown filters out (to protect CJK candidate confirmation), so
the inline sub-task draft could not be committed at all: neither Enter, tapping
away, nor any on-screen control saved the text.

- Add an always-visible submit button beside the input as an accessible,
  discoverable commit target (mouse-clickable, screen-reader labelled). Its
  mousedown is preventDefaulted so a desktop click keeps input focus, commits,
  and keeps the field open for rapid entry — mirroring the main add-task bar.
  On touch the tap commits via the blur path below.
- Commit the draft on blur, but only on touch (isTouchActive): the natural
  mobile "done" gesture is tapping away, and the soft-keyboard Enter is
  unreliable there (#8791). Desktop keeps click-away-to-cancel — Enter and the
  button are the reliable commit paths — so blur no longer silently creates a
  task, and a user can move to the button without the draft being committed out
  from under them. Escape still discards on all devices.

Both commit paths read the live input value, so IME/predictive-text buffering
no longer strands the text.

Closes #8856. Supersedes the standalone #8791 commit-on-blur branch.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:16:42 +02:00
Johannes Millan
420292e37c feat(notes): allow outlook: + note/task-app deep-links in URL-scheme allowlist (#8859)
Restore outlook: (reported in #8859) plus the same-class note/task apps
notion:, things:, omnifocus:, bear:, joplin: that the GHSA-hr87 fix also
silently broke. All low-risk registered-app deep-links, same class as the
already-allowed obsidian:/logseq:/zotero:.
2026-07-08 17:12:09 +02:00
aakhter
0e0b82542c
perf(ui): make date/keys pipes pure to stop per-CD formatting (#8854)
* perf(ui): make date/keys pipes pure to stop per-CD formatting

localeDate and scheduledDateGroup were pure:false, so they re-ran (and
localeDate re-allocated a DatePipe + formatted via Intl) on EVERY change-
detection pass — 35-42 times per CD in the schedule month grid alone.
keys was pure:false too; to-array was impure with zero usages.

Make all four pure (delete to-array). Preserve locale reactivity: the
three hot grids (schedule-month/week, planner-day) precompute their day
labels in a computed() keyed on currentLocale(), binding plain strings
(removes the per-cell pipe entirely); the remaining localeDate call
sites pass the reactive locale as an explicit arg (:locale()) so change
detection re-formats only when the locale signal changes.
scheduledDateGroup takes today as an arg. keys becomes pure — and
dialog-time-estimate.deleteValue now replaces the map reference instead
of deleting in place so the pure keys pipe still updates.

SPAP-26

* test(pipes): stub DateTimeFormatService in specs broken by pure-pipe change

SPAP-26 added inject(DateTimeFormatService) to DialogSyncImportConflict and
IssuePanelCalendarAgenda so their templates can pass the reactive locale to
the now-pure localeDate pipe. Both component specs construct the component but
their TestBeds never provided the service, so DI failed at construction with
NG0201 (DateTimeFormatService -> GlobalConfigService -> Store) — 7 + 2 = 9
failing specs.

Provide the same { currentLocale, formatTime } stub already used in the
dialog-sync-import-conflict render block. Both spec files now pass
(9 + 2 SUCCESS locally).

SPAP-26
2026-07-08 17:02:36 +02:00
Johannes Millan
761c08056c ci(sync): detect PR changes via merge-base, not stale base.sha
The E2E Sync PR gate ran the full SuperSync + WebDAV suites on PRs with
no sync changes. The detect job diffed `${BASE_SHA}...HEAD` where HEAD is
the refs/pull/N/merge commit (PR merged into current base) and BASE_SHA is
the event-time pull_request.base.sha. As the base branch advances while a
PR is open, that sha goes stale; since it stays an ancestor of the merge
commit, the three-dot range expands to every commit merged into the base
since the PR opened, tripping the sync patterns on unrelated churn.

Diff the PR head against its merge-base with the live base (HEAD^1) so the
range is exactly the PR's own changes, regardless of base movement. No
extra fetch needed: HEAD^1 and the PR head are both parents of the
checked-out merge commit, and their merge-base is reachable under
fetch-depth: 0. Verified on PR #8722: 250 leaked files -> the real 7.
2026-07-08 16:48:56 +02:00
Johannes Millan
610ca64894
fix(sync): harden file-based .bak recovery, split gap detection & conflict counts (#8857)
* fix(sync): harden file-based .bak recovery and split gap detection

Post-merge review of the SPAP-8/9/10/11 series found five defects in the
file-based sync adapter; all fixed here with regression tests proven
red/green against the previous code. Design cross-checked by a 7-reviewer
multi-agent pass; its findings are folded in.

- Split gap detection suppressed a syncVersion reset when the remote
  clock was EQUAL OR GREATER_THAN the last-seen clock — the exact bug the
  SPAP-9 review follow-up removed from the single-file path. A dominating
  client's snapshot reset (which compacts ops this client never saw) was
  treated as cosmetic, skipping snapshot hydration and silently
  diverging. Now EQUAL only, matching the single-file path.

- .bak recovery staged the CORRUPT primary's rev, which setLastServerSeq
  then promoted to _lastSeenRevs: every later poll's SPAP-10 pre-check
  read "unchanged" and skipped the re-download while the upload path (no
  .bak recovery) kept failing on the corrupt primary — sync wedged until
  another client rewrote the file. A recovery download now never
  stages/promotes the rev (each poll re-recovers and re-seeds the heal
  cache), and every site that rewrites _lastSeenRevs drops any stale
  staged rev via the shared _commitLastSeenRev.

- Snapshot uploads (force-upload / "Use Local" / E2EE re-encryption) left
  the pre-snapshot .bak behind. After a password rotation the stale
  old-key .bak was silently "recovered" by a still-old-key client
  (suppressing its wrong-password prompt) and heal-uploaded back over the
  new-key primary, reverting the rotation. Snapshot uploads now write the
  same payload to .bak FIRST, then the primary (_forceUploadWithBakFirst;
  deliberately FATAL — aborting pre-primary leaves the remote consistent
  for a retry). Applies to sync-data.json.bak and sync-ops.json.bak;
  sync-state.json.bak is deliberately exempt: its adoption is
  ref-validated (EQUAL clock vs snapshotRef), so a stale copy is inert,
  and it must keep serving the compaction crash window it exists for.

- Recovery additionally refuses a PLAINTEXT .bak when encryption is
  expected: decoding trusts the file's own prefix flags, so a plaintext
  .bak decodes even under a wrong/rotated key — the same
  wrong-password-suppression class via mode (rather than key) mismatch.

- The split migration wrote the v3 tombstone BEFORE neutralizing the
  legacy .bak (best-effort): a crash between the writes left a live v2
  .bak that an OFF client's recovery would resurrect over the tombstone,
  forking the folder. Neutralize-first, fatal. Residual: a step-3 failure
  after the migration's ops-file commit leaves a live v2 file until the
  next snapshot upload (documented at the call site).

- sync-ops.json — the hot file, rewritten on every op-bearing sync — had
  no backup at all, so a torn write wedged split sync until a manual
  force-upload. It now gets the same backup-before-overwrite + recovery +
  heal treatment as the single-file format. deleteAllData deletes the
  split files BEFORE the tombstone and treats source-of-truth deletion
  failures as errors (success:false) — it previously left every split
  file behind and reported success.

The three backup/recover pairs are collapsed into shared _writeBakFile /
_readBakFile helpers (the EQUAL||GREATER_THAN divergence above is exactly
the copy-drift failure mode this prevents); .bak file names move into
FILE_BASED_SYNC_CONSTANTS as remote-format surface.

* fix(sync): show the exact pending-op count in the conflict dialog

Compaction can fold still-unsynced ops into the snapshot baseline clock,
so the dialog's vector-clock delta could report "0 changes" right next to
"N local changes pending" — and the false 0 skipped the secondary
USE_REMOTE overwrite confirmation. The dialog now prefers the EXACT
pending-op count carried on LocalDataConflictError (new optional
ConflictData.localUnsyncedOpsCount): it is precisely "what USE_REMOTE
would discard", so both the displayed count and the >= 20-difference
confirmation threshold work from a truthful figure; the clock delta
remains the fallback when no measured count is supplied.
Display/confirmation-only — no clock or op-log semantics touched. Also
asserts the explicit-null lastSyncedVectorClock contract at the
fresh-client conflict throw sites (test gap from SPAP-7).

* chore(lint): enforce tx-handle-only access in op-log transactions

The SQLite op-log adapter serializes every entry point through a
per-connection FIFO queue; awaiting an adapter method inside a
.transaction() callback enqueues behind the transaction's own slot and
silently deadlocks all op-log persistence. The port contract documents
the precondition and #8849 promised a lint rule — this adds it
(no-adapter-in-tx, scoped to src/app/op-log) with RuleTester specs.
Matching is rename-proof: it flags access on the SAME receiver the
transaction was opened on (plain identifiers and any `this.<field>`), so
it does not depend on the field being named `_adapter`; known heuristic
gaps (extracted callbacks, aliasing, method indirection) are documented.
Also corrects the port doc: IndexedDB serializes only overlapping-scope
transactions; SQLite provides the stronger whole-connection exclusion.
2026-07-08 16:45:16 +02:00
Maikel Hajiabadi
1810412015
feat(filetree): add option to add subfolders and items within folders #8525 (#8722)
* feat(filetree): add option to add subfolders and items within folders #8525

* fix(menu-tree): prevent item duplication when creating inside folder

* test(op-log): update ActionType spec and compact code for MENU_TREE_ADD_ITEM

* refactor(filetree): dedupe folder-id prefix strip in context menu

* refactor(menu-tree): add items to folders via existing tree-update op

Rework "add project/tag inside folder" to compute the new tree in the
service and dispatch the existing updateProjectTree/updateTagTree ops,
matching how "add subfolder" and drag-reorder already persist.

Removes the newly-introduced MENU_TREE_ADD_ITEM op type (action, enum
member, compact code 'MA', reducer handler) and the on(addProject)
handler (scope creep). Benefits:
- No new synced op type (the highest-risk change class).
- Forward-compatible: old clients understand updateProjectTree/Tag, so
  the item lands in the folder everywhere (the granular op no-op'd on
  old clients, leaving the item at root).
- _insertNodeIntoFolder falls back to root-append when the target
  folder is missing, so a placement is never silently dropped.

Behavior covered by new MenuTreeService specs (insert, null-folder,
missing-folder fallback, tag move-dedup).

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-08 16:33:40 +02:00
aakhter
eca816a68c
feat(sync): SPAP-11 opt-in split-file sync format for O(delta) syncs (#8802)
* feat(sync): SPAP-11 opt-in split-file sync format for O(delta) syncs

Splits the single sync-data.json into a small always-read/written sync-ops.json
(the commit point) plus a sync-state.json snapshot rewritten only on
compaction / force-upload / gap-repair / migration, so a normal sync transfers
O(delta) instead of O(dataset). Opt-in via the isUseSplitSyncFiles setting; the
legacy file is left as a v3 tombstone so old clients hard-stop rather than
silently diverge.

Review follow-ups:
- Recompaction triggers at combinedOps > MAX_RECENT_OPS (2000) and trims to
  SPLIT_COMPACTION_THRESHOLD (1000), so it no longer rebuilds the snapshot on
  every op-bearing sync once the folder crosses 1000 (test b2).
- Split download stages the ops-file rev to _pendingRevs (not _lastSeenRevs),
  promoted only in setLastServerSeq after durable apply — mirrors the single-file
  crash-safety path (test g).
- Split rev-precheck is gated on sinceSeq > 0, so a forceFromSeq0 download
  (USE_REMOTE / rehydrate) always fetches ops + snapshot (test h).

Stacked on SPAP-9 (#8787, merged) and SPAP-10 (#8795): the split path reuses
their per-provider causality (_lastSeenVectorClocks / compareVectorClocks) and
rev (_lastSeenRevs / _pendingRevs) infrastructure.

SPAP-11

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): return whole file-based ops buffer per download page

The file-based download paths (single-file _downloadOps and split-format
_downloadOpsSplit) truncated recentOps to the caller's page size
(DOWNLOAD_PAGE_SIZE = 500) via slice(0, limit), but they return ops by array
index and ignore sinceSeq. The caller (operation-log-download.service) loops on
`hasMore`, advancing sinceSeq by the returned index-based serverSeq — so when the
buffer exceeds the page size the adapter kept re-returning the SAME oldest slice.
A behind client never received its newest ops and the loop spun until it bailed
(MAX_DOWNLOAD_ITERATIONS / in-memory cap), leaving the sync stuck (success:false)
and unable to converge.

This is the common case for the split format, whose ops buffer floor is
SPLIT_COMPACTION_THRESHOLD (1000) — always above DOWNLOAD_PAGE_SIZE. The
single-file path shares it, latent since MAX_RECENT_OPS was raised 500 -> 2000
(before, buffer == page, so hasMore never tripped).

File-based providers have no server-side cursor (they re-download the whole file
each call), so cross-call pagination cannot advance and truncation just drops the
newest ops. The recentOps buffer is already bounded by MAX_RECENT_OPS, so return
it WHOLE with hasMore=false and let the caller's appliedOpIds dedup decide what is
actually new. Returning everything (rather than slicing to a cap) also converges
safely if a future higher-MAX_RECENT_OPS client writes an over-cap buffer, instead
of re-entering the same stranding loop.

Documents the `limit` divergence on the OperationSyncCapable.downloadOps
interface. Regression tests cover a 600-op buffer with page size 500 on both the
split and single-file paths (newest ops delivered, hasMore=false).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(sync): drop redundant recentOps-length clause from gap detection (SPAP-33)

The file-based gap detector treated a trimming gap as real only when the ops
buffer was also at its cap:

    oldestOpSyncVersion > sinceSeq + 1 && recentOps.length >= <cap>

The length clause is redundant for correctness — syncVersion is contiguous and
every bump carries at least one op, so oldestOpSyncVersion > sinceSeq + 1 already
proves the op at sinceSeq+1 existed and was trimmed away, and it never
false-positives. But the clause caused a false NEGATIVE: a buffer trimmed at a
SMALLER floor than the current cap — a legacy buffer written by an old client
with a lower MAX_RECENT_OPS, or (in the split format) a buffer trimmed to
SPLIT_COMPACTION_THRESHOLD — has a genuine gap while holding fewer ops than the
cap, so the gap was suppressed and the behind client applied ops without the
snapshot base and silently diverged.

Drop the clause in both the single-file (>= MAX_RECENT_OPS) and split
(>= SPLIT_COMPACTION_THRESHOLD) paths, so a behind client correctly falls back to
the snapshot. Updates the single-file test that asserted the suppressed behavior
and adds a split-path regression (short trimmed buffer -> gap + snapshot load).

Previously deferred as SPAP-33; pulled in as it prevents silent cross-client
divergence and sits in the same download-correctness path as the pagination fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-08 14:29:50 +02:00
Johannes Millan
fdbfb2ee9f ci: gate both SuperSync and WebDAV E2E on sync PR changes
Supersede the SuperSync-only gate with e2e-sync-pr.yml, which shares
one change-detection job and one frontend build across both suites and
exposes two independent required checks (SuperSync E2E Gate, WebDAV E2E
Gate). Each suite runs only when its own path set changed; sharing the
build avoids a third frontend build per sync PR.
2026-07-08 13:15:18 +02:00
Johannes Millan
a26dec85c3 ci: cover sync-providers in supersync e2e triggers
The SuperSync client (packages/sync-providers/src/super-sync) drives
the @supersync suite but was absent from both the new PR gate's detect
pattern and the nightly push paths, so a PR editing only the client
could merge green without the suite ever running. Add the package to
both. Also switch the detect grep to a here-string to remove a
theoretical SIGPIPE under pipefail.
2026-07-08 13:15:18 +02:00
Johannes Millan
3a10e14c72 ci: add path-gated SuperSync E2E PR check
Runs the SuperSync E2E suite on PRs only when sync-affecting code
changed, via a detect/build/shard/gate job shape. The always-running
gate job is the required status check, so path-filtered runs never
leave a required check pending. Nightly scheduled run remains the
backstop for cross-cutting changes the path heuristic misses.
2026-07-08 13:15:18 +02:00
aakhter
ae7cfe1646
perf(sync): SPAP-10 skip full download when the remote rev is unchanged (#8795)
Adds a cheap getFileRev pre-check to the file-based sync download path: when the
provider reports the same rev we last confirmed, skip the full sync-data.json
download and return an empty "nothing new" result. Any getFileRev error falls
through to a full download, so the check never fails a sync.

Review follow-ups:
- Allowlist is [Dropbox, OneDrive] — the two providers whose getFileRev is a
  cheap metadata-only call with a content-stable rev. WebDAV and LocalFile are
  excluded: their getFileRev does a full-body read (no bandwidth saved, and a
  changed rev would download the file twice) and can return a weak/coarse
  validator. WebDAV re-add behind a strong-ETag/PROPFIND check: SPAP-29.
- The downloaded rev is staged as pending and promoted to last-seen (and
  persisted) only in setLastServerSeq — after the caller durably applies the ops,
  the same ordering as the seq cursor. A crash between download and apply can no
  longer strand a rev ahead of un-applied ops and skip them on the next poll.

Also isolates the Sync Cycle Cache integration specs onto the LocalFile provider
so they exercise in-cycle cache invalidation without being short-circuited by the
rev-precheck.

SPAP-10

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:05:59 +02:00
John Costa
3dc9dc8db2
fix(tasks): persist collapsed subtask state across restart (#8788)
* fix(tasks): persist collapsed subtask state across restart #8781

Collapse/expand of a task's subtasks was written by non-persistent
actions, so the op-log never captured it and the state reset to
expanded on every reload.

Route collapse changes through the absolute, replay-safe updateTaskUi
op. toggleSubTaskMode now resolves the next _hideSubTasksMode at
dispatch time (extracted into a pure getNextHideSubTasksMode helper)
and persists that absolute value, so it is captured to the op-log and
restored on reload. Remove the old relative toggleTaskHideSubTasks
action and its reducer, which was non-deterministic on replay and threw
on a missing task. Register [Task] Update Task Ui in the ActionType enum
and compaction code map.

* test(tasks): add op-log round-trip test for _hideSubTasksMode #8781

The existing updateTaskUi test only asserted the action's persistence
metadata. Add a test that runs the value through the full op-log path
(dispatch -> capture -> JSON serialize -> convertOpToAction -> reducer
replay) and asserts the collapse value survives. The JSON hop is where a
dropped value would regress on the sync transport and SQLite op-log
backend.
2026-07-08 13:01:04 +02:00
Johannes Millan
c08832c9dc test(tasks): harden notes focus timing spec 2026-07-07 18:28:11 +02:00
Johannes Millan
b7839e9615 fix(e2e): accept overwrite confirmation in provider-switch conflict test
Since #8785, a fresh client with no last-synced baseline gets an
OVERWRITE_WARNING_UNKNOWN confirmation after clicking Keep remote. The
test never accepted it, so the confirm backdrop blocked the later
triggerSync() click and timed out. Accept the confirmation, mirroring
the sibling webdav conflict tests.
2026-07-07 18:27:49 +02:00
Johannes Millan
05f6bd27d1
fix(op-log): serialize SQLite adapter transactions on the shared connection (#8849)
* fix(op-log): serialize SQLite adapter transactions on shared connection

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

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

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

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

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

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

Key the FIFO queue to the connection (WeakMap<SqliteDb>) so every adapter
over one SqliteDb shares one queue. Add a contract test that drives two
adapters over one connection concurrently (verified red with per-instance
keying, green with per-connection). Also: document the re-entrancy
precondition as unenforced (a lint rule, not a runtime flag, is the right
guard — a flag cannot distinguish a re-entrant call from a legal
concurrent one) and correct init/getLastSeq/port-contract doc drift.
2026-07-07 18:00:25 +02:00
Johannes Millan
0d7e011081 fix(tasks): remove spurious scrollbar on single-line add-task input
The add-task-bar title became a cdkTextareaAutosize textarea in the #8812
redesign. The directive measures the content and pins an exact px height,
so the element must never scroll itself, but it had no overflow rule and
fell back to a textarea's UA default of overflow:auto. That paints a
spurious scrollbar on a single line whenever the rendered line box
(16px * 1.4 = 22.4px) doesn't land exactly on the pinned integer height
(font/zoom/platform dependent).

Set overflow:hidden on .main-input; the directive keeps the caret in view
via programmatic scroll, so nothing is lost.
2026-07-07 17:57:03 +02:00
Johannes Millan
7445a9bb9e
fix(ios): clamp bogus third-party keyboard height (#8778) (#8848)
Some iOS third-party keyboards (e.g. Sogou) report a near-full-screen
frame in `keyboardWillShow`. That value flowed unclamped into
`--keyboard-height`, and the global add-task bar is positioned
`bottom: calc(var(--keyboard-height) + var(--s2))`, so it was flung to
the top of the screen instead of sitting above the keyboard. Native
keyboards report a correct height and were unaffected.

Clamp the plugin-reported height to a plausible range, and — only for a
frame the clamp had to correct — override `--keyboard-height` with the
measured obscured area (`baseHeight - visualViewport.height`), the same
reliable signal the non-iOS path already uses. Well-behaved keyboards
skip the override entirely and keep their exact prior behaviour, so this
cannot regress them.

Adds a pure `sanitizeIosKeyboardHeight` util with unit tests.
2026-07-07 17:40:11 +02:00
Myk
c330ab1d7b
feat(tasks): optional setting to sort completed tasks by completion date (#8810)
* feat(tasks): optional setting to sort completed tasks by completion date

Adds an opt-in "Sort completed tasks by completion date (newest first)"
setting under Settings > Tasks. When enabled, the Done task list is ordered
by each task's doneOn timestamp descending; when off (the default) behaviour
is unchanged. Sorting is applied at the doneTasks$ choke point so it covers
the Today list and project/tag contexts alike.

* refactor(tasks): make completion-date sort the calm default, not a toggle

Builds on the previous commit's opt-in setting. Ordering the Done list by
completion date (newest first) is the expected default for reviewing just-
finished work (issue #2936), and no manual done-order is preserved anyway,
so drop the toggle and apply the sort unconditionally at doneTasks$. Removes
the config field + default, the settings-form checkbox, and the T/en.json
label; moves the pure helper to work-context.util.ts with its own spec
(stable-sort and empty-array cases included).

Also guards _move so a reorder WITHIN the now-auto-sorted Done list no longer
emits a spurious moveTaskInTodayList op that would sync to other devices;
dragging a task out of Done (DONE -> UNDONE) still works. Specs cover both.

* fix(tasks): skip keyboard reorder of auto-sorted done tasks

Complements the drag guard from the previous commit. The move up/down/top/
bottom shortcuts on a top-level done task in the main list dispatched a
taskIds-reorder that the completion-date-sorted Done list ignores, emitting
a spurious op that syncs to other devices. Guard _moveAndRefocus (the single
choke point for all four keyboard reorders) so it no-ops for done main-list
tasks. Done subtasks and backlog tasks stay reorderable — neither is
auto-sorted.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-07 17:32:53 +02:00
Johannes Millan
3e56836a23 docs(plans): add iOS home screen widget port plan 2026-07-07 17:08:48 +02:00
aakhter
7f953aae0f
fix(sync): causality-aware conflict gating to cut false conflict dialogs (#8787)
Use vector clocks to resolve file-based sync conflicts by causality instead
of always surfacing the binary USE_LOCAL/USE_REMOTE dialog:

- keep-local when local strictly dominates the remote snapshot
- apply-snapshot (no dialog) when the snapshot strictly dominates local
- dialog when clocks are concurrent and can't be auto-resolved

Cosmetic syncVersion-reset suppression is gated on the last-seen vector
clock and treated as cosmetic ONLY when the clocks are EQUAL. A GREATER_THAN
remote clock proves the writer did more work, not that this client received
the intervening ops (a snapshot can compact ops we never saw), so it now
conservatively triggers a seq-0 resync rather than being silently skipped.

CONCURRENT-snapshot auto-merge defaults OFF and, when enabled, only merges
if the retained recent ops provably bridge the whole gap to the snapshot
clock (local ⊔ recentOps ⊒ snapshot); otherwise it falls back to the
user-recoverable dialog instead of replaying only recentOps and silently
dropping compacted-only entities. Re-enabling default-on is gated on
multi-client E2E validation (SPAP-34).

SPAP-9

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:07:54 +02:00
Mochammad Fadhlan Al-Ghiffari
a0fb90d0a8
docs: update styling breakpoint table (#8846) 2026-07-07 16:50:49 +02:00
Zak Molloy
32e6a2a66d
docs: fix breakpoint names and values in styling guide table #8829 (#8845)
The breakpoint table in docs/styling-guide.md had four wrong values
(xxs at 450px, s at 800px, m at 1000px, l at 1200px) and three wrong
names (s instead of sm, m instead of md, l instead of lg) compared to
the actual SCSS variables in _media-queries.scss and CSS custom
properties in _css-variables.scss.

Co-authored-by: Debian <x@zak.linux>
2026-07-07 16:41:01 +02:00
Johannes Millan
00d2073c27
fix(android): ignore stale native focus-timer completion (#8805) (#8814)
The native foreground-service completion is bridged into the store via
handleNativeTimerComplete$. Its guard only checked purpose/isRunning, so a
stale/late/duplicate native work-completion could complete a *different*,
freshly-started work session — e.g. the one the user just advanced into from a
break with the blue "next session" arrow — and in Pomodoro completing a work
session auto-spawns a break, so the arrow appeared to start another break.

Require the session to have reached its scheduled end by the WALL CLOCK
(now - startedAt >= duration) rather than trusting the stored (and, while
backgrounded, frozen) timer.elapsed. This rejects a completion landing on a
just-started session while preserving the genuine over-run-on-resume case
(#7856), where now - startedAt is already >= duration. Applied symmetrically to
the break branch.

Adds 5 regression tests for the stale/duplicate completion guard.
2026-07-07 16:26:58 +02:00
Johannes Millan
994f0522ff
feat(tasks): redesign add-task-bar layout, toggles, note field & a11y (#8812)
* feat(tasks): move add-task-bar toggles into the actions row

The add-to-top/bottom and search toggles used to overlay the input's
top-right corner. They now sit at the start of the actions row (create
mode) and the search info row (search mode), prepended so they scroll
together with the rest of the row, separated by a dashed divider and
sized to match the neighbouring action chips.

A new ng-content slot on add-task-bar-actions lets the parent project
the toggles into the scrollable row; they are still rendered in the
search row when the action bar is not, so search stays toggleable.

* feat(tasks): wrap long add-task-bar titles onto multiple lines

The title field is now an auto-growing textarea (cdkTextareaAutosize) so a
long title wraps into view instead of scrolling off the right edge, which
is especially helpful on narrow screens. It stays single-line in meaning:
Enter still submits and pasted newlines are collapsed to spaces so no line
break ever reaches the task title.

cdkTextareaAutosize sizes the textarea to rows*line-height and ignores its
own padding, so the vertical breathing room lives on a wrapper element and
the field grows a line at a time. matInput is dropped (there is no
mat-form-field here and it broke the autosize height calc).

E2E selectors that targeted the title by the `input` tag now use the
tag-agnostic `.main-input` class.

* feat(tasks): refine add-task-bar toggles, note field and input autosize

- Move the note control from a labeled chip into an icon toggle in the
  left group (search · note · add-to-top/bottom); hidden in search mode.
- Renumber the input shortcuts: Ctrl+1 add-to-bottom, Ctrl+2 search,
  Ctrl+3 note, Ctrl+4-9 the action chips.
- Keep the submit (+) button's space reserved while empty via opacity
  (visibility is re-asserted by mat-icon's anti-FOUC rule, so it stayed
  visible).
- Animate the note field expand/collapse with the shared @expandFade.
- Fix a cdkTextareaAutosize height lag: the directive measures the
  textarea with its horizontal padding stripped but its width unchanged,
  so padding on the textarea made the measured wrap width wider than the
  rendered one and the field grew a few chars after the text had already
  wrapped (clipped line + scrollbar). Keep the title and note textareas
  padding-free and move the gutters to their wrappers so measured and
  rendered wrap widths match.

* feat(tasks): improve add-task-bar a11y, shortcut order and toggle emphasis

- Renumber the input shortcuts to match the on-screen left-to-right order:
  Ctrl+1 search, Ctrl+2 note, Ctrl+3 add-to-top/bottom (chips stay Ctrl+4-9);
  update the shortcut hints embedded in the tooltips accordingly.
- Add aria-label to every icon-only button and aria-pressed to the search
  and note toggles so screen readers announce name and on/off state.
- Take the invisible submit (+) out of the tab order (disabled while empty).
- Guard the note toggle (and its Ctrl+2) to create mode; it is a no-op while
  searching, where the note field does not render.
- Escape now dismisses an open task-suggestion list before closing the bar.
- Un-dim the toggles (0.6 + :focus-visible so keyboard focus isn't faint) and
  give the submit + full-strength primary tint so the mobile CTA reads as the
  primary action.
- Use the edit_note glyph for the note toggle (clearer than a chat bubble).

* refactor(tasks): apply add-task-bar multi-review cleanups

- De-duplicate the tooltip/aria-label expressions with @let (search,
  backlog, add-to-bottom) so the two attributes can never drift.
- Split the Ctrl-shortcut map into local toggles (1-3) and action
  shortcuts (4-9) so stopPropagation derives from structure instead of a
  parallel key array that had to be kept in sync.
- Restore the "note attached" cue: the note toggle carries .has-value
  (undimmed + tinted) when a collapsed note still holds text, distinct
  from the .active pill shown while the note field is open.
- Remove dead code: the vestigial switch-add-to-btn class on the submit
  button, the unused .search-input rule, a stale duplicated SCSS comment,
  and the orphaned NOTE_BUTTON translation key.
- Document that the projected .inline-action-controls styles must stay in
  the parent stylesheet (ng-content keeps the parent's encapsulation).

* feat(tasks): use notes bubble icon and restore expanded note draft

Match the notes icon used across the task feature (task list item,
detail panel, archived-task dialog) instead of the one-off edit_note
glyph. The glyph flips outline -> filled once the note field has text,
mirroring the detail panel's empty / has-notes states.

Also start the note field expanded when reopening the bar with a
persisted draft note, so it is visible rather than hidden behind the
collapsed toggle.

* fix(tasks): repair startup-overlay selector for add-task-bar textarea

The add-task-bar title field became a <textarea class="main-input">, but
StartupOverlayService still queried 'add-task-bar.global input'. A CSS input
type selector does not match a textarea, so on Android cold-start the partial-
text handoff (cursor position + focus) never ran and the overlay cleared only
via the 3s safety timeout. Target .main-input and retype to HTMLTextAreaElement.

Also address multi-review nits on the same branch:
- guard expandNote() in search mode so Ctrl+Enter cannot strand isNoteExpanded
  (mirrors toggleNote()), with a spec covering it
- drop the unused fadeAnimation import and animations entry
- type the inputEl viewChild as ElementRef<HTMLTextAreaElement> (matches noteEl)
- repoint WorkViewPage.addBtn to .e2e-add-task-submit (.switch-add-to-btn now
  belongs to the add-to-backlog toggle)

* test(tasks): clear add-task-bar sessionStorage between component specs

* docs(tasks): update add-task-bar keyboard shortcuts for new Ctrl mapping
2026-07-07 16:26:36 +02:00
Johannes Millan
b30c861b96
fix(calendar): show agenda-view plugin events without re-navigation (#8811)
Plugin issue-provider calendars (e.g. the Google Calendar plugin) register
asynchronously after the issue-provider store is hydrated. calendarEvents$
read getUseAgendaView() imperatively inside a store-stream map, so on first
load the filter ran before the plugin registered (returning false) and the
plugin's events stayed absent until a re-subscription (navigating away and
back) re-projected the stream.

Expose registration changes from the registry as a BehaviorSubject-backed
registrationChanges$ and fold it into the plugin-provider filter so it re-runs
when a plugin (un)registers. A BehaviorSubject (not toObservable(signal)) keeps
calendarEvents$ emitting synchronously on subscribe.
2026-07-07 13:43:25 +02:00
aakhter
9ae49a9e82
perf(work-context): collapse per-tick activeWorkContext$ churn with distinctUntilChanged (#8806)
selectActiveWorkContext re-runs every second while a task tracks time
(selectTaskEntitiesInActiveProjects gets a new reference each tick),
allocating a new WorkContext whose content is usually identical. That
cascaded: activeWorkContextTTData$ tore down and re-subscribed the
time-tracking state$ merge every second, and activeWorkContextTitle$
re-ran. Add a content-aware distinctUntilChanged (arrays compared by
content, all other fields by reference — correct under NgRx) so no-op
per-tick re-emissions collapse to one while any real change still emits.

SPAP-18
2026-07-07 11:58:03 +02:00
aakhter
cb6780933b
fix(sync): atomic file-based sync writes — backup-before-overwrite + remove force-overwrite race (#8786)
* fix(sync): atomic file-based sync writes - backup-before-overwrite + remove force-overwrite race

Adds a two-phase write for file-based sync (Dropbox/WebDAV/LocalFile): the current remote sync-data.json is copied to sync-data.json.bak before being overwritten (non-fatal if unsupported), and downloads recover from .bak when the main file is corrupt/empty/unparseable, surfacing a recovery notice.

Removes the unconditional isForceOverwrite=true on the rev-match retry path in _uploadWithMismatchFallback: retries stay conditional and a genuine concurrent change throws a retryable error so the next cycle merges the concurrent ops. Force-overwrite of the primary file now originates only from explicit user actions. Also reconciles a stale in-memory syncVersion against the remote on download.

SPAP-8

* fix(sync): recover encrypted/compressed corrupt files and heal the primary

Addresses review feedback on the atomic-writes .bak recovery path:

1. Recovery now triggers for encrypted/compressed primary files. A truncated
   or garbage ENCRYPTED/COMPRESSED sync-data.json fails during decrypt/decompress
   (DecryptError/DecompressError), not JSON parse, so those cases were never
   recoverable — .bak recovery silently no-opped for exactly the users who enable
   encryption or compression, the interrupted-write scenario the feature targets.
   Add both errors to _isRecoverableCorruption. Safe: an undecodable .bak makes
   _recoverFromBackup return null and the original error still surfaces.

2. Recovery heals the primary instead of getting stuck. _downloadSyncFile now
   annotates the corrupt primary's rev onto the decode error, and the recovery
   path seeds the sync-cycle cache with THAT rev (not the .bak rev). The next
   conditional upload then matches sync-data.json and overwrites (repairs) it,
   instead of mismatching forever and re-recovering/re-failing every cycle. Also
   de-dup the recovery snack per corrupt rev so a download-only client (which
   cannot heal the file) does not re-toast on every sync.

Tests: cover both via a REAL decode failure (a compressed body that is not valid
gzip, producing a genuine DecompressError rather than an injected error type) —
one asserting .bak recovery, one asserting the follow-up upload conditions on the
corrupt primary rev and heals the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:57:25 +02:00
aakhter
2fd9acbe60
fix(sync): don't report lifetime op totals as "changes since last sync" in conflict dialog (#8785)
* fix(sync): thread real lastSyncedVectorClock through LocalDataConflictError

The sync conflict dialog reported thousands of changes because getChangeCount() summed the entire (lifetime) vector clock whenever lastSyncedVectorClock was falsy, and the file-based (Dropbox) conflict path hardcoded that field to null - so it always hit the summing branch.

LocalDataConflictError now carries the client's last-synced vector clock (and timestamp). operation-log-sync populates it from the local snapshot clock at the seq-0-download-with-local-ops throw site, and passes null explicitly for genuinely-fresh clients. _handleLocalDataConflict reads it from the error instead of hardcoding null. getChangeCount() drops the sum-the-whole-clock fallback and returns null when no last-synced baseline exists; the dialog renders 'unknown' and still shows the overwrite confirmation, worded without a count.

SPAP-7

* refactor(sync): clarify conflict-count baseline and drop dead lastSyncedUpdate

Addresses non-blocking review feedback on the conflict-dialog change counts:

1. Soften the "last-synced baseline" wording. The snapshot vector clock is an
   APPROXIMATE baseline, not an exact last-synced one: compaction folds
   still-unsynced local ops into the snapshot clock (while only deleting synced
   ops), so a localClock - snapshotClock delta can under-count real local changes.
   Documented on getSnapshotVectorClock() and LocalDataConflictError so the count
   reads as the display heuristic it is.

   Left the count on the vector-clock delta rather than switching the local side to
   error.unsyncedCount: the wholly-fresh-client conflict throws with unsyncedCount=0
   despite meaningful store data, so unsyncedCount would misreport that path as
   "0 local changes" - worse than the current "unknown".

2. Drop the dead lastSyncedUpdate parameter from LocalDataConflictError. All three
   throw sites passed null and nothing ever populated it, so it was pure plumbing.
   The shared ConflictData.local.lastSyncedUpdate field and dialog row are left
   intact; the op-log handler now sets null explicitly (the dialog shows "Never"/"-",
   correct for a no-last-sync conflict).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:51:19 +02:00
oon arfiandwi
0c07053032
feat(plugin): add PluginAPI.request with manifest allowedHosts allowlist (#8721)
* feat(plugin): add generic request capability to PluginAPI

* feat(plugin): gate PluginAPI.request behind manifest allowedHosts

PluginAPI.request is otherwise reachable to any host the shared SSRF
filter does not block. Add a manifest-declared, host-enforced allowlist
so a plugin's outbound reach is explicit and reviewable at install.

- PluginManifest.allowedHosts: exact hostnames the plugin may reach.
- PluginBridgeService enforces it before the shared HTTP/SSRF layer:
  exact host, case-insensitive, trailing-dot tolerant, port-agnostic;
  userinfo tricks resolved via URL parsing; fail-closed (empty/omitted
  allowedHosts disables request entirely).
- validatePluginManifest rejects malformed allowedHosts at install.

Companion tests: bridge enforcement (reject non-declared host,
fail-closed on undefined/empty, case-insensitive + trailing-dot,
userinfo-trick blocked) and manifest validation.

* style(plugin): fix prettier formatting flagged by CI in request API

The generic-request commit slipped past checkFile on three files; CI
prettier flagged the multi-line request() signature and a long spec
assertion string. Formatting only, no behavior change.

* feat(plugin): require "http" permission for PluginAPI.request

Network egress becomes an explicit, opt-in capability (like nodeExecution):
request now requires "http" in the manifest permissions in addition to a
matching allowedHosts entry. Missing either is fail-closed. Enforced
host-side (before the shared SSRF layer) on both the iframe and
Function-sandbox paths; the "http" permission gets a human-readable line in
the plugin security info.

Tests: fail-closed without the "http" permission.

* feat(plugin): surface plugin network reach in the plugin-management UI

Render the manifest allowedHosts as a chip-set beside permissions/hooks
(with a count in the collapsible title), so a plugin's outbound network
reach is reviewable in-app instead of only in the raw manifest.

Tests: allowedHosts shown in the collapsible title, omitted when none.

* feat(plugin): block redirects on PluginAPI.request (SSRF-via-redirect)

Only the initial request URL is allowlist/SSRF-checked, but HttpClient/XHR
auto-follows 3xx — so a declared host could 302 to a private/metadata IP and
return internal content to the plugin. Execute the request path via fetch with
redirect:"error" so any redirect is refused instead of chased.

- New PluginHttpHelperOpts.blockRedirects (default false); PluginBridge sets it
  for request. Issue-provider HTTP is untouched (still HttpClient).
- fetch executor preserves the HttpClient contract callers depend on: query
  params, timeout via AbortController (covering the body read, not just headers),
  text/json parsing, and non-2xx rejecting with .status + .error.
- Only plain objects/arrays are JSON-serialized; FormData/Blob/URLSearchParams/
  ArrayBuffer(View) pass through, and Content-Type: application/json is added
  only for the JSON case (HttpClient parity).
- Web/desktop only: on native, Capacitor patches fetch and ignores
  redirect/signal, so native falls back to HttpClient (documented limitation).
- Blocks all redirects incl. benign same-host ones; manual per-hop re-validation
  is not possible on the web (cross-origin Location is opaque). Documented.

Tests: redirect refused, .status/.error parity, SSRF still pre-checked,
body pass-through, params + responseType:text, real-timer timeout, and native
falls back to HttpClient.

* fix(plugin): gate allowedHosts UI on the "http" capability

The plugin-management panel showed a "Network access" section (and title count)
whenever allowedHosts was non-empty, regardless of permissions — advertising
reach for a capability the bridge rejects without the "http" permission. Gate the
section, the chip loop, and the title count on a getNetworkReachHosts() helper
(hosts only when permissions includes "http"). Addresses review on #8721.

* docs(plugin): note _requestNoRedirect bypasses NetworkRetryInterceptorService

The fetch path skips Angular HTTP_INTERCEPTORS, so PluginAPI.request GETs lose the
single status-0 retry the HttpClient paths keep. Inherent to redirect:"error"
(XHR/HttpClient cannot block redirects). Flagged in review on #8721.
2026-07-07 11:50:17 +02:00
aakhter
0842ffe1fa
perf(planner): drop per-CD getters/allocations in planner-task and kb (#8808)
planner-task took a non-signal @Input task and exposed per-change-
detection getters (titleHasLinks ran a regex scan, timeEstimate, and
the isDone/isDragReady/isCurrent HostBinding getters) that re-executed
on every CD pass, per row. Convert task to a required signal input,
replace the getters with computed()s, and move the class bindings to
host metadata (mirrors schedule-event). The kb getters in task and
main-header allocated a fresh {} on the no-shortcuts path every CD
pass; return a shared frozen EMPTY_KEYBOARD_CONFIG via a pure
keyboardConfigOrEmpty helper so the value is referentially stable.
Behavior (current-task highlight, drag-ready, done styling, shortcut
hints, sub-task count) is unchanged.

SPAP-28
2026-07-07 11:49:46 +02:00
aakhter
e29cd4cd11
perf(tasks): stable per-task selector outputs via per-subscription factories (#8809)
selectTasksWithSubTasksByIds and selectTasksById were module-level
props-selectors sharing one depth-1 memo slot across all concurrent
subscribers, so different id-sets mutually evicted the slot and both
recomputed on every dispatched action. selectTasksWithSubTasksByIds also
rebuilt {...task, subTasks} for every id whenever the entities slice
changed (every second while tracking), so mainListTasks$/backlogTasks$
emitted all-new TaskWithSubTasks arrays each tick and every OnPush row
re-evaluated its template.

Add selectTasksWithSubTasksByIdsFactory / selectTasksByIdFactory that
mint a fresh createSelector per subscription (created inside the
switchMap at each call site) so each subscription owns its memo. The
with-subtasks factory keeps a per-instance cache keyed on the task
entity ref + its subtask entity refs, returning the identical
TaskWithSubTasks object for any task whose refs are unchanged — only the
tracked task gets a new object. Belt-and-braces distinctUntilChanged
(fastArrayCompare) on mainListTasks$/backlogTasks$ suppresses no-op
re-emissions. Output shape/order/filtering are unchanged.

SPAP-19
2026-07-07 11:49:15 +02:00
aakhter
132bd5452c
perf(tasks): stop per-tick O(n) Today read in autoAddTodayTagOnTracking (#8807)
autoAddTodayTagOnTracking listened to addTimeSpent (fires every second
while tracking) via withLatestFrom(selectTodayTaskIds), a continuously
subscribed selector that recomputes computeOrderedTaskIdsForToday — an
O(n) scan over all task entities — every tick because its entity input
churns per second. Reorder the cheap dueDay/dueWithTime field checks
first, add distinctUntilChanged on task.id so the body runs once per
tracked-task change, and read selectTodayTaskIds lazily on-demand
(concatMap + first) only for the rare qualifying action. Membership
semantics, parent-task logic, and the isApplyingRemoteOps hydration
guard are unchanged. Keeps selectTodayTaskIds (dueDay-based virtual
membership) rather than the TODAY tag's ordering-only taskIds.

SPAP-21
2026-07-07 11:48:28 +02:00
Johannes Millan
dc90711cfd
docs(i18n): refresh datetime-locale description across all locales (#8803)
Clarify that spelled-out month/weekday names follow the chosen locale
(e.g. ISO 8601 shows Swedish names), and bring all 27 non-en locale
translations up to the current en text — retiring the stale "controlled
by your OS" note that #8585 already corrected in English.
2026-07-06 19:19:06 +02:00
Johannes Millan
3399491f75
fix(tasks): navigate to Today for context-less tasks from search (#8780) (#8801)
A non-archived task with no project, no tags, and not due today resolved
to an empty location in NavigateToTaskService. Because ''.startsWith is
always true, navigate() treated it as the current context and only tried
to focus a task element that isn't on the /search page, so tapping the
search result did nothing (most commonly an overdue Today/Inbox task).

Route such tasks to the Today list (where overdue/context-less tasks are
shown) and guard against an empty location so it surfaces an error snack
instead of silently swallowing the navigation.
2026-07-06 19:11:51 +02:00
Johannes Millan
462fb616c9
fix(sync): stop file-based E2EE key loss from leaking plaintext (GHSA-9544) (#8800)
File-based providers (WebDAV, Dropbox, OneDrive, Nextcloud, LocalFile)
derived "should I encrypt?" from key presence alone, so a silently
dropped credential-store key made the next sync upload the full
sync-data.json in plaintext with no error — same class as GHSA-9v8x,
which only covered SuperSync.

Root cause: there was no durable, per-provider record of encryption
intent. The global sync.isEncryptionEnabled flag is shared across
providers and re-derived from key presence in the settings form, so it
is unreliable in both directions (stale-true after a SuperSync→file
switch; flipped false by a routine save once the key is gone).

Fix — persist intent per provider, enforce at one boundary, recover
uniformly:

- Store isEncryptionEnabled in each file-based provider's privateCfg,
  written atomically with the key on enable/disable (closes the
  non-atomic disable window) and backfilled from the key present at
  save time / first sync for pre-fix configs. Read everywhere as
  `isEncryptionEnabled ?? !!encryptKey`.
- WrappedProviderService now derives the adapter's isEncrypt from this
  per-provider intent, not the global flag — so a stale global flag no
  longer blocks legitimate plaintext sync and privateCfg writes keep the
  adapter cache correct.
- The upload service fails closed for file-based intent-on-but-key-gone
  via a new adapter hook (isEncryptionKeyMissing), throwing
  EncryptNoPasswordError BEFORE either upload loop — never plaintext,
  and never the permanent markRejected the snapshot path would apply.
- SyncWrapperService routes EncryptNoPasswordError to the enter-password
  recovery dialog on the main sync, force-upload and USE_LOCAL conflict
  paths (previously only main sync; the others dead-ended in a snack).
- The encrypt handler keeps throwing on isEncrypt-without-key as a
  last-line backstop.

Residual: a pre-fix config whose key is dropped before intent is ever
recorded (never synced/saved after upgrade) cannot be distinguished from
"never encrypted" and is not retroactively protected — no record exists.

Regression tests per layer: adapter hooks, upload-boundary guard,
per-provider intent persistence, form derivation/preservation, and
conflict-path recovery routing.
2026-07-06 18:49:52 +02:00
Johannes Millan
172d045222
fix(android): reserve full system-bar inset for the mobile bottom nav (#8792) (#8799)
The bottom nav reserved only env(safe-area-inset-bottom)/2, halved in
c115c46b53 to avoid an oversized gap above iOS's thin home indicator but
applied globally. Android's system bars (3-button/gesture) are opaque and
need full clearance, so half left the nav buttons drawn under them.

Centralize the reservation in a --bottom-nav-safe-area token: half the
inset by default (iOS/web unchanged), full var(--safe-area-bottom) for
native Android (also robust across the SystemBars WebView<140 band where
raw env() reads 0). Applied to nav height/padding, main-content padding
and the snackbar margins so they stay in lockstep.
2026-07-06 18:34:24 +02:00
Johannes Millan
e58c186947
fix(issue): prevent config dialog freeze on multi-select calendar fields (#8798)
Formly runs in immutable mode and skips its own rebuild only when it gets
the exact emitted model reference back. formlyModelChange() ran that emit
through mergeIssueProviderModelUpdates(), producing a new object that
defeated the guard; because immutable mode re-clones array field values on
every rebuild, this spun an infinite rebuild->patchValue->modelChange loop
that froze the app when selecting a "Calendars to display" entry (an
array-valued multiSelect field). Assign the emitted model by reference on
the Formly path; partial custom-cfg updates still merge via updateModel().

Fixes #8777
2026-07-06 18:34:06 +02:00
Johannes Millan
4d5c5a00b4
fix(app): hide donation UI in Mac App Store build (Guideline 3.1.1) (#8797)
The Mac App Store rejected the build under Guideline 3.1.1 for linking to
GitHub Sponsors donations outside In-App Purchase. iOS already hid donation
UI via !IS_IOS_NATIVE, but the macOS App Store (Electron) build had no
equivalent gate.

Detect the MAS build by reusing the existing getDistChannel() preload bridge
(driven by Electron's process.mas -> 'mac-store'). Add IS_MAC_APP_STORE and a
combined IS_APPLE_APP_STORE (iOS || Mac App Store) constant, and gate every
donation surface on it:
- the Donate nav item and the Help -> Contribute link,
- the entire /donate page body (reachable by direct URL),
- the "Contribute to the project" link in the rate dialog (which points to
  CONTRIBUTING.md -> GitHub Sponsors; shown on MAS but not iOS),
- the now-inert "Donate Page" toggle in App-Features settings.

Also closes a pre-existing iOS gap where the /donate page body was reachable
by direct URL. Direct-download, Linux, Windows and web builds keep the
donation UI.
2026-07-06 18:09:59 +02:00
CJ Felux
85204b13c5
fix(menu): resolve camel case label in menu bar #8445 (#8744) 2026-07-06 17:22:51 +02:00
Johannes Millan
2b2b8e6ec6
fix(tasks): allow adding sub-tasks while IME composition is active (#8783)
* fix(tasks): allow adding sub-tasks while IME composition is active

The inline sub-task input committed the titleDraft signal on Enter, but
Angular's DefaultValueAccessor buffers ngModelChange during IME /
predictive-text composition. The signal therefore stayed empty until
compositionend, which many predictive keyboards only fire once a trailing
space is typed. Pressing Enter mid-composition read an empty title and
silently dropped the sub-task, so it could only be added when a trailing
space was present.

Read the live input element value on commit (falling back to the signal
when no DOM is available) and clear the element directly, so composition
buffering no longer strands the draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DresRBp5yMkoLK2NZAKmrV

* docs(tasks): clarify _commit IME comment; fix inaccurate fallback note

The fallback comment claimed the signal path is hit 'in unit tests
without a DOM', but the viewChild resolves in TestBed too, so it is
never exercised there. Reword to state what the fallback actually is
(defensive null-safety) and document why Enter mid-IME-candidate is
already filtered in onKeydown, so a future reader sees the two guards
work together. No behavior change.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-06 17:19:21 +02:00
dependabot[bot]
89847a2f2c
chore(deps)(deps): bump the github-actions-minor group with 9 updates (#8789)
Bumps the github-actions-minor group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [ruby/setup-ruby](https://github.com/ruby/setup-ruby) | `1.314.0` | `1.316.0` |
| [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) | `1.0.159` | `1.0.165` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.36.2` | `4.36.3` |
| [github/codeql-action/autobuild](https://github.com/github/codeql-action) | `4.36.2` | `4.36.3` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.36.2` | `4.36.3` |
| [docker/login-action](https://github.com/docker/login-action) | `4.2.0` | `4.4.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `6.1.0` | `6.2.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `4.1.0` | `4.2.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `7.2.0` | `7.3.0` |


Updates `ruby/setup-ruby` from 1.314.0 to 1.316.0
- [Release notes](https://github.com/ruby/setup-ruby/releases)
- [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb)
- [Commits](9eb537ca03...d45b1a4e94)

Updates `anthropics/claude-code-action` from 1.0.159 to 1.0.165
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](a92e7c70a4...558b1d6cab)

Updates `github/codeql-action/init` from 4.36.2 to 4.36.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](8aad20d150...54f647b7e1)

Updates `github/codeql-action/autobuild` from 4.36.2 to 4.36.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](8aad20d150...54f647b7e1)

Updates `github/codeql-action/analyze` from 4.36.2 to 4.36.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](8aad20d150...54f647b7e1)

Updates `docker/login-action` from 4.2.0 to 4.4.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](650006c6eb...af1e73f918)

Updates `docker/metadata-action` from 6.1.0 to 6.2.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](80c7e94dd9...dc80280410)

Updates `docker/setup-buildx-action` from 4.1.0 to 4.2.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](d7f5e7f509...bb05f3f551)

Updates `docker/build-push-action` from 7.2.0 to 7.3.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](f9f3042f7e...53b7df96c9)

---
updated-dependencies:
- dependency-name: ruby/setup-ruby
  dependency-version: 1.316.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.165
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
- dependency-name: github/codeql-action/init
  dependency-version: 4.36.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.36.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.36.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-minor
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: docker/metadata-action
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-minor
- dependency-name: docker/build-push-action
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  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-07-06 17:18:09 +02:00
Johannes Millan
b3e3722425 test(e2e): re-issue finish-day click until daily-summary nav starts
The finish-day button is a routerLink inside an @if (hasDoneTasks()) /
@else swap, so marking a task done re-creates the element. A one-shot
click can land before Angular wires the new element's routerLink and
silently no-ops, hanging archiveDoneTasks for the full 30s waitForURL.
Retry the click via expect().toPass() until navigation actually starts.

Recurrence of the race first addressed in b8d1dbe261.
2026-07-06 17:04:20 +02:00
Johannes Millan
245330bd68
fix(tasks): shorten reminder snooze button label to fit small screens (#8743)
The reminder dialog footer could not fit all actions in one row on small
screens. Show only the snooze duration (e.g. "10m") on the split-button's
main action instead of "Snooze 10m" — the snooze icon already conveys the
action — freeing horizontal space so the row fits.

Add a `mainLabel` input to the shared split-button so the abbreviated main
action keeps a full tooltip and aria-label for discoverability/accessibility.


Claude-Session: https://claude.ai/code/session_015797BREShEaTkBsA3tRBuX

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-03 23:36:05 +02:00
Johannes Millan
cb586f87e9
fix(sync): report honest status when SuperSync encryption key is missing (#8741)
* fix(work-context): use project icon for header title icon

The header title icon read the active work context's icon, but
selectActiveWorkContext forced icon: null for projects (a leftover
from when projects had no icon field). Pass the project's own icon
through so the header matches the side nav.

* fix(sync): report honest status when SuperSync encryption key is missing

When a provider mandates E2E encryption (SuperSync) but no key is
configured, the GHSA-9v8x guard skips the upload while pending ops stay
unsynced. The low-level result was byte-identical to "nothing to upload",
so the wrapper claimed IN_SYNC — silently one-way syncing (downloads
apply, local edits never leave; device loss = data loss).

Thread an `encryptionRequiredKeyMissing` flag from the upload guard
through UploadResult and the orchestrator's UploadOutcome so the wrapper
reports UNKNOWN_OR_CHANGED (UpdateRemote) instead of IN_SYNC, and
short-circuits the LWW re-upload loop that would otherwise spin against
the same missing key.

Also stop reverting to a keyless config when enable-encryption fails
after the server was already wiped: that left a mandatory-encryption
provider with no key, so the upload guard blocked every future upload and
the wiped server could never be repopulated (the advertised "Sync Now"
recovery was a no-op — account stranded). Keep the new key so the next
sync re-uploads the data encrypted, mirroring the password-change flow.

Refs #8731
2026-07-03 18:35:33 +02:00
Johannes Millan
bf710637d2
feat(android): add home screen widget for today's tasks (#8737)
* feat(android): add home screen widget for today's tasks (#3818)

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

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

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

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

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

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

---------

Co-authored-by: ilvez <1476689+ilvez@users.noreply.github.com>
2026-07-03 18:32:34 +02:00
Johannes Millan
1fde5cf664 docs(security): revise secure-storage plan after multi-agent review 2026-07-03 18:21:16 +02:00
Johannes Millan
08d13fd7d5
fix(local-rest-api): validate PATCH/create field value types (#8738)
PATCH /tasks/:id picked allowed keys but never checked value types, so a
wrong-typed value (e.g. tagIds:123, timeEstimate:"abc") was written into
the store and the synced op-log, corrupting state locally and tripping
typia-as-corrupt on other devices when the op replays. Both PATCH and
create now typia.validate the values and return 400 before dispatching.

Also harden the Electron server: reset isListening eagerly and call
closeAllConnections() on stop so a keep-alive socket can't leave the
server stuck and no-op a later re-enable, reject requests with 503 while
disabled, and log an actionable message on EADDRINUSE.

Closes #8732
Refs #7484
2026-07-03 18:20:33 +02:00
Johannes Millan
25d7d6a379
ci(lighthouse): raise script-count budget to 220, align warn to 210 (#8740)
* fix(work-context): use project icon for header title icon

The header title icon read the active work context's icon, but
selectActiveWorkContext forced icon: null for projects (a leftover
from when projects had no icon field). Pass the project's own icon
through so the header matches the side nav.

* ci(lighthouse): raise script-count budget 200->220, align warn to 210

The Lighthouse `resource-summary.script.count` budget in budget.json was
an exact hard cap of 200 that the app has crept up against as it grows
(master passed at <=200; a recent build hit 201 and failed CI on an
unrelated PR). Bump the hard budget to 220 to restore headroom.

The parallel warn threshold in .lighthouserc.json was 170 - already
exceeded by ~30, so it fired on every run as permanent, meaningless
noise. Raise it to 210 so it sits just below the 220 hard cap and again
functions as an early warning before the budget is breached.
2026-07-03 18:13:23 +02:00
Johannes Millan
421dc353e8 docs(sync): add sync-engine extraction plan 2026-07-03 16:14:22 +02:00