From 880243b24f08d544b4542eb145f9a379dd01c609 Mon Sep 17 00:00:00 2001 From: Filip Date: Wed, 17 Jun 2026 11:03:29 -0400 Subject: [PATCH] =?UTF-8?q?feat(task-repeat-cfg):=20RRULE=20epic=20phase?= =?UTF-8?q?=202=20=E2=80=94=20heatmap=20preview=20+=20completion=20simulat?= =?UTF-8?q?ion=20(#8231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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- — hyphen, since feat/rrule-epic/ 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__ 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__ 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__) 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: · in N days" chip sits under the summary. - Rhythm stats ribbon above the calendar: count in view · ~every N days · ends after N / until / 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 + } + @if (endType() === 'UNTIL') { + + } + + +
+ +
+ +
+
+
+ {{ + (scheduleSelectedId() === 'COMPLETION' + ? T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESC_COMPLETION + : T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESC_FIXED + ) | translate + }} +
+ + + } @else { + + } + + + @if (hasRule()) { + @if (resultHeatmapData(); as hd) { +
+ @if (startParts(); as sp) { +
+ {{ T.F.TASK_REPEAT.F.START_DATE | translate }}: +
+ + + +
+
+ } + @if (hasActivity()) { +
+ + @if (showActivity() && activitySummary(); as a) { + + {{ T.F.TASK_REPEAT.D_EDIT.TIME_SPENT_TOTAL | translate }}: + {{ a.total }} + · + {{ T.F.TASK_REPEAT.D_EDIT.TIME_SPENT_THIS_WEEK | translate }}: + {{ a.thisWeek }} + · + {{ T.F.TASK_REPEAT.D_EDIT.TIME_SPENT_THIS_MONTH | translate }}: + {{ a.thisMonth }} + + } +
+ } + + @if (weekdayHeaderGlyphs().size) { +
+ {{ T.F.TASK_REPEAT.F.CAL_GLYPH_HINT | translate }} +
+ } + @if (previewWindowEmpty()) { +
+ {{ T.F.TASK_REPEAT.F.RRULE_PREVIEW_EMPTY_WINDOW | translate }} +
+ } + @if (simulatedCompletion(); as sim) { +
+ {{ T.F.TASK_REPEAT.F.RRULE_SIM_LABEL | translate }} {{ sim }} + +
+ } +
+ } @else { +
+ {{ T.F.TASK_REPEAT.F.RRULE_PREVIEW_INVALID | translate }} +
+ } + } +
+ + +
+ + + + @if (menuDayAfterStart()) { + + } + @if (isMonthlyRule()) { + + } + @if (isYearlyRule()) { + + } + @if (menuDaySimAllowed() || menuDayIsSim()) { + + } + @if (!isMonthlyRule() || !isYearlyRule()) { +
+ @if (!isMonthlyRule()) { + + } + @if (!isYearlyRule()) { + + } +
+ @if (expandedSwitch() === 'MONTHLY') { + + } + @if (expandedSwitch() === 'YEARLY') { + + } + } +
+ +
+ + + + @if (isWeeklyRule()) { + + } + @if (isMonthlyRule()) { + + + } + @if (isYearlyRule()) { + + + } +
+ @if (!isWeeklyRule()) { + + } + @if (!isMonthlyRule()) { + + } + @if (!isYearlyRule()) { + + } +
+ + @if (expandedSwitch() === 'WEEKLY') { + + } + @if (expandedSwitch() === 'MONTHLY') { + + + } + @if (expandedSwitch() === 'YEARLY') { + + + } +
+ + + + + + + + + +
+ + + @if (hasMonthLimits()) { + + } + + - + @if (isRRuleMode() && rrulePreview(); as p) {
→ {{ p.human }}
- @if (repeatCfg().repeatFromCompletionDate) { - @if (p.completionExample; as ex) { -
- {{ T.F.TASK_REPEAT.F.RRULE_NEXT_AFTER_COMPLETION | translate }}: - {{ ex.done | date: 'mediumDate' }} → {{ ex.next | date: 'mediumDate' }} -
- } - } @else if (p.upcoming.length) { -
- {{ T.F.TASK_REPEAT.F.RRULE_NEXT | translate }}: - @for (d of p.upcoming; track d.getTime(); let last = $last) { - {{ d | date: 'mediumDate' }} - @if (!last) { - · + @if (nextOccurrence(); as n) { +
+ schedule + {{ T.F.TASK_REPEAT.F.RRULE_PREVIEW_NEXT | translate }}: + {{ n.date | date: 'mediumDate' }} + + @if (n.daysAway <= 0) { + {{ T.G.HEATMAP_TODAY | translate }} + } @else { + {{ T.G.HEATMAP_IN_DAYS | translate: { nr: n.daysAway } }} } - } + +
+ } + @if (rruleLegacyIncompat()) { +
+ {{ T.F.TASK_REPEAT.F.RRULE_LEGACY_INCOMPAT | translate }} +
+ } + @if (repeatCfg().repeatFromCompletionDate && p.completionExample; as ex) { +
+ {{ T.F.TASK_REPEAT.F.RRULE_NEXT_AFTER_COMPLETION | translate }}: + {{ ex.done | date: 'mediumDate' }} → {{ ex.next | date: 'mediumDate' }}
} -
{{ p.rrule }}
} diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.scss b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.scss index 829f61144e..b7407ecbe7 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.scss +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.scss @@ -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: · 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 { diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts index 69653fe98a..706fd46374 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.spec.ts @@ -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 = { 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>; let mockTaskRepeatCfgService: jasmine.SpyObj; @@ -61,8 +72,13 @@ describe('DialogEditTaskRepeatCfgComponent', () => { targetDate?: string; }, getTaskRepeatCfgById$ReturnValue?: Observable | Subject, + seriesTasks: TaskCopy[] = [], ): Promise> => { - 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) => { 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); + }); + }); }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts index 0cd4edee81..d10fbda56d 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.ts @@ -5,6 +5,8 @@ import { effect, inject, signal, + untracked, + viewChild, } from '@angular/core'; import { Task, TaskReminderOptionId } from '../../tasks/task.model'; import { @@ -19,6 +21,7 @@ import { TaskRepeatCfgService } from '../task-repeat-cfg.service'; import { DEFAULT_TASK_REPEAT_CFG, QUICK_SETTING_PRESETS, + RepeatQuickSetting, TaskRepeatCfg, TaskRepeatCfgCopy, toSyncSafeQuickSetting, @@ -38,33 +41,70 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { getDbDateStr, isDBDateStr } from '../../../util/get-db-date-str'; import { formatMonthDay } from '../../../util/format-month-day.util'; import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date'; -import { first } from 'rxjs/operators'; +import { first, filter, switchMap } from 'rxjs/operators'; +import { from as fromPromise } from 'rxjs'; +import { TaskService } from '../../tasks/task.service'; +import { TaskArchiveService } from '../../archive/task-archive.service'; +import { calcRepeatTaskSeriesTimeSpent } from '../calc-repeat-task-series-time-spent.util'; +import { msToString } from '../../../ui/duration/ms-to-string.pipe'; import { getQuickSettingUpdates } from './get-quick-setting-updates'; import { getTaskRepeatCfgChanges } from './get-task-repeat-cfg-changes'; import { SnackService } from '../../../core/snack/snack.service'; -import { getFirstRRuleOccurrence, isRRuleValid } from '../store/rrule-occurrence.util'; +import { + getFirstRRuleOccurrence, + getRRuleOccurrencesInRange, + isRRuleValid, +} from '../store/rrule-occurrence.util'; +import { getEffectiveRepeatStartDate } from '../store/get-effective-repeat-start-date.util'; import { FREQ_TO_CYCLE, safeParseRRuleOptions } from '../util/rrule-parse.util'; import { getAlignedStartDate, + isRRuleLegacyRepresentable, legacyTaskRepeatCfgToRRule, rruleToLegacyTaskRepeatCfg, } from '../util/legacy-cfg-to-rrule.util'; import { RruleBuilderComponent } from './rrule-builder/rrule-builder.component'; +import { RepeatFreqPickerComponent } from './repeat-freq-picker/repeat-freq-picker.component'; import { buildRRuleHumanizeOpts, getRRulePreview } from '../util/rrule-preview.util'; -import { DatePipe } from '@angular/common'; +import { DatePipe, NgTemplateOutlet } from '@angular/common'; import { clockStringFromDate } from '../../../ui/duration/clock-string-from-date'; import { ChipListInputComponent } from '../../../ui/chip-list-input/chip-list-input.component'; -import { MatButton } from '@angular/material/button'; +import { MatButton, MatIconButton } from '@angular/material/button'; import { MatIcon } from '@angular/material/icon'; import { Log } from '../../../core/log'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component'; import { GlobalConfigService } from '../../config/global-config.service'; -import { RRuleFeatureFlagService } from '../../config/rrule-feature-flag.service'; import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const'; import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service'; -import { RepeatTaskHeatmapComponent } from '../repeat-task-heatmap/repeat-task-heatmap.component'; import { CollapsibleComponent } from '../../../ui/collapsible/collapsible.component'; +import { DateAdapter } from '@angular/material/core'; +import { DayData, HeatmapViewData } from '../../../ui/heatmap/heatmap.component'; +import { HeatmapSwitcherComponent } from '../../../ui/heatmap/heatmap-switcher.component'; +import { + buildHeatmapMonths, + buildHeatmapWeeks, + buildProjectionDayMap, + heatmapOccurrenceTotal, +} from '../../../ui/heatmap/build-heatmap-data.util'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { MatTooltip } from '@angular/material/tooltip'; +import { + clearMonths, + setEnd as setEndInRRule, + setUntil as setUntilInRRule, + setYearDay, + toggleByDay, + toggleByMonth, + toggleMonthDay, + toggleNthDay, + weekdayAnnotations, +} from '../util/rrule-calendar-ops.util'; +import { rruleToFormModel, RRULE_WEEKDAYS, RRuleWeekday } from '../util/rrule-form.util'; +import { + SegmentedButtonGroupComponent, + SegmentedButtonOption, +} from '../../../ui/segmented-button-group/segmented-button-group.component'; // Fields whose change requires offering "Update all task instances?" — covers // what propagates to existing tasks (vs. schedule fields, which only affect @@ -97,18 +137,28 @@ type RepeatCfgWorking = Omit | TaskRepeatCfg; ChipListInputComponent, MatDialogActions, MatButton, + MatIconButton, MatIcon, - RepeatTaskHeatmapComponent, + HeatmapSwitcherComponent, CollapsibleComponent, RruleBuilderComponent, + RepeatFreqPickerComponent, DatePipe, + NgTemplateOutlet, + MatMenu, + MatMenuItem, + MatMenuTrigger, + MatTooltip, + SegmentedButtonGroupComponent, ], }) export class DialogEditTaskRepeatCfgComponent { private _globalConfigService = inject(GlobalConfigService); - private _rruleFlag = inject(RRuleFeatureFlagService); + private _dateAdapter = inject(DateAdapter); private _tagService = inject(TagService); private _taskRepeatCfgService = inject(TaskRepeatCfgService); + private _taskService = inject(TaskService); + private _taskArchiveService = inject(TaskArchiveService); private _matDialog = inject(MatDialog); private _matDialogRef = inject>(MatDialogRef); @@ -126,7 +176,6 @@ export class DialogEditTaskRepeatCfgComponent { }>(MAT_DIALOG_DATA); T: typeof T = T; - isHeatmapExpanded = false; repeatCfgInitial = signal(undefined); repeatCfg = signal(this._initializeRepeatCfg()); @@ -145,6 +194,69 @@ export class DialogEditTaskRepeatCfgComponent { return this._data.repeatCfg?.id || this._data.task?.repeatCfgId || null; }); + // --- Activity overlay: tracked time of the saved series, merged into the + // preview calendar as a GREEN spectrum (distinct from the blue projection). + // Only an existing cfg has history; new ones show projection only. Togglable — + // OFF hides the green cells, the time summary, and the activity legend. --- + private readonly _seriesTasks = toSignal( + toObservable(this.repeatCfgId).pipe( + filter((id): id is string => !!id), + switchMap((id) => fromPromise(this._loadSeriesTasks(id))), + ), + { initialValue: [] as Task[] }, + ); + /** Total tracked ms per `YYYY-MM-DD` across the whole series. */ + readonly activityByDay = computed>(() => { + const m = new Map(); + for (const t of this._seriesTasks() ?? []) { + for (const [d, ms] of Object.entries(t.timeSpentOnDay ?? {})) { + if (ms > 0) { + m.set(d, (m.get(d) ?? 0) + ms); + } + } + } + return m; + }); + readonly hasActivity = computed(() => this.activityByDay().size > 0); + readonly showActivity = signal(true); + readonly activitySummary = computed<{ + total: string; + thisWeek: string; + thisMonth: string; + } | null>(() => { + if (!this.hasActivity()) { + return null; + } + const s = calcRepeatTaskSeriesTimeSpent(this._seriesTasks() ?? []); + return { + total: msToString(s.total), + thisWeek: msToString(s.thisWeek), + thisMonth: msToString(s.thisMonth), + }; + }); + + private async _loadSeriesTasks(id: string): Promise { + const [archive, currentTasks] = await Promise.all([ + this._taskArchiveService.load(), + this._taskService.allTasks$.pipe(first()).toPromise(), + ]); + const out: Task[] = []; + for (const t of currentTasks ?? []) { + if (t.repeatCfgId === id) { + out.push(t); + } + } + if (archive?.ids) { + for (const tid of archive.ids) { + const a = archive.entities[tid]; + if (a && a.repeatCfgId === id) { + out.push(a as Task); + } + } + } + return out; + } + essentialFormFields = signal([]); advancedFormFields = signal(TASK_REPEAT_CFG_ADVANCED_FORM_CFG); @@ -154,19 +266,62 @@ export class DialogEditTaskRepeatCfgComponent { // The RRULE builder (shown when quickSetting === 'RRULE') is a child component // with its own live preview; the dialog only needs to know when to render it. - private _formValue = toSignal(this.formGroup1().valueChanges, { - initialValue: null as { quickSetting?: string } | null, - }); - isRRuleMode = computed( - () => (this._formValue()?.quickSetting ?? this.repeatCfg().quickSetting) === 'RRULE', + // quickSetting now lives on the working cfg (driven by the chip picker), not + // a formly control, so read it directly. + isRRuleMode = computed(() => this.repeatCfg().quickSetting === 'RRULE'); + + // Value-equal key for the chip-picker options: only the reference DAY and the + // locale change the date-aware labels (e.g. "Monthly on the 13th"). formly + // emits a CLONED model — and thus a new `repeatCfg()` — on every keystroke in + // ANY field, so reading `repeatCfg()` directly rebuilt the whole option list + // (16 `translate.instant` + 4 `toLocaleDateString`) per character. Mirrors + // `_previewScheduleCfg`'s custom `equal` for the same reason. + private readonly _quickSettingOptionsKey = computed( + () => { + const sd = this.repeatCfg().startDate as string | Date | undefined; + const refDate = + sd instanceof Date ? sd : sd ? dateStrToUtcDate(sd) : this._getReferenceDate(); + return { + refDateStr: getDbDateStr(refDate), + locale: this._dateTimeFormatService.currentLocale(), + }; + }, + { equal: (a, b) => a.refDateStr === b.refDateStr && a.locale === b.locale }, ); + // Options for the TickTick-style chip picker (replaces the dropdown). Tracks + // only the value-equal key above; the build itself reads `repeatCfg()` for the + // start date `untracked`, so an unrelated-field keystroke never rebuilds it. + quickSettingOptions = computed(() => { + this._quickSettingOptionsKey(); + return untracked(() => this._buildQuickSettingOptions()); + }); + + // Common presets shown by default in the dropdown (in this order); the rest + // hide behind "More options". Order: every day, weekly, monthly, yearly, every + // weekday — then "Custom" and "More options" are appended by the picker. + readonly quickSettingCommon: readonly string[] = [ + 'DAILY', + 'WEEKLY_CURRENT_WEEKDAY', + 'MONTHLY_CURRENT_DATE', + 'YEARLY_CURRENT_DATE', + 'MONDAY_TO_FRIDAY', + ]; // Live result/preview shown at the dialog bottom in RRULE mode. The builder // keeps `repeatCfg().rrule` up to date via onRRuleChange, so this stays live. private _humanize = buildRRuleHumanizeOpts( (k) => this._translateService.instant(k) as string, ); + // Gated on isRRuleValid like the save path — getRRulePreview's + // rule.after() walk is otherwise unbounded for a never-firing rule + // (e.g. raw override FREQ=DAILY;BYWEEKNO=53;BYMONTH=2: a multi-second + // main-thread freeze PER KEYSTROKE). isRRuleValid is memoised and pre-screens + // exactly that class, the same guard the save path applies. + // True once the working cfg carries a fireable rule. Presets carry their own + // canonical rrule too, so the calendar/preview now shows in EVERY mode, not + // only the RRULE builder — the start date is picked there for all of them. + readonly hasRule = computed(() => isRRuleValid(this.repeatCfg().rrule)); rrulePreview = computed(() => - this.isRRuleMode() + isRRuleValid(this.repeatCfg().rrule) ? getRRulePreview( this.repeatCfg().rrule, this.repeatCfg().startDate, @@ -174,6 +329,838 @@ export class DialogEditTaskRepeatCfgComponent { ) : null, ); + // Sentinel warning: this rule is outside what the legacy fallback fields can + // represent, so the save writes the never-fires sentinel + // (LEGACY_NEVER_FIRES_FALLBACK) — devices on older app versions (and + // flag-off devices, which also route the legacy engine) will create no tasks + // for it. Decided contract: absent tasks beat fabricated wrong-day tasks + // that would sync back to every device. + rruleLegacyIncompat = computed( + () => + isRRuleValid(this.repeatCfg().rrule) && + !isRRuleLegacyRepresentable(this.repeatCfg().rrule), + ); + + // Calendar (heatmap) preview of the next year's occurrences for the live rule + // — built from the in-progress rrule, no saved cfg required (so it works while + // authoring a brand-new recurrence). Always shown for a valid rule (it doubles + // as the start-date picker), gated only on `hasRule()` / `resultHeatmapData()`. + private readonly _PREVIEW_HEATMAP_DAYS = 365; + // Months of look-back included in the home/shifted window so recent tracked + // time (the green Activity overlay) shows alongside the upcoming occurrences. + private readonly _PREVIEW_PAST_MONTHS = 3; + // Year-window navigation: 0 = the next 365 days from today; ±n shifts the + // window by whole years, unbounded in both directions (the projection is + // computed per window, so any year is reachable). + previewYearOffset = signal(0); + previewPrevYear(): void { + this.previewYearOffset.update((o) => o - 1); + } + previewNextYear(): void { + this.previewYearOffset.update((o) => o + 1); + } + previewNavLabel = computed(() => { + const hd = this.resultHeatmapData(); + if (!hd) { + return ''; + } + const a = hd.rangeStart.getFullYear(); + const b = hd.rangeEnd.getFullYear(); + return a === b ? `${a}` : `${a} – ${b}`; + }); + // True when the rendered window holds no occurrence (and no sim) — the grid + // alone would read as broken, so the template adds an explanatory hint. + previewWindowEmpty = computed(() => { + const hd = this.resultHeatmapData(); + return !!hd && ![...hd.dayMap.values()].some((d) => d.isProjected || d.isCompleted); + }); + // The month view navigates without walls; once the shown month leaves the + // current window, shift the window a year so its data follows. + onPreviewMonthChange(vm: { y: number; m: number }): void { + const hd = this.resultHeatmapData(); + if (!hd) { + return; + } + const monthStart = new Date(vm.y, vm.m, 1); + const monthEnd = new Date(vm.y, vm.m + 1, 0, 23, 59, 59); + if (monthEnd < hd.rangeStart) { + this.previewYearOffset.update((o) => o - 1); + } else if (monthStart > hd.rangeEnd) { + this.previewYearOffset.update((o) => o + 1); + } + } + // A clicked projected day (YYYY-MM-DD): "simulate completing here" → for a + // repeat-from-completion schedule the rule re-anchors from that day (the + // After-completion behavior, made interactive). + simulatedCompletion = signal(null); + // Fullscreen: a manual toggle in the title bar (the dialog already widens + // dynamically via `dialog-recurring`; this is the user's escape hatch for the + // full-width year strip). + isFullScreen = signal(false); + toggleFullScreen(): void { + this._setFullScreen(!this.isFullScreen()); + } + private _setFullScreen(isFullScreen: boolean): void { + this.isFullScreen.set(isFullScreen); + if (isFullScreen) { + this._matDialogRef.addPanelClass('dialog-fullscreen'); + } else { + this._matDialogRef.removePanelClass('dialog-fullscreen'); + } + } + clearSimulation(): void { + this.simulatedCompletion.set(null); + } + + // ---- Calendar context menus (direct-manipulation rule editing) ---- + // A click on a day / weekday header / month label opens a contextual MatMenu + // (hidden trigger positioned at the pointer) whose actions edit the SAME rule + // the builder edits, via the pure rrule-calendar-ops helpers + onRRuleChange. + readonly menuDay = signal(null); + readonly menuWeekdayIdx = signal(null); + /** Which frequency the (shared) nth-weekday submenu currently targets — set when + * its trigger opens, so the 1st/2nd/…/Last items apply to the right freq. */ + readonly menuNthFreq = signal<'MONTHLY' | 'YEARLY'>('MONTHLY'); + /** Which "switch to …" frequency the bottom icon row has expanded INLINE inside + * the current menu (null = collapsed). Click an icon to open it, click the same + * icon again to close — a plain toggle, not a hover-opened sub-menu. Reset every + * time a menu opens. */ + readonly expandedSwitch = signal<'WEEKLY' | 'MONTHLY' | 'YEARLY' | null>(null); + toggleSwitchExpand(freq: 'WEEKLY' | 'MONTHLY' | 'YEARLY'): void { + this.expandedSwitch.update((cur) => (cur === freq ? null : freq)); + } + readonly menuPos = signal<{ x: string; y: string }>({ x: '0px', y: '0px' }); + /** The month (0=Jan … 11=Dec) the month-label menu targets — set from the click + * (the month view's title, or a year-view month block). */ + readonly menuMonthIdx = signal(new Date().getMonth()); + + private readonly _dayMenuTrigger = viewChild('dayMenuTrigger', { + read: MatMenuTrigger, + }); + private readonly _weekdayMenuTrigger = viewChild('weekdayMenuTrigger', { + read: MatMenuTrigger, + }); + private readonly _monthMenuTrigger = viewChild('monthMenuTrigger', { + read: MatMenuTrigger, + }); + + // The live structured model parsed from the working rule — drives menu-item + // visibility (freq/mode) and the weekday-header annotation glyphs. + private readonly _previewModel = computed(() => + rruleToFormModel(this.repeatCfg().rrule, this._previewRefDate()), + ); + // Current frequency of the working rule — drives which calendar-menu options are + // "native" (shown at the top) vs which SWITCH the frequency (grouped under a + // per-target-freq icon button in the menu's bottom row). + readonly isWeeklyRule = computed(() => this._previewModel().freq === 'WEEKLY'); + readonly isMonthlyRule = computed(() => this._previewModel().freq === 'MONTHLY'); + readonly isYearlyRule = computed(() => this._previewModel().freq === 'YEARLY'); + // Per-weekday header glyphs (Mon=0 … Sun=6): nth ordinal (top), selected-day + // dot (mid), in-months grid (bottom) — the generic shape the calendar renders. + readonly weekdayHeaderGlyphs = computed(() => { + const ann = weekdayAnnotations(this._previewModel()); + const out = new Map(); + ann.forEach((a, idx) => { + out.set(idx, { + top: a.nth.length ? a.nth.join(',') : undefined, + mid: a.selected ? '●' : undefined, + bottom: a.inMonths ? '▦' : undefined, + }); + }); + return out; + }); + // Human-readable per-weekday tooltip (Mon=0 … Sun=6) spelling out what the + // glyphs mean — shown on hover over a weekday header that has something set. + readonly weekdayHeaderTooltips = computed(() => { + const ann = weekdayAnnotations(this._previewModel()); + const inst = (k: string): string => this._translateService.instant(k) as string; + const ord = new Map([ + ['1', T.F.TASK_REPEAT.F.ORD_FIRST], + ['2', T.F.TASK_REPEAT.F.ORD_SECOND], + ['3', T.F.TASK_REPEAT.F.ORD_THIRD], + ['4', T.F.TASK_REPEAT.F.ORD_FOURTH], + ['L', T.F.TASK_REPEAT.F.ORD_LAST], + ]); + const out = new Map(); + ann.forEach((a, idx) => { + const parts: string[] = []; + if (a.nth.length) { + const ords = a.nth.map((g) => (ord.has(g) ? inst(ord.get(g)!) : g)).join(', '); + parts.push(`${inst(T.F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY)}: ${ords}`); + } + if (a.selected) { + parts.push(inst(T.F.TASK_REPEAT.F.CAL_MENU_SELECTED_DAYS)); + } + if (a.inMonths) { + parts.push(inst(T.F.TASK_REPEAT.F.CAL_TIP_IN_MONTHS)); + } + if (parts.length) { + out.set(idx, parts.join(' · ')); + } + }); + return out; + }); + // Tooltip for the month label/title when BYMONTH limits the rule — lists them. + readonly monthTooltip = computed(() => + this.hasMonthLimits() + ? (this._translateService.instant(T.F.TASK_REPEAT.F.CAL_TIP_LIMITED_MONTHS, { + months: this.limitedMonthNames(), + }) as string) + : '', + ); + + private _previewRefDate(): Date { + const sd = this.repeatCfg().startDate as string | Date | undefined; + if (sd instanceof Date) return sd; + if (sd) return dateStrToUtcDate(sd); + return new Date(); + } + private _setMenuPos(event: MouseEvent): void { + this.menuPos.set({ x: event.clientX + 'px', y: event.clientY + 'px' }); + } + + onPreviewDayMenu({ data, event }: { data: DayData; event: MouseEvent }): void { + if (!data?.dateStr) { + return; + } + event.preventDefault(); + this.menuDay.set(data); + this.expandedSwitch.set(null); + this._setMenuPos(event); + this._dayMenuTrigger()?.openMenu(); + } + onWeekdayHeaderMenu({ + weekdayIdx, + event, + }: { + weekdayIdx: number; + event: MouseEvent; + }): void { + event.preventDefault(); + this.menuWeekdayIdx.set(weekdayIdx); + this.expandedSwitch.set(null); + this._setMenuPos(event); + this._weekdayMenuTrigger()?.openMenu(); + } + onMonthLabelMenu({ month, event }: { month: number; event: MouseEvent }): void { + event.preventDefault(); + this.menuMonthIdx.set(month); + this._setMenuPos(event); + this._monthMenuTrigger()?.openMenu(); + } + + // --- day-menu actions --- + /** True when the clicked day is strictly after the start (so "ends on" applies). */ + readonly menuDayAfterStart = computed(() => { + const d = this.menuDay()?.dateStr; + const s = this.repeatCfg().startDate as string | undefined; + return !!d && !!s && d > s; + }); + // Whether the clicked day is ALREADY the target of each settable action. When it + // is, the menu item flips to "Remove …" and the action toggles off (the day-of- + // month / day-of-year / simulate ops already add-or-remove; only the end date + // needs the handler to branch). Mirrors the month menu's Limit/Unlimit pattern. + readonly menuDayIsEnd = computed(() => { + const d = this.menuDay()?.dateStr; + return !!d && this.endType() === 'UNTIL' && d === this.endUntil(); + }); + readonly menuDayMonthActive = computed(() => { + const day = this.menuDay()?.date?.getDate(); + const m = this._previewModel(); + return ( + day != null && + m.freq === 'MONTHLY' && + m.monthlyMode === 'DAY_OF_MONTH' && + m.monthDays.includes(day) + ); + }); + readonly menuDayYearActive = computed(() => { + const dt = this.menuDay()?.date; + const m = this._previewModel(); + return ( + !!dt && + m.freq === 'YEARLY' && + m.yearlyMode === 'DAY_OF_MONTH' && + m.byMonth.length === 1 && + m.byMonth[0] === dt.getMonth() + 1 && + m.monthDays.length === 1 && + m.monthDays[0] === dt.getDate() + ); + }); + readonly menuDayIsSim = computed(() => { + const d = this.menuDay()?.dateStr; + return !!d && d === this.simulatedCompletion(); + }); + /** "Simulate completing here" only makes sense for a repeat-from-completion + * schedule — that's the only kind whose later occurrences re-anchor when you + * finish one (a start-anchored series stays fixed to the calendar, so a + * completion shifts nothing). So it's offered only on/after the start of such + * a schedule (a completion can't precede the rule's start), and never for a + * COUNT rule — completion + COUNT is the unsupported, never-terminating + * combination the save path rejects, and re-anchoring it would render up to + * ~2×COUNT marks. Presets are always start-anchored, so this also keeps + * simulate out of the preset (non-RRULE) calendar entirely. */ + readonly menuDaySimAllowed = computed(() => { + const cfg = this.repeatCfg(); + const d = this.menuDay()?.dateStr; + const s = cfg.startDate as string | undefined; + return ( + !!d && !!s && d >= s && !!cfg.repeatFromCompletionDate && this.endType() !== 'COUNT' + ); + }); + menuSetStart(): void { + const d = this.menuDay()?.dateStr; + // Floor at today (new-cfg rule) and skip a no-op re-pick. + if (!d || d < getDbDateStr(new Date()) || d === this.repeatCfg().startDate) { + return; + } + this._applyStartDate(d); + } + /** Apply an explicit start-date pick (M/D/Y fields or the "Set start" menu). + * A "repeat from completion" schedule that has already run anchors its + * preview and next-occurrence on `lastTaskCreationDay`; an explicit start + * edit re-defines that anchor, so the stale runtime marker is dropped — else + * the new start has no visible (or real) effect. Fixed schedules ignore it. */ + private _applyStartDate(dateStr: string): void { + this.repeatCfg.update((cfg) => ({ + ...cfg, + startDate: dateStr, + ...(cfg.repeatFromCompletionDate && cfg.lastTaskCreationDay + ? { lastTaskCreationDay: undefined } + : {}), + })); + this._clearEndIfBeforeStart(dateStr); + const sim = this.simulatedCompletion(); + if (sim) { + if (sim < dateStr) { + // The new start moved PAST the sim — a completion can't precede the + // rule's start, so the sim no longer makes sense; drop it. + this.simulatedCompletion.set(null); + } else { + // The sim still sits on/after the new start, so it stays valid. Moving + // the start changes the schedule slice, which the sim-watcher effect + // would otherwise treat as an edit and clear — advance its tracker in + // lockstep so the kept sim survives the start change. + this._lastScheduleSlice = this._previewScheduleCfg(); + } + } + this.focusStart.set(dateStr); + } + menuEndsOn(): void { + const d = this.menuDay()?.dateStr; + if (!d) { + return; + } + // Toggle: clicking the day that's already the end clears the end back to Never. + this.onRRuleChange( + this.menuDayIsEnd() + ? setEndInRRule(this.repeatCfg().rrule, this._previewRefDate(), 'NEVER') + : setUntilInRRule(this.repeatCfg().rrule, this._previewRefDate(), d), + ); + } + menuToggleMonthDay(): void { + const d = this.menuDay()?.date; + if (!d) { + return; + } + this.onRRuleChange( + toggleMonthDay(this.repeatCfg().rrule, this._previewRefDate(), d.getDate()), + ); + } + menuSetYearDay(): void { + const d = this.menuDay()?.date; + if (!d) { + return; + } + this.onRRuleChange( + setYearDay( + this.repeatCfg().rrule, + this._previewRefDate(), + d.getMonth() + 1, + d.getDate(), + ), + ); + } + menuSimulate(): void { + const d = this.menuDay()?.dateStr; + if (!d) { + return; + } + const turningOn = d !== this.simulatedCompletion(); + // Defensive guard mirroring the template's `@if`: a sim can only be SET where + // it's offered (on/after the start of a from-completion, non-COUNT schedule); + // turning the active one OFF is always allowed. Simulation is a pure preview — + // it never mutates the persisted schedule type. The cfg here is already + // from-completion (the guard guarantees it), so resultHeatmapData re-anchors + // off the existing flag; there is nothing to flip. + if (turningOn && !this.menuDaySimAllowed()) { + return; + } + this.simulatedCompletion.set(turningOn ? d : null); + } + + // --- weekday-header-menu actions --- + private _menuWeekday(): RRuleWeekday | null { + const i = this.menuWeekdayIdx(); + return i == null ? null : (RRULE_WEEKDAYS[i] ?? null); + } + // The menu's weekday is already in the CURRENT frequency's selected-days set → + // the native item flips to "Remove from selected days" (the switch variants + // always add, since the weekday can't already be selected in another freq). + readonly menuWeekdaySelectedActive = computed(() => { + const wd = this._menuWeekday(); + if (!wd) { + return false; + } + const m = this._previewModel(); + const inSet = + m.freq === 'WEEKLY' || + (m.freq === 'MONTHLY' && m.monthlyMode === 'WEEKDAYS') || + (m.freq === 'YEARLY' && m.yearlyMode === 'WEEKDAYS'); + return inSet && m.byDay.includes(wd); + }); + /** True when the menu's weekday already sits at ordinal `ord` in the nth-weekday + * mode the open sub-menu targets (menuNthFreq) — flips that ordinal to "Remove". */ + menuNthActive(ord: number): boolean { + const wd = this._menuWeekday(); + if (!wd) { + return false; + } + const m = this._previewModel(); + const freq = this.menuNthFreq(); + const inNth = + (freq === 'MONTHLY' && m.freq === 'MONTHLY' && m.monthlyMode === 'NTH_WEEKDAY') || + (freq === 'YEARLY' && m.freq === 'YEARLY' && m.yearlyMode === 'NTH_WEEKDAY'); + return inNth && m.nthDays.some((r) => r.pos === ord && r.days.includes(wd)); + } + menuToggleNth(ordinal: number, freq: 'MONTHLY' | 'YEARLY'): void { + const wd = this._menuWeekday(); + if (!wd) { + return; + } + this.onRRuleChange( + toggleNthDay(this.repeatCfg().rrule, this._previewRefDate(), wd, ordinal, freq), + ); + } + // "Selected days of the week" targets BYDAY in an EXPLICIT frequency — the menu + // offers a weekly / monthly / yearly variant of the same label, each switching + // to that freq (grey hint shown when it differs from the current rule). + menuToggleSelectedDay(freq: 'WEEKLY' | 'MONTHLY' | 'YEARLY'): void { + const wd = this._menuWeekday(); + if (!wd) { + return; + } + this.onRRuleChange( + toggleByDay(this.repeatCfg().rrule, this._previewRefDate(), wd, freq), + ); + } + + // --- month-label-menu action --- + readonly menuMonth = computed(() => this.menuMonthIdx() + 1); // 1..12 + readonly menuMonthName = computed( + () => (this._dateAdapter.getMonthNames('long') as string[])[this.menuMonthIdx()], + ); + readonly menuMonthActive = computed(() => + this._previewModel().byMonth.includes(this.menuMonth()), + ); + menuToggleMonth(): void { + this.onRRuleChange( + toggleByMonth(this.repeatCfg().rrule, this._previewRefDate(), this.menuMonth()), + ); + } + /** Months (0=Jan … 11=Dec) currently limited via BYMONTH — fed to the calendar + * so it can chip the limited month(s). */ + readonly limitedMonths = computed(() => this._previewModel().byMonth.map((m) => m - 1)); + readonly hasMonthLimits = computed(() => this._previewModel().byMonth.length > 0); + /** Short names of the limited months, e.g. "Jan, Jun" — shown in the + * "remove all month limits" item. */ + readonly limitedMonthNames = computed(() => { + const names = this._dateAdapter.getMonthNames('short') as string[]; + return this._previewModel() + .byMonth.map((m) => names[m - 1]) + .join(', '); + }); + menuClearMonthLimits(): void { + this.onRRuleChange(clearMonths(this.repeatCfg().rrule, this._previewRefDate())); + } + + // ---- Ends + Schedule type (dialog-level — apply to presets AND custom, not + // just the builder; extracted from rrule-builder). Ends edits the rrule via + // setEnd; Schedule type is the separate repeatFromCompletionDate flag. ---- + readonly endOptions: readonly SegmentedButtonOption[] = [ + { id: 'NEVER', labelKey: T.F.TASK_REPEAT.F.RRULE_END_NEVER }, + { id: 'UNTIL', labelKey: T.F.TASK_REPEAT.F.RRULE_END_UNTIL }, + { id: 'COUNT', labelKey: T.F.TASK_REPEAT.F.RRULE_END_COUNT }, + ]; + readonly scheduleTypeOptions: readonly SegmentedButtonOption[] = [ + { id: 'START', labelKey: T.F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_START }, + { id: 'COMPLETION', labelKey: T.F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_COMPLETION }, + ]; + readonly endType = computed(() => this._previewModel().endType); // NEVER|COUNT|UNTIL + readonly endCount = computed(() => this._previewModel().count); + readonly endUntil = computed(() => this._previewModel().until); + readonly scheduleSelectedId = computed(() => + this.repeatCfg().repeatFromCompletionDate ? 'COMPLETION' : 'START', + ); + /** A year past the start — the default UNTIL when switching to "On date" with no + * date yet, so the rule gets a real UNTIL (empty wouldn't round-trip → NEVER). */ + private _defaultUntil(): string { + const u = this._previewRefDate(); + const d = new Date(u); + d.setFullYear(d.getFullYear() + 1); + return getDbDateStr(d); + } + onEndTypeChange(id: string | number): void { + const t = String(id) as 'NEVER' | 'COUNT' | 'UNTIL'; + const value = + t === 'COUNT' + ? this.endCount() || 10 + : t === 'UNTIL' + ? this.endUntil() || this._defaultUntil() + : undefined; + this.onRRuleChange( + setEndInRRule(this.repeatCfg().rrule, this._previewRefDate(), t, value), + ); + } + setEndCount(v: string): void { + this.onRRuleChange( + setEndInRRule(this.repeatCfg().rrule, this._previewRefDate(), 'COUNT', v), + ); + } + setEndUntil(v: string): void { + if (!v) { + return; + } + this.onRRuleChange( + setEndInRRule(this.repeatCfg().rrule, this._previewRefDate(), 'UNTIL', v), + ); + } + onScheduleTypeChange(id: string | number): void { + this.onRepeatFromCompletionChange(id === 'COMPLETION'); + } + /** A start moved on/after the end (UNTIL) leaves the series empty — drop the end + * back to "Never" so the rule still fires. Call AFTER the startDate update. */ + private _clearEndIfBeforeStart(startDate: string): void { + const until = this.endUntil(); + if (until && until <= startDate) { + this.onRRuleChange( + setEndInRRule(this.repeatCfg().rrule, this._previewRefDate(), 'NEVER'), + ); + } + } + // Only the schedule-relevant slice of the working cfg, with value equality — + // formly emits a CLONED model on every keystroke in ANY field (title, notes, + // …), so hanging the projection directly off `repeatCfg()` rebuilt the full + // 365-day calendar per character typed. This memo recomputes downstream only + // when a field that actually changes the projection changes. + private readonly _previewScheduleCfg = computed( + () => { + const cfg = this.repeatCfg(); + return { + rrule: cfg.rrule, + startDate: cfg.startDate, + lastTaskCreationDay: cfg.lastTaskCreationDay, + repeatFromCompletionDate: cfg.repeatFromCompletionDate, + // join: the cloned model produces a NEW array reference per keystroke, + // so compare by content. + exdatesKey: (cfg.deletedInstanceDates ?? []).join(','), + }; + }, + { + equal: (a, b) => + a.rrule === b.rrule && + a.startDate === b.startDate && + a.lastTaskCreationDay === b.lastTaskCreationDay && + a.repeatFromCompletionDate === b.repeatFromCompletionDate && + a.exdatesKey === b.exdatesKey, + }, + ); + resultHeatmapData = computed(() => { + const cfg = this._previewScheduleCfg(); + if (!isRRuleValid(cfg.rrule)) { + return null; + } + const rrule = cfg.rrule as string; + // Per-instance overrides (moves / RDATE) are Phase 8; here only EXDATE skips + // (deletedInstanceDates) apply to the projection. + const exdates = cfg.exdatesKey ? cfg.exdatesKey.split(',') : []; + // The window: a look-back of a few months plus a ~365-day forward span, + // shifted by whole years via the ‹ › navigation and padded to full calendar + // months so the month view never shows a half-covered month. The look-back + // is what surfaces the GREEN activity overlay — tracked time lives in the + // PAST, so a purely forward window would never show any of it. Projection + // (blue) still starts at TODAY in the home window (no "projected" marks on + // days already past); shifted windows show the full pattern. + const offset = this.previewYearOffset(); + const today = new Date(); + today.setHours(12, 0, 0, 0); + const anchor = new Date(today); + anchor.setFullYear(anchor.getFullYear() + offset); + const backStart = new Date(anchor); + backStart.setMonth(backStart.getMonth() - this._PREVIEW_PAST_MONTHS); + const anchorEnd = new Date(anchor); + anchorEnd.setDate(anchorEnd.getDate() + this._PREVIEW_HEATMAP_DAYS); + const from = new Date(backStart.getFullYear(), backStart.getMonth(), 1, 12, 0, 0); + const to = new Date(anchorEnd.getFullYear(), anchorEnd.getMonth() + 1, 0, 12, 0, 0); + const occFrom = offset === 0 ? today : from; + // The util only reads repeatFromCompletionDate / lastTaskCreationDay / + // startDate — all carried by the narrow schedule slice. + const baseStart = getEffectiveRepeatStartDate(cfg); + const sim = this.simulatedCompletion(); + // Only a "repeat from completion" schedule re-anchors when you finish a task + // (the completion effect rewrites startDate/lastTaskCreationDay to the + // completion day). A fixed-calendar schedule keeps its dates, so completing a + // day must NOT shift the rest. + const reAnchors = !!cfg.repeatFromCompletionDate; + + let occ: Date[]; + if (sim && reAnchors) { + // Keep the original occurrences up to the completion day, then re-anchor + // the rest of the series to that day (next fires from the completion). + const [y, m, d] = sim.split('-').map(Number); + const simDay = new Date(y, m - 1, d, 12, 0, 0); + const beforeEnd = new Date(simDay); + beforeEnd.setDate(beforeEnd.getDate() - 1); + const afterFrom = new Date(simDay); + afterFrom.setDate(afterFrom.getDate() + 1); + const before = getRRuleOccurrencesInRange( + { rrule, startDate: baseStart, exdates }, + occFrom, + beforeEnd, + ); + const after = getRRuleOccurrencesInRange( + { rrule, startDate: sim, exdates }, + afterFrom, + to, + ); + occ = [...before, ...after]; + } else { + // No re-anchor: calendar stays fixed (no simulation, or a non + // from-completion schedule where finishing a task never shifts dates). + occ = getRRuleOccurrencesInRange( + { rrule, startDate: baseStart, exdates }, + occFrom, + to, + ); + } + // An empty window — home included — still renders: a valid rule can have + // no occurrence in the next 365 days (multi-year intervals, a far-future + // start), and returning null would also drop the ‹ › nav, stranding the + // user with no way to reach the window where it DOES fire. The template + // shows an explanatory hint instead of a bare grid (previewWindowEmpty). + const dayMap = buildProjectionDayMap(occ, from, to); + if (sim) { + const day = dayMap.get(sim); + if (day) { + day.isProjected = false; + day.isCompleted = true; + day.level = 4; + } + } + // --- preview-only flourishes (the Activity heatmap never sets these) --- + // Occurrence order within the window → the tooltip's "occurrence #N" label. + occ.forEach((d, i) => { + const day = dayMap.get(getDbDateStr(d)); + if (day) { + day.occurrenceIndex = i; + } + }); + // Today's ring — only when today falls inside the rendered window. + const todayDay = dayMap.get(getDbDateStr(new Date())); + if (todayDay) { + todayDay.isToday = true; + } + // Spotlight the next upcoming occurrence — only in the HOME window, where + // occ[0] is the true next from today. Derived from the occurrence set this + // computed already built (NOT from rrulePreview/repeatCfg), so an unrelated + // field keystroke never rebuilds the projection. + if (offset === 0 && occ.length) { + const nextDay = dayMap.get(getDbDateStr(occ[0])); + if (nextDay?.isProjected) { + nextDay.isNext = true; + } + } + // Mark the start / anchor day — the click-to-set target (Option A). baseStart + // is the effective anchor the projection above is computed from. + const startDay = dayMap.get(baseStart); + if (startDay) { + startDay.isStart = true; + } + // Mark the end (UNTIL) day specially when the rule has one and it falls in + // the window — so "Ends on this date" reads as a distinct boundary marker. + const until = safeParseRRuleOptions(rrule)?.until; + if (until instanceof Date) { + const endDay = dayMap.get(getDbDateStr(until)); + if (endDay) { + endDay.isEnd = true; + } + } + // Activity overlay (GREEN) — merge the saved series' tracked time into the + // same window when enabled. Levels are relative to the busiest tracked day in + // view, mirroring the standalone Activity heatmap this replaces. + if (this.showActivity()) { + const act = this.activityByDay(); + let maxMs = 0; + act.forEach((ms, d) => { + if (dayMap.has(d)) { + maxMs = Math.max(maxMs, ms); + } + }); + if (maxMs > 0) { + act.forEach((ms, d) => { + const day = dayMap.get(d); + if (day) { + day.timeSpent = ms; + const ratio = ms / maxMs; + day.activityLevel = ratio > 0.75 ? 4 : ratio > 0.5 ? 3 : ratio > 0.25 ? 2 : 1; + } + }); + } + } + const firstDay = this._dateAdapter.getFirstDayOfWeek(); + const monthNames = this._dateAdapter.getMonthNames('short'); + return { + ...buildHeatmapWeeks(dayMap, from, to, firstDay, monthNames), + months: buildHeatmapMonths( + dayMap, + from, + to, + firstDay, + monthNames, + heatmapOccurrenceTotal, + ), + dayMap, + rangeStart: from, + rangeEnd: to, + }; + }); + + // The true next upcoming occurrence (from now, independent of the viewed year) + // — drives the "Next: … in N days" chip and the spotlight in resultHeatmapData. + readonly nextOccurrence = computed<{ + dateStr: string; + date: Date; + daysAway: number; + } | null>(() => { + const next = this.rrulePreview()?.upcoming?.[0]; + if (!next) { + return null; + } + const today = new Date(); + today.setHours(0, 0, 0, 0); + const d = new Date(next); + d.setHours(0, 0, 0, 0); + const daysAway = Math.round((d.getTime() - today.getTime()) / 86_400_000); + return { dateStr: getDbDateStr(next), date: next, daysAway }; + }); + + // The chosen start broken into editable Month / Day / Year parts. The dedicated + // start-date form field is hidden in RRULE mode (the calendar below IS the + // picker — Option A); these three fields, shown above the calendar, are the + // precise alternative to clicking a day. Both write the same + // `repeatCfg().startDate`, so the calendar and the fields stay in sync. + // Set to the new start date whenever the user DELIBERATELY picks one (the M/D/Y + // fields or the "Set start" menu) so the calendar jumps/scrolls to it. A plain + // mirror of startDate would also fire on unrelated rebuilds and on open; this + // only changes on an explicit pick, leaving the default view alone otherwise. + readonly focusStart = signal(null); + readonly startParts = computed<{ y: number; m: number; d: number } | null>(() => { + const sd = this.repeatCfg().startDate as string | Date | undefined; + if (!sd) { + return null; + } + if (sd instanceof Date) { + return { y: sd.getFullYear(), m: sd.getMonth() + 1, d: sd.getDate() }; + } + const [y, m, d] = sd.split('-').map(Number); + if (!y || !m || !d) { + return null; + } + return { y, m, d }; + }); + // Localized month names for the month had) --- + private readonly _optButtons = viewChildren>('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(() => { + 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(''); + 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 + // - -
- - - @if (model().freq === 'WEEKLY') { -
- -
- @for (w of weekdays; track w.value) { - - } + − + + +
-
- } - - - @if (model().freq === 'MONTHLY') { -
- {{ T.F.TASK_REPEAT.F.RRULE_MONTHLY_MODE | translate }}
- - @if (model().monthlyMode === 'DAY_OF_MONTH') { -
- -
- @for (d of dayGrid; track d) { - - } - @for (nd of negativeDays; track nd.value) { - - } -
- -
- {{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_DESCRIPTION | translate }} -
-
- } - - @if (model().monthlyMode === 'WEEKDAYS') { - - } - } - - - @if (model().freq === 'YEARLY') { -
- {{ T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE | translate }} - -
-
- {{ T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE_DESCRIPTION | translate }} -
- @if (model().yearlyMode === 'DAY_OF_MONTH') { -
- -
- @for (d of dayGrid; track d) { - - } - @for (nd of negativeDays; track nd.value) { - - } -
- -
- } - @if (model().yearlyMode === 'WEEKDAYS') { - - } - } - - - -
-
- - @for (o of ordinalOpts; track o.value) { - - } - -
- @if (isSetPosCustom()) { - - } -
- @for (w of weekdays; track w.value) { - - } -
-
-
- {{ T.F.TASK_REPEAT.F.RRULE_BYSETPOS_DESCRIPTION | translate }} -
-
- - - @if (isNthMode()) { -
- - @for (nd of model().nthDays; track $index; let rowIdx = $index) { -
- - @if (isNthRowCustom(rowIdx)) { - - } -
- @for (w of weekdays; track w.value) { - - } -
- @if (model().nthDays.length > 1) { - - } -
- } - @if (canAddNthDay()) { - - } -
- {{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY_DESCRIPTION | translate }} -
-
- } - - -
- -
- @for (m of months; track m.value) { - - } -
-
- {{ T.F.TASK_REPEAT.F.RRULE_BYMONTH_DESCRIPTION | translate }} -
- -
- {{ T.F.TASK_REPEAT.F.RRULE_END | translate }} - - @if (model().endType === 'COUNT') { - - } - @if (model().endType === 'UNTIL') { - - } -
- - -
- -
- - -
-
- {{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESCRIPTION | translate }} -
-
- - + +
+ + + + @if (model().freq === 'WEEKLY') { +
+ +
+ @for (w of weekdays; track w.value) { + + } +
+
+ } + + + @if (model().freq === 'MONTHLY') { +
+ {{ + T.F.TASK_REPEAT.F.RRULE_MONTHLY_MODE | translate + }} + +
+ + @if (model().monthlyMode === 'DAY_OF_MONTH') { +
+
+ @for (d of dayGrid; track d) { + + } +
+
+ @for (nd of negativeDays; track nd.value) { + + } +
+ + @if (showCustomDays()) { + +
+ {{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_DESCRIPTION | translate }} +
+ } +
+ } + @if (model().monthlyMode === 'WEEKDAYS') { + + } + } + + + @if (model().freq === 'YEARLY') { +
+ {{ + T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE | translate + }} + +
+ @if (model().yearlyMode === 'DAY_OF_MONTH') { +
+
+ @for (d of dayGrid; track d) { + + } +
+
+ @for (nd of negativeDays; track nd.value) { + + } +
+ + @if (showCustomDays()) { + + } +
+ } + @if (model().yearlyMode === 'WEEKDAYS') { + + } + } + + + +
+
+ + @for (o of ordinalOpts; track o.value) { + + } + +
+ @if (isSetPosCustom()) { + + } +
+ @for (w of weekdays; track w.value) { + + } +
+
+ {{ T.F.TASK_REPEAT.F.RRULE_BYSETPOS_DESCRIPTION | translate }} +
+
+
+ + + @if (isNthMode()) { +
+ + @for (nd of model().nthDays; track $index; let rowIdx = $index) { +
+ + @if (isNthRowCustom(rowIdx)) { + + } +
+ @for (w of weekdays; track w.value) { + + } +
+ @if (model().nthDays.length > 1) { + + } +
+ } + @if (canAddNthDay()) { + + } +
+ {{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY_DESCRIPTION | translate }} +
+
+ } + +
+ + + +
+ @if (engineOff()) { +
+ {{ T.F.TASK_REPEAT.F.RRULE_ENGINE_OFF_ADVANCED | translate }} +
+ } + +
+ +
+ @for (m of months; track m.value) { + + } +
+
+ {{ T.F.TASK_REPEAT.F.RRULE_BYMONTH_DESCRIPTION | translate }} +
+
+
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)); + } } diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.spec.ts index a8c3ac1885..3c3f1a1127 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.spec.ts @@ -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; let component: RruleBuilderComponent; - const setup = async ( - rrule = '', - startDate = '2024-06-03', - repeatFromCompletion = false, - ): Promise => { + const setup = async (rrule = '', startDate = '2024-06-03'): Promise => { 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 frequency’s 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 mode’s 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); + } }); }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.ts index ff62cb9ee9..9767688433 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.ts @@ -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(''); startDate = input(undefined); - repeatFromCompletion = input(false); rruleChange = output(); - repeatFromCompletionChange = output(); private _model = signal(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 { + 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[] = [ - { 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[]>(() => { + 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[] = [ { 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[] = [ - { 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[] = 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): 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 { + 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 = { 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 = { 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); - } } diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts index 20288dea3e..26627372a1 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts @@ -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 => { - let patched: Record = {}; - const field = { - model: { startDate }, - form: { - patchValue: (v: Record) => { - 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. }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts index 2d7a0d184c..1d13210b97 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts @@ -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, -): 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', diff --git a/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.html b/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.html index 75f3afcb94..af8441d6e1 100644 --- a/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.html +++ b/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.html @@ -15,10 +15,17 @@
} @if (heatmapData(); as data) { - } diff --git a/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.scss b/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.scss index 055eacac96..9a4ccc7580 100644 --- a/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.scss +++ b/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.scss @@ -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); } diff --git a/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.spec.ts b/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.spec.ts new file mode 100644 index 0000000000..51acf07c8d --- /dev/null +++ b/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.spec.ts @@ -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 = {}, +): Promise> => { + 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: '
' }, + }) + .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); + 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); + 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); + 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); + 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); + 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); + 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); + 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); + } + }); +}); diff --git a/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.ts b/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.ts index 55467fead5..7765802c28 100644 --- a/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.ts +++ b/src/app/features/task-repeat-cfg/repeat-task-heatmap/repeat-task-heatmap.component.ts @@ -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(); @@ -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(null); + readonly selectedYear = computed(() => { + 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(() => { + const years = new Set(); + 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(() => { + readonly heatmapData = computed(() => { 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 { @@ -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; startDate: Date; endDate: Date; @@ -124,12 +257,14 @@ export class RepeatTaskHeatmapComponent { } { const dayMap = new Map(); 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, - 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 { + 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(); + 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; } } diff --git a/src/app/features/task-repeat-cfg/store/get-effective-repeat-start-date.util.ts b/src/app/features/task-repeat-cfg/store/get-effective-repeat-start-date.util.ts index 0a8b77ce99..0053125d37 100644 --- a/src/app/features/task-repeat-cfg/store/get-effective-repeat-start-date.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-effective-repeat-start-date.util.ts @@ -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; } diff --git a/src/app/features/task-repeat-cfg/store/rrule-occurrence.complex.spec.ts b/src/app/features/task-repeat-cfg/store/rrule-occurrence.complex.spec.ts index 63ec68779b..9e7299a10c 100644 --- a/src/app/features/task-repeat-cfg/store/rrule-occurrence.complex.spec.ts +++ b/src/app/features/task-repeat-cfg/store/rrule-occurrence.complex.spec.ts @@ -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 diff --git a/src/app/features/task-repeat-cfg/store/rrule-occurrence.invariants.spec.ts b/src/app/features/task-repeat-cfg/store/rrule-occurrence.invariants.spec.ts index 0a16852671..cae9b83010 100644 --- a/src/app/features/task-repeat-cfg/store/rrule-occurrence.invariants.spec.ts +++ b/src/app/features/task-repeat-cfg/store/rrule-occurrence.invariants.spec.ts @@ -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 7–11s 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 + // (7–11s) 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); diff --git a/src/app/features/task-repeat-cfg/store/rrule-occurrence.util.ts b/src/app/features/task-repeat-cfg/store/rrule-occurrence.util.ts index 0f24cc50427c44db97c19771a5208355c3662a43..fb23131a8a8e96ddba089bbc1cb8e61ee6764192 100644 GIT binary patch literal 27694 zcmds=>v9`Omf!tZPmwK;S70{*UUX|oO?6NrwJkSA$|Ti2T3RNGK#{}BY56KPvYPIoXn~Mh`#J@IKp8@w?v?_@^bQ=|G8$WH@GN9lkhRT z80CLI&4-=q<*>g$?dS2YgM1P`h0{?n2zN#!zQ6P2RxiD?Q=F9#TdfuUuTzZjm40zn zYj`V!7yH})>G*K(`1#xCJKM{AVS~KRM&rD@cQNS|!?C^{XQ#P+X^+bXX$Fiio%H%E zHk0Os-frm&#=5og<(Id@mtiZrZk~;@;k2KPmc!P;VN-pEqSKjvC zewB~)%V}?9e_v+(Ubi>7UJ8@*Y{DP8y6M(t6h?VDk3rTsXCkBPFe;`KhM5%M>O3Fj zm-#4Up>ui`I@xH{<4HDDM{QenJ5F;s^oHT0pRuCiRAYsHewKCUEx*XK$@A+@Kc|C! z2TF*(X;p_;MYtFh7x`$Su5G4+?0wE}y>TMWs5qI9C(Gfan4E_%q06*0nU1plm*G4g z<&PtQ!dk1O@Z`D`Hv9eA1@iJ}Ivm?fr=vW)Iefm%8hf4daGs6Bu+ZzRaOi{z{EYH( z(Z9^Qnt49#W+Ra=FtK9T*V3B#hYQ-kH316m>^|yJitx(Mvy#_N3S; z=xmop?WI<*5B>_h_J)_)sFw{VVLI$WcBT^=Z1Zs3GoECc7ZgPZG z_tm@ny|f(0MQESpldY4jY?2>LMvbL*+6`to?N2oINpFz^|>UN^K^=B2tj$vfF}oQK{djNkW|4UJgn_(Htegp04TQMuefuRH9Woll;K zq{r5wf$a zHv}Uxu;K&5>rM4H243Ii`9;V&o!kf3=617578-GHo1J2Cfe6Do%dn{}IurPW_MPYZ zd%JhqfpMn!lGeI%>lOqSr1yrCd~~YacPuRd@a6D=-TDaH0-5s=cv5uH0D0@I723B2 z+8aM^Z-2k@_Q|uiTbpk;UhUm(gMVA&ATVX#R6hb>wu-f^Wo`N^x!1)L3 zt{m*>-IH6NZrws7F=4jl_|-w!2wy)qzPGx1yt=yjBtCkvxwG?Z^ZED3JDUfG$Ith- zHxFO#?b7tl+M|c7&DAyjtEo=(9jm|U4ZFpasmFG2Y`pV>^cwqe$OP9`o7Xw846qDC z`{?-f{@%0gw;Mq_l3%lDtZ0|SeLQ-lL37cDtCrQ$c_gcT>%WOC-0L@8(c)2jA*RHbu z`@kyt*N;u*N)w4+E;2NWWY>iK*<0H8VFC0665Zps4D^s{>Yrqt_YokNNebejK&(kp^E5j~?7zeP>E9jnl-W8%}zBwDkgIN1EU=)3m0-^KmwiIh9@b z(AX^W41=)EFAw(YQ}h1a^iY#ZP^CUEux-%to9KctCA$HLL4C3LsMq#Y{Cg zGjTWQj;h#4mznxz(qZU^;2di)DUYScxGh)-8&47MEBn3C{>{hO>eJzxtWN&>yY`Yh zI+|oN*?K0`KRa8SOC&*h>t8n$o5svcHf_3h#?cl}Qs!{l8xG+UHdv-;8kyzeez;i- zEKGiHWX4+o46I!?Id?-MPPs4p9c8;YPLp2SojTHtrm+lVzJOeUCq#9##>p& znx4q}b7ZgCs}PdDN)J|sHP)H{HEf$*$6$@C9*$Kz@>6?BUWhA*{IUoC$Rm!RgDNWR zKB*hmjk%HnuKCo-a%9fmBwQmORTGD9l6KLQj8XwXjOVf zMu+>`kxKJgCdY3#`IkZnjkS9ZRv$fty-fRr#`@~l57+oO5To(@-q-RJMReB}e8U&k zfqTOJ9K3BGn;G$fK+AzlV!Zqc<4y6O$ecB0djYOGOu8M10UqEeByt1KSPGK z9dUODyg-GS1&mEw?NPKxT}1+T5+4vvIyIR{T^#vtvagLFPVzpMb~0EI{bT}~5$7(j zo4&TICWMvZtnb{5m;2l7o7v*ew_olYmzLzcN68zQAaa=fy|k)Bx;>ucFlSo(z2NHj~Sb8&ZY!B0+a8&H=~eo0IHEq?Dgg$F5|8|3{ig0 z>#me_GRbTf&FpN51JUcam?{b|EDgG7IB5#z9VIU8BHC_LIEZ$K{Hh*-hII4D|-qwK1Qn@B9MYv|oJ$K2%XNfPRf zhtWmQ62G1&@0mfz1cFHz!+@6k9l{Pk7?wldZj@E!IBt{ zlyi|{ZrW2HL|%}3l-)F*0+WtCC5uNB*UUbn&*%U8kLItO;&e(Ivdg9wqxLZkr4FY< zlLPq3J&btN8AboOETef-5cH|7U!$jgP#Es&XYyDmvSurYIMiO$sDGug~6 zl|G#x2)+_-P}DWx*`*!-{15+QErn6N8>k7z6BPLpq9_=TX2Zw~K`jaD8aF9Qs=159 z;MM*>8$rwo1qRv1%p32Ri1!GGs~Ei}LUJuH5WZFX-E8psv=A8eUO}T%+qPSbp>U;^ zcGG=JYTDKr!kn_k3xMgyUhTOjIoTnwX}6Q8mU;EYoVxdu!eZF9UThIjB~04`TJ z#;6{4Fo&uFCT2|9k zKVS(-kcA~g^hpmG3#r|6(2wmQxOPcQF9m;3vpzxPyiWWAlunN5!c-5h=AlvGN49*xwfbrTN^RSJ`s%>Pj`KN-qjWPSCT1WeU)aMtdf~vCn{>*dqG`_0Gl{ZDP#DYdq;ffgxwS=o)$8(KYt z-f8eJy5WnB4aCp%Yh$JtX)qKJ#)5oPrJlSWGdwrKSA(N9vr^Od>R)}o!gp?+5^5ba z8pn%`!e)#%t-y(KQgH}zg?IFHBdnzbkS16OuNBg22DuXnGpjh7vvdWPFt*}n<^Zi= zMl9QiwhHUaRDkFD5Na|^eB$uy#YX0Wq;As6_ z>muvg^9By>oz*)_{^0I{2PX2Ga*{}csx;mNK>Zk(lqPD*!>xQ1(MHuEgjTCn$j3@Q z-HAycE|bTqP?*G=ZEmuS%bzI$)V>_6e!u>82g3y`efH~5k-PLVob;nYepR>Vi_+Q9l}C=5$av``!_lpKKxY$j87aUNP4iA{@8TWIFZq%$+)av67|teR`<&GoPEpj~D8 zEQiKY!rQd2L{vRbG}W`{2hPwM53yX>Cxn79PV!9UK}_qe>U!`|^j- zV4*e{_kN#;r}*zgF%~vZD35@fD7f%BiLFu{5xSOcvBrta`l52V2|=L#zlxruT(g+V z9YP=S5-i07VxCh|{2%W_Jg6mRB>M0NonVyXLyHGJwj)9-%lj7O zRV|a%LgDmSM(`F?k*nIB<}xmto%3vXMko#s-}A_}Ln>GTG)&P%x5wQvJXs2Q#wtU= zf5mm8J3#?5V#QzP;R{IRg7pT2snzTloGOot2d(T=ByyS_E0N6QgvOmej*V87g8kYk z8W>HanMx#M>g;%Dd-HYb@c|t@ zQa|hZ<39bYeXY*c)Y(0{SbwN~R`07v{qf*ITHLD`lwZRbz?zKk2{{epST9^T3}|hX z#}Fa$WGwpZ!ffBPY;$V$3h{jjktxp%2{L^>R(L}`xcc)N5_WJz*z%eU+nY|vo_p?9 z1h&2!->{UYOq9Ioh{1qV^_z8nM*&Yv5JUT|ytDEjnV`aB$TZO~Bt>}#E5}f{A+v~- zn3)h0T%EafnkHD^DBIBS-lAYSHt|#sO}?-`+KnNr`SKx4xJR(?s@s5J?or_3NyDpd`26QThoiOShwmt; z6O+~W=b?$bkDXa};`lh6O(rq8LX=caqG%M#n?OCR^#A=oA2?=+Ke0d4#GJ?5M`>!! z04Aglkoo}?8WvbJ5C*u%Q;-g|sI529H&IGdwTX$GZ25#(IZj_{&*sj=kg#ct?kXPL z+}e`7dsx_a(#h_2m4H=JYfvw`)WUhE{p>7i5zG=mQCP3ZnSEcP2zyiof_#L%Q|;QW zLM08&f48U!SB>$LeEk~n@HQuSKVg8*<$h>s;GZze>-*GnH9E}`TF4LmThE}-_h!V}|KUATX zS-fE(mx1V%newu&h{{-xt)BPXxLhKLbII37m&l}PuS0wsc9HG2SRr*^FmOJ&n50;` z;?vUS}#l|V{$7d4yw_k05}Fg8||Zhv3%Z>uw3+~Bx;YE=e^}-zkp|Lh&Do8 zT!$7F1Z=toJxbwWX6#noBGP#ka3a{A%2=2er zo((Yei-xAn#!#Zs7#h}p5l81E4I@^I^lvDi9=W!LNy0hV*MX8ERoHkOp1nQTK774* zV8C(Fqs-3&M|d=QyW7#d)<&zp6K#f=P4XkmP+~6CJoY9)H3q0BNnHagBPAf#F3w>` zO7B=&?=gy75U=<;I6pk&y=sz#>E!w*-BL|i0ZHMV@r&_VV1a}taEjmT?rb063R1_X zo}axn`xgV|+1u~--t4b@{?k7%$v8H<*VB)z2tSS@a5hRIP29IHmFn>vzIX#IAYPvh z&f^tgTvtg=+1Xl&l@KS-1#2FwGR|?F_em{8#i`lZS_Q|HFKf#Loh2~yMbmVFGz9&| zs&H*U?9WHCa8zipHES-a_BFM1V^R*`M-|aLduvv+4CWO+C~Ka6*CKcKVuzME8cvq0 zOy-0rBI=hh2j&O;_BFxL?fnDW-dKljlQaXdKIPb@*OgOo>Y z$^VQQ^)mq@?TBqiteIHx>Ik{K)bYs}RU~PkEG*WfZqJ@{(6P6DvBm^twquZeK#9FB#s~d^VL=`6#oRm#+oYpCC9o&ZuUV4@1W8#miq^?B z(lJ_!_yQW-Y(R#R12aF{#7k9E6S_#6?uK{j5eX0cqi!~;lTcpCQX-rs6NG;Y+qz7o zQE)1^@J+>_E>bEYj;CylqbmlsgqzR7Bn{+L?H|kO5 zPQuVqWeFD)ZSmFFFGS;_N;WE+#zxi>jS{8jn#qRy;O#>71>w5jn&%%F^&8)oX|7nF0xJLz z)#%NWYsC;$mkuW2KB&+6!t=5u-OmgK3mdAB(vJC2Ln`2{!Iez`lzJWYb;){la-)HQ zlb35|3M?JXo8TvQBI<6e-EDJFiPMD9C~wCH?eN#X*vu+;ZiCM}jsP7$IYhT1+u)=i zb*tDurCi z*UMMtS}Bsy;<%PpdZ-E( z86}Y9_2J7`FaOStlqewNYK){M=xoPhESN`Ka!#h(#<4QS-cWOyA+`0Vf+9#*R$Lgk zVb!UG3=Az`u*xG7H<~1=qDK9l=Tjh1ZCqh>i+rr2hk(2O(5y#7!^y@d?Rt+VoE{)XWIq^%rdk-AfLN0 zIM_|~eg>Hu^Sa7Y59;v>aXfEF77w|i>HG|ixSD(smQ7bt+}>o? zK&jLB=ggBiH^;~VeO@7j=e&1QbD9S-(`*ZvS=3V6Q)(zRjxP0l+oWjgO{D{O6+4(2 zU*LtZRYRF0rs0lf+`mSgIgwF?N##@hOe-dC4HX|&j_{|O$Ekbk2M>O!xli9(-zPTv zbA8V&5Ftb-UQQ9(KwiwiT_F>SBn3@aXO|fWRR>_0VEc% zb3YE{kfYdk)X|bL-koOP;$|_3n$VbCV;(FGP-Qak@H@R)ONV~VrS!Tk;hiQIJrYB8 zb3vKJ7-0?>P~=#g?7&D3ARzJ})aiANwPmtLULj8)TKw?KZ#fV7Oa6QLjywB=5N1XqwnH$68C6Tu z^v1;?ZxF`v$$hinl{h7PEluy2-|{)xOL2PsPMTaAi7k6sSvqEXD}9A=0nzbRltzvR zZ==@USf`@bR2DAj#$681y-*lZo9!%M;U^rphV7@Pjfhg26kO)=@|RF-y%BlAi4J}l zTBP0f@Qsx(`EDk2mx#+3B@L6nId%~$c};&Wq$oGURf&rtb&YcvM?aBA=IIWU*^*M8 z>S&4MD({5(OE!Dj)E6$=`~nEVWx$4+M_Vx8F0y4=86R1HD> zksDtd_wUwr!Gj8zPHh7@y7pz+wa5vW(7LrWy?8l?28fOP4|z8k}6)x=nQ{=uP0oGk`XfDKO9Xc&f^~ zG!qlM(WKx4##IbBR18-;_malmSQk`#SLG!YqA=1|wz*H*U155{`Y+AyQzCYJUf{H1 z}LZ(5?5_9j<3+~+OwAC^(_q#Wm>mJ?Be1@f+ z=qiLMjV8@bZ`7Ge$BKMyBdV9}m9A8DqvERKC-2c=t+f@hgjm&SZcNl^^uHoo#bl3{Vfyl~;7sE-5j9^kZF+ z%XSyU;k<(4^LQtI2TtTx?r5>wiPY_ES(7b>u}7shuDh5xSgCUroVZdoAKHMFx{K3o zbK$`q5-8*RwkL?Va_J>;4P8~D`fgn_5F;UE;TKi-S;<;0s+bZ#3R`Fq`q4E=s_jx< zK)jQ#$ik^)HoEEpKWD-9QnmXex;b}7sDdi>>7J2%at~o}8^BPPs(3KKuVWF7bVCd0 zXP^WY5mKoWi1^$wpyG>Wiu0iokIlJ^lp)BPZcqSKgn_4H_>SOM$1^5cvX)uF$yZ60 zc?3n5RH*<{p)qjOWB6m#jkra@ps@~avO-Ieq!qKjUgE*3Op=Kh>Mj+km^GRwRNSdE zrfbLAWv+|@j(92Ft&;kSH?bJ`h{FH$@gF;VG#?`)GHc6c=+t2qCHqzJu>@K((J>uT z6aA74EGl@~L3EL6u)B8%2m7T}T6bKAZK&^;6__?)>^O~blS|}P3&de@&^jHH-B3+x z8Lj3*5i%7@?h*M+ldQ(am|*{@>X^Uj1$p*6URVevfP|+!m0M9fb*6$v2?GeO#BY6# zzPIe^=I-7+yC|e&#leNCYH>zyrK&b+n4@ z{*m+1ewj+@teUYM2bXYxRb{Lcd{9wZrJh^iw!)4C7f7ZcC8V6~i1O{!p>Zhz)E<@_ zg}CBi5)l()EE$#-_o(5d3k_oePSr3qoO!y&sSbrF4A~SjwuGic^g?&*QRig9s>{dh zrl%FZ_sQ&BHpgy1OOa2bxt+WtVk(Q(jv80t8e%tTA*d2{N-sw&br~i)!0Bu=P8zsV^eI9_y}=rA_#eAe zc$irOvza_`)H9(+wC=@s!0xk&Nq1 zvrh+b((r6|$vrEk{gFmM)cvGV=`trJp!C1=(VW7XIW0G<%j)OkW}o2Yx}IHj3YfC1 zhbw1%+Fc#-so7gnzP0-++p1sGL8>-rfqKzxYAb#$pH^$Pw^#rY#~owVr=g=t z3FxM;gEoFY7M=s+V&oB+vP5%^z^bRlzkm4X{u*)CNg+>B3v#2-s+pXBYYUcbX3o2e zUz)_|?E<@tL;KoTE2i5kFY&@6Qo`2`aVPIR46klnWeL<7byz|;9CKBKFOAst!p6-PcB+MVa zt86r}VY+x#1K)gW0i(*?Tzm;Cvt%+z_%@C;b~&C%zjtOg#*l4u^R^~KiJ&e)i8C!_ z>|B{6JL=BlOPgHEm@vo0%nEuN_#J)A&B=QrVGEye<%lYm)Ub9FoSnp_$CpaK*pV;Y z@#RN5wJS_TaXm3zIdu&jFW0lq{GM8giy1#U5UrHV=>8IDvbTpTG}X9b<8| zwJ^Y2qKJUVQ6=Qj;-EXbaS(9SyEYrYKgG74NsxpuH@|^xf}dpG{5&(=af^};liYYe zc4mz!!G*?eNj{kiWI}-BYN`z>*{8N0I(x!FZWW)}c9%H_b@MIAxRY*RZ}82MI0+?e zG>?UZ)tT4wz-c>OGyY)$JGtUTOEqxx{{_cR{T~H*rPE1x!zzo5ArZ+9G8&E2wxKuO zmtxzFTa4OtUq0zYfxc~VXfHqTRW1H%5r6(F7jl6h48XamV#HKj6leSi6nS}2t; z6NWV9BC$2o#X{d)uj&0b+E#+?GqH2=+U-VKTTQM@44)Nl5#F_)z9DiJ|Lq>tK`Jko?w~TEmH$}gFxp2lS_voU ze5Pgm%y#yhJzWmxoz@mN&TsLrF(B!{>3LxeC12X1Q6(EFg(?5-lNd+j-x(U=9Hwx- zE~#jW?+MdqlRn7>)BjS5d)=S;n-0#@9Q zHtOiob5(8I+xE{GNo*8}uz$qH?!zEEz{!m9Mc%Q?-fs%eoZO6GR!22~wx1E93MeFbXwKf) zt%RC)Sml&u@?x9$Zktb{yEbRChjwR;{>2_?PG5-G-ZxDJF`JpZ-pY^ac^uBv)eXyH zsV04;dex#UWQf{A#&-&&+2|A&=p7%Q6joY^pQH_ung${g^jbl)E?zr7O6uY`O`5g`gpw#FgdR{bUO#&$*_|;n zvvy1+3=#(Xzuzx;_CaYX{XTMe z(&p*+VfoK4TtAYA2d)c&EmVG;D_368Sep8SnnW#T3@B=&A-G2Hu+S&~gZ-P|PA~dDuhPM!6DNN|LtbtLb>|6>)2q;Md(A3&6Wy;jJ zzy?c$94S114k}r)h-t!&MQxV_kGM8E4_Z=$TMo3j31MyH7OX#!fF=amA*KN7L*8`7 zvV?(*s}h0`gF|;H3UDK04FWFe*zeco?Sm^F`|GN2KUtsbJ#*C`>fU4orw&;YfzcpU z*}^xnRL3P-EJ}zdflP#XJexyuhMFecdMUXh6b%OmHt7t7AuQb_qL15gI@hpH;h{E5 z!C@KdgBFPyC}3j~(vsljG%_KsFn7X9ArAAjJ=<8Hw$;_~vwKXhDaAUTEL1y#_1f<0 zB+cl1Pkma@GDgoM@>Eu!2Foy;vp=lgtz-nEMeY(AHl!x@JQ3gLNTJRn;-lw@G=YE> zN>H{RuTR;|wKFqM4aq7F0@8|KT)W@&-Jqk&8e z$qp6KD0PmO?{@_-Se}WV!#dk~|EoBz2M;5W?ZW=NRZ_qPR>bl}eCoCjDeQAt3C} zyUQ=k%+Jox`m=NNGn2~?+X776g?G>1=%c+C^P1p|(CnDNqq}pLkMCx-mn-+vW)!bD zgyCET+POPC1l9xvePM3#Soc^S(7SH$pg$v%Q~U& UoZszMqvELTJvPyM#LFlC1rL66x&QzG diff --git a/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts b/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts index 5ff69b2bfe..1db90a78b7 100644 --- a/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts +++ b/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts @@ -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; diff --git a/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.spec.ts b/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.spec.ts index a7f15395f1..52b36b2aae 100644 --- a/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.spec.ts +++ b/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.spec.ts @@ -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', diff --git a/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts b/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts index 81dd1c25a7..5c036bb659 100644 --- a/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts +++ b/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts @@ -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> = { + 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 | 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 = { + 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)[field] = false; + }); + days.forEach((w) => { + const field = RRULE_IDX_TO_FIELD[w.weekday]; + if (field) (out as Record)[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)[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=,-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=,-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=,-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 = { - 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)[field] = false; - }); - if (weekdays.length) { - weekdays.forEach((w) => { - const field = RRULE_IDX_TO_FIELD[w.weekday]; - if (field) (out as Record)[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)[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=,-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'); diff --git a/src/app/features/task-repeat-cfg/util/rrule-calendar-ops.util.spec.ts b/src/app/features/task-repeat-cfg/util/rrule-calendar-ops.util.spec.ts new file mode 100644 index 0000000000..2f41fc8812 --- /dev/null +++ b/src/app/features/task-repeat-cfg/util/rrule-calendar-ops.util.spec.ts @@ -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 + }); + }); +}); diff --git a/src/app/features/task-repeat-cfg/util/rrule-calendar-ops.util.ts b/src/app/features/task-repeat-cfg/util/rrule-calendar-ops.util.ts new file mode 100644 index 0000000000..48372fdf0d --- /dev/null +++ b/src/app/features/task-repeat-cfg/util/rrule-calendar-ops.util.ts @@ -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 => { + const map = new Map(); + 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; +}; diff --git a/src/app/features/task-repeat-cfg/util/rrule-parse.util.ts b/src/app/features/task-repeat-cfg/util/rrule-parse.util.ts index 06266b97cb..6699965234 100644 --- a/src/app/features/task-repeat-cfg/util/rrule-parse.util.ts +++ b/src/app/features/task-repeat-cfg/util/rrule-parse.util.ts @@ -12,17 +12,30 @@ export type RRuleParsedOptions = Partial> & 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(); + /** 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 — diff --git a/src/app/features/tasks/add-task-bar/add-task-bar-actions/add-task-bar-actions.component.ts b/src/app/features/tasks/add-task-bar/add-task-bar-actions/add-task-bar-actions.component.ts index 6065e47e7f..de6051fa29 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar-actions/add-task-bar-actions.component.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar-actions/add-task-bar-actions.component.ts @@ -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(), ); }); diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 6901905aa3..63759e0a20 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/app/ui/heatmap/build-heatmap-data.util.spec.ts b/src/app/ui/heatmap/build-heatmap-data.util.spec.ts new file mode 100644 index 0000000000..bc1c00d037 --- /dev/null +++ b/src/app/ui/heatmap/build-heatmap-data.util.spec.ts @@ -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(''); + }); +}); diff --git a/src/app/ui/heatmap/build-heatmap-data.util.ts b/src/app/ui/heatmap/build-heatmap-data.util.ts new file mode 100644 index 0000000000..d761df16fe --- /dev/null +++ b/src/app/ui/heatmap/build-heatmap-data.util.ts @@ -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 (Jan…Dec). + */ +export const buildHeatmapWeeks = ( + dayMap: Map, + 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 => { + const occSet = new Set(occurrences.map(getDbDateStr)); + const dayMap = new Map(); + 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, + 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}×` : ''; +}; diff --git a/src/app/ui/heatmap/heatmap-month-calendar.component.html b/src/app/ui/heatmap/heatmap-month-calendar.component.html new file mode 100644 index 0000000000..22c0289cad --- /dev/null +++ b/src/app/ui/heatmap/heatmap-month-calendar.component.html @@ -0,0 +1,129 @@ +
+ @if (tip(); as t) { +
+ {{ t.text }} +
+ } +
+ +
+ {{ monthLabel() }} + @if (isViewMonthLimited()) { + + } +
+ +
+ +
+ @for (col of weekdayCols(); track col.weekdayIdx) { +
+ {{ col.label }} + @if (weekdayHeaderGlyphs()?.get(col.weekdayIdx); as g) { + + {{ g.top }} + {{ g.mid }} + {{ g.bottom }} + + } +
+ } +
+ +
+ @for (week of weeks(); track $index) { + @for (cell of week; track cell.dateStr) { +
+ {{ cell.dayNum }} +
+ } + } +
+ + @if (legendMode() !== 'none') { +
+ @if (legendMode() === 'intensity') { + + {{ T.G.HEATMAP_LOW | translate }} + + + + + {{ T.G.HEATMAP_HIGH | translate }} + } @else { + {{ T.G.HEATMAP_START_DAY | translate }} + {{ T.G.HEATMAP_TODAY | translate }} + {{ T.G.HEATMAP_PROJECTED | translate }} + {{ T.G.HEATMAP_NEXT | translate }} + {{ T.G.HEATMAP_END_DAY | translate }} + @if (showActivity()) { + + + + + {{ T.G.HEATMAP_ACTIVITY_LEGEND | translate }} + + } + } +
+ } +
diff --git a/src/app/ui/heatmap/heatmap-month-calendar.component.scss b/src/app/ui/heatmap/heatmap-month-calendar.component.scss new file mode 100644 index 0000000000..615b972ec4 --- /dev/null +++ b/src/app/ui/heatmap/heatmap-month-calendar.component.scss @@ -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 1–3) 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)); +} diff --git a/src/app/ui/heatmap/heatmap-month-calendar.component.spec.ts b/src/app/ui/heatmap/heatmap-month-calendar.component.spec.ts new file mode 100644 index 0000000000..ff1d1d8a07 --- /dev/null +++ b/src/app/ui/heatmap/heatmap-month-calendar.component.spec.ts @@ -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(), +): ComponentFixture => { + 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; // Mar–May 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)); // Mar–May 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); + }); +}); diff --git a/src/app/ui/heatmap/heatmap-month-calendar.component.ts b/src/app/ui/heatmap/heatmap-month-calendar.component.ts new file mode 100644 index 0000000000..fb51878de4 --- /dev/null +++ b/src/app/ui/heatmap/heatmap-month-calendar.component.ts @@ -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); + + // 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>(); + readonly rangeStart = input.required(); + readonly rangeEnd = input.required(); + /** Which legend to show beneath the grid. */ + readonly legendMode = input<'intensity' | 'projection' | 'none'>('intensity'); + readonly dayClick = output(); + readonly dayDblClick = output(); + /** 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(false); + /** Preview-only flourish, default OFF so the Activity heatmap is untouched: + * tint weekend columns. */ + readonly showWeekends = input(false); + /** When true, the legend shows the green "tracked time" (activity) swatch. */ + readonly showActivity = input(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(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 | 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 | null>(null); + /** Tooltip for the month title (e.g. the BYMONTH limit list). */ + readonly monthTooltip = input(''); + /** 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(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(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(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(() => { + 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()); + } +} diff --git a/src/app/ui/heatmap/heatmap-switcher.component.html b/src/app/ui/heatmap/heatmap-switcher.component.html new file mode 100644 index 0000000000..287b9b6cf2 --- /dev/null +++ b/src/app/ui/heatmap/heatmap-switcher.component.html @@ -0,0 +1,63 @@ +
+ + + {{ T.G.HEATMAP_VIEW_MONTH | translate }} + + + {{ T.G.HEATMAP_VIEW_YEAR | translate }} + + + + @if (view() === 'month') { + + } @else if (data(); as d) { + + } +
diff --git a/src/app/ui/heatmap/heatmap-switcher.component.scss b/src/app/ui/heatmap/heatmap-switcher.component.scss new file mode 100644 index 0000000000..4b062793eb --- /dev/null +++ b/src/app/ui/heatmap/heatmap-switcher.component.scss @@ -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; + } +} diff --git a/src/app/ui/heatmap/heatmap-switcher.component.spec.ts b/src/app/ui/heatmap/heatmap-switcher.component.spec.ts new file mode 100644 index 0000000000..1c5553f048 --- /dev/null +++ b/src/app/ui/heatmap/heatmap-switcher.component.spec.ts @@ -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 => { + 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()); + 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(); + }); +}); diff --git a/src/app/ui/heatmap/heatmap-switcher.component.ts b/src/app/ui/heatmap/heatmap-switcher.component.ts new file mode 100644 index 0000000000..50cf630241 --- /dev/null +++ b/src/app/ui/heatmap/heatmap-switcher.component.ts @@ -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(); + readonly dayMap = input.required>(); + readonly rangeStart = input.required(); + readonly rangeEnd = input.required(); + readonly legendMode = input<'intensity' | 'projection' | 'none'>('intensity'); + /** Storage slot for remembering the chosen view (empty → not persisted). */ + readonly persistKey = input(''); + /** View to open on first render when nothing is persisted. */ + readonly initialView = input('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(''); + readonly canNavPrev = input(false); + readonly canNavNext = input(false); + readonly navPrev = output(); + readonly navNext = output(); + readonly dayClick = output(); + readonly dayDblClick = output(); + /** 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(false); + /** Passed through to the MONTH view: unlimited month navigation. The consumer + * listens to `monthViewChange` and moves its data window along. */ + readonly monthBoundless = input(false); + readonly monthViewChange = output<{ y: number; m: number }>(); + /** Passed through to both views — preview-only flourish (default off). */ + readonly showWeekends = input(false); + /** Passed through to both views — show the green activity legend swatch. */ + readonly showActivity = input(false); + /** Direct-manipulation mode — day clicks become contextual-menu requests, and + * (month view) weekday headers / month title become clickable + annotated. */ + readonly interactiveMenus = input(false); + /** Per-weekday header glyphs (Mon=0 … Sun=6), passed to the MONTH view. */ + readonly weekdayHeaderGlyphs = input | null>(null); + /** Months (0=Jan … 11=Dec) limited via BYMONTH — shown with a chip in both views. */ + readonly limitedMonths = input(null); + /** Per-weekday (Mon=0 … Sun=6) hover tooltip text, passed to both views. */ + readonly weekdayHeaderTooltips = input | null>(null); + /** Tooltip for a limited month label/title, passed to both views. */ + readonly monthTooltip = input(''); + 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(null); + + readonly view = signal('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); + } + } +} diff --git a/src/app/ui/heatmap/heatmap.component.html b/src/app/ui/heatmap/heatmap.component.html index 84153dc255..01e23fcbd7 100644 --- a/src/app/ui/heatmap/heatmap.component.html +++ b/src/app/ui/heatmap/heatmap.component.html @@ -1,52 +1,160 @@ @if (data(); as heatmapData) { -
-
-
-
- @for (label of dayLabels(); track $index) { -
{{ label }}
- } -
- -
-
- @for (month of heatmapData.monthLabels; track $index) { -
{{ month }}
+ @if (heatmapData.months; as months) { +
+ @if (tip(); as t) { +
+ {{ t.text }} +
+ } + @if (navLabel()) { +
+ + @if (canNavPrev() || canNavNext()) { + + } +
{{ navLabel() }}
+ @if (canNavPrev() || canNavNext()) { + }
- -
- @for (week of heatmapData.weeks; track $index) { -
- @for (day of week.days; track $index) { -
+ } +
+
+ @for (row of dayLabelRows(); track row.weekdayIdx) { +
+ {{ row.label }} +
+ } +
+
+ @for (m of months; track $index) { +
+
+ @for (week of m.weeks; track $index) { +
+ @for (day of week.days; track $index) { +
+ } +
+ } +
+
+ {{ m.label }} + @if (isMonthLimited(m.monthIndex)) { + + } +
+ @if (m.total) { +
{{ m.total }}
}
}
+ + @if (label()) { +
{{ label() }}
+ } + + @if (legendMode() === 'intensity') { +
+ {{ T.G.HEATMAP_LOW | translate }} +
+
+
+
+
+ {{ T.G.HEATMAP_HIGH | translate }} +
+ } @else if (legendMode() === 'projection') { + +
+ + {{ T.G.HEATMAP_START_DAY | translate }} + + + {{ T.G.HEATMAP_TODAY | translate }} + + + {{ T.G.HEATMAP_PROJECTED | translate }} + + + {{ T.G.HEATMAP_NEXT | translate }} + + + {{ T.G.HEATMAP_END_DAY | translate }} + + @if (showActivity()) { + + + + + + + + {{ T.G.HEATMAP_ACTIVITY_LEGEND | translate }} + + } +
+ }
- - @if (label()) { -
{{ label() }}
- } - - @if (showLegend()) { -
- Less -
-
-
-
-
- More -
- } -
+ } } diff --git a/src/app/ui/heatmap/heatmap.component.scss b/src/app/ui/heatmap/heatmap.component.scss index 4b39a61c31..6c3ec58905 100644 --- a/src/app/ui/heatmap/heatmap.component.scss +++ b/src/app/ui/heatmap/heatmap.component.scss @@ -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 (Jan–Dec 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) + ); } } diff --git a/src/app/ui/heatmap/heatmap.component.ts b/src/app/ui/heatmap/heatmap.component.ts index ed4f553be3..8856d52857 100644 --- a/src/app/ui/heatmap/heatmap.component.ts +++ b/src/app/ui/heatmap/heatmap.component.ts @@ -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; + 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); - readonly data = input.required(); - readonly label = input(''); - readonly showLegend = input(true); - readonly scrollToEnd = input(false); - - private readonly _scrollableContent = - viewChild>('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(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(); + readonly label = input(''); + /** Legend under the grid: relative intensity (Low→High) 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(''); + readonly canNavPrev = input(false); + readonly canNavNext = input(false); + readonly navPrev = output(); + readonly navNext = output(); + /** Emits the clicked day (non-empty cells only). Consumers decide what to do. */ + readonly dayClick = output(); + /** Emits the double-clicked day (e.g. simulate-completion in the dialog). */ + readonly dayDblClick = output(); + /** 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(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(null); + /** Per-weekday (Mon=0 … Sun=6) hover tooltip spelling out what's set on it. */ + readonly weekdayHeaderTooltips = input | null>(null); + /** Tooltip for a limited month label (the BYMONTH limit list). */ + readonly monthTooltip = input(''); + /** 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(false); + /** Preview-only flourish, default OFF so the Activity heatmap is untouched: + * tint weekend rows. */ + readonly showWeekends = input(false); + /** When true, the legend shows the green "tracked time" (activity) swatch. */ + readonly showActivity = input(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; } } diff --git a/src/app/ui/heatmap/year-nav.util.spec.ts b/src/app/ui/heatmap/year-nav.util.spec.ts new file mode 100644 index 0000000000..f1438e840e --- /dev/null +++ b/src/app/ui/heatmap/year-nav.util.spec.ts @@ -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(); + }); +}); diff --git a/src/app/ui/heatmap/year-nav.util.ts b/src/app/ui/heatmap/year-nav.util.ts new file mode 100644 index 0000000000..94abb989c7 --- /dev/null +++ b/src/app/ui/heatmap/year-nav.util.ts @@ -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; +}; diff --git a/src/app/ui/segmented-button-group/segmented-button-group.component.scss b/src/app/ui/segmented-button-group/segmented-button-group.component.scss index afc8ba919e..7737da874f 100644 --- a/src/app/ui/segmented-button-group/segmented-button-group.component.scss +++ b/src/app/ui/segmented-button-group/segmented-button-group.component.scss @@ -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); + } } diff --git a/src/app/util/assert-never.ts b/src/app/util/assert-never.ts new file mode 100644 index 0000000000..232e04a4b6 --- /dev/null +++ b/src/app/util/assert-never.ts @@ -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)}`); +}; diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index b8e3d02381..787b7a1b45 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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 Jan–Apr, Mondays only in June, or yearly 'every Saturday, March–November'. 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 Jan–Apr, Mondays only in June, or yearly 'every Saturday, March–November'. 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 (1–31, 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 Mon–Fri + '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 Mon–Fri + '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, 1–53. 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 March–November (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 Mon–Fri (pair it with Set position in Advanced for 'the last weekday of the month'). 'On the last day' = month-end (28–31).", + "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 Mon–Fri (pair it with Set position in Advanced for 'the last working day of the month'). 'On the last day' = month-end (28–31).", "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 download the Chrome extension to allow communication with the Jira API and Idle Time Handling. Note that this doesn't work for mobile. Without the extension, the features will not work!", + "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": "

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 this device only; it is not synced. Leave it off unless you are testing the feature, and reload the app after changing it.

", + "HELP": "

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 this device only; it is not synced. Leave it off unless you are testing the feature, and reload the app after changing it.

If you sync between several devices, enable it on all of them or on none. 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).

", "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", diff --git a/src/styles/components/_overwrite-material.scss b/src/styles/components/_overwrite-material.scss index 36bd55e67d..af74f147ce 100644 --- a/src/styles/components/_overwrite-material.scss +++ b/src/styles/components/_overwrite-material.scss @@ -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 {