From 8907184fded2c3065717f8c9744e1ec3e7fb2b96 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Wed, 22 Apr 2026 15:37:40 -0400 Subject: [PATCH] Add chore expiry notification system with scheduling and tests - Implemented `trigger_chore_expiry.py` script to manually trigger chore expiry notifications for users via the admin API. - Developed `chore_expiry_notification_scheduler.py` to handle the logic for sending notifications for chores expiring within the next 75 minutes. - Created utility functions in `schedule_utils.py` to determine scheduling and deadlines for chores. - Added comprehensive tests for the chore expiry notification system in `test_chore_expiry_notification_scheduler.py`, covering various scenarios including scheduled chores, confirmations, and user settings. --- .../specs/{ => archive}/feat-state-expire.md | 0 .github/specs/feat-notify-ending-chore.md | 148 +++++ backend/api/admin_api.py | 38 ++ backend/main.py | 2 + backend/scripts/trigger_chore_expiry.py | 112 ++++ ...est_chore_expiry_notification_scheduler.py | 544 ++++++++++++++++++ .../chore_expiry_notification_scheduler.py | 237 ++++++++ backend/utils/schedule_utils.py | 91 +++ frontend/vue-app/public/sw.js | 13 +- 9 files changed, 1181 insertions(+), 4 deletions(-) rename .github/specs/{ => archive}/feat-state-expire.md (100%) create mode 100644 .github/specs/feat-notify-ending-chore.md create mode 100644 backend/scripts/trigger_chore_expiry.py create mode 100644 backend/tests/test_chore_expiry_notification_scheduler.py create mode 100644 backend/utils/chore_expiry_notification_scheduler.py create mode 100644 backend/utils/schedule_utils.py diff --git a/.github/specs/feat-state-expire.md b/.github/specs/archive/feat-state-expire.md similarity index 100% rename from .github/specs/feat-state-expire.md rename to .github/specs/archive/feat-state-expire.md diff --git a/.github/specs/feat-notify-ending-chore.md b/.github/specs/feat-notify-ending-chore.md new file mode 100644 index 0000000..529b027 --- /dev/null +++ b/.github/specs/feat-notify-ending-chore.md @@ -0,0 +1,148 @@ +# Feature: Push notification for chores that are going to expire in the next hour. + +## Overview + +When a scheduled chore is going to expire within the next hour and it has not be marked completed or pending, a push notification should go out to tell the user that the scheduled chore needs to be finished soon. + +**Goal:** When a chore is about to expire (within the hour) send a push notification to the user and allow the user to acknowlege it. + +**User Story:** +As a parent, I should receive a notification when a scheduled chore is going to expire soon. When I click on the notification, I should be brought to the page with the chore. + +**Rules:** + +1. If there are multiple chores that are going to expire, present them in the push notification as well. +2. The notification should show which child the chore belongs to. +3. If a chore is scheduled to expire between 9:00pm to 9:15pm, the notification for it should go out at 8:00pm. That should give the earlier hour expiration plenty of notice. + +**Questions:** + +1. If there are multiple chores in the push, should we say there are multiple chores ending next hour or should we list each by name? +2. Should we section the notification if there are multiple children? [ex. Child1 chores: ... Child2 chores: ...] +3. Should we reuse one of the other hourly schedulers or create a new one? should we refactor and have one hourly scheduler that does multiple operations? + +--- + +## Data Model Changes + +### Backend Model + +No new models required. The feature reuses existing models: + +- `ChoreSchedule` — provides schedule mode, day configs, deadline times, and enabled state +- `PendingConfirmation` — queried to determine if a chore is already pending or approved (either status means the child has acted on it; no notification is needed) + +### Frontend Model + +No changes to TypeScript interfaces. + +--- + +## Backend Implementation + +**`backend/utils/schedule_utils.py`** (new) + +A Python port of the three core functions from `frontend/vue-app/src/common/scheduleUtils.ts`. The frontend is the authoritative source for schedule math; this file mirrors that logic server-side so the scheduler can determine which chores are active and when they expire without making HTTP calls or duplicating logic ad-hoc. + +- `interval_hits_today(anchor_date, interval_days, local_date)` — returns True if an interval-mode schedule fires on the given local date +- `is_scheduled_today(schedule_dict, local_date)` — returns True for days-mode (weekday match) or interval-mode (anchor + interval math); paused schedules always return True +- `get_due_time_today(schedule_dict, local_date)` — returns `(hour, minute)` or `None` (None means Anytime or paused — no expiry) + +Note on weekday convention: the app uses JS/Python convention where Sunday = 0 and Saturday = 6 (same as `DayConfig.day`). Python's `date.weekday()` returns Monday = 0, so a conversion of `(weekday + 1) % 7` is applied when comparing. + +**`backend/utils/chore_expiry_notification_scheduler.py`** (new) + +- `get_expiring_chores_for_user(user_id, tz_str, now_dt)` — loads all children for the user, iterates their chore tasks, checks for an active schedule with a deadline falling in `(now, now + 75 min]`, and excludes any chore that already has a `PendingConfirmation` record (any status). Returns a list of dicts with child and task metadata. +- `_build_push_payload(expiring)` — constructs the push payload. Single chore: title names the chore and deep-link fields are set for direct navigation. Multiple chores: generic title, `child_id` and `entity_id` are null, and the body lists chores grouped by child name (e.g. `Alex: Clean Room, Make Bed\nSam: Trash`). +- `send_chore_expiry_notifications_for_user(user_id, tz_str)` — calls `get_expiring_chores_for_user`, builds the payload, and calls `send_push_to_user`. +- `run_chore_expiry_check(app)` — skips the e2e environment, loops all verified users, skips users with `push_notifications_enabled=False`, and calls `send_chore_expiry_notifications_for_user` for each eligible user. +- `start_chore_expiry_notification_scheduler(app)` — registers an APScheduler `cron` job at `minute=0` (top of every hour), consistent with the existing schedulers. + +**Push payload type:** `chore_expiring_soon`. The absence of `approve_token` and `deny_token` fields is intentional and is what triggers the service worker to suppress action buttons. + +**`backend/main.py`** (modified) + +Import added and `start_chore_expiry_notification_scheduler(app)` called after the existing schedulers. + +## Backend Tests + +- [x] `get_expiring_chores_for_user`: chore in 75-min window → included +- [x] `get_expiring_chores_for_user`: deadline in the past → excluded +- [x] `get_expiring_chores_for_user`: deadline beyond 75-min window → excluded +- [x] `get_expiring_chores_for_user`: Anytime schedule (no deadline) → excluded +- [x] `get_expiring_chores_for_user`: chore not scheduled today (wrong weekday) → excluded +- [x] `get_expiring_chores_for_user`: status=pending confirmation exists → excluded +- [x] `get_expiring_chores_for_user`: status=approved confirmation exists → excluded +- [x] `get_expiring_chores_for_user`: no schedule → excluded +- [x] `get_expiring_chores_for_user`: paused schedule (enabled=False) → excluded +- [x] `get_expiring_chores_for_user`: interval mode hits today → included +- [x] `get_expiring_chores_for_user`: interval mode misses today → excluded +- [x] `get_expiring_chores_for_user`: multiple children returns entries for both +- [x] `_build_push_payload`: single chore → named title, deep-link fields set +- [x] `_build_push_payload`: multiple chores → generic title, child_id/entity_id null +- [x] `_build_push_payload`: body groups chore names by child +- [x] `_build_push_payload`: no approve_token or deny_token in payload +- [x] `send_chore_expiry_notifications_for_user`: calls send_push_to_user with correct payload +- [x] `send_chore_expiry_notifications_for_user`: no push when no chores expiring +- [x] `run_chore_expiry_check`: skips when DB_ENV=e2e +- [x] `run_chore_expiry_check`: skips user with push_notifications_enabled=False +- [x] `run_chore_expiry_check`: skips unverified user +- [x] `run_chore_expiry_check`: sends push for eligible verified user with push enabled +- [x] `schedule_utils`: interval_hits_today — same day as anchor hits +- [x] `schedule_utils`: interval_hits_today — correct interval day hits +- [x] `schedule_utils`: interval_hits_today — non-interval day misses +- [x] `schedule_utils`: interval_hits_today — date before anchor misses +- [x] `schedule_utils`: interval_hits_today — empty anchor hits today +- [x] `schedule_utils`: is_scheduled_today — days mode matching weekday +- [x] `schedule_utils`: is_scheduled_today — days mode non-matching weekday +- [x] `schedule_utils`: is_scheduled_today — paused always true +- [x] `schedule_utils`: is_scheduled_today — interval mode hit +- [x] `schedule_utils`: is_scheduled_today — interval mode miss +- [x] `schedule_utils`: get_due_time_today — days mode returns (hour, minute) +- [x] `schedule_utils`: get_due_time_today — Anytime returns None +- [x] `schedule_utils`: get_due_time_today — wrong day returns None +- [x] `schedule_utils`: get_due_time_today — paused returns None +- [x] `schedule_utils`: get_due_time_today — interval mode returns (hour, minute) +- [x] `schedule_utils`: get_due_time_today — interval Anytime returns None + +--- + +## Frontend Implementation + +**`frontend/vue-app/public/sw.js`** (modified) + +In the `push` event handler, the `actions` array passed to `showNotification` is now conditionally set: when `payload.type === 'chore_expiring_soon'` the array is empty (no buttons); otherwise Approve and Deny buttons are shown as before. The `notificationclick` handler requires no changes — tapping the notification body already navigates to `/parent/{child_id}?scrollTo={entity_id}` when those fields are present, and falls back to `/parent` when they are null (multi-chore case). + +## Frontend Tests + +No automated tests added. The `sw.js` change is a simple conditional and is covered by manual verification: triggering a `chore_expiring_soon` push and confirming no action buttons appear in the browser notification, and that tapping opens the correct URL. + +--- + +## Future Considerations + +- **75-minute window double-notification**: A chore deadline that falls in the overlap of two consecutive 75-minute windows (e.g. 9:10 pm appears in both the 8:00 pm run's window and the 9:00 pm run's window) will produce two notifications. This is a known and accepted trade-off; no deduplication is applied. A future improvement could track notified chores per day in a lightweight DB table to prevent repeats. +- **Task extensions**: `TaskExtension` sets an `extension_date` that allows a chore to carry over to another day. Extended chores are treated identically to regular scheduled chores — the schedule deadline time is used as-is. A future enhancement could optionally adjust the deadline time for extended chores. + +--- + +## Acceptance Criteria (Definition of Done) + +### Backend + +- [x] `backend/utils/schedule_utils.py` created — Python port of schedule math from `scheduleUtils.ts` +- [x] `backend/utils/chore_expiry_notification_scheduler.py` created — hourly scheduler with 75-min look-ahead window +- [x] Chores with `Anytime` schedule (no deadline) are not notified +- [x] Chores with a `pending` or `approved` confirmation are not notified +- [x] Chores on paused schedules are not notified +- [x] Users with `push_notifications_enabled=False` are skipped +- [x] Unverified users are skipped +- [x] e2e environment is skipped +- [x] Scheduler registered in `backend/main.py` +- [x] All backend tests pass + +### Frontend + +- [x] `sw.js` updated — `chore_expiring_soon` notifications show no Approve/Deny action buttons +- [x] Tapping a single-chore notification navigates to the child's chore page +- [x] Tapping a multi-chore notification navigates to `/parent` diff --git a/backend/api/admin_api.py b/backend/api/admin_api.py index a1d7d8f..1e6c973 100644 --- a/backend/api/admin_api.py +++ b/backend/api/admin_api.py @@ -247,3 +247,41 @@ def send_test_digest(): return jsonify({'items_sent': items_sent}), 200 except Exception as e: return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500 + + +@admin_api.route('/admin/test/trigger-chore-expiry', methods=['POST']) +def trigger_test_chore_expiry(): + """Trigger the chore expiry notification check for a specific user by email address. + + Only active when DB_ENV is not 'production'. Requires admin authentication. + Note: actual push delivery requires VAPID keys to be configured. + """ + if os.environ.get('DB_ENV') == 'production': + return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404 + + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + + caller_dict = users_db.get(Query().id == user_id) + if not caller_dict or caller_dict.get('role') != 'admin': + return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403 + + data = request.get_json() or {} + email = data.get('email', '').strip().lower() + if not email: + return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400 + + target = users_db.get(Query().email == email) + if not target: + return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404 + + target_id = target.get('id') + tz_str = target.get('timezone') + + try: + from utils.chore_expiry_notification_scheduler import send_chore_expiry_notifications_for_user + chores_notified = send_chore_expiry_notifications_for_user(target_id, tz_str) + return jsonify({'chores_notified': chores_notified}), 200 + except Exception as e: + return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500 diff --git a/backend/main.py b/backend/main.py index 55343d3..cb403b8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -26,6 +26,7 @@ from events.broadcaster import Broadcaster from events.sse import sse_response_for_user, send_to_user from api.utils import get_current_user_id from utils.account_deletion_scheduler import start_deletion_scheduler +from utils.chore_expiry_notification_scheduler import start_chore_expiry_notification_scheduler from utils.digest_scheduler import start_digest_scheduler from utils.state_expiry_scheduler import start_state_expiry_scheduler @@ -142,6 +143,7 @@ start_background_threads() start_deletion_scheduler() start_digest_scheduler(app) start_state_expiry_scheduler(app) +start_chore_expiry_notification_scheduler(app) if __name__ == '__main__': app.run(debug=False, host='0.0.0.0', port=5000, threaded=True) \ No newline at end of file diff --git a/backend/scripts/trigger_chore_expiry.py b/backend/scripts/trigger_chore_expiry.py new file mode 100644 index 0000000..41a1283 --- /dev/null +++ b/backend/scripts/trigger_chore_expiry.py @@ -0,0 +1,112 @@ +""" +Script to trigger the chore expiry notification check for a specific user via the admin API. + +Usage: + python scripts/trigger_chore_expiry.py + +The script logs in as an admin using the ADMIN_EMAIL and ADMIN_PASSWORD +environment variables (defaults to localhost:5000). + +Environment variables: + ADMIN_EMAIL - Admin account email (required) + ADMIN_PASSWORD - Admin account password (required) + API_BASE_URL - Base URL of the backend API (default: http://localhost:5000) + +NOTE: When targeting a proxied frontend URL (e.g. nginx), append /api to the URL. + e.g. API_BASE_URL=https://dev.chores.ryankegel.com/api +""" +import sys +import os +import json +import urllib.request +import urllib.error + +API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:5000').rstrip('/') + + +def _post(path: str, body: dict, cookie: str | None = None) -> tuple[int, dict]: + url = f"{API_BASE_URL}{path}" + data = json.dumps(body).encode() + headers = {'Content-Type': 'application/json'} + if cookie: + headers['Cookie'] = cookie + req = urllib.request.Request(url, data=data, headers=headers, method='POST') + try: + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as e: + try: + return e.code, json.loads(e.read()) + except Exception: + return e.code, {} + + +def _login() -> str: + """Log in as admin and return the cookie header string.""" + admin_email = os.environ.get('ADMIN_EMAIL', '').strip() + admin_password = os.environ.get('ADMIN_PASSWORD', '').strip() + + if not admin_email or not admin_password: + print('Error: ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required') + sys.exit(1) + + login_url = f"{API_BASE_URL}/auth/login" + login_data = json.dumps({'email': admin_email, 'password': admin_password}).encode() + login_req = urllib.request.Request( + login_url, + data=login_data, + headers={'Content-Type': 'application/json'}, + method='POST', + ) + try: + with urllib.request.urlopen(login_req) as resp: + raw_cookies = resp.headers.get_all('Set-Cookie') or [] + except urllib.error.HTTPError as e: + print(f'Login failed ({e.code})') + sys.exit(1) + + cookie_header = '; '.join(part.split(';')[0] for part in raw_cookies) + if not cookie_header: + print('Login succeeded but no cookies were returned') + sys.exit(1) + + return cookie_header + + +def main() -> None: + if len(sys.argv) != 2: + print('Usage: python scripts/trigger_chore_expiry.py ') + sys.exit(1) + + target_email = sys.argv[1].strip() + cookie = _login() + + status, body = _post( + '/admin/test/trigger-chore-expiry', + {'email': target_email}, + cookie=cookie, + ) + + if status == 200: + count = body.get('chores_notified', 0) + if count == 0: + print(f'No expiring chores found for {target_email} in the next 75 minutes — no push sent.') + else: + print(f'Push notification sent for {count} expiring chore(s) for {target_email}.') + elif status == 404 and body.get('code') == 'NOT_FOUND': + print('Error: This endpoint is disabled in the current environment (DB_ENV=production).') + sys.exit(1) + elif status == 404 and body.get('code') == 'USER_NOT_FOUND': + print(f'Error: No user found with email {target_email}') + sys.exit(1) + elif status == 403: + admin_email = os.environ.get('ADMIN_EMAIL', '') + print(f'Error: {admin_email} is not an admin account.') + sys.exit(1) + else: + print(f'Error ({status}): {body.get("error", body)}') + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/backend/tests/test_chore_expiry_notification_scheduler.py b/backend/tests/test_chore_expiry_notification_scheduler.py new file mode 100644 index 0000000..b99f2a4 --- /dev/null +++ b/backend/tests/test_chore_expiry_notification_scheduler.py @@ -0,0 +1,544 @@ +"""Tests for the chore expiry notification scheduler.""" +import os +import pytest +from datetime import datetime, timedelta, timezone +from unittest.mock import patch, MagicMock +from flask import Flask +from tinydb import Query + +from utils.chore_expiry_notification_scheduler import ( + get_expiring_chores_for_user, + _build_push_payload, + send_chore_expiry_notifications_for_user, + run_chore_expiry_check, +) +from utils.schedule_utils import interval_hits_today, is_scheduled_today, get_due_time_today +from db.db import users_db, child_db, task_db, chore_schedules_db, pending_confirmations_db +from tests.conftest import TEST_SECRET_KEY + +USER_ID = "expiry_notif_user" +CHILD_ID = "expiry_notif_child" +CHILD_ID_2 = "expiry_notif_child_2" +TASK_ID = "expiry_notif_task" +TASK_ID_2 = "expiry_notif_task_2" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _seed_user(push_enabled: bool = True, verified: bool = True): + users_db.remove(Query().id == USER_ID) + users_db.insert({ + "id": USER_ID, + "first_name": "Notify", + "last_name": "Tester", + "email": f"{USER_ID}@example.com", + "password": "hashed", + "verified": verified, + "role": "user", + "image_id": None, + "marked_for_deletion": False, + "timezone": "UTC", + "email_digest_enabled": False, + "push_notifications_enabled": push_enabled, + }) + + +def _seed_child(child_id: str = CHILD_ID, task_ids: list = None): + if task_ids is None: + task_ids = [TASK_ID] + child_db.remove(Query().id == child_id) + child_db.insert({ + "id": child_id, + "user_id": USER_ID, + "name": "Alex" if child_id == CHILD_ID else "Sam", + "age": 8, + "points": 0, + "tasks": task_ids, + "rewards": [], + "image_id": None, + }) + + +def _seed_task(task_id: str = TASK_ID, name: str = "Clean Room"): + task_db.remove(Query().id == task_id) + task_db.insert({ + "id": task_id, + "user_id": USER_ID, + "name": name, + "points": 10, + "type": "chore", + "image_id": None, + }) + + +def _seed_schedule( + child_id: str = CHILD_ID, + task_id: str = TASK_ID, + mode: str = "days", + day_configs: list = None, + default_has_deadline: bool = True, + default_hour: int = 21, + default_minute: int = 0, + enabled: bool = True, + interval_days: int = 1, + anchor_date: str = "", + interval_has_deadline: bool = True, + interval_hour: int = 21, + interval_minute: int = 0, +): + """Seed a chore schedule. For 'days' mode, day_configs defaults to all 7 days.""" + if day_configs is None: + day_configs = [ + {"day": d, "hour": default_hour, "minute": default_minute} + for d in range(7) + ] + chore_schedules_db.remove( + (Query().child_id == child_id) & (Query().task_id == task_id) + ) + chore_schedules_db.insert({ + "id": f"sched_{child_id}_{task_id}", + "child_id": child_id, + "task_id": task_id, + "mode": mode, + "day_configs": day_configs, + "default_hour": default_hour, + "default_minute": default_minute, + "default_has_deadline": default_has_deadline, + "interval_days": interval_days, + "anchor_date": anchor_date, + "interval_has_deadline": interval_has_deadline, + "interval_hour": interval_hour, + "interval_minute": interval_minute, + "enabled": enabled, + }) + + +def _seed_confirmation(child_id: str = CHILD_ID, task_id: str = TASK_ID, status: str = "pending"): + pending_confirmations_db.remove( + (Query().child_id == child_id) & (Query().entity_id == task_id) + ) + pending_confirmations_db.insert({ + "id": f"conf_{child_id}_{task_id}", + "user_id": USER_ID, + "child_id": child_id, + "entity_id": task_id, + "entity_type": "chore", + "status": status, + "approved_at": None, + "created_at": 0, + "updated_at": 0, + }) + + +def _cleanup(): + for cid in (CHILD_ID, CHILD_ID_2): + child_db.remove(Query().id == cid) + chore_schedules_db.remove(Query().child_id == cid) + pending_confirmations_db.remove(Query().child_id == cid) + for tid in (TASK_ID, TASK_ID_2): + task_db.remove(Query().id == tid) + users_db.remove(Query().id == USER_ID) + + +def _now_with_deadline_in_window(minutes_ahead: int = 30) -> tuple[datetime, int, int]: + """Return (now_dt, hour, minute) so that deadline = now + minutes_ahead.""" + now = datetime.now(timezone.utc) + deadline = now + timedelta(minutes=minutes_ahead) + return now, deadline.hour, deadline.minute + + +@pytest.fixture +def app(): + flask_app = Flask(__name__) + flask_app.config['TESTING'] = True + flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY + return flask_app + + +# --------------------------------------------------------------------------- +# schedule_utils unit tests +# --------------------------------------------------------------------------- + +class TestIntervalHitsToday: + def test_same_day_as_anchor_hits(self): + from datetime import date + d = date(2026, 4, 22) + assert interval_hits_today("2026-04-22", 3, d) is True + + def test_interval_day_hits(self): + from datetime import date + assert interval_hits_today("2026-04-22", 3, date(2026, 4, 25)) is True + + def test_non_interval_day_misses(self): + from datetime import date + assert interval_hits_today("2026-04-22", 3, date(2026, 4, 24)) is False + + def test_before_anchor_misses(self): + from datetime import date + assert interval_hits_today("2026-04-22", 1, date(2026, 4, 21)) is False + + def test_empty_anchor_hits_today(self): + from datetime import date + d = date(2026, 4, 22) + assert interval_hits_today("", 1, d) is True + + +class TestIsScheduledToday: + def test_days_mode_matching_weekday(self): + from datetime import date + # 2026-04-22 is Wednesday → JS weekday 3 (Sun=0..Sat=6) + schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 3}]} + assert is_scheduled_today(schedule, date(2026, 4, 22)) is True + + def test_days_mode_non_matching_weekday(self): + from datetime import date + schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 1}]} + assert is_scheduled_today(schedule, date(2026, 4, 22)) is False + + def test_paused_schedule_always_true(self): + from datetime import date + schedule = {"mode": "days", "enabled": False, "day_configs": []} + assert is_scheduled_today(schedule, date(2026, 4, 22)) is True + + def test_interval_mode_hit(self): + from datetime import date + schedule = { + "mode": "interval", "enabled": True, + "interval_days": 1, "anchor_date": "2026-04-22", + } + assert is_scheduled_today(schedule, date(2026, 4, 22)) is True + + def test_interval_mode_miss(self): + from datetime import date + schedule = { + "mode": "interval", "enabled": True, + "interval_days": 3, "anchor_date": "2026-04-22", + } + assert is_scheduled_today(schedule, date(2026, 4, 23)) is False + + +class TestGetDueTimeToday: + def test_days_mode_returns_due_time(self): + from datetime import date + # Wednesday → JS 3 (Sun=0..Sat=6) + schedule = { + "mode": "days", "enabled": True, + "default_has_deadline": True, + "day_configs": [{"day": 3, "hour": 21, "minute": 0}], + } + assert get_due_time_today(schedule, date(2026, 4, 22)) == (21, 0) + + def test_days_mode_anytime_returns_none(self): + from datetime import date + schedule = { + "mode": "days", "enabled": True, + "default_has_deadline": False, + "day_configs": [{"day": 4, "hour": 21, "minute": 0}], + } + assert get_due_time_today(schedule, date(2026, 4, 22)) is None + + def test_days_mode_wrong_day_returns_none(self): + from datetime import date + schedule = { + "mode": "days", "enabled": True, + "default_has_deadline": True, + "day_configs": [{"day": 1, "hour": 21, "minute": 0}], + } + assert get_due_time_today(schedule, date(2026, 4, 22)) is None + + def test_paused_returns_none(self): + from datetime import date + schedule = { + "mode": "days", "enabled": False, + "default_has_deadline": True, + "day_configs": [{"day": 4, "hour": 21, "minute": 0}], + } + assert get_due_time_today(schedule, date(2026, 4, 22)) is None + + def test_interval_mode_returns_due_time(self): + from datetime import date + schedule = { + "mode": "interval", "enabled": True, + "interval_days": 1, "anchor_date": "2026-04-22", + "interval_has_deadline": True, + "interval_hour": 20, "interval_minute": 30, + } + assert get_due_time_today(schedule, date(2026, 4, 22)) == (20, 30) + + def test_interval_mode_anytime_returns_none(self): + from datetime import date + schedule = { + "mode": "interval", "enabled": True, + "interval_days": 1, "anchor_date": "2026-04-22", + "interval_has_deadline": False, + "interval_hour": 20, "interval_minute": 30, + } + assert get_due_time_today(schedule, date(2026, 4, 22)) is None + + +# --------------------------------------------------------------------------- +# get_expiring_chores_for_user tests +# --------------------------------------------------------------------------- + +class TestGetExpiringChoresForUser: + def setup_method(self): + _cleanup() + _seed_user() + _seed_child() + _seed_task() + + def teardown_method(self): + _cleanup() + + def test_chore_in_window_included(self, app): + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(default_hour=h, default_minute=m) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert len(result) == 1 + assert result[0]['task_id'] == TASK_ID + assert result[0]['child_id'] == CHILD_ID + + def test_chore_deadline_past_excluded(self, app): + # Deadline 10 minutes in the past + now = datetime.now(timezone.utc) + past = now - timedelta(minutes=10) + _seed_schedule(default_hour=past.hour, default_minute=past.minute) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_chore_deadline_beyond_window_excluded(self, app): + # Deadline 80 minutes ahead (beyond 75-min window) + now, h, m = _now_with_deadline_in_window(minutes_ahead=80) + _seed_schedule(default_hour=h, default_minute=m) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_anytime_chore_excluded(self, app): + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(default_has_deadline=False, default_hour=h, default_minute=m) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_wrong_day_excluded(self, app): + from datetime import date + now = datetime.now(timezone.utc) + # Schedule only on a day that is NOT today + today_js = (now.weekday() + 1) % 7 + wrong_day = (today_js + 1) % 7 + h = (now + timedelta(minutes=30)).hour + m = (now + timedelta(minutes=30)).minute + _seed_schedule( + day_configs=[{"day": wrong_day, "hour": h, "minute": m}], + default_hour=h, + default_minute=m, + ) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_pending_confirmation_excludes_chore(self, app): + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(default_hour=h, default_minute=m) + _seed_confirmation(status="pending") + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_approved_confirmation_excludes_chore(self, app): + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(default_hour=h, default_minute=m) + _seed_confirmation(status="approved") + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_no_schedule_excluded(self, app): + now = datetime.now(timezone.utc) + # No schedule seeded + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_paused_schedule_excluded(self, app): + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(enabled=False, default_hour=h, default_minute=m) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_interval_mode_hit_included(self, app): + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + today_iso = now.strftime("%Y-%m-%d") + _seed_schedule( + mode="interval", + interval_days=1, + anchor_date=today_iso, + interval_has_deadline=True, + interval_hour=h, + interval_minute=m, + ) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert len(result) == 1 + + def test_interval_mode_miss_excluded(self, app): + now = datetime.now(timezone.utc) + h = (now + timedelta(minutes=30)).hour + m = (now + timedelta(minutes=30)).minute + # Anchor yesterday with interval_days=2 → not today + yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d") + _seed_schedule( + mode="interval", + interval_days=2, + anchor_date=yesterday, + interval_has_deadline=True, + interval_hour=h, + interval_minute=m, + ) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert result == [] + + def test_multiple_children_grouped(self, app): + _seed_child(CHILD_ID_2, task_ids=[TASK_ID_2]) + _seed_task(TASK_ID_2, name="Take Out Trash") + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(child_id=CHILD_ID, task_id=TASK_ID, default_hour=h, default_minute=m) + _seed_schedule(child_id=CHILD_ID_2, task_id=TASK_ID_2, default_hour=h, default_minute=m) + with app.app_context(): + result = get_expiring_chores_for_user(USER_ID, "UTC", now) + assert len(result) == 2 + child_ids = {r['child_id'] for r in result} + assert CHILD_ID in child_ids + assert CHILD_ID_2 in child_ids + + +# --------------------------------------------------------------------------- +# _build_push_payload tests +# --------------------------------------------------------------------------- + +class TestBuildPushPayload: + def test_single_chore_sets_title_and_deep_link(self): + expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}] + payload = _build_push_payload(expiring) + assert payload['title'] == "Chore ending soon: Clean Room" + assert payload['child_id'] == "c1" + assert payload['entity_id'] == "t1" + assert payload['type'] == 'chore_expiring_soon' + + def test_multiple_chores_uses_generic_title(self): + expiring = [ + {"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}, + {"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"}, + ] + payload = _build_push_payload(expiring) + assert payload['title'] == "Chores ending soon" + assert payload['child_id'] is None + assert payload['entity_id'] is None + + def test_body_groups_by_child(self): + expiring = [ + {"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}, + {"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"}, + {"child_id": "c2", "child_name": "Sam", "task_id": "t3", "task_name": "Trash"}, + ] + payload = _build_push_payload(expiring) + assert "Alex: Clean Room, Make Bed" in payload['body'] + assert "Sam: Trash" in payload['body'] + + def test_no_approve_deny_tokens(self): + expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}] + payload = _build_push_payload(expiring) + assert 'approve_token' not in payload + assert 'deny_token' not in payload + + +# --------------------------------------------------------------------------- +# send_chore_expiry_notifications_for_user tests +# --------------------------------------------------------------------------- + +class TestSendChoreExpiryNotificationsForUser: + def setup_method(self): + _cleanup() + _seed_user() + _seed_child() + _seed_task() + + def teardown_method(self): + _cleanup() + + def test_sends_push_when_chore_expiring(self, app): + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(default_hour=h, default_minute=m) + with app.app_context(): + with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push: + count = send_chore_expiry_notifications_for_user(USER_ID, "UTC") + assert count == 1 + mock_push.assert_called_once() + payload = mock_push.call_args[0][1] + assert payload['type'] == 'chore_expiring_soon' + + def test_no_push_when_no_expiring_chores(self, app): + # No schedule seeded → nothing expiring + with app.app_context(): + with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push: + count = send_chore_expiry_notifications_for_user(USER_ID, "UTC") + assert count == 0 + mock_push.assert_not_called() + + +# --------------------------------------------------------------------------- +# run_chore_expiry_check tests +# --------------------------------------------------------------------------- + +class TestRunChoreExpiryCheck: + def setup_method(self): + _cleanup() + + def teardown_method(self): + _cleanup() + + def test_skips_when_db_env_is_e2e(self, app): + with patch.dict(os.environ, {'DB_ENV': 'e2e'}): + with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push: + run_chore_expiry_check(app) + mock_push.assert_not_called() + + def test_skips_user_with_push_disabled(self, app): + _seed_user(push_enabled=False) + _seed_child() + _seed_task() + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(default_hour=h, default_minute=m) + with app.app_context(): + with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push: + run_chore_expiry_check(app) + mock_push.assert_not_called() + + def test_skips_unverified_user(self, app): + _seed_user(verified=False) + _seed_child() + _seed_task() + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(default_hour=h, default_minute=m) + with app.app_context(): + with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push: + run_chore_expiry_check(app) + mock_push.assert_not_called() + + def test_sends_push_for_eligible_user(self, app): + _seed_user(push_enabled=True, verified=True) + _seed_child() + _seed_task() + now, h, m = _now_with_deadline_in_window(minutes_ahead=30) + _seed_schedule(default_hour=h, default_minute=m) + with app.app_context(): + with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push: + run_chore_expiry_check(app) + mock_push.assert_called_once() diff --git a/backend/utils/chore_expiry_notification_scheduler.py b/backend/utils/chore_expiry_notification_scheduler.py new file mode 100644 index 0000000..8a3ce98 --- /dev/null +++ b/backend/utils/chore_expiry_notification_scheduler.py @@ -0,0 +1,237 @@ +""" +Hourly scheduler that sends push notifications to parents when their children's +scheduled chores are going to expire within the next 75 minutes and haven't been +marked completed or submitted for confirmation yet. + +Window rationale (75 min): + The spec requires that chores expiring between 9:00 pm and 9:15 pm both + generate a notification at 8:00 pm. A 75-minute look-ahead window + (now < deadline <= now + 75 min) satisfies this for the hourly cron run at + the top of each hour. + +Double-notification note: + A chore deadline that falls within the overlap of two consecutive 75-minute + windows (e.g. 9:10 pm appears in both the 8:00 pm and 9:00 pm windows) may + produce two push notifications. No deduplication is applied; this is + intentional per the product decision made during spec review. +""" + +import logging +import os +from datetime import datetime, timedelta, timezone + +from apscheduler.schedulers.background import BackgroundScheduler +from tinydb import Query + +from utils.schedule_utils import get_due_time_today, is_scheduled_today +from utils.push_sender import send_push_to_user + +logger = logging.getLogger(__name__) + +_WINDOW_MINUTES = 75 + + +def get_expiring_chores_for_user( + user_id: str, + tz_str: str | None, + now_dt: datetime, +) -> list[dict]: + """Return chores that will expire within the next _WINDOW_MINUTES minutes. + + Each entry is:: + + { + 'child_id': str, + 'child_name': str, + 'task_id': str, + 'task_name': str, + } + + A chore is included only when ALL of the following are true: + - It has a ChoreSchedule with enabled=True + - It is scheduled today (days or interval mode) + - It has a deadline (not Anytime) + - The deadline falls in (now_dt, now_dt + _WINDOW_MINUTES] + - There is no existing pending_confirmation record for this child + task + (status='pending' means awaiting approval; status='approved' means done) + + Must be called within a Flask app context. + """ + from db.db import child_db, task_db, pending_confirmations_db + from db.chore_schedules import get_schedule + + try: + from zoneinfo import ZoneInfo + local_now = datetime.now(ZoneInfo(tz_str)) if tz_str else now_dt + except Exception: + local_now = now_dt + + local_date = local_now.date() + window_end = local_now + timedelta(minutes=_WINDOW_MINUTES) + + ChildQ = Query() + children = child_db.search(ChildQ.user_id == user_id) + + expiring: list[dict] = [] + + for child_dict in children: + child_id = child_dict.get('id') + child_name = child_dict.get('name', 'Unknown') + task_ids = child_dict.get('tasks', []) + + TaskQ = Query() + for task_id in task_ids: + task = task_db.get( + (TaskQ.id == task_id) & + (TaskQ.type == 'chore') & + ((TaskQ.user_id == user_id) | (TaskQ.user_id == None)) + ) + if not task: + continue + + schedule = get_schedule(child_id, task_id) + if schedule is None: + continue + + schedule_dict = schedule.to_dict() + + if not schedule_dict.get('enabled', True): + continue # paused schedules have no deadline + + if not is_scheduled_today(schedule_dict, local_date): + continue + + due = get_due_time_today(schedule_dict, local_date) + if due is None: + continue # Anytime — no expiry + + due_hour, due_minute = due + # Build a timezone-aware deadline datetime for today + deadline_dt = local_now.replace( + hour=due_hour, minute=due_minute, second=0, microsecond=0 + ) + + # Include only when deadline is strictly after now and within window + if not (local_now < deadline_dt <= window_end): + continue + + # Skip if any confirmation record exists (pending OR approved = done today) + PendingQ = Query() + has_confirmation = pending_confirmations_db.get( + (PendingQ.child_id == child_id) & + (PendingQ.entity_id == task_id) & + (PendingQ.entity_type == 'chore') & + (PendingQ.user_id == user_id) + ) + if has_confirmation: + continue + + expiring.append({ + 'child_id': child_id, + 'child_name': child_name, + 'task_id': task_id, + 'task_name': task.get('name', 'Unknown'), + }) + + return expiring + + +def _build_push_payload(expiring: list[dict]) -> dict: + """Build the push notification payload from a list of expiring chore entries.""" + if len(expiring) == 1: + entry = expiring[0] + title = f"Chore ending soon: {entry['task_name']}" + child_id = entry['child_id'] + entity_id = entry['task_id'] + else: + title = 'Chores ending soon' + child_id = None + entity_id = None + + # Group by child and format body text + by_child: dict[str, list[str]] = {} + for entry in expiring: + by_child.setdefault(entry['child_name'], []).append(entry['task_name']) + + body_lines = [ + f"{name}: {', '.join(tasks)}" for name, tasks in by_child.items() + ] + body = '\n'.join(body_lines) + + return { + 'type': 'chore_expiring_soon', + 'title': title, + 'body': body, + 'child_id': child_id, + 'entity_id': entity_id, + 'entity_type': 'chore', + } + + +def send_chore_expiry_notifications_for_user(user_id: str, tz_str: str | None) -> int: + """Check for expiring chores and send a push notification if any are found. + + Returns the number of chores included in the notification (0 = nothing sent). + Must be called within a Flask app context. + """ + now_dt = datetime.now(timezone.utc) + expiring = get_expiring_chores_for_user(user_id, tz_str, now_dt) + if not expiring: + return 0 + + payload = _build_push_payload(expiring) + send_push_to_user(user_id, payload) + logger.info( + f'Chore expiry: sent notification for {len(expiring)} chore(s) to user {user_id}' + ) + return len(expiring) + + +def run_chore_expiry_check(app) -> None: + """Check all verified, push-enabled users for soon-to-expire chores.""" + if os.environ.get('DB_ENV') == 'e2e': + return + + with app.app_context(): + try: + from db.db import users_db + + UserQ = Query() + users = users_db.search(UserQ.verified == True) + + for user_dict in users: + user_id = user_dict.get('id') + tz_str = user_dict.get('timezone') + + if not user_dict.get('push_notifications_enabled', True): + continue + + try: + count = send_chore_expiry_notifications_for_user(user_id, tz_str) + if count: + logger.info( + f'Chore expiry: notified {count} expiring chore(s) for user {user_id}' + ) + except Exception as e: + logger.error(f'Chore expiry: failed for user {user_id}: {e}') + + except Exception as e: + logger.error(f'Error in run_chore_expiry_check: {e}') + + +def start_chore_expiry_notification_scheduler(app) -> BackgroundScheduler: + """Start the hourly chore-expiry notification scheduler. + + Runs at the top of every hour (minute=0), matching the existing schedulers. + Returns the scheduler instance. + """ + scheduler = BackgroundScheduler() + scheduler.add_job( + run_chore_expiry_check, + 'cron', + minute=0, + args=[app], + ) + scheduler.start() + logger.info('Chore expiry notification scheduler started (runs every hour at :00)') + return scheduler diff --git a/backend/utils/schedule_utils.py b/backend/utils/schedule_utils.py new file mode 100644 index 0000000..08d7c51 --- /dev/null +++ b/backend/utils/schedule_utils.py @@ -0,0 +1,91 @@ +""" +Python port of the frontend schedule logic in scheduleUtils.ts. + +These utilities determine whether a chore is scheduled on a given day and what +its deadline time is. The frontend is the source of truth for schedule math; +keep this file in sync with any changes to scheduleUtils.ts. +""" + +from datetime import date + + +def interval_hits_today(anchor_date: str, interval_days: int, local_date: date) -> bool: + """Return True if an interval-mode schedule fires on local_date. + + anchor_date: ISO date string e.g. "2026-02-25", or "" (backward compat → + treats local_date itself as anchor, so it always hits today and + every interval_days days after). + Dates before the anchor always return False (scheduling hasn't started yet). + """ + if anchor_date: + parts = anchor_date.split('-') + anchor = date(int(parts[0]), int(parts[1]), int(parts[2])) + else: + # Empty anchor = start from today (backward compat) + anchor = local_date + + diff_days = (local_date - anchor).days + if diff_days < 0: + return False + return diff_days % interval_days == 0 + + +def is_scheduled_today(schedule: dict, local_date: date) -> bool: + """Return True if the schedule applies on local_date. + + - days mode: today's weekday (0=Sun … 6=Sat) is in day_configs + - interval mode: interval_hits_today + - paused (enabled=False): always True (chore shows every day, like no schedule) + """ + if not schedule.get('enabled', True): + return True # paused = show always, like an unscheduled chore + + mode = schedule.get('mode', 'days') + if mode == 'days': + # Python's weekday(): Mon=0..Sun=6. Our convention: Sun=0..Sat=6 (JS/TS). + # Convert: js_day = (python_weekday + 1) % 7 + js_weekday = (local_date.weekday() + 1) % 7 + day_configs = schedule.get('day_configs', []) + return any(dc.get('day') == js_weekday for dc in day_configs) + else: + return interval_hits_today( + schedule.get('anchor_date', ''), + schedule.get('interval_days', 2), + local_date, + ) + + +def get_due_time_today(schedule: dict, local_date: date) -> tuple[int, int] | None: + """Return (hour, minute) for the deadline today, or None if no deadline applies. + + Returns None when: + - The schedule is paused (enabled=False) — no deadline, acts like Anytime + - The day is not scheduled + - The schedule has no deadline (default_has_deadline=False or + interval_has_deadline=False → 'Anytime') + + Callers treat None as 'active all day with no expiry'. + """ + if not schedule.get('enabled', True): + return None # paused = no deadline, no expiry + + mode = schedule.get('mode', 'days') + if mode == 'days': + if not schedule.get('default_has_deadline', True): + return None # Anytime + js_weekday = (local_date.weekday() + 1) % 7 + day_configs = schedule.get('day_configs', []) + dc = next((d for d in day_configs if d.get('day') == js_weekday), None) + if dc is None: + return None + return (dc.get('hour', 0), dc.get('minute', 0)) + else: + if not interval_hits_today( + schedule.get('anchor_date', ''), + schedule.get('interval_days', 2), + local_date, + ): + return None + if not schedule.get('interval_has_deadline', True): + return None # Anytime + return (schedule.get('interval_hour', 0), schedule.get('interval_minute', 0)) diff --git a/frontend/vue-app/public/sw.js b/frontend/vue-app/public/sw.js index 98c6937..8794b5f 100644 --- a/frontend/vue-app/public/sw.js +++ b/frontend/vue-app/public/sw.js @@ -20,14 +20,19 @@ self.addEventListener('push', function (event) { } const title = payload.title || 'Reward App' + // Expiry-warning notifications are informational only — no action buttons + const actions = + payload.type === 'chore_expiring_soon' + ? [] + : [ + { action: 'approve', title: 'Approve' }, + { action: 'deny', title: 'Deny' }, + ] const options = { body: payload.body || '', icon: '/icons/icon-192x192.png', data: payload, - actions: [ - { action: 'approve', title: 'Approve' }, - { action: 'deny', title: 'Deny' }, - ], + actions, } event.waitUntil(self.registration.showNotification(title, options))