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.
271 lines
14 KiB
Markdown
271 lines
14 KiB
Markdown
# Feature: Chores can be scheduled to occur on certain calendar days or weeks and have a time during the day where they must be finished by.
|
||
|
||
## Overview
|
||
|
||
**Goal:** Chores can be edited to only show up in child mode on particular days and during certain times. This encourages a child to perform chores on those days before a certain time of day.
|
||
|
||
**User Story:**
|
||
|
||
- As a parent, I will be able to select an assigned chore and configure it to occur on any combination of the days of the week. I will also be able to configure the latest possible time of day on each of those configured days that the chore must be done.
|
||
- As a child, I will not see the chore appear if it was configured for a different day of the week. I will also not be able to see a chore if the current time of day is past the configured latest time of the chore.
|
||
|
||
**Rules:**
|
||
|
||
- Follow instructions from .github/copilot-instructions.md
|
||
|
||
---
|
||
|
||
## 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 Models
|
||
|
||
**New model — `backend/models/chore_schedule.py`:**
|
||
|
||
- `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`
|
||
|
||
**New model — `backend/models/task_extension.py`:**
|
||
|
||
- `id: str`
|
||
- `child_id: str`
|
||
- `task_id: str`
|
||
- `date: str` — ISO date string supplied by the client, e.g. `'2026-02-22'`
|
||
|
||
### Frontend Models
|
||
|
||
In `frontend/vue-app/src/common/models.ts`:
|
||
|
||
- 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
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
**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 [2–7], weekday picker, single `TimeSelector`
|
||
- Calls `setChoreSchedule()` on Save; emits `saved` and `cancelled`
|
||
|
||
**New `frontend/vue-app/src/components/shared/TimeSelector.vue`:**
|
||
|
||
- Props/emits as described in Frontend Design section
|
||
- Up/Down arrows for Hour and Minute (15-min increments), AM/PM toggle
|
||
- Scoped CSS, `:root` variables only
|
||
|
||
**`frontend/vue-app/src/common/api.ts` additions:**
|
||
|
||
- `getChoreSchedule(childId, taskId)`
|
||
- `setChoreSchedule(childId, taskId, schedule)`
|
||
- `deleteChoreSchedule(childId, taskId)`
|
||
- `extendChoreTime(childId, taskId, localDate: string)` — passes client's local ISO date in request body
|
||
|
||
---
|
||
|
||
## Frontend Tests
|
||
|
||
- [x] `scheduleUtils.ts`: unit test all helpers — `isScheduledToday` (days mode, interval mode), `intervalHitsToday`, `isPastTime`, `isExtendedToday`, `formatDueTimeLabel`, `msUntilExpiry`; include timezone-agnostic cases using mocked `Date` objects
|
||
- [x] `TimeSelector.vue`: increment/decrement, AM/PM toggle, 15-min boundary wrapping (e.g. 11:45 AM → 12:00 PM)
|
||
- [x] `ScheduleModal.vue`: mode toggle renders correct sub-form; unchecking a day removes its time config; Save emits correct `ChoreSchedule` shape
|
||
|
||
---
|
||
|
||
## Future Considerations
|
||
|
||
---
|
||
|
||
## Acceptance Criteria (Definition of Done)
|
||
|
||
### 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
|