Files
chore/.github/specs/archive/feat-schedule-enable-disable.md
Ryan Kegel e9f4343426
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 5m43s
added fixes for bug plan 1.0.6RC01
2026-03-24 15:31:57 -04:00

243 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Feature: Chore Schedule Enable/Disable Toggle
## Overview
**Goal:** Allow parents to pause and resume a chore schedule without deleting it. A paused schedule retains all its configuration (days, intervals, deadlines) but acts as if the chore had no schedule — it shows every day with no deadline or expiry — until re-enabled.
**User Story:**
As a parent, I want to temporarily pause a chore's schedule so my child still sees it every day but it loses its deadline constraints, without losing the schedule configuration I've set up.
**Rules:**
follow .github/copilot-instructions.md
---
## UI Design
### Toggle Placement
A dedicated row between the `ModalDialog` heading and the mode toggle ("Specific Days" / "Every X Days"):
```
┌────────────────────────────────────┐
│ 🛏️ Schedule Chore │ heading (unchanged)
│ Make your bed │
│ │
│ Schedule ━━━━━━━━━━━━━━━● ON │ ← new toggle row
│ │
│ [Specific Days] [Every X Days] │ mode toggle (unchanged)
│ ... │
└────────────────────────────────────┘
```
- Adds ~44px of height; does not modify `ModalDialog.vue`.
- Sits above all config, reflecting its role as the highest-priority decision ("Is this schedule even active?").
### Toggle Widget
Clean slider (no text inside the track) + external label:
- **ON:** Track = `var(--btn-primary)` (#667eea), label = "Enabled"
- **OFF:** Track = `var(--form-input-border)` (#cbd5e1), label = "Paused"
- **Thumb:** `#fff` with subtle box-shadow
- **Dimensions:** 48×24px track, 20px circular thumb, 44px min-height row (mobile touch target)
- Uses `role="switch"` and `aria-checked` for accessibility.
### Dim-on-Disable
When the toggle is OFF, the form body (mode toggle + all form fields) receives `opacity: 0.45; pointer-events: none`. The action buttons (Cancel / Save) remain fully interactive so the user can save the "paused" state.
### Color Rationale
Green (`--btn-green`) is reserved for "reward" semantics in this app. Brand blue (`--btn-primary`) is the correct choice for a generic on/off state, matching the mode toggle, day chips, and primary CTA button.
---
## Data Model Changes
### Backend Model — `backend/models/chore_schedule.py`
- [x] Add field `enabled: bool = True` to `ChoreSchedule` dataclass
- [x] Add `enabled=d.get('enabled', True)` to `from_dict()`
- [x] Add `'enabled': self.enabled` to `to_dict()`
### Frontend Model — `frontend/vue-app/src/common/models.ts`
- [x] Add `enabled?: boolean` to `ChoreSchedule` interface
---
## Backend Implementation
### API — `backend/api/chore_schedule_api.py`
- [x] Accept `enabled` field in the schedule create/update endpoint payload
- [x] Persist `enabled` value through to the database
### Scheduler Logic
- [x] In `scheduleUtils.ts` (`isScheduledToday`), return `true` when `schedule.enabled === false` — a paused chore shows every day, like an unscheduled chore:
```typescript
if (schedule.enabled === false) return true; // paused = show always, like an unscheduled chore
```
- [x] In `scheduleUtils.ts` (`getDueTimeToday`), return `null` when `schedule.enabled === false` — a paused chore has no deadline or expiry:
```typescript
if (schedule.enabled === false) return null; // paused = no deadline, no expiry
```
> Note: Scheduling is evaluated client-side. The original spec referenced Python; this was updated to reflect the actual architecture.
> Note: Behavior was revised from "hide chore" to "show always, no deadline" — paused acts like the chore has no schedule at all.
### SSE Events
- [x] Existing `chore_schedule_updated` event is sufficient (the payload includes the full schedule object, which will now contain `enabled`)
---
## Backend Tests
### Unit Tests — `backend/tests/test_chore_schedule_api.py`
- [x] **test_create_schedule_enabled_by_default**: Create a schedule without specifying `enabled`; verify the persisted schedule has `enabled=True`
- [x] **test_create_schedule_with_enabled_false**: Create a schedule with `enabled=False`; verify it persists correctly
- [x] **test_update_schedule_toggle_enabled**: Create a schedule (enabled), update it with `enabled=False`, verify the change persists; toggle back to `True`, verify again
- [x] **test_enabled_field_in_get_response**: Fetch a schedule via GET; verify the `enabled` field is present in the JSON response
- [x] **test_enabled_invalid_value_returns_400**: Pass a non-boolean `enabled` value; verify 400 response
### Model Tests
- [x] **test_chore_schedule_from_dict_defaults_enabled**: Call `ChoreSchedule.from_dict({...})` without `enabled`; assert `enabled is True`
- [x] **test_chore_schedule_from_dict_enabled_false**: Call `ChoreSchedule.from_dict({..., 'enabled': False})`; assert `enabled is False`
- [x] **test_chore_schedule_to_dict_includes_enabled**: Create a `ChoreSchedule` instance; call `to_dict()`; assert `'enabled'` key is present with correct value
---
## Frontend Implementation
### Component — `frontend/vue-app/src/components/shared/ScheduleModal.vue`
#### Template
- [x] Add toggle row between `ModalDialog` open and mode toggle
- [x] Wrap mode toggle + form content in `<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">`
- [x] Keep action buttons (Cancel / Save) outside the dim wrapper
#### Script
- [x] Add `const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)`
- [x] Add `const origEnabled = props.schedule?.enabled ?? true` for dirty tracking
- [x] Add `if (scheduleEnabled.value !== origEnabled) return true` to `isDirty` computed
- [x] Include `enabled: scheduleEnabled.value` in both `setChoreSchedule` payloads (days + interval modes)
#### CSS
- [x] Add toggle row styles (`.schedule-toggle-row`, `.toggle-label`)
- [x] Add toggle track/thumb styles (`.toggle-track`, `.toggle-track.on`, `.toggle-thumb`)
- [x] Add dim wrapper styles (`.schedule-body`, `.schedule-body.disabled`)
### Design Tokens — `frontend/vue-app/src/assets/colors.css` (optional)
- [ ] Optionally add reusable toggle tokens (not needed — existing tokens used directly)
---
## Frontend Tests
### scheduleUtils Tests — `frontend/vue-app/src/__tests__/scheduleUtils.spec.ts`
- [x] **returns true when enabled is false on a scheduled day (days mode)**: A paused schedule always returns `true` regardless of day
- [x] **returns true when enabled is false on an unscheduled day (days mode)**: A paused schedule returns `true` even on days that would normally be skipped
- [x] **returns true when enabled is false on a hit day (interval mode)**: A paused interval schedule always returns `true`
- [x] **returns true when enabled is false on a non-hit day (interval mode)**: A paused interval schedule returns `true` even on non-hit days
- [x] **getDueTimeToday returns null when paused**: A paused schedule's `getDueTimeToday` returns `null` — no deadline, no expiry
- [x] **treats enabled=undefined as enabled (backward compat)**: Schedules without `enabled` field behave as enabled
---
## CSS Spec
```css
/* ── Enable/Disable Toggle ────────────────────── */
.schedule-toggle-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.6rem;
margin-bottom: 1rem;
min-height: 44px;
}
.toggle-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--form-label, #444);
user-select: none;
}
.toggle-track {
position: relative;
width: 48px;
height: 24px;
border-radius: 999px;
border: none;
background: var(--form-input-border, #cbd5e1);
cursor: pointer;
transition: background 0.2s ease;
padding: 0;
flex-shrink: 0;
}
.toggle-track.on {
background: var(--btn-primary, #667eea);
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease;
}
.toggle-track.on .toggle-thumb {
transform: translateX(24px);
}
/* ── Dim wrapper when paused ──────────────────── */
.schedule-body {
transition: opacity 0.2s ease;
}
.schedule-body.disabled {
opacity: 0.45;
pointer-events: none;
}
```
---
## Acceptance Criteria (Definition of Done)
### Backend
- [x] `ChoreSchedule` model has `enabled: bool` field, defaulting to `True`
- [x] API accepts and persists `enabled` in create/update
- [x] Client-side scheduler: `isScheduledToday` returns `true` for paused — chore shows every day
- [x] Client-side scheduler: `getDueTimeToday` returns `null` for paused — no deadline or expiry
- [x] All backend unit tests pass (28/28)
### Frontend
- [x] Toggle row renders in `ScheduleModal` between heading and mode toggle
- [x] Toggle uses `--btn-primary` for ON and `--form-input-border` for OFF
- [x] Form body dims with `opacity: 0.45` and `pointer-events: none` when paused
- [x] `enabled` field is included in save payloads for both modes
- [x] Dirty detection tracks `enabled` changes
- [x] Toggle meets 44px minimum touch target
- [x] All frontend scheduleUtils tests pass (38/38)