feat: Refactor ScheduleModal to support interval scheduling with date input and deadline toggle
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m30s

- Updated ChoreSchedule model to include anchor_date and interval_has_deadline.
- Refactored interval scheduling logic in scheduleUtils to use anchor_date.
- Introduced DateInputField component for selecting anchor dates in ScheduleModal.
- Enhanced ScheduleModal to include a stepper for interval days and a toggle for deadline.
- Updated tests for ScheduleModal and scheduleUtils to reflect new interval scheduling logic.
- Added DateInputField tests to ensure proper functionality and prop handling.
This commit is contained in:
2026-02-26 15:16:46 -05:00
parent 2403daa3f7
commit a197f8e206
12 changed files with 797 additions and 172 deletions

View File

@@ -70,19 +70,23 @@ def set_chore_schedule(child_id, task_id):
)
else:
interval_days = data.get('interval_days', 2)
anchor_weekday = data.get('anchor_weekday', 0)
anchor_date = data.get('anchor_date', '')
interval_has_deadline = data.get('interval_has_deadline', True)
interval_hour = data.get('interval_hour', 0)
interval_minute = data.get('interval_minute', 0)
if not isinstance(interval_days, int) or not (2 <= interval_days <= 7):
return jsonify({'error': 'interval_days must be an integer between 2 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(anchor_weekday, int) or not (0 <= anchor_weekday <= 6):
return jsonify({'error': 'anchor_weekday must be an integer between 0 and 6', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(anchor_date, str):
return jsonify({'error': 'anchor_date must be a string', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(interval_has_deadline, bool):
return jsonify({'error': 'interval_has_deadline must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = ChoreSchedule(
child_id=child_id,
task_id=task_id,
mode='interval',
interval_days=interval_days,
anchor_weekday=anchor_weekday,
anchor_date=anchor_date,
interval_has_deadline=interval_has_deadline,
interval_hour=interval_hour,
interval_minute=interval_minute,
)

View File

@@ -37,8 +37,9 @@ class ChoreSchedule(BaseModel):
default_minute: int = 0 # master deadline minute for 'days' mode
# mode='interval' fields
interval_days: int = 2 # 27
anchor_weekday: int = 0 # 0=Sun6=Sat
interval_days: int = 2 # 17
anchor_date: str = "" # ISO date string e.g. "2026-02-25"; "" = use today
interval_has_deadline: bool = True # False = "Anytime" (no deadline)
interval_hour: int = 0
interval_minute: int = 0
@@ -52,7 +53,8 @@ class ChoreSchedule(BaseModel):
default_hour=d.get('default_hour', 8),
default_minute=d.get('default_minute', 0),
interval_days=d.get('interval_days', 2),
anchor_weekday=d.get('anchor_weekday', 0),
anchor_date=d.get('anchor_date', ''),
interval_has_deadline=d.get('interval_has_deadline', True),
interval_hour=d.get('interval_hour', 0),
interval_minute=d.get('interval_minute', 0),
id=d.get('id'),
@@ -70,7 +72,8 @@ class ChoreSchedule(BaseModel):
'default_hour': self.default_hour,
'default_minute': self.default_minute,
'interval_days': self.interval_days,
'anchor_weekday': self.anchor_weekday,
'anchor_date': self.anchor_date,
'interval_has_deadline': self.interval_has_deadline,
'interval_hour': self.interval_hour,
'interval_minute': self.interval_minute,
})

View File

@@ -117,7 +117,8 @@ def test_set_schedule_interval_mode(client):
payload = {
"mode": "interval",
"interval_days": 3,
"anchor_weekday": 2,
"anchor_date": "2026-03-01",
"interval_has_deadline": True,
"interval_hour": 14,
"interval_minute": 30,
}
@@ -126,25 +127,57 @@ def test_set_schedule_interval_mode(client):
data = resp.get_json()
assert data["mode"] == "interval"
assert data["interval_days"] == 3
assert data["anchor_weekday"] == 2
assert data["anchor_date"] == "2026-03-01"
assert data["interval_has_deadline"] is True
assert data["interval_hour"] == 14
assert data["interval_minute"] == 30
def test_set_schedule_interval_bad_days(client):
# interval_days = 1 is out of range [27]
payload = {"mode": "interval", "interval_days": 1, "anchor_weekday": 0}
def test_set_schedule_interval_days_1_valid(client):
"""interval_days=1 is now valid (range changed to [1, 7])."""
payload = {"mode": "interval", "interval_days": 1, "anchor_date": ""}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
assert resp.get_json()["interval_days"] == 1
def test_set_schedule_interval_days_0_invalid(client):
"""interval_days=0 is still out of range."""
payload = {"mode": "interval", "interval_days": 0, "anchor_date": ""}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_interval_bad_weekday(client):
# anchor_weekday = 7 is out of range [06]
payload = {"mode": "interval", "interval_days": 2, "anchor_weekday": 7}
def test_set_schedule_interval_days_8_invalid(client):
"""interval_days=8 is still out of range."""
payload = {"mode": "interval", "interval_days": 8, "anchor_date": ""}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_interval_has_deadline_false(client):
"""interval_has_deadline=False is accepted and persisted."""
payload = {
"mode": "interval",
"interval_days": 2,
"anchor_date": "",
"interval_has_deadline": False,
"interval_hour": 0,
"interval_minute": 0,
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
assert resp.get_json()["interval_has_deadline"] is False
def test_set_schedule_interval_anchor_date_empty_string(client):
"""anchor_date empty string is valid (means use today)."""
payload = {"mode": "interval", "interval_days": 2, "anchor_date": ""}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
assert resp.get_json()["anchor_date"] == ""
def test_set_schedule_invalid_mode(client):
payload = {"mode": "weekly", "day_configs": []}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
@@ -160,13 +193,56 @@ def test_set_schedule_upserts_existing(client):
# Overwrite with interval mode
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "interval", "interval_days": 2, "anchor_weekday": 0, "interval_hour": 9, "interval_minute": 0},
json={"mode": "interval", "interval_days": 2, "anchor_date": "", "interval_hour": 9, "interval_minute": 0},
)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
assert resp.get_json()["mode"] == "interval"
def test_old_record_missing_new_fields_loads_with_defaults(client):
"""Old DB records without anchor_date/interval_has_deadline load with correct defaults."""
from db.chore_schedules import upsert_schedule
from models.chore_schedule import ChoreSchedule
# Insert a schedule as if it was created before phase 2 (missing new fields)
old_style = ChoreSchedule(
child_id=TEST_CHILD_ID,
task_id=TEST_TASK_ID,
mode='interval',
interval_days=3,
anchor_date='', # default value
interval_has_deadline=True, # default value
interval_hour=8,
interval_minute=0,
)
upsert_schedule(old_style)
# Manually wipe the new fields from the raw stored record to simulate a pre-phase-2 record
from db.db import chore_schedules_db
from tinydb import Query
ScheduleQ = Query()
record = chore_schedules_db.search(
(ScheduleQ.child_id == TEST_CHILD_ID) & (ScheduleQ.task_id == TEST_TASK_ID)
)[0]
doc_id = chore_schedules_db.get(
(ScheduleQ.child_id == TEST_CHILD_ID) & (ScheduleQ.task_id == TEST_TASK_ID)
).doc_id
chore_schedules_db.update(
lambda rec: (
rec.pop('anchor_date', None),
rec.pop('interval_has_deadline', None),
),
doc_ids=[doc_id],
)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
data = resp.get_json()
assert data['anchor_date'] == ''
assert data['interval_has_deadline'] is True
# ---------------------------------------------------------------------------
# DELETE schedule
# ---------------------------------------------------------------------------