feat: implement state expiry for chores and rewards, adding scheduler and event handling
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m40s

This commit is contained in:
2026-04-21 23:21:53 -04:00
parent bc481527c8
commit 6982fa561f
8 changed files with 764 additions and 2 deletions

103
.github/specs/feat-state-expire.md vendored Normal file
View File

@@ -0,0 +1,103 @@
# Feature: Chore and Reward states expire at the end of the day.
## Overview
Chores and rewards can be pending, too late, etc. At the start of a new day, the state of chores and rewards should be reset. Notifications in the notification nav selector should be cleared out as well.
**Goal:** Chores and reward states get reset at the start of a new day.
**User Story:** As a parent, if I forget to approve/reject a pending chore or reward by the next day, then those items will have to be selected again by the child to go back to pending.
As a parent, if a scheduled chore is too late, the next day that chore will reset, but still follow it's schedule.
**Rules:**
Create frontend and backend unit tests
---
## Data Model Changes
### Backend Model
No model changes were required. `PendingConfirmation` already inherits `created_at: float` (Unix timestamp) from `BaseModel`, which is sufficient to determine whether a record is from a prior calendar day.
### Frontend Model
No model changes required. The `ChildChoreConfirmationPayload` and `ChildRewardRequestEventPayload` types are unchanged; the existing `RESET` and `CANCELLED` operations are reused.
---
## Backend Implementation
A new hourly scheduler (`backend/utils/state_expiry_scheduler.py`) runs idempotently to expire stale pending records:
- `_get_today_start_timestamp(tz_str)` computes the Unix timestamp for midnight today in the user's IANA timezone, falling back to UTC.
- `expire_stale_pending_for_user(user_id, tz_str)` queries `pending_confirmations_db` for records where `status='pending'` and `created_at < today_start` for that user. Each matching record is deleted from the database, then an SSE event is fired: `CHILD_CHORE_CONFIRMATION(RESET)` for chores, `CHILD_REWARD_REQUEST(CANCELLED)` for rewards. Uses `send_event_to_user` directly since there is no HTTP request context.
- `run_state_expiry_check(app)` iterates all verified users and calls `expire_stale_pending_for_user` for each. Skips entirely when `DB_ENV=e2e`.
- `start_state_expiry_scheduler(app)` registers the hourly cron job via APScheduler and also runs an immediate check on startup so any records that expired while the server was down are cleared promptly.
The scheduler is registered in `backend/main.py` alongside the existing digest and deletion schedulers.
## Backend Tests
Tests live in `backend/tests/test_state_expiry_scheduler.py`. Coverage:
- `_get_today_start_timestamp` returns a float before now, falls back to UTC on `None` or invalid timezone, and shifts midnight correctly across timezones.
- Pending chore from yesterday is deleted and fires a `CHILD_CHORE_CONFIRMATION(RESET)` SSE event with the correct `task_id`.
- Pending reward from yesterday is deleted and fires a `CHILD_REWARD_REQUEST(CANCELLED)` SSE event with the correct `reward_id`.
- Pending record created today is not deleted.
- Record with `status='approved'` from yesterday is not deleted.
- User with no pending records produces no errors and fires no SSE events.
- Multiple stale records for one user are all expired in a single call.
- `run_state_expiry_check` expires records for all verified users across multiple users.
- Only stale records are expired; fresh records from today are preserved.
- `DB_ENV=e2e` causes the scheduler to skip all processing.
---
## Frontend Implementation
Two files were updated to handle the `RESET` operation fired when chore pending states expire:
- `NotificationView.vue`: Added `'RESET'` to the `child_chore_confirmation` operation allow-list so the notification list refreshes when a pending chore confirmation is expired by the scheduler.
- `ParentLayout.vue`: Added `'RESET'` to the `child_chore_confirmation` handler allow-list so the notification badge count is re-fetched when a pending chore confirmation is expired.
The child view (`ChildView.vue`) required no changes — it already refreshes on any `child_chore_confirmation` operation regardless of the operation value.
## Frontend Tests
Tests live in `src/components/__tests__/NotificationView.spec.ts` and `src/components/__tests__/ParentLayout.spec.ts`.
- `NotificationView.vue` increments `refreshKey` on each of `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`, and `RESET` operations for `child_chore_confirmation`.
- `NotificationView.vue` does not increment `refreshKey` on unknown operations.
- `NotificationView.vue` increments `refreshKey` on `CREATED`, `CANCELLED`, and `GRANTED` operations for `child_reward_request`.
- `ParentLayout.vue` re-fetches the notification count on each of `CONFIRMED`, `APPROVED`, `REJECTED`, `CANCELLED`, and `RESET` operations for `child_chore_confirmation`.
- `ParentLayout.vue` does not re-fetch on unknown operations.
- `ParentLayout.vue` re-fetches the notification count on `CREATED`, `CANCELLED`, and `GRANTED` operations for `child_reward_request`.
---
## Future Considerations
- Push notifications (Web Push) currently reference pending items; expired records will not trigger a push notification removal. A future improvement could send a silent push to dismiss stale items from the notification tray.
- If digest emails are scheduled near midnight, the expiry scheduler may clear items before the digest runs. The digest scheduler already skips empty pending lists gracefully, so no action is needed, but the ordering could be made explicit.
---
## Acceptance Criteria (Definition of Done)
### Backend
- [x] Hourly scheduler deletes `PendingConfirmation` records with `status='pending'` created on a prior calendar day (per user timezone)
- [x] `CHILD_CHORE_CONFIRMATION(RESET)` SSE event is fired for each expired chore record
- [x] `CHILD_REWARD_REQUEST(CANCELLED)` SSE event is fired for each expired reward record
- [x] Scheduler skips processing when `DB_ENV=e2e`
- [x] Scheduler runs on startup to handle records that expired during downtime
- [x] `status='approved'` records are not deleted
- [x] Records created today are not deleted
- [x] Backend unit tests pass (17 tests)
### Frontend
- [x] Notification badge in `ParentLayout.vue` re-fetches count on `RESET` operation
- [x] Notification list in `NotificationView.vue` refreshes on `RESET` operation
- [x] Frontend unit tests pass (18 tests)