No description
Find a file
Filip 880243b24f
feat(task-repeat-cfg): RRULE epic phase 2 — heatmap preview + completion simulation (#8231)
* fix(task-repeat): show from-completion toggle for no-rrule completion cfgs

A completion-relative cfg with no rrule and a kept-preset quickSetting (any
pre-RRULE or imported cfg, since migration is lazy/save-only) opened under
its preset label, hiding the "from completion" toggle, then silently reset
repeatFromCompletionDate to false on the next save — flipping the task from
"repeat after completion" to "repeat from start date".

Hoist the repeatFromCompletionDate check above the if(cfg.rrule) guard and
migrate no-rrule completion cfgs into the builder, so the toggle is always
visible and quickSetting === 'RRULE' skips the preset reset.

* fix(task-repeat): reject never-firing rrules without freezing the main thread

isRRuleValid set valid=true whenever the rule merely constructed, even when
the probe yielded no occurrence — so a contradictory rule (e.g.
FREQ=DAILY;BYMONTH=13) counted as valid and the engine deferred to it,
bypassing the legacy fallback and silently never creating a task. Worse,
rrule.js walks day-by-day to its year-275760 ceiling (~3.8s main-thread
freeze) before returning null, and its UNTIL/before bounds only apply to
ACCEPTED occurrences, so they cannot cap a never-firing rule.

Pre-screen contradictory BY-constraints (out-of-range values, impossible
BYMONTH+BYMONTHDAY combos) before iterating — conservatively, only when an
entire constraint is unsatisfiable, so a sound rule is never mis-dropped to
legacy. Require the probe to actually return an occurrence. Strip UNTIL and
COUNT for the probe: they are anchor-relative end conditions, and a rule
whose window already closed still has a sound pattern that the engine
applies per cfg — keeping them would resurrect a finished rule forever via
the UNTIL-less legacy fallback.

* fix(task-repeat): make startDate-less legacy→rrule migration deterministic

_parseStart fell back to new Date() when a cfg had no startDate, encoding
today's day-of-month into the migrated rrule (vs the legacy engine's day-1
semantics) and making the result depend on WHEN the dialog was opened — two
devices migrating the same legacy cfg on different days would produce
diverging rrules. Fall back to a fixed 1970-01-01 epoch (month 1, day 1)
instead. UI paths always set startDate, so the fallback is only a safety net.

* feat(task-repeat): gate RRULE engine behind a local per-device flag

The experimental RRULE recurrence engine is now OFF by default behind a
per-device flag stored in localStorage (NOT synced), mirroring the
TaskWidgetSettings pattern. While off, the legacy repeatCycle engine stays
authoritative for every occurrence calculation, and clients that never opt
in — including older / mobile clients — are unaffected; enabling it on one
device never propagates the half-built engine to another over sync.

- isRRuleEngineEnabled() reads localStorage live (no module cache) so it is
  free of cross-spec state and runtime-toggleable. The three occurrence
  routing guards check cfg.rrule FIRST, so a device with no RRULE configs
  never touches storage.
- RRuleFeatureFlagService plus a local config section (Tasks tab) provide the
  opt-in toggle, routed through the service like taskWidget so the value
  never reaches the sync wire.
- The advanced 'RRULE' quick-setting is hidden from the repeat dialog and the
  add-task-bar menu while off — except for a config already in RRULE mode, so
  an existing rule stays editable (the builder is its only editor).

Lets the epic merge to master without the half-state problem and gives opt-in
testers a way to exercise it across devices before it ships default-on in a
later release.

* docs(task-repeat): add RRULE epic review watchlist

Distil every critique from the PR #7948 review thread (9 rounds) into a single
reference catalogued by risk class, with status (done / deferred / open) and the
phase each item bubbles up in. The recurring themes — synced-field forward-compat,
JSON-undefined clears not propagating, legacy↔RRULE fallback fidelity, engine
robustness, log redaction — recur in almost every round, so the doc leads with an
"Always-verify" checklist to run on any PR touching synced repeat state. Linked
from the implementation plan.

* docs(task-repeat): add RRULE epic roadmap + branch model

Standing-PR epic model (feat/rrule-epic -> master) with a branch per phase
(feat/rrule-epic-pN-<slug> — hyphen, since feat/rrule-epic/<phase> is a git
file/directory ref conflict with the feat/rrule-epic branch). Documents the
fork-based contribution flow, the issue wiring (#4020 needs reopen after the
#7948 revert, #4931 parent, #7239 Phase 7), the off-by-default flag, and folds
in the full feature-comparison table. Linked from the implementation plan.

* docs(task-repeat): render epic phases as a task list

Replace the wide phase table with a GitHub-native checklist (interactive
checkboxes + progress bar, branch inline per row) so it renders cleanly when
pasted into the standing PR body. No content change.

* test(task-repeat): cover rrule re-anchor in the reschedule effect

`rrule` is in SCHEDULE_AFFECTING_FIELDS so editing only the rule re-anchors the
live instance; if it were dropped, an RRULE edit would not re-enter the effect
and the stale lastTaskCreationDay would silently suppress every later occurrence.
Add a regression test (engine flag on, rrule-only change) asserting the re-anchor
— the field was previously unreferenced in the effects spec.

* docs(task-repeat): assign persist-boundary guard to its ingestion phases

The §5 persist-boundary guard (sub-daily / repeatCycle wire-safety, COUNT+
completion) has near-zero reachability until a non-dialog write path exists, so
defer it to the phases that introduce those paths rather than add a speculative
sanitizer now: untrusted-ingestion guard → Phase 7 (REST/import), sub-daily
handling → Phase 12, COUNT+completion → Phase 5. Also note the rrule re-anchor
test under Phase 1 follow-ups.

* docs(task-repeat): correct Phase 1 branch attribution

Core was built on feat/cron-recurring-schedules (PR #7948), merged then
reverted, and feat/rrule-epic was created from that state — so Phase 1 is the
BASE of the integration branch, not a merge into it. Cite the real source branch
+ commits, and separate Core from the follow-ups committed directly on
feat/rrule-epic.

* docs(task-repeat): render epic phases as a compact table

Switch the phase checklist back to a table (✓/phase/branch/scope/status) with
short cells + numbered footnotes for the nuance, so it renders cleanly — the
earlier table was cramped by a long scope column.

* feat(task-repeat): Phase 2 — live RRULE calendar preview + completion simulation

Adds a calendar (heatmap) preview to the RRULE builder, driven by the in-progress
rule so it works while authoring a brand-new recurrence (no saved cfg needed):

- generic `heatmap` UI gains a `dayClick` output plus `isProjected`/`isCompleted`
  cell states; week-grid + projection layout extracted to `build-heatmap-data.util`.
- the dialog's result block gets a "Calendar preview" toggle showing the next 365
  days of projected occurrences; clicking a day simulates completing it there and,
  for a repeat-from-completion schedule, re-anchors the rest of the series (a
  fixed-calendar schedule is unaffected, by design).
- `repeat-task-heatmap` (the saved-cfg history view) overlays upcoming occurrences
  via `getRRuleOccurrencesInRange`.

All projection/simulation is naturally behind the RRULE engine flag (RRULE-mode
UI). Per-instance overrides (moves/RDATE) are Phase 8 — only EXDATE skips
(deletedInstanceDates) apply here. Specs for the new util and the dialog
gating/projection/simulation.

* docs(task-repeat): mark Phase 2 in progress in the epic roadmap

Bump the Phase-2 (heatmap + simulation) row to in-progress now that the live
calendar preview + completion simulation has landed on feat/rrule-epic-p2-heatmap.

* feat(ui): month-grouped heatmap layout + month calendar view with year/month toggle

Reshape the heatmap surfaces around months:

- HeatmapComponent gains a `groupByMonth` layout — each month its own mini
  day-grid, spaced, with a label and per-month total beneath (hours for
  activity/history, occurrence count for projections); built by
  buildHeatmapMonths in build-heatmap-data.util.
- New HeatmapMonthCalendarComponent: single-month calendar with numbered,
  level-coloured day cells, prev/next navigation bounded to the data range,
  weekday headers and a legend (Low→High gradient for relative activity
  coloring, projected/completed swatches for projections).
- New HeatmapSwitcherComponent wraps both behind a Year/Month toggle; wired
  into the RRULE builder calendar preview (clickable, simulation-aware), the
  repeat history heatmap and the metric activity heatmap.
- Heatmap day tooltips and legend labels now go through T/translate.

The metric share-canvas export intentionally keeps the continuous weeks strip.
Specs for the month grouping, total formatters and calendar navigation.

* fix(task-repeat): correct calendar-preview review findings

Five fixes from the Phase-2 review pass:

- The month calendar compared wall-clock "now" against the noon-anchored
  rangeStart, so opening the preview before noon defaulted the view to the
  month of rangeEnd — a year ahead. Compare calendar days instead.
- A navigated month is discarded when it falls outside the current data range
  (e.g. the metric year select swaps the range under a mounted calendar),
  instead of stranding the user on an all-empty month with nav disabled.
- Other-month spill-over cells no longer emit dayClick: they render greyed
  with no level/completed styling, so consumer actions fired with zero visual
  feedback.
- Simulation clicks are ignored for fixed-calendar schedules (nothing
  re-anchors on completion), the sim hint only shows for repeat-from-completion
  rules, and an active simulation is cleared when the rule or schedule type is
  edited — previously a stale sim silently distorted the preview of the newly
  authored rule and fixed schedules rendered a false "completed" day that
  inflated the month's occurrence count.
- The 365-day projection no longer rebuilds on every keystroke in unrelated
  dialog fields: the heavy computed now hangs off a value-equal memo of the
  four schedule-relevant fields (formly emits a cloned model per keystroke).
  getEffectiveRepeatStartDate's parameter is narrowed to the fields it reads.

* feat(ui): heatmap feedback round — year row, labels, legend, persistence, year nav

Address review feedback on the heatmap surfaces:

- Year view renders Jan–Dec in a single row (horizontal scroll when narrow)
  with the weekday labels (Sun–Sat) pinned back on the left, outside the
  scroll area.
- Year view gets a legend again via a legendMode input on HeatmapComponent:
  Low→High intensity gradient for activity data, projected/completed swatches
  for projections; the switcher forwards each consumer's mode.
- The Year/Month toggle is persisted per consumer (heatmap-switcher
  persistKey → localStorage; a per-device UI preference, not synced).
- Metrics year filter is prev/next chevron navigation over the years with
  data instead of a dropdown.
- The recurring-task history heatmap gains the same year filter; its range is
  now one calendar year (upcoming occurrences overlay the selected year's
  remaining days). This also removes the 16-month view's duplicate month
  labels and keeps the weeks grid within the 54-week layout cap.

* refactor(ui): resolve remaining heatmap review findings

- Replace hardcoded #fff day-number colors in the month calendar with the
  theming contract: translucent levels inherit the theme text color, solid
  primary cells use var(--c-contrast) — readable in light and dark themes.
- Drop the ::ng-deep .mat-button-toggle-button override in the shared switcher
  for Material's --mat-button-toggle-label-text-size token (no .mat-* piercing,
  per the styling rules).
- Finish the half-migration in activity-heatmap: delete its private
  _buildWeeksGrid copy (incl. two hardcoded English month arrays) and dead
  getDayClass/getDayTitle/weeks members; the shared buildHeatmapWeeks (with
  localized month names) now feeds the share-canvas export.
- Delete HeatmapComponent's unreachable legacy strip layout and its dead
  showLegend/scrollToEnd/groupByMonth inputs, scroll effect and styles — the
  component renders the month-grouped layout only; weeks/monthLabels stay in
  HeatmapData purely as export data.
- Rename legendMode values to what they render: 'intensity' | 'projection' |
  'none' (was 'hours'/'occurrences', named after a discarded absolute-buckets
  design).
- Export HeatmapViewData and use it in the switcher input and both explicit
  consumer computeds instead of three hand-rolled intersection types.

* feat(ui): heatmap feedback round 2 — in-card year nav, wider metrics, activity setting

- Year navigation (‹ 2026 ›) moves INSIDE the heatmap card, styled like the
  month calendar's ‹ June 2026 › header: new navLabel/canNavPrev/canNavNext
  inputs + navPrev/navNext outputs on HeatmapComponent, passed through the
  switcher. The metrics page's external year row is gone, and the recurring
  task history heatmap now always shows its year filter (chevrons disable at
  the data bounds).
- The metrics heatmap section widens (880px → 1200px) so the 12 month blocks
  use the page's spare width instead of scrolling.
- The recurring-cfg dialog keeps its normal width by design — the year strip
  scrolls horizontally inside the card; the heatmap's old 500px cap is gone so
  it fills the dialog.
- New setting under Settings → Tasks: "Show recurring task activity when
  editing a recurring task config" (TasksConfig.isExpandActivityForRepeatEdit,
  optional synced field — old clients ignore it). When on, the dialog's
  Activity section starts expanded.

* fix(heatmap): center year strip and cap month calendar width

Year strip left all leftover card width right of December; the
month calendar's 1fr/aspect-ratio cells scaled with the page and
forced vertical scrolling on the metrics view. Center the strip,
legend and repeat-dialog time summary; cap the calendar at 360px.

* fix(task-repeat-cfg): keep calendar preview visible during rule edits

The preview vanished with no way back in two states: a rule with no
occurrence in the 365-day window rendered nothing while the toggle
stayed open, and a mid-edit unparseable rule removed the whole
result strip including the toggle button. Keep the strip rendered in
builder mode and show an explanatory hint in both empty states.

* fix(task-repeat-cfg): offer custom recurring config with engine off

The RRULE builder replaced the legacy Custom UI, but its dropdown
entry was gated behind the per-device engine flag - flag-off devices
were left with fixed presets only. Always offer it: saves write the
legacy mirror fields the flag-off engine schedules from, the same
fallback older sync clients use.

* test(task-repeat-cfg): scope rrule engine flag hooks to their suites

Top-level beforeEach/afterEach attach to Jasmine's root suite, and Karma
bundles all specs into one env — so four occurrence spec files were
forcing the rrule engine flag ON around every test in the app. Move the
hooks inside each file's describe so default-off specs stay
bundle-order-independent.

* fix(heatmap): tighten projection trust and polish review findings

- Project future occurrences only when the cfg has a valid rrule
  (mirrors the routing utils' gate): the legacy-cfg converter diverges
  from the legacy engine for WEEKLY interval>=2 and zero-weekday cfgs,
  so those overlays showed days the device would never fire on.
- Skip zero-value timeSpentOnDay entries in availableYears — a
  zero-time year rendered no heatmap, stranding the user on an empty
  view with the year nav removed.
- Year-stamp the first month block of each year in buildHeatmapMonths
  so a rolling 13-month window can't show two identical month labels.
- Clear an active completion simulation when startDate or excluded
  days change (schedule-slice effect) — a sim belongs to the exact
  schedule it was clicked on.
- Localize heatmap day labels via DateAdapter like the month view.
- Add an opt-in interactive mode to heatmap day cells (role, tabindex,
  enter/space) now that click-to-simulate is a real interaction.
- Extract the duplicated prev/next year-nav quartet into a shared
  year-nav util; fix the stale getRRuleOccurrencesInRange comment.

* test(task-repeat-cfg): scope rrule engine flag hooks to their suites

Top-level beforeEach/afterEach attach to Jasmine's root suite, and Karma
bundles all specs into one env — so four occurrence spec files were
forcing the rrule engine flag ON around every test in the app. Move the
hooks inside each file's describe so default-off specs stay
bundle-order-independent.

* fix(task-repeat-cfg): pre-screen impossible BYMONTH x BYYEARDAY/BYWEEKNO rules

_canNeverFire only paired BYMONTH with positive BYMONTHDAY, so
contradictions like FREQ=DAILY;BYWEEKNO=53;BYMONTH=2 slipped past the
pre-screen into the validity probe, which walks rrule.js day-by-day to
its iteration ceiling (~7-10s measured) before resolving false - a
one-time main-thread freeze on the save click that first sees the rule.

Extend the pre-screen with month-possibility checks for positive
year-days (both leap layouts) and positive week numbers (a conservative
day-span superset that covers WKST shifts and year-boundary spill -
ISO week 1 can include late December and week 53 early January, which
rrule.js honors via ISO week-year semantics; verified empirically).
Negative values count from the year's end, so they skip the check.
Conservative by construction: satisfiable combos are pinned by spec to
stay valid.

Also corrects the spec comment that described the probe as a bounded
.between() (it is _canNeverFire + an unbounded memoised .after()),
replaces the timing assertion that only ever exercised the O(1)
fast-reject path, and adds a save-path spec proving a never-firing raw
override is rejected by the isRRuleValid gate before the startDate
probe can walk.

* fix(task-repeat-cfg): derive RRULE quick-setting availability live

On the task/repeatCfgId edit path the cfg loads async, after
_initializeFormConfig captured includeRRule in the buildOptions closure
- so on a flag-off device a completion cfg migrated to builder mode left
the quick-setting select holding 'RRULE' with no matching option.
Read the flag/mode per evaluation instead and key the options memo on it
so the async migration rebuilds the list.

Also flip buildRepeatQuickSettingOptions' includeRRule default to false:
a future caller that forgets the param now fails safe (no advanced
option on flag-off devices) instead of silently offering rules the
local engine would ignore. Both existing call sites pass explicitly.

* test(task-repeat-cfg): use withContext for combo validity assertions

Jasmine's toBe() takes no message argument - the rule name passed as a
second param was silently ignored.

* fix(heatmap): keep current-year month labels plain

Only stamp the year onto a month block when it differs from the current
year - 'Jun', not 'Jun 2026', for the implied default year; the next
year's stamp still marks the boundary in a rolling window.

* fix(heatmap): drop current-year from month header and hide dead year arrows

- Month-calendar header reads 'June' for the current year; other years
  keep the year suffix so cross-year navigation stays unambiguous.
- The year strip's prev/next arrows render only when at least one
  direction is navigable - a single-year series showed two permanently
  disabled buttons.

* chore: ignore local run-verification screenshots

* feat(task-repeat-cfg): unbounded year/month navigation for the calendar preview

The preview was a fixed next-365-days window. Now:

- The year strip gets in-card arrows that shift the window by whole
  years, unlimited in both directions; the title shows the window's
  year span (e.g. 2026 - 2027). The projection is computed per window,
  so any year is reachable, including past ones (full pattern shown).
- The month calendar gains a boundless mode (new heatmap-month-calendar
  input + viewMonthChange output, passed through the switcher): month
  arrows never hit a wall, and when the shown month leaves the window
  the dialog shifts the window a year so data follows.
- The window is padded to full calendar months so the month view never
  renders a half-covered month; in the home window projection still
  starts at today (no projected marks on days already past).
- A navigated window with no occurrences stays rendered (returning null
  would tear down the calendar and its nav mid-navigation); only the
  home window keeps the hide-when-empty behavior.

* fix(heatmap): unreachable history, display-only clicks, and projection titles

- A repeat cfg whose only tracked history is in older years opened on an
  empty current year with heatmapData() null - no heatmap and no year
  nav, so the history was unreachable. selectedYear now defaults to the
  newest year that renders something; the current year is offered only
  when the projection overlay can mark it; and an empty navigated year
  stays rendered (empty grid + nav) instead of tearing down the way
  back. Pinned by a new component spec.
- Click handlers and pointer/hover affordances are now gated on the
  interactive input like the ARIA role/tabindex already were - in
  display-only heatmaps mouse clicks no longer emit dayClick while
  keyboard users can't activate the same cells.
- In a projection calendar, empty days no longer report fake activity
  ('0 tasks, 0m'): interactive ones describe the simulation click
  (new G.HEATMAP_SIMULATE_DAY string), display-only ones just carry
  the date.

* fix(task-repeat-cfg): reject sub-daily FREQs at the engine validity gate

save() already blocks FREQ=HOURLY/MINUTELY/SECONDLY, but a synced,
imported or REST-ingested rule never passes through the dialog - it
routed into the day-granular engine and silently collapsed to ~daily
firing at local noon. isRRuleValid now rejects non-day-granular FREQs,
dropping such rules to the legacy repeatCycle fallback like any other
invalid rule. The persist-boundary ingestion guard (strip/reject at the
write path) still lands with Phase 7; roadmap + watchlist updated to
say so instead of overclaiming.

* docs: document the repeat-edit activity setting in the settings wiki

isExpandActivityForRepeatEdit shipped in the Tasks settings form
without the wiki entry the documentation guide requires.

* feat(task-repeat-cfg): fullscreen toggle for the recurring-task dialog

A fullscreen/fullscreen_exit icon button sits in the dialog title bar.
Opening the calendar preview auto-expands the dialog (the year strip
needs the room on short screens, where it used to push the action
buttons out of view); closing the preview shrinks it back ONLY when the
preview caused the expansion - a manual toggle takes ownership either
way. Sizing rides on a new reusable .dialog-fullscreen overlay panel
class in the shared Material overwrite layer.

* fix(task-repeat-cfg): need-based fullscreen and no redundant upcoming line

- The calendar preview only auto-expands to fullscreen when it actually
  doesn't fit: a post-render measurement checks whether the calendar is
  clipped vertically or the year strip is forced to scroll sideways
  (and fullscreen would buy it width). On a screen where everything
  fits, the dialog stays its normal size.
- The textual 'Upcoming: ...' line hides while the calendar is open -
  it shows the same occurrences the calendar already shows spatially.

* fix(task-repeat-cfg): render the fullscreen toggle as a Material icon button

MatIconButton was missing from the dialog's imports, so the mat-icon-button
attribute did nothing and the toggle rendered as an unstyled native button.

* feat(task-repeat-cfg): show the assembled rrule beside the raw override

The raw rrule string moves out of the sticky result band (which keeps
the humanized reading, upcoming dates and calendar toggle) into the
builder's Advanced section, right beneath the raw-override input - so
reading the assembled string and overriding it happen in one place -
with a copy-to-clipboard button. E2E selectors updated to the new
location (the readout sits inside the Advanced collapsible now).

* fix(heatmap): instant day-cell tooltips via matTooltip

The cells used the native title attribute, whose browser-fixed ~1-2s
hover delay made the heatmap feel unresponsive. MatTooltip's default
0ms show delay matches the instant feel of GitHub's contribution
heatmap (measured ~55ms hover-to-visible), positioned above the cell
and styled with the app theme.

* feat(task-repeat-cfg): hide off-schedule days in the repeat-task heatmap

Every day of the year rendered a grey level-0 cell, so a weekly task
showed six 'missed-looking' cells per week - reading like a broken
streak. With the engine flag on and a valid rrule, the schedule is now
computed for the whole selected year and days the task is NOT scheduled
on (and that carry no tracked time) are dropped from the day map; the
grid renders them as transparent placeholders in the same position.
Visible cells now read as the actual streak: filled = tracked, grey =
genuinely missed occurrence, dashed = upcoming. Off-schedule days WITH
tracked time stay visible, and legacy/flag-off cfgs keep the previous
full-grid view (the schedule isn't reliably knowable there).

* feat(task-repeat-cfg): say what the rule actually does while the engine is off

With the per-device advanced-recurrence flag off, a saved rule only
drives scheduling through its simplified legacy mirror - yet the
builder described full engine behavior, including things that silently
don't happen (end conditions foremost: UNTIL/COUNT have no legacy
equivalent, so the series repeats forever).

The builder now reads the flag and tells the truth:
- a notice at the top explains that scheduling runs from a simplified
  version of the saved rule until the engine is enabled
- the Ends row warns that end conditions only take effect with the
  engine on (shown only when one is selected)
- the Advanced section notes its selections are saved but not scheduled

All three disappear the moment the flag is on.

* fix(task-repeat-cfg): never strand the empty home window; open future windows at their start

- A valid rule with no occurrence in the next 365 days (multi-year
  intervals, a far-future start) nulled out the home preview entirely,
  taking the year nav with it - the same stranding already fixed for
  navigated windows. The empty window now renders with its nav alive
  plus a 'No occurrences in this window' hint instead of a bare grid.
- The month calendar's out-of-range fallback is direction-aware: a
  window entirely in the future (year-jumped projection) opens at its
  START month; past windows (history years) keep opening at their END.

* feat(task-repeat-cfg): write never-fires legacy fallback for unrepresentable rules

Decided contract for the legacy mirror fields (the wire format for old
clients and the schedule for flag-off devices): either they fire on the
same days as the rrule, or they are the LEGACY_NEVER_FIRES_FALLBACK
sentinel (repeatCycle WEEKLY with every weekday flag false, which is
deterministically dead on every released legacy engine and fully
wire-stable). Rules outside legacy expressiveness - COUNT/UNTIL,
seasonal BYMONTH, BYWEEKNO/BYYEARDAY, multi-day lists, out-of-union
ordinals, yearly weekday modes - previously persisted a nearest-pattern
approximation, so old and flag-off clients created tasks on wrong days
that synced back to every device; absent tasks beat fabricated ones.

Also in this change:
- interval-1 FREQ=DAILY;BYDAY=... now maps losslessly onto WEEKLY flags
  instead of approximating as plain DAILY
- the dialog warns at authoring time (isRRuleLegacyRepresentable ->
  RRULE_LEGACY_INCOMPAT) and the engine-off builder notices now say
  "creates no tasks" instead of "simplified rhythm" for these rules
- the live rrule preview is gated on the memoised isRRuleValid like the
  calendar preview already was; previously a never-firing raw override
  (e.g. FREQ=DAILY;BYWEEKNO=53;BYMONTH=2) froze the main thread for
  multiple seconds per keystroke via the ungated rule.after() walk
- the engine-flag settings help now tells multi-device accounts to
  enable the flag everywhere or nowhere: engines diverging on a day
  produce different rpt_<cfgId>_<dueDay> ids, i.e. duplicate tasks on
  every device

* build(deps): pin rrule to exact 2.8.1

Occurrence streams are downstream of a third-party parser with known quirks; a caret upgrade that changes parsing on some devices mid-account re-creates duplicate-task mechanics between identical app versions (different rpt_<cfgId>_<dueDay> ids for the same cfg). Bump only deliberately - the engine invariants/differential specs double as the upgrade tripwire.

* docs(task-repeat-cfg): write down the dual-engine endgame, risk model and flip gates

Roadmap additions (phase scope unchanged):
- the governing risk model: engine divergence on a multi-device account
  means duplicate tasks (per-device recomputation deduped only by
  rpt_<cfgId>_<dueDay>) plus lastTaskCreationDay flip-flop - not merely
  shifted dates; the mixed window opens when ONE device opts in
- the legacy-fallback contract (faithful-or-sentinel, never silent
  approximation) as a documented policy
- the dual-engine endgame: lazy-migrate on edit -> lossless-only
  (interval=1) backfill at flag default-on -> soak -> remove flag ->
  delete legacy engine one release later; legacy dual-write stays
  forever as the wire format; post-legacy invalid-rrule fallback is
  pause + repair prompt (isPaused exists), decided now
- flip gates before default-on: mixed-version convergence simulation,
  differential fuzz across all three calculators (divergences pinned as
  expected output, not excluded), typia wire round-trip per producer
- recorded why a bounded between/until probe CANNOT replace the
  _canNeverFire heuristics: rrule.js checks until only against emitted
  occurrences; measured 10.1 s walk with UNTIL set

Watchlist: sentinel contract entries, monthlyWeekOfMonth out-of-range
resolved via sentinel, MONTHLY_ANCHOR_RESET scope sharpened (applies to
the whole flag-off installed base, not just PRE-rrule versions), rrule
exact-pin note.

Model comment: the stale-anchor analysis claimed a stale anchor is
inert once rrule is set - true only for flag-ON clients; while the flag
defaults off every remote client routes the legacy engine, so the gap
covers the whole installed base until default-on. Comment now says so
and notes the sentinel path is unaffected (cycle switch to WEEKLY is
wire-stable).

* feat(task-repeat-cfg): align engine-off notices with the never-fires sentinel

The old texts described the pre-sentinel contract (simplified rhythm; series repeats without end). With the sentinel, a rule beyond legacy expressiveness creates no tasks while the engine is off - say exactly that.

* feat(task-repeat-cfg): wip heatmap calendar + extract heatmap constants

* feat(task-repeat-cfg): TickTick-style frequency picker in repeat dialog

Restructures the repeat-config dialog's frequency selection from a dropdown to
a TickTick-style chip group (repeat-freq-picker), while keeping every existing
recurring feature: all quick-setting presets, the full RRULE custom builder
(intervals/multiples, multi-weekday, month-day grid + custom CSV, nth-weekday
rows, BYSETPOS, BYMONTH, ends, advanced), tags, estimate, time/remind, notes,
subtasks, wait-for-completion, skip-overdue, preview + heatmap.

quickSetting is no longer a formly field — the chip picker drives it via
onQuickSettingSelect (mirroring the old select change handler, incl. the #5806
reference-date behaviour) with options from quickSettingOptions(). The RRULE
engine, presets and sync-safe persistence are unchanged. Scoped entirely to
task-repeat-cfg/. Specs updated; build + dialog/const/tz specs green.

* feat(task-repeat-cfg): restyle repeat dialog to TickTick recurrence flow

Reworks the repeat-config dialog's look + flow to match TickTick's recurrence
editor, on the existing RRULE engine, keeping every SP feature:
- round weekday chips, quiet section labels, segmented pills (schedule type),
  cleaner inputs and a calendar-style day-of-month grid;
- the live recurrence summary moves in-sheet under the builder as an
  accent-edged card (was a pinned bottom band);
- seasonal BYMONTH moves into the builder's Advanced collapsible so the main
  flow stays clean (TickTick has no seasonal months up front);
- presets (chip row), full custom builder (interval/multiples, multi-weekday,
  month-day grid + CSV, nth-weekday rows, BYSETPOS, ends, from-completion),
  WKST/BYWEEKNO/BYYEARDAY/raw override, and all task-default extras unchanged.
Pure presentation + template moves; engine/persistence untouched. Build green;
dialog/const/tz specs green (59/10/2).

* feat(task-repeat-cfg): polish repeat dialog toward TickTick (curated presets, cleaner flow)

- Repeat chips now show 6 curated common presets + a 'More options' toggle that
  reveals the full set; 'Custom' is rendered last as an accent-outlined chip.
  No preset removed — just progressive disclosure.
- Rename labels for clarity: section 'Recurring Config' -> 'Repeat'; collapsibles
  'Advanced options' -> 'Advanced rule' (rule internals) and 'Advanced
  configuration' -> 'Defaults for new tasks' (task template fields); dialog title
  'Add/Edit Recurring Task Config' -> 'Set up repeat' / 'Edit repeat'.
- Move the task Title field out of the top into 'Defaults for new tasks' so the
  Repeat picker is the first thing (TickTick-clean).
- Shorten the wordy schedule-type description; add a divider under the picker.
Presentation/labels + a field move only; RRULE engine, presets and sync-safe
persistence unchanged. Build green; dialog/const/tz specs green.

* feat(task-repeat-cfg): rebuild custom recurrence as a TickTick-style settings sheet

Reworks the RRULE builder's look + flow to match TickTick's custom recurrence:
- 'Repeat every' is a label-left row with a − N + stepper;
- frequency (Day/Week/Month/Year), Ends (Never/On date/After) and Schedule type
  are iOS/TickTick segmented controls (pill-on-track), replacing the raw native
  selects in the main flow;
- weekday selectors are centered circular chips;
- monthly/yearly pattern stay as selects but themed with a custom chevron;
- shortened the Ends option labels for the segmented control.
All controls bind to the existing builder methods — RRULE assembly, presets and
sync-safe persistence are unchanged. Build green; builder/dialog/const/tz specs
green (27/59/10/2).

* feat(task-repeat-cfg): polish recurring dialog — dropdown picker, calendar day grid, centered layout

- Repeat selector is now a custom CDK-overlay dropdown; "More options"
  expands the long tail in the open panel instead of closing it. Order:
  every day, weekly, monthly, yearly, every weekday, Custom, More.
- "Custom recurring config" option renamed to "Custom".
- Interval row reads as one inline phrase "Every [n] [unit]"; unit is a
  dropdown that pluralizes with the interval (Day/Days …).
- Monthly/Yearly mode labels shortened to "On" with concise options
  (Day of month, Nth weekday, Selected weekdays, Specific date, …).
- Day-of-month picker laid out as a 7-column calendar grid with
  borderless circular cells (deadline-modal look), fits a 375px phone.
- Builder content centered into one consistent-width column; segmented
  bars stretch uniformly.
- Evaluated-rule summary pinned above the action buttons, outside the
  scroll area.

* fix(task-repeat-cfg): bound the rrule validity probe to a decade

The validity probe used an unbounded `.after()`: an exotic never-firing
rule that slipped past the `_canNeverFire` O(1) pre-screen (e.g. a
BYSETPOS past its occurrence set) made rrule.js walk day-by-day to its
year-275760 ceiling — a multi-second main-thread freeze that the live
preview hit on every keystroke.

Replace it with a bounded `between(anchor, anchor + 10y)` that early-exits
on the first occurrence: the window caps the work for any never-fire rule
the heuristic doesn't model, and the early-exit keeps a firing rule at one
occurrence. `_canNeverFire` stays as the O(1) pre-screen for the realistic
contradictory rules (notably the FREQ=DAILY+BYWEEKNO class, whose per-day
expansion is too slow to probe even over a short window). "Fires within a
decade" is a deliberate product rule. Addresses the dual-engine review.

* feat(task-repeat-cfg): widen the recurring dialog dynamically

The dialog sized to its content and was pinned to a fixed narrow column, so
it never used the available width. Add a `dialog-recurring` panel class that
sizes the surface to `min(95vw, 600px)` and lifts Material's 80vw default —
the modal now widens with the viewport and fills the width on a phone. The
form column keeps a 600px cap so controls stay readable, and the pinned
result band (calendar preview) drops the cap in fullscreen so the expand
actually buys it room — previously the 420px content cap defeated it.

* feat(task-repeat-cfg): hide the day-list free-text field behind a "custom…" toggle

The free-text day list ("1,15,-5") sat permanently under the day-of-month
grid (monthly) and the specific-date grid (yearly), duplicating what the grid
already shows. Tuck it behind a "custom…" button; the grid + last-day chips
cover the common cases. The field binds `monthDays`, so it opens pre-filled
with the days already selected. An existing rule whose days the grid can't
represent (e.g. a typed "1,15,-5") auto-reveals the field so it stays editable.

* fix(heatmap): single shared tooltip to stop overlay stacking in the preview

Per-cell matTooltip on the dense calendar grid trailed a stack of overlays on
a fast cursor sweep (the show-delay only reduced it). Replace it with one
shared readout per heatmap that follows the hovered/focused cell — there is
never more than one tooltip element, in the year strip, the month calendar, and
the recurrence preview alike. Positioned relative to the non-scrolling root so a
horizontal scroll can't strand a stale tip; pointer-events:none so it can't
re-trigger a hover; shown on focus too for keyboard parity. Drops the now-unused
matTooltip + HEATMAP_TOOLTIP_SHOW_DELAY.

* feat(task-repeat-cfg): richer recurrence preview — next spotlight, rhythm stats, reveal, calendar realism

Four additive enhancements to the dialog's calendar preview:
- Next-occurrence spotlight: the next upcoming cell pulses, and a "Next: <date>
  · in N days" chip sits under the summary.
- Rhythm stats ribbon above the calendar: count in view · ~every N days · ends
  after N / until <date> / runs forever (from the rule's COUNT/UNTIL).
- Staggered reveal: occurrence cells fade/scale in on open and on view switch
  (fill-mode backwards, so a keystroke that reuses cells never re-animates).
- Calendar realism: today ring, weekend tint, and a richer tooltip ("occurrence
  #N · in N days").

All gated so the shared Activity heatmap + canvas export are untouched: new
DayData flags (isNext/isToday/revealIndex) are only set by the dialog, and the
new heatmap/switcher inputs (animateReveal/showWeekends) default off. Spotlight
is derived from the projection's own occurrence set, not rrulePreview, so an
unrelated field keystroke still doesn't rebuild the projection. Honors
prefers-reduced-motion.

* fix(task-repeat-cfg): bound never-firing rrule probes against MAXYEAR walk

rrule.js cannot stop a never-firing walk early: when no day passes the
BY-filters its iterator callback never fires, so it spins period-by-period
to MAXYEAR (9999). A 2020-anchored validity probe walked ~8000 years of
periods on contradictory rules (e.g. FREQ=DAILY;BYYEARDAY=60;BYWEEKNO=53) —
a 7-11s main-thread freeze reachable from a dialog keystroke or the first
scan of a synced/imported rule.

- isRRuleValid: anchor the probe at 9620 (= 2020 + 19*400). The Gregorian
  calendar repeats exactly every 400 years, so [9620,9630] is calendar-
  identical to [2020,2030] (verdicts unchanged across a 700-rule battery)
  while bounding the never-fire walk to ~380y of periods.
- _firesFromStart: the occurrence queries anchor at the cfg's real start,
  where an INTERVAL>1 phase can miss a positional constraint (fires from the
  canonical anchor yet never from the real start). Gate _buildRuleSet with a
  bounded phase- and calendar-preserving probe shifted near 9999, so such a
  rule resolves to "no occurrence" instead of walking to 9999.

Pure/deterministic (fixed anchors); identical results for firing rules,
never-fire resolves bounded. Adds timing + phase regression specs.

* refactor(task-repeat-cfg): P2 review fixes + drop heatmap reveal animation

Curator P2 review follow-ups:
- dialog: restore quickSettingOptions memoization (value-equal
  {refDateStr, locale} key; build read untracked) so it stops rebuilding
  ~16 translate.instant + 4 toLocaleDateString per keystroke.
- dialog: re-derive the legacy never-fires sentinel on every schedule-
  touched save (no longer gated on a present startDate), so a non-
  representable rule cannot persist stale legacy fields.
- legacy-cfg-to-rrule: assertNever exhaustiveness guard on switch(cycle)
  (new shared util src/app/util/assert-never.ts).
- repeat-task-heatmap: extract one _usesRRuleEngine predicate shared by the
  two dual-engine routing sites.
- rrule-builder: replace the two hand-rolled .rb-seg segmented controls with
  the shared segmented-button-group.
- repeat-freq-picker: document why the raw cdkConnectedOverlay deviates from
  mat-select/mat-menu (in-panel expand + trigger-width match).

Remove the heatmap preview reveal animation (staggered cell entrance): drop
animateReveal through heatmap/month-calendar/switcher, the is-animate-reveal
CSS + keyframes, and the --reveal-index plumbing. Keep the occurrence-order
data for the "occurrence #N" tooltip, renamed revealIndex -> occurrenceIndex
(uncapped now the animation-delay cap is gone).

* fix(task-repeat-cfg): restore listbox keyboard a11y to repeat-freq-picker

The custom dropdown replaced a native <select> but dropped its keyboard
support: the panel declared role="listbox" while the option children were
plain buttons (no role="option", no aria-selected, no roving tabindex/arrow
keys, and focus never entered the panel on open — only Escape was handled).

- options now carry role="option" + [attr.aria-selected]; the listbox holds
  only option children (the More toggle is a sibling).
- roving tabindex + Arrow/Home/End navigation across the panel rows, with a
  visible :focus-visible ring.
- focus moves into the panel on open (and stays on the More toggle when it
  expands the long tail in place), mirroring the native select.

Also dedupe the 'RRULE' quick-setting magic string: a shared
RRULE_QUICK_SETTING const (model) used by the picker and the options builder
so they can't drift.

* refactor(task-repeat-cfg): P2 review polish — tokens, parse memo, orphan key, probe spec

- rrule-builder SCSS: restore var(--card-border-radius) (was literal 9px) and
  swap the native-select chevron's baked-in #888 fill for a per-theme variant
  (.isDarkTheme), so it tracks light/dark like the native datepicker indicator.
- safeParseRRuleOptions: add a small string-keyed parse cache (the dialog's
  preview/validity computeds re-parse the same rule ~4x per keystroke, and the
  engine re-parses on every scan). Result is treated read-only by all callers.
- remove the orphaned RRULE_YEARLY_MODE_DESCRIPTION i18n key (lost its last
  template reference in the restyle) from t.const.ts + en.json.
- pin the isRRuleValid "fires within a decade" probe boundary with a spec:
  FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;BYDAY=MO first fires >10y past the anchor
  -> invalid (deliberate product rule); plain Feb-29 fires in-window -> valid.
  Guards against a future window/anchor edit silently flipping a real rule.

* fix(task-repeat-cfg): close _canNeverFire gaps + correct stale probe comments

P2 review follow-up. The reviewed range predates the 9620-anchor probe fix,
which already bounds every never-firing class to sub-second (DAILY;BYSETPOS=2
6764ms->341ms, BYWEEKNO=10;BYYEARDAY=300 11257ms->527ms). This makes the named
classes O(1) and fixes comments that still described the pre-anchor behavior.

- _canNeverFire: flag BYSETPOS beyond the per-period set for DAILY (1 slot) and
  WEEKLY (BYDAY count) — e.g. FREQ=DAILY;BYSETPOS=2; and BYWEEKNO x BYYEARDAY
  contradictions independent of BYMONTH (the month-intersection checks were
  skipped when bymonth was empty) — e.g. BYWEEKNO=10 x BYYEARDAY=300. Both reuse
  the conservative month-superset helpers / provable set-size bounds, so no
  sound rule is mis-flagged (verified: in-range BYSETPOS and satisfiable
  weekno x yearday stay valid).
- correct the comments in _canNeverFire + the complex spec: rrule.js MAXYEAR is
  9999 (not 275760); the validity probe does NOT bound never-firing rules via
  its window — it is bounded by anchoring near 9999 (already shipped); and
  _canNeverFire is an O(1) optimization, not the termination guarantee.
- add a repeat-freq-picker unit spec (preset ordering, More toggle, Custom, and
  the listbox roving-focus keyboard navigation).

* style(task-repeat-cfg): compact the Ends / Schedule-type segmented controls

The shared segmented-button-group's default (lg) treatment — a 3px border plus
a scale-up + elevation on the active segment — was too heavy in the recurring
dialog and made the selection appear to "bounce" as it moved.

- segmented-button-group: flesh out the md (compact) size — 1px border, no
  zoom/lift, a subtle tinted active fill, and nowrap labels so a two-word label
  ("On date") stays on one line. Only the rrule-builder uses md, so the lg
  consumers (focus-mode) are unaffected.
- rrule-builder: stack .rb-ends as a column so the Ends control keeps a stable
  full width above its conditional COUNT/UNTIL input, instead of resizing (and
  re-wrapping its labels) as that input appeared/disappeared.

* feat(task-repeat-cfg): unified preview calendar for the recurring dialog

Merge the start-date picker, occurrence projection and activity history into
one calendar in the Recur dialog (RRULE epic P2):

- Click a day to set the start; drag to pan the 365-day strip; double-click to
  simulate a completion. Start is also editable via M/D/Y fields kept in sync
  with the calendar.
- Green tracked-time overlay merged into the projection (toggleable, with a
  total / this-week / this-month summary), surfaced via a look-back window so
  past activity shows alongside upcoming occurrences.
- Today drawn as a robust white inset ring (survives the start fill); legend
  gains start / today / next-occurrence / projected and a green tracked-time
  colour range.
- Layout: Repeat -> Every/On -> calendar -> Ends / Schedule type; the calendar
  is projected into the rrule-builder between the On and Ends sections, and
  those two rows are single-line.
- Rename user-facing "weekday(s)" -> "days of the week" (day-of-week sense) and
  "working day(s)" (Mon-Fri sense).
- Drop the dead Calendar-preview toggle / auto-fullscreen and align the specs.

* feat(task-repeat-cfg): calendar as direct-manipulation rrule editor

Turn the recurrence calendar into a direct editor: clicking a day, a
weekday header or the month label opens a contextual menu that edits the
same rule the builder edits (new pure rrule-calendar-ops helpers).

- Weekday menu lists every weekday-targeting variant (weekly / monthly /
  yearly, plain + nth); identical labels are differentiated by a grey
  "switches to ..." hint.
- Ends + Schedule type are nested at the top of a renamed "Advanced"
  section (Custom-only), with the frequency-specific "On ..." day
  selection moved in beneath them - the calendar is now the primary day
  picker.
- Month menu gains "Remove all month limits (...)" listing the limited
  months.
- Changing frequency or monthly/yearly mode resets the day selection to
  none, so a stale selection from the previous mode can't silently narrow
  or dead-end the new rule.
- Setting the start date jumps the month view / scrolls the year strip to
  it, and re-anchors a running "after I complete it" schedule (drops the
  stale lastTaskCreationDay) so the new start actually takes effect.
- Heatmap gains interactive menus, per-weekday annotations, an end-day
  marker, tracked-time in tooltips, and limited-month chips, all kept
  optional so the metrics Activity heatmap is unchanged.

* fix(task-repeat-cfg): correct BYSETPOS never-fire false positives

The _canNeverFire BYSETPOS pre-screen wrongly flagged sound rules, which
silently drops a cfg to the legacy repeatCycle fallback and reschedules
it - so real recurring rules were being broken.

Two false positives (each FIRES in rrule.js 2.8.1):

- Negative BYSETPOS. `Math.abs(p) > slots` flagged e.g. DAILY;BYSETPOS=-2
  and WEEKLY;BYDAY=MO;BYSETPOS=-2, but rrule.js clamps an out-of-range
  negative and still fires. Flag only positive overshoot (`p > slots`);
  negatives now short-circuit, matching how the other combo checks already
  skip on any negative value.
- WEEKLY without BYDAY. _maxSetPosSlots returned the BYDAY count (1 when
  absent), but rrule.js treats BYMONTHDAY / BYYEARDAY / BYWEEKNO as
  EXPANDERS within the week, so the per-week set exceeds it - e.g.
  WEEKLY;BYMONTHDAY=1,2,3;BYSETPOS=2 fires. Bound WEEKLY only when the sole
  positional part is BYDAY; otherwise return null (probe handles it).

Adds the regressing vectors to the "no false positives" suite and drops
two fragile wall-clock timing asserts (the memoised validity cache makes
the timed block a cache hit; the surviving sibling keeps a generous
<1000ms regression guard). De-dups the negative-value guards into
hasNegYearDay / hasNegWeekNo to mirror the existing hasNegDay.

* fix(heatmap): distinguish the recurrence end-day from the next-occurrence

Both markers were 2px rings differing only by --c-warn vs --c-accent,
which are near-identical reddish hues in the default theme - and the
static legend can't show the next-occurrence pulse, so the two swatches
read as the same. Make the end (UNTIL) day a SOLID warn block instead of
a ring: solid-vs-hollow is unmistakable regardless of theme colours.
Applied to both the year strip and the month calendar, cells and legend.

* feat(task-repeat-cfg): group frequency-switch menu options under icon buttons

The calendar context menus listed every weekday/day option flat, with a
grey "(switches to …)" hint on the ones that change the frequency. Bury
those switch options instead: only the CURRENT frequency's options stay at
the top of the menu; each other frequency gets one icon button in a bottom
row that opens a deeper sub-menu of its options.

- Weekday-header menu: native Selected-days (+ Nth for monthly/yearly) at
  top; a bottom row of week / month / year icons, each opening that
  frequency's {Selected days of the week, Nth day of the week ▸ ordinal}.
- Day menu: native "On this day of the month/year" for the current freq at
  top; the other under a single month/year switch icon.
- Switch triggers are mat-menu-items (restyled into a compact icon row) so
  the nested sub-menu opens and the parent stays open - a plain icon-button
  trigger inside a menu closes it instead.

Icons: calendar_view_week / calendar_view_month / calendar_month; the old
CAL_MENU_SWITCHES_* strings are repurposed as the icons' tooltips. Drops
the inline switch hints and their computeds.

* fix(task-repeat-cfg): match calendar-menu switch icons to app style + tooltips

The frequency-switch icon buttons in the calendar menus used a native
`title` attribute and a cramped square cell. Use Material `matTooltip`
(responsive / touch-aware, like the rest of the app) and render them as
round 40px icon buttons with the themed hover state-layer and ripple
(mat-menu-item provides those once the cell is made round).

* fix(task-repeat-cfg): stop the weekday menu focusing a switch icon on open

In Yearly mode, opening the weekday menu painted a stray grey state-layer
circle on the "switch to weekly" icon. MatMenu focuses its first registered
item on open, and items projected via *ngTemplateOutlet register AFTER the
static switch-icon buttons — so the focus landed on an icon. Inline the
native weekday options (static, declared before the switch row) so the
first item is the expected text option; the switch sub-menus keep using the
shared templates.

* feat(task-repeat-cfg): toggle-aware calendar menu labels (set vs remove)

When the clicked day / weekday already carries a setting, the menu item now
reads "Remove …" and the action toggles it off — mirroring the month menu's
Limit/Unlimit. Covers every settable calendar action:

- Day: Ends on this date <-> Remove end date (the only one whose handler
  needed a branch; the rest already add-or-remove); On this day of the
  month/year <-> Remove …; Simulate completing here <-> Stop simulating.
- Weekday: Selected days of the week <-> Remove from selected days; each nth
  ordinal <-> Remove <ordinal>.

"Set as start date" stays (a rule's anchor can't be unset), and the
frequency-switch sub-menu variants always add (the target weekday/day can't
already be set in another frequency). Active-state derived from the parsed
model via computeds; new CAL_MENU_REMOVE_* strings.

* style(heatmap): fold v18.10.0 palette + dark contrast into calendar & 365

Match upstream v18.10.0's "improved heatmap / recurrent task calendar
design" while keeping all of our preview features (start / next / end /
today / projected / activity / simulate markers, glyphs, menus).

- Hoist the cell palette into themeable vars on the container
  (--heatmap-container-bg / -cell-border / -hover-outline / -level-0..4-bg)
  and drive cells + legend from them; add an inset cell-border hook
  (transparent by default).
- Dark theme: re-tune via the vars only — level-0 becomes an ink overlay
  (rgba(var(--ink-on-channel), 0.16)) and levels mix primary at 38/55/72%
  so the activity cells stay distinct on dark backgrounds.
- Apply the same palette to the month "Calendar" view (was 25/45/65%) so it
  and the 365 strip share one look.

Light theme is unchanged (identical level values); the visible win is the
dark-mode contrast and one shared, themeable palette.

* feat(task-repeat-cfg): add leading icons to calendar context-menu items

Every named item in the day / weekday / month context menus (and their
sub-menus) now leads with a Material icon for faster scanning:

- Day: flag (start), event_busy (end), calendar_view_month / calendar_month
  (this day of the month / year), science (simulate).
- Weekday: calendar_view_week (selected days), format_list_numbered (nth);
  the nth ordinals get looks_one…looks_4 / last_page.
- Month: filter_alt (limit) / filter_alt_off (clear all limits).

Day-of-X and selected-days reuse their frequency's switch-icon glyph so the
item and its switch button read as the same thing. The icon-only
frequency-switch buttons are unchanged.

* fix(heatmap): make calendar text + markers track the active theme

The recurring calendar didn't fully honour the picked theme (e.g. Cybr:
day numbers rendered white instead of the theme's red), and several markers
shared a colour.

- Day numbers now use the theme's --text-color explicitly. Inside a
  mat-dialog the cells were inheriting Material's on-surface colour (white),
  ignoring themes that recolour their text; the busiest day also hard-coded
  white. Removed both.
- Distinct, theme-derived markers (no two share a hue by default):
  start = primary, next = accent (pulsing ring), end = error (was --c-warn;
  red "stop", falls back to --c-warn), completed = success (green "done",
  was accent — which made it identical to next), activity = success ramp,
  today = a neutral --ink ring (was hard-coded #fff). Legend swatches
  realigned to match (completed swatch had even been the wrong colour).

Swept all 14 themes in their light/dark modes: numbers now theme-coloured
everywhere; markers distinct except Everforest, whose own --c-accent and
--c-success are the same olive (next vs completed there stay distinct by
shape/animation). start follows --c-primary, which a few themes (Cybr)
choose not to recolour.

* fix(task-repeat-cfg): click-toggle switch options, distinct start, pulsing next-legend

- Bottom "switch to …" icons no longer hover-open a sub-menu. They're now
  plain icon-button toggles: click expands that frequency's options INLINE
  in the same menu, click again collapses; the active icon is highlighted.
  This also kills the stray focus-circle that appeared on the first switch
  icon when a weekday was clicked in Daily mode (a non-menu-item button
  isn't auto-focused), and the parent menu stays open throughout.
- start day now carries a contrasting ink ring (not a same-colour halo) so
  it stays distinct from the green tracked-time cells even when the theme's
  primary is close to that green.
- The "next occurrence" legend swatch now pulses like its cell marker.

Verified end-to-end in a clean profile: Daily-mode weekday menu opens with
no circle, hover does nothing, click expands inline (parent stays), click
again collapses.

* feat(heatmap): hover tooltips spelling out weekday + month statuses

Hovering a weekday header (Mon–Sun) that has something set now shows a
tooltip listing it — e.g. "Nth day of the week: 2nd, last · Selected days
of the week · Days of the week in months" — instead of just the terse
glyphs. Hovering a month label/title that's BYMONTH-limited shows the
limited-month list ("Limited to Jun, Jul").

The dialog derives both from the live rule (weekdayAnnotations) and passes
them through the switcher to the month + year views as optional inputs, so
the metrics Activity heatmap (which passes neither) is unaffected.

* fix(heatmap): amber simulate-completion + visible today/next legend swatches

- Simulated "completed here" day is now warm amber (var(--c-warning,
  #e8a13a)) instead of green — it was --c-success, the same family as the
  green tracked-time cells, so the two read too alike.
- The today and next-occurrence legend swatches were drawn with an inset
  box-shadow ring on a tiny transparent box, which rendered as nothing in
  some themes. Switched both to an `outline` ring (like the dotted
  projected swatch, which always showed), so they're reliably visible; the
  next swatch keeps its pulse via an outline-colour keyframe.

Verified in a clean profile: today shows an ink ring, next shows a pulsing
accent ring.

* feat(task-repeat-cfg): concise recurrence preset labels

Rename the common quick-setting presets to a tighter "Freq (detail)" form:

- Every day              → Daily
- Every week on Monday   → Weekly (Mon)        (short weekday)
- Every month on day 15  → Monthly (15th)      (ordinal day)
- Every year on the 6/15 → Yearly (Jun 15)     (short month + day)
- Every Monday–Friday    → Every weekday (Mon - Fri)

Each option passes its own interpolation params, so the new compact forms
(short weekday / ordinal day / "MMM d") are scoped to these labels and don't
affect the verbose presets (biweekly, quarterly, nth-weekday, etc.) that
still use the long/numeric values. en.json only; other locales keep their
existing templates until translated.

* feat(task-repeat-cfg): tighten the remaining recurrence preset labels

Apply the concise "Freq (detail)" style to the rest of the presets:

- Every other day              → Every 2 days
- Every other week on Monday   → Every 2 weeks (Mon)
- Every month on the first day → Monthly (1st)
- Every month on the last day  → Monthly (last day)
- Every month on the 3rd Mon   → Monthly (third Mon)
- Every month on the last Mon  → Monthly (last Mon)
- Every 3 months on the day 15 → Every 3 months (15th)
- Every 6 months on the day 15 → Every 6 months (15th)
- Every 2 years on the 6/15    → Every 2 years (Jun 15)

Weekday → short, day-of-month → ordinal, day/month → "MMM d", matching the
common presets. The nth-weekday ordinal keeps its _NTH (dative) variant for
non-English grammar. The verbose long-form date params are now unused and
removed. en.json only.

* test(task-repeat-cfg): update preset-label param expectations to concise forms

The dialog spec asserted the old quick-setting params (numeric day, numeric
day/month). They now carry the concise forms - ordinal day ("15th") and
short month+day ("May 1") - so the expectations are updated to match (via a
shared ordinalDay helper). Fixes the 3 pre-push test failures.

* feat(task-repeat-cfg): show only the selected schedule-type description

The schedule-type hint always showed both halves ("Fixed: … After I
complete it: …"). Show only the one matching the current selection:

- Fixed dates       → "Keep a set calendar rhythm from the start date."
- After I complete it → "Count the interval from each completion (e.g. every
  3 days = 3 days after you finish)."

Split into two keys driven by scheduleSelectedId(); the old combined key is
left for other locales until translated.

* feat(task-repeat-cfg): simulate-completion flips schedule type and respects the start date

A simulated completion only re-anchors a "from completion" schedule, so
"Simulate completing here" now turns that mode on when you pick a day —
otherwise the what-if preview wouldn't actually shift.

- Gate the simulate menu item: only offer it on/after the start date (a
  completion can't happen before the rule begins); the "Stop simulating"
  variant stays reachable for an active sim.
- Moving the start date past an existing simulation drops that sim (it no
  longer makes sense before the new start).
- Fix the self-defeating interaction with the sim-watcher effect: flipping
  the schedule type changes the schedule slice, which the watcher treats as
  an edit and clears the sim. Promote its tracked slice to a field and
  advance it in lockstep from menuSimulate so the freshly-set sim survives
  the flip it triggered.

Unit tests flush the effect so they actually guard the survive-the-flip
behavior; verified live across all four cases.

* fix(task-repeat-cfg): keep the simulation when a new start date precedes it

Moving the start to BEFORE a simulated completion left the completion valid
(it still sits on/after the start), so the sim should stay — but the
sim-watcher effect fired on the startDate slice change and cleared it.

_applyStartDate now only drops the sim when the new start moves PAST it
(a completion can't precede the rule's start); when the sim still sits
on/after the new start it advances the watcher's tracked slice in lockstep
so the kept sim survives the start change. Spec flushes the effect so it
genuinely guards both the keep and the drop paths.

* fix(task-repeat-cfg): make simulate a pure preview, drop the schedule-type flip

menuSimulate flipped repeatFromCompletionDate -> true unconditionally to make
the calendar re-anchor. That mutation of persisted state caused three problems:

- A start-anchored COUNT rule got forced to from-completion, which save rejects
  with RRULE_COUNT_WITH_COMPLETION - blocking save on a setting the user never
  knowingly changed (and re-anchoring COUNT renders up to ~2x COUNT marks).
- Toggling the sim off cleared simulatedCompletion but left the flag true, a
  one-way start-anchored -> from-completion conversion persisted on save.
- Presets (always start-anchored, no from-completion control) showed a preview
  the saved cfg would not have.

Simulation is now a pure preview that never touches the persisted schedule type.
It is offered only on/after the start of a from-completion, non-COUNT schedule
(menuDaySimAllowed), so presets and start-anchored rules no longer expose it and
resultHeatmapData re-anchors off the existing flag. menuSimulate gains a
defensive guard mirroring the template. Restores the documented contract that
simulation only exists for repeat-from-completion schedules.
2026-06-17 17:03:29 +02:00
.air 18.4.2 2026-05-01 23:07:19 +02:00
.devcontainer chore: add git and testing tools out of the box in devcontainers 2025-05-12 11:13:06 +02:00
.github ci(e2e): retry Docker service start to survive Docker Hub pull flakes 2026-06-09 10:50:07 +02:00
.husky fix(build): auto-generate env.generated.ts on checkout via husky hook 2026-03-06 16:39:21 +01:00
.signpath/policies/super-productivity build: sign path setup 4 2026-01-28 12:51:50 +01:00
.vscode chore: add git and testing tools out of the box in devcontainers 2025-05-12 11:13:06 +02:00
android fix(android): support Vanadium WebView #8005 (#8082) 2026-06-08 11:27:41 +02:00
build 18.9.1 2026-06-05 22:45:35 +02:00
docs feat(task-repeat-cfg): RRULE epic phase 2 — heatmap preview + completion simulation (#8231) 2026-06-17 17:03:29 +02:00
e2e feat(task-repeat-cfg): RRULE epic phase 2 — heatmap preview + completion simulation (#8231) 2026-06-17 17:03:29 +02:00
electron fix(security): stored XSS → RCE chain — note image, plugin nodeExecution, CSP (GHSA-78rv-m663-4fph) (#8178) 2026-06-08 20:20:17 +02:00
eslint-local-rules docs(sync): consolidate sync docs + enforce the contributor model 2026-05-15 16:51:50 +02:00
fastlane fix(ci): repair v18.9.0 release pipeline (MAS build, iOS submit, flaky lock test) (#8040) 2026-06-05 22:26:42 +02:00
ios fix(ci): repair v18.9.0 release pipeline (MAS build, iOS submit, flaky lock test) (#8040) 2026-06-05 22:26:42 +02:00
nginx refactor(e2e): migrate to production Dockerfile for E2E tests 2026-01-21 14:30:24 +01:00
packages chore(deps): bump @types/supertest from 6.0.3 to 7.2.0 (#7430) 2026-06-09 11:54:04 +02:00
scripts refactor(scripts): share log migration helper #7917 (#8116) 2026-06-08 12:09:45 +02:00
snap/hooks fix(snap): add filesystem and desktop integration plugs 2026-01-17 12:44:30 +01:00
src feat(task-repeat-cfg): RRULE epic phase 2 — heatmap preview + completion simulation (#8231) 2026-06-17 17:03:29 +02:00
tools feat(task-repeat): RFC 5545 RRULE recurring schedules — EPIC · Phase 1/13 (Closes #4020) (#7948) 2026-06-09 11:25:35 +02:00
.browserslistrc build: update browser support list 2025-08-13 19:47:44 +02:00
.dockerignore fix(docker): simplify env handling for Docker builds 2025-08-09 12:16:31 +02:00
.editorconfig chore: update gradle/java indent_size to 4 2024-09-29 09:40:49 +08:00
.env.example docs: change template of the .env file to include the mandatory unsplash key 2025-08-12 18:10:59 +02:00
.gitattributes chore: fix LF/CRLF for errant SCSS file (again) (#7117) 2026-04-09 19:42:54 +02:00
.gitignore feat(task-repeat-cfg): RRULE epic phase 2 — heatmap preview + completion simulation (#8231) 2026-06-17 17:03:29 +02:00
.gitmodules chore: Update android submodule to use feat/platform-android-offline branch (for capacitor) 2024-09-12 09:49:41 +08:00
.gitpod.yml refactor: make prettier work for angular 2025-02-21 14:31:22 +01:00
.npmrc chore(deps): add cooldown for NPM and GH Actions to reduce supply chain attack risk (#7685) 2026-05-20 11:50:30 +02:00
.nvmrc feat: add .nvmrc file with Node.js v22.18.0 2025-08-13 19:47:44 +02:00
.prettierignore feat(sync): add Helm chart and WebSocket push for SuperSync (#6971) 2026-03-30 21:34:30 +02:00
.prettierrc.json refactor: make prettier work for angular 2025-02-21 14:31:22 +01:00
.stylelintrc.mjs build(stylelint): fix font-family-no-missing-generic-family-keyword 2025-01-04 13:49:50 +01:00
AGENTS.md docs: update AGENTS.md to include CLAUDE.md reference 2026-01-15 17:20:39 +01:00
angular.json refactor(sync-providers): extract local file provider 2026-05-13 11:36:19 +02:00
ARCHITECTURE-DECISIONS.md feat(project): project completion experience (#8036) 2026-06-08 13:43:38 +02:00
capacitor.config.ts fix(android): correct edge-to-edge bar colors and top inset on Capacitor 8 2026-05-29 14:55:22 +02:00
CLAUDE.md feat(task-repeat): RFC 5545 RRULE recurring schedules — EPIC · Phase 1/13 (Closes #4020) (#7948) 2026-06-09 11:25:35 +02:00
CONTRIBUTING.md docs(sync): consolidate sync docs + enforce the contributor model 2026-05-15 16:51:50 +02:00
docker-compose.e2e.fast.yaml fix(ci): fix WebDAV config path for hacdias/webdav v5 2026-02-16 11:07:52 +01:00
docker-compose.e2e.yaml fix(ci): fix WebDAV config path for hacdias/webdav v5 2026-02-16 11:07:52 +01:00
docker-compose.supersync.yaml fix(dev): update default SuperSync port to 1901 for local development 2026-01-24 21:14:57 +01:00
docker-compose.yaml fix(infra): close db-startup race in supersync e2e stack 2026-04-29 16:17:56 +02:00
docker-entrypoint.sh refactor(e2e): migrate to production Dockerfile for E2E tests 2026-01-21 14:30:24 +01:00
Dockerfile fix(docker): include sync packages in image build 2026-05-16 20:48:38 +02:00
Dockerfile.e2e.dev feat(e2e): add Docker-based E2E test isolation 2026-01-04 17:09:39 +01:00
Dockerfile.e2e.dev.fast build(e2e): add fast local Docker Compose setup for E2E tests 2026-01-09 18:00:24 +01:00
electron-builder.yaml fix(caldav-plugin): make recurring-occurrence edits/deletes safe and quiet #7492 (#8149) 2026-06-08 16:05:52 +02:00
eslint.config.js docs(sync): consolidate sync docs + enforce the contributor model 2026-05-15 16:51:50 +02:00
funding.json chore(funding): drop broken repositoryUrl.wellKnown line 2026-05-14 17:21:39 +02:00
Gemfile 10.1.1 2024-11-06 19:44:38 +01:00
Gemfile.lock chore(deps): bump jwt in the bundler group across 1 directory (#7970) 2026-06-03 10:03:23 +02:00
LICENSE fix: typo in license 2019-01-29 18:21:51 +00:00
ngsw-config.json fix(pwa): cache the hashed icon font so it renders offline on iOS #8138 2026-06-08 18:29:55 +02:00
package-lock.json feat(task-repeat-cfg): RRULE epic phase 2 — heatmap preview + completion simulation (#8231) 2026-06-17 17:03:29 +02:00
package.json feat(task-repeat-cfg): RRULE epic phase 2 — heatmap preview + completion simulation (#8231) 2026-06-17 17:03:29 +02:00
README.md docs(readme): remove TestMu AI sponsor 2026-06-01 16:36:35 +02:00
SECURITY.md build: update links to match our new organization 2026-01-05 14:45:06 +01:00
tsconfig.base.json feat(sync): add OneDrive sync provider with PKCE auth (#7523) 2026-05-26 15:31:06 +02:00
tsconfig.json build: try to get rid of inline compilation to js 2025-04-25 12:58:16 +02:00
webdav.yaml build: simplify docker setup and fix e2e 2025-07-18 20:00:10 +02:00

Banner

An advanced todo list app with timeboxing & time tracking capabilities that supports importing tasks from your calendar, Jira, GitHub and others

🌐 Open Web App or 💻 Download


MIT license   GitHub Discussions

Reddit Community   Super Productivity on Mastodon   Tweet

animated

💻 Downloads & Install

Get it on Flathub Get it from the Snap Store English badge Play Store Badge F-Droid Badge Obtanium Badge App Store Badge

For all current downloads, package links, and platform-specific notes: check the wiki
Get it on GitHub


Ukraine Flag
Humanitarian Aid for Ukraine
Support humanitarian relief via the official National Bank of Ukraine account.


✔️ Features

  • Keep organized and focused! Plan and categorize your tasks using sub-tasks, projects and tags and color code them as needed.
  • Use timeboxing and track your time. Create time sheets and work summaries in a breeze to easily export them to your company's time tracking system.
  • Helps you to establish healthy & productive habits:
    • A break reminder reminds you when it's time to step away.
    • The anti-procrastination feature helps you gain perspective when you really need to.
    • Need some extra focus? A Pomodoro timer is also always at hand.
    • Collect personal metrics to see, which of your work routines need adjustments.
  • Integrate with Jira, Trello, GitHub, GitLab, Gitea, OpenProject, Linear, ClickUp and Azure DevOps. Auto import tasks assigned to you, plan the details locally, automatically create work logs, and get notified immediately, when something changes.
  • Basic CalDAV integration.
  • Back up and synchronize your data across multiple devices with Dropbox and WebDAV support
  • Attach context information to tasks and projects. Create notes, attach files or create project-level bookmarks for links, files, and even commands.
  • Super Productivity respects your privacy and does NOT collect any data and there are no user accounts or registration. You decide where you store your data!
  • It's free and open source and always will be.

And much more!

Work View with global links

Note

The web version has some limitations: See the Web App vs Desktop comparison for more details.

📖 Documentation and Guides

Getting Started

Starting Point in Wiki:
First stepsReferenceHow-To

Productivity Tips:
Keyboard ShortcutsShort Syntax

Need Help?
Visit the discussions page

See the bottom of the README for more information on the documentation.

Advanced Topics

Here are some other topics covered in the official wiki:

Development:
Run dev serverPackage the appBuild for AndroidRun with Docker

Data Management:
User DataIssue ProvidersSync Providers

Customization:
PluginsThemes

APIs:
Sync ServerPluginsREST

Community

The development of Super Productivity is driven by a wonderful community of users and contributors. Thank you all so much for your support!

👀 Check out our awesome curated list of community-created resources about Super Productivity

♥️ Contributing

If you want to get involved, please check out the CONTRIBUTING.md

There are several ways to help.

  1. Spread the word: More users mean more people testing and contributing to the app which in turn means better stability and possibly more and better features. You can vote for Super Productivity on Slant, Product Hunt, Softpedia or on AlternativeTo, you can tweet about it, share it on LinkedIn, reddit or any of your favorite social media platforms. Every little bit helps!

  2. Provide a Pull Request: Here is a list of the most popular community requests and here some info on how to run the development build (wiki). Please make sure that you're following the commit message format and to also include the issue number in your commit message, if you're fixing a particular issue (e.g.: feat: add nice feature #31).

  3. Answer questions: You know the answer to another user's problem? Share your knowledge!

  4. Provide your opinion: Some community suggestions are controversial. Your input might be helpful and if it is just an up- or down-vote.

  5. Provide a more refined UI spec for existing feature requests

  6. Report bugs

  7. Make a feature or improvement request: Something can be done better? Something essential missing? Let us know!

  8. Translations, Icons, etc.: You don't have to be a programmer to help; learn how to contribute translations!

  1. Sponsor the project

  2. Create custom plugins or custom themes

Special Thanks to our Sponsors!!!

Recently support for Super Productivity has been growing! A big thank you to all our sponsors!

(If you are, intend to or have been a sponsor and want to be shown here, please let me know!)

Code Signing

Windows binaries are signed. Free code signing is provided by SignPath.io, certificate by SignPath Foundation.

Documentation: Manual versus Automated

There are two wikis: the official one hosted in by GitHub autonomously generated variant using DeepWiki.com. The manually curated version is a more stable and approachable resource designed to help you understand the app from a more human-focused perspective whereas DeepWiki is optimized for explaining the code itself with little regard for context beyond that.

Official Wiki

It is preferable to maintain local documentation rather than rely on an external service. It also preferable that the documentation is updated in tandem with the code changes as demonstrated in this commit.

Changes to files within ./docs/wiki are linted in CI before being automatically sync'd to the repository's official Wiki hosted by GitHub.

Migrating to Docusaurus is a long-term goal once the content and structure of the wiki has matured and the remaining "legacy docs" have either been reworked or removed. There are some automations in development to help reduce the difference between the published docs and the state of the code while retaining a human-in-the-loop.

DeepWiki.com

If you have very specific questions about how the code works or why a bug might be producing a particular message it might be useful to Ask DeepWiki . It can help "cite your sources" when discussing functionality and code that you don't fully understand as part of feature requests or bug reports.

This automated reference does come with some significant drawbacks:

  1. Intent: Describes what code does, not why decisions or tradeoffs were made.
  2. Staleness: Will *always* lag behind the code.
  3. Code-Focused: Does not provide guides or conceptual explanations.
  4. Cost: Potential future cost and higher resource usage than static docs.