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.
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>
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:.
* 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
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.
* 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.
* 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>
* 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>
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.
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.
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.
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>
* 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.
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.
* 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.
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.
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.
* 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>
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>
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>
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.
* 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
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.
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
* 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>
* 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>
* 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.
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
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
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
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.
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.
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.
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.
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
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.
* 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>
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.
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>
* 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
* 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>
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
* 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.