Commit graph

149 commits

Author SHA1 Message Date
Jon Kilroy
d5afe62016
feat(schedule): add a single-day view to the Schedule tab (#9058)
* feat(schedule): allow 'day' as a persisted time-view mode

* feat(schedule): compute single-day range and header for day view

* feat(schedule): add day-view toggle and single-day labels

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

* docs(schedule): note the day view mode in the Schedule wiki

* fix(schedule): make the day-view header compact and locale-aware

Address review feedback on the single-day view:

- Build the day title with a locale-aware Intl skeleton instead of a
  hardcoded 'EEE, MMM d, yyyy' pattern, which pinned en-US field ordering.
- Shrink the title responsively so it never gets ellipsis-clipped: show
  the full weekday + date + year on roomy widths, and fall back to a
  compact month + day below TABLET, where the toggle group and nav
  controls leave too little room. The weekday still shows in the
  day-column header.
- Rename the .day-view-btn test hook to .e2e-day-view-btn per convention.

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

* refactor(schedule): rename week-month-* classes to time-view-*

The toggle group now has three buttons (Day, Week, Month), so the
week-month-selector / week-month-btn names no longer describe it. Rename
to time-view-selector / time-view-btn. Pure CSS class rename, no behavior
change.

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

* refactor(schedule): complete time-view-* rename in zen theme

The class rename left zen.css targeting .week-month-*, so its overrides
matched nothing. The :not(.active) rule is the load-bearing one: its
!important suppresses the toggle's hover highlight, which zen exists to
strip.

Co-authored-by: Jon Kilroy <jkusa@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Co-authored-by: Jon Kilroy <jkusa@users.noreply.github.com>
2026-07-17 16:00:08 +02:00
Johannes Millan
6da578aa8d
fix(sync): defer LocalFile folder pick commit to settings Save (#9075) (#9085)
* fix(sync): defer LocalFile folder pick commit to settings Save (#9075)

* test(sync): pin Android SAF pick route through form value (#9075)
2026-07-16 19:11:02 +02:00
Parman Mohammadalizadeh
7fba6f55e2
fix(tasks): decode multipart and transfer-encoded eml bodies #8975 (#8999)
* fix(tasks): decode multipart and transfer-encoded eml bodies #8975

Dropping a real-world .eml (e.g. saved from Outlook) onto the add-task
button created a title-only task: the parser only accepted a single,
unencoded text/plain body, but most clients send multipart/alternative
(plain + HTML) with quoted-printable or base64 transfer encoding on the
plain part.

eml-parser.ts now walks multipart/* structures (bounded recursion depth)
for the first supported text/plain leaf, and decodes quoted-printable/
base64 transfer encodings on it. HTML bodies and non-UTF-8/ASCII
charsets are still never decoded, matching the existing threat model
for this untrusted, inert note content.

* fix(tasks): keep unsupported-encoding eml test accurate after decode support

The service-level test for "unsupported body encoding" used a base64
fixture, which the eml-parser fix now legitimately decodes. Switch it
to a genuinely unsupported transfer-encoding token, and add a
dedicated test asserting base64 bodies decode and wrap in the notes
code fence as expected.

* fix(tasks): skip Content-Disposition: attachment parts in eml import

_extractPlainText() picked the first supported text/plain leaf in wire
order regardless of Content-Disposition, so a text/plain attachment
could be imported as the note when the real body was HTML, or shadow
the real body if it appeared first in the multipart structure. Skip
any part (leaf or multipart container) marked as an attachment before
inspecting it further.

Addresses review feedback from @johannesjo on PR #8999.

* fix(tasks): treat non-inline disposition and legacy name= as attachment

Content-Disposition is optional (legacy mail marks a filename via
Content-Type's name= parameter instead), and RFC 2183 §2.8 requires
any disposition type other than inline — recognized or not — to be
treated as attachment. The previous fix only matched the literal
token "attachment", so a text/plain part identified solely by a
legacy name= parameter, or one with an unrecognized disposition type
(e.g. x-download), still shadowed the real body.

_isAttachmentPart() now treats any present disposition other than
inline as an attachment, and falls back to the Content-Type name=
hint only when Content-Disposition is absent entirely.

Addresses further review feedback from @johannesjo on PR #8999.

* fix(tasks): recognize RFC 2231 name*/name*0 attachment filename hints

The legacy Content-Type name= fallback (used when Content-Disposition
is absent) only matched the literal key "name", missing RFC 2231's
encoded (name*) and continuation (name*0, name*0*, name*1, ...)
spellings of the same parameter. A part identified solely by a
continued name*0/name*1 filename hint slipped through undetected and
could be imported as the note instead of producing a title-only task.

_parseContentType() now matches any RFC 2231 spelling of name via a
presence-only regex; the value is never decoded or reassembled since
only the filename hint's existence matters.

Addresses further review feedback from @johannesjo on PR #8999.

* fix(tasks): recognize RFC 2231 filename*/filename*0 disposition hints

Content-Disposition's hasFilename fallback (used when the disposition
type token fails to parse, e.g. a type-less "; filename=...") only
matched the literal key "filename=", missing RFC 2231's encoded
(filename*) and continuation (filename*0, filename*0*, ...) spellings
of the same parameter -- the mirror of the name*/name*0 gap fixed for
Content-Type.

_parseContentDisposition() now matches any RFC 2231 spelling of
filename via a presence-only regex, symmetric to _NAME_PARAM_KEY_RE.

Addresses further review feedback from @johannesjo on PR #8999.
2026-07-15 09:55:39 +02:00
Johannes Millan
2864a39c85
fix(sync): file-based provider atomicity & conflict UX (#8960) (#9004)
* docs(plugins): add microsoft 365 calendar provider plan

* docs(plugins): add microsoft 365 calendar provider plan

* docs(ios): plan internal testflight builds

* fix(sync): prevent file-based sync data loss

Preserve post-snapshot operations and stage remote baselines until durable apply.

Use conditional writes and a resumable marker for provider concurrency and legacy migration. Report trustworthy remote timestamps and document transport limits.

Refs #8960

* fix(sync): avoid biased conflict recommendations

Highlight a side only when its timestamp or known change count is strictly greater, leaving unknown and tied metadata neutral.

* test(sync): assert options arg on split-file processRemoteOps

The split-file snapshot path now routes post-snapshot ops through
_processRemoteOpsWithStartupCleanup, which calls processRemoteOps with an
(empty) options object. Update the assertion to match the 2-arg call so the
unit suite passes.

* refactor(sync): dedupe strong-ETag regex, document snapshot-op boundary

- Extract the duplicated RFC 7232 strong-entity-tag pattern into a single
  STRONG_ETAG_RE constant with a comment noting why the char class is safe to
  interpolate into an If-Match header (no CR/LF header splitting).
- Explain why sv === undefined ops are classified as snapshot-included: they are
  legacy migration ops fully contained in the snapshot, and _validateSnapshotRef
  enforces a clock-EQUAL boundary. No behavior change.
2026-07-14 19:23:24 +02:00
Johannes Millan
91a1f0eda9
fix(tasks): keep REST project moves atomic across replay (#9001)
* fix(tasks): make REST project moves atomic

Capture affected subtasks in the persisted update action so project moves replay consistently, and repair stale project and section references during archive and restore.

Fixes #8983

* fix(tasks): harden project move replay

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-14 17:25:37 +02:00
Erick0412-dev
3ae2360345
feat: suppress idle dialog during active work sessions (#8965)
* @
feat: suppress idle dialog during focus mode sessions

Add a new setting (default: ON) to suppress the idle time dialog when a
focus mode session is actively running. The idle popup no longer
interrupts Pomodoro, Flowtime, or Countdown sessions.

Changes:
- Add isSuppressIdleDuringFocusMode field to IdleConfig
- Add checkbox to Settings → Time Tracking → Idle Handling
- Skip idle trigger in idle.effects.ts when focus session is active
- Add translations for all 28 languages

Closes: #1834
References: #1676
@

* feat: add opt-in setting to suppress idle dialog during work sessions

Address review feedback: narrow scope, default OFF, fix existing-user config merge.

Changes:
- default: isSuppressIdleDuringFocusMode changed from true to false (opt-in)
- reducer: deep-merge idle section on loadAllData so the new field defaults
  properly for users with persisted idle configs
- i18n: revert all non-English translation files, keep only en.json
- effects: fix Prettier/ESLint indentation
- tests: add reducer test for idle section merging + idle-effects spec
- docs: add setting to Settings-and-Preferences.md
Co-Authored-By: Claude <noreply@anthropic.com>

* fix: repair idle-effects test harness for CI

- Use ReplaySubject<void>(1) so onReady$ replay survives late subscription
- Provide LOCAL_ACTIONS token + provideMockActions
- Fix TS2740 type mismatch on chromeInterfaceMock.onReady$
Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-14 16:45:49 +02:00
Johannes Millan
ce8ed47328
fix(locale): localize ISO weekday labels (#8991) 2026-07-14 12:23:42 +02:00
aakhter
5b4200df95
fix(sync): enforce conflict-journal retention mid-session, not only at start (#8948)
* fix(sync): enforce conflict-journal retention mid-session, not only at start

pruneOnStart (14 days / newest 200) ran only at startup, so a long-lived
session could grow SUP_CONFLICT_JOURNAL unboundedly. record() now checks
the store count (cheap) and, when it exceeds JOURNAL_MAX_ENTRIES plus a
slack of JOURNAL_PRUNE_SLACK, runs the same age+count prune — amortized
to once every JOURNAL_PRUNE_SLACK records. The prune core is extracted
and shared with pruneOnStart; the observe-only never-throw contract of
record() is unchanged.

SPAP-36 (deferred LOW from PR #8874 review)

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

* fix(sync): await tx.done with the deletes in the conflict-journal prune

An aborted prune delete transaction rejects both the delete requests and
tx.done. Awaiting them separately (Promise.all(deletes), then await tx.done)
leaves tx.done's rejection unhandled once the delete aggregate rejects first, so
it escapes as a global unhandled rejection during active conflict resolution.
Await both in one Promise.all so tx.done always has a handler. Adds an
aborted-transaction regression test (fails on the old sequencing) and updates
the retention model comment + doc to reflect mid-session pruning.

Addresses maintainer review on #8948.

SPAP-36

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:26:10 +02:00
Johannes Millan
329da9b9f3
fix(sync): harden full-state operation recovery (#8973)
* fix(sync): prevent destructive full-state sync races

Use causal ordering and server-sequence preconditions for full-state operations, surface snapshot rejection, deduplicate migrations, and retain queued WebSocket downloads.\n\nFixes #8959

* fix(sync): block uploads after rejected imports

Keep incremental operations behind a durable barrier when a local import or restore is rejected. Release the barrier only after a newer full-state snapshot is accepted, and surface the blocked state instead of reporting sync success.

* fix(sync): harden causal repair recovery

Persist repair base cursors across the client, provider, and server so stale snapshots are rejected before quota or history mutation and legacy repairs cannot poison restore state.

Atomically rebase stale repairs after downloading the missing suffix, preserve rejected-import barriers and WebSocket watermarks, and cover PostgreSQL serialization.

* fix(sync): harden repair recovery edge cases

Block dependent uploads after any rejected full-state operation, reset negotiated capabilities across provider config changes, and bound WebSocket download retries.

Update sync test doubles for causal repairs and the expanded duplicate-operation identity.
2026-07-13 22:58:17 +02:00
Johannes Millan
5624f6891d
fix(sync): harden op-log replay and recovery (#8978)
* fix(sync): harden operation-log failure recovery

Keep compaction, archive replacement, legacy recovery, and reducer replay checkpointing deterministic across crashes and concurrent clients.

Refs #8958

* fix(sync): harden remote operation recovery

Persist reducer failures across hydration and fail closed for full-state operations. Serialize recovery and archive mutations, and report skipped emergency compaction accurately.

* fix(sync): preserve operations across replay failures
2026-07-13 20:03:42 +02:00
Johannes Millan
9a38397da5
fix(supersync-server): op data-loss on retention/cleanup & auth-endpoint hardening (#8971)
* fix(sync): preserve offline operations and harden auth

Accept operations from long-offline clients without rewriting their timestamps, and retain a replayable full-state base during cleanup. Neutralize registration account discovery and suppress repeated login and recovery token emails with atomic claims.

Fixes #8961

* fix(sync): harden auth and retention edge cases

Make unauthenticated auth responses neutral across delivery and resend failures, isolate WebAuthn ceremonies, and consume login and recovery tokens atomically. Add monitored handling for histories without a replay base and strengthen regression coverage.

Refs #8961

* perf(sync): reduce cleanup replay-base queries

Reuse the maintained full-state sequence marker during retention cleanup while preserving a query fallback for legacy and stale-marker rows. Clarify no-base warnings and strengthen exact auth-response and recovery transaction tests.

Refs #8961

* fix(sync): reject non-integer op timestamps before BigInt persistence

A non-integer or non-finite client timestamp passed the schema
(timestamp is z.number(), not .int()) and threw at BigInt() during
upload, aborting the whole batch as an unstructured 500. Reject it in
validateOp as a per-op INVALID_TIMESTAMP instead. Op age stays
unbounded so long-offline backlogs are still accepted.

Refs #8961
2026-07-13 14:28:45 +02:00
Johannes Millan
a9ed0b2df2
Merge pull request #8519 from archit-goyal/codex/caldav-empty-dropdown-guidance
Clarify empty CalDAV option loads
2026-07-13 12:46:55 +02:00
Johannes Millan
c1564c316d docs(integrations): clarify CalDAV server URLs 2026-07-13 12:02:28 +02:00
Johannes Millan
291995f343 Merge remote-tracking branch 'origin/master' into feat/sync-55222e
* origin/master: (25 commits)
  refactor(tasks): extract shared task ordering helpers (#8926)
  feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)
  refactor(config): reduce duplicated form and selector boilerplate (#8928)
  feat(work-view): show break time today (#8909)
  feat(tasks): navigate from empty add-subtask input (#8916)
  docs(development): add instructions for setting up local tests with Chromium (#8887)
  chore(lint): remove unused eslint-disable directives (#8913)
  fix(accessibility): add ARIA roles, live regions, and alt attributes to banner component (#8888)
  chore(ui): removes dead utilities and op-log leftovers [#8260 - Tier A] (#8892)
  fix(app): hide donation page on macOS (#8915)
  chore(scripts): remove one-off codemod scripts [#8260 - Tier A] (#8893)
  fix(sync): harden SuperSync E2EE against metadata tampering (GHSA-8pxh-mgc7-gp3g) (#8904)
  feat: add Home Assistant Bridge to community plugins (#8891)
  docs(sync): note local-file rev-check/write is non-atomic (#8898) (#8902)
  18.14.0
  fix(tasks): remove postal-mime dep and harden eml import (#8901)
  style(tasks): elevate add-subtask input
  fix(config): restore day-start offset after operation replay (#8899)
  fix(task-repeat): allow selecting day-of-month recurrence (#8896)
  feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)
  ...

# Conflicts:
#	docs/wiki/3.06-User-Data.md
#	src/app/op-log/backup/backup.service.spec.ts
#	src/app/op-log/backup/backup.service.ts
#	src/app/op-log/sync/conflict-resolution.service.ts
2026-07-11 18:50:32 +02:00
Johannes Millan
582929e375 docs(sync): document atomic checkpoint, db v8 barrier and rebuild undo
Update the archive-operation lifecycle docs to the atomic
reducer-checkpoint + clock-merge design, record the IndexedDB v8
downgrade barrier, drop the removed PENDING_OPERATION_EXPIRY_MS
constant, and describe the durable Use-Server-Data Undo across reloads.
2026-07-11 18:06:20 +02:00
aakhter
962c5bbeb1
feat(sync): conflict journal + disjoint-field auto-merge + review UI (#8874)
* feat(sync): conflict-journal foundation (observe-only)

Add a device-local IndexedDB conflict journal that records every sync-
conflict auto-resolution so the discarded ("losing") side is preserved
and reviewable later. Foundation subtask for the conflict-review epic;
no UI here, verifiable purely by unit tests.

- New SUP_CONFLICT_JOURNAL IndexedDB store (own DB; never touches the
  op-log SUP_OPS schema) + ConflictJournalService with record/query API
  (unreviewedCount$, list, markKept, markFlipped, getEntry) and 14-day /
  200-entry retention pruning, wired to run on app start via APP_INITIALIZER.
- Pure classifier buildConflictJournalEntry maps each resolution to the
  agreed taxonomy (newer/tie/delete-wins/noise/clock-corruption-
  suspected; disjoint-merge reserved for the next subtask), capturing the
  loser's field values verbatim. NOISE_FIELDS is limited to metadata
  timestamps (modified/lastModified/created): the list-ordering arrays
  carry membership as well as order, so an overlap on them is surfaced as
  a reviewable conflict rather than silently classified as noise.
- Emission is strictly observe-only: journaling runs after the LWW plan is
  built, wrapped in try/catch (record() swallows its own errors); clock-
  corruption attribution uses a WeakSet side-channel tagged at detection.
  The existing conflict-resolution suite stays 138/138 green, proving LWW
  picks are unchanged. One-sided / sequential / EQUAL updates never become
  conflicts and produce zero journal entries.

SPAP-13

* feat(sync): disjoint-field auto-merge for concurrent edits

When two clients concurrently edit the same entity but different fields
(A changes title, B changes notes), whole-entity LWW previously discarded
one side. Keep both when the non-noise changed-field sets are disjoint.

In _resolveConflictsWithLWW, before LWW picks a winner, each CONCURRENT
conflict is tested for merge eligibility (neither side deleting/archiving;
both changed >=1 real field; non-noise field sets disjoint). If eligible,
synthesize a single merged UPDATE op — the current entity overlaid with
the other side's non-noise fields, noise fields resolved by the greater
(timestamp, clientId) — carrying a vector clock that dominates both sides,
so it propagates through normal sync. The resolution is winner 'merged'
and is journaled reason 'disjoint-merge' / status 'info' (not counted as
unreviewed). Any overlap on a non-noise field, or a delete/archive on
either side, falls through to the existing LWW path unchanged.

Convergence: both clients compute the byte-identical merged entity
(disjoint real fields each owned by one side; noise resolved by the same
global tiebreak) with clocks dominating both originals, so the two
independently-synthesized merged ops carry identical full-entity payloads
and re-resolve to the same state via ordinary LWW without re-merging.

No sync-core/protocol change. Existing conflict-resolution suite stays
138/138; SPAP-13 journal specs stay green.

SPAP-14

* feat(sync): sync conflicts review UI (banner, badge, page, flip)

Builds the conflict-review UI on top of the device-local conflict journal.

- Post-sync summary banner "N conflicts auto-resolved (X remote, Y local
  won)" with REVIEW / DISMISS, replacing the bare LWW_CONFLICTS_AUTO_
  RESOLVED snack at its emission sites; a persistent badge on the sync
  icon bound to unreviewedCount$ (survives banner dismiss).
- New /sync-conflicts page: Unreviewed | History tabs, rows grouped by
  entity type with winner + reason chips, expandable per-field diff
  (LOCAL vs REMOTE, device name + wall-clock time, winner marked),
  per-row KEEP / FLIP and bulk KEEP ALL / FLIP ALL → LOCAL / → REMOTE.
  History renders merged auto-merges as per-field chips.
- Flip dispatches a normal entity update with the loser's journaled field
  values (syncs like a user edit, no history rewind) and marks the entry
  flipped; a stale-flip confirm appears (with the current value shown)
  when the entity changed since resolution.
- i18n under F.SYNC.CONFLICT_REVIEW.

Delete-restore and archived-entity flip are surfaced as unsupported for
now (an update op can't recreate an absent entity) — follow-up.

SPAP-15

* fix(sync): count disjoint-merge ops in localWinOpsCreated

autoResolveConflictsLWW returned only newLocalWinOps.length, excluding the
synthesized merged ops appended in STEP 3b. A sync whose only conflicts were
disjoint-field merges returned 0, so the caller (immediate-upload.service.ts)
skipped the immediate re-upload and reported IN_SYNC while the merged op sat
unsynced until a later cycle.

Count mergedResolutions.length too — each merge appends exactly one pending
local op. Mirrors the rejection-handler path (operation-log-sync.service.ts).
Add a regression spec asserting a merge-only conflict returns
localWinOpsCreated: 1 (fails on the old return, passes now).

Also harden the _corruptionSuspectedConflicts WeakSet doc against a future
refactor that clones EntityConflict between detect and resolve.

Addresses review feedback on PR #8874.

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

* fix(sync): address second-pass review (delete-lost, archive guard test, polish)

Second-pass review follow-ups (johannesjo). The MEDIUM localWinOpsCreated
item was already fixed in 23e9a56.

Important:
- delete-lost classification: when a delete LOSES to a concurrent newer edit,
  the loser side is a pure Delete op (no field changes), so it fell through to
  `noise`/`info` and never surfaced. Add a `delete-lost` reason classified as
  `unreviewed` (inverse of `delete-wins`), checked before the noise fallthrough.
  + regression spec.
- archive-vs-disjoint-edit test (d2): an archive is an UPDATE, so eligibility
  does NOT reject it — only the `_isArchivePlan` guard does. New test forces
  eligibility TRUE and asserts no merged op is synthesized, so a guard
  regression now fails a test (previously nothing covered it).

Minor:
- pruneOnStart: wrap in try/catch (log + return 0, matching record()'s
  observe-only contract); reset the poisoned `_initPromise` in `_ensureDb` on
  open failure so a transient IndexedDB error can't wedge the service.
- sync-conflicts-page.scss: use `--color-success` token + color-mix tint
  instead of hardcoded #4caf50 / rgba(76,175,80,…); drop the dead
  `--color-warning` fallback (#e6a817 didn't match the real #ff9800 token).
- main-header badge: matBadgeColor warn -> accent (a resolved count is not an
  error); move the count announcement to `matBadgeDescription` and give the
  button a stable sync-action aria-label (it was null at count 0, leaving the
  icon-only button unnamed).
- remove dead i18n key LWW_CONFLICTS_AUTO_RESOLVED (t.const.ts + en.json).
- add direct unit spec for noiseTiebreakSide (incl. equal-timestamp clientId
  determinism) and buildMergedFieldDiffs.

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

* fix(sync): address third-pass review (flip capability, field presence, opaque ops, profile isolation)

Blocking #3 — delete-lost/delete-wins offered a false-success Flip:
canFlip() is now reason/field-aware (refuses delete-lost/delete-wins,
empty loser changes, and relationship-bearing fields whose bare adapter
update would bypass meta-reducer invariants); flip() guards on it and
only marks an entry flipped when an op was actually dispatched.

Blocking #2 — field absent vs side-set-undefined: fieldDiffs now record
per-side presence (localChanged/remoteChanged); loserChangesFor/
winnerChangesFor only emit fields the side actually changed, so Flip no
longer clears winner-only fields and the stale guard no longer compares
undefined. Legacy entries without flags fall back to value-presence
(exact, since op payloads are pure JSON).

Blocking #1 — non-adapter action payloads: per-op extraction now falls
back to capture-time entityChanges (TIME_TRACKING/syncTimeSpent), and
ops with no readable delta (convertToSubTask & co) are "opaque": never
classified noise/info, preserved verbatim as kind:'action' diffs for
review, excluded from flip/stale computations, and never disjoint-merge
eligible (merging would drop the mutation and diverge the two clients).

Profile isolation — switchProfile clears the conflict journal
(ConflictJournalService.clearAll) so a new profile cannot see the
previous profile's entity data or Flip against the wrong dataset.

Also: replace the as-any selector cast with a typed union-member
narrowing, and add the required docs (sync-and-op-log/
conflict-journal-and-review.md incl. the atomicity/no-re-merge
contract, wiki 3.06 + 4.23).

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

* fix(sync): address fourth-pass review (flip short-syntax, selector throws, restore isolation)

HIGH — a title-only flip matched shortSyntax$'s exact trigger shape, so
#tag/+project/@schedule tokens in the discarded title re-parsed into
cross-entity mutations: the TASK flip now dispatches with
isIgnoreShortSyntax: true (journaled literal value, not user input).

MEDIUM — selectTagById/selectNoteById THROW on a missing entity, so
expanding (or flipping) a TAG/NOTE row whose entity is gone rejected
unhandled before the !current guard: _readCurrentEntity now catches and
returns undefined, and getStaleState/flip degrade gracefully.

MEDIUM — the journal survived backup restores: clearAll() moved to the
BackupService.importCompleteBackup chokepoint, covering every full
dataset replacement (profile switch, JSON import, local-backup restore,
SuperSync restore) instead of only the profile switch.

LOW — ISSUE_PROVIDER's factory selectById is now special-cased (was
rendering the inner selector function as the "current" entity);
schedule/reminder fields (dueDay/dueWithTime/deadline*/reminderId) join
the flip blocklist (renamed FLIP_UNSAFE_FIELDS) since their invariants
live in dedicated flows; loser/winnerChangesFor early-return for merged
entries (their tiebreak diffs carry pickedSide, so the per-diff check
alone did not exclude them).

Docs updated accordingly (dev doc + wiki 3.06/4.23).

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

* fix(sync): refuse flip for remindAt/deadlineRemindAt (reminder-lifecycle)

FLIP_UNSAFE_FIELDS is a deny-list: any Task field not listed is flippable
via a bare updateTask. reminderId was covered, but the sibling reminder-
lifecycle timestamps remindAt and deadlineRemindAt were not — a conflict on
either alone (e.g. concurrent deadline-reminder-lead edits) would pass
canFlip and, when flipped, write the field without scheduling/cancelling the
actual reminder in ReminderService, leaving a dangling/missing notification.

Add both to FLIP_UNSAFE_FIELDS, document the deny-list drift risk, and pin
the reminder/schedule members with per-field canFlip=false spec cases.

* fix(sync): make disjoint-merge convergent + close flip stale-guard blind spot

Two confirmed cross-device data defects found in the SPAP-14 whole-PR review:

1. Full-entity merged op diverges (CRITICAL). The merged op was a full-entity
   snapshot of each client's CURRENT state, so any field NEITHER conflicting
   side touched rode along. Under ordinary staggered sync (one client already
   applied a third device's edit to that field, the other not yet), the two
   clients synthesize different snapshots that tie under LWW at the identical
   max(timestamp) and diverge PERMANENTLY. Fix: synthesize a PARTIAL delta
   (union of the two sides' changed fields only), derived purely from the ops
   so both clients compute the byte-identical map; lwwUpdateMetaReducer applies
   it via updateOne (shallow merge), leaving untouched fields alone.
   synthesizeMergedEntity -> synthesizeMergedChanges.

   Also: detectConflicts emits one conflict per remote op with no per-entity
   aggregation, so an entity with >=2 concurrent remote ops synthesized multiple
   merged ops whose clocks dominate one another -> a dominated sibling's field
   is silently dropped, falsely journaled as 'kept both'. Fix: refuse merge for
   any entity with >1 conflict this batch and fall back to whole-entity LWW.

2. Flip stale-guard blind to loser-only fields (HIGH). getStaleState only
   compared WINNER-changed fields, but flip writes LOSER-changed fields. A
   post-resolution edit to a loser-only field was undetected and silently
   overwritten. Fix: also flag stale when a loser-only field flip will write
   diverged from the value flip would write; bulk flipAllToSide now SKIPS stale
   entries instead of silently applying them.

Adds regression specs for the un-conflicted-field ride-along, multi-remote-op
refusal, and loser-only stale detection (per-entry + bulk). Full conflict suite
green (disjoint-merge 12, ui 24, conflict-resolution 138, journal 20, hook 6,
util 14, review-util 13, superseded 42, banner 3, page 6).

* fix(sync): scope flip stale-guard loser-only check to remote-won entries

Re-review of efe66d7 found the new loser-only stale check false-positives on
LOCAL-won conflicts: there the loser is the REMOTE side, whose value was never
applied (current holds the un-recorded base), so current != flipVal is the
NORMAL post-resolution state, not an edit. That made flipAllToSide silently skip
legitimate local-won entries and single flip nag with a spurious confirm.

Scope loserOnlyStale to winner==='remote' (the only case where the loser's
optimistically-applied local value persists, giving a valid unedited baseline).
Remote-won silent-loss detection (the originally-reported defect) is unchanged.
Loser-only fields on LOCAL-won entries remain undetectable without a journaled
post-resolution baseline — documented as a follow-up. +2 regression specs
(local-won no-false-positive: getStaleState + bulk flip).

* fix(sync): restrict disjoint-merge to types with a RECREATE_FALLBACK

The merged op is a partial delta. If it wins over a concurrent DELETE on a
passive-observer client (one that already applied that delete), it reaches
lwwUpdateMetaReducer's addOne recreate branch WITHOUT passing through the
full-entity reconstruction in _convertToLWWUpdatesIfNeeded (that runs only for
conflict winners, not non-conflicting remote ops). For a type without a
RECREATE_FALLBACK (NOTE/METRIC/TASK_REPEAT_CFG/ISSUE_PROVIDER) the bare partial
addOne yields a Typia-invalid entity -> 'Repair failed' dead-end; the parent's
full-snapshot merged op recreated validly, so the partial delta is a regression
there.

Refuse disjoint-merge for fallback-less types (fall back to whole-entity LWW,
whose local-win op carries a full snapshot). Residual: fallback types can still
recreate with DEFAULT_* backfill diverging in that rare 3-device race — the same
bounded limitation already documented in recreate-fallback.const.ts. +regression
spec (NOTE disjoint conflict -> LWW, not merged).

* fix(sync): harden conflict-journal failure and lifecycle paths

Three hardening improvements from the final review pass:

1. Journal disjoint merges only AFTER the merged op is durably appended
   (STEP 3b), not at plan time. A 'merged' entry claims both sides were
   kept, which is only true once the op is persisted — an append failure
   could previously leave a phantom 'kept both' journal entry. +regression
   spec (a6): append failure -> no merged journal entry.

2. Extend the never-throw contract to ALL ConflictJournalService methods.
   list() is awaited (via maybeShowSummaryBanner) inside
   autoResolveConflictsLWW's notification step — i.e. after ops were
   already applied — so a transient IndexedDB failure there failed an
   otherwise-completed sync. list -> [], getEntry -> undefined,
   markKept/markFlipped swallowed (entry stays unreviewed, user retries).
   +spec.

3. Recover from abnormal IndexedDB closure: idb's terminated hook now
   drops the memoized _db/_initPromise handles so the next call reopens
   instead of failing on a dead connection for the rest of the session
   (deferred fourth-pass item). +spec.

Plus: reciprocal note in recreate-fallback.const.ts that membership also
opts a type into disjoint-merge, and doc updates for the new contracts.

* test(sync): pin the terminated-hook wiring in the journal recovery spec

The recovery spec called _resetDbHandles() directly, so removing the
terminated: callback from openDB (functionally reverting the force-close
recovery) kept all tests green. Fire the real 'close' event idb listens
for instead (duck-typed FakeEvent for fake-indexeddb) and assert the
handles were cleared. Mutation-verified: deleting the terminated line
now fails this spec.

* fix(sync): clear conflict journal inside the op-log lock on import

Author-review finding: importCompleteBackup released the OPERATION_LOG
lock before clearAll(), leaving a narrow cross-tab window where a
concurrent conflict resolution's fresh post-import journal entry gets
wiped. Clearing inside the lock serializes the clear strictly before any
post-import journaling. (_resetAllLastServerSeqs is journal-independent
local bookkeeping and keeps its persist-first ordering.)

Also documents the merged-op composition residual from the same review:
a merged partial op is not closed under later whole-op LWW composition
in the no-pending-local concurrent-apply path — verified pre-existing
(branch and behavior identical at the pre-SPAP-14 merge-base with plain
user ops), so recorded as a class-level op-log residual rather than
patched here.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-11 17:48:46 +02:00
J. Loops
7733c4a7e5
feat(work-view): show break time today (#8909) 2026-07-11 15:37:57 +02:00
Johannes Millan
b52087338e
feat(tasks): navigate from empty add-subtask input (#8916) 2026-07-11 13:33:35 +02:00
Lane Sawyer
ae158f8cd5
docs(development): add instructions for setting up local tests with Chromium (#8887)
* docs(wiki): document CHROME_BIN setup for unit tests

The pre-push hook runs the Karma unit tests, which need a resolvable
Chrome binary. Without a system Chrome or CHROME_BIN set, git push
fails with "No binary for Chrome browser". Add a section covering both
a system Chrome install and installing Chrome for Testing via
@puppeteer/browsers with CHROME_BIN wired into the shell profile.

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

* docs(wiki): note snap/flatpak caveats for the test browser

Snap Chromium works but isn't auto-detected (set CHROME_BIN=/snap/bin/chromium);
Flatpak is not recommended because karma-chrome-launcher execs the binary
directly and the sandbox blocks Karma's temp profile dir.

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

* Improve documentation around Chromium

* add bash to codeblock

* docs(wiki): fix Chrome-for-Testing install path and Chromium claims

Address review feedback:
- Option A: only real Google Chrome is auto-detected on Linux
  (karma-chrome-launcher probes google-chrome/-stable, not chromium);
  drop the overstated "finds it automatically" for Chromium.
- Option B: pin `--path "$HOME/.cache/puppeteer"` — the standalone
  @puppeteer/browsers CLI defaults to the cwd, so the bare command
  downloaded chrome into ./chrome and left CHROME_BIN empty.
- Note macOS differs (binary is "Google Chrome for Testing" in a .app,
  so `find -name chrome` is Linux-only).

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

* Human polish of instructions

* final PR feedback

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:25:02 +02:00
Johannes Millan
5220684b7e
fix(app): hide donation page on macOS (#8915)
* fix(app): hide donation page on macOS

Use the stable macOS bridge instead of the MAS runtime flag and redirect direct donation-page navigation on restricted Apple builds.

* fix(app): harden donation platform gating

Give the restriction a behavior-specific contract, cover platform classification and real router redirects, and correct the platform documentation.
2026-07-11 13:02:19 +02:00
Johannes Millan
7866e16b56 docs(sync): document recovery checkpoints 2026-07-10 19:38:03 +02:00
Johannes Millan
d5db11f329
fix(tasks): remove postal-mime dep and harden eml import (#8901)
* style(tasks): elevate add-subtask input

* fix(tasks): parse eml files without external dependency

Replace postal-mime with a bounded parser for sender, subject, and unencoded plain-text bodies. Ignore unsupported MIME body formats and document the root dependency policy.

* fix(tasks): isolate and harden eml import

Lazy-load the local parser, reject lossy or unsupported bodies, and store accepted text as literal notes so imported content cannot trigger remote resource loads. Document the title-only fallback and expand regression coverage.

* fix(tasks): decode eml headers and harden import parsing

- Decode RFC 2047 encoded-words in the subject and sender name so
  international titles show "Grüße" instead of raw "=?UTF-8?...?=".
- Replace the charset regex with a quote-aware Content-Type parser so a
  charset inside another quoted parameter can't be mistaken for the real
  one, and ignore RFC 822 header comments (e.g. "7bit (comment)").
- Cap the synced title (300) and body (100k) so a crafted .eml can't
  amplify into an oversized op (the literal fence can double the body).
- Document parseEml's intentional MIME omissions to prevent a later
  "fix" that reopens the untrusted-body attack surface.
2026-07-10 17:05:50 +02:00
Johannes Millan
c68929d1fb
fix(task-repeat): allow selecting day-of-month recurrence (#8896)
* fix(task-repeat): allow selecting day-of-month mode

* fix(task-repeat): clarify monthly recurrence pattern

* test(task-repeat): cover day-of-month persistence

* docs(task-repeat): explain custom monthly patterns
2026-07-10 13:55:23 +02:00
Johannes Millan
762b3c5d8d
feat(plugins): add Todoist import plugin with Import/Export launcher (#8882)
* feat(plugins): add Todoist import plugin with Import/Export launcher

* fix(plugins): fix cross-chunk subtask loss in todoist-import + hardening

* fix(plugins): clamp non-finite Todoist durations + review polish

* feat(plugins): add Eisenhower priority mapping to Todoist import

Replace the single "map priorities to p1-p3 tags" checkbox with one
mutually-exclusive control (Nothing / p1-p3 tags / Eisenhower matrix).

The new Eisenhower option reuses SP's built-in urgent/important tags by
title (p1 -> urgent+important, p2 -> important, p3 -> urgent, p4 -> none),
so imported tasks populate the Eisenhower Matrix board with no new tag
and no new plugin API. Collapsing Todoist's single priority axis onto the
2-D matrix is opinionated, so it stays opt-in and off by default.

Requested in the review of #8882.

* fix(plugins): harden Todoist import data integrity

* fix(plugins): improve Todoist import feedback
2026-07-10 13:30:03 +02:00
Yehonatan Avrahimi
18c48d5ea8
feat(tasks): add task through eml file hover (#8282)
* feat(eml): add EML file parsing and task creation on drop

Currently a simple skeleton, needs more work, but should be a good POC before refining.

Fixes #518

* fix(main-header): correct email message formatting and handle EML upload errors

while this commit looks bad, its a first commit before refining this solution and sending the final PR.

Fixes #518

* refactor(eml): move eml-parser to util and tighten isFileEml check

* feat(eml): on eml hover error parse error to log and show a snack message

* bugfix(eml-typecheck): tighten type for 'sender' to not have bugs

* refactor(eml-drop): updated file locations for each file drop

* bugfix(eml-drop): use stable npm package for parsing eml files

* bugfix(eml-drop): remove relevant "dev" flag

* refactor(eml-drop): drop empty emails with a snack instead of throwing

* test(eml-drop): add isFileEml and parseEml unit tests

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

* rafactor(eml-service): refactor code to be consistent  as written in issue number 3

* bugfix(eml-drop): remove file name when geting an error

as written in 1. inside the pr

* docs: update task creation instructions to include .eml file support

* refactor(eml-parser): update import statement for PostalMime to dynamic import

* refactor(eml-drop): enhance file drop handling and improve error logging

* test(eml-drop): add unit tests for EmlDropDirective and EmlDropService

* feat(tasks): save eml body to task notes and harden eml parsing

- capture the parsed plain-text email body into task notes (text/plain
  only, never HTML, to avoid injecting untrusted markup into markdown notes)
- pass the File/Blob straight to postal-mime so it applies the message's
  own charset/transfer-encoding instead of forcing UTF-8 via file.text()
- use relative import paths to match repo convention
- document why the drop handler relies on the global dragover preventDefault
- update wiki wording + tests for the new add() signature and notes behavior

* fix(tasks): harden eml-to-task against untrusted subject/size

Follow-up to the eml-drop feature from a multi-agent review:

- suppress short-syntax on imported email subjects: email content is
  untrusted external input, but TaskService.add() dispatches addTask which
  ShortSyntaxEffects parses for #tag/@date/+project/URL tokens — stripping
  them from the intended literal "sender: subject" title and mutating
  tags/scheduling/attachments. Thread isIgnoreShortSyntax through add()
  (matching the existing plugin-bridge pattern) and set it here.
- guard against oversized .eml files (postal-mime parses synchronously on
  the main thread and the body syncs to every device) with a 10MB cap +
  EML_TOO_LARGE snack.
- trim sender/subject so whitespace-only headers don't bypass the empty
  check or block the address fallback.
- drop the stale "add attachment" TODO.

* fix(tasks): log bounded eml parse reason, not the raw error

The source is untrusted email content and the log history is exportable
(rule #9). Log only e.message (postal-mime's throw messages are structural,
so no email content leaks) instead of the raw error object + stack.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-07-09 17:58:35 +02:00
Johannes Millan
7f604f68f7
feat(task-repeat): default skip-overdue for new everyday recurring tasks (#8861)
* feat(task-repeat): default skip-overdue for new everyday recurring tasks

New recurring configs now default skipOverdue ("Don't let overdue instances
pile up") to ON only for a plain everyday schedule — the "Daily" preset or a
CUSTOM every-single-day cycle. That is the one case where the option is both
useful and provably safe: everyday tasks are the only schedule that actually
piles up (one empty overdue copy per missed day), and today is always
scheduled so a missed instance regenerates the same day and can never
silently vanish (it cannot even drop to zero).

Every other schedule stays OFF — workday/weekly/monthly/yearly and every-N-day
custom cycles keep their one missed occurrence visible, so a real obligation
(pay rent on the 1st, renew the domain) never disappears until its next
occurrence. Deriving the default purely from the effective schedule means the
"Daily" preset and a CUSTOM every-day cycle (the same schedule entered two
ways) get the same default — no "same schedule, different default" surprise.

The default is seeded from the chosen schedule in both config-creation paths:
the repeat dialog (re-derived from the final schedule on save, and skipped
when the user explicitly toggled the Advanced checkbox) and the inline
add-task-bar recurrence. Existing configs and DEFAULT_TASK_REPEAT_CFG
(skipOverdue: false) are unchanged; only newly created configs are affected.

Supersedes the broader daily+Mon-Fri variant, dropping the schedule-change
checkbox re-sync machinery: an Advanced checkbox opened on a Daily config and
then switched may briefly show a stale ON, but save always persists the safe
re-derived value.

* docs(task-repeat): clarify custom every-day default; signpost baseline

Multi-review follow-ups (no behavior change):
- Wiki 2.06 + 4.13: a CUSTOM every-single-day cycle also defaults skipOverdue
  ON (it is the same schedule as the Daily preset); "off" now reads "custom
  cycles longer than a day" so the docs match getDefaultSkipOverdue.
- Comment on DEFAULT_TASK_REPEAT_CFG.skipOverdue pointing to the schedule-aware
  creation default, so the model's `false` baseline is not mistaken for the
  effective default.
- Tighten the save() display-gap comment: the cosmetic seeded-ON/persist-OFF
  gap applies to any new non-Daily config whose Advanced panel is opened
  untouched, not only a Daily→switch.

* refactor(task-repeat): use type-only import in skip-overdue predicate

TaskRepeatCfgCopy is used only in type position; match the codebase's import type convention (58 other files). No behavior change.
2026-07-08 19:55:37 +02:00
Johannes Millan
994f0522ff
feat(tasks): redesign add-task-bar layout, toggles, note field & a11y (#8812)
* feat(tasks): move add-task-bar toggles into the actions row

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(tasks): update add-task-bar keyboard shortcuts for new Ctrl mapping
2026-07-07 16:26:36 +02:00
Johannes Millan
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
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
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
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
c7d6131db8
feat(plainspace): Collaborate-on-Plainspace discovery + smoother connect (#8649)
* feat(plainspace): add Collaborate action to project context menu

Surface Plainspace sharing from the project context menu (active, non-Inbox,
not-yet-shared projects) so it can be discovered at the moment of collaboration
intent, reusing PlainspaceShareService. A new selectIsProjectSharedOnPlainspace
selector hides the action once a project is shared to avoid provisioning a
duplicate space on a repeat click.

* docs(plainspace): document collaboration in wiki

Add Plainspace to the issue-integration comparison (matrix + per-provider
section) and a how-to for sharing a project via the project menu, including the
network/privacy caveat.

* feat(plainspace): place Collaborate action below the share-list item

Group the two share/export actions and lift Collaborate higher for
discoverability. Gated to active, non-inbox, not-already-shared projects.

* fix(plainspace): smoother first-run connect and value-first dialog

Pre-check connectivity and revalidate a stored token before the space
picker, so a stale/foreign token routes to the connect dialog and an
offline state shows a calm message instead of the raw 'check your token'
picker error. Make the connect dialog value-first: lead with what you
get, drop the 4-step how-to and email hint in favor of one short pointer
(token-creation guidance moves to plainspace.org).

* docs(plans): dedicated from-Super-Productivity flow on plainspace.org

Open plan for a guided token/connect flow on plainspace.org when a user
arrives from SP (Model A manual token, Model B OAuth-style handoff).

* refactor(plainspace): drop redundant connect pre-check (multi-review)

The space picker already detects a stale token and offers a reconnect
(#8616), so the revalidate() pre-check duplicated that path, cost an
extra GET /me, and forced re-auth on a valid token during a transient
server blip. Keep only the one-line offline guard; drop revalidate(),
the discriminated union, and the unnecessary _isOnline() seam (navigator
.onLine is spyable in the runner). Fix stale doc comments and document
the disabled-provider trade-off in the selector.

* feat(plainspace): show brand icon in the connect dialog title

* feat(plainspace): deep-link connect dialog to the from-SP onboarding flow

Point the dialog's 'Open Plainspace' link at the dedicated
/connect/super-productivity entrypoint (which guides token creation)
instead of the bare marketing host, closing the connect-flow funnel leak.

* feat(plainspace): bounce back to the app after connecting (desktop)

Append a validated `?return=superproductivity://plainspace-connect` deep
link to the connect URL, gated on IS_ELECTRON (only desktop registers the
scheme — web/mobile would get dead buttons). Handle that action in the
Electron protocol handler by surfacing the window, so the connect page's
"Open Super Productivity" button re-focuses the app.

Desktop-only; needs an on-device check.
2026-07-01 16:39:34 +02:00
Johannes Millan
14a56f861e
feat(electron): trigger global shortcuts via superproductivity:// URLs (#8645)
* feat(electron): trigger global shortcuts via superproductivity:// URLs

On Wayland the compositor owns global hotkeys, so Electron's globalShortcut
often does not register. Add three actions — toggle-visibility, new-note and
new-task — to the existing protocol handler so a compositor keybind can call
`xdg-open superproductivity://<action>`. No extra CLI tool or runtime is
needed: xdg-open (Linux), open (macOS) and start (Windows) already ship with
the OS, and the running instance receives the URL via the existing
single-instance / second-instance path.

Extract the show/hide logic into a shared toggleWindowVisibility() used by both
the globalShowHide shortcut and the new protocol action, and add a key-repeat
debounce so one held key press no longer hides then immediately re-shows the
window.

Docs: add a Wayland keybind recipe (Niri/sway/Hyprland) to the keyboard
shortcuts wiki page.

Refs #7114

* fix(electron): correct toggle-visibility focus race and debounce

On Linux/Windows the second-instance handler pre-focused the window before processProtocolUrl ran, so toggle-visibility always read 'visible' and hid the window the user asked to show. Skip that pre-focus for toggle-visibility only (new getProtocolAction helper); every other action keeps the bring-to-front behavior.

Make the key-repeat debounce direction-agnostic with a sliding quiet-gap (1000->750ms): it now guards both show and hide, settles a held key on a single toggle, and fires on the xdg-open path where the old isHidden-only guard was always false.

Stop logging the create-task title and URL path to the exportable log (CLAUDE.md rule 9 / privacy).

Add coverage for the real second-instance path, held-key-from-hidden, gap expiry, the #7282 minimize fallback, the macOS hide path, unfocused-show, and the log redaction; document AppImage/Flatpak/Snap scheme registration. Refs #7114.

* fix(electron): show window on cold-start toggle-visibility launch

Cold start: when superproductivity://toggle-visibility launches the app (it wasn't running), the freshly-shown window was immediately hidden again because the toggle saw it focused. Flag the cold-start URL during the argv scan and SHOW — never toggle — the window once it's ready (via processPendingProtocolUrls), respecting start-minimized-to-tray. The already-running second-instance path keeps real toggle behavior.

Rename the two interactive protocol actions new-task/new-note to add-task/add-note: aligns with the app's Add-Task vocabulary and the globalAddTask/globalAddNote keys, and avoids colliding with the programmatic create-task/<title>. The action names are a frozen public contract once users bind them in compositor configs, so this is the pre-merge moment to settle the naming. Refs #7114.
2026-06-30 17:22:30 +02:00
Johannes Millan
06a7425698
feat(focus-mode): make preparation opt-in, smooth start transition (#8639)
* feat(focus-mode): make preparation opt-in, smooth start transition

The full-screen preparation countdown is now opt-in (off by default) via a
new isShowPreparation config flag; the deprecated isSkipPreparation is kept
for synced-config back-compat. By default, starting a session now plays a
brief inline rocket launch from the play button, then begins.

Smooth the prep->running swap: the clock/controls cross-fade sequentially
(old fades out, then new fades in) via a new fadeSwap animation.

Fix the focus task-selector panel that rendered transparent (undefined
--c-bg-raised) -> opaque highest-elevation surface on the standard scrim.

* fix(focus-mode): guard re-entrant start, reduced-motion, clock cross-fade

- Ignore a re-entrant startSession() while the inline launch is playing
  (keyboard Enter / double-click on the still-focused FAB) and disable the
  play button during launch, so a second timer can't reset the new session.
- Skip the inline rocket launch + its 800ms delay under prefers-reduced-motion
  and start immediately (no invisible dead delay for motion-sensitive users).
- Cross-fade the clock digits with the duration slider (fade out before fade
  in) instead of a hard visibility toggle.
- Fix stale e2e launch-duration comment (~600ms -> ~800ms).
2026-06-29 19:44:53 +02:00
Johannes Millan
faa9434a6a
fix(sync): surface OneDrive OAuth token errors and document Entra setup (#8580)
A OneDrive token-exchange 400 (issue #8572) surfaced only as a generic
"HTTP 400 Bad Request" / "copy the code exactly" message, hiding the
real cause: the authorize step succeeds, then token redemption fails
because the custom Microsoft Entra app isn't registered as a public
client ("Allow public client flows" disabled -> AADSTS7000218).

- onedrive provider: parse the OAuth error body once, log the safe
  short `error` code on every token failure, and route the verbose
  AADSTSxxxxx `error_description` to HttpNotOkAPIError.detail (UI only,
  per the existing privacy split).
- sync-wrapper: show a OneDrive-specific snack pointing at the Entra
  registration fix and interpolating the AADSTS detail; other providers
  keep INVALID_AUTH_CODE.
- in-app info text now shows the desktop redirect URI and links to the
  setup guide; add a full OneDrive section to the configure-sync wiki
  note (it was entirely missing).
- test: auth-code 400 surfaces the detail to the UI, logs the error
  code, and keeps the description out of the structured log.
2026-06-24 16:18:46 +02:00
Johannes Millan
280c2c1c52
fix(caldav): route WebDAV verbs through native HTTP on Android (#8558) (#8577)
CalDAV calendar discovery (PROPFIND) and event search (REPORT) failed on
the Android (Capacitor) and web apps while working on Mac and iPad.

Root causes:
- Android: Capacitor's CapacitorHttp routes non-GET/HEAD/OPTIONS/TRACE
  requests through Java's HttpURLConnection, which throws ProtocolException
  for WebDAV/CalDAV verbs (PROPFIND, REPORT, ...). Affects every CalDAV
  calendar user on Android, not just iCloud.
- Web: pure CORS — iCloud's CalDAV server sends no Access-Control-Allow-Origin
  header, so the browser blocks the request. Unfixable client-side.

Fix (Android/native): on native platforms, route the WebDAV/CalDAV verbs
through the existing OkHttp/URLSession-backed native HTTP executor (the same
WebDavHttp plugin the WebDAV sync feature already uses), which accepts
arbitrary method strings. Standard verbs (GET/POST/PUT/DELETE/...) keep the
HttpClient path unchanged. The native path preserves SSRF validation, auth
headers, and HttpClient's non-2xx error shape (.status) so plugin retry/404
checks behave identically.

The rerouted set is scoped to the verbs the CalDAV plugin uses (PROPFIND,
REPORT) and stays within what the native WebDavHttp plugin implements.

Web is unchanged and still limited by CORS for servers (like iCloud) that
don't send CORS headers; documented in 3.05-Web-App-vs-Desktop.md.

The reroute is gated by injectable tokens (PLUGIN_HTTP_IS_NATIVE,
PLUGIN_HTTP_NATIVE_EXECUTOR) for testability.
2026-06-24 13:45:39 +02:00
Johannes Millan
431bc50da9
fix(sync): help Nextcloud users find their user ID + auto-detect it (#7617) (#8547)
* docs(sync): point Nextcloud user-ID lookup at the WebDAV URL (#7617)

The field hint, the 404 test-connection message, and the wiki all told
users to find their user ID under 'Settings -> Personal info' / 'your
Files URL'. Both are unreliable: Personal info does not clearly surface
the uid, and a folder's address bar shows a folder ID, not the user ID
(reporter followed it and got a folder ID). Redirect all three to the
authoritative source: Files -> settings gear (bottom-left) -> the WebDAV
URL '.../remote.php/dav/files/<user-id>/'.

* feat(sync): auto-detect Nextcloud user ID via OCS (#7617)

The Nextcloud WebDAV files path needs the account's internal user ID,
which differs from the email/login name people enter and is awkward to
find by hand — the root cause of the recurring '404 / connection test
failed' reports. Add a 'Detect user ID' button to the Nextcloud sync
config that asks the server for it.

- packages/sync-providers: discoverNextcloudUserId() calls the OCS
  endpoint /ocs/v2.php/cloud/user (OCS-APIRequest header) authenticated
  with login + app password and returns ocs.data.id. A 401 reports bad
  credentials (cleanly distinct from the 404 wrong-user-id path); a 200
  that isn't an OCS payload reports 'not a Nextcloud/OCS server'. A
  missing/wrong URL scheme is refused up front (matches the provider's
  own _cfgOrError check) so credentials never hit a schemeless host.
- Dialog: button fills the Username field with the detected ID. To keep
  auth working, if 'Login name' was empty and the user had typed their
  login into 'Username', that login is preserved into 'Login name'
  before Username is overwritten with the ID. Result handling split into
  _applyDetectedUserIdResult for unit-testability.
- Additive and optional: generic WebDAV and the save/sync path are
  untouched, no synced data shapes change.
- Tests: 7 package specs (success, trailing-slash, login fallback, 401,
  non-OCS 200, missing id, scheme guard) + 6 dialog specs (login guard,
  fill+confirm, 3 login-preservation cases, failure).
2026-06-23 11:56:15 +02:00
John Costa
0a0723521b
feat(azure-devops): add optional WIQL override for auto backlog import (#8516)
* feat(azure-devops): add optional WIQL override for auto backlog import

Adds an optional autoImportWiql config field to the Azure DevOps issue
provider plugin. When set, it fully replaces the generated auto-import
backlog query, so the user controls scope, state filtering and ordering
(e.g. to filter by iteration path, area path or work item type, or to
match custom done-state names). When empty, behavior is unchanged from
today's scope-based query, so it is fully backward compatible.

This mirrors the Jira provider's autoAddBacklogJqlQuery (a full query the
user owns) rather than appending a fragment, which can only narrow the
hard-coded English done-state exclusion and cannot accept a real exported
WIQL Select..From..Where statement.

Adds a vitest suite covering the default scopes, quote escaping, the
verbatim override and the blank-fallback, wires the plugin into the
plugin-tests CI matrix, and updates the issue integration comparison wiki.

Closes #7674

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DY1ESWymmU9x9ykLNaRHGH

* test(azure-devops): use node test env, drop unneeded jsdom dependency

The plugin tests exercise no DOM API, so vitest's default node
environment is sufficient (matching the clickup and google-calendar
provider plugins). Removing jsdom prunes ~530 lines of transitive
devDependencies from the lockfile.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-06-22 17:38:49 +02:00
Johannes Millan
20e85a2a19
refactor(tasks): restructure reminder dialog footer into primary + overflow (#8517)
* refactor(tasks): restructure reminder dialog footer into primary + overflow

The footer showed up to four visually identical stroked buttons (Snooze,
Done, Add to Today, Start) with no hierarchy. Reduce to a single filled
primary action plus a Snooze menu and an overflow (kebab) menu:

- Primary: Start (single) / Schedule for today (multiple)
- Snooze button keeps the quick-defer menu (10/30/60 min, tomorrow)
- New overflow menu holds the rest (Done/Complete, Add to Today, edit,
  unschedule, dismiss-keep-today)

All existing actions remain available; only their grouping changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* refactor(tasks): add dropdown caret to reminder snooze button

The Snooze button opens a menu but had no visible affordance signalling
that. Add a trailing arrow_drop_down icon (native Material iconPositionEnd
slot) so the menu trigger reads as such, surfaced by the review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* test(reminders): open overflow menu to reach Done in deadline specs

The reminder dialog's "Done" action moved from a footer button into the
new overflow ("More actions") menu, so the two deadline e2e specs must
open that menu before clicking Done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* refactor(tasks): refine reminder footer hierarchy after UX review

Follow-up to the footer restructure, addressing review findings:

- Surface a one-tap "Done" (check) icon for single reminders, so the most
  common reminder response isn't two taps deep in the overflow menu. Left
  out for the multi-task case, where a one-tap complete-all is a footgun
  and per-row checks already exist.
- Make the primary action deadline-aware: deadline reminders now default
  to the safe "Add to Today" instead of "Start" (which sets the current
  task and reorders Today as a side effect). Start moves into the overflow
  for deadlines; scheduled reminders keep Start as primary.
- Move "Edit (reschedule)" into the Snooze ("when") menu so all
  time-deferral actions are grouped and the overflow holds pure
  dispositions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* test(reminders): click one-tap Done icon in deadline specs

The single-task reminder footer now surfaces "Done" as a one-tap icon
button (aria-label "Mark as done"), so the deadline specs click it
directly instead of opening the overflow menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* refactor(tasks): put primary reminder action on the right, Done in menu

Per UX feedback:
- Reverse the footer button order so the filled primary action is always
  on the right (overflow on the left, Snooze in the middle).
- Move "Done" back into the overflow menu instead of surfacing it as a
  standalone icon button.

Update the deadline e2e specs to open the overflow menu before clicking
Done accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* feat(tasks): allow dismissing reminder dialog + snooze split-button

Three reminder-dialog improvements:

- Click-away to dismiss: backdrop click / Escape now close the dialog and
  dismiss the reminders (clearing the reminder while keeping the task and
  its schedule). disableClose is kept and the events are handled in the
  dialog, so the worker does not immediately re-open it in a loop.
- Snooze split-button: a single click snoozes 10 min (the common default,
  previously a hidden double-click) and a joined dropdown caret opens the
  full options menu. Uses a new shared .g-split-btn style.
- Overflow button: replace the lone vertical "three dots" icon with a
  low-emphasis labeled "More" button, giving a clean emphasis ramp
  (More < Snooze < primary) with the primary kept on the right.

Updates the deadline e2e specs and wiki for the new dismiss behavior, and
adds unit tests for the backdrop/Escape dismissal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* fix(tasks): make reminder dialog close on backdrop/Escape

Per product decision, clicking the backdrop or pressing Escape now simply
closes the reminder dialog (drop disableClose) rather than dismissing the
reminders. Deadline reminders are still cleared on close (avoiding the 10s
re-fire loop); scheduled reminders stay active and the worker re-shows them
until the user acts on them.

Reverts the earlier intercept-and-dismiss approach (and its unit tests);
updates the wiki accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* feat(tasks): add split-button UI component, restructure reminder footer

Add a reusable <split-button> UI component (src/app/ui/split-button): a
primary default action joined flush to a compact, centered overflow trigger
that opens a passed-in menu. The joined treatment lives in the shared style
layer (styles/components/split-button.scss) and now renders with no gap
between the two halves and a properly centered trigger icon.

Use it in the reminder dialog footer: limit the footer to two actions —
"Snooze 10m" (showing the snooze duration) and the primary CTA (Start /
Add to Today) — and fold the former "More actions" and snooze-options menus
into a single overflow menu opened by the icon button right of snooze.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* fix(tasks): fuse split-button halves and update reminder dialog test

The split-button trigger was pushed 8px away from the default action by
Angular Material's `.mat-mdc-dialog-actions .mat-mdc-button-base +
.mat-mdc-button-base` rule (specificity 0,3,0), which outweighed the
joining `margin-left: -1px`. Match Material's selector hooks so the
negative margin wins and the two halves render flush inside dialogs.

Also drop the stale `disableClose: true` assertion from the reminder
module spec to match the now-dismissable dialog (fixes failing CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* fix(tasks): clarify reminder dialog "add to today" button label

The multi-task primary button said "Schedule for today", which is both long
and misleading: those tasks are already scheduled — the action drops their
time and keeps them on Today (all day), or for deadlines adds them to Today
while keeping the deadline date.

Key the label on deadline vs schedule instead of single vs multiple:
- all deadlines -> "Add to Today" (added to today, deadline preserved)
- scheduled/mixed -> "Today" (already scheduled; kept on today, all day)

Reuses the existing TODAY_TAG_TITLE string; SCHEDULE_FOR_TODAY remains for
the per-row tooltips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* fix(tasks): distinguish deadline vs scheduled in reminder today-button

After weighing options, label the reminder dialog's primary today-action by
what it actually does in each case:
- all deadlines -> "Add to Today" (task is added to Today; deadline kept)
- scheduled/mixed -> "Keep in Today" (already scheduled; kept on Today, all
  day, with the specific time dropped)

This replaces the bare, verb-less "Today" with accurate wording consistent
with the dialog's existing "keep in Today" vocabulary. Adds the
KEEP_IN_TODAY en string (other locales fall back to English via fallbackLang).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bqs4hesfHeJATSCr4mRoXE

* chore(reminders): drop unused SNOOZE_OPTIONS string, regenerate t.const.ts

The reminder-dialog footer trigger uses MORE_ACTIONS, not SNOOZE_OPTIONS,
which was a dead key from an earlier iteration. Remove it and regenerate
t.const.ts through prettier so the file is back to the formatted form
(the prior commit had committed raw generator output, churning ~760 lines).

* refactor(ui): trim unused split-button inputs, add unit spec

The split-button's only consumer never overrides color or triggerIcon, so
drop both inputs (and the deprecated ThemePalette type) and inline the
defaults. Add a unit spec covering content projection, mainClick, menu
wiring, disabled state, and the trigger aria-label.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 15:42:44 +02:00
archit-goyal
2bf0d804cb Clarify empty CalDAV option loads 2026-06-21 13:32:35 +05:30
Johannes Millan
9faf0a50d5
fix(sync): clarify Nextcloud connection-test 404 (wrong user ID) (#7617) (#8513)
* fix(plainspace): don't tint claim-list link icon with accent color

* fix(sync): clarify Nextcloud connection-test 404 (wrong user ID) (#7617)

A base-root 404 in the WebDAV connection test means auth succeeded but the
DAV path /remote.php/dav/files/<userName>/ does not exist — i.e. the
"Username" holds an email/display name instead of the account's user ID
(Nextcloud accepts email/login for auth but the files path needs the uid).

Previously this surfaced as a bare scrubbed hostname in the snack, which
users misread as a stripped URL. Now:

- WebdavApi.testConnection maps thrown errors to a readable, privacy-safe
  message plus an HTTP errorCode discriminator (404/401/status).
- The Nextcloud "Test Connection" path shows a specific hint on 404
  explaining that "Username" must be the user ID, not email/display name.
- Wiki + field guidance clarified accordingly.
2026-06-20 15:18:53 +02:00
Mohamed Abdeltawab
0b4bc79354
refactor: retire GET /api/sync/snapshot, re-scope GET /api/sync/status as diagnostic (#8496)
Retire GET /api/sync/snapshot (attack-surface reduction):
- Remove route handler from sync.routes.ts (no production client caller)
- Keep internal generateSnapshot() and all downstream code
- Update tests: delete GET /snapshot describe block, rework isolation
  test (sync.routes.spec.ts), remove GET assertion (sync-fixes.spec.ts),
  remove SimulatedClient.getSnapshot() helper + rework 2 integration tests
  to use GET /api/sync/ops instead (multi-client-sync.integration.spec.ts)

Re-scope GET /api/sync/status as diagnostic:
- Add doc comment to route handler marking it diagnostic
- Update all documentation (README, API wiki, architecture diagrams)

Documentation updates across 5 files remove GET /snapshot references
and label GET /status as diagnostic.

Breaking: self-hosters with external tooling relying on GET /snapshot
must migrate — no shipped client version ever called this endpoint.
2026-06-20 12:02:58 +02:00
Symon Baikov
cbe3f1527f
fix(caldav): support Vikunja project discovery (#8481) 2026-06-19 12:15:31 +02:00
Symon Baikov
69cb69df51
docs(sync): document Proton Drive via rclone WebDAV (#8064) 2026-06-19 11:05:30 +02:00
Johannes Millan
0150ee23b3
feat(focus-mode): surface session-done + notify on countdown completion (#8475)
Countdown is the only focus mode that auto-stops with no follow-up
(Pomodoro transitions into a surfaced break; Flowtime only stops on
explicit user action), so completion could pass unnoticed: the SessionDone
screen wasn't reliably shown and there was no cross-platform notification.

On automatic Countdown completion (surfaceSessionDoneOnCompletion$):
- If the focus overlay is open, the reducer's SessionDone screen is already
  visible — nothing forced.
- If the overlay is hidden (user working elsewhere), surface a non-modal
  banner (BannerId.FocusModeSessionDone) with a 'What's next?' action that
  opens the SessionDone screen and self-dismisses once the overlay opens —
  instead of seizing the screen.
- Raise an OS notification only when the app is unfocused (skipped while
  focused, since the surfaced UI is alert enough and it would fire on
  idle-resume) and not on Android, where the native foreground service
  already posts its own completion notification (documented in
  FocusModeForegroundService.onTimerComplete to keep the contract visible).

Uses the injectable IS_ANDROID_WEB_VIEW_TOKEN (testable). Manual end and
Pomodoro/Flowtime are excluded.
2026-06-18 17:33:02 +02:00
Johannes Millan
c5651f52c7 docs(wiki): add Proton Drive via rclone + WebDAV guide
Document syncing to Proton Drive on desktop using the existing WebDAV
provider pointed at a local 'rclone serve webdav' bridge, instead of a
dedicated built-in provider. Marked experimental and no-support, in line
with how generic WebDAV is treated, since it relies on rclone's
reverse-engineered Proton backend.
2026-06-18 15:18:15 +02:00
Maikel Hajiabadi
ec16757c82
fix(keyboard): resolve macOS global shortcut layout mismatch (#8378) (#8381)
* fix(keyboard): resolve macOS global shortcut layout mismatch (#8378)

* fix(keyboard-layout): log layout-detection failure and resolve layoutReady with map copy

* fix(keyboard-shortcut): remove debug console logs and add macOS scope comment

* fix(keyboard-shortcut): preserve modifier separator when mapping plus key shortcut

* refactor(keyboard-shortcut): export mapping helpers and avoid as any cast in configuration mapping

* test(keyboard-shortcut): add unit tests for layout shortcut translation logic

* test(keyboard-shortcut): use correct KeyboardConfig type instead of as any in test fixture

* docs(keyboard-shortcut): hoist macOS physical shortcut layout comments to helper JSDoc

* refactor(config): extract global shortcut keys and mapping helpers

* test: add test helper capability for IS_ELECTRON and IS_MAC

* test: add comprehensive integration unit tests for global shortcut effects

* feat(electron): eagerly trigger layout detection on Electron startup for macOS timing fix

* refactor(config): InjectionToken migration, layout detection hardening, and startup optimization

* refactor: address non-blocking suggestions for layout detection and DI tokens

* build(electron): move keyboard-config.model to shared-with-frontend to fix ASAR require

* fix(client-id): increase entropy to 6 chars and fix related test regressions
2026-06-17 12:57:47 +02:00
Meh-S-Eze
3db96fd8a3
fix(plugins): expose focused task API to iframe plugins (#8291)
Co-authored-by: Shem Freeze <whatamehs@Shems-MacBook-Air.local>
2026-06-15 14:57:34 +02:00
Mohamed Abdeltawab
a4c0dc7d46
refactor: remove dead SyncStateCorruptedError, compat exports, and 0 byte sync-providers barrel #8328 (#8396)
* refactor: remove dead SyncStateCorruptedError, compat exports, and 0-byte sync-providers barrel #8328

* docs(sync): drop stale SyncStateCorruptedError/fail-fast references (#8328)

The fail-fast dependency-resolution subsystem (DependencyResolverService +
SyncStateCorruptedError throw) was removed earlier; OperationApplierService now
bulk-dispatches ops in causal arrival order and returns a failedOp for the caller
to re-validate/retry. Update the sync architecture docs, archive-operations
diagram, and user-data wiki to match. Completes the dead-code cleanup for #8396.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
2026-06-15 14:16:14 +02:00
Johannes Millan
0ec8c207a8 feat(theme): add Plainspace built-in theme
A warm paper theme ported from plainspace.org: cream surfaces, a
terracotta primary and a teal accent, in light and dark. Registered in
BUILT_IN_THEMES (requiredMode: system) and listed in the theming docs.

Surfaces/ink primitives drive the semantic tokens; the primary/accent
palette ramps recolor the brand. Dark-mode Category-B tokens (subtasks,
notes, selected rows, schedule events) are re-derived from the warm
surface ladder so nothing leaks the base cold-grey ramp.

Signature paper touches:
- squared graph-paper backdrop (reuses the body::before layer)
- hand-drawn terracotta underlines on headings and collapsible headers
- a hand-drawn rule under each board column header in place of a panel box
- warm soft paper shadows (--card-shadow split per mode) and tighter
  3/6/8px radii

The ambient primary-tinted gradient wash is removed (repurposed to the
grid); remaining gradients are contextual and left untouched.
2026-06-15 14:06:21 +02:00