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

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

View File

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