fix: return extension_date regardless of server UTC date
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 4m14s

list_child_tasks used date_type.today() (server UTC) to look up the
TaskExtension record. When the client's local date differed from the
server's UTC date (e.g. user is UTC-5 and it's past midnight UTC),
the lookup returned None and extension_date was incorrectly null.

Fix:
- Add get_extension_for_child_task() to db/task_extensions.py that
  queries by child_id + task_id only, without a date filter, and
  returns the entry with the latest date when multiple exist.
- Update list_child_tasks to use the new function. The comment already
  states 'client does all time math', so returning the stored
  extension_date is correct — the frontend compares it to local date.
- In extend_chore_time, delete any existing extension for the
  child+task before inserting the new one to prevent stale records
  from accumulating across days.
- Add regression test that inserts an extension for yesterday's date
  and asserts extension_date is non-null in the list-tasks response.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-12 01:59:51 +00:00
parent 861b3dc9d4
commit bb7c4c469c
4 changed files with 47 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
from time import sleep from time import sleep
from datetime import date as date_type, datetime, timezone from datetime import datetime, timezone
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from tinydb import Query from tinydb import Query
@@ -31,7 +31,7 @@ from models.tracking_event import TrackingEvent
from utils.tracking_logger import log_tracking_event from utils.tracking_logger import log_tracking_event
from collections import defaultdict from collections import defaultdict
from db.chore_schedules import get_schedule from db.chore_schedules import get_schedule
from db.task_extensions import get_extension from db.task_extensions import get_extension_for_child_task
import logging import logging
child_api = Blueprint('child_api', __name__) child_api = Blueprint('child_api', __name__)
@@ -288,8 +288,7 @@ def list_child_tasks(id):
if task_obj.type == 'chore': if task_obj.type == 'chore':
schedule = get_schedule(id, tid) schedule = get_schedule(id, tid)
ct_dict['schedule'] = schedule.to_dict() if schedule else None ct_dict['schedule'] = schedule.to_dict() if schedule else None
today_str = date_type.today().isoformat() ext = get_extension_for_child_task(id, tid)
ext = get_extension(id, tid, today_str)
ct_dict['extension_date'] = ext.date if ext else None ct_dict['extension_date'] = ext.date if ext else None
# Attach pending confirmation status for chores # Attach pending confirmation status for chores

View File

@@ -160,11 +160,14 @@ def extend_chore_time(child_id, task_id):
if not date or not isinstance(date, str): if not date or not isinstance(date, str):
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400 return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
# 409 if already extended for this date # 409 if already extended for this exact date
existing = get_extension(child_id, task_id, date) existing = get_extension(child_id, task_id, date)
if existing: if existing:
return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409 return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
# Clear any prior extension for this child+task before inserting the new one
# so stale records from previous dates do not accumulate.
delete_extension_for_child_task(child_id, task_id)
extension = TaskExtension(child_id=child_id, task_id=task_id, date=date) extension = TaskExtension(child_id=child_id, task_id=task_id, date=date)
add_extension(extension) add_extension(extension)

View File

@@ -30,3 +30,20 @@ def delete_extensions_for_task(task_id: str) -> None:
def delete_extension_for_child_task(child_id: str, task_id: str) -> None: def delete_extension_for_child_task(child_id: str, task_id: str) -> None:
q = Query() q = Query()
task_extensions_db.remove((q.child_id == child_id) & (q.task_id == task_id)) task_extensions_db.remove((q.child_id == child_id) & (q.task_id == task_id))
def get_extension_for_child_task(child_id: str, task_id: str) -> TaskExtension | None:
"""Return the extension for a child+task without filtering by date.
Avoids timezone mismatches between the server (UTC) and the client's local
date. The caller (or the frontend) is responsible for deciding whether the
returned extension_date is still applicable.
"""
q = Query()
results = task_extensions_db.search(
(q.child_id == child_id) & (q.task_id == task_id)
)
if not results:
return None
latest = max(results, key=lambda r: r.get('date', ''))
return TaskExtension.from_dict(latest)

View File

@@ -439,6 +439,29 @@ def test_list_child_tasks_returns_extension_date_when_set(client):
assert tasks[TASK_GOOD_ID]['extension_date'] == today assert tasks[TASK_GOOD_ID]['extension_date'] == today
def test_list_child_tasks_returns_extension_date_for_yesterday(client):
"""Regression: extension inserted for yesterday's date must still be returned.
Previously, list-tasks used date_type.today() on the server to look up
extensions, causing a timezone mismatch when the client's local date
differed from the server's UTC date. The fix removes the date filter so
any stored extension is returned regardless of which date it was created
for.
"""
_setup_sched_child_and_tasks(task_db, child_db)
from datetime import date as date_type_local, timedelta
yesterday = (date_type_local.today() - timedelta(days=1)).isoformat()
task_extensions_db.insert({
'id': 'ext-yesterday',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_GOOD_ID,
'date': yesterday,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['extension_date'] == yesterday
def test_list_child_tasks_extension_date_null_when_not_set(client): def test_list_child_tasks_extension_date_null_when_not_set(client):
"""Good chore with no extension returns extension_date=null.""" """Good chore with no extension returns extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db) _setup_sched_child_and_tasks(task_db, child_db)