# 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 reject_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 Reject 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/Reject action buttons
- [x] Tapping a single-chore notification navigates to the child's chore page
- [x] Tapping a multi-chore notification navigates to `/parent`
