diff --git a/.github/specs/feat-calendar-chore/feat-calendar-chore.md b/.github/specs/feat-calendar-chore/feat-calendar-chore.md index 0287de6..7f9f7db 100644 --- a/.github/specs/feat-calendar-chore/feat-calendar-chore.md +++ b/.github/specs/feat-calendar-chore/feat-calendar-chore.md @@ -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 -### 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=Sun–6=Sat), hour: int, minute: int }` +- For `mode='interval'`: `interval_days: int` (2–7), `anchor_weekday: int` (0=Sun–6=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. -- I would like for this icon button to be changed to a kebab menu that drops down the following: - - Edit points - - Schedule - - 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. +- `id: str` +- `child_id: str` +- `task_id: str` +- `date: str` — ISO date string supplied by the client, e.g. `'2026-02-22'` -#### Schedule Modal (or View) +### Frontend Models -- When 'Schedule' is selected from the dropdown menu, a new modal or view will appear. -- 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. +In `frontend/vue-app/src/common/models.ts`: -#### 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` 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 (Sunday–Saturday) +- 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 [2–7] +- Weekday picker to select the anchor day (0=Sun–6=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 (1–12) 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 -- 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. -- 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? +**New DB files:** + +- `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//task//schedule` — returns raw `ChoreSchedule`; 404 if none +- `PUT /child//task//schedule` — create or replace schedule; fires `chore_schedule_modified` SSE (`operation: 'SET'`) +- `DELETE /child//task//schedule` — remove schedule; fires `chore_schedule_modified` SSE (`operation: 'DELETED'`) +- `POST /child//task//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//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 -- [ ] +- [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 +**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(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`; 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 [2–7], 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 -- [ ] +- [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 -- [ ] +- [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//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 -- [ ] +- [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 diff --git a/.vscode/settings.json b/.vscode/settings.json index ac2bf43..f52d0a4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,7 @@ { + "python.venvPath": "${workspaceFolder}/backend/.venv", + "python.terminal.activateEnvironment": true, + "python.terminal.shellIntegration.enabled": true, "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { "tsconfig.json": "tsconfig.*.json, env.d.ts", @@ -19,5 +22,7 @@ }, "chat.tools.terminal.autoApprove": { "&": true - } + }, + "python-envs.defaultEnvManager": "ms-python.python:venv", + "python-envs.pythonProjects": [] } \ No newline at end of file diff --git a/backend/api/child_api.py b/backend/api/child_api.py index ab140c9..022a8bb 100644 --- a/backend/api/child_api.py +++ b/backend/api/child_api.py @@ -28,6 +28,9 @@ from models.tracking_event import TrackingEvent from api.utils import get_validated_user_id from utils.tracking_logger import log_tracking_event from collections import defaultdict +from db.chore_schedules import get_schedule +from db.task_extensions import get_extension +from datetime import date as date_type import logging child_api = Blueprint('child_api', __name__) @@ -273,6 +276,18 @@ def list_child_tasks(id): ct_dict = ct.to_dict() if custom_value is not None: ct_dict['custom_value'] = custom_value + + # Attach schedule and most recent extension_date (client does all time math) + if task.get('is_good'): + 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) return jsonify({'tasks': child_tasks}), 200 diff --git a/backend/api/chore_schedule_api.py b/backend/api/chore_schedule_api.py new file mode 100644 index 0000000..8aba95a --- /dev/null +++ b/backend/api/chore_schedule_api.py @@ -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//task//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//task//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//task//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//task//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 diff --git a/backend/db/chore_schedules.py b/backend/db/chore_schedules.py new file mode 100644 index 0000000..a54eeab --- /dev/null +++ b/backend/db/chore_schedules.py @@ -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) diff --git a/backend/db/db.py b/backend/db/db.py index 14adcad..5b39811 100644 --- a/backend/db/db.py +++ b/backend/db/db.py @@ -75,6 +75,8 @@ pending_reward_path = os.path.join(base_dir, 'pending_rewards.json') users_path = os.path.join(base_dir, 'users.json') tracking_events_path = os.path.join(base_dir, 'tracking_events.json') child_overrides_path = os.path.join(base_dir, 'child_overrides.json') +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 _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) _tracking_events_db = TinyDB(tracking_events_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 child_db = LockedTable(_child_db) @@ -95,6 +99,8 @@ pending_reward_db = LockedTable(_pending_rewards_db) users_db = LockedTable(_users_db) tracking_events_db = LockedTable(_tracking_events_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': child_db.truncate() @@ -105,4 +111,6 @@ if os.environ.get('DB_ENV', 'prod') == 'test': users_db.truncate() tracking_events_db.truncate() child_overrides_db.truncate() + chore_schedules_db.truncate() + task_extensions_db.truncate() diff --git a/backend/db/task_extensions.py b/backend/db/task_extensions.py new file mode 100644 index 0000000..519f2d0 --- /dev/null +++ b/backend/db/task_extensions.py @@ -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) diff --git a/backend/events/types/chore_schedule_modified.py b/backend/events/types/chore_schedule_modified.py new file mode 100644 index 0000000..e194e7d --- /dev/null +++ b/backend/events/types/chore_schedule_modified.py @@ -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') diff --git a/backend/events/types/chore_time_extended.py b/backend/events/types/chore_time_extended.py new file mode 100644 index 0000000..bab5672 --- /dev/null +++ b/backend/events/types/chore_time_extended.py @@ -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') diff --git a/backend/events/types/event_types.py b/backend/events/types/event_types.py index 4ab7774..1ce6520 100644 --- a/backend/events/types/event_types.py +++ b/backend/events/types/event_types.py @@ -23,3 +23,6 @@ class EventType(Enum): CHILD_OVERRIDE_DELETED = "child_override_deleted" PROFILE_UPDATED = "profile_updated" + + CHORE_SCHEDULE_MODIFIED = "chore_schedule_modified" + CHORE_TIME_EXTENDED = "chore_time_extended" diff --git a/backend/main.py b/backend/main.py index 9d51037..5380d9a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,7 @@ from api.admin_api import admin_api from api.auth_api import auth_api from api.child_api import child_api from api.child_override_api import child_override_api +from api.chore_schedule_api import chore_schedule_api from api.image_api import image_api from api.reward_api import reward_api from api.task_api import task_api @@ -37,6 +38,7 @@ app = Flask(__name__) app.register_blueprint(admin_api) app.register_blueprint(child_api) app.register_blueprint(child_override_api) +app.register_blueprint(chore_schedule_api) app.register_blueprint(reward_api) app.register_blueprint(task_api) app.register_blueprint(image_api) diff --git a/backend/models/chore_schedule.py b/backend/models/chore_schedule.py new file mode 100644 index 0000000..eb432d2 --- /dev/null +++ b/backend/models/chore_schedule.py @@ -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 # 0–23 (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 # 2–7 + anchor_weekday: int = 0 # 0=Sun–6=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 diff --git a/backend/models/task_extension.py b/backend/models/task_extension.py new file mode 100644 index 0000000..2c73fd3 --- /dev/null +++ b/backend/models/task_extension.py @@ -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 diff --git a/backend/tests/test_child_api.py b/backend/tests/test_child_api.py index 5821ef4..c844284 100644 --- a/backend/tests/test_child_api.py +++ b/backend/tests/test_child_api.py @@ -4,11 +4,12 @@ import os from flask import Flask from api.child_api import child_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 models.child import Child import jwt from werkzeug.security import generate_password_hash +from datetime import date as date_type # Test user credentials @@ -348,4 +349,142 @@ def test_assignable_rewards_multiple_user_same_name(client): ids = [r['id'] for r in data['rewards']] # Both user rewards should be present, not the system one assert set(names) == {'Prize'} - assert set(ids) == {'userr1', 'userr2'} \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/tests/test_chore_schedule_api.py b/backend/tests/test_chore_schedule_api.py new file mode 100644 index 0000000..29b3fd8 --- /dev/null +++ b/backend/tests/test_chore_schedule_api.py @@ -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 [2–7] + 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 [0–6] + 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 diff --git a/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts b/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts new file mode 100644 index 0000000..d7b6902 --- /dev/null +++ b/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts @@ -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: '
', + props: ['imageUrl', 'title', 'subtitle'], +} +const TimeSelectorStub = { + template: '
', + 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('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('.interval-input') + await intervalInput.setValue(3) + + // Set anchor_weekday to 2 (Tuesday) + const anchorSelect = w.find('.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('.interval-input') + expect(Number((input.element as HTMLInputElement).value)).toBe(4) + const select = w.find('.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() + }) +}) diff --git a/frontend/vue-app/src/__tests__/TimeSelector.spec.ts b/frontend/vue-app/src/__tests__/TimeSelector.spec.ts new file mode 100644 index 0000000..aa7ea31 --- /dev/null +++ b/frontend/vue-app/src/__tests__/TimeSelector.spec.ts @@ -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 }) + }) +}) diff --git a/frontend/vue-app/src/__tests__/scheduleUtils.spec.ts b/frontend/vue-app/src/__tests__/scheduleUtils.spec.ts new file mode 100644 index 0000000..4c4c851 --- /dev/null +++ b/frontend/vue-app/src/__tests__/scheduleUtils.spec.ts @@ -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) + }) +}) diff --git a/frontend/vue-app/src/common/api.ts b/frontend/vue-app/src/common/api.ts index b90974f..eae3588 100644 --- a/frontend/vue-app/src/common/api.ts +++ b/frontend/vue-app/src/common/api.ts @@ -111,3 +111,52 @@ export async function deleteChildOverride(childId: string, entityId: string): Pr method: 'DELETE', }) } + +// ── Chore Schedule API ────────────────────────────────────────────────────── + +/** + * Get the schedule for a specific child + task pair. + */ +export async function getChoreSchedule(childId: string, taskId: string): Promise { + 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 { + 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 { + 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 { + return fetch(`/api/child/${childId}/task/${taskId}/extend`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date: localDate }), + }) +} diff --git a/frontend/vue-app/src/common/models.ts b/frontend/vue-app/src/common/models.ts index 118cb16..ae5ea0a 100644 --- a/frontend/vue-app/src/common/models.ts +++ b/frontend/vue-app/src/common/models.ts @@ -8,6 +8,40 @@ export interface Task { } 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 // 0–23 (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 // 2–7 + anchor_weekday: number // 0=Sun–6=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 { id: string first_name: string @@ -97,6 +131,8 @@ export interface Event { | TrackingEventCreatedPayload | ChildOverrideSetPayload | ChildOverrideDeletedPayload + | ChoreScheduleModifiedPayload + | ChoreTimeExtendedPayload } export interface ChildModifiedEventPayload { @@ -213,3 +249,14 @@ export const CHILD_OVERRIDE_FIELDS = [ 'created_at', 'updated_at', ] as const + +export interface ChoreScheduleModifiedPayload { + child_id: string + task_id: string + operation: 'SET' | 'DELETED' +} + +export interface ChoreTimeExtendedPayload { + child_id: string + task_id: string +} diff --git a/frontend/vue-app/src/common/scheduleUtils.ts b/frontend/vue-app/src/common/scheduleUtils.ts new file mode 100644 index 0000000..f419f12 --- /dev/null +++ b/frontend/vue-app/src/common/scheduleUtils.ts @@ -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}` +} diff --git a/frontend/vue-app/src/components/child/ChildView.vue b/frontend/vue-app/src/components/child/ChildView.vue index 7554f82..59e454c 100644 --- a/frontend/vue-app/src/components/child/ChildView.vue +++ b/frontend/vue-app/src/components/child/ChildView.vue @@ -14,6 +14,7 @@ import type { Event, Task, RewardStatus, + ChildTask, ChildTaskTriggeredEventPayload, ChildRewardTriggeredEventPayload, ChildRewardRequestEventPayload, @@ -22,7 +23,18 @@ import type { TaskModifiedEventPayload, RewardModifiedEventPayload, ChildModifiedEventPayload, + ChoreScheduleModifiedPayload, + ChoreTimeExtendedPayload, } from '@/common/models' +import { + isScheduledToday, + isPastTime, + getDueTimeToday, + formatDueTimeLabel, + msUntilExpiry, + isExtendedToday, + toLocalISODate, +} from '@/common/scheduleUtils' const route = useRoute() const router = useRouter() @@ -290,12 +302,90 @@ function removeInactivityListeners() { if (inactivityTimer) clearTimeout(inactivityTimer) } +const childChoreListRef = ref() const readyItemId = ref(null) +const expiryTimers = ref([]) +const lastFetchDate = ref(toLocalISODate(new Date())) function handleItemReady(itemId: string) { 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(() => childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming), ) @@ -310,6 +400,9 @@ onMounted(async () => { eventBus.on('reward_modified', handleRewardModified) eventBus.on('child_modified', handleChildModified) 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) { const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id if (idParam !== undefined) { @@ -321,6 +414,7 @@ onMounted(async () => { rewards.value = data.rewards || [] } loading.value = false + setTimeout(() => resetExpiryTimers(), 300) }) } } @@ -340,6 +434,10 @@ onUnmounted(() => { eventBus.off('reward_modified', handleRewardModified) eventBus.off('child_modified', handleChildModified) eventBus.off('child_reward_request', handleRewardRequest) + eventBus.off('chore_schedule_modified', handleChoreScheduleModified) + eventBus.off('chore_time_extended', handleChoreTimeExtended) + document.removeEventListener('visibilitychange', onVisibilityChange) + clearExpiryTimers() removeInactivityListeners() }) @@ -362,14 +460,17 @@ onUnmounted(() => { :readyItemId="readyItemId" @item-ready="handleItemReady" @trigger-item="triggerTask" - :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })" - :filter-fn=" - (item) => { - return item.is_good - } + :getItemClass=" + (item: ChildTask) => ({ + bad: !item.is_good, + good: item.is_good, + 'chore-inactive': isChoreExpired(item), + }) " + :filter-fn="(item: ChildTask) => item.is_good && isChoreScheduledToday(item)" > -