Commit graph

21525 commits

Author SHA1 Message Date
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
Johannes Millan
911f39402d ci(android): publish per-push dev builds to the Play internal track
Fold per-push Android dev distribution into build-android.yml (which
already builds a signed APK on every master push) instead of a second
workflow that duplicated the whole build. On non-tag master pushes it
stamps a dev versionCode into the working copy of build.gradle and, after
the existing build, ships the play APK to the Play 'internal' track so a
fresh build auto-updates on the phone. Internal testing skips review, so
builds land in minutes; dev builds (code base+1..999) and releases
(base+9000) share the track but stay distinct by versionCode. All new
steps are gated to refs/heads/master (non-tag); the tag/release path is
untouched, and every dev step is continue-on-error so it can never break
the release-critical build.

versionCode = last stable release's code + commits-since-that-tag
(first-parent), staying in the 999-slot band above the release and below
the next version's base. The base is derived from the last stable tag
(not the working tree), so a version bump landing on master before its
tag is pushed can't make dev codes jump ahead and then regress. Logic
lives in tools/android-dev-version-code.js (reusing getAndroidVersionInfo)
with unit tests; it degrades to skip on any anomaly.

Reuses existing keystore/Play/Unsplash secrets; no new secrets.
2026-07-03 15:45:33 +02:00
Johannes Millan
b60845b42c
test(sync): reliably dismiss setup-time E2EE dialog in webdav e2e helper (#8727)
#8709 added a mandatory disableClose "Encrypt before first upload?" modal on
every fresh file-based sync setup. The setupWebdavSync helper dismissed it with
locator.isVisible({ timeout }), but isVisible() returns the CURRENT state
immediately and never polls — the timeout is effectively ignored. The modal
opens only after save()'s awaited provider-auth check and a lazy import() of the
dialog chunk, so it appears a beat after the Save click; isVisible() raced it,
returned false, and the Skip click was never issued. The leftover backdrop then
blocked every following interaction, failing ~11-12 @webdav specs per run with
"cdk-overlay-backdrop intercepts pointer events" and conflict dialogs that never
appeared. It passed only when the modal happened to already be up at the instant
of the check, which is why the failures looked flaky.

Use waitFor({ state: 'visible' }), which actually polls until the modal appears,
then click Skip and confirm it closes. Apply the same wait to the encrypt-at-
setup fill path. Verified green via a full @webdav CI run.
2026-07-03 15:27:48 +02:00
Johannes Millan
a927b3347b feat(work-context): show active project/tag icon in header title 2026-07-03 14:52:47 +02:00
Johannes Millan
2ddc16053c
fix(task-repeat-cfg): don't crash on a deleted repeat config (#8715) (#8726)
* ci(release): explain App Store 'previous version in review' failures

When a release is tagged before the previous version clears Apple review,
App Store Connect refuses to create the version or attach the build, and
the lane died with a raw spaceship stacktrace ('cannot create a new
version...' / 'pre-release build could not be added'). Both hit v18.13.1
(iOS + macOS) and v18.12.1 (macOS).

Match those two messages and replace the stacktrace with a one-line,
actionable annotation (let the previous version finish review, or pull it
from review, then re-run) -- still a hard failure, since the version did
not go out and a green run would falsely read as 'released'. The existing
'review submission already in progress' race stays a soft success.

Static annotation text only (no e/options interpolation; key-material).

* fix(task-repeat-cfg): don't crash when completing a task with a deleted repeat config

selectTaskRepeatCfgById throws on a missing config; completing a recurring
task (or editing its subtasks) whose repeatCfgId dangles — e.g. after
cross-client sync deleted the config — crashed the whole app. Route those
task-derived lookups through the non-throwing selector and skip instead. (#8715)

* fix(task-repeat-cfg): guard remaining deleted-repeat-config crash paths

Extend the #8715 fix to the three task-derived UI lookups that still used
the throwing selectTaskRepeatCfgById: move-to-project (task + context menu)
and the edit-repeat dialog. A task whose repeatCfgId dangles (e.g. the config
was deleted via cross-client sync) no longer crashes the app on these actions
— move falls back to a plain task move, the edit dialog aborts and closes. (#8715)
2026-07-03 14:49:24 +02:00
Johannes Millan
98ce93eb0e
fix(boards): keep Eisenhower "Not Completed" filter across restarts (#8723) (#8725)
* ci(release): explain App Store 'previous version in review' failures

When a release is tagged before the previous version clears Apple review,
App Store Connect refuses to create the version or attach the build, and
the lane died with a raw spaceship stacktrace ('cannot create a new
version...' / 'pre-release build could not be added'). Both hit v18.13.1
(iOS + macOS) and v18.12.1 (macOS).

Match those two messages and replace the stacktrace with a one-line,
actionable annotation (let the previous version finish review, or pull it
from review, then re-run) -- still a hard failure, since the version did
not go out and a green run would falsely read as 'released'. The existing
'review submission already in progress' race stays a soft success.

Static annotation text only (no e/options interpolation; key-material).

* fix(boards): keep Eisenhower "Not Completed" filter across restarts (#8723)

The fixBuggyDefaultBoardFilters normalizer ran on every loadAllData and
reset any Eisenhower quadrant with taskDoneState UnDone back to All. With
stable panel IDs it could not tell the old buggy default apart from a
user's intentional "Not Completed" choice, so that choice was clobbered
on every restart.

Drop the Eisenhower taskDoneState branch; new installs already get the
relaxed All default from DEFAULT_BOARDS. The Kanban DONE branch is kept
(separate, unreported concern).
2026-07-03 14:48:50 +02:00
Johannes Millan
4d94be258d feat(plainspace): use Plainspace brand icon for header open link 2026-07-03 14:36:18 +02:00
Johannes Millan
cbeb7c704f
fix(sync): name the discarded title in LWW conflict banner + fix fr dismiss label (#8694) (#8724)
* ci(release): explain App Store 'previous version in review' failures

When a release is tagged before the previous version clears Apple review,
App Store Connect refuses to create the version or attach the build, and
the lane died with a raw spaceship stacktrace ('cannot create a new
version...' / 'pre-release build could not be added'). Both hit v18.13.1
(iOS + macOS) and v18.12.1 (macOS).

Match those two messages and replace the stacktrace with a one-line,
actionable annotation (let the previous version finish review, or pull it
from review, then re-run) -- still a hard failure, since the version did
not go out and a green run would falsely read as 'released'. The existing
'review submission already in progress' race stays a soft success.

Static annotation text only (no e/options interpolation; key-material).

* fix(sync): name discarded title in LWW conflict banner; fix fr dismiss label (#8694)

* fix(sync): surface the last discarded title in LWW conflict banner

Multi-review follow-up to the #8694 banner:
- Show the LAST non-empty discarded title (the user's final offline rename),
  not the first — a stale intermediate rename is misleading. Also simpler
  (drops the first-wins guard).
- Document why the kept==discarded equality guard is correct: when a title
  edit loses to a concurrent other-field remote win, the current state still
  shows the rejected local title, so an annotation would only repeat it —
  silencing it is right, not a hidden divergence.

fr.json G.DISMISS "Rejeter"->"Ignorer" (prior commit) is an intentional,
owner-approved deviation from the en-only rule: it is a mistranslation fix
for the shared banner dismiss label, not a new string. Other locales may
carry the same "Reject"-flavored label — left as a follow-up.
2026-07-03 14:13:43 +02:00
Myk
8003a9e125
fix(tasks): treat a missing title as blank instead of crashing isBlankTask #8713 (#8714) 2026-07-03 11:13:26 +02:00
Myk
108820695e
fix(issuePanel): allow removing an issue provider whose plugin is uninstalled #8711 (#8712) 2026-07-03 11:12:04 +02:00
Johannes Millan
10a47888a6
feat(sync): offer E2EE before first upload for file-based providers (#8709)
* feat(sync): offer E2EE at setup for file-based providers

File-based providers (WebDAV, Nextcloud, Dropbox, OneDrive, local file)
support optional client-side encryption but, unlike SuperSync, have no
mandatory-encryption upload guard — so their first setup sync would ship
plaintext before a user could enable E2EE.

Offer to set an encryption password during first-time setup and persist it
as part of the SAME config save: the key goes to the provider's privateCfg
and isEncryptionEnabled to the global config, atomically with isEnabled.
The normal first sync then encrypts from the first op via the standard
download-first flow — no separate snapshot-overwrite and no plaintext-upload
race. Skipping keeps the existing unencrypted behavior; works offline too.

Reuses DialogEnableEncryptionComponent in a new side-effect-free
collectPasswordOnly mode (returns the password, writes nothing), with a
provider-aware Skip action for the disableClose setup modal.

* test(sync): skip file-based setup encryption prompt in webdav e2e helper

Fresh file-based setup now opens the optional "Encrypt before first upload?"
dialog before the config is persisted, so setupWebdavSync would stall on the
disableClose modal. Dismiss it via a new Skip selector so every WebDAV spec
proceeds unencrypted, exactly as before; encryption specs still enable it
afterwards via enableEncryption().

* test(sync): add e2e for file-based setup-time encryption

Adds a @webdav @encryption spec that configures WebDAV with the encryption
password set in the setup dialog, then asserts the first upload's remote
sync-data.json does NOT contain the task title in plaintext (compression is
off by default, so a plaintext upload would) — proving the first sync is
encrypted — and that a second client with the same password decrypts and
receives the task without overwriting the remote.

setupWebdavSync gains an encryptAtSetup option (fills the new dialog instead
of skipping it); adds e2e selectors on the setup dialog password/confirm/
submit controls.

* ci(e2e): allow targeting the webdav job via workflow_dispatch grep

The webdav e2e job hardcoded --grep "@webdav"; add a webdav_grep dispatch
input (default @webdav, mirroring the supersync job) so a specific WebDAV
test can be run on demand.

* test(sync): fix remote sync-file path in setup-encryption e2e

Non-production builds nest the file under a /DEV segment
(sync-providers.factory.ts). The raw-fetch assertion omitted it and 404'd;
the CI trace confirmed the app uploaded to <folder>/DEV/sync-data.json and
that the first upload was encrypted.

* test(sync): cover joining an unencrypted remote with setup-time encryption

Adds the data-safety edge case for file-based setup-time E2EE: a client that
sets a password at setup while joining a remote that already holds UNENCRYPTED
data. Asserts the join reads and preserves the existing data (does not overwrite
the remote with the joining client's empty state) and that a subsequent write
upgrades the remote to encrypted (no plaintext titles remain).
2026-07-02 17:17:01 +02:00
Johannes Millan
5c9c0c2131 18.13.1 2026-07-02 16:41:38 +02:00
Johannes Millan
b9fa3bdfc1 test(focus-mode): stub matchMedia so inline-launch tests are hermetic
macOS/Windows CI runners report prefers-reduced-motion: reduce, making the
component skip the rocket animation and dispatch startFocusSession
immediately. The spec read the host setting directly, so the three
inline-launch assertions failed on those platforms (release build 18.13.0)
while passing on Linux. Stub matchMedia in both startSession describe blocks
and add coverage for the reduced-motion shortcut.
2026-07-02 16:38:43 +02:00
Johannes Millan
429dc4c7e9 ci(release): tolerate concurrent App Store review-submission race
The iOS and macOS release lanes both run on a v* tag push and can race
on App Store Connect's per-app review-submission state. When they
collide, the lane that submits second fails with 'A review submission is
already in progress' -- but only after its binary has already uploaded
and processed successfully, so the build is safe.

Route both lanes through a shared submit_to_app_store helper that treats
that one collision as a soft success and emits a GitHub Actions warning
annotation so the required manual step (add the build to the open
submission) stays visible on a green run. Every other failure, and any
failure before upload (e.g. the version-creation race), still aborts the
lane loudly.
2026-07-02 16:38:43 +02:00
Johannes Millan
2261160436
fix(sync): scan all tags when archiving to avoid dangling tag refs (#8710)
* fix(sync): scan all tags when archiving to avoid dangling tag refs

The archive path only cleaned tags named in each task's own tagIds, so a
one-sided tag->task reference (tag.taskIds holds an id the task omits) left
behind by a sync replay was never removed. That dangling reference later
tripped cross-model validation and forced a reconciliation/REPAIR.

Lift removeTasksFromAllTags into the shared helpers and reuse it in the
archive path so cleanup is symmetric with the delete path (scans every tag).
Add regression coverage.

* refactor(store): route project-delete tag cleanup through shared helper

Replace the hand-rolled all-tags scan in handleDeleteProject with the shared
removeTasksFromAllTags helper, matching the delete and archive paths (and
gaining its no-op skip for tags that don't reference the deleted tasks).

Move the helper into the STATE UPDATE HELPERS section (it is a state->state
transform, not a list helper) and let its JSDoc own the one-sided-tag-ref
rationale, trimming the duplicated archive call-site comment to a pointer.

No behavior change: final tag taskIds are identical; only unaffected tags now
keep their reference identity instead of being rewritten to equal arrays.
2026-07-02 16:37:39 +02:00
Johannes Millan
78e546ff6a 18.13.0 2026-07-02 15:25:39 +02:00
Johannes Millan
a740618d50 docs: prefer GitHub Actions for E2E suites 2026-07-02 15:15:52 +02:00
Myk
310e8cf2db
fix(plugins): spawn nodeExecution scripts via process.execPath so packaged apps work #8707 (#8708) 2026-07-02 15:05:57 +02:00
Johannes Millan
d00e2df354
refactor(ui): unify primary/secondary action button treatment across dialogs (#8706)
Standardize dialog actions: primary=mat-flat-button color=primary,
secondary/cancel=mat-button, destructive=color=warn. Drop dead Bootstrap
'btn btn-primary' classes and decorative check/close icons. Document the
convention in docs/styling-guide.md and update e2e selectors that keyed
on the old stroked variant.

Closes #8683
2026-07-02 14:42:59 +02:00
Johannes Millan
e7d98439a8
feat(update-check): notify desktop users about new releases (#5463) (#8705)
* feat(update-check): notify desktop users about new releases (#5463)

* fix(update-check): build release URL locally, use HttpClient with timeout

* feat(update-check): add translations for all locales
2026-07-02 14:13:09 +02:00
Johannes Millan
b692eb72ce
feat(rate-dialog): calm, recurring, win-timed store rating prompt (#8704)
* feat(rate-dialog): native store review + prompt after a productive win

Baseline of PR #8680 (squashed) so review improvements land as a separate,
cherry-pickable commit on top.

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

* feat(rate-dialog): calm banner, feedback cooldown, delayed win prompt

Review + UX follow-ups on top of PR #8680:
- Fix (correctness): re-check full eligibility at prompt fire-time, not just
  opt-out, so a crash/data-damage recorded after arming still suppresses it.
- Fix: iOS advances the rating cadence only once the native request resolves;
  a reject leaves eligibility intact instead of burning a lifetime prompt.
- Fix: Android review-flow failure now logs and abandons instead of opening the
  Play Store unprompted (the trigger is an automatic win, not a user tap).
- UX: web/electron/F-Droid now show a calm, non-modal banner that opens the full
  rate/feedback dialog on request, rather than a modal shoved in mid-flow.
- UX: 'give feedback' no longer permanently opts out — it starts a long cooldown
  (~90 app-start days) so an engaged user can still be asked once more later.
- UX: the win prompt fires a few seconds after the completion, not on the tap.
- UX: dedicated 'Send feedback' entry in the Help menu (GitHub Discussions).
- UX: show the maintainer email as selectable text (mailto: dead-end fallback).
- Refactor: move selectTodayProgress into work-context.selectors (colocation);
  idempotency guard on the win subscription.

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

* test(rate-dialog): import WIN_PROMPT_DELAY_MS in spec instead of mirroring it

Export the delay constant from the service and reference it in the spec so the
test can't silently drift from the real value. Found by a review pass.

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

* feat(rate-dialog): recurring cadence, version-age gate, GitHub star CTA

Growth-focused follow-ups (review found the lifetime cap starves review
recency/velocity, which is what stores rank on):

- Recurring cadence: after the fixed onboarding tiers (32/96 app-start days) the
  prompt no longer stops forever — it recurs every ~180 app-start days (~6+
  months of real use), well inside Apple's ~3/365 allowance and Play's own
  quota. Still calm: win-timed, opt-out/crash/feedback-gated, OS-throttled.
- Version-age gate: hold the prompt for 7 days after the app version changes so a
  fresh (possibly regressed) release isn't asked to be rated immediately. Tracked
  via two device-local LS keys, checked at arm time and fire time.
- Play tier-burn is now only a deferral, not a lifetime loss (recurring cadence).
- Web/Electron CTA: 'Star us on GitHub' (the desktop-distribution equivalent of
  store ranking) instead of the near-zero-conversion how-to-rate doc.

Tests cover recurrence at + beyond the last tier, the version-age gate, and the
updated cadence expectations. 60/60 green; tsc clean.

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

* fix(rate-dialog): register iOS plugin, drop version-age gate, cut per-tick churn

Second multi-review pass (7 reviewers):

- CRITICAL (Codex, verified): the iOS StoreReview Capacitor plugin was never
  registered — CustomViewController registers WebDavHttpPlugin but not
  StoreReviewPlugin, so requestReview() rejected and the native App Store review
  card never showed. Register it via registerPluginInstance.
- Remove the version-age gate (4 reviewers): getAppVersionStr() changes every
  release and the app ships ~weekly, so the 7-day window kept re-arming on every
  update (web SW reload / Electron auto-update) and near-permanently suppressed
  the prompt on desktop — the platform where the GitHub-star intent matters most.
  It also duplicated the 30-day crash gate, which already covers crashing
  regressions. Drops 2 LS keys, a constant, and ~40 LOC.
- Perf: add distinctUntilChanged on the armed win stream so the 1s time-tracking
  tick (new {done,total} with identical numbers) no longer re-runs scan/filter
  every second for the whole session.

59/59 specs green; tsc clean. Kotlin/Swift can't be compiled here — the iOS
registration needs an on-device/simulator smoke test.

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

* chore(i18n): translate new rate-prompt strings into all locales

Add BANNER_ACTION, BTN_STAR_GITHUB (F.D_RATE) and SEND_FEEDBACK (MH.HM) to all
27 non-en locale files — previously they existed only in en.json and fell back
to English for everyone else. SEND_FEEDBACK reuses each locale's existing
feedback wording for consistency; GitHub is kept untranslated.

Deliberately edits locale files beyond the usual en-only workflow, per request.
Best-effort translations — a native check on the less-common languages is
welcome.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 13:52:10 +02:00
jibin jose
393f686e4b
feat(theme): add global wallpaper with per-context override (#8643) (#8663)
Fixes the Today background leaking onto non-context pages (Planner,
Schedule, Boards, Config) and the startup flash reported in #8643.

The active work context stays "Today" (the reducer default) on pages
that aren't a tag/project, so its background was shown there wrongly.
Resolve the background per-route instead: per-context image -> global
wallpaper -> none, with overlay-opacity and blur following whichever
image is actually shown (never the sticky context's on global pages).

- add a global wallpaper (image dark/light + overlay opacity + blur) to
  MiscConfig, surfaced via a "Set wallpaper..." dialog under Theme
- resolveBackground() replaces the URL image helper and returns the
  styling source; a cleared/empty image falls back to the global one
- app.component derives opacity/blur from the resolved source

Builds on the URL-aware background stream contributed in the PR.

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Co-authored-by: jibin7jose <jibin7jose@users.noreply.github.com>
2026-07-02 12:53:38 +02:00
Johannes Millan
db99a4c345
fix(tasks): refocus add-task input after clicking + button (#8703)
Clicking the + button moved focus onto the button, which unmounts once
the input clears, dropping focus to <body>. Route the click through a
new onSubmitBtnClick() that refocuses the input after addTask() settles
so the next task can be typed right away. The Enter-key path already
retained input focus, so it is unchanged.
2026-07-02 12:53:30 +02:00
Johannes Millan
6c1117b7b0
fix(gitlab): reject bare project slugs in config validation (#8701)
The #8667 regex only rejected pasted display names (values with spaces),
so a bare single-segment slug like `test_config` still passed and produced
a confusing 404 at poll time (#8665). A GitLab project reference must be a
numeric ID or a namespace-qualified path (`group/project`, subgroups, or
the `%2F`-encoded form) — the REST API cannot resolve a bare slug (verified
live: `/projects/gitlab/issues` → 404, `/projects/gitlab-org%2Fgitlab/issues`
→ 200). Require a path separator for the non-numeric branch so the mistake
gets inline feedback (Save/Test are gated on form validity) instead. The
separator lookahead keeps the char class a single unnested quantifier.
2026-07-02 12:53:12 +02:00
Johannes Millan
222fa4c8aa
feat(sync): show banner when LWW sync discards a user content edit (#8694) (#8702)
* feat(sync): show banner when LWW discards a user content edit

Auto-resolved LWW conflicts were only surfaced as a generic "N local/
remote wins" count snack, so a field-level edit silently dropped by
last-write-wins (e.g. a title edit lost to a concurrent notes edit) was
invisible to the user (#8694).

Split the resolution outcome: routine self-healing (reschedule/repeat/
archive/done churn) keeps the quiet count snack, while a resolution that
discarded a genuine content edit (task title/notes/subtasks/attachments)
shows a dismissible banner naming the affected task(s).

- New pure summarizeLwwResolutions() classifier in @sp/sync-core (inspects
  the losing side's changed fields; UPDATE only, so create/delete/move and
  scheduling churn stay routine).
- Titles are HTML-escaped before the innerHTML banner (they come from
  synced remote data) and never logged.

* fix(sync): correct + simplify LWW content-conflict notice (multi-review)

Address multi-agent review of the previous commit:

- CRITICAL: the classifier's multi-entity branch made the feature a no-op
  in production. Captured ops are always wrapped as
  { actionPayload, entityChanges: [] } and task edits never populate
  entityChanges (only time-tracking does), so a real title/notes edit was
  read as having no changed fields → classified routine → banner never
  fired. Fixed by dropping the branch and relying on extractUpdateChanges
  (which unwraps actionPayload), gated to UPDATE ops. Spec now uses the
  real wrapped payload shape so this can't regress.
- Move the classifier out of the framework-agnostic @sp/sync-core package
  into the app (findLwwContentConflicts); it held app-specific TASK field
  names and was exported but unused. TASK-only, no generics/callback.
- De-duplicate content conflicts per task (one task can yield several
  concurrent conflicts) so the banner never lists a title twice.
- Direction-neutral wording ("Older edits may have been discarded") since
  the discarded side depends on which client won.
- Use the banner's built-in dismiss button instead of a no-op action.
- Consolidate escapeHtml: escapeHtmlAttr now re-exports the shared util.
- Guard against a non-string title before trim().

* fix(sync): drop dead 'attachments' from LWW content fields (review)

Second-review WARNING: attachments are edited via dedicated
[TaskAttachment] actions with payload { taskId, taskAttachment }, never
updateTask({ task: { changes: { attachments } } }), so extractUpdateChanges
never surfaces an 'attachments' key — the entry could never match and
falsely claimed coverage. Removed it (title/notes/subTaskIds are live) and
added a guard test asserting a real attachment-action-shaped op is not
flagged, so it isn't naively re-added.
2026-07-02 12:53:02 +02:00
Johannes Millan
c4f1a57fa9
feat(plainspace): add "Open in Plainspace" header button for shared projects (#8700)
* feat(plainspace): add "Open in Plainspace" header button for shared projects

Surfaces a one-click link to the bound Plainspace space in the project
header (page-title actions), shown only for projects shared on Plainspace.

- getSpaceUrl$ resolves the space slug via /me and builds {host}/{slug}
- openProjectOnPlainspace opens it externally (Electron) / new tab (web),
  with an OPEN_FAILED snack when the slug can't be resolved (offline/token)
- selectPlainspaceProviderForProject selector (selectIsProjectShared\* DRY'd)

* fix(plainspace): harden Open-in-Plainspace URL resolution + a11y

Multi-review follow-ups:
- getSpaceUrl$ guards a malformed /me body (non-array projects) instead of
  throwing an unhandled rejection, and requires a non-empty slug (blank slug
  would open the host root) — both now return null → OPEN_FAILED snack
- add aria-label to the header icon button
- correct stale comments referencing the removed context-menu entry

* docs(plainspace): note web popup-blocker limitation on external open
2026-07-02 12:52:53 +02:00
Johannes Millan
e283973e36 feat(plainspace): update icon to new two-figure group mark 2026-07-02 12:19:52 +02:00