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.
This commit is contained in:
Filip 2026-06-17 11:03:29 -04:00 committed by GitHub
parent b6d5989ba4
commit 880243b24f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 8991 additions and 1390 deletions

1
.gitignore vendored
View file

@ -129,3 +129,4 @@ data/
# Generated App Store Connect "What's New" notes (see tools/prepare-appstore-release-notes.js)
/fastlane/appstore_metadata
.tmp-run-shots/

View file

@ -50,10 +50,13 @@ Released-master `MonthlyWeekOfMonth = 1|2|3|4|-1`; `MonthlyWeekday = 0..6`.
- [ ] **No optional synced field is "cleared" via `undefined`** expecting it to
propagate — JSON.stringify drops the key, so remotes never see the clear. Use a
wire-stable strategy. (§3.)
- [ ] **Legacy fallback fields stay faithful.** Old clients ignore `rrule` and schedule
from `repeatCycle` + weekday flags + monthly anchors + `startDate`-day. Any rule
the builder can emit must round-trip to legacy fields that fire on the _same days_,
or the value must be left unset so old clients fall back cleanly. (§4.)
- [ ] **Legacy fallback fields stay faithful — or sentinel, never approximate.** Old
clients ignore `rrule` and schedule from `repeatCycle` + weekday flags + monthly
anchors + `startDate`-day. Any rule the builder can emit must round-trip to legacy
fields that fire on the _same days_; a rule outside legacy expressiveness writes
`LEGACY_NEVER_FIRES_FALLBACK` (all-false WEEKLY — old clients create nothing)
plus the authoring-time warning. Decided policy 2026-06-11 — see the roadmap's
"Legacy-fallback contract". (§4.)
- [ ] **Effects inject `LOCAL_ACTIONS`** (lint-enforced); multi-entity changes go through
a meta-reducer, not effect fan-out; no `Date.now()` in pure engine/reducer code.
- [ ] **Never log rule bodies or task content**`rrule` (raw-override = free-text),
@ -70,11 +73,11 @@ Released-master `MonthlyWeekOfMonth = 1|2|3|4|-1`; `MonthlyWeekday = 0..6`.
(`_processQuickSettingForDate`). Clamp centralized in the `addTaskRepeatCfgToTask` /
`updateTaskRepeatCfg` / `updateTaskRepeatCfgs` **action creators**
(`toSyncSafeQuickSetting` / `_toPersisted`, `MASTER_SAFE_QUICK_SETTINGS`). (Comments #59.)
- **`monthlyWeekOfMonth` out-of-range** — `rruleToLegacyTaskRepeatCfg` blind-casts the
BYDAY ordinal; builder custom-ordinal allows ±5 (monthly) / ±53 (yearly), so
`BYDAY=5MO``5`, `BYDAY=-2MO``-2` are outside `1|2|3|4|-1`. Fix: only set the anchor
when model-valid (`n===-1 || (n>=1&&n<=4)`), else leave unset; mirror a strip/repair in
the action boundary **and `data-repair.ts`**. (Comment #15.1 — verify still guarded.)
- **`monthlyWeekOfMonth` out-of-range** — `rruleToLegacyTaskRepeatCfg` only maps
model-valid ordinals (`1..4 | -1`); an out-of-range ordinal (`BYDAY=5MO`, `-2MO`) now
routes the whole rule to the never-fires sentinel (spec-pinned). Still open from the
original finding: mirror a strip/repair in the action boundary **and `data-repair.ts`**
for non-dialog producers. (Comment #15.1.)
- ❓ **`null` anchors not master-safe** — `monthlyWeekOfMonth`/`monthlyWeekday = null` must
be normalized to absent/`undefined` before persist (`_normalizeMonthlyAnchor`). (Comments #13.3, #15.) The
follow-up fix `fix(task-repeat-cfg): clear repeat-from-completion…` touches this path.
@ -93,8 +96,11 @@ rrule: undefined }` branch is removed); editing a preset no longer drops the can
- 🔶 **`MONTHLY_ANCHOR_RESET` (nth-weekday → day-of-month switch)** — clearing
`monthlyWeekOfMonth`/`monthlyWeekday` via `undefined` doesn't propagate, so a **released**
client (ignores `rrule`, checks Nth-weekday anchors first) keeps scheduling the old
rule. Affects the whole installed base during the mixed-version window; self-heals once
both devices run the rrule build, loses no data. **Tracked follow-up** — needs a
rule. Scope sharpened 2026-06-11: because the per-device flag defaults OFF, _every_
remote client routes the legacy engine today — the gap applies to the whole installed
base (incl. fully-updated flag-off devices) until the flag defaults on, not just
PRE-rrule versions; the model comment now says so. Self-heals once
both devices route the rrule engine, loses no data. **Tracked follow-up** — needs a
wire-stable clear/replace strategy or the two-release `| null` migration, not a code
comment alone. (Comments #16.3, #17.1, #19.) **This is the canonical example — any new
"switch mode clears a legacy anchor" path inherits it.**
@ -102,7 +108,20 @@ rrule: undefined }` branch is removed); editing a preset no longer drops the can
## 4. Legacy ⇄ RRULE dual-engine fidelity ✅ (keep faithful)
Old clients schedule from legacy fields; new clients prefer a valid `rrule`. Divergence =
the same task fires on different days per device.
the same task fires on different days per device — and because creation is per-device
recomputation deduped by `rpt_<cfgId>_<dueDay>`, different days = **duplicate tasks on
every device** plus `lastTaskCreationDay` cross-suppression (see the roadmap's
"governing risk model"). Note the flag gates evaluation, so "old client" here includes
every **flag-off** rrule-aware build.
- ✅ **Non-representable rules → never-fires sentinel** (decided 2026-06-11):
COUNT/UNTIL, seasonal `BYMONTH`, `BYWEEKNO`/`BYYEARDAY`, multi-day lists,
out-of-union ordinals, yearly weekday modes write `LEGACY_NEVER_FIRES_FALLBACK`
(all-false WEEKLY, every value wire-stable) instead of a wrong-day approximation;
the dialog warns via `isRRuleLegacyRepresentable` / `RRULE_LEGACY_INCOMPAT`, and the
flag-off builder notices say "creates no tasks" rather than "simplified rhythm".
Bonus: the sentinel's `repeatCycle: 'WEEKLY'` switch is wire-stable, so it sidesteps
the §3 stale-anchor gap (anchors are only read in the legacy MONTHLY branch).
- ✅ **Malformed `rrule` → silent dormancy** — the three dispatchers gate on `isRRuleValid`
and fall back to legacy fields instead of returning `null`. (Comments #5, #7; reinforced
@ -132,7 +151,11 @@ BYMONTH=13`, Feb-30 combos) walked to year 275760 (~3.8s freeze) and returned `t
Fixed: `_canNeverFire` pre-screen (contradictory BY-constraints) + the probe must
actually return an occurrence; `UNTIL`/`COUNT` stripped for the probe.
(Commit `d1ef458`; Comment #20 🟠.) Note: `.between()`/`until` do **not** bound a
never-firing rule in rrule.js — only `_canNeverFire` does.
never-firing rule in rrule.js — only `_canNeverFire` does. Re-measured 2026-06-11:
`FREQ=DAILY;BYWEEKNO=53;BYMONTH=2` with `UNTIL=2030` still walked **10.1 s** — the
"replace heuristics with a bounded 10-year probe" idea is structurally impossible on
rrule.js (until/between are checked against _emitted_ occurrences only). Heuristics
stay; the live preview is now gated on the same memoised `isRRuleValid`.
- ✅ **`_canNeverFire` combo coverage** — the original pre-screen only paired
`BYMONTH` × positive `BYMONTHDAY`, so `BYMONTH` × `BYYEARDAY` / `BYWEEKNO`
contradictions (`FREQ=DAILY;BYWEEKNO=53;BYMONTH=2`: measured ~710s) slipped through
@ -148,9 +171,11 @@ BYMONTH=13`, Feb-30 combos) walked to year 275760 (~3.8s freeze) and returned `t
- 🔶 **Sub-daily FREQ (HOURLY/MINUTELY/SECONDLY)** — engine maps every occurrence to local
noon, so sub-daily silently collapses to ~daily; also `FREQ_TO_CYCLE` miss →
`repeatCycle: undefined` → required-field typia failure. Rejected at the **dialog**
only. **Mirror the guard at the engine/persist/`data-repair` boundary** so a synced/
imported/REST rule can't slip past. (Comments #15.2, #20 🟡.) Phase 12 owns real
sub-daily support.
AND at the **engine validity gate** (`isRRuleValid` rejects non-day-granular FREQs →
a synced/imported rule drops to the legacy `repeatCycle` fallback instead of
mis-firing daily). Still open: the persist/`data-repair` ingestion guard (strip or
reject at the write boundary) — lands with Phase 7's ingestion guards.
(Comments #15.2, #20 🟡.) Phase 12 owns real sub-daily support.
- 🔶 **`COUNT` + `repeatFromCompletionDate`** — completion re-anchors `startDate` +
`lastTaskCreationDay`, restarting the COUNT window → never terminates. Rejected at the
dialog only; mirror at the persist boundary. (Comment #20 🟡.)
@ -229,6 +254,10 @@ BYMONTH=13`, Feb-30 combos) walked to year 275760 (~3.8s freeze) and returned `t
- ❓ **Lockfile** — earlier rounds flagged stale `cron-parser` / `cronstrue` entries in
`package-lock.json` with no source imports. Should be gone post-pivot — verify
`package-lock.json` only adds `rrule` (+ pre-existing `tslib`). (Comments #9, #20.)
- ✅ **`rrule` pinned exact (`2.8.1`, no caret)** — occurrence streams are downstream of
a third-party parser; a caret upgrade changing parsing on some devices mid-account
re-creates the duplicate-task mechanics between identical app versions. Bump only
deliberately, with the invariants/differential specs as the tripwire.
---

View file

@ -66,21 +66,21 @@ reverted; the integration branch was created from it — so it is the starting c
a merge in). Phases 2+ each = a PR `feat/rrule-epic-pN-<slug> → feat/rrule-epic`, body
`Part of` the epic PR (never `Closes`).
| ✓ | Phase | Branch | Scope | Status |
| --- | -------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- | ------------ |
| ☑ | **1 — Core** | `feat/cron-recurring-schedules` (#7948 → reverted) → base of this branch | engine · builder · legacy⇄RRULE migration · forward-compat clamp · tests · flag + review follow-ups¹ | ✅ base |
| | **2 — Heatmap + simulation** | `feat/rrule-epic-p2-heatmap` | 365-day heatmap; click a day to simulate + re-anchor | ⏸ waypoint |
| ☐ | **3 — Natural language `@+`** | `feat/rrule-epic-p3-nl` | `@+<phrase>` → RRULE + add-bar + preview | ⏸ waypoint |
| ☐ | **4 — Due-date derivation** | `feat/rrule-epic-p4-duetype` | per-instance Due: offset / until-next / period-end / fixed / from-completion / none | ⏸ waypoint |
| ☐ | **5 — Ends after N completions** | `feat/rrule-epic-p5-endsafter` | stop after N completed² | ⏸ waypoint |
| ☐ | **6 — Missed-occurrence backfill** | `feat/rrule-epic-p6-backfill` | a task per missed occurrence (+ build-set-once perf) | ⏸ waypoint |
| ☐ | **7 — REST API recurring** | `feat/rrule-epic-p7-rest` | create recurring via REST (#7239) + ingestion guards³ | ⏸ waypoint |
| ☐ | **8 — RECURRENCE-ID overrides** | `feat/rrule-epic-p8-overrides` | edit one occurrence via RDATE+EXDATE | ⏸ waypoint |
| ☐ | **9 — iCal / RRULE export** | `feat/rrule-epic-p9-ical` | export `.ics` / RRULE | ⬜ not built |
| ☐ | **10 — Adaptive scheduling** | `feat/rrule-epic-p10-adaptive` | learn cadence → suggest/adjust; op-log-deterministic | 🔭 not built |
| ☐ | **11 — Trigger-based recurrence** | `feat/rrule-epic-p11-trigger` | fires on a state-change, not a clock | 🔭 not built |
| ☐ | **12 — Sub-daily / hourly** | `feat/rrule-epic-p12-subdaily` | `FREQ=HOURLY`/`BYHOUR`; owns sub-daily engine + persist guard⁴ | 🔭 not built |
| ☐ | **13 — Multiple reminders/occurrence** | `feat/rrule-epic-p13-reminders` | >1 reminder offset per instance | 🔭 not built |
| ✓ | Phase | Branch | Scope | Status |
| --- | -------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | -------------- |
| ☑ | **1 — Core** | `feat/cron-recurring-schedules` (#7948 → reverted) → base of this branch | engine · builder · legacy⇄RRULE migration · forward-compat clamp · tests · flag + review follow-ups¹ | ✅ base |
| 🚧 | **2 — Heatmap + simulation** | `feat/rrule-epic-p2-heatmap` | live 365-day calendar preview of the in-progress rule; click a day to simulate completion + re-anchor | 🚧 in progress |
| ☐ | **3 — Natural language `@+`** | `feat/rrule-epic-p3-nl` | `@+<phrase>` → RRULE + add-bar + preview | ⏸ waypoint |
| ☐ | **4 — Due-date derivation** | `feat/rrule-epic-p4-duetype` | per-instance Due: offset / until-next / period-end / fixed / from-completion / none | ⏸ waypoint |
| ☐ | **5 — Ends after N completions** | `feat/rrule-epic-p5-endsafter` | stop after N completed² | ⏸ waypoint |
| ☐ | **6 — Missed-occurrence backfill** | `feat/rrule-epic-p6-backfill` | a task per missed occurrence (+ build-set-once perf) | ⏸ waypoint |
| ☐ | **7 — REST API recurring** | `feat/rrule-epic-p7-rest` | create recurring via REST (#7239) + ingestion guards³ | ⏸ waypoint |
| ☐ | **8 — RECURRENCE-ID overrides** | `feat/rrule-epic-p8-overrides` | edit one occurrence via RDATE+EXDATE | ⏸ waypoint |
| ☐ | **9 — iCal / RRULE export** | `feat/rrule-epic-p9-ical` | export `.ics` / RRULE | ⬜ not built |
| ☐ | **10 — Adaptive scheduling** | `feat/rrule-epic-p10-adaptive` | learn cadence → suggest/adjust; op-log-deterministic | 🔭 not built |
| ☐ | **11 — Trigger-based recurrence** | `feat/rrule-epic-p11-trigger` | fires on a state-change, not a clock | 🔭 not built |
| ☐ | **12 — Sub-daily / hourly** | `feat/rrule-epic-p12-subdaily` | `FREQ=HOURLY`/`BYHOUR`; owns sub-daily engine + persist guard⁴ | 🔭 not built |
| ☐ | **13 — Multiple reminders/occurrence** | `feat/rrule-epic-p13-reminders` | >1 reminder offset per instance | 🔭 not built |
¹ On `feat/rrule-epic` (fork→origin PR pending): off-by-default per-device flag,
from-completion flip, `isRRuleValid` never-fire/freeze, deterministic `_parseStart`, rrule
@ -89,11 +89,95 @@ re-anchor test.
at the persist boundary.
³ Persist-boundary guards for untrusted ingestion (unsupported-FREQ / `repeatCycle`
wire-safety) — defends the non-dialog write path.
⁴ Until Phase 12, sub-daily FREQs are rejected at save **and** the persist boundary.
⁴ Until Phase 12, sub-daily FREQs are rejected at save **and** at the engine validity
gate (`isRRuleValid` → legacy fallback, covering synced/imported rules the dialog never
saw); the persist-boundary ingestion guard lands with Phase 7 (note ³).
Status key: ✅ base · ⏸ on waypoint, awaiting its slice · ⬜ not started · 🔭 newly scoped,
not built. Donetick assignee rotation / round-robin is out of scope — SP recurrence is
single-assignee.
Status key: ✅ base · 🚧 in progress · ⏸ on waypoint, awaiting its slice · ⬜ not started ·
🔭 newly scoped, not built. Donetick assignee rotation / round-robin is out of scope — SP
recurrence is single-assignee.
---
## The governing risk model: duplicates, not shifted dates
Occurrence creation is **per-device recomputation**, not a replayed derivation: each
device independently computes "what's due" and emits concrete ops, deduped only by the
deterministic id `rpt_<cfgId>_<dueDay>` (`get-repeatable-task-id.util.ts`). The flag
gates **evaluation** (it sits inside all three calculators), so two devices routing
different engines that disagree on the day produce **different ids → both instances are
created and sync to every device** — and each side's `lastTaskCreationDay` update then
feeds the other engine a foreign anchor (creation/suppression flip-flop). Any engine
divergence on a multi-device account is therefore a **duplicate-task generator for the
whole mixed window**, which is why:
- the flag help text warns multi-device accounts to enable it everywhere or nowhere
(the flag is per-device localStorage, never synced — the mixed window opens the
moment ONE device opts in, not at default-flip);
- the converter divergence classes are pinned as expected-output contract cases, not
an exclusion list (see flip gates below);
- `rrule` is **pinned to an exact version** (`2.8.1`, no caret): a caret upgrade that
changes rrule.js parsing on some devices mid-account re-creates the same duplicate
mechanics between identical app versions. Treat an rrule bump like an engine change —
the differential/invariants specs are the upgrade tripwire.
## Legacy-fallback contract (decided policy)
The legacy fields written alongside `rrule` are the **wire format** for old clients and
the schedule for flag-off devices. The contract
(`rruleToLegacyTaskRepeatCfg`, decided 2026-06-11):
- **Within legacy expressiveness** → fields fire on the **same days** (modulo the two
documented divergence classes: WEEKLY `INTERVAL>1` week-grouping/WKST phase, and the
day>28 clamp-vs-skip edge — the lazy-migrate class).
- **Outside legacy expressiveness** (COUNT/UNTIL, seasonal `BYMONTH`, `BYWEEKNO` /
`BYYEARDAY`, multi-day lists, out-of-union ordinals, yearly weekday modes) → the
**never-fires sentinel** `LEGACY_NEVER_FIRES_FALLBACK` (`repeatCycle: 'WEEKLY'`, all
weekday flags `false` — deterministically dead on every released legacy engine, every
value wire-stable). Old/flag-off devices create **no** tasks rather than tasks on
wrong days that would sync back to every device. The dialog warns at authoring time
(`isRRuleLegacyRepresentable``RRULE_LEGACY_INCOMPAT`).
- Never a silent best-effort approximation in between.
## Dual-engine endgame (written down so the second engine cannot become immortal)
1. **Now (flag default-off):** lazy-migrate on edit — the dialog already converts
legacy `CUSTOM` cfgs in the builder, the migration rides a user-intent op, and the
preview shows the user the new schedule at exactly the moment semantics could shift.
2. **At flag default-on:** a one-time data-repair backfill of `rrule` for **only the
provably-lossless class** (`interval=1` — empirically exact and DST-proof). Never
bulk-convert the divergent classes; they lazy-migrate or age out.
3. **Then:** flag default-on → soak (one release minimum) → flag removed → legacy
engine deleted one release later.
4. **Forever:** legacy-field dual-write stays — it is the wire format for old clients,
cheap, and independent of the engine question.
5. **Post-legacy-engine fallback for an invalid `rrule`:** pause + repair prompt
(`isPaused` already exists on the model) — never silent rescheduling. Decided now so
deleting the legacy engine has no open design question attached.
## Flip gates — what must be green before flag default-on (and before engine deletion)
In priority order; #1 models the production failure mode and outranks the rest:
1. **Mixed-version convergence simulation** — two simulated devices sharing an op
stream, one routing RRULE and one legacy, driven through day rollovers; assert no
duplicate `rpt_<cfgId>_<day>` instances and no `lastTaskCreationDay` flip-flop
suppression.
2. **Differential fuzz spec** over the legacy cfg space (cycle × interval 16 ×
weekday masks incl. empty × monthly anchors × day-29/30/31 + Feb-29 starts),
comparing multi-year streams legacy vs converted-RRULE under both CI timezones —
with the known divergences pinned as **expected-output cases**, not excluded. Must
run against **all three calculators**, especially `getNewestPossibleDueDate` (what
task creation actually consumes).
3. **typia wire round-trip per producer path** (builder save, converter, backfill,
later REST/repair) against the released schema — guards the catastrophic mode
(out-of-union value → old-client data-repair, no rollback).
Note on the engine-robustness alternative considered and rejected (2026-06-11): a
bounded `between`/`until` probe **cannot** replace the `_canNeverFire` heuristics —
rrule.js checks `until` only against _emitted_ occurrences, so a never-firing rule
walks to year 275760 regardless (measured: 10.1 s with `UNTIL` set). The heuristics
stay; new BY-part interplay checks belong there (watchlist §5).
---

View file

@ -22,6 +22,9 @@ Note: Differences between the Web app and the Desktop app are specified in [[3.0
- **Show current task in the tray / status menu (macOS/Windows only)** — Allows
the desktop tray or status menu to show the active task. On macOS, the current
countdown setting takes priority while it is enabled.
- **Show recurring task activity when editing a recurring task config** — Opens
the "Edit recurring task" dialog with the Activity section (the series'
tracked-time heatmap) already expanded instead of collapsed.
#### global-settings.Short-Syntax

View file

@ -64,9 +64,10 @@ test.describe('Add Task Bar custom recurring option', () => {
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(dialog.locator('dialog-edit-task-repeat-cfg')).toBeVisible();
// Builder mode is preselected for the custom option; the live result band
// shows an assembled rule which saves as the task's rrule.
const expr = dialog.locator('.rrule-result__expr');
// Builder mode is preselected for the custom option; the assembled rule
// (under the builder's Advanced collapsible) saves as the task's rrule.
await dialog.locator('rrule-builder collapsible .collapsible-header').click();
const expr = dialog.locator('rrule-builder .rb-built__expr');
await expect(expr).toBeVisible({ timeout: 5000 });
const builtRule = (await expr.textContent())!.trim();
expect(builtRule).toMatch(/^FREQ=/);

View file

@ -5,7 +5,7 @@ import { type Locator, type Page } from '@playwright/test';
* Round-trip e2e coverage for the newer RRULE builder widgets: custom nth
* ordinals, the BYSETPOS multi-select toggles, mode-switch hygiene, and the
* yearly BYMONTH seeding. The dialog's live result band is the oracle:
* - `.rrule-result__expr` shows the exact assembled rrule string
* - `.rb-built__expr` (under Advanced > raw override) shows the exact assembled rrule string
* - `.rrule-result__next` shows engine-computed upcoming dates
* so recurrence semantics are assertable without waiting for real time to
* pass. Each test also reopens the dialog after save to verify the parsed
@ -62,7 +62,17 @@ const switchToBuilderMode = async (page: Page, dialog: Locator): Promise<void> =
.getByRole('option', { name: /custom recurring config/i })
.first()
.click();
await expect(dialog.locator('.rrule-result__expr')).toBeVisible({ timeout: 5000 });
await expect(dialog.locator('.rrule-result__human')).toBeVisible({ timeout: 5000 });
};
// The assembled-rule readout lives under the builder's Advanced collapsible
// (next to the raw override) — expand it and return the locator.
const openBuiltExpr = async (dialog: Locator): Promise<Locator> => {
const builder = dialog.locator('rrule-builder');
await builder.locator('collapsible .collapsible-header').click();
const expr = builder.locator('.rb-built__expr');
await expect(expr).toBeVisible({ timeout: 5000 });
return expr;
};
const addTaskWithDetailOpen = async (
@ -98,7 +108,7 @@ test.describe('RRULE builder round-trips', () => {
await switchToBuilderMode(page, dialog);
const builder = dialog.locator('rrule-builder');
const expr = dialog.locator('.rrule-result__expr');
const expr = await openBuiltExpr(dialog);
// MONTHLY → nth-weekday mode → switch the row's ordinal to custom → -2.
await builder.locator('select').nth(0).selectOption('MONTHLY');
@ -137,7 +147,7 @@ test.describe('RRULE builder round-trips', () => {
await switchToBuilderMode(page, dialog);
const builder = dialog.locator('rrule-builder');
const expr = dialog.locator('.rrule-result__expr');
const expr = await openBuiltExpr(dialog);
// MONTHLY → weekday-set mode (byDay pre-seeded with the start weekday).
await builder.locator('select').nth(0).selectOption('MONTHLY');
@ -169,7 +179,7 @@ test.describe('RRULE builder round-trips', () => {
await switchToBuilderMode(page, dialog);
const builder = dialog.locator('rrule-builder');
const expr = dialog.locator('.rrule-result__expr');
const expr = await openBuiltExpr(dialog);
// Weekday-set mode with a 'second' narrowing…
await builder.locator('select').nth(0).selectOption('MONTHLY');
@ -201,7 +211,7 @@ test.describe('RRULE builder round-trips', () => {
await switchToBuilderMode(page, dialog);
const builder = dialog.locator('rrule-builder');
const expr = dialog.locator('.rrule-result__expr');
const expr = await openBuiltExpr(dialog);
await builder.locator('select').nth(0).selectOption('YEARLY');
// Without BYMONTH a bare yearly BYMONTHDAY would fire EVERY month

3
package-lock.json generated
View file

@ -27,7 +27,7 @@
"hash-wasm": "^4.12.0",
"https-proxy-agent": "^7.0.0",
"node-fetch": "^2.7.0",
"rrule": "^2.8.1",
"rrule": "2.8.1",
"uuidv7": "^1.2.1"
},
"devDependencies": {
@ -22010,6 +22010,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View file

@ -193,7 +193,7 @@
"hash-wasm": "^4.12.0",
"https-proxy-agent": "^7.0.0",
"node-fetch": "^2.7.0",
"rrule": "^2.8.1",
"rrule": "2.8.1",
"uuidv7": "^1.2.1"
},
"devDependencies": {

View file

@ -47,6 +47,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
isTrayShowCurrent: true,
defaultProjectId: INBOX_PROJECT.id,
isMarkdownFormattingInNotesEnabled: true,
isExpandActivityForRepeatEdit: false,
notesTemplate: defaultTaskNotesTemplate,
},
misc: {

View file

@ -40,6 +40,13 @@ export const TASKS_SETTINGS_FORM_CFG: ConfigFormSection<TasksConfig> = {
label: T.GCF.TASKS.IS_MARKDOWN_FORMATTING_IN_NOTES_ENABLED,
},
},
{
key: 'isExpandActivityForRepeatEdit',
type: 'checkbox',
templateOptions: {
label: T.GCF.TASKS.IS_EXPAND_ACTIVITY_FOR_REPEAT_EDIT,
},
},
{
key: 'defaultProjectId',
type: 'project-select',

View file

@ -65,6 +65,8 @@ export type TasksConfig = Readonly<{
isConfirmBeforeDelete?: boolean;
isTrayShowCurrent: boolean;
isMarkdownFormattingInNotesEnabled: boolean;
// Open the "Activity" section expanded when editing a recurring task config.
isExpandActivityForRepeatEdit?: boolean;
defaultProjectId?: string | null | false; // allow 'false' because of #569
notesTemplate: string;
}>;

View file

@ -20,26 +20,19 @@
</div>
@if (heatmapData(); as data) {
@if (availableYears().length > 0) {
<div class="select-menu">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
>
<mat-label>{{ T.F.METRIC.CMP.YEAR | translate }}</mat-label>
<mat-select
[value]="selectedYear()"
(selectionChange)="onYearChange($event.value)"
>
@for (year of availableYears(); track year) {
<mat-option [value]="year">{{ year }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
<heatmap [data]="data" />
<heatmap-switcher
[data]="data"
[dayMap]="data.dayMap"
[rangeStart]="data.rangeStart"
[rangeEnd]="data.rangeEnd"
legendMode="intensity"
persistKey="metrics"
[navLabel]="selectedYear().toString()"
[canNavPrev]="canPrevYear()"
[canNavNext]="canNextYear()"
(navPrev)="prevYear()"
(navNext)="nextYear()"
/>
} @else {
<p>{{ T.F.METRIC.CMP.NO_ADDITIONAL_DATA_YET | translate }}</p>
}

View file

@ -1,6 +1,3 @@
.select-menu {
margin-bottom: var(--s2);
}
.activity-heatmap {
margin: var(--s3) 0;

View file

@ -122,7 +122,26 @@ describe('ActivityHeatmapComponent', () => {
TestBed.configureTestingModule({
imports: [ActivityHeatmapComponent],
providers: [
{ provide: DateAdapter, useValue: { getFirstDayOfWeek: () => 0 } },
{
provide: DateAdapter,
useValue: {
getFirstDayOfWeek: () => 0,
getMonthNames: () => [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
},
},
{ provide: SnackService, useValue: snackServiceSpy },
{ provide: ShareService, useValue: shareServiceSpy },
{ provide: WorkContextService, useValue: workContextServiceSpy },

View file

@ -11,22 +11,22 @@ import { WorkContextService } from '../../work-context/work-context.service';
import { combineLatestWith, map, tap } from 'rxjs/operators';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from '../../../t.const';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconButton } from '@angular/material/button';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltip } from '@angular/material/tooltip';
import { MatIcon } from '@angular/material/icon';
import { SnackService } from '../../../core/snack/snack.service';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { msToString } from '../../../ui/duration/ms-to-string.pipe';
import { ShareService } from '../../../core/share/share.service';
import { DayData, WeekData } from '../../../ui/heatmap/heatmap.component';
import { HeatmapSwitcherComponent } from '../../../ui/heatmap/heatmap-switcher.component';
import {
DayData,
WeekData,
HeatmapComponent,
} from '../../../ui/heatmap/heatmap.component';
buildHeatmapMonths,
buildHeatmapWeeks,
heatmapHoursTotal,
} from '../../../ui/heatmap/build-heatmap-data.util';
import { DateAdapter } from '@angular/material/core';
import { Worklog } from '../../worklog/worklog.model';
import { nextYearOf, prevYearOf } from '../../../ui/heatmap/year-nav.util';
interface YearlyActivityData {
dayMap: Map<string, DayData>;
@ -39,15 +39,7 @@ interface YearlyActivityData {
templateUrl: './activity-heatmap.component.html',
styleUrls: ['./activity-heatmap.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
HeatmapComponent,
TranslatePipe,
MatFormFieldModule,
MatIconButton,
MatSelectModule,
MatTooltip,
MatIcon,
],
imports: [HeatmapSwitcherComponent, TranslatePipe, MatIconButton, MatTooltip, MatIcon],
})
export class ActivityHeatmapComponent {
private readonly _worklogService = inject(WorklogService);
@ -68,7 +60,6 @@ export class ActivityHeatmapComponent {
return availableYears.length > 0 ? availableYears[0] : new Date().getFullYear();
});
T: typeof T = T;
weeks: WeekData[] = [];
isSharing = signal(false);
private readonly _activeWorkContextTitle = toSignal(
this._workContextService.activeWorkContextTitle$,
@ -79,9 +70,31 @@ export class ActivityHeatmapComponent {
this._userSelectedYear.set(year); // Only update user selection
}
// Day labels adjusted for first day of week
// Prev/next navigation over the years that actually have data
// (availableYears is sorted newest-first).
readonly canPrevYear = computed(
() => prevYearOf(this.availableYears(), this.selectedYear()) !== null,
);
readonly canNextYear = computed(
() => nextYearOf(this.availableYears(), this.selectedYear()) !== null,
);
prevYear(): void {
const y = prevYearOf(this.availableYears(), this.selectedYear());
if (y !== null) {
this._userSelectedYear.set(y);
}
}
nextYear(): void {
const y = nextYearOf(this.availableYears(), this.selectedYear());
if (y !== null) {
this._userSelectedYear.set(y);
}
}
// Day labels for the share-canvas export — localized via DateAdapter like the
// on-screen views, rotated so the locale's first weekday comes first.
readonly dayLabels = computed(() => {
const allDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const allDays = this._dateAdapter.getDayOfWeekNames('short');
const firstDay = this._dateAdapter.getFirstDayOfWeek();
return [...allDays.slice(firstDay), ...allDays.slice(0, firstDay)];
});
@ -112,13 +125,29 @@ export class ActivityHeatmapComponent {
return null;
}
// Rebuild the weeks grid with the current firstDayOfWeek setting
return this._buildWeeksGrid(
rawData.dayMap,
rawData.startDate,
rawData.endDate,
firstDay,
);
// Rebuild the weeks grid with the current firstDayOfWeek setting. `weeks`/
// `monthLabels` stay for the share-canvas export; `months` drives the
// month-grouped on-screen layout.
return {
...buildHeatmapWeeks(
rawData.dayMap,
rawData.startDate,
rawData.endDate,
firstDay,
this._dateAdapter.getMonthNames('short'),
),
months: buildHeatmapMonths(
rawData.dayMap,
rawData.startDate,
rawData.endDate,
firstDay,
this._dateAdapter.getMonthNames('short'),
heatmapHoursTotal,
),
dayMap: rawData.dayMap,
rangeStart: rawData.startDate,
rangeEnd: rawData.endDate,
};
});
private _buildHeatmapDataFromWorklog(
@ -205,111 +234,6 @@ export class ActivityHeatmapComponent {
};
}
private _buildWeeksGrid(
dayMap: Map<string, DayData>,
startDate: Date,
endDate: Date,
firstDayOfWeek: number = 0,
): { weeks: WeekData[]; monthLabels: string[] } {
const weeks: WeekData[] = [];
const monthLabels: string[] = [];
let currentMonth = -1;
// Find the first day (based on firstDayOfWeek setting) before or on the start date
const firstDay = new Date(startDate);
const dayOfWeek = firstDay.getDay();
// Calculate days to go back to reach the first day of the week
const daysToGoBack = (dayOfWeek - firstDayOfWeek + 7) % 7;
firstDay.setDate(firstDay.getDate() - daysToGoBack);
// Build weeks
const currentDate = new Date(firstDay);
let weekCount = 0;
while (currentDate <= endDate || weeks.length === 0) {
const week: WeekData = { days: [] };
// Add 7 days for this week
for (let i = 0; i < 7; i++) {
const dateStr = getDbDateStr(currentDate);
const dayData = dayMap.get(dateStr);
// Only include days within our range
if (currentDate >= startDate && currentDate <= endDate) {
week.days.push(dayData || null);
// Track month changes for labels
const month = currentDate.getMonth();
if (month !== currentMonth && currentDate.getDate() <= 7 && weekCount > 0) {
// Add month label at the start of the month
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
monthLabels.push(monthNames[month]);
currentMonth = month;
} else if (monthLabels.length === 0 && weekCount === 0) {
// Add first month
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
monthLabels.push(monthNames[month]);
currentMonth = month;
}
} else {
week.days.push(null);
}
currentDate.setDate(currentDate.getDate() + 1);
}
weeks.push(week);
weekCount++;
// Safety limit
if (weeks.length > 54) {
break;
}
}
return { weeks, monthLabels };
}
getDayClass(day: DayData | null): string {
if (!day) {
return 'day empty';
}
return `day level-${day.level}`;
}
getDayTitle(day: DayData | null): string {
if (!day) {
return '';
}
return `${day.dateStr}: ${day.taskCount} tasks, ${msToString(day.timeSpent)}`;
}
private _extractAvailableYearsFromWorklog(worklog: Worklog): number[] {
if (!worklog) return [];
const yearSet = new Set<number>();

View file

@ -59,7 +59,9 @@ section {
}
.heatmap-section {
max-width: 880px;
// Wide enough for the 12 month blocks side by side (the year strip), so the
// heatmap uses the page's spare width instead of scrolling horizontally.
max-width: 1200px;
margin: var(--s4) auto 0;
}

View file

@ -27,7 +27,6 @@ describe('buildRepeatQuickSettingOptions', () => {
new Date(2026, 5, 2),
'en-US',
translateService,
true,
);
expect(options.map((o) => o.value)).toEqual([
'DAILY',
@ -57,18 +56,6 @@ describe('buildRepeatQuickSettingOptions', () => {
expect(monthlyNthWeekdayOption?.label).not.toContain('F.TASK_REPEAT.F.ORD_FIRST:');
});
it('omits the RRULE option by default (fail-safe for callers that forget the param)', () => {
// The advanced builder is gated behind a per-device engine flag; a caller
// that doesn't opt in must not surface it on flag-off devices.
const options = buildRepeatQuickSettingOptions(
new Date(2026, 5, 2),
'en-US',
translateService,
);
expect(options.some((o) => o.value === 'RRULE')).toBe(false);
expect(options.length).toBe(15);
});
// Regression test for #7945: a Date the form's `defaultValue` left unparsed
// would reach `dateStrToUtcDate`, return Invalid Date, make weekOfMonth NaN,
// and `ORDINAL_KEYS[NaN-1]` → undefined → instant(undefined) threw.
@ -83,7 +70,6 @@ describe('buildRepeatQuickSettingOptions', () => {
new Date('Invalid Date'),
'en-US',
translateService,
true,
);
expect(options.length).toBe(16);
options.forEach((o) => expect(o.label).toBeTruthy());

View file

@ -1,6 +1,6 @@
import { TranslateService } from '@ngx-translate/core';
import { T } from '../../../t.const';
import { RepeatQuickSetting } from '../task-repeat-cfg.model';
import { RepeatQuickSetting, RRULE_QUICK_SETTING } from '../task-repeat-cfg.model';
const ORDINAL_KEYS = [
T.F.TASK_REPEAT.F.ORD_FIRST_NTH,
@ -13,24 +13,35 @@ export const buildRepeatQuickSettingOptions = (
refDate: Date,
locale: string,
translateService: TranslateService,
// The 'RRULE' (advanced recurrence) builder is gated behind a local per-device
// flag. When off, the option is omitted so users can't author rules the local
// engine would ignore — callers still pass `true` for a config already in
// RRULE mode so an existing one stays editable. Defaults to FALSE so a caller
// that forgets the param fails safe (no advanced option on flag-off devices).
includeRRule = false,
): { value: RepeatQuickSetting; label: string }[] => {
// Guard against an invalid Date slipping through (e.g. a non-DB date string).
// An invalid date makes the weekOfMonth math NaN, so ORDINAL_KEYS[NaN-1] is
// undefined and translate's `instant(undefined)` throws, crashing the whole
// dialog (#7945). Fall back to "today" so options still render.
const safeRefDate = isNaN(refDate.getTime()) ? new Date() : refDate;
const refWeekdayStr = safeRefDate.toLocaleDateString(locale, { weekday: 'long' });
const refDayStr = safeRefDate.toLocaleDateString(locale, { day: 'numeric' });
const refDayAndMonthStr = safeRefDate.toLocaleDateString(locale, {
// Compact forms for the concise preset labels ("Weekly (Mon)", "Monthly
// (15th)", "Yearly (Jun 15)", "Every 3 months (15th)", …).
const refWeekdayShortStr = safeRefDate.toLocaleDateString(locale, { weekday: 'short' });
const refMonthDayShortStr = safeRefDate.toLocaleDateString(locale, {
month: 'short',
day: 'numeric',
month: 'numeric',
});
// Locale-aware day ordinal (en: 15→"15th", 1→"1st"); plain number if the
// runtime can't form ordinals for the locale.
const refDayOrdinalStr = ((n: number): string => {
try {
const suffix: Record<string, string> = {
one: 'st',
two: 'nd',
few: 'rd',
other: 'th',
};
const cat = new Intl.PluralRules(locale, { type: 'ordinal' }).select(n);
return `${n}${suffix[cat] ?? 'th'}`;
} catch {
return String(n);
}
})(safeRefDate.getDate());
// 1-based occurrence of refDate's weekday within its month, capped to 4.
const weekOfMonth = Math.min(Math.floor((safeRefDate.getDate() - 1) / 7) + 1, 4);
const ordinalStr = translateService.instant(ORDINAL_KEYS[weekOfMonth - 1]);
@ -55,19 +66,19 @@ export const buildRepeatQuickSettingOptions = (
{
value: 'WEEKLY_CURRENT_WEEKDAY',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_WEEKLY_CURRENT_WEEKDAY, {
weekdayStr: refWeekdayStr,
weekdayStr: refWeekdayShortStr,
}),
},
{
value: 'BIWEEKLY_CURRENT_WEEKDAY',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_BIWEEKLY_CURRENT_WEEKDAY, {
weekdayStr: refWeekdayStr,
weekdayStr: refWeekdayShortStr,
}),
},
{
value: 'MONTHLY_CURRENT_DATE',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_MONTHLY_CURRENT_DATE, {
dateDayStr: refDayStr,
dateDayStr: refDayOrdinalStr,
}),
},
{
@ -82,47 +93,49 @@ export const buildRepeatQuickSettingOptions = (
value: 'MONTHLY_NTH_WEEKDAY',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_MONTHLY_NTH_WEEKDAY, {
ordinalStr,
weekdayStr: refWeekdayStr,
weekdayStr: refWeekdayShortStr,
}),
},
{
value: 'MONTHLY_LAST_WEEKDAY',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_MONTHLY_LAST_WEEKDAY, {
weekdayStr: refWeekdayStr,
weekdayStr: refWeekdayShortStr,
}),
},
{
value: 'QUARTERLY_CURRENT_DATE',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_QUARTERLY_CURRENT_DATE, {
dateDayStr: refDayStr,
dateDayStr: refDayOrdinalStr,
}),
},
{
value: 'SEMIANNUALLY_CURRENT_DATE',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_SEMIANNUALLY_CURRENT_DATE, {
dateDayStr: refDayStr,
dateDayStr: refDayOrdinalStr,
}),
},
{
value: 'YEARLY_CURRENT_DATE',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_YEARLY_CURRENT_DATE, {
dayAndMonthStr: refDayAndMonthStr,
dayAndMonthStr: refMonthDayShortStr,
}),
},
{
value: 'EVERY_OTHER_YEAR_CURRENT_DATE',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_EVERY_OTHER_YEAR_CURRENT_DATE, {
dayAndMonthStr: refDayAndMonthStr,
dayAndMonthStr: refMonthDayShortStr,
}),
},
];
if (includeRRule) {
options.push({
value: 'RRULE',
label: translateService.instant(T.F.TASK_REPEAT.F.Q_RRULE),
});
}
// The builder mode replaced the legacy "Custom" UI, so it must always be on
// offer regardless of the per-device RRULE engine flag — flag-off devices
// schedule from the legacy mirror fields the dialog persists alongside the
// rule (rruleToLegacyTaskRepeatCfg), the same fallback old sync clients use.
options.push({
value: RRULE_QUICK_SETTING,
label: translateService.instant(T.F.TASK_REPEAT.F.Q_RRULE),
});
return options;
};

View file

@ -9,21 +9,19 @@
? (T.F.TASK_REPEAT.D_EDIT.EDIT | translate)
: (T.F.TASK_REPEAT.D_EDIT.ADD | translate)
}}
<button
mat-icon-button
type="button"
class="fullscreen-toggle"
[title]="T.G.TOGGLE_FULLSCREEN | translate"
[attr.aria-label]="T.G.TOGGLE_FULLSCREEN | translate"
(click)="toggleFullScreen()"
>
<mat-icon>{{ isFullScreen() ? 'fullscreen_exit' : 'fullscreen' }}</mat-icon>
</button>
</h1>
<mat-dialog-content>
@if (repeatCfgId(); as cfgId) {
<collapsible
[title]="T.F.TASK_REPEAT.D_EDIT.HEATMAP_LABEL | translate"
icon="bar_chart"
(isExpandedChange)="isHeatmapExpanded = $event"
>
@if (isHeatmapExpanded) {
<repeat-task-heatmap [repeatCfgId]="cfgId" />
}
</collapsible>
}
<div class="form-wrapper">
<formly-form
(modelChange)="repeatCfg.set($event)"
@ -32,16 +30,657 @@
[model]="repeatCfg()"
></formly-form>
<!-- The recurrence controls (Repeat / Every / On / Ends / Schedule type)
come FIRST; the calendar below previews the rule they define. -->
<repeat-freq-picker
[label]="T.F.TASK_REPEAT.F.QUICK_SETTING | translate"
[options]="quickSettingOptions()"
[commonValues]="quickSettingCommon"
[value]="repeatCfg().quickSetting"
(selected)="onQuickSettingSelect($event)"
></repeat-freq-picker>
@if (isRRuleMode()) {
<!-- Custom mode: the builder hosts the calendar in its projection slot
(between On and Ends), so Ends / Schedule type sit BELOW it. -->
<rrule-builder
[rrule]="repeatCfg().rrule ?? ''"
[startDate]="repeatCfg().startDate"
[repeatFromCompletion]="repeatCfg().repeatFromCompletionDate ?? false"
(rruleChange)="onRRuleChange($event)"
(repeatFromCompletionChange)="onRepeatFromCompletionChange($event)"
></rrule-builder>
>
<ng-container [ngTemplateOutlet]="cal"></ng-container>
<!-- Projected into the TOP of the builder's "Advanced rule" section —
Ends + Schedule type are advanced, Custom-only settings. -->
<div
recurrenceOptions
class="rrule-ends"
>
<div class="rrule-ends__field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_END | translate }}</label>
<div class="rrule-ends__control">
<segmented-button-group
[options]="endOptions"
[selectedId]="endType()"
[ariaLabel]="T.F.TASK_REPEAT.F.RRULE_END | translate"
size="md"
(selectionChange)="onEndTypeChange($event)"
></segmented-button-group>
@if (endType() === 'COUNT') {
<input
class="rrule-ends__num"
type="number"
min="1"
max="1000"
[value]="endCount()"
(input)="setEndCount($any($event.target).value)"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_END_COUNT | translate"
/>
}
@if (endType() === 'UNTIL') {
<input
class="rrule-ends__date"
type="date"
[value]="endUntil()"
(input)="setEndUntil($any($event.target).value)"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_END_UNTIL | translate"
/>
}
</div>
</div>
<div class="rrule-ends__field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE | translate }}</label>
<div class="rrule-ends__control">
<segmented-button-group
[options]="scheduleTypeOptions"
[selectedId]="scheduleSelectedId()"
[ariaLabel]="T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE | translate"
size="md"
(selectionChange)="onScheduleTypeChange($event)"
></segmented-button-group>
</div>
</div>
<div class="rrule-ends__hint">
{{
(scheduleSelectedId() === 'COMPLETION'
? T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESC_COMPLETION
: T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESC_FIXED
) | translate
}}
</div>
</div>
</rrule-builder>
} @else {
<!-- Preset mode: no builder, so the calendar renders standalone. -->
<ng-container [ngTemplateOutlet]="cal"></ng-container>
}
<!-- Start-date picker + occurrence calendar — the start picker for EVERY
repeat mode (click a day = set start; double-click = simulate). In
RRULE mode it's projected into the builder between On and Ends; for
presets it renders standalone. The textual summary stays in the
sticky strip at the bottom. -->
<ng-template #cal>
@if (hasRule()) {
@if (resultHeatmapData(); as hd) {
<div class="rrule-cal">
@if (startParts(); as sp) {
<div class="rrule-cal__start">
<span class="rrule-cal__start-label"
>{{ T.F.TASK_REPEAT.F.START_DATE | translate }}:</span
>
<div class="rrule-cal__start-fields">
<select
class="rrule-cal__start-field rrule-cal__start-month"
(change)="setStartMonth(+$any($event.target).value)"
[attr.aria-label]="T.F.TASK_REPEAT.F.START_DATE | translate"
>
@for (mo of monthOptions(); track mo.value) {
<option
[value]="mo.value"
[selected]="mo.value === sp.m"
>
{{ mo.name }}
</option>
}
</select>
<input
class="rrule-cal__start-field rrule-cal__start-day"
type="number"
min="1"
max="31"
[value]="sp.d"
(change)="setStartDay(+$any($event.target).value)"
[attr.aria-label]="T.F.TASK_REPEAT.F.START_DATE | translate"
/>
<input
class="rrule-cal__start-field rrule-cal__start-year"
type="number"
min="1970"
max="2999"
[value]="sp.y"
(change)="setStartYear(+$any($event.target).value)"
[attr.aria-label]="T.F.TASK_REPEAT.F.START_DATE | translate"
/>
</div>
</div>
}
@if (hasActivity()) {
<div class="rrule-cal__activity">
<label class="rrule-cal__activity-toggle">
<input
type="checkbox"
[checked]="showActivity()"
(change)="showActivity.set($any($event.target).checked)"
/>
{{ T.F.TASK_REPEAT.D_EDIT.HEATMAP_LABEL | translate }}
</label>
@if (showActivity() && activitySummary(); as a) {
<span class="rrule-cal__activity-summary">
{{ T.F.TASK_REPEAT.D_EDIT.TIME_SPENT_TOTAL | translate }}:
{{ a.total }}
<span class="rrule-result__sep">&middot;</span>
{{ T.F.TASK_REPEAT.D_EDIT.TIME_SPENT_THIS_WEEK | translate }}:
{{ a.thisWeek }}
<span class="rrule-result__sep">&middot;</span>
{{ T.F.TASK_REPEAT.D_EDIT.TIME_SPENT_THIS_MONTH | translate }}:
{{ a.thisMonth }}
</span>
}
</div>
}
<heatmap-switcher
class="rrule-cal__switcher"
[data]="hd"
[dayMap]="hd.dayMap"
[rangeStart]="hd.rangeStart"
[rangeEnd]="hd.rangeEnd"
legendMode="projection"
[initialView]="'month'"
[showActivity]="showActivity() && hasActivity()"
[interactive]="true"
[interactiveMenus]="true"
[weekdayHeaderGlyphs]="weekdayHeaderGlyphs()"
[weekdayHeaderTooltips]="weekdayHeaderTooltips()"
[monthTooltip]="monthTooltip()"
[limitedMonths]="limitedMonths()"
[focusDate]="focusStart()"
[navLabel]="previewNavLabel()"
[canNavPrev]="true"
[canNavNext]="true"
[monthBoundless]="true"
[showWeekends]="true"
(navPrev)="previewPrevYear()"
(navNext)="previewNextYear()"
(monthViewChange)="onPreviewMonthChange($event)"
(dayMenu)="onPreviewDayMenu($event)"
(weekdayHeaderMenu)="onWeekdayHeaderMenu($event)"
(monthLabelMenu)="onMonthLabelMenu($event)"
/>
@if (weekdayHeaderGlyphs().size) {
<div class="rrule-cal__glyph-hint">
{{ T.F.TASK_REPEAT.F.CAL_GLYPH_HINT | translate }}
</div>
}
@if (previewWindowEmpty()) {
<div class="rrule-cal__hint">
{{ T.F.TASK_REPEAT.F.RRULE_PREVIEW_EMPTY_WINDOW | translate }}
</div>
}
@if (simulatedCompletion(); as sim) {
<div class="rrule-cal__sim">
{{ T.F.TASK_REPEAT.F.RRULE_SIM_LABEL | translate }} {{ sim }}
<button
type="button"
class="rrule-cal__sim-reset"
(click)="clearSimulation()"
>
{{ T.F.TASK_REPEAT.F.RRULE_SIM_RESET | translate }}
</button>
</div>
}
</div>
} @else {
<div class="rrule-cal__hint">
{{ T.F.TASK_REPEAT.F.RRULE_PREVIEW_INVALID | translate }}
</div>
}
}
</ng-template>
<!-- Calendar context menus (hidden triggers positioned at the click point;
opened programmatically from the calendar's menu outputs). Each action
edits the SAME rule via rrule-calendar-ops + onRRuleChange. -->
<div
#dayMenuTrigger
class="cal-menu-anchor"
[style.left]="menuPos().x"
[style.top]="menuPos().y"
[matMenuTriggerFor]="dayMenu"
></div>
<!-- Day menu. Native options for the current frequency sit at the top; the
"On this day of the month/year" options that would SWITCH the frequency
are grouped under a per-target-freq icon button in the bottom row. -->
<mat-menu #dayMenu="matMenu">
<button
mat-menu-item
type="button"
(click)="menuSetStart()"
>
<mat-icon>flag</mat-icon>
{{ T.F.TASK_REPEAT.F.CAL_MENU_SET_START | translate }}
</button>
@if (menuDayAfterStart()) {
<button
mat-menu-item
type="button"
(click)="menuEndsOn()"
>
<mat-icon>event_busy</mat-icon>
{{
(menuDayIsEnd()
? T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_ENDS
: T.F.TASK_REPEAT.F.CAL_MENU_ENDS_ON
) | translate
}}
</button>
}
@if (isMonthlyRule()) {
<button
mat-menu-item
type="button"
(click)="menuToggleMonthDay()"
>
<mat-icon>calendar_view_month</mat-icon>
{{
(menuDayMonthActive()
? T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_DAY_OF_MONTH
: T.F.TASK_REPEAT.F.CAL_MENU_DAY_OF_MONTH
) | translate
}}
</button>
}
@if (isYearlyRule()) {
<button
mat-menu-item
type="button"
(click)="menuSetYearDay()"
>
<mat-icon>calendar_month</mat-icon>
{{
(menuDayYearActive()
? T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_DAY_OF_YEAR
: T.F.TASK_REPEAT.F.CAL_MENU_DAY_OF_YEAR
) | translate
}}
</button>
}
@if (menuDaySimAllowed() || menuDayIsSim()) {
<button
mat-menu-item
type="button"
(click)="menuSimulate()"
>
<mat-icon>science</mat-icon>
{{
(menuDayIsSim()
? T.F.TASK_REPEAT.F.CAL_MENU_STOP_SIMULATE
: T.F.TASK_REPEAT.F.CAL_MENU_SIMULATE
) | translate
}}
</button>
}
@if (!isMonthlyRule() || !isYearlyRule()) {
<div class="cal-menu-switch">
@if (!isMonthlyRule()) {
<button
mat-icon-button
type="button"
class="cal-menu-switch__btn"
[class.is-active]="expandedSwitch() === 'MONTHLY'"
[matTooltip]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_MONTHLY | translate"
[attr.aria-label]="
T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_MONTHLY | translate
"
[attr.aria-expanded]="expandedSwitch() === 'MONTHLY'"
(click)="toggleSwitchExpand('MONTHLY'); $event.stopPropagation()"
>
<mat-icon>calendar_view_month</mat-icon>
</button>
}
@if (!isYearlyRule()) {
<button
mat-icon-button
type="button"
class="cal-menu-switch__btn"
[class.is-active]="expandedSwitch() === 'YEARLY'"
[matTooltip]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_YEARLY | translate"
[attr.aria-label]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_YEARLY | translate"
[attr.aria-expanded]="expandedSwitch() === 'YEARLY'"
(click)="toggleSwitchExpand('YEARLY'); $event.stopPropagation()"
>
<mat-icon>calendar_month</mat-icon>
</button>
}
</div>
@if (expandedSwitch() === 'MONTHLY') {
<button
mat-menu-item
type="button"
(click)="menuToggleMonthDay()"
>
<mat-icon>calendar_view_month</mat-icon>
{{ T.F.TASK_REPEAT.F.CAL_MENU_DAY_OF_MONTH | translate }}
</button>
}
@if (expandedSwitch() === 'YEARLY') {
<button
mat-menu-item
type="button"
(click)="menuSetYearDay()"
>
<mat-icon>calendar_month</mat-icon>
{{ T.F.TASK_REPEAT.F.CAL_MENU_DAY_OF_YEAR | translate }}
</button>
}
}
</mat-menu>
<div
#weekdayMenuTrigger
class="cal-menu-anchor"
[style.left]="menuPos().x"
[style.top]="menuPos().y"
[matMenuTriggerFor]="weekdayMenu"
></div>
<!-- Weekday-header menu. Native weekday options for the current frequency at
the top; the options that SWITCH frequency live under a per-target-freq
icon button in the bottom row (each opening the same Selected-days / Nth
sub-menu for that frequency). -->
<mat-menu #weekdayMenu="matMenu">
<!-- Native options are INLINE (not via *ngTemplateOutlet): MatMenu focuses
its first registered item on open, and template-projected items
register AFTER static ones — which made the focus land on a switch
icon below, painting a stray state-layer circle on it. Inline = static
= first, so focus lands on this text item as expected. -->
@if (isWeeklyRule()) {
<button
mat-menu-item
type="button"
(click)="menuToggleSelectedDay('WEEKLY')"
>
<mat-icon>calendar_view_week</mat-icon>
{{
(menuWeekdaySelectedActive()
? T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_SELECTED_DAYS
: T.F.TASK_REPEAT.F.CAL_MENU_SELECTED_DAYS
) | translate
}}
</button>
}
@if (isMonthlyRule()) {
<button
mat-menu-item
type="button"
(click)="menuToggleSelectedDay('MONTHLY')"
>
<mat-icon>calendar_view_week</mat-icon>
{{
(menuWeekdaySelectedActive()
? T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_SELECTED_DAYS
: T.F.TASK_REPEAT.F.CAL_MENU_SELECTED_DAYS
) | translate
}}
</button>
<button
mat-menu-item
type="button"
[matMenuTriggerFor]="nthMenu"
(menuOpened)="menuNthFreq.set('MONTHLY')"
>
<mat-icon>format_list_numbered</mat-icon>
{{ T.F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY | translate }}
</button>
}
@if (isYearlyRule()) {
<button
mat-menu-item
type="button"
(click)="menuToggleSelectedDay('YEARLY')"
>
<mat-icon>calendar_view_week</mat-icon>
{{
(menuWeekdaySelectedActive()
? T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_SELECTED_DAYS
: T.F.TASK_REPEAT.F.CAL_MENU_SELECTED_DAYS
) | translate
}}
</button>
<button
mat-menu-item
type="button"
[matMenuTriggerFor]="nthMenu"
(menuOpened)="menuNthFreq.set('YEARLY')"
>
<mat-icon>format_list_numbered</mat-icon>
{{ T.F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY | translate }}
</button>
}
<div class="cal-menu-switch">
@if (!isWeeklyRule()) {
<button
mat-icon-button
type="button"
class="cal-menu-switch__btn"
[class.is-active]="expandedSwitch() === 'WEEKLY'"
[matTooltip]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_WEEKLY | translate"
[attr.aria-label]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_WEEKLY | translate"
[attr.aria-expanded]="expandedSwitch() === 'WEEKLY'"
(click)="toggleSwitchExpand('WEEKLY'); $event.stopPropagation()"
>
<mat-icon>calendar_view_week</mat-icon>
</button>
}
@if (!isMonthlyRule()) {
<button
mat-icon-button
type="button"
class="cal-menu-switch__btn"
[class.is-active]="expandedSwitch() === 'MONTHLY'"
[matTooltip]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_MONTHLY | translate"
[attr.aria-label]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_MONTHLY | translate"
[attr.aria-expanded]="expandedSwitch() === 'MONTHLY'"
(click)="toggleSwitchExpand('MONTHLY'); $event.stopPropagation()"
>
<mat-icon>calendar_view_month</mat-icon>
</button>
}
@if (!isYearlyRule()) {
<button
mat-icon-button
type="button"
class="cal-menu-switch__btn"
[class.is-active]="expandedSwitch() === 'YEARLY'"
[matTooltip]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_YEARLY | translate"
[attr.aria-label]="T.F.TASK_REPEAT.F.CAL_MENU_SWITCHES_YEARLY | translate"
[attr.aria-expanded]="expandedSwitch() === 'YEARLY'"
(click)="toggleSwitchExpand('YEARLY'); $event.stopPropagation()"
>
<mat-icon>calendar_month</mat-icon>
</button>
}
</div>
<!-- Clicking a switch icon expands that frequency's options INLINE here
(no hover-opened sub-menu); clicking it again collapses them. -->
@if (expandedSwitch() === 'WEEKLY') {
<button
mat-menu-item
type="button"
(click)="menuToggleSelectedDay('WEEKLY')"
>
<mat-icon>calendar_view_week</mat-icon>
{{ T.F.TASK_REPEAT.F.CAL_MENU_SELECTED_DAYS | translate }}
</button>
}
@if (expandedSwitch() === 'MONTHLY') {
<button
mat-menu-item
type="button"
(click)="menuToggleSelectedDay('MONTHLY')"
>
<mat-icon>calendar_view_week</mat-icon>
{{ T.F.TASK_REPEAT.F.CAL_MENU_SELECTED_DAYS | translate }}
</button>
<button
mat-menu-item
type="button"
[matMenuTriggerFor]="nthMenu"
(menuOpened)="menuNthFreq.set('MONTHLY')"
>
<mat-icon>format_list_numbered</mat-icon>
{{ T.F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY | translate }}
</button>
}
@if (expandedSwitch() === 'YEARLY') {
<button
mat-menu-item
type="button"
(click)="menuToggleSelectedDay('YEARLY')"
>
<mat-icon>calendar_view_week</mat-icon>
{{ T.F.TASK_REPEAT.F.CAL_MENU_SELECTED_DAYS | translate }}
</button>
<button
mat-menu-item
type="button"
[matMenuTriggerFor]="nthMenu"
(menuOpened)="menuNthFreq.set('YEARLY')"
>
<mat-icon>format_list_numbered</mat-icon>
{{ T.F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY | translate }}
</button>
}
</mat-menu>
<!-- nth-ordinal items toggle: an ordinal already holding this weekday flips
to "Remove <ordinal>" (the ordinal label nested into the remove string). -->
<mat-menu #nthMenu="matMenu">
<button
mat-menu-item
type="button"
(click)="menuToggleNth(1, menuNthFreq())"
>
<mat-icon>looks_one</mat-icon>
@if (menuNthActive(1)) {
{{
T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_NTH
| translate: { ordinal: (T.F.TASK_REPEAT.F.ORD_FIRST | translate) }
}}
} @else {
{{ T.F.TASK_REPEAT.F.ORD_FIRST | translate }}
}
</button>
<button
mat-menu-item
type="button"
(click)="menuToggleNth(2, menuNthFreq())"
>
<mat-icon>looks_two</mat-icon>
@if (menuNthActive(2)) {
{{
T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_NTH
| translate: { ordinal: (T.F.TASK_REPEAT.F.ORD_SECOND | translate) }
}}
} @else {
{{ T.F.TASK_REPEAT.F.ORD_SECOND | translate }}
}
</button>
<button
mat-menu-item
type="button"
(click)="menuToggleNth(3, menuNthFreq())"
>
<mat-icon>looks_3</mat-icon>
@if (menuNthActive(3)) {
{{
T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_NTH
| translate: { ordinal: (T.F.TASK_REPEAT.F.ORD_THIRD | translate) }
}}
} @else {
{{ T.F.TASK_REPEAT.F.ORD_THIRD | translate }}
}
</button>
<button
mat-menu-item
type="button"
(click)="menuToggleNth(4, menuNthFreq())"
>
<mat-icon>looks_4</mat-icon>
@if (menuNthActive(4)) {
{{
T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_NTH
| translate: { ordinal: (T.F.TASK_REPEAT.F.ORD_FOURTH | translate) }
}}
} @else {
{{ T.F.TASK_REPEAT.F.ORD_FOURTH | translate }}
}
</button>
<button
mat-menu-item
type="button"
(click)="menuToggleNth(-1, menuNthFreq())"
>
<mat-icon>last_page</mat-icon>
@if (menuNthActive(-1)) {
{{
T.F.TASK_REPEAT.F.CAL_MENU_REMOVE_NTH
| translate: { ordinal: (T.F.TASK_REPEAT.F.ORD_LAST | translate) }
}}
} @else {
{{ T.F.TASK_REPEAT.F.ORD_LAST | translate }}
}
</button>
</mat-menu>
<div
#monthMenuTrigger
class="cal-menu-anchor"
[style.left]="menuPos().x"
[style.top]="menuPos().y"
[matMenuTriggerFor]="monthMenu"
></div>
<mat-menu #monthMenu="matMenu">
<button
mat-menu-item
type="button"
(click)="menuToggleMonth()"
>
<mat-icon>filter_alt</mat-icon>
@if (menuMonthActive()) {
{{
T.F.TASK_REPEAT.F.CAL_MENU_UNLIMIT_MONTH
| translate: { month: menuMonthName() }
}}
} @else {
{{
T.F.TASK_REPEAT.F.CAL_MENU_LIMIT_MONTH
| translate: { month: menuMonthName() }
}}
}
</button>
@if (hasMonthLimits()) {
<button
mat-menu-item
type="button"
(click)="menuClearMonthLimits()"
>
<mat-icon>filter_alt_off</mat-icon>
{{
T.F.TASK_REPEAT.F.CAL_MENU_CLEAR_MONTHS
| translate: { months: limitedMonthNames() }
}}
</button>
}
</mat-menu>
<collapsible [title]="T.F.TASK_REPEAT.D_EDIT.ADVANCED_CFG | translate">
<chip-list-input
(addItem)="addTag($event)"
@ -62,29 +701,38 @@
</div>
</mat-dialog-content>
<!-- Live result/preview — pinned above the action buttons in RRULE mode. -->
<!-- Natural-language summary of the rule — Custom (RRULE) mode only, kept in a
sticky strip above the actions so it stays in view as the form scrolls. -->
@if (isRRuleMode() && rrulePreview(); as p) {
<div class="rrule-result">
<div class="rrule-result__human">&rarr; {{ p.human }}</div>
@if (repeatCfg().repeatFromCompletionDate) {
@if (p.completionExample; as ex) {
<div class="rrule-result__next">
{{ T.F.TASK_REPEAT.F.RRULE_NEXT_AFTER_COMPLETION | translate }}:
{{ ex.done | date: 'mediumDate' }} &rarr; {{ ex.next | date: 'mediumDate' }}
</div>
}
} @else if (p.upcoming.length) {
<div class="rrule-result__next">
{{ T.F.TASK_REPEAT.F.RRULE_NEXT | translate }}:
@for (d of p.upcoming; track d.getTime(); let last = $last) {
<span>{{ d | date: 'mediumDate' }}</span>
@if (!last) {
<span class="rrule-result__sep">&middot;</span>
@if (nextOccurrence(); as n) {
<div class="rrule-result__next-chip">
<mat-icon>schedule</mat-icon>
<span class="rrule-result__next-date"
>{{ T.F.TASK_REPEAT.F.RRULE_PREVIEW_NEXT | translate }}:
{{ n.date | date: 'mediumDate' }}</span
>
<span class="rrule-result__next-away">
@if (n.daysAway <= 0) {
{{ T.G.HEATMAP_TODAY | translate }}
} @else {
{{ T.G.HEATMAP_IN_DAYS | translate: { nr: n.daysAway } }}
}
}
</span>
</div>
}
@if (rruleLegacyIncompat()) {
<div class="rrule-result__legacy-warning">
{{ T.F.TASK_REPEAT.F.RRULE_LEGACY_INCOMPAT | translate }}
</div>
}
@if (repeatCfg().repeatFromCompletionDate && p.completionExample; as ex) {
<div class="rrule-result__next">
{{ T.F.TASK_REPEAT.F.RRULE_NEXT_AFTER_COMPLETION | translate }}:
{{ ex.done | date: 'mediumDate' }} &rarr; {{ ex.next | date: 'mediumDate' }}
</div>
}
<div class="rrule-result__expr">{{ p.rrule }}</div>
</div>
}

View file

@ -2,6 +2,16 @@ h1 mat-icon {
transform: rotate(45deg);
}
h1[mat-dialog-title] {
display: flex;
align-items: center;
gap: var(--s-half);
}
.fullscreen-toggle {
margin-left: auto;
}
// Bound the whole dialog to the viewport and lay it out as a flex column so
// the scrollable form and the pinned result band share the available height
// instead of overflowing past the action buttons.
@ -11,6 +21,20 @@ h1 mat-icon {
max-height: 85vh;
}
// Fullscreen state (`dialog-fullscreen` panel class on the overlay pane): the
// pane is viewport-sized, so the wrapper stretches to fill it and the 85vh cap
// no longer applies.
:host-context(.dialog-fullscreen) .dialog-help-wrapper {
height: 100%;
max-height: none;
}
// In fullscreen the result band (calendar preview) fills the surface that's
// the width the expand was for. The form column above keeps its 600px cap.
:host-context(.dialog-fullscreen) .rrule-result {
max-width: none;
}
mat-dialog-content {
flex: 1 1 auto;
// Let the flex parent govern height instead of Material's fixed 65vh so the
@ -53,27 +77,355 @@ mat-dialog-actions {
}
}
// Centered content column. The dialog surface itself widens dynamically with the
// viewport (`.dialog-recurring`, see _overwrite-material.scss); the form fills it
// up to a cap so the controls stay readable even when fullscreen makes the
// surface very wide (the calendar preview below is what uses that extra width).
.form-wrapper {
min-width: 280px;
max-width: 620px;
width: 100%;
min-width: 0;
max-width: 600px;
margin: 0 auto;
}
// Live RRULE result/preview, pinned above the dialog action buttons.
.rrule-result {
// Divider under the Repeat picker so the recurrence flow reads as its own
// clean section above the builder.
repeat-freq-picker {
display: block;
padding-bottom: var(--s);
margin-bottom: var(--s);
border-bottom: 1px solid var(--separator-color, var(--extra-border-color));
}
// Empty-state hint inside the result band (shown when no rule is assembled yet).
.rrule-result__empty-hint {
font-size: 12px;
opacity: 0.7;
text-align: center;
margin-top: var(--s-half);
}
// Start-date picker + occurrence calendar, placed inline ABOVE the repeat
// dropdown. No card framing / accent bar (unlike the bottom summary strip) it
// reads as part of the form, not a pinned result.
.rrule-cal {
margin: var(--s) 0;
// Start-date editor: Month / Day / Year fields, the precise alternative to
// clicking a day in the calendar below (both write the same start date).
&__start {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s-half);
margin-bottom: var(--s-half);
font-size: 12px;
}
&__start-label {
font-weight: 600;
}
&__start-fields {
display: inline-flex;
align-items: center;
gap: var(--s-quarter, 4px);
}
&__start-field {
padding: 3px 6px;
border: 1px solid var(--separator-color, var(--extra-border-color));
border-radius: var(--card-border-radius);
background: var(--bg-lightest, transparent);
color: var(--text-color);
font: inherit;
font-size: 12px;
&:focus {
outline: none;
border-color: var(--c-primary);
}
}
// Drop the native number spinners on the day/year fields the calendar below
// is the spinner; the bare number reads cleaner.
&__start-day,
&__start-year {
-moz-appearance: textfield;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
&__start-day {
width: 2.6em;
text-align: center;
}
&__start-year {
width: 3.6em;
text-align: center;
}
&__start-month {
max-width: 9em;
}
&__pick-tap {
opacity: 0.6;
}
&__activity {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s);
margin-bottom: var(--s-half);
font-size: 12px;
}
&__activity-toggle {
display: inline-flex;
align-items: center;
gap: var(--s-quarter, 4px);
cursor: pointer;
}
&__activity-summary {
opacity: 0.75;
}
&__hint {
margin-top: var(--s-half);
font-size: 12px;
opacity: 0.7;
text-align: center;
}
// Explains the weekday-header annotation glyphs (nth / selected / in-months).
&__glyph-hint {
margin-top: var(--s-quarter, 4px);
font-size: 11px;
opacity: 0.6;
text-align: center;
}
&__sim {
margin-top: var(--s-half);
font-size: 12px;
text-align: center;
}
&__sim-reset {
margin-left: var(--s-half);
border: none;
background: transparent;
color: var(--c-primary);
font: inherit;
text-decoration: underline;
cursor: pointer;
}
}
// Ends + Schedule type schedule-level controls shown below the calendar for
// every mode (presets too). Centered, label + control on one line each.
.rrule-ends {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-half);
margin: var(--s) 0;
&__field {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--s);
> label {
font-size: 13px;
font-weight: 600;
opacity: 0.75;
}
}
&__control {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--s-half);
}
&__num,
&__date {
height: 34px;
padding: 0 var(--s);
border: 1px solid var(--separator-color, var(--extra-border-color));
border-radius: var(--card-border-radius);
background: var(--bg-lightest, transparent);
color: var(--text-color);
font: inherit;
font-size: 13px;
}
&__num {
width: 4.5em;
text-align: center;
}
&__hint {
max-width: 520px;
font-size: 12px;
opacity: 0.6;
text-align: center;
}
}
// Bottom "switch to …" icon row inside a calendar context menu: the options that
// would CHANGE the frequency are grouped under a per-target-freq icon button,
// laid on one line. Global (not scoped) because MatMenu renders in a CDK overlay
// outside this component's view.
::ng-deep .cal-menu-switch {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--s-quarter, 4px);
padding: var(--s-quarter, 4px) var(--s, 8px);
margin-top: var(--s-quarter, 4px);
border-top: 1px solid var(--separator-color, var(--extra-border-color));
// A menu with no native top items (e.g. a weekday click in Daily mode) is all
// switch icons no border then, it would float above nothing.
&:first-child {
margin-top: 0;
border-top: none;
}
// The switch icons are mat-icon-buttons (NOT mat-menu-items): clicking one
// toggles its options open/closed INLINE no hover-opened sub-menu, and a
// plain icon-button isn't auto-focused so the menu never paints a stray
// focus circle on it.
.cal-menu-switch__btn {
min-width: 0;
min-height: 0;
width: 40px;
height: 40px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
overflow: hidden; // clip the ripple / hover state-layer to the circle
mat-icon {
margin: 0;
color: var(--text-color-muted, currentColor);
}
// Expanded highlighted so it's clear which frequency's options are showing.
&.is-active {
background: color-mix(in srgb, var(--c-accent) 20%, transparent);
mat-icon {
color: var(--c-accent);
}
}
}
}
// Zero-size fixed anchors for the calendar context menus positioned at the
// click point; the MatMenu opens from here.
.cal-menu-anchor {
position: fixed;
width: 0;
height: 0;
visibility: hidden;
pointer-events: none;
}
// Live recurrence summary card, pinned above the action buttons (outside the
// scroll area), so the evaluated rule stays in view while the form scrolls.
.rrule-result {
flex: 0 0 auto;
width: 100%;
max-width: 600px;
margin: var(--s) auto 0;
padding: var(--s) var(--s2);
border-radius: var(--card-border-radius);
background: var(--extra-border-color);
border-radius: 10px;
background: var(--bg-lightest, var(--extra-border-color));
border-left: 3px solid var(--c-primary);
font-size: 13px;
line-height: 1.4;
line-height: 1.5;
&__human {
font-weight: bold;
font-weight: 600;
font-size: 14px;
}
&__next {
margin-top: var(--s-quarter, 2px);
opacity: 0.85;
opacity: 0.8;
}
// Caption above the calendar picker (Option A) shows the chosen start and
// hints that tapping a day changes it.
&__pick-hint {
margin-top: var(--s-half);
font-size: 12px;
text-align: center;
}
&__pick-tap {
margin-left: var(--s-half);
opacity: 0.6;
}
// "Next: <date> · in N days" spotlight chip.
&__next-chip {
display: inline-flex;
align-items: center;
gap: var(--s-half);
margin-top: var(--s-half);
padding: 2px var(--s);
border-radius: 999px;
background: color-mix(in srgb, var(--c-accent) 14%, transparent);
font-size: 12px;
.mat-icon {
font-size: 15px;
width: 15px;
height: 15px;
color: var(--c-accent);
}
}
&__next-date {
font-weight: 600;
}
&__next-away {
opacity: 0.7;
}
// Rhythm stats row above the open calendar (count · ~every N days · ends ).
&__stats {
margin-top: var(--s);
font-size: 12px;
opacity: 0.75;
text-align: center;
}
&__legacy-warning {
margin-top: var(--s-quarter, 2px);
color: var(--c-warn);
font-size: 12px;
}
&__sep {
@ -82,11 +434,74 @@ mat-dialog-actions {
}
&__expr {
margin-top: var(--s-quarter, 2px);
margin-top: var(--s-half);
font-family: monospace;
opacity: 0.7;
font-size: 11px;
opacity: 0.55;
word-break: break-all;
}
&__cal-toggle {
display: inline-flex;
align-items: center;
gap: var(--s-quarter, 4px);
margin-top: var(--s);
padding: 2px var(--s);
color: var(--c-primary);
background: transparent;
border: none;
border-radius: var(--card-border-radius);
cursor: pointer;
font-size: 12px;
.mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
background: var(--c-primary-10, rgba(0, 0, 0, 0.06));
}
}
&__cal {
display: block;
margin-top: var(--s);
}
&__sim-hint {
margin-top: var(--s-quarter, 4px);
font-size: 11px;
opacity: 0.6;
}
&__cal-empty {
margin-top: var(--s-quarter, 4px);
font-size: 11px;
opacity: 0.6;
font-style: italic;
}
&__sim {
display: flex;
align-items: center;
gap: var(--s);
margin-top: var(--s-quarter, 4px);
font-size: 12px;
font-weight: bold;
color: var(--c-primary);
}
&__sim-reset {
padding: 2px var(--s);
color: var(--text-color);
background: transparent;
border: 1px solid var(--extra-border-color);
border-radius: var(--card-border-radius);
cursor: pointer;
font-size: 11px;
}
}
:host collapsible {

View file

@ -14,16 +14,27 @@ import { CustomDateAdapter } from '../../../core/date-time-format/custom-date-ad
import { DialogEditTaskRepeatCfgComponent } from './dialog-edit-task-repeat-cfg.component';
import { TaskRepeatCfgService } from '../task-repeat-cfg.service';
import { TaskService } from '../../tasks/task.service';
import { TaskArchiveService } from '../../archive/task-archive.service';
import { TagService } from '../../tag/tag.service';
import { GlobalConfigService } from '../../config/global-config.service';
import { DateTimeFormatService } from '../../../core/date-time-format/date-time-format.service';
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
import { DayData } from '../../../ui/heatmap/heatmap.component';
import { TaskCopy } from '../../tasks/task.model';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../../../t.const';
import { SnackService } from '../../../core/snack/snack.service';
import { setRRuleEngineEnabled } from '../../config/rrule-engine-flag';
// Mirrors the ordinal-day format the option builder now passes for the concise
// "Monthly (15th)" label.
const ordinalDay = (n: number): string => {
const suffix: Record<string, string> = { one: 'st', two: 'nd', few: 'rd', other: 'th' };
return `${n}${suffix[new Intl.PluralRules('en-US', { type: 'ordinal' }).select(n)] ?? 'th'}`;
};
describe('DialogEditTaskRepeatCfgComponent', () => {
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogEditTaskRepeatCfgComponent>>;
let mockTaskRepeatCfgService: jasmine.SpyObj<TaskRepeatCfgService>;
@ -61,8 +72,13 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
targetDate?: string;
},
getTaskRepeatCfgById$ReturnValue?: Observable<TaskRepeatCfg> | Subject<TaskRepeatCfg>,
seriesTasks: TaskCopy[] = [],
): Promise<ComponentFixture<DialogEditTaskRepeatCfgComponent>> => {
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
mockDialogRef = jasmine.createSpyObj('MatDialogRef', [
'close',
'addPanelClass',
'removePanelClass',
]);
mockTaskRepeatCfgService = jasmine.createSpyObj('TaskRepeatCfgService', [
'getTaskRepeatCfgById$',
'updateTaskRepeatCfg',
@ -82,7 +98,10 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
tagsNoMyDayAndNoList$: of([]),
});
mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], {
cfg: () => ({ reminder: { defaultTaskRemindOption: null } }),
cfg: () => ({ reminder: { defaultTaskRemindOption: null }, tasks: {} }),
// CustomDateAdapter.getFirstDayOfWeek() reads this — needed by the result
// calendar preview (heatmap) build.
localization: () => ({ firstDayOfWeek: 0 }),
});
mockDateTimeFormatService = jasmine.createSpyObj('DateTimeFormatService', [], {
currentLocale: () => 'en-US',
@ -108,6 +127,11 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
{ provide: MatDialogRef, useValue: mockDialogRef },
{ provide: MAT_DIALOG_DATA, useValue: dialogData },
{ provide: TaskRepeatCfgService, useValue: mockTaskRepeatCfgService },
{ provide: TaskService, useValue: { allTasks$: of(seriesTasks) } },
{
provide: TaskArchiveService,
useValue: { load: () => Promise.resolve({ ids: [], entities: {} }) },
},
{ provide: TagService, useValue: mockTagService },
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
{ provide: DateTimeFormatService, useValue: mockDateTimeFormatService },
@ -241,7 +265,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
});
// Re-trigger form config initialization
(fixture.componentInstance as any)._initializeFormConfig();
(fixture.componentInstance as any)._buildQuickSettingOptions();
const monthlyCall = instantCalls.find(
(c) => c.key === T.F.TASK_REPEAT.F.Q_MONTHLY_CURRENT_DATE,
@ -250,12 +274,13 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
(c) => c.key === T.F.TASK_REPEAT.F.Q_YEARLY_CURRENT_DATE,
);
// Due date is May 1st — day should be "1", month/day should contain "5" and "1"
// Due date is May 1st — concise labels use the ordinal day ("1st") and the
// short month + day ("May 1").
const dueDate = new Date(2026, 4, 1); // May 1st
const expectedDayStr = dueDate.toLocaleDateString('en-US', { day: 'numeric' });
const expectedDayStr = ordinalDay(dueDate.getDate());
const expectedDayAndMonthStr = dueDate.toLocaleDateString('en-US', {
day: 'numeric',
month: 'numeric',
month: 'short',
});
expect(monthlyCall).toBeDefined();
@ -279,14 +304,14 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
return key;
});
(fixture.componentInstance as any)._initializeFormConfig();
(fixture.componentInstance as any)._buildQuickSettingOptions();
const monthlyCall = instantCalls.find(
(c) => c.key === T.F.TASK_REPEAT.F.Q_MONTHLY_CURRENT_DATE,
);
const today = new Date();
const todayDayStr = today.toLocaleDateString('en-US', { day: 'numeric' });
const todayDayStr = ordinalDay(today.getDate());
expect(monthlyCall).toBeDefined();
expect(monthlyCall!.params.dateDayStr).toBe(todayDayStr);
@ -310,15 +335,15 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
return key;
});
(fixture.componentInstance as any)._initializeFormConfig();
(fixture.componentInstance as any)._buildQuickSettingOptions();
const monthlyCall = instantCalls.find(
(c) => c.key === T.F.TASK_REPEAT.F.Q_MONTHLY_CURRENT_DATE,
);
// startDate is March 15 — day should be "15"
// startDate is March 15 — concise label uses the ordinal day ("15th").
const startDate = new Date(2026, 2, 15); // March 15
const expectedDayStr = startDate.toLocaleDateString('en-US', { day: 'numeric' });
const expectedDayStr = ordinalDay(startDate.getDate());
expect(monthlyCall).toBeDefined();
expect(monthlyCall!.params.dateDayStr).toBe(expectedDayStr);
@ -574,6 +599,542 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
}));
});
describe('result calendar preview (Phase 2)', () => {
const rruleCfg: TaskRepeatCfg = {
...DEFAULT_TASK_REPEAT_CFG,
id: 'rr-cal-preview',
title: 'Biweekly Mon',
startDate: '2024-06-03',
quickSetting: 'RRULE',
repeatCycle: 'WEEKLY',
rrule: 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO',
};
it('shows the calendar immediately for a valid rule (no toggle)', async () => {
// The calendar doubles as the start-date picker, so it is always present
// for a valid rule rather than hidden behind a toggle.
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
expect(fixture.componentInstance.resultHeatmapData()).not.toBeNull();
});
it('projects future occurrences', async () => {
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
const hd = c.resultHeatmapData();
expect(hd).not.toBeNull();
expect(hd!.months!.length).toBeGreaterThan(0);
const hasProjected = hd!.months!.some((m) =>
m.weeks.some((w) => w.days.some((d) => !!d?.isProjected)),
);
expect(hasProjected).toBe(true);
});
it('exposes the next upcoming occurrence (date + non-negative countdown)', async () => {
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const n = fixture.componentInstance.nextOccurrence();
expect(n).not.toBeNull();
expect(n!.dateStr).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(n!.daysAway).toBeGreaterThanOrEqual(0);
});
it('spotlights exactly one "next" cell in the home window', async () => {
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
const nextCells = [...c.resultHeatmapData()!.dayMap.values()].filter(
(d) => d.isNext,
);
expect(nextCells.length).toBe(1);
});
it('computes rhythm stats: count, average gap, and an end label', async () => {
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
const s = c.previewStats();
expect(s).not.toBeNull();
expect(s!.count).toBeGreaterThan(0);
// FREQ=WEEKLY;INTERVAL=2 → occurrences are 14 days apart.
expect(s!.avgGapDays).toBe(14);
expect(typeof s!.end).toBe('string');
expect(s!.end.length).toBeGreaterThan(0);
});
it('reports a finite end label for a COUNT rule', async () => {
const fixture = await setupTestBed({
repeatCfg: { ...rruleCfg, rrule: 'FREQ=WEEKLY;BYDAY=MO;COUNT=5' },
});
const c = fixture.componentInstance;
// Whether i18n is loaded (interpolates "5") or not (returns the key
// containing "AFTER"), a COUNT rule yields a finite end label — never the
// "runs forever" / NEVER one.
const end = c.previewStats()!.end;
expect(end).toMatch(/5|AFTER/i);
expect(end).not.toMatch(/forever|NEVER/i);
});
// Simulation only exists for repeat-from-completion schedules.
const completionCfg: TaskRepeatCfg = {
...rruleCfg,
id: 'rr-cal-completion',
repeatFromCompletionDate: true,
};
it('toggles the simulated completion day on double-click (from-completion cfg)', async () => {
const fixture = await setupTestBed({ repeatCfg: completionCfg });
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
c.menuSimulate();
expect(c.simulatedCompletion()).toBe('2099-01-06');
// Re-double-clicking the same day clears it.
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
c.menuSimulate();
expect(c.simulatedCompletion()).toBeNull();
});
it('does not offer simulate for a start-anchored schedule, and never mutates the flag', async () => {
// Simulation is a pure preview of from-completion re-anchoring; a
// start-anchored series stays fixed when an occurrence is completed, so the
// what-if is not offered there — and calling it (defensive) must NOT
// silently convert the schedule to from-completion.
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
expect(c.repeatCfg().repeatFromCompletionDate).toBeFalsy();
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
expect(c.menuDaySimAllowed()).toBe(false);
c.menuSimulate();
fixture.detectChanges();
expect(c.simulatedCompletion()).toBeNull();
expect(c.repeatCfg().repeatFromCompletionDate).toBeFalsy();
});
it('does not offer simulate for a COUNT rule (completion + COUNT is unsupported)', async () => {
// from-completion + COUNT never terminates and the save path rejects it,
// so simulate — which would re-anchor the COUNT window from the sim day —
// is withheld even on a from-completion cfg.
const fixture = await setupTestBed({
repeatCfg: { ...completionCfg, rrule: 'FREQ=WEEKLY;BYDAY=MO;COUNT=5' },
});
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
expect(c.menuDaySimAllowed()).toBe(false);
});
it('offers simulate only on/after the start date (from-completion schedule)', async () => {
const fixture = await setupTestBed({
repeatCfg: { ...completionCfg, startDate: '2099-01-05' },
});
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2099-01-04' } as DayData); // before start
expect(c.menuDaySimAllowed()).toBe(false);
c.menuDay.set({ dateStr: '2099-01-05' } as DayData); // the start
expect(c.menuDaySimAllowed()).toBe(true);
c.menuDay.set({ dateStr: '2099-01-09' } as DayData); // after start
expect(c.menuDaySimAllowed()).toBe(true);
});
it('clears a simulation that the new start date would precede', async () => {
const fixture = await setupTestBed({
repeatCfg: { ...completionCfg, startDate: '2099-01-01' },
});
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
c.menuSimulate();
expect(c.simulatedCompletion()).toBe('2099-01-06');
// Move the start AFTER the sim → the sim is now before the start → dropped.
c.menuDay.set({ dateStr: '2099-01-10' } as DayData);
c.menuSetStart();
fixture.detectChanges();
expect(c.simulatedCompletion()).toBeNull();
});
it('keeps a simulation when the new start date precedes it', async () => {
// Moving the start to BEFORE the sim leaves the completion valid (it still
// sits on/after the start), so the sim must survive — including the
// sim-watcher effect that fires on the startDate slice change.
const fixture = await setupTestBed({
repeatCfg: { ...completionCfg, startDate: '2099-01-05' },
});
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2099-01-20' } as DayData);
c.menuSimulate();
fixture.detectChanges();
expect(c.simulatedCompletion()).toBe('2099-01-20');
// Move the start EARLIER, still before the sim → the sim stays.
c.menuDay.set({ dateStr: '2099-01-10' } as DayData);
c.menuSetStart();
fixture.detectChanges();
expect(c.repeatCfg().startDate).toBe('2099-01-10');
expect(c.simulatedCompletion()).toBe('2099-01-20');
});
it('clears an active simulation when the rule is edited', async () => {
// A sim belongs to the rule it was clicked on; keeping it across an edit
// would re-anchor the NEW rule's series at a day picked for the old one.
const fixture = await setupTestBed({ repeatCfg: completionCfg });
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
c.menuSimulate();
expect(c.simulatedCompletion()).toBe('2099-01-06');
c.onRRuleChange('FREQ=MONTHLY;BYMONTHDAY=15');
expect(c.simulatedCompletion()).toBeNull();
});
it('manual fullscreen toggle adds/removes the fullscreen panel class', async () => {
// The calendar is always shown; fullscreen is purely the title-bar escape
// hatch for the wide year strip.
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
expect(c.isFullScreen()).toBe(false);
c.toggleFullScreen();
expect(c.isFullScreen()).toBe(true);
expect(mockDialogRef.addPanelClass).toHaveBeenCalledWith('dialog-fullscreen');
c.toggleFullScreen();
expect(c.isFullScreen()).toBe(false);
expect(mockDialogRef.removePanelClass).toHaveBeenCalledWith('dialog-fullscreen');
});
it('year arrows shift the projection window by whole years, both directions', async () => {
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
const home = c.resultHeatmapData()!;
// Window is padded to full calendar months.
expect(home.rangeStart.getDate()).toBe(1);
const homeStartYear = home.rangeStart.getFullYear();
c.previewNextYear();
expect(c.resultHeatmapData()!.rangeStart.getFullYear()).toBe(homeStartYear + 1);
c.previewPrevYear();
c.previewPrevYear();
expect(c.resultHeatmapData()!.rangeStart.getFullYear()).toBe(homeStartYear - 1);
expect(c.previewNavLabel()).toContain(String(homeStartYear - 1));
});
it('renders an empty HOME window with nav + hint instead of nothing (far-future start)', async () => {
// A valid rule with no occurrence in the next 365 days used to null out
// the preview entirely — including the arrows, so the window where
// it DOES fire was unreachable.
const y = new Date().getFullYear() + 5;
const fixture = await setupTestBed({
repeatCfg: { ...rruleCfg, rrule: 'FREQ=DAILY', startDate: `${y}-01-15` },
});
const c = fixture.componentInstance;
expect(c.resultHeatmapData()).not.toBeNull();
expect(c.previewWindowEmpty()).toBe(true);
for (let i = 0; i < 5; i++) {
c.previewNextYear();
}
expect(c.previewWindowEmpty()).toBe(false);
});
it('keeps a navigated window rendered even when it has no occurrences (no stranding)', async () => {
// Far in the past, before the cfg's startDate, the window is empty — the
// calendar and its nav must survive so the user can navigate back.
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
for (let i = 0; i < 5; i++) {
c.previewPrevYear();
}
expect(c.resultHeatmapData()).not.toBeNull();
// Back home it is populated again.
for (let i = 0; i < 5; i++) {
c.previewNextYear();
}
expect(c.resultHeatmapData()).not.toBeNull();
});
it('does not mark days already past as projected in the home window', async () => {
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
const home = c.resultHeatmapData()!;
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
[...home.dayMap.values()]
.filter((d) => d.isProjected)
.forEach((d) => expect(d.dateStr >= todayStr).toBe(true));
});
it('month navigation past the window edge pulls the window along', async () => {
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
const home = c.resultHeatmapData()!;
const homeStartYear = home.rangeStart.getFullYear();
// A month fully after the window end → window shifts forward a year.
c.onPreviewMonthChange({
y: home.rangeEnd.getFullYear(),
m: home.rangeEnd.getMonth() + 1,
});
expect(c.resultHeatmapData()!.rangeStart.getFullYear()).toBe(homeStartYear + 1);
// A month still inside the window → no shift.
const cur = c.resultHeatmapData()!;
c.onPreviewMonthChange({
y: cur.rangeStart.getFullYear(),
m: cur.rangeStart.getMonth() + 2,
});
expect(c.resultHeatmapData()!.rangeStart.getFullYear()).toBe(homeStartYear + 1);
});
it('clears an active simulation when startDate or excluded days change (formly model edit)', async () => {
// These edits arrive only as a new formly model (no dedicated handler) —
// the schedule-slice effect must drop the sim, same as a rule edit.
const fixture = await setupTestBed({ repeatCfg: completionCfg });
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
c.menuSimulate();
expect(c.simulatedCompletion()).toBe('2099-01-06');
c.repeatCfg.update((cfg) => ({ ...cfg, startDate: '2024-07-01' }) as any);
fixture.detectChanges();
expect(c.simulatedCompletion()).toBeNull();
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
c.menuSimulate();
expect(c.simulatedCompletion()).toBe('2099-01-06');
c.repeatCfg.update(
(cfg) => ({ ...cfg, deletedInstanceDates: ['2099-01-05'] }) as any,
);
fixture.detectChanges();
expect(c.simulatedCompletion()).toBeNull();
});
it('keeps an active simulation across an unrelated (title) edit', async () => {
const fixture = await setupTestBed({ repeatCfg: completionCfg });
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2099-01-06' } as DayData);
c.menuSimulate();
c.repeatCfg.update((cfg) => ({ ...cfg, title: 'typing…' }) as any);
fixture.detectChanges();
expect(c.simulatedCompletion()).toBe('2099-01-06');
});
it('does not rebuild the projection when an unrelated field changes', async () => {
// formly emits a cloned model per keystroke in ANY field; the projection
// must only recompute when a schedule-relevant field changes.
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
const c = fixture.componentInstance;
const before = c.resultHeatmapData();
expect(before).not.toBeNull();
c.repeatCfg.update((cfg) => ({ ...cfg, title: 'typing…' }) as any);
expect(c.resultHeatmapData()).toBe(before); // same reference — no rebuild
c.repeatCfg.update((cfg) => ({ ...cfg, rrule: 'FREQ=DAILY' }) as any);
expect(c.resultHeatmapData()).not.toBe(before); // schedule change rebuilds
});
it('merges the saved series tracked time into the calendar as a green activity overlay', async () => {
// #3: a purely forward window never showed activity (tracked time is in
// the PAST). With the look-back window, a recently tracked day of the
// series surfaces as a green (activityLevel) cell in the projection.
const trackedDate = new Date();
trackedDate.setDate(trackedDate.getDate() - 10);
const trackedDay = getDbDateStr(trackedDate);
const seriesTask = {
...mockTask,
id: 'series-1',
repeatCfgId: 'rr-activity',
timeSpentOnDay: { [trackedDay]: 3_600_000 },
} as unknown as TaskCopy;
const fixture = await setupTestBed(
{
repeatCfg: {
...mockRepeatCfg,
id: 'rr-activity',
quickSetting: 'RRULE',
rrule: 'FREQ=DAILY',
},
},
undefined,
[seriesTask],
);
const c = fixture.componentInstance;
// Flush the repeatCfgId → toObservable → switchMap(loadSeriesTasks) →
// Promise.all pipeline (a couple of microtask/macrotask turns).
fixture.detectChanges();
await fixture.whenStable();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
await fixture.whenStable();
expect(c.hasActivity()).toBe(true);
const day = c.resultHeatmapData()!.dayMap.get(trackedDay)!;
expect(day).toBeTruthy();
expect(day.timeSpent).toBe(3_600_000);
// Sole tracked day → busiest in view → top green level.
expect(day.activityLevel).toBe(4);
// Toggling activity OFF drops the green merge.
c.showActivity.set(false);
const dayOff = c.resultHeatmapData()!.dayMap.get(trackedDay)!;
expect(dayOff.activityLevel).toBeUndefined();
});
});
describe('calendar context-menu actions', () => {
const monthlyCfg: TaskRepeatCfg = {
...DEFAULT_TASK_REPEAT_CFG,
id: 'cal-menu',
title: 'Monthly 10th',
startDate: '2026-06-15',
quickSetting: 'RRULE',
repeatCycle: 'MONTHLY',
rrule: 'FREQ=MONTHLY;BYMONTHDAY=10',
};
it('day menu: "on this day of month" adds BYMONTHDAY', async () => {
const fixture = await setupTestBed({ repeatCfg: monthlyCfg });
const c = fixture.componentInstance;
c.menuDay.set({ date: new Date(2026, 5, 20), dateStr: '2026-06-20' } as DayData);
c.menuToggleMonthDay();
expect(c.repeatCfg().rrule).toBe('FREQ=MONTHLY;BYMONTHDAY=10,20');
});
it('day menu: "ends on" sets UNTIL for a day after the start', async () => {
const fixture = await setupTestBed({ repeatCfg: monthlyCfg });
const c = fixture.componentInstance;
c.menuDay.set({ date: new Date(2027, 0, 1), dateStr: '2027-01-01' } as DayData);
expect(c.menuDayIsEnd()).toBe(false);
c.menuEndsOn();
expect(c.repeatCfg().rrule).toContain('UNTIL=20270101');
});
it('day menu: clicking the day that IS the end removes the end (toggle)', async () => {
const fixture = await setupTestBed({
repeatCfg: {
...monthlyCfg,
rrule: 'FREQ=MONTHLY;BYMONTHDAY=10;UNTIL=20270101T120000Z',
},
});
const c = fixture.componentInstance;
c.menuDay.set({ date: new Date(2027, 0, 1), dateStr: '2027-01-01' } as DayData);
// The clicked day is the end → the menu flips to "Remove" and toggles off.
expect(c.menuDayIsEnd()).toBe(true);
c.menuEndsOn();
expect(c.repeatCfg().rrule).not.toContain('UNTIL');
});
it('weekday menu: nth ordinal adds BYDAY=2MO', async () => {
const fixture = await setupTestBed({ repeatCfg: monthlyCfg });
const c = fixture.componentInstance;
c.menuWeekdayIdx.set(0); // MO
c.menuToggleNth(2, 'MONTHLY');
expect(c.repeatCfg().rrule).toBe('FREQ=MONTHLY;BYDAY=2MO');
});
it('weekday menu: the YEARLY nth variant produces a yearly nth rule', async () => {
const fixture = await setupTestBed({ repeatCfg: monthlyCfg });
const c = fixture.componentInstance;
c.menuWeekdayIdx.set(0); // MO
c.menuToggleNth(2, 'YEARLY');
expect(c.repeatCfg().rrule).toContain('FREQ=YEARLY');
expect(c.repeatCfg().rrule).toContain('BYDAY=2MO');
});
it('weekday menu: selected day adds a plain BYDAY (monthly weekdays)', async () => {
const fixture = await setupTestBed({ repeatCfg: monthlyCfg });
const c = fixture.componentInstance;
c.menuWeekdayIdx.set(2); // WE
c.menuToggleSelectedDay('MONTHLY');
expect(c.repeatCfg().rrule).toBe('FREQ=MONTHLY;BYDAY=WE');
});
it('weekday menu: the WEEKLY variant switches a monthly rule to weekly', async () => {
const fixture = await setupTestBed({ repeatCfg: monthlyCfg });
const c = fixture.componentInstance;
c.menuWeekdayIdx.set(2); // WE
c.menuToggleSelectedDay('WEEKLY');
expect(c.repeatCfg().rrule).toBe('FREQ=WEEKLY;BYDAY=WE');
});
it('month-label menu: toggles BYMONTH for the viewed month', async () => {
const fixture = await setupTestBed({
repeatCfg: { ...monthlyCfg, rrule: 'FREQ=DAILY', repeatCycle: 'DAILY' },
});
const c = fixture.componentInstance;
c.menuMonthIdx.set(5); // June → month 6
c.menuToggleMonth();
expect(c.repeatCfg().rrule).toContain('BYMONTH=6');
});
it('a calendar rule-edit switches the quick-setting to Custom (RRULE)', async () => {
// A preset (e.g. Daily) edited via the calendar becomes a custom rule.
const fixture = await setupTestBed({
repeatCfg: {
...monthlyCfg,
rrule: 'FREQ=DAILY',
repeatCycle: 'DAILY',
quickSetting: 'DAILY',
},
});
const c = fixture.componentInstance;
expect(c.repeatCfg().quickSetting).toBe('DAILY');
c.menuMonthIdx.set(5);
c.menuToggleMonth();
expect(c.repeatCfg().quickSetting).toBe('RRULE');
});
it('weekday menu: "selected days" works in WEEKLY mode (adds BYDAY)', async () => {
const fixture = await setupTestBed({
repeatCfg: {
...monthlyCfg,
rrule: 'FREQ=WEEKLY;BYDAY=MO',
repeatCycle: 'WEEKLY',
},
});
const c = fixture.componentInstance;
c.menuWeekdayIdx.set(2); // WE
c.menuToggleSelectedDay('WEEKLY');
expect(c.repeatCfg().rrule).toBe('FREQ=WEEKLY;BYDAY=MO,WE');
});
it('moving the start on/after the end (UNTIL) clears the end back to Never', async () => {
const fixture = await setupTestBed({
repeatCfg: {
...monthlyCfg,
rrule: 'FREQ=DAILY;UNTIL=20260620T120000Z',
repeatCycle: 'DAILY',
startDate: '2026-06-01',
},
});
const c = fixture.componentInstance;
expect(c.repeatCfg().rrule).toContain('UNTIL');
c.menuDay.set({ dateStr: '2026-07-01' } as DayData); // after the 6/20 end
c.menuSetStart();
expect(c.repeatCfg().rrule).not.toContain('UNTIL');
});
it('setting the start re-anchors a running from-completion schedule', async () => {
// A from-completion schedule that has run anchors on lastTaskCreationDay, so
// an explicit start edit would otherwise be swallowed — clear the marker.
const fixture = await setupTestBed({
repeatCfg: {
...monthlyCfg,
repeatFromCompletionDate: true,
lastTaskCreationDay: '2026-01-01',
startDate: '2026-06-01',
},
});
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2026-07-01' } as DayData);
c.menuSetStart();
expect(c.repeatCfg().startDate).toBe('2026-07-01');
expect(c.repeatCfg().lastTaskCreationDay).toBeUndefined();
});
it('setting the start on a fixed schedule leaves lastTaskCreationDay alone', async () => {
const fixture = await setupTestBed({
repeatCfg: {
...monthlyCfg,
repeatFromCompletionDate: false,
lastTaskCreationDay: '2026-01-01',
startDate: '2026-06-01',
},
});
const c = fixture.componentInstance;
c.menuDay.set({ dateStr: '2026-07-01' } as DayData);
c.menuSetStart();
expect(c.repeatCfg().startDate).toBe('2026-07-01');
expect(c.repeatCfg().lastTaskCreationDay).toBe('2026-01-01');
});
});
describe('RRULE builder mode', () => {
it('_processQuickSettingForDate forces RRULE mode for a non-preset rrule cfg', async () => {
const fixture = await setupTestBed({ task: mockTask });
@ -922,11 +1483,13 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
expect(fixture.componentInstance.repeatCfg().quickSetting).toBe('RRULE');
});
it('offers the RRULE quick-setting option after the ASYNC load migrates a cfg into builder mode (flag off)', fakeAsync(async () => {
// includeRRule must be read live, not captured at construction: on the
// task/repeatCfgId path the cfg arrives async, AFTER _initializeFormConfig
// ran — a snapshot taken then would leave the select holding 'RRULE' with
// no matching option on a flag-off device.
it('offers the RRULE quick-setting option with the engine flag OFF, incl. on the async edit path', fakeAsync(async () => {
// The builder replaced the legacy Custom UI, so the option is ALWAYS
// offered regardless of the per-device engine flag — including on the
// task/repeatCfgId path, where the cfg arrives async and migrates into
// builder mode only after the form config was built. (A flag-gated
// option list would leave the select holding 'RRULE' with no matching
// option here.)
setRRuleEngineEnabled(false);
const taskWithRepeatCfg = {
...mockTask,
@ -938,15 +1501,13 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
fixture.detectChanges();
tick();
const field = component
.essentialFormFields()
.find((f) => f.key === 'quickSetting')!;
const evalOptions = field.expressionProperties![
'templateOptions.options'
] as unknown as (model: Record<string, unknown>) => { value: string }[];
// Option building now lives in the chip picker's source method (live
// `includeRRule`), not a formly field's expressionProperties.
const buildOptions = (): { value: string }[] =>
(component as any)._buildQuickSettingOptions();
// Flag off, nothing loaded yet → no advanced option.
expect(evalOptions({}).some((o) => o.value === 'RRULE')).toBe(false);
// Custom ('RRULE') is always on offer, even with the engine flag off.
expect(buildOptions().some((o) => o.value === 'RRULE')).toBe(true);
// Async load delivers a completion cfg that migrates to builder mode.
repeatCfgSubject.next({
@ -956,7 +1517,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
} as TaskRepeatCfg);
tick();
expect(component.repeatCfg().quickSetting).toBe('RRULE');
expect(evalOptions({}).some((o) => o.value === 'RRULE')).toBe(true);
expect(buildOptions().some((o) => o.value === 'RRULE')).toBe(true);
}));
it('opens a NO-rrule completion-relative cfg in builder mode (migrates to rrule)', async () => {
@ -1162,4 +1723,37 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
expect(savedCfg.repeatCycle).toBe('DAILY');
});
});
describe('onQuickSettingSelect (chip picker)', () => {
// Replaces the old formly-select `change` handler coverage (#5806): the
// chip picker now drives quickSetting via onQuickSettingSelect.
it('uses the selected start date for date-writing presets (not today)', async () => {
const fixture = await setupTestBed({ task: mockTask });
const component = fixture.componentInstance;
component.repeatCfg.update((c) => ({ ...c, startDate: '2099-09-15' }));
component.onQuickSettingSelect('MONTHLY_CURRENT_DATE');
expect(component.repeatCfg().quickSetting).toBe('MONTHLY_CURRENT_DATE');
expect(component.repeatCfg().startDate).toBe('2099-09-15');
});
it('applies weekday flags from the selected start date for weekly presets', async () => {
const fixture = await setupTestBed({ task: mockTask });
const component = fixture.componentInstance;
// 2099-09-14 is a Monday.
component.repeatCfg.update((c) => ({ ...c, startDate: '2099-09-14' }));
component.onQuickSettingSelect('WEEKLY_CURRENT_WEEKDAY');
expect(component.repeatCfg().monday).toBe(true);
expect(component.repeatCfg().tuesday).toBe(false);
});
it('switching to Custom (RRULE) keeps the current rrule for the builder', async () => {
const fixture = await setupTestBed({ task: mockTask });
const component = fixture.componentInstance;
component.onQuickSettingSelect('DAILY');
const dailyRule = component.repeatCfg().rrule;
component.onQuickSettingSelect('RRULE');
expect(component.repeatCfg().quickSetting).toBe('RRULE');
expect(component.repeatCfg().rrule).toBe(dailyRule);
});
});
});

View file

@ -0,0 +1,96 @@
<div class="rfp">
@if (label()) {
<label class="rfp-lbl">{{ label() }}</label>
}
<button
#trigger
type="button"
class="rfp-trigger"
[class.is-open]="isOpen()"
cdkOverlayOrigin
#origin="cdkOverlayOrigin"
[attr.aria-expanded]="isOpen()"
aria-haspopup="listbox"
(click)="isOpen() ? close() : open(trigger)"
>
<span class="rfp-trigger__label">{{ selectedLabel() }}</span>
<span
class="rfp-trigger__chevron"
aria-hidden="true"
>▾</span
>
</button>
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOpen]="isOpen()"
[cdkConnectedOverlayPositions]="overlayPositions"
[cdkConnectedOverlayWidth]="triggerWidth()"
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
(backdropClick)="close()"
(overlayKeydown)="$event.key === 'Escape' && close()"
(detach)="close()"
>
<div
class="rfp-panel"
(keydown)="onPanelKeydown($event)"
>
<div
class="rfp-options"
role="listbox"
[attr.aria-label]="label() || null"
>
@for (o of presets(); track o.value) {
<button
#optBtn
type="button"
class="rfp-opt"
role="option"
[class.active]="o.value === value()"
[attr.aria-selected]="o.value === value()"
[tabindex]="isFocusable(o.value) ? 0 : -1"
(click)="select(o.value)"
>
{{ o.label }}
</button>
}
@if (customOption(); as c) {
<button
#optBtn
type="button"
class="rfp-opt rfp-opt--custom"
role="option"
[class.active]="c.value === value()"
[attr.aria-selected]="c.value === value()"
[tabindex]="isFocusable(c.value) ? 0 : -1"
(click)="select(c.value)"
>
{{ c.label }}
</button>
}
</div>
@if (canToggle()) {
<button
#optBtn
type="button"
class="rfp-opt rfp-opt--more"
[attr.aria-expanded]="isExpanded()"
[tabindex]="isFocusable(MORE_KEY) ? 0 : -1"
(click)="toggleExpanded()"
>
{{
(isExpanded()
? T.F.TASK_REPEAT.F.QUICK_SETTING_FEWER
: T.F.TASK_REPEAT.F.QUICK_SETTING_MORE
) | translate
}}
</button>
}
</div>
</ng-template>
</div>

View file

@ -0,0 +1,119 @@
:host {
display: block;
margin: var(--s) 0;
}
.rfp-lbl {
display: block;
margin-bottom: var(--s-half);
font-size: 12px;
font-weight: 600;
opacity: 0.7;
}
// Trigger themed to match the dialog inputs (border, height, custom chevron).
.rfp-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s);
width: 100%;
height: 38px;
padding: 0 var(--s);
color: var(--text-color);
background: var(--bg, transparent);
border: 1px solid var(--extra-border-color);
border-radius: 9px;
font: inherit;
font-size: 14px;
cursor: pointer;
text-align: start;
&:hover {
border-color: var(--c-primary);
}
&.is-open {
border-color: var(--c-primary);
}
&__label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
&__chevron {
flex: 0 0 auto;
font-size: 10px;
opacity: 0.6;
transition: transform 0.15s ease;
}
&.is-open &__chevron {
transform: rotate(180deg);
}
}
// Dropdown panel rendered in the CDK overlay (body level), but still an
// embedded view of this component, so emulated-encapsulation styles apply.
.rfp-panel {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
padding: var(--s-half);
background: var(--card-bg, var(--bg));
border: 1px solid var(--extra-border-color);
border-radius: 10px;
box-shadow: var(--whiteframe-shadow-4dp, 0 2px 10px rgba(0, 0, 0, 0.3));
}
// The listbox rows; the More toggle is a sibling so the listbox holds only
// role="option" children.
.rfp-options {
display: flex;
flex-direction: column;
}
.rfp-opt {
display: block;
width: 100%;
padding: var(--s-half) var(--s);
border: none;
border-radius: 7px;
background: transparent;
color: var(--text-color);
font: inherit;
font-size: 14px;
text-align: start;
cursor: pointer;
&:hover {
background: var(--bg-lightest, rgba(127, 127, 127, 0.12));
}
// Keyboard focus ring roving focus moves between rows with no pointer.
&:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: -2px;
}
&.active {
background: var(--c-accent);
color: var(--palette-accent-contrast, #fff);
font-weight: 600;
}
// "Custom" accent text so it reads as the escape hatch.
&--custom:not(.active) {
color: var(--c-accent);
}
// "More / Fewer options" quiet, set off from the presets above.
&--more {
margin-top: var(--s-quarter, 2px);
opacity: 0.75;
font-size: 13px;
}
}

View file

@ -0,0 +1,110 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
RepeatFreqOption,
RepeatFreqPickerComponent,
} from './repeat-freq-picker.component';
// Unit coverage for the picker's branching logic (preset ordering, More toggle,
// Custom handling) and the listbox keyboard navigation — previously exercised
// only indirectly through the dialog spec.
describe('RepeatFreqPickerComponent', () => {
const OPTS: RepeatFreqOption[] = [
{ value: 'DAILY', label: 'Every day' },
{ value: 'WEEKLY_CURRENT_WEEKDAY', label: 'Weekly Mon' },
{ value: 'MONTHLY_CURRENT_DATE', label: 'Monthly 15' },
{ value: 'WEEKENDS', label: 'Weekends' }, // tail (not common)
{ value: 'QUARTERLY_CURRENT_DATE', label: 'Quarterly' }, // tail
{ value: 'RRULE', label: 'Custom' },
];
const COMMON = ['DAILY', 'WEEKLY_CURRENT_WEEKDAY', 'MONTHLY_CURRENT_DATE'];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RepeatFreqPickerComponent, TranslateModule.forRoot()],
});
});
const make = (
value?: string,
opts: RepeatFreqOption[] = OPTS,
common: readonly string[] = COMMON,
): RepeatFreqPickerComponent => {
const fixture = TestBed.createComponent(RepeatFreqPickerComponent);
fixture.componentRef.setInput('options', opts);
fixture.componentRef.setInput('commonValues', common);
if (value !== undefined) {
fixture.componentRef.setInput('value', value);
}
return fixture.componentInstance;
};
it('shows the common presets first and hides the tail until expanded', () => {
const c = make('DAILY');
expect(c.presets().map((o) => o.value)).toEqual(COMMON);
expect(c.canToggle()).toBe(true);
c.toggleExpanded();
expect(c.presets().map((o) => o.value)).toEqual([
...COMMON,
'WEEKENDS',
'QUARTERLY_CURRENT_DATE',
]);
});
it('appends the active tail preset while collapsed so the selection always shows', () => {
const c = make('WEEKENDS');
expect(c.presets().map((o) => o.value)).toEqual([...COMMON, 'WEEKENDS']);
});
it('exposes Custom separately and resolves the selected label', () => {
const c = make('RRULE');
expect(c.customOption()?.value).toBe('RRULE');
expect(c.selectedLabel()).toBe('Custom');
});
it('canToggle() is false when there is no hidden tail', () => {
const c = make(
undefined,
[
{ value: 'DAILY', label: 'd' },
{ value: 'RRULE', label: 'c' },
],
['DAILY'],
);
expect(c.canToggle()).toBe(false);
});
it('select() emits and closes; toggleExpanded() keeps the panel open', () => {
const c = make('DAILY');
const emitted: string[] = [];
c.selected.subscribe((v) => emitted.push(v));
c.open(document.createElement('div'));
expect(c.isOpen()).toBe(true);
c.toggleExpanded();
expect(c.isOpen()).toBe(true); // long-tail expand must NOT close the panel
expect(c.isExpanded()).toBe(true);
c.select('WEEKENDS');
expect(emitted).toEqual(['WEEKENDS']);
expect(c.isOpen()).toBe(false); // selecting a value closes
});
it('focusableKeys() = visible options then the More toggle', () => {
const c = make('DAILY');
expect(c.focusableKeys()).toEqual([...COMMON, 'RRULE', c.MORE_KEY]);
});
it('roving focus: opens on the selection and arrow/Home/End move it', () => {
const c = make('DAILY');
c.open(document.createElement('div'));
const keys = c.focusableKeys();
expect(c.isFocusable('DAILY')).toBe(true);
c.onPanelKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(c.isFocusable(keys[1])).toBe(true);
c.onPanelKeydown(new KeyboardEvent('keydown', { key: 'End' }));
expect(c.isFocusable(keys[keys.length - 1])).toBe(true);
c.onPanelKeydown(new KeyboardEvent('keydown', { key: 'ArrowDown' }));
expect(c.isFocusable(keys[0])).toBe(true); // wraps to the top
c.onPanelKeydown(new KeyboardEvent('keydown', { key: 'Home' }));
expect(c.isFocusable(keys[0])).toBe(true);
});
});

View file

@ -0,0 +1,228 @@
import {
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
input,
output,
signal,
viewChildren,
} from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { ConnectedPosition, OverlayModule } from '@angular/cdk/overlay';
import { T } from '../../../../t.const';
import { RRULE_QUICK_SETTING } from '../../task-repeat-cfg.model';
export interface RepeatFreqOption {
value: string;
/** Already-translated, date-aware label (from buildRepeatQuickSettingOptions). */
label: string;
}
const CUSTOM_VALUE = RRULE_QUICK_SETTING;
/** Roving-focus key for the "More/Fewer options" toggle — not a selectable value. */
const MORE_KEY = '__more__';
/**
* Presentational frequency picker for the repeat-config dialog, rendered as a
* custom dropdown (CDK overlay so the panel is never clipped by the dialog's
* scroll container). The curated common presets show first (in `commonValues`
* order every day, weekly, monthly, yearly, every weekday), then "Custom",
* then a "More options" entry that reveals the long tail IN THE OPEN PANEL
* (clicking it does not close the dropdown). All recurrence logic presets,
* RRULE engine, sync-safe persistence stays in the dialog/service; this
* component only presents the choice.
*
* DEVIATION (deliberate, not an accidental reinvention of mat-select/mat-menu):
* the defining interaction is "More options expands the long tail IN-PANEL
* without closing", which fights both Material primitives
* - `mat-select` is a value form-control with fixed option rows; it has no
* notion of an in-panel expandable section plus a separate "Custom" entry
* that switches the dialog into builder mode.
* - `mat-menu` closes on every `mat-menu-item` activation, does NOT match its
* panel width to the trigger (this picker does, via `triggerWidth`), and
* keeping it open for the toggle would mean mixing plain buttons with
* `mat-menu-item`s (breaking its arrow-key roving focus) plus width-matching
* CSS overrides of Material internals exactly what the styling guide
* forbids.
* A raw `cdkConnectedOverlay` (the only one in `src/app`) is therefore the
* lighter, rule-consistent option: it owns the panel without overriding any
* shared/Material component. Keep the recurrence/presentation split above so
* this stays a thin, swappable view if Material ever grows a matching control.
*/
@Component({
selector: 'repeat-freq-picker',
templateUrl: './repeat-freq-picker.component.html',
styleUrls: ['./repeat-freq-picker.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TranslatePipe, OverlayModule],
})
export class RepeatFreqPickerComponent {
readonly T = T;
readonly CUSTOM_VALUE = CUSTOM_VALUE;
readonly MORE_KEY = MORE_KEY;
readonly label = input<string>('');
readonly options = input.required<RepeatFreqOption[]>();
/** Preset values shown while collapsed; the rest hide behind "More options". */
readonly commonValues = input<readonly string[]>([]);
readonly value = input<string | undefined>(undefined);
readonly selected = output<string>();
readonly isOpen = signal(false);
readonly isExpanded = signal(false);
/** Panel width is matched to the trigger, captured when the dropdown opens. */
readonly triggerWidth = signal(0);
readonly overlayPositions: ConnectedPosition[] = [
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 4,
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -4,
},
];
private readonly _byValue = computed(() => {
const m = new Map<string, RepeatFreqOption>();
for (const o of this.options()) {
m.set(o.value, o);
}
return m;
});
readonly selectedLabel = computed<string>(() => {
const o = this._byValue().get(this.value() ?? '');
return o ? o.label : '';
});
// Presets in panel order: the curated common list first (kept in the order the
// caller gives), then — when expanded — the long tail in build order. Custom is
// rendered separately, last. While collapsed the active selection is appended if
// it's a hidden tail preset, so the current choice always shows.
readonly presets = computed<RepeatFreqOption[]>(() => {
const byVal = this._byValue();
const commonSet = new Set(this.commonValues());
const head: RepeatFreqOption[] = [];
for (const v of this.commonValues()) {
const o = byVal.get(v);
if (o) {
head.push(o);
}
}
if (this.isExpanded()) {
const tail = this.options().filter(
(o) => o.value !== CUSTOM_VALUE && !commonSet.has(o.value),
);
return [...head, ...tail];
}
const val = this.value();
if (val && val !== CUSTOM_VALUE && !commonSet.has(val)) {
const o = byVal.get(val);
if (o) {
head.push(o);
}
}
return head;
});
readonly customOption = computed<RepeatFreqOption | undefined>(() =>
this._byValue().get(CUSTOM_VALUE),
);
// Show the More/Fewer entry only when the long tail actually holds something.
readonly canToggle = computed<boolean>(() => {
const commonSet = new Set(this.commonValues());
return this.options().some(
(o) => o.value !== CUSTOM_VALUE && !commonSet.has(o.value),
);
});
// --- keyboard navigation (listbox pattern; restores what the old <select> had) ---
private readonly _optButtons = viewChildren<ElementRef<HTMLButtonElement>>('optBtn');
/** Focusable rows in DOM/visual order: value options, then the More toggle.
* Drives roving tabindex + arrow-key navigation while the panel is open. */
readonly focusableKeys = computed<string[]>(() => {
const keys = this.presets().map((p) => p.value);
if (this.customOption()) keys.push(CUSTOM_VALUE);
if (this.canToggle()) keys.push(MORE_KEY);
return keys;
});
/** The single tabindex=0 row (roving focus); all others are -1. */
private readonly _focusedKey = signal<string>('');
isFocusable(key: string): boolean {
return key === this._focusedKey();
}
open(trigger: HTMLElement): void {
this.triggerWidth.set(trigger.offsetWidth);
const keys = this.focusableKeys();
const val = this.value();
// Land roving focus on the current selection (or the first row).
this._focusedKey.set(val && keys.includes(val) ? val : (keys[0] ?? ''));
this.isOpen.set(true);
// Move focus into the panel once the overlay has rendered — the old native
// <select> received focus on open; the hand-rolled panel previously did not.
setTimeout(() => this._focusCurrent());
}
close(): void {
this.isOpen.set(false);
}
select(value: string): void {
this.selected.emit(value);
this.close();
}
/** Toggle the long tail without closing the panel (the user's key ask). Keep
* focus on the toggle so a keyboard user stays put as rows appear/collapse. */
toggleExpanded(): void {
this.isExpanded.update((v) => !v);
this._focusedKey.set(MORE_KEY);
setTimeout(() => this._focusCurrent());
}
/** Arrow / Home / End move roving focus between the panel rows. Enter/Space
* activate natively (each row is a <button>); Escape is handled by the overlay. */
onPanelKeydown(event: KeyboardEvent): void {
const keys = this.focusableKeys();
if (!keys.length) return;
const cur = Math.max(0, keys.indexOf(this._focusedKey()));
let next = cur;
switch (event.key) {
case 'ArrowDown':
next = (cur + 1) % keys.length;
break;
case 'ArrowUp':
next = (cur - 1 + keys.length) % keys.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = keys.length - 1;
break;
default:
return;
}
event.preventDefault();
this._focusedKey.set(keys[next]);
this._focusCurrent();
}
private _focusCurrent(): void {
const idx = this.focusableKeys().indexOf(this._focusedKey());
this._optButtons()[idx >= 0 ? idx : 0]?.nativeElement.focus();
}
}

View file

@ -1,404 +1,68 @@
<div class="rb">
<!-- Frequency + interval, read as a sentence -->
<div class="rb-row">
@if (engineOff()) {
<!-- The engine flag is per-device and off: the rule is persisted in full,
but scheduling runs from the simplified legacy mirror derived at save.
Say so up front instead of describing behavior that won't happen. -->
<div class="rb-engine-off">
{{ T.F.TASK_REPEAT.F.RRULE_ENGINE_OFF_NOTICE | translate }}
</div>
}
<!-- Repeat every: label + stepper + frequency unit dropdown, all on one line -->
<div class="rb-row rb-row--repeat">
<span class="rb-lbl">{{ T.F.TASK_REPEAT.F.RRULE_INTERVAL | translate }}</span>
<input
#intervalIn
class="rb-num"
type="number"
min="1"
max="1000"
[value]="model().interval"
(input)="setInterval(intervalIn.value)"
/>
<select
#freqSel
class="rb-sel"
(change)="setFreq(freqSel.value)"
>
@for (o of freqOpts; track o.value) {
<option
[value]="o.value"
[selected]="o.value === model().freq"
<div class="rb-repeat-controls">
<div class="rb-stepper">
<button
type="button"
class="rb-step"
[disabled]="model().interval <= 1"
(click)="setInterval(model().interval - 1 + '')"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_INTERVAL_DECREMENT | translate"
>
{{ o.label | translate }}
</option>
}
</select>
</div>
<!-- WEEKLY: weekday toggle buttons -->
@if (model().freq === 'WEEKLY') {
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYDAY | translate }}</label>
<div class="rb-toggles">
@for (w of weekdays; track w.value) {
<button
type="button"
class="rb-tgl"
[class.active]="model().byDay.includes(w.value)"
[title]="w.full"
(click)="toggleDay(w.value)"
>
{{ w.short }}
</button>
}
</button>
<input
#intervalIn
class="rb-step-num"
type="number"
min="1"
max="1000"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_INTERVAL | translate"
[value]="model().interval"
(input)="setInterval(intervalIn.value)"
/>
<button
type="button"
class="rb-step"
(click)="setInterval(model().interval + 1 + '')"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_INTERVAL_INCREMENT | translate"
>
+
</button>
</div>
</div>
}
<!-- MONTHLY -->
@if (model().freq === 'MONTHLY') {
<div class="rb-row">
<span class="rb-lbl">{{ T.F.TASK_REPEAT.F.RRULE_MONTHLY_MODE | translate }}</span>
<select
#mMode
class="rb-sel rb-sel--wide"
(change)="setMonthlyMode(mMode.value)"
#freqSel
class="rb-sel rb-sel--freq"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_FREQ | translate"
(change)="setFreq(freqSel.value)"
>
@for (o of monthlyModeOpts; track o.value) {
@for (o of freqOpts(); track o.value) {
<option
[value]="o.value"
[selected]="o.value === model().monthlyMode"
[selected]="o.value === model().freq"
>
{{ o.label | translate }}
</option>
}
</select>
</div>
@if (model().monthlyMode === 'DAY_OF_MONTH') {
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY | translate }}</label>
<div class="rb-toggles rb-toggles--days">
@for (d of dayGrid; track d) {
<button
type="button"
class="rb-tgl"
[class.active]="model().monthDays.includes(d)"
(click)="toggleMonthDay(d)"
>
{{ d }}
</button>
}
@for (nd of negativeDays; track nd.value) {
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="model().monthDays.includes(nd.value)"
(click)="toggleMonthDay(nd.value)"
>
{{ nd.label | translate }}
</button>
}
</div>
<input
class="rb-text"
type="text"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_PLACEHOLDER | translate"
[value]="model().monthDays.join(',')"
(change)="setMonthDays($any($event.target).value)"
/>
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_DESCRIPTION | translate }}
</div>
</div>
}
<!-- nth-weekday rows render in the shared block below (monthly + yearly). -->
@if (model().monthlyMode === 'WEEKDAYS') {
<ng-container *ngTemplateOutlet="weekdaySetBlock"></ng-container>
}
}
<!-- YEARLY -->
@if (model().freq === 'YEARLY') {
<div class="rb-row">
<span class="rb-lbl">{{ T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE | translate }}</span>
<select
#yMode
class="rb-sel rb-sel--wide"
(change)="setYearlyMode(yMode.value)"
>
@for (o of yearlyModeOpts; track o.value) {
<option
[value]="o.value"
[selected]="o.value === model().yearlyMode"
>
{{ o.label | translate }}
</option>
}
</select>
</div>
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE_DESCRIPTION | translate }}
</div>
@if (model().yearlyMode === 'DAY_OF_MONTH') {
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY | translate }}</label>
<div class="rb-toggles rb-toggles--days">
@for (d of dayGrid; track d) {
<button
type="button"
class="rb-tgl"
[class.active]="model().monthDays.includes(d)"
(click)="toggleMonthDay(d)"
>
{{ d }}
</button>
}
@for (nd of negativeDays; track nd.value) {
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="model().monthDays.includes(nd.value)"
(click)="toggleMonthDay(nd.value)"
>
{{ nd.label | translate }}
</button>
}
</div>
<input
class="rb-text"
type="text"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_PLACEHOLDER | translate"
[value]="model().monthDays.join(',')"
(change)="setMonthDays($any($event.target).value)"
/>
</div>
}
@if (model().yearlyMode === 'WEEKDAYS') {
<ng-container *ngTemplateOutlet="weekdaySetBlock"></ng-container>
}
}
<!-- Weekday set + "which occurrence" (BYSETPOS) toggles — shared by the
monthly and yearly WEEKDAYS modes (all bindings are frequency-agnostic). -->
<ng-template #weekdaySetBlock>
<div class="rb-row">
<div class="rb-toggles">
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="isSetPosEvery()"
(click)="clearSetPos()"
>
{{ T.F.TASK_REPEAT.F.RRULE_SETPOS_EVERY | translate }}
</button>
@for (o of ordinalOpts; track o.value) {
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="isSetPosActive(o.value)"
(click)="toggleSetPos(o.value)"
>
{{ o.label | translate }}
</button>
}
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="isSetPosCustom()"
(click)="toggleSetPosCustomMode()"
>
{{ T.F.TASK_REPEAT.F.ORD_CUSTOM | translate }}
</button>
</div>
@if (isSetPosCustom()) {
<input
#customWhich
class="rb-text rb-text--inline"
type="text"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYSETPOS_PLACEHOLDER | translate"
[value]="model().bySetPos"
(change)="setCustomBySetPos(customWhich.value)"
/>
}
<div class="rb-toggles">
@for (w of weekdays; track w.value) {
<button
type="button"
class="rb-tgl"
[class.active]="model().byDay.includes(w.value)"
[title]="w.full"
(click)="toggleDay(w.value)"
>
{{ w.short }}
</button>
}
</div>
</div>
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_BYSETPOS_DESCRIPTION | translate }}
</div>
</ng-template>
<!-- nth-weekday rows (monthly + yearly): per-weekday ordinals, e.g. the 3rd
Monday and 4th Sunday → BYDAY=3MO,4SU. One ordinal+weekday per line, + adds more. -->
@if (isNthMode()) {
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY | translate }}</label>
@for (nd of model().nthDays; track $index; let rowIdx = $index) {
<div class="rb-row">
<select
#pos
class="rb-sel"
(change)="setNthDayPos(rowIdx, pos.value)"
>
@for (o of availableOrdinalOpts(rowIdx); track o.value) {
<option
[value]="o.value"
[selected]="!isNthRowCustom(rowIdx) && o.value === nd.pos"
>
{{ o.label | translate }}
</option>
}
<option
[value]="ORD_CUSTOM"
[selected]="isNthRowCustom(rowIdx)"
>
{{ T.F.TASK_REPEAT.F.ORD_CUSTOM | translate }}
</option>
</select>
@if (isNthRowCustom(rowIdx)) {
<input
#customPos
class="rb-num"
type="number"
[min]="-nthPosBound()"
[max]="nthPosBound()"
[value]="nd.pos"
[title]="T.F.TASK_REPEAT.F.ORD_CUSTOM_DESCRIPTION | translate"
(change)="setNthDayCustomPos(rowIdx, customPos.value)"
/>
}
<div class="rb-toggles">
@for (w of weekdays; track w.value) {
<button
type="button"
class="rb-tgl"
[class.active]="nd.days.includes(w.value)"
[title]="w.full"
(click)="toggleNthDayWeekday(rowIdx, w.value)"
>
{{ w.short }}
</button>
}
</div>
@if (model().nthDays.length > 1) {
<button
type="button"
class="rb-tgl"
[title]="T.G.DELETE | translate"
(click)="removeNthDay(rowIdx)"
>
&times;
</button>
}
</div>
}
@if (canAddNthDay()) {
<button
type="button"
class="rb-tgl rb-tgl--wide"
(click)="addNthDay()"
>
+ {{ T.F.TASK_REPEAT.F.RRULE_ADD_NTH | translate }}
</button>
}
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY_DESCRIPTION | translate }}
</div>
</div>
}
<!-- In months (optional seasonal constraint, any frequency) -->
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYMONTH | translate }}</label>
<div class="rb-toggles rb-toggles--months">
@for (m of months; track m.value) {
<button
type="button"
class="rb-tgl"
[class.active]="model().byMonth.includes(m.value)"
[title]="m.full"
(click)="toggleMonth(m.value)"
>
{{ m.short }}
</button>
}
</div>
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_BYMONTH_DESCRIPTION | translate }}
</div>
</div>
<!-- Ends -->
<div class="rb-row">
<span class="rb-lbl">{{ T.F.TASK_REPEAT.F.RRULE_END | translate }}</span>
<select
#endSel
class="rb-sel rb-sel--wide"
(change)="setEndType(endSel.value)"
>
@for (o of endOpts; track o.value) {
<option
[value]="o.value"
[selected]="o.value === model().endType"
>
{{ o.label | translate }}
</option>
}
</select>
@if (model().endType === 'COUNT') {
<input
#countIn
class="rb-num"
type="number"
min="1"
max="1000"
[value]="model().count"
(input)="setCount(countIn.value)"
/>
}
@if (model().endType === 'UNTIL') {
<input
#untilIn
class="rb-date"
type="date"
[value]="model().until"
(input)="setUntil(untilIn.value)"
/>
}
</div>
<!-- Schedule type: fixed calendar dates vs. count the interval from completion -->
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE | translate }}</label>
<div class="rb-toggles">
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="!fromCompletion()"
(click)="setRepeatFromCompletion(false)"
>
{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_START | translate }}
</button>
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="fromCompletion()"
(click)="setRepeatFromCompletion(true)"
>
{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_COMPLETION | translate }}
</button>
</div>
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESCRIPTION | translate }}
</div>
</div>
<!-- NOTE: the live result/preview lives at the dialog bottom (just above the
action buttons), not here — see DialogEditTaskRepeatCfgComponent. -->
<!-- Projection slot: the dialog drops the occurrence calendar HERE, directly under
the "repeat every" line. The frequency-specific day selection ("On …") lives
inside Advanced below — the calendar is the primary day-picking surface. -->
<ng-content></ng-content>
<!-- Advanced -->
<collapsible
@ -407,11 +71,372 @@
(isExpandedChange)="setShowAdvanced($event)"
>
<div class="rb-adv">
<!-- "On …" — frequency-specific day selection. Lives at the TOP of Advanced;
the calendar above is the quick way, this is the precise way. -->
<!-- WEEKLY: weekday circles -->
@if (model().freq === 'WEEKLY') {
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYDAY | translate }}</label>
<div class="rb-toggles rb-weekrow">
@for (w of weekdays; track w.value) {
<button
type="button"
class="rb-tgl"
[class.active]="model().byDay.includes(w.value)"
[attr.aria-pressed]="model().byDay.includes(w.value)"
[title]="w.full"
(click)="toggleDay(w.value)"
>
{{ w.short }}
</button>
}
</div>
</div>
}
<!-- MONTHLY -->
@if (model().freq === 'MONTHLY') {
<div class="rb-row">
<span class="rb-lbl">{{
T.F.TASK_REPEAT.F.RRULE_MONTHLY_MODE | translate
}}</span>
<select
#mMode
class="rb-sel"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_MONTHLY_MODE | translate"
(change)="setMonthlyMode(mMode.value)"
>
@for (o of monthlyModeOpts; track o.value) {
<option
[value]="o.value"
[selected]="o.value === model().monthlyMode"
>
{{ o.label | translate }}
</option>
}
</select>
</div>
@if (model().monthlyMode === 'DAY_OF_MONTH') {
<div class="rb-field">
<div class="rb-toggles rb-toggles--days">
@for (d of dayGrid; track d) {
<button
type="button"
class="rb-tgl"
[class.active]="model().monthDays.includes(d)"
[attr.aria-pressed]="model().monthDays.includes(d)"
(click)="toggleMonthDay(d)"
>
{{ d }}
</button>
}
</div>
<div class="rb-toggles rb-day-extras">
@for (nd of negativeDays; track nd.value) {
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="model().monthDays.includes(nd.value)"
[attr.aria-pressed]="model().monthDays.includes(nd.value)"
(click)="toggleMonthDay(nd.value)"
>
{{ nd.label | translate }}
</button>
}
</div>
<button
type="button"
class="rb-tgl rb-tgl--wide rb-custom-toggle"
[class.active]="showCustomDays()"
[attr.aria-expanded]="showCustomDays()"
(click)="toggleCustomDays()"
>
{{ T.F.TASK_REPEAT.F.ORD_CUSTOM | translate }}
</button>
@if (showCustomDays()) {
<input
class="rb-text"
type="text"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY | translate"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_PLACEHOLDER | translate"
[value]="model().monthDays.join(',')"
(change)="setMonthDays($any($event.target).value)"
/>
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_DESCRIPTION | translate }}
</div>
}
</div>
}
@if (model().monthlyMode === 'WEEKDAYS') {
<ng-container *ngTemplateOutlet="weekdaySetBlock"></ng-container>
}
}
<!-- YEARLY -->
@if (model().freq === 'YEARLY') {
<div class="rb-row">
<span class="rb-lbl">{{
T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE | translate
}}</span>
<select
#yMode
class="rb-sel"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE | translate"
(change)="setYearlyMode(yMode.value)"
>
@for (o of yearlyModeOpts; track o.value) {
<option
[value]="o.value"
[selected]="o.value === model().yearlyMode"
>
{{ o.label | translate }}
</option>
}
</select>
</div>
@if (model().yearlyMode === 'DAY_OF_MONTH') {
<div class="rb-field">
<div class="rb-toggles rb-toggles--days">
@for (d of dayGrid; track d) {
<button
type="button"
class="rb-tgl"
[class.active]="model().monthDays.includes(d)"
[attr.aria-pressed]="model().monthDays.includes(d)"
(click)="toggleMonthDay(d)"
>
{{ d }}
</button>
}
</div>
<div class="rb-toggles rb-day-extras">
@for (nd of negativeDays; track nd.value) {
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="model().monthDays.includes(nd.value)"
[attr.aria-pressed]="model().monthDays.includes(nd.value)"
(click)="toggleMonthDay(nd.value)"
>
{{ nd.label | translate }}
</button>
}
</div>
<button
type="button"
class="rb-tgl rb-tgl--wide rb-custom-toggle"
[class.active]="showCustomDays()"
[attr.aria-expanded]="showCustomDays()"
(click)="toggleCustomDays()"
>
{{ T.F.TASK_REPEAT.F.ORD_CUSTOM | translate }}
</button>
@if (showCustomDays()) {
<input
class="rb-text"
type="text"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY | translate"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_PLACEHOLDER | translate"
[value]="model().monthDays.join(',')"
(change)="setMonthDays($any($event.target).value)"
/>
}
</div>
}
@if (model().yearlyMode === 'WEEKDAYS') {
<ng-container *ngTemplateOutlet="weekdaySetBlock"></ng-container>
}
}
<!-- Weekday set + "which occurrence" (BYSETPOS) toggles — shared by the
monthly and yearly WEEKDAYS modes (all bindings are frequency-agnostic). -->
<ng-template #weekdaySetBlock>
<div class="rb-field">
<div class="rb-toggles">
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="isSetPosEvery()"
[attr.aria-pressed]="isSetPosEvery()"
(click)="clearSetPos()"
>
{{ T.F.TASK_REPEAT.F.RRULE_SETPOS_EVERY | translate }}
</button>
@for (o of ordinalOpts; track o.value) {
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="isSetPosActive(o.value)"
[attr.aria-pressed]="isSetPosActive(o.value)"
(click)="toggleSetPos(o.value)"
>
{{ o.label | translate }}
</button>
}
<button
type="button"
class="rb-tgl rb-tgl--wide"
[class.active]="isSetPosCustom()"
[attr.aria-pressed]="isSetPosCustom()"
(click)="toggleSetPosCustomMode()"
>
{{ T.F.TASK_REPEAT.F.ORD_CUSTOM | translate }}
</button>
</div>
@if (isSetPosCustom()) {
<input
#customWhich
class="rb-text rb-text--inline"
type="text"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_BYSETPOS | translate"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYSETPOS_PLACEHOLDER | translate"
[value]="model().bySetPos"
(change)="setCustomBySetPos(customWhich.value)"
/>
}
<div class="rb-toggles rb-weekrow">
@for (w of weekdays; track w.value) {
<button
type="button"
class="rb-tgl"
[class.active]="model().byDay.includes(w.value)"
[attr.aria-pressed]="model().byDay.includes(w.value)"
[title]="w.full"
(click)="toggleDay(w.value)"
>
{{ w.short }}
</button>
}
</div>
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_BYSETPOS_DESCRIPTION | translate }}
</div>
</div>
</ng-template>
<!-- nth-weekday rows (monthly + yearly): per-weekday ordinals, e.g. the 3rd
Monday and 4th Sunday → BYDAY=3MO,4SU. One ordinal+weekday per line. -->
@if (isNthMode()) {
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY | translate }}</label>
@for (nd of model().nthDays; track $index; let rowIdx = $index) {
<div class="rb-row">
<select
#pos
class="rb-sel"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY | translate"
(change)="setNthDayPos(rowIdx, pos.value)"
>
@for (o of availableOrdinalOpts(rowIdx); track o.value) {
<option
[value]="o.value"
[selected]="!isNthRowCustom(rowIdx) && o.value === nd.pos"
>
{{ o.label | translate }}
</option>
}
<option
[value]="ORD_CUSTOM"
[selected]="isNthRowCustom(rowIdx)"
>
{{ T.F.TASK_REPEAT.F.ORD_CUSTOM | translate }}
</option>
</select>
@if (isNthRowCustom(rowIdx)) {
<input
#customPos
class="rb-num"
type="number"
[min]="-nthPosBound()"
[max]="nthPosBound()"
[value]="nd.pos"
[title]="T.F.TASK_REPEAT.F.ORD_CUSTOM_DESCRIPTION | translate"
(change)="setNthDayCustomPos(rowIdx, customPos.value)"
/>
}
<div class="rb-toggles rb-weekrow">
@for (w of weekdays; track w.value) {
<button
type="button"
class="rb-tgl"
[class.active]="nd.days.includes(w.value)"
[attr.aria-pressed]="nd.days.includes(w.value)"
[title]="w.full"
(click)="toggleNthDayWeekday(rowIdx, w.value)"
>
{{ w.short }}
</button>
}
</div>
@if (model().nthDays.length > 1) {
<button
type="button"
class="rb-tgl"
[title]="T.G.DELETE | translate"
(click)="removeNthDay(rowIdx)"
>
&times;
</button>
}
</div>
}
@if (canAddNthDay()) {
<button
type="button"
class="rb-tgl rb-tgl--wide"
(click)="addNthDay()"
>
+ {{ T.F.TASK_REPEAT.F.RRULE_ADD_NTH | translate }}
</button>
}
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY_DESCRIPTION | translate }}
</div>
</div>
}
<div class="rb-adv__sep"></div>
<!-- The dialog projects Ends + Schedule type here (Custom-only schedule
settings), under the "On …" selection. -->
<ng-content select="[recurrenceOptions]"></ng-content>
<div class="rb-adv__sep"></div>
@if (engineOff()) {
<div class="rb-hint rb-hint--engine-off">
{{ T.F.TASK_REPEAT.F.RRULE_ENGINE_OFF_ADVANCED | translate }}
</div>
}
<!-- In months (optional seasonal constraint, any frequency) -->
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYMONTH | translate }}</label>
<div class="rb-toggles rb-toggles--months">
@for (m of months; track m.value) {
<button
type="button"
class="rb-tgl"
[class.active]="model().byMonth.includes(m.value)"
[attr.aria-pressed]="model().byMonth.includes(m.value)"
[title]="m.full"
(click)="toggleMonth(m.value)"
>
{{ m.short }}
</button>
}
</div>
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_BYMONTH_DESCRIPTION | translate }}
</div>
</div>
<div class="rb-field">
<label>{{ T.F.TASK_REPEAT.F.RRULE_WKST | translate }}</label>
<select
#wkstSel
class="rb-sel rb-sel--wide"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_WKST | translate"
(change)="setWkst(wkstSel.value)"
>
<option
@ -442,6 +467,7 @@
#bwn
class="rb-text"
type="text"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_BYWEEKNO | translate"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYWEEKNO_PLACEHOLDER | translate"
[value]="model().byWeekNo"
(input)="setByWeekNo(bwn.value)"
@ -457,6 +483,7 @@
#byd
class="rb-text"
type="text"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_BYYEARDAY | translate"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYYEARDAY_PLACEHOLDER | translate"
[value]="model().byYearDay"
(input)="setByYearDay(byd.value)"
@ -473,6 +500,7 @@
#raw
class="rb-text rb-text--mono"
type="text"
[attr.aria-label]="T.F.TASK_REPEAT.F.RRULE_RAW | translate"
[placeholder]="T.F.TASK_REPEAT.F.RRULE_RAW_PLACEHOLDER | translate"
[value]="model().rawOverride"
(input)="setRawOverride(raw.value)"
@ -480,6 +508,21 @@
<div class="rb-hint">
{{ T.F.TASK_REPEAT.F.RRULE_RAW_DESCRIPTION | translate }}
</div>
<!-- The assembled rule the builder currently emits, with a copy
affordance — lives here next to the raw override so reading and
overriding the string happen in one place. -->
<div class="rb-built">
<code class="rb-built__expr">{{ builtRRule() }}</code>
<button
mat-icon-button
type="button"
[title]="T.F.WORKLOG.EXPORT.COPY_TO_CLIPBOARD | translate"
[attr.aria-label]="T.F.WORKLOG.EXPORT.COPY_TO_CLIPBOARD | translate"
(click)="copyRRule()"
>
<mat-icon>content_copy</mat-icon>
</button>
</div>
</div>
</div>
</collapsible>

View file

@ -8,36 +8,119 @@
gap: var(--s2);
}
// A sentence-style row: label + inline controls.
.rb-row {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--s);
}
.rb-lbl {
font-weight: bold;
opacity: 0.85;
// Row labels: sentence-case, medium weight, slightly muted, centered.
.rb-lbl,
.rb-field > label {
font-size: 13px;
font-weight: 600;
opacity: 0.75;
text-align: center;
}
.rb-field {
display: flex;
flex-direction: column;
gap: var(--s-quarter, 4px);
align-items: center;
gap: var(--s);
}
// Inline variant: label sits on the SAME line as its control (e.g. "Ends" +
// the three end-type buttons, "Schedule type" + the two options). Wraps only
// on a genuinely narrow surface.
.rb-field--inline {
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
> label {
font-weight: bold;
opacity: 0.85;
margin-right: var(--s-half);
}
}
// Shared control styling (native select / input themed to the app).
// "Repeat every" reads as one inline phrase: label + stepper + unit dropdown
// grouped close together (no space-between push).
.rb-row--repeat {
gap: var(--s);
}
.rb-repeat-controls {
display: inline-flex;
align-items: center;
gap: var(--s-half);
}
// --- Interval stepper ( N +) ---
.rb-stepper {
display: inline-flex;
align-items: center;
border: 1px solid var(--extra-border-color);
border-radius: var(--card-border-radius);
overflow: hidden;
}
.rb-step {
width: 38px;
height: 38px;
border: none;
background: transparent;
color: var(--text-color);
font-size: 20px;
line-height: 1;
cursor: pointer;
&:hover:not([disabled]) {
background: var(--bg-lightest);
}
&[disabled] {
opacity: 0.35;
cursor: default;
}
}
.rb-step-num {
width: 46px;
height: 38px;
border: none;
border-left: 1px solid var(--extra-border-color);
border-right: 1px solid var(--extra-border-color);
background: transparent;
color: var(--text-color);
text-align: center;
font-size: 15px;
font-weight: 600;
font-family: inherit;
-moz-appearance: textfield;
appearance: textfield;
&:focus {
outline: none;
}
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
// Segmented controls (end condition, schedule type) now use the shared
// `segmented-button-group` component no local segmented styles needed.
// --- Inputs / native selects ---
.rb-sel,
.rb-num,
.rb-text,
.rb-date {
height: 40px; // touch-friendly
height: 38px;
padding: 0 var(--s);
color: var(--text-color);
background: var(--bg, transparent);
@ -52,13 +135,41 @@
}
}
// Native select themed with a custom chevron so it doesn't look raw. A native
// <select> can't host a pseudo-element or inherit `currentColor` into a
// background data-URI, so the arrow color is baked into the SVG and swapped per
// theme (same approach as the native datepicker indicator).
.rb-sel {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 30px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23666' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
cursor: pointer;
:host-context(.isDarkTheme) & {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%23bbb' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
}
}
.rb-num {
width: 64px;
width: 56px;
text-align: center;
font-weight: 600;
}
.rb-sel--wide {
min-width: 180px;
min-width: 200px;
}
// Unit dropdown in the "Repeat every" line sized to its content, and matched
// to the stepper number (15px / 600) so the inline phrase reads uniform.
.rb-sel--freq {
min-width: 96px;
font-size: 15px;
font-weight: 600;
}
.rb-text {
@ -68,36 +179,51 @@
font-family: monospace;
}
// Sits inline next to a select within a row (e.g. the custom BYSETPOS input).
&--inline {
width: 110px;
}
}
// Toggle buttons for weekdays / months.
// --- Toggle chips (weekdays / months / day-of-month / segmented pills) ---
.rb-toggles {
display: flex;
flex-wrap: wrap;
gap: var(--s-quarter, 4px);
justify-content: center;
gap: var(--s-half);
&--months {
max-width: 320px;
}
// Calendar-style day-of-month grid (~7 per row).
// Day-of-month picker laid out as a 7-column calendar grid. Capped so 7
// columns fit a 375px-wide phone with margin, and shrinks below that.
&--days {
max-width: 300px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: var(--s-quarter, 4px);
width: 100%;
max-width: 322px;
}
}
.rb-tgl--wide {
min-width: auto;
padding: 0 var(--s2);
// "Last day / 2nd-last / 3rd-last" pills sit on their own row under the grid.
.rb-day-extras {
margin-top: var(--s-half);
}
// "custom…" reveal for the free-text day list quiet, set apart from the grid.
.rb-custom-toggle {
margin-top: var(--s-half);
opacity: 0.85;
}
.rb-weekrow {
gap: var(--s-half);
}
.rb-tgl {
min-width: 40px;
height: 40px; // touch-friendly tap target
min-width: 38px;
height: 38px;
padding: 0 var(--s);
touch-action: manipulation;
display: inline-flex;
@ -110,9 +236,9 @@
font-size: 13px;
cursor: pointer;
transition:
background 0.1s ease,
color 0.1s ease,
border-color 0.1s ease;
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
&:hover {
border-color: var(--c-primary);
@ -122,14 +248,94 @@
background: var(--c-primary);
border-color: var(--c-primary);
color: #fff;
font-weight: bold;
font-weight: 600;
}
}
// Wide pills read as a segmented option (occurrence-which, add row, etc).
.rb-tgl--wide {
min-width: auto;
padding: 0 var(--s2);
border-radius: 19px;
}
// Weekday letter chips circles (TickTick). Single-letter chips in weekday
// groups only not the day-of-month grid, month grid, or wide pills.
.rb-toggles:not(.rb-toggles--days):not(.rb-toggles--months) .rb-tgl:not(.rb-tgl--wide) {
min-width: 38px;
width: 38px;
height: 38px;
padding: 0;
border-radius: 50%;
}
// Day-of-month grid styled like the deadline modal's calendar: borderless
// circular cells, primary-filled when selected, subtle hover otherwise.
.rb-toggles--days .rb-tgl {
min-width: 0;
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
padding: 0;
font-size: 13px;
border: none;
border-radius: 50%;
&:hover:not(.active) {
background: var(--bg-lightest, rgba(127, 127, 127, 0.12));
}
}
.rb-ends {
display: flex;
// Stack the segmented control above its conditional COUNT/UNTIL input so the
// control keeps a stable full width a row layout let it (and its label
// wrapping) resize as the input appeared/disappeared, so the control
// "bounced" with each selection.
flex-direction: column;
align-items: center;
gap: var(--s);
}
.rb-hint {
font-size: 12px;
line-height: 1.4;
opacity: 0.65;
opacity: 0.6;
text-align: center;
}
// The assembled rule + copy button beneath the raw-override field.
.rb-built {
display: flex;
align-items: center;
gap: var(--s-half);
margin-top: var(--s-half);
&__expr {
font-family: monospace;
font-size: 12px;
opacity: 0.75;
overflow-wrap: anywhere;
min-width: 0;
}
}
// Engine-off messaging: the per-device advanced-recurrence flag is off, so
// parts of the rule won't drive scheduling — accented so it reads as a notice,
// not as one more field description.
.rb-engine-off {
font-size: 12px;
line-height: 1.4;
padding: var(--s) var(--s2);
border-left: 3px solid var(--c-warn, var(--c-primary));
background: var(--c-dark-10);
border-radius: var(--card-border-radius);
}
.rb-hint--engine-off {
opacity: 0.9;
border-left: 3px solid var(--c-warn, var(--c-primary));
padding-left: var(--s);
}
.rb-adv {
@ -137,4 +343,10 @@
flex-direction: column;
gap: var(--s2);
padding-top: var(--s);
// Divider between the projected Ends/Schedule block and the rrule-only
// advanced fields (BYMONTH / WKST / raw ).
&__sep {
border-top: 1px solid var(--separator-color, var(--extra-border-color));
}
}

View file

@ -2,24 +2,22 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RruleBuilderComponent } from './rrule-builder.component';
import { SnackService } from '../../../../core/snack/snack.service';
import { setRRuleEngineEnabled } from '../../../config/rrule-engine-flag';
describe('RruleBuilderComponent', () => {
let fixture: ComponentFixture<RruleBuilderComponent>;
let component: RruleBuilderComponent;
const setup = async (
rrule = '',
startDate = '2024-06-03',
repeatFromCompletion = false,
): Promise<void> => {
const setup = async (rrule = '', startDate = '2024-06-03'): Promise<void> => {
await TestBed.configureTestingModule({
imports: [RruleBuilderComponent, TranslateModule.forRoot(), NoopAnimationsModule],
providers: [{ provide: SnackService, useValue: { open: () => undefined } }],
}).compileComponents();
fixture = TestBed.createComponent(RruleBuilderComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('rrule', rrule);
fixture.componentRef.setInput('startDate', startDate);
fixture.componentRef.setInput('repeatFromCompletion', repeatFromCompletion);
fixture.detectChanges();
};
@ -30,6 +28,25 @@ describe('RruleBuilderComponent', () => {
expect(component.model().nthDays).toEqual([{ pos: 2, days: ['TU'] }]);
});
it('re-syncs the model when the rrule INPUT changes externally (calendar edit)', async () => {
await setup('FREQ=DAILY');
expect(component.model().freq).toBe('DAILY');
// The calendar mutates the shared rule via the dialog → the input changes.
fixture.componentRef.setInput('rrule', 'FREQ=WEEKLY;BYDAY=MO,WE');
fixture.detectChanges();
expect(component.model().freq).toBe('WEEKLY');
expect(component.model().byDay).toEqual(['MO', 'WE']);
});
it('ignores its own emission round-tripping back (no churn)', async () => {
await setup('FREQ=MONTHLY;BYDAY=2MO');
const before = component.model();
// Same rule structurally → the re-sync guard skips, model reference unchanged.
fixture.componentRef.setInput('rrule', 'FREQ=MONTHLY;BYDAY=2MO');
fixture.detectChanges();
expect(component.model()).toBe(before);
});
it('adds a second nth-weekday row and emits combined BYDAY (3MO,4SU)', async () => {
await setup('FREQ=MONTHLY;BYDAY=3MO');
const emitted: string[] = [];
@ -135,6 +152,10 @@ describe('RruleBuilderComponent', () => {
// own $index (the weekday index) shadowed the outer row index, so clicking
// anything but the first weekday targeted a wrong/non-existent row.
await setup('FREQ=MONTHLY;BYDAY=3MO');
// The "On …" selection now lives inside the (collapsed-by-default) Advanced
// section — expand it so its weekday buttons are in the DOM.
component.setShowAdvanced(true);
fixture.detectChanges();
const emitted: string[] = [];
component.rruleChange.subscribe((r) => emitted.push(r));
@ -183,8 +204,10 @@ describe('RruleBuilderComponent', () => {
component.rruleChange.subscribe((r) => emitted.push(r));
component.setFreq('YEARLY');
expect(component.model().byMonth).toEqual([6]);
// default yearly mode = on date → must carry BYMONTH to mean "once a year"
expect(emitted[emitted.length - 1]).toBe('FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=3');
// The day selection resets to none on a frequency switch, but the seeded
// BYMONTH still pins the rule to June (once a year on the DTSTART day) rather
// than letting a bare yearly rule expand across every month.
expect(emitted[emitted.length - 1]).toBe('FREQ=YEARLY;BYMONTH=6');
});
it('switching to YEARLY keeps an existing month selection', async () => {
@ -224,6 +247,34 @@ describe('RruleBuilderComponent', () => {
expect(component.model().bySetPos).toBe('');
});
it('changing frequency clears the previous frequencys day selection', async () => {
// A byDay left over from WEEKLY would survive into MONTHLY (where the
// weekday-set sub-mode shows it as picked) — a stale, invisible selection.
await setup('FREQ=WEEKLY;BYDAY=MO,WE');
expect(component.model().byDay).toEqual(['MO', 'WE']);
const emitted: string[] = [];
component.rruleChange.subscribe((r) => emitted.push(r));
component.setFreq('MONTHLY');
expect(component.model().byDay).toEqual([]);
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY');
});
it('switching monthly mode clears the previous modes day selection', async () => {
await setup('FREQ=MONTHLY;BYMONTHDAY=15');
expect(component.model().monthDays).toEqual([15]);
component.setMonthlyMode('WEEKDAYS');
// The day-of-month 15 must not linger into the weekday-set mode.
expect(component.model().monthDays).toEqual([]);
expect(component.model().byDay).toEqual([]);
});
it('switching monthly mode away from nth clears the nth rows', async () => {
await setup('FREQ=MONTHLY;BYDAY=2MO');
expect(component.model().monthlyMode).toBe('NTH_WEEKDAY');
component.setMonthlyMode('DAY_OF_MONTH');
expect(component.model().nthDays).toEqual([]);
});
it('a predefined set-position toggle closes the explicitly opened custom input', async () => {
await setup('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR');
component.toggleSetPosCustomMode(); // open custom input (empty value)
@ -306,18 +357,22 @@ describe('RruleBuilderComponent', () => {
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYMONTHDAY=1,15,-5');
});
it('initializes the schedule-type toggle from the repeatFromCompletion input', async () => {
await setup('FREQ=DAILY;INTERVAL=3', '2024-06-03', true);
expect(component.fromCompletion()).toBe(true);
// NOTE: Ends + Schedule type moved to the dialog level (they apply to presets
// too) — those behaviours are covered in the dialog spec now.
it('shows the engine-off notice while the flag is off', async () => {
setRRuleEngineEnabled(false);
await setup('FREQ=DAILY;COUNT=3');
expect(fixture.nativeElement.querySelector('.rb-engine-off')).toBeTruthy();
});
it('emits repeatFromCompletionChange when the schedule type is toggled', async () => {
await setup('FREQ=DAILY;INTERVAL=3');
expect(component.fromCompletion()).toBe(false);
const emitted: boolean[] = [];
component.repeatFromCompletionChange.subscribe((v) => emitted.push(v));
component.setRepeatFromCompletion(true);
expect(component.fromCompletion()).toBe(true);
expect(emitted[emitted.length - 1]).toBe(true);
it('hides the engine-off notice when the flag is on', async () => {
setRRuleEngineEnabled(true);
try {
await setup('FREQ=DAILY;COUNT=3');
expect(fixture.nativeElement.querySelector('.rb-engine-off')).toBeFalsy();
} finally {
setRRuleEngineEnabled(false);
}
});
});

View file

@ -3,15 +3,21 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
OnInit,
output,
signal,
untracked,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { CollapsibleComponent } from '../../../../ui/collapsible/collapsible.component';
import { SnackService } from '../../../../core/snack/snack.service';
import { RRuleFeatureFlagService } from '../../../config/rrule-feature-flag.service';
import { T } from '../../../../t.const';
import { dateStrToUtcDate } from '../../../../util/date-str-to-utc-date';
import {
@ -77,24 +83,43 @@ const MONTH_T_KEYS = [
templateUrl: './rrule-builder.component.html',
styleUrls: ['./rrule-builder.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TranslatePipe, CollapsibleComponent, NgTemplateOutlet],
imports: [
TranslatePipe,
CollapsibleComponent,
NgTemplateOutlet,
MatIcon,
MatIconButton,
],
})
export class RruleBuilderComponent implements OnInit {
private _translateService = inject(TranslateService);
private _snackService = inject(SnackService);
private _rruleFlag = inject(RRuleFeatureFlagService);
/** With the per-device engine off, the saved rule only drives scheduling
* through its simplified legacy mirror several fields then describe
* behavior that won't happen, so their hints must say so. */
readonly engineOff = computed(() => !this._rruleFlag.isEnabled());
T: typeof T = T;
rrule = input<string>('');
startDate = input<string | undefined>(undefined);
repeatFromCompletion = input<boolean>(false);
rruleChange = output<string>();
repeatFromCompletionChange = output<boolean>();
private _model = signal<RRuleFormModel>(defaultRRuleFormModel());
model = this._model.asReadonly();
// Orthogonal to the rrule string: re-anchors the interval to the completion
// day. Owned here (not in the rrule body) and surfaced to the dialog via output.
private _fromCompletion = signal(false);
fromCompletion = this._fromCompletion.asReadonly();
/** The assembled rule the exact string the builder emits to the dialog;
* shown beneath the raw-override field with a copy affordance. */
readonly builtRRule = computed(() => formModelToRRule(this._model()));
async copyRRule(): Promise<void> {
try {
await navigator.clipboard.writeText(this.builtRRule());
this._snackService.open(T.GLOBAL_SNACK.COPY_TO_CLIPPBOARD);
} catch {
// Clipboard blocked (permissions / insecure context) — the string stays
// visible and selectable, so there is nothing heavier worth doing here.
}
}
// Toggle data — short label for the button, full name for the tooltip.
weekdays = RRULE_WEEKDAYS.map((value, i) => {
@ -106,12 +131,30 @@ export class RruleBuilderComponent implements OnInit {
return { value: i + 1, full, short: full.slice(0, 3) };
});
freqOpts: SelectOpt<RRuleFormModel['freq']>[] = [
{ value: 'DAILY', label: T.F.TASK_REPEAT.F.C_DAY },
{ value: 'WEEKLY', label: T.F.TASK_REPEAT.F.C_WEEK },
{ value: 'MONTHLY', label: T.F.TASK_REPEAT.F.C_MONTH },
{ value: 'YEARLY', label: T.F.TASK_REPEAT.F.C_YEAR },
];
// Unit dropdown labels pluralize with the interval ("Day" vs "Days") — an
// interval > 1 reads "Repeat every 2 Weeks". Computed so it tracks the
// stepper live.
freqOpts = computed<SelectOpt<RRuleFormModel['freq']>[]>(() => {
const plural = this._model().interval > 1;
return [
{
value: 'DAILY',
label: plural ? T.F.TASK_REPEAT.F.C_DAYS : T.F.TASK_REPEAT.F.C_DAY,
},
{
value: 'WEEKLY',
label: plural ? T.F.TASK_REPEAT.F.C_WEEKS : T.F.TASK_REPEAT.F.C_WEEK,
},
{
value: 'MONTHLY',
label: plural ? T.F.TASK_REPEAT.F.C_MONTHS : T.F.TASK_REPEAT.F.C_MONTH,
},
{
value: 'YEARLY',
label: plural ? T.F.TASK_REPEAT.F.C_YEARS : T.F.TASK_REPEAT.F.C_YEAR,
},
];
});
monthlyModeOpts: SelectOpt<RRuleFormModel['monthlyMode']>[] = [
{ value: 'DAY_OF_MONTH', label: T.F.TASK_REPEAT.F.RRULE_MODE_DAY_OF_MONTH },
{ value: 'NTH_WEEKDAY', label: T.F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY },
@ -138,11 +181,8 @@ export class RruleBuilderComponent implements OnInit {
];
/** Sentinel select value for the per-row custom ordinal input. */
readonly ORD_CUSTOM = 'CUSTOM';
endOpts: SelectOpt<RRuleFormModel['endType']>[] = [
{ value: 'NEVER', label: T.F.TASK_REPEAT.F.RRULE_END_NEVER },
{ value: 'UNTIL', label: T.F.TASK_REPEAT.F.RRULE_END_UNTIL },
{ value: 'COUNT', label: T.F.TASK_REPEAT.F.RRULE_END_COUNT },
];
// Ends + Schedule type now live at the DIALOG level (they apply to presets
// too, not just the custom builder) — see dialog-edit-task-repeat-cfg.
weekdaySelectOpts: SelectOpt<RRuleWeekday>[] = this.weekdays.map((w) => ({
value: w.value,
label: w.full,
@ -153,6 +193,31 @@ export class RruleBuilderComponent implements OnInit {
// no change-after-checked) so a fresh builder gives the dialog a valid rrule
// even before the user touches anything.
afterNextRender(() => this.rruleChange.emit(formModelToRRule(this._model())));
// Re-sync from the `rrule` INPUT when it changes externally (the calendar now
// edits the same rule — see rrule-calendar-ops.util). Only `rrule()` is
// tracked; the body is untracked so internal model edits don't re-enter.
// Skip when the incoming rule is STRUCTURALLY what the builder already shows
// (its own emission round-tripping back) — that's the feedback-loop guard.
effect(() => {
const incoming = this.rrule();
untracked(() => {
const ref = this.startDate()
? dateStrToUtcDate(this.startDate() as string)
: new Date();
const incomingModel = rruleToFormModel(incoming, ref);
if (formModelToRRule(incomingModel) === formModelToRRule(this._model())) {
return;
}
// Adopt the external rule. UI-only reveal state is re-derived from the new
// model; `showAdvanced` is kept open if it was (the user expanded it).
this._model.set({
...incomingModel,
showAdvanced: this._model().showAdvanced || incomingModel.showAdvanced,
});
this.showCustomDays.set(this._hasOffGridDays(incomingModel.monthDays));
});
});
}
// Start month (1..12) — seeds BYMONTH when switching to YEARLY (see setFreq).
@ -164,7 +229,8 @@ export class RruleBuilderComponent implements OnInit {
: new Date();
this._refMonth = ref.getMonth() + 1;
this._model.set(rruleToFormModel(this.rrule(), ref));
this._fromCompletion.set(this.repeatFromCompletion());
// Keep an existing custom day list visible (the grid can't represent it).
this.showCustomDays.set(this._hasOffGridDays(this._model().monthDays));
}
private _patch(patch: Partial<RRuleFormModel>): void {
@ -183,16 +249,29 @@ export class RruleBuilderComponent implements OnInit {
return sd ? dateStrToUtcDate(sd).getMonth() + 1 : this._refMonth;
}
// A blank "On …" day selection — every day-pattern field zeroed. Returns fresh
// arrays each call so no two models ever share a reference.
private _blankOnSelection(): Partial<RRuleFormModel> {
return { byDay: [], monthDays: [], nthDays: [], bySetPos: '' };
}
// The view-only state that mirrors a day selection (custom inputs, expanded
// rows) — reset alongside the model so nothing stale lingers after a switch.
private _resetOnSelectionViewState(): void {
this._customSetPos.set(false);
this._customNthRows.set(new Set());
this.showCustomDays.set(false);
}
// --- field setters (kept out of the template for type-safety) ---
setFreq(v: string): void {
const freq = v as RRuleFormModel['freq'];
const prev = this._model().freq;
if (freq === prev) return;
// Frequency switch starts the occurrence narrowing clean — a BYSETPOS left
// over from another frequency's weekday-set mode would silently narrow the
// new rule with no UI to see or clear it.
this._customSetPos.set(false);
const patch: Partial<RRuleFormModel> = { freq, bySetPos: '' };
// A frequency switch starts the day selection clean: a byDay / monthDays /
// nthDays / BYSETPOS left over from the previous frequency would silently
// narrow (or dead-end) the new rule with no matching control to surface it.
this._resetOnSelectionViewState();
const patch: Partial<RRuleFormModel> = { freq, ...this._blankOnSelection() };
if (freq === 'YEARLY' && !this._model().byMonth.length) {
// YEARLY date/weekday modes need BYMONTH: per RFC 5545, FREQ=YEARLY with
// a bare BYMONTHDAY expands across every month — i.e. fires monthly.
@ -209,17 +288,6 @@ export class RruleBuilderComponent implements OnInit {
}
this._seededMonth = null;
}
if (freq === 'MONTHLY') {
// Monthly nth ordinals only reach ±5 — clamp custom poses set in YEARLY.
const cur = this._model().nthDays;
const clamped = cur.map((d) => ({
...d,
pos: Math.max(-5, Math.min(5, d.pos)),
}));
if (clamped.some((d, idx) => d.pos !== cur[idx].pos)) {
patch.nthDays = clamped;
}
}
this._patch(patch);
}
setInterval(v: string): void {
@ -234,16 +302,22 @@ export class RruleBuilderComponent implements OnInit {
const cur = this._model().byMonth;
this._patch({ byMonth: cur.includes(m) ? cur.filter((x) => x !== m) : [...cur, m] });
}
// Mode switches start the occurrence narrowing clean: a BYSETPOS left over
// from the weekday-set mode would silently narrow a day-of-month rule (e.g.
// BYMONTHDAY=15;BYSETPOS=2 = never fires) with no UI to see or clear it.
// Mode switches start the day selection clean: the previous sub-mode's days
// (a BYMONTHDAY=15, a weekday set, an nth row, a leftover BYSETPOS) don't
// belong to the new mode and would silently narrow or dead-end it.
setMonthlyMode(v: string): void {
this._customSetPos.set(false);
this._patch({ monthlyMode: v as RRuleFormModel['monthlyMode'], bySetPos: '' });
this._resetOnSelectionViewState();
this._patch({
monthlyMode: v as RRuleFormModel['monthlyMode'],
...this._blankOnSelection(),
});
}
setYearlyMode(v: string): void {
this._customSetPos.set(false);
this._patch({ yearlyMode: v as RRuleFormModel['yearlyMode'], bySetPos: '' });
this._resetOnSelectionViewState();
this._patch({
yearlyMode: v as RRuleFormModel['yearlyMode'],
...this._blankOnSelection(),
});
}
// --- nth-weekday rows (per-weekday ordinals → BYDAY=3MO,4SU) ---
/** True while either the monthly or yearly nth-weekday mode is active. */
@ -349,14 +423,18 @@ export class RruleBuilderComponent implements OnInit {
setMonthDays(v: string): void {
this._patch({ monthDays: parseIntList(v, 31) });
}
setEndType(v: string): void {
this._patch({ endType: v as RRuleFormModel['endType'] });
// The free-text day list (e.g. "1,15,-5") is hidden behind a "custom…" button:
// the grid (1..31) + last-day chips cover the common cases. Revealed on demand,
// its input binds `monthDays`, so it opens pre-filled with the selected days.
showCustomDays = signal(false);
toggleCustomDays(): void {
this.showCustomDays.update((v) => !v);
}
setCount(v: string): void {
this._patch({ count: Math.max(1, Math.floor(+v) || 1) });
}
setUntil(v: string): void {
this._patch({ until: v });
/** Days neither the grid (1..31) nor the last-day chips can show their
* presence means an existing rule used the custom list, so reveal it. */
private _hasOffGridDays(days: number[]): boolean {
const chipVals = new Set(this.negativeDays.map((n) => n.value));
return days.some((d) => !((d >= 1 && d <= 31) || chipVals.has(d)));
}
setShowAdvanced(v: boolean): void {
this._patch({ showAdvanced: v });
@ -428,8 +506,4 @@ export class RruleBuilderComponent implements OnInit {
setRawOverride(v: string): void {
this._patch({ rawOverride: v });
}
setRepeatFromCompletion(v: boolean): void {
this._fromCompletion.set(v);
this.repeatFromCompletionChange.emit(v);
}
}

View file

@ -78,48 +78,7 @@ describe('TaskRepeatCfgFormConfig', () => {
// legacy Custom UI — the RRULE builder owns that toggle now (covered by
// rrule-builder.component.spec).
describe('quickSetting change handler', () => {
const getChangeHandler = (): ((field: unknown, event: unknown) => void) => {
const field = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
(f) => f.key === 'quickSetting',
);
return field!.templateOptions!.change as (field: unknown, event: unknown) => void;
};
const callWith = (
startDate: string | undefined,
quickSetting: string,
): Record<string, unknown> => {
let patched: Record<string, unknown> = {};
const field = {
model: { startDate },
form: {
patchValue: (v: Record<string, unknown>) => {
patched = v;
},
},
};
getChangeHandler()(field, { value: quickSetting });
return patched;
};
it('uses the selected start date for date-writing presets (not today)', () => {
// Regression: without the reference date, MONTHLY_CURRENT_DATE stamped
// *today* into the model, overwriting a user-picked future anchor.
const patched = callWith('2099-09-15', 'MONTHLY_CURRENT_DATE');
expect(patched['startDate']).toBe('2099-09-15');
});
it('uses the selected start date for the weekday flags of weekly presets', () => {
// 2099-09-14 is a Monday.
const patched = callWith('2099-09-14', 'WEEKLY_CURRENT_WEEKDAY');
expect(patched['monday']).toBe(true);
expect(patched['tuesday']).toBe(false);
});
it('falls back to today when no start date is set', () => {
const patched = callWith(undefined, 'MONTHLY_CURRENT_DATE');
expect(typeof patched['startDate']).toBe('string');
});
});
// NOTE: the quickSetting `change` handler was removed with the formly select.
// Its reference-date behaviour (#5806) now lives in the dialog component's
// onQuickSettingSelect — covered in dialog-edit-task-repeat-cfg.component.spec.
});

View file

@ -3,30 +3,9 @@ import { T } from '../../../t.const';
import { isValidSplitTime } from '../../../util/is-valid-split-time';
import { TASK_REMINDER_OPTIONS } from '../../planner/dialog-schedule-task/task-reminder-options.const';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
import { RepeatQuickSetting, TaskRepeatCfg } from '../task-repeat-cfg.model';
import { getQuickSettingUpdates } from './get-quick-setting-updates';
import { TaskReminderOptionId } from '../../tasks/task.model';
const updateParent = (
field: FormlyFieldConfig,
changes: Partial<TaskRepeatCfg>,
): void => {
// possibly better?
field.form?.patchValue({
// ...field.parent.model,
...changes,
} as any);
};
export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
{
key: 'title',
type: 'input',
templateOptions: {
label: T.F.TASK_REPEAT.F.TITLE,
},
},
{
key: 'startDate',
type: 'date',
@ -40,43 +19,22 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
},
parsers: [(val: unknown) => (val instanceof Date ? getDbDateStr(val) : val)],
},
{
key: 'quickSetting',
type: 'select',
defaultValue: 'DAILY',
templateOptions: {
required: true,
label: T.F.TASK_REPEAT.F.QUICK_SETTING,
// NOTE replaced in component to allow for dynamic translation
options: [],
change: (field, event) => {
// Pass the selected start date as the reference, else date-writing
// presets (MONTHLY_CURRENT_DATE etc.) stamp *today* into the model and
// silently overwrite a user-picked future anchor (save-time recompute
// then reads the overwritten value).
const sd = (field.model as { startDate?: string | Date } | undefined)?.startDate;
const referenceDate =
sd instanceof Date ? sd : sd ? dateStrToUtcDate(sd) : undefined;
const updatesForQuickSetting = getQuickSettingUpdates(
event.value as RepeatQuickSetting,
referenceDate,
);
if (updatesForQuickSetting) {
// NOTE: for some reason this doesn't update the model value, just the view value :(
updateParent(field, updatesForQuickSetting);
}
},
},
},
// REPEAT CFG END
// NOTE: the legacy "Custom" recurrence container (repeat-every / cycle /
// weekday / monthly-anchor controls) was removed — the RRULE builder replaces
// it. Existing legacy cfgs are migrated to RRULE on open
// (legacyTaskRepeatCfgToRRule + _processQuickSettingForDate).
// NOTE: `quickSetting` is no longer a formly field — it is driven by the
// TickTick-style chip picker (repeat-freq-picker) in the dialog component
// (onQuickSettingSelect / quickSettingOptions). The legacy "Custom" container
// was already replaced by the RRULE builder; legacy cfgs migrate to RRULE on
// open (legacyTaskRepeatCfgToRRule + _processQuickSettingForDate).
];
export const TASK_REPEAT_CFG_ADVANCED_FORM_CFG: FormlyFieldConfig[] = [
{
key: 'title',
type: 'input',
templateOptions: {
label: T.F.TASK_REPEAT.F.TITLE,
},
},
{
key: 'defaultEstimate',
type: 'duration',

View file

@ -15,10 +15,17 @@
</div>
}
@if (heatmapData(); as data) {
<heatmap
<heatmap-switcher
[data]="data"
[label]=""
[showLegend]="false"
[scrollToEnd]="true"
[dayMap]="data.dayMap"
[rangeStart]="data.rangeStart"
[rangeEnd]="data.rangeEnd"
legendMode="intensity"
persistKey="repeat-history"
[navLabel]="selectedYear().toString()"
[canNavPrev]="canPrevYear()"
[canNavNext]="canNextYear()"
(navPrev)="prevYear()"
(navNext)="nextYear()"
/>
}

View file

@ -1,6 +1,5 @@
:host {
display: block;
max-width: 500px;
margin-bottom: var(--s2);
}
@ -8,6 +7,7 @@
display: flex;
gap: var(--s2);
flex-wrap: wrap;
justify-content: center;
font-size: 13px;
margin-bottom: var(--s);
}

View file

@ -0,0 +1,262 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { DateAdapter } from '@angular/material/core';
import { TranslateModule } from '@ngx-translate/core';
import { RepeatTaskHeatmapComponent } from './repeat-task-heatmap.component';
import { TaskService } from '../../tasks/task.service';
import { TaskArchiveService } from '../../archive/task-archive.service';
import { TaskRepeatCfgService } from '../task-repeat-cfg.service';
import { Task } from '../../tasks/task.model';
import { TaskRepeatCfg } from '../task-repeat-cfg.model';
import { setRRuleEngineEnabled } from '../../config/rrule-engine-flag';
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Built from entries — date-string object-literal keys trip the
// naming-convention lint rule.
const task = (timeSpent: [string, number][]): Task =>
({
id: 't1',
repeatCfgId: 'CFG1',
timeSpentOnDay: Object.fromEntries(timeSpent),
}) as unknown as Task;
const setup = async (
tasks: Task[],
cfg: Partial<TaskRepeatCfg> = {},
): Promise<ComponentFixture<RepeatTaskHeatmapComponent>> => {
TestBed.configureTestingModule({
imports: [RepeatTaskHeatmapComponent, TranslateModule.forRoot()],
providers: [
{ provide: TaskService, useValue: { allTasks$: of(tasks) } },
{
provide: TaskArchiveService,
useValue: { load: () => Promise.resolve({ ids: [], entities: {} }) },
},
{
provide: TaskRepeatCfgService,
useValue: { getTaskRepeatCfgById$: () => of({ id: 'CFG1', ...cfg }) },
},
{
provide: DateAdapter,
useValue: {
getFirstDayOfWeek: () => 0,
getMonthNames: () => MONTHS,
getDayOfWeekNames: () => WEEKDAYS,
},
},
],
})
.overrideComponent(RepeatTaskHeatmapComponent, {
// Logic-only tests — skip rendering the switcher/heatmap children.
set: { template: '<div></div>' },
})
.compileComponents();
const fixture = TestBed.createComponent(RepeatTaskHeatmapComponent);
fixture.componentRef.setInput('repeatCfgId', 'CFG1');
fixture.detectChanges();
// _loadTasksForRepeatCfg resolves async — flush the microtask queue.
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
return fixture;
};
describe('RepeatTaskHeatmapComponent year selection', () => {
beforeEach(() => setRRuleEngineEnabled(false));
it('defaults to the newest year WITH data when the current year has none', async () => {
// Regression: a cfg whose only history is in older years opened on an
// empty current year with heatmapData() null — no heatmap, no year nav,
// history unreachable.
const fixture = await setup([task([['2024-03-05', 3_600_000]])]);
const c = fixture.componentInstance;
expect(c.availableYears()).toEqual([2024]);
expect(c.selectedYear()).toBe(2024);
expect(c.heatmapData()).not.toBeNull();
});
it('offers only years that render something (flag off → no empty current year)', async () => {
const fixture = await setup([
task([
['2023-01-05', 1_000],
['2024-03-05', 3_600_000],
]),
]);
const c = fixture.componentInstance;
expect(c.availableYears()).toEqual([2024, 2023]);
expect(c.availableYears()).not.toContain(new Date().getFullYear());
});
it('falls back to the current year when there is no data at all', async () => {
const fixture = await setup([]);
const c = fixture.componentInstance;
expect(c.selectedYear()).toBe(new Date().getFullYear());
});
it('includes the current year when the projection overlay can mark it (flag on + valid rrule)', async () => {
setRRuleEngineEnabled(true);
try {
const fixture = await setup([task([['2024-03-05', 3_600_000]])], {
rrule: 'FREQ=WEEKLY;BYDAY=MO',
startDate: '2024-01-01',
} as Partial<TaskRepeatCfg>);
const c = fixture.componentInstance;
expect(c.availableYears()).toEqual([new Date().getFullYear(), 2024]);
} finally {
setRRuleEngineEnabled(false);
}
});
it('hides cells for days the task is not scheduled on (streak view, flag on)', async () => {
setRRuleEngineEnabled(true);
try {
const fixture = await setup([task([['2024-03-04', 3_600_000]])], {
rrule: 'FREQ=WEEKLY;BYDAY=MO',
startDate: '2024-01-01',
} as Partial<TaskRepeatCfg>);
const c = fixture.componentInstance;
// Defaults to the current (projection-capable) year; only Mondays —
// scheduled days — survive in the map, so off-schedule days render as
// transparent placeholders instead of grey "missed" cells.
const dayMap = c.heatmapData()!.dayMap;
expect(dayMap.size).toBeGreaterThan(50);
for (const d of dayMap.values()) {
expect(d.date.getDay()).withContext(d.dateStr).toBe(1);
}
} finally {
setRRuleEngineEnabled(false);
}
});
it('keeps off-schedule days that carry tracked time', async () => {
setRRuleEngineEnabled(true);
try {
// 2024-03-05 is a Tuesday — real tracked time on a day the rule never
// fires must stay visible (instance moved / done off-schedule).
const fixture = await setup([task([['2024-03-05', 3_600_000]])], {
rrule: 'FREQ=WEEKLY;BYDAY=MO',
startDate: '2024-01-01',
} as Partial<TaskRepeatCfg>);
const c = fixture.componentInstance;
c.prevYear(); // current year is the default — navigate to the data year
expect(c.selectedYear()).toBe(2024);
const dayMap = c.heatmapData()!.dayMap;
expect(dayMap.has('2024-03-05')).toBe(true); // tracked Tuesday stays
expect(dayMap.has('2024-03-12')).toBe(false); // bare Tuesday is hidden
expect(dayMap.has('2024-03-11')).toBe(true); // scheduled Monday stays
} finally {
setRRuleEngineEnabled(false);
}
});
it('hides off-schedule days for a legacy cfg WITHOUT the rrule engine (flag off)', async () => {
// Regression (curator round 2): the streak hide used to be gated behind the
// experimental engine, so legacy `repeatCycle` tasks — every normal user —
// saw a full grey grid. The overlay now runs off the legacy engine itself.
const fixture = await setup([task([['2024-03-04', 3_600_000]])], {
repeatCycle: 'WEEKLY',
repeatEvery: 1,
monday: true,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
startDate: '2024-01-01',
} as Partial<TaskRepeatCfg>);
const c = fixture.componentInstance;
// Defaults to the current (projection-capable) year; only Mondays survive,
// so off-schedule days render as transparent placeholders.
const dayMap = c.heatmapData()!.dayMap;
expect(dayMap.size).toBeGreaterThan(40);
let projectedCount = 0;
for (const d of dayMap.values()) {
expect(d.date.getDay()).withContext(d.dateStr).toBe(1);
if (d.isProjected) {
projectedCount++;
}
}
// Upcoming Mondays this year are projected (outlined) cells.
expect(projectedCount).toBeGreaterThan(0);
});
it('adds the current year to the nav for a legacy recurring cfg (flag off)', async () => {
// History only in 2024, but the legacy weekly schedule projects into the
// current year — which must therefore be reachable.
const fixture = await setup([task([['2024-03-04', 3_600_000]])], {
repeatCycle: 'WEEKLY',
repeatEvery: 1,
monday: true,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
startDate: '2024-01-01',
} as Partial<TaskRepeatCfg>);
const c = fixture.componentInstance;
expect(c.availableYears()).toContain(new Date().getFullYear());
});
it('keeps a legacy off-schedule day that carries tracked time (flag off)', async () => {
// 2024-03-05 is a Tuesday — the weekly-Monday rule never fires there, but
// real tracked time must stay visible (instance moved / done off-schedule).
const fixture = await setup([task([['2024-03-05', 3_600_000]])], {
repeatCycle: 'WEEKLY',
repeatEvery: 1,
monday: true,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
startDate: '2024-01-01',
} as Partial<TaskRepeatCfg>);
const c = fixture.componentInstance;
c.prevYear(); // current year is the default — navigate to the data year
expect(c.selectedYear()).toBe(2024);
const dayMap = c.heatmapData()!.dayMap;
expect(dayMap.has('2024-03-05')).toBe(true); // tracked Tuesday stays
expect(dayMap.has('2024-03-12')).toBe(false); // bare Tuesday is hidden
expect(dayMap.has('2024-03-04')).toBe(true); // scheduled Monday stays
});
it('keeps an empty navigated year rendered while other years exist (no stranding)', async () => {
setRRuleEngineEnabled(true);
try {
// Projection puts the current year in the list, but a rule whose UNTIL
// already passed projects nothing — the view must stay up (empty grid +
// nav) instead of tearing down the way back.
const fixture = await setup([task([['2024-03-05', 3_600_000]])], {
rrule: 'FREQ=WEEKLY;BYDAY=MO;UNTIL=20240601T000000Z',
startDate: '2024-01-01',
} as Partial<TaskRepeatCfg>);
const c = fixture.componentInstance;
expect(c.availableYears().length).toBeGreaterThan(1);
c.nextYear(); // navigate to the (empty) current year
expect(c.selectedYear()).toBe(new Date().getFullYear());
expect(c.heatmapData()).not.toBeNull();
c.prevYear(); // and back to the data
expect(c.selectedYear()).toBe(2024);
} finally {
setRRuleEngineEnabled(false);
}
});
});

View file

@ -4,6 +4,7 @@ import {
computed,
inject,
input,
signal,
} from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { TaskService } from '../../tasks/task.service';
@ -12,30 +13,40 @@ import { from } from 'rxjs';
import { filter, first, switchMap } from 'rxjs/operators';
import { Task } from '../../tasks/task.model';
import { DateAdapter } from '@angular/material/core';
import { DayData, HeatmapViewData } from '../../../ui/heatmap/heatmap.component';
import { HeatmapSwitcherComponent } from '../../../ui/heatmap/heatmap-switcher.component';
import {
DayData,
WeekData,
HeatmapData,
HeatmapComponent,
} from '../../../ui/heatmap/heatmap.component';
buildHeatmapMonths,
buildHeatmapWeeks,
heatmapHoursTotal,
} from '../../../ui/heatmap/build-heatmap-data.util';
import { T } from '../../../t.const';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { TranslateModule } from '@ngx-translate/core';
import { calcRepeatTaskSeriesTimeSpent } from '../calc-repeat-task-series-time-spent.util';
import { msToString } from '../../../ui/duration/ms-to-string.pipe';
import { TaskRepeatCfgService } from '../task-repeat-cfg.service';
import { TaskRepeatCfg } from '../task-repeat-cfg.model';
import { getRRuleOccurrencesInRange, isRRuleValid } from '../store/rrule-occurrence.util';
import { getNextRepeatOccurrence } from '../store/get-next-repeat-occurrence.util';
import { getEffectiveRepeatStartDate } from '../store/get-effective-repeat-start-date.util';
import { isRRuleEngineEnabled } from '../../config/rrule-engine-flag';
import { nextYearOf, prevYearOf } from '../../../ui/heatmap/year-nav.util';
@Component({
selector: 'repeat-task-heatmap',
templateUrl: './repeat-task-heatmap.component.html',
styleUrls: ['./repeat-task-heatmap.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [HeatmapComponent, TranslateModule],
imports: [HeatmapSwitcherComponent, TranslateModule],
})
export class RepeatTaskHeatmapComponent {
readonly T = T;
private readonly _taskService = inject(TaskService);
private readonly _taskArchiveService = inject(TaskArchiveService);
private readonly _dateAdapter = inject(DateAdapter);
private readonly _taskRepeatCfgService = inject(TaskRepeatCfgService);
readonly repeatCfgId = input.required<string>();
@ -47,9 +58,109 @@ export class RepeatTaskHeatmapComponent {
{ initialValue: null },
);
// The cfg itself — drives the projected future occurrences (Phase 2). The
// overlay is only rendered when the RRULE engine flag is on; otherwise the
// heatmap stays the history-only view.
private readonly _loadedCfg = toSignal(
toObservable(this.repeatCfgId).pipe(
filter((id): id is string => !!id),
switchMap((repeatCfgId) =>
this._taskRepeatCfgService.getTaskRepeatCfgById$(repeatCfgId).pipe(first()),
),
),
{ initialValue: null },
);
// Year filter: navigate the series one calendar year at a time. Years with
// tracked data are reachable; the current year joins them only when the
// projection overlay can put marks on it (else it's just an empty view).
private readonly _userSelectedYear = signal<number | null>(null);
readonly selectedYear = computed<number>(() => {
const sel = this._userSelectedYear();
const years = this.availableYears();
if (sel !== null && years.includes(sel)) {
return sel;
}
// Default to the newest year that actually renders something — a cfg whose
// only history is in older years must not open on an empty current year
// with the nav gone (that made the existing history unreachable).
return years[0] ?? new Date().getFullYear();
});
readonly availableYears = computed<number[]>(() => {
const years = new Set<number>();
for (const task of this._loadedTasks() ?? []) {
// Zero-value entries (start/stop tracking instantly) don't make a year's
// heatmap render (`hasData` needs timeSpent > 0) — offering such a year
// here would navigate to an empty view with no way back.
for (const [dateStr, timeSpent] of Object.entries(task.timeSpentOnDay ?? {})) {
const y = +dateStr.slice(0, 4);
if (y && timeSpent > 0) {
years.add(y);
}
}
}
const cfg = this._loadedCfg();
// The current year joins the navigable set when the schedule overlay is
// active for this cfg — i.e. it can put (at least structurally) marks on the
// current year. That now holds for ANY recurring cfg, not only flag-on RRULE
// ones: the streak/projection overlay renders for legacy `repeatCycle` cfgs
// too (see `_buildHeatmapData`). A cfg that can't enumerate a schedule (no
// rrule, no usable repeatEvery) adds nothing, so a history-only series still
// opens on its newest data year.
if (years.size === 0 || (!!cfg && this._hasProjectionSchedule(cfg))) {
years.add(new Date().getFullYear());
}
return [...years].sort((a, b) => b - a);
});
/**
* Which engine is authoritative for this cfg's schedule overlay the single
* routing predicate shared by `_hasProjectionSchedule` and
* `_scheduledDaysInYear` so the two can never drift:
* - RRULE engine: the per-device flag is on AND the rule parses + fires.
* - else the legacy `repeatCycle` engine, which needs a usable `repeatEvery`.
*/
private _usesRRuleEngine(cfg: TaskRepeatCfg): boolean {
return isRRuleEngineEnabled() && !!cfg.rrule && isRRuleValid(cfg.rrule);
}
private _hasUsableRepeatEvery(cfg: TaskRepeatCfg): boolean {
return Number.isInteger(cfg.repeatEvery) && cfg.repeatEvery >= 1;
}
/**
* True when the schedule overlay is active for this cfg: a flag-on valid
* RRULE, or a legacy cfg with a usable `repeatEvery` (the legacy engine then
* drives the streak/projection). Kept structural (not "fires this year") so an
* exhausted rule still contributes a navigable if empty current year,
* which the no-stranding guards in `heatmapData()` keep rendered.
*/
private _hasProjectionSchedule(cfg: TaskRepeatCfg): boolean {
return this._usesRRuleEngine(cfg) || this._hasUsableRepeatEvery(cfg);
}
readonly canPrevYear = computed(
() => prevYearOf(this.availableYears(), this.selectedYear()) !== null,
);
readonly canNextYear = computed(
() => nextYearOf(this.availableYears(), this.selectedYear()) !== null,
);
prevYear(): void {
const y = prevYearOf(this.availableYears(), this.selectedYear());
if (y !== null) {
this._userSelectedYear.set(y);
}
}
nextYear(): void {
const y = nextYearOf(this.availableYears(), this.selectedYear());
if (y !== null) {
this._userSelectedYear.set(y);
}
}
private readonly _rawHeatmapData = computed(() => {
const tasks = this._loadedTasks();
return tasks ? this._buildHeatmapData(tasks) : null;
return tasks
? this._buildHeatmapData(tasks, this._loadedCfg(), this.selectedYear())
: null;
});
readonly formattedTimeSummary = computed(() => {
@ -65,7 +176,7 @@ export class RepeatTaskHeatmapComponent {
};
});
readonly heatmapData = computed<HeatmapData | null>(() => {
readonly heatmapData = computed<HeatmapViewData | null>(() => {
const rawData = this._rawHeatmapData();
const firstDay = this._dateAdapter.getFirstDayOfWeek();
@ -73,17 +184,35 @@ export class RepeatTaskHeatmapComponent {
return null;
}
// Check if there's any actual data
if (!rawData.hasData) {
// Hide an all-empty heatmap only when there is nowhere else to navigate —
// with other years available, returning null would tear down the switcher
// INCLUDING its year nav and strand the user (an empty grid keeps the way
// back open).
if (!rawData.hasData && this.availableYears().length <= 1) {
return null;
}
return this._buildWeeksGrid(
rawData.dayMap,
rawData.startDate,
rawData.endDate,
firstDay,
);
const monthNames = this._dateAdapter.getMonthNames('short');
return {
...buildHeatmapWeeks(
rawData.dayMap,
rawData.startDate,
rawData.endDate,
firstDay,
monthNames,
),
months: buildHeatmapMonths(
rawData.dayMap,
rawData.startDate,
rawData.endDate,
firstDay,
monthNames,
heatmapHoursTotal,
),
dayMap: rawData.dayMap,
rangeStart: rawData.startDate,
rangeEnd: rawData.endDate,
};
});
private async _loadTasksForRepeatCfg(repeatCfgId: string): Promise<Task[]> {
@ -116,7 +245,11 @@ export class RepeatTaskHeatmapComponent {
return matchingTasks;
}
private _buildHeatmapData(tasks: Task[]): {
private _buildHeatmapData(
tasks: Task[],
cfg: TaskRepeatCfg | null | undefined,
year: number,
): {
dayMap: Map<string, DayData>;
startDate: Date;
endDate: Date;
@ -124,12 +257,14 @@ export class RepeatTaskHeatmapComponent {
} {
const dayMap = new Map<string, DayData>();
const now = new Date();
const oneYearAgo = new Date(now);
oneYearAgo.setFullYear(now.getFullYear() - 1);
// One calendar year per view (the year filter navigates) — this also keeps
// every month label unambiguous (no two "Jun" blocks from different years).
const yearStart = new Date(year, 0, 1);
const horizon = new Date(year, 11, 31);
// Initialize all days in the past year
const currentDate = new Date(oneYearAgo);
while (currentDate <= now) {
// Initialize all days of the selected year.
const currentDate = new Date(yearStart);
while (currentDate <= horizon) {
const dateStr = getDbDateStr(currentDate);
dayMap.set(dateStr, {
date: new Date(currentDate),
@ -194,68 +329,123 @@ export class RepeatTaskHeatmapComponent {
}
}
// Overlay the schedule (Phase 2). The streak renders for EVERY recurring
// cfg, not only flag-on RRULE ones: `_scheduledDaysInYear` routes to
// whichever engine is authoritative for this cfg — the RRULE engine when the
// per-device flag is on and the rule is valid, else the legacy `repeatCycle`
// engine. The legacy path uses that engine ITSELF (not the rrule converter),
// so the overlay reproduces task creation exactly — no WEEKLY-interval≥2 /
// zero-weekday divergence, where a wrong overlay would be worse than none.
// Per-instance overrides (moves/RDATE) are Phase 8; here only EXDATE
// (deletedInstanceDates) skips apply.
if (cfg) {
const scheduledDays = this._scheduledDaysInYear(cfg, yearStart, horizon);
// No occurrence lands in this year (e.g. a year before the schedule
// started, or an exhausted rule) → leave the history-only grid untouched
// rather than deleting every untracked day.
if (scheduledDays.size > 0) {
// A future occurrence is any scheduled day on/after tomorrow. Anchor on
// the START of tomorrow (local midnight), NOT `now + 24h`: occurrences
// sit at local noon, so a `now + 24h` cutoff dropped tomorrow's cell
// once the clock passed noon today (its dashed outline vanished).
const startOfTomorrow = new Date(now);
startOfTomorrow.setHours(0, 0, 0, 0);
startOfTomorrow.setDate(startOfTomorrow.getDate() + 1);
for (const [dateStr, day] of dayMap) {
if (scheduledDays.has(dateStr)) {
// Future scheduled day with no tracked time → projected overlay, but
// never override a day that already has real tracked activity.
if (day.timeSpent === 0 && day.date >= startOfTomorrow) {
day.isProjected = true;
day.level = 1;
hasData = true;
}
} else if (day.timeSpent === 0) {
// Off-schedule day with no tracked time disappears from the map: the
// grid renders it as a transparent placeholder cell in the SAME
// position, so the visible cells read as the actual streak — a
// weekly task no longer shows six grey "missed" cells per week.
// Genuinely missed occurrence days (scheduled, past, untracked) stay
// grey; off-schedule days that carry tracked time stay visible.
dayMap.delete(dateStr);
}
}
}
}
return {
dayMap,
startDate: oneYearAgo,
endDate: now,
startDate: yearStart,
endDate: horizon,
hasData,
};
}
private _buildWeeksGrid(
dayMap: Map<string, DayData>,
startDate: Date,
endDate: Date,
firstDayOfWeek: number = 0,
): HeatmapData {
const weeks: WeekData[] = [];
const monthLabels: string[] = [];
const monthNames = this._dateAdapter.getMonthNames('short');
let currentMonth = -1;
// Find the first day (based on firstDayOfWeek setting) before or on the start date
const firstDay = new Date(startDate);
const dayOfWeek = firstDay.getDay();
const daysToGoBack = (dayOfWeek - firstDayOfWeek + 7) % 7;
firstDay.setDate(firstDay.getDate() - daysToGoBack);
// Build weeks
const currentDate = new Date(firstDay);
let weekCount = 0;
while (currentDate <= endDate || weeks.length === 0) {
const week: WeekData = { days: [] };
for (let i = 0; i < 7; i++) {
const dateStr = getDbDateStr(currentDate);
const dayData = dayMap.get(dateStr);
if (currentDate >= startDate && currentDate <= endDate) {
week.days.push(dayData || null);
const month = currentDate.getMonth();
if (month !== currentMonth && currentDate.getDate() <= 7 && weekCount > 0) {
monthLabels.push(monthNames[month]);
currentMonth = month;
} else if (monthLabels.length === 0 && weekCount === 0) {
monthLabels.push(monthNames[month]);
currentMonth = month;
}
} else {
week.days.push(null);
}
currentDate.setDate(currentDate.getDate() + 1);
}
weeks.push(week);
weekCount++;
if (weeks.length > 54) {
break;
}
/**
* The set of `YYYY-MM-DD` day strings the cfg is scheduled on within
* `[yearStart, horizon]`, using whichever engine is authoritative for this
* cfg:
*
* - RRULE engine when the per-device flag is on AND the rule is valid a
* single range query that already honors EXDATEs.
* - else the legacy `repeatCycle` engine, iterated via
* `getNextRepeatOccurrence` in INCLUSIVE mode (which neutralizes the
* last-creation gate) so the WHOLE year's pattern is enumerated past and
* future exactly as the engine would create tasks. Skipped instances
* (`deletedInstanceDates`) are subtracted afterwards, since the legacy
* engine doesn't apply EXDATEs itself.
*
* Empty when the cfg can't enumerate a schedule (no rrule, no usable
* `repeatEvery`, or no occurrence in range).
*/
private _scheduledDaysInYear(
cfg: TaskRepeatCfg,
yearStart: Date,
horizon: Date,
): Set<string> {
if (this._usesRRuleEngine(cfg)) {
return new Set(
getRRuleOccurrencesInRange(
{
// `_usesRRuleEngine` already established `cfg.rrule` is a present,
// valid rule (the extracted predicate breaks TS's inline narrowing).
rrule: cfg.rrule as string,
// For a from-completion schedule this anchors past occurrences at
// the CURRENT effective start — a best-effort reading of history,
// since every completion re-anchored the series along the way.
startDate: getEffectiveRepeatStartDate(cfg),
exdates: cfg.deletedInstanceDates ?? [],
},
yearStart,
horizon,
).map((occ) => getDbDateStr(occ)),
);
}
return { weeks, monthLabels };
// Legacy engine. A malformed/absent repeatEvery can't enumerate anything —
// bail without invoking the engine (which would only warn and return null).
if (!this._hasUsableRepeatEvery(cfg)) {
return new Set();
}
const days = new Set<string>();
const horizonStr = getDbDateStr(horizon);
let cursor = new Date(yearStart);
// Hard cap well above one-occurrence-per-day in a leap year — a safety net
// against a non-advancing engine result, never reached in practice.
for (let guard = 0; guard < 400; guard++) {
const next = getNextRepeatOccurrence(cfg, cursor, { inclusive: true });
if (!next || getDbDateStr(next) > horizonStr) {
break;
}
days.add(getDbDateStr(next));
cursor = new Date(next);
cursor.setDate(cursor.getDate() + 1);
}
for (const ex of cfg.deletedInstanceDates ?? []) {
days.delete(ex);
}
return days;
}
}

View file

@ -1,6 +1,11 @@
import { TaskRepeatCfg } from '../task-repeat-cfg.model';
export const getEffectiveRepeatStartDate = (cfg: TaskRepeatCfg): string => {
export const getEffectiveRepeatStartDate = (
cfg: Pick<
TaskRepeatCfg,
'repeatFromCompletionDate' | 'lastTaskCreationDay' | 'startDate'
>,
): string => {
if (cfg.repeatFromCompletionDate && cfg.lastTaskCreationDay) {
return cfg.lastTaskCreationDay;
}

View file

@ -251,17 +251,26 @@ describe('rrule-occurrence engine — complex variants × settings', () => {
expect(isRRuleValid(r as string | undefined)).toBe(false),
);
});
it('rejects sub-daily FREQs (day-granular engine — Phase 12 owns sub-daily)', () => {
// The dialog blocks these at save; this engine-level gate covers rules
// the dialog never saw (synced / imported / REST-ingested), which would
// otherwise silently collapse to ~daily firing at local noon.
['FREQ=HOURLY', 'FREQ=MINUTELY;INTERVAL=30', 'FREQ=SECONDLY'].forEach((r) =>
expect(isRRuleValid(r)).withContext(r).toBe(false),
);
});
});
describe('isRRuleValid never-firing rules', () => {
// A parseable rule whose pattern matches no real date (BYMONTH=13, Feb-30)
// used to make rrule.js's .after() walk day-by-day to its year-275760 ceiling
// — a multi-second main-thread freeze that ALSO returned `true`, so the engine
// deferred to a rule that silently never fires (bypassing the legacy
// fallback). `_canNeverFire` now rejects these contradiction classes in O(1)
// before any probe; a never-firing rule it doesn't recognise still resolves
// to false via the (unbounded, memoised) `.after()` probe — after a one-time
// multi-second walk, which is why extending the pre-screen matters.
// walks period-by-period to rrule.js's MAXYEAR=9999 ceiling before yielding
// nothing, and used to ALSO return `true`, so the engine deferred to a rule
// that silently never fires (bypassing the legacy fallback). `_canNeverFire`
// rejects these contradiction classes in O(1) before any probe; a never-firing
// rule it doesn't recognise still resolves to false via the validity probe —
// which is anchored near 9999, so that walk is bounded (sub-second, memoised)
// rather than the multi-second freeze a 2020-anchored probe produced.
it('rejects a never-firing rule instead of treating it as valid', () => {
expect(isRRuleValid('FREQ=DAILY;BYMONTH=13')).toBe(false);
expect(isRRuleValid('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30')).toBe(false);
@ -291,6 +300,52 @@ describe('rrule-occurrence engine — complex variants × settings', () => {
].forEach((r) => expect(isRRuleValid(r)).withContext(r).toBe(true));
});
it('pre-screens positive BYSETPOS beyond the per-period set (DAILY/WEEKLY)', () => {
// A day holds 1 occurrence and a week holds at most its BYDAY count, so a
// POSITIVE BYSETPOS past that matches nothing. These previously walked
// seconds to the ceiling on the probe; _canNeverFire now rejects them.
expect(isRRuleValid('FREQ=DAILY;BYSETPOS=2')).toBe(false);
expect(isRRuleValid('FREQ=DAILY;BYDAY=MO;BYSETPOS=2')).toBe(false);
expect(isRRuleValid('FREQ=WEEKLY;BYDAY=MO;BYSETPOS=5')).toBe(false);
expect(isRRuleValid('FREQ=WEEKLY;BYDAY=MO,TU,WE;BYSETPOS=4')).toBe(false);
});
it('keeps in-range BYSETPOS valid (no false positives)', () => {
[
'FREQ=DAILY;BYSETPOS=1', // the single daily slot
'FREQ=DAILY;BYSETPOS=-1', // last == only
'FREQ=WEEKLY;BYDAY=MO,TU,WE;BYSETPOS=2', // 2nd of 3
'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1', // last weekday
// rrule.js CLAMPS an out-of-range negative BYSETPOS and still fires, so
// a negative value must never be flagged (regression: these were dropped
// to the legacy fallback by the old `Math.abs(p) > slots` check).
'FREQ=DAILY;BYSETPOS=-2',
'FREQ=DAILY;BYSETPOS=-100',
'FREQ=WEEKLY;BYDAY=MO;BYSETPOS=-2',
// With NO BYDAY, BYMONTHDAY / BYYEARDAY / BYWEEKNO EXPAND the per-week set
// past the (zero) BYDAY count, so the bound doesn't hold — must not flag.
'FREQ=WEEKLY;BYMONTHDAY=1,2,3;BYSETPOS=2',
'FREQ=WEEKLY;BYYEARDAY=1,2,3;BYSETPOS=2',
'FREQ=WEEKLY;BYWEEKNO=10,20;BYSETPOS=2',
].forEach((r) => expect(isRRuleValid(r)).withContext(r).toBe(true));
});
it('pre-screens BYWEEKNO × BYYEARDAY contradictions (no BYMONTH)', () => {
// A year-day and a week number that can never share a month can never
// coincide — week 10 is ~March, year-day 300 is ~October. The BYMONTH-based
// checks are skipped when bymonth is empty, so this needs its own pre-screen.
expect(isRRuleValid('FREQ=DAILY;BYWEEKNO=10;BYYEARDAY=300')).toBe(false);
expect(isRRuleValid('FREQ=YEARLY;BYWEEKNO=2;BYYEARDAY=200')).toBe(false);
});
it('keeps satisfiable BYWEEKNO × BYYEARDAY valid (no false positives)', () => {
[
'FREQ=DAILY;BYWEEKNO=10;BYYEARDAY=66', // year-day 66 (~Mar 7) sits in week 10
'FREQ=YEARLY;BYWEEKNO=1;BYYEARDAY=1', // Jan 1 can be ISO week 1
'FREQ=DAILY;BYWEEKNO=10;BYYEARDAY=-300', // negative year-days skip the check
].forEach((r) => expect(isRRuleValid(r)).withContext(r).toBe(true));
});
it('keeps a sound pattern with a past UNTIL / COUNT valid', () => {
// UNTIL/COUNT are anchor-relative end conditions stripped for the validity
// probe: the PATTERN is sound and the engine applies the real start/end per

View file

@ -105,6 +105,92 @@ describe('rrule-occurrence invariants', () => {
});
});
describe('never-firing rules stay bounded (no multi-second freeze)', () => {
// rrule.js cannot stop a never-firing walk early: no day passes the
// BY-filters, so its iterator callback never fires and it spins one period
// at a time up to MAXYEAR=9999. A 2020-anchored probe walked ~8000 years of
// periods → a 711s main-thread freeze (measured) on these inputs. These
// rules are NOT caught by the O(1) `_canNeverFire` heuristic (BYYEARDAY ×
// BYWEEKNO, BYSETPOS past its set) — they reach the probe — so they exercise
// exactly the anchor-near-9999 bound that replaced the freeze.
const NEVER_FIRE_UNCAUGHT = [
'FREQ=DAILY;BYYEARDAY=60;BYWEEKNO=53',
'FREQ=WEEKLY;BYDAY=MO;BYSETPOS=2',
'FREQ=MONTHLY;BYMONTHDAY=15;BYSETPOS=3',
];
NEVER_FIRE_UNCAUGHT.forEach((rrule) => {
it(`"${rrule}" → invalid, resolved well below the old freeze`, () => {
// Cold cache (unique strings) → this measures the actual probe walk, not
// a memo hit.
const t0 = performance.now();
const valid = isRRuleValid(rrule);
const elapsedMs = performance.now() - t0;
// A never-firing rule must be rejected so the engine uses the legacy
// fallback instead of a rule that silently never creates a task.
expect(valid).toBe(false);
// The bounded walk is sub-second locally (~0.5s worst). 4s leaves wide
// CI headroom while still flagging a regression to the ~8000-year walk
// (711s) a 2020 anchor produced. NB: only isRRuleValid is timed here —
// getNext/getFirst use the cfg's real start (not the bounded anchor) and
// would walk to 9999 for a never-firing DAILY rule; in production they
// are always gated behind isRRuleValid, which now rejects these.
expect(elapsedMs).toBeLessThan(4000);
});
});
it('INTERVAL phase that never fires from the real start → empty, bounded', () => {
// Passes isRRuleValid: the PATTERN fires (Wednesdays) at the canonical
// anchor (itself a Wednesday). But FREQ=DAILY;INTERVAL=7 from a MONDAY
// start locks every 7th day to a Monday → never a Wednesday. The queries
// anchor at the real start, so without the `_firesFromStart` guard rrule
// would walk start→9999. 2024-06-03 is a Monday.
const RULE = 'FREQ=DAILY;INTERVAL=7;BYDAY=WE';
expect(isRRuleValid(RULE)).toBe(true);
const cfg = inp(RULE, { startDate: '2024-06-03' });
const t0 = performance.now();
const next = getNextRRuleOccurrence(cfg, new Date(2024, 5, 3, 12));
const ms = performance.now() - t0;
expect(next).toBeNull();
expect(getFirstRRuleOccurrence(cfg)).toBeNull();
expect(
getRRuleOccurrencesInRange(
cfg,
new Date(2024, 0, 1, 12),
new Date(2025, 0, 1, 12),
),
).toEqual([]);
expect(ms).toBeLessThan(4000);
});
it('the same INTERVAL-phase rule DOES fire from an aligned (Wednesday) start', () => {
// Positive control for the guard above: 2024-06-05 is a Wednesday, so the
// interval-7 phase lands on Wednesdays and the start day itself fires.
const cfg = inp('FREQ=DAILY;INTERVAL=7;BYDAY=WE', { startDate: '2024-06-05' });
expect(getDbDateStr(getFirstRRuleOccurrence(cfg)!)).toBe('2024-06-05');
});
});
describe('"fires within a decade" probe boundary (deliberate product rule)', () => {
// isRRuleValid probes a fixed 10-year window (anchored near MAXYEAR to bound
// the never-fire walk — see rrule-occurrence.util). A rule whose FIRST
// occurrence is >10y past that anchor is therefore classified invalid, by
// design: a real recurrence that first fires more than a decade out does not
// exist in practice, and accepting it would reintroduce the unbounded probe.
// These specs pin the boundary so a future window/anchor edit can't silently
// flip a real rule (reachable only via raw override).
it('rejects a rule whose first occurrence is >10y past the probe anchor', () => {
// "Feb 29 that is also a Monday" recurs only every ~28y, so the first hit
// lands well beyond the 10-year window (9644-02-29 from the 9620 anchor).
expect(isRRuleValid('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29;BYDAY=MO')).toBe(false);
});
it('accepts the analogous rule that DOES fire within the decade', () => {
// Plain "Feb 29" fires on the very first leap year in-window — control
// ensuring the rejection above is the >10y boundary, not the BY-parts.
expect(isRRuleValid('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29')).toBe(true);
});
});
it('getNextRRuleOccurrence is deterministic', () => {
const a = getNextRRuleOccurrence(inp('FREQ=WEEKLY;BYDAY=MO'), BASE);
const b = getNextRRuleOccurrence(inp('FREQ=WEEKLY;BYDAY=MO'), BASE);

View file

@ -35,6 +35,10 @@ export type RepeatQuickSetting =
// because existing stored data and data-repair still produce it.
| 'CUSTOM';
/** The "Custom" / RRULE-builder quickSetting value. A single named constant so
* the options builder and the freq picker can't drift on the magic string. */
export const RRULE_QUICK_SETTING = 'RRULE';
// Every concrete preset, in menu order — excludes the 'RRULE' builder mode and
// the legacy 'CUSTOM' persistence value. Single source for preset-driven logic
// (dialog preset detection + preset inference); a preset missing here would
@ -126,19 +130,24 @@ export interface TaskRepeatCfgCopy {
// Anchor presence is the discriminator — there is no separate mode field.
// ONLY absent-or-numeric is sync-safe: released clients' typia schema has no
// `null` here, so a null must never be persisted (clearing happens via
// `undefined`; a stale anchor on remote clients is inert once `rrule` is
// set, since the occurrence engine routes on it). Issue #6040.
// INHERENT GAP (not fixable, don't try): an `undefined` clear is dropped by
// the op-log's JSON partial-update merge, so a remote PRE-rrule client (which
// ignores `rrule`) keeps scheduling from a stale anchor after an nth-weekday →
// day-of-month switch. There is no in-schema value meaning "no anchor" to send
// instead (0 is a valid weekday; null/0 trip released clients' repair dialog).
// A `| null` migration would NOT help: it only benefits a client that is
// null-aware yet rrule-UNAWARE, but null can only ship with-or-after rrule, so
// that band is empty — any client new enough to accept the null clear already
// routes on `rrule`, where the stale anchor is inert. Only the UPDATE path is
// affected (ADD sends the field absent → no anchor remotely). See the op-log
// JSON round-trip spec pinning this behavior.
// `undefined`). Issue #6040.
// INHERENT GAP (not fixable in-schema, don't try): an `undefined` clear is
// dropped by the op-log's JSON partial-update merge, so a remote client that
// routes the LEGACY engine keeps scheduling from a stale anchor after an
// nth-weekday → day-of-month switch. That is every remote client today:
// PRE-rrule versions ignore `rrule` entirely, and rrule-aware builds route
// legacy too while the per-device engine flag (default OFF, never synced)
// is off — the stale anchor is inert only on flag-ON clients, whose engine
// routes on `rrule`. The gap therefore stays live for the whole installed
// base until the flag defaults on; it self-heals then, losing no data.
// There is no in-schema value meaning "no anchor" to send instead (0 is a
// valid weekday; null/0 trip released clients' repair dialog), and a
// `| null` migration can't ship sooner than the rrule routing itself. Only
// the UPDATE path is affected (ADD sends the field absent → no anchor
// remotely). Note the never-fires sentinel path is NOT affected: it switches
// `repeatCycle` to 'WEEKLY' (wire-stable), and anchors are only read in the
// legacy MONTHLY branch. See the op-log JSON round-trip spec pinning this
// behavior, and docs/research/rrule-epic-review-watchlist.md §3.
monthlyWeekOfMonth?: MonthlyWeekOfMonth;
monthlyWeekday?: MonthlyWeekday;

View file

@ -1,5 +1,7 @@
import {
getAlignedStartDate,
isRRuleLegacyRepresentable,
LEGACY_NEVER_FIRES_FALLBACK,
legacyTaskRepeatCfgToRRule,
rruleToLegacyTaskRepeatCfg,
} from './legacy-cfg-to-rrule.util';
@ -189,19 +191,17 @@ describe('rruleToLegacyTaskRepeatCfg', () => {
expect(second.monthlyWeekday).toBe(2); // Tuesday
});
it('never emits an out-of-union monthlyWeekOfMonth for out-of-range BYDAY ordinals', () => {
it('writes the never-fires sentinel for out-of-range BYDAY ordinals (no out-of-union anchor, no wrong-day fallback)', () => {
// The builder's custom ordinal input allows ±5 for monthly rules, but the
// synced model (and released clients' typia schema) only allows 1..4 | -1.
// An out-of-range ordinal must leave the anchor unset — the rrule engine
// still fires correctly; old clients fall back to day-of-month.
const fifth = rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=5MO');
expect(fifth.repeatCycle).toBe('MONTHLY');
expect(fifth.monthlyWeekOfMonth).toBeUndefined();
expect(fifth.monthlyWeekday).toBeUndefined();
const secondToLast = rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=-2MO');
expect(secondToLast.monthlyWeekOfMonth).toBeUndefined();
expect(secondToLast.monthlyWeekday).toBeUndefined();
// An out-of-range ordinal has no faithful legacy form — old clients get
// the sentinel (no tasks) instead of a wrong-day day-of-month fallback.
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=5MO')).toEqual({
...LEGACY_NEVER_FIRES_FALLBACK,
});
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=-2MO')).toEqual({
...LEGACY_NEVER_FIRES_FALLBACK,
});
// In-range ordinals keep mapping.
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=4MO').monthlyWeekOfMonth).toBe(
@ -215,18 +215,16 @@ describe('rruleToLegacyTaskRepeatCfg', () => {
expect('repeatCycle' in out).toBe(false);
});
it('leaves multi-weekday or out-of-range BYSETPOS rules unmapped (no single anchor)', () => {
expect(
rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=-1')
.monthlyWeekOfMonth,
).toBeUndefined();
expect(
rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=5').monthlyWeekOfMonth,
).toBeUndefined();
expect(
rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1,-1')
.monthlyWeekOfMonth,
).toBeUndefined();
it('writes the never-fires sentinel for multi-weekday or out-of-range BYSETPOS rules (no single anchor)', () => {
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=-1')).toEqual({
...LEGACY_NEVER_FIRES_FALLBACK,
});
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=5')).toEqual({
...LEGACY_NEVER_FIRES_FALLBACK,
});
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1,-1')).toEqual({
...LEGACY_NEVER_FIRES_FALLBACK,
});
});
it('MONTHLY last day → monthlyLastDay', () => {
@ -298,6 +296,105 @@ describe('rruleToLegacyTaskRepeatCfg', () => {
expect('startDate' in out).toBe(false);
});
it('maps interval-1 DAILY with plain BYDAY losslessly onto WEEKLY flags', () => {
// FREQ=DAILY;BYDAY=MO,WE,FR ≡ weekly-interval-1 on those days — legacy
// WEEKLY interval 1 is a pure weekday filter, so this is exact.
const out = rruleToLegacyTaskRepeatCfg('FREQ=DAILY;BYDAY=MO,WE,FR');
expect(out.repeatCycle).toBe('WEEKLY');
expect(out.repeatEvery).toBe(1);
expect(out.monday).toBe(true);
expect(out.wednesday).toBe(true);
expect(out.friday).toBe(true);
expect(out.tuesday).toBe(false);
expect(out.sunday).toBe(false);
// With an interval the daily step is filtered THROUGH the weekday set —
// weekly fields cannot express that.
expect(rruleToLegacyTaskRepeatCfg('FREQ=DAILY;INTERVAL=2;BYDAY=MO')).toEqual({
...LEGACY_NEVER_FIRES_FALLBACK,
});
});
describe('never-fires sentinel for non-representable rules', () => {
// Decided contract: a rule the legacy fields cannot faithfully represent
// writes LEGACY_NEVER_FIRES_FALLBACK, so old/flag-off clients create NO
// tasks rather than tasks on wrong days (which would sync to every
// device). The classes below are exactly the RRULE differentiators.
const NON_REPRESENTABLE: [string, string][] = [
['COUNT end condition', 'FREQ=WEEKLY;BYDAY=MO;COUNT=10'],
['UNTIL end condition', 'FREQ=DAILY;UNTIL=20301231T120000Z'],
['seasonal BYMONTH window (weekly)', 'FREQ=WEEKLY;BYDAY=SA;BYMONTH=3,4,5'],
['seasonal BYMONTH window (monthly)', 'FREQ=MONTHLY;BYMONTHDAY=1;BYMONTH=6'],
['filtered daily (BYMONTH)', 'FREQ=DAILY;BYMONTH=2'],
['week numbers', 'FREQ=YEARLY;BYWEEKNO=20'],
['year days', 'FREQ=YEARLY;BYYEARDAY=100'],
['multiple month days', 'FREQ=MONTHLY;BYMONTHDAY=1,15'],
['yearly BYMONTHDAY without BYMONTH (fires monthly)', 'FREQ=YEARLY;BYMONTHDAY=10'],
['yearly BYMONTH without BYMONTHDAY', 'FREQ=YEARLY;BYMONTH=6'],
['multi-month yearly', 'FREQ=YEARLY;BYMONTH=3,9;BYMONTHDAY=1'],
['yearly weekday mode', 'FREQ=YEARLY;BYMONTH=3;BYDAY=SA'],
['last business day', 'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1'],
];
NON_REPRESENTABLE.forEach(([label, rule]) => {
it(`sentinels: ${label}`, () => {
expect(rruleToLegacyTaskRepeatCfg(rule, '2024-06-03')).toEqual({
...LEGACY_NEVER_FIRES_FALLBACK,
});
expect(isRRuleLegacyRepresentable(rule)).toBe(false);
});
});
it('the sentinel never fires on the legacy WEEKLY engine and is wire-stable', () => {
// All-false weekday flags = the legacy WEEKLY scan loops can never match.
expect(LEGACY_NEVER_FIRES_FALLBACK.repeatCycle).toBe('WEEKLY');
const flagKeys = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
] as const;
flagKeys.forEach((k) => expect(LEGACY_NEVER_FIRES_FALLBACK[k]).toBe(false));
// Every load-bearing value survives the op-log's JSON partial update —
// a remote legacy client MUST receive the cycle switch and the false
// flags (an undefined-dropped key would leave it firing the old rule).
const wire = JSON.parse(JSON.stringify(LEGACY_NEVER_FIRES_FALLBACK));
expect(wire.repeatCycle).toBe('WEEKLY');
expect(wire.repeatEvery).toBe(1);
flagKeys.forEach((k) => expect(wire[k]).toBe(false));
expect(wire.monthlyLastDay).toBe(false);
// The numeric anchors stay absent on the wire (undefined → dropped) —
// master-safe; a stale remote anchor is harmless under repeatCycle
// WEEKLY (anchors are only read in the legacy MONTHLY branch).
expect('monthlyWeekOfMonth' in wire).toBe(false);
});
it('representable rules report representable', () => {
[
'FREQ=DAILY;INTERVAL=3',
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR',
'FREQ=MONTHLY;BYDAY=2TU',
'FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1',
'FREQ=MONTHLY;BYMONTHDAY=-1',
'FREQ=MONTHLY;BYMONTHDAY=15',
'FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1',
'FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=17',
'FREQ=YEARLY',
'FREQ=DAILY;BYDAY=MO,WE,FR',
].forEach((rule) => {
expect(isRRuleLegacyRepresentable(rule)).withContext(rule).toBe(true);
});
});
it('unparseable and sub-daily rules are not flagged (rejected upstream)', () => {
expect(isRRuleLegacyRepresentable('not an rrule')).toBe(true);
expect(isRRuleLegacyRepresentable('FREQ=HOURLY')).toBe(true);
expect(isRRuleLegacyRepresentable(undefined)).toBe(true);
});
});
it('round-trips a weekly cfg (legacy → rrule → legacy)', () => {
const legacy = cfg({
repeatCycle: 'WEEKLY',

View file

@ -1,13 +1,19 @@
import {
MonthlyWeekOfMonth,
MonthlyWeekday,
RepeatCycleOption,
TaskRepeatCfg,
TaskRepeatCfgCopy,
} from '../task-repeat-cfg.model';
import { normalizeWeekdays, toNumArray } from './rrule-weekday.util';
import { FREQ_TO_CYCLE, safeParseRRuleOptions } from './rrule-parse.util';
import {
FREQ_TO_CYCLE,
RRuleParsedOptions,
safeParseRRuleOptions,
} from './rrule-parse.util';
import { getFirstRRuleOccurrence } from '../store/rrule-occurrence.util';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { assertNever } from '../../../util/assert-never';
/**
* Converts a legacy (pre-RRULE) TaskRepeatCfg `repeatCycle` + `repeatEvery` +
@ -128,13 +134,244 @@ const RRULE_IDX_TO_FIELD: (keyof TaskRepeatCfg)[] = [
];
/**
* Best-effort inverse of `legacyTaskRepeatCfgToRRule`: derives the legacy schedule
* fields (`repeatCycle`, `repeatEvery`, weekday flags, monthly anchors) from an
* RRULE body. Used to keep those fields populated alongside `rrule` so older sync
* clients which ignore the unknown `rrule` field still get a faithful
* recurrence to fall back on (plan P1.3 reverse direction).
* Legacy-field payload that makes old / flag-off clients create NOTHING for
* this cfg: the legacy WEEKLY engine requires at least one weekday flag to be
* `true` (`get-next-repeat-occurrence` / `get-newest-possible-due-date` scan
* loops), so an all-false WEEKLY never fires deterministically and on every
* released version. Written for rules the legacy fields CANNOT faithfully
* represent (decided policy): a fabricated wrong-day task syncs back to every
* device and is worse than an absent one. Every value is wire-stable (`false`
* survives the op-log's JSON partial update; `'WEEKLY'`/`1` are in-union), and
* `repeatCycle: 'WEEKLY'` also sidesteps any stale monthly anchor a remote
* client may hold anchors are only read in the legacy MONTHLY branch.
*/
export const LEGACY_NEVER_FIRES_FALLBACK: Readonly<Partial<TaskRepeatCfgCopy>> = {
repeatCycle: 'WEEKLY',
repeatEvery: 1,
monday: false,
tuesday: false,
wednesday: false,
thursday: false,
friday: false,
saturday: false,
sunday: false,
monthlyWeekOfMonth: undefined,
monthlyWeekday: undefined,
monthlyLastDay: false,
};
/**
* Exact-or-null inverse mapping: the legacy schedule fields that fire on the
* SAME days as the rule, or `null` when the legacy field set cannot express
* the rule (end conditions, seasonal BYMONTH, BYWEEKNO/BYYEARDAY, multi-day
* lists, out-of-union ordinals, ). `null` is the sentinel trigger never a
* best-effort approximation; see `rruleToLegacyTaskRepeatCfg`.
*
* Returns `{}` for an unparseable or sub-daily rule (legacy fields left untouched).
* Known accepted divergences (the lazy-migrate class, NOT sentinel material):
* WEEKLY INTERVAL>1 week-grouping vs rolling 7-day blocks (+ WKST phase), and
* the day>28 clamp-vs-skip edge pattern and cadence match; only rare edge
* days differ.
*/
const _legacyEquivalent = (
opts: RRuleParsedOptions,
cycle: RepeatCycleOption,
startDate?: string,
): Partial<TaskRepeatCfgCopy> | null => {
// COUNT/UNTIL have no legacy field: old clients would resurrect an ended
// series forever — strictly worse than creating nothing.
if (opts.count != null || opts.until != null) return null;
// Week-number / year-day constraints have no legacy notion at all.
if (toNumArray(opts.byweekno).length || toNumArray(opts.byyearday).length) {
return null;
}
const interval = opts.interval && opts.interval > 0 ? opts.interval : 1;
// Build on the mutable copy type — TaskRepeatCfg is Readonly.
const out: Partial<TaskRepeatCfgCopy> = {
repeatCycle: cycle,
repeatEvery: interval,
// Monthly anchors discriminate the legacy MONTHLY paths — always reset so a
// stale nth-weekday/last-day anchor from a previous preset or rule can't
// override the new rule's semantics. They are re-set below only when this
// rule actually encodes them. The numeric anchors reset to `undefined`:
// released clients' typia schema allows these fields only absent-or-numeric,
// so a `null` must never reach the wire (it would trip their validation /
// repair flow). The cost: a partial update can't clear a stale anchor on
// REMOTE clients (JSON.stringify drops undefined keys) — see the
// `task-repeat-cfg.model.ts` anchor comment for the full gap analysis.
// `monthlyLastDay` resets to `false`, a master-safe value that DOES survive
// the JSON wire.
monthlyWeekOfMonth: undefined,
monthlyWeekday: undefined,
monthlyLastDay: false,
};
const weekdays = normalizeWeekdays(opts.byweekday);
const monthDays = toNumArray(opts.bymonthday);
const setPos = toNumArray(opts.bysetpos);
const months = toNumArray(opts.bymonth);
const setWeekdayFlags = (days: { weekday: number }[]): void => {
RRULE_IDX_TO_FIELD.forEach((field) => {
(out as Record<string, unknown>)[field] = false;
});
days.forEach((w) => {
const field = RRULE_IDX_TO_FIELD[w.weekday];
if (field) (out as Record<string, unknown>)[field] = true;
});
};
switch (cycle) {
case 'DAILY': {
// Seasonal/positional daily (BYMONTH window, BYMONTHDAY, BYSETPOS) has no
// legacy DAILY notion.
if (monthDays.length || setPos.length || months.length) return null;
if (!weekdays.length) return out;
// FREQ=DAILY;BYDAY=… at INTERVAL=1 is exactly "weekly on those days" —
// legacy WEEKLY interval 1 is a pure weekday filter, so this maps
// losslessly. With an interval the daily step is filtered THROUGH the
// weekday set, which weekly fields cannot express.
if (interval !== 1 || weekdays.some((w) => w.n != null)) return null;
out.repeatCycle = 'WEEKLY';
setWeekdayFlags(weekdays);
return out;
}
case 'WEEKLY': {
// Seasonal weekly (BYMONTH) and positional parts have no legacy notion.
if (monthDays.length || setPos.length || months.length) return null;
if (weekdays.some((w) => w.n != null)) return null;
setWeekdayFlags(weekdays);
if (!weekdays.length && startDate) {
// A BYDAY-less FREQ=WEEKLY means "weekly on the start weekday" — set
// that flag (mirroring the forward converter), so the legacy WEEKLY
// engine still fires on older clients instead of having every flag
// false.
const idx = (_parseStart(startDate).dow + 6) % 7; // UTC 0=Sun → RRULE 0=Mon
const field = RRULE_IDX_TO_FIELD[idx];
if (field) (out as Record<string, unknown>)[field] = true;
}
return out;
}
case 'MONTHLY': {
// A BYMONTH window on a monthly rule (fire only some months) has no
// legacy notion.
if (months.length) return null;
const nthOrdinal = weekdays.length ? weekdays[0].n : undefined;
if (nthOrdinal != null) {
// "2nd Tuesday" → BYDAY=2TU. legacy monthlyWeekday is 0=Sun…6=Sat.
// Ordinals outside the model union (BYDAY=5MO, -2MO — the builder's
// custom input allows ±5) must NOT be persisted: released clients
// typia-validate monthlyWeekOfMonth against 1|2|3|4|-1, and an
// out-of-union value trips their repair flow.
if (
weekdays.length !== 1 ||
!_isLegacyMonthlyOrdinal(nthOrdinal) ||
monthDays.length ||
setPos.length
) {
return null;
}
out.monthlyWeekOfMonth = nthOrdinal;
out.monthlyWeekday = ((weekdays[0].weekday + 1) % 7) as MonthlyWeekday;
return out;
}
if (weekdays.length) {
// The builder's weekday-set form of the same anchor: BYDAY=FR;
// BYSETPOS=-1 ("last Friday") is losslessly equivalent to BYDAY=-1FR.
// Multi-weekday sets and out-of-range ordinals have no single legacy
// anchor.
if (
weekdays.length === 1 &&
monthDays.length === 0 &&
setPos.length === 1 &&
_isLegacyMonthlyOrdinal(setPos[0])
) {
out.monthlyWeekOfMonth = setPos[0] as MonthlyWeekOfMonth;
out.monthlyWeekday = ((weekdays[0].weekday + 1) % 7) as MonthlyWeekday;
return out;
}
return null;
}
if (monthDays.length === 1 && monthDays[0] === -1) {
// Pure "last day of month". NOT set for the clamp idiom
// (BYMONTHDAY=<d>,-1;BYSETPOS=1) — there the legacy day comes from the
// aligned startDate (see getAlignedStartDate), and the legacy engine
// clamps it natively.
if (setPos.length) return null;
out.monthlyLastDay = true;
return out;
}
// Plain day-of-month forms — legacy reads the day from startDate, which
// the save path aligns onto the rule's day (getAlignedStartDate):
// no BYMONTHDAY (= dtstart's day), a single positive day, or the clamp
// idiom BYMONTHDAY=<d>,-1;BYSETPOS=1.
if (!monthDays.length) return setPos.length ? null : out;
if (monthDays.length === 1 && monthDays[0] > 0 && !setPos.length) return out;
if (_isClampIdiom(monthDays, setPos)) return out;
return null;
}
case 'YEARLY': {
// Legacy YEARLY is exactly "once a year on startDate's month+day" —
// weekday modes (e.g. weekdays-within-months) have no legacy notion.
if (weekdays.length) return null;
if (!months.length) {
// No BYMONTH: with any BYMONTHDAY the rule fires that day EVERY month
// (RFC expansion) — not a yearly legacy schedule. Bare FREQ=YEARLY is
// the dtstart anniversary.
return monthDays.length || setPos.length ? null : out;
}
if (months.length !== 1) return null; // multi-month = several firings/year
if (!monthDays.length) {
// BYMONTH without BYMONTHDAY: the engine fires in the rule's month,
// but old clients read BOTH month and day from startDate, which the
// aligner does not move for this shape — they would fire in the
// start month. Not representable.
return null;
}
if (monthDays.length === 1 && monthDays[0] > 0 && !setPos.length) return out;
if (_isClampIdiom(monthDays, setPos)) return out;
return null;
}
default:
// Exhaustiveness guard: a new RepeatCycleOption must get an explicit
// branch here. Without it the value would fall through to the sentinel
// (null → LEGACY_NEVER_FIRES_FALLBACK), silently making old/flag-off
// clients create nothing for a real schedule — a compile error is far
// safer than that silent data divergence.
return assertNever(cycle);
}
};
/** BYMONTHDAY=<d>,-1;BYSETPOS=1 the RFC clamp idiom emitted by the forward
* converter for day>28 (see `_clampedMonthDayPart`). */
const _isClampIdiom = (monthDays: number[], setPos: number[]): boolean =>
monthDays.length === 2 &&
monthDays.filter((d) => d > 0).length === 1 &&
monthDays.includes(-1) &&
setPos.length === 1 &&
setPos[0] === 1;
/**
* Inverse of `legacyTaskRepeatCfgToRRule`: derives the legacy schedule fields
* (`repeatCycle`, `repeatEvery`, weekday flags, monthly anchors) from an RRULE
* body. Used to keep those fields populated alongside `rrule` as the wire
* format for older sync clients which ignore the unknown `rrule` field and
* schedule from the legacy fields.
*
* Contract (decided policy, not best-effort): when the rule is within legacy
* expressiveness the fields fire on the SAME days (modulo the documented
* WEEKLY-interval / clamp divergences); when it is NOT
* COUNT/UNTIL, seasonal BYMONTH, BYWEEKNO/BYYEARDAY, multi-day lists,
* out-of-union ordinals the `LEGACY_NEVER_FIRES_FALLBACK` sentinel is
* written instead, so old clients create NO tasks rather than tasks on wrong
* days that would sync back to every device. The dialog surfaces this state
* to the user via `isRRuleLegacyRepresentable`.
*
* Returns `{}` for an unparseable or sub-daily rule (legacy fields left
* untouched both are rejected at the save/persist boundary anyway).
* Day-of-month has no legacy field: legacy MONTHLY day recurrence (and YEARLY)
* reads the day/month from `startDate` callers that persist must pair this
* with `getAlignedStartDate` (the dialog does so once at save; doing it here
@ -147,86 +384,23 @@ export const rruleToLegacyTaskRepeatCfg = (
const opts = safeParseRRuleOptions(rrule);
if (!opts) return {};
const cycle = FREQ_TO_CYCLE[opts.freq];
if (!cycle) return {}; // sub-daily — no legacy equivalent
if (!cycle) return {}; // sub-daily — no legacy equivalent, rejected upstream
// Build on the mutable copy type — TaskRepeatCfg is Readonly.
const out: Partial<TaskRepeatCfgCopy> = {
repeatCycle: cycle,
repeatEvery: opts.interval && opts.interval > 0 ? opts.interval : 1,
// Monthly anchors discriminate the legacy MONTHLY paths — always reset so a
// stale nth-weekday/last-day anchor from a previous preset or rule can't
// override the new rule's semantics. They are re-set below only when this
// rule actually encodes them. The numeric anchors reset to `undefined`:
// released clients' typia schema allows these fields only absent-or-numeric,
// so a `null` must never reach the wire (it would trip their validation /
// repair flow). The cost: a partial update can't clear a stale anchor on
// REMOTE clients (JSON.stringify drops undefined keys) — harmless on
// rrule-aware clients (the engine routes on `rrule`) and only a best-effort
// approximation gap on legacy ones. `monthlyLastDay` resets to `false`, a
// master-safe value that DOES survive the JSON wire.
monthlyWeekOfMonth: undefined,
monthlyWeekday: undefined,
monthlyLastDay: false,
};
return _legacyEquivalent(opts, cycle, startDate) ?? { ...LEGACY_NEVER_FIRES_FALLBACK };
};
const weekdays = normalizeWeekdays(opts.byweekday);
const monthDays = toNumArray(opts.bymonthday);
if (cycle === 'WEEKLY') {
// Reset all flags, then enable the rule's weekdays (Mon-indexed).
RRULE_IDX_TO_FIELD.forEach((field) => {
(out as Record<string, unknown>)[field] = false;
});
if (weekdays.length) {
weekdays.forEach((w) => {
const field = RRULE_IDX_TO_FIELD[w.weekday];
if (field) (out as Record<string, unknown>)[field] = true;
});
} else if (startDate) {
// A BYDAY-less FREQ=WEEKLY means "weekly on the start weekday" — set that
// flag (mirroring the forward converter), so the legacy WEEKLY engine
// still fires on older clients instead of having every flag false.
const idx = (_parseStart(startDate).dow + 6) % 7; // UTC 0=Sun → RRULE 0=Mon
const field = RRULE_IDX_TO_FIELD[idx];
if (field) (out as Record<string, unknown>)[field] = true;
}
} else if (cycle === 'MONTHLY') {
const setPos = toNumArray(opts.bysetpos);
const nthOrdinal = weekdays.length ? weekdays[0].n : undefined;
if (nthOrdinal != null && _isLegacyMonthlyOrdinal(nthOrdinal)) {
// "2nd Tuesday" → BYDAY=2TU. legacy monthlyWeekday is 0=Sun…6=Sat.
// Ordinals outside the model union (BYDAY=5MO, -2MO — the builder's
// custom input allows ±5) must NOT be persisted: released clients
// typia-validate monthlyWeekOfMonth against 1|2|3|4|-1, and an
// out-of-union value trips their repair flow. Left unset, the rrule
// engine still fires correctly and old clients fall back to
// day-of-month — an approximation instead of broken validation.
out.monthlyWeekOfMonth = nthOrdinal;
out.monthlyWeekday = ((weekdays[0].weekday + 1) % 7) as MonthlyWeekday;
} else if (
weekdays.length === 1 &&
weekdays[0].n == null &&
monthDays.length === 0 &&
setPos.length === 1 &&
_isLegacyMonthlyOrdinal(setPos[0])
) {
// The builder's weekday-set form of the same anchor: BYDAY=FR;BYSETPOS=-1
// ("last Friday") is losslessly equivalent to BYDAY=-1FR. Without this
// mapping old clients would fall back to startDate's day-of-month — a
// wrong recurrence rather than an approximation. Multi-weekday sets and
// out-of-range ordinals have no single legacy anchor and stay unmapped.
out.monthlyWeekOfMonth = setPos[0] as MonthlyWeekOfMonth;
out.monthlyWeekday = ((weekdays[0].weekday + 1) % 7) as MonthlyWeekday;
} else if (monthDays.length === 1 && monthDays[0] === -1) {
// Pure "last day of month". NOT set for the clamp idiom
// (BYMONTHDAY=<d>,-1;BYSETPOS=1) — there the legacy day comes from the
// aligned startDate (see getAlignedStartDate), and the legacy engine
// clamps it natively.
out.monthlyLastDay = true;
}
}
return out;
/**
* True when the rule maps onto legacy fields that fire on the same days
* i.e. `rruleToLegacyTaskRepeatCfg` will NOT write the never-fires sentinel.
* Unparseable / sub-daily rules return `true`: they never reach persistence
* (save-path rejects), so there is nothing to warn about.
*/
export const isRRuleLegacyRepresentable = (rrule: string | undefined): boolean => {
const opts = safeParseRRuleOptions(rrule);
if (!opts) return true;
const cycle = FREQ_TO_CYCLE[opts.freq];
if (!cycle) return true;
return _legacyEquivalent(opts, cycle) != null;
};
const _pad2 = (n: number): string => String(n).padStart(2, '0');

View file

@ -0,0 +1,135 @@
import {
setUntil,
setYearDay,
toggleByDay,
toggleByMonth,
toggleMonthDay,
toggleNthDay,
weekdayAnnotations,
} from './rrule-calendar-ops.util';
import { rruleToFormModel } from './rrule-form.util';
// Fixed reference date (10 Jun 2020) so the form-model defaults are deterministic.
const REF = new Date('2020-06-10T12:00:00Z');
describe('rrule-calendar-ops.util', () => {
describe('toggleMonthDay', () => {
it('switches a non-monthly rule into a fresh day-of-month set', () => {
expect(toggleMonthDay('FREQ=DAILY', REF, 15)).toBe('FREQ=MONTHLY;BYMONTHDAY=15');
});
it('adds a second day within day-of-month mode (sorted)', () => {
expect(toggleMonthDay('FREQ=MONTHLY;BYMONTHDAY=15', REF, 20)).toBe(
'FREQ=MONTHLY;BYMONTHDAY=15,20',
);
});
it('removes a day that was already selected', () => {
expect(toggleMonthDay('FREQ=MONTHLY;BYMONTHDAY=15', REF, 15)).toBe('FREQ=MONTHLY');
});
});
describe('setYearDay', () => {
it('sets the single yearly date (month + day)', () => {
expect(setYearDay('FREQ=DAILY', REF, 6, 10)).toBe(
'FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=10',
);
});
it('clears when the active date is re-selected', () => {
expect(setYearDay('FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=10', REF, 6, 10)).toBe(
'FREQ=YEARLY',
);
});
it('moves the date when a different day is selected', () => {
expect(setYearDay('FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=10', REF, 3, 5)).toBe(
'FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=5',
);
});
});
describe('setUntil', () => {
it('sets UNTIL (noon UTC) and the end-type', () => {
expect(setUntil('FREQ=WEEKLY;BYDAY=MO', REF, '2026-12-31')).toBe(
'FREQ=WEEKLY;BYDAY=MO;UNTIL=20261231T120000Z',
);
});
});
describe('toggleByDay', () => {
it('starts a fresh monthly weekday set', () => {
expect(toggleByDay('FREQ=DAILY', REF, 'MO', 'MONTHLY')).toBe(
'FREQ=MONTHLY;BYDAY=MO',
);
});
it('adds a weekday within the set (Mon-first)', () => {
expect(toggleByDay('FREQ=MONTHLY;BYDAY=MO', REF, 'WE', 'MONTHLY')).toBe(
'FREQ=MONTHLY;BYDAY=MO,WE',
);
});
it('yearly weekday set keeps BYMONTH', () => {
expect(toggleByDay('FREQ=YEARLY;BYMONTH=6', REF, 'SA', 'YEARLY')).toBe(
'FREQ=YEARLY;BYMONTH=6;BYDAY=SA',
);
});
});
describe('toggleNthDay', () => {
it('starts a fresh nth-weekday row', () => {
expect(toggleNthDay('FREQ=DAILY', REF, 'MO', 2, 'MONTHLY')).toBe(
'FREQ=MONTHLY;BYDAY=2MO',
);
});
it('adds a second ordinal/weekday', () => {
expect(toggleNthDay('FREQ=MONTHLY;BYDAY=2MO', REF, 'SU', 4, 'MONTHLY')).toBe(
'FREQ=MONTHLY;BYDAY=2MO,4SU',
);
});
it('removes a weekday at its ordinal', () => {
expect(toggleNthDay('FREQ=MONTHLY;BYDAY=2MO', REF, 'MO', 2, 'MONTHLY')).toBe(
'FREQ=MONTHLY',
);
});
it('supports the last (-1) ordinal', () => {
expect(toggleNthDay('FREQ=DAILY', REF, 'FR', -1, 'MONTHLY')).toBe(
'FREQ=MONTHLY;BYDAY=-1FR',
);
});
});
describe('toggleByMonth', () => {
it('adds a month constraint to any frequency', () => {
expect(toggleByMonth('FREQ=DAILY', REF, 6)).toBe('FREQ=DAILY;BYMONTH=6');
});
it('removes a month that was set', () => {
expect(toggleByMonth('FREQ=DAILY;BYMONTH=6', REF, 6)).toBe('FREQ=DAILY');
});
it('keeps months sorted', () => {
expect(toggleByMonth('FREQ=DAILY;BYMONTH=6', REF, 3)).toBe(
'FREQ=DAILY;BYMONTH=3,6',
);
});
});
describe('weekdayAnnotations', () => {
it('marks nth ordinals per weekday (MO=0 … SU=6)', () => {
const a = weekdayAnnotations(rruleToFormModel('FREQ=MONTHLY;BYDAY=2MO,4SU', REF));
expect(a.get(0)?.nth).toEqual(['2']); // MO
expect(a.get(6)?.nth).toEqual(['4']); // SU
expect(a.get(0)?.selected).toBe(false);
});
it('marks the last ordinal as L', () => {
const a = weekdayAnnotations(rruleToFormModel('FREQ=MONTHLY;BYDAY=-1FR', REF));
expect(a.get(4)?.nth).toEqual(['L']); // FR
});
it('marks selected days for monthly weekday set', () => {
const a = weekdayAnnotations(rruleToFormModel('FREQ=MONTHLY;BYDAY=MO,WE', REF));
expect(a.get(0)?.selected).toBe(true); // MO
expect(a.get(2)?.selected).toBe(true); // WE
expect(a.get(0)?.nth).toEqual([]);
});
it('marks in-months days for yearly weekday set', () => {
const a = weekdayAnnotations(
rruleToFormModel('FREQ=YEARLY;BYMONTH=6;BYDAY=SA', REF),
);
expect(a.get(5)?.inMonths).toBe(true); // SA
});
});
});

View file

@ -0,0 +1,249 @@
import {
formModelToRRule,
RRULE_WEEKDAYS,
RRuleFormModel,
RRuleWeekday,
rruleToFormModel,
} from './rrule-form.util';
/**
* Pure edit operations that let the preview calendar manipulate the RRULE string
* directly (click a day / weekday / month patch the rule). Every op is
* `(rrule, refDate, …) => newRrule`: parse the current rule into the structured
* form model, patch one field, and re-serialize so all rule logic stays in
* `rrule-form.util.ts` (shared with the builder) and is never duplicated.
*
* The calendar and the builder therefore edit the SAME rule: the dialog routes
* these results through its `onRRuleChange`, the builder re-syncs from the input.
*/
type MonthlyOrYearly = 'MONTHLY' | 'YEARLY';
type ByDayFreq = 'WEEKLY' | 'MONTHLY' | 'YEARLY';
const edit = (
rrule: string | undefined,
refDate: Date,
patch: (m: RRuleFormModel) => void,
): string => {
const model = rruleToFormModel(rrule, refDate);
patch(model);
// A calendar click is an explicit intent to define the rule structurally, so
// never let a parse-time raw override (set by the round-trip guard for rules
// the structured form can't reproduce verbatim) short-circuit serialization.
model.rawOverride = '';
return formModelToRRule(model);
};
/** Order a weekday list Mon-first so the emitted BYDAY is canonical. */
const monFirst = (days: RRuleWeekday[]): RRuleWeekday[] =>
RRULE_WEEKDAYS.filter((d) => days.includes(d));
/** Toggle a day-of-month (1..31, or -1 last) in MONTHLY day-of-month mode. */
export const toggleMonthDay = (
rrule: string | undefined,
refDate: Date,
day: number,
): string =>
edit(rrule, refDate, (m) => {
// Switching INTO day-of-month from another mode starts a fresh set (don't
// inherit the refDate-seeded default day); within the mode, toggle.
const wasMode = m.freq === 'MONTHLY' && m.monthlyMode === 'DAY_OF_MONTH';
m.freq = 'MONTHLY';
m.monthlyMode = 'DAY_OF_MONTH';
if (!wasMode) {
m.monthDays = [day];
} else {
m.monthDays = m.monthDays.includes(day)
? m.monthDays.filter((d) => d !== day)
: [...m.monthDays, day].sort((a, b) => a - b);
}
});
/** Set the single YEARLY date (month 1..12 + day 1..31). Re-click a different
* day to move it; clicking the active date again clears it back to start-anchored. */
export const setYearDay = (
rrule: string | undefined,
refDate: Date,
month: number,
day: number,
): string =>
edit(rrule, refDate, (m) => {
m.freq = 'YEARLY';
m.yearlyMode = 'DAY_OF_MONTH';
const isActive =
m.byMonth.length === 1 &&
m.byMonth[0] === month &&
m.monthDays.length === 1 &&
m.monthDays[0] === day;
if (isActive) {
m.byMonth = [];
m.monthDays = [];
} else {
m.byMonth = [month];
m.monthDays = [day];
}
});
/** Set UNTIL (end on date) and flip the end-type to UNTIL. */
export const setUntil = (
rrule: string | undefined,
refDate: Date,
dateStr: string,
): string =>
edit(rrule, refDate, (m) => {
m.endType = 'UNTIL';
m.until = dateStr;
});
/** Set the end condition: NEVER / after COUNT / UNTIL a date. */
export const setEnd = (
rrule: string | undefined,
refDate: Date,
endType: 'NEVER' | 'COUNT' | 'UNTIL',
value?: string | number,
): string =>
edit(rrule, refDate, (m) => {
m.endType = endType;
if (endType === 'COUNT' && value != null) {
m.count = Math.max(1, Math.trunc(Number(value)) || 1);
}
if (endType === 'UNTIL' && value != null) {
m.until = String(value);
}
});
/** Toggle a plain weekday in the "selected days of the week" set, in WEEKLY /
* MONTHLY / YEARLY mode (WEEKLY = the native weekly BYDAY; MONTHLY/YEARLY =
* their WEEKDAYS mode). */
export const toggleByDay = (
rrule: string | undefined,
refDate: Date,
weekday: RRuleWeekday,
freq: ByDayFreq,
): string =>
edit(rrule, refDate, (m) => {
const wasMode =
freq === 'WEEKLY'
? m.freq === 'WEEKLY'
: freq === 'MONTHLY'
? m.freq === 'MONTHLY' && m.monthlyMode === 'WEEKDAYS'
: m.freq === 'YEARLY' && m.yearlyMode === 'WEEKDAYS';
m.freq = freq;
if (freq === 'MONTHLY') {
m.monthlyMode = 'WEEKDAYS';
} else if (freq === 'YEARLY') {
m.yearlyMode = 'WEEKDAYS';
}
const base = wasMode ? m.byDay : [];
const has = base.includes(weekday);
m.byDay = monFirst(has ? base.filter((d) => d !== weekday) : [...base, weekday]);
});
/** Toggle a weekday at a given ordinal (1..4, -1 last) in nth-weekday mode. */
export const toggleNthDay = (
rrule: string | undefined,
refDate: Date,
weekday: RRuleWeekday,
ordinal: number,
freq: MonthlyOrYearly,
): string =>
edit(rrule, refDate, (m) => {
const wasMode =
freq === 'MONTHLY'
? m.freq === 'MONTHLY' && m.monthlyMode === 'NTH_WEEKDAY'
: m.freq === 'YEARLY' && m.yearlyMode === 'NTH_WEEKDAY';
m.freq = freq;
if (freq === 'MONTHLY') {
m.monthlyMode = 'NTH_WEEKDAY';
} else {
m.yearlyMode = 'NTH_WEEKDAY';
}
const rows = wasMode ? m.nthDays.map((r) => ({ pos: r.pos, days: [...r.days] })) : [];
const row = rows.find((r) => r.pos === ordinal);
if (row) {
row.days = monFirst(
row.days.includes(weekday)
? row.days.filter((d) => d !== weekday)
: [...row.days, weekday],
);
} else {
rows.push({ pos: ordinal, days: [weekday] });
}
m.nthDays = rows.filter((r) => r.days.length > 0);
});
/** Toggle a month (1..12) in the BYMONTH seasonal constraint. */
export const toggleByMonth = (
rrule: string | undefined,
refDate: Date,
month: number,
): string =>
edit(rrule, refDate, (m) => {
const has = m.byMonth.includes(month);
m.byMonth = has
? m.byMonth.filter((x) => x !== month)
: [...m.byMonth, month].sort((a, b) => a - b);
});
/** Clear ALL BYMONTH limits (the rule fires in every month again). */
export const clearMonths = (rrule: string | undefined, refDate: Date): string =>
edit(rrule, refDate, (m) => {
m.byMonth = [];
});
/** Per-weekday annotation glyph state, derived from the live rule. Keyed by the
* RRULE weekday index (0=Mon 6=Sun) so the calendar can map its columns. */
export interface WeekdayAnnotation {
/** Ordinal labels for nth-weekday mode, e.g. ['2','L'] for 2nd + last. */
nth: string[];
/** Member of the MONTHLY "selected days of the week" set. */
selected: boolean;
/** Member of the YEARLY "days of the week in months" set. */
inMonths: boolean;
}
/** Short ordinal glyph: 1..4 → "1".."4"; -1 → "L"; other → the signed number. */
const ordinalGlyph = (pos: number): string => (pos === -1 ? 'L' : String(pos));
/** Build per-weekday annotations from a parsed model (pure; used by the dialog to
* feed the calendar's weekday-header glyphs). Only the active mode contributes. */
export const weekdayAnnotations = (
model: RRuleFormModel,
): Map<number, WeekdayAnnotation> => {
const map = new Map<number, WeekdayAnnotation>();
const ensure = (idx: number): WeekdayAnnotation => {
let a = map.get(idx);
if (!a) {
a = { nth: [], selected: false, inMonths: false };
map.set(idx, a);
}
return a;
};
const idxOf = (wd: RRuleWeekday): number => RRULE_WEEKDAYS.indexOf(wd);
const nthActive =
(model.freq === 'MONTHLY' && model.monthlyMode === 'NTH_WEEKDAY') ||
(model.freq === 'YEARLY' && model.yearlyMode === 'NTH_WEEKDAY');
if (nthActive) {
for (const row of model.nthDays) {
for (const d of row.days) {
ensure(idxOf(d)).nth.push(ordinalGlyph(row.pos));
}
}
}
if (
model.freq === 'WEEKLY' ||
(model.freq === 'MONTHLY' && model.monthlyMode === 'WEEKDAYS')
) {
for (const d of model.byDay) {
ensure(idxOf(d)).selected = true;
}
}
if (model.freq === 'YEARLY' && model.yearlyMode === 'WEEKDAYS') {
for (const d of model.byDay) {
ensure(idxOf(d)).inMonths = true;
}
}
return map;
};

View file

@ -12,17 +12,30 @@ export type RRuleParsedOptions = Partial<ReturnType<typeof RRule.parseString>> &
freq: Frequency;
};
// Rule strings are few, immutable, and re-parsed many times per keystroke (the
// dialog preview/validity computeds) and on every overdue/day-change scan. The
// regex parse is the cost; a tiny memo makes the repeats free. Callers treat the
// result as read-only (they spread it into a new options object, never mutate
// it), so a shared instance is safe.
const _parseCache = new Map<string, RRuleParsedOptions | null>();
/** Parse an RRULE body; null when unparseable or lacking a FREQ. */
export const safeParseRRuleOptions = (
rrule: string | undefined,
): RRuleParsedOptions | null => {
if (!rrule || !rrule.trim()) return null;
const cached = _parseCache.get(rrule);
if (cached !== undefined) return cached;
let result: RRuleParsedOptions | null;
try {
const opts = RRule.parseString(rrule);
return opts.freq == null ? null : (opts as RRuleParsedOptions);
result = opts.freq == null ? null : (opts as RRuleParsedOptions);
} catch {
return null;
result = null;
}
if (_parseCache.size > 200) _parseCache.clear();
_parseCache.set(rrule, result);
return result;
};
/** rrule Frequency day-granular repeat cycle. Sub-daily FREQs are absent

View file

@ -37,7 +37,6 @@ import { Project } from '../../../project/project.model';
import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service';
import { RepeatQuickSetting } from '../../../task-repeat-cfg/task-repeat-cfg.model';
import { buildRepeatQuickSettingOptions } from '../../../task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options';
import { RRuleFeatureFlagService } from '../../../config/rrule-feature-flag.service';
import { DateService } from '../../../../core/date/date.service';
import { MenuTreeService } from '../../../menu-tree/menu-tree.service';
import { SelectOptionRowComponent } from '../../../../ui/select-option-row/select-option-row.component';
@ -71,7 +70,6 @@ export class AddTaskBarActionsComponent {
private _translateService = inject(TranslateService);
private _dateService = inject(DateService);
private _menuTreeService = inject(MenuTreeService);
private _rruleFlag = inject(RRuleFeatureFlagService);
stateService = inject(AddTaskBarStateService);
T = T;
@ -193,8 +191,6 @@ export class AddTaskBarActionsComponent {
refDate,
this._dateTimeFormatService.currentLocale(),
this._translateService,
// Advanced 'RRULE' entry only when the per-device engine flag is on.
this._rruleFlag.isEnabled(),
);
});

View file

@ -138,6 +138,13 @@ const T = {
'F.BOARDS.FORM.BACKLOG_TASK_FILTER_ONLY_BACKLOG',
BACKLOG_TASK_FILTER_TYPE: 'F.BOARDS.FORM.BACKLOG_TASK_FILTER_TYPE',
COLUMNS: 'F.BOARDS.FORM.COLUMNS',
ONLY_PARENT_TASKS: 'F.BOARDS.FORM.ONLY_PARENT_TASKS',
PROJECT: 'F.BOARDS.FORM.PROJECT',
PROJECT_ALL: 'F.BOARDS.FORM.PROJECT_ALL',
SCHEDULED_STATE: 'F.BOARDS.FORM.SCHEDULED_STATE',
SCHEDULED_STATE_ALL: 'F.BOARDS.FORM.SCHEDULED_STATE_ALL',
SCHEDULED_STATE_NOT_SCHEDULED: 'F.BOARDS.FORM.SCHEDULED_STATE_NOT_SCHEDULED',
SCHEDULED_STATE_SCHEDULED: 'F.BOARDS.FORM.SCHEDULED_STATE_SCHEDULED',
SORT_BY: 'F.BOARDS.FORM.SORT_BY',
SORT_BY_CREATED: 'F.BOARDS.FORM.SORT_BY_CREATED',
SORT_BY_DUE: 'F.BOARDS.FORM.SORT_BY_DUE',
@ -147,13 +154,6 @@ const T = {
SORT_DIR: 'F.BOARDS.FORM.SORT_DIR',
SORT_DIR_ASC: 'F.BOARDS.FORM.SORT_DIR_ASC',
SORT_DIR_DESC: 'F.BOARDS.FORM.SORT_DIR_DESC',
SCHEDULED_STATE: 'F.BOARDS.FORM.SCHEDULED_STATE',
SCHEDULED_STATE_ALL: 'F.BOARDS.FORM.SCHEDULED_STATE_ALL',
SCHEDULED_STATE_NOT_SCHEDULED: 'F.BOARDS.FORM.SCHEDULED_STATE_NOT_SCHEDULED',
SCHEDULED_STATE_SCHEDULED: 'F.BOARDS.FORM.SCHEDULED_STATE_SCHEDULED',
PROJECT: 'F.BOARDS.FORM.PROJECT',
PROJECT_ALL: 'F.BOARDS.FORM.PROJECT_ALL',
ONLY_PARENT_TASKS: 'F.BOARDS.FORM.ONLY_PARENT_TASKS',
TAGS_EXCLUDED: 'F.BOARDS.FORM.TAGS_EXCLUDED',
TAGS_EXCLUDED_MATCH_ALL: 'F.BOARDS.FORM.TAGS_EXCLUDED_MATCH_ALL',
TAGS_EXCLUDED_MATCH_ANY: 'F.BOARDS.FORM.TAGS_EXCLUDED_MATCH_ANY',
@ -1696,14 +1696,13 @@ const T = {
DUE_BUTTON: 'F.TASK.ADD_TASK_BAR.DUE_BUTTON',
ESTIMATE_BUTTON: 'F.TASK.ADD_TASK_BAR.ESTIMATE_BUTTON',
PLACEHOLDER_CREATE: 'F.TASK.ADD_TASK_BAR.PLACEHOLDER_CREATE',
TOOLTIP_CLEAR_DEADLINE: 'F.TASK.ADD_TASK_BAR.TOOLTIP_CLEAR_DEADLINE',
PLACEHOLDER_SEARCH: 'F.TASK.ADD_TASK_BAR.PLACEHOLDER_SEARCH',
REPEAT_BUTTON: 'F.TASK.ADD_TASK_BAR.REPEAT_BUTTON',
SEARCH_INFO_TEXT: 'F.TASK.ADD_TASK_BAR.SEARCH_INFO_TEXT',
TAGS_BUTTON: 'F.TASK.ADD_TASK_BAR.TAGS_BUTTON',
TODAY: 'F.TASK.ADD_TASK_BAR.TODAY',
TOGGLE_ADD_TO_BACKLOG_TODAY: 'F.TASK.ADD_TASK_BAR.TOGGLE_ADD_TO_BACKLOG_TODAY',
TOGGLE_ADD_TOP_OR_BOTTOM: 'F.TASK.ADD_TASK_BAR.TOGGLE_ADD_TOP_OR_BOTTOM',
TOGGLE_ADD_TO_BACKLOG_TODAY: 'F.TASK.ADD_TASK_BAR.TOGGLE_ADD_TO_BACKLOG_TODAY',
TOMORROW: 'F.TASK.ADD_TASK_BAR.TOMORROW',
TOOLTIP_ADD_TASK: 'F.TASK.ADD_TASK_BAR.TOOLTIP_ADD_TASK',
TOOLTIP_ADD_TO_BACKLOG: 'F.TASK.ADD_TASK_BAR.TOOLTIP_ADD_TO_BACKLOG',
@ -1711,6 +1710,7 @@ const T = {
TOOLTIP_ADD_TO_TODAY: 'F.TASK.ADD_TASK_BAR.TOOLTIP_ADD_TO_TODAY',
TOOLTIP_ADD_TO_TOP: 'F.TASK.ADD_TASK_BAR.TOOLTIP_ADD_TO_TOP',
TOOLTIP_CLEAR_DATE: 'F.TASK.ADD_TASK_BAR.TOOLTIP_CLEAR_DATE',
TOOLTIP_CLEAR_DEADLINE: 'F.TASK.ADD_TASK_BAR.TOOLTIP_CLEAR_DEADLINE',
TOOLTIP_CLEAR_ESTIMATE: 'F.TASK.ADD_TASK_BAR.TOOLTIP_CLEAR_ESTIMATE',
TOOLTIP_CLEAR_REPEAT: 'F.TASK.ADD_TASK_BAR.TOOLTIP_CLEAR_REPEAT',
TOOLTIP_CLEAR_TAGS: 'F.TASK.ADD_TASK_BAR.TOOLTIP_CLEAR_TAGS',
@ -1982,9 +1982,13 @@ const T = {
},
F: {
C_DAY: 'F.TASK_REPEAT.F.C_DAY',
C_DAYS: 'F.TASK_REPEAT.F.C_DAYS',
C_MONTH: 'F.TASK_REPEAT.F.C_MONTH',
C_MONTHS: 'F.TASK_REPEAT.F.C_MONTHS',
C_WEEK: 'F.TASK_REPEAT.F.C_WEEK',
C_WEEKS: 'F.TASK_REPEAT.F.C_WEEKS',
C_YEAR: 'F.TASK_REPEAT.F.C_YEAR',
C_YEARS: 'F.TASK_REPEAT.F.C_YEARS',
DEFAULT_ESTIMATE: 'F.TASK_REPEAT.F.DEFAULT_ESTIMATE',
DISABLE_AUTO_UPDATE_SUBTASKS: 'F.TASK_REPEAT.F.DISABLE_AUTO_UPDATE_SUBTASKS',
DISABLE_AUTO_UPDATE_SUBTASKS_DESCRIPTION:
@ -2026,12 +2030,35 @@ const T = {
Q_WEEKLY_CURRENT_WEEKDAY: 'F.TASK_REPEAT.F.Q_WEEKLY_CURRENT_WEEKDAY',
Q_YEARLY_CURRENT_DATE: 'F.TASK_REPEAT.F.Q_YEARLY_CURRENT_DATE',
QUICK_SETTING: 'F.TASK_REPEAT.F.QUICK_SETTING',
QUICK_SETTING_FEWER: 'F.TASK_REPEAT.F.QUICK_SETTING_FEWER',
QUICK_SETTING_MORE: 'F.TASK_REPEAT.F.QUICK_SETTING_MORE',
REMIND_AT: 'F.TASK_REPEAT.F.REMIND_AT',
REMIND_AT_PLACEHOLDER: 'F.TASK_REPEAT.F.REMIND_AT_PLACEHOLDER',
SKIP_FOR_DATE: 'F.TASK_REPEAT.F.SKIP_FOR_DATE',
SKIP_INSTANCE: 'F.TASK_REPEAT.F.SKIP_INSTANCE',
REPEAT_CYCLE: 'F.TASK_REPEAT.F.REPEAT_CYCLE',
REPEAT_EVERY: 'F.TASK_REPEAT.F.REPEAT_EVERY',
CAL_MENU_SET_START: 'F.TASK_REPEAT.F.CAL_MENU_SET_START',
CAL_MENU_ENDS_ON: 'F.TASK_REPEAT.F.CAL_MENU_ENDS_ON',
CAL_MENU_REMOVE_ENDS: 'F.TASK_REPEAT.F.CAL_MENU_REMOVE_ENDS',
CAL_MENU_DAY_OF_MONTH: 'F.TASK_REPEAT.F.CAL_MENU_DAY_OF_MONTH',
CAL_MENU_REMOVE_DAY_OF_MONTH: 'F.TASK_REPEAT.F.CAL_MENU_REMOVE_DAY_OF_MONTH',
CAL_MENU_DAY_OF_YEAR: 'F.TASK_REPEAT.F.CAL_MENU_DAY_OF_YEAR',
CAL_MENU_REMOVE_DAY_OF_YEAR: 'F.TASK_REPEAT.F.CAL_MENU_REMOVE_DAY_OF_YEAR',
CAL_MENU_SIMULATE: 'F.TASK_REPEAT.F.CAL_MENU_SIMULATE',
CAL_MENU_STOP_SIMULATE: 'F.TASK_REPEAT.F.CAL_MENU_STOP_SIMULATE',
CAL_MENU_LIMIT_MONTH: 'F.TASK_REPEAT.F.CAL_MENU_LIMIT_MONTH',
CAL_MENU_UNLIMIT_MONTH: 'F.TASK_REPEAT.F.CAL_MENU_UNLIMIT_MONTH',
CAL_MENU_CLEAR_MONTHS: 'F.TASK_REPEAT.F.CAL_MENU_CLEAR_MONTHS',
CAL_MENU_SELECTED_DAYS: 'F.TASK_REPEAT.F.CAL_MENU_SELECTED_DAYS',
CAL_MENU_REMOVE_SELECTED_DAYS: 'F.TASK_REPEAT.F.CAL_MENU_REMOVE_SELECTED_DAYS',
CAL_MENU_REMOVE_NTH: 'F.TASK_REPEAT.F.CAL_MENU_REMOVE_NTH',
CAL_TIP_IN_MONTHS: 'F.TASK_REPEAT.F.CAL_TIP_IN_MONTHS',
CAL_TIP_LIMITED_MONTHS: 'F.TASK_REPEAT.F.CAL_TIP_LIMITED_MONTHS',
CAL_MENU_SWITCHES_WEEKLY: 'F.TASK_REPEAT.F.CAL_MENU_SWITCHES_WEEKLY',
CAL_MENU_SWITCHES_MONTHLY: 'F.TASK_REPEAT.F.CAL_MENU_SWITCHES_MONTHLY',
CAL_MENU_SWITCHES_YEARLY: 'F.TASK_REPEAT.F.CAL_MENU_SWITCHES_YEARLY',
CAL_GLYPH_HINT: 'F.TASK_REPEAT.F.CAL_GLYPH_HINT',
RRULE_BYDAY: 'F.TASK_REPEAT.F.RRULE_BYDAY',
RRULE_BYDAY_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_BYDAY_DESCRIPTION',
RRULE_BYMONTH: 'F.TASK_REPEAT.F.RRULE_BYMONTH',
@ -2054,10 +2081,15 @@ const T = {
RRULE_END_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_END_DESCRIPTION',
RRULE_END_NEVER: 'F.TASK_REPEAT.F.RRULE_END_NEVER',
RRULE_END_UNTIL: 'F.TASK_REPEAT.F.RRULE_END_UNTIL',
RRULE_ENGINE_OFF_ADVANCED: 'F.TASK_REPEAT.F.RRULE_ENGINE_OFF_ADVANCED',
RRULE_ENGINE_OFF_END: 'F.TASK_REPEAT.F.RRULE_ENGINE_OFF_END',
RRULE_ENGINE_OFF_NOTICE: 'F.TASK_REPEAT.F.RRULE_ENGINE_OFF_NOTICE',
RRULE_FREQ: 'F.TASK_REPEAT.F.RRULE_FREQ',
RRULE_FREQ_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_FREQ_DESCRIPTION',
RRULE_INTERVAL: 'F.TASK_REPEAT.F.RRULE_INTERVAL',
RRULE_INTERVAL_DECREMENT: 'F.TASK_REPEAT.F.RRULE_INTERVAL_DECREMENT',
RRULE_INTERVAL_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_INTERVAL_DESCRIPTION',
RRULE_INTERVAL_INCREMENT: 'F.TASK_REPEAT.F.RRULE_INTERVAL_INCREMENT',
RRULE_ADVANCED: 'F.TASK_REPEAT.F.RRULE_ADVANCED',
RRULE_ADVANCED_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_ADVANCED_DESCRIPTION',
RRULE_SCHEDULE_TYPE: 'F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE',
@ -2065,8 +2097,17 @@ const T = {
RRULE_SCHEDULE_FROM_COMPLETION: 'F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_COMPLETION',
RRULE_SCHEDULE_TYPE_DESCRIPTION:
'F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESCRIPTION',
RRULE_SCHEDULE_TYPE_DESC_FIXED: 'F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESC_FIXED',
RRULE_SCHEDULE_TYPE_DESC_COMPLETION:
'F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESC_COMPLETION',
RRULE_NEXT: 'F.TASK_REPEAT.F.RRULE_NEXT',
RRULE_NEXT_AFTER_COMPLETION: 'F.TASK_REPEAT.F.RRULE_NEXT_AFTER_COMPLETION',
RRULE_CALENDAR_PREVIEW: 'F.TASK_REPEAT.F.RRULE_CALENDAR_PREVIEW',
RRULE_CALENDAR_PREVIEW_EMPTY: 'F.TASK_REPEAT.F.RRULE_CALENDAR_PREVIEW_EMPTY',
RRULE_PREVIEW_INVALID: 'F.TASK_REPEAT.F.RRULE_PREVIEW_INVALID',
RRULE_SIM_LABEL: 'F.TASK_REPEAT.F.RRULE_SIM_LABEL',
RRULE_SIM_RESET: 'F.TASK_REPEAT.F.RRULE_SIM_RESET',
RRULE_SIM_HINT: 'F.TASK_REPEAT.F.RRULE_SIM_HINT',
RRULE_NLP_EVERY: 'F.TASK_REPEAT.F.RRULE_NLP_EVERY',
RRULE_NLP_DAY: 'F.TASK_REPEAT.F.RRULE_NLP_DAY',
RRULE_NLP_DAYS: 'F.TASK_REPEAT.F.RRULE_NLP_DAYS',
@ -2101,7 +2142,15 @@ const T = {
RRULE_RAW_PLACEHOLDER: 'F.TASK_REPEAT.F.RRULE_RAW_PLACEHOLDER',
RRULE_FREQ_UNSUPPORTED: 'F.TASK_REPEAT.F.RRULE_FREQ_UNSUPPORTED',
RRULE_INVALID: 'F.TASK_REPEAT.F.RRULE_INVALID',
RRULE_LEGACY_INCOMPAT: 'F.TASK_REPEAT.F.RRULE_LEGACY_INCOMPAT',
RRULE_NO_OCCURRENCE: 'F.TASK_REPEAT.F.RRULE_NO_OCCURRENCE',
RRULE_PREVIEW_EMPTY_WINDOW: 'F.TASK_REPEAT.F.RRULE_PREVIEW_EMPTY_WINDOW',
RRULE_PREVIEW_NEXT: 'F.TASK_REPEAT.F.RRULE_PREVIEW_NEXT',
RRULE_PREVIEW_COUNT: 'F.TASK_REPEAT.F.RRULE_PREVIEW_COUNT',
RRULE_PREVIEW_EVERY: 'F.TASK_REPEAT.F.RRULE_PREVIEW_EVERY',
RRULE_PREVIEW_ENDS_AFTER: 'F.TASK_REPEAT.F.RRULE_PREVIEW_ENDS_AFTER',
RRULE_PREVIEW_ENDS_UNTIL: 'F.TASK_REPEAT.F.RRULE_PREVIEW_ENDS_UNTIL',
RRULE_PREVIEW_ENDS_NEVER: 'F.TASK_REPEAT.F.RRULE_PREVIEW_ENDS_NEVER',
RRULE_COUNT_WITH_COMPLETION: 'F.TASK_REPEAT.F.RRULE_COUNT_WITH_COMPLETION',
RRULE_RAW: 'F.TASK_REPEAT.F.RRULE_RAW',
RRULE_RAW_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_RAW_DESCRIPTION',
@ -2118,7 +2167,6 @@ const T = {
RRULE_ADD_NTH: 'F.TASK_REPEAT.F.RRULE_ADD_NTH',
RRULE_NTH_WEEKDAY_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY_DESCRIPTION',
RRULE_YEARLY_MODE: 'F.TASK_REPEAT.F.RRULE_YEARLY_MODE',
RRULE_YEARLY_MODE_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_YEARLY_MODE_DESCRIPTION',
RRULE_MONTH_1: 'F.TASK_REPEAT.F.RRULE_MONTH_1',
RRULE_MONTH_2: 'F.TASK_REPEAT.F.RRULE_MONTH_2',
RRULE_MONTH_3: 'F.TASK_REPEAT.F.RRULE_MONTH_3',
@ -2146,6 +2194,7 @@ const T = {
SKIP_OVERDUE: 'F.TASK_REPEAT.F.SKIP_OVERDUE',
SKIP_OVERDUE_DESCRIPTION: 'F.TASK_REPEAT.F.SKIP_OVERDUE_DESCRIPTION',
START_DATE: 'F.TASK_REPEAT.F.START_DATE',
RRULE_PICK_START_HINT: 'F.TASK_REPEAT.F.RRULE_PICK_START_HINT',
START_TIME: 'F.TASK_REPEAT.F.START_TIME',
START_TIME_DESCRIPTION: 'F.TASK_REPEAT.F.START_TIME_DESCRIPTION',
SUNDAY: 'F.TASK_REPEAT.F.SUNDAY',
@ -2372,6 +2421,21 @@ const T = {
ENABLED: 'G.ENABLED',
EXAMPLE_VAL: 'G.EXAMPLE_VAL',
EXTENSION_INFO: 'G.EXTENSION_INFO',
HEATMAP_ACTIVITY: 'G.HEATMAP_ACTIVITY',
HEATMAP_ACTIVITY_LEGEND: 'G.HEATMAP_ACTIVITY_LEGEND',
HEATMAP_COMPLETED_SIM: 'G.HEATMAP_COMPLETED_SIM',
HEATMAP_END_DAY: 'G.HEATMAP_END_DAY',
HEATMAP_HIGH: 'G.HEATMAP_HIGH',
HEATMAP_IN_DAYS: 'G.HEATMAP_IN_DAYS',
HEATMAP_LOW: 'G.HEATMAP_LOW',
HEATMAP_NEXT: 'G.HEATMAP_NEXT',
HEATMAP_OCCURRENCE_NR: 'G.HEATMAP_OCCURRENCE_NR',
HEATMAP_PROJECTED: 'G.HEATMAP_PROJECTED',
HEATMAP_SIMULATE_DAY: 'G.HEATMAP_SIMULATE_DAY',
HEATMAP_START_DAY: 'G.HEATMAP_START_DAY',
HEATMAP_TODAY: 'G.HEATMAP_TODAY',
HEATMAP_VIEW_MONTH: 'G.HEATMAP_VIEW_MONTH',
HEATMAP_VIEW_YEAR: 'G.HEATMAP_VIEW_YEAR',
HIDE: 'G.HIDE',
ICON_INP_DESCRIPTION: 'G.ICON_INP_DESCRIPTION',
INBOX_PROJECT_TITLE: 'G.INBOX_PROJECT_TITLE',
@ -2394,6 +2458,7 @@ const T = {
SUBMIT: 'G.SUBMIT',
TITLE: 'G.TITLE',
TODAY_TAG_TITLE: 'G.TODAY_TAG_TITLE',
TOGGLE_FULLSCREEN: 'G.TOGGLE_FULLSCREEN',
TOGGLE_PASSWORD_VISIBILITY: 'G.TOGGLE_PASSWORD_VISIBILITY',
UNDO: 'G.UNDO',
WITHOUT_PROJECT: 'G.WITHOUT_PROJECT',
@ -2738,6 +2803,7 @@ const T = {
IS_AUTO_ADD_WORKED_ON_TO_TODAY: 'GCF.TASKS.IS_AUTO_ADD_WORKED_ON_TO_TODAY',
IS_AUTO_MARK_PARENT_AS_DONE: 'GCF.TASKS.IS_AUTO_MARK_PARENT_AS_DONE',
IS_CONFIRM_BEFORE_DELETE: 'GCF.TASKS.IS_CONFIRM_BEFORE_DELETE',
IS_EXPAND_ACTIVITY_FOR_REPEAT_EDIT: 'GCF.TASKS.IS_EXPAND_ACTIVITY_FOR_REPEAT_EDIT',
IS_MARKDOWN_FORMATTING_IN_NOTES_ENABLED:
'GCF.TASKS.IS_MARKDOWN_FORMATTING_IN_NOTES_ENABLED',
IS_TRAY_SHOW_CURRENT: 'GCF.TASKS.IS_TRAY_SHOW_CURRENT',

View file

@ -0,0 +1,154 @@
import {
buildHeatmapMonths,
buildHeatmapWeeks,
buildProjectionDayMap,
heatmapHoursTotal,
heatmapOccurrenceTotal,
} from './build-heatmap-data.util';
import { DayData } from './heatmap.component';
const D = (s: string): Date => {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d, 12, 0, 0);
};
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
describe('buildProjectionDayMap', () => {
it('marks only occurrence days as projected (level 1) over the inclusive range', () => {
const map = buildProjectionDayMap(
[D('2024-06-03'), D('2024-06-10')],
D('2024-06-01'),
D('2024-06-12'),
);
expect(map.size).toBe(12); // inclusive both ends
expect(map.get('2024-06-03')!.isProjected).toBe(true);
expect(map.get('2024-06-03')!.level).toBe(1);
expect(map.get('2024-06-10')!.isProjected).toBe(true);
expect(map.get('2024-06-04')!.isProjected).toBe(false);
expect(map.get('2024-06-04')!.level).toBe(0);
});
it('ignores occurrences outside the range', () => {
const map = buildProjectionDayMap(
[D('2024-05-01')],
D('2024-06-01'),
D('2024-06-03'),
);
expect([...map.values()].every((d) => !d.isProjected)).toBe(true);
});
});
describe('buildHeatmapWeeks', () => {
it('lays days into 7-day weeks and emits month labels', () => {
const map = buildProjectionDayMap([], D('2024-06-01'), D('2024-06-30'));
const grid = buildHeatmapWeeks(map, D('2024-06-01'), D('2024-06-30'), 0, MONTHS);
expect(grid.weeks.length).toBeGreaterThan(0);
grid.weeks.forEach((w) => expect(w.days.length).toBe(7));
expect(grid.monthLabels).toContain('Jun');
});
it('pads cells outside the range with null', () => {
// Jun 1 2024 is a Saturday; week-start Sunday → leading nulls before it.
const map = buildProjectionDayMap([], D('2024-06-01'), D('2024-06-01'));
const grid = buildHeatmapWeeks(map, D('2024-06-01'), D('2024-06-01'), 0, MONTHS);
const nonNull = grid.weeks.flatMap((w) => w.days).filter((d) => d !== null);
expect(nonNull.length).toBe(1);
expect(nonNull[0]!.dateStr).toBe('2024-06-01');
});
});
describe('buildHeatmapMonths', () => {
it('groups days into one block per month, each a 7-row column grid', () => {
const map = buildProjectionDayMap(
[D('2024-01-15'), D('2024-02-10')],
D('2024-01-01'),
D('2024-02-29'),
);
const blocks = buildHeatmapMonths(
map,
D('2024-01-01'),
D('2024-02-29'),
0,
MONTHS,
heatmapOccurrenceTotal,
);
expect(blocks.map((b) => b.label)).toEqual(['Jan 2024', 'Feb']);
blocks.forEach((b) => b.weeks.forEach((w) => expect(w.days.length).toBe(7)));
// one projected occurrence in each month
expect(blocks[0].total).toBe('1×');
expect(blocks[1].total).toBe('1×');
});
it('year-stamps the first block of each year so a rolling window spanning the same month twice stays unambiguous', () => {
const map = buildProjectionDayMap([], D('2024-06-15'), D('2025-06-15'));
const blocks = buildHeatmapMonths(
map,
D('2024-06-15'),
D('2025-06-15'),
0,
MONTHS,
heatmapOccurrenceTotal,
);
const labels = blocks.map((b) => b.label);
// 13 partial months: Jun 2024 … Jun 2025 — the two Junes must differ.
expect(labels.length).toBe(13);
expect(labels[0]).toBe('Jun 2024');
expect(labels[7]).toBe('Jan 2025');
expect(labels[12]).toBe('Jun');
expect(labels[0]).not.toBe(labels[12]);
// Non-boundary months stay plain.
expect(labels[1]).toBe('Jul');
});
it('keeps CURRENT-year labels plain — only other years get stamped', () => {
// "Jun" not "Jun 2026": the current year is the implied default; the next
// year's stamp marks the boundary in a rolling window.
const y = new Date().getFullYear();
const from = D(`${y}-06-15`);
const to = D(`${y + 1}-06-15`);
const map = buildProjectionDayMap([], from, to);
const labels = buildHeatmapMonths(
map,
from,
to,
0,
MONTHS,
heatmapOccurrenceTotal,
).map((b) => b.label);
expect(labels[0]).toBe('Jun');
expect(labels[7]).toBe(`Jan ${y + 1}`);
expect(labels[12]).toBe('Jun');
const sameYear = buildHeatmapMonths(
buildProjectionDayMap([], D(`${y}-01-01`), D(`${y}-02-28`)),
D(`${y}-01-01`),
D(`${y}-02-28`),
0,
MONTHS,
heatmapOccurrenceTotal,
).map((b) => b.label);
expect(sameYear).toEqual(['Jan', 'Feb']);
});
it('heatmapHoursTotal sums day time as whole hours', () => {
const days = [{ timeSpent: 7_200_000 }, { timeSpent: 3_600_000 }] as DayData[];
expect(heatmapHoursTotal(days)).toBe('3h');
expect(heatmapHoursTotal([])).toBe('0h');
});
it('heatmapOccurrenceTotal is empty when a month has no occurrences', () => {
expect(heatmapOccurrenceTotal([])).toBe('');
});
});

View file

@ -0,0 +1,157 @@
import { DayData, HeatmapData, MonthBlock, WeekData } from './heatmap.component';
import { getDbDateStr } from '../../util/get-db-date-str';
/**
* Lay a `dayMap` out into a GitHub-style week grid between `startDate` and
* `endDate`. Extracted from RepeatTaskHeatmapComponent so the live RRULE preview
* can reuse it. `monthNames` is the localized short month list (JanDec).
*/
export const buildHeatmapWeeks = (
dayMap: Map<string, DayData>,
startDate: Date,
endDate: Date,
firstDayOfWeek: number,
monthNames: string[],
): HeatmapData => {
const weeks: WeekData[] = [];
const monthLabels: string[] = [];
let currentMonth = -1;
// Back up to the first day of the week containing the start date.
const firstDay = new Date(startDate);
const daysToGoBack = (firstDay.getDay() - firstDayOfWeek + 7) % 7;
firstDay.setDate(firstDay.getDate() - daysToGoBack);
const currentDate = new Date(firstDay);
let weekCount = 0;
while (currentDate <= endDate || weeks.length === 0) {
const week: WeekData = { days: [] };
for (let i = 0; i < 7; i++) {
const dateStr = getDbDateStr(currentDate);
const dayData = dayMap.get(dateStr);
if (currentDate >= startDate && currentDate <= endDate) {
week.days.push(dayData || null);
const month = currentDate.getMonth();
if (month !== currentMonth && currentDate.getDate() <= 7 && weekCount > 0) {
monthLabels.push(monthNames[month]);
currentMonth = month;
} else if (monthLabels.length === 0 && weekCount === 0) {
monthLabels.push(monthNames[month]);
currentMonth = month;
}
} else {
week.days.push(null);
}
currentDate.setDate(currentDate.getDate() + 1);
}
weeks.push(week);
weekCount++;
if (weeks.length > 54) break;
}
return { weeks, monthLabels };
};
/**
* Build a `dayMap` over `[from, to]` (inclusive) that marks the given occurrence
* days as projected (level 1, `isProjected`). Used by the live RRULE preview to
* render upcoming occurrences as a calendar without any past-activity data.
*/
export const buildProjectionDayMap = (
occurrences: Date[],
from: Date,
to: Date,
): Map<string, DayData> => {
const occSet = new Set(occurrences.map(getDbDateStr));
const dayMap = new Map<string, DayData>();
const cur = new Date(from);
cur.setHours(12, 0, 0, 0);
while (cur <= to) {
const dateStr = getDbDateStr(cur);
const isProjected = occSet.has(dateStr);
dayMap.set(dateStr, {
date: new Date(cur),
dateStr,
taskCount: 0,
timeSpent: 0,
level: isProjected ? 1 : 0,
isProjected,
});
cur.setDate(cur.getDate() + 1);
}
return dayMap;
};
/**
* Group `[startDate, endDate]` into calendar months, laying each month's days
* into its own weekday-row column grid (a mini calendar). `formatTotal` builds
* the per-month label shown beneath the block from that month's day data e.g.
* total hours for an activity heatmap, or an occurrence count for a projection.
*/
export const buildHeatmapMonths = (
dayMap: Map<string, DayData>,
startDate: Date,
endDate: Date,
firstDayOfWeek: number,
monthNames: string[],
formatTotal: (days: DayData[]) => string,
): MonthBlock[] => {
const blocks: MonthBlock[] = [];
const cursor = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
const lastMonth = new Date(endDate.getFullYear(), endDate.getMonth(), 1);
const currentYear = new Date().getFullYear();
let labeledYear: number | null = null;
while (cursor <= lastMonth) {
const year = cursor.getFullYear();
const month = cursor.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const weeks: WeekData[] = [];
const monthDays: DayData[] = [];
let column: WeekData = { days: [] };
// Pad the first column so day-of-week rows line up.
const lead = (new Date(year, month, 1).getDay() - firstDayOfWeek + 7) % 7;
for (let p = 0; p < lead; p++) column.days.push(null);
for (let d = 1; d <= daysInMonth; d++) {
const dd = dayMap.get(getDbDateStr(new Date(year, month, d))) ?? null;
column.days.push(dd);
if (dd) monthDays.push(dd);
if (column.days.length === 7) {
weeks.push(column);
column = { days: [] };
}
}
if (column.days.length) {
while (column.days.length < 7) column.days.push(null);
weeks.push(column);
}
// A rolling window can span the same month twice (e.g. two Junes a year
// apart) — year-stamp the first block of each year so they're tellable
// apart. The CURRENT year stays plain ("Jun", not "Jun 2026"): it's the
// implied default, and the next year's stamp marks the boundary.
const label =
year !== labeledYear && year !== currentYear
? `${monthNames[month]} ${year}`
: monthNames[month];
labeledYear = year;
blocks.push({ label, total: formatTotal(monthDays), weeks, monthIndex: month });
cursor.setMonth(cursor.getMonth() + 1);
}
return blocks;
};
/** Per-month total as whole hours (e.g. `186h`) — for activity/history heatmaps. */
export const heatmapHoursTotal = (days: DayData[]): string =>
`${Math.round(days.reduce((sum, d) => sum + d.timeSpent, 0) / 3_600_000)}h`;
/** Per-month total as an occurrence count (e.g. `6×`) for projection previews;
* empty when the month has none. */
export const heatmapOccurrenceTotal = (days: DayData[]): string => {
const n = days.filter((d) => d.isProjected || d.isCompleted).length;
return n ? `${n}×` : '';
};

View file

@ -0,0 +1,129 @@
<div
class="month-cal"
[class.is-interactive]="interactive()"
>
@if (tip(); as t) {
<div
class="heatmap-tip"
[style.left.px]="t.x"
[style.top.px]="t.y"
>
{{ t.text }}
</div>
}
<div class="month-cal__header">
<button
mat-icon-button
type="button"
[disabled]="!canPrev()"
(click)="prev()"
>
<mat-icon>chevron_left</mat-icon>
</button>
<div
class="month-cal__title"
[class.is-clickable]="interactiveMenus()"
[class.is-limited]="isViewMonthLimited()"
[matTooltip]="monthTooltip()"
[matTooltipDisabled]="!monthTooltip()"
(click)="onTitleClick($event)"
>
{{ monthLabel() }}
@if (isViewMonthLimited()) {
<span class="month-cal__limit-chip"></span>
}
</div>
<button
mat-icon-button
type="button"
[disabled]="!canNext()"
(click)="next()"
>
<mat-icon>chevron_right</mat-icon>
</button>
</div>
<div class="month-cal__weekdays">
@for (col of weekdayCols(); track col.weekdayIdx) {
<div
class="month-cal__weekday"
[class.is-clickable]="interactiveMenus()"
[matTooltip]="weekdayHeaderTooltips()?.get(col.weekdayIdx) || ''"
[matTooltipDisabled]="!weekdayHeaderTooltips()?.get(col.weekdayIdx)"
(click)="onHeaderClick(col.weekdayIdx, $event)"
>
<span class="month-cal__weekday-label">{{ col.label }}</span>
@if (weekdayHeaderGlyphs()?.get(col.weekdayIdx); as g) {
<span class="month-cal__weekday-ann">
<span class="month-cal__ann-slot">{{ g.top }}</span>
<span class="month-cal__ann-slot">{{ g.mid }}</span>
<span class="month-cal__ann-slot">{{ g.bottom }}</span>
</span>
}
</div>
}
</div>
<div class="month-cal__grid">
@for (week of weeks(); track $index) {
@for (cell of week; track cell.dateStr) {
<div
[class]="getCellClass(cell)"
[attr.role]="isCellInteractive(cell) ? 'button' : null"
[attr.tabindex]="isCellInteractive(cell) ? 0 : null"
[attr.aria-label]="!cell.isOtherMonth ? getCellTitle(cell) : null"
(mouseenter)="showTip(cell, $event)"
(mouseleave)="hideTip()"
(focus)="showTip(cell, $event)"
(blur)="hideTip()"
(click)="onCellClick(cell, $event)"
(dblclick)="onCellDblClick(cell)"
(keydown.enter)="isCellInteractive(cell) && onCellKeydown($event, cell)"
(keydown.space)="isCellInteractive(cell) && onCellKeydown($event, cell)"
>
{{ cell.dayNum }}
</div>
}
}
</div>
@if (legendMode() !== 'none') {
<div class="month-cal__legend">
@if (legendMode() === 'intensity') {
<!-- Coloring is RELATIVE (ratio to the busiest day), so the legend is a
Low→High gradient, not absolute hour buckets. -->
<span class="month-cal__legend-item">{{ T.G.HEATMAP_LOW | translate }}</span>
<span class="month-cal__legend-item"><i class="sw level-1"></i></span>
<span class="month-cal__legend-item"><i class="sw level-2"></i></span>
<span class="month-cal__legend-item"><i class="sw level-3"></i></span>
<span class="month-cal__legend-item"><i class="sw level-4"></i></span>
<span class="month-cal__legend-item">{{ T.G.HEATMAP_HIGH | translate }}</span>
} @else {
<span class="month-cal__legend-item"
><i class="sw start"></i>{{ T.G.HEATMAP_START_DAY | translate }}</span
>
<span class="month-cal__legend-item"
><i class="sw today"></i>{{ T.G.HEATMAP_TODAY | translate }}</span
>
<span class="month-cal__legend-item"
><i class="sw projected"></i>{{ T.G.HEATMAP_PROJECTED | translate }}</span
>
<span class="month-cal__legend-item"
><i class="sw next"></i>{{ T.G.HEATMAP_NEXT | translate }}</span
>
<span class="month-cal__legend-item"
><i class="sw end"></i>{{ T.G.HEATMAP_END_DAY | translate }}</span
>
@if (showActivity()) {
<span class="month-cal__legend-item">
<span class="sw-ramp">
<i class="sw activity-1"></i><i class="sw activity-2"></i
><i class="sw activity-3"></i><i class="sw activity-4"></i>
</span>
{{ T.G.HEATMAP_ACTIVITY_LEGEND | translate }}
</span>
}
}
</div>
}
</div>

View file

@ -0,0 +1,420 @@
.month-cal {
// Same themeable palette as the year strip (folded from upstream v18.10.0) so
// the Calendar and 365 views share one look; the dark block below re-tunes it.
--heatmap-container-bg: var(--c-dark-10);
--heatmap-level-1-bg: color-mix(in srgb, var(--c-primary) 20%, transparent);
--heatmap-level-2-bg: color-mix(in srgb, var(--c-primary) 40%, transparent);
--heatmap-level-3-bg: color-mix(in srgb, var(--c-primary) 60%, transparent);
--heatmap-level-4-bg: var(--c-primary);
position: relative;
display: flex;
flex-direction: column;
gap: var(--s);
padding: var(--s2);
background: var(--heatmap-container-bg);
border-radius: var(--card-border-radius);
// The 1fr/aspect-ratio cells scale with the container, so on a wide page
// (metrics) the calendar outgrows the viewport cap it and keep it centered.
width: 100%;
max-width: 360px;
margin: 0 auto;
&__header {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s);
}
&__title {
font-weight: bold;
min-width: 9em;
text-align: center;
&.is-clickable {
cursor: pointer;
border-radius: var(--card-border-radius);
&:hover {
text-decoration: underline;
}
}
// Limited via BYMONTH accented so the restriction reads at a glance.
&.is-limited {
color: var(--c-primary);
}
}
&__limit-chip {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
margin-left: 4px;
vertical-align: middle;
background: var(--c-accent, var(--c-primary));
}
&__weekdays,
&__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: var(--s-quarter);
}
&__weekday {
position: relative;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 11px;
color: var(--text-color-muted);
&.is-clickable {
cursor: pointer;
border-radius: var(--card-border-radius);
&:hover {
background: var(--bg-lightest, rgba(127, 127, 127, 0.12));
}
}
}
// Per-weekday annotation glyphs (nth ordinal / selected-day / in-months), a
// tiny 3-slot column pinned to the right edge of the header.
&__weekday-ann {
position: absolute;
right: 1px;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 8px;
line-height: 1.1;
color: var(--c-primary);
pointer-events: none;
}
&__ann-slot {
min-height: 8px;
}
&__legend {
display: flex;
flex-wrap: wrap;
gap: var(--s);
justify-content: center;
margin-top: var(--s-quarter);
font-size: 11px;
color: var(--text-color-muted);
}
&__legend-item {
display: inline-flex;
align-items: center;
}
}
.sw {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
margin-right: 4px;
&.level-1 {
background: var(--heatmap-level-1-bg);
}
&.level-2 {
background: var(--heatmap-level-2-bg);
}
&.level-3 {
background: var(--heatmap-level-3-bg);
}
&.level-4 {
background: var(--heatmap-level-4-bg);
}
&.projected {
background: transparent;
outline: 1px dotted var(--c-primary);
outline-offset: -2px;
}
// Next upcoming occurrence: an accent ring, PULSING. Uses outline (not an
// inset box-shadow, which didn't render on the tiny transparent swatch in
// some themes) so it's reliably visible — like the dotted `.projected` swatch.
&.next {
background: transparent;
outline: 2px solid var(--c-accent);
outline-offset: -2px;
animation: heatmap-next-legend-pulse 1.8s ease-in-out infinite;
}
// Recurrence end (UNTIL) day: a SOLID error block (vs the hollow `.next` ring).
&.end {
background: var(--c-error, var(--c-warn));
}
&.start {
background: var(--c-primary);
}
// Activity (tracked time) legend ramp low high green, matching the cells.
&.activity-1 {
background: color-mix(in srgb, var(--c-success, #43a047) 25%, transparent);
}
&.activity-2 {
background: color-mix(in srgb, var(--c-success, #43a047) 45%, transparent);
}
&.activity-3 {
background: color-mix(in srgb, var(--c-success, #43a047) 65%, transparent);
}
&.activity-4 {
background: var(--c-success, #43a047);
}
// Today: an ink (theme text) ring via outline (reliably visible on the small
// transparent swatch, unlike an inset box-shadow).
&.today {
background: transparent;
outline: 2px solid var(--ink, currentColor);
outline-offset: -2px;
}
// Simulated completion: warm amber a distinct hue from the green
// tracked-time / activity cells (was --c-success, which read too close).
&.completed {
background: var(--c-warning, #e8a13a);
}
}
// Tracked-time range in the legend: 4 green swatches packed flush into a bar.
.sw-ramp {
display: inline-flex;
align-items: center;
gap: 2px;
margin-right: 4px;
.sw {
margin-right: 0;
}
}
.cal-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--card-border-radius);
font-size: 13px;
transition: var(--transition-fast);
// Use the THEME's text colour explicitly — inside a mat-dialog the cells would
// otherwise inherit Material's on-surface colour (e.g. white), which ignores a
// theme that recolours its text (cybr red). Solid-fill markers below set
// their own contrast colour on top.
color: var(--text-color);
&.other-month {
color: var(--text-color-muted);
opacity: 0.4;
}
// in-month, no activity plain number, no fill.
// Translucent fills (levels 13) keep the theme's default text color — the
// background barely shifts, so the surrounding text color stays readable in
// BOTH light and dark themes. Solid-primary cells use the palette's contrast
// token (what themes declare as readable ON primary) never a hex literal.
&.level-1 {
background: var(--heatmap-level-1-bg);
}
&.level-2 {
background: var(--heatmap-level-2-bg);
}
&.level-3 {
background: var(--heatmap-level-3-bg);
}
&.level-4 {
background: var(--heatmap-level-4-bg);
color: var(--c-contrast);
}
// Activity (tracked time) overlay a GREEN spectrum, distinct from the blue
// projection/start so past effort reads apart from future occurrences.
&.activity-1 {
background: color-mix(in srgb, var(--c-success, #43a047) 25%, transparent);
}
&.activity-2 {
background: color-mix(in srgb, var(--c-success, #43a047) 45%, transparent);
}
&.activity-3 {
background: color-mix(in srgb, var(--c-success, #43a047) 65%, transparent);
}
// Top activity level keeps the theme's own text colour (no hard-coded white) so
// the number reads as part of the picked theme, like every other cell.
&.activity-4 {
background: var(--c-success, #43a047);
}
// Projected future occurrence: dotted primary border.
&.projected {
background: transparent;
outline: 2px dotted var(--c-primary);
outline-offset: -2px;
}
// Simulated "completed here" day: solid WARM AMBER + ring a distinct hue
// from start (primary), next (accent), end (error) AND the green tracked-time
// cells (was --c-success/green, which read too close to tracked time).
&.completed {
background: var(--c-warning, #e8a13a);
color: var(--c-contrast);
outline: 2px solid var(--c-warning, #e8a13a);
outline-offset: 1px;
}
// Recurrence end (UNTIL) day: a SOLID error block the series stops here.
// Error (red) is the "stop" hue and a distinct marker from next / completed;
// falls back to the base warn token for themes that don't set --c-error.
&.end {
position: relative;
z-index: 2;
background: var(--c-error, var(--c-warn));
color: var(--c-contrast);
outline: 2px solid var(--c-error, var(--c-warn));
outline-offset: 1px;
}
// Today: an INK ring (the theme's text colour) so it matches the picked theme
// and stays high-contrast on any background neutral, so it never reads as
// one of the coloured occurrence markers. Drawn on a ::before overlay so it is
// NEVER clobbered when the cell is also `.start` / `.completed`.
&.today {
position: relative;
z-index: 2;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
// INSET ring so it sits ON TOP of any fill (incl. a solid start anchor).
box-shadow: inset 0 0 0 2px var(--ink, currentColor);
pointer-events: none;
z-index: 3;
}
}
// Next upcoming occurrence: pulsing accent ring on a ::after overlay (kept off
// the cell's own `animation` slot).
&.next {
position: relative;
z-index: 1;
&::after {
content: '';
position: absolute;
inset: -2px;
border-radius: var(--card-border-radius);
pointer-events: none;
animation: heatmap-next-pulse 1.8s ease-in-out infinite;
}
}
// Recurrence start / anchor day solid primary fill + a contrasting INK ring
// (not a same-colour halo) so it reads as the anchor even when the theme's
// primary is close to the green tracked-time cells.
&.start {
background: var(--c-primary);
color: var(--c-contrast);
font-weight: 600;
z-index: 2;
box-shadow:
0 0 0 1px var(--heatmap-container-bg, var(--c-dark-10)),
0 0 0 3px var(--ink, currentColor);
}
}
// Pointer/hover affordances only where clicking actually does something
// (`interactive` input) display-only calendars keep inert cells.
.month-cal.is-interactive .cal-day:not(.other-month) {
cursor: pointer;
&:hover {
outline: 1px solid var(--c-dark-20);
}
}
// Weekend tint (showWeekends input) only non-occurrence in-month cells.
.cal-day.weekend:not(.projected):not(.completed) {
background: color-mix(in srgb, var(--c-accent) 10%, transparent);
}
@keyframes heatmap-next-pulse {
0%,
100% {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--c-accent) 70%, transparent);
}
50% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 25%, transparent);
}
}
// Legend swatch pulse animates the outline COLOUR (the swatch uses an outline
// ring, not a box-shadow), so it fades in/out without reflow.
@keyframes heatmap-next-legend-pulse {
0%,
100% {
outline-color: var(--c-accent);
}
50% {
outline-color: color-mix(in srgb, var(--c-accent) 35%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.cal-day.next::after,
.sw.next {
animation: none;
}
}
// Dark theme re-tune the palette vars for contrast (v18.10.0), matching the
// year strip: stronger primary mixes over an ink overlay so the activity cells
// stay distinct on dark backgrounds.
:host-context(.isDarkTheme) {
.month-cal {
--heatmap-container-bg: var(--c-light-05);
--heatmap-level-1-bg: color-mix(
in srgb,
var(--c-primary) 38%,
rgba(var(--ink-on-channel), 0.16)
);
--heatmap-level-2-bg: color-mix(
in srgb,
var(--c-primary) 55%,
rgba(var(--ink-on-channel), 0.16)
);
--heatmap-level-3-bg: color-mix(
in srgb,
var(--c-primary) 72%,
rgba(var(--ink-on-channel), 0.16)
);
}
}
// Single shared tooltip: one readout above the hovered/focused cell (replaces
// per-cell matTooltip, which trailed on a fast sweep). Pointer-events off so it
// never re-triggers a hover; centered + lifted above the cell.
.heatmap-tip {
position: absolute;
z-index: 20;
transform: translate(-50%, calc(-100% - 6px));
padding: 4px 8px;
border-radius: 6px;
background: var(--c-dark-90, rgba(0, 0, 0, 0.85));
color: var(--c-light-90, #fff);
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
pointer-events: none;
box-shadow: var(--whiteframe-shadow-2dp, 0 1px 4px rgba(0, 0, 0, 0.4));
}

View file

@ -0,0 +1,202 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DateAdapter } from '@angular/material/core';
import { TranslateModule } from '@ngx-translate/core';
import { HeatmapMonthCalendarComponent } from './heatmap-month-calendar.component';
import { DayData } from './heatmap.component';
import { buildProjectionDayMap } from './build-heatmap-data.util';
const D = (s: string): Date => {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d, 12, 0, 0);
};
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const setup = (
rangeStart: Date,
rangeEnd: Date,
dayMap = new Map<string, DayData>(),
): ComponentFixture<HeatmapMonthCalendarComponent> => {
TestBed.configureTestingModule({
// Real TranslateModule (no translations → keys pass through): the template's
// `| translate` pipes need the full service surface, not just `instant`.
imports: [HeatmapMonthCalendarComponent, TranslateModule.forRoot()],
providers: [
{
provide: DateAdapter,
useValue: {
getFirstDayOfWeek: () => 0,
getMonthNames: () => MONTHS,
getDayOfWeekNames: () => WEEKDAYS,
},
},
],
});
const fixture = TestBed.createComponent(HeatmapMonthCalendarComponent);
fixture.componentRef.setInput('rangeStart', rangeStart);
fixture.componentRef.setInput('rangeEnd', rangeEnd);
fixture.componentRef.setInput('dayMap', dayMap);
fixture.detectChanges();
return fixture;
};
describe('HeatmapMonthCalendarComponent', () => {
// Past ranges so "today" is never inside → default month is deterministic.
it('defaults to the range-end month and lays it into 7-column weeks', () => {
const c = setup(new Date(2024, 4, 1), new Date(2024, 4, 31)).componentInstance; // May 2024
expect(c.viewMonth()).toEqual({ y: 2024, m: 4 });
// May 1 2024 is a Wednesday → Sunday-start lead 3, 31 days → 5 rows.
expect(c.weeks().length).toBe(5);
c.weeks().forEach((w) => expect(w.length).toBe(7));
});
it('clamps navigation to a single-month range', () => {
const c = setup(new Date(2024, 4, 1), new Date(2024, 4, 31)).componentInstance;
expect(c.canPrev()).toBe(false);
expect(c.canNext()).toBe(false);
});
it('navigates prev within a multi-month range and stops at the start', () => {
const c = setup(new Date(2024, 2, 1), new Date(2024, 4, 31)).componentInstance; // MarMay 2024
expect(c.viewMonth()).toEqual({ y: 2024, m: 4 }); // May (range end)
expect(c.canNext()).toBe(false);
expect(c.canPrev()).toBe(true);
c.prev();
expect(c.viewMonth()).toEqual({ y: 2024, m: 3 }); // Apr
c.prev();
expect(c.viewMonth()).toEqual({ y: 2024, m: 2 }); // Mar
expect(c.canPrev()).toBe(false);
c.prev(); // no-op past the bound
expect(c.viewMonth()).toEqual({ y: 2024, m: 2 });
});
it('marks in-month vs other-month cells', () => {
const c = setup(new Date(2024, 4, 1), new Date(2024, 4, 31)).componentInstance;
const flat = c.weeks().flat();
expect(flat.find((cell) => cell.dateStr === '2024-05-15')!.isOtherMonth).toBe(false);
// Leading cells belong to April.
expect(flat[0].isOtherMonth).toBe(true);
});
it('defaults to the CURRENT month even before noon when rangeStart is noon-anchored', () => {
// Regression: the projection preview anchors rangeStart at local noon; an
// instant comparison made "today 09:00 < rangeStart 12:00" → out of range →
// the calendar opened on the month of rangeEnd, a year ahead.
jasmine.clock().install();
try {
jasmine.clock().mockDate(new Date(2024, 4, 10, 9, 0, 0)); // 09:00 May 10
const c = setup(
new Date(2024, 4, 10, 12, 0, 0), // noon "today"
new Date(2025, 4, 10, 12, 0, 0),
).componentInstance;
expect(c.viewMonth()).toEqual({ y: 2024, m: 4 }); // May 2024, not May 2025
} finally {
jasmine.clock().uninstall();
}
});
it('defaults to the range START for a window entirely in the future', () => {
// A year-jumped projection window opens at its first month, not its last —
// landing on rangeEnd after a FORWARD jump was disorienting. Past windows
// (history years) still open on their most recent month.
const y = new Date().getFullYear() + 2;
const c = setup(new Date(y, 2, 1), new Date(y, 7, 31)).componentInstance;
expect(c.viewMonth()).toEqual({ y, m: 2 });
});
it('falls back into range when the data range changes under a navigated month', () => {
// Regression: metric year select swaps dayMap/range while the component
// stays mounted; a navigated month outside the new range stranded the user
// on an all-empty month with nav disabled.
const fixture = setup(new Date(2024, 2, 1), new Date(2024, 4, 31)); // MarMay 2024
const c = fixture.componentInstance;
c.prev();
expect(c.viewMonth()).toEqual({ y: 2024, m: 3 }); // navigated to Apr 2024
fixture.componentRef.setInput('rangeStart', new Date(2022, 0, 1));
fixture.componentRef.setInput('rangeEnd', new Date(2022, 11, 31));
fixture.detectChanges();
expect(c.viewMonth()).toEqual({ y: 2022, m: 11 }); // snapped to the new range
});
it('boundless mode navigates past the range walls and emits viewMonthChange', () => {
const fixture = setup(new Date(2024, 4, 1), new Date(2024, 4, 31)); // May 2024 only
fixture.componentRef.setInput('boundless', true);
fixture.detectChanges();
const c = fixture.componentInstance;
expect(c.canPrev()).toBe(true);
expect(c.canNext()).toBe(true);
const emitted: { y: number; m: number }[] = [];
c.viewMonthChange.subscribe((vm) => emitted.push(vm));
c.next(); // out of range — must stand, not snap back
expect(c.viewMonth()).toEqual({ y: 2024, m: 5 }); // Jun
c.prev();
c.prev();
expect(c.viewMonth()).toEqual({ y: 2024, m: 3 }); // Apr
expect(emitted).toEqual([
{ y: 2024, m: 5 },
{ y: 2024, m: 4 },
{ y: 2024, m: 3 },
]);
});
it('boundless mode keeps a navigated month when the data range shifts under it', () => {
// The consumer moves its window to follow viewMonthChange — that input swap
// must not snap the calendar back like the bounded fallback does.
const fixture = setup(new Date(2024, 2, 1), new Date(2024, 4, 31));
fixture.componentRef.setInput('boundless', true);
fixture.detectChanges();
const c = fixture.componentInstance;
c.next(); // Jun 2024, outside the original range
expect(c.viewMonth()).toEqual({ y: 2024, m: 5 });
fixture.componentRef.setInput('rangeStart', new Date(2024, 5, 1));
fixture.componentRef.setInput('rangeEnd', new Date(2025, 5, 30));
fixture.detectChanges();
expect(c.viewMonth()).toEqual({ y: 2024, m: 5 }); // still June
});
it('emits dayClick only when interactive, and never for other-month spill-over cells', () => {
// dayMap covers Apr 28 May 31, so May's leading grey cells HAVE data.
const map = buildProjectionDayMap(
[D('2024-04-29'), D('2024-05-15')],
D('2024-04-28'),
D('2024-05-31'),
);
const fixture = setup(D('2024-04-28'), D('2024-05-31'), map);
const c = fixture.componentInstance;
expect(c.viewMonth()).toEqual({ y: 2024, m: 4 }); // May
const emitted: unknown[] = [];
c.dayClick.subscribe((d) => emitted.push(d));
const flat = c.weeks().flat();
const inMonth = flat.find((cell) => !cell.isOtherMonth && !!cell.data);
const ev = new MouseEvent('click');
// Display-only (default): mouse clicks must not act — keyboard users
// couldn't activate the same cells.
c.onCellClick(inMonth!, ev);
expect(emitted.length).toBe(0);
fixture.componentRef.setInput('interactive', true);
fixture.detectChanges();
const greyWithData = flat.find((cell) => cell.isOtherMonth && !!cell.data);
expect(greyWithData).toBeTruthy();
c.onCellClick(greyWithData!, ev);
expect(emitted.length).toBe(0);
c.onCellClick(inMonth!, ev);
expect(emitted.length).toBe(1);
});
});

View file

@ -0,0 +1,401 @@
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
output,
signal,
untracked,
} from '@angular/core';
import { DateAdapter } from '@angular/material/core';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
import { DayData } from './heatmap.component';
import { getDbDateStr } from '../../util/get-db-date-str';
import { msToString } from '../duration/ms-to-string.pipe';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { T } from '../../t.const';
interface CalCell {
dateStr: string;
dayNum: number;
data: DayData | null;
isOtherMonth: boolean;
}
/**
* Single-month calendar view of a heatmap `dayMap`: numbered, level-coloured day
* cells with prev/next month navigation, bounded to `[rangeStart, rangeEnd]`. A
* companion to the year strip (see HeatmapComponent); the switcher toggles
* between them.
*/
@Component({
selector: 'heatmap-month-calendar',
templateUrl: './heatmap-month-calendar.component.html',
styleUrls: ['./heatmap-month-calendar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [MatIcon, MatIconButton, MatTooltip, TranslatePipe],
})
export class HeatmapMonthCalendarComponent {
readonly T = T;
private readonly _dateAdapter = inject(DateAdapter);
private readonly _translateService = inject(TranslateService);
private readonly _elRef = inject<ElementRef<HTMLElement>>(ElementRef);
// Single shared tooltip (see HeatmapComponent): one readout follows the
// hovered/focused cell instead of a matTooltip overlay per cell, which trailed
// on a fast sweep across the grid.
readonly tip = signal<{ x: number; y: number; text: string } | null>(null);
showTip(cell: CalCell, ev: Event): void {
const text = this.getCellTitle(cell);
const el = ev.currentTarget as HTMLElement | null;
const container = this._elRef.nativeElement.querySelector(
'.month-cal',
) as HTMLElement | null;
if (!text || !el || !container) {
this.tip.set(null);
return;
}
const cr = el.getBoundingClientRect();
const hr = container.getBoundingClientRect();
const halfWidth = cr.width / 2;
const x = cr.left - hr.left + halfWidth;
const y = cr.top - hr.top;
this.tip.set({ x, y, text });
}
hideTip(): void {
this.tip.set(null);
}
readonly dayMap = input.required<Map<string, DayData>>();
readonly rangeStart = input.required<Date>();
readonly rangeEnd = input.required<Date>();
/** Which legend to show beneath the grid. */
readonly legendMode = input<'intensity' | 'projection' | 'none'>('intensity');
readonly dayClick = output<DayData>();
readonly dayDblClick = output<DayData>();
/** When true, day cells become keyboard-reachable buttons (for consumers that
* act on `dayClick`, e.g. click-to-simulate). Display-only calendars keep
* plain, non-focusable cells. */
readonly interactive = input<boolean>(false);
/** Preview-only flourish, default OFF so the Activity heatmap is untouched:
* tint weekend columns. */
readonly showWeekends = input<boolean>(false);
/** When true, the legend shows the green "tracked time" (activity) swatch. */
readonly showActivity = input<boolean>(false);
/** Direct-manipulation mode (recurring dialog): day clicks open a contextual
* menu instead of firing `dayClick`; the weekday headers and the month title
* become clickable; per-weekday glyph annotations render. Display-only
* calendars (metrics) leave this off and are unaffected. */
readonly interactiveMenus = input<boolean>(false);
/** Generic per-weekday header glyphs, keyed by weekday index (Mon=0 Sun=6),
* rendered as a tiny top/mid/bottom column on the right of each header. The
* consumer owns their meaning (the dialog maps rule state glyphs). */
readonly weekdayHeaderGlyphs = input<Map<
number,
{ top?: string; mid?: string; bottom?: string }
> | null>(null);
/** Per-weekday (Mon=0 Sun=6) hover tooltip text spelling out what's set on
* that weekday; the header shows it on hover. */
readonly weekdayHeaderTooltips = input<Map<number, string> | null>(null);
/** Tooltip for the month title (e.g. the BYMONTH limit list). */
readonly monthTooltip = input<string>('');
/** Day-cell click in `interactiveMenus` mode carries the DOM event so the
* consumer can anchor a menu at the pointer. */
readonly dayMenu = output<{ data: DayData; event: MouseEvent }>();
/** Weekday-header click (weekday index Mon=0 … Sun=6) + event for anchoring. */
readonly weekdayHeaderMenu = output<{ weekdayIdx: number; event: MouseEvent }>();
/** Month-title click — carries the shown month (0=Jan … 11=Dec) + event. */
readonly monthLabelMenu = output<{ month: number; event: MouseEvent }>();
/** Months (0=Jan … 11=Dec) limited via BYMONTH — the title shows a chip then. */
readonly limitedMonths = input<number[] | null>(null);
/** When true, month navigation is unlimited in both directions instead of
* bounded to `[rangeStart, rangeEnd]`. The consumer is expected to listen to
* `viewMonthChange` and move its data window along (days the current dayMap
* doesn't cover render as plain cells until it does). */
readonly boundless = input<boolean>(false);
/** The month now shown, emitted on every prev/next navigation. */
readonly viewMonthChange = output<{ y: number; m: number }>();
/** A date (YYYY-MM-DD) the consumer wants brought into view e.g. the start
* date just changed. Jumps the calendar to that month (and tells the consumer,
* via viewMonthChange, so it can move its data window along). */
readonly focusDate = input<string | null>(null);
constructor() {
effect(() => {
const f = this.focusDate();
if (!f) {
return;
}
const [y, m] = f.split('-').map(Number);
if (!y || !m) {
return;
}
const vm = { y, m: m - 1 };
untracked(() => {
const cur = this._viewMonth();
if (cur && cur.y === vm.y && cur.m === vm.m) {
return;
}
this._viewMonth.set(vm);
this.viewMonthChange.emit(vm);
});
});
}
// Explicit user navigation; null → the computed default month. A navigated
// month that falls OUTSIDE the current data range (the inputs changed under
// us, e.g. the metric year select) is discarded — otherwise the calendar
// would strand the user on an all-empty month with the nav buttons disabled.
// In boundless mode there are no range walls (the consumer moves its data
// window along), so the user's month always stands.
private readonly _viewMonth = signal<{ y: number; m: number } | null>(null);
readonly viewMonth = computed(() => {
const vm = this._viewMonth();
return vm && (this.boundless() || this._isMonthInRange(vm))
? vm
: this._defaultMonth();
});
readonly monthLabel = computed(() => {
const { y, m } = this.viewMonth();
const name = (this._dateAdapter.getMonthNames('long') as string[])[m];
// The current year is the implied default — "June", not "June 2026"; only
// other years carry the year so cross-year navigation stays unambiguous.
return y === new Date().getFullYear() ? name : `${name} ${y}`;
});
readonly weekdayLabels = computed(() => {
const names = this._dateAdapter.getDayOfWeekNames('short') as string[];
const first = this._dateAdapter.getFirstDayOfWeek();
return [...names.slice(first), ...names.slice(0, first)];
});
/** Header columns in display order, each tagged with its weekday index
* (Mon=0 Sun=6) so clicks/glyphs map to a stable weekday regardless of the
* locale's first day. */
readonly weekdayCols = computed<{ label: string; weekdayIdx: number }[]>(() => {
const names = this._dateAdapter.getDayOfWeekNames('short') as string[];
const first = this._dateAdapter.getFirstDayOfWeek();
const cols: { label: string; weekdayIdx: number }[] = [];
for (let c = 0; c < 7; c++) {
const jsDay = (first + c) % 7; // 0=Sun … 6=Sat
cols.push({ label: names[jsDay], weekdayIdx: (jsDay + 6) % 7 }); // → Mon=0
}
return cols;
});
onHeaderClick(weekdayIdx: number, event: MouseEvent): void {
if (this.interactiveMenus()) {
this.weekdayHeaderMenu.emit({ weekdayIdx, event });
}
}
onTitleClick(event: MouseEvent): void {
if (this.interactiveMenus()) {
this.monthLabelMenu.emit({ month: this.viewMonth().m, event });
}
}
readonly isViewMonthLimited = computed(
() => !!this.limitedMonths()?.includes(this.viewMonth().m),
);
readonly weeks = computed<CalCell[][]>(() => {
const { y, m } = this.viewMonth();
const firstDow = this._dateAdapter.getFirstDayOfWeek();
const lead = (new Date(y, m, 1).getDay() - firstDow + 7) % 7;
const daysInMonth = new Date(y, m + 1, 0).getDate();
const rows = Math.ceil((lead + daysInMonth) / 7);
const map = this.dayMap();
const grid: CalCell[][] = [];
const cur = new Date(y, m, 1 - lead);
for (let r = 0; r < rows; r++) {
const row: CalCell[] = [];
for (let c = 0; c < 7; c++) {
const dateStr = getDbDateStr(cur);
row.push({
dateStr,
dayNum: cur.getDate(),
data: map.get(dateStr) ?? null,
isOtherMonth: cur.getMonth() !== m,
});
cur.setDate(cur.getDate() + 1);
}
grid.push(row);
}
return grid;
});
readonly canPrev = computed(() => {
if (this.boundless()) return true;
const { y, m } = this.viewMonth();
return new Date(y, m, 0) >= this._dayStart(this.rangeStart());
});
readonly canNext = computed(() => {
if (this.boundless()) return true;
const { y, m } = this.viewMonth();
return new Date(y, m + 1, 1) <= this.rangeEnd();
});
prev(): void {
if (!this.canPrev()) return;
const { y, m } = this.viewMonth();
const vm = m === 0 ? { y: y - 1, m: 11 } : { y, m: m - 1 };
this._viewMonth.set(vm);
this.viewMonthChange.emit(vm);
}
next(): void {
if (!this.canNext()) return;
const { y, m } = this.viewMonth();
const vm = m === 11 ? { y: y + 1, m: 0 } : { y, m: m + 1 };
this._viewMonth.set(vm);
this.viewMonthChange.emit(vm);
}
onCellKeydown(event: Event, cell: CalCell): void {
// Space must activate, not scroll.
event.preventDefault();
this.onCellClick(cell, event as unknown as MouseEvent);
}
isCellInteractive(cell: CalCell): boolean {
return this.interactive() && !!cell.data && !cell.isOtherMonth;
}
onCellClick(cell: CalCell, event: MouseEvent): void {
// Mouse clicks only act where the calendar IS interactive — display-only
// contexts must not emit on click while keyboard users can't activate.
// Other-month spill-over cells render greyed with no level/completed
// styling — emitting for them would trigger consumer actions with zero
// visual feedback on the clicked cell.
if (!this.interactive() || !cell.data || cell.isOtherMonth) {
return;
}
// Direct-manipulation mode: hand the consumer the day + event to anchor a
// contextual menu. Otherwise keep the plain dayClick behaviour.
if (this.interactiveMenus()) {
this.dayMenu.emit({ data: cell.data, event });
} else {
this.dayClick.emit(cell.data);
}
}
onCellDblClick(cell: CalCell): void {
if (this.interactive() && cell.data && !cell.isOtherMonth) {
this.dayDblClick.emit(cell.data);
}
}
getCellClass(cell: CalCell): string {
if (cell.isOtherMonth) return 'cal-day other-month';
const weekend = this.showWeekends() && this._isWeekend(cell) ? ' weekend' : '';
const d = cell.data;
if (!d) return `cal-day${weekend}`;
return `cal-day level-${d.level}${
d.activityLevel ? ` activity activity-${d.activityLevel}` : ''
}${d.isProjected ? ' projected' : ''}${d.isCompleted ? ' completed' : ''}${
d.isNext ? ' next' : ''
}${d.isToday ? ' today' : ''}${d.isStart ? ' start' : ''}${
d.isEnd ? ' end' : ''
}${weekend}`;
}
private _isWeekend(cell: CalCell): boolean {
const dow = (cell.data?.date ?? new Date(`${cell.dateStr}T00:00:00`)).getDay();
return dow === 0 || dow === 6;
}
/** Day-granular countdown from today, or '' for past days. */
private _countdown(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const days = Math.round((d.getTime() - today.getTime()) / 86_400_000);
if (days < 0) {
return '';
}
return days === 0
? (this._translateService.instant(T.G.HEATMAP_TODAY) as string)
: (this._translateService.instant(T.G.HEATMAP_IN_DAYS, { nr: days }) as string);
}
getCellTitle(cell: CalCell): string {
const d = cell.data;
if (!d || cell.isOtherMonth) return cell.dateStr;
// Activity heatmap (intensity) reports tracked time in its own line.
if (this.legendMode() !== 'projection') {
return `${d.dateStr}: ${this._translateService.instant(T.G.HEATMAP_ACTIVITY, {
count: d.taskCount,
time: msToString(d.timeSpent),
})}`;
}
// Projection preview: occurrence/start label, plus tracked time when the day
// has any (so hovering a green day shows the hours spent).
let title: string;
if (d.isStart) {
title = `${d.dateStr}: ${this._translateService.instant(T.G.HEATMAP_START_DAY)}`;
} else if (d.isEnd) {
title = `${d.dateStr}: ${this._translateService.instant(T.G.HEATMAP_END_DAY)}`;
} else if (d.isCompleted) {
title = `${d.dateStr}: ${this._translateService.instant(T.G.HEATMAP_COMPLETED_SIM)}`;
} else if (d.isProjected) {
const parts = [this._translateService.instant(T.G.HEATMAP_PROJECTED) as string];
if (d.occurrenceIndex != null) {
parts.push(
this._translateService.instant(T.G.HEATMAP_OCCURRENCE_NR, {
nr: d.occurrenceIndex + 1,
}) as string,
);
}
const cd = this._countdown(d.date);
if (cd) {
parts.push(cd);
}
title = `${d.dateStr}: ${parts.join(' · ')}`;
} else {
title = d.dateStr;
}
if (d.timeSpent > 0) {
title += ` · ${this._translateService.instant(T.G.HEATMAP_ACTIVITY_LEGEND)}: ${msToString(
d.timeSpent,
)}`;
}
return title;
}
private _defaultMonth(): { y: number; m: number } {
// Compare calendar DAYS, not instants: rangeStart is often anchored at
// local noon (the projection preview), so an instant comparison before
// noon put "today" outside the range and defaulted the view to the month
// of rangeEnd — a year ahead.
const today = this._dayStart(new Date());
const inRange =
today >= this._dayStart(this.rangeStart()) && today <= this.rangeEnd();
// Out-of-range fallback is direction-aware: a window entirely in the
// FUTURE (e.g. a year-jumped projection) opens at its START; a window in
// the past (e.g. a history year) at its END — the most recent month.
const ref = inRange
? today
: today < this._dayStart(this.rangeStart())
? this.rangeStart()
: this.rangeEnd();
return { y: ref.getFullYear(), m: ref.getMonth() };
}
/** True when any day of month `vm` overlaps `[rangeStart, rangeEnd]`. */
private _isMonthInRange(vm: { y: number; m: number }): boolean {
const monthStart = new Date(vm.y, vm.m, 1);
const monthEnd = new Date(vm.y, vm.m + 1, 0, 23, 59, 59);
return monthEnd >= this._dayStart(this.rangeStart()) && monthStart <= this.rangeEnd();
}
private _dayStart(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
}

View file

@ -0,0 +1,63 @@
<div class="heatmap-switcher">
<mat-button-toggle-group
class="heatmap-switcher__toggle"
[value]="view()"
(change)="setView($event.value)"
hideSingleSelectionIndicator
>
<mat-button-toggle value="month">
{{ T.G.HEATMAP_VIEW_MONTH | translate }}
</mat-button-toggle>
<mat-button-toggle value="year">
{{ T.G.HEATMAP_VIEW_YEAR | translate }}
</mat-button-toggle>
</mat-button-toggle-group>
@if (view() === 'month') {
<heatmap-month-calendar
[dayMap]="dayMap()"
[rangeStart]="rangeStart()"
[rangeEnd]="rangeEnd()"
[legendMode]="legendMode()"
[interactive]="interactive()"
[interactiveMenus]="interactiveMenus()"
[weekdayHeaderGlyphs]="weekdayHeaderGlyphs()"
[weekdayHeaderTooltips]="weekdayHeaderTooltips()"
[monthTooltip]="monthTooltip()"
[limitedMonths]="limitedMonths()"
[boundless]="monthBoundless()"
[showWeekends]="showWeekends()"
[showActivity]="showActivity()"
[focusDate]="focusDate()"
(viewMonthChange)="monthViewChange.emit($event)"
(dayClick)="dayClick.emit($event)"
(dayDblClick)="dayDblClick.emit($event)"
(dayMenu)="dayMenu.emit($event)"
(weekdayHeaderMenu)="weekdayHeaderMenu.emit($event)"
(monthLabelMenu)="monthLabelMenu.emit($event)"
/>
} @else if (data(); as d) {
<heatmap
[data]="d"
[legendMode]="legendMode()"
[navLabel]="navLabel()"
[canNavPrev]="canNavPrev()"
[canNavNext]="canNavNext()"
[interactive]="interactive()"
[interactiveMenus]="interactiveMenus()"
[limitedMonths]="limitedMonths()"
[weekdayHeaderTooltips]="weekdayHeaderTooltips()"
[monthTooltip]="monthTooltip()"
[showWeekends]="showWeekends()"
[showActivity]="showActivity()"
[focusDate]="focusDate()"
(navPrev)="navPrev.emit()"
(navNext)="navNext.emit()"
(dayClick)="dayClick.emit($event)"
(dayDblClick)="dayDblClick.emit($event)"
(dayMenu)="dayMenu.emit($event)"
(weekdayHeaderMenu)="weekdayHeaderMenu.emit($event)"
(monthLabelMenu)="monthLabelMenu.emit($event)"
/>
}
</div>

View file

@ -0,0 +1,16 @@
.heatmap-switcher {
display: flex;
flex-direction: column;
gap: var(--s);
&__toggle {
align-self: center;
// Never let the centered pill exceed / clip against the container edge (e.g.
// a narrow surface) keep its rounded right edge fully visible.
max-width: 100%;
flex-shrink: 0;
// Material system token sizes the toggle text without piercing component
// internals (no .mat-* override, per the styling rules).
--mat-button-toggle-label-text-size: 12px;
}
}

View file

@ -0,0 +1,73 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DateAdapter } from '@angular/material/core';
import { TranslateModule } from '@ngx-translate/core';
import { HeatmapSwitcherComponent } from './heatmap-switcher.component';
import { DayData, HeatmapData } from './heatmap.component';
const STORAGE_KEY = 'sp_heatmap_view_test';
const setup = (persistKey = 'test'): ComponentFixture<HeatmapSwitcherComponent> => {
TestBed.configureTestingModule({
imports: [HeatmapSwitcherComponent, TranslateModule.forRoot()],
providers: [
{
provide: DateAdapter,
useValue: {
getFirstDayOfWeek: () => 0,
getMonthNames: () => [
'J',
'F',
'M',
'A',
'M',
'J',
'J',
'A',
'S',
'O',
'N',
'D',
],
getDayOfWeekNames: () => ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
},
},
],
});
const fixture = TestBed.createComponent(HeatmapSwitcherComponent);
const data: HeatmapData = { weeks: [], monthLabels: [], months: [] };
fixture.componentRef.setInput('data', data);
fixture.componentRef.setInput('dayMap', new Map<string, DayData>());
fixture.componentRef.setInput('rangeStart', new Date(2024, 0, 1));
fixture.componentRef.setInput('rangeEnd', new Date(2024, 11, 31));
fixture.componentRef.setInput('persistKey', persistKey);
fixture.detectChanges();
return fixture;
};
describe('HeatmapSwitcherComponent', () => {
afterEach(() => localStorage.removeItem(STORAGE_KEY));
it('defaults to the year view', () => {
const c = setup().componentInstance;
expect(c.view()).toBe('year');
});
it('restores a persisted view choice', () => {
localStorage.setItem(STORAGE_KEY, 'month');
const c = setup().componentInstance;
expect(c.view()).toBe('month');
});
it('persists the view choice on change', () => {
const c = setup().componentInstance;
c.setView('month');
expect(c.view()).toBe('month');
expect(localStorage.getItem(STORAGE_KEY)).toBe('month');
});
it('does not persist without a persistKey', () => {
const c = setup('').componentInstance;
c.setView('month');
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
});
});

View file

@ -0,0 +1,131 @@
import {
ChangeDetectionStrategy,
Component,
effect,
input,
output,
signal,
} from '@angular/core';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { TranslatePipe } from '@ngx-translate/core';
import { T } from '../../t.const';
import { DayData, HeatmapComponent, HeatmapViewData } from './heatmap.component';
import { HeatmapMonthCalendarComponent } from './heatmap-month-calendar.component';
import { Log } from '../../core/log';
type HeatmapView = 'year' | 'month';
const STORAGE_PREFIX = 'sp_heatmap_view_';
/**
* Wraps the year strip (HeatmapComponent, month-grouped) and the single-month
* calendar (HeatmapMonthCalendarComponent) behind a Year/Month toggle. Consumers
* supply both the pre-built year `data` and the raw `dayMap` + range the month
* view navigates. With a `persistKey`, the chosen view is remembered per
* consumer in localStorage (a per-device UI preference not synced).
*/
@Component({
selector: 'heatmap-switcher',
templateUrl: './heatmap-switcher.component.html',
styleUrls: ['./heatmap-switcher.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
HeatmapComponent,
HeatmapMonthCalendarComponent,
MatButtonToggleModule,
TranslatePipe,
],
})
export class HeatmapSwitcherComponent {
readonly T = T;
readonly data = input.required<HeatmapViewData | null>();
readonly dayMap = input.required<Map<string, DayData>>();
readonly rangeStart = input.required<Date>();
readonly rangeEnd = input.required<Date>();
readonly legendMode = input<'intensity' | 'projection' | 'none'>('intensity');
/** Storage slot for remembering the chosen view (empty → not persisted). */
readonly persistKey = input<string>('');
/** View to open on first render when nothing is persisted. */
readonly initialView = input<HeatmapView>('year');
/** Optional in-card nav for the YEAR view (e.g. 2026 ); the month view has
* its own June 2026 nav. Hidden while the label is empty. */
readonly navLabel = input<string>('');
readonly canNavPrev = input<boolean>(false);
readonly canNavNext = input<boolean>(false);
readonly navPrev = output<void>();
readonly navNext = output<void>();
readonly dayClick = output<DayData>();
readonly dayDblClick = output<DayData>();
/** Passed through to both views: makes day cells keyboard-reachable buttons
* for consumers that act on `dayClick` (e.g. click-to-simulate). */
readonly interactive = input<boolean>(false);
/** Passed through to the MONTH view: unlimited month navigation. The consumer
* listens to `monthViewChange` and moves its data window along. */
readonly monthBoundless = input<boolean>(false);
readonly monthViewChange = output<{ y: number; m: number }>();
/** Passed through to both views — preview-only flourish (default off). */
readonly showWeekends = input<boolean>(false);
/** Passed through to both views — show the green activity legend swatch. */
readonly showActivity = input<boolean>(false);
/** Direct-manipulation mode day clicks become contextual-menu requests, and
* (month view) weekday headers / month title become clickable + annotated. */
readonly interactiveMenus = input<boolean>(false);
/** Per-weekday header glyphs (Mon=0 … Sun=6), passed to the MONTH view. */
readonly weekdayHeaderGlyphs = input<Map<
number,
{ top?: string; mid?: string; bottom?: string }
> | null>(null);
/** Months (0=Jan … 11=Dec) limited via BYMONTH — shown with a chip in both views. */
readonly limitedMonths = input<number[] | null>(null);
/** Per-weekday (Mon=0 … Sun=6) hover tooltip text, passed to both views. */
readonly weekdayHeaderTooltips = input<Map<number, string> | null>(null);
/** Tooltip for a limited month label/title, passed to both views. */
readonly monthTooltip = input<string>('');
readonly dayMenu = output<{ data: DayData; event: MouseEvent }>();
readonly weekdayHeaderMenu = output<{ weekdayIdx: number; event: MouseEvent }>();
readonly monthLabelMenu = output<{ month: number; event: MouseEvent }>();
/** A date (YYYY-MM-DD) to bring into view in the active sub-view month view
* jumps to its month, year strip scrolls to its cell. */
readonly focusDate = input<string | null>(null);
readonly view = signal<HeatmapView>('year');
private _restored = false;
constructor() {
// Inputs aren't available at construction time — apply the initial view once
// they are: a persisted preference wins, otherwise the consumer's initialView.
effect(() => {
if (this._restored) {
return;
}
this._restored = true;
const key = this.persistKey();
if (key) {
try {
const stored = localStorage.getItem(STORAGE_PREFIX + key);
if (stored === 'year' || stored === 'month') {
this.view.set(stored);
return;
}
} catch (e) {
Log.err('Failed to read heatmap view preference', e);
}
}
this.view.set(this.initialView());
});
}
setView(view: HeatmapView): void {
this.view.set(view);
const key = this.persistKey();
if (!key) {
return;
}
try {
localStorage.setItem(STORAGE_PREFIX + key, view);
} catch (e) {
Log.err('Failed to persist heatmap view preference', e);
}
}
}

View file

@ -1,52 +1,160 @@
@if (data(); as heatmapData) {
<div class="heatmap-container">
<div class="heatmap-grid">
<div class="day-labels">
<div class="month-spacer"></div>
@for (label of dayLabels(); track $index) {
<div class="day-label">{{ label }}</div>
}
</div>
<div
class="scrollable-content"
#scrollableContent
>
<div class="heatmap-months">
@for (month of heatmapData.monthLabels; track $index) {
<div class="month-label">{{ month }}</div>
@if (heatmapData.months; as months) {
<div
class="heatmap-container"
[class.is-interactive]="interactive()"
>
@if (tip(); as t) {
<div
class="heatmap-tip"
[style.left.px]="t.x"
[style.top.px]="t.y"
>
{{ t.text }}
</div>
}
@if (navLabel()) {
<div class="heatmap-nav">
<!-- Arrows only when there is anywhere to go — a single navigable
year would render two permanently dead buttons. -->
@if (canNavPrev() || canNavNext()) {
<button
mat-icon-button
type="button"
[disabled]="!canNavPrev()"
(click)="navPrev.emit()"
>
<mat-icon>chevron_left</mat-icon>
</button>
}
<div class="heatmap-nav__title">{{ navLabel() }}</div>
@if (canNavPrev() || canNavNext()) {
<button
mat-icon-button
type="button"
[disabled]="!canNavNext()"
(click)="navNext.emit()"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
</div>
<div class="weeks">
@for (week of heatmapData.weeks; track $index) {
<div class="week">
@for (day of week.days; track $index) {
<div
[class]="getDayClass(day)"
[title]="getDayTitle(day)"
></div>
}
<div class="heatmap-month-row">
<div class="day-labels day-labels--blocks">
@for (row of dayLabelRows(); track row.weekdayIdx) {
<div
class="day-label"
[class.is-clickable]="interactiveMenus()"
[matTooltip]="weekdayHeaderTooltips()?.get(row.weekdayIdx) || ''"
[matTooltipDisabled]="!weekdayHeaderTooltips()?.get(row.weekdayIdx)"
(click)="onYearWeekdayClick(row.weekdayIdx, $event)"
>
{{ row.label }}
</div>
}
</div>
<div
class="heatmap-month-blocks"
[class.dragging]="isDragging()"
[class.is-pannable]="interactive()"
(pointerdown)="onBlocksPointerDown($event)"
(pointermove)="onBlocksPointerMove($event)"
(pointerup)="onBlocksPointerUp($event)"
(pointercancel)="onBlocksPointerUp($event)"
>
@for (m of months; track $index) {
<div class="heatmap-month">
<div class="weeks">
@for (week of m.weeks; track $index) {
<div class="week">
@for (day of week.days; track $index) {
<div
[class]="getDayClass(day)"
[attr.role]="interactive() && day ? 'button' : null"
[attr.tabindex]="interactive() && day ? 0 : null"
[attr.aria-label]="day ? getDayTitle(day) : null"
(mouseenter)="showTip(day, $event)"
(mouseleave)="hideTip()"
(focus)="showTip(day, $event)"
(blur)="hideTip()"
(click)="onDayClick(day, $event)"
(dblclick)="interactive() && day && dayDblClick.emit(day)"
(keydown.enter)="interactive() && onDayKeydown($event, day)"
(keydown.space)="interactive() && onDayKeydown($event, day)"
></div>
}
</div>
}
</div>
<div
class="heatmap-month__label"
[class.is-clickable]="interactiveMenus()"
[class.is-limited]="isMonthLimited(m.monthIndex)"
[matTooltip]="monthTooltip()"
[matTooltipDisabled]="!monthTooltip() || !isMonthLimited(m.monthIndex)"
(click)="onYearMonthClick(m.monthIndex, $event)"
>
{{ m.label }}
@if (isMonthLimited(m.monthIndex)) {
<span class="heatmap-month__limit-chip"></span>
}
</div>
@if (m.total) {
<div class="heatmap-month__total">{{ m.total }}</div>
}
</div>
}
</div>
</div>
@if (label()) {
<div class="heatmap-label">{{ label() }}</div>
}
@if (legendMode() === 'intensity') {
<div class="heatmap-legend">
<span>{{ T.G.HEATMAP_LOW | translate }}</span>
<div class="legend-item level-0"></div>
<div class="legend-item level-1"></div>
<div class="legend-item level-2"></div>
<div class="legend-item level-3"></div>
<div class="legend-item level-4"></div>
<span>{{ T.G.HEATMAP_HIGH | translate }}</span>
</div>
} @else if (legendMode() === 'projection') {
<!-- Each swatch + its label is one nowrap unit; the legend wraps BETWEEN
units (not mid-label) so a narrow strip flows cleanly onto 2 lines. -->
<div class="heatmap-legend heatmap-legend--wrap">
<span class="legend-pair">
<span class="legend-item start"></span>{{ T.G.HEATMAP_START_DAY | translate }}
</span>
<span class="legend-pair">
<span class="legend-item today"></span>{{ T.G.HEATMAP_TODAY | translate }}
</span>
<span class="legend-pair">
<span class="legend-item projected"></span
>{{ T.G.HEATMAP_PROJECTED | translate }}
</span>
<span class="legend-pair">
<span class="legend-item next"></span>{{ T.G.HEATMAP_NEXT | translate }}
</span>
<span class="legend-pair">
<span class="legend-item end"></span>{{ T.G.HEATMAP_END_DAY | translate }}
</span>
@if (showActivity()) {
<span class="legend-pair">
<span class="legend-ramp">
<span class="legend-item activity-1"></span>
<span class="legend-item activity-2"></span>
<span class="legend-item activity-3"></span>
<span class="legend-item activity-4"></span>
</span>
{{ T.G.HEATMAP_ACTIVITY_LEGEND | translate }}
</span>
}
</div>
}
</div>
@if (label()) {
<div class="heatmap-label">{{ label() }}</div>
}
@if (showLegend()) {
<div class="heatmap-legend">
<span>Less</span>
<div class="legend-item level-0"></div>
<div class="legend-item level-1"></div>
<div class="legend-item level-2"></div>
<div class="legend-item level-3"></div>
<div class="legend-item level-4"></div>
<span>More</span>
</div>
}
</div>
}
}

View file

@ -1,12 +1,43 @@
.heatmap-container {
// Themeable palette (folded from upstream v18.10.0's heatmap redesign): the
// cell levels, container, border and hover outline are driven by these vars so
// a theme (and the dark-mode block below) can re-tune them in one place.
--heatmap-container-bg: var(--c-dark-10);
--heatmap-cell-border: transparent;
--heatmap-hover-outline: var(--c-dark-20);
--heatmap-level-0-bg: var(--c-dark-10);
--heatmap-level-1-bg: color-mix(in srgb, var(--c-primary) 20%, transparent);
--heatmap-level-2-bg: color-mix(in srgb, var(--c-primary) 40%, transparent);
--heatmap-level-3-bg: color-mix(in srgb, var(--c-primary) 60%, transparent);
--heatmap-level-4-bg: var(--c-primary);
position: relative;
display: flex;
flex-direction: column;
gap: var(--s);
padding: var(--s2);
background: var(--c-dark-10);
background: var(--heatmap-container-bg);
border-radius: var(--card-border-radius);
}
// Single shared tooltip: one readout positioned above the hovered/focused cell
// (replaces per-cell matTooltip, which trailed on a fast sweep). Pointer-events
// off so it never re-triggers a hover; centered + lifted above the cell.
.heatmap-tip {
position: fixed;
z-index: 20;
transform: translate(-50%, calc(-100% - 6px));
padding: 4px 8px;
border-radius: 6px;
background: var(--c-dark-90, rgba(0, 0, 0, 0.85));
color: var(--c-light-90, #fff);
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
pointer-events: none;
box-shadow: var(--whiteframe-shadow-2dp, 0 1px 4px rgba(0, 0, 0, 0.4));
}
.heatmap-label {
font-size: 12px;
text-align: center;
@ -14,23 +45,54 @@
margin-bottom: -8px;
}
.heatmap-grid {
// In-card navigation header ( 2026 ) mirrors the month calendar's header.
.heatmap-nav {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s);
&__title {
font-weight: bold;
min-width: 4em;
text-align: center;
}
}
// Month-grouped layout: each month is its own mini-grid, all
// months in ONE row (JanDec side by side) with a label + total beneath each;
// the row scrolls horizontally when it doesn't fit, with the weekday labels
// pinned outside the scroll area.
.heatmap-month-row {
display: flex;
gap: var(--s);
align-items: flex-start;
// Center the strip when it's narrower than the card (otherwise the leftover
// space all sits right of December); overflow still scrolls INSIDE
// .heatmap-month-blocks, so centering the row is safe.
justify-content: center;
}
.scrollable-content {
.heatmap-month-blocks {
display: flex;
flex-direction: column;
gap: var(--s);
flex-wrap: nowrap;
gap: var(--s2);
overflow-x: auto;
overflow-y: hidden;
padding-bottom: var(--s-half);
// Smooth scrolling
scroll-behavior: smooth;
// Drag-to-pan: grab affordance + no text selection while dragging the strip.
&.is-pannable {
cursor: grab;
user-select: none;
-webkit-user-select: none;
touch-action: pan-y;
}
&.dragging {
cursor: grabbing;
scroll-behavior: auto;
}
// Better scrollbar styling
&::-webkit-scrollbar {
height: 8px;
}
@ -50,18 +112,46 @@
}
}
.heatmap-months {
.heatmap-month {
display: flex;
gap: var(--s-quarter);
font-size: 12px;
line-height: 12px;
height: 12px;
color: var(--text-color-muted);
flex-shrink: 0;
flex-direction: column;
align-items: center;
gap: var(--s-half);
.month-label {
flex: 0 0 auto;
width: calc(4 * 12px + 4 * 2px); // 4 weeks * (cell width + gap)
&__label {
font-size: 12px;
color: var(--text-color-muted);
&.is-clickable {
cursor: pointer;
&:hover {
color: var(--c-primary);
}
}
// Limited via BYMONTH accented + a chip so it's clear this month is one of
// the few the rule is restricted to.
&.is-limited {
color: var(--c-primary);
font-weight: 600;
}
}
&__limit-chip {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-left: 3px;
vertical-align: middle;
background: var(--c-accent, var(--c-primary));
}
&__total {
font-size: 11px;
opacity: 0.55;
margin-top: -4px;
}
}
@ -76,15 +166,24 @@
text-align: right;
flex-shrink: 0;
.month-spacer {
height: 17px; // 12px (heatmap-months height) + 8px (gap in scrollable-content)
flex-shrink: 0;
}
.day-label {
height: 12px;
line-height: 12px;
flex-shrink: 0;
&.is-clickable {
cursor: pointer;
&:hover {
color: var(--c-primary);
}
}
}
// Month-block variant: the mini-grids start at the very top (labels/totals
// sit BELOW them), so no month-spacer offset is needed.
&--blocks {
width: auto;
}
}
@ -106,112 +205,312 @@
width: 12px;
height: 12px;
border-radius: 2px;
cursor: pointer;
transition: var(--transition-fast);
flex-shrink: 0;
box-shadow: inset 0 0 0 1px var(--heatmap-cell-border);
&.empty {
background: transparent;
cursor: default;
box-shadow: none;
}
&.level-0 {
background: var(--c-dark-10);
background: var(--heatmap-level-0-bg);
}
&.level-1 {
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
background: var(--heatmap-level-1-bg);
}
&.level-2 {
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
background: var(--heatmap-level-2-bg);
}
&.level-3 {
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
background: var(--heatmap-level-3-bg);
}
&.level-4 {
background: var(--c-primary);
background: var(--heatmap-level-4-bg);
}
&:not(.empty):hover {
transform: scale(1.3);
outline: 1px solid var(--c-dark-20);
// Activity (tracked time) overlay GREEN spectrum, distinct from the blue
// projection/start.
&.activity-1 {
background: color-mix(in srgb, var(--c-success, #43a047) 20%, transparent);
}
&.activity-2 {
background: color-mix(in srgb, var(--c-success, #43a047) 40%, transparent);
}
&.activity-3 {
background: color-mix(in srgb, var(--c-success, #43a047) 60%, transparent);
}
&.activity-4 {
background: var(--c-success, #43a047);
}
// Projected future occurrence: dotted blue (primary) border.
&.projected {
background: transparent;
outline: 1px dotted var(--c-primary);
outline-offset: -1px;
}
// Simulated "completed here" day: warm amber a distinct hue from start
// (primary), next (accent), end (error) AND the green tracked-time cells.
&.completed {
background: var(--c-warning, #e8a13a);
outline: 2px solid var(--c-warning, #e8a13a);
outline-offset: 1px;
}
// Recurrence end (UNTIL) day: a SOLID error block the "stop" hue, distinct
// from next / completed; falls back to base warn for themes without --c-error.
&.end {
position: relative;
z-index: 2;
background: var(--c-error, var(--c-warn));
box-shadow: 0 0 0 1px var(--c-error, var(--c-warn));
}
// Today: an INK ring (the theme's text colour) — matches the picked theme and
// stays high-contrast on any background while reading as neutral (never one of
// the coloured occurrence markers). On a ::before overlay so it's never
// clobbered when the cell is also `.start` / `.completed`.
&.today {
position: relative;
z-index: 2;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 2px;
// INSET ring so it sits ON TOP of any fill (incl. a solid start anchor).
box-shadow: inset 0 0 0 1.5px var(--ink, currentColor);
pointer-events: none;
z-index: 3;
}
}
// Next upcoming occurrence: a pulsing accent ring on a ::after overlay (kept
// off the cell's own `animation` slot).
&.next {
position: relative;
z-index: 1;
&::after {
content: '';
position: absolute;
inset: -2px;
border-radius: 3px;
pointer-events: none;
animation: heatmap-next-pulse 1.8s ease-in-out infinite;
}
}
// Recurrence start / anchor day solid primary fill + 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.
&.start {
background: var(--c-primary);
z-index: 2;
box-shadow:
0 0 0 1px var(--heatmap-container-bg, var(--c-dark-10)),
0 0 0 2px var(--ink, currentColor);
}
}
// Pointer/hover affordances only where clicking actually does something
// (`interactive` input) display-only heatmaps keep inert cells.
.heatmap-container.is-interactive .day:not(.empty) {
cursor: pointer;
&:hover {
transform: scale(1.3);
outline: 1px solid var(--heatmap-hover-outline);
z-index: 1;
}
}
// Weekend tint (showWeekends input) only the empty background cells, so
// occurrence cells keep their look. Activity heatmap never sets it.
.day.level-0.weekend {
background: color-mix(in srgb, var(--c-accent) 12%, var(--heatmap-level-0-bg));
}
@keyframes heatmap-next-pulse {
0%,
100% {
box-shadow: 0 0 0 1px color-mix(in srgb, var(--c-accent) 70%, transparent);
}
50% {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-accent) 25%, transparent);
}
}
// Legend swatch pulse animates the outline COLOUR (the swatch rings with an
// outline, not a box-shadow), fading in/out without reflow.
@keyframes heatmap-next-legend-pulse {
0%,
100% {
outline-color: var(--c-accent);
}
50% {
outline-color: color-mix(in srgb, var(--c-accent) 35%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.day.next::after,
.heatmap-legend .legend-item.next {
animation: none;
}
}
.heatmap-legend {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-half);
font-size: 11px;
color: var(--text-color-muted);
padding-right: var(--s-half);
span {
margin: 0 var(--s-half);
}
// Projection legend: wrap BETWEEN swatch+label pairs (each pair is nowrap), so
// a narrow year strip flows onto two clean lines instead of splitting labels.
&--wrap {
flex-wrap: wrap;
row-gap: var(--s-quarter, 4px);
.legend-pair {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
span {
margin: 0;
}
}
}
// Tracked-time range: 4 green swatches packed flush into a gradient bar.
.legend-ramp {
display: inline-flex;
align-items: center;
gap: 2px;
span {
margin: 0;
}
}
.legend-item {
width: 12px;
height: 12px;
border-radius: 2px;
box-shadow: inset 0 0 0 1px var(--heatmap-cell-border);
&.level-0 {
background: var(--c-dark-10);
background: var(--heatmap-level-0-bg);
}
&.level-1 {
background: color-mix(in srgb, var(--c-primary) 20%, transparent);
background: var(--heatmap-level-1-bg);
}
&.level-2 {
background: color-mix(in srgb, var(--c-primary) 40%, transparent);
background: var(--heatmap-level-2-bg);
}
&.level-3 {
background: color-mix(in srgb, var(--c-primary) 60%, transparent);
background: var(--heatmap-level-3-bg);
}
&.level-4 {
background: var(--heatmap-level-4-bg);
}
&.projected {
background: transparent;
outline: 1px dotted var(--c-primary);
outline-offset: -1px;
}
// Next upcoming occurrence: an accent ring, PULSING. Uses outline (not an
// inset box-shadow, which didn't render on the tiny transparent swatch in
// some themes) so it's reliably visible, like the dotted `.projected` swatch.
&.next {
background: transparent;
outline: 2px solid var(--c-accent);
outline-offset: -1px;
animation: heatmap-next-legend-pulse 1.8s ease-in-out infinite;
}
// Recurrence end (UNTIL) day: a SOLID error block (vs the hollow `.next` ring).
&.end {
background: var(--c-error, var(--c-warn));
}
&.start {
background: var(--c-primary);
}
// Activity (tracked time) legend ramp low high green, matching the cells.
&.activity-1 {
background: color-mix(in srgb, var(--c-success, #43a047) 20%, transparent);
}
&.activity-2 {
background: color-mix(in srgb, var(--c-success, #43a047) 40%, transparent);
}
&.activity-3 {
background: color-mix(in srgb, var(--c-success, #43a047) 60%, transparent);
}
&.activity-4 {
background: var(--c-success, #43a047);
}
// Today + completed: outline ring / amber fill see the cell rules above.
&.today {
background: transparent;
outline: 2px solid var(--ink, currentColor);
outline-offset: -1px;
}
&.completed {
background: var(--c-warning, #e8a13a);
}
}
}
// Dark theme support
// Dark theme support re-tune the palette vars only (v18.10.0): a more opaque
// level-0 base (ink overlay) and stronger primary mixes give the cells better
// contrast on dark backgrounds; cells, legend and hover all read from the vars.
:host-context(.isDarkTheme) {
.heatmap-container {
background: var(--c-light-05);
}
.scrollable-content {
&::-webkit-scrollbar-track {
background: var(--c-light-05);
}
&::-webkit-scrollbar-thumb {
background: var(--c-light-10);
&:hover {
background: var(--c-light-33);
}
}
}
.day {
&.level-0 {
background: var(--c-light-05);
}
&:not(.empty):hover {
outline-color: var(--c-light-10);
}
}
.heatmap-legend .legend-item.level-0 {
background: var(--c-light-05);
--heatmap-container-bg: var(--c-light-05);
--heatmap-hover-outline: var(--c-light-60);
--heatmap-level-0-bg: rgba(var(--ink-on-channel), 0.16);
--heatmap-level-1-bg: color-mix(
in srgb,
var(--c-primary) 38%,
rgba(var(--ink-on-channel), 0.16)
);
--heatmap-level-2-bg: color-mix(
in srgb,
var(--c-primary) 55%,
rgba(var(--ink-on-channel), 0.16)
);
--heatmap-level-3-bg: color-mix(
in srgb,
var(--c-primary) 72%,
rgba(var(--ink-on-channel), 0.16)
);
}
}

View file

@ -6,10 +6,17 @@ import {
ElementRef,
inject,
input,
viewChild,
output,
signal,
untracked,
} from '@angular/core';
import { DateAdapter } from '@angular/material/core';
import { MatIcon } from '@angular/material/icon';
import { MatIconButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { msToString } from '../duration/ms-to-string.pipe';
import { T } from '../../t.const';
export interface DayData {
date: Date;
@ -17,15 +24,45 @@ export interface DayData {
taskCount: number;
timeSpent: number;
level: number; // 0-4 for color intensity
isProjected?: boolean; // a future, not-yet-created occurrence (outlined cell)
isCompleted?: boolean; // a simulated "completed here" day (solid, ringed)
// Additive preview-only flags (set by the recurrence dialog; the Activity
// heatmap never sets them, so its rendering is unchanged):
isNext?: boolean; // the next upcoming occurrence — spotlit with a pulse ring
isToday?: boolean; // today's cell — marked with a ring
isStart?: boolean; // the recurrence's start/anchor day (click-to-set in the dialog preview)
isEnd?: boolean; // the recurrence's UNTIL (end) day — marked specially
occurrenceIndex?: number; // 0-based order in the window — drives the tooltip's "occurrence #N"
activityLevel?: number; // 1-4 tracked-time intensity (GREEN overlay), distinct from projection
}
export interface WeekData {
days: (DayData | null)[];
}
export interface MonthBlock {
label: string; // localized short month name (e.g. "Jan")
total: string; // pre-formatted total shown beneath (caller picks time vs count)
weeks: WeekData[]; // this month's days, laid in weekday-row columns
monthIndex: number; // 0=Jan … 11=Dec — for click targeting + limited-month chip
}
export interface HeatmapData {
// The continuous week grid — rendered by nothing on-screen anymore, but the
// metric share-canvas export still draws it.
weeks: WeekData[];
monthLabels: string[];
// The month-grouped layout this component renders: each month its own
// mini-grid with a label + total beneath.
months?: MonthBlock[];
}
/** The full payload the heatmap-switcher consumes: the rendered year data plus
* the raw dayMap + range its month-calendar view navigates. */
export interface HeatmapViewData extends HeatmapData {
dayMap: Map<string, DayData>;
rangeStart: Date;
rangeEnd: Date;
}
@Component({
@ -34,49 +71,307 @@ export interface HeatmapData {
styleUrls: ['./heatmap.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [],
imports: [TranslatePipe, MatIcon, MatIconButton, MatTooltip],
})
export class HeatmapComponent {
readonly T = T;
private readonly _dateAdapter = inject(DateAdapter);
private readonly _translateService = inject(TranslateService);
private readonly _elRef = inject<ElementRef<HTMLElement>>(ElementRef);
readonly data = input.required<HeatmapData | null>();
readonly label = input<string>('');
readonly showLegend = input<boolean>(true);
readonly scrollToEnd = input<boolean>(false);
private readonly _scrollableContent =
viewChild<ElementRef<HTMLElement>>('scrollableContent');
/** A date (YYYY-MM-DD) the consumer wants brought into view e.g. the start
* date just changed. Scrolls the (horizontally panned) month strip so that
* day's cell is centred. No-op when the day isn't in the rendered window. */
readonly focusDate = input<string | null>(null);
constructor() {
effect(() => {
const data = this.data();
const scrollEl = this._scrollableContent()?.nativeElement;
if (data && scrollEl && this.scrollToEnd()) {
// Use setTimeout to ensure DOM is updated
setTimeout(() => {
scrollEl.scrollTo({ left: scrollEl.scrollWidth, behavior: 'instant' });
});
const f = this.focusDate();
if (!f) {
return;
}
// The `.start` cell is repainted from the same change that moved focusDate;
// defer one tick so the scroll targets the up-to-date DOM.
untracked(() => setTimeout(() => this._scrollFocusIntoView()));
});
}
private _scrollFocusIntoView(): void {
const container = this._elRef.nativeElement.querySelector(
'.heatmap-month-blocks',
) as HTMLElement | null;
const cell = container?.querySelector('.day.start') as HTMLElement | null;
if (!container || !cell) {
return;
}
const cr = cell.getBoundingClientRect();
const kr = container.getBoundingClientRect();
const halfCell = cr.width / 2;
const halfView = container.clientWidth / 2;
// Centre the cell in the (horizontally panned) strip.
container.scrollLeft += cr.left - kr.left + halfCell - halfView;
}
// Single shared tooltip: instead of a matTooltip overlay per cell (which
// stacked into a trail on a fast sweep across the dense grid), one readout
// follows the hovered/focused cell. Positioned relative to `.heatmap-container`
// (the non-scrolling root), so a horizontal scroll of the month strip never
// strands a stale overlay.
readonly tip = signal<{ x: number; y: number; text: string } | null>(null);
showTip(day: DayData | null, ev: Event): void {
const text = this.getDayTitle(day);
const cell = ev.currentTarget as HTMLElement | null;
if (!text || !cell) {
this.tip.set(null);
return;
}
// VIEWPORT coords for a position:fixed tip — a container-relative absolute
// tip extended the scroll area of whatever scrolls around the heatmap (the
// dialog content), which jittered the bottom scrollbar on hover.
const cr = cell.getBoundingClientRect();
const halfWidth = cr.width / 2;
this.tip.set({ x: cr.left + halfWidth, y: cr.top, text });
}
hideTip(): void {
this.tip.set(null);
}
readonly data = input.required<HeatmapData | null>();
readonly label = input<string>('');
/** Legend under the grid: relative intensity (LowHigh) for activity data,
* projected/completed swatches for projections, or none. */
readonly legendMode = input<'intensity' | 'projection' | 'none'>('none');
/** Optional in-card navigation header (e.g. 2026 ), styled like the month
* calendar's June 2026 header. Hidden while empty. */
readonly navLabel = input<string>('');
readonly canNavPrev = input<boolean>(false);
readonly canNavNext = input<boolean>(false);
readonly navPrev = output<void>();
readonly navNext = output<void>();
/** Emits the clicked day (non-empty cells only). Consumers decide what to do. */
readonly dayClick = output<DayData>();
/** Emits the double-clicked day (e.g. simulate-completion in the dialog). */
readonly dayDblClick = output<DayData>();
/** Direct-manipulation mode: a (non-drag) day click emits `dayMenu` with the
* DOM event so the consumer can anchor a contextual menu, instead of firing
* `dayClick`. */
readonly interactiveMenus = input<boolean>(false);
readonly dayMenu = output<{ data: DayData; event: MouseEvent }>();
/** Weekday-label click (weekday index Mon=0 … Sun=6) + event — direct-manip. */
readonly weekdayHeaderMenu = output<{ weekdayIdx: number; event: MouseEvent }>();
/** Month-label click (0=Jan … 11=Dec) + event — direct-manip. */
readonly monthLabelMenu = output<{ month: number; event: MouseEvent }>();
/** Months (0=Jan … 11=Dec) currently limited via BYMONTH — shown with a chip. */
readonly limitedMonths = input<number[] | null>(null);
/** Per-weekday (Mon=0 … Sun=6) hover tooltip spelling out what's set on it. */
readonly weekdayHeaderTooltips = input<Map<number, string> | null>(null);
/** Tooltip for a limited month label (the BYMONTH limit list). */
readonly monthTooltip = input<string>('');
/** When true, day cells become keyboard-reachable buttons (for consumers that
* act on `dayClick`, e.g. click-to-simulate). Display-only heatmaps keep
* plain, non-focusable cells. */
readonly interactive = input<boolean>(false);
/** Preview-only flourish, default OFF so the Activity heatmap is untouched:
* tint weekend rows. */
readonly showWeekends = input<boolean>(false);
/** When true, the legend shows the green "tracked time" (activity) swatch. */
readonly showActivity = input<boolean>(false);
onYearWeekdayClick(weekdayIdx: number, event: MouseEvent): void {
if (this.interactiveMenus()) {
this.weekdayHeaderMenu.emit({ weekdayIdx, event });
}
}
onYearMonthClick(month: number, event: MouseEvent): void {
if (this.interactiveMenus()) {
this.monthLabelMenu.emit({ month, event });
}
}
isMonthLimited(month: number): boolean {
return !!this.limitedMonths()?.includes(month);
}
onDayKeydown(event: Event, day: DayData | null): void {
if (day) {
// Space must activate, not scroll.
event.preventDefault();
this.dayClick.emit(day);
}
}
// --- Drag-to-pan the month strip (it overflows horizontally for a full year).
// Pointer-based so it works with mouse, touch and pen; a drag past a small
// threshold suppresses the trailing click so panning never sets the start day.
readonly isDragging = signal(false);
private _dragEl: HTMLElement | null = null;
private _dragStartX = 0;
private _dragStartScroll = 0;
private _dragMoved = false;
private _dragPointerId: number | null = null;
private _dragCaptured = false;
onBlocksPointerDown(ev: PointerEvent): void {
if (ev.button !== 0) {
return;
}
const el = ev.currentTarget as HTMLElement;
this._dragEl = el;
this._dragStartX = ev.clientX;
this._dragStartScroll = el.scrollLeft;
this._dragMoved = false;
this._dragCaptured = false;
this._dragPointerId = ev.pointerId;
// Capture is DEFERRED until an actual drag starts (see move). Capturing on
// pointerdown retargets the synthesized `click` to this container, which
// swallowed plain clicks on a day cell — set-start stopped working in the
// year view.
}
onBlocksPointerMove(ev: PointerEvent): void {
if (!this._dragEl || this._dragPointerId !== ev.pointerId) {
return;
}
const dx = ev.clientX - this._dragStartX;
if (!this._dragMoved && Math.abs(dx) > 3) {
// A real pan began: NOW take pointer capture (smooth tracking) and flag the
// move so the trailing click is suppressed.
this._dragMoved = true;
this.isDragging.set(true);
this._dragEl.setPointerCapture?.(ev.pointerId);
this._dragCaptured = true;
}
if (this._dragMoved) {
this._dragEl.scrollLeft = this._dragStartScroll - dx;
}
}
onBlocksPointerUp(ev: PointerEvent): void {
if (!this._dragEl) {
return;
}
if (this._dragCaptured) {
this._dragEl.releasePointerCapture?.(ev.pointerId);
}
this._dragEl = null;
this._dragPointerId = null;
this._dragCaptured = false;
this.isDragging.set(false);
}
// Cell click — swallowed when it is the tail of a pan (see _dragMoved), so a
// drag across the dense grid never lands as a click/menu.
onDayClick(day: DayData | null, event: MouseEvent): void {
if (!this.interactive() || !day) {
return;
}
if (this._dragMoved) {
this._dragMoved = false;
return;
}
if (this.interactiveMenus()) {
this.dayMenu.emit({ data: day, event });
} else {
this.dayClick.emit(day);
}
}
readonly dayLabels = computed(() => {
const allDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Localized like the month-calendar view of the same widget (DateAdapter),
// rotated so the locale's first weekday comes first.
const allDays = this._dateAdapter.getDayOfWeekNames('short');
const firstDay = this._dateAdapter.getFirstDayOfWeek();
return [...allDays.slice(firstDay), ...allDays.slice(0, firstDay)];
});
/** Weekday rows tagged with their weekday index (Mon=0 Sun=6) so a click maps
* to a stable weekday regardless of the locale's first day. */
readonly dayLabelRows = computed<{ label: string; weekdayIdx: number }[]>(() => {
const names = this._dateAdapter.getDayOfWeekNames('short') as string[];
const firstDay = this._dateAdapter.getFirstDayOfWeek();
const rows: { label: string; weekdayIdx: number }[] = [];
for (let r = 0; r < 7; r++) {
const jsDay = (firstDay + r) % 7; // 0=Sun … 6=Sat
rows.push({ label: names[jsDay], weekdayIdx: (jsDay + 6) % 7 }); // → Mon=0
}
return rows;
});
getDayClass(day: DayData | null): string {
if (!day) {
return 'day empty';
}
return `day level-${day.level}`;
const weekend =
this.showWeekends() && (day.date.getDay() === 0 || day.date.getDay() === 6)
? ' weekend'
: '';
return `day level-${day.level}${
day.activityLevel ? ` activity activity-${day.activityLevel}` : ''
}${day.isProjected ? ' projected' : ''}${day.isCompleted ? ' completed' : ''}${
day.isNext ? ' next' : ''
}${day.isToday ? ' today' : ''}${day.isStart ? ' start' : ''}${
day.isEnd ? ' end' : ''
}${weekend}`;
}
/** Day-granular countdown from today, or '' for past days. */
private _countdown(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const days = Math.round((d.getTime() - today.getTime()) / 86_400_000);
if (days < 0) {
return '';
}
return days === 0
? (this._translateService.instant(T.G.HEATMAP_TODAY) as string)
: (this._translateService.instant(T.G.HEATMAP_IN_DAYS, { nr: days }) as string);
}
getDayTitle(day: DayData | null): string {
if (!day) {
return '';
}
return `${day.dateStr}: ${day.taskCount} tasks, ${msToString(day.timeSpent)}`;
// Activity heatmap (intensity mode) already reports tracked time in its line.
if (this.legendMode() !== 'projection') {
return `${day.dateStr}: ${this._translateService.instant(T.G.HEATMAP_ACTIVITY, {
count: day.taskCount,
time: msToString(day.timeSpent),
})}`;
}
// Projection preview: an occurrence/start label, plus the tracked time when
// the day has any (so hovering a green day shows the hours spent).
let title: string;
if (day.isStart) {
title = `${day.dateStr}: ${this._translateService.instant(T.G.HEATMAP_START_DAY)}`;
} else if (day.isEnd) {
title = `${day.dateStr}: ${this._translateService.instant(T.G.HEATMAP_END_DAY)}`;
} else if (day.isCompleted) {
title = `${day.dateStr}: ${this._translateService.instant(T.G.HEATMAP_COMPLETED_SIM)}`;
} else if (day.isProjected) {
const parts = [this._translateService.instant(T.G.HEATMAP_PROJECTED) as string];
if (day.occurrenceIndex != null) {
parts.push(
this._translateService.instant(T.G.HEATMAP_OCCURRENCE_NR, {
nr: day.occurrenceIndex + 1,
}) as string,
);
}
const cd = this._countdown(day.date);
if (cd) {
parts.push(cd);
}
title = `${day.dateStr}: ${parts.join(' · ')}`;
} else {
title = day.dateStr;
}
if (day.timeSpent > 0) {
title += ` · ${this._translateService.instant(T.G.HEATMAP_ACTIVITY_LEGEND)}: ${msToString(
day.timeSpent,
)}`;
}
return title;
}
}

View file

@ -0,0 +1,24 @@
import { nextYearOf, prevYearOf } from './year-nav.util';
describe('year-nav util (years sorted newest-first)', () => {
const years = [2026, 2024, 2023];
it('prevYearOf walks toward older years and stops at the oldest', () => {
expect(prevYearOf(years, 2026)).toBe(2024);
expect(prevYearOf(years, 2024)).toBe(2023);
expect(prevYearOf(years, 2023)).toBeNull();
});
it('nextYearOf walks toward newer years and stops at the newest', () => {
expect(nextYearOf(years, 2023)).toBe(2024);
expect(nextYearOf(years, 2024)).toBe(2026);
expect(nextYearOf(years, 2026)).toBeNull();
});
it('returns null when the selected year is not in the list', () => {
expect(prevYearOf(years, 2025)).toBeNull();
expect(nextYearOf(years, 2025)).toBeNull();
expect(prevYearOf([], 2026)).toBeNull();
expect(nextYearOf([], 2026)).toBeNull();
});
});

View file

@ -0,0 +1,15 @@
/**
* Prev/next year navigation over the years a heatmap has data for. `years` is
* sorted newest-first (as both heatmap consumers build it), so "prev" walks
* toward the END of the list and "next" toward the front. Returns the target
* year, or null when there is none (also when `selected` isn't in the list).
*/
export const prevYearOf = (years: number[], selected: number): number | null => {
const i = years.indexOf(selected);
return i !== -1 && i < years.length - 1 ? years[i + 1] : null;
};
export const nextYearOf = (years: number[], selected: number): number | null => {
const i = years.indexOf(selected);
return i > 0 ? years[i - 1] : null;
};

View file

@ -95,6 +95,35 @@
outline-offset: 2px;
}
:host([data-size='md']) .segment {
padding: var(--s-half) var(--s);
// Compact variant (data-size='md') for dense form rows. The default (lg) "card"
// treatment a 3px border plus a scale-up + elevation on the active segment
// reads as the selection "bouncing" and far too heavy in a small control, so md
// drops the zoom/lift and uses a 1px border with a tinted active fill, matching
// the other compact controls it sits beside.
:host([data-size='md']) {
.segment {
border-width: 1px;
border-radius: var(--card-border-radius);
padding: var(--s-half) var(--s);
}
.segment-label {
font-size: 13px;
white-space: nowrap; // keep multi-word labels (e.g. "On date") on one line
}
@include mousePrimaryDevice {
.segment:not(.is-active):hover {
transform: none;
box-shadow: none;
background: var(--bg-lightest);
}
}
.segment.is-active {
border-width: 1px;
transform: none;
box-shadow: none;
background: color-mix(in srgb, var(--c-primary) 16%, transparent);
}
}

View file

@ -0,0 +1,13 @@
/**
* Exhaustiveness guard for discriminated unions / enums. Place in the `default`
* of a `switch` (or the `else` of a chain) over a closed union: TypeScript
* resolves `x` to `never` only when every case is handled, so adding a new
* member to the union turns the call into a COMPILE error the missing branch
* is caught before it can silently fall through at runtime.
*
* Throws if reached at runtime (a malformed value bypassing the type system),
* so it doubles as a runtime assertion.
*/
export const assertNever = (x: never): never => {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
};

View file

@ -1141,12 +1141,12 @@
"L_IS_ENABLED": "Enabled",
"L_IS_HIDE_BUTTON": "Hide toolbar button",
"L_STREAK_MODE": "Streak tracking mode",
"L_STREAK_MODE_SPECIFIC_DAYS": "Specific weekdays",
"L_STREAK_MODE_SPECIFIC_DAYS": "Specific days of the week",
"L_STREAK_MODE_WEEKLY_FREQUENCY": "Weekly frequency (e.g. 3 times per week)",
"L_TITLE": "Title",
"L_TRACK_STREAKS": "Track streaks",
"L_TYPE": "Type",
"L_WEEKDAYS": "Weekdays to check for streak",
"L_WEEKDAYS": "Days of the week to check for streak",
"L_WEEKLY_FREQUENCY": "Completions per week",
"TITLE": "Simple Counters & Habit Tracking",
"TYPE_CLICK_COUNTER": "Click counter",
@ -1918,9 +1918,9 @@
"OK": "Skip"
},
"D_EDIT": {
"ADD": "Add Recurring Task Config",
"ADVANCED_CFG": "Advanced configuration",
"EDIT": "Edit Recurring Task Config",
"ADD": "Set up repeat",
"ADVANCED_CFG": "Defaults for new tasks",
"EDIT": "Edit repeat",
"HEATMAP_LABEL": "Activity",
"TAG_LABEL": "Tags",
"TIME_SPENT_THIS_MONTH": "This month",
@ -1929,9 +1929,13 @@
},
"F": {
"C_DAY": "Day",
"C_DAYS": "Days",
"C_MONTH": "Month",
"C_MONTHS": "Months",
"C_WEEK": "Week",
"C_WEEKS": "Weeks",
"C_YEAR": "Year",
"C_YEARS": "Years",
"DEFAULT_ESTIMATE": "Default Estimate",
"DISABLE_AUTO_UPDATE_SUBTASKS": "Disable auto-updating subtasks",
"DISABLE_AUTO_UPDATE_SUBTASKS_DESCRIPTION": "Do not update inherited subtasks automatically when the newest instance changes",
@ -1953,40 +1957,63 @@
"ORD_SECOND_NTH": "second",
"ORD_THIRD": "third",
"ORD_THIRD_NTH": "third",
"Q_BIWEEKLY_CURRENT_WEEKDAY": "Every 2 weeks on {{weekdayStr}}",
"Q_BIWEEKLY_CURRENT_WEEKDAY": "Every 2 weeks ({{weekdayStr}})",
"Q_CUSTOM": "Custom recurring config",
"Q_DAILY": "Every day",
"Q_EVERY_OTHER_DAY": "Every other day",
"Q_EVERY_OTHER_YEAR_CURRENT_DATE": "Every 2 years on the {{dayAndMonthStr}}",
"Q_MONDAY_TO_FRIDAY": "Every Monday through Friday",
"Q_MONTHLY_CURRENT_DATE": "Every month on the day {{dateDayStr}}",
"Q_MONTHLY_FIRST_DAY": "Every month on the first day",
"Q_MONTHLY_LAST_DAY": "Every month on the last day",
"Q_MONTHLY_LAST_WEEKDAY": "Every month on the last {{weekdayStr}}",
"Q_MONTHLY_NTH_WEEKDAY": "Every month on the {{ordinalStr}} {{weekdayStr}}",
"Q_QUARTERLY_CURRENT_DATE": "Every 3 months on the day {{dateDayStr}}",
"Q_RRULE": "Custom recurring config",
"Q_SEMIANNUALLY_CURRENT_DATE": "Every 6 months on the day {{dateDayStr}}",
"Q_DAILY": "Daily",
"Q_EVERY_OTHER_DAY": "Every 2 days",
"Q_EVERY_OTHER_YEAR_CURRENT_DATE": "Every 2 years ({{dayAndMonthStr}})",
"Q_MONDAY_TO_FRIDAY": "Every weekday (Mon - Fri)",
"Q_MONTHLY_CURRENT_DATE": "Monthly ({{dateDayStr}})",
"Q_MONTHLY_FIRST_DAY": "Monthly (1st)",
"Q_MONTHLY_LAST_DAY": "Monthly (last day)",
"Q_MONTHLY_LAST_WEEKDAY": "Monthly (last {{weekdayStr}})",
"Q_MONTHLY_NTH_WEEKDAY": "Monthly ({{ordinalStr}} {{weekdayStr}})",
"Q_QUARTERLY_CURRENT_DATE": "Every 3 months ({{dateDayStr}})",
"Q_RRULE": "Custom",
"Q_SEMIANNUALLY_CURRENT_DATE": "Every 6 months ({{dateDayStr}})",
"Q_WEEKENDS": "Every weekend (Sat & Sun)",
"Q_WEEKLY_CURRENT_WEEKDAY": "Every week on {{weekdayStr}}",
"Q_YEARLY_CURRENT_DATE": "Every year on the {{dayAndMonthStr}}",
"QUICK_SETTING": "Recurring Config",
"Q_WEEKLY_CURRENT_WEEKDAY": "Weekly ({{weekdayStr}})",
"Q_YEARLY_CURRENT_DATE": "Yearly ({{dayAndMonthStr}})",
"QUICK_SETTING": "Repeat",
"QUICK_SETTING_FEWER": "Fewer options",
"QUICK_SETTING_MORE": "More options",
"REMIND_AT": "Remind at",
"REMIND_AT_PLACEHOLDER": "Select when to remind",
"SKIP_FOR_DATE": "Skip for {{date}}",
"SKIP_INSTANCE": "Skip for today",
"REPEAT_CYCLE": "Recur cycle",
"REPEAT_EVERY": "Recur every",
"RRULE_BYDAY": "On weekdays",
"RRULE_BYDAY_DESCRIPTION": "Pick one or more weekdays. Used by Weekly, by Monthly's 'On selected weekdays', and by Yearly's 'On weekdays within months'. E.g. Mon + Wed = every Monday and Wednesday.",
"CAL_MENU_SET_START": "Set as start date",
"CAL_MENU_ENDS_ON": "Ends on this date",
"CAL_MENU_DAY_OF_MONTH": "On this day of the month",
"CAL_MENU_DAY_OF_YEAR": "On this day of the year",
"CAL_MENU_SIMULATE": "Simulate completing here",
"CAL_MENU_STOP_SIMULATE": "Stop simulating here",
"CAL_MENU_LIMIT_MONTH": "Limit to {{month}}",
"CAL_MENU_UNLIMIT_MONTH": "Remove {{month}} from limit",
"CAL_MENU_CLEAR_MONTHS": "Remove all month limits ({{months}})",
"CAL_MENU_REMOVE_ENDS": "Remove end date",
"CAL_MENU_REMOVE_DAY_OF_MONTH": "Remove this day of the month",
"CAL_MENU_REMOVE_DAY_OF_YEAR": "Remove this day of the year",
"CAL_MENU_SELECTED_DAYS": "Selected days of the week",
"CAL_MENU_REMOVE_SELECTED_DAYS": "Remove from selected days",
"CAL_MENU_REMOVE_NTH": "Remove {{ordinal}}",
"CAL_TIP_IN_MONTHS": "Days of the week in months",
"CAL_TIP_LIMITED_MONTHS": "Limited to {{months}}",
"CAL_MENU_SWITCHES_WEEKLY": "Switch to weekly",
"CAL_MENU_SWITCHES_MONTHLY": "Switch to monthly",
"CAL_MENU_SWITCHES_YEARLY": "Switch to yearly",
"CAL_GLYPH_HINT": "Header marks: number = nth weekday · ● = selected day · ▦ = days in months",
"RRULE_BYDAY": "On days of the week",
"RRULE_BYDAY_DESCRIPTION": "Pick one or more days of the week. Used by Weekly, by Monthly's 'On selected days of the week', and by Yearly's 'On days of the week within months'. E.g. Mon + Wed = every Monday and Wednesday.",
"RRULE_BYMONTH": "In months",
"RRULE_BYMONTH_DESCRIPTION": "Limit to these months — works with any frequency; leave empty for every month. E.g. daily in JanApr, Mondays only in June, or yearly 'every Saturday, MarchNovember'. For Yearly this also sets which month(s) the date/weekday falls in.",
"RRULE_BYMONTH_DESCRIPTION": "Limit to these months — works with any frequency; leave empty for every month. E.g. daily in JanApr, Mondays only in June, or yearly 'every Saturday, MarchNovember'. For Yearly this also sets which month(s) the date or day of the week falls in.",
"RRULE_BYMONTHDAY": "Day of month",
"RRULE_BYMONTHDAY_DESCRIPTION": "Tap the day(s) of the month (131, e.g. 15), or Last / 2nd-last / 3rd-last to count from the end. Need others? Type a custom list like 1,15,-5. Note: months without a chosen day are skipped (e.g. 31 never fires in February) — also select 'Last day' to cover shorter months.",
"RRULE_BYMONTHDAY_LIST": "Days of month (list)",
"RRULE_BYMONTHDAY_LIST_DESCRIPTION": "Repeat on several days of the month, or count from the end. Comma-separated; negatives count back from month-end. E.g. 1,15,-1 = the 1st, 15th and last day. Overrides the single 'Day of month' above.",
"RRULE_BYSETPOS": "Set position (BYSETPOS)",
"RRULE_BYSETPOS_DESCRIPTION": "From all the matches in each period, keep only the selected occurrences. E.g. choose MonFri + 'last' = the last weekday of the month; add 'first' to get both. Custom accepts comma-separated numbers; negatives count from the end (-2 = 2nd-to-last).",
"RRULE_BYSETPOS_DESCRIPTION": "From all the matches in each period, keep only the selected occurrences. E.g. choose MonFri + 'last' = the last working day of the month; add 'first' to get both. Custom accepts comma-separated numbers; negatives count from the end (-2 = 2nd-to-last).",
"RRULE_BYWEEKNO": "Week numbers (BYWEEKNO)",
"RRULE_BYWEEKNO_DESCRIPTION": "For Yearly only: limit to these ISO week numbers, 153. Comma-separated; negatives count from year-end. E.g. 20,21 = weeks 20 and 21 each year.",
"RRULE_BYYEARDAY": "Days of year (BYYEARDAY)",
@ -1994,22 +2021,35 @@
"RRULE_COUNT": "Number of occurrences",
"RRULE_COUNT_DESCRIPTION": "How many times the task is created in total, then it stops. E.g. 10 = ten occurrences.",
"RRULE_END": "Ends",
"RRULE_END_COUNT": "After a number of times",
"RRULE_END_COUNT": "After",
"RRULE_END_DESCRIPTION": "When the series stops. Never = open-ended. After a number of times = a fixed count (e.g. 10 occurrences). On a date = nothing is created after it.",
"RRULE_END_NEVER": "Never",
"RRULE_END_UNTIL": "On a date",
"RRULE_END_UNTIL": "On date",
"RRULE_ENGINE_OFF_ADVANCED": "While the advanced engine is off, a rule using these selections is saved in full but creates no tasks on this device.",
"RRULE_ENGINE_OFF_END": "End conditions only take effect when the advanced engine is on — with it off, a rule with an end condition creates no tasks on this device.",
"RRULE_ENGINE_OFF_NOTICE": "The advanced recurrence engine is off on this device (Settings → Advanced recurrence). The rule is saved in full, but scheduling runs from a simplified version of it: the basic daily/weekly/monthly/yearly rhythm with a single anchor day. A rule beyond that — end conditions, seasonal months, week numbers and other advanced options — creates no tasks while the engine is off.",
"RRULE_FREQ": "Frequency",
"RRULE_FREQ_DESCRIPTION": "How often the task repeats: Daily, Weekly, Monthly or Yearly. All the options below adapt to this choice. E.g. Weekly = once a week, Monthly = once a month.",
"RRULE_INTERVAL": "Repeat every",
"RRULE_INTERVAL": "Every",
"RRULE_INTERVAL_DECREMENT": "Decrease interval",
"RRULE_INTERVAL_DESCRIPTION": "How many of the chosen units between repeats. 1 = every time, 2 = every other, 3 = every third. E.g. Weekly + 2 = every other week; Monthly + 3 = once a quarter.",
"RRULE_ADVANCED": "Advanced options",
"RRULE_INTERVAL_INCREMENT": "Increase interval",
"RRULE_ADVANCED": "Advanced",
"RRULE_ADVANCED_DESCRIPTION": "Optional power-user rule parts: week start, set position, multiple/negative days of the month, week-of-year, day-of-year, and a raw rule override. Leave them blank if you don't need them.",
"RRULE_SCHEDULE_TYPE": "Schedule type",
"RRULE_SCHEDULE_FROM_START": "Fixed dates (from start)",
"RRULE_SCHEDULE_FROM_COMPLETION": "After I complete it",
"RRULE_SCHEDULE_TYPE_DESCRIPTION": "Fixed dates repeats on a set calendar rhythm from the start date; After completion restarts the count when you finish (so 'every 3 days' = 3 days after each completion). It changes only which date the next task lands on — whether the next task waits for you to finish is the separate 'Only create next task after completion' option (Advanced).",
"RRULE_SCHEDULE_TYPE_DESCRIPTION": "Fixed: keeps a set calendar rhythm from the start date. After I complete it: counts the interval from each completion (e.g. every 3 days = 3 days after you finish).",
"RRULE_SCHEDULE_TYPE_DESC_FIXED": "Keep a set calendar rhythm from the start date.",
"RRULE_SCHEDULE_TYPE_DESC_COMPLETION": "Count the interval from each completion (e.g. every 3 days = 3 days after you finish).",
"RRULE_NEXT": "Upcoming",
"RRULE_NEXT_AFTER_COMPLETION": "After completion, e.g.",
"RRULE_CALENDAR_PREVIEW": "Calendar preview",
"RRULE_CALENDAR_PREVIEW_EMPTY": "Nothing to preview: the current rule has no occurrences in the next 12 months (or is incomplete).",
"RRULE_PREVIEW_INVALID": "The recurrence rule is incomplete — finish the settings above to see the preview.",
"RRULE_SIM_LABEL": "Simulating completion on",
"RRULE_SIM_RESET": "Reset",
"RRULE_SIM_HINT": "Tip: click a day to simulate completing it there and preview how the series re-anchors.",
"RRULE_NLP_EVERY": "every",
"RRULE_NLP_DAY": "day",
"RRULE_NLP_DAYS": "days",
@ -2028,8 +2068,8 @@
"RRULE_NLP_TIME": "time",
"RRULE_NLP_TIMES": "times",
"RRULE_NLP_UNTIL": "until",
"RRULE_NLP_WEEKDAY": "weekday",
"RRULE_NLP_WEEKDAYS": "weekdays",
"RRULE_NLP_WEEKDAY": "working day",
"RRULE_NLP_WEEKDAYS": "working days",
"RRULE_NLP_IN": "in",
"RRULE_NLP_LAST": "last",
"RRULE_NLP_ST": "st",
@ -2044,24 +2084,31 @@
"RRULE_RAW_PLACEHOLDER": "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1",
"RRULE_FREQ_UNSUPPORTED": "Only daily, weekly, monthly and yearly frequencies are supported — sub-daily rules (hourly, minutely) are not yet.",
"RRULE_INVALID": "This recurrence rule is invalid. Please adjust the options.",
"RRULE_LEGACY_INCOMPAT": "This schedule uses options older app versions can't run. Devices on an older version — or with the advanced recurrence engine off — won't create tasks for it.",
"RRULE_NO_OCCURRENCE": "This recurrence never produces an occurrence. Please check the rule and start date.",
"RRULE_PREVIEW_EMPTY_WINDOW": "No occurrences in this window — use the arrows to find the next one.",
"RRULE_PREVIEW_NEXT": "Next",
"RRULE_PREVIEW_COUNT": "{{nr}} in view",
"RRULE_PREVIEW_EVERY": "~every {{nr}} days",
"RRULE_PREVIEW_ENDS_AFTER": "ends after {{nr}}",
"RRULE_PREVIEW_ENDS_UNTIL": "until {{date}}",
"RRULE_PREVIEW_ENDS_NEVER": "runs forever",
"RRULE_COUNT_WITH_COMPLETION": "\"After N times\" cannot be combined with \"from completion\" — completing re-anchors the schedule, so the count would never finish. Use an until date instead.",
"RRULE_RAW": "Raw RRULE override",
"RRULE_RAW_DESCRIPTION": "Type a complete RFC 5545 RRULE to express anything the options above can't. When filled it replaces all of them. E.g. FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 = the last weekday of every month. Leave empty to use the options above.",
"RRULE_RAW_DESCRIPTION": "Type a complete RFC 5545 RRULE to express anything the options above can't. When filled it replaces all of them. E.g. FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 = the last working day of every month. Leave empty to use the options above.",
"RRULE_WKST": "Week starts on",
"RRULE_WKST_DEFAULT": "Default (Monday)",
"RRULE_WKST_DESCRIPTION": "Which day the week starts on. Only matters for Weekly with an interval above 1, where it decides which days count as the same week. Default is Monday (ISO).",
"RRULE_MODE_DAY_OF_MONTH": "On a day of the month",
"RRULE_MODE_DAY_OF_MONTH": "Day of month",
"RRULE_MODE_LAST_DAY": "On the last day of the month",
"RRULE_MODE_NTH_WEEKDAY": "On an nth weekday",
"RRULE_MODE_WEEKDAYS": "On selected weekdays",
"RRULE_MODE_ON_DATE": "On a specific date",
"RRULE_MODE_ON_WEEKDAYS": "On weekdays within the months",
"RRULE_NTH_WEEKDAY": "Which weekdays",
"RRULE_MODE_NTH_WEEKDAY": "Nth day of the week",
"RRULE_MODE_WEEKDAYS": "Selected days of the week",
"RRULE_MODE_ON_DATE": "Specific date",
"RRULE_MODE_ON_WEEKDAYS": "Days of the week in months",
"RRULE_NTH_WEEKDAY": "Which days of the week",
"RRULE_ADD_NTH": "Add line",
"RRULE_NTH_WEEKDAY_DESCRIPTION": "Each line is an occurrence + one or more weekdays, e.g. 3rd + Monday = the 3rd Monday, or 1st + Mon,Tue = the 1st Monday and the 1st Tuesday. Add lines (+) to combine occurrences — a '3rd Monday' line and a '4th Sunday' line = BYDAY=3MO,4SU. Each line uses a different occurrence and is independent (not 'the 3rd of all of them'; that's the 'On selected weekdays' set instead).",
"RRULE_YEARLY_MODE": "Yearly pattern",
"RRULE_YEARLY_MODE_DESCRIPTION": "How the yearly repeat is anchored. 'On a specific date' = a month and day (e.g. March 15). 'On weekdays within the months' = e.g. every Saturday in MarchNovember (choose the months and the weekdays).",
"RRULE_NTH_WEEKDAY_DESCRIPTION": "Each line is an occurrence + one or more days of the week, e.g. 3rd + Monday = the 3rd Monday, or 1st + Mon,Tue = the 1st Monday and the 1st Tuesday. Add lines (+) to combine occurrences — a '3rd Monday' line and a '4th Sunday' line = BYDAY=3MO,4SU. Each line uses a different occurrence and is independent (not 'the 3rd of all of them'; that's the 'On selected days of the week' set instead).",
"RRULE_YEARLY_MODE": "On",
"RRULE_MONTH_1": "January",
"RRULE_MONTH_2": "February",
"RRULE_MONTH_3": "March",
@ -2074,14 +2121,14 @@
"RRULE_MONTH_10": "October",
"RRULE_MONTH_11": "November",
"RRULE_MONTH_12": "December",
"RRULE_MONTHLY_MODE": "Monthly pattern",
"RRULE_MONTHLY_MODE_DESCRIPTION": "How the monthly repeat is anchored. 'On a day of the month' = a fixed date (e.g. the 15th). 'On an nth weekday' = e.g. the 2nd Tuesday or last Friday. 'On selected weekdays' = a weekday set such as MonFri (pair it with Set position in Advanced for 'the last weekday of the month'). 'On the last day' = month-end (2831).",
"RRULE_MONTHLY_MODE": "On",
"RRULE_MONTHLY_MODE_DESCRIPTION": "How the monthly repeat is anchored. 'On a day of the month' = a fixed date (e.g. the 15th). 'On an nth day of the week' = e.g. the 2nd Tuesday or last Friday. 'On selected days of the week' = a set of days of the week such as MonFri (pair it with Set position in Advanced for 'the last working day of the month'). 'On the last day' = month-end (2831).",
"RRULE_DAY_2ND_LAST": "2nd-last",
"RRULE_DAY_3RD_LAST": "3rd-last",
"RRULE_DAY_LAST": "Last",
"RRULE_SETPOS": "Which one",
"RRULE_SETPOS_EVERY": "Every",
"RRULE_SETPOS_DESCRIPTION": "Which occurrence of that weekday within the month: First, Second, Third, Fourth or Last. E.g. Last + Friday = the last Friday of each month.",
"RRULE_SETPOS_DESCRIPTION": "Which occurrence of that day of the week within the month: First, Second, Third, Fourth or Last. E.g. Last + Friday = the last Friday of each month.",
"RRULE_UNTIL": "End date",
"RRULE_UNTIL_DESCRIPTION": "The last date an occurrence may fall on; nothing is created after it. E.g. 2024-12-31.",
"SATURDAY": "Saturday",
@ -2089,6 +2136,7 @@
"SKIP_OVERDUE": "Don't let overdue instances pile up",
"SKIP_OVERDUE_DESCRIPTION": "Stops unfinished recurring tasks from piling up as overdue duplicates. An empty missed instance is removed once a newer one exists; instances where you tracked time or completed subtasks are kept.",
"START_DATE": "Start date",
"RRULE_PICK_START_HINT": "Tap a day to set the start",
"START_TIME": "Scheduled start time",
"START_TIME_DESCRIPTION": "E.g. 15:00. Leave blank for an all day task",
"SUNDAY": "Sunday",
@ -2099,8 +2147,8 @@
"WAIT_FOR_COMPLETION_DESCRIPTION": "Prevents multiple pending recurring tasks from piling up. The next instance will only be created once the current task is completed",
"WEDNESDAY": "Wednesday",
"WEEK_OF_MONTH": "Week of month",
"WEEKDAY": "Weekday",
"WEEKDAY_REQUIRED": "Select at least one weekday"
"WEEKDAY": "Day of the week",
"WEEKDAY_REQUIRED": "Select at least one day of the week"
},
"SNACK_REPEAT_DIALOG_FAIL": "Could not open repeat config dialog. You can configure it from the task menu."
},
@ -2312,6 +2360,21 @@
"ENABLED": "Enabled",
"EXAMPLE_VAL": "e.g. 32m",
"EXTENSION_INFO": "Please <a target=\"_blank\" href=\"https://chrome.google.com/webstore/detail/super-productivity/ljkbjodfmekklcoibdnhahlaalhihmlb\"> download the Chrome extension</a> to allow communication with the Jira API and Idle Time Handling. Note that this doesn't work for mobile. <strong>Without the extension, the features will not work!</strong>",
"HEATMAP_ACTIVITY": "{{count}} tasks, {{time}}",
"HEATMAP_ACTIVITY_LEGEND": "tracked time",
"HEATMAP_COMPLETED_SIM": "completed (simulated)",
"HEATMAP_END_DAY": "end date",
"HEATMAP_HIGH": "High",
"HEATMAP_IN_DAYS": "in {{nr}} days",
"HEATMAP_LOW": "Low",
"HEATMAP_NEXT": "next occurrence",
"HEATMAP_OCCURRENCE_NR": "occurrence #{{nr}}",
"HEATMAP_PROJECTED": "projected occurrence",
"HEATMAP_SIMULATE_DAY": "double-click to simulate completing here",
"HEATMAP_START_DAY": "start date",
"HEATMAP_TODAY": "today",
"HEATMAP_VIEW_MONTH": "Calendar",
"HEATMAP_VIEW_YEAR": "365 days",
"HIDE": "Hide",
"ICON_INP_DESCRIPTION": "All UTF-8 emojis are also supported!",
"INBOX_PROJECT_TITLE": "Inbox",
@ -2334,6 +2397,7 @@
"SUBMIT": "Submit",
"TITLE": "Title",
"TODAY_TAG_TITLE": "Today",
"TOGGLE_FULLSCREEN": "Toggle fullscreen",
"TOGGLE_PASSWORD_VISIBILITY": "Toggle password visibility",
"UNDO": "Undo",
"WITHOUT_PROJECT": "Without Project",
@ -2443,7 +2507,7 @@
},
"RRULE_ENGINE": {
"TITLE": "Advanced recurrence (experimental)",
"HELP": "<div><p>Enables the new RFC 5545 (RRULE) recurrence engine for repeating tasks — end conditions, multiple days per month and other custom rules. This is experimental and applies to <strong>this device only</strong>; it is not synced. Leave it off unless you are testing the feature, and reload the app after changing it.</p></div>",
"HELP": "<div><p>Enables the new RFC 5545 (RRULE) recurrence engine for repeating tasks — end conditions, multiple days per month and other custom rules. This is experimental and applies to <strong>this device only</strong>; it is not synced. Leave it off unless you are testing the feature, and reload the app after changing it.</p><p><strong>If you sync between several devices, enable it on all of them or on none.</strong> A device with the engine on and one with it off compute occurrences independently — where a schedule diverges between the two engines, each device creates its own task and both sync everywhere (duplicates).</p></div>",
"IS_ENABLED": "Enable advanced recurrence engine (this device only)"
},
"IDLE": {
@ -2676,6 +2740,7 @@
"IS_AUTO_ADD_WORKED_ON_TO_TODAY": "Automatically add worked-on tasks to today",
"IS_AUTO_MARK_PARENT_AS_DONE": "Automatically mark parent task as completed when all subtasks are completed",
"IS_CONFIRM_BEFORE_DELETE": "Confirm before deleting tasks",
"IS_EXPAND_ACTIVITY_FOR_REPEAT_EDIT": "Show recurring task activity when editing a recurring task config",
"IS_MARKDOWN_FORMATTING_IN_NOTES_ENABLED": "Enable Markdown formatting in task notes",
"IS_TRAY_SHOW_CURRENT": "Show current task in the tray / status menu (macOS/Windows only)",
"NOTES_TEMPLATE": "Task description template",

View file

@ -188,6 +188,44 @@ body.isNativeMobile .project-complete-fullscreen-dialog {
height: calc(100dvh - var(--safe-area-top) - var(--safe-area-bottom)) !important;
}
// Recurring-task dialog: a surface that widens dynamically with the viewport
// (instead of sizing to its content) up to a tidy cap, and lifts Material's 80vw
// default so a phone gets the full width. Declared BEFORE `.dialog-fullscreen`
// so that, when both classes are present, the fullscreen rule wins.
.dialog-recurring {
width: min(95vw, 600px) !important;
max-width: 95vw !important;
}
// Generic runtime-toggleable fullscreen state for any dialog added/removed
// via MatDialogRef.addPanelClass('dialog-fullscreen') (e.g. the recurring-task
// dialog's expand button). Unlike the project-complete variant above, the
// dialog keeps its normal surface chrome.
.dialog-fullscreen {
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
max-height: 100vh !important;
margin: 0 !important;
@supports (height: 100dvh) {
height: 100dvh !important;
max-height: 100dvh !important;
}
.mat-mdc-dialog-surface {
width: 100%;
height: 100%;
max-height: none;
border-radius: 0;
}
}
body.isNativeMobile .dialog-fullscreen {
width: calc(100vw - var(--safe-area-left) - var(--safe-area-right)) !important;
height: calc(100dvh - var(--safe-area-top) - var(--safe-area-bottom)) !important;
}
// FORMS
// --------
.mat-mdc-form-field {