mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-21 02:20:12 +00:00
503 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
3d2c811e78 | chore(task-repeat): revert RRULE Phase 1 from master (#7948) Develop the full RFC-5545 RRULE epic on the long-running feat/rrule-epic branch and merge once complete and testable in final form, rather than landing 13 phases into master one half-state at a time. Work preserved on feat/rrule-epic. Not yet shipped (post-v18.9.1), so no user impact. | ||
|
|
ccbd66d3d9 |
test(e2e): retry supersync time-estimate dialog until input binds
The fill('10m') could fire its `input` event before the duration input's
value-accessor (an `input` HostListener) was wired, so the typed value
never reached the model and submit() saved an empty time — the panel
stayed "-/-" and toContainText('10m') timed out (scheduled run
27197862391, SuperSync 1/6). Retry the whole open→fill→submit cycle so a
fill that didn't stick is re-applied once the control binds.
|
||
|
|
1718b0a8b8
|
feat(task-repeat): RFC 5545 RRULE recurring schedules — EPIC · Phase 1/13 (Closes #4020) (#7948)
* feat(task-repeat): add cron / natural-language recurring schedules
Add a CRON repeat cycle so a single recurring task can express schedules
the day/week/month/year cycles cannot (e.g. every Saturday, March through
November). The cron expression drives occurrence generation; all other
schedule fields are ignored for CRON cfgs.
- Occurrence engine: getNewestPossibleCronDueDate / getNextCronOccurrence in
cron-occurrence.util.ts (cron-parser), wired into the existing
getNewestPossibleDueDate / getNextRepeatOccurrence dispatchers. Recurrence
stays day-granular: sub-daily crons create at most one task per day.
- English to cron via the crono-eng WASM module (src/assets/crono-eng.wasm,
loaded lazily), with a builtin regex parser as fallback until/if the module
is unavailable. naturalLanguageToCron() also passes raw cron through.
- Cron to english live preview under the input via cronstrue; the field shows
the interpreted cron + humanized reading as the user types and warns when an
expression is sub-daily.
- Invalid / unrecognized input shows an error snack and blocks save.
- Add-task bar: @+<cron-or-phrase> short syntax attaches a CRON repeat cfg.
- tools/build-crono-wasm.js regenerates the WASM asset from the sibling
crono-eng Zig project (committed asset is the source of truth for builds).
* fix(task-repeat): cron edge cases, clearer warnings, and corpus tests
Hardening + UX pass on the cron repeat cycle, driven by verifying the full
crono-eng corpus (415 phrases) end to end.
Fixes
- Silent never-fires: crono-eng can translate phrases (a specific year, "nearest
weekday"/W, "n-to-last day"/L-n, biweekly #-lists) into Quartz forms the
recurrence engine (cron-parser) cannot run. These passed UI validation yet a
task would never be created. naturalLanguageToCron now gates its output on
cron-parser, so such input is rejected at the field instead.
- Off-by-one for midnight crons: cron-parser's next() is exclusive of an
exact-boundary currentDate, so getNextCronOccurrence skipped the first
eligible day for 00:00 schedules (e.g. first occurrence landed a day late).
Seed the search one ms before the lower-bound day so a midnight fire stays
eligible; now matches the non-cron daily convention.
UX
- Live preview shows the interpreted cron + plain-English reading below the
field, and warns when the time of day is ignored (day-granular engine) or the
schedule is sub-daily ("runs at most once per day").
- Friendlier validation/snack message with examples instead of terse jargon.
Tests
- tools/test-crono-wasm.js (npm run test:crono): corpus-driven harness asserting
the committed WASM matches all 415 corpus crons, and that cron-parser +
cronstrue accept them — classifying the 27 known engine-unsupported forms so
it stays a regression guard.
- cron-occurrence.util.spec.ts: occurrence engine across daily/weekly/monthly/
month-range/sub-daily, varied times, start-date and last-creation gating.
- parse-natural-cron.util.spec.ts: raw passthrough, phrase recognition, null
cases, loader resilience.
- task-repeat-cfg-form.cron.spec.ts: field validator + live-preview/time-warning.
* fix(task-repeat): live cron preview + @+ title strip; add E2E
Fixes found by adding end-to-end coverage of the cron feature.
- Live preview did not render: Formly's mat-hint does not reflect a dynamic
expressionProperties.description, so the interpreted-cron/English preview never
updated as the user typed. Move it into the dialog template, driven by a
computed signal off the form's valueChanges (getCronPreview in cron-preview.util),
so it updates live and persists after applying. The time-of-day-ignored and
sub-daily warnings now render as proper translated lines.
- "@+<phrase>" short syntax left the clause in the title: shortSyntax set
taskChanges.title for the cron strip, but the later parseTimeSpentChanges
reassignment wiped it, so a bare "Foo @+every day" submitted the raw title.
Strip into the working title and emit the cleaned title at the end.
- shortSyntax returned cronExpression unconditionally (undefined when absent),
breaking 38 exact-match assertions; only include the key when set.
Tests
- e2e/tests/recurring/cron-repeat.spec.ts: @+ short-syntax attaches a CRON cfg
(title stripped); full dialog flow (phrase → live preview + time warning →
save → CRON cfg persisted).
- short-syntax.spec: @+ extraction/stripping, with and without other syntax.
- getCronPreview unit tests (interpreted cron, English, timed/sub-daily flags).
* test(task-repeat): property/fuzz/metamorphic/edge cron tests; fix first-occurrence
Large second testing pass (≈+58 unit tests, +2 E2E, new property harness),
which surfaced one gap and one upstream quirk.
Fix
- getFirstRepeatOccurrence returned null for CRON cfgs (no case → default), so a
timed CRON task's first instance and the startDate-moved-earlier path fell
back to today instead of the cron's first fire. Add getFirstCronOccurrence
(first fire on/after startDate) and route CRON through it.
Tests
- cron-occurrence.invariants.spec: property/invariant battery over many crons
(no-throw, result strictly-after / on-or-before bounds, determinism,
monotonicity), calendar edges (leap-day, year rollover, DST spring/fall),
pathological "Feb 30" (null, no hang), and getFirstCronOccurrence /
getFirstRepeatOccurrence routing.
- parse-natural-cron.metamorphic.spec: relational tests (case/whitespace/order/
irrelevant-text invariance, idempotence, determinism, raw passthrough).
- short-syntax.spec: more @+ cases (raw cron after @+, bare @ is not cron,
unrecognized phrase ignored, clause stops at the next delimiter).
- tools/test-crono-properties.js (npm run test:crono:props): WASM determinism,
marshalling fuzz (empty/oversized/unicode/boundary), English→cron→English
round-trip, and occurrence simulation across every runnable corpus cron.
- e2e: invalid phrase blocks save + shows inline error (Save disabled);
raw cron expression preview + save. Shared dialog-open helper.
Note: documented a cron-parser DST quirk — prev() skips the spring-forward
midnight, so "newest due" can land a day earlier on that date (day-granular,
once a year; lastTaskCreationDay prevents duplicates).
* test(task-repeat): selector integration, serialization, and WASM buffer-reuse
- selectors.spec: CRON cases for the selectors that drive task creation —
selectAllUnprocessedTaskRepeatCfgs (due today, overdue, already-created,
paused, future start, invalid expr, deleted instance) and
selectTaskRepeatCfgsForExactDay (exact-day match). Proves a CRON cfg is
picked up by addAllDueToday's selector path.
- cron-occurrence.invariants.spec: a CRON cfg survives a JSON round-trip with
identical occurrence behavior (sync / backup integrity).
- test-crono-properties.js: buffer-reuse checks — a short translation after a
long one is uncontaminated, and results stay deterministic across a 600-call
interleaved loop (the WASM module reuses static input/output buffers).
* test(task-repeat): cross-timezone day-resolution harness
The Karma suite only runs in the host timezone (Chrome on Windows ignores the
TZ env var — the reason the pre-existing *.tz.spec fails), so cron day-resolution
was never verified across zones. Add a Node harness (npm run test:crono:tz) that
spawns a child process per timezone — Node honors TZ — and asserts day-class
invariants in each: weekly-Monday resolves to a Monday, monthly day-15 to the
15th, daily to the next calendar day, time-of-day never shifts the day, and a
full-year daily iteration is monotonic with 366 distinct days (no DST skip in
next()). Covers fractional offsets (India +05:30, Chatham +12:45/+13:45) and
Southern-hemisphere DST (Sydney). Mirrors getNextCronOccurrence's core math.
* fix(task-repeat): DST-safe newest-due; verify real engine across timezones
Closes the time-based reliability gaps from the previous testing pass.
Fix
- getNewestPossibleCronDueDate no longer uses cron-parser's prev(), which skips
the spring-forward midnight in DST zones (a daily cron then looked like it
didn't fire that day). It now walks days backward and asks "does the cron fire
during this local day?" via a next()-based probe (next() is monotonic across
DST). Spring-forward and fall-back now resolve the exact day.
Tests
- main.ts exposes the real occurrence utils on __e2eTestHelpers.cron (dev/stage
only, stripped from production).
- e2e/cron-timezone.spec: runs the REAL engine under five forced browser
timezones (Playwright timezoneId — reliable regardless of host OS, unlike the
Karma TZ env). Each asserts the timezone was actually applied (live UTC
offset) AND that day-class results are correct, incl. fractional offsets
(Chatham +12:45) and the spring-forward day.
- test-crono-tz.js: also asserts a daily cron fires on every calendar day of the
year in all 8 zones (regression guard for the prev() skip).
- invariants.spec: spring-forward / fall-back now assert the exact day for both
next() and newest (no longer hedged).
- effects.spec: a CRON cfg's first occurrence is computed through the real
updateTaskAfterMakingItRepeatable$ effect path (live new Date()).
* test(task-repeat): dev jumpDay clock helper + live day-change e2e
Adds __e2eTestHelpers.jumpDay(n) / resetClock() (dev/stage only, stripped from
production) so recurring/day-change behavior can be fast-forwarded from the
DevTools console without touching the OS clock: jumpDay shifts Date.now() by a
cumulative day offset and forces the day-change re-sample so addAllDueToday()
runs. New e2e asserts jumpDay(1) creates the next daily instance — covering the
live day-change -> creation path end to end.
* feat(task-repeat): add "create a task for each missed occurrence" option
By default, opening the app after several scheduled occurrences were
missed creates only a single instance for the most recent occurrence.
When enabled (advanced section), a separate overdue task is created for
every missed occurrence instead, oldest first, capped at the 30 most
recent to avoid flooding the list / emitting a large sync batch.
- new getAllMissedDueDates() util reuses getNextRepeatOccurrence so the
per-cycle and CRON occurrence logic stays single-sourced and DST-safe
- service multi-spawn path is gated to the catch-up flow (target day is
today or earlier) and is mutually exclusive with skipOverdue /
waitForCompletion; the single newest-occurrence path is unchanged
- form checkbox hides when "skip overdue" is set, and vice versa
Part of #4020
* fix(task-repeat): re-anchor lastTaskCreationDay when cron expression changes
cronExpression was missing from SCHEDULE_AFFECTING_FIELDS, so editing a
CRON schedule did not re-run rescheduleTaskOnRepeatCfgUpdate$. A
previously computed (often future) lastTaskCreationDay then stuck around
and silently suppressed every occurrence until real time caught up to it
— e.g. a "Jan-Aug" cron set while the clock was past August anchored to
Jan 1 next year and produced no tasks in between.
Adding 'cronExpression' makes a cron edit re-anchor from the current
date, consistent with repeatCycle / repeatEvery / weekday edits.
Part of #4020
* feat(tasks): surface @+ recurring short syntax in autocomplete and help
The `@+<schedule>` add-task short syntax existed but was undiscoverable.
- add recurring examples to the `@` autocomplete suggestions: typing `@+`
now autocompletes phrases like `@+every monday` or
`@+every saturday from march through november`
- document `@+` in the Settings -> Short Syntax help text
Part of #4020
* fix(tasks): remove @+ recurring autocomplete entries
The `@+` examples added under the `@` mention trigger kept the suggestion
dropdown open while typing `@+<phrase>`, so pressing Enter selected a
suggestion instead of submitting the task — breaking the headline
type-and-go flow. The `@+` syntax stays documented in Settings -> Short
Syntax; only the dropdown entries are removed.
Part of #4020
* refactor(task-repeat): show cron only inside Custom recurring config
Cron appeared twice in the recurring-config dropdown: as a top-level
"Cron / natural language" quick-setting AND as a cycle inside "Custom
recurring config". Drop the duplicate top-level option; cron is now
reached via Custom -> repeatCycle = Cron.
- remove the CRON quick-setting option from the dropdown builder
- keep the repeatCycle select visible when CRON is chosen (only hide
repeatEvery) so the user can switch back
- map existing cron configs (incl. legacy quickSetting 'CRON') to CUSTOM
on load so the dropdown matches the stored cycle
- update the cron E2E to drive the dialog via Custom -> Cron
Part of #4020
* test(task-repeat): make @+ short-syntax e2e date-independent
The test asserted the `@+every monday` task appears in the Today view, but
a weekly schedule's first instance is the next Monday — so on any non-Monday
the task is correctly scheduled for a future day and isn't in Today, making
the test fail depending on the day it runs. Verify the attached CRON config
and the stripped title via the store instead, which is view- and
date-independent.
Part of #4020
* feat(task-repeat): RFC 5545 RRULE recurring schedules [WIP]
Overhaul recurring tasks from the bespoke repeatCycle format to RFC 5545
RRULE as the canonical recurrence representation (legacy fields kept
populated for old-client forward-compat). Adds a structured RRULE builder,
RRULE-backed quick presets, per-instance due-date derivation, multiple end
conditions (COUNT / UNTIL / after-N-completions), an occurrence heatmap with
completion simulation, natural-language @+ short syntax, and a REST API for
recurring tasks. Migration is lazy (on dialog open) so existing configs keep
firing untouched until edited.
WIP: some required features and tests are still outstanding, and it needs
much more real-world (user-based) testing before it is merge-ready.
* feat(task-repeat): per-occurrence overrides, due-date control move, @+ preview, NL fix [WIP]
- RECURRENCE-ID: per-instance overrides (move / re-time / re-title) surfaced to
the engine as RDATE + EXDATE so every projection stays consistent.
- Move the due-date derivation control out of the rrule builder into the dialog
so it applies to all recurring configs (presets + Custom).
- Live @+ recurrence preview (humanized rule + next occurrence date) in the
add-task-bar, so a far-off rule is obvious before submit.
- Fix @+ "every other <weekday> in <month>" mis-parsing to YEARLY;INTERVAL=2
("every 2 years"); weekly-style interval + weekdays now stays WEEKLY.
* refactor(task-repeat): trim PR to engine+builder+migration+preview; fix curator review
Trim the WIP recurring-schedules PR to its reviewable core — RFC 5545 occurrence
engine, structured RRULE builder, legacy<->RRULE migration (both directions),
live text preview, quick-setting presets, and tests — and defer the rest to
follow-up sub-MRs: natural-language @+, due-date derivation, ends-after-N-
completions, missed-occurrence backfill, heatmap preview + simulation, REST
recurring creation, and RECURRENCE-ID per-instance overrides. The full
implementation is preserved on branch feat/recurring-full.
Curator review fixes:
- quickSetting forward-compat: never persist a value outside the released
(master) union. The newer preset literals AND 'RRULE' map to 'CUSTOM' at every
persist boundary (dialog save, add-task-bar, data-repair downgrade); the rich
value drives the dialog UI in-memory only and the builder reconstructs from the
opaque rrule on open. Fixes old/mobile typia sync-validation treating such
tasks as corrupt.
- Malformed rrule now falls back to the legacy schedule fields (guarded by
isRRuleValid) instead of silently stopping the task.
- Reverse converter maps a BYDAY-less FREQ=WEEKLY onto the start weekday, so the
legacy WEEKLY engine still fires on old clients.
- Stop logging the raw rule body (the raw-override field makes it free-text user
input; log history is exportable).
- DRY: share normalizeWeekdays / toNumArray between the two converters.
* refactor(task-repeat): clamp quickSetting at the action creators
The op-log replays the action payload, not reduced state
(operation-capture.service.ts), so a reducer-level clamp would not keep an
out-of-union quickSetting off the wire. Clamp in the addTaskRepeatCfgToTask /
updateTaskRepeatCfg / updateTaskRepeatCfgs creators instead -- the single
boundary every dispatcher passes through, so old/mobile clients always replay a
value their typia union accepts. The newer preset literals and 'RRULE' stay
in-memory in the dialog form only.
Removes the now-redundant per-call-site clamps in the dialog save path and the
add-task-bar; the @+ and REST paths inherit the clamp for free when their phases
return. Adds an action-creator spec covering the clamp at each entry point.
* feat(task-repeat): nth-weekday rows support multiple weekdays per ordinal
Each ordinal row (1st/2nd/3rd/4th/last) now multi-selects weekdays via toggle
buttons, so one line can mean "the 1st Monday and the 1st Tuesday" -> BYDAY=1MO,1TU.
The per-row ordinal dropdown excludes positions already used by other rows (each
ordinal anchors at most one line), and the add button hides once all are used.
Parsing groups weekdays that share an ordinal back into one row. Applies to both
the monthly and yearly nth-weekday modes; updates the helper copy accordingly.
* test(task-repeat): complex RRULE coverage + day-by-day create-loop sim
Adds high-complexity coverage for the occurrence engine and builder:
- engine variants x settings: per-day ordinals, BYSETPOS last-weekday,
BYMONTHDAY=-1, seasonal BYMONTH, BYWEEKNO/BYYEARDAY, leap years, mixed with
COUNT / UNTIL / EXDATE / lastTaskCreationDay
- builder forward + mode-detection + lossless round-trips (incl. multi-weekday
ordinals and sub-daily raw-override)
- cfg->engine routing for complex rules with deletedInstanceDates and the
malformed-rrule legacy fallback
- a "day-march" simulator that drives getNewestPossibleDueDate one day at a time
with lastTaskCreationDay fed back like the service, asserting the created
stream has no dupes/skips, honors EXDATE/COUNT, is idempotent within a day,
and documents Phase-1 app-closed catch-up (newest missed only)
Also trims the deferred @+ short-syntax e2e (returns with its phase) and fixes
the dialog-flow e2e to target the current .rrule-result preview selector.
* feat(task-repeat): custom ordinal input for nth-weekday rows and BYSETPOS
The nth-weekday ordinal dropdown and the weekday-set 'which occurrence'
dropdown each gain a 'custom…' option that reveals a free-form input,
allowing any non-zero ordinal (e.g. -2 = 2nd-to-last, 5FR) and
comma-separated BYSETPOS lists (e.g. 2,-1). Parsed rules with such values
now render structurally instead of falling back to the raw override.
* feat(task-repeat): make weekday-set occurrence (BYSETPOS) a multi-select
Replace the single-value 'which occurrence' dropdown with toggle buttons
matching the weekday toggles, so several occurrences can combine (e.g.
first + last weekday = BYSETPOS=1,-1). 'Every' clears the narrowing; the
custom input stays for values without a toggle.
* fix(task-repeat): address review findings on rrule migration fidelity
- Yearly builder rules seed BYMONTH from the start month: a bare
FREQ=YEARLY;BYMONTHDAY=n expands across every month (RFC 5545) and
fired monthly for fresh yearly configs.
- quickSetting change handler passes the selected start date, so
date-writing presets no longer overwrite a user-picked future anchor
with today.
- Remove the createForEachMissed checkbox + copy: the engine has no
backfill support yet, so the setting silently did nothing (and wrote
an undeclared field into synced state).
- rruleToLegacyTaskRepeatCfg always resets the monthly anchor fields so
stale nth-weekday/last-day anchors can't survive the merge, sets
monthlyLastDay only for a pure BYMONTHDAY=-1 rule, and aligns
startDate to the rule's first occurrence for date-anchored
monthly/yearly rules (legacy clients read day/month from startDate);
save() re-derives the fallback so later startDate edits stay aligned.
- _normalizeMonthlyAnchor keeps monthlyLastDay on RRULE saves — it is
the derived old-client fallback for BYMONTHDAY=-1, not a stale preset
flag.
- Legacy CUSTOM migration preserves clamp semantics: day > 28 anchors
emit BYMONTHDAY=<d>,-1;BYSETPOS=1 (the day, or the last day of
shorter months) instead of a plain BYMONTHDAY that skips such months;
the builder serializer emits BYSETPOS in day-of-month modes so these
rules round-trip structurally.
- Regenerate package-lock.json to drop stale cron-parser/cronstrue
entries left over from the earlier cron approach.
* fix(task-repeat): harden rrule builder + legacy fallback after deep review
Correctness:
- Clear bySetPos (and its custom-input state) on monthly/yearly mode and
frequency switches: a BYSETPOS left over from the weekday-set mode
silently narrowed day-of-month rules (BYMONTHDAY=15;BYSETPOS=2 never
fires) with no UI to see or clear it.
- Move startDate alignment out of rruleToLegacyTaskRepeatCfg into a new
arithmetic getAlignedStartDate, applied once at save and only when the
schedule actually changed: the occurrence-search version corrupted the
clamp idiom (aligned a day-31 rule onto Feb 28/29 so old clients fired
on the wrong day forever), collapsed multi-day BYMONTHDAY lists to
their earliest day, rewrote the visible start-date field live on every
builder interaction, cost ~60ms/keystroke for sparse rules, and put
startDate into the change diff on title-only edits — retriggering the
issue-#7373 reschedule. Weekday-anchored rules are no longer aligned.
- Emit the monthly-anchor resets as null/false instead of undefined (in
the converter AND the presets' MONTHLY_ANCHOR_RESET): JSON.stringify
drops undefined keys from op-log payloads, so the reset never reached
remote clients and stale nth-weekday/last-day anchors survived there.
The anchor model fields now allow null; the nth-weekday engine guard
strips it.
- Enforce the YEARLY BYMONTH guard in the serializer: yearly date mode
without months now emits a plain FREQ=YEARLY (anniversary) instead of
a bare BYMONTHDAY that fires monthly; parsed bare yearly rules fall
back to the raw override, preserving their semantics verbatim.
- Drop the auto-seeded BYMONTH when switching away from YEARLY (it
silently constrained the new rule to one month) and seed from the
CURRENT start date rather than the ngOnInit-captured month.
- Clamp custom nth ordinals per frequency (±5 monthly, ±53 yearly) —
BYDAY=10MO is valid RFC but matches nothing, ever — and reject an
ordinal another row already anchors (duplicate rows collapse on
reload). Re-clamp poses when switching into MONTHLY.
- Drop BYSETPOS=0 on parse (parseString accepts it; re-emitting creates
a rule the occurrence engine silently treats as dead).
- Predefined set-position toggles close the explicitly-opened custom
input (both rendered active with contradictory state).
Cleanup:
- Extract parseIntList (4 cloned int-list sanitizers), pushBySetPos /
pushByDaySet (4 copy-pasted emit blocks), _clampedMonthDayPart
(monthly/yearly clamp duplication); share the monthly/yearly
weekday-set template block via ngTemplateOutlet; parse BYSETPOS via a
computed signal instead of per-CD string parsing.
- Correct the BYMONTHDAY hint: it claimed short months clamp to their
last day, but plain BYMONTHDAY skips them.
* test: make timezone demo specs host-independent
Six .tz.spec demonstration tests branched on the host's CURRENT
getTimezoneOffset() (or a literal 'America/Los_Angeles' name check)
while asserting against January test instants. In DST-observing zones
the summer offset differs from the January one (e.g. New York: EDT 240
vs EST 300), so the wrong branch was taken and the suite failed every
summer on US-east machines, blocking pre-push hooks.
Branch on the offset AT the test instant instead, with the correct
longitude threshold for each instant (07:00 UTC flips the local day at
UTC-7, 06:00 UTC at UTC-6, midnight UTC at UTC±0) — the tests now pass
in any host timezone year-round.
* test: pin browser timezone to Europe/Berlin in karma config
Re-enable the previously commented-out TZ pin so timezone-sensitive
specs run deterministically where Chrome honors the env var
(Linux/macOS, incl. CI). Verified empirically that headless Chrome on
Windows IGNORES TZ and keeps the host zone — the *.tz.spec.ts files
keep their offset-at-test-instant branching as the Windows backstop.
* test: make supersync dump/restore helpers cross-platform
createFullDump/restoreFullDump used POSIX-only shell constructs (output
redirection to /tmp, 'cat | psql', 'rm -f'). On Windows the restore
pipeline failed AFTER the 'DROP SCHEMA public CASCADE' had already
succeeded, leaving the test database without any tables — every
subsequent spec in the run then failed in createTestUser with 'table
public.users does not exist' (5 cascading failures + dozens of
never-ran tests).
Capture pg_dump via execSync stdout + fs.writeFileSync, feed the
restore through stdin (execSync input), use os.tmpdir() and
fs.unlinkSync. Verified: full dump-restore spec plus the three spec
files it previously poisoned all pass on Windows (14/14).
* test(e2e): round-trip coverage for new rrule builder widgets
Covers the four gaps hand-testing would otherwise need to catch, using
the dialog's live result band as the oracle (.rrule-result__expr =
exact assembled rule, .rrule-result__next = engine-computed upcoming
dates — no waiting for real time):
- custom nth ordinal (-2 = 2nd-to-last weekday) emits the right BYDAY
and persists verbatim
- BYSETPOS multi-select (first + last) emits BYSETPOS=1,-1 and persists
- mode switch drops a weekday-set BYSETPOS instead of leaking it into a
day-of-month rule (dead-rule regression)
- switching to YEARLY seeds BYMONTH and the upcoming dates are strictly
one per year
Persistence asserted via the NgRx store: saving a recurring cfg
re-plans the task onto its first occurrence (usually not today), so a
UI reopen from the work view is date-dependent; the parse-to-widget
rendering side is covered by the dialog/builder unit specs.
* fix(add-task-bar): open the repeat dialog for the custom recurring option
The repeat quick-setting menu emits the 'RRULE' value for 'Custom
recurring config', but the add-task-bar still branched on the legacy
'CUSTOM' value — picking the option fell through to the preset path and
silently created a weekly-fallback cfg instead of opening the dialog.
Route both 'RRULE' and 'CUSTOM' through the dialog path, preselect the
RRULE builder in the dialog via a new initialQuickSetting dialog input
(the user explicitly asked for a custom rule), and show the tune icon
for the menu entry again. Covered by a new e2e: menu pick → task
created → builder dialog opens → saved rule persists verbatim.
* fix(task-repeat): address review on rrule alignment + anchor sync safety
- getAlignedStartDate re-anchors onto the rule's actual first occurrence
(occurrence-set-neutral for INTERVAL/COUNT/UNTIL) instead of pure date
math that shifted INTERVAL cadence and skipped valid clamped occurrences
- save() always re-derives the legacy fallback fields against the final
startDate, not only when alignment moved it
- monthly anchors never persist null (released clients' typia schema only
allows absent-or-numeric): undefined resets everywhere, null stripped at
the action-creator boundary, model reverted to master signature
- round-trip guard ignores BYSETPOS=0 so the cleaned rule is emitted
instead of being preserved verbatim via raw override
* docs(wiki): fix MD024 duplicate headings on repeating-tasks page
* fix(task-repeat): harden rrule save guards + restore preset labels on reopen
Correctness (from deep review of the branch diff):
- reject COUNT combined with repeatFromCompletionDate at save: completion
re-anchors startDate/lastTaskCreationDay, restarting the COUNT window, so
the series would never terminate
- reject parseable rules that yield zero occurrences (e.g.
FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30): they persisted as silently dead
recurrences with the legacy fallback bypassed
- map the builder's single-weekday BYSETPOS form (BYDAY=FR;BYSETPOS=-1,
'last Friday') onto the legacy nth-weekday anchor; old clients previously
fell back to startDate's day-of-month — a wrong recurrence
Polish:
- infer the preset back from the stored rrule when quickSetting was
wire-clamped to CUSTOM, so Weekends & co. reopen under their label
instead of the raw builder; new QUICK_SETTING_PRESETS single source
replaces the hand-maintained KNOWN_PRESETS set
- extract shared safeParseRRuleOptions + FREQ_TO_CYCLE (six drifted
hand-rolled parse wrappers); memoize isRRuleValid (per-cfg routing guard
on every overdue scan); share noonUtc/toLocalNoon between engine and
preview; fold toMonthArray onto toNumArray; merge the duplicate
_toPersisted action-creator helpers
- trim the wiki section documenting the create-for-each-missed feature
that was deliberately cut from this PR
* fix(task-repeat): address curator review on anchor validity + fallback phase
- never emit/persist an out-of-union monthlyWeekOfMonth: range-guard the
BYDAY ordinal in rruleToLegacyTaskRepeatCfg (the builder's custom input
allows ±5, the synced model only 1..4|-1), sanitize null AND out-of-range
anchors at the action-creator boundary, and mirror the repair in
data-repair — an out-of-union value trips released clients' typia
validation/repair flow
- reject sub-daily frequencies (raw override FREQ=HOURLY/...) at save: the
day-granular engine would accept but silently collapse them to ~daily,
and they have no legacy repeatCycle for old clients
- log hygiene: the update-all-instances flow now logs changed keys + task
ids instead of raw changes/task objects (title, notes and rrule body are
user content; the log history is exportable); engine warns log the error
name only, since rrule.js messages can embed the free-text rule body
- align single-BYDAY weekly rules onto the engine's first occurrence: the
engine groups weeks by WKST while the legacy fallback counts rolling
7-day blocks from startDate, so biweekly cfgs whose start is off the
pattern day fired on OPPOSITE alternating weeks on old vs new clients
(and WKST shifted the engine's phase, which legacy cannot express);
after re-anchoring the cadence is exactly interval*7 days in both
- diff dialog saves against the PROCESSED cfg: the lazy legacy->rrule
migration (and preset inference) no longer leak into the change set of a
title-only edit — rrule is schedule-affecting, so that leak relocated
today's live instance on unrelated edits; empty change sets skip the
update dispatch entirely instead of creating a no-op sync op
* fix(task-repeat): keep presets rrule-backed + builder mode for completion cfgs
- stop stripping the preset-generated rrule at save: getQuickSettingUpdates
OVERWRITES the rule with the preset's canonical one, so the old 'clear on
switch away from builder' branch broke the every-cfg-carries-its-rrule
contract — and an rrule:undefined clear would not even propagate (dropped
by the op-log JSON wire, leaving remote clients on the old rule); the spec
asserting the stale behavior is inverted
- completion-relative cfgs always open in builder mode: the schedule-type
toggle only exists there, so preset inference (or a stored faithful preset
label) would hide the one control that explains how the cfg fires
- monthly anchor clears: document the wire gap properly instead of flipping
values again — null trips released clients' blocking data-repair confirm
dialog on every sync (typia has no null on these fields), undefined clears
only locally; durable clears require shipping '| null' on both fields in a
release FIRST, then switching the reset value (sequenced migration note in
the model + an op-log JSON round-trip spec pinning current behavior)
- replace 6 hardcoded rrule-builder placeholders with translation keys
- revert the settings help text advertising the deferred @+ short syntax
* test(e2e): drop CUSTOM weekday-checkbox spec superseded by rrule builder
The #8025 e2e (merged in from master) drives the legacy CUSTOM recurring
form's cycle select + weekday checkboxes, which this branch removes — the
RRULE builder replaces that UI and its weekday toggles are covered by
rrule-builder-roundtrip.spec.ts. The #8025 failure mode (a saved weekly
cfg that never fires) is blocked generally at save by the first-occurrence
probe.
* test(e2e): de-flake worklog history day-row wait
The worklog is rebuilt from the archive on the history navigation, so the
day row only appears once that async load + month-expand animation settles.
The bare `waitFor` fell back to the 15s action timeout, which CI load
(prod build, 3 workers) could exceed. Use a web-first `expect().toBeVisible()`
with 30s headroom instead.
* fix(task-repeat-cfg): clear repeat-from-completion when switching to a preset
The "from completion" schedule-type toggle exists only inside the RRULE
builder. Selecting a preset hides it, but the flag stuck on the cfg, so a
preset could keep firing relative to completion with no visible control.
Clear it at the save() edit boundary, but only when actually set, so an
untouched preset save stays an empty-diff no-op (#7373 class) rather than
dispatching a spurious undefined->false op. The ADD path already starts from
a false default, so the dialog is the only path that needs it.
Also reword the monthly-anchor clear comments: the cross-client divergence on
an nth-weekday -> day-of-month switch is an inherent, unfixable limitation for
pre-rrule clients (no in-schema "no anchor" value; a `| null` migration would
help an empty client band), not a deferred TODO. Make the round-trip test pin
this explicitly and start the monthlyLastDay case from `true` so it proves the
`false` clear survives the wire instead of passing vacuously.
|
||
|
|
b6f3aead1f |
test(migration): gate on work-context switch to de-flake legacy migration e2e
page.goto only changes the URL hash, so the SPA switches work-context client-side while the previous view's task-list lingers in the DOM. The task-list/count checks were satisfiable by the stale render, leaving the final task-title assertion to carry the wait for the switch on a 10s budget that occasionally outlasted under heavy parallel load. Gate on the project title rendering in <main> first, mirroring navigateToProjectByName. |
||
|
|
3ff0eebf92
|
fix(sync): make 'Use Server Data' recoverable + guard the destructive choice (#8107) (#8151)
* fix(sync): back up local state before USE_REMOTE replace #8107 forceDownloadRemoteState cleared all unsynced local ops and replaced NgRx state with the server snapshot with no recovery point, making an unintended 'Use Server Data' conflict choice irreversibly destructive. Capture a pre-wipe snapshot via the existing single-slot IMPORT_BACKUP store (BackupService.captureImportBackup) before clearUnsyncedOps, and abort the replace if the backup fails. Offer a 30s Undo snack afterwards that restores it (BackupService.restoreImportBackup). Covers both the conflict-dialog USE_REMOTE path and the manual force-download. * fix(sync): durable restore entry + one-shot recovery for USE_REMOTE backup #8107 Addresses multi-review findings on the pre-wipe backup: - Add a persistent 'Restore data from before last sync replace' button to the sync settings (all providers, gated on a remaining backup), so recovery survives a missed/replaced Undo snack or a failure that aborts after the wipe — the snack was previously the only reader of the backup. - Make restore one-shot: consume the IMPORT_BACKUP slot after a successful restore, preventing a second restore from toggling back to the replaced state and avoiding a lingering plaintext snapshot. - Undo snack: WARNING type (honest framing + dismiss control) and no auto-dismiss timer instead of SUCCESS/30s. * test(sync): e2e for USE_REMOTE pre-replace backup + undo #8107 WebDAV two-client conflict: Client B's local task is wiped by 'Keep remote' (USE_REMOTE), then the Undo snack restores it. Reproduces the #8107 data loss and verifies recovery. Modeled on the existing webdav-first-sync-conflict USE_REMOTE test. Runnable via npm run e2e:webdav:file (needs the docker WebDAV server; not runnable in the sandbox where published ports are unreachable). * fix(sync): guard USE_REMOTE in conflict dialog; drop durable restore button #8107 Swap the over-built recovery surface for a root-cause fix: - Add a confirmation guard before 'Use Server Data' (USE_REMOTE) discards pending local changes (INCOMING_IMPORT with local ops), mirroring the existing USE_LOCAL guard. The dialog frames the server as 'recommended', so this stops a misclick from silently wiping data the user can't tell is newer than the server's — attacking the cause, not just recovery. - Revert the durable settings restore button + its one-shot clear / hasImportBackup (beyond the minimal fix; only that button used them). Keeps the minimal recovery net: pre-wipe capture + sticky WARNING undo snack (and its passing WebDAV e2e). * fix(sync): explain encryption-blocked restore instead of generic error #8107 Defect 2: server-side Restore-from-History can't replay end-to-end- encrypted ops, so it rejects with a 4xx whose reason mentions encryption (the provider embeds that reason in the thrown error message). The client showed a meaningless 'Failed to restore data' (bbinet's screenshot). Detect the encryption reason in the restore catch and show a dedicated RESTORE_ENCRYPTED message explaining the limitation; falls back to the generic message for any other failure. * fix(sync): guard USE_REMOTE undo against superseded backup slot #8107 The pre-replace backup uses a single IndexedDB slot shared with the backup-import flow. The Undo snack never expires, so an intervening import or a second 'Use Server Data' could overwrite the slot before the user clicks Undo, silently restoring the wrong snapshot. Thread the backup's savedAt token from capture through the snack to restore; restoreImportBackup() now refuses when the stored backup no longer matches the token. Also clear the slot after a successful restore so a full copy of the replaced state stops lingering in IDB (uses the previously-uncalled clearImportBackup). |
||
|
|
a8bccb9e31
|
fix(tasks): add subtask via "a" shortcut when detail panel is open (#8152)
* build: drop unused elevate.exe from Windows build #8135 elevate.exe is bundled by electron-builder's NSIS target solely so electron-updater can apply privileged per-machine updates. We ship no in-app auto-updater (electron-updater is not a dependency; the autoUpdater block in electron/start-app.ts is commented out), so the binary is unused dead weight and a frequent AV false-positive. Set packElevateHelper: false to stop bundling it; safe because perMachine is not true. * fix(tasks): add subtask via "a" shortcut when detail panel is open With the detail panel open, pressing "a" created a sub-task but did not focus it for editing. Two paths were broken: - Focus inside the panel: the panel auto-focuses a detail item on open, so focus is not on a <task> row and the global task-shortcut handler drops the shortcut. The panel now handles taskAddSubTask in its keydown listener (stopPropagation avoids a double-add when focus is on an in-panel row). - Focus on the main-list task row: addSubTaskTo -> focusTaskById targeted the last #t-<id> copy, which is the in-panel copy inside the collapsed (hidden) sub-task section and cannot take focus. focusTaskById now falls back to the last focusable copy, so the new sub-task title is focused for editing (the panel follows focus to the new sub-task). |
||
|
|
eff41c041d
|
feat(project): project completion experience (#8036)
* feat(project): add project completion with celebration + trophy view
Completing a project marks it done and archives it, with a celebration
dialog (confetti + live stats) and a prompt to resolve unfinished tasks
(move to Inbox / mark done). Completed projects show a trophy badge and
a Reopen action on the archived-projects page.
isDone stays distinct from isArchived; selectArchivedProjects is left
intact so completed projects' tasks stay filtered out of Today/Overdue.
Append/merge deferred to #8032. Plan: docs/plans/2026-06-05-project-completion.md
* refactor(project): drop Archive menu item; Complete is the retire path
Archive and Complete produced near-identical end states; collapse to one
user-facing action. Removes the 'Archive project' menu item and handler so
Complete is the single way to retire a project. The archiveProject action,
reducer and ProjectService.archive() stay (needed for op-log replay of
historical archive ops and the legacy unarchive/restore path). Wiki updated;
menu specs cover the Complete flow.
* fix(project): keep completion dialog open for the active project
MatDialog closeOnNavigation (default true) dismissed the celebration the
moment completing the currently-active project navigated to '/'. Navigate
first, then open the dialog.
* test(project): e2e for completion flow (complete, celebrate, reopen)
Covers the resolve-unfinished-tasks prompt, the celebration dialog with
stats, the trophy badge on the archived page, and reopening. Adds an
openProjectContextMenu page-object helper.
* feat(project): confirm project completion
* feat(project): show completion celebration fullscreen
* fix(project): harden completion celebration flow
* style(project): use spacing tokens in completion dialog
* fix(project): align completion screen with project context
* style(project): soften completion screen coloring
* style(project): reuse completion screen surfaces
* fix: refine project completion dialog actions
* fix: complete projects with atomic task resolution
* fix: restore archive path in project completion UI
* refactor(project): remove dead completion code
The project-level completeProject action (OpType.Update) was never
dispatched — completion goes exclusively through the atomic
TaskSharedActions.completeProject (OpType.Batch) meta-reducer. Drop the
dead action, its reducer case, the PROJECT_COMPLETE enum member and the
immutable 'PCO' op-log code (which would otherwise be permanently
reserved for an op that can never be produced); enum count 147->146.
Also remove the unused selectCompletedProjects / selectPlainArchivedProjects
selectors (no consumers) and the misspelled, unused 'angel' confetti
field, keeping the regression tests that guard selectArchivedProjects.
* fix(project): make project completion non-reversible
Completion resolves a project's open tasks (move-to-inbox / mark-done),
which reopen cannot truly restore, so the "Reopen"/"Undo" affordances on
the completion path were misleading. Drop the celebration dialog's Reopen
button and the post-complete undo snack; the fullscreen celebration is
the feedback and deliberate reactivation still lives on the
archived-projects page (Project.reopen kept for it).
Also restore the project title param on the archive confirm dialog (it
was rendering a raw {{title}} placeholder), remove the now-unused
moveTasksToInbox / markTasksDone resolution helpers (the meta-reducer
resolves tasks atomically), and drop the orphaned UNDO / S.COMPLETED
i18n keys. Updates the completion e2e to the close flow and asserts the
resolution props are forwarded to the atomic action.
* fix(tasks): cancel native reminders for project-completed tasks
Completing a project marks its unfinished tasks done inside the
meta-reducer (no per-task updateTask), so unscheduleDoneTask$'s
native-reminder cancellation is bypassed and an OS-scheduled Android
notification could still fire for a now-done task. Add a local-only
effect that cancels native reminders for the force-completed task ids.
Local-only by design: it dispatches no actions (the persistent
dismissReminderOnly/clearDeadlineReminder would each be an extra synced
op), and done tasks are already filtered from reminders$ on all
platforms — only the native Android notification needs explicit removal.
* test(project): drop TaskService spies orphaned by helper removal
getByIdWithSubTaskData$/moveToProject/setDone/setUnDone were only used
by the removed moveTasksToInbox/markTasksDone helpers and their deleted
tests.
* feat(sync): affectedEntities multi-entity conflict detection for atomic completion
WIP checkpoint of the atomic completeProject approach. The Batch op declares
every touched entity (PROJECT, INBOX, TASKs, TODAY_TAG) via a new
affectedEntities field threaded through op-log capture, conflict detection,
the sync server (+Prisma migration) and shared-schema. Per-effect
completeProject handlers (issue two-way-sync, time-block, repeat-cfg)
re-derive the task changes the atomic op bypasses.
* revert(sync): remove affectedEntities multi-entity conflict detection
Reverts
|
||
|
|
0d1869263f |
docs: remove outdated and implemented plan docs
Delete 29 plan/design docs whose work has shipped or been superseded (SuperSync slices, sync-core extraction, encryption-at-rest drafts, document-mode/Stage-A persistence, calendar/CalDAV concepts, focus-mode time-tracking sync, etc.). Kept the still-forward-looking docs (e.g. supersync-encryption-at-rest, sync-core-simplification-roadmap, calendar-two-way-sync-technical-analysis). Source comments that cited deleted docs are rewritten into self-contained inline rationale so no "see docs/..." reference dangles. |
||
|
|
c13e8ee25b |
test(e2e): fix worklog finish-day navigation race #8033
The worklog 'show worklog after completing tasks' test waited on
waitForSelector('task-list') after Save & go home, but daily-summary
renders its own task-list (planner-day), so that resolved immediately
without waiting for finishDay's deferred router.navigate(['/active/tasks']).
The test then went to /history and the pending redirect clobbered it,
so .week-row never rendered (timeout, screenshot showed Today).
Apply the proven sibling pattern (commit
|
||
|
|
b59b266719 |
test(e2e): unbreak boards done-toggle and time-tracking selector
markTaskAsDone() threw on locators without data-task-id, breaking the Boards #7498 specs that pass <planner-task>. Branch on the id instead: <task> rows keep the dup-row done-state wait; wrapper rows (which can relocate panels on done) just settle and let the caller assert. Scope the time-tracking spec's main play button to play-button .play-btn so it no longer collides with a task's .start-task-btn (same play_arrow icon) once the task is in the TODAY view. |
||
|
|
5c73ec6220 | test(e2e): stabilize finish day history flow | ||
|
|
645797f0bd
|
fix(reminder): require action for reminder popup #8051 (#8057)
* fix(reminder): require action for reminder popup #8051 * test(reminder): update deadline reminder e2e expectation --------- Co-authored-by: cocojojo5213 <cocojojo5213@users.noreply.github.com> |
||
|
|
8e08b48a98
|
fix(task-repeat-cfg): keep custom weekdays interactive (#8034) | ||
|
|
7c03c84fad
|
feat(history): merge Quick History and Worklog into a unified History view (#8033)
* feat(history): merge Quick History and Worklog into one view - single /history route; legacy worklog & quick-history redirect to it - one History menu entry; navigate-to-task targets history - show per-day work start-end as its own column in the day table - show enabled habits/simple-counters (icon + tooltip) in the expanded day - extract HistoryTaskRowComponent for archived task rows - remove WorklogComponent and QuickHistoryComponent * fix(history): restore quick history behavior * refactor(history): collapse to a single unified view - remove the Worklog/Quick History toggle; show one full all-time history tree (year -> month -> week -> day) - drop the quick-history mode, significance filter and ?view= param - extract shared HistoryDayMetaComponent; reuse HistoryTaskRowComponent in the Daily Summary "This Week" widget - convert project colors + dateStr auto-expand to signals - update e2e specs to the single-view selectors * refactor(history): remove dead quick-history code and i18n keys After merging Quick History into the unified History view, the quick-history data pipeline had no consumers. Remove it: - _quickHistoryData$, quickHistoryWeeks$, _loadQuickHistoryForWorkContext - map-archive-to-worklog-weeks util (+ tz spec) and WorklogYearsWithWeeks - orphaned F.QUICK_HISTORY, MH.QUICK_HISTORY, MH.WORKLOG translation keys Keep WorklogWeekSimple (base of the still-used WorklogWeek). * docs(history): reflect Quick History merge into unified History The quick-history and worklog routes are now legacy aliases for the single unified History view; drop the stale 'current-year mode/toggle' framing. Concepts-note prose flagged for human review. * feat(history): improve a11y and i18n of the unified history view Accessibility: - week table uses <thead> + <th scope=col>; icon-only work-times header gets a translated aria-label - expandable day row: real <button> disclosure control carrying aria-expanded/aria-controls (keyboard + AT path) instead of a role=button <tr>; row keeps a pointer click - archived-task title is now a <button> (was a non-focusable <span>) i18n: translate previously hardcoded aria-labels (export/restore) and the week-range tooltip; reuse VIEW_TASK_DETAILS/RESTORE_TASK_FROM_ARCHIVE, add EXPORT_DATA + WEEK_RANGE_TOOLTIP keys. Polish (carried in): convert worklogData$ to a toSignal signal (drop AsyncPipe + dead animations), relocate the shared row to features/worklog/worklog-task-row (WorklogTaskRowComponent) to break a worklog<->history cycle, make template-unused services private, tighten projectColor input type. SCSS focus rings use focus-ring tokens. e2e: locate task titles via td.title button after the span->button change. |
||
|
|
ab7ae29858 | fix(calendar): hide archived event banners | ||
|
|
25596b7a7d | test(recurring): use shared date input helper | ||
|
|
9e50904f95 |
test(e2e): harden flaky recurring start-date specs via shared helpers
Five recurring start-date specs flaked in CI (run 27005469443), all in the
Material datepicker start-date flow. Two root causes:
- setStartDate set the value via fill() -> press('Tab') -> toHaveValue(),
but the input's (dateChange) handler clears innerValue to null when a
blur-time parse loses the race with dialog bind/animation, and the one-way
[ngModel]="innerValue()" re-renders the field empty. The prior retry
wrapped only the fill, not the Tab-commit, so the value still vanished.
- #7423 and #7951 navigated the planner with plain page.goto('/#/planner'),
which Angular's router occasionally drops mid-bootstrap.
Extract the helpers (duplicated 3-5x at varying robustness) into a shared
e2e/utils/recurring-task-helpers.ts: setRecurStartDate now retries the whole
fill+Tab+verify cycle via expect().toPass(), and all planner navigation goes
through the hash-drop-resistant gotoHashRoute. #6860 keeps its keyboard-typing
path but wraps it in the same retry. Also removes a waitForTimeout(800) that
violated the e2e no-fixed-wait rule.
Verified: checkFile clean; specs pass with --repeat-each=3 (18/18).
|
||
|
|
eeb14c661f |
test(e2e): stabilize detail-panel arrow-key nav against focus race
Opening the detail panel schedules a deferred auto-focus that can land after taskB.focus() and pull focus into the panel; a lone ArrowDown is then swallowed (focusNext() reads document.activeElement, finds no task row, and stays put). Wrap focus+keypress+assert as a retried unit via expect(...).toPass() on both arrow-key tests so a lost keypress re-fires. Verified 40/40 (5 tests x 8 repeats) locally. |
||
|
|
c0bf92834c
|
test(e2e): dismiss add-task-bar backdrop before finish day (#8014)
The global add-task-bar backdrop (app.component @fade .backdrop) could be left open during the mark-all-done force-clicks. Its full-screen backdrop then intercepts pointer events on the .e2e-finish-day button, so the click retries until the 15s actionTimeout and the test times out — the source of the 'should have exactly 4 archived tasks after finish day' CI flakiness. Dismiss the backdrop (which is our custom class, not the CDK overlay backdrop) before clicking finish day. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
3df394f129 | chore: remove unused eslint disables | ||
|
|
e517139a35 | test(e2e): wait for finish day navigation | ||
|
|
c4d81f38da |
test(task-repeat-cfg): guard timed cold-reopen day-change creation
Locks in that a timed daily repeat task gets its new-day instance created after a cold reopen across a day boundary. Investigated under #7951 (a reporter's instance was not created on next-day reopen); it did not reproduce in web, so the guard documents the working path while the reporter's bug is chased as Electron/config-specific. |
||
|
|
5e4759a3cd |
test(e2e): settle state before reload in #7344 recurring spec
The "convert past-dated task to YEARLY recurring" spec intermittently failed on CI with `page.reload: Timeout 30000ms exceeded`, blocking the master release pipeline. The trace showed the reload fired while the op-log/IndexedDB flush from saving the repeat config was still draining, leaving the navigation hung for the full timeout. The feature itself was never broken — the failure screenshot showed correct behavior. Guard the two reloads that follow a write with waitForStatePersistence() so the flush completes before navigating. Verified with 5 consecutive passes (retries=0). |
||
|
|
079f920561
|
fix(task-repeat-cfg): create real instance when startDate moved to today with no live instance (#7983)
* test(task-repeat-cfg): add regression tests for startDate→today no-live-instance (#7923) Unit test: exercises _getActionsForTaskRepeatCfg directly in the post-re-anchor state (startDate=today, lastTaskCreationDay=yesterday, no live instances), confirming the service-level creation path works. E2e test: full reproduction of the #7923 repro steps — future startDate, delete the live instance, change startDate to today via a transparent projection — and asserts that a real task appears in the Today view (not a transparent projection). * fix(task-repeat-cfg): create real instance when startDate moved to today with no live instance (#7923) Two fixes for the scenario: delete a recurring task's live instance, then move startDate to today via a transparent projection. 1. add-tasks-for-tomorrow.service.ts: yield to the event loop (setTimeout 0) before reading the store in addAllDueToday(). The re-anchor dispatch from rescheduleTaskOnRepeatCfgUpdate$ updates the NgRx store synchronously, but the store notification propagates asynchronously; the yield ensures the selector sees lastTaskCreationDay = yesterday (not the stale future date) and includes the config for today. 2. task-repeat-cfg.service.ts: when taskAlreadyExists (a concurrent addAllDueToday() call or the date-change effect already created the task), still dispatch updateTaskRepeatCfg to advance lastTaskCreationDay. Without this, the transparent planner projection lingered because lastTaskCreationDay stayed at yesterday while the real task was already visible in Today. All 65 unit tests updated and passing. E2e test passes end-to-end. * test(e2e): stabilize #7923 today-column guard against route animation The final guard matched the today column via the rendered date text (`.date` = "1/5"). During the planner route-enter animation the leaving and entering `planner-day[data-day="2026-05-01"]` columns are briefly both in the DOM, so the locator resolved to 2 elements and tripped a strict-mode violation before the projection assertion ran. Select the column by its stable `data-day` attribute and assert the projection count across every matching column, which is correct regardless of how many copies the animation leaves behind. Also fix the inaccurate "Wednesday" date comment (May 1 2026 is a Friday; the repeat is DAILY so the weekday is irrelevant). --------- Co-authored-by: Johannes Millan <johannes.millan@gmail.com> |
||
|
|
941f4f28b2 | fix(android): route hardware back through Capacitor | ||
|
|
2fe40a6e75 |
test(e2e): de-flake archive-subtasks mark-done helper
The work-view list animates and reorders rows while tasks are marked done, so hovering each row's .first-line to reveal the done button intermittently timed out for 15s (mid-animation / collapsing subtask), failing CI under retries: 0. Force-click the first visible undone row's own done-toggle directly (the toggle is always rendered at the e2e viewport) and poll until none remain: a click missed because the row moved is retried, :visible skips collapsed subtasks, and the count poll replaces the fixed sleep so the subtask->parent auto-complete cascade is handled. |
||
|
|
dc0378edd6
|
[codex] Fix sync import conflict from startup example tasks (#7976)
* fix(sync): ignore startup example tasks during import
* fix(sync): defer startup example tasks until initial sync completes
Switch ExampleTasksService to afterInitialSyncDoneStrict$ so example tasks
are not created before the first remote import lands. On a fresh synced
client the imported tasks are then already in the store and the length===0
guard skips creation entirely — closing the same conflict for file-based
providers (Dropbox/WebDAV), which the op-log marker gate does not cover.
The isExampleTask marker + gate exclusion stay as a safety net for the
narrow case where example tasks are created on a still-empty server and an
import arrives before they are uploaded.
Also dedupe the two markRejected call sites into _discardExampleTaskOps and
document the local-only read (a remote isExampleTask flag can never bypass
the conflict dialog) and the intentional mixed-case behavior.
* test(sync): cover mixed-pending and empty-discard import gate cases
Add a SyncImportConflictGateService test for the mixed case (real pending
work + startup example tasks): the conflict dialog is still shown, but the
example-task id is reported in discardablePendingOpIds and intentionally
left for the caller to keep on USE_LOCAL — locking the documented invariant.
Also assert markRejected is not called on the config-only silent-accept path,
pinning the empty-array guard in _discardExampleTaskOps.
* test(sync): integration coverage for example-task import gate
Run the real OperationLogStoreService + SyncImportConflictGateService against
IndexedDB (no mocks) to cover the seam the unit specs stub:
- example-task creates persisted with their real multi-entity payload are
recognized as non-meaningful and reported as discardable
- after markRejected they are actually excluded from getUnsynced() (never
uploaded), while a pending config op is preserved
- real user work alongside example tasks still triggers the dialog
- a remote-sourced example-task op is never discardable (gate reads local
pending only)
Verified load-bearing: removing the gate exclusion turns the first case red.
* fix(sync): mark onboarding done when example tasks are skipped
EXAMPLE_TASKS_CREATED was only written after creating example tasks, so a
synced client that skipped creation because real tasks already existed never
set the flag — and could recreate onboarding tasks on a later startup when
the task list happened to be empty.
Set the flag whenever tasks already exist at the initial-sync gate, so
onboarding runs at most once per client.
* docs(sync): note known limits of the example-task import gate
Document two accepted, narrow residuals of syncing onboarding example tasks:
- upload→piggyback path: example ops accepted in the same upload round are
already synced and not in the discard list; state stays correct because the
SYNC_IMPORT filter drops them as CONCURRENT on receivers.
- file-based snapshot path: hasMeaningfulStoreData() counts example tasks (no
in-state marker), so a fresh Dropbox/WebDAV client that made example tasks
while sync was disabled can still see the conflict dialog.
* test(sync): cover fresh-client example-task import gate (e2e)
Add a SuperSync e2e proving a fresh client with first-run example tasks accepts
an incoming SYNC_IMPORT without a conflict dialog, plus a backward-compatible
{ allowExampleTasks } opt-in to createSimulatedClient (the harness otherwise
pre-sets SUP_EXAMPLE_TASKS_CREATED).
Uses waitForInitialSync:false + a race between the conflict dialog and sync
completion so the test actually fails when the dialog appears (pre-fix), and
asserts all four onboarding titles are absent. Guards the op-log isExampleTask
marker/gate path (not the afterInitialSyncDoneStrict$ timing, which a fresh
e2e client cannot exercise since sync is disabled at boot).
* docs(sync): link example-task import-gate limitations to #7985
|
||
|
|
fcc4216391
|
fix(calendar): hide archived calendar task events from the schedule (#7979)
* fix(calendar): hide archived calendar task events from the schedule A calendar event imported as a task, completed and archived via "Finish Day", re-surfaced in the Schedule/Planner as a "not yet added" entry once its per-day skip reset — i.e. the next day (#7971). The schedule view filter only excluded events linked to *live* tasks: selectAllCalendarTaskEventIds reads the NgRx state, never the IndexedDB archive. Archived calendar tasks therefore stopped suppressing their event. Feed archived calendar-task event ids (read via TaskArchiveService) into the same filter, re-read whenever the active set changes. Adds unit coverage (including the active→archived transition and guards against over-filtering) and a live Playwright e2e repro. * test(calendar): extract ONE_DAY_MS to satisfy no-mixed-operators lint * perf(calendar): only reload the task archive when linked event ids change selectAllCalendarTaskEventIds emits a new array reference on every task mutation (including the per-second time-tracking tick), so feeding it straight into switchMap(load()) re-read the entire task archive from IndexedDB on each of those. Gate the load with distinctUntilChanged before the switchMap so the archive is re-read only when the set of linked calendar event ids actually changes (the #7971 archive transition still triggers it), and shareReplay the result so concurrent subscribers share one load. Also drop dead null-guarding on TaskArchive.load(), which always returns a full archive. |
||
|
|
63460195b0 |
fix(simple-counter): stop habit chart popout sub-pixel jitter
The habit edit popout hard-overrode the chart canvas width/height in CSS while Chart.js ran responsive + maintainAspectRatio. That fought the inline size Chart.js writes on every resize, so its ResizeObserver kept chasing a size it didn't set and the canvas/dialog height oscillated by under 1px every frame. Remove the override and let Chart.js own the canvas size. Add an e2e regression guard that samples the steady-state dialog/canvas height and asserts it no longer oscillates (was ~0.53px flipping every frame). Fixes #7957 |
||
|
|
34d4aa0bbf
|
feat(tasks): allow dragging tasks into subtask lists (#7962)
* feat(tasks): allow dragging tasks into subtask lists
* fix(tasks): support childless subtask drop targets
* fix(tasks): support dragging subtasks back to main list
* fix(tasks): preserve nested subtask sorting
* fix(tasks): address subtask drag review feedback
* fix(tasks): address drag conversion review
* test: skip onboarding in migration fresh-start e2e
* fix(tasks): harden convertToSubTask guards and dedupe tag cleanup
Address review findings on the drag-to-subtask feature (#7905):
- Keep the section and crud meta-reducer guards in lock-step via a shared
canApplyConvertToSubTask(). Previously the section reducer stripped a task
from its section even when the crud reducer rejected the convert (missing
target parent or self-target), leaving the task top-level yet dropped from
section ordering on a replayed/concurrent op.
- Reject nesting under a target that is itself a subtask. The UI renders only
two levels, so deeper nesting would orphan the task and leave the
grandparent's time aggregation stale.
- Tighten op-log payload validation to require string taskId/targetParentId.
- Dedupe the "remove task ids from all tags" logic shared by
convertToSubTask/deleteTask/deleteTasks into removeTasksFromAllTags().
- Collapse the tri-state afterTaskId positioning in handleConvertToMainTask
(undefined and null both prepend via moveItemAfterAnchor).
- Name the DragPointer type in DropListService.
Adds reducer specs for the rejected-target-parent cases.
* fix(tasks): remove empty subtask drop-target for childless parents
The dashed empty sub-task drop zone that appeared under childless parent
tasks during a drag was unwanted UI. Remove it along with its supporting
machinery (the subTaskDropCandidate signal in ScheduleExternalDragService,
the pointerdown candidate-arming in TaskListComponent, the
isEmptySubTaskDropTargetMounted computed, and the related template/SCSS).
Consequence: a task can now be nested by dragging only onto a parent that
already has a subtask list. Dragging a subtask back out to a main list
(convertToMainTask) and nesting into an existing subtask list
(convertToSubTask) are unchanged.
* fix(tasks): reliably convert subtasks dragged to the top-level list
Two issues prevented dragging a subtask out to the top-level list to
convert it into a main task:
- CDK only caches a sibling drop-list's geometry when its enterPredicate
passes at drag start (_startReceiving). The pointer is always over the
source subtask list then, so the top-level list was never cached and
conversion silently failed until an unrelated parent drag warmed it.
Open a one-microtask accept window at subtask drag start so CDK caches
the top-level lists' geometry; the pointer guard resumes afterwards.
- An expanded neighbour's subtask list "caught" the drag in the dead-band
just above the next parent (sibling order resolves subtask lists before
the top-level list), silently re-parenting the subtask instead of
converting it, and growing/sticking once entered. Treat only an actual
subtask row as "inside" a foreign subtask list; its trailing padding now
falls through to the top-level list for conversion. The source list
still blocks anywhere, so in-list sorting is unaffected.
* fix(tasks): use midpoint-crossing sort for drag preview placement
CDK's SingleAxisSortStrategy swaps siblings as soon as the cursor enters
any part of their clientRect. For task lists whose item rects span the
parent's full element (header + its expanded subtask list), that swap
displaces the drop preview far below the cursor — landing it past the
target's subtasks instead of where the user is pointing.
Monkey-patch the strategy's `_getItemIndexFromPointerPosition` with a
relative-position midpoint rule (the pattern used by dnd-kit and
react-beautiful-dnd): a sibling only swaps with the dragged item once
the cursor crosses into the half of the sibling that's on the dragged
item's approach side. `enter()` keeps CDK's first-inside semantics so
initial placement still lands somewhere sensible.
Also extend `_pointerSubTaskList()` with a `.sub-tasks` wrapper fallback
so the leading strip between a parent's header and its first subtask
routes the drag into that SUB list at index 0 instead of falling through
to the top-level list and converting at the parent's slot.
* test(tasks): align drag predicate spec data
* test(tasks): reconcile createMockDrop calls after combining drag PRs
Combining the two #7905 PR lineages crossed a stale signature:
the midpoint-sort commit added enterPredicate cases calling the old
3-arg createMockDrop(modelId, filteredTasks, listId), while keilogic's
spec-alignment commit narrowed it to (modelId, listId) since
enterPredicate never reads filteredTasks. Drop the vestigial arrays
from the three affected cases so they match the simplified helper.
* fix(tasks): widen first/last subtask drop targets
Dragging a task to the first/last position of a subtask list was hard:
the midpoint sort patch gives each slot a half-row trigger, and the
two end slots have no neighbouring row to borrow the other half from,
so they were only half of the edge row. The existing "easier dragging"
padding lived on :host, outside the `.task-list-inner` cdkDropList, so
it never grew the CDK hit-rect.
Move that padding inside the sub-task drop list so the strip above the
first and below the last subtask is part of the drop rect. CDK's
enter() then drops a task arriving there as the first/last child
(SingleAxisSortStrategy._shouldEnterAsFirstChild). The matching :host
padding is dropped for sub-lists so the visible gap stays compact.
* refactor(tasks): apply multi-review follow-ups to subtask drag
Hardening and fixes surfaced by the multi-agent review of the
drag-into-subtask feature:
- Scope the CDK midpoint sort patch to VERTICAL lists. It mutates the
shared SingleAxisSortStrategy prototype app-wide, so horizontal lists
(boards, issue panel) were silently swept into the new hit-test;
gating to vertical keeps them on CDK's stock behaviour and sidesteps
the right-to-left index-inversion question. Update the spec to assert
the horizontal fall-back.
- Keep first-child re-parent in a sub-list's leading pad. The drop-target
CSS fix moved the SUB list's top padding inside `.task-list-inner`, so
part of the header→first-row strip now lives in the drop rect; report
the leading pad as a row (re-parent) while the trailing pad stays a
convert-to-main dead-band. Doc comment updated to match.
- Memoise the per-pointer subtask-list hit-test in DropListService so the
several enterPredicate calls CDK fires per pointer move reuse one
document.elementFromPoint.
- Tighten convertToSubTask op-log validation to isValidEntityId (rejects
'' / 'undefined' / 'null') instead of a bare typeof string check.
- Log ids, not full task objects, in the drop handler (titles/notes must
not reach the exportable log).
- Drop the dead _resetMidpointSortPatchForTests export; note why the
seeded drag pointer is inert after a plain tap.
- Add direct unit tests for the canConvert/canApply guard pair.
* test(tasks): cover drop()-to-convert dispatch and the drag e2e
Close the two coverage gaps flagged in review:
- Unit: drive the public drop() handler with CdkDragDrop events and
assert the resulting convertToSubTask / convertToMainTask dispatch,
including the newIds→afterTaskId placement math (after-anchor,
first-slot null anchor) and the isDone flag for the DONE list. This
was previously untested — only _move() and the reducer were.
- E2E: a real CDK drag of a top-level task onto a subtask row, asserting
it converts to a subtask. Uses the manual stepped-mouse gesture (CDK
ignores HTML5 dragTo), matching work-view/sections.spec.ts. Verified
green 3× in a row.
* fix(tasks): cut drop snap-back flicker by emitting default list sync
On drop, CDK tears down its preview/placeholder but never moves the real
DOM node (the list is NgRx-driven), so the un-moved task is briefly
revealed at its old slot until the store re-render lands. customizeUndone
Tasks() pushed *every* work-view list emission through
observeOn(animationFrameScheduler), adding a guaranteed extra frame to
that re-render and widening the visible snap-back.
Keep the frame-defer only for the customized (sort/group/filter) path —
it does heavier work and is driven by CD-bound signals, where a sync emit
can re-enter change detection. The default path is store-driven only, so
emit it on the same tick: the list re-renders without the extra frame and
the dropped task lands in place with far less (ideally no) snap-back.
task-view-customizer spec (43) green; drag-into-subtask e2e green.
* fix(tasks): scope CDK midpoint sort patch to task-list instances
Patch the dragged list's own SingleAxisSortStrategy instance instead of
the shared prototype. registerDropList is only called by task lists, so
the prototype mutation silently changed the hit-test of every other
vertical CDK list (planner, notes, boards, tree-dnd). Shadowing the
method per instance keeps the midpoint-crossing rule scoped to task
lists and leaves all other lists on CDK's stock behaviour.
The CDK-rename guard and 'this' binding are unchanged; the global
isPatched flag is dropped since each instance now patches independently.
* fix(sync): validate afterTaskId in convertToSubTask payload
The convertToSubTask op-log validation branch accepted any payload with
valid taskId/targetParentId, ignoring afterTaskId. Require it to be a
string or null (the action type's contract) so a crafted/malformed sync
payload is rejected at the validation boundary rather than treated as a
not-found anchor downstream.
* docs(tasks): clarify customizer frame-defer and drag-to-done intent
Comment-only. Correct the task-view-customizer note: the customized
path stays on the animation-frame scheduler to batch the signal-driven
emission burst on context switch (commit
|
||
|
|
396ba6a7bb
|
fix(task-repeat-cfg): don't strand today's instance on edit (#7951) (#7955)
* feat(sync): strengthen WebDAV as-is/unsupported warning * fix(calendar): use actual time spent for done time-block events When a scheduled task was marked Done, the Google Calendar time-block event's duration was recomputed with the app-wide remaining-estimate formula `max(timeEstimate - timeSpent, 0)`. For a completed task that is meaningless: it shrank the slot to a leftover sliver (estimate > spent) or collapsed to 0 and hit the flat 30-min default (spent >= estimate). Branch on `isDone`: done tasks now reflect the actual time spent (falling back to the estimate, then the 30-min default), while active tasks keep the remaining-estimate model that mirrors the schedule view. Closes #7949 * fix(task-repeat-cfg): prevent crash editing recurring task with no start date (#7945) A repeat config with no stored startDate (e.g. DAILY/MONDAY_TO_FRIDAY or older configs) let Formly's `defaultValue: new Date()` put a raw Date into the model — `parsers` don't run on defaultValue. That Date reached `dateStrToUtcDate`, returned Invalid Date, made the weekOfMonth math NaN, and `ORDINAL_KEYS[NaN-1]` was undefined, so `translate.instant(undefined)` threw and broke the edit dialog. - Default startDate to a 'YYYY-MM-DD' string so the model stays type-consistent and no Date leaks into the cfg. - Guard `buildRepeatQuickSettingOptions` against an invalid Date so a bad value can never crash the dialog again. - Add regression tests for both. * fix(task-repeat-cfg): don't strand today's instance on edit (#7951) Editing a schedule-affecting field of a recurring task relocated the live instance via getNextRepeatOccurrence(), which is exclusive of today by contract. A still-valid daily/weekly instance was therefore pushed to tomorrow and lastTaskCreationDay advanced past today, leaving today empty and never recreated (the reported "task not created on the new day"). Add an opt-in `inclusive` mode to getNextRepeatOccurrence() that scans from today, with a floor so MONTHLY/YEARLY never resolve to an already passed anchor, and use it in rescheduleTaskOnRepeatCfgUpdate$. The default (exclusive) behaviour is unchanged for preview / scheduled-list / heatmap. For timed tasks (startTime + remindAt) the reschedule advances to the next future occurrence when today's start time has already passed, so relocating an instance never fires an immediate "missed reminder" (#7354). Tests: unit repro + edge cases (repeatFromCompletionDate, repeatEvery>1 off-cycle, overdue relocation, MONTHLY/YEARLY today+floor, past-time timed reschedule) and a fail-before/pass-after e2e. |
||
|
|
d7e5be08b5
|
test(e2e): harden flaky auto-start-focus-on-play via hash nav (#7953)
Run #3925 (Build All & Release on master) failed solely on auto-start-focus-on-play.spec.ts:53 — every other test passed and the merged commit (local-backup) is unrelated to focus mode, so this is a flake, not a regression. The test enabled `autoStartFocusOnPlay` in Settings and then did a hard `page.goto('/')` reload before pressing play. A full reload re-bootstraps the app and re-reads the config from IndexedDB, which races the debounced persistence of the toggle we just flipped: if the write hasn't flushed, the reloaded app boots with the setting OFF, `syncTrackingStartToSession$` early-returns, and the focus indicator never spawns — a flake no expect timeout can fix. Switch to a same-document hash navigation (`/#/tag/TODAY/tasks`, the pattern already used by `navigateToMiscSettings`). This preserves the in-memory NgRx config the toggle updated synchronously, so the test exercises the auto-start behavior without depending on persistence timing. Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
3663c8e81d
|
test(e2e): harden flaky recurring move-start-date-earlier #7724 spec (#7929)
The "moving startDate earlier still projects the new days" test
(recurring-move-start-date-earlier-no-instance-bug-7724) was ~50% flaky
in the scheduled E2E suite. All failures were in test setup, never in the
projection assertion the test actually verifies:
- The Material datepicker Start-date input intermittently dropped the
first fill while the dialog was still binding (left empty + ng-invalid).
Retry the fill until the value sticks before committing with Tab.
- page.goto to a SPA hash route was occasionally swallowed by the Angular
router mid-bootstrap, leaving the work-view on "Today" instead of the
Inbox project (and sometimes rewriting the fragment back), so a plain
reload reloaded the wrong route. Add gotoHashRoute: when the expected
view marker doesn't render, hop through about:blank so the retry is a
cross-document load that bootstraps fresh on the target URL.
- The post-navigation right-click raced the still-settling task list
("element not stable"). Retry opening the context menu with a short
per-attempt timeout.
Verified 30/30 consecutive passes locally (was 3/6 failing before).
Co-authored-by: Claude <noreply@anthropic.com>
|
||
|
|
558e6a7664 |
test(e2e): drop dead onboarding preset-click fallback in fresh-start test
The 'app should handle fresh start correctly' test already suppresses the
first-run onboarding takeover via context.addInitScript(skipOnboardingForE2E),
which seeds SUP_ONBOARDING_PRESET_DONE so isShowOnboardingPresets stays false
and the side-nav is never hidden. The follow-up preset-card click was dead:
its isVisible({ timeout: 5000 }) guard is a no-op (Playwright ignores the
timeout on isVisible and returns immediately), and its comment wrongly claimed
the context skips the init script. Remove the fallback and the now-orphaned
[attr.e2e] preset-card hook (no other consumers). Verified test still passes.
|
||
|
|
cc63a566da |
test(e2e): handle onboarding takeover in fresh-start migration test
The 'app should handle fresh start correctly' test creates a genuinely fresh context, so it skips the skipOnboardingForE2E init script the shared page fixture injects and lands on the first-run onboarding preset screen. Since the onboarding takeover hides magic-side-nav (visibility:hidden), the side-nav visibility wait timed out. Dismiss onboarding by selecting a preset before asserting the side-nav. Add a stable [e2e] hook to the preset cards for the selector. |
||
|
|
11e2eafcf3 |
test(e2e): skip onboarding in fresh-start migration test
The fresh-start test boots an unseeded browser context, so the first-run onboarding preset screen took over. Since #7885 hides the side-nav during onboarding (visibility:hidden), the test's wait for a visible magic-side-nav timed out. Seed the onboarding-skip flag before boot like the shared fixture does, keeping the fresh-empty-storage coverage while reaching the work view. |
||
|
|
87212472fa |
test(e2e): scope supersync backup-revert DB restore to test user
The backup-revert test restored via a global DROP SCHEMA public CASCADE + full pg_dump on the shared test Postgres. Under Playwright fullyParallel (3 workers/shard) this reverted concurrent @supersync tests' in-flight data mid-run, flaking supersync-snapshot-vector-clock (server latestSeq regressed, e.g. 4 to 1). Replace with a per-user snapshot/restore of operations, user_sync_state and sync_devices via a COPY round-trip in one transaction, preserving exact last_seq so the SYNC_IMPORT_EXISTS path still fires. Other users' rows are never touched. Also correct the misleading #7810 comment in the snapshot test. Verified green: both files pass across --repeat-each=10 with workers=3 (the parallel race reproduced, now clean). Refs #7810 |
||
|
|
1cc8a8eb2d
|
test(e2e): de-flake three scheduled-run E2E failures (#7862)
Fixes the flaky failures observed in the scheduled master E2E run
(actions/runs/26672979319), all single-attempt (retries: 0 by design):
- sections DnD ("drag source/target has no bounding box"): stableBoundingBox
polled until a box existed then re-read it, reopening the TOCTOU race the
poll closes. Capture the validated box inside the poll and return it.
- focus auto-start indicator: the running-label assertion used a hardcoded
5s timeout, tighter than the suite's 20s default (set for slow rendering).
The indicator is gated behind an async effect chain, so inherit the
default timeout instead.
- wrong-password "overwrite remote" confirm button stayed disabled: the old
helper committed input values, then separately asserted the button enabled,
with no recovery when form validity lagged. Fold the re-fill and the
toBeEnabled check into one toPass so a still-disabled button re-triggers a
re-fill.
|
||
|
|
ab5a2ffce9
|
fix(i18n): boot with bundled English when translation fetch fails offline (#7854) (#7875)
The app loads UI translations at runtime via ngx-translate's TranslateHttpLoader (GET ./assets/i18n/<lang>.json). When that request fails with a status-0 network error — e.g. opened offline via Safari's Reading List, which serves the cached HTML but bypasses the service worker that would otherwise serve the cached en.json — the stock loader has no fallback. The rejection reaches GlobalErrorHandler, which treats it as critical and renders a crash card instead of letting the app boot. Wrap TranslateHttpLoader in TranslateHttpLoaderWithFallback: on a status-0 failure, fall back to the English translations bundled at build time so the app boots with readable text (self-healing once a cached or online copy loads). Any other failure (404, JSON parse error = real deploy problem) is rethrown so it stays visible. Place it beside NetworkRetryInterceptorService in core/http, which already handles the same status-0 transient-network class. - unit: success / status-0 fallback (en + non-en) / 404 rethrow - e2e: aborts the i18n fetch, asserts no crash card and that the side-nav renders real English (not raw MH.* keys) - prod build verified: initial bundle 4.95 MB raw, under the 5.5 MB budget |
||
|
|
380b199152
|
test(sections): de-flake intra-section drag reorder e2e (#7861)
The "reorders tasks within a section via drag and drop" test
intermittently failed with "drag source/target has no bounding box"
(e.g. CI run 26681989260). Each CDK drop re-creates the moved task as a
fresh @for :enter, replaying the expandInOnly animation (height: 0 -> *),
so its layout box collapses to zero height mid-gesture and boundingBox()
returns null.
Disable animations for this test via the app's own isDisableAnimations
config (toggles @HostBinding('@.disabled')), which removes the height:0
window. Verified 5/5 green locally.
Scoped to the reorder test only; the cross-list "drops a task into a
section" test keeps animations on, since disabling them makes the empty
drop target reflow instantly and miss.
|
||
|
|
55dc14a4f1
|
Feat/issue 7848 d0002a (#7852)
* test(e2e): harden section reorder drag against bounding-box races boundingBox() is a one-shot snapshot that returns null when the element has no layout box — which happens after every CDK drop, since the section task-list re-renders (@for track reorders nodes) and freshly-dropped tasks run the expandInOnly enter animation at height:0. Add a stableBoundingBox() helper that waits for visibility then polls for a real, non-null box before driving the pointer gesture, and exclude .ng-animating nodes when picking the reorder source/target (matching the existing 'before' capture). Test-only change; reorder behavior itself is covered by section.reducer unit tests. * fix(calendar): dedupe duplicate-UID iCal events Some providers (Google/Outlook) emit the same UID twice for invited or modified events — once fully populated, once sparse. These are plain VEVENTs (no RRULE, no RECURRENCE-ID), so per RFC 5545 they are the same event, yet they rendered as two identical entries in the schedule overlay. The prior fix for #3837 only deduplicated recurring-series RECURRENCE-ID overrides; plain duplicate-UID copies were never collapsed. Collapse events sharing a resolved id at the end of the iCal parser, keeping the richer copy (the one carrying a description/url). Recurring occurrences are unaffected — each carries a unique `<uid>_<occurrence>` id. The pass runs before Google-URL synthesis so the tie-break compares only feed-provided data. The no-duplicate common case returns the original array unchanged. Tie-break uses field "richness", not RFC SEQUENCE: the model carries no SEQUENCE and the observed duplicates share a revision (SEQUENCE would tie). A test documents this decision. Refs #7848, #3837 |
||
|
|
f10d0e9d48
|
Android soft-keyboard: fix dark-theme white flash on resize (#7839)
* test(e2e): raise timeouts for two CI-flaky waits Both timeouts repeatedly hit their limit on saturated scheduled runners without indicating a real product regression: - supersync.page.ts:781 — final sync-state icon waitFor went 10s → 30s. The race above it already consumed a 30s budget, so falling back to 10s here is too tight when the runner is hot (e.g. SuperSync 5/6 in run 26514574130, "Client A can migrate multiple times" failure). - repeat-task-day-change #6230 — post-midnight visibility went 30s → 60s. Even with the focus-event fallback added in |
||
|
|
72a4d79976 |
test(e2e): use setSystemTime for #6230 day-change test
setFixedTime freezes Date.now(), which permanently wedges the effect's debounceTime(1000): RxJS gates emission on scheduler.now() (= Date.now()), so `now` never reaches `lastTime + dueTime` and addAllDueToday() never runs after the simulated midnight crossing. The day change is detected but the new repeat instance is never created, so the assertion times out (no timeout bump can fix a frozen clock). Switch to setSystemTime so the clock keeps ticking and the debounce settles, matching the existing fix in recurring-missed-day-bug-4559. Drop the post- midnight wait back to 30s now that the mechanism resolves in ~1-2s. |
||
|
|
827aede24f |
test(e2e): add sync-stall recovery to flaky supersync setup and migration
setupSuperSync now re-triggers sync once if the sync-state icon never appears, recovering first-client setup stalls under parallel CI load. The multi-migration test re-syncs Client B when an op hasn't downloaded yet, since B has auto-sync disabled and DOM polling alone can't recover a not-yet-pulled task. Both paths run only after the original wait times out, so passing runs are unaffected. |
||
|
|
0f31b822b9 |
test(e2e): raise timeouts for two CI-flaky waits
Both timeouts repeatedly hit their limit on saturated scheduled runners
without indicating a real product regression:
- supersync.page.ts:781 — final sync-state icon waitFor went 10s → 30s.
The race above it already consumed a 30s budget, so falling back to
10s here is too tight when the runner is hot (e.g. SuperSync 5/6 in
run 26514574130, "Client A can migrate multiple times" failure).
- repeat-task-day-change #6230 — post-midnight visibility went 30s → 60s.
Even with the focus-event fallback added in
|
||
|
|
2580ea6eb1
|
perf(plugins): skip no-op doc-mode saves + rename to doc-mode (#7825)
* perf(plugins): skip no-op doc-mode saves via lastSeenDocBytes equality
Closes #7815.
flushSave and flushSaveSync now compute the would-be-written raw bytes
first and short-circuit when they equal lastSeenDocBytes — a typed-then-
reverted cycle inside the 30s throttle window no longer produces a
postMessage round-trip, an IDB transaction, or an op-log entry.
The baseline is intentionally NOT updated on a skip: it already equals
these bytes, so the self-echo invariant for PERSISTED_DATA_CHANGED
(#7752) is preserved.
Drive-by: added the isDocCorrupt guard to flushSave for symmetry with
flushSaveSync / scheduleSave. The schedule gate normally prevents
flushSave from firing on a corrupt doc, but corruption can be set
between schedule and fire (e.g. a remote-update reload turning up an
unparseable blob), so the explicit guard is cheaper than reasoning
about that invariant.
Persistence rename: saveContextDoc → persistContextDocRaw. The caller
now owns serialisation (via serializeContextDoc) so the byte-compare
can run before the persist dispatch. Three persistence.spec.ts tests
replace the dropped saveContextDoc test: exact-raw write, encoder
determinism (the premise the byte-compare relies on), and
write-idempotence (which proves the skip loses no information).
* refactor(plugins): apply doc-mode no-op-save review findings (#7815)
Multi-review follow-ups for #7815:
- Tighten serializeContextDoc determinism test: previously asserted
equality between a doc and its `{...doc}` spread, which preserves V8
insertion order — the test would pass even against a future encoder
that randomised iteration over Map-backed nodes (the real failure
mode for the byte-compare). Now asserts repeated-call determinism on
the same input directly, plus type+non-empty shape so a future return-
type change (e.g. Uint8Array) breaks here, not in the editor.
- Document the sync/async stamp asymmetry in flushSaveSync: it stamps
lastSeenDocBytes BEFORE the void dispatch, whereas flushSave stamps
AFTER `await`. Both are correct (the sync path is fire-and-forget and
the iframe can be torn down mid-call, so a post-dispatch stamp would
silently drop on teardown) but the divergence wasn't called out in
the inline comments.
* refactor(plugins): rename document-mode to doc-mode
Renames the bundled plugin's id (`document-mode` → `doc-mode`), display
name (`Document Mode (Alpha)` → `Doc Mode (Alpha)`), package name,
directory, and asset path. No migration: the plugin has never been
published, so there is no on-disk user data to preserve.
Touched everywhere the id was hardcoded:
- Plugin manifest, package.json, build/deploy/test scripts, log prefixes
- Host BUNDLED_PLUGIN_PATHS entry (src/app/plugins/plugin.service.ts)
- Host comments and test fixtures (plugin-hooks, plugin-persistence-key,
plugin-persistence.model, conflict-resolution.service.spec)
- E2E spec filenames + PLUGIN_ID constant + label assertions
- build-all.js plugin orchestrator
- build/release-notes.md user-facing entry
Test pass: 88/88 plugin specs, 8/8 plugin-hooks, 132/132
conflict-resolution, 15/15 plugin-persistence-key.util.
Bundle redeployed to src/assets/bundled-plugins/doc-mode (gitignored).
The old `src/assets/bundled-plugins/document-mode/` dir is gitignored and
left on disk after this commit; the host loader no longer references it,
so it's inert. Remove with `rm -rf src/assets/bundled-plugins/document-mode`.
* refactor(plugins): apply doc-mode rename review findings
Multi-review follow-ups for the rename in
|
||
|
|
aa1c13e805 |
test(e2e): instrument snapshot-vector-clock flake for diagnosis (#7810)
Capture per-page network traffic (untruncated response bodies) and IDB snapshot state (op-log contents, last_server_seq) around the failing A.syncAndWait at Phase 2. Adds timing marks so the dump segments by phase and dumps on both inner and late failures. Documents that the snapshot fast-forward path is not in play at this assertion despite the test name. |
||
|
|
ebf1fe7ec8 |
test(e2e): stabilize repeat-task-day-change test against CI timer throttling
The day-change emission in GlobalTrackingIntervalService has three real-world paths: 1s interval, window:focus, visibilitychange. Under CI load the 1s setInterval can starve past the 30s assertion window. Dispatch a focus event after the clock advance to trigger focusBased$ (debounced 100ms), exercising the same addAllDueToday() chain deterministically. |
||
|
|
ebf29ed01a |
test(e2e): stabilize recurring future-start-date test against op-log race
The reload-then-verify pattern races with op-log persistence: after Save, 8 ops are captured (addTask, addRepeatCfg, updateRepeatCfg, 4x updateTask, planTaskForDay), but page.reload() fires before the concatMap'd persist queue drains. On CI only 3 of 8 ops reached IndexedDB, dropping the updateTask that sets dueDay to the future start so the task stayed in Today after hydration. Assert the bug behavior in-session first — that gives the queue up to 10s to drain — then reload and re-assert. |
||
|
|
8f274582e2
|
feat(plugins): Stage A keyed persistence with LWW (#7749) (#7763)
* feat(plugins): keyed persistence API for per-context LWW (Stage A Phase 1+3) Add an optional `key` argument to `persistDataSynced` / `loadSyncedData`, composed at the bridge transport boundary into `pluginId:key` entity ids. Distinct keys now produce distinct ops that LWW-resolve per-entity, enabling document-mode-style plugins to avoid cross-context blob overwrites without changing existing keyless callers. Phase 3: `removePluginUserData(pluginId)` now sweeps the full prefix (legacy entry + every keyed entry), dispatching one delete per match with the rule-6 setTimeout(0) trailer so remote replicas don't keep keyed entries after uninstall. The reducer-only "smart prefix match" shortcut is wrong (one op for the prefix only, remote keyed entries leak) — see docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md Phase 3. Phase 4 (document-mode plugin-side migration of the legacy single-blob entry) is left as a separate follow-up so the host change can be reviewed in isolation. Issue #7749 * feat(document-mode): migrate to keyed persistence (Stage A Phase 4) Move from one synced blob under the bare plugin id to per-entity keyed entries: - meta — { enabledCtxIds: string[] }, owned by background.ts - doc:${ctxId} — one entry per context, owned by the editor iframe - __meta__ — migration stamp Each entry has its own LWW timestamp on the host, so a concurrent edit in project A on Device 1 and project B on Device 2 no longer whole-blob-collide. The migration runs idempotently from both background.ts and editor.ts (stamp-guarded), splits the legacy single blob into keyed entries, then tombstones the legacy entry with an empty payload — giving LWW a winning side against any offline device that still writes the old shape. flushSave / flushSaveSync no longer need to read+merge sibling state, since each context's entry stands alone. The future-version blob guard (isStorageUnreadable) is dropped — it referenced the wrapping blob's version, which no longer exists; per-doc corruption still falls back via isDocCorrupt. Issue #7749 * fix(plugins): tighten Stage A keyspace at the boundaries Multi-review surfaced three small gaps in the keyed-persistence rollout: - The synchronous composeId throw covers the bridge's iframe and direct-API entry points, but three in-process callers (plugin-config.service, plugin.service, plugin-config-dialog) bypass the bridge and route directly into the persistence service. A user-installed plugin with `id: "evil:plugin"` passed manifest validation and would have collided with the legitimate `evil` plugin's keyed namespace — `removePluginUserData('evil')` would have over-matched the sweep. Reject the colon at install time in `validatePluginManifest`; keep the bridge throw as defense-in-depth. - The new `key` arg at the bridge was typia-asserted on `data` but unchecked itself. A compromised iframe could pass a multi-megabyte string or a non-string value via postMessage. `data` is capped at 1 MB, but the entity id composed from `key` would be stored verbatim in NgRx state, IndexedDB, the op-log, and on the sync wire — bypassing the data cap. Add `assertPluginPersistenceKey` with a 256-char cap. - `_loadPersistedData` silently returned `null` when composeId threw, while `_persistDataSynced` rethrew. The asymmetry made a malformed pluginId look like "no data yet" on the load side, indistinguishable from a fresh install. Hoist composeId + key validation out of the load try/catch so it throws symmetrically. Issue #7749 * fix(plugins): lower per-write cap to 256 KB The pre-Stage-A 1 MB cap was sized for the old single-blob shape, where one entry held every context's data. With the keyed split, each entity gets its own write budget — 1 MB per write is wildly over-provisioned for the realistic upper bound of plugin payloads (heavy document-mode docs ~30–100 KB, configs and automations KB-scale). 256 KB keeps 2–5× headroom over realistic payloads while bounding the per-plugin storage growth more tightly. Document-mode's migration loop now skips oversized legacy docs instead of aborting the whole run: a user whose legacy blob holds one ~500 KB doc (legal under the old cap) keeps the other contexts migrated and the original bytes preserved in the legacy entry. The success stamp stays at migrated:0 in that case so a future build (or pruning of the doc) can complete the migration without data loss. Issue #7749 * test(plugins): e2e migration of legacy single-blob to keyed entries The migration logic in document-mode is unit-tested against a mock PluginAPI, which can't catch real-iframe quirks (postMessage handling of undefined second args, commit-chain timing under the host's per-entity rate limiter, hydration ordering against the op-log). Add two end-to-end scenarios: - Fresh install: enable the plugin, verify the __meta__ stamp lands at migrated:1 (the migration's final write — observing it implies every earlier step completed). - Legacy blob: seed a pre-Stage-A single-blob entry via the e2e helper store, enable the plugin, verify the legacy entry is tombstoned, meta carries the enabledCtxIds, and each doc landed under its own doc:${ctxId} key. Issue #7749 * chore(plugins): drop dead code and review-driven polish Four small follow-ups from the multi-review pass: - Don't log the plugin-supplied `key` value. Plugins may use user content (search queries, doc titles) as keys; the log history is exportable. Log `keyLen` instead, per CLAUDE.md rule 9. - Delete `detectStaleLegacyWrite` and its 3 specs. Exported and fully tested, but zero non-test callers — banner UI is forbidden by project convention for transient-only messaging. If the need resurfaces, the implementation is four lines. - Drop the `attemptedAt` field from `MigrationStamp`. It was written but never read; the success stamp is the only re-entry gate, and the resume path is just "re-run the loop" — re-writes are content- idempotent. Saves one rate-limited write per fresh migration. - Update `docs/plans/2026-05-23-stage-a-keyed-plugin-persistence.md` with an implementation-status table referencing the shipping commits, so future readers don't have to dig through git. Issue #7749 |