From 3f72c5a82a35c5c32deb6f9ae605cd490709b1fd Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Wed, 7 Oct 2026 18:04:04 +0000 Subject: [PATCH] feat(chores): custom "every N days" recurrence (#1038) Recurring chores can now repeat every 2-365 days. The interval is stored in the existing recurrence field as every__days (every_2_days is the N=2 case), so availability, the anchor date, calendar projection, the calendar card and reports all read one value with no new field or extra sensor payload. The admin panel adds an "Every N days (custom)" option with a "Repeat every (days)" number field; the chore list shows "Every N days". The WS add/update schemas reject an out-of-range interval. New strings translated into every locale. --- custom_components/taskmate/const.py | 23 ++++ custom_components/taskmate/coord_calendar.py | 6 +- custom_components/taskmate/coord_chores.py | 21 ++-- custom_components/taskmate/coord_reports.py | 6 +- custom_components/taskmate/models.py | 6 +- custom_components/taskmate/websocket.py | 17 ++- .../taskmate/www/locales/da.json | 5 + .../taskmate/www/locales/de.json | 5 + .../taskmate/www/locales/en-GB.json | 5 + .../taskmate/www/locales/en.json | 5 + .../taskmate/www/locales/fr.json | 5 + .../taskmate/www/locales/nb.json | 5 + .../taskmate/www/locales/nn.json | 5 + .../taskmate/www/locales/pl.json | 5 + .../taskmate/www/locales/pt-BR.json | 5 + .../taskmate/www/locales/pt.json | 5 + .../taskmate/www/taskmate-calendar-card.js | 14 ++- .../taskmate/www/taskmate-panel.js | 49 +++++++- tests/test_every_n_days.py | 118 ++++++++++++++++++ 19 files changed, 285 insertions(+), 25 deletions(-) create mode 100644 tests/test_every_n_days.py diff --git a/custom_components/taskmate/const.py b/custom_components/taskmate/const.py index bdb6c0ed..a172e7e6 100644 --- a/custom_components/taskmate/const.py +++ b/custom_components/taskmate/const.py @@ -1,5 +1,6 @@ """Constants for TaskMate integration.""" +import re from typing import Final DOMAIN: Final = "taskmate" @@ -76,6 +77,28 @@ "every_6_months": 182, } +# Custom "every N days" recurrences (#1038) are stored as ``every__days``; +# the original every_2_days is simply the N=2 case. N is bounded so a typo +# can't park a chore for years. +MIN_RECURRENCE_INTERVAL_DAYS: Final = 2 +MAX_RECURRENCE_INTERVAL_DAYS: Final = 365 +_EVERY_N_DAYS_RE: Final = re.compile(r"every_(\d{1,3})_days") + + +def recurrence_interval_days(recurrence: str | None) -> int | None: + """N for an ``every__days`` recurrence, else None (also when N is out of range).""" + match = _EVERY_N_DAYS_RE.fullmatch(recurrence) if isinstance(recurrence, str) else None + if not match: + return None + days = int(match.group(1)) + return days if MIN_RECURRENCE_INTERVAL_DAYS <= days <= MAX_RECURRENCE_INTERVAL_DAYS else None + + +def recurrence_period_days(recurrence: str | None) -> int: + """Nominal period of a Mode-B recurrence in days (weekly when unknown).""" + return recurrence_interval_days(recurrence) or RECURRENCE_PERIOD_DAYS.get(recurrence or "", 7) + + # Badge tiers, lowest to highest. BADGE_TIERS: Final = ["bronze", "silver", "gold", "platinum"] diff --git a/custom_components/taskmate/coord_calendar.py b/custom_components/taskmate/coord_calendar.py index c0e6302a..4a57c916 100644 --- a/custom_components/taskmate/coord_calendar.py +++ b/custom_components/taskmate/coord_calendar.py @@ -18,6 +18,7 @@ MAX_CALENDAR_PROJECTION_DAYS, MIN_CALENDAR_PROJECTION_DAYS, TIME_CATEGORY_ICONS, + recurrence_interval_days, ) from .models import Chore @@ -245,11 +246,12 @@ def _is_chore_base_scheduled_for_date(self, chore: Chore, day: date) -> bool: pass return True - if recurrence == "every_2_days" and anchor_iso: + interval_days = recurrence_interval_days(recurrence) + if interval_days and anchor_iso: try: anchor = date.fromisoformat(anchor_iso) diff = (day - anchor).days - return diff >= 0 and diff % 2 == 0 + return diff >= 0 and diff % interval_days == 0 except ValueError: return False diff --git a/custom_components/taskmate/coord_chores.py b/custom_components/taskmate/coord_chores.py index da3b45b4..2357bc84 100644 --- a/custom_components/taskmate/coord_chores.py +++ b/custom_components/taskmate/coord_chores.py @@ -11,7 +11,13 @@ from . import images, photos from .chore_undo import child_can_undo, undo_window_seconds -from .const import CHORE_NOTE_MAX_LENGTH, CHORE_SUGGESTED_POINTS_MAX, DAILY_PROGRESS_KEEP_DAYS, QUALITY_RATINGS +from .const import ( + CHORE_NOTE_MAX_LENGTH, + CHORE_SUGGESTED_POINTS_MAX, + DAILY_PROGRESS_KEEP_DAYS, + QUALITY_RATINGS, + recurrence_interval_days, +) from .coord_rejections import clean_reject_reason from .coord_teamwork import teamwork_config_error from .models import Chore, ChoreCompletion, PointsTransaction @@ -1823,11 +1829,8 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: now = dt_util.now() today = dt_util.as_local(now).date() - window_days = { - "every_2_days": 2, - "weekly": 7, - "every_2_weeks": 14, - }.get(recurrence, 7) + interval_days = recurrence_interval_days(recurrence) + window_days = interval_days or {"weekly": 7, "every_2_weeks": 14}.get(recurrence, 7) record = self.storage.get_last_completed(chore.id, child_id) current_iso = record.get("current") @@ -1863,14 +1866,14 @@ def is_chore_available_for_child(self, chore, child_id: str) -> bool: # from, so moving one week's chore doesn't shift every week after it. last_dt = self.occurrence_origin(chore, last_dt) - # every_2_days with anchor — check alignment - if recurrence == "every_2_days" and recurrence_start: + # every__days with anchor — check alignment + if interval_days and recurrence_start: try: anchor = date.fromisoformat(recurrence_start) days_since_anchor = (today - anchor).days if days_since_anchor < 0: return False - if days_since_anchor % 2 != 0: + if days_since_anchor % interval_days != 0: return False return last_dt < today except ValueError: diff --git a/custom_components/taskmate/coord_reports.py b/custom_components/taskmate/coord_reports.py index 26615ab2..c5523d20 100644 --- a/custom_components/taskmate/coord_reports.py +++ b/custom_components/taskmate/coord_reports.py @@ -16,7 +16,7 @@ from homeassistant.util import dt as dt_util -from .const import RECURRENCE_PERIOD_DAYS +from .const import recurrence_period_days _LOGGER = logging.getLogger(__name__) @@ -175,7 +175,7 @@ def _expected_occurrences(self, chore, start: date, end: date) -> int: shift += 1 if mode == "recurring": - period_days = RECURRENCE_PERIOD_DAYS.get(getattr(chore, "recurrence", "weekly"), 7) + period_days = recurrence_period_days(getattr(chore, "recurrence", "weekly")) return max(0, span // period_days + shift) due_days = [d.lower() for d in (getattr(chore, "due_days", []) or [])] @@ -340,7 +340,7 @@ def _chore_falls_on(self, chore, day: date) -> bool: return override if mode == "recurring": - period_days = RECURRENCE_PERIOD_DAYS.get(getattr(chore, "recurrence", "weekly"), 7) + period_days = recurrence_period_days(getattr(chore, "recurrence", "weekly")) anchor_raw = getattr(chore, "recurrence_start", "") or "" try: anchor = date.fromisoformat(anchor_raw) if anchor_raw else None diff --git a/custom_components/taskmate/models.py b/custom_components/taskmate/models.py index 5c44ca35..c9904624 100644 --- a/custom_components/taskmate/models.py +++ b/custom_components/taskmate/models.py @@ -362,9 +362,11 @@ class Chore: schedule_mode: str = "specific_days" due_days: list[str] = field(default_factory=list) # Mode A: days to show chore # Mode B fields - recurrence: str = "weekly" # every_2_days | weekly | every_2_weeks | monthly | every_3_months | every_6_months + recurrence: str = ( + "weekly" # every__days (N 2-365) | weekly | every_2_weeks | monthly | every_3_months | every_6_months + ) recurrence_day: str = "" # optional: which day of week for weekly/every_2_weeks - recurrence_start: str = "" # optional: ISO date anchor for every_2_days + recurrence_start: str = "" # optional: ISO date anchor for every__days first_occurrence_mode: str = "available_immediately" # available_immediately | wait_for_first_occurrence # Dynamic visibility visibility_entity: str = "" # optional: entity_id to check for visibility diff --git a/custom_components/taskmate/websocket.py b/custom_components/taskmate/websocket.py index 40b19db6..19486f8f 100644 --- a/custom_components/taskmate/websocket.py +++ b/custom_components/taskmate/websocket.py @@ -75,13 +75,16 @@ DIFFICULTY_TIERS, DOMAIN, MAX_CHORE_TAGS, + MAX_RECURRENCE_INTERVAL_DAYS, MAX_TIME_PERIODS, + MIN_RECURRENCE_INTERVAL_DAYS, SCHEDULE_MODES, TAG_ID_MAX_LENGTH, TEAM_POINTS_MODES, TEAM_SIZE_MAX, TIME_CATEGORY_ICONS, is_valid_completion_sound, + recurrence_interval_days, ) from .coord_birthdays import normalize_birthday from .coord_inspections import INSPECTION_BONUS_MAX, INSPECTION_FAIL_MODES, INSPECTION_WINDOWS @@ -718,6 +721,16 @@ def _image_url_or_blank(value): } +def _recurrence(value): + """A recurrence name; a custom every__days interval (#1038) must have N in range.""" + text = str(value) + if text.startswith("every_") and text.endswith("_days") and recurrence_interval_days(text) is None: + raise vol.Invalid( + f"Custom recurrence must be every {MIN_RECURRENCE_INTERVAL_DAYS}-{MAX_RECURRENCE_INTERVAL_DAYS} days" + ) + return text + + def _tag_id_list(value): """Validate the NFC / QR tag ids linked to a chore (#923). @@ -755,7 +768,7 @@ def _chore_payload_schema(*, require_name: bool): vol.Optional("difficulty"): vol.In(DIFFICULTY_TIERS), vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES), vol.Optional("due_days"): [str], - vol.Optional("recurrence"): str, + vol.Optional("recurrence"): _recurrence, vol.Optional("recurrence_day"): str, vol.Optional("recurrence_start"): str, vol.Optional("first_occurrence_mode"): str, @@ -2500,7 +2513,7 @@ async def _ws_templates_get(hass, connection, msg, coordinator): vol.Optional("completion_sound"): _completion_sound, vol.Optional("schedule_mode"): vol.In(SCHEDULE_MODES), vol.Optional("due_days"): [str], - vol.Optional("recurrence"): str, + vol.Optional("recurrence"): _recurrence, vol.Optional("recurrence_day"): str, vol.Optional("recurrence_start"): str, vol.Optional("first_occurrence_mode"): str, diff --git a/custom_components/taskmate/www/locales/da.json b/custom_components/taskmate/www/locales/da.json index 2b36e5f1..551a908b 100644 --- a/custom_components/taskmate/www/locales/da.json +++ b/custom_components/taskmate/www/locales/da.json @@ -843,6 +843,9 @@ "panel.chore_points_label": "Point", "panel.chore_points_per_window_label": "Point pr. periode", "panel.chore_recurrence_day_hint": "f.eks. monday — lad feltet være tomt for en hvilken som helst dag", + "panel.chore_recurrence_interval_label": "Gentag hver (dage)", + "panel.chore_recurrence_interval_hint": "Helt antal dage, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Angiv et antal dage mellem {min} og {max}", "panel.chore_recurrence_day_label": "Ugedag (kun ugentligt)", "panel.chore_recurrence_label": "Gentagelse", "panel.chore_recurrence_start_hint": "Startdato — tom = i dag", @@ -1281,6 +1284,8 @@ "panel.pending_tooltip": "{count} afventer — klik for at gennemgå", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "Hver 2. dag", + "panel.recurrence_every_n_days": "Hver N. dag (brugerdefineret)", + "panel.recurrence_every_count_days": "Hver {count}. dag", "panel.recurrence_every_2_weeks": "Hver 2. uge", "panel.recurrence_every_3_months": "Hver 3. måned", "panel.recurrence_every_6_months": "Hver 6. måned", diff --git a/custom_components/taskmate/www/locales/de.json b/custom_components/taskmate/www/locales/de.json index 06ddbab7..0ea27f19 100644 --- a/custom_components/taskmate/www/locales/de.json +++ b/custom_components/taskmate/www/locales/de.json @@ -793,6 +793,9 @@ "panel.chore_points_label": "Punkte", "panel.chore_points_per_window_label": "Punkte pro Zeitfenster", "panel.chore_recurrence_day_hint": "z.B. Montag — leer lassen für beliebigen Tag", + "panel.chore_recurrence_interval_label": "Wiederholen alle (Tage)", + "panel.chore_recurrence_interval_hint": "Ganze Anzahl Tage, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Gib eine Anzahl Tage zwischen {min} und {max} ein", "panel.chore_recurrence_day_label": "Wochentag (nur wöchentlich)", "panel.chore_recurrence_label": "Wiederholung", "panel.chore_recurrence_start_hint": "Ankerdatum — leer = heute", @@ -1231,6 +1234,8 @@ "panel.pending_tooltip": "{count} ausstehend — klicken zum Überprüfen", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "Alle 2 Tage", + "panel.recurrence_every_n_days": "Alle N Tage (benutzerdefiniert)", + "panel.recurrence_every_count_days": "Alle {count} Tage", "panel.recurrence_every_2_weeks": "Alle 2 Wochen", "panel.recurrence_every_3_months": "Alle 3 Monate", "panel.recurrence_every_6_months": "Alle 6 Monate", diff --git a/custom_components/taskmate/www/locales/en-GB.json b/custom_components/taskmate/www/locales/en-GB.json index 72a82c18..5b996b9f 100644 --- a/custom_components/taskmate/www/locales/en-GB.json +++ b/custom_components/taskmate/www/locales/en-GB.json @@ -843,6 +843,9 @@ "panel.chore_points_label": "Points", "panel.chore_points_per_window_label": "Points per window", "panel.chore_recurrence_day_hint": "e.g. monday — leave blank for any day", + "panel.chore_recurrence_interval_label": "Repeat every (days)", + "panel.chore_recurrence_interval_hint": "Whole number of days, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Enter a number of days between {min} and {max}", "panel.chore_recurrence_day_label": "Day of week (weekly only)", "panel.chore_recurrence_label": "Recurrence", "panel.chore_recurrence_start_hint": "Anchor date — blank = today", @@ -1281,6 +1284,8 @@ "panel.pending_tooltip": "{count} pending — click to review", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "Every 2 days", + "panel.recurrence_every_n_days": "Every N days (custom)", + "panel.recurrence_every_count_days": "Every {count} days", "panel.recurrence_every_2_weeks": "Every 2 weeks", "panel.recurrence_every_3_months": "Every 3 months", "panel.recurrence_every_6_months": "Every 6 months", diff --git a/custom_components/taskmate/www/locales/en.json b/custom_components/taskmate/www/locales/en.json index 72a82c18..5b996b9f 100644 --- a/custom_components/taskmate/www/locales/en.json +++ b/custom_components/taskmate/www/locales/en.json @@ -843,6 +843,9 @@ "panel.chore_points_label": "Points", "panel.chore_points_per_window_label": "Points per window", "panel.chore_recurrence_day_hint": "e.g. monday — leave blank for any day", + "panel.chore_recurrence_interval_label": "Repeat every (days)", + "panel.chore_recurrence_interval_hint": "Whole number of days, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Enter a number of days between {min} and {max}", "panel.chore_recurrence_day_label": "Day of week (weekly only)", "panel.chore_recurrence_label": "Recurrence", "panel.chore_recurrence_start_hint": "Anchor date — blank = today", @@ -1281,6 +1284,8 @@ "panel.pending_tooltip": "{count} pending — click to review", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "Every 2 days", + "panel.recurrence_every_n_days": "Every N days (custom)", + "panel.recurrence_every_count_days": "Every {count} days", "panel.recurrence_every_2_weeks": "Every 2 weeks", "panel.recurrence_every_3_months": "Every 3 months", "panel.recurrence_every_6_months": "Every 6 months", diff --git a/custom_components/taskmate/www/locales/fr.json b/custom_components/taskmate/www/locales/fr.json index fbec1fae..b9881328 100644 --- a/custom_components/taskmate/www/locales/fr.json +++ b/custom_components/taskmate/www/locales/fr.json @@ -793,6 +793,9 @@ "panel.chore_points_label": "Points", "panel.chore_points_per_window_label": "Points par période", "panel.chore_recurrence_day_hint": "ex. lundi — laisser vide pour n'importe quel jour", + "panel.chore_recurrence_interval_label": "Répéter tous les (jours)", + "panel.chore_recurrence_interval_hint": "Nombre entier de jours, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Saisissez un nombre de jours entre {min} et {max}", "panel.chore_recurrence_day_label": "Jour de la semaine (hebdomadaire uniquement)", "panel.chore_recurrence_label": "Récurrence", "panel.chore_recurrence_start_hint": "Date de début — vide = aujourd'hui", @@ -1231,6 +1234,8 @@ "panel.pending_tooltip": "{count} en attente — cliquer pour vérifier", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "Tous les 2 jours", + "panel.recurrence_every_n_days": "Tous les N jours (personnalisé)", + "panel.recurrence_every_count_days": "Tous les {count} jours", "panel.recurrence_every_2_weeks": "Toutes les 2 semaines", "panel.recurrence_every_3_months": "Tous les 3 mois", "panel.recurrence_every_6_months": "Tous les 6 mois", diff --git a/custom_components/taskmate/www/locales/nb.json b/custom_components/taskmate/www/locales/nb.json index f7daff8d..9439997f 100644 --- a/custom_components/taskmate/www/locales/nb.json +++ b/custom_components/taskmate/www/locales/nb.json @@ -793,6 +793,9 @@ "panel.chore_points_label": "Poeng", "panel.chore_points_per_window_label": "Poeng per vindu", "panel.chore_recurrence_day_hint": "f.eks. mandag — la stå tomt for hvilken som helst dag", + "panel.chore_recurrence_interval_label": "Gjenta hver (dager)", + "panel.chore_recurrence_interval_hint": "Helt antall dager, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Skriv inn et antall dager mellom {min} og {max}", "panel.chore_recurrence_day_label": "Ukedag (kun ukentlig)", "panel.chore_recurrence_label": "Gjentakelse", "panel.chore_recurrence_start_hint": "Ankerdato — tomt = i dag", @@ -1231,6 +1234,8 @@ "panel.pending_tooltip": "{count} ventende — klikk for å gjennomgå", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "Annenhver dag", + "panel.recurrence_every_n_days": "Hver N. dag (egendefinert)", + "panel.recurrence_every_count_days": "Hver {count}. dag", "panel.recurrence_every_2_weeks": "Annenhver uke", "panel.recurrence_every_3_months": "Hver 3. måned", "panel.recurrence_every_6_months": "Hvert halvår", diff --git a/custom_components/taskmate/www/locales/nn.json b/custom_components/taskmate/www/locales/nn.json index 8d06e996..fb684d8a 100644 --- a/custom_components/taskmate/www/locales/nn.json +++ b/custom_components/taskmate/www/locales/nn.json @@ -793,6 +793,9 @@ "panel.chore_points_label": "Poeng", "panel.chore_points_per_window_label": "Poeng per vindauge", "panel.chore_recurrence_day_hint": "t.d. måndag — la stå tomt for kva dag som helst", + "panel.chore_recurrence_interval_label": "Gjenta kvar (dagar)", + "panel.chore_recurrence_interval_hint": "Heilt tal dagar, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Skriv inn eit tal dagar mellom {min} og {max}", "panel.chore_recurrence_day_label": "Vekedag (berre vekentleg)", "panel.chore_recurrence_label": "Gjentaking", "panel.chore_recurrence_start_hint": "Ankerdato — tomt = i dag", @@ -1231,6 +1234,8 @@ "panel.pending_tooltip": "{count} ventande — klikk for å gjennomgå", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "Kvar 2. dag", + "panel.recurrence_every_n_days": "Kvar N. dag (eigendefinert)", + "panel.recurrence_every_count_days": "Kvar {count}. dag", "panel.recurrence_every_2_weeks": "Kvar 2. veke", "panel.recurrence_every_3_months": "Kvar 3. månad", "panel.recurrence_every_6_months": "Kvar 6. månad", diff --git a/custom_components/taskmate/www/locales/pl.json b/custom_components/taskmate/www/locales/pl.json index 2bfa9292..9945506b 100644 --- a/custom_components/taskmate/www/locales/pl.json +++ b/custom_components/taskmate/www/locales/pl.json @@ -843,6 +843,9 @@ "panel.chore_points_label": "Punkty", "panel.chore_points_per_window_label": "Punkty za okres", "panel.chore_recurrence_day_hint": "np. monday — pozostaw puste dla dowolnego dnia", + "panel.chore_recurrence_interval_label": "Powtarzaj co (dni)", + "panel.chore_recurrence_interval_hint": "Całkowita liczba dni, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Podaj liczbę dni od {min} do {max}", "panel.chore_recurrence_day_label": "Dzień tygodnia (tylko co tydzień)", "panel.chore_recurrence_label": "Powtarzanie", "panel.chore_recurrence_start_hint": "Data początkowa — puste = dziś", @@ -1281,6 +1284,8 @@ "panel.pending_tooltip": "Oczekujące: {count} — kliknij, aby przejrzeć", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "Co 2 dni", + "panel.recurrence_every_n_days": "Co N dni (własne)", + "panel.recurrence_every_count_days": "Co {count} dni", "panel.recurrence_every_2_weeks": "Co 2 tygodnie", "panel.recurrence_every_3_months": "Co 3 miesiące", "panel.recurrence_every_6_months": "Co 6 miesięcy", diff --git a/custom_components/taskmate/www/locales/pt-BR.json b/custom_components/taskmate/www/locales/pt-BR.json index 733a90a7..a0fae9cc 100644 --- a/custom_components/taskmate/www/locales/pt-BR.json +++ b/custom_components/taskmate/www/locales/pt-BR.json @@ -793,6 +793,9 @@ "panel.chore_points_label": "Pontos", "panel.chore_points_per_window_label": "Pontos por janela", "panel.chore_recurrence_day_hint": "ex: segunda — deixe em branco para qualquer dia", + "panel.chore_recurrence_interval_label": "Repetir a cada (dias)", + "panel.chore_recurrence_interval_hint": "Número inteiro de dias, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Informe um número de dias entre {min} e {max}", "panel.chore_recurrence_day_label": "Dia da semana (somente semanal)", "panel.chore_recurrence_label": "Recorrência", "panel.chore_recurrence_start_hint": "Data âncora — em branco = hoje", @@ -1231,6 +1234,8 @@ "panel.pending_tooltip": "{count} pendente(s) — clique para revisar", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "A cada 2 dias", + "panel.recurrence_every_n_days": "A cada N dias (personalizado)", + "panel.recurrence_every_count_days": "A cada {count} dias", "panel.recurrence_every_2_weeks": "A cada 2 semanas", "panel.recurrence_every_3_months": "A cada 3 meses", "panel.recurrence_every_6_months": "A cada 6 meses", diff --git a/custom_components/taskmate/www/locales/pt.json b/custom_components/taskmate/www/locales/pt.json index a6229aa0..3e0be129 100644 --- a/custom_components/taskmate/www/locales/pt.json +++ b/custom_components/taskmate/www/locales/pt.json @@ -798,6 +798,9 @@ "panel.chore_points_label": "Pontos", "panel.chore_points_per_window_label": "Pontos por janela", "panel.chore_recurrence_day_hint": "ex. segunda-feira — deixe em branco para qualquer dia", + "panel.chore_recurrence_interval_label": "Repetir a cada (dias)", + "panel.chore_recurrence_interval_hint": "Número inteiro de dias, {min}–{max}", + "panel.chore_recurrence_interval_invalid": "Introduza um número de dias entre {min} e {max}", "panel.chore_recurrence_day_label": "Dia da semana (apenas semanal)", "panel.chore_recurrence_label": "Recorrência", "panel.chore_recurrence_start_hint": "Data âncora — em branco = hoje", @@ -1236,6 +1239,8 @@ "panel.pending_tooltip": "{count} pendente(s) — clique para revisar", "panel.pts_display": "{count} {points_name}", "panel.recurrence_every_2_days": "A cada 2 dias", + "panel.recurrence_every_n_days": "A cada N dias (personalizado)", + "panel.recurrence_every_count_days": "A cada {count} dias", "panel.recurrence_every_2_weeks": "A cada 2 semanas", "panel.recurrence_every_3_months": "A cada 3 meses", "panel.recurrence_every_6_months": "A cada 6 meses", diff --git a/custom_components/taskmate/www/taskmate-calendar-card.js b/custom_components/taskmate/www/taskmate-calendar-card.js index 62112c80..994e874a 100644 --- a/custom_components/taskmate/www/taskmate-calendar-card.js +++ b/custom_components/taskmate/www/taskmate-calendar-card.js @@ -31,6 +31,15 @@ const WINDOW_DAYS = { every_6_months: 180, }; +// Custom "every N days" recurrences (#1038) are stored as every__days +// (every_2_days included); bounds match recurrence_interval_days() in const.py. +function recurrenceIntervalDays(recurrence) { + const m = /^every_(\d{1,3})_days$/.exec(recurrence || ""); + if (!m) return null; + const n = Number(m[1]); + return n >= 2 && n <= 365 ? n : null; +} + function ymd(date, tz) { return date.toLocaleDateString("en-CA", { timeZone: tz }); } @@ -366,12 +375,13 @@ class TaskMateCalendarCard extends LitElement { return true; } - if (recurrence === "every_2_days" && anchorStr) { + const intervalDays = recurrenceIntervalDays(recurrence); + if (intervalDays && anchorStr) { try { const anchor = new Date(anchorStr + "T00:00:00"); const diff = diffDays(dayDate, anchor); if (diff < 0) return false; - return diff % 2 === 0; + return diff % intervalDays === 0; } catch (e) { return false; } } diff --git a/custom_components/taskmate/www/taskmate-panel.js b/custom_components/taskmate/www/taskmate-panel.js index 5662ad1b..50386480 100644 --- a/custom_components/taskmate/www/taskmate-panel.js +++ b/custom_components/taskmate/www/taskmate-panel.js @@ -52,6 +52,7 @@ const SCHEDULE_MODES = [ const RECURRENCES = [ { v: "every_2_days", lk: "panel.recurrence_every_2_days" }, + { v: "every_n_days", lk: "panel.recurrence_every_n_days" }, { v: "weekly", lk: "panel.recurrence_weekly" }, { v: "every_2_weeks", lk: "panel.recurrence_every_2_weeks" }, { v: "monthly", lk: "panel.recurrence_monthly" }, @@ -59,6 +60,18 @@ const RECURRENCES = [ { v: "every_6_months", lk: "panel.recurrence_every_6_months" }, ]; +// Custom "every N days" recurrences (#1038) are stored as every__days; the +// editor shows them as the "every_n_days" option plus a number field. Bounds +// match recurrence_interval_days() in const.py. +const RECURRENCE_INTERVAL_MIN = 2; +const RECURRENCE_INTERVAL_MAX = 365; +function recurrenceIntervalDays(recurrence) { + const m = /^every_(\d{1,3})_days$/.exec(recurrence || ""); + if (!m) return null; + const n = Number(m[1]); + return n >= RECURRENCE_INTERVAL_MIN && n <= RECURRENCE_INTERVAL_MAX ? n : null; +} + const FIRST_OCCURRENCE = [ { v: "available_immediately", lk: "panel.first_occ_available_immediately" }, { v: "wait_for_first_occurrence", lk: "panel.first_occ_wait" }, @@ -2185,7 +2198,7 @@ class TaskMatePanel extends HTMLElement { difficulty: "medium", claim_allowance_minutes: 0, schedule_mode: "specific_days", - due_days: [], recurrence: "weekly", recurrence_day: "", recurrence_start: "", + due_days: [], recurrence: "weekly", recurrence_interval: 3, recurrence_day: "", recurrence_start: "", first_occurrence_mode: "available_immediately", assignment_mode: "everyone", assignment_rotation_anchor: "", manual_start_child_id: "", @@ -2229,6 +2242,7 @@ class TaskMatePanel extends HTMLElement { weather_temp_max: c.weather_temp_max ?? "", weather_wind_max: c.weather_wind_max ?? "", manual_start_child_id: "", + ...this._recurrenceDraft(c.recurrence), } }); } else { this._openDialog({ kind: "chore", mode: "add", data: blank }); @@ -2423,6 +2437,16 @@ class TaskMatePanel extends HTMLElement { this._showToast("err", this._t("panel.chore_team_conflict")); return; } + let recurrence = d.recurrence || "weekly"; + if (recurrence === "every_n_days") { + const n = Number(d.recurrence_interval); + if (!Number.isInteger(n) || n < RECURRENCE_INTERVAL_MIN || n > RECURRENCE_INTERVAL_MAX) { + this._showToast("err", this._t("panel.chore_recurrence_interval_invalid", + { min: RECURRENCE_INTERVAL_MIN, max: RECURRENCE_INTERVAL_MAX })); + return; + } + recurrence = `every_${n}_days`; + } const base = { name: d.name.trim(), description: d.description || "", @@ -2443,7 +2467,7 @@ class TaskMatePanel extends HTMLElement { weekly_target: Math.max(0, Number(d.weekly_target) || 0), schedule_mode: d.schedule_mode || "specific_days", due_days: d.due_days || [], - recurrence: d.recurrence || "weekly", + recurrence, recurrence_day: d.recurrence_day || "", recurrence_start: d.recurrence_start || "", first_occurrence_mode: d.first_occurrence_mode || "available_immediately", @@ -6474,7 +6498,7 @@ class TaskMatePanel extends HTMLElement { ? `${this._esc(childById[curChild].name)}` : isRotation ? `—` : ""; const schedLabel = c.schedule_mode === "recurring" - ? this._labelOf(RECURRENCES, c.recurrence) + (c.recurrence_day && c.recurrence_day !== "any_day" ? ` · ${this._labelOf(DAYS, c.recurrence_day)}` : "") + ? this._recurrenceLabel(c.recurrence) + (c.recurrence_day && c.recurrence_day !== "any_day" ? ` · ${this._labelOf(DAYS, c.recurrence_day)}` : "") : c.schedule_mode === "one_shot" ? this._t("panel.common_one_shot") : ((c.due_days || []).length === 0 ? this._t("panel.common_daily") : (c.due_days || []).map(d => this._labelOf(DAYS, d)).join(" · ")); @@ -9519,8 +9543,11 @@ class TaskMatePanel extends HTMLElement { ` : "", showRecurring ? `
- ${this._select(this._t("panel.chore_recurrence_label"), "recurrence", d.recurrence, RECURRENCES)} - ${this._field(this._t("panel.chore_recurrence_day_label"), "recurrence_day", d.recurrence_day, "text", this._t("panel.chore_recurrence_day_hint"))} + ${this._select(this._t("panel.chore_recurrence_label"), "recurrence", d.recurrence, RECURRENCES, "", true)} + ${d.recurrence === "every_n_days" + ? this._field(this._t("panel.chore_recurrence_interval_label"), "recurrence_interval", d.recurrence_interval, "number", + this._t("panel.chore_recurrence_interval_hint", { min: RECURRENCE_INTERVAL_MIN, max: RECURRENCE_INTERVAL_MAX })) + : this._field(this._t("panel.chore_recurrence_day_label"), "recurrence_day", d.recurrence_day, "text", this._t("panel.chore_recurrence_day_hint"))}
${this._dateField(this._t("panel.chore_recurrence_start_label"), "recurrence_start", d.recurrence_start, this._t("panel.chore_recurrence_start_hint"))} @@ -10630,6 +10657,18 @@ class TaskMatePanel extends HTMLElement { return `${this._esc(id)}`; } + /** Editor fields for a stored recurrence: every__days opens as the custom option. */ + _recurrenceDraft(recurrence) { + const n = recurrenceIntervalDays(recurrence); + return n && n !== 2 ? { recurrence: "every_n_days", recurrence_interval: n } : {}; + } + + _recurrenceLabel(recurrence) { + const n = recurrenceIntervalDays(recurrence); + if (n && n !== 2) return this._t("panel.recurrence_every_count_days", { count: n }); + return this._labelOf(RECURRENCES, recurrence); + } + _labelOf(arr, val) { const item = arr.find(x => x.v === val); if (!item) return val || ""; diff --git a/tests/test_every_n_days.py b/tests/test_every_n_days.py new file mode 100644 index 00000000..844dee28 --- /dev/null +++ b/tests/test_every_n_days.py @@ -0,0 +1,118 @@ +"""Custom "every N days" recurrence (#1038). + +Stored as ``every__days`` (every_2_days is the N=2 case) so availability, +calendar projection and reports all read one value with no extra field. +""" + +from __future__ import annotations + +import datetime as dt +from datetime import date, timedelta +from unittest.mock import MagicMock, patch + +import pytest +import voluptuous as vol + +from custom_components.taskmate.const import recurrence_interval_days, recurrence_period_days +from custom_components.taskmate.models import Child, Chore +from custom_components.taskmate.websocket import _recurrence + +from .test_assignment_modes import _coord as _sched_coord +from .test_coordinator_logic import _date, _make_coord +from .test_projection_report import _coord as _report_coord +from .test_projection_report import _for, _today + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("every_2_days", 2), + ("every_3_days", 3), + ("every_365_days", 365), + ("every_1_days", None), + ("every_366_days", None), + ("every_n_days", None), + ("weekly", None), + ("", None), + (None, None), + ], +) +def test_recurrence_interval_days(value, expected): + assert recurrence_interval_days(value) == expected + + +def test_recurrence_period_days_uses_the_interval(): + assert recurrence_period_days("every_10_days") == 10 + assert recurrence_period_days("every_2_weeks") == 14 + assert recurrence_period_days("bogus") == 7 + + +def test_ws_validator_rejects_out_of_range_intervals(): + assert _recurrence("every_5_days") == "every_5_days" + assert _recurrence("weekly") == "weekly" + for bad in ("every_1_days", "every_400_days", "every_n_days"): + with pytest.raises(vol.Invalid): + _recurrence(bad) + + +def _chore(**kw) -> Chore: + return Chore(name="Towels", schedule_mode="recurring", recurrence="every_3_days", **kw) + + +def _available(coord, chore, now_dt) -> bool: + import custom_components.taskmate.coordinator as _mod + + with patch.object(_mod.dt_util, "now", return_value=now_dt): + return coord.is_chore_available_for_child(chore, "kid1") + + +class TestAvailability: + def test_window_is_n_days_from_last_completion(self): + coord = _make_coord() + coord.storage.get_last_completed = MagicMock(return_value={"current": "2024-03-17T12:00:00+00:00"}) + chore = _chore() + assert _available(coord, chore, _date(2024, 3, 19)) is False # 2 days + assert _available(coord, chore, _date(2024, 3, 20)) is True # 3 days + + def test_anchor_aligns_to_every_nth_day(self): + coord = _make_coord() + coord.storage.get_last_completed = MagicMock(return_value={"current": "2024-03-01T12:00:00+00:00"}) + chore = _chore(recurrence_start="2024-03-10") + assert _available(coord, chore, _date(2024, 3, 16)) is True # anchor + 6 + assert _available(coord, chore, _date(2024, 3, 17)) is False # anchor + 7 + + def test_future_anchor_defers_first_occurrence(self): + coord = _make_coord() + coord.storage.get_last_completed = MagicMock(return_value={}) + chore = _chore(recurrence_start="2024-03-25") + assert _available(coord, chore, _date(2024, 3, 20)) is False + + +def test_calendar_projection_falls_on_every_nth_day(): + coord = _sched_coord([Child(name="A")]) + chore = Chore(name="Plant", schedule_mode="recurring", recurrence="every_5_days", recurrence_start="2026-04-20") + start = date(2026, 4, 20) + on = [o for o in range(16) if coord._is_chore_scheduled_for_date(chore, start + dt.timedelta(days=o))] + assert on == [0, 5, 10, 15] + + +def test_projection_report_counts_every_nth_day(): + chore = Chore( + name="Plant", + id="c", + points=5, + schedule_mode="recurring", + recurrence="every_3_days", + recurrence_start=_today().isoformat(), + ) + # days 0, 3, 6 of a 7-day window + assert _for(_report_coord([chore]).projection_report(7), "a")["chores"] == 3 + + +def test_falls_on_without_anchor_uses_the_interval(): + coord = _report_coord() + chore = Chore(name="Rec", id="c", schedule_mode="recurring", recurrence="every_4_days") + today = _today() + assert coord._chore_falls_on(chore, today) is True + assert coord._chore_falls_on(chore, today + timedelta(days=2)) is False + assert coord._chore_falls_on(chore, today + timedelta(days=4)) is True