Add chore expiry notification system with scheduling and tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m59s

- 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.
This commit is contained in:
2026-04-22 15:37:40 -04:00
parent 6982fa561f
commit 8907184fde
9 changed files with 1181 additions and 4 deletions

View File

@@ -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`

View File

@@ -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

View File

@@ -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)

View File

@@ -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 <email>
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 <email>')
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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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))

View File

@@ -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))