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
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:
@@ -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=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.
|
- `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 (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
|
## 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 [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
|
## 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
|
||||||
|
|||||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@@ -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": []
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
144
backend/api/chore_schedule_api.py
Normal file
144
backend/api/chore_schedule_api.py
Normal 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
|
||||||
39
backend/db/chore_schedules.py
Normal file
39
backend/db/chore_schedules.py
Normal 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)
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
27
backend/db/task_extensions.py
Normal file
27
backend/db/task_extensions.py
Normal 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)
|
||||||
25
backend/events/types/chore_schedule_modified.py
Normal file
25
backend/events/types/chore_schedule_modified.py
Normal 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')
|
||||||
17
backend/events/types/chore_time_extended.py
Normal file
17
backend/events/types/chore_time_extended.py
Normal 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')
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
71
backend/models/chore_schedule.py
Normal file
71
backend/models/chore_schedule.py
Normal 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 # 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
|
||||||
29
backend/models/task_extension.py
Normal file
29
backend/models/task_extension.py
Normal 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
|
||||||
@@ -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
|
||||||
243
backend/tests/test_chore_schedule_api.py
Normal file
243
backend/tests/test_chore_schedule_api.py
Normal 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 [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
|
||||||
283
frontend/vue-app/src/__tests__/ScheduleModal.spec.ts
Normal file
283
frontend/vue-app/src/__tests__/ScheduleModal.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
154
frontend/vue-app/src/__tests__/TimeSelector.spec.ts
Normal file
154
frontend/vue-app/src/__tests__/TimeSelector.spec.ts
Normal 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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
254
frontend/vue-app/src/__tests__/scheduleUtils.spec.ts
Normal file
254
frontend/vue-app/src/__tests__/scheduleUtils.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 // 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 {
|
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
|
||||||
|
}
|
||||||
|
|||||||
123
frontend/vue-app/src/common/scheduleUtils.ts
Normal file
123
frontend/vue-app/src/common/scheduleUtils.ts
Normal 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}`
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
340
frontend/vue-app/src/components/shared/ScheduleModal.vue
Normal file
340
frontend/vue-app/src/components/shared/ScheduleModal.vue
Normal 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>
|
||||||
174
frontend/vue-app/src/components/shared/TimeSelector.vue
Normal file
174
frontend/vue-app/src/components/shared/TimeSelector.vue
Normal 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 // 0–23 (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>
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user