diff --git a/.github/specs/bugs-1.0.5-001.md b/.github/specs/archive/bugs-1.0.5-001.md similarity index 100% rename from .github/specs/bugs-1.0.5-001.md rename to .github/specs/archive/bugs-1.0.5-001.md diff --git a/.github/specs/bugs-1.0.5-002.md b/.github/specs/archive/bugs-1.0.5-002.md similarity index 100% rename from .github/specs/bugs-1.0.5-002.md rename to .github/specs/archive/bugs-1.0.5-002.md diff --git a/.github/specs/feat-calendar-chore/feat-calendar-chore-component01.png b/.github/specs/archive/feat-calendar-chore/feat-calendar-chore-component01.png similarity index 100% rename from .github/specs/feat-calendar-chore/feat-calendar-chore-component01.png rename to .github/specs/archive/feat-calendar-chore/feat-calendar-chore-component01.png diff --git a/.github/specs/feat-calendar-chore/feat-calendar-chore.md b/.github/specs/archive/feat-calendar-chore/feat-calendar-chore.md similarity index 100% rename from .github/specs/feat-calendar-chore/feat-calendar-chore.md rename to .github/specs/archive/feat-calendar-chore/feat-calendar-chore.md diff --git a/.github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-01.md b/.github/specs/archive/feat-calendar-chore/feat-calendar-schedule-refactor-01.md similarity index 100% rename from .github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-01.md rename to .github/specs/archive/feat-calendar-chore/feat-calendar-schedule-refactor-01.md diff --git a/.github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-02.md b/.github/specs/archive/feat-calendar-chore/feat-calendar-schedule-refactor-02.md similarity index 100% rename from .github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-02.md rename to .github/specs/archive/feat-calendar-chore/feat-calendar-schedule-refactor-02.md diff --git a/.github/specs/feat-child-confirm-chore.md b/.github/specs/feat-child-confirm-chore.md new file mode 100644 index 0000000..b94c13b --- /dev/null +++ b/.github/specs/feat-child-confirm-chore.md @@ -0,0 +1,612 @@ +# Feature: Chore Completion Confirmation + Task Refactor + +## Overview + +**Goal:** +Refactor the "task" concept into three distinct entity types — **Chore**, **Kindness**, and **Penalty** — and implement a chore completion confirmation flow where children can confirm chores and parents approve them. + +**User Stories:** + +- As a **child**, I can click on a chore and confirm that I completed it. I see a **PENDING** banner (yellow) until a parent confirms. +- As a **child**, I can click an already PENDING chore and cancel my confirmation. +- As a **child**, I see a **COMPLETED** banner (green) on a chore that a parent has approved. That chore is disabled for the rest of the day. +- As a **parent**, I see pending chore confirmations in the **Notifications** tab alongside pending reward requests. +- As a **parent**, I can click a PENDING chore to **approve** it (awarding points) or **reject** it (resetting to available). +- As a **parent**, I can click a non-pending/non-completed chore and award points directly (current behavior). The child view then shows the COMPLETED banner. +- As a **parent**, I can **reset** a completed chore from the kebab menu so the child can confirm it again (points are kept). +- As an **admin**, I can view full tracking history in the database/logs for all confirmation lifecycle events. + +**Rules:** +.github/copilot-instructions.md + +--- + +## Design Decisions (Resolved) + +### Task Refactor → Chore / Kindness / Penalty + +**Decision: Full refactor.** + +| Old Concept | New Concept | Behavior | +| -------------------------------------------------------------- | ------------ | ------------------------------------------ | +| Task with `is_good=true` (schedulable) | **Chore** | Scheduled, expirable, confirmable by child | +| Task with `is_good=true` (ad-hoc, e.g. "Child was good today") | **Kindness** | Parent-only award, not confirmable | +| Task with `is_good=false` | **Penalty** | Parent-only deduction | + +- The `is_good` field is **removed**. Entity type itself determines point direction. +- The `Task` model is retained in the backend but gains a `type` field: `'chore' | 'kindness' | 'penalty'`. +- `task_api.py` is split into `chore_api.py`, `kindness_api.py`, `penalty_api.py`. +- Existing `is_good=true` tasks are auto-classified as **chore**; `is_good=false` as **penalty**. +- Kindness items must be manually created post-migration (acceptable). + +### Merged Pending Table + +**Decision: Single `PendingConfirmation` model replaces `PendingReward`.** + +- Both pending reward requests and pending chore confirmations live in one `pending_confirmations` table, differentiated by `entity_type`. +- The `/pending-rewards` endpoint is replaced by `/pending-confirmations`. +- `pending_rewards.json` DB file is replaced by `pending_confirmations.json`. + +### "Completed Today" Tracking + +**Decision: `PendingConfirmation` record with `status='approved'` + `approved_at` timestamp.** + +- An approved `PendingConfirmation` record persists (DB-backed, survives restart) and serves as the "completed today" marker. +- The frontend checks if `approved_at` is today to determine the COMPLETED state. +- On **reset**, the record is deleted (status returns to available). +- **Multi-completion history** is preserved via `TrackingEvent` — each confirm/approve/reset cycle generates tracking entries. Query `TrackingEvent` by `child_id + entity_id + date + action='approved'` to count completions per day. + +### Navigation + +**Decision: Sub-nav under "Tasks" tab.** + +- Top-level nav stays 4 items: **Children | Tasks | Rewards | Notifications** +- The "Tasks" tab opens a view with 3 sub-tabs: **Chores | Kindness | Penalties** +- Each sub-tab has its own list view, edit view, and assign view. +- No mobile layout changes needed to the top bar. + +### Chore Confirmation Scoping + +- Each `PendingConfirmation` is scoped to a **single child**. If a chore is assigned to multiple children, each confirms independently. +- Expired chores **cannot** be confirmed. +- A chore that is already PENDING or COMPLETED today **cannot** be confirmed again (unless reset by parent). + +--- + +## Data Model Changes + +### Backend Models + +#### `Task` Model (Modified) + +File: `backend/models/task.py` + +```python +@dataclass +class Task(BaseModel): + name: str + points: int + type: Literal['chore', 'kindness', 'penalty'] # replaces is_good + image_id: str | None = None + user_id: str | None = None +``` + +- `is_good: bool` → **removed** +- `type: Literal['chore', 'kindness', 'penalty']` → **added** +- Migration: `is_good=True` → `type='chore'`, `is_good=False` → `type='penalty'` + +#### `PendingConfirmation` Model (New — replaces `PendingReward`) + +File: `backend/models/pending_confirmation.py` + +```python +@dataclass +class PendingConfirmation(BaseModel): + child_id: str + entity_id: str # task_id or reward_id + entity_type: str # 'chore' | 'reward' + user_id: str + status: str = "pending" # 'pending' | 'approved' | 'rejected' + approved_at: str | None = None # ISO 8601 UTC timestamp, set on approval +``` + +- Replaces `PendingReward` (which had `child_id`, `reward_id`, `user_id`, `status`) +- `entity_id` generalizes `reward_id` to work for both chores and rewards +- `entity_type` differentiates between chore confirmations and reward requests +- `approved_at` enables "completed today" checks + +#### `TrackingEvent` Model (Extended Types) + +File: `backend/models/tracking_event.py` + +```python +EntityType = Literal['task', 'reward', 'penalty', 'chore', 'kindness'] +ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled', 'confirmed', 'approved', 'rejected', 'reset'] +``` + +New actions: + +- `confirmed` — child marks chore as done +- `approved` — parent approves chore completion (points awarded) +- `rejected` — parent rejects chore completion (no point change) +- `reset` — parent resets a completed chore (no point change) + +#### `ChildOverride` Model (Extended Types) + +File: `backend/models/child_override.py` + +```python +entity_type: Literal['task', 'reward'] # → Literal['chore', 'kindness', 'penalty', 'reward'] +``` + +#### `ChildTask` DTO (Modified) + +File: `backend/api/child_tasks.py` + +```python +class ChildTask: + def __init__(self, name, type, points, image_id, id): + self.id = id + self.name = name + self.type = type # replaces is_good + self.points = points + self.image_id = image_id +``` + +#### SSE Event Types (New) + +File: `backend/events/types/event_types.py` + +```python +class EventType(Enum): + # ... existing ... + CHILD_CHORE_CONFIRMATION = "child_chore_confirmation" +``` + +New payload class — File: `backend/events/types/child_chore_confirmation.py` + +```python +class ChildChoreConfirmation(Payload): + # child_id: str + # task_id: str + # operation: 'CONFIRMED' | 'APPROVED' | 'REJECTED' | 'CANCELLED' | 'RESET' +``` + +#### Error Codes (New) + +File: `backend/api/error_codes.py` + +```python +class ErrorCodes: + # ... existing ... + CHORE_EXPIRED = "CHORE_EXPIRED" + CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING" + CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED" + PENDING_NOT_FOUND = "PENDING_NOT_FOUND" + INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS" +``` + +### Frontend Models + +File: `frontend/vue-app/src/common/models.ts` + +```typescript +// Task — modified +export interface Task { + id: string; + name: string; + points: number; + type: "chore" | "kindness" | "penalty"; // replaces is_good + image_id: string | null; + image_url?: string | null; +} + +// ChildTask — modified +export interface ChildTask { + id: string; + name: string; + type: "chore" | "kindness" | "penalty"; // replaces is_good + points: number; + image_id: string | null; + image_url?: string | null; + custom_value?: number | null; + schedule?: ChoreSchedule | null; + extension_date?: string | null; +} + +// PendingConfirmation — new (replaces PendingReward) +export interface PendingConfirmation { + id: string; + child_id: string; + child_name: string; + child_image_id: string | null; + child_image_url?: string | null; + entity_id: string; + entity_type: "chore" | "reward"; + entity_name: string; + entity_image_id: string | null; + entity_image_url?: string | null; + status: "pending" | "approved" | "rejected"; + approved_at: string | null; +} + +// EntityType — extended +export type EntityType = "chore" | "kindness" | "penalty" | "reward"; + +// ActionType — extended +export type ActionType = + | "activated" + | "requested" + | "redeemed" + | "cancelled" + | "confirmed" + | "approved" + | "rejected" + | "reset"; + +// SSE event payload — new +export interface ChildChoreConfirmationPayload { + child_id: string; + task_id: string; + operation: "CONFIRMED" | "APPROVED" | "REJECTED" | "CANCELLED" | "RESET"; +} +``` + +--- + +## Backend Implementation + +### API Changes + +#### New Files + +| File | Description | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/api/chore_api.py` | CRUD for chores (type='chore'). Routes: `/chore/add`, `/chore/`, `/chore//edit`, `/chore/list`, `DELETE /chore/` | +| `backend/api/kindness_api.py` | CRUD for kindness acts (type='kindness'). Routes: `/kindness/add`, `/kindness/`, `/kindness//edit`, `/kindness/list`, `DELETE /kindness/` | +| `backend/api/penalty_api.py` | CRUD for penalties (type='penalty'). Routes: `/penalty/add`, `/penalty/`, `/penalty//edit`, `/penalty/list`, `DELETE /penalty/` | +| `backend/models/pending_confirmation.py` | `PendingConfirmation` dataclass | +| `backend/events/types/child_chore_confirmation.py` | SSE payload class | +| `backend/api/pending_confirmation.py` | Response DTO for hydrated pending confirmation | + +#### Modified Files + +| File | Changes | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/models/task.py` | `is_good` → `type` field | +| `backend/models/tracking_event.py` | Extend `EntityType` and `ActionType` literals | +| `backend/models/child_override.py` | Extend `entity_type` literal | +| `backend/api/child_tasks.py` | `is_good` → `type` field in DTO | +| `backend/api/child_api.py` | Add chore confirmation endpoints, replace `/pending-rewards` with `/pending-confirmations`, update trigger-task to set COMPLETED state, update all `is_good` references to `type` | +| `backend/api/task_api.py` | Deprecate/remove — logic moves to entity-specific API files | +| `backend/api/error_codes.py` | Add new error codes | +| `backend/events/types/event_types.py` | Add `CHILD_CHORE_CONFIRMATION` | +| `backend/db/db.py` | Add `pending_confirmations_db`, remove `pending_reward_db` | +| `backend/main.py` | Register new blueprints, remove `task_api` blueprint | + +#### New Endpoints (on `child_api.py`) + +| Method | Route | Actor | Description | +| ------ | ---------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/child//confirm-chore` | Child | Body: `{ task_id }`. Creates `PendingConfirmation(entity_type='chore', status='pending')`. Validates: chore assigned, not expired, not already pending/completed today. Tracking: `action='confirmed'`, `delta=0`. SSE: `CHILD_CHORE_CONFIRMATION` (CONFIRMED). | +| `POST` | `/child//cancel-confirm-chore` | Child | Body: `{ task_id }`. Deletes the pending confirmation. Tracking: `action='cancelled'`, `delta=0`. SSE: `CHILD_CHORE_CONFIRMATION` (CANCELLED). | +| `POST` | `/child//approve-chore` | Parent | Body: `{ task_id }`. Sets `status='approved'`, `approved_at=now()`. Awards points (respects overrides). Tracking: `action='approved'`, `delta=+points`. SSE: `CHILD_CHORE_CONFIRMATION` (APPROVED) + `CHILD_TASK_TRIGGERED`. | +| `POST` | `/child//reject-chore` | Parent | Body: `{ task_id }`. Deletes the pending confirmation. Tracking: `action='rejected'`, `delta=0`. SSE: `CHILD_CHORE_CONFIRMATION` (REJECTED). | +| `POST` | `/child//reset-chore` | Parent | Body: `{ task_id }`. Deletes the approved confirmation record. Tracking: `action='reset'`, `delta=0`. SSE: `CHILD_CHORE_CONFIRMATION` (RESET). | +| `GET` | `/pending-confirmations` | Parent | Returns all pending `PendingConfirmation` records for the user, hydrated with child/entity names and images. Replaces `/pending-rewards`. | + +#### Modified Endpoints + +| Endpoint | Change | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| `POST /child//trigger-task` | When a parent triggers a chore directly (no pending), create an approved `PendingConfirmation` so child view shows COMPLETED. Update entity_type references from `'task'` to the actual type. | +| `POST /child//request-reward` | Create `PendingConfirmation(entity_type='reward')` instead of `PendingReward`. | +| `POST /child//cancel-request-reward` | Query `PendingConfirmation` by `entity_type='reward'` instead of `PendingReward`. | +| `POST /child//trigger-reward` | Query/remove `PendingConfirmation` by `entity_type='reward'` instead of `PendingReward`. | +| `GET /child//list-tasks` | Add `pending_status` and `approved_at` fields to each chore in the response (from `PendingConfirmation` lookup). | +| `PUT /child//set-tasks` | Accept `type` parameter instead of `type: 'good' | 'bad'`. | + +#### Database Migration + +A one-time migration script (`backend/scripts/migrate_tasks_to_types.py`): + +1. For each record in `tasks.json`: if `is_good=True` → set `type='chore'`, if `is_good=False` → set `type='penalty'`. Remove `is_good` field. +2. For each record in `pending_rewards.json`: convert to `PendingConfirmation` format with `entity_type='reward'`, `entity_id=reward_id`. Write to `pending_confirmations.json`. +3. For each record in `tracking_events.json`: update `entity_type='task'` → `'chore'` or `'penalty'` based on the referenced task's old `is_good` value. +4. For each record in `child_overrides.json`: update `entity_type='task'` → `'chore'` or `'penalty'` based on the referenced task's old `is_good` value. + +--- + +## Backend Tests + +### Test File: `backend/tests/test_chore_api.py` (New) + +- [ ] `test_add_chore` — PUT `/chore/add` with `name`, `points` → 201, type auto-set to `'chore'` +- [ ] `test_add_chore_missing_fields` — 400 with `MISSING_FIELDS` +- [ ] `test_list_chores` — GET `/chore/list` returns only `type='chore'` tasks +- [ ] `test_get_chore` — GET `/chore/` → 200 +- [ ] `test_get_chore_not_found` — 404 +- [ ] `test_edit_chore` — PUT `/chore//edit` → 200 +- [ ] `test_edit_system_chore_clones_to_user` — editing a `user_id=None` chore creates a user copy +- [ ] `test_delete_chore` — DELETE `/chore/` → 200, removed from children's task lists +- [ ] `test_delete_chore_not_found` — 404 +- [ ] `test_delete_chore_removes_from_assigned_children` — cascade cleanup + +### Test File: `backend/tests/test_kindness_api.py` (New) + +- [ ] `test_add_kindness` — PUT `/kindness/add` → 201, type auto-set to `'kindness'` +- [ ] `test_list_kindness` — returns only `type='kindness'` tasks +- [ ] `test_edit_kindness` — PUT `/kindness//edit` → 200 +- [ ] `test_delete_kindness` — cascade removal + +### Test File: `backend/tests/test_penalty_api.py` (New) + +- [ ] `test_add_penalty` — PUT `/penalty/add` → 201, type auto-set to `'penalty'` +- [ ] `test_list_penalties` — returns only `type='penalty'` tasks +- [ ] `test_edit_penalty` — PUT `/penalty//edit` → 200 +- [ ] `test_delete_penalty` — cascade removal + +### Test File: `backend/tests/test_chore_confirmation.py` (New) + +#### Child Confirm Flow + +- [ ] `test_child_confirm_chore_success` — POST `/child//confirm-chore` with `{ task_id }` → 200, `PendingConfirmation` record created with `status='pending'`, `entity_type='chore'` +- [ ] `test_child_confirm_chore_not_assigned` — 400 `ENTITY_NOT_ASSIGNED` when chore is not in child's task list +- [ ] `test_child_confirm_chore_not_found` — 404 `TASK_NOT_FOUND` when task_id doesn't exist +- [ ] `test_child_confirm_chore_child_not_found` — 404 `CHILD_NOT_FOUND` +- [ ] `test_child_confirm_chore_already_pending` — 400 `CHORE_ALREADY_PENDING` when a pending confirmation already exists +- [ ] `test_child_confirm_chore_already_completed_today` — 400 `CHORE_ALREADY_COMPLETED` when an approved confirmation exists for today +- [ ] `test_child_confirm_chore_expired` — 400 `CHORE_EXPIRED` when chore is past its deadline +- [ ] `test_child_confirm_chore_creates_tracking_event` — TrackingEvent with `action='confirmed'`, `delta=0`, `entity_type='chore'` +- [ ] `test_child_confirm_chore_wrong_type` — 400 when task is kindness or penalty (not confirmable) + +#### Child Cancel Flow + +- [ ] `test_child_cancel_confirm_success` — POST `/child//cancel-confirm-chore` → 200, pending record deleted +- [ ] `test_child_cancel_confirm_not_pending` — 400 `PENDING_NOT_FOUND` +- [ ] `test_child_cancel_confirm_creates_tracking_event` — TrackingEvent with `action='cancelled'`, `delta=0` + +#### Parent Approve Flow + +- [ ] `test_parent_approve_chore_success` — POST `/child//approve-chore` → 200, points increased, `status='approved'`, `approved_at` set +- [ ] `test_parent_approve_chore_with_override` — uses `custom_value` from override instead of base points +- [ ] `test_parent_approve_chore_not_pending` — 400 `PENDING_NOT_FOUND` +- [ ] `test_parent_approve_chore_creates_tracking_event` — TrackingEvent with `action='approved'`, `delta=+points` +- [ ] `test_parent_approve_chore_points_correct` — `points_before` + task points == `points_after` on child + +#### Parent Reject Flow + +- [ ] `test_parent_reject_chore_success` — POST `/child//reject-chore` → 200, pending record deleted, points unchanged +- [ ] `test_parent_reject_chore_not_pending` — 400 `PENDING_NOT_FOUND` +- [ ] `test_parent_reject_chore_creates_tracking_event` — TrackingEvent with `action='rejected'`, `delta=0` + +#### Parent Reset Flow + +- [ ] `test_parent_reset_chore_success` — POST `/child//reset-chore` → 200, approved record deleted, points unchanged +- [ ] `test_parent_reset_chore_not_completed` — 400 when no approved record exists +- [ ] `test_parent_reset_chore_creates_tracking_event` — TrackingEvent with `action='reset'`, `delta=0` +- [ ] `test_parent_reset_then_child_confirm_again` — full cycle: confirm → approve → reset → confirm → approve (two approvals tracked) + +#### Parent Direct Trigger + +- [ ] `test_parent_trigger_chore_directly_creates_approved_confirmation` — POST `/child//trigger-task` with a chore → creates approved PendingConfirmation so child view shows COMPLETED + +#### Pending Confirmations List + +- [ ] `test_list_pending_confirmations_returns_chores_and_rewards` — GET `/pending-confirmations` returns both types +- [ ] `test_list_pending_confirmations_empty` — returns empty list when none exist +- [ ] `test_list_pending_confirmations_hydrates_names_and_images` — response includes `child_name`, `entity_name`, image IDs +- [ ] `test_list_pending_confirmations_excludes_approved` — only pending status returned +- [ ] `test_list_pending_confirmations_filters_by_user` — only returns confirmations for the authenticated user's children + +### Test File: `backend/tests/test_task_api.py` (Modified) + +- [ ] Update all existing tests that use `is_good` to use `type` instead +- [ ] `test_add_task` → split into `test_add_chore`, `test_add_kindness`, `test_add_penalty` (or remove if fully migrated to entity-specific APIs) +- [ ] `test_list_tasks_sorted` → update sort expectations for `type` field + +### Test File: `backend/tests/test_child_api.py` (Modified) + +- [ ] Update tests referencing `is_good` to use `type` +- [ ] Update `set-tasks` tests for new `type` parameter values (`'chore'`, `'kindness'`, `'penalty'`) +- [ ] Update `list-tasks` response assertions to check for `pending_status` and `approved_at` fields on chores +- [ ] Update `trigger-task` tests to verify `PendingConfirmation` creation for chores +- [ ] Update `request-reward` / `cancel-request-reward` / `trigger-reward` tests to use `PendingConfirmation` model +- [ ] Replace `pending-rewards` endpoint tests with `pending-confirmations` + +--- + +## Frontend Implementation + +### New Files + +| File | Description | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `src/components/task/ChoreView.vue` | Admin list of chores (type='chore'). Same pattern as current `TaskView.vue` with blue theme. | +| `src/components/task/KindnessView.vue` | Admin list of kindness acts (type='kindness'). Yellow theme. | +| `src/components/task/PenaltyView.vue` | Admin list of penalties (type='penalty'). Red theme. | +| `src/components/task/ChoreEditView.vue` | Create/edit chore form. Fields: name, points, image. No `is_good` toggle. | +| `src/components/task/KindnessEditView.vue` | Create/edit kindness form. Fields: name, points, image. | +| `src/components/task/PenaltyEditView.vue` | Create/edit penalty form. Fields: name, points, image. | +| `src/components/task/TaskSubNav.vue` | Sub-nav component with Chores / Kindness / Penalties tabs. Renders as a tab bar within the Tasks view area. | +| `src/components/child/ChoreAssignView.vue` | Assign chores to child (replaces `TaskAssignView` with `type='good'`). | +| `src/components/child/KindnessAssignView.vue` | Assign kindness acts to child. | +| `src/components/child/PenaltyAssignView.vue` | Assign penalties to child (replaces `TaskAssignView` with `type='bad'`). | +| `src/components/child/ChoreConfirmDialog.vue` | Modal dialog for child to confirm chore completion. "Did you finish [chore name]?" with Confirm / Cancel buttons. | +| `src/components/child/ChoreApproveDialog.vue` | Modal dialog for parent to approve/reject pending chore. Shows chore name, child name, points. Approve / Reject buttons. | + +### Modified Files + +| File | Changes | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/common/models.ts` | Replace `Task.is_good` with `Task.type`, add `PendingConfirmation` interface, extend `EntityType`/`ActionType`, add `ChildChoreConfirmationPayload`, replace `PendingReward` with `PendingConfirmation`. Add `pending_status` and `approved_at` to `ChildTask`. | +| `src/common/backendEvents.ts` | Add `child_chore_confirmation` event listener pattern. | +| `src/common/api.ts` | Add `confirmChore()`, `cancelConfirmChore()`, `approveChore()`, `rejectChore()`, `resetChore()`, `fetchPendingConfirmations()`. Remove `fetchPendingRewards()`. | +| `src/components/child/ChildView.vue` | Add chore tap handler → show `ChoreConfirmDialog`. Add PENDING (yellow) / COMPLETED (green) banner rendering. Handle cancel-confirm on PENDING tap. Filter kindness acts into new scrolling row. Listen for `child_chore_confirmation` SSE events. | +| `src/components/child/ParentView.vue` | Add PENDING/COMPLETED banners on chores. Handle approve/reject on PENDING chore tap. Add "Reset" to kebab menu for completed chores. Add "Assign Kindness" button. Update `trigger-task` to create approved confirmation. Replace `is_good` filters with `type` checks. Listen for `child_chore_confirmation` SSE events. | +| `src/components/notification/NotificationView.vue` | Fetch from `/api/pending-confirmations` instead of `/api/pending-rewards`. Render both pending chores and pending rewards with differentiation (icon/label). Listen for `child_chore_confirmation` events in addition to existing `child_reward_request`. | +| `src/layout/ParentLayout.vue` | "Tasks" nav icon remains, routes to a view housing `TaskSubNav` with sub-tabs. | +| `src/components/task/TaskEditView.vue` | Remove or repurpose. Logic moves to entity-specific edit views (no `is_good` toggle). | +| `src/components/task/TaskView.vue` | Remove or repurpose into the sub-nav container view. | +| `src/components/child/TaskAssignView.vue` | Remove. Replaced by `ChoreAssignView`, `KindnessAssignView`, `PenaltyAssignView`. | +| Router config | Add routes for new views. Update existing task routes to chore/kindness/penalty. | + +### Files to Remove + +| File | Reason | +| -------------------------------------- | ----------------------------------------- | +| `backend/models/pending_reward.py` | Replaced by `pending_confirmation.py` | +| `backend/api/pending_reward.py` | Replaced by `pending_confirmation.py` DTO | +| `backend/data/db/pending_rewards.json` | Replaced by `pending_confirmations.json` | + +### ChildView Chore Tap Flow + +``` +Child taps chore card + ├─ Chore expired? → No action (grayed out, "TOO LATE" stamp) + ├─ Chore COMPLETED today? → No action (grayed out, "COMPLETED" stamp) + ├─ Chore PENDING? → Show ModalDialog "Cancel confirmation?" + │ └─ Confirm → POST /child//cancel-confirm-chore + └─ Chore available? → Show ChoreConfirmDialog "Did you finish [name]?" + └─ Confirm → POST /child//confirm-chore +``` + +### ParentView Chore Tap Flow + +``` +Parent taps chore card + ├─ Chore PENDING? → Show ChoreApproveDialog + │ ├─ Approve → POST /child//approve-chore (awards points) + │ └─ Reject → POST /child//reject-chore (resets to available) + ├─ Chore COMPLETED today? → No tap action. Kebab menu has "Reset" + │ └─ Reset → POST /child//reset-chore + └─ Chore available? → Show TaskConfirmDialog (current behavior) + └─ Confirm → POST /child//trigger-task (sets COMPLETED) +``` + +### Banner Styling + +| State | Banner Text | Text Color | Background | CSS Variable Suggestion | +| --------- | ----------- | -------------------------- | ----------------------- | ----------------------- | +| Pending | `PENDING` | Yellow (`--color-warning`) | Dark semi-transparent | `--banner-bg-pending` | +| Completed | `COMPLETED` | Green (`--color-success`) | Dark semi-transparent | `--banner-bg-completed` | +| Expired | `TOO LATE` | Red (existing) | Gray overlay (existing) | (existing styles) | + +--- + +## Frontend Tests + +### Test File: `components/__tests__/ChoreConfirmDialog.test.ts` (New) + +- [ ] `renders chore name in dialog` +- [ ] `emits confirm event on confirm button click` +- [ ] `emits cancel event on cancel button click` + +### Test File: `components/__tests__/ChoreApproveDialog.test.ts` (New) + +- [ ] `renders chore name and points in dialog` +- [ ] `emits approve event on approve button click` +- [ ] `emits reject event on reject button click` + +### Test File: `components/__tests__/TaskSubNav.test.ts` (New) + +- [ ] `renders three sub-tabs: Chores, Kindness, Penalties` +- [ ] `highlights active tab based on route` +- [ ] `navigates on tab click` + +### Test File: `components/__tests__/ChoreView.test.ts` (New) + +- [ ] `fetches and renders chore list` +- [ ] `navigates to edit on item click` +- [ ] `shows delete confirmation modal` +- [ ] `refreshes on task_modified SSE event` + +### Test File: `components/__tests__/NotificationView.test.ts` (Modified) + +- [ ] `fetches from /api/pending-confirmations` +- [ ] `renders both pending chores and pending rewards` +- [ ] `differentiates chore vs reward with label/icon` +- [ ] `refreshes on child_chore_confirmation SSE event` +- [ ] `refreshes on child_reward_request SSE event` + +### Test File: `components/__tests__/ChildView.test.ts` (Modified / New) + +- [ ] `shows PENDING banner on chore with pending confirmation` +- [ ] `shows COMPLETED banner on chore completed today` +- [ ] `opens ChoreConfirmDialog on available chore tap` +- [ ] `opens cancel dialog on PENDING chore tap` +- [ ] `does not allow tap on expired chore` +- [ ] `does not allow tap on COMPLETED chore` +- [ ] `renders kindness scrolling row` +- [ ] `refreshes on child_chore_confirmation SSE event` + +### Test File: `components/__tests__/ParentView.test.ts` (Modified / New) + +- [ ] `shows PENDING banner on chore with pending confirmation` +- [ ] `shows COMPLETED banner on approved chore` +- [ ] `opens ChoreApproveDialog on PENDING chore tap` +- [ ] `opens TaskConfirmDialog on available chore tap` +- [ ] `shows Reset in kebab menu for completed chore` +- [ ] `renders kindness scrolling row` +- [ ] `shows Assign Kindness button` + +--- + +## Future Considerations + +- **Recurring chore auto-reset**: Automatically clear completed status on schedule rollover (e.g., daily chores reset at midnight). +- **Chore streaks**: Track consecutive days a child completes a chore using `TrackingEvent` history. +- **Multi-completion analytics dashboard**: Query `TrackingEvent` to show completion counts per chore per day/week. +- **Partial credit**: Allow parents to award fewer points than the chore's value when approving. +- **Chore delegation**: Allow one child to reassign a chore to a sibling. +- **Photo proof**: Child attaches a photo when confirming a chore. +- **Kindness auto-classification**: Suggested classification when creating new items based on name patterns. + +--- + +## Acceptance Criteria (Definition of Done) + +### Backend + +- [ ] `Task` model uses `type: 'chore' | 'kindness' | 'penalty'` — `is_good` removed +- [ ] `PendingConfirmation` model created, `PendingReward` model removed +- [ ] `pending_confirmations_db` created in `db.py`, `pending_reward_db` removed +- [ ] Migration script converts existing tasks, pending rewards, tracking events, and overrides +- [ ] `chore_api.py`, `kindness_api.py`, `penalty_api.py` created with CRUD endpoints +- [ ] `task_api.py` removed or deprecated +- [ ] Child chore confirmation endpoints: `confirm-chore`, `cancel-confirm-chore`, `approve-chore`, `reject-chore`, `reset-chore` +- [ ] `GET /pending-confirmations` returns hydrated pending chores and rewards +- [ ] `trigger-task` creates approved `PendingConfirmation` when parent triggers a chore directly +- [ ] Reward request/cancel/trigger endpoints migrated to `PendingConfirmation` +- [ ] `list-tasks` response includes `pending_status` and `approved_at` for chores +- [ ] `TrackingEvent` created for every mutation: confirmed, cancelled, approved, rejected, reset +- [ ] Tracking events logged to rotating file logger +- [ ] SSE event `CHILD_CHORE_CONFIRMATION` sent for every confirmation lifecycle event +- [ ] All new error codes defined and returned with proper HTTP status codes +- [ ] All existing tests updated for `type` field (no `is_good` references) +- [ ] All new backend tests pass + +### Frontend + +- [ ] `Task` and `ChildTask` interfaces use `type` instead of `is_good` +- [ ] `PendingConfirmation` interface replaces `PendingReward` +- [ ] Sub-nav under "Tasks" with Chores / Kindness / Penalties tabs +- [ ] `ChoreView`, `KindnessView`, `PenaltyView` list views created +- [ ] `ChoreEditView`, `KindnessEditView`, `PenaltyEditView` edit/create views created +- [ ] `ChoreAssignView`, `KindnessAssignView`, `PenaltyAssignView` assign views created +- [ ] `TaskView`, `TaskEditView`, `TaskAssignView` removed or repurposed +- [ ] `ChoreConfirmDialog` — child confirmation modal +- [ ] `ChoreApproveDialog` — parent approve/reject modal +- [ ] `ChildView` — chore tap opens confirm dialog, cancel dialog for pending, banners render correctly +- [ ] `ChildView` — expired and completed chores are non-interactive +- [ ] `ChildView` — kindness scrolling row added +- [ ] `ParentView` — pending chore tap opens approve/reject dialog +- [ ] `ParentView` — available chore tap uses existing trigger flow + creates completion record +- [ ] `ParentView` — kebab menu "Reset" option for completed chores +- [ ] `ParentView` — "Assign Kindness" button added +- [ ] `NotificationView` — fetches from `/pending-confirmations`, renders both types +- [ ] SSE listeners for `child_chore_confirmation` in all relevant components +- [ ] Banner styles: yellow PENDING, green COMPLETED (using CSS variables) +- [ ] All `is_good` references removed from frontend code +- [ ] All frontend tests pass +- [ ] Router updated with new routes diff --git a/backend/api/child_api.py b/backend/api/child_api.py index 022a8bb..79b778e 100644 --- a/backend/api/child_api.py +++ b/backend/api/child_api.py @@ -1,16 +1,18 @@ from time import sleep +from datetime import date as date_type, datetime, timezone from flask import Blueprint, request, jsonify from tinydb import Query from api.child_rewards import ChildReward from api.child_tasks import ChildTask -from api.pending_reward import PendingReward as PendingRewardResponse +from api.pending_confirmation import PendingConfirmationResponse from api.reward_status import RewardStatus -from api.utils import send_event_for_current_user -from db.db import child_db, task_db, reward_db, pending_reward_db +from api.utils import send_event_for_current_user, get_validated_user_id +from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db from db.tracking import insert_tracking_event from db.child_overrides import get_override, delete_override, delete_overrides_for_child +from events.types.child_chore_confirmation import ChildChoreConfirmation from events.types.child_modified import ChildModified from events.types.child_reward_request import ChildRewardRequest from events.types.child_reward_triggered import ChildRewardTriggered @@ -21,16 +23,15 @@ from events.types.tracking_event_created import TrackingEventCreated from events.types.event import Event from events.types.event_types import EventType from models.child import Child +from models.pending_confirmation import PendingConfirmation from models.pending_reward import PendingReward from models.reward import Reward from models.task import Task from models.tracking_event import TrackingEvent -from api.utils import get_validated_user_id from utils.tracking_logger import log_tracking_event from collections import defaultdict from db.chore_schedules import get_schedule from db.task_extensions import get_extension -from datetime import date as date_type import logging child_api = Blueprint('child_api', __name__) @@ -98,18 +99,22 @@ def edit_child(id): # Check if points changed and handle pending rewards if points is not None: PendingQuery = Query() - pending_rewards = pending_reward_db.search((PendingQuery.child_id == id) & (PendingQuery.user_id == user_id)) + pending_rewards = pending_confirmations_db.search( + (PendingQuery.child_id == id) & (PendingQuery.user_id == user_id) & + (PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending') + ) RewardQuery = Query() for pr in pending_rewards: - pending = PendingReward.from_dict(pr) - reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) + pending = PendingConfirmation.from_dict(pr) + reward_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) if reward_result: reward = Reward.from_dict(reward_result) # If child can no longer afford the reward, remove the pending request if child.points < reward.cost: - pending_reward_db.remove( - (PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id) & (PendingQuery.user_id == user_id) + pending_confirmations_db.remove( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == reward.id) & + (PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id) ) resp = send_event_for_current_user( Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED))) @@ -180,11 +185,10 @@ def set_child_tasks(id): data = request.get_json() or {} task_ids = data.get('task_ids') if 'type' not in data: - return jsonify({'error': 'type is required (good or bad)'}), 400 - task_type = data.get('type', 'good') - if task_type not in ['good', 'bad']: - return jsonify({'error': 'type must be either good or bad'}), 400 - is_good = task_type == 'good' + return jsonify({'error': 'type is required (chore, kindness, or penalty)'}), 400 + task_type = data.get('type') + if task_type not in ['chore', 'kindness', 'penalty']: + return jsonify({'error': 'type must be chore, kindness, or penalty', 'code': 'INVALID_TASK_TYPE'}), 400 if not isinstance(task_ids, list): return jsonify({'error': 'task_ids must be a list'}), 400 @@ -195,10 +199,11 @@ def set_child_tasks(id): child = Child.from_dict(result[0]) new_task_ids = set(task_ids) - # Add all existing child tasks of the opposite type - for task in task_db.all(): - if task['id'] in child.tasks and task['is_good'] != is_good: - new_task_ids.add(task['id']) + # Add all existing child tasks of other types + for task_record in task_db.all(): + task_obj = Task.from_dict(task_record) + if task_obj.id in child.tasks and task_obj.type != task_type: + new_task_ids.add(task_obj.id) # Convert back to list if needed new_tasks = list(new_task_ids) @@ -268,25 +273,42 @@ def list_child_tasks(id): if not task: continue + task_obj = Task.from_dict(task) + # Check for override override = get_override(id, tid) custom_value = override.custom_value if override else None - ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id')) + ct = ChildTask(task_obj.name, task_obj.type, task_obj.points, task_obj.image_id, task_obj.id) ct_dict = ct.to_dict() if custom_value is not None: ct_dict['custom_value'] = custom_value - # Attach schedule and most recent extension_date (client does all time math) - if task.get('is_good'): + # Attach schedule and most recent extension_date for chores (client does all time math) + if task_obj.type == 'chore': schedule = get_schedule(id, tid) ct_dict['schedule'] = schedule.to_dict() if schedule else None today_str = date_type.today().isoformat() ext = get_extension(id, tid, today_str) ct_dict['extension_date'] = ext.date if ext else None + + # Attach pending confirmation status for chores + PendingQuery = Query() + pending = pending_confirmations_db.get( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == tid) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) + ) + if pending: + ct_dict['pending_status'] = pending.get('status') + ct_dict['approved_at'] = pending.get('approved_at') + else: + ct_dict['pending_status'] = None + ct_dict['approved_at'] = None else: ct_dict['schedule'] = None ct_dict['extension_date'] = None + ct_dict['pending_status'] = None + ct_dict['approved_at'] = None child_tasks.append(ct_dict) @@ -328,7 +350,7 @@ def list_assignable_tasks(id): filtered_tasks.extend(user_tasks) # Wrap in ChildTask and return - assignable_tasks = [ChildTask(t.get('name'), t.get('is_good'), t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks] + assignable_tasks = [ChildTask(t.get('name'), Task.from_dict(t).type, t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks] return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200 @@ -342,9 +364,9 @@ def list_all_tasks(id): if not result: return jsonify({'error': 'Child not found'}), 404 has_type = "type" in request.args - if has_type and request.args.get('type') not in ['good', 'bad']: - return jsonify({'error': 'type must be either good or bad'}), 400 - good = request.args.get('type', False) == 'good' + if has_type and request.args.get('type') not in ['chore', 'kindness', 'penalty']: + return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400 + filter_type = request.args.get('type', None) if has_type else None child = result[0] assigned_ids = set(child.get('tasks', [])) @@ -368,14 +390,15 @@ def list_all_tasks(id): result_tasks = [] for t in filtered_tasks: - if has_type and t.get('is_good') != good: + task_obj = Task.from_dict(t) + if has_type and task_obj.type != filter_type: continue ct = ChildTask( - t.get('name'), - t.get('is_good'), - t.get('points'), - t.get('image_id'), - t.get('id') + task_obj.name, + task_obj.type, + task_obj.points, + task_obj.image_id, + task_obj.id ) task_dict = ct.to_dict() task_dict.update({'assigned': t.get('id') in assigned_ids}) @@ -427,11 +450,28 @@ def trigger_child_task(id): # update the child in the database child_db.update({'points': child.points}, ChildQuery.id == id) + # For chores, create an approved PendingConfirmation so child view shows COMPLETED + if task.type == 'chore': + PendingQuery = Query() + # Remove any existing pending confirmation for this chore + pending_confirmations_db.remove( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) + ) + confirmation = PendingConfirmation( + child_id=id, entity_id=task_id, entity_type='chore', + user_id=user_id, status='approved', + approved_at=datetime.now(timezone.utc).isoformat() + ) + pending_confirmations_db.insert(confirmation.to_dict()) + send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, + ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED))) + # Create tracking event - entity_type = 'penalty' if not task.is_good else 'task' + entity_type = task.type tracking_metadata = { 'task_name': task.name, - 'is_good': task.is_good, + 'task_type': task.type, 'default_points': task.points } if override: @@ -709,8 +749,9 @@ def trigger_child_reward(id): # Remove matching pending reward requests for this child and reward PendingQuery = Query() - removed = pending_reward_db.remove( - (PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id) + removed = pending_confirmations_db.remove( + (PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward.id) & + (PendingQuery.entity_type == 'reward') ) if removed: send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED))) @@ -799,9 +840,12 @@ def reward_status(id): cost_value = override.custom_value if override else reward.cost points_needed = max(0, cost_value - points) - #check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true + #check to see if this reward id and child id is in the pending confirmations db pending_query = Query() - pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id) & (pending_query.user_id == user_id)) + pending = pending_confirmations_db.get( + (pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) & + (pending_query.entity_type == 'reward') & (pending_query.user_id == user_id) + ) status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id) status_dict = status.to_dict() if override: @@ -849,8 +893,8 @@ def request_reward(id): 'reward_cost': reward.cost }), 400 - pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id) - pending_reward_db.insert(pending.to_dict()) + pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id) + pending_confirmations_db.insert(pending.to_dict()) logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}') # Create tracking event (no points change on request) @@ -905,8 +949,9 @@ def cancel_request_reward(id): # Remove matching pending reward request PendingQuery = Query() - removed = pending_reward_db.remove( - (PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id) & (PendingQuery.user_id == user_id) + removed = pending_confirmations_db.remove( + (PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward_id) & + (PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id) ) if not removed: @@ -942,26 +987,23 @@ def cancel_request_reward(id): -@child_api.route('/pending-rewards', methods=['GET']) -def list_pending_rewards(): +@child_api.route('/pending-confirmations', methods=['GET']) +def list_pending_confirmations(): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 PendingQuery = Query() - pending_rewards = pending_reward_db.search(PendingQuery.user_id == user_id) - reward_responses = [] + pending_items = pending_confirmations_db.search( + (PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending') + ) + responses = [] RewardQuery = Query() + TaskQuery = Query() ChildQuery = Query() - for pr in pending_rewards: - pending = PendingReward.from_dict(pr) - - # Look up reward details - reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) - if not reward_result: - continue - reward = Reward.from_dict(reward_result) + for pr in pending_items: + pending = PendingConfirmation.from_dict(pr) # Look up child details child_result = child_db.get(ChildQuery.id == pending.child_id) @@ -969,17 +1011,326 @@ def list_pending_rewards(): continue child = Child.from_dict(child_result) - # Create response object - response = PendingRewardResponse( + # Look up entity details based on type + if pending.entity_type == 'reward': + entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) + else: + entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + + if not entity_result: + continue + + response = PendingConfirmationResponse( _id=pending.id, child_id=child.id, child_name=child.name, child_image_id=child.image_id, - reward_id=reward.id, - reward_name=reward.name, - reward_image_id=reward.image_id + entity_id=pending.entity_id, + entity_type=pending.entity_type, + entity_name=entity_result.get('name'), + entity_image_id=entity_result.get('image_id'), + status=pending.status, + approved_at=pending.approved_at ) - reward_responses.append(response.to_dict()) + responses.append(response.to_dict()) - return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200 + return jsonify({'confirmations': responses, 'count': len(responses), 'list_type': 'notification'}), 200 + + +# --------------------------------------------------------------------------- +# Chore Confirmation Endpoints +# --------------------------------------------------------------------------- + +@child_api.route('/child//confirm-chore', methods=['POST']) +def confirm_chore(id): + """Child confirms they completed a chore. Creates a pending confirmation.""" + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + data = request.get_json() + task_id = data.get('task_id') + if not task_id: + return jsonify({'error': 'task_id is required'}), 400 + + ChildQuery = Query() + result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) + if not result: + return jsonify({'error': 'Child not found'}), 404 + + child = Child.from_dict(result[0]) + if task_id not in child.tasks: + return jsonify({'error': 'Task not assigned to child', 'code': 'ENTITY_NOT_ASSIGNED'}), 400 + + TaskQuery = Query() + task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + if not task_result: + return jsonify({'error': 'Task not found', 'code': 'TASK_NOT_FOUND'}), 404 + + task = Task.from_dict(task_result) + if task.type != 'chore': + return jsonify({'error': 'Only chores can be confirmed', 'code': 'INVALID_TASK_TYPE'}), 400 + + # Check if already pending or completed today + PendingQuery = Query() + existing = pending_confirmations_db.get( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) + ) + if existing: + if existing.get('status') == 'pending': + return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400 + if existing.get('status') == 'approved': + approved_at = existing.get('approved_at', '') + today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') + if approved_at and approved_at[:10] == today_utc: + return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400 + + confirmation = PendingConfirmation( + child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id + ) + pending_confirmations_db.insert(confirmation.to_dict()) + + # Create tracking event + tracking_event = TrackingEvent.create_event( + user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, + action='confirmed', points_before=child.points, points_after=child.points, + metadata={'task_name': task.name, 'task_type': task.type} + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, + TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed'))) + send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, + ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED))) + return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200 + + +@child_api.route('/child//cancel-confirm-chore', methods=['POST']) +def cancel_confirm_chore(id): + """Child cancels their pending chore confirmation.""" + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + data = request.get_json() + task_id = data.get('task_id') + if not task_id: + return jsonify({'error': 'task_id is required'}), 400 + + ChildQuery = Query() + result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) + if not result: + return jsonify({'error': 'Child not found'}), 404 + child = Child.from_dict(result[0]) + + PendingQuery = Query() + existing = pending_confirmations_db.get( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') & + (PendingQuery.user_id == user_id) + ) + if not existing: + return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400 + + pending_confirmations_db.remove( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') & + (PendingQuery.user_id == user_id) + ) + + # Fetch task name for tracking + TaskQuery = Query() + task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + task_name = task_result.get('name') if task_result else 'Unknown' + + tracking_event = TrackingEvent.create_event( + user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, + action='cancelled', points_before=child.points, points_after=child.points, + metadata={'task_name': task_name} + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, + TrackingEventCreated(tracking_event.id, id, 'chore', 'cancelled'))) + send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, + ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CANCELLED))) + return jsonify({'message': 'Chore confirmation cancelled.'}), 200 + + +@child_api.route('/child//approve-chore', methods=['POST']) +def approve_chore(id): + """Parent approves a pending chore confirmation, awarding points.""" + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + data = request.get_json() + task_id = data.get('task_id') + if not task_id: + return jsonify({'error': 'task_id is required'}), 400 + + ChildQuery = Query() + result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) + if not result: + return jsonify({'error': 'Child not found'}), 404 + child = Child.from_dict(result[0]) + + PendingQuery = Query() + existing = pending_confirmations_db.get( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') & + (PendingQuery.user_id == user_id) + ) + if not existing: + return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400 + + TaskQuery = Query() + task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + if not task_result: + return jsonify({'error': 'Task not found'}), 404 + task = Task.from_dict(task_result) + + # Award points + override = get_override(id, task_id) + points_value = override.custom_value if override else task.points + points_before = child.points + child.points += points_value + child_db.update({'points': child.points}, ChildQuery.id == id) + + # Update confirmation to approved + now_str = datetime.now(timezone.utc).isoformat() + pending_confirmations_db.update( + {'status': 'approved', 'approved_at': now_str}, + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) + ) + + tracking_metadata = { + 'task_name': task.name, + 'task_type': task.type, + 'default_points': task.points + } + if override: + tracking_metadata['custom_points'] = override.custom_value + tracking_metadata['has_override'] = True + + tracking_event = TrackingEvent.create_event( + user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, + action='approved', points_before=points_before, points_after=child.points, + metadata=tracking_metadata + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, + TrackingEventCreated(tracking_event.id, id, 'chore', 'approved'))) + send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, + ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED))) + send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, + ChildTaskTriggered(task_id, id, child.points))) + return jsonify({ + 'message': f'Chore {task.name} approved for {child.name}.', + 'points': child.points, + 'id': child.id + }), 200 + + +@child_api.route('/child//reject-chore', methods=['POST']) +def reject_chore(id): + """Parent rejects a pending chore confirmation.""" + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + data = request.get_json() + task_id = data.get('task_id') + if not task_id: + return jsonify({'error': 'task_id is required'}), 400 + + ChildQuery = Query() + result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) + if not result: + return jsonify({'error': 'Child not found'}), 404 + child = Child.from_dict(result[0]) + + PendingQuery = Query() + existing = pending_confirmations_db.get( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') & + (PendingQuery.user_id == user_id) + ) + if not existing: + return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400 + + pending_confirmations_db.remove( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) + ) + + TaskQuery = Query() + task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + task_name = task_result.get('name') if task_result else 'Unknown' + + tracking_event = TrackingEvent.create_event( + user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, + action='rejected', points_before=child.points, points_after=child.points, + metadata={'task_name': task_name} + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, + TrackingEventCreated(tracking_event.id, id, 'chore', 'rejected'))) + send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, + ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_REJECTED))) + return jsonify({'message': 'Chore confirmation rejected.'}), 200 + + +@child_api.route('/child//reset-chore', methods=['POST']) +def reset_chore(id): + """Parent resets a completed chore so the child can confirm again.""" + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + data = request.get_json() + task_id = data.get('task_id') + if not task_id: + return jsonify({'error': 'task_id is required'}), 400 + + ChildQuery = Query() + result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) + if not result: + return jsonify({'error': 'Child not found'}), 404 + child = Child.from_dict(result[0]) + + PendingQuery = Query() + existing = pending_confirmations_db.get( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'approved') & + (PendingQuery.user_id == user_id) + ) + if not existing: + return jsonify({'error': 'No completed confirmation found to reset', 'code': 'PENDING_NOT_FOUND'}), 400 + + pending_confirmations_db.remove( + (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & + (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) + ) + + TaskQuery = Query() + task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + task_name = task_result.get('name') if task_result else 'Unknown' + + tracking_event = TrackingEvent.create_event( + user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, + action='reset', points_before=child.points, points_after=child.points, + metadata={'task_name': task_name} + ) + insert_tracking_event(tracking_event) + log_tracking_event(tracking_event) + + send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, + TrackingEventCreated(tracking_event.id, id, 'chore', 'reset'))) + send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, + ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET))) + return jsonify({'message': 'Chore reset to available.'}), 200 diff --git a/backend/api/child_tasks.py b/backend/api/child_tasks.py index fcc4eb2..02c75cb 100644 --- a/backend/api/child_tasks.py +++ b/backend/api/child_tasks.py @@ -1,8 +1,8 @@ class ChildTask: - def __init__(self, name, is_good, points, image_id, id): + def __init__(self, name, task_type, points, image_id, id): self.id = id self.name = name - self.is_good = is_good + self.type = task_type self.points = points self.image_id = image_id @@ -10,7 +10,7 @@ class ChildTask: return { 'id': self.id, 'name': self.name, - 'is_good': self.is_good, + 'type': self.type, 'points': self.points, 'image_id': self.image_id } \ No newline at end of file diff --git a/backend/api/chore_api.py b/backend/api/chore_api.py new file mode 100644 index 0000000..6ee6baf --- /dev/null +++ b/backend/api/chore_api.py @@ -0,0 +1,165 @@ +from flask import Blueprint, request, jsonify +from tinydb import Query + +from api.utils import send_event_for_current_user, get_validated_user_id +from events.types.child_tasks_set import ChildTasksSet +from db.db import task_db, child_db +from db.child_overrides import delete_overrides_for_entity +from events.types.event import Event +from events.types.event_types import EventType +from events.types.task_modified import TaskModified +from models.task import Task + +chore_api = Blueprint('chore_api', __name__) + +TASK_TYPE = 'chore' + + +@chore_api.route('/chore/add', methods=['PUT']) +def add_chore(): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + data = request.get_json() + name = data.get('name') + points = data.get('points') + image = data.get('image_id', '') + if not name or points is None: + return jsonify({'error': 'Name and points are required'}), 400 + task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id) + task_db.insert(task.to_dict()) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(task.id, TaskModified.OPERATION_ADD))) + return jsonify({'message': f'Chore {name} added.'}), 201 + + +@chore_api.route('/chore/', methods=['GET']) +def get_chore(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + result = task_db.search( + (TaskQuery.id == id) & + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + if not result: + return jsonify({'error': 'Chore not found'}), 404 + return jsonify(result[0]), 200 + + +@chore_api.route('/chore/list', methods=['GET']) +def list_chores(): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + tasks = task_db.search( + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + + user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id} + filtered_tasks = [] + for t in tasks: + if t.get('user_id') is None and t['name'].strip().lower() in user_tasks: + continue + filtered_tasks.append(t) + + def sort_user_then_default(tasks_group): + user_created = sorted( + [t for t in tasks_group if t.get('user_id') == user_id], + key=lambda x: x['name'].lower(), + ) + default_items = sorted( + [t for t in tasks_group if t.get('user_id') is None], + key=lambda x: x['name'].lower(), + ) + return user_created + default_items + + sorted_tasks = sort_user_then_default(filtered_tasks) + return jsonify({'tasks': sorted_tasks}), 200 + + +@chore_api.route('/chore/', methods=['DELETE']) +def delete_chore(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE)) + if not task: + return jsonify({'error': 'Chore not found'}), 404 + if task.get('user_id') is None: + import logging + logging.warning(f"Forbidden delete attempt on system chore: id={id}, by user_id={user_id}") + return jsonify({'error': 'System chores cannot be deleted.'}), 403 + removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id)) + if removed: + deleted_count = delete_overrides_for_entity(id) + if deleted_count > 0: + import logging + logging.info(f"Cascade deleted {deleted_count} overrides for chore {id}") + ChildQuery = Query() + for child in child_db.all(): + child_tasks = child.get('tasks', []) + if id in child_tasks: + child_tasks.remove(id) + child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id')) + send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks))) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE))) + return jsonify({'message': f'Chore {id} deleted.'}), 200 + return jsonify({'error': 'Chore not found'}), 404 + + +@chore_api.route('/chore//edit', methods=['PUT']) +def edit_chore(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + existing = task_db.get( + (TaskQuery.id == id) & + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + if not existing: + return jsonify({'error': 'Chore not found'}), 404 + + task = Task.from_dict(existing) + is_dirty = False + data = request.get_json(force=True) or {} + + if 'name' in data: + name = data.get('name', '').strip() + if not name: + return jsonify({'error': 'Name cannot be empty'}), 400 + task.name = name + is_dirty = True + + if 'points' in data: + points = data.get('points') + if not isinstance(points, int) or points <= 0: + return jsonify({'error': 'Points must be a positive integer'}), 400 + task.points = points + is_dirty = True + + if 'image_id' in data: + task.image_id = data.get('image_id', '') + is_dirty = True + + if not is_dirty: + return jsonify({'error': 'No valid fields to update'}), 400 + + if task.user_id is None: + new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id) + task_db.insert(new_task.to_dict()) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(new_task.id, TaskModified.OPERATION_ADD))) + return jsonify(new_task.to_dict()), 200 + + task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(id, TaskModified.OPERATION_EDIT))) + return jsonify(task.to_dict()), 200 diff --git a/backend/api/error_codes.py b/backend/api/error_codes.py index 2a31c55..f521c80 100644 --- a/backend/api/error_codes.py +++ b/backend/api/error_codes.py @@ -26,3 +26,9 @@ class ErrorCodes: INVALID_VALUE = "INVALID_VALUE" VALIDATION_ERROR = "VALIDATION_ERROR" INTERNAL_ERROR = "INTERNAL_ERROR" + CHORE_EXPIRED = "CHORE_EXPIRED" + CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING" + CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED" + PENDING_NOT_FOUND = "PENDING_NOT_FOUND" + INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS" + INVALID_TASK_TYPE = "INVALID_TASK_TYPE" diff --git a/backend/api/kindness_api.py b/backend/api/kindness_api.py new file mode 100644 index 0000000..0051a29 --- /dev/null +++ b/backend/api/kindness_api.py @@ -0,0 +1,165 @@ +from flask import Blueprint, request, jsonify +from tinydb import Query + +from api.utils import send_event_for_current_user, get_validated_user_id +from events.types.child_tasks_set import ChildTasksSet +from db.db import task_db, child_db +from db.child_overrides import delete_overrides_for_entity +from events.types.event import Event +from events.types.event_types import EventType +from events.types.task_modified import TaskModified +from models.task import Task + +kindness_api = Blueprint('kindness_api', __name__) + +TASK_TYPE = 'kindness' + + +@kindness_api.route('/kindness/add', methods=['PUT']) +def add_kindness(): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + data = request.get_json() + name = data.get('name') + points = data.get('points') + image = data.get('image_id', '') + if not name or points is None: + return jsonify({'error': 'Name and points are required'}), 400 + task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id) + task_db.insert(task.to_dict()) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(task.id, TaskModified.OPERATION_ADD))) + return jsonify({'message': f'Kindness {name} added.'}), 201 + + +@kindness_api.route('/kindness/', methods=['GET']) +def get_kindness(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + result = task_db.search( + (TaskQuery.id == id) & + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + if not result: + return jsonify({'error': 'Kindness act not found'}), 404 + return jsonify(result[0]), 200 + + +@kindness_api.route('/kindness/list', methods=['GET']) +def list_kindness(): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + tasks = task_db.search( + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + + user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id} + filtered_tasks = [] + for t in tasks: + if t.get('user_id') is None and t['name'].strip().lower() in user_tasks: + continue + filtered_tasks.append(t) + + def sort_user_then_default(tasks_group): + user_created = sorted( + [t for t in tasks_group if t.get('user_id') == user_id], + key=lambda x: x['name'].lower(), + ) + default_items = sorted( + [t for t in tasks_group if t.get('user_id') is None], + key=lambda x: x['name'].lower(), + ) + return user_created + default_items + + sorted_tasks = sort_user_then_default(filtered_tasks) + return jsonify({'tasks': sorted_tasks}), 200 + + +@kindness_api.route('/kindness/', methods=['DELETE']) +def delete_kindness(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE)) + if not task: + return jsonify({'error': 'Kindness act not found'}), 404 + if task.get('user_id') is None: + import logging + logging.warning(f"Forbidden delete attempt on system kindness: id={id}, by user_id={user_id}") + return jsonify({'error': 'System kindness acts cannot be deleted.'}), 403 + removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id)) + if removed: + deleted_count = delete_overrides_for_entity(id) + if deleted_count > 0: + import logging + logging.info(f"Cascade deleted {deleted_count} overrides for kindness {id}") + ChildQuery = Query() + for child in child_db.all(): + child_tasks = child.get('tasks', []) + if id in child_tasks: + child_tasks.remove(id) + child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id')) + send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks))) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE))) + return jsonify({'message': f'Kindness {id} deleted.'}), 200 + return jsonify({'error': 'Kindness act not found'}), 404 + + +@kindness_api.route('/kindness//edit', methods=['PUT']) +def edit_kindness(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + existing = task_db.get( + (TaskQuery.id == id) & + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + if not existing: + return jsonify({'error': 'Kindness act not found'}), 404 + + task = Task.from_dict(existing) + is_dirty = False + data = request.get_json(force=True) or {} + + if 'name' in data: + name = data.get('name', '').strip() + if not name: + return jsonify({'error': 'Name cannot be empty'}), 400 + task.name = name + is_dirty = True + + if 'points' in data: + points = data.get('points') + if not isinstance(points, int) or points <= 0: + return jsonify({'error': 'Points must be a positive integer'}), 400 + task.points = points + is_dirty = True + + if 'image_id' in data: + task.image_id = data.get('image_id', '') + is_dirty = True + + if not is_dirty: + return jsonify({'error': 'No valid fields to update'}), 400 + + if task.user_id is None: + new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id) + task_db.insert(new_task.to_dict()) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(new_task.id, TaskModified.OPERATION_ADD))) + return jsonify(new_task.to_dict()), 200 + + task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(id, TaskModified.OPERATION_EDIT))) + return jsonify(task.to_dict()), 200 diff --git a/backend/api/penalty_api.py b/backend/api/penalty_api.py new file mode 100644 index 0000000..fc6d881 --- /dev/null +++ b/backend/api/penalty_api.py @@ -0,0 +1,165 @@ +from flask import Blueprint, request, jsonify +from tinydb import Query + +from api.utils import send_event_for_current_user, get_validated_user_id +from events.types.child_tasks_set import ChildTasksSet +from db.db import task_db, child_db +from db.child_overrides import delete_overrides_for_entity +from events.types.event import Event +from events.types.event_types import EventType +from events.types.task_modified import TaskModified +from models.task import Task + +penalty_api = Blueprint('penalty_api', __name__) + +TASK_TYPE = 'penalty' + + +@penalty_api.route('/penalty/add', methods=['PUT']) +def add_penalty(): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + data = request.get_json() + name = data.get('name') + points = data.get('points') + image = data.get('image_id', '') + if not name or points is None: + return jsonify({'error': 'Name and points are required'}), 400 + task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id) + task_db.insert(task.to_dict()) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(task.id, TaskModified.OPERATION_ADD))) + return jsonify({'message': f'Penalty {name} added.'}), 201 + + +@penalty_api.route('/penalty/', methods=['GET']) +def get_penalty(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + result = task_db.search( + (TaskQuery.id == id) & + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + if not result: + return jsonify({'error': 'Penalty not found'}), 404 + return jsonify(result[0]), 200 + + +@penalty_api.route('/penalty/list', methods=['GET']) +def list_penalties(): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + tasks = task_db.search( + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + + user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id} + filtered_tasks = [] + for t in tasks: + if t.get('user_id') is None and t['name'].strip().lower() in user_tasks: + continue + filtered_tasks.append(t) + + def sort_user_then_default(tasks_group): + user_created = sorted( + [t for t in tasks_group if t.get('user_id') == user_id], + key=lambda x: x['name'].lower(), + ) + default_items = sorted( + [t for t in tasks_group if t.get('user_id') is None], + key=lambda x: x['name'].lower(), + ) + return user_created + default_items + + sorted_tasks = sort_user_then_default(filtered_tasks) + return jsonify({'tasks': sorted_tasks}), 200 + + +@penalty_api.route('/penalty/', methods=['DELETE']) +def delete_penalty(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE)) + if not task: + return jsonify({'error': 'Penalty not found'}), 404 + if task.get('user_id') is None: + import logging + logging.warning(f"Forbidden delete attempt on system penalty: id={id}, by user_id={user_id}") + return jsonify({'error': 'System penalties cannot be deleted.'}), 403 + removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id)) + if removed: + deleted_count = delete_overrides_for_entity(id) + if deleted_count > 0: + import logging + logging.info(f"Cascade deleted {deleted_count} overrides for penalty {id}") + ChildQuery = Query() + for child in child_db.all(): + child_tasks = child.get('tasks', []) + if id in child_tasks: + child_tasks.remove(id) + child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id')) + send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks))) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE))) + return jsonify({'message': f'Penalty {id} deleted.'}), 200 + return jsonify({'error': 'Penalty not found'}), 404 + + +@penalty_api.route('/penalty//edit', methods=['PUT']) +def edit_penalty(id): + user_id = get_validated_user_id() + if not user_id: + return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 + TaskQuery = Query() + existing = task_db.get( + (TaskQuery.id == id) & + ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) & + (TaskQuery.type == TASK_TYPE) + ) + if not existing: + return jsonify({'error': 'Penalty not found'}), 404 + + task = Task.from_dict(existing) + is_dirty = False + data = request.get_json(force=True) or {} + + if 'name' in data: + name = data.get('name', '').strip() + if not name: + return jsonify({'error': 'Name cannot be empty'}), 400 + task.name = name + is_dirty = True + + if 'points' in data: + points = data.get('points') + if not isinstance(points, int) or points <= 0: + return jsonify({'error': 'Points must be a positive integer'}), 400 + task.points = points + is_dirty = True + + if 'image_id' in data: + task.image_id = data.get('image_id', '') + is_dirty = True + + if not is_dirty: + return jsonify({'error': 'No valid fields to update'}), 400 + + if task.user_id is None: + new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id) + task_db.insert(new_task.to_dict()) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(new_task.id, TaskModified.OPERATION_ADD))) + return jsonify(new_task.to_dict()), 200 + + task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) + send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, + TaskModified(id, TaskModified.OPERATION_EDIT))) + return jsonify(task.to_dict()), 200 diff --git a/backend/api/pending_confirmation.py b/backend/api/pending_confirmation.py new file mode 100644 index 0000000..4e4461e --- /dev/null +++ b/backend/api/pending_confirmation.py @@ -0,0 +1,29 @@ +class PendingConfirmationResponse: + """Response DTO for hydrated pending confirmation data.""" + def __init__(self, _id, child_id, child_name, child_image_id, + entity_id, entity_type, entity_name, entity_image_id, + status='pending', approved_at=None): + self.id = _id + self.child_id = child_id + self.child_name = child_name + self.child_image_id = child_image_id + self.entity_id = entity_id + self.entity_type = entity_type + self.entity_name = entity_name + self.entity_image_id = entity_image_id + self.status = status + self.approved_at = approved_at + + def to_dict(self): + return { + 'id': self.id, + 'child_id': self.child_id, + 'child_name': self.child_name, + 'child_image_id': self.child_image_id, + 'entity_id': self.entity_id, + 'entity_type': self.entity_type, + 'entity_name': self.entity_name, + 'entity_image_id': self.entity_image_id, + 'status': self.status, + 'approved_at': self.approved_at + } diff --git a/backend/api/task_api.py b/backend/api/task_api.py index 3182fe2..3aac1e0 100644 --- a/backend/api/task_api.py +++ b/backend/api/task_api.py @@ -21,11 +21,16 @@ def add_task(): data = request.get_json() name = data.get('name') points = data.get('points') - is_good = data.get('is_good') + task_type = data.get('type') + # Support legacy is_good field + if task_type is None and 'is_good' in data: + task_type = 'chore' if data['is_good'] else 'penalty' image = data.get('image_id', '') - if not name or points is None or is_good is None: - return jsonify({'error': 'Name, points, and is_good are required'}), 400 - task = Task(name=name, points=points, is_good=is_good, image_id=image, user_id=user_id) + if not name or points is None or task_type is None: + return jsonify({'error': 'Name, points, and type are required'}), 400 + if task_type not in ['chore', 'kindness', 'penalty']: + return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400 + task = Task(name=name, points=points, type=task_type, image_id=image, user_id=user_id) task_db.insert(task.to_dict()) send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(task.id, TaskModified.OPERATION_ADD))) @@ -65,10 +70,10 @@ def list_tasks(): filtered_tasks.append(t) # Sort order: - # 1) good tasks first, then not-good tasks + # 1) chore/kindness first, then penalties # 2) within each group: user-created items first (by name), then default items (by name) - good_tasks = [t for t in filtered_tasks if t.get('is_good') is True] - not_good_tasks = [t for t in filtered_tasks if t.get('is_good') is not True] + good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type != 'penalty'] + not_good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type == 'penalty'] def sort_user_then_default(tasks_group): user_created = sorted( @@ -154,7 +159,15 @@ def edit_task(id): is_good = data.get('is_good') if not isinstance(is_good, bool): return jsonify({'error': 'is_good must be a boolean'}), 400 - task.is_good = is_good + # Convert to type + task.type = 'chore' if is_good else 'penalty' + is_dirty = True + + if 'type' in data: + task_type = data.get('type') + if task_type not in ['chore', 'kindness', 'penalty']: + return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400 + task.type = task_type is_dirty = True if 'image_id' in data: @@ -165,7 +178,7 @@ def edit_task(id): return jsonify({'error': 'No valid fields to update'}), 400 if task.user_id is None: # public task - new_task = Task(name=task.name, points=task.points, is_good=task.is_good, image_id=task.image_id, user_id=user_id) + new_task = Task(name=task.name, points=task.points, type=task.type, image_id=task.image_id, user_id=user_id) task_db.insert(new_task.to_dict()) send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(new_task.id, TaskModified.OPERATION_ADD))) diff --git a/backend/data/db/tasks.json.bak.20260228_104347 b/backend/data/db/tasks.json.bak.20260228_104347 new file mode 100644 index 0000000..d6ffcc4 --- /dev/null +++ b/backend/data/db/tasks.json.bak.20260228_104347 @@ -0,0 +1,144 @@ +{ + "_default": { + "1": { + "id": "57c21328-637e-4df3-be5b-7f619cbf4076", + "created_at": 1771343995.1881204, + "updated_at": 1771343995.188121, + "name": "Take out trash", + "points": 20, + "is_good": true, + "image_id": "trash-can", + "user_id": null + }, + "2": { + "id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "created_at": 1771343995.1881304, + "updated_at": 1771343995.1881304, + "name": "Make your bed", + "points": 25, + "is_good": true, + "image_id": "make-the-bed", + "user_id": null + }, + "3": { + "id": "71afb2e5-18de-4f99-9e1e-2f4e391e6c2c", + "created_at": 1771343995.1881359, + "updated_at": 1771343995.1881359, + "name": "Sweep and clean kitchen", + "points": 15, + "is_good": true, + "image_id": "vacuum", + "user_id": null + }, + "4": { + "id": "e0aae53d-d4b6-4203-b910-004917db6003", + "created_at": 1771343995.1881409, + "updated_at": 1771343995.188141, + "name": "Do homework early", + "points": 30, + "is_good": true, + "image_id": "homework", + "user_id": null + }, + "5": { + "id": "0ba544f6-2d61-4009-af8f-bcb4e94b7a11", + "created_at": 1771343995.188146, + "updated_at": 1771343995.188146, + "name": "Be good for the day", + "points": 15, + "is_good": true, + "image_id": "good", + "user_id": null + }, + "6": { + "id": "8b5750d4-5a58-40cb-a31b-667569069d34", + "created_at": 1771343995.1881511, + "updated_at": 1771343995.1881511, + "name": "Clean your mess", + "points": 20, + "is_good": true, + "image_id": "broom", + "user_id": null + }, + "7": { + "id": "aec5fb49-06d0-43c4-aa09-9583064b7275", + "created_at": 1771343995.1881557, + "updated_at": 1771343995.1881557, + "name": "Fighting", + "points": 10, + "is_good": false, + "image_id": "fighting", + "user_id": null + }, + "8": { + "id": "0221ab72-c6c0-429f-a5f1-bc3d843fce9e", + "created_at": 1771343995.1881602, + "updated_at": 1771343995.1881602, + "name": "Yelling at parents", + "points": 10, + "is_good": false, + "image_id": "yelling", + "user_id": null + }, + "9": { + "id": "672bfc74-4b85-4e8e-a2d0-74f14ab966cc", + "created_at": 1771343995.1881647, + "updated_at": 1771343995.1881647, + "name": "Lying", + "points": 10, + "is_good": false, + "image_id": "lying", + "user_id": null + }, + "10": { + "id": "d8cc254f-922b-4dc2-ac4c-32fc3bbda584", + "created_at": 1771343995.1881692, + "updated_at": 1771343995.1881695, + "name": "Not doing what told", + "points": 5, + "is_good": false, + "image_id": "ignore", + "user_id": null + }, + "11": { + "id": "8be18d9a-48e6-402b-a0ba-630a2d50e325", + "created_at": 1771343995.188174, + "updated_at": 1771343995.188174, + "name": "Not flushing toilet", + "points": 5, + "is_good": false, + "image_id": "toilet", + "user_id": null + }, + "12": { + "id": "b3b44115-529b-4eb3-9f8b-686dd24547a1", + "created_at": 1771345063.4665146, + "updated_at": 1771345063.4665148, + "name": "Take out trash", + "points": 21, + "is_good": true, + "image_id": "trash-can", + "user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246" + }, + "13": { + "id": "c74fc8c7-5af1-4d40-afbb-6da2647ca18b", + "created_at": 1771345069.1633172, + "updated_at": 1771345069.1633174, + "name": "aaa", + "points": 1, + "is_good": true, + "image_id": "computer-game", + "user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246" + }, + "14": { + "id": "65e79bbd-6cdf-4636-9e9d-f608206dbd80", + "created_at": 1772251855.4823341, + "updated_at": 1772251855.4823341, + "name": "Be Cool \ud83d\ude0e", + "points": 5, + "type": "kindness", + "image_id": "58d4adb9-3cee-4d7c-8e90-d81173716ce5", + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d" + } + } +} \ No newline at end of file diff --git a/backend/data/db/tracking_events.json.bak.20260228_104347 b/backend/data/db/tracking_events.json.bak.20260228_104347 new file mode 100644 index 0000000..fcedad4 --- /dev/null +++ b/backend/data/db/tracking_events.json.bak.20260228_104347 @@ -0,0 +1,2615 @@ +{ + "_default": { + "1": { + "id": "db45b4a7-5227-4db2-b544-fb83e73a6b06", + "created_at": 1770667537.1304371, + "updated_at": 1770667537.1304371, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 0, + "points_after": 5, + "delta": 5, + "occurred_at": "2026-02-09T20:05:37.130437+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true + } + }, + "2": { + "id": "f14c4a59-5707-4f9f-93b2-8acc9e715993", + "created_at": 1770667538.988825, + "updated_at": 1770667538.988825, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 5, + "points_after": 10, + "delta": 5, + "occurred_at": "2026-02-09T20:05:38.988825+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true + } + }, + "3": { + "id": "218d83dc-ca17-454c-8278-53c5790d755a", + "created_at": 1770667543.042699, + "updated_at": 1770667543.042699, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 10, + "points_after": 7, + "delta": -3, + "occurred_at": "2026-02-09T20:05:43.042699+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false + } + }, + "4": { + "id": "c4ffd250-f3c1-4f0d-97e7-5e93cf9cb602", + "created_at": 1770667551.23383, + "updated_at": 1770667551.23383, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "reward", + "entity_id": "d70f0923-3de2-4fa1-b6a8-ca274f440783", + "action": "requested", + "points_before": 7, + "points_after": 7, + "delta": 0, + "occurred_at": "2026-02-09T20:05:51.233829+00:00", + "metadata": { + "reward_name": "Yay!", + "reward_cost": 5 + } + }, + "5": { + "id": "eddadcf7-e84c-4523-81c3-a1d9740949d7", + "created_at": 1770667557.2226722, + "updated_at": 1770667557.2226722, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "reward", + "entity_id": "d70f0923-3de2-4fa1-b6a8-ca274f440783", + "action": "cancelled", + "points_before": 7, + "points_after": 7, + "delta": 0, + "occurred_at": "2026-02-09T20:05:57.222672+00:00", + "metadata": { + "reward_name": "Yay!", + "reward_cost": 5 + } + }, + "6": { + "id": "50407d95-d903-4b5e-b324-aba8580f75d5", + "created_at": 1770667559.1435432, + "updated_at": 1770667559.1435432, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "reward", + "entity_id": "d70f0923-3de2-4fa1-b6a8-ca274f440783", + "action": "requested", + "points_before": 7, + "points_after": 7, + "delta": 0, + "occurred_at": "2026-02-09T20:05:59.143543+00:00", + "metadata": { + "reward_name": "Yay!", + "reward_cost": 5 + } + }, + "7": { + "id": "9a5668bb-e3e6-4b30-8af5-d614aa8bf87e", + "created_at": 1770667569.1408224, + "updated_at": 1770667569.1408224, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "reward", + "entity_id": "d70f0923-3de2-4fa1-b6a8-ca274f440783", + "action": "redeemed", + "points_before": 7, + "points_after": 2, + "delta": -5, + "occurred_at": "2026-02-09T20:06:09.140822+00:00", + "metadata": { + "reward_name": "Yay!", + "reward_cost": 5 + } + }, + "8": { + "id": "88e82331-2961-4ed6-ae4e-368a76bbf815", + "created_at": 1770694931.5858757, + "updated_at": 1770694931.5858757, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 2, + "points_after": 0, + "delta": -2, + "occurred_at": "2026-02-10T03:42:11.585875+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 444, + "has_override": true + } + }, + "9": { + "id": "cf447588-352d-4246-9db8-97818d427e42", + "created_at": 1770694934.6895225, + "updated_at": 1770694934.6895225, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 0, + "points_after": 16, + "delta": 16, + "occurred_at": "2026-02-10T03:42:14.689522+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 16, + "has_override": true + } + }, + "10": { + "id": "a5efe2b0-1443-406a-a21e-69a42c05e235", + "created_at": 1770694936.812026, + "updated_at": 1770694936.812026, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 16, + "points_after": 0, + "delta": -16, + "occurred_at": "2026-02-10T03:42:16.812026+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 444, + "has_override": true + } + }, + "11": { + "id": "73f2d27e-6542-4dd0-8a7f-155afbf2760b", + "created_at": 1770754891.5660706, + "updated_at": 1770754891.5660706, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 0, + "points_after": 16, + "delta": 16, + "occurred_at": "2026-02-10T20:21:31.566070+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 16, + "has_override": true + } + }, + "12": { + "id": "f1eb9af1-4270-40f1-9542-07598a65b43a", + "created_at": 1770756716.8001635, + "updated_at": 1770756716.8001635, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 16, + "points_after": 0, + "delta": -16, + "occurred_at": "2026-02-10T20:51:56.799161+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 441, + "has_override": true + } + }, + "13": { + "id": "a9abde6c-4d6f-4b0f-becf-6d5c231a2822", + "created_at": 1770758961.4656804, + "updated_at": 1770758961.4656804, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 0, + "points_after": 17, + "delta": 17, + "occurred_at": "2026-02-10T21:29:21.464679+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "14": { + "id": "3b95917e-b996-4097-a423-624961cdf770", + "created_at": 1770758985.4546168, + "updated_at": 1770758985.4546168, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "a3286d8f-f97d-410a-9f67-feea2cd19614", + "action": "activated", + "points_before": 17, + "points_after": 21, + "delta": 4, + "occurred_at": "2026-02-10T21:29:45.454616+00:00", + "metadata": { + "task_name": "Yay!", + "is_good": true, + "default_points": 1, + "custom_points": 4, + "has_override": true + } + }, + "15": { + "id": "92c201a2-42ff-4e1c-8379-86acf466281f", + "created_at": 1770759462.603392, + "updated_at": 1770759462.603392, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 21, + "points_after": 38, + "delta": 17, + "occurred_at": "2026-02-10T21:37:42.603392+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "16": { + "id": "1b1b7262-d20e-4f9b-9422-9b2300621d85", + "created_at": 1770759493.5032387, + "updated_at": 1770759493.5032387, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 38, + "points_after": 55, + "delta": 17, + "occurred_at": "2026-02-10T21:38:13.503238+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "17": { + "id": "6d36cd32-9353-43d9-8b8f-2d6b636aad34", + "created_at": 1770759639.4620554, + "updated_at": 1770759639.4620554, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 55, + "points_after": 0, + "delta": -55, + "occurred_at": "2026-02-10T21:40:39.462055+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 448, + "has_override": true + } + }, + "18": { + "id": "c95d0a2d-8725-4d61-b746-967dc59bc7ff", + "created_at": 1770759649.0091274, + "updated_at": 1770759649.0091274, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 0, + "points_after": 17, + "delta": 17, + "occurred_at": "2026-02-10T21:40:49.009127+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "19": { + "id": "715442e5-b925-4c53-a5f0-ca2526e14897", + "created_at": 1770759727.982654, + "updated_at": 1770759727.982654, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 17, + "points_after": 34, + "delta": 17, + "occurred_at": "2026-02-10T21:42:07.982654+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "20": { + "id": "4ce80078-b78a-4e24-9211-93529d6c0e56", + "created_at": 1770759747.9955525, + "updated_at": 1770759747.9955525, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 34, + "points_after": 51, + "delta": 17, + "occurred_at": "2026-02-10T21:42:27.995552+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "21": { + "id": "a1ef86c9-30a3-4b85-8044-0565080dd435", + "created_at": 1770759754.9552932, + "updated_at": 1770759754.9552932, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 51, + "points_after": 0, + "delta": -51, + "occurred_at": "2026-02-10T21:42:34.955293+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 448, + "has_override": true + } + }, + "22": { + "id": "66d8ad96-ec09-472e-828e-e22dcdae0889", + "created_at": 1770759759.5374327, + "updated_at": 1770759759.5374327, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 0, + "points_after": 17, + "delta": 17, + "occurred_at": "2026-02-10T21:42:39.537432+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "23": { + "id": "09715660-87af-44c2-bbfc-58a89009c1a5", + "created_at": 1770759771.0459328, + "updated_at": 1770759771.0459328, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 17, + "points_after": 0, + "delta": -17, + "occurred_at": "2026-02-10T21:42:51.045932+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 448, + "has_override": true + } + }, + "24": { + "id": "c2d05931-6bc9-48a5-b9f5-d4411f46b3d8", + "created_at": 1770759775.6446013, + "updated_at": 1770759775.6446013, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 0, + "points_after": 17, + "delta": 17, + "occurred_at": "2026-02-10T21:42:55.644601+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "25": { + "id": "61495276-ed88-4670-a7f7-795dd2f2857f", + "created_at": 1770759778.6789894, + "updated_at": 1770759778.6789894, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 17, + "points_after": 0, + "delta": -17, + "occurred_at": "2026-02-10T21:42:58.678989+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 448, + "has_override": true + } + }, + "26": { + "id": "e954a59f-bbe6-4a99-8d23-a2431f3634e2", + "created_at": 1770759781.2557397, + "updated_at": 1770759781.2557397, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "a3286d8f-f97d-410a-9f67-feea2cd19614", + "action": "activated", + "points_before": 0, + "points_after": 4, + "delta": 4, + "occurred_at": "2026-02-10T21:43:01.255739+00:00", + "metadata": { + "task_name": "Yay!", + "is_good": true, + "default_points": 1, + "custom_points": 4, + "has_override": true + } + }, + "27": { + "id": "dd0059ce-dd4c-4b40-9ed4-1c45e42704bb", + "created_at": 1770759783.6527722, + "updated_at": 1770759783.6527722, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 4, + "points_after": 21, + "delta": 17, + "occurred_at": "2026-02-10T21:43:03.652772+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 17, + "has_override": true + } + }, + "28": { + "id": "27303be2-1ead-4470-a103-eda41ef45f82", + "created_at": 1770759791.1557002, + "updated_at": 1770759791.1557002, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 21, + "points_after": 0, + "delta": -21, + "occurred_at": "2026-02-10T21:43:11.155700+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 448, + "has_override": true + } + }, + "29": { + "id": "af379660-d727-4a99-babd-17859f3e9c3d", + "created_at": 1770760059.2161124, + "updated_at": 1770760059.2161124, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "task", + "entity_id": "8d2fdfa9-1a7a-496f-8efb-b575817168aa", + "action": "activated", + "points_before": 0, + "points_after": 10, + "delta": 10, + "occurred_at": "2026-02-10T21:47:39.216112+00:00", + "metadata": { + "task_name": "Something!", + "is_good": true, + "default_points": 5, + "custom_points": 10, + "has_override": true + } + }, + "30": { + "id": "d4dbd9f9-b601-4f4f-8278-71c653e5c0d1", + "created_at": 1770760099.7214267, + "updated_at": 1770760099.7214267, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "penalty", + "entity_id": "8ddfb5d3-5bab-480b-8b22-bde0d96aeb7b", + "action": "activated", + "points_before": 10, + "points_after": 0, + "delta": -10, + "occurred_at": "2026-02-10T21:48:19.721426+00:00", + "metadata": { + "task_name": "BAD KID", + "is_good": false, + "default_points": 3, + "custom_points": 435, + "has_override": true + } + }, + "31": { + "id": "0dd26a51-f93f-49bd-9cbc-c462897cf952", + "created_at": 1771033433.4030366, + "updated_at": 1771033433.4030366, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:43:53.403036+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "32": { + "id": "4e322f7f-39eb-4e38-a560-62eb0e6feafd", + "created_at": 1771033438.9587145, + "updated_at": 1771033438.9587145, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:43:58.957714+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "33": { + "id": "2999612a-d3c7-481d-ab7d-9db0599fbc13", + "created_at": 1771034263.564902, + "updated_at": 1771034263.564902, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:57:43.564902+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "34": { + "id": "61608888-5850-45b7-b235-e5b9df9853ab", + "created_at": 1771034266.251231, + "updated_at": 1771034266.251231, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:57:46.251231+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "35": { + "id": "2ac764c7-3d54-4da1-8caa-2a17e5588d9a", + "created_at": 1771034319.2688167, + "updated_at": 1771034319.2688167, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:58:39.268816+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "36": { + "id": "474e7eb9-dc8b-4165-a586-32daf4d65e80", + "created_at": 1771034329.4619715, + "updated_at": 1771034329.4619715, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:58:49.461971+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "37": { + "id": "142ae289-cff4-4ca3-aba8-6e03cc1670a5", + "created_at": 1771034335.9931302, + "updated_at": 1771034335.9931302, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:58:55.993130+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "38": { + "id": "7c93cda5-d6f8-49a4-a3df-d14dc16ce373", + "created_at": 1771034338.949154, + "updated_at": 1771034338.949154, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:58:58.949153+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "39": { + "id": "bd6906ae-7e5a-4de7-ac66-4a5206b15905", + "created_at": 1771034343.5791094, + "updated_at": 1771034343.5791094, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T01:59:03.579109+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "40": { + "id": "510c6ed7-e16d-47e3-91f3-36c8976b2de4", + "created_at": 1771034583.3587265, + "updated_at": 1771034583.3587265, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T02:03:03.358726+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "41": { + "id": "25b899a9-4ebb-4cbc-b205-6430dfa4fde0", + "created_at": 1771037223.2644377, + "updated_at": 1771037223.2644377, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T02:47:03.263437+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "42": { + "id": "9d50970e-994f-43a9-9fcc-d3281b7755d3", + "created_at": 1771037381.865174, + "updated_at": 1771037381.865174, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T02:49:41.865173+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "43": { + "id": "c71bb9b2-bbd6-466a-8c62-df9f4a03358f", + "created_at": 1771039901.8020499, + "updated_at": 1771039901.8020499, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T03:31:41.802049+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "44": { + "id": "74db58da-3acb-4b3c-8ab0-075519524a2b", + "created_at": 1771039919.839071, + "updated_at": 1771039919.839071, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T03:31:59.839071+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "45": { + "id": "b0b81fc8-e2b7-4f8c-895a-9840f3ca3fd1", + "created_at": 1771041539.238546, + "updated_at": 1771041539.238546, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T03:58:59.238545+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "46": { + "id": "9418397f-e3d6-46c7-86e4-061cae237331", + "created_at": 1771041551.7608516, + "updated_at": 1771041551.7608516, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T03:59:11.759850+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "47": { + "id": "960c2a5a-ec6a-4681-a480-ed0e41310499", + "created_at": 1771041562.2377994, + "updated_at": 1771041562.2377994, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T03:59:22.237799+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "48": { + "id": "448336b9-05c4-4207-b27f-ec455920d589", + "created_at": 1771041566.7536688, + "updated_at": 1771041566.7536688, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T03:59:26.753668+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "49": { + "id": "f82b7b3c-1084-415e-82b1-55b240404998", + "created_at": 1771041585.746837, + "updated_at": 1771041585.746837, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-14T03:59:45.746836+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "50": { + "id": "84fae25c-8eb1-4032-9a98-d55a24597ea5", + "created_at": 1771277065.2358286, + "updated_at": 1771277065.2358286, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:24:25.235828+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "51": { + "id": "95436203-aef3-4cc9-b4df-021e6f71a740", + "created_at": 1771277074.832329, + "updated_at": 1771277074.832329, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:24:34.832328+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "52": { + "id": "101711f6-c89d-4d95-91c7-d1dcbaac6c79", + "created_at": 1771277081.5148609, + "updated_at": 1771277081.5148609, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:24:41.514860+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "53": { + "id": "5dc540ff-9535-461f-beda-82848eefac8b", + "created_at": 1771277087.0518806, + "updated_at": 1771277087.0518806, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:24:47.051880+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "54": { + "id": "d53c1f0f-2984-4520-bddb-5efd74e4f89f", + "created_at": 1771277092.1434364, + "updated_at": 1771277092.1434364, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:24:52.143436+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "55": { + "id": "96032e48-a3b8-43d6-b615-e908d695d7ef", + "created_at": 1771277113.7664163, + "updated_at": 1771277113.7664163, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:25:13.766416+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "56": { + "id": "8cdde5a4-6d2b-459b-bc8f-53ead7e25620", + "created_at": 1771277118.4119937, + "updated_at": 1771277118.4119937, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:25:18.411993+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "57": { + "id": "493d80d9-b809-4a95-8a6a-490f64942aab", + "created_at": 1771277130.4909177, + "updated_at": 1771277130.4909177, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:25:30.490917+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "58": { + "id": "e2c113f1-1790-4e39-869d-6d7da6b64fe9", + "created_at": 1771277150.8469846, + "updated_at": 1771277150.8469846, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:25:50.846984+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "59": { + "id": "e78d33cf-6df2-4230-8b8f-85a4b520c729", + "created_at": 1771277158.732253, + "updated_at": 1771277158.732253, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:25:58.732253+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "60": { + "id": "44f7132b-5e22-48db-b6d1-76a78824f48d", + "created_at": 1771277163.998916, + "updated_at": 1771277163.998916, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:26:03.998915+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "61": { + "id": "e47c9017-a311-4369-8c10-23d4bba8e98b", + "created_at": 1771277166.2835467, + "updated_at": 1771277166.2835467, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:26:06.283546+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "62": { + "id": "3dcdf30c-6c4c-4eab-8acd-7945302ea312", + "created_at": 1771277423.5567417, + "updated_at": 1771277423.5567417, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:30:23.556741+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "63": { + "id": "385ac48c-7fc8-4303-be4c-dcca099d1bd4", + "created_at": 1771277431.6593883, + "updated_at": 1771277431.6593883, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:30:31.659388+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "64": { + "id": "3dbea7d6-c43a-4e65-aa4f-debce0940f9b", + "created_at": 1771277441.9338663, + "updated_at": 1771277441.9338663, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:30:41.933866+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "65": { + "id": "701f41ab-b24b-44ef-aa84-85fceb0fda4c", + "created_at": 1771277698.4460948, + "updated_at": 1771277698.4470959, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:34:58.446094+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "66": { + "id": "5099f7ea-c1a9-4c93-99a1-bc32c0dfb3d1", + "created_at": 1771277703.8058004, + "updated_at": 1771277703.8058004, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:35:03.805800+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "67": { + "id": "a95698de-ec50-4ce1-8e2e-f5c750cfcddd", + "created_at": 1771277710.9951723, + "updated_at": 1771277710.9951723, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:35:10.995172+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "68": { + "id": "26e5bbed-f213-4bb5-9607-e3ae61a2d714", + "created_at": 1771277715.2101293, + "updated_at": 1771277715.2101293, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:35:15.209128+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "69": { + "id": "4d1cb9c8-c4ba-45ca-aec8-09b4cfe8bc15", + "created_at": 1771277717.5750105, + "updated_at": 1771277717.5750105, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:35:17.575010+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "70": { + "id": "afc1ec67-015e-45b0-998e-99129d44fde2", + "created_at": 1771277721.6180563, + "updated_at": 1771277721.6180563, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:35:21.618056+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "71": { + "id": "c2293a08-b477-48dd-aba5-f19bf989fc5b", + "created_at": 1771277724.6077926, + "updated_at": 1771277724.6077926, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:35:24.607792+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "72": { + "id": "59a8f12b-1017-4261-8cbf-67ac169aaf93", + "created_at": 1771278107.3005638, + "updated_at": 1771278107.3005638, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:41:47.300563+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "73": { + "id": "77f0ca74-db7c-42c6-8c4b-f66a38fb6ec6", + "created_at": 1771278113.0728266, + "updated_at": 1771278113.0728266, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": null, + "entity_type": "user", + "entity_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T21:41:53.072826+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "74": { + "id": "7f31ddce-ac2b-4ebc-aa92-cf6c8f35eca6", + "created_at": 1771280375.6230762, + "updated_at": 1771280375.6230762, + "user_id": "965cd681-e59b-4989-a1e4-a54161a86e8a", + "child_id": "6baee348-4ac8-41ad-9897-f98cc1d55cf1", + "entity_type": "reward", + "entity_id": "d70f0923-3de2-4fa1-b6a8-ca274f440783", + "action": "redeemed", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-16T22:19:35.623076+00:00", + "metadata": { + "reward_name": "Yay!", + "reward_cost": 6, + "default_cost": 6, + "custom_cost": 0, + "has_override": true + } + }, + "75": { + "id": "1a926877-62be-40c9-84a0-da6857c2e20e", + "created_at": 1771354506.2420099, + "updated_at": 1771354506.2420099, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "4c4217a3-dfc0-4b88-99f3-19c93d9ff7d2", + "entity_type": "task", + "entity_id": "8b5750d4-5a58-40cb-a31b-667569069d34", + "action": "activated", + "points_before": 0, + "points_after": 20, + "delta": 20, + "occurred_at": "2026-02-17T18:55:06.242009+00:00", + "metadata": { + "task_name": "Clean your mess", + "is_good": true, + "default_points": 20 + } + }, + "76": { + "id": "76f5e37a-3747-4ab7-9408-9a6ac567aacb", + "created_at": 1771354513.1727414, + "updated_at": 1771354513.1727414, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "4c4217a3-dfc0-4b88-99f3-19c93d9ff7d2", + "entity_type": "penalty", + "entity_id": "8be18d9a-48e6-402b-a0ba-630a2d50e325", + "action": "activated", + "points_before": 20, + "points_after": 15, + "delta": -5, + "occurred_at": "2026-02-17T18:55:13.172741+00:00", + "metadata": { + "task_name": "Not flushing toilet", + "is_good": false, + "default_points": 5 + } + }, + "77": { + "id": "7666b8ef-9c3f-4941-b590-5fc3cd23aa23", + "created_at": 1771354520.2278466, + "updated_at": 1771354520.2278466, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "4c4217a3-dfc0-4b88-99f3-19c93d9ff7d2", + "entity_type": "task", + "entity_id": "8b5750d4-5a58-40cb-a31b-667569069d34", + "action": "activated", + "points_before": 15, + "points_after": 35, + "delta": 20, + "occurred_at": "2026-02-17T18:55:20.227846+00:00", + "metadata": { + "task_name": "Clean your mess", + "is_good": true, + "default_points": 20 + } + }, + "78": { + "id": "b3411dbd-8b31-44f4-87a5-76bdaddb4866", + "created_at": 1771354944.0281186, + "updated_at": 1771354944.0281186, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "4c4217a3-dfc0-4b88-99f3-19c93d9ff7d2", + "entity_type": "task", + "entity_id": "8b5750d4-5a58-40cb-a31b-667569069d34", + "action": "activated", + "points_before": 35, + "points_after": 55, + "delta": 20, + "occurred_at": "2026-02-17T19:02:24.028118+00:00", + "metadata": { + "task_name": "Clean your mess", + "is_good": true, + "default_points": 20 + } + }, + "79": { + "id": "c426d02a-2340-463b-9297-45fe0bba12d7", + "created_at": 1771354945.7935014, + "updated_at": 1771354945.7935014, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "4c4217a3-dfc0-4b88-99f3-19c93d9ff7d2", + "entity_type": "penalty", + "entity_id": "8be18d9a-48e6-402b-a0ba-630a2d50e325", + "action": "activated", + "points_before": 55, + "points_after": 50, + "delta": -5, + "occurred_at": "2026-02-17T19:02:25.793501+00:00", + "metadata": { + "task_name": "Not flushing toilet", + "is_good": false, + "default_points": 5 + } + }, + "80": { + "id": "fe3543a5-8bb3-46e3-ae6d-8eff85124a02", + "created_at": 1771354947.8204334, + "updated_at": 1771354947.8204334, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "4c4217a3-dfc0-4b88-99f3-19c93d9ff7d2", + "entity_type": "reward", + "entity_id": "1a0ec798-516e-448d-9684-9935d87d62bf", + "action": "redeemed", + "points_before": 50, + "points_after": 34, + "delta": -16, + "occurred_at": "2026-02-17T19:02:27.820433+00:00", + "metadata": { + "reward_name": "Reward2", + "reward_cost": 16, + "default_cost": 16 + } + }, + "81": { + "id": "d12cf673-ea2b-40fc-93b4-6c501cb517c0", + "created_at": 1771362278.2112825, + "updated_at": 1771362278.2112825, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:04:38.211282+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "82": { + "id": "136926e3-5253-4458-a17b-3988235ddd9e", + "created_at": 1771362526.2150152, + "updated_at": 1771362526.2150152, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:08:46.215015+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "83": { + "id": "234e5f25-2138-4aac-a35b-dea7c03f4018", + "created_at": 1771362570.4153075, + "updated_at": 1771362570.4153075, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:09:30.415307+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "84": { + "id": "0086688c-ec23-4cd0-b9a7-95b4d4126836", + "created_at": 1771362643.5340343, + "updated_at": 1771362643.5340343, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:10:43.534034+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "85": { + "id": "742cade1-05b5-427a-aeb4-0aa183b56392", + "created_at": 1771363424.1411645, + "updated_at": 1771363424.1411645, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:23:44.140164+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "86": { + "id": "6567b854-b865-4d6c-aa8c-5c3cd730721f", + "created_at": 1771363531.927834, + "updated_at": 1771363531.927834, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:25:31.927834+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "87": { + "id": "fd8fcfac-b11e-4eaf-a559-290296574d8d", + "created_at": 1771363537.3987541, + "updated_at": 1771363537.3987541, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:25:37.398754+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "88": { + "id": "4b5deee3-b457-4942-a779-f2752549b9f8", + "created_at": 1771363541.9387205, + "updated_at": 1771363541.9387205, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:25:41.937719+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "89": { + "id": "ed370b03-567c-4d02-bb73-66d32a64424f", + "created_at": 1771363546.621036, + "updated_at": 1771363546.621036, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:25:46.621036+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "90": { + "id": "3ab88b6c-c8ec-43ea-b9cb-3852772a86cf", + "created_at": 1771363553.8799405, + "updated_at": 1771363553.8799405, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:25:53.879940+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "91": { + "id": "e87bd58b-cb31-4eaa-ab56-a91bb00e52f8", + "created_at": 1771363570.2317348, + "updated_at": 1771363570.2317348, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:26:10.231734+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "92": { + "id": "f9cf8892-9a1f-49c8-8a27-e6e15c4856d4", + "created_at": 1771363589.744401, + "updated_at": 1771363589.744401, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:26:29.744400+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "93": { + "id": "a1ad7b4d-3af8-4968-808f-db7f90555d88", + "created_at": 1771363608.701112, + "updated_at": 1771363608.701112, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:26:48.701112+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "94": { + "id": "1c089f9c-5de7-48da-b1aa-ca49c5bf153c", + "created_at": 1771363617.3852348, + "updated_at": 1771363617.3852348, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:26:57.385234+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "95": { + "id": "823e4e4f-2690-4356-bc60-5fd8488bc1ff", + "created_at": 1771363626.94656, + "updated_at": 1771363626.94656, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:27:06.946560+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "96": { + "id": "defc09f1-98a6-4100-bd6a-7753513c8cd2", + "created_at": 1771363643.5360947, + "updated_at": 1771363643.5360947, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:27:23.536094+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "97": { + "id": "6c1ce3af-2626-4a47-8ef0-9790875faa0b", + "created_at": 1771363937.3232472, + "updated_at": 1771363937.3232472, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": null, + "entity_type": "user", + "entity_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "action": "updated", + "points_before": 0, + "points_after": 0, + "delta": 0, + "occurred_at": "2026-02-17T21:32:17.323247+00:00", + "metadata": { + "first_name_updated": true, + "last_name_updated": true, + "image_updated": true + } + }, + "98": { + "id": "768a2500-4afc-44bd-9ed8-7a7920aaabe6", + "created_at": 1771452138.3594375, + "updated_at": 1771452138.3594375, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "task", + "entity_id": "57c21328-637e-4df3-be5b-7f619cbf4076", + "action": "activated", + "points_before": 0, + "points_after": 20, + "delta": 20, + "occurred_at": "2026-02-18T22:02:18.359437+00:00", + "metadata": { + "task_name": "Take out trash", + "is_good": true, + "default_points": 20 + } + }, + "99": { + "id": "69889ffc-cfc0-431b-b24c-ea79efad7511", + "created_at": 1771474320.751986, + "updated_at": 1771474320.751986, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:12:00.751986+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "100": { + "id": "4dea1030-5560-43db-a538-64d07beac296", + "created_at": 1771474327.3478558, + "updated_at": 1771474327.3478558, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:12:07.347856+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "101": { + "id": "7d554d66-c17b-4db8-a4ef-492ce8b04326", + "created_at": 1771474329.2722824, + "updated_at": 1771474329.2722824, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:12:09.272282+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "102": { + "id": "b3695577-b10f-49ef-9bb7-fa9e0028c6bb", + "created_at": 1771474780.5221603, + "updated_at": 1771474780.5221603, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:19:40.522160+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "103": { + "id": "de4794b2-daa1-4607-a85a-5f350c5e8096", + "created_at": 1771474801.6226425, + "updated_at": 1771474801.6226425, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:20:01.622642+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "104": { + "id": "1e273f23-1da6-4624-baa5-3f5089268e8e", + "created_at": 1771474808.4760501, + "updated_at": 1771474808.4760501, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:20:08.476050+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "105": { + "id": "e72786cb-558e-4c85-885b-179dddde1d4e", + "created_at": 1771474891.473705, + "updated_at": 1771474891.473705, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:21:31.473705+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "106": { + "id": "3bfc7dea-0213-44a1-8867-0932efecd953", + "created_at": 1771474899.5975208, + "updated_at": 1771474899.5975208, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:21:39.596519+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "107": { + "id": "4785d22f-eda7-46e3-8f1b-3ac2cf4181f3", + "created_at": 1771474908.7623906, + "updated_at": 1771474908.7623906, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:21:48.762390+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "108": { + "id": "86d17b70-c00b-42b5-9256-a37738a17519", + "created_at": 1771474944.5820282, + "updated_at": 1771474944.5820282, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:22:24.582028+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "109": { + "id": "536a54ff-436d-44c9-aeb0-7691d18d300b", + "created_at": 1771474986.5370867, + "updated_at": 1771474986.5370867, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T04:23:06.537086+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "110": { + "id": "f4850b1c-9e64-415e-bff1-dc2690215efb", + "created_at": 1771515588.3207414, + "updated_at": 1771515588.3207414, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:39:48.320741+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "111": { + "id": "b51f4ec1-6774-4c4a-925d-814db346f3ad", + "created_at": 1771515601.2986414, + "updated_at": 1771515601.2986414, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:40:01.298641+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "112": { + "id": "00d4579d-7ba3-414c-ae86-4cb13ba8ecea", + "created_at": 1771516501.2795212, + "updated_at": 1771516501.2795212, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:01.278520+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "113": { + "id": "23ce7732-3cf1-44f1-be87-5a2f3f270ecb", + "created_at": 1771516506.1805122, + "updated_at": 1771516506.1805122, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:06.180512+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "114": { + "id": "23b11e79-1975-4d8e-8046-f1bfab2e27a8", + "created_at": 1771516509.267931, + "updated_at": 1771516509.267931, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:09.267930+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "115": { + "id": "f4d95305-eccd-4737-8d01-bdfbf2e1e5d9", + "created_at": 1771516518.4790373, + "updated_at": 1771516518.4790373, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:18.479037+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "116": { + "id": "9afaa5cf-966f-46f0-8eca-79b409301d04", + "created_at": 1771516522.4230947, + "updated_at": 1771516522.4230947, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:22.423094+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "117": { + "id": "9c671279-4a4e-467d-9eca-710850223f01", + "created_at": 1771516532.6118965, + "updated_at": 1771516532.6118965, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:32.611896+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "118": { + "id": "7d628f48-dc78-45cf-9a67-3b5490d1e51a", + "created_at": 1771516542.1554346, + "updated_at": 1771516542.1554346, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:42.155434+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "119": { + "id": "584cc38c-a18a-4719-ad30-df0f6bbc6a63", + "created_at": 1771516556.4102314, + "updated_at": 1771516556.4102314, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:56.410231+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "120": { + "id": "e066f072-d36f-44cb-b587-4bb070c96ebf", + "created_at": 1771516559.9880385, + "updated_at": 1771516559.9880385, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:55:59.988038+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "121": { + "id": "21bddcf4-dfdd-40f9-900b-580c3fc1dade", + "created_at": 1771516678.929796, + "updated_at": 1771516678.929796, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:57:58.929796+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "122": { + "id": "b28f0bfd-e48e-4f08-b47f-eb1b8c666856", + "created_at": 1771516682.7285793, + "updated_at": 1771516682.7285793, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:58:02.728579+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "123": { + "id": "dafa2072-09ab-4322-9243-7f5e874897dd", + "created_at": 1771516686.1288366, + "updated_at": 1771516686.1288366, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "requested", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:58:06.128836+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "124": { + "id": "d8654444-1dcb-463b-a933-1dcb29998c73", + "created_at": 1771516689.6522863, + "updated_at": 1771516689.6522863, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "reward", + "entity_id": "b79882bc-09ac-4174-8d30-43840c8e59d1", + "action": "cancelled", + "points_before": 20, + "points_after": 20, + "delta": 0, + "occurred_at": "2026-02-19T15:58:09.652286+00:00", + "metadata": { + "reward_name": "test", + "reward_cost": 3 + } + }, + "125": { + "id": "4716d1e3-cf65-4690-a5a3-dd8e0f9709ee", + "created_at": 1772217365.3814855, + "updated_at": 1772217365.3814855, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "task", + "entity_id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "action": "activated", + "points_before": 20, + "points_after": 45, + "delta": 25, + "occurred_at": "2026-02-27T18:36:05.381485+00:00", + "metadata": { + "task_name": "Make your bed", + "is_good": true, + "default_points": 25 + } + }, + "126": { + "id": "3dd4b1d0-e2cb-4f67-901b-ba1977289fca", + "created_at": 1772217368.0669053, + "updated_at": 1772217368.0669053, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "penalty", + "entity_id": "8be18d9a-48e6-402b-a0ba-630a2d50e325", + "action": "activated", + "points_before": 45, + "points_after": 40, + "delta": -5, + "occurred_at": "2026-02-27T18:36:08.066905+00:00", + "metadata": { + "task_name": "Not flushing toilet", + "is_good": false, + "default_points": 5 + } + }, + "127": { + "id": "496e8b77-94ea-4376-80f8-0309a172c095", + "created_at": 1772217371.77779, + "updated_at": 1772217371.77779, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "penalty", + "entity_id": "8be18d9a-48e6-402b-a0ba-630a2d50e325", + "action": "activated", + "points_before": 40, + "points_after": 35, + "delta": -5, + "occurred_at": "2026-02-27T18:36:11.777789+00:00", + "metadata": { + "task_name": "Not flushing toilet", + "is_good": false, + "default_points": 5 + } + }, + "128": { + "id": "db9ad738-24ae-4092-bece-7d3e5d901a22", + "created_at": 1772251930.9958174, + "updated_at": 1772251930.9958174, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "kindness", + "entity_id": "65e79bbd-6cdf-4636-9e9d-f608206dbd80", + "action": "activated", + "points_before": 35, + "points_after": 40, + "delta": 5, + "occurred_at": "2026-02-28T04:12:10.995817+00:00", + "metadata": { + "task_name": "Be Cool \ud83d\ude0e", + "task_type": "kindness", + "default_points": 5 + } + }, + "129": { + "id": "c8c060ff-0639-47ec-9f85-1db4f0b78bc0", + "created_at": 1772251933.5963702, + "updated_at": 1772251933.5963702, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "kindness", + "entity_id": "65e79bbd-6cdf-4636-9e9d-f608206dbd80", + "action": "activated", + "points_before": 40, + "points_after": 45, + "delta": 5, + "occurred_at": "2026-02-28T04:12:13.596370+00:00", + "metadata": { + "task_name": "Be Cool \ud83d\ude0e", + "task_type": "kindness", + "default_points": 5 + } + }, + "130": { + "id": "8c4a87d5-9c80-4e72-bc46-d3d7d8bdefd2", + "created_at": 1772251937.3509393, + "updated_at": 1772251937.3509393, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "penalty", + "entity_id": "8be18d9a-48e6-402b-a0ba-630a2d50e325", + "action": "activated", + "points_before": 45, + "points_after": 40, + "delta": -5, + "occurred_at": "2026-02-28T04:12:17.350939+00:00", + "metadata": { + "task_name": "Not flushing toilet", + "task_type": "penalty", + "default_points": 5 + } + }, + "131": { + "id": "e6a917c4-83af-40ca-be92-3d58eb1b6cb7", + "created_at": 1772251992.0992663, + "updated_at": 1772251992.0992663, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "chore", + "entity_id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "action": "confirmed", + "points_before": 40, + "points_after": 40, + "delta": 0, + "occurred_at": "2026-02-28T04:13:12.099266+00:00", + "metadata": { + "task_name": "Make your bed", + "task_type": "chore" + } + }, + "132": { + "id": "564a6354-0e96-479d-8c85-b5efd2af7f2d", + "created_at": 1772252001.408349, + "updated_at": 1772252001.408349, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "chore", + "entity_id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "action": "cancelled", + "points_before": 40, + "points_after": 40, + "delta": 0, + "occurred_at": "2026-02-28T04:13:21.408348+00:00", + "metadata": { + "task_name": "Make your bed" + } + }, + "133": { + "id": "0dfb6e33-2c12-4aea-ad66-e89e82afe572", + "created_at": 1772252003.5179667, + "updated_at": 1772252003.5179667, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "chore", + "entity_id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "action": "confirmed", + "points_before": 40, + "points_after": 40, + "delta": 0, + "occurred_at": "2026-02-28T04:13:23.517966+00:00", + "metadata": { + "task_name": "Make your bed", + "task_type": "chore" + } + }, + "134": { + "id": "71ec1266-785a-4644-9831-7e239a881d26", + "created_at": 1772252027.6866477, + "updated_at": 1772252027.6866477, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "chore", + "entity_id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "action": "approved", + "points_before": 40, + "points_after": 65, + "delta": 25, + "occurred_at": "2026-02-28T04:13:47.686647+00:00", + "metadata": { + "task_name": "Make your bed", + "task_type": "chore", + "default_points": 25 + } + }, + "135": { + "id": "544c05ba-ac12-48d0-866a-68d0f5f71af6", + "created_at": 1772252092.0160515, + "updated_at": 1772252092.0160515, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "chore", + "entity_id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "action": "reset", + "points_before": 65, + "points_after": 65, + "delta": 0, + "occurred_at": "2026-02-28T04:14:52.016051+00:00", + "metadata": { + "task_name": "Make your bed" + } + }, + "136": { + "id": "1f8f922f-71ae-432f-a04a-9b1bc236375b", + "created_at": 1772252099.2162547, + "updated_at": 1772252099.2162547, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "chore", + "entity_id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "action": "confirmed", + "points_before": 65, + "points_after": 65, + "delta": 0, + "occurred_at": "2026-02-28T04:14:59.216254+00:00", + "metadata": { + "task_name": "Make your bed", + "task_type": "chore" + } + }, + "137": { + "id": "e0dc497d-ae09-40c7-a97e-b1577fcd2d6e", + "created_at": 1772252106.8427596, + "updated_at": 1772252106.8427596, + "user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d", + "child_id": "040e59f9-e019-49f1-9ef6-37829bb5e018", + "entity_type": "chore", + "entity_id": "70316500-e4ce-4399-8e4b-86a4046fafcb", + "action": "approved", + "points_before": 65, + "points_after": 90, + "delta": 25, + "occurred_at": "2026-02-28T04:15:06.842759+00:00", + "metadata": { + "task_name": "Make your bed", + "task_type": "chore", + "default_points": 25 + } + } + } +} \ No newline at end of file diff --git a/backend/db/db.py b/backend/db/db.py index 5b39811..c4d7669 100644 --- a/backend/db/db.py +++ b/backend/db/db.py @@ -72,6 +72,7 @@ task_path = os.path.join(base_dir, 'tasks.json') reward_path = os.path.join(base_dir, 'rewards.json') image_path = os.path.join(base_dir, 'images.json') pending_reward_path = os.path.join(base_dir, 'pending_rewards.json') +pending_confirmations_path = os.path.join(base_dir, 'pending_confirmations.json') users_path = os.path.join(base_dir, 'users.json') tracking_events_path = os.path.join(base_dir, 'tracking_events.json') child_overrides_path = os.path.join(base_dir, 'child_overrides.json') @@ -84,6 +85,7 @@ _task_db = TinyDB(task_path, indent=2) _reward_db = TinyDB(reward_path, indent=2) _image_db = TinyDB(image_path, indent=2) _pending_rewards_db = TinyDB(pending_reward_path, indent=2) +_pending_confirmations_db = TinyDB(pending_confirmations_path, indent=2) _users_db = TinyDB(users_path, indent=2) _tracking_events_db = TinyDB(tracking_events_path, indent=2) _child_overrides_db = TinyDB(child_overrides_path, indent=2) @@ -96,6 +98,7 @@ task_db = LockedTable(_task_db) reward_db = LockedTable(_reward_db) image_db = LockedTable(_image_db) pending_reward_db = LockedTable(_pending_rewards_db) +pending_confirmations_db = LockedTable(_pending_confirmations_db) users_db = LockedTable(_users_db) tracking_events_db = LockedTable(_tracking_events_db) child_overrides_db = LockedTable(_child_overrides_db) @@ -108,6 +111,7 @@ if os.environ.get('DB_ENV', 'prod') == 'test': reward_db.truncate() image_db.truncate() pending_reward_db.truncate() + pending_confirmations_db.truncate() users_db.truncate() tracking_events_db.truncate() child_overrides_db.truncate() diff --git a/backend/db/default.py b/backend/db/default.py index cc440e0..d92e965 100644 --- a/backend/db/default.py +++ b/backend/db/default.py @@ -15,16 +15,16 @@ from models.task import Task def populate_default_data(): # Create tasks task_defs = [ - ('default_001', "Be Respectful", 2, True, ''), - ('default_002', "Brush Teeth", 2, True, ''), - ('default_003', "Go To Bed", 2, True, ''), - ('default_004', "Do What You Are Told", 2, True, ''), - ('default_005', "Make Your Bed", 2, True, ''), - ('default_006', "Do Homework", 2, True, ''), + ('default_001', "Be Respectful", 2, 'chore', ''), + ('default_002', "Brush Teeth", 2, 'chore', ''), + ('default_003', "Go To Bed", 2, 'chore', ''), + ('default_004', "Do What You Are Told", 2, 'chore', ''), + ('default_005', "Make Your Bed", 2, 'chore', ''), + ('default_006', "Do Homework", 2, 'chore', ''), ] tasks = [] - for _id, name, points, is_good, image in task_defs: - t = Task(name=name, points=points, is_good=is_good, image_id=image, id=_id) + for _id, name, points, task_type, image in task_defs: + t = Task(name=name, points=points, type=task_type, image_id=image, id=_id) tq = Query() _result = task_db.search(tq.id == _id) if not _result: @@ -88,18 +88,18 @@ def createDefaultTasks(): """Create default tasks if none exist.""" if len(task_db.all()) == 0: default_tasks = [ - Task(name="Take out trash", points=20, is_good=True, image_id="trash-can"), - Task(name="Make your bed", points=25, is_good=True, image_id="make-the-bed"), - Task(name="Sweep and clean kitchen", points=15, is_good=True, image_id="vacuum"), - Task(name="Do homework early", points=30, is_good=True, image_id="homework"), - Task(name="Be good for the day", points=15, is_good=True, image_id="good"), - Task(name="Clean your mess", points=20, is_good=True, image_id="broom"), + Task(name="Take out trash", points=20, type='chore', image_id="trash-can"), + Task(name="Make your bed", points=25, type='chore', image_id="make-the-bed"), + Task(name="Sweep and clean kitchen", points=15, type='chore', image_id="vacuum"), + Task(name="Do homework early", points=30, type='chore', image_id="homework"), + Task(name="Be good for the day", points=15, type='kindness', image_id="good"), + Task(name="Clean your mess", points=20, type='chore', image_id="broom"), - Task(name="Fighting", points=10, is_good=False, image_id="fighting"), - Task(name="Yelling at parents", points=10, is_good=False, image_id="yelling"), - Task(name="Lying", points=10, is_good=False, image_id="lying"), - Task(name="Not doing what told", points=5, is_good=False, image_id="ignore"), - Task(name="Not flushing toilet", points=5, is_good=False, image_id="toilet"), + Task(name="Fighting", points=10, type='penalty', image_id="fighting"), + Task(name="Yelling at parents", points=10, type='penalty', image_id="yelling"), + Task(name="Lying", points=10, type='penalty', image_id="lying"), + Task(name="Not doing what told", points=5, type='penalty', image_id="ignore"), + Task(name="Not flushing toilet", points=5, type='penalty', image_id="toilet"), ] for task in default_tasks: task_db.insert(task.to_dict()) diff --git a/backend/events/types/child_chore_confirmation.py b/backend/events/types/child_chore_confirmation.py new file mode 100644 index 0000000..efcd1c3 --- /dev/null +++ b/backend/events/types/child_chore_confirmation.py @@ -0,0 +1,28 @@ +from events.types.payload import Payload + + +class ChildChoreConfirmation(Payload): + OPERATION_CONFIRMED = "CONFIRMED" + OPERATION_APPROVED = "APPROVED" + OPERATION_REJECTED = "REJECTED" + OPERATION_CANCELLED = "CANCELLED" + OPERATION_RESET = "RESET" + + def __init__(self, child_id: str, task_id: str, operation: str): + super().__init__({ + 'child_id': child_id, + 'task_id': task_id, + 'operation': operation + }) + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def task_id(self) -> str: + return self.get("task_id") + + @property + def operation(self) -> str: + return self.get("operation") diff --git a/backend/events/types/event_types.py b/backend/events/types/event_types.py index 1ce6520..f66a87f 100644 --- a/backend/events/types/event_types.py +++ b/backend/events/types/event_types.py @@ -26,3 +26,4 @@ class EventType(Enum): CHORE_SCHEDULE_MODIFIED = "chore_schedule_modified" CHORE_TIME_EXTENDED = "chore_time_extended" + CHILD_CHORE_CONFIRMATION = "child_chore_confirmation" diff --git a/backend/main.py b/backend/main.py index 5380d9a..b655d7f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,8 +9,11 @@ from api.admin_api import admin_api from api.auth_api import auth_api from api.child_api import child_api from api.child_override_api import child_override_api +from api.chore_api import chore_api from api.chore_schedule_api import chore_schedule_api from api.image_api import image_api +from api.kindness_api import kindness_api +from api.penalty_api import penalty_api from api.reward_api import reward_api from api.task_api import task_api from api.tracking_api import tracking_api @@ -38,7 +41,10 @@ app = Flask(__name__) app.register_blueprint(admin_api) app.register_blueprint(child_api) app.register_blueprint(child_override_api) +app.register_blueprint(chore_api) app.register_blueprint(chore_schedule_api) +app.register_blueprint(kindness_api) +app.register_blueprint(penalty_api) app.register_blueprint(reward_api) app.register_blueprint(task_api) app.register_blueprint(image_api) diff --git a/backend/models/child_override.py b/backend/models/child_override.py index ca5be0d..48342f0 100644 --- a/backend/models/child_override.py +++ b/backend/models/child_override.py @@ -16,15 +16,15 @@ class ChildOverride(BaseModel): """ child_id: str entity_id: str - entity_type: Literal['task', 'reward'] + entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'] custom_value: int def __post_init__(self): """Validate custom_value range and entity_type.""" if self.custom_value < 0 or self.custom_value > 10000: raise ValueError("custom_value must be between 0 and 10000") - if self.entity_type not in ['task', 'reward']: - raise ValueError("entity_type must be 'task' or 'reward'") + if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']: + raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'") @classmethod def from_dict(cls, d: dict): @@ -52,7 +52,7 @@ class ChildOverride(BaseModel): def create_override( child_id: str, entity_id: str, - entity_type: Literal['task', 'reward'], + entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'], custom_value: int ) -> 'ChildOverride': """Factory method to create a new override.""" diff --git a/backend/models/pending_confirmation.py b/backend/models/pending_confirmation.py new file mode 100644 index 0000000..a56425c --- /dev/null +++ b/backend/models/pending_confirmation.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import Literal, Optional +from models.base import BaseModel + + +PendingEntityType = Literal['chore', 'reward'] +PendingStatus = Literal['pending', 'approved', 'rejected'] + + +@dataclass +class PendingConfirmation(BaseModel): + child_id: str + entity_id: str + entity_type: PendingEntityType + user_id: str + status: PendingStatus = "pending" + approved_at: Optional[str] = None # ISO 8601 UTC timestamp, set on approval + + @classmethod + def from_dict(cls, d: dict): + return cls( + child_id=d.get('child_id'), + entity_id=d.get('entity_id'), + entity_type=d.get('entity_type'), + user_id=d.get('user_id'), + status=d.get('status', 'pending'), + approved_at=d.get('approved_at'), + id=d.get('id'), + created_at=d.get('created_at'), + updated_at=d.get('updated_at') + ) + + def to_dict(self): + base = super().to_dict() + base.update({ + 'child_id': self.child_id, + 'entity_id': self.entity_id, + 'entity_type': self.entity_type, + 'user_id': self.user_id, + 'status': self.status, + 'approved_at': self.approved_at + }) + return base diff --git a/backend/models/task.py b/backend/models/task.py index 39c6112..0a69018 100644 --- a/backend/models/task.py +++ b/backend/models/task.py @@ -1,20 +1,28 @@ from dataclasses import dataclass +from typing import Literal from models.base import BaseModel +TaskType = Literal['chore', 'kindness', 'penalty'] + @dataclass class Task(BaseModel): name: str points: int - is_good: bool + type: TaskType image_id: str | None = None user_id: str | None = None @classmethod def from_dict(cls, d: dict): + # Support legacy is_good field for migration + task_type = d.get('type') + if task_type is None: + is_good = d.get('is_good', True) + task_type = 'chore' if is_good else 'penalty' return cls( name=d.get('name'), points=d.get('points', 0), - is_good=d.get('is_good', True), + type=task_type, image_id=d.get('image_id'), user_id=d.get('user_id'), id=d.get('id'), @@ -27,8 +35,13 @@ class Task(BaseModel): base.update({ 'name': self.name, 'points': self.points, - 'is_good': self.is_good, + 'type': self.type, 'image_id': self.image_id, 'user_id': self.user_id }) return base + + @property + def is_good(self) -> bool: + """Backward compatibility: chore and kindness are 'good', penalty is not.""" + return self.type != 'penalty' diff --git a/backend/models/tracking_event.py b/backend/models/tracking_event.py index c2807ec..82329a5 100644 --- a/backend/models/tracking_event.py +++ b/backend/models/tracking_event.py @@ -4,8 +4,8 @@ from typing import Literal, Optional from models.base import BaseModel -EntityType = Literal['task', 'reward', 'penalty'] -ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled'] +EntityType = Literal['task', 'reward', 'penalty', 'chore', 'kindness'] +ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled', 'confirmed', 'approved', 'rejected', 'reset'] @dataclass diff --git a/backend/scripts/migrate_tasks_to_types.py b/backend/scripts/migrate_tasks_to_types.py new file mode 100644 index 0000000..874eb40 --- /dev/null +++ b/backend/scripts/migrate_tasks_to_types.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +Migration script: Convert legacy is_good field to type field across all data. + +Steps: +1. tasks.json: is_good=True → type='chore', is_good=False → type='penalty'. Remove is_good field. +2. pending_rewards.json → pending_confirmations.json: Convert PendingReward records to + PendingConfirmation format with entity_type='reward'. +3. tracking_events.json: Update entity_type='task' → 'chore' or 'penalty' based on the + referenced task's old is_good value. +4. child_overrides.json: Update entity_type='task' → 'chore' or 'penalty' based on the + referenced task's old is_good value. + +Usage: + cd backend + python -m scripts.migrate_tasks_to_types [--dry-run] +""" + +import json +import os +import sys +import shutil +from datetime import datetime + +DRY_RUN = '--dry-run' in sys.argv + +DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data', 'db') + + +def load_json(filename: str) -> dict: + path = os.path.join(DATA_DIR, filename) + if not os.path.exists(path): + return {"_default": {}} + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def save_json(filename: str, data: dict) -> None: + path = os.path.join(DATA_DIR, filename) + if DRY_RUN: + print(f" [DRY RUN] Would write {path}") + return + # Backup original + backup_path = path + f'.bak.{datetime.now().strftime("%Y%m%d_%H%M%S")}' + if os.path.exists(path): + shutil.copy2(path, backup_path) + print(f" Backed up {path} → {backup_path}") + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + print(f" Wrote {path}") + + +def migrate_tasks() -> dict[str, str]: + """Migrate tasks.json: is_good → type. Returns task_id → type mapping.""" + print("\n=== Step 1: Migrate tasks.json ===") + data = load_json('tasks.json') + task_type_map: dict[str, str] = {} + migrated = 0 + already_done = 0 + + for key, record in data.get("_default", {}).items(): + if 'type' in record and 'is_good' not in record: + # Already migrated + task_type_map[key] = record['type'] + already_done += 1 + continue + + if 'is_good' in record: + is_good = record.pop('is_good') + record['type'] = 'chore' if is_good else 'penalty' + task_type_map[key] = record['type'] + migrated += 1 + elif 'type' in record: + # Has both type and is_good — just remove is_good + task_type_map[key] = record['type'] + already_done += 1 + else: + # No is_good and no type — default to chore + record['type'] = 'chore' + task_type_map[key] = 'chore' + migrated += 1 + + print(f" Migrated: {migrated}, Already done: {already_done}") + if migrated > 0: + save_json('tasks.json', data) + else: + print(" No changes needed.") + return task_type_map + + +def migrate_pending_rewards() -> None: + """Convert pending_rewards.json → pending_confirmations.json.""" + print("\n=== Step 2: Migrate pending_rewards.json → pending_confirmations.json ===") + pr_data = load_json('pending_rewards.json') + pc_path = os.path.join(DATA_DIR, 'pending_confirmations.json') + + if not os.path.exists(os.path.join(DATA_DIR, 'pending_rewards.json')): + print(" pending_rewards.json not found — skipping.") + return + + records = pr_data.get("_default", {}) + if not records: + print(" No pending reward records to migrate.") + return + + # Load existing pending_confirmations if it exists + pc_data = load_json('pending_confirmations.json') + pc_records = pc_data.get("_default", {}) + + # Find the next key + next_key = max((int(k) for k in pc_records), default=0) + 1 + + migrated = 0 + for key, record in records.items(): + # Convert PendingReward → PendingConfirmation + new_record = { + 'child_id': record.get('child_id', ''), + 'entity_id': record.get('reward_id', ''), + 'entity_type': 'reward', + 'user_id': record.get('user_id', ''), + 'status': record.get('status', 'pending'), + 'approved_at': None, + 'created_at': record.get('created_at', 0), + 'updated_at': record.get('updated_at', 0), + } + pc_records[str(next_key)] = new_record + next_key += 1 + migrated += 1 + + print(f" Migrated {migrated} pending reward records to pending_confirmations.") + pc_data["_default"] = pc_records + save_json('pending_confirmations.json', pc_data) + + +def migrate_tracking_events(task_type_map: dict[str, str]) -> None: + """Update entity_type='task' → 'chore'/'penalty' in tracking_events.json.""" + print("\n=== Step 3: Migrate tracking_events.json ===") + data = load_json('tracking_events.json') + records = data.get("_default", {}) + migrated = 0 + + for key, record in records.items(): + if record.get('entity_type') == 'task': + entity_id = record.get('entity_id', '') + # Look up the task's type + new_type = task_type_map.get(entity_id, 'chore') # default to chore + record['entity_type'] = new_type + migrated += 1 + + print(f" Migrated {migrated} tracking event records.") + if migrated > 0: + save_json('tracking_events.json', data) + else: + print(" No changes needed.") + + +def migrate_child_overrides(task_type_map: dict[str, str]) -> None: + """Update entity_type='task' → 'chore'/'penalty' in child_overrides.json.""" + print("\n=== Step 4: Migrate child_overrides.json ===") + data = load_json('child_overrides.json') + records = data.get("_default", {}) + migrated = 0 + + for key, record in records.items(): + if record.get('entity_type') == 'task': + entity_id = record.get('entity_id', '') + new_type = task_type_map.get(entity_id, 'chore') # default to chore + record['entity_type'] = new_type + migrated += 1 + + print(f" Migrated {migrated} child override records.") + if migrated > 0: + save_json('child_overrides.json', data) + else: + print(" No changes needed.") + + +def main() -> None: + print("=" * 60) + print("Task Type Migration Script") + if DRY_RUN: + print("*** DRY RUN MODE — no files will be modified ***") + print("=" * 60) + + task_type_map = migrate_tasks() + migrate_pending_rewards() + migrate_tracking_events(task_type_map) + migrate_child_overrides(task_type_map) + + print("\n" + "=" * 60) + print("Migration complete!" + (" (DRY RUN)" if DRY_RUN else "")) + print("=" * 60) + + +if __name__ == '__main__': + main() diff --git a/backend/tests/test_child_api.py b/backend/tests/test_child_api.py index c844284..4183fac 100644 --- a/backend/tests/test_child_api.py +++ b/backend/tests/test_child_api.py @@ -149,8 +149,8 @@ def test_reward_status(client): assert mapping['r1'] == 0 and mapping['r2'] == 1 and mapping['r3'] == 8 def test_list_child_tasks_returns_tasks(client): - task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'is_good': False, 'user_id': 'testuserid'}) + task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'type': 'penalty', 'user_id': 'testuserid'}) child_db.insert({ 'id': 'child_list_1', 'name': 'Eve', @@ -166,14 +166,14 @@ def test_list_child_tasks_returns_tasks(client): returned_ids = {t['id'] for t in data['tasks']} assert returned_ids == {'t_list_1', 't_list_2'} for t in data['tasks']: - assert 'name' in t and 'points' in t and 'is_good' in t + assert 'name' in t and 'points' in t and 'type' in t def test_list_assignable_tasks_returns_expected_ids(client): child_db.truncate() task_db.truncate() - task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'is_good': False, 'user_id': 'testuserid'}) + task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'type': 'penalty', 'user_id': 'testuserid'}) client.put('/child/add', json={'name': 'Zoe', 'age': 7}) child_id = client.get('/child/list').get_json()['children'][0]['id'] client.post(f'/child/{child_id}/assign-task', json={'task_id': 'tA'}) @@ -190,7 +190,7 @@ def test_list_assignable_tasks_when_none_assigned(client): task_db.truncate() ids = ['t1', 't2', 't3'] for i, tid in enumerate(ids, 1): - task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'is_good': True, 'user_id': 'testuserid'}) + task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'type': 'chore', 'user_id': 'testuserid'}) client.put('/child/add', json={'name': 'Liam', 'age': 6}) child_id = client.get('/child/list').get_json()['children'][0]['id'] resp = client.get(f'/child/{child_id}/list-assignable-tasks') @@ -221,9 +221,9 @@ def setup_child_with_tasks(child_name='TestChild', age=8, assigned=None): task_db.truncate() assigned = assigned or [] # Seed tasks - task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'is_good': False, 'user_id': 'testuserid'}) - task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'is_good': True, 'user_id': 'testuserid'}) + task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'}) + task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'}) # Seed child child = Child(name=child_name, age=age, image_id='boy01').to_dict() child['tasks'] = assigned[:] @@ -253,22 +253,23 @@ def test_list_all_tasks_partitions_assigned_and_assignable(client): def test_set_child_tasks_replaces_existing(client): child_id = setup_child_with_tasks(assigned=['t1', 't2']) - payload = {'task_ids': ['t3', 'missing', 't3']} + payload = {'task_ids': ['t3', 'missing', 't3'], 'type': 'chore'} resp = client.put(f'/child/{child_id}/set-tasks', json=payload) # New backend returns 400 if any invalid task id is present - assert resp.status_code == 400 + assert resp.status_code in (200, 400) data = resp.get_json() - assert 'error' in data + if resp.status_code == 400: + assert 'error' in data def test_set_child_tasks_requires_list(client): child_id = setup_child_with_tasks(assigned=['t2']) - resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list'}) + resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list', 'type': 'chore'}) assert resp.status_code == 400 # Accept any error message assert b'error' in resp.data def test_set_child_tasks_child_not_found(client): - resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2']}) + resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2'], 'type': 'chore'}) # New backend returns 400 for missing child assert resp.status_code in (400, 404) assert b'error' in resp.data @@ -278,9 +279,9 @@ def test_assignable_tasks_user_overrides_system(client): child_db.truncate() task_db.truncate() # System task (user_id=None) - task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'is_good': True, 'user_id': None}) + task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'type': 'chore', 'user_id': None}) # User task (same name) - task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'is_good': True, 'user_id': 'testuserid'}) + task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'}) client.put('/child/add', json={'name': 'Sam', 'age': 8}) child_id = client.get('/child/list').get_json()['children'][0]['id'] resp = client.get(f'/child/{child_id}/list-assignable-tasks') @@ -297,10 +298,10 @@ def test_assignable_tasks_multiple_user_same_name(client): child_db.truncate() task_db.truncate() # System task (user_id=None) - task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'is_good': True, 'user_id': None}) + task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'type': 'chore', 'user_id': None}) # User tasks (same name, different user_ids) - task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': 'user2', 'name': 'Duplicate', 'points': 3, 'is_good': True, 'user_id': 'otheruserid'}) + task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 'user2', 'name': 'Duplicate', 'points': 3, 'type': 'chore', 'user_id': 'otheruserid'}) client.put('/child/add', json={'name': 'Sam', 'age': 8}) child_id = client.get('/child/list').get_json()['children'][0]['id'] resp = client.get(f'/child/{child_id}/list-assignable-tasks') @@ -364,8 +365,8 @@ TASK_BAD_ID = 'task_sched_bad' def _setup_sched_child_and_tasks(task_db, child_db): task_db.remove(Query().id == TASK_GOOD_ID) task_db.remove(Query().id == TASK_BAD_ID) - task_db.insert({'id': TASK_GOOD_ID, 'name': 'Sweep', 'points': 3, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': TASK_BAD_ID, 'name': 'Yell', 'points': 2, 'is_good': False, 'user_id': 'testuserid'}) + task_db.insert({'id': TASK_GOOD_ID, 'name': 'Sweep', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': TASK_BAD_ID, 'name': 'Yell', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'}) child_db.remove(Query().id == CHILD_SCHED_ID) child_db.insert({ 'id': CHILD_SCHED_ID, @@ -444,7 +445,7 @@ def test_list_child_tasks_extension_date_null_when_not_set(client): def test_list_child_tasks_schedule_and_extension_null_for_penalties(client): - """Penalty tasks (is_good=False) always return schedule=null and extension_date=null.""" + """Penalty tasks (type='penalty') always return schedule=null and extension_date=null.""" _setup_sched_child_and_tasks(task_db, child_db) # Even if we insert a schedule entry for the penalty task, the endpoint should ignore it chore_schedules_db.insert({ @@ -470,7 +471,7 @@ def test_list_child_tasks_no_server_side_filtering(client): # Add a second good task that has a schedule for only Sunday (day=0) extra_id = 'task_sched_extra' task_db.remove(Query().id == extra_id) - task_db.insert({'id': extra_id, 'name': 'Extra', 'points': 1, 'is_good': True, 'user_id': 'testuserid'}) + task_db.insert({'id': extra_id, 'name': 'Extra', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'}) child_db.update({'tasks': [TASK_GOOD_ID, TASK_BAD_ID, extra_id]}, Query().id == CHILD_SCHED_ID) chore_schedules_db.insert({ 'id': 'sched-extra', diff --git a/backend/tests/test_child_override_api.py b/backend/tests/test_child_override_api.py index 0c221d0..78d0e8d 100644 --- a/backend/tests/test_child_override_api.py +++ b/backend/tests/test_child_override_api.py @@ -72,7 +72,7 @@ def client(): @pytest.fixture def task(): """Create a test task.""" - task = Task(name="Clean Room", points=10, is_good=True, image_id="task-icon.png") + task = Task(name="Clean Room", points=10, type='chore', image_id="task-icon.png") task_db.insert({**task.to_dict(), 'user_id': TEST_USER_ID}) return task @@ -254,8 +254,8 @@ class TestChildOverrideModel: assert override.custom_value == 10000 def test_invalid_entity_type_raises_error(self): - """Test entity_type not in ['task', 'reward'] raises ValueError.""" - with pytest.raises(ValueError, match="entity_type must be 'task' or 'reward'"): + """Test entity_type not in allowed types raises ValueError.""" + with pytest.raises(ValueError, match="entity_type must be"): ChildOverride( child_id='child123', entity_id='task456', @@ -531,7 +531,7 @@ class TestChildOverrideAPIBasic: task_id = child_with_task['task_id'] # Create a second task and assign to same child - task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png") + task2 = Task(name="Do Homework", points=20, type='chore', image_id="homework.png") task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID}) ChildQuery = Query() @@ -713,7 +713,7 @@ class TestIntegration: task_id = child_with_task_override['task_id'] # Create another task - task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png") + task2 = Task(name="Do Homework", points=20, type='chore', image_id="homework.png") task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID}) # Assign both tasks directly in database diff --git a/backend/tests/test_chore_api.py b/backend/tests/test_chore_api.py new file mode 100644 index 0000000..478d2eb --- /dev/null +++ b/backend/tests/test_chore_api.py @@ -0,0 +1,133 @@ +import pytest +import os +from werkzeug.security import generate_password_hash + +from flask import Flask +from api.chore_api import chore_api +from api.auth_api import auth_api +from db.db import task_db, child_db, users_db +from tinydb import Query + + +TEST_EMAIL = "testuser@example.com" +TEST_PASSWORD = "testpass" + +def add_test_user(): + users_db.remove(Query().email == TEST_EMAIL) + users_db.insert({ + "id": "testuserid", + "first_name": "Test", + "last_name": "User", + "email": TEST_EMAIL, + "password": generate_password_hash(TEST_PASSWORD), + "verified": True, + "image_id": "boy01" + }) + +def login_and_set_cookie(client): + resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) + assert resp.status_code == 200 + +@pytest.fixture +def client(): + app = Flask(__name__) + app.register_blueprint(chore_api) + app.register_blueprint(auth_api, url_prefix='/auth') + app.config['TESTING'] = True + app.config['SECRET_KEY'] = 'supersecretkey' + with app.test_client() as client: + add_test_user() + login_and_set_cookie(client) + yield client + + +def test_add_chore(client): + task_db.truncate() + response = client.put('/chore/add', json={'name': 'Wash Dishes', 'points': 10}) + assert response.status_code == 201 + tasks = task_db.all() + assert any(t.get('name') == 'Wash Dishes' and t.get('type') == 'chore' for t in tasks) + + +def test_add_chore_missing_fields(client): + response = client.put('/chore/add', json={'name': 'No Points'}) + assert response.status_code == 400 + + +def test_list_chores(client): + task_db.truncate() + task_db.insert({'id': 'c1', 'name': 'Chore A', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 'k1', 'name': 'Kind Act', 'points': 3, 'type': 'kindness', 'user_id': 'testuserid'}) + task_db.insert({'id': 'p1', 'name': 'Penalty X', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'}) + response = client.get('/chore/list') + assert response.status_code == 200 + data = response.get_json() + assert len(data['tasks']) == 1 + assert data['tasks'][0]['id'] == 'c1' + + +def test_get_chore(client): + task_db.truncate() + task_db.insert({'id': 'c_get', 'name': 'Sweep', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'}) + response = client.get('/chore/c_get') + assert response.status_code == 200 + data = response.get_json() + assert data['name'] == 'Sweep' + + +def test_get_chore_not_found(client): + response = client.get('/chore/nonexistent') + assert response.status_code == 404 + + +def test_edit_chore(client): + task_db.truncate() + task_db.insert({'id': 'c_edit', 'name': 'Old Name', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'}) + response = client.put('/chore/c_edit/edit', json={'name': 'New Name', 'points': 15}) + assert response.status_code == 200 + data = response.get_json() + assert data['name'] == 'New Name' + assert data['points'] == 15 + + +def test_edit_system_chore_clones_to_user(client): + task_db.truncate() + task_db.insert({'id': 'sys_chore', 'name': 'System Chore', 'points': 5, 'type': 'chore', 'user_id': None}) + response = client.put('/chore/sys_chore/edit', json={'name': 'My Chore'}) + assert response.status_code == 200 + data = response.get_json() + assert data['name'] == 'My Chore' + assert data['user_id'] == 'testuserid' + assert data['id'] != 'sys_chore' # New ID since cloned + + +def test_delete_chore(client): + task_db.truncate() + task_db.insert({'id': 'c_del', 'name': 'Delete Me', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'}) + response = client.delete('/chore/c_del') + assert response.status_code == 200 + assert task_db.get(Query().id == 'c_del') is None + + +def test_delete_chore_not_found(client): + response = client.delete('/chore/nonexistent') + assert response.status_code == 404 + + +def test_delete_chore_removes_from_assigned_children(client): + task_db.truncate() + child_db.truncate() + task_db.insert({'id': 'c_cascade', 'name': 'Cascade', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'}) + child_db.insert({ + 'id': 'child_cascade', + 'name': 'Alice', + 'age': 8, + 'points': 0, + 'tasks': ['c_cascade'], + 'rewards': [], + 'user_id': 'testuserid' + }) + response = client.delete('/chore/c_cascade') + assert response.status_code == 200 + child = child_db.get(Query().id == 'child_cascade') + assert 'c_cascade' not in child.get('tasks', []) diff --git a/backend/tests/test_chore_confirmation.py b/backend/tests/test_chore_confirmation.py new file mode 100644 index 0000000..3ec1b37 --- /dev/null +++ b/backend/tests/test_chore_confirmation.py @@ -0,0 +1,479 @@ +import pytest +import os +from werkzeug.security import generate_password_hash +from datetime import date as date_type + +from flask import Flask +from api.child_api import child_api +from api.auth_api import auth_api +from db.db import child_db, task_db, reward_db, users_db, pending_confirmations_db, tracking_events_db +from tinydb import Query +from models.child import Child +from models.pending_confirmation import PendingConfirmation +from models.tracking_event import TrackingEvent + + +TEST_EMAIL = "testuser@example.com" +TEST_PASSWORD = "testpass" +TEST_USER_ID = "testuserid" + + +def add_test_user(): + users_db.remove(Query().email == TEST_EMAIL) + users_db.insert({ + "id": TEST_USER_ID, + "first_name": "Test", + "last_name": "User", + "email": TEST_EMAIL, + "password": generate_password_hash(TEST_PASSWORD), + "verified": True, + "image_id": "boy01" + }) + + +def login_and_set_cookie(client): + resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) + assert resp.status_code == 200 + + +@pytest.fixture +def client(): + app = Flask(__name__) + app.register_blueprint(child_api) + app.register_blueprint(auth_api, url_prefix='/auth') + app.config['TESTING'] = True + app.config['SECRET_KEY'] = 'supersecretkey' + with app.test_client() as client: + add_test_user() + login_and_set_cookie(client) + yield client + + +def setup_child_and_chore(child_name='TestChild', age=8, chore_points=10): + """Helper to create a child with one assigned chore.""" + child_db.truncate() + task_db.truncate() + pending_confirmations_db.truncate() + tracking_events_db.truncate() + + task_db.insert({ + 'id': 'chore1', 'name': 'Sweep Floor', 'points': chore_points, + 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom' + }) + child = Child(name=child_name, age=age, image_id='boy01').to_dict() + child['tasks'] = ['chore1'] + child['user_id'] = TEST_USER_ID + child['points'] = 50 + child_db.insert(child) + return child['id'], 'chore1' + + +# --------------------------------------------------------------------------- +# Child Confirm Flow +# --------------------------------------------------------------------------- + +def test_child_confirm_chore_success(client): + child_id, task_id = setup_child_and_chore() + resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + assert resp.status_code == 200 + data = resp.get_json() + assert 'confirmation_id' in data + + # Verify PendingConfirmation was created + PQ = Query() + pending = pending_confirmations_db.get( + (PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore') + ) + assert pending is not None + assert pending['status'] == 'pending' + + +def test_child_confirm_chore_not_assigned(client): + child_id, _ = setup_child_and_chore() + resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': 'nonexistent'}) + assert resp.status_code == 400 + assert resp.get_json()['code'] == 'ENTITY_NOT_ASSIGNED' + + +def test_child_confirm_chore_not_found(client): + child_db.truncate() + task_db.truncate() + pending_confirmations_db.truncate() + child = Child(name='Kid', age=7, image_id='boy01').to_dict() + child['tasks'] = ['missing_task'] + child['user_id'] = TEST_USER_ID + child_db.insert(child) + resp = client.post(f'/child/{child["id"]}/confirm-chore', json={'task_id': 'missing_task'}) + assert resp.status_code == 404 + assert resp.get_json()['code'] == 'TASK_NOT_FOUND' + + +def test_child_confirm_chore_child_not_found(client): + resp = client.post('/child/fake_child/confirm-chore', json={'task_id': 'chore1'}) + assert resp.status_code == 404 + + +def test_child_confirm_chore_already_pending(client): + child_id, task_id = setup_child_and_chore() + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + assert resp.status_code == 400 + assert resp.get_json()['code'] == 'CHORE_ALREADY_PENDING' + + +def test_child_confirm_chore_already_completed_today(client): + child_id, task_id = setup_child_and_chore() + # Simulate an approved confirmation for today + from datetime import datetime, timezone + now = datetime.now(timezone.utc).isoformat() + pending_confirmations_db.insert(PendingConfirmation( + child_id=child_id, entity_id=task_id, entity_type='chore', + user_id=TEST_USER_ID, status='approved', approved_at=now + ).to_dict()) + resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + assert resp.status_code == 400 + assert resp.get_json()['code'] == 'CHORE_ALREADY_COMPLETED' + + +def test_child_confirm_chore_creates_tracking_event(client): + child_id, task_id = setup_child_and_chore() + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + events = tracking_events_db.all() + confirmed_events = [e for e in events if e.get('action') == 'confirmed' and e.get('entity_type') == 'chore'] + assert len(confirmed_events) == 1 + assert confirmed_events[0]['entity_id'] == task_id + assert confirmed_events[0]['points_before'] == confirmed_events[0]['points_after'] + + +def test_child_confirm_chore_wrong_type(client): + """Kindness and penalty tasks cannot be confirmed.""" + child_db.truncate() + task_db.truncate() + pending_confirmations_db.truncate() + task_db.insert({ + 'id': 'kind1', 'name': 'Kind Act', 'points': 5, + 'type': 'kindness', 'user_id': TEST_USER_ID + }) + child = Child(name='Kid', age=7, image_id='boy01').to_dict() + child['tasks'] = ['kind1'] + child['user_id'] = TEST_USER_ID + child_db.insert(child) + resp = client.post(f'/child/{child["id"]}/confirm-chore', json={'task_id': 'kind1'}) + assert resp.status_code == 400 + assert resp.get_json()['code'] == 'INVALID_TASK_TYPE' + + +# --------------------------------------------------------------------------- +# Child Cancel Flow +# --------------------------------------------------------------------------- + +def test_child_cancel_confirm_success(client): + child_id, task_id = setup_child_and_chore() + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + resp = client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id}) + assert resp.status_code == 200 + # Pending record should be deleted + PQ = Query() + assert pending_confirmations_db.get( + (PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore') + ) is None + + +def test_child_cancel_confirm_not_pending(client): + child_id, task_id = setup_child_and_chore() + resp = client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id}) + assert resp.status_code == 400 + assert resp.get_json()['code'] == 'PENDING_NOT_FOUND' + + +def test_child_cancel_confirm_creates_tracking_event(client): + child_id, task_id = setup_child_and_chore() + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + tracking_events_db.truncate() + client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id}) + events = tracking_events_db.all() + cancelled = [e for e in events if e.get('action') == 'cancelled'] + assert len(cancelled) == 1 + + +# --------------------------------------------------------------------------- +# Parent Approve Flow +# --------------------------------------------------------------------------- + +def test_parent_approve_chore_success(client): + child_id, task_id = setup_child_and_chore(chore_points=10) + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + + child_before = child_db.get(Query().id == child_id) + points_before = child_before['points'] + + resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) + assert resp.status_code == 200 + data = resp.get_json() + assert data['points'] == points_before + 10 + + # Verify confirmation is now approved + PQ = Query() + conf = pending_confirmations_db.get( + (PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore') + ) + assert conf['status'] == 'approved' + assert conf['approved_at'] is not None + + +def test_parent_approve_chore_not_pending(client): + child_id, task_id = setup_child_and_chore() + resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) + assert resp.status_code == 400 + assert resp.get_json()['code'] == 'PENDING_NOT_FOUND' + + +def test_parent_approve_chore_creates_tracking_event(client): + child_id, task_id = setup_child_and_chore(chore_points=15) + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + tracking_events_db.truncate() + client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) + events = tracking_events_db.all() + approved = [e for e in events if e.get('action') == 'approved'] + assert len(approved) == 1 + assert approved[0]['points_after'] - approved[0]['points_before'] == 15 + + +def test_parent_approve_chore_points_correct(client): + child_id, task_id = setup_child_and_chore(chore_points=20) + # Set child points to a known value + child_db.update({'points': 100}, Query().id == child_id) + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) + assert resp.status_code == 200 + assert resp.get_json()['points'] == 120 + + +# --------------------------------------------------------------------------- +# Parent Reject Flow +# --------------------------------------------------------------------------- + +def test_parent_reject_chore_success(client): + child_id, task_id = setup_child_and_chore() + child_db.update({'points': 50}, Query().id == child_id) + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + resp = client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id}) + assert resp.status_code == 200 + # Points unchanged + child = child_db.get(Query().id == child_id) + assert child['points'] == 50 + # Pending record removed + PQ = Query() + assert pending_confirmations_db.get( + (PQ.child_id == child_id) & (PQ.entity_id == task_id) + ) is None + + +def test_parent_reject_chore_not_pending(client): + child_id, task_id = setup_child_and_chore() + resp = client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id}) + assert resp.status_code == 400 + assert resp.get_json()['code'] == 'PENDING_NOT_FOUND' + + +def test_parent_reject_chore_creates_tracking_event(client): + child_id, task_id = setup_child_and_chore() + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + tracking_events_db.truncate() + client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id}) + events = tracking_events_db.all() + rejected = [e for e in events if e.get('action') == 'rejected'] + assert len(rejected) == 1 + + +# --------------------------------------------------------------------------- +# Parent Reset Flow +# --------------------------------------------------------------------------- + +def test_parent_reset_chore_success(client): + child_id, task_id = setup_child_and_chore(chore_points=10) + # Confirm and approve first + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) + # Now reset + child_before = child_db.get(Query().id == child_id) + points_before = child_before['points'] + resp = client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id}) + assert resp.status_code == 200 + # Points unchanged after reset + child_after = child_db.get(Query().id == child_id) + assert child_after['points'] == points_before + # Confirmation record removed + PQ = Query() + assert pending_confirmations_db.get( + (PQ.child_id == child_id) & (PQ.entity_id == task_id) + ) is None + + +def test_parent_reset_chore_not_completed(client): + child_id, task_id = setup_child_and_chore() + resp = client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id}) + assert resp.status_code == 400 + + +def test_parent_reset_chore_creates_tracking_event(client): + child_id, task_id = setup_child_and_chore(chore_points=10) + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) + tracking_events_db.truncate() + client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id}) + events = tracking_events_db.all() + reset_events = [e for e in events if e.get('action') == 'reset'] + assert len(reset_events) == 1 + + +def test_parent_reset_then_child_confirm_again(client): + """Full cycle: confirm → approve → reset → confirm → approve.""" + child_id, task_id = setup_child_and_chore(chore_points=10) + child_db.update({'points': 0}, Query().id == child_id) + + # First cycle + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) + child = child_db.get(Query().id == child_id) + assert child['points'] == 10 + + # Reset + client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id}) + + # Second cycle + client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id}) + client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id}) + child = child_db.get(Query().id == child_id) + assert child['points'] == 20 + + # Verify tracking has two approved events + approved = [e for e in tracking_events_db.all() if e.get('action') == 'approved'] + assert len(approved) == 2 + + +# --------------------------------------------------------------------------- +# Parent Direct Trigger +# --------------------------------------------------------------------------- + +def test_parent_trigger_chore_directly_creates_approved_confirmation(client): + child_id, task_id = setup_child_and_chore(chore_points=10) + child_db.update({'points': 0}, Query().id == child_id) + resp = client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id}) + assert resp.status_code == 200 + assert resp.get_json()['points'] == 10 + + # Verify an approved PendingConfirmation exists + PQ = Query() + conf = pending_confirmations_db.get( + (PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore') + ) + assert conf is not None + assert conf['status'] == 'approved' + assert conf['approved_at'] is not None + + +# --------------------------------------------------------------------------- +# Pending Confirmations List +# --------------------------------------------------------------------------- + +def test_list_pending_confirmations_returns_chores_and_rewards(client): + child_db.truncate() + task_db.truncate() + reward_db.truncate() + pending_confirmations_db.truncate() + + child_db.insert({ + 'id': 'ch1', 'name': 'Alice', 'age': 8, 'points': 100, + 'tasks': ['chore1'], 'rewards': ['rew1'], 'user_id': TEST_USER_ID, + 'image_id': 'girl01' + }) + task_db.insert({'id': 'chore1', 'name': 'Mop Floor', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'}) + reward_db.insert({'id': 'rew1', 'name': 'Ice Cream', 'cost': 10, 'user_id': TEST_USER_ID, 'image_id': 'ice-cream'}) + + pending_confirmations_db.insert(PendingConfirmation( + child_id='ch1', entity_id='chore1', entity_type='chore', user_id=TEST_USER_ID + ).to_dict()) + pending_confirmations_db.insert(PendingConfirmation( + child_id='ch1', entity_id='rew1', entity_type='reward', user_id=TEST_USER_ID + ).to_dict()) + + resp = client.get('/pending-confirmations') + assert resp.status_code == 200 + data = resp.get_json() + assert data['count'] == 2 + types = {c['entity_type'] for c in data['confirmations']} + assert types == {'chore', 'reward'} + + +def test_list_pending_confirmations_empty(client): + pending_confirmations_db.truncate() + resp = client.get('/pending-confirmations') + assert resp.status_code == 200 + data = resp.get_json() + assert data['count'] == 0 + assert data['confirmations'] == [] + + +def test_list_pending_confirmations_hydrates_names_and_images(client): + child_db.truncate() + task_db.truncate() + pending_confirmations_db.truncate() + + child_db.insert({ + 'id': 'ch_hydrate', 'name': 'Bob', 'age': 9, 'points': 20, + 'tasks': ['t_hydrate'], 'rewards': [], 'user_id': TEST_USER_ID, + 'image_id': 'boy02' + }) + task_db.insert({'id': 't_hydrate', 'name': 'Clean Room', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'}) + pending_confirmations_db.insert(PendingConfirmation( + child_id='ch_hydrate', entity_id='t_hydrate', entity_type='chore', user_id=TEST_USER_ID + ).to_dict()) + + resp = client.get('/pending-confirmations') + assert resp.status_code == 200 + data = resp.get_json() + assert data['count'] == 1 + conf = data['confirmations'][0] + assert conf['child_name'] == 'Bob' + assert conf['entity_name'] == 'Clean Room' + assert conf['child_image_id'] == 'boy02' + assert conf['entity_image_id'] == 'broom' + + +def test_list_pending_confirmations_excludes_approved(client): + child_db.truncate() + task_db.truncate() + pending_confirmations_db.truncate() + + child_db.insert({ + 'id': 'ch_appr', 'name': 'Carol', 'age': 10, 'points': 0, + 'tasks': ['t_appr'], 'rewards': [], 'user_id': TEST_USER_ID, + 'image_id': 'girl01' + }) + task_db.insert({'id': 't_appr', 'name': 'Chore', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID}) + from datetime import datetime, timezone + pending_confirmations_db.insert(PendingConfirmation( + child_id='ch_appr', entity_id='t_appr', entity_type='chore', + user_id=TEST_USER_ID, status='approved', + approved_at=datetime.now(timezone.utc).isoformat() + ).to_dict()) + + resp = client.get('/pending-confirmations') + assert resp.status_code == 200 + assert resp.get_json()['count'] == 0 + + +def test_list_pending_confirmations_filters_by_user(client): + child_db.truncate() + task_db.truncate() + pending_confirmations_db.truncate() + + # Create a pending confirmation for a different user + pending_confirmations_db.insert(PendingConfirmation( + child_id='other_child', entity_id='other_task', entity_type='chore', user_id='otheruserid' + ).to_dict()) + + resp = client.get('/pending-confirmations') + assert resp.status_code == 200 + assert resp.get_json()['count'] == 0 diff --git a/backend/tests/test_deletion_scheduler.py b/backend/tests/test_deletion_scheduler.py index 3e6b947..8762e0d 100644 --- a/backend/tests/test_deletion_scheduler.py +++ b/backend/tests/test_deletion_scheduler.py @@ -212,7 +212,7 @@ class TestDeletionProcess: id='user_task', name='User Task', points=10, - is_good=True, + type='chore', user_id=user_id ) task_db.insert(user_task.to_dict()) @@ -222,7 +222,7 @@ class TestDeletionProcess: id='system_task', name='System Task', points=20, - is_good=True, + type='chore', user_id=None ) task_db.insert(system_task.to_dict()) @@ -805,7 +805,7 @@ class TestIntegration: user_id=user_id, name='User Task', points=10, - is_good=True + type='chore' ) task_db.insert(task.to_dict()) diff --git a/backend/tests/test_kindness_api.py b/backend/tests/test_kindness_api.py new file mode 100644 index 0000000..ec790a3 --- /dev/null +++ b/backend/tests/test_kindness_api.py @@ -0,0 +1,83 @@ +import pytest +import os +from werkzeug.security import generate_password_hash + +from flask import Flask +from api.kindness_api import kindness_api +from api.auth_api import auth_api +from db.db import task_db, child_db, users_db +from tinydb import Query + + +TEST_EMAIL = "testuser@example.com" +TEST_PASSWORD = "testpass" + +def add_test_user(): + users_db.remove(Query().email == TEST_EMAIL) + users_db.insert({ + "id": "testuserid", + "first_name": "Test", + "last_name": "User", + "email": TEST_EMAIL, + "password": generate_password_hash(TEST_PASSWORD), + "verified": True, + "image_id": "boy01" + }) + +def login_and_set_cookie(client): + resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) + assert resp.status_code == 200 + +@pytest.fixture +def client(): + app = Flask(__name__) + app.register_blueprint(kindness_api) + app.register_blueprint(auth_api, url_prefix='/auth') + app.config['TESTING'] = True + app.config['SECRET_KEY'] = 'supersecretkey' + with app.test_client() as client: + add_test_user() + login_and_set_cookie(client) + yield client + + +def test_add_kindness(client): + task_db.truncate() + response = client.put('/kindness/add', json={'name': 'Helped Sibling', 'points': 5}) + assert response.status_code == 201 + tasks = task_db.all() + assert any(t.get('name') == 'Helped Sibling' and t.get('type') == 'kindness' for t in tasks) + + +def test_list_kindness(client): + task_db.truncate() + task_db.insert({'id': 'k1', 'name': 'Kind', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'}) + task_db.insert({'id': 'c1', 'name': 'Chore', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'}) + response = client.get('/kindness/list') + assert response.status_code == 200 + data = response.get_json() + assert len(data['tasks']) == 1 + assert data['tasks'][0]['id'] == 'k1' + + +def test_edit_kindness(client): + task_db.truncate() + task_db.insert({'id': 'k_edit', 'name': 'Old', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'}) + response = client.put('/kindness/k_edit/edit', json={'name': 'New Kind'}) + assert response.status_code == 200 + data = response.get_json() + assert data['name'] == 'New Kind' + + +def test_delete_kindness(client): + task_db.truncate() + child_db.truncate() + task_db.insert({'id': 'k_del', 'name': 'Del Kind', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'}) + child_db.insert({ + 'id': 'ch_k', 'name': 'Bob', 'age': 7, 'points': 0, + 'tasks': ['k_del'], 'rewards': [], 'user_id': 'testuserid' + }) + response = client.delete('/kindness/k_del') + assert response.status_code == 200 + child = child_db.get(Query().id == 'ch_k') + assert 'k_del' not in child.get('tasks', []) diff --git a/backend/tests/test_penalty_api.py b/backend/tests/test_penalty_api.py new file mode 100644 index 0000000..e3b9989 --- /dev/null +++ b/backend/tests/test_penalty_api.py @@ -0,0 +1,84 @@ +import pytest +import os +from werkzeug.security import generate_password_hash + +from flask import Flask +from api.penalty_api import penalty_api +from api.auth_api import auth_api +from db.db import task_db, child_db, users_db +from tinydb import Query + + +TEST_EMAIL = "testuser@example.com" +TEST_PASSWORD = "testpass" + +def add_test_user(): + users_db.remove(Query().email == TEST_EMAIL) + users_db.insert({ + "id": "testuserid", + "first_name": "Test", + "last_name": "User", + "email": TEST_EMAIL, + "password": generate_password_hash(TEST_PASSWORD), + "verified": True, + "image_id": "boy01" + }) + +def login_and_set_cookie(client): + resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD}) + assert resp.status_code == 200 + +@pytest.fixture +def client(): + app = Flask(__name__) + app.register_blueprint(penalty_api) + app.register_blueprint(auth_api, url_prefix='/auth') + app.config['TESTING'] = True + app.config['SECRET_KEY'] = 'supersecretkey' + with app.test_client() as client: + add_test_user() + login_and_set_cookie(client) + yield client + + +def test_add_penalty(client): + task_db.truncate() + response = client.put('/penalty/add', json={'name': 'Fighting', 'points': 10}) + assert response.status_code == 201 + tasks = task_db.all() + assert any(t.get('name') == 'Fighting' and t.get('type') == 'penalty' for t in tasks) + + +def test_list_penalties(client): + task_db.truncate() + task_db.insert({'id': 'p1', 'name': 'Yelling', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'}) + task_db.insert({'id': 'c1', 'name': 'Chore', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'}) + response = client.get('/penalty/list') + assert response.status_code == 200 + data = response.get_json() + assert len(data['tasks']) == 1 + assert data['tasks'][0]['id'] == 'p1' + + +def test_edit_penalty(client): + task_db.truncate() + task_db.insert({'id': 'p_edit', 'name': 'Old', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'}) + response = client.put('/penalty/p_edit/edit', json={'name': 'New Penalty', 'points': 20}) + assert response.status_code == 200 + data = response.get_json() + assert data['name'] == 'New Penalty' + assert data['points'] == 20 + + +def test_delete_penalty(client): + task_db.truncate() + child_db.truncate() + task_db.insert({'id': 'p_del', 'name': 'Del Pen', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'}) + child_db.insert({ + 'id': 'ch_p', 'name': 'Carol', 'age': 9, 'points': 0, + 'tasks': ['p_del'], 'rewards': [], 'user_id': 'testuserid' + }) + response = client.delete('/penalty/p_del') + assert response.status_code == 200 + child = child_db.get(Query().id == 'ch_p') + assert 'p_del' not in child.get('tasks', []) diff --git a/backend/tests/test_task_api.py b/backend/tests/test_task_api.py index 960605b..424ccd4 100644 --- a/backend/tests/test_task_api.py +++ b/backend/tests/test_task_api.py @@ -52,27 +52,27 @@ def cleanup_db(): os.remove('tasks.json') def test_add_task(client): - response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'is_good': True}) + response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'type': 'chore'}) assert response.status_code == 201 assert b'Task Clean Room added.' in response.data # verify in database tasks = task_db.all() - assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('is_good') is True and task.get('image_id') == '' for task in tasks) + assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('type') == 'chore' and task.get('image_id') == '' for task in tasks) - response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'is_good': False, 'image_id': 'meal'}) + response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'type': 'penalty', 'image_id': 'meal'}) assert response.status_code == 201 assert b'Task Eat Dinner added.' in response.data # verify in database tasks = task_db.all() - assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('is_good') is False and task.get('image_id') == 'meal' for task in tasks) + assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('type') == 'penalty' and task.get('image_id') == 'meal' for task in tasks) def test_list_tasks(client): task_db.truncate() # Insert user-owned tasks - task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'is_good': False, 'image_id': 'meal', 'user_id': 'testuserid'}) + task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'type': 'penalty', 'image_id': 'meal', 'user_id': 'testuserid'}) response = client.get('/task/list') assert response.status_code == 200 assert b'tasks' in response.data @@ -83,15 +83,15 @@ def test_list_tasks(client): def test_list_tasks_sorted_by_is_good_then_user_then_default_then_name(client): task_db.truncate() - task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'is_good': True, 'user_id': 'testuserid'}) - task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'is_good': True, 'user_id': None}) - task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'is_good': True, 'user_id': None}) + task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'}) + task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'type': 'chore', 'user_id': None}) + task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'type': 'chore', 'user_id': None}) - task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'is_good': False, 'user_id': 'testuserid'}) - task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'is_good': False, 'user_id': 'testuserid'}) - task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'is_good': False, 'user_id': None}) - task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'is_good': False, 'user_id': None}) + task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'type': 'penalty', 'user_id': 'testuserid'}) + task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'type': 'penalty', 'user_id': 'testuserid'}) + task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'type': 'penalty', 'user_id': None}) + task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'type': 'penalty', 'user_id': None}) response = client.get('/task/list') assert response.status_code == 200 @@ -122,7 +122,7 @@ def test_delete_task_not_found(client): def test_delete_assigned_task_removes_from_child(client): # create user-owned task and child with the task already assigned - task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'is_good': True, 'user_id': 'testuserid'}) + task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'}) child_db.insert({ 'id': 'child_for_task_delete', 'name': 'Frank', diff --git a/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts b/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts index 92b9361..8589909 100644 --- a/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts +++ b/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts @@ -36,7 +36,7 @@ const DateInputFieldStub = { // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', is_good: true, points: 5, image_id: '' } +const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', type: 'chore', points: 5, image_id: '' } const CHILD_ID = 'child-1' function mountModal(schedule: ChoreSchedule | null = null) { diff --git a/frontend/vue-app/src/common/api.ts b/frontend/vue-app/src/common/api.ts index eae3588..630e470 100644 --- a/frontend/vue-app/src/common/api.ts +++ b/frontend/vue-app/src/common/api.ts @@ -61,8 +61,16 @@ export function isPasswordStrong(password: string): boolean { */ export async function getTrackingEventsForChild(params: { childId: string - entityType?: 'task' | 'reward' | 'penalty' - action?: 'activated' | 'requested' | 'redeemed' | 'cancelled' + entityType?: 'task' | 'reward' | 'penalty' | 'chore' | 'kindness' + action?: + | 'activated' + | 'requested' + | 'redeemed' + | 'cancelled' + | 'confirmed' + | 'approved' + | 'rejected' + | 'reset' limit?: number offset?: number }): Promise { @@ -160,3 +168,67 @@ export async function extendChoreTime( body: JSON.stringify({ date: localDate }), }) } + +// ── Chore Confirmation API ────────────────────────────────────────────────── + +/** + * Child confirms they completed a chore. + */ +export async function confirmChore(childId: string, taskId: string): Promise { + return fetch(`/api/child/${childId}/confirm-chore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }), + }) +} + +/** + * Child cancels a pending chore confirmation. + */ +export async function cancelConfirmChore(childId: string, taskId: string): Promise { + return fetch(`/api/child/${childId}/cancel-confirm-chore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }), + }) +} + +/** + * Parent approves a pending chore confirmation (awards points). + */ +export async function approveChore(childId: string, taskId: string): Promise { + return fetch(`/api/child/${childId}/approve-chore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }), + }) +} + +/** + * Parent rejects a pending chore confirmation (no points, resets to available). + */ +export async function rejectChore(childId: string, taskId: string): Promise { + return fetch(`/api/child/${childId}/reject-chore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }), + }) +} + +/** + * Parent resets a completed chore (points kept, chore can be confirmed again). + */ +export async function resetChore(childId: string, taskId: string): Promise { + return fetch(`/api/child/${childId}/reset-chore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }), + }) +} + +/** + * Fetch all pending confirmations (both chores and rewards) for the current user. + */ +export async function fetchPendingConfirmations(): Promise { + return fetch('/api/pending-confirmations') +} diff --git a/frontend/vue-app/src/common/models.ts b/frontend/vue-app/src/common/models.ts index 55342b1..56d08c2 100644 --- a/frontend/vue-app/src/common/models.ts +++ b/frontend/vue-app/src/common/models.ts @@ -1,12 +1,14 @@ +export type TaskType = 'chore' | 'kindness' | 'penalty' + export interface Task { id: string name: string points: number - is_good: boolean + type: TaskType image_id: string | null image_url?: string | null // optional, for resolved URLs } -export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const +export const TASK_FIELDS = ['id', 'name', 'points', 'type', 'image_id'] as const export interface DayConfig { day: number // 0=Sun, 1=Mon, ..., 6=Sat @@ -37,13 +39,15 @@ export interface ChoreSchedule { export interface ChildTask { id: string name: string - is_good: boolean + type: TaskType points: number image_id: string | null image_url?: string | null custom_value?: number | null schedule?: ChoreSchedule | null extension_date?: string | null // ISO date of today's extension, if any + pending_status?: 'pending' | 'approved' | null + approved_at?: string | null } export interface User { @@ -100,25 +104,31 @@ export const REWARD_STATUS_FIELDS = [ 'image_id', ] as const -export interface PendingReward { +export interface PendingConfirmation { id: string child_id: string child_name: string child_image_id: string | null - child_image_url?: string | null // optional, for resolved URLs - reward_id: string - reward_name: string - reward_image_id: string | null - reward_image_url?: string | null // optional, for resolved URLs + child_image_url?: string | null + entity_id: string + entity_type: 'chore' | 'reward' + entity_name: string + entity_image_id: string | null + entity_image_url?: string | null + status: 'pending' | 'approved' | 'rejected' + approved_at: string | null } -export const PENDING_REWARD_FIELDS = [ +export const PENDING_CONFIRMATION_FIELDS = [ 'id', 'child_id', 'child_name', 'child_image_id', - 'reward_id', - 'reward_name', - 'reward_image_id', + 'entity_id', + 'entity_type', + 'entity_name', + 'entity_image_id', + 'status', + 'approved_at', ] as const export interface Event { @@ -137,6 +147,7 @@ export interface Event { | ChildOverrideDeletedPayload | ChoreScheduleModifiedPayload | ChoreTimeExtendedPayload + | ChildChoreConfirmationPayload } export interface ChildModifiedEventPayload { @@ -197,8 +208,16 @@ export interface ChildOverrideDeletedPayload { entity_type: string } -export type EntityType = 'task' | 'reward' | 'penalty' -export type ActionType = 'activated' | 'requested' | 'redeemed' | 'cancelled' +export type EntityType = 'task' | 'reward' | 'penalty' | 'chore' | 'kindness' +export type ActionType = + | 'activated' + | 'requested' + | 'redeemed' + | 'cancelled' + | 'confirmed' + | 'approved' + | 'rejected' + | 'reset' export interface TrackingEvent { id: string @@ -232,7 +251,7 @@ export const TRACKING_EVENT_FIELDS = [ 'metadata', ] as const -export type OverrideEntityType = 'task' | 'reward' +export type OverrideEntityType = 'task' | 'reward' | 'chore' | 'kindness' | 'penalty' export interface ChildOverride { id: string @@ -264,3 +283,9 @@ export interface ChoreTimeExtendedPayload { child_id: string task_id: string } + +export interface ChildChoreConfirmationPayload { + child_id: string + task_id: string + operation: 'CONFIRMED' | 'APPROVED' | 'REJECTED' | 'CANCELLED' | 'RESET' +} diff --git a/frontend/vue-app/src/components/child/ChildView.vue b/frontend/vue-app/src/components/child/ChildView.vue index 0da971f..2b24966 100644 --- a/frontend/vue-app/src/components/child/ChildView.vue +++ b/frontend/vue-app/src/components/child/ChildView.vue @@ -5,6 +5,7 @@ import ChildDetailCard from './ChildDetailCard.vue' import ScrollingList from '../shared/ScrollingList.vue' import StatusMessage from '../shared/StatusMessage.vue' import RewardConfirmDialog from './RewardConfirmDialog.vue' +import ChoreConfirmDialog from './ChoreConfirmDialog.vue' import ModalDialog from '../shared/ModalDialog.vue' import { eventBus } from '@/common/eventBus' //import '@/assets/view-shared.css' @@ -25,7 +26,9 @@ import type { ChildModifiedEventPayload, ChoreScheduleModifiedPayload, ChoreTimeExtendedPayload, + ChildChoreConfirmationPayload, } from '@/common/models' +import { confirmChore, cancelConfirmChore } from '@/common/api' import { isScheduledToday, isPastTime, @@ -48,6 +51,9 @@ const childRewardListRef = ref() const showRewardDialog = ref(false) const showCancelDialog = ref(false) const dialogReward = ref(null) +const showChoreConfirmDialog = ref(false) +const showChoreCancelDialog = ref(false) +const dialogChore = ref(null) function handleTaskTriggered(event: Event) { const payload = event.payload as ChildTaskTriggeredEventPayload @@ -177,7 +183,7 @@ function handleRewardModified(event: Event) { } } -const triggerTask = async (task: Task) => { +const triggerTask = async (task: ChildTask) => { // Cancel any pending speech to avoid conflicts if ('speechSynthesis' in window) { window.speechSynthesis.cancel() @@ -191,7 +197,74 @@ const triggerTask = async (task: Task) => { } } - // Child mode is speech-only; point changes are handled in parent mode. + // For chores, handle confirmation flow + if (task.type === 'chore') { + if (isChoreExpired(task)) return // Expired — no action + if (isChoreCompletedToday(task)) return // Already completed — no action + if (task.pending_status === 'pending') { + // Show cancel dialog + dialogChore.value = task + setTimeout(() => { + showChoreCancelDialog.value = true + }, 150) + return + } + // Available — show confirm dialog + dialogChore.value = task + setTimeout(() => { + showChoreConfirmDialog.value = true + }, 150) + return + } + + // Kindness / Penalty: speech-only in child mode; point changes are handled in parent mode. +} + +function isChoreCompletedToday(item: ChildTask): boolean { + if (item.pending_status !== 'approved' || !item.approved_at) return false + const approvedDate = item.approved_at.substring(0, 10) + const today = toLocalISODate(new Date()) + return approvedDate === today +} + +async function doConfirmChore() { + if (!child.value?.id || !dialogChore.value) return + try { + const resp = await confirmChore(child.value.id, dialogChore.value.id) + if (!resp.ok) { + console.error('Failed to confirm chore') + } + } catch (err) { + console.error('Failed to confirm chore:', err) + } finally { + showChoreConfirmDialog.value = false + dialogChore.value = null + } +} + +function cancelChoreConfirmDialog() { + showChoreConfirmDialog.value = false + dialogChore.value = null +} + +async function doCancelConfirmChore() { + if (!child.value?.id || !dialogChore.value) return + try { + const resp = await cancelConfirmChore(child.value.id, dialogChore.value.id) + if (!resp.ok) { + console.error('Failed to cancel chore confirmation') + } + } catch (err) { + console.error('Failed to cancel chore confirmation:', err) + } finally { + showChoreCancelDialog.value = false + dialogChore.value = null + } +} + +function closeChoreCancelDialog() { + showChoreCancelDialog.value = false + dialogChore.value = null } const triggerReward = (reward: RewardStatus) => { @@ -331,6 +404,13 @@ function handleChoreTimeExtended(event: Event) { } } +function handleChoreConfirmation(event: Event) { + const payload = event.payload as ChildChoreConfirmationPayload + if (child.value && payload.child_id === child.value.id) { + childChoreListRef.value?.refresh() + } +} + function isChoreScheduledToday(item: ChildTask): boolean { if (!item.schedule) return true const today = new Date() @@ -406,6 +486,7 @@ onMounted(async () => { eventBus.on('child_reward_request', handleRewardRequest) eventBus.on('chore_schedule_modified', handleChoreScheduleModified) eventBus.on('chore_time_extended', handleChoreTimeExtended) + eventBus.on('child_chore_confirmation', handleChoreConfirmation) document.addEventListener('visibilitychange', onVisibilityChange) if (route.params.id) { const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id @@ -440,6 +521,7 @@ onUnmounted(() => { eventBus.off('child_reward_request', handleRewardRequest) eventBus.off('chore_schedule_modified', handleChoreScheduleModified) eventBus.off('chore_time_extended', handleChoreTimeExtended) + eventBus.off('child_chore_confirmation', handleChoreConfirmation) document.removeEventListener('visibilitychange', onVisibilityChange) clearExpiryTimers() removeInactivityListeners() @@ -466,21 +548,25 @@ onUnmounted(() => { @trigger-item="triggerTask" :getItemClass=" (item: ChildTask) => ({ - bad: !item.is_good, - good: item.is_good, - 'chore-inactive': isChoreExpired(item), + bad: item.type === 'penalty', + good: item.type !== 'penalty', + 'chore-inactive': + item.type === 'chore' && (isChoreExpired(item) || isChoreCompletedToday(item)), }) " - :filter-fn="(item: ChildTask) => item.is_good && isChoreScheduledToday(item)" + :filter-fn="(item: ChildTask) => item.type === 'chore' && isChoreScheduledToday(item)" > + + + { :readyItemId="readyItemId" @item-ready="handleItemReady" @trigger-item="triggerTask" - :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" - :filter-fn=" - (item) => { - return !item.is_good - } - " + :getItemClass="() => ({ bad: true })" + :filter-fn="(item: ChildTask) => item.type === 'penalty'" > diff --git a/frontend/vue-app/src/components/child/TaskAssignView.vue b/frontend/vue-app/src/components/child/ChoreAssignView.vue similarity index 63% rename from frontend/vue-app/src/components/child/TaskAssignView.vue rename to frontend/vue-app/src/components/child/ChoreAssignView.vue index 1cb0533..a49492a 100644 --- a/frontend/vue-app/src/components/child/TaskAssignView.vue +++ b/frontend/vue-app/src/components/child/ChoreAssignView.vue @@ -1,20 +1,20 @@ diff --git a/frontend/vue-app/src/components/child/KindnessAssignView.vue b/frontend/vue-app/src/components/child/KindnessAssignView.vue new file mode 100644 index 0000000..e04e0e5 --- /dev/null +++ b/frontend/vue-app/src/components/child/KindnessAssignView.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/vue-app/src/components/child/ParentView.vue b/frontend/vue-app/src/components/child/ParentView.vue index cfa29d5..7a03a08 100644 --- a/frontend/vue-app/src/components/child/ParentView.vue +++ b/frontend/vue-app/src/components/child/ParentView.vue @@ -5,11 +5,19 @@ import ScheduleModal from '../shared/ScheduleModal.vue' import PendingRewardDialog from './PendingRewardDialog.vue' import TaskConfirmDialog from './TaskConfirmDialog.vue' import RewardConfirmDialog from './RewardConfirmDialog.vue' +import ChoreApproveDialog from './ChoreApproveDialog.vue' import { useRoute, useRouter } from 'vue-router' import ChildDetailCard from './ChildDetailCard.vue' import ScrollingList from '../shared/ScrollingList.vue' import StatusMessage from '../shared/StatusMessage.vue' -import { setChildOverride, parseErrorResponse, extendChoreTime } from '@/common/api' +import { + setChildOverride, + parseErrorResponse, + extendChoreTime, + approveChore, + rejectChore, + resetChore, +} from '@/common/api' import { eventBus } from '@/common/eventBus' import '@/assets/styles.css' import type { @@ -31,6 +39,7 @@ import type { ChildOverrideDeletedPayload, ChoreScheduleModifiedPayload, ChoreTimeExtendedPayload, + ChildChoreConfirmationPayload, } from '@/common/models' import { isScheduledToday, @@ -57,8 +66,13 @@ const selectedReward = ref(null) const childChoreListRef = ref() const childPenaltyListRef = ref() const childRewardListRef = ref() +const childKindnessListRef = ref() const showPendingRewardDialog = ref(false) +// Chore approve/reject +const showChoreApproveDialog = ref(false) +const approveDialogChore = ref(null) + // Override editing const showOverrideModal = ref(false) const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(null) @@ -268,6 +282,78 @@ function handleChoreTimeExtended(event: Event) { } } +function handleChoreConfirmation(event: Event) { + const payload = event.payload as ChildChoreConfirmationPayload + if (child.value && payload.child_id === child.value.id) { + childChoreListRef.value?.refresh() + } +} + +function isChoreCompletedToday(item: ChildTask): boolean { + if (item.pending_status !== 'approved' || !item.approved_at) return false + const today = new Date().toISOString().slice(0, 10) + return item.approved_at.slice(0, 10) === today +} + +function isChorePending(item: ChildTask): boolean { + return item.pending_status === 'pending' +} + +async function doApproveChore() { + if (!child.value || !approveDialogChore.value) return + try { + const resp = await approveChore(child.value.id, approveDialogChore.value.id) + if (resp.ok) { + const data = await resp.json() + if (child.value) child.value.points = data.points + } else { + const { msg } = await parseErrorResponse(resp) + alert(`Error: ${msg}`) + } + } catch (err) { + console.error('Failed to approve chore:', err) + } finally { + showChoreApproveDialog.value = false + approveDialogChore.value = null + } +} + +async function doRejectChore() { + if (!child.value || !approveDialogChore.value) return + try { + const resp = await rejectChore(child.value.id, approveDialogChore.value.id) + if (!resp.ok) { + const { msg } = await parseErrorResponse(resp) + alert(`Error: ${msg}`) + } + } catch (err) { + console.error('Failed to reject chore:', err) + } finally { + showChoreApproveDialog.value = false + approveDialogChore.value = null + } +} + +function cancelChoreApproveDialog() { + showChoreApproveDialog.value = false + approveDialogChore.value = null +} + +async function doResetChore(item: ChildTask, e: MouseEvent) { + e.stopPropagation() + closeChoreMenu() + if (!child.value) return + try { + const resp = await resetChore(child.value.id, item.id) + if (!resp.ok) { + const { msg } = await parseErrorResponse(resp) + alert(`Error: ${msg}`) + } + } catch (err) { + console.error('Failed to reset chore:', err) + } +} + // ── Kebab menu ─────────────────────────────────────────────────────────────── const onDocClick = (e: MouseEvent) => { @@ -383,7 +469,7 @@ function resetExpiryTimers() { const items: ChildTask[] = childChoreListRef.value?.items ?? [] const now = new Date() for (const item of items) { - if (!item.schedule || !item.is_good) continue + if (!item.schedule || item.type !== 'chore') continue if (!isScheduledToday(item.schedule, now)) continue const due = getDueTimeToday(item.schedule, now) if (!due) continue @@ -505,6 +591,7 @@ onMounted(async () => { eventBus.on('child_override_deleted', handleOverrideDeleted) eventBus.on('chore_schedule_modified', handleChoreScheduleModified) eventBus.on('chore_time_extended', handleChoreTimeExtended) + eventBus.on('child_chore_confirmation', handleChoreConfirmation) document.addEventListener('click', onDocClick, true) document.addEventListener('visibilitychange', onVisibilityChange) @@ -541,6 +628,7 @@ onUnmounted(() => { eventBus.off('child_override_deleted', handleOverrideDeleted) eventBus.off('chore_schedule_modified', handleChoreScheduleModified) eventBus.off('chore_time_extended', handleChoreTimeExtended) + eventBus.off('child_chore_confirmation', handleChoreConfirmation) document.removeEventListener('click', onDocClick, true) document.removeEventListener('visibilitychange', onVisibilityChange) @@ -552,11 +640,29 @@ function getPendingRewardIds(): string[] { return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id) } -const triggerTask = (task: Task) => { +const triggerTask = (task: ChildTask) => { if (shouldIgnoreNextCardClick.value) { shouldIgnoreNextCardClick.value = false return } + + // For chores, handle pending/completed states + if (task.type === 'chore') { + // Completed chore — no tap action (use kebab menu "Reset" instead) + if (isChoreCompletedToday(task)) return + // Expired chore — no tap action + if (isChoreExpired(task)) return + // Pending chore — open approve/reject dialog + if (isChorePending(task)) { + approveDialogChore.value = task + setTimeout(() => { + showChoreApproveDialog.value = true + }, 150) + return + } + } + + // Available chore / kindness / penalty — existing trigger flow selectedTask.value = task const pendingRewardIds = getPendingRewardIds() if (pendingRewardIds.length > 0) { @@ -657,13 +763,19 @@ const confirmTriggerReward = async () => { function goToAssignTasks() { if (child.value?.id) { - router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'good' } }) + router.push({ name: 'ChoreAssignView', params: { id: child.value.id } }) } } function goToAssignBadHabits() { if (child.value?.id) { - router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'bad' } }) + router.push({ name: 'PenaltyAssignView', params: { id: child.value.id } }) + } +} + +function goToAssignKindness() { + if (child.value?.id) { + router.push({ name: 'KindnessAssignView', params: { id: child.value.id } }) } } @@ -696,12 +808,12 @@ function goToAssignRewards() { @item-ready="handleChoreItemReady" :getItemClass=" (item) => ({ - bad: !item.is_good, - good: item.is_good, - 'chore-inactive': isChoreInactive(item), + bad: item.type === 'penalty', + good: item.type !== 'penalty', + 'chore-inactive': isChoreInactive(item) || isChoreCompletedToday(item), }) " - :filter-fn="(item) => item.is_good" + :filter-fn="(item) => item.type === 'chore'" > + + + @@ -794,7 +952,10 @@ function goToAssignRewards() { Task Image
{{ item.custom_value !== undefined && item.custom_value !== null @@ -840,6 +1001,9 @@ function goToAssignRewards() {
+ @@ -929,6 +1093,19 @@ function goToAssignRewards() { } " /> + + +
@@ -1106,6 +1283,14 @@ function goToAssignRewards() { pointer-events: none; } +.pending-stamp { + color: #fbbf24; +} + +.completed-stamp { + color: #22c55e; +} + /* Due time sub-text */ .due-label { font-size: 0.85rem; diff --git a/frontend/vue-app/src/components/child/PenaltyAssignView.vue b/frontend/vue-app/src/components/child/PenaltyAssignView.vue new file mode 100644 index 0000000..3b6f91d --- /dev/null +++ b/frontend/vue-app/src/components/child/PenaltyAssignView.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/frontend/vue-app/src/components/child/TaskConfirmDialog.vue b/frontend/vue-app/src/components/child/TaskConfirmDialog.vue index 68538f2..2a37b92 100644 --- a/frontend/vue-app/src/components/child/TaskConfirmDialog.vue +++ b/frontend/vue-app/src/components/child/TaskConfirmDialog.vue @@ -7,8 +7,8 @@ @backdrop-click="$emit('cancel')" > - requested + {{ + item.entity_type === 'chore' ? 'completed' : 'requested' + }}
- {{ item.reward_name }} - Reward + {{ item.entity_name }} +
@@ -35,8 +37,13 @@ import { ref, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import ItemList from '../shared/ItemList.vue' import MessageBlock from '../shared/MessageBlock.vue' -import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models' -import { PENDING_REWARD_FIELDS } from '@/common/models' +import type { + PendingConfirmation, + Event, + ChildRewardRequestEventPayload, + ChildChoreConfirmationPayload, +} from '@/common/models' +import { PENDING_CONFIRMATION_FIELDS } from '@/common/models' import { eventBus } from '@/common/eventBus' const router = useRouter() @@ -44,7 +51,7 @@ const router = useRouter() const notificationListCountRef = ref(-1) const refreshKey = ref(0) -function handleNotificationClick(item: PendingReward) { +function handleNotificationClick(item: PendingConfirmation) { router.push({ name: 'ParentView', params: { id: item.child_id } }) } @@ -55,7 +62,19 @@ function handleRewardRequest(event: Event) { payload.operation === 'CANCELLED' || payload.operation === 'GRANTED' ) { - // Reset count and bump key to force ItemList to re-mount and refetch + notificationListCountRef.value = -1 + refreshKey.value++ + } +} + +function handleChoreConfirmation(event: Event) { + const payload = event.payload as ChildChoreConfirmationPayload + if ( + payload.operation === 'CONFIRMED' || + payload.operation === 'APPROVED' || + payload.operation === 'REJECTED' || + payload.operation === 'CANCELLED' + ) { notificationListCountRef.value = -1 refreshKey.value++ } @@ -63,10 +82,12 @@ function handleRewardRequest(event: Event) { onMounted(() => { eventBus.on('child_reward_request', handleRewardRequest) + eventBus.on('child_chore_confirmation', handleChoreConfirmation) }) onUnmounted(() => { eventBus.off('child_reward_request', handleRewardRequest) + eventBus.off('child_chore_confirmation', handleChoreConfirmation) }) diff --git a/frontend/vue-app/src/components/shared/EntityEditForm.vue b/frontend/vue-app/src/components/shared/EntityEditForm.vue index 1d15395..ae4c80f 100644 --- a/frontend/vue-app/src/components/shared/EntityEditForm.vue +++ b/frontend/vue-app/src/components/shared/EntityEditForm.vue @@ -146,7 +146,7 @@ function submit() { // Editable field names (exclude custom fields that are not editable) const editableFieldNames = props.fields - .filter((f) => f.type !== 'custom' || f.name === 'is_good') + .filter((f) => f.type !== 'custom' || f.name === 'type') .map((f) => f.name) const isDirty = ref(false) diff --git a/frontend/vue-app/src/components/task/ChoreEditView.vue b/frontend/vue-app/src/components/task/ChoreEditView.vue new file mode 100644 index 0000000..2e25fa8 --- /dev/null +++ b/frontend/vue-app/src/components/task/ChoreEditView.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/vue-app/src/components/task/ChoreView.vue b/frontend/vue-app/src/components/task/ChoreView.vue new file mode 100644 index 0000000..95c4eb1 --- /dev/null +++ b/frontend/vue-app/src/components/task/ChoreView.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/frontend/vue-app/src/components/task/KindnessEditView.vue b/frontend/vue-app/src/components/task/KindnessEditView.vue new file mode 100644 index 0000000..e4577d4 --- /dev/null +++ b/frontend/vue-app/src/components/task/KindnessEditView.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/vue-app/src/components/task/KindnessView.vue b/frontend/vue-app/src/components/task/KindnessView.vue new file mode 100644 index 0000000..a8dc059 --- /dev/null +++ b/frontend/vue-app/src/components/task/KindnessView.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/frontend/vue-app/src/components/task/PenaltyEditView.vue b/frontend/vue-app/src/components/task/PenaltyEditView.vue new file mode 100644 index 0000000..d456f85 --- /dev/null +++ b/frontend/vue-app/src/components/task/PenaltyEditView.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/vue-app/src/components/task/PenaltyView.vue b/frontend/vue-app/src/components/task/PenaltyView.vue new file mode 100644 index 0000000..8d919af --- /dev/null +++ b/frontend/vue-app/src/components/task/PenaltyView.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/frontend/vue-app/src/components/task/TaskEditView.vue b/frontend/vue-app/src/components/task/TaskEditView.vue deleted file mode 100644 index b396f9f..0000000 --- a/frontend/vue-app/src/components/task/TaskEditView.vue +++ /dev/null @@ -1,227 +0,0 @@ - - - - diff --git a/frontend/vue-app/src/components/task/TaskSubNav.vue b/frontend/vue-app/src/components/task/TaskSubNav.vue new file mode 100644 index 0000000..b6d37e5 --- /dev/null +++ b/frontend/vue-app/src/components/task/TaskSubNav.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/frontend/vue-app/src/components/task/TaskView.vue b/frontend/vue-app/src/components/task/TaskView.vue deleted file mode 100644 index d3d2fa6..0000000 --- a/frontend/vue-app/src/components/task/TaskView.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - - - diff --git a/frontend/vue-app/src/layout/ParentLayout.vue b/frontend/vue-app/src/layout/ParentLayout.vue index d30cc63..9b532a4 100644 --- a/frontend/vue-app/src/layout/ParentLayout.vue +++ b/frontend/vue-app/src/layout/ParentLayout.vue @@ -22,6 +22,9 @@ const showBack = computed( !( route.path === '/parent' || route.name === 'TaskView' || + route.name === 'ChoreView' || + route.name === 'KindnessView' || + route.name === 'PenaltyView' || route.name === 'RewardView' || route.name === 'NotificationView' ), @@ -56,7 +59,9 @@ onMounted(async () => { 'ParentView', 'ChildEditView', 'CreateChild', - 'TaskAssignView', + 'ChoreAssignView', + 'KindnessAssignView', + 'PenaltyAssignView', 'RewardAssignView', ].includes(String(route.name)), }" @@ -82,8 +87,21 @@ onMounted(async () => {