# 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` 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//task//schedule` — returns raw `ChoreSchedule`; 404 if none - `PUT /child//task//schedule` — create or replace schedule; fires `chore_schedule_modified` SSE (`operation: 'SET'`) - `DELETE /child//task//schedule` — remove schedule; fires `chore_schedule_modified` SSE (`operation: 'DELETED'`) - `POST /child//task//extend` — body: `{ date: "YYYY-MM-DD" }` (client's local date); inserts a `TaskExtension`; returns 409 if a `TaskExtension` already exists for this `child_id + task_id + date`; fires `chore_time_extended` SSE **Modify `backend/api/child_api.py` — `GET /child//list-tasks`:** - For each task, look up `ChoreSchedule` for `(child_id, task_id)` and the most recent `TaskExtension` - Return `schedule: ChoreSchedule | null` and `extension_date: string | null` on each task object - **No filtering, no time math, no annotations** — the client handles all of this **New SSE event types in `backend/events/types/`:** - `chore_schedule_modified.py` — payload: `child_id`, `task_id`, `operation: Literal['SET', 'DELETED']` - `chore_time_extended.py` — payload: `child_id`, `task_id` - Register both in `event_types.py` --- ## Backend Tests - [x] `test_chore_schedule_api.py`: CRUD schedule endpoints, extend endpoint (409 on duplicate date, accepts client-supplied date) - [x] Additions to `test_child_api.py`: verify `schedule` and `extension_date` fields are returned on each task; verify no server-side time filtering or annotation occurs --- ## Frontend Implementation **New utility — `frontend/vue-app/src/common/scheduleUtils.ts`:** - `isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean` — handles both `days` and `interval` modes - `getDueTimeToday(schedule: ChoreSchedule, localDate: Date): { hour: number; minute: number } | null` - `isPastTime(dueHour: number, dueMin: number, localNow: Date): boolean` - `isExtendedToday(extensionDate: string | null | undefined, localDate: Date): boolean` - `formatDueTimeLabel(hour: number, minute: number): string` — returns e.g. `"3:00 PM"` - `msUntilExpiry(dueHour: number, dueMin: number, localNow: Date): number` - `intervalHitsToday(anchorWeekday: number, intervalDays: number, localDate: Date): boolean` **`ParentView.vue`:** - Derive computed chore state per card using `scheduleUtils` and `new Date()` - Remove `:enableEdit="true"` and `@edit-item` from the Chores `ScrollingList` (keep for Penalties) - Add `activeMenuFor = ref(null)` and capture-phase `document` click listener (same pattern as `ChildrenListView.vue`) - In chore `#item` slot: add `.kebab-wrap > .kebab-btn + .kebab-menu` per card with items: Edit Points, Schedule, Extend Time (conditional on `isPastTime`) - Bind `.chore-inactive` class when `!isScheduledToday || isPastTime` - Add `TOO LATE` badge overlay when `isPastTime` - Show `"Due by {{ formatDueTimeLabel(...) }}"` sub-text when due time exists and not yet expired - Manage `setTimeout` handles in a `ref`; clear in `onUnmounted` and on every re-fetch - Listen for `chore_schedule_modified` and `chore_time_extended` SSE events → re-fetch task list and reset timers **`ChildView.vue`:** - Derive computed chore state per card using `scheduleUtils` and `new Date()` - Filter out chores where `!isScheduledToday` (client-side, not server-side) - Add `.chore-expired` grayout + `TOO LATE` stamp when `isPastTime` - Show `"Due by {{ formatDueTimeLabel(...) }}"` sub-text when due time exists and not yet expired - Manage `setTimeout` handles the same way as ParentView - Listen for `chore_schedule_modified` and `chore_time_extended` SSE events → re-fetch and reset timers **New `frontend/vue-app/src/components/shared/ScheduleModal.vue`:** - Wraps `ModalDialog`; custom slot content for schedule form - Mode toggle (Specific Days / Every X Days) - Specific Days: 7 checkbox rows, each reveals an inline `TimeSelector` when checked - Interval: number input [2–7], weekday picker, single `TimeSelector` - Calls `setChoreSchedule()` on Save; emits `saved` and `cancelled` **New `frontend/vue-app/src/components/shared/TimeSelector.vue`:** - Props/emits as described in Frontend Design section - Up/Down arrows for Hour and Minute (15-min increments), AM/PM toggle - Scoped CSS, `:root` variables only **`frontend/vue-app/src/common/api.ts` additions:** - `getChoreSchedule(childId, taskId)` - `setChoreSchedule(childId, taskId, schedule)` - `deleteChoreSchedule(childId, taskId)` - `extendChoreTime(childId, taskId, localDate: string)` — passes client's local ISO date in request body --- ## Frontend Tests - [x] `scheduleUtils.ts`: unit test all helpers — `isScheduledToday` (days mode, interval mode), `intervalHitsToday`, `isPastTime`, `isExtendedToday`, `formatDueTimeLabel`, `msUntilExpiry`; include timezone-agnostic cases using mocked `Date` objects - [x] `TimeSelector.vue`: increment/decrement, AM/PM toggle, 15-min boundary wrapping (e.g. 11:45 AM → 12:00 PM) - [x] `ScheduleModal.vue`: mode toggle renders correct sub-form; unchecking a day removes its time config; Save emits correct `ChoreSchedule` shape --- ## 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//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