Add TimeSelector and ScheduleModal components with tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s

- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality.
- Created ScheduleModal component for scheduling chores with options for specific days or intervals.
- Added utility functions for scheduling logic in scheduleUtils.ts.
- Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
This commit is contained in:
2026-02-23 15:44:55 -05:00
parent d8822b44be
commit 234adbe05f
26 changed files with 2880 additions and 60 deletions

View File

@@ -15,65 +15,227 @@
--- ---
## Architecture Notes
### Timezone Safety
Due times represent the **child's local time of day**. The server must never perform time-based computations (is it today? has the time passed?) because it may be in a different timezone than the client.
**All time logic runs in the browser using `new Date()` (the client's local clock).** The backend only stores and returns raw schedule data. The frontend computes `isScheduledToday`, `isPastTime`, `due_time_label`, and `setTimeout` durations locally.
### Auto-Expiry While Browser is Open
When a chore's due time passes while the browser tab is already open, the UI must update automatically without a page refresh. The frontend sets a `setTimeout` per chore (calculated from the due time and the current local time). When the timer fires, the chore's state is updated reactively in local component state — no re-fetch required.
---
## Data Model Changes ## Data Model Changes
### Backend Model ### Backend Models
### Frontend Model **New model — `backend/models/chore_schedule.py`:**
### Frontend Design - `id: str`
- `child_id: str`
- `task_id: str`
- `mode: Literal['days', 'interval']`
- For `mode='days'`: `day_configs: list[DayConfig]`
- `DayConfig`: `{ day: int (0=Sun6=Sat), hour: int, minute: int }`
- For `mode='interval'`: `interval_days: int` (27), `anchor_weekday: int` (0=Sun6=Sat), `interval_hour: int`, `interval_minute: int`
- Inherits `id`, `created_at`, `updated_at` from `BaseModel`
#### Chore card **New model — `backend/models/task_extension.py`:**
- Currently, when a chore is pressed on the ParentView, it shows an icon that represents an edit button. This is shown in .github/feat-calendar-chore/feat-calendar-chore-component01.png outlined in red. - `id: str`
- I would like for this icon button to be changed to a kebab menu that drops down the following: - `child_id: str`
- Edit points - `task_id: str`
- Schedule - `date: str` — ISO date string supplied by the client, e.g. `'2026-02-22'`
- Extend Time
- The look and feel of the dropdown should follow the design of kebab-menu in ChildrenListView
- Penalties and Rewards will keep the current edit icon for now and it will function the same.
- When the parent selects 'Edit points' for a chore, the current functionality for editing override points will occur.
- When the parent selects 'Schedule' a new form modal(or view) will appear that allows the parent to schedule the chore.
- In ParentView, when a chore is either scheduled for a different day than the current day or the current time is past the chore's time, the chore card will appear 'grayed out' (or should it have a badge instead?) This just let's the parent know that the chore is not scheduled or past the point of the time for the day. The chore can still show the kebab menu and it can still be activated by the parent.
- In ChildView, when a chore is either scheduled for a different day than the current day it will not appear. This makes it simple for the child to focus on the current day's chores.
- In ChildView, when a chore is scheduled for the current day, but it is past the lastest possible time, the chore should appear 'grayed out', and a text stamp should appear on the card that says 'TOO LATE' This is similar to when a reward is PENDING in ChildView.vue with the 'pending' class.
- Once a time of day is past the chore's time, the chore will not 'reset' again until the next day it is scheduled or until the parent edit's the chore and changes the time or until the parent selects the 'Extend Time' option from ParentView.
- The 'Extend Time' menu item should only be shown if the chore being 'edited' has a timeout time and that timeout time has expired.
- When 'Extend Time' is selected, the chore will 'reset' and shown as normal for the remainder of the day.
- 'Extend Time' only cancels the timeout for a chore on that single day.
#### Schedule Modal (or View) ### Frontend Models
- When 'Schedule' is selected from the dropdown menu, a new modal or view will appear. In `frontend/vue-app/src/common/models.ts`:
- The component will have a title of 'Schedule Chore' and a subtitle of the chore's name and icon. It should look like ModalDialog. Should it use EntityEditForm?
- The component will allow the user to make a choice between scheduling the chore on certain days of the week, or occur every X days [2-7] starting on [Day of the week] This way a chore can either be consistent every week, or follow a pattern of X days.
- If scheduling for certain days, there should be checkbox buttons with the labels (Sunday - Saturday) This let's the parent select on what days the chore will be valid. Checking a box next to a day should show a time selector next to the day. This time selector will show an hour, minute, and AM/PM selector that will change in 15 minute increments. We have to also consider mobile devices. Up/Down arrows to increment and decrement the time?
- There should be a Save and Cancel button on the bottom of the form
- If scheduling for every X days, then there should only be one time selector. In this mode, the chore will only have one configurable time no matter what day of the week it is.
#### Time Selector - Add `DayConfig` interface: `{ day: number; hour: number; minute: number }`
- Add `ChoreSchedule` interface mirroring the Python model (`mode`, `day_configs`, `interval_days`, `anchor_weekday`, `interval_hour`, `interval_minute`)
- Extend `ChildTask` wire type with:
- `schedule?: ChoreSchedule | null` — raw schedule data returned from backend
- `extension_date?: string | null` — ISO date of the most recent `TaskExtension` for this child+task, if any
- The time selector should function like the 'Time Only' component in https://primevue.org/datepicker/#time ---
## Frontend Design
### Chore card
- Currently, when a chore is pressed on the ParentView, it shows an icon that represents an edit button. This is shown in `.github/feat-calendar-chore/feat-calendar-chore-component01.png` outlined in red.
- This icon button will be changed to a kebab menu that drops down the following:
- **Edit Points** — opens the existing override points modal
- **Schedule** — opens the Schedule Modal
- **Extend Time** — only visible when the chore is computed as expired (`isPastTime === true`)
- The look and feel of the dropdown follows the kebab menu pattern in `ChildrenListView.vue`.
- Penalties and Rewards keep the current edit icon button and existing behavior.
- The kebab menu is placed per-card in the chore `#item` slot in `ParentView.vue` (not inside `ScrollingList`).
**ParentView card states (computed client-side from `item.schedule`, `item.extension_date`, and `new Date()`):**
- No schedule → card appears normally, no annotations
- Scheduled but wrong day → card is grayed out (`.chore-inactive` class: `opacity: 0.45; filter: grayscale(60%)`)
- Correct day, due time not yet passed → show `"Due by X:XX PM"` sub-text on card
- Correct day, due time passed (and no extension for today) → card is grayed out **and** a `TOO LATE` badge overlay appears (same absolute-positioned `.pending` pattern, using `--item-card-bad-border` color)
- Chore always shows the kebab menu and can always be triggered by the parent regardless of state
**ChildView card states (computed client-side):**
- No schedule → card appears normally
- Scheduled but wrong day → hidden entirely (filtered out in component, not server-side)
- Correct day, due time not yet passed → show `"Due by X:XX PM"` sub-text
- Correct day, due time passed → grayed out with `TOO LATE` badge overlay (same pattern as `PENDING` in `ChildView.vue`)
**Auto-expiry timers:**
- After task list is fetched or updated, for each chore where a due time exists for today and `isPastTime` is `false`, compute `msUntilExpiry` using `scheduleUtils.msUntilExpiry(dueHour, dueMin, new Date())` and set a `setTimeout`
- When the timer fires, flip that chore's reactive state to expired locally — no re-fetch needed
- All timer handles are stored in a `ref<number[]>` and cleared in `onUnmounted`
- Timers are also cleared and reset whenever the task list is re-fetched (e.g. on SSE events)
**Extend Time behavior:**
- When selected, calls `extendChoreTime(childId, taskId)` with the client's local ISO date
- The chore resets to normal for the remainder of that day only
- After a successful extend, the local chore state is updated to reflect `extension_date = today` and timers are reset
### Schedule Modal
- Triggered from the **Schedule** kebab menu item in ParentView
- Wraps `ModalDialog` with title `"Schedule Chore"` and subtitle showing the chore's name and icon
- Does **not** use `EntityEditForm` — custom layout required for the day/time matrix
- Top toggle: **"Specific Days"** vs **"Every X Days"**
**Specific Days mode:**
- 7 checkbox rows, one per day (SundaySaturday)
- Checking a day reveals an inline `TimeSelector` component for that day
- Unchecking a day removes its time configuration
- Each checked day can have its own independent due time
**Every X Days mode:**
- Number selector for interval [27]
- Weekday picker to select the anchor day (0=Sun6=Sat)
- Anchor logic: the first occurrence is calculated as the current week's matching weekday; subsequent occurrences repeat every X days indefinitely. See `scheduleUtils.intervalHitsToday(anchorWeekday, intervalDays, localDate)`
- One shared `TimeSelector` applies to all occurrences
- Save and Cancel buttons at the bottom
### Time Selector
- Custom-built `TimeSelector.vue` component (no PrimeVue dependency)
- Props: `modelValue: { hour: number; minute: number }` (24h internally, 12h display)
- Emits: `update:modelValue`
- UI: Up/Down arrow buttons for Hour (112) and Minute (00, 15, 30, 45), AM/PM toggle button
- Minutes increment/decrement in 15-minute steps with boundary wrapping (e.g. 11:45 AM → 12:00 PM)
- Large tap targets for mobile devices
- Scoped CSS using `:root` CSS variables only
- Modeled after the Time Only component at https://primevue.org/datepicker/#time
--- ---
## Backend Implementation ## Backend Implementation
- child_api.py will need it's /child endpoint modified so that in child mode, only the chores (tasks with is_good = true) that are not scheduled, or scheduled for that current day to be returned. **New DB files:**
- a new child_api endpoint may need to be created to extend time on a chore that has expired it's time.
- should a new tinyDB database file be used for scheduling since each child has a potential calendar? - `backend/db/chore_schedules.py` — TinyDB table `chore_schedules`
- `backend/db/task_extensions.py` — TinyDB table `task_extensions`
**New API file — `backend/api/chore_schedule_api.py`:**
- `GET /child/<child_id>/task/<task_id>/schedule` — returns raw `ChoreSchedule`; 404 if none
- `PUT /child/<child_id>/task/<task_id>/schedule` — create or replace schedule; fires `chore_schedule_modified` SSE (`operation: 'SET'`)
- `DELETE /child/<child_id>/task/<task_id>/schedule` — remove schedule; fires `chore_schedule_modified` SSE (`operation: 'DELETED'`)
- `POST /child/<child_id>/task/<task_id>/extend` — body: `{ date: "YYYY-MM-DD" }` (client's local date); inserts a `TaskExtension`; returns 409 if a `TaskExtension` already exists for this `child_id + task_id + date`; fires `chore_time_extended` SSE
**Modify `backend/api/child_api.py` — `GET /child/<id>/list-tasks`:**
- For each task, look up `ChoreSchedule` for `(child_id, task_id)` and the most recent `TaskExtension`
- Return `schedule: ChoreSchedule | null` and `extension_date: string | null` on each task object
- **No filtering, no time math, no annotations** — the client handles all of this
**New SSE event types in `backend/events/types/`:**
- `chore_schedule_modified.py` — payload: `child_id`, `task_id`, `operation: Literal['SET', 'DELETED']`
- `chore_time_extended.py` — payload: `child_id`, `task_id`
- Register both in `event_types.py`
---
## Backend Tests ## Backend Tests
- [ ] - [x] `test_chore_schedule_api.py`: CRUD schedule endpoints, extend endpoint (409 on duplicate date, accepts client-supplied date)
- [x] Additions to `test_child_api.py`: verify `schedule` and `extension_date` fields are returned on each task; verify no server-side time filtering or annotation occurs
--- ---
## Frontend Implementation ## Frontend Implementation
**New utility — `frontend/vue-app/src/common/scheduleUtils.ts`:**
- `isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean` — handles both `days` and `interval` modes
- `getDueTimeToday(schedule: ChoreSchedule, localDate: Date): { hour: number; minute: number } | null`
- `isPastTime(dueHour: number, dueMin: number, localNow: Date): boolean`
- `isExtendedToday(extensionDate: string | null | undefined, localDate: Date): boolean`
- `formatDueTimeLabel(hour: number, minute: number): string` — returns e.g. `"3:00 PM"`
- `msUntilExpiry(dueHour: number, dueMin: number, localNow: Date): number`
- `intervalHitsToday(anchorWeekday: number, intervalDays: number, localDate: Date): boolean`
**`ParentView.vue`:**
- Derive computed chore state per card using `scheduleUtils` and `new Date()`
- Remove `:enableEdit="true"` and `@edit-item` from the Chores `ScrollingList` (keep for Penalties)
- Add `activeMenuFor = ref<string | null>(null)` and capture-phase `document` click listener (same pattern as `ChildrenListView.vue`)
- In chore `#item` slot: add `.kebab-wrap > .kebab-btn + .kebab-menu` per card with items: Edit Points, Schedule, Extend Time (conditional on `isPastTime`)
- Bind `.chore-inactive` class when `!isScheduledToday || isPastTime`
- Add `TOO LATE` badge overlay when `isPastTime`
- Show `"Due by {{ formatDueTimeLabel(...) }}"` sub-text when due time exists and not yet expired
- Manage `setTimeout` handles in a `ref<number[]>`; clear in `onUnmounted` and on every re-fetch
- Listen for `chore_schedule_modified` and `chore_time_extended` SSE events → re-fetch task list and reset timers
**`ChildView.vue`:**
- Derive computed chore state per card using `scheduleUtils` and `new Date()`
- Filter out chores where `!isScheduledToday` (client-side, not server-side)
- Add `.chore-expired` grayout + `TOO LATE` stamp when `isPastTime`
- Show `"Due by {{ formatDueTimeLabel(...) }}"` sub-text when due time exists and not yet expired
- Manage `setTimeout` handles the same way as ParentView
- Listen for `chore_schedule_modified` and `chore_time_extended` SSE events → re-fetch and reset timers
**New `frontend/vue-app/src/components/shared/ScheduleModal.vue`:**
- Wraps `ModalDialog`; custom slot content for schedule form
- Mode toggle (Specific Days / Every X Days)
- Specific Days: 7 checkbox rows, each reveals an inline `TimeSelector` when checked
- Interval: number input [27], weekday picker, single `TimeSelector`
- Calls `setChoreSchedule()` on Save; emits `saved` and `cancelled`
**New `frontend/vue-app/src/components/shared/TimeSelector.vue`:**
- Props/emits as described in Frontend Design section
- Up/Down arrows for Hour and Minute (15-min increments), AM/PM toggle
- Scoped CSS, `:root` variables only
**`frontend/vue-app/src/common/api.ts` additions:**
- `getChoreSchedule(childId, taskId)`
- `setChoreSchedule(childId, taskId, schedule)`
- `deleteChoreSchedule(childId, taskId)`
- `extendChoreTime(childId, taskId, localDate: string)` — passes client's local ISO date in request body
---
## Frontend Tests ## Frontend Tests
- [ ] - [x] `scheduleUtils.ts`: unit test all helpers — `isScheduledToday` (days mode, interval mode), `intervalHitsToday`, `isPastTime`, `isExtendedToday`, `formatDueTimeLabel`, `msUntilExpiry`; include timezone-agnostic cases using mocked `Date` objects
- [x] `TimeSelector.vue`: increment/decrement, AM/PM toggle, 15-min boundary wrapping (e.g. 11:45 AM → 12:00 PM)
- [x] `ScheduleModal.vue`: mode toggle renders correct sub-form; unchecking a day removes its time config; Save emits correct `ChoreSchedule` shape
--- ---
@@ -85,8 +247,24 @@
### Backend ### Backend
- [ ] - [x] `ChoreSchedule` and `TaskExtension` models created with `from_dict()`/`to_dict()` serialization
- [x] `chore_schedules` and `task_extensions` TinyDB tables created
- [x] `chore_schedule_api.py` CRUD and extend endpoints implemented
- [x] `GET /child/<id>/list-tasks` returns `schedule` and `extension_date` on each task; performs no time math or filtering
- [x] Extend endpoint accepts client-supplied ISO date; returns 409 on duplicate for same date
- [x] All new SSE events fire on every mutation
- [x] All backend tests pass
### Frontend ### Frontend
- [ ] - [x] `scheduleUtils.ts` implemented and fully unit tested, including timezone-safe mocked Date cases
- [x] `TimeSelector.vue` implemented with 15-min increments, AM/PM toggle, mobile-friendly tap targets
- [x] `ScheduleModal.vue` implemented with Specific Days and Every X Days modes
- [x] ParentView chore edit icon replaced with kebab menu (Edit Points, Schedule, Extend Time)
- [x] ParentView chore cards: wrong day → grayed out; expired time → grayed out + TOO LATE badge
- [x] ChildView chore cards: wrong day → hidden; expired time → grayed out + TOO LATE badge
- [x] "Due by X:XX PM" sub-text shown on cards in both views when applicable
- [x] `setTimeout` auto-expiry implemented; timers cleared on unmount and re-fetch
- [x] API helpers added to `api.ts`
- [x] SSE listeners added in ParentView and ChildView for `chore_schedule_modified` and `chore_time_extended`
- [x] All frontend tests pass

View File

@@ -1,4 +1,7 @@
{ {
"python.venvPath": "${workspaceFolder}/backend/.venv",
"python.terminal.activateEnvironment": true,
"python.terminal.shellIntegration.enabled": true,
"explorer.fileNesting.enabled": true, "explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts", "tsconfig.json": "tsconfig.*.json, env.d.ts",
@@ -19,5 +22,7 @@
}, },
"chat.tools.terminal.autoApprove": { "chat.tools.terminal.autoApprove": {
"&": true "&": true
} },
"python-envs.defaultEnvManager": "ms-python.python:venv",
"python-envs.pythonProjects": []
} }

View File

@@ -28,6 +28,9 @@ from models.tracking_event import TrackingEvent
from api.utils import get_validated_user_id from api.utils import get_validated_user_id
from utils.tracking_logger import log_tracking_event from utils.tracking_logger import log_tracking_event
from collections import defaultdict 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 import logging
child_api = Blueprint('child_api', __name__) child_api = Blueprint('child_api', __name__)
@@ -273,6 +276,18 @@ def list_child_tasks(id):
ct_dict = ct.to_dict() ct_dict = ct.to_dict()
if custom_value is not None: if custom_value is not None:
ct_dict['custom_value'] = custom_value ct_dict['custom_value'] = custom_value
# Attach schedule and most recent extension_date (client does all time math)
if task.get('is_good'):
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
else:
ct_dict['schedule'] = None
ct_dict['extension_date'] = None
child_tasks.append(ct_dict) child_tasks.append(ct_dict)
return jsonify({'tasks': child_tasks}), 200 return jsonify({'tasks': child_tasks}), 200

View File

@@ -0,0 +1,144 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user
from api.error_codes import ErrorCodes
from db.db import child_db
from db.chore_schedules import get_schedule, upsert_schedule, delete_schedule
from db.task_extensions import get_extension, add_extension
from models.chore_schedule import ChoreSchedule
from models.task_extension import TaskExtension
from events.types.event import Event
from events.types.event_types import EventType
from events.types.chore_schedule_modified import ChoreScheduleModified
from events.types.chore_time_extended import ChoreTimeExtended
import logging
chore_schedule_api = Blueprint('chore_schedule_api', __name__)
logger = logging.getLogger(__name__)
def _validate_child(child_id: str, user_id: str):
"""Return child dict if found and owned by user, else None."""
ChildQuery = Query()
result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
return result[0] if result else None
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['GET'])
def get_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
schedule = get_schedule(child_id, task_id)
if not schedule:
return jsonify({'error': 'Schedule not found'}), 404
return jsonify(schedule.to_dict()), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['PUT'])
def set_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
mode = data.get('mode')
if mode not in ('days', 'interval'):
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
if mode == 'days':
day_configs = data.get('day_configs', [])
if not isinstance(day_configs, list):
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = ChoreSchedule(
child_id=child_id,
task_id=task_id,
mode='days',
day_configs=day_configs,
)
else:
interval_days = data.get('interval_days', 2)
anchor_weekday = data.get('anchor_weekday', 0)
interval_hour = data.get('interval_hour', 0)
interval_minute = data.get('interval_minute', 0)
if not isinstance(interval_days, int) or not (2 <= interval_days <= 7):
return jsonify({'error': 'interval_days must be an integer between 2 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
if not isinstance(anchor_weekday, int) or not (0 <= anchor_weekday <= 6):
return jsonify({'error': 'anchor_weekday must be an integer between 0 and 6', 'code': ErrorCodes.INVALID_VALUE}), 400
schedule = ChoreSchedule(
child_id=child_id,
task_id=task_id,
mode='interval',
interval_days=interval_days,
anchor_weekday=anchor_weekday,
interval_hour=interval_hour,
interval_minute=interval_minute,
)
upsert_schedule(schedule)
send_event_for_current_user(Event(
EventType.CHORE_SCHEDULE_MODIFIED.value,
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_SET)
))
return jsonify(schedule.to_dict()), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['DELETE'])
def delete_chore_schedule(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
removed = delete_schedule(child_id, task_id)
if not removed:
return jsonify({'error': 'Schedule not found'}), 404
send_event_for_current_user(Event(
EventType.CHORE_SCHEDULE_MODIFIED.value,
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_DELETED)
))
return jsonify({'message': 'Schedule deleted'}), 200
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/extend', methods=['POST'])
def extend_chore_time(child_id, task_id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
if not _validate_child(child_id, user_id):
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
data = request.get_json() or {}
date = data.get('date')
if not date or not isinstance(date, str):
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
# 409 if already extended for this date
existing = get_extension(child_id, task_id, date)
if existing:
return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
extension = TaskExtension(child_id=child_id, task_id=task_id, date=date)
add_extension(extension)
send_event_for_current_user(Event(
EventType.CHORE_TIME_EXTENDED.value,
ChoreTimeExtended(child_id, task_id)
))
return jsonify(extension.to_dict()), 200

View File

@@ -0,0 +1,39 @@
from db.db import chore_schedules_db
from models.chore_schedule import ChoreSchedule
from tinydb import Query
def get_schedule(child_id: str, task_id: str) -> ChoreSchedule | None:
q = Query()
result = chore_schedules_db.search((q.child_id == child_id) & (q.task_id == task_id))
if not result:
return None
return ChoreSchedule.from_dict(result[0])
def upsert_schedule(schedule: ChoreSchedule) -> None:
q = Query()
existing = chore_schedules_db.get((q.child_id == schedule.child_id) & (q.task_id == schedule.task_id))
if existing:
chore_schedules_db.update(schedule.to_dict(), (q.child_id == schedule.child_id) & (q.task_id == schedule.task_id))
else:
chore_schedules_db.insert(schedule.to_dict())
def delete_schedule(child_id: str, task_id: str) -> bool:
q = Query()
existing = chore_schedules_db.get((q.child_id == child_id) & (q.task_id == task_id))
if not existing:
return False
chore_schedules_db.remove((q.child_id == child_id) & (q.task_id == task_id))
return True
def delete_schedules_for_child(child_id: str) -> None:
q = Query()
chore_schedules_db.remove(q.child_id == child_id)
def delete_schedules_for_task(task_id: str) -> None:
q = Query()
chore_schedules_db.remove(q.task_id == task_id)

View File

@@ -75,6 +75,8 @@ pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
users_path = os.path.join(base_dir, 'users.json') users_path = os.path.join(base_dir, 'users.json')
tracking_events_path = os.path.join(base_dir, 'tracking_events.json') tracking_events_path = os.path.join(base_dir, 'tracking_events.json')
child_overrides_path = os.path.join(base_dir, 'child_overrides.json') child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
# Use separate TinyDB instances/files for each collection # Use separate TinyDB instances/files for each collection
_child_db = TinyDB(child_path, indent=2) _child_db = TinyDB(child_path, indent=2)
@@ -85,6 +87,8 @@ _pending_rewards_db = TinyDB(pending_reward_path, indent=2)
_users_db = TinyDB(users_path, indent=2) _users_db = TinyDB(users_path, indent=2)
_tracking_events_db = TinyDB(tracking_events_path, indent=2) _tracking_events_db = TinyDB(tracking_events_path, indent=2)
_child_overrides_db = TinyDB(child_overrides_path, indent=2) _child_overrides_db = TinyDB(child_overrides_path, indent=2)
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
_task_extensions_db = TinyDB(task_extensions_path, indent=2)
# Expose table objects wrapped with locking # Expose table objects wrapped with locking
child_db = LockedTable(_child_db) child_db = LockedTable(_child_db)
@@ -95,6 +99,8 @@ pending_reward_db = LockedTable(_pending_rewards_db)
users_db = LockedTable(_users_db) users_db = LockedTable(_users_db)
tracking_events_db = LockedTable(_tracking_events_db) tracking_events_db = LockedTable(_tracking_events_db)
child_overrides_db = LockedTable(_child_overrides_db) child_overrides_db = LockedTable(_child_overrides_db)
chore_schedules_db = LockedTable(_chore_schedules_db)
task_extensions_db = LockedTable(_task_extensions_db)
if os.environ.get('DB_ENV', 'prod') == 'test': if os.environ.get('DB_ENV', 'prod') == 'test':
child_db.truncate() child_db.truncate()
@@ -105,4 +111,6 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
users_db.truncate() users_db.truncate()
tracking_events_db.truncate() tracking_events_db.truncate()
child_overrides_db.truncate() child_overrides_db.truncate()
chore_schedules_db.truncate()
task_extensions_db.truncate()

View File

@@ -0,0 +1,27 @@
from db.db import task_extensions_db
from models.task_extension import TaskExtension
from tinydb import Query
def get_extension(child_id: str, task_id: str, date: str) -> TaskExtension | None:
q = Query()
result = task_extensions_db.search(
(q.child_id == child_id) & (q.task_id == task_id) & (q.date == date)
)
if not result:
return None
return TaskExtension.from_dict(result[0])
def add_extension(extension: TaskExtension) -> None:
task_extensions_db.insert(extension.to_dict())
def delete_extensions_for_child(child_id: str) -> None:
q = Query()
task_extensions_db.remove(q.child_id == child_id)
def delete_extensions_for_task(task_id: str) -> None:
q = Query()
task_extensions_db.remove(q.task_id == task_id)

View File

@@ -0,0 +1,25 @@
from events.types.payload import Payload
class ChoreScheduleModified(Payload):
OPERATION_SET = 'SET'
OPERATION_DELETED = 'DELETED'
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')

View File

@@ -0,0 +1,17 @@
from events.types.payload import Payload
class ChoreTimeExtended(Payload):
def __init__(self, child_id: str, task_id: str):
super().__init__({
'child_id': child_id,
'task_id': task_id,
})
@property
def child_id(self) -> str:
return self.get('child_id')
@property
def task_id(self) -> str:
return self.get('task_id')

View File

@@ -23,3 +23,6 @@ class EventType(Enum):
CHILD_OVERRIDE_DELETED = "child_override_deleted" CHILD_OVERRIDE_DELETED = "child_override_deleted"
PROFILE_UPDATED = "profile_updated" PROFILE_UPDATED = "profile_updated"
CHORE_SCHEDULE_MODIFIED = "chore_schedule_modified"
CHORE_TIME_EXTENDED = "chore_time_extended"

View File

@@ -9,6 +9,7 @@ from api.admin_api import admin_api
from api.auth_api import auth_api from api.auth_api import auth_api
from api.child_api import child_api from api.child_api import child_api
from api.child_override_api import child_override_api from api.child_override_api import child_override_api
from api.chore_schedule_api import chore_schedule_api
from api.image_api import image_api from api.image_api import image_api
from api.reward_api import reward_api from api.reward_api import reward_api
from api.task_api import task_api from api.task_api import task_api
@@ -37,6 +38,7 @@ app = Flask(__name__)
app.register_blueprint(admin_api) app.register_blueprint(admin_api)
app.register_blueprint(child_api) app.register_blueprint(child_api)
app.register_blueprint(child_override_api) app.register_blueprint(child_override_api)
app.register_blueprint(chore_schedule_api)
app.register_blueprint(reward_api) app.register_blueprint(reward_api)
app.register_blueprint(task_api) app.register_blueprint(task_api)
app.register_blueprint(image_api) app.register_blueprint(image_api)

View File

@@ -0,0 +1,71 @@
from dataclasses import dataclass, field
from typing import Literal
from models.base import BaseModel
@dataclass
class DayConfig:
day: int # 0=Sun, 1=Mon, ..., 6=Sat
hour: int # 023 (24h)
minute: int # 0, 15, 30, or 45
def to_dict(self) -> dict:
return {
'day': self.day,
'hour': self.hour,
'minute': self.minute,
}
@classmethod
def from_dict(cls, d: dict) -> 'DayConfig':
return cls(
day=d.get('day', 0),
hour=d.get('hour', 0),
minute=d.get('minute', 0),
)
@dataclass
class ChoreSchedule(BaseModel):
child_id: str
task_id: str
mode: Literal['days', 'interval']
# mode='days' fields
day_configs: list = field(default_factory=list) # list of DayConfig dicts
# mode='interval' fields
interval_days: int = 2 # 27
anchor_weekday: int = 0 # 0=Sun6=Sat
interval_hour: int = 0
interval_minute: int = 0
@classmethod
def from_dict(cls, d: dict) -> 'ChoreSchedule':
return cls(
child_id=d.get('child_id'),
task_id=d.get('task_id'),
mode=d.get('mode', 'days'),
day_configs=d.get('day_configs', []),
interval_days=d.get('interval_days', 2),
anchor_weekday=d.get('anchor_weekday', 0),
interval_hour=d.get('interval_hour', 0),
interval_minute=d.get('interval_minute', 0),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'child_id': self.child_id,
'task_id': self.task_id,
'mode': self.mode,
'day_configs': self.day_configs,
'interval_days': self.interval_days,
'anchor_weekday': self.anchor_weekday,
'interval_hour': self.interval_hour,
'interval_minute': self.interval_minute,
})
return base

View File

@@ -0,0 +1,29 @@
from dataclasses import dataclass
from models.base import BaseModel
@dataclass
class TaskExtension(BaseModel):
child_id: str
task_id: str
date: str # ISO date string supplied by client, e.g. '2026-02-22'
@classmethod
def from_dict(cls, d: dict) -> 'TaskExtension':
return cls(
child_id=d.get('child_id'),
task_id=d.get('task_id'),
date=d.get('date'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at'),
)
def to_dict(self) -> dict:
base = super().to_dict()
base.update({
'child_id': self.child_id,
'task_id': self.task_id,
'date': self.date,
})
return base

View File

@@ -4,11 +4,12 @@ import os
from flask import Flask from flask import Flask
from api.child_api import child_api from api.child_api import child_api
from api.auth_api import auth_api from api.auth_api import auth_api
from db.db import child_db, reward_db, task_db, users_db from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db
from tinydb import Query from tinydb import Query
from models.child import Child from models.child import Child
import jwt import jwt
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from datetime import date as date_type
# Test user credentials # Test user credentials
@@ -348,4 +349,142 @@ def test_assignable_rewards_multiple_user_same_name(client):
ids = [r['id'] for r in data['rewards']] ids = [r['id'] for r in data['rewards']]
# Both user rewards should be present, not the system one # Both user rewards should be present, not the system one
assert set(names) == {'Prize'} assert set(names) == {'Prize'}
assert set(ids) == {'userr1', 'userr2'} assert set(ids) == {'userr1', 'userr2'}
# ---------------------------------------------------------------------------
# list-tasks: schedule and extension_date fields
# ---------------------------------------------------------------------------
CHILD_SCHED_ID = 'child_sched_test'
TASK_GOOD_ID = 'task_sched_good'
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'})
child_db.remove(Query().id == CHILD_SCHED_ID)
child_db.insert({
'id': CHILD_SCHED_ID,
'name': 'SchedKid',
'age': 7,
'points': 0,
'tasks': [TASK_GOOD_ID, TASK_BAD_ID],
'rewards': [],
'user_id': 'testuserid',
})
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID)
def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client):
"""Every task in the response must have 'schedule' and 'extension_date' keys."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
assert resp.status_code == 200
for task in resp.get_json()['tasks']:
assert 'schedule' in task
assert 'extension_date' in task
def test_list_child_tasks_returns_schedule_when_set(client):
"""Good chore with a saved schedule returns that schedule object."""
_setup_sched_child_and_tasks(task_db, child_db)
chore_schedules_db.insert({
'id': 'sched-1',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_GOOD_ID,
'mode': 'days',
'day_configs': [{'day': 1, 'hour': 8, 'minute': 0}],
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
sched = tasks[TASK_GOOD_ID]['schedule']
assert sched is not None
assert sched['mode'] == 'days'
assert sched['day_configs'] == [{'day': 1, 'hour': 8, 'minute': 0}]
def test_list_child_tasks_schedule_null_when_not_set(client):
"""Good chore with no schedule returns schedule=null."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['schedule'] is None
def test_list_child_tasks_returns_extension_date_when_set(client):
"""Good chore with a TaskExtension for today returns today's ISO date."""
_setup_sched_child_and_tasks(task_db, child_db)
today = date_type.today().isoformat()
task_extensions_db.insert({
'id': 'ext-1',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_GOOD_ID,
'date': today,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['extension_date'] == today
def test_list_child_tasks_extension_date_null_when_not_set(client):
"""Good chore with no extension returns extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db)
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_GOOD_ID]['extension_date'] is None
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."""
_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({
'id': 'sched-bad',
'child_id': CHILD_SCHED_ID,
'task_id': TASK_BAD_ID,
'mode': 'days',
'day_configs': [{'day': 0, 'hour': 9, 'minute': 0}],
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
tasks = {t['id']: t for t in resp.get_json()['tasks']}
assert tasks[TASK_BAD_ID]['schedule'] is None
assert tasks[TASK_BAD_ID]['extension_date'] is None
def test_list_child_tasks_no_server_side_filtering(client):
"""All assigned tasks are returned regardless of schedule — no server-side day/time filtering."""
_setup_sched_child_and_tasks(task_db, child_db)
# 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'})
child_db.update({'tasks': [TASK_GOOD_ID, TASK_BAD_ID, extra_id]}, Query().id == CHILD_SCHED_ID)
chore_schedules_db.insert({
'id': 'sched-extra',
'child_id': CHILD_SCHED_ID,
'task_id': extra_id,
'mode': 'days',
'day_configs': [{'day': 0, 'hour': 7, 'minute': 0}], # Sunday only
'interval_days': 2,
'anchor_weekday': 0,
'interval_hour': 0,
'interval_minute': 0,
})
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
# Both good tasks must be present; server never filters based on schedule/time
assert TASK_GOOD_ID in returned_ids
assert extra_id in returned_ids

View File

@@ -0,0 +1,243 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.chore_schedule_api import chore_schedule_api
from api.auth_api import auth_api
from db.db import users_db, child_db, chore_schedules_db, task_extensions_db
from tinydb import Query
TEST_EMAIL = "sched_test@example.com"
TEST_PASSWORD = "testpass"
TEST_CHILD_ID = "sched-child-1"
TEST_TASK_ID = "sched-task-1"
TEST_USER_ID = "sched-user-1"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Sched",
"last_name": "Tester",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "",
})
def add_test_child():
child_db.remove(Query().id == TEST_CHILD_ID)
child_db.insert({
"id": TEST_CHILD_ID,
"user_id": TEST_USER_ID,
"name": "Test Child",
"points": 0,
"image_id": "",
"tasks": [TEST_TASK_ID],
"rewards": [],
})
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_schedule_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()
add_test_child()
chore_schedules_db.truncate()
task_extensions_db.truncate()
login_and_set_cookie(client)
yield client
# ---------------------------------------------------------------------------
# GET schedule
# ---------------------------------------------------------------------------
def test_get_schedule_not_found(client):
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 404
def test_get_schedule_returns_404_for_unknown_child(client):
resp = client.get('/child/bad-child/task/bad-task/schedule')
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# PUT (set) schedule days mode
# ---------------------------------------------------------------------------
def test_set_schedule_days_mode(client):
payload = {
"mode": "days",
"day_configs": [
{"day": 1, "hour": 8, "minute": 0},
{"day": 3, "hour": 9, "minute": 30},
],
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "days"
assert len(data["day_configs"]) == 2
assert data["child_id"] == TEST_CHILD_ID
assert data["task_id"] == TEST_TASK_ID
def test_get_schedule_after_set(client):
payload = {"mode": "days", "day_configs": [{"day": 0, "hour": 7, "minute": 0}]}
client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "days"
assert data["day_configs"][0]["day"] == 0
# ---------------------------------------------------------------------------
# PUT (set) schedule interval mode
# ---------------------------------------------------------------------------
def test_set_schedule_interval_mode(client):
payload = {
"mode": "interval",
"interval_days": 3,
"anchor_weekday": 2,
"interval_hour": 14,
"interval_minute": 30,
}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 200
data = resp.get_json()
assert data["mode"] == "interval"
assert data["interval_days"] == 3
assert data["anchor_weekday"] == 2
assert data["interval_hour"] == 14
assert data["interval_minute"] == 30
def test_set_schedule_interval_bad_days(client):
# interval_days = 1 is out of range [27]
payload = {"mode": "interval", "interval_days": 1, "anchor_weekday": 0}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_interval_bad_weekday(client):
# anchor_weekday = 7 is out of range [06]
payload = {"mode": "interval", "interval_days": 2, "anchor_weekday": 7}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_invalid_mode(client):
payload = {"mode": "weekly", "day_configs": []}
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
assert resp.status_code == 400
def test_set_schedule_upserts_existing(client):
# Set once with days mode
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "days", "day_configs": [{"day": 1, "hour": 8, "minute": 0}]},
)
# Overwrite with interval mode
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "interval", "interval_days": 2, "anchor_weekday": 0, "interval_hour": 9, "interval_minute": 0},
)
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
assert resp.get_json()["mode"] == "interval"
# ---------------------------------------------------------------------------
# DELETE schedule
# ---------------------------------------------------------------------------
def test_delete_schedule(client):
client.put(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule',
json={"mode": "days", "day_configs": []},
)
resp = client.delete(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 200
# Verify gone
resp2 = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp2.status_code == 404
def test_delete_schedule_not_found(client):
chore_schedules_db.truncate()
resp = client.delete(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# POST extend
# ---------------------------------------------------------------------------
def test_extend_chore_time(client):
resp = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
assert resp.status_code == 200
data = resp.get_json()
assert data["child_id"] == TEST_CHILD_ID
assert data["task_id"] == TEST_TASK_ID
assert data["date"] == "2025-01-15"
def test_extend_chore_time_duplicate_returns_409(client):
task_extensions_db.truncate()
client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
resp = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
assert resp.status_code == 409
def test_extend_chore_time_different_dates_allowed(client):
task_extensions_db.truncate()
r1 = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-15"},
)
r2 = client.post(
f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend',
json={"date": "2025-01-16"},
)
assert r1.status_code == 200
assert r2.status_code == 200
def test_extend_chore_time_missing_date(client):
resp = client.post(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/extend', json={})
assert resp.status_code == 400
def test_extend_chore_time_bad_child(client):
resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"})
assert resp.status_code == 404

View File

@@ -0,0 +1,283 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ScheduleModal from '../components/shared/ScheduleModal.vue'
import type { ChildTask, ChoreSchedule } from '../common/models'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockSetChoreSchedule = vi.fn()
vi.mock('@/common/api', () => ({
setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args),
parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }),
}))
// Stubs that render their default slot so form content is accessible
const ModalDialogStub = {
template: '<div><slot /></div>',
props: ['imageUrl', 'title', 'subtitle'],
}
const TimeSelectorStub = {
template: '<div class="time-selector-stub" />',
props: ['modelValue'],
emits: ['update:modelValue'],
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TASK: ChildTask = { id: 'task-1', name: 'Clean Room', is_good: true, points: 5, image_id: '' }
const CHILD_ID = 'child-1'
function mountModal(schedule: ChoreSchedule | null = null) {
return mount(ScheduleModal, {
props: { task: TASK, childId: CHILD_ID, schedule },
global: {
stubs: { ModalDialog: ModalDialogStub, TimeSelector: TimeSelectorStub },
},
})
}
beforeEach(() => {
mockSetChoreSchedule.mockReset()
mockSetChoreSchedule.mockResolvedValue({ ok: true })
})
// ---------------------------------------------------------------------------
// Mode toggle
// ---------------------------------------------------------------------------
describe('ScheduleModal mode toggle', () => {
it('defaults to Specific Days mode', () => {
const w = mountModal()
expect(w.find('.days-form').exists()).toBe(true)
expect(w.find('.interval-form').exists()).toBe(false)
})
it('switches to Every X Days on button click', async () => {
const w = mountModal()
const modeBtns = w.findAll('.mode-btn')
await modeBtns[1].trigger('click') // "Every X Days"
expect(w.find('.interval-form').exists()).toBe(true)
expect(w.find('.days-form').exists()).toBe(false)
})
it('switches back to Specific Days', async () => {
const w = mountModal()
const modeBtns = w.findAll('.mode-btn')
await modeBtns[1].trigger('click') // switch to interval
await modeBtns[0].trigger('click') // switch back
expect(w.find('.days-form').exists()).toBe(true)
expect(w.find('.interval-form').exists()).toBe(false)
})
it('marks "Specific Days" button active by default', () => {
const w = mountModal()
const modeBtns = w.findAll('.mode-btn')
expect(modeBtns[0].classes()).toContain('active')
expect(modeBtns[1].classes()).not.toContain('active')
})
it('marks "Every X Days" button active after switching', async () => {
const w = mountModal()
const modeBtns = w.findAll('.mode-btn')
await modeBtns[1].trigger('click')
expect(modeBtns[1].classes()).toContain('active')
expect(modeBtns[0].classes()).not.toContain('active')
})
})
// ---------------------------------------------------------------------------
// Specific Days form — check/uncheck days
// ---------------------------------------------------------------------------
describe('ScheduleModal Specific Days form', () => {
it('renders 7 day rows', () => {
const w = mountModal()
expect(w.findAll('.day-row').length).toBe(7)
})
it('no days are checked by default (no existing schedule)', () => {
const w = mountModal()
const checkboxes = w.findAll<HTMLInputElement>('input[type="checkbox"]')
expect(checkboxes.every((cb) => !(cb.element as HTMLInputElement).checked)).toBe(true)
})
it('checking a day reveals a TimeSelector for that day', async () => {
const w = mountModal()
expect(w.findAll('.time-selector-stub').length).toBe(0)
const checkboxes = w.findAll('input[type="checkbox"]')
await checkboxes[1].trigger('change') // Monday (idx 1)
await nextTick()
expect(w.findAll('.time-selector-stub').length).toBe(1)
})
it('unchecking a day removes its TimeSelector', async () => {
const w = mountModal()
const checkboxes = w.findAll('input[type="checkbox"]')
await checkboxes[0].trigger('change') // check Sunday
await nextTick()
expect(w.findAll('.time-selector-stub').length).toBe(1)
await checkboxes[0].trigger('change') // uncheck Sunday
await nextTick()
expect(w.findAll('.time-selector-stub').length).toBe(0)
})
it('Save is disabled when no days checked (days mode)', () => {
const w = mountModal()
const saveBtn = w.find('.btn-primary')
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(true)
})
it('Save is enabled after checking at least one day', async () => {
const w = mountModal()
const checkboxes = w.findAll('input[type="checkbox"]')
await checkboxes[2].trigger('change') // Tuesday
await nextTick()
const saveBtn = w.find('.btn-primary')
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(false)
})
it('pre-populates checked days from an existing schedule', () => {
const existing: ChoreSchedule = {
child_id: CHILD_ID,
task_id: TASK.id,
mode: 'days',
day_configs: [
{ day: 1, hour: 8, minute: 0 },
{ day: 4, hour: 9, minute: 30 },
],
interval_days: 2,
anchor_weekday: 0,
interval_hour: 0,
interval_minute: 0,
}
const w = mountModal(existing)
// Two TimeSelectorStubs should already be visible
expect(w.findAll('.time-selector-stub').length).toBe(2)
})
})
// ---------------------------------------------------------------------------
// Save emits correct ChoreSchedule shape — days mode
// ---------------------------------------------------------------------------
describe('ScheduleModal save — days mode', () => {
it('calls setChoreSchedule with correct days payload', async () => {
const w = mountModal()
// Check Monday (idx 1)
const checkboxes = w.findAll('input[type="checkbox"]')
await checkboxes[1].trigger('change')
await nextTick()
await w.find('.btn-primary').trigger('click')
await nextTick()
expect(mockSetChoreSchedule).toHaveBeenCalledWith(
CHILD_ID,
TASK.id,
expect.objectContaining({
mode: 'days',
day_configs: [{ day: 1, hour: 8, minute: 0 }],
}),
)
})
it('emits "saved" after successful save', async () => {
const w = mountModal()
const checkboxes = w.findAll('input[type="checkbox"]')
await checkboxes[0].trigger('change') // check Sunday
await nextTick()
await w.find('.btn-primary').trigger('click')
await nextTick()
await nextTick() // await the async save()
expect(w.emitted('saved')).toBeTruthy()
})
it('does not emit "saved" on API error', async () => {
mockSetChoreSchedule.mockResolvedValue({ ok: false })
const w = mountModal()
const checkboxes = w.findAll('input[type="checkbox"]')
await checkboxes[0].trigger('change')
await nextTick()
await w.find('.btn-primary').trigger('click')
await nextTick()
await nextTick()
expect(w.emitted('saved')).toBeFalsy()
})
})
// ---------------------------------------------------------------------------
// Save emits correct ChoreSchedule shape — interval mode
// ---------------------------------------------------------------------------
describe('ScheduleModal save — interval mode', () => {
it('calls setChoreSchedule with correct interval payload', async () => {
const w = mountModal()
// Switch to interval mode
await w.findAll('.mode-btn')[1].trigger('click')
// Set interval_days input to 3
const intervalInput = w.find<HTMLInputElement>('.interval-input')
await intervalInput.setValue(3)
// Set anchor_weekday to 2 (Tuesday)
const anchorSelect = w.find<HTMLSelectElement>('.anchor-select')
await anchorSelect.setValue(2)
await w.find('.btn-primary').trigger('click')
await nextTick()
expect(mockSetChoreSchedule).toHaveBeenCalledWith(
CHILD_ID,
TASK.id,
expect.objectContaining({
mode: 'interval',
interval_days: 3,
anchor_weekday: 2,
interval_hour: expect.any(Number),
interval_minute: expect.any(Number),
}),
)
})
it('Save enabled by default in interval mode (interval_days defaults to 2)', async () => {
const w = mountModal()
await w.findAll('.mode-btn')[1].trigger('click')
const saveBtn = w.find('.btn-primary')
expect((saveBtn.element as HTMLButtonElement).disabled).toBe(false)
})
it('pre-populates interval fields from existing interval schedule', () => {
const existing: ChoreSchedule = {
child_id: CHILD_ID,
task_id: TASK.id,
mode: 'interval',
day_configs: [],
interval_days: 4,
anchor_weekday: 3,
interval_hour: 14,
interval_minute: 30,
}
const w = mountModal(existing)
// Should default to interval mode
expect(w.find('.interval-form').exists()).toBe(true)
const input = w.find<HTMLInputElement>('.interval-input')
expect(Number((input.element as HTMLInputElement).value)).toBe(4)
const select = w.find<HTMLSelectElement>('.anchor-select')
expect(Number((select.element as HTMLSelectElement).value)).toBe(3)
})
})
// ---------------------------------------------------------------------------
// Cancel
// ---------------------------------------------------------------------------
describe('ScheduleModal cancel', () => {
it('emits "cancelled" when Cancel is clicked', async () => {
const w = mountModal()
await w.find('.btn-secondary').trigger('click')
expect(w.emitted('cancelled')).toBeTruthy()
})
})

View File

@@ -0,0 +1,154 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TimeSelector from '../components/shared/TimeSelector.vue'
function mk(hour: number, minute: number) {
return mount(TimeSelector, { props: { modelValue: { hour, minute } } })
}
// ---------------------------------------------------------------------------
// Display
// ---------------------------------------------------------------------------
describe('TimeSelector display', () => {
it('shows 12 for midnight (hour=0)', () => {
const w = mk(0, 0)
expect(w.findAll('.time-value')[0].text()).toBe('12')
expect(w.find('.ampm-btn').text()).toBe('AM')
})
it('shows 12 for noon (hour=12)', () => {
const w = mk(12, 0)
expect(w.findAll('.time-value')[0].text()).toBe('12')
expect(w.find('.ampm-btn').text()).toBe('PM')
})
it('shows 3:30 PM for hour=15 minute=30', () => {
const w = mk(15, 30)
const cols = w.findAll('.time-value')
expect(cols[0].text()).toBe('3')
expect(cols[1].text()).toBe('30')
expect(w.find('.ampm-btn').text()).toBe('PM')
})
it('pads single-digit minutes', () => {
const w = mk(8, 0)
expect(w.findAll('.time-value')[1].text()).toBe('00')
})
})
// ---------------------------------------------------------------------------
// Increment / Decrement hour
// ---------------------------------------------------------------------------
describe('TimeSelector hour increment/decrement', () => {
it('emits hour+1 when increment hour clicked', async () => {
const w = mk(8, 15)
await w.findAll('.arrow-btn')[0].trigger('click')
const emitted = w.emitted('update:modelValue') as any[]
expect(emitted[0][0]).toEqual({ hour: 9, minute: 15 })
})
it('emits hour-1 when decrement hour clicked', async () => {
const w = mk(8, 15)
await w.findAll('.arrow-btn')[1].trigger('click')
const emitted = w.emitted('update:modelValue') as any[]
expect(emitted[0][0]).toEqual({ hour: 7, minute: 15 })
})
it('wraps hour 23 → 0 on increment', async () => {
const w = mk(23, 0)
await w.findAll('.arrow-btn')[0].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
})
it('wraps hour 0 → 23 on decrement', async () => {
const w = mk(0, 0)
await w.findAll('.arrow-btn')[1].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 23, minute: 0 })
})
})
// ---------------------------------------------------------------------------
// Increment / Decrement minute (15-min steps)
// ---------------------------------------------------------------------------
describe('TimeSelector minute increment/decrement', () => {
it('emits minute+15 on increment', async () => {
const w = mk(8, 0)
await w.findAll('.arrow-btn')[2].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 8, minute: 15 })
})
it('emits minute-15 on decrement', async () => {
const w = mk(8, 30)
await w.findAll('.arrow-btn')[3].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 8, minute: 15 })
})
it('minute 45 → 0 and carry hour on increment', async () => {
const w = mk(8, 45)
await w.findAll('.arrow-btn')[2].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 9, minute: 0 })
})
it('minute 0 → 45 and borrow hour on decrement', async () => {
const w = mk(8, 0)
await w.findAll('.arrow-btn')[3].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 7, minute: 45 })
})
// The key boundary case from the spec: 11:45 AM → 12:00 PM
it('11:45 AM (hour=11, minute=45) → increment minute → 12:00 PM (hour=12)', async () => {
const w = mk(11, 45)
await w.findAll('.arrow-btn')[2].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 12, minute: 0 })
})
// Inverse: 12:00 PM (hour=12) → decrement minute → 11:45 AM (hour=11, minute=45)
it('12:00 PM (hour=12, minute=0) → decrement minute → 11:45 (hour=11)', async () => {
const w = mk(12, 0)
await w.findAll('.arrow-btn')[3].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 11, minute: 45 })
})
// Midnight boundary: 0:00 → decrement → 23:45
it('0:00 AM (hour=0, minute=0) → decrement minute → 23:45', async () => {
const w = mk(0, 0)
await w.findAll('.arrow-btn')[3].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 23, minute: 45 })
})
// 23:45 → increment → 0:00
it('23:45 → increment minute → 0:00', async () => {
const w = mk(23, 45)
await w.findAll('.arrow-btn')[2].trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
})
})
// ---------------------------------------------------------------------------
// AM/PM toggle
// ---------------------------------------------------------------------------
describe('TimeSelector AM/PM toggle', () => {
it('toggles AM → PM (hour < 12 adds 12)', async () => {
const w = mk(8, 0)
await w.find('.ampm-btn').trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 20, minute: 0 })
})
it('toggles PM → AM (hour >= 12 subtracts 12)', async () => {
const w = mk(14, 30)
await w.find('.ampm-btn').trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 2, minute: 30 })
})
it('toggles midnight (0) → 12 (noon)', async () => {
const w = mk(0, 0)
await w.find('.ampm-btn').trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 12, minute: 0 })
})
it('toggles noon (12) → 0 (midnight)', async () => {
const w = mk(12, 0)
await w.find('.ampm-btn').trigger('click')
expect(w.emitted('update:modelValue')![0][0]).toEqual({ hour: 0, minute: 0 })
})
})

View File

@@ -0,0 +1,254 @@
import { describe, it, expect } from 'vitest'
import {
intervalHitsToday,
isScheduledToday,
getDueTimeToday,
isPastTime,
isExtendedToday,
formatDueTimeLabel,
msUntilExpiry,
toLocalISODate,
} from '../common/scheduleUtils'
import type { ChoreSchedule } from '../common/models'
// ---------------------------------------------------------------------------
// toLocalISODate
// ---------------------------------------------------------------------------
describe('toLocalISODate', () => {
it('formats a date as YYYY-MM-DD', () => {
const d = new Date(2025, 0, 5) // Jan 5, 2025 local
expect(toLocalISODate(d)).toBe('2025-01-05')
})
it('pads month and day', () => {
const d = new Date(2025, 2, 9) // Mar 9
expect(toLocalISODate(d)).toBe('2025-03-09')
})
})
// ---------------------------------------------------------------------------
// intervalHitsToday
// ---------------------------------------------------------------------------
describe('intervalHitsToday', () => {
it('anchor day hits on itself', () => {
// Wednesday = weekday 3
const wednesday = new Date(2025, 0, 8) // Jan 8, 2025 is a Wednesday
expect(intervalHitsToday(3, 2, wednesday)).toBe(true)
})
it('anchor=Wednesday, interval=2, Thursday does NOT hit', () => {
const thursday = new Date(2025, 0, 9)
expect(intervalHitsToday(3, 2, thursday)).toBe(false)
})
it('anchor=Wednesday, interval=2, Friday hits (2 days after anchor)', () => {
const friday = new Date(2025, 0, 10)
expect(intervalHitsToday(3, 2, friday)).toBe(true)
})
it('interval=1 hits every day', () => {
// interval of 1 means every day; diffDays % 1 === 0 always
// Note: interval_days=1 is disallowed in UI (min 2) but logic should still work
const monday = new Date(2025, 0, 6)
expect(intervalHitsToday(1, 1, monday)).toBe(true)
})
})
// ---------------------------------------------------------------------------
// isScheduledToday
// ---------------------------------------------------------------------------
describe('isScheduledToday', () => {
const daysSchedule: ChoreSchedule = {
child_id: 'c1',
task_id: 't1',
mode: 'days',
day_configs: [
{ day: 1, hour: 8, minute: 0 }, // Monday
{ day: 3, hour: 9, minute: 30 }, // Wednesday
],
interval_days: 2,
anchor_weekday: 0,
interval_hour: 0,
interval_minute: 0,
}
it('returns true on a scheduled weekday', () => {
const monday = new Date(2025, 0, 6) // Jan 6, 2025 = Monday
expect(isScheduledToday(daysSchedule, monday)).toBe(true)
})
it('returns false on an unscheduled weekday', () => {
const tuesday = new Date(2025, 0, 7) // Tuesday
expect(isScheduledToday(daysSchedule, tuesday)).toBe(false)
})
it('interval mode delegates to intervalHitsToday', () => {
const intervalSchedule: ChoreSchedule = {
child_id: 'c1',
task_id: 't1',
mode: 'interval',
day_configs: [],
interval_days: 2,
anchor_weekday: 3, // Wednesday
interval_hour: 8,
interval_minute: 0,
}
const wednesday = new Date(2025, 0, 8)
expect(isScheduledToday(intervalSchedule, wednesday)).toBe(true)
const thursday = new Date(2025, 0, 9)
expect(isScheduledToday(intervalSchedule, thursday)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// getDueTimeToday
// ---------------------------------------------------------------------------
describe('getDueTimeToday', () => {
const daysSchedule: ChoreSchedule = {
child_id: 'c1',
task_id: 't1',
mode: 'days',
day_configs: [{ day: 1, hour: 8, minute: 30 }],
interval_days: 2,
anchor_weekday: 0,
interval_hour: 0,
interval_minute: 0,
}
it('returns correct time for a scheduled day', () => {
const monday = new Date(2025, 0, 6)
expect(getDueTimeToday(daysSchedule, monday)).toEqual({ hour: 8, minute: 30 })
})
it('returns null for an unscheduled day', () => {
const tuesday = new Date(2025, 0, 7)
expect(getDueTimeToday(daysSchedule, tuesday)).toBeNull()
})
it('interval mode returns interval time on hit days', () => {
const intervalSchedule: ChoreSchedule = {
child_id: 'c1',
task_id: 't1',
mode: 'interval',
day_configs: [],
interval_days: 2,
anchor_weekday: 3,
interval_hour: 14,
interval_minute: 45,
}
const wednesday = new Date(2025, 0, 8)
expect(getDueTimeToday(intervalSchedule, wednesday)).toEqual({ hour: 14, minute: 45 })
})
it('interval mode returns null on non-hit days', () => {
const intervalSchedule: ChoreSchedule = {
child_id: 'c1',
task_id: 't1',
mode: 'interval',
day_configs: [],
interval_days: 2,
anchor_weekday: 3,
interval_hour: 14,
interval_minute: 45,
}
const thursday = new Date(2025, 0, 9)
expect(getDueTimeToday(intervalSchedule, thursday)).toBeNull()
})
})
// ---------------------------------------------------------------------------
// isPastTime
// ---------------------------------------------------------------------------
describe('isPastTime', () => {
it('returns true when current time equals due time', () => {
const now = new Date(2025, 0, 6, 8, 30, 0)
expect(isPastTime(8, 30, now)).toBe(true)
})
it('returns true when current time is past due time', () => {
const now = new Date(2025, 0, 6, 9, 0, 0)
expect(isPastTime(8, 30, now)).toBe(true)
})
it('returns false when current time is before due time', () => {
const now = new Date(2025, 0, 6, 8, 0, 0)
expect(isPastTime(8, 30, now)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// isExtendedToday
// ---------------------------------------------------------------------------
describe('isExtendedToday', () => {
it('returns true when extension date matches today', () => {
const today = new Date(2025, 0, 15)
expect(isExtendedToday('2025-01-15', today)).toBe(true)
})
it('returns false when extension date does not match today', () => {
const today = new Date(2025, 0, 15)
expect(isExtendedToday('2025-01-14', today)).toBe(false)
})
it('returns false for null extension date', () => {
const today = new Date(2025, 0, 15)
expect(isExtendedToday(null, today)).toBe(false)
})
it('returns false for undefined extension date', () => {
const today = new Date(2025, 0, 15)
expect(isExtendedToday(undefined, today)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// formatDueTimeLabel
// ---------------------------------------------------------------------------
describe('formatDueTimeLabel', () => {
it('formats midnight as 12:00 AM', () => {
expect(formatDueTimeLabel(0, 0)).toBe('12:00 AM')
})
it('formats noon as 12:00 PM', () => {
expect(formatDueTimeLabel(12, 0)).toBe('12:00 PM')
})
it('formats 8:30 AM', () => {
expect(formatDueTimeLabel(8, 30)).toBe('8:30 AM')
})
it('formats 14:45 as 2:45 PM', () => {
expect(formatDueTimeLabel(14, 45)).toBe('2:45 PM')
})
it('pads single-digit minutes', () => {
expect(formatDueTimeLabel(9, 5)).toBe('9:05 AM')
})
})
// ---------------------------------------------------------------------------
// msUntilExpiry
// ---------------------------------------------------------------------------
describe('msUntilExpiry', () => {
it('returns positive ms when due time is in the future', () => {
const now = new Date(2025, 0, 6, 8, 0, 0, 0)
const ms = msUntilExpiry(8, 30, now)
expect(ms).toBe(30 * 60 * 1000) // 30 minutes
})
it('returns 0 when due time is in the past', () => {
const now = new Date(2025, 0, 6, 9, 0, 0, 0)
expect(msUntilExpiry(8, 30, now)).toBe(0)
})
it('returns 0 when due time equals current time', () => {
const now = new Date(2025, 0, 6, 8, 30, 0, 0)
expect(msUntilExpiry(8, 30, now)).toBe(0)
})
it('correctly handles seconds and milliseconds offset', () => {
// 8:29:30.500 → due at 8:30:00.000 = 29500ms remaining
const now = new Date(2025, 0, 6, 8, 29, 30, 500)
expect(msUntilExpiry(8, 30, now)).toBe(29500)
})
})

View File

@@ -111,3 +111,52 @@ export async function deleteChildOverride(childId: string, entityId: string): Pr
method: 'DELETE', method: 'DELETE',
}) })
} }
// ── Chore Schedule API ──────────────────────────────────────────────────────
/**
* Get the schedule for a specific child + task pair.
*/
export async function getChoreSchedule(childId: string, taskId: string): Promise<Response> {
return fetch(`/api/child/${childId}/task/${taskId}/schedule`)
}
/**
* Create or replace the schedule for a specific child + task pair.
*/
export async function setChoreSchedule(
childId: string,
taskId: string,
schedule: object,
): Promise<Response> {
return fetch(`/api/child/${childId}/task/${taskId}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(schedule),
})
}
/**
* Delete the schedule for a specific child + task pair.
*/
export async function deleteChoreSchedule(childId: string, taskId: string): Promise<Response> {
return fetch(`/api/child/${childId}/task/${taskId}/schedule`, {
method: 'DELETE',
})
}
/**
* Extend a timed-out chore for the remainder of today only.
* `localDate` is the client's local ISO date (e.g. '2026-02-22').
*/
export async function extendChoreTime(
childId: string,
taskId: string,
localDate: string,
): Promise<Response> {
return fetch(`/api/child/${childId}/task/${taskId}/extend`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: localDate }),
})
}

View File

@@ -8,6 +8,40 @@ export interface Task {
} }
export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const
export interface DayConfig {
day: number // 0=Sun, 1=Mon, ..., 6=Sat
hour: number // 023 (24h)
minute: number // 0, 15, 30, or 45
}
export interface ChoreSchedule {
id: string
child_id: string
task_id: string
mode: 'days' | 'interval'
// mode='days'
day_configs: DayConfig[]
// mode='interval'
interval_days: number // 27
anchor_weekday: number // 0=Sun6=Sat
interval_hour: number
interval_minute: number
created_at: number
updated_at: number
}
export interface ChildTask {
id: string
name: string
is_good: boolean
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
}
export interface User { export interface User {
id: string id: string
first_name: string first_name: string
@@ -97,6 +131,8 @@ export interface Event {
| TrackingEventCreatedPayload | TrackingEventCreatedPayload
| ChildOverrideSetPayload | ChildOverrideSetPayload
| ChildOverrideDeletedPayload | ChildOverrideDeletedPayload
| ChoreScheduleModifiedPayload
| ChoreTimeExtendedPayload
} }
export interface ChildModifiedEventPayload { export interface ChildModifiedEventPayload {
@@ -213,3 +249,14 @@ export const CHILD_OVERRIDE_FIELDS = [
'created_at', 'created_at',
'updated_at', 'updated_at',
] as const ] as const
export interface ChoreScheduleModifiedPayload {
child_id: string
task_id: string
operation: 'SET' | 'DELETED'
}
export interface ChoreTimeExtendedPayload {
child_id: string
task_id: string
}

View File

@@ -0,0 +1,123 @@
import type { ChoreSchedule, DayConfig } from './models'
/**
* Returns the JS day-of-week index for a given Date.
* 0 = Sunday, 6 = Saturday (matches Python / our DayConfig.day convention).
*/
function getLocalWeekday(d: Date): number {
return d.getDay()
}
/**
* Returns true if the interval schedule hits on the given localDate.
*
* Anchor: the most recent occurrence of `anchorWeekday` on or before today (this week).
* Pattern: every `intervalDays` days starting from that anchor.
*/
export function intervalHitsToday(
anchorWeekday: number,
intervalDays: number,
localDate: Date,
): boolean {
const todayWeekday = getLocalWeekday(localDate)
// Find the most recent anchorWeekday on or before today within the current week.
// We calculate the anchor as: (today - daysSinceAnchor)
const daysSinceAnchor = (todayWeekday - anchorWeekday + 7) % 7
const anchor = new Date(localDate)
anchor.setDate(anchor.getDate() - daysSinceAnchor)
anchor.setHours(0, 0, 0, 0)
const today = new Date(localDate)
today.setHours(0, 0, 0, 0)
const diffDays = Math.round((today.getTime() - anchor.getTime()) / 86400000)
return diffDays % intervalDays === 0
}
/**
* Returns true if the schedule applies today (localDate).
* - 'days' mode: today's weekday is in day_configs
* - 'interval' mode: intervalHitsToday
*/
export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean {
if (schedule.mode === 'days') {
const todayWeekday = getLocalWeekday(localDate)
return schedule.day_configs.some((dc: DayConfig) => dc.day === todayWeekday)
} else {
return intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)
}
}
/**
* Returns the due time {hour, minute} for today, or null if no due time is configured
* for today (e.g. the day is not scheduled).
*/
export function getDueTimeToday(
schedule: ChoreSchedule,
localDate: Date,
): { hour: number; minute: number } | null {
if (schedule.mode === 'days') {
const todayWeekday = getLocalWeekday(localDate)
const dayConfig = schedule.day_configs.find((dc: DayConfig) => dc.day === todayWeekday)
if (!dayConfig) return null
return { hour: dayConfig.hour, minute: dayConfig.minute }
} else {
if (!intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)) return null
return { hour: schedule.interval_hour, minute: schedule.interval_minute }
}
}
/**
* Returns true if the given due time has already passed given the current local time.
*/
export function isPastTime(dueHour: number, dueMinute: number, localNow: Date): boolean {
const nowMinutes = localNow.getHours() * 60 + localNow.getMinutes()
const dueMinutes = dueHour * 60 + dueMinute
return nowMinutes >= dueMinutes
}
/**
* Returns true if extensionDate matches today's local ISO date string.
*/
export function isExtendedToday(
extensionDate: string | null | undefined,
localDate: Date,
): boolean {
if (!extensionDate) return false
return extensionDate === toLocalISODate(localDate)
}
/**
* Formats a 24h hour/minute pair into a 12h "X:XX AM/PM" label.
*/
export function formatDueTimeLabel(hour: number, minute: number): string {
const period = hour < 12 ? 'AM' : 'PM'
const h12 = hour % 12 === 0 ? 12 : hour % 12
const mm = String(minute).padStart(2, '0')
return `${h12}:${mm} ${period}`
}
/**
* Returns the number of milliseconds from localNow until the due time fires today.
* Returns 0 if it has already passed.
*/
export function msUntilExpiry(dueHour: number, dueMinute: number, localNow: Date): number {
const nowMs =
localNow.getHours() * 3600000 +
localNow.getMinutes() * 60000 +
localNow.getSeconds() * 1000 +
localNow.getMilliseconds()
const dueMs = dueHour * 3600000 + dueMinute * 60000
return Math.max(0, dueMs - nowMs)
}
/**
* Returns the local date as an ISO date string (YYYY-MM-DD) without timezone conversion.
*/
export function toLocalISODate(d: Date): string {
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}

View File

@@ -14,6 +14,7 @@ import type {
Event, Event,
Task, Task,
RewardStatus, RewardStatus,
ChildTask,
ChildTaskTriggeredEventPayload, ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload, ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload, ChildRewardRequestEventPayload,
@@ -22,7 +23,18 @@ import type {
TaskModifiedEventPayload, TaskModifiedEventPayload,
RewardModifiedEventPayload, RewardModifiedEventPayload,
ChildModifiedEventPayload, ChildModifiedEventPayload,
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
} from '@/common/models' } from '@/common/models'
import {
isScheduledToday,
isPastTime,
getDueTimeToday,
formatDueTimeLabel,
msUntilExpiry,
isExtendedToday,
toLocalISODate,
} from '@/common/scheduleUtils'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -290,12 +302,90 @@ function removeInactivityListeners() {
if (inactivityTimer) clearTimeout(inactivityTimer) if (inactivityTimer) clearTimeout(inactivityTimer)
} }
const childChoreListRef = ref()
const readyItemId = ref<string | null>(null) const readyItemId = ref<string | null>(null)
const expiryTimers = ref<number[]>([])
const lastFetchDate = ref<string>(toLocalISODate(new Date()))
function handleItemReady(itemId: string) { function handleItemReady(itemId: string) {
readyItemId.value = itemId readyItemId.value = itemId
} }
function handleChoreScheduleModified(event: Event) {
const payload = event.payload as ChoreScheduleModifiedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
setTimeout(() => resetExpiryTimers(), 300)
}
}
function handleChoreTimeExtended(event: Event) {
const payload = event.payload as ChoreTimeExtendedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
setTimeout(() => resetExpiryTimers(), 300)
}
}
function isChoreScheduledToday(item: ChildTask): boolean {
if (!item.schedule) return true
const today = new Date()
return isScheduledToday(item.schedule, today)
}
function isChoreExpired(item: ChildTask): boolean {
if (!item.schedule) return false
const today = new Date()
const due = getDueTimeToday(item.schedule, today)
if (!due) return false
if (item.extension_date && isExtendedToday(item.extension_date, today)) return false
return isPastTime(due.hour, due.minute, today)
}
function choreDueLabel(item: ChildTask): string | null {
if (!item.schedule) return null
const today = new Date()
const due = getDueTimeToday(item.schedule, today)
if (!due) return null
if (item.extension_date && isExtendedToday(item.extension_date, today)) return null
return formatDueTimeLabel(due.hour, due.minute)
}
function clearExpiryTimers() {
expiryTimers.value.forEach((t) => clearTimeout(t))
expiryTimers.value = []
}
function resetExpiryTimers() {
clearExpiryTimers()
const items: ChildTask[] = childChoreListRef.value?.items ?? []
const now = new Date()
for (const item of items) {
if (!item.schedule) continue
const due = getDueTimeToday(item.schedule, now)
if (!due) continue
if (item.extension_date && isExtendedToday(item.extension_date, now)) continue
const ms = msUntilExpiry(due.hour, due.minute, now)
if (ms > 0) {
const tid = window.setTimeout(() => {
childChoreListRef.value?.refresh()
}, ms)
expiryTimers.value.push(tid)
}
}
}
function onVisibilityChange() {
if (document.visibilityState === 'visible') {
const today = toLocalISODate(new Date())
if (today !== lastFetchDate.value) {
lastFetchDate.value = today
childChoreListRef.value?.refresh()
setTimeout(() => resetExpiryTimers(), 300)
}
}
}
const hasPendingRewards = computed(() => const hasPendingRewards = computed(() =>
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming), childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
) )
@@ -310,6 +400,9 @@ onMounted(async () => {
eventBus.on('reward_modified', handleRewardModified) eventBus.on('reward_modified', handleRewardModified)
eventBus.on('child_modified', handleChildModified) eventBus.on('child_modified', handleChildModified)
eventBus.on('child_reward_request', handleRewardRequest) eventBus.on('child_reward_request', handleRewardRequest)
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
eventBus.on('chore_time_extended', handleChoreTimeExtended)
document.addEventListener('visibilitychange', onVisibilityChange)
if (route.params.id) { if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) { if (idParam !== undefined) {
@@ -321,6 +414,7 @@ onMounted(async () => {
rewards.value = data.rewards || [] rewards.value = data.rewards || []
} }
loading.value = false loading.value = false
setTimeout(() => resetExpiryTimers(), 300)
}) })
} }
} }
@@ -340,6 +434,10 @@ onUnmounted(() => {
eventBus.off('reward_modified', handleRewardModified) eventBus.off('reward_modified', handleRewardModified)
eventBus.off('child_modified', handleChildModified) eventBus.off('child_modified', handleChildModified)
eventBus.off('child_reward_request', handleRewardRequest) eventBus.off('child_reward_request', handleRewardRequest)
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
eventBus.off('chore_time_extended', handleChoreTimeExtended)
document.removeEventListener('visibilitychange', onVisibilityChange)
clearExpiryTimers()
removeInactivityListeners() removeInactivityListeners()
}) })
</script> </script>
@@ -362,14 +460,17 @@ onUnmounted(() => {
:readyItemId="readyItemId" :readyItemId="readyItemId"
@item-ready="handleItemReady" @item-ready="handleItemReady"
@trigger-item="triggerTask" @trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" :getItemClass="
:filter-fn=" (item: ChildTask) => ({
(item) => { bad: !item.is_good,
return item.is_good good: item.is_good,
} 'chore-inactive': isChoreExpired(item),
})
" "
:filter-fn="(item: ChildTask) => item.is_good && isChoreScheduledToday(item)"
> >
<template #item="{ item }"> <template #item="{ item }: { item: ChildTask }">
<span v-if="isChoreExpired(item)" class="pending">TOO LATE</span>
<div class="item-name">{{ item.name }}</div> <div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" /> <img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div <div
@@ -383,6 +484,7 @@ onUnmounted(() => {
}} }}
Points Points
</div> </div>
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
</template> </template>
</ScrollingList> </ScrollingList>
<ScrollingList <ScrollingList
@@ -565,6 +667,18 @@ onUnmounted(() => {
pointer-events: none; pointer-events: none;
filter: grayscale(0.7); filter: grayscale(0.7);
} }
:deep(.chore-inactive) {
opacity: 0.45;
filter: grayscale(0.6);
}
.due-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--due-label-color, #aaa);
margin-top: 0.15rem;
letter-spacing: 0.3px;
}
.modal-message { .modal-message {
margin-bottom: 1.2rem; margin-bottom: 1.2rem;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue' import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue' import ModalDialog from '../shared/ModalDialog.vue'
import ScheduleModal from '../shared/ScheduleModal.vue'
import PendingRewardDialog from './PendingRewardDialog.vue' import PendingRewardDialog from './PendingRewardDialog.vue'
import TaskConfirmDialog from './TaskConfirmDialog.vue' import TaskConfirmDialog from './TaskConfirmDialog.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue' import RewardConfirmDialog from './RewardConfirmDialog.vue'
@@ -8,7 +9,7 @@ import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue' import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue' import ScrollingList from '../shared/ScrollingList.vue'
import StatusMessage from '../shared/StatusMessage.vue' import StatusMessage from '../shared/StatusMessage.vue'
import { setChildOverride, parseErrorResponse } from '@/common/api' import { setChildOverride, parseErrorResponse, extendChoreTime } from '@/common/api'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import '@/assets/styles.css' import '@/assets/styles.css'
import type { import type {
@@ -17,6 +18,7 @@ import type {
Event, Event,
Reward, Reward,
RewardStatus, RewardStatus,
ChildTask,
ChildTaskTriggeredEventPayload, ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload, ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload, ChildRewardRequestEventPayload,
@@ -27,7 +29,18 @@ import type {
RewardModifiedEventPayload, RewardModifiedEventPayload,
ChildOverrideSetPayload, ChildOverrideSetPayload,
ChildOverrideDeletedPayload, ChildOverrideDeletedPayload,
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
} from '@/common/models' } from '@/common/models'
import {
isScheduledToday,
isPastTime,
getDueTimeToday,
formatDueTimeLabel,
msUntilExpiry,
isExtendedToday,
toLocalISODate,
} from '@/common/scheduleUtils'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -56,6 +69,20 @@ const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'r
null, null,
) )
// Kebab menu
const activeMenuFor = ref<string | null>(null)
const shouldIgnoreNextCardClick = ref(false)
// Schedule modal
const showScheduleModal = ref(false)
const scheduleTarget = ref<ChildTask | null>(null)
// Expiry timers
const expiryTimers = ref<number[]>([])
// Last fetch date (for overnight detection)
const lastFetchDate = ref<string>(toLocalISODate(new Date()))
function handleItemReady(itemId: string) { function handleItemReady(itemId: string) {
readyItemId.value = itemId readyItemId.value = itemId
} }
@@ -216,6 +243,155 @@ function handleOverrideDeleted(event: Event) {
} }
} }
function handleChoreScheduleModified(event: Event) {
const payload = event.payload as ChoreScheduleModifiedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
resetExpiryTimers()
}
}
function handleChoreTimeExtended(event: Event) {
const payload = event.payload as ChoreTimeExtendedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
resetExpiryTimers()
}
}
// ── Kebab menu ───────────────────────────────────────────────────────────────
const onDocClick = (e: MouseEvent) => {
if (activeMenuFor.value !== null) {
const path = (e.composedPath?.() ?? (e as any).path ?? []) as EventTarget[]
const inside = path.some((node) => {
if (!(node instanceof HTMLElement)) return false
return (
node.classList.contains('chore-kebab-wrap') ||
node.classList.contains('kebab-btn') ||
node.classList.contains('kebab-menu')
)
})
if (!inside) {
activeMenuFor.value = null
if (path.some((n) => n instanceof HTMLElement && n.classList.contains('item-card'))) {
shouldIgnoreNextCardClick.value = true
}
}
}
}
function openChoreMenu(taskId: string, e: MouseEvent) {
e.stopPropagation()
activeMenuFor.value = taskId
}
function closeChoreMenu() {
activeMenuFor.value = null
}
// ── Schedule modal ────────────────────────────────────────────────────────────
function openScheduleModal(item: ChildTask, e: MouseEvent) {
e.stopPropagation()
closeChoreMenu()
scheduleTarget.value = item
showScheduleModal.value = true
}
function onScheduleSaved() {
showScheduleModal.value = false
scheduleTarget.value = null
childChoreListRef.value?.refresh()
resetExpiryTimers()
}
// ── Extend Time ───────────────────────────────────────────────────────────────
async function doExtendTime(item: ChildTask, e: MouseEvent) {
e.stopPropagation()
closeChoreMenu()
if (!child.value) return
const today = toLocalISODate(new Date())
const res = await extendChoreTime(child.value.id, item.id, today)
if (!res.ok) {
const { msg } = await parseErrorResponse(res)
alert(`Error: ${msg}`)
}
// SSE chore_time_extended event will trigger a refresh
}
// ── Schedule state helpers (for item-slot use) ────────────────────────────────
function isChoreScheduledToday(item: ChildTask): boolean {
if (!item.schedule) return true // no schedule = always active
return isScheduledToday(item.schedule, new Date())
}
function isChoreExpired(item: ChildTask): boolean {
if (!item.schedule) return false
const now = new Date()
if (!isScheduledToday(item.schedule, now)) return false
const due = getDueTimeToday(item.schedule, now)
if (!due) return false
if (isExtendedToday(item.extension_date, now)) return false
return isPastTime(due.hour, due.minute, now)
}
function choreDueLabel(item: ChildTask): string | null {
if (!item.schedule) return null
const now = new Date()
if (!isScheduledToday(item.schedule, now)) return null
const due = getDueTimeToday(item.schedule, now)
if (!due) return null
if (isExtendedToday(item.extension_date, now)) return null
if (isPastTime(due.hour, due.minute, now)) return null
return `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
}
function isChoreInactive(item: ChildTask): boolean {
return !isChoreScheduledToday(item) || isChoreExpired(item)
}
// ── Expiry timers ─────────────────────────────────────────────────────────────
function clearExpiryTimers() {
expiryTimers.value.forEach(clearTimeout)
expiryTimers.value = []
}
function resetExpiryTimers() {
clearExpiryTimers()
const items: ChildTask[] = childChoreListRef.value?.items ?? []
const now = new Date()
for (const item of items) {
if (!item.schedule || !item.is_good) continue
if (!isScheduledToday(item.schedule, now)) continue
const due = getDueTimeToday(item.schedule, now)
if (!due) continue
if (isExtendedToday(item.extension_date, now)) continue
if (isPastTime(due.hour, due.minute, now)) continue
const ms = msUntilExpiry(due.hour, due.minute, now)
const handle = setTimeout(() => {
// trigger a reactive update by refreshing the list
childChoreListRef.value?.refresh()
}, ms) as unknown as number
expiryTimers.value.push(handle)
}
}
// ── Midnight detection (tab left open overnight) ──────────────────────────────
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
const today = toLocalISODate(new Date())
if (today !== lastFetchDate.value) {
lastFetchDate.value = today
childChoreListRef.value?.refresh()
resetExpiryTimers()
}
}
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') { function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
// If editing a pending reward, warn first // If editing a pending reward, warn first
if (type === 'reward' && (item as any).redeeming) { if (type === 'reward' && (item as any).redeeming) {
@@ -230,6 +406,11 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
showOverrideModal.value = true showOverrideModal.value = true
} }
function editChorePoints(item: Task) {
handleEditItem(item, 'task')
closeChoreMenu()
}
async function confirmPendingRewardAndEdit() { async function confirmPendingRewardAndEdit() {
if (!pendingEditOverrideTarget.value) return if (!pendingEditOverrideTarget.value) return
const item = pendingEditOverrideTarget.value.entity as any const item = pendingEditOverrideTarget.value.entity as any
@@ -304,6 +485,11 @@ onMounted(async () => {
eventBus.on('child_reward_request', handleRewardRequest) eventBus.on('child_reward_request', handleRewardRequest)
eventBus.on('child_override_set', handleOverrideSet) eventBus.on('child_override_set', handleOverrideSet)
eventBus.on('child_override_deleted', handleOverrideDeleted) eventBus.on('child_override_deleted', handleOverrideDeleted)
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
eventBus.on('chore_time_extended', handleChoreTimeExtended)
document.addEventListener('click', onDocClick, true)
document.addEventListener('visibilitychange', onVisibilityChange)
if (route.params.id) { if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
@@ -335,6 +521,12 @@ onUnmounted(() => {
eventBus.off('reward_modified', handleRewardModified) eventBus.off('reward_modified', handleRewardModified)
eventBus.off('child_override_set', handleOverrideSet) eventBus.off('child_override_set', handleOverrideSet)
eventBus.off('child_override_deleted', handleOverrideDeleted) eventBus.off('child_override_deleted', handleOverrideDeleted)
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
eventBus.off('chore_time_extended', handleChoreTimeExtended)
document.removeEventListener('click', onDocClick, true)
document.removeEventListener('visibilitychange', onVisibilityChange)
clearExpiryTimers()
}) })
function getPendingRewardIds(): string[] { function getPendingRewardIds(): string[] {
@@ -470,27 +662,66 @@ function goToAssignRewards() {
:ids="tasks" :ids="tasks"
itemKey="tasks" itemKey="tasks"
imageField="image_id" imageField="image_id"
:enableEdit="true" :enableEdit="false"
:childId="child?.id" :childId="child?.id"
:readyItemId="readyItemId" :readyItemId="readyItemId"
:isParentAuthenticated="true" :isParentAuthenticated="true"
@trigger-item="triggerTask" @trigger-item="triggerTask"
@edit-item="(item) => handleEditItem(item, 'task')"
@item-ready="handleItemReady" @item-ready="handleItemReady"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" :getItemClass="
:filter-fn=" (item) => ({
(item) => { bad: !item.is_good,
return item.is_good good: item.is_good,
} 'chore-inactive': isChoreInactive(item),
})
" "
:filter-fn="(item) => item.is_good"
> >
<template #item="{ item }"> <template #item="{ item }: { item: ChildTask }">
<!-- Kebab menu -->
<div class="chore-kebab-wrap" @click.stop>
<button
class="kebab-btn"
@mousedown.stop.prevent
@click="openChoreMenu(item.id, $event)"
:aria-expanded="activeMenuFor === item.id ? 'true' : 'false'"
aria-label="Options"
>
</button>
<div
v-if="activeMenuFor === item.id"
class="kebab-menu"
@mousedown.stop.prevent
@click.stop
>
<button class="menu-item" @mousedown.stop.prevent @click="editChorePoints(item)">
Edit Points
</button>
<button
class="menu-item"
@mousedown.stop.prevent
@click="openScheduleModal(item, $event)"
>
Schedule
</button>
<button
v-if="isChoreExpired(item)"
class="menu-item"
@mousedown.stop.prevent
@click="doExtendTime(item, $event)"
>
Extend Time
</button>
</div>
</div>
<!-- TOO LATE badge -->
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
<div class="item-name">{{ item.name }}</div> <div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" /> <img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div <div class="item-points good-points">
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ {{
item.custom_value !== undefined && item.custom_value !== null item.custom_value !== undefined && item.custom_value !== null
? item.custom_value ? item.custom_value
@@ -498,6 +729,7 @@ function goToAssignRewards() {
}} }}
Points Points
</div> </div>
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
</template> </template>
</ScrollingList> </ScrollingList>
<ScrollingList <ScrollingList
@@ -595,6 +827,16 @@ function goToAssignRewards() {
" "
/> />
<!-- Schedule Modal -->
<ScheduleModal
v-if="showScheduleModal && scheduleTarget && child"
:task="scheduleTarget"
:childId="child.id"
:schedule="scheduleTarget.schedule ?? null"
@saved="onScheduleSaved"
@cancelled="showScheduleModal = false"
/>
<!-- Override Edit Modal --> <!-- Override Edit Modal -->
<ModalDialog <ModalDialog
v-if="showOverrideModal && overrideEditTarget && child" v-if="showOverrideModal && overrideEditTarget && child"
@@ -730,6 +972,101 @@ function goToAssignRewards() {
border-color: var(--list-item-border-reward); border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward); background: var(--list-item-bg-reward);
} }
:deep(.chore-inactive) {
opacity: 0.45;
filter: grayscale(60%);
}
/* Chore kebab menu (inside item-card which is position:relative in ScrollingList) */
.chore-kebab-wrap {
position: absolute;
top: 4px;
right: 4px;
z-index: 20;
}
.kebab-btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
color: var(--kebab-icon-color, #4a4a6a);
border-radius: 6px;
font-size: 1.4rem;
}
.kebab-btn:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
}
.kebab-menu {
position: absolute;
top: 36px;
right: 0;
min-width: 140px;
background: var(--kebab-menu-bg, #f7fafc);
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
box-shadow: var(--kebab-menu-shadow);
backdrop-filter: blur(var(--kebab-menu-blur));
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 30;
border-radius: 6px;
}
.menu-item {
padding: 0.85rem 0.9rem;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
font-weight: 600;
color: var(--menu-item-color, #333);
font-size: 1rem;
}
.menu-item:hover {
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
}
/* TOO LATE stamp on expired chores */
.chore-stamp {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: rgba(34, 34, 43, 0.85);
color: var(--btn-danger, #ef4444);
font-weight: 700;
font-size: 1.05rem;
text-align: center;
border-radius: 6px;
padding: 0.4rem 0;
letter-spacing: 2px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
opacity: 0.95;
pointer-events: none;
}
/* Due time sub-text */
.due-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--item-points-color, #ffd166);
margin-top: 0.2rem;
letter-spacing: 0.3px;
}
/* Override modal styles */ /* Override modal styles */
.override-content { .override-content {

View File

@@ -0,0 +1,340 @@
<template>
<ModalDialog
:image-url="task.image_url"
:title="'Schedule Chore'"
:subtitle="task.name"
@backdrop-click="$emit('cancelled')"
>
<!-- Mode toggle -->
<div class="mode-toggle">
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
Specific Days
</button>
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
Every X Days
</button>
</div>
<!-- Specific Days form -->
<div v-if="mode === 'days'" class="days-form">
<div v-for="(label, idx) in DAY_LABELS" :key="idx" class="day-row">
<label class="day-check">
<input type="checkbox" :checked="isDayChecked(idx)" @change="toggleDay(idx)" />
<span class="day-label">{{ label }}</span>
</label>
<TimeSelector
v-if="isDayChecked(idx)"
:modelValue="getDayTime(idx)"
@update:modelValue="setDayTime(idx, $event)"
class="day-time-selector"
/>
</div>
</div>
<!-- Interval form -->
<div v-else class="interval-form">
<div class="interval-row">
<label class="field-label">Every</label>
<input v-model.number="intervalDays" type="number" min="2" max="7" class="interval-input" />
<span class="field-label">days, starting on</span>
<select v-model.number="anchorWeekday" class="anchor-select">
<option v-for="(label, idx) in DAY_LABELS" :key="idx" :value="idx">{{ label }}</option>
</select>
</div>
<div class="interval-time-row">
<label class="field-label">Due by</label>
<TimeSelector :modelValue="intervalTime" @update:modelValue="intervalTime = $event" />
</div>
</div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
<!-- Actions -->
<div class="modal-actions">
<button class="btn-secondary" @click="$emit('cancelled')" :disabled="saving">Cancel</button>
<button class="btn-primary" @click="save" :disabled="saving || !isValid">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ModalDialog from './ModalDialog.vue'
import TimeSelector from './TimeSelector.vue'
import { setChoreSchedule, parseErrorResponse } from '@/common/api'
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
interface TimeValue {
hour: number
minute: number
}
const props = defineProps<{
task: ChildTask
childId: string
schedule: ChoreSchedule | null
}>()
const emit = defineEmits<{
(e: 'saved'): void
(e: 'cancelled'): void
}>()
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
// ── local state ──────────────────────────────────────────────────────────────
const mode = ref<'days' | 'interval'>(props.schedule?.mode ?? 'days')
// days mode
const dayTimes = ref<Map<number, TimeValue>>(buildDayTimes(props.schedule))
// interval mode
const intervalDays = ref<number>(props.schedule?.interval_days ?? 2)
const anchorWeekday = ref<number>(props.schedule?.anchor_weekday ?? 0)
const intervalTime = ref<TimeValue>({
hour: props.schedule?.interval_hour ?? 8,
minute: props.schedule?.interval_minute ?? 0,
})
const saving = ref(false)
const errorMsg = ref<string | null>(null)
// ── helpers ──────────────────────────────────────────────────────────────────
function buildDayTimes(schedule: ChoreSchedule | null): Map<number, TimeValue> {
const map = new Map<number, TimeValue>()
if (schedule?.mode === 'days') {
for (const dc of schedule.day_configs) {
map.set(dc.day, { hour: dc.hour, minute: dc.minute })
}
}
return map
}
function isDayChecked(dayIdx: number): boolean {
return dayTimes.value.has(dayIdx)
}
function getDayTime(dayIdx: number): TimeValue {
return dayTimes.value.get(dayIdx) ?? { hour: 8, minute: 0 }
}
function toggleDay(dayIdx: number) {
if (dayTimes.value.has(dayIdx)) {
dayTimes.value.delete(dayIdx)
} else {
dayTimes.value.set(dayIdx, { hour: 8, minute: 0 })
}
}
function setDayTime(dayIdx: number, val: TimeValue) {
dayTimes.value.set(dayIdx, val)
}
// ── validation ───────────────────────────────────────────────────────────────
const isValid = computed(() => {
if (mode.value === 'days') {
return dayTimes.value.size > 0
} else {
return intervalDays.value >= 2 && intervalDays.value <= 7
}
})
// ── save ─────────────────────────────────────────────────────────────────────
async function save() {
if (!isValid.value || saving.value) return
errorMsg.value = null
saving.value = true
let payload: object
if (mode.value === 'days') {
const day_configs: DayConfig[] = Array.from(dayTimes.value.entries()).map(([day, t]) => ({
day,
hour: t.hour,
minute: t.minute,
}))
payload = { mode: 'days', day_configs }
} else {
payload = {
mode: 'interval',
interval_days: intervalDays.value,
anchor_weekday: anchorWeekday.value,
interval_hour: intervalTime.value.hour,
interval_minute: intervalTime.value.minute,
}
}
const res = await setChoreSchedule(props.childId, props.task.id, payload)
saving.value = false
if (res.ok) {
emit('saved')
} else {
const { msg } = await parseErrorResponse(res)
errorMsg.value = msg
}
}
</script>
<style scoped>
.mode-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1.2rem;
}
.mode-btn {
flex: 1;
padding: 0.55rem 0.4rem;
border: 1.5px solid var(--primary, #667eea);
border-radius: 6px;
background: transparent;
color: var(--primary, #667eea);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.mode-btn.active {
background: var(--btn-primary, #667eea);
color: #fff;
}
/* Days form */
.days-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-bottom: 1rem;
}
.day-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.day-check {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
min-width: 110px;
}
.day-check input[type='checkbox'] {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
accent-color: var(--btn-primary, #667eea);
}
.day-label {
font-size: 0.95rem;
font-weight: 600;
color: var(--secondary, #7257b3);
}
.day-time-selector {
margin-left: auto;
}
/* Interval form */
.interval-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.interval-row,
.interval-time-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.field-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--secondary, #7257b3);
}
.interval-input {
width: 3.5rem;
padding: 0.4rem 0.4rem;
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px;
font-size: 1rem;
text-align: center;
color: var(--secondary, #7257b3);
font-weight: 700;
}
.anchor-select {
padding: 0.4rem 0.5rem;
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px;
font-size: 0.95rem;
color: var(--secondary, #7257b3);
font-weight: 600;
background: var(--modal-bg, #fff);
}
/* Error */
.error-msg {
color: var(--error, #e53e3e);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
/* Actions */
.modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
.btn-primary {
background: var(--btn-primary, #667eea);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.4rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--secondary, #7257b3);
border: 1.5px solid var(--secondary, #7257b3);
border-radius: 8px;
padding: 0.55rem 1.4rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<div class="time-selector">
<!-- Hour column -->
<div class="time-col">
<button class="arrow-btn" @click="incrementHour" aria-label="Increase hour"></button>
<div class="time-value">{{ displayHour }}</div>
<button class="arrow-btn" @click="decrementHour" aria-label="Decrease hour"></button>
</div>
<div class="time-separator">:</div>
<!-- Minute column -->
<div class="time-col">
<button class="arrow-btn" @click="incrementMinute" aria-label="Increase minute"></button>
<div class="time-value">{{ displayMinute }}</div>
<button class="arrow-btn" @click="decrementMinute" aria-label="Decrease minute"></button>
</div>
<!-- AM/PM toggle -->
<button class="ampm-btn" @click="toggleAmPm">{{ period }}</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface TimeValue {
hour: number // 023 (24h)
minute: number // 0, 15, 30, or 45
}
const props = defineProps<{ modelValue: TimeValue }>()
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
const MINUTES = [0, 15, 30, 45]
// ── display helpers ──────────────────────────────────────────────────────────
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
const displayHour = computed(() => {
const h = props.modelValue.hour % 12
return String(h === 0 ? 12 : h)
})
const displayMinute = computed(() => String(props.modelValue.minute).padStart(2, '0'))
// ── mutations ────────────────────────────────────────────────────────────────
function incrementHour() {
const next = (props.modelValue.hour + 1) % 24
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
}
function decrementHour() {
const next = (props.modelValue.hour - 1 + 24) % 24
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
}
function incrementMinute() {
const idx = MINUTES.indexOf(props.modelValue.minute)
if (idx === MINUTES.length - 1) {
// Wrap minute to 0 and carry into next hour
const nextHour = (props.modelValue.hour + 1) % 24
emit('update:modelValue', { hour: nextHour, minute: 0 })
} else {
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx + 1] })
}
}
function decrementMinute() {
const idx = MINUTES.indexOf(props.modelValue.minute)
if (idx === 0) {
// Wrap minute to 45 and borrow from previous hour
const prevHour = (props.modelValue.hour - 1 + 24) % 24
emit('update:modelValue', { hour: prevHour, minute: 45 })
} else {
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx - 1] })
}
}
function toggleAmPm() {
const next = props.modelValue.hour < 12 ? props.modelValue.hour + 12 : props.modelValue.hour - 12
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
}
</script>
<style scoped>
.time-selector {
display: inline-flex;
align-items: center;
gap: 0.25rem;
user-select: none;
}
.time-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.arrow-btn {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
color: var(--primary, #667eea);
transition: background 0.15s;
}
.arrow-btn:hover {
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
}
.time-value {
width: 2.5rem;
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
font-weight: 700;
color: var(--secondary, #7257b3);
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px;
background: var(--modal-bg, #fff);
}
.time-separator {
font-size: 1.6rem;
font-weight: 700;
color: var(--secondary, #7257b3);
padding-bottom: 0.1rem;
align-self: center;
}
.ampm-btn {
width: 3rem;
height: 2.5rem;
font-size: 0.95rem;
font-weight: 700;
border: 1.5px solid var(--btn-primary, #667eea);
border-radius: 6px;
background: var(--btn-primary, #667eea);
color: #fff;
cursor: pointer;
margin-left: 0.35rem;
transition: opacity 0.15s;
}
.ampm-btn:hover {
opacity: 0.85;
}
@media (max-width: 480px) {
.arrow-btn,
.time-value {
width: 2.8rem;
height: 2.8rem;
}
.ampm-btn {
width: 3.2rem;
height: 2.8rem;
}
}
</style>

View File

@@ -14,12 +14,12 @@ export default defineConfig({
}, },
proxy: { proxy: {
'/api': { '/api': {
target: 'http://192.168.1.102:5000', target: 'http://192.168.1.219:5000',
changeOrigin: true, changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ''), rewrite: (p) => p.replace(/^\/api/, ''),
}, },
'/events': { '/events': {
target: 'http://192.168.1.102:5000', target: 'http://192.168.1.219:5000',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },