diff --git a/.github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-02.md b/.github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-02.md index 865b761..e67198c 100644 --- a/.github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-02.md +++ b/.github/specs/feat-calendar-chore/feat-calendar-schedule-refactor-02.md @@ -126,42 +126,113 @@ PC: Implemented via a Tethered Popover with three clickable columns. ### Backend Models +`ChoreSchedule` changes: + +- Remove `anchor_weekday: int = 0` +- Add `anchor_date: str = ""` — ISO date string (e.g. `"2026-02-25"`). Empty string means "use today" (backward compat for old DB records). +- Add `interval_has_deadline: bool = True` — when `False`, deadline is ignored ("Anytime"). +- Change `interval_days` valid range from `[2, 7]` to `[1, 7]`. + +`from_dict` defaults: `anchor_date` defaults to `""`, `interval_has_deadline` defaults to `True` for backward compat with existing DB records. + ### Frontend Models +`ChoreSchedule` interface changes: + +- Remove `anchor_weekday: number` +- Add `anchor_date: string` +- Add `interval_has_deadline: boolean` + --- ## Frontend Design +- `DateInputField.vue` — new shared component at `frontend/vue-app/src/components/shared/DateInputField.vue` + - Props: `modelValue: string` (ISO date string), `min?: string` (ISO date, for disabling past dates), emits `update:modelValue` + - Wraps a native `` with styling matching the `TimePickerPopover` button: `--kebab-menu-border` border, `--modal-bg` background, `--secondary` text color + - Passes `min` to the native input so the browser disables past dates (no custom calendar needed) + - Fully scoped styles using CSS variables from `colors.css` + +- `ScheduleModal.vue` — "Every X Days" section fully replaced; "Specific Days" section unchanged + +--- + ## Backend Implementation +- `backend/models/chore_schedule.py` + - Remove `anchor_weekday: int = 0` + - Add `anchor_date: str = ""` + - Add `interval_has_deadline: bool = True` + - Update `from_dict` to default new fields for backward compat + +- `backend/api/chore_schedule_api.py` + - Change `interval_days` validation from `[2, 7]` to `[1, 7]` + - Accept `anchor_date` (string, ISO format) instead of `anchor_weekday` + - Accept `interval_has_deadline` (boolean) + --- ## Backend Tests -- [ ] +- [x] Update existing interval-mode tests to use `anchor_date` instead of `anchor_weekday` +- [x] Add test: `interval_days: 1` is now valid (was previously rejected) +- [x] Add test: `interval_has_deadline: false` is accepted and persisted +- [x] Add test: old DB records without `anchor_date` / `interval_has_deadline` load with correct defaults --- ## Frontend Implementation -- [ ] +- [x] Created `frontend/vue-app/src/components/shared/DateInputField.vue` + - Props: `modelValue: string` (ISO date), `min?: string`, emits `update:modelValue` + - Styled to match `TimePickerPopover` button (border, background, text color) + - Passes `min` to native `` to disable past dates + - Fully scoped styles using `colors.css` variables +- [x] Refactored `ScheduleModal.vue` — "Every X Days" section + - Removed `anchorWeekday` state; added `anchorDate: ref` (default: today ISO) and `hasDeadline: ref` (default: `true`) + - Changed `intervalDays` min from 2 → 1 + - Replaced `` with a `−` / value / `+` stepper, capped 1–7, styled with Phase 1 chip/button variables + - Replaced `` anchor weekday with `DateInputField` (min = today's ISO date) + - Replaced `TimeSelector` with `TimePickerPopover` (exact reuse from Phase 1) + - Added "Anytime" toggle link below the deadline row; when active, hides `TimePickerPopover` and sets `hasDeadline = false`; when inactive, shows `TimePickerPopover` and sets `hasDeadline = true` + - Added "Next occurrence: [Weekday, Mon DD]" computed label (pure frontend, `Intl.DateTimeFormat`): starting from `anchorDate`, add `intervalDays` days repeatedly until result ≥ today; displayed as subtle italic label beneath the form rows (same style as Phase 1's "Default (HH:MM AM/PM)" label) + - Load logic: read `schedule.anchor_date` (default to today if empty), `schedule.interval_has_deadline`, `schedule.interval_days` (clamped to ≥1) + - Save logic: write `anchor_date`, `interval_has_deadline`; always write `interval_hour`/`interval_minute` (backend ignores them when `interval_has_deadline=false`) + - "Specific Days" mode left unchanged --- ## Frontend Tests +- [x] `DateInputField.vue`: renders the formatted date value; emits `update:modelValue` on change; `min` prop prevents selection of past dates +- [x] `ScheduleModal.vue` (Every X Days): stepper clamps to 1–7 at both ends; "Anytime" toggle hides the time picker and sets flag; restoring deadline shows the time picker; save payload contains `anchor_date`, `interval_has_deadline`, and correct `interval_days`; next occurrence label updates correctly when interval or anchor date changes; loading an existing schedule restores all fields including `anchor_date` and `interval_has_deadline` + --- ## Future Considerations +- A fully custom calendar (bottom sheet on mobile, tethered popover on desktop) could replace `DateInputField` in a future phase for a more polished mobile experience. +- `TimePickerPopover` could similarly gain a bottom-sheet variant for mobile. + --- ## Acceptance Criteria (Definition of Done) ### Backend -- [ ] +- [x] `anchor_weekday` removed; `anchor_date` (string) added with empty-string default for old records +- [x] `interval_has_deadline` (bool) added, defaults to `True` for old records +- [x] `interval_days` valid range updated to `[1, 7]` +- [x] All existing and new backend tests pass ### Frontend -- [ ] +- [x] New `DateInputField` component: styled native date input, respects `min`, emits ISO string +- [x] "Every X Days" mode shows `−`/`+` stepper for interval (1–7), `DateInputField` for anchor date, `TimePickerPopover` for deadline +- [x] "Anytime" toggle clears the deadline (sets `interval_has_deadline = false`) and hides the time picker +- [x] "Next occurrence" label computes and displays the next date ≥ today based on anchor + interval +- [x] Past dates are disabled in the date input (via `min`) +- [x] Existing schedules load correctly — `anchor_date` restored, `interval_has_deadline` restored +- [x] Save payload is valid and consumed by the existing API unchanged +- [x] "Specific Days" mode is unchanged +- [x] Frontend component tests written and passing for `DateInputField` and the refactored `ScheduleModal` interval section diff --git a/backend/api/chore_schedule_api.py b/backend/api/chore_schedule_api.py index 125cfbf..3dd65d7 100644 --- a/backend/api/chore_schedule_api.py +++ b/backend/api/chore_schedule_api.py @@ -70,19 +70,23 @@ def set_chore_schedule(child_id, task_id): ) else: interval_days = data.get('interval_days', 2) - anchor_weekday = data.get('anchor_weekday', 0) + anchor_date = data.get('anchor_date', '') + interval_has_deadline = data.get('interval_has_deadline', True) 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 + if not isinstance(interval_days, int) or not (1 <= interval_days <= 7): + return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400 + if not isinstance(anchor_date, str): + return jsonify({'error': 'anchor_date must be a string', 'code': ErrorCodes.INVALID_VALUE}), 400 + if not isinstance(interval_has_deadline, bool): + return jsonify({'error': 'interval_has_deadline must be a boolean', '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, + anchor_date=anchor_date, + interval_has_deadline=interval_has_deadline, interval_hour=interval_hour, interval_minute=interval_minute, ) diff --git a/backend/models/chore_schedule.py b/backend/models/chore_schedule.py index d06ea5a..5a748ee 100644 --- a/backend/models/chore_schedule.py +++ b/backend/models/chore_schedule.py @@ -37,8 +37,9 @@ class ChoreSchedule(BaseModel): default_minute: int = 0 # master deadline minute for 'days' mode # mode='interval' fields - interval_days: int = 2 # 2–7 - anchor_weekday: int = 0 # 0=Sun–6=Sat + interval_days: int = 2 # 1–7 + anchor_date: str = "" # ISO date string e.g. "2026-02-25"; "" = use today + interval_has_deadline: bool = True # False = "Anytime" (no deadline) interval_hour: int = 0 interval_minute: int = 0 @@ -52,7 +53,8 @@ class ChoreSchedule(BaseModel): default_hour=d.get('default_hour', 8), default_minute=d.get('default_minute', 0), interval_days=d.get('interval_days', 2), - anchor_weekday=d.get('anchor_weekday', 0), + anchor_date=d.get('anchor_date', ''), + interval_has_deadline=d.get('interval_has_deadline', True), interval_hour=d.get('interval_hour', 0), interval_minute=d.get('interval_minute', 0), id=d.get('id'), @@ -70,7 +72,8 @@ class ChoreSchedule(BaseModel): 'default_hour': self.default_hour, 'default_minute': self.default_minute, 'interval_days': self.interval_days, - 'anchor_weekday': self.anchor_weekday, + 'anchor_date': self.anchor_date, + 'interval_has_deadline': self.interval_has_deadline, 'interval_hour': self.interval_hour, 'interval_minute': self.interval_minute, }) diff --git a/backend/tests/test_chore_schedule_api.py b/backend/tests/test_chore_schedule_api.py index 29b3fd8..175b36a 100644 --- a/backend/tests/test_chore_schedule_api.py +++ b/backend/tests/test_chore_schedule_api.py @@ -117,7 +117,8 @@ def test_set_schedule_interval_mode(client): payload = { "mode": "interval", "interval_days": 3, - "anchor_weekday": 2, + "anchor_date": "2026-03-01", + "interval_has_deadline": True, "interval_hour": 14, "interval_minute": 30, } @@ -126,25 +127,57 @@ def test_set_schedule_interval_mode(client): data = resp.get_json() assert data["mode"] == "interval" assert data["interval_days"] == 3 - assert data["anchor_weekday"] == 2 + assert data["anchor_date"] == "2026-03-01" + assert data["interval_has_deadline"] is True 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} +def test_set_schedule_interval_days_1_valid(client): + """interval_days=1 is now valid (range changed to [1, 7]).""" + payload = {"mode": "interval", "interval_days": 1, "anchor_date": ""} + resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) + assert resp.status_code == 200 + assert resp.get_json()["interval_days"] == 1 + + +def test_set_schedule_interval_days_0_invalid(client): + """interval_days=0 is still out of range.""" + payload = {"mode": "interval", "interval_days": 0, "anchor_date": ""} 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} +def test_set_schedule_interval_days_8_invalid(client): + """interval_days=8 is still out of range.""" + payload = {"mode": "interval", "interval_days": 8, "anchor_date": ""} 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_has_deadline_false(client): + """interval_has_deadline=False is accepted and persisted.""" + payload = { + "mode": "interval", + "interval_days": 2, + "anchor_date": "", + "interval_has_deadline": False, + "interval_hour": 0, + "interval_minute": 0, + } + resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) + assert resp.status_code == 200 + assert resp.get_json()["interval_has_deadline"] is False + + +def test_set_schedule_interval_anchor_date_empty_string(client): + """anchor_date empty string is valid (means use today).""" + payload = {"mode": "interval", "interval_days": 2, "anchor_date": ""} + resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload) + assert resp.status_code == 200 + assert resp.get_json()["anchor_date"] == "" + + 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) @@ -160,13 +193,56 @@ def test_set_schedule_upserts_existing(client): # 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}, + json={"mode": "interval", "interval_days": 2, "anchor_date": "", "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" +def test_old_record_missing_new_fields_loads_with_defaults(client): + """Old DB records without anchor_date/interval_has_deadline load with correct defaults.""" + from db.chore_schedules import upsert_schedule + from models.chore_schedule import ChoreSchedule + + # Insert a schedule as if it was created before phase 2 (missing new fields) + old_style = ChoreSchedule( + child_id=TEST_CHILD_ID, + task_id=TEST_TASK_ID, + mode='interval', + interval_days=3, + anchor_date='', # default value + interval_has_deadline=True, # default value + interval_hour=8, + interval_minute=0, + ) + upsert_schedule(old_style) + + # Manually wipe the new fields from the raw stored record to simulate a pre-phase-2 record + from db.db import chore_schedules_db + from tinydb import Query + ScheduleQ = Query() + record = chore_schedules_db.search( + (ScheduleQ.child_id == TEST_CHILD_ID) & (ScheduleQ.task_id == TEST_TASK_ID) + )[0] + doc_id = chore_schedules_db.get( + (ScheduleQ.child_id == TEST_CHILD_ID) & (ScheduleQ.task_id == TEST_TASK_ID) + ).doc_id + chore_schedules_db.update( + lambda rec: ( + rec.pop('anchor_date', None), + rec.pop('interval_has_deadline', None), + ), + doc_ids=[doc_id], + ) + + resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule') + assert resp.status_code == 200 + data = resp.get_json() + assert data['anchor_date'] == '' + assert data['interval_has_deadline'] is True + + # --------------------------------------------------------------------------- # DELETE schedule # --------------------------------------------------------------------------- diff --git a/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts b/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts index 6474053..ecf401e 100644 --- a/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts +++ b/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts @@ -8,9 +8,11 @@ import type { ChildTask, ChoreSchedule } from '../common/models' // Mocks // --------------------------------------------------------------------------- const mockSetChoreSchedule = vi.fn() +const mockDeleteChoreSchedule = vi.fn() vi.mock('@/common/api', () => ({ setChoreSchedule: (...args: unknown[]) => mockSetChoreSchedule(...args), + deleteChoreSchedule: (...args: unknown[]) => mockDeleteChoreSchedule(...args), parseErrorResponse: vi.fn().mockResolvedValue({ msg: 'error', code: 'ERR' }), })) @@ -19,11 +21,17 @@ const ModalDialogStub = { template: '', props: ['imageUrl', 'title', 'subtitle'], } -const TimeSelectorStub = { - template: '', +const TimePickerPopoverStub = { + template: '', props: ['modelValue'], emits: ['update:modelValue'], } +const DateInputFieldStub = { + template: + '', + props: ['modelValue', 'min'], + emits: ['update:modelValue'], +} // --------------------------------------------------------------------------- // Helpers @@ -35,16 +43,21 @@ function mountModal(schedule: ChoreSchedule | null = null) { return mount(ScheduleModal, { props: { task: TASK, childId: CHILD_ID, schedule }, global: { - stubs: { ModalDialog: ModalDialogStub, TimeSelector: TimeSelectorStub }, + stubs: { + ModalDialog: ModalDialogStub, + TimePickerPopover: TimePickerPopoverStub, + DateInputField: DateInputFieldStub, + }, }, }) } beforeEach(() => { mockSetChoreSchedule.mockReset() + mockDeleteChoreSchedule.mockReset() mockSetChoreSchedule.mockResolvedValue({ ok: true }) + mockDeleteChoreSchedule.mockResolvedValue({ ok: true }) }) - // --------------------------------------------------------------------------- // Mode toggle // --------------------------------------------------------------------------- @@ -89,57 +102,59 @@ describe('ScheduleModal mode toggle', () => { }) // --------------------------------------------------------------------------- -// Specific Days form — check/uncheck days +// Specific Days form — chip toggles // --------------------------------------------------------------------------- describe('ScheduleModal Specific Days form', () => { - it('renders 7 day rows', () => { + it('renders 7 day chips', () => { const w = mountModal() - expect(w.findAll('.day-row').length).toBe(7) + expect(w.findAll('.chip').length).toBe(7) }) - it('no days are checked by default (no existing schedule)', () => { + it('no chips are active by default (no existing schedule)', () => { const w = mountModal() - const checkboxes = w.findAll('input[type="checkbox"]') - expect(checkboxes.every((cb) => !(cb.element as HTMLInputElement).checked)).toBe(true) + const chips = w.findAll('.chip') + expect(chips.every((c) => !c.classes().includes('active'))).toBe(true) }) - it('checking a day reveals a TimeSelector for that day', async () => { + it('clicking a chip makes it active', 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) + const chips = w.findAll('.chip') + await chips[1].trigger('click') // Monday + expect(chips[1].classes()).toContain('active') }) - it('unchecking a day removes its TimeSelector', async () => { + it('clicking an active chip deactivates it', 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) + const chips = w.findAll('.chip') + await chips[1].trigger('click') // activate + await chips[1].trigger('click') // deactivate + expect(chips[1].classes()).not.toContain('active') }) - it('Save is disabled when no days checked (days mode)', () => { + it('selecting a chip shows the default-deadline-row', async () => { + const w = mountModal() + expect(w.find('.default-deadline-row').exists()).toBe(false) + await w.findAll('.chip')[0].trigger('click') + expect(w.find('.default-deadline-row').exists()).toBe(true) + }) + + it('Save is disabled when no days selected (isDirty is false)', () => { 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 () => { + it('Save is enabled after selecting at least one day', async () => { const w = mountModal() - const checkboxes = w.findAll('input[type="checkbox"]') - await checkboxes[2].trigger('change') // Tuesday + await w.findAll('.chip')[2].trigger('click') // 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', () => { + it('pre-populates active chips from an existing schedule', () => { const existing: ChoreSchedule = { + id: 's1', child_id: CHILD_ID, task_id: TASK.id, mode: 'days', @@ -147,14 +162,20 @@ describe('ScheduleModal Specific Days form', () => { { day: 1, hour: 8, minute: 0 }, { day: 4, hour: 9, minute: 30 }, ], - interval_days: 2, - anchor_weekday: 0, + default_hour: 8, + default_minute: 0, + interval_days: 1, + anchor_date: '', + interval_has_deadline: true, interval_hour: 0, interval_minute: 0, + created_at: 0, + updated_at: 0, } const w = mountModal(existing) - // Two TimeSelectorStubs should already be visible - expect(w.findAll('.time-selector-stub').length).toBe(2) + const chips = w.findAll('.chip') + expect(chips[1].classes()).toContain('active') // Monday idx 1 + expect(chips[4].classes()).toContain('active') // Thursday idx 4 }) }) @@ -164,9 +185,8 @@ describe('ScheduleModal Specific Days form', () => { 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') + // Select Monday (chip idx 1) + await w.findAll('.chip')[1].trigger('click') await nextTick() await w.find('.btn-primary').trigger('click') @@ -177,20 +197,45 @@ describe('ScheduleModal save — days mode', () => { TASK.id, expect.objectContaining({ mode: 'days', - day_configs: [{ day: 1, hour: 8, minute: 0 }], + day_configs: [{ day: 1, hour: expect.any(Number), minute: expect.any(Number) }], }), ) }) + it('calls deleteChoreSchedule when 0 days selected on an existing schedule', async () => { + const existing: ChoreSchedule = { + id: 's1', + child_id: CHILD_ID, + task_id: TASK.id, + mode: 'days', + day_configs: [{ day: 2, hour: 8, minute: 0 }], + default_hour: 8, + default_minute: 0, + interval_days: 1, + anchor_date: '', + interval_has_deadline: true, + interval_hour: 0, + interval_minute: 0, + created_at: 0, + updated_at: 0, + } + const w = mountModal(existing) + await w.findAll('.chip')[2].trigger('click') // deselect Wednesday + await nextTick() + await w.find('.btn-primary').trigger('click') + await nextTick() + await nextTick() + expect(mockDeleteChoreSchedule).toHaveBeenCalledWith(CHILD_ID, TASK.id) + }) + 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 w.findAll('.chip')[0].trigger('click') // Sunday await nextTick() await w.find('.btn-primary').trigger('click') await nextTick() - await nextTick() // await the async save() + await nextTick() expect(w.emitted('saved')).toBeTruthy() }) @@ -198,8 +243,7 @@ describe('ScheduleModal save — days mode', () => { 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 w.findAll('.chip')[0].trigger('click') await nextTick() await w.find('.btn-primary').trigger('click') @@ -210,22 +254,138 @@ describe('ScheduleModal save — days mode', () => { }) }) +// --------------------------------------------------------------------------- +// Interval form — stepper +// --------------------------------------------------------------------------- +describe('ScheduleModal interval form — stepper', () => { + async function openInterval(schedule: ChoreSchedule | null = null) { + const w = mountModal(schedule) + await w.findAll('.mode-btn')[1].trigger('click') + return w + } + + it('default stepper value is 1', async () => { + const w = await openInterval() + expect(w.find('.stepper-value').text()).toBe('1') + }) + + it('increment button increases the value', async () => { + const w = await openInterval() + await w.findAll('.stepper-btn')[1].trigger('click') + expect(w.find('.stepper-value').text()).toBe('2') + }) + + it('decrement button is disabled when value is 1', async () => { + const w = await openInterval() + const decrementBtn = w.findAll('.stepper-btn')[0] + expect((decrementBtn.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('increment button is disabled when value is 7', async () => { + const w = await openInterval() + const plusBtn = w.findAll('.stepper-btn')[1] + for (let i = 0; i < 6; i++) await plusBtn.trigger('click') + expect((plusBtn.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('value does not go above 7', async () => { + const w = await openInterval() + const plusBtn = w.findAll('.stepper-btn')[1] + for (let i = 0; i < 10; i++) await plusBtn.trigger('click') + expect(w.find('.stepper-value').text()).toBe('7') + }) + + it('pre-populates interval_days from existing schedule', async () => { + const existing: ChoreSchedule = { + id: 's2', + child_id: CHILD_ID, + task_id: TASK.id, + mode: 'interval', + day_configs: [], + interval_days: 4, + anchor_date: '2026-03-10', + interval_has_deadline: true, + interval_hour: 0, + interval_minute: 0, + created_at: 0, + updated_at: 0, + } + const w = await openInterval(existing) + expect(w.find('.stepper-value').text()).toBe('4') + }) +}) + +// --------------------------------------------------------------------------- +// Interval form — Anytime toggle +// --------------------------------------------------------------------------- +describe('ScheduleModal interval form — Anytime toggle', () => { + async function openInterval() { + const w = mountModal() + await w.findAll('.mode-btn')[1].trigger('click') + return w + } + + it('shows TimePickerPopover by default (hasDeadline=true)', async () => { + const w = await openInterval() + expect(w.find('.time-picker-popover-stub').exists()).toBe(true) + expect(w.find('.anytime-label').exists()).toBe(false) + }) + + it('shows "Clear (Anytime)" link by default', async () => { + const w = await openInterval() + const link = w.find('.interval-time-row .link-btn') + expect(link.text()).toContain('Clear') + }) + + it('clicking "Clear (Anytime)" hides TimePickerPopover and shows anytime label', async () => { + const w = await openInterval() + await w.find('.interval-time-row .link-btn').trigger('click') + await nextTick() + expect(w.find('.time-picker-popover-stub').exists()).toBe(false) + expect(w.find('.anytime-label').exists()).toBe(true) + }) + + it('clicking "Set deadline" after clearing restores TimePickerPopover', async () => { + const w = await openInterval() + await w.find('.interval-time-row .link-btn').trigger('click') + await nextTick() + await w.find('.interval-time-row .link-btn').trigger('click') + await nextTick() + expect(w.find('.time-picker-popover-stub').exists()).toBe(true) + expect(w.find('.anytime-label').exists()).toBe(false) + }) + + it('pre-populates interval_has_deadline=false from existing schedule', () => { + const existing: ChoreSchedule = { + id: 's3', + child_id: CHILD_ID, + task_id: TASK.id, + mode: 'interval', + day_configs: [], + interval_days: 2, + anchor_date: '', + interval_has_deadline: false, + interval_hour: 0, + interval_minute: 0, + created_at: 0, + updated_at: 0, + } + const w = mountModal(existing) + expect(w.find('.anytime-label').exists()).toBe(true) + expect(w.find('.time-picker-popover-stub').exists()).toBe(false) + }) +}) + // --------------------------------------------------------------------------- // Save emits correct ChoreSchedule shape — interval mode // --------------------------------------------------------------------------- describe('ScheduleModal save — interval mode', () => { - it('calls setChoreSchedule with correct interval payload', async () => { + it('calls setChoreSchedule with anchor_date and interval_has_deadline in 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('.interval-input') - await intervalInput.setValue(3) - - // Set anchor_weekday to 2 (Tuesday) - const anchorSelect = w.find('.anchor-select') - await anchorSelect.setValue(2) + const plusBtn = w.findAll('.stepper-btn')[1] + await plusBtn.trigger('click') // interval_days = 2 + await plusBtn.trigger('click') // interval_days = 3 await w.find('.btn-primary').trigger('click') await nextTick() @@ -236,38 +396,72 @@ describe('ScheduleModal save — interval mode', () => { expect.objectContaining({ mode: 'interval', interval_days: 3, - anchor_weekday: 2, + anchor_date: expect.any(String), + interval_has_deadline: true, interval_hour: expect.any(Number), interval_minute: expect.any(Number), }), ) }) - it('Save enabled by default in interval mode (interval_days defaults to 2)', async () => { + it('payload has interval_has_deadline=false when Anytime toggle is active', async () => { + const w = mountModal() + await w.findAll('.mode-btn')[1].trigger('click') + await w.find('.interval-time-row .link-btn').trigger('click') // Clear + await nextTick() + await w.find('.btn-primary').trigger('click') + await nextTick() + expect(mockSetChoreSchedule).toHaveBeenCalledWith( + CHILD_ID, + TASK.id, + expect.objectContaining({ interval_has_deadline: false }), + ) + }) + + it('Save enabled by default in interval mode', 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', () => { + it('pre-populates anchor_date in the DateInputField stub', () => { const existing: ChoreSchedule = { + id: 's4', child_id: CHILD_ID, task_id: TASK.id, mode: 'interval', day_configs: [], - interval_days: 4, - anchor_weekday: 3, - interval_hour: 14, - interval_minute: 30, + interval_days: 2, + anchor_date: '2026-04-01', + interval_has_deadline: true, + interval_hour: 8, + interval_minute: 0, + created_at: 0, + updated_at: 0, } const w = mountModal(existing) - // Should default to interval mode - expect(w.find('.interval-form').exists()).toBe(true) - const input = w.find('.interval-input') - expect(Number((input.element as HTMLInputElement).value)).toBe(4) - const select = w.find('.anchor-select') - expect(Number((select.element as HTMLSelectElement).value)).toBe(3) + const dateInput = w.find('.date-input-field-stub') + expect((dateInput.element as HTMLInputElement).value).toBe('2026-04-01') + }) +}) + +// --------------------------------------------------------------------------- +// Interval form — next occurrence label +// --------------------------------------------------------------------------- +describe('ScheduleModal interval form — next occurrence', () => { + it('shows next occurrences label in interval mode', async () => { + const w = mountModal() + await w.findAll('.mode-btn')[1].trigger('click') + await nextTick() + expect(w.find('.next-occurrence-label').exists()).toBe(true) + }) + + it('next occurrences label contains "Next occurrences:"', async () => { + const w = mountModal() + await w.findAll('.mode-btn')[1].trigger('click') + await nextTick() + expect(w.find('.next-occurrence-label').text()).toContain('Next occurrences:') }) }) diff --git a/frontend/vue-app/src/__tests__/scheduleUtils.spec.ts b/frontend/vue-app/src/__tests__/scheduleUtils.spec.ts index 4c4c851..6de0035 100644 --- a/frontend/vue-app/src/__tests__/scheduleUtils.spec.ts +++ b/frontend/vue-app/src/__tests__/scheduleUtils.spec.ts @@ -31,26 +31,35 @@ describe('toLocalISODate', () => { // --------------------------------------------------------------------------- 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) + expect(intervalHitsToday('2025-01-08', 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(2025-01-08), interval=2, Thursday does NOT hit', () => { + const thursday = new Date(2025, 0, 9) // 1 day after anchor, 1 % 2 !== 0 + expect(intervalHitsToday('2025-01-08', 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('anchor=Wednesday(2025-01-08), interval=2, Friday hits (2 days after anchor)', () => { + const friday = new Date(2025, 0, 10) // 2 days after anchor, 2 % 2 === 0 + expect(intervalHitsToday('2025-01-08', 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) + const monday = new Date(2025, 0, 6) // anchor is that same Monday + expect(intervalHitsToday('2025-01-06', 1, monday)).toBe(true) + const tuesday = new Date(2025, 0, 7) + expect(intervalHitsToday('2025-01-06', 1, tuesday)).toBe(true) + }) + + it('returns false for a date before the anchor', () => { + const beforeAnchor = new Date(2025, 0, 7) // Jan 7 is before Jan 8 anchor + expect(intervalHitsToday('2025-01-08', 2, beforeAnchor)).toBe(false) + }) + + it('empty anchor string treats localDate as the anchor (always hits)', () => { + const today = new Date(2025, 0, 8) + expect(intervalHitsToday('', 2, today)).toBe(true) }) }) @@ -67,7 +76,8 @@ describe('isScheduledToday', () => { { day: 3, hour: 9, minute: 30 }, // Wednesday ], interval_days: 2, - anchor_weekday: 0, + anchor_date: '', + interval_has_deadline: true, interval_hour: 0, interval_minute: 0, } @@ -89,7 +99,8 @@ describe('isScheduledToday', () => { mode: 'interval', day_configs: [], interval_days: 2, - anchor_weekday: 3, // Wednesday + anchor_date: '2025-01-08', // Wednesday + interval_has_deadline: true, interval_hour: 8, interval_minute: 0, } @@ -110,7 +121,8 @@ describe('getDueTimeToday', () => { mode: 'days', day_configs: [{ day: 1, hour: 8, minute: 30 }], interval_days: 2, - anchor_weekday: 0, + anchor_date: '', + interval_has_deadline: true, interval_hour: 0, interval_minute: 0, } @@ -132,7 +144,8 @@ describe('getDueTimeToday', () => { mode: 'interval', day_configs: [], interval_days: 2, - anchor_weekday: 3, + anchor_date: '2025-01-08', + interval_has_deadline: true, interval_hour: 14, interval_minute: 45, } @@ -147,13 +160,31 @@ describe('getDueTimeToday', () => { mode: 'interval', day_configs: [], interval_days: 2, - anchor_weekday: 3, + anchor_date: '2025-01-08', + interval_has_deadline: true, interval_hour: 14, interval_minute: 45, } const thursday = new Date(2025, 0, 9) expect(getDueTimeToday(intervalSchedule, thursday)).toBeNull() }) + + it('interval mode with interval_has_deadline=false returns null (Anytime)', () => { + const intervalSchedule: ChoreSchedule = { + child_id: 'c1', + task_id: 't1', + mode: 'interval', + day_configs: [], + interval_days: 2, + anchor_date: '2025-01-08', + interval_has_deadline: false, + interval_hour: 14, + interval_minute: 45, + } + const wednesday = new Date(2025, 0, 8) + // Even on a hit day, Anytime means no deadline → null + expect(getDueTimeToday(intervalSchedule, wednesday)).toBeNull() + }) }) // --------------------------------------------------------------------------- diff --git a/frontend/vue-app/src/common/models.ts b/frontend/vue-app/src/common/models.ts index de79155..649dddf 100644 --- a/frontend/vue-app/src/common/models.ts +++ b/frontend/vue-app/src/common/models.ts @@ -24,8 +24,9 @@ export interface ChoreSchedule { default_hour?: number // master deadline hour; present when mode='days' default_minute?: number // master deadline minute; present when mode='days' // mode='interval' - interval_days: number // 2–7 - anchor_weekday: number // 0=Sun–6=Sat + interval_days: number // 1–7 + anchor_date: string // ISO date string e.g. "2026-02-25"; "" means use today + interval_has_deadline: boolean // false = "Anytime" (no deadline) interval_hour: number interval_minute: number created_at: number diff --git a/frontend/vue-app/src/common/scheduleUtils.ts b/frontend/vue-app/src/common/scheduleUtils.ts index f419f12..dde6ef4 100644 --- a/frontend/vue-app/src/common/scheduleUtils.ts +++ b/frontend/vue-app/src/common/scheduleUtils.ts @@ -11,27 +11,32 @@ function getLocalWeekday(d: Date): number { /** * 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. + * Anchor: the ISO date string from `anchor_date` (e.g. "2026-02-26"). + * An empty string means "use localDate as anchor" (backward compat) which + * hits on today (diffDays=0) and every intervalDays days after. + * + * Dates before the anchor always return false (scheduling hasn't started yet). */ export function intervalHitsToday( - anchorWeekday: number, + anchorDate: string, intervalDays: number, localDate: Date, ): boolean { - const todayWeekday = getLocalWeekday(localDate) + // Parse anchor: use localDate itself when anchor is empty (backward compat) + let anchor: Date + if (anchorDate) { + const [y, m, d] = anchorDate.split('-').map(Number) + anchor = new Date(y, m - 1, d, 0, 0, 0, 0) + } else { + anchor = new Date(localDate) + anchor.setHours(0, 0, 0, 0) + } - // 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 target = new Date(localDate) + target.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) + const diffDays = Math.round((target.getTime() - anchor.getTime()) / 86_400_000) + if (diffDays < 0) return false // before anchor — not started yet return diffDays % intervalDays === 0 } @@ -45,13 +50,16 @@ export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): bool const todayWeekday = getLocalWeekday(localDate) return schedule.day_configs.some((dc: DayConfig) => dc.day === todayWeekday) } else { - return intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate) + return intervalHitsToday(schedule.anchor_date ?? '', 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). + * Returns the due time {hour, minute} for today, or null if: + * - the day is not scheduled, OR + * - the schedule has no deadline (interval_has_deadline === false → "Anytime") + * + * Callers treat null as "active all day with no expiry". */ export function getDueTimeToday( schedule: ChoreSchedule, @@ -63,7 +71,10 @@ export function getDueTimeToday( if (!dayConfig) return null return { hour: dayConfig.hour, minute: dayConfig.minute } } else { - if (!intervalHitsToday(schedule.anchor_weekday, schedule.interval_days, localDate)) return null + if (!intervalHitsToday(schedule.anchor_date ?? '', schedule.interval_days, localDate)) + return null + // interval_has_deadline === false means "Anytime" — no expiry time + if (schedule.interval_has_deadline === false) return null return { hour: schedule.interval_hour, minute: schedule.interval_minute } } } diff --git a/frontend/vue-app/src/components/shared/DateInputField.vue b/frontend/vue-app/src/components/shared/DateInputField.vue new file mode 100644 index 0000000..d43445f --- /dev/null +++ b/frontend/vue-app/src/components/shared/DateInputField.vue @@ -0,0 +1,39 @@ + + + + + + + diff --git a/frontend/vue-app/src/components/shared/ScheduleModal.vue b/frontend/vue-app/src/components/shared/ScheduleModal.vue index 1cc3ca4..64289f9 100644 --- a/frontend/vue-app/src/components/shared/ScheduleModal.vue +++ b/frontend/vue-app/src/components/shared/ScheduleModal.vue @@ -56,17 +56,59 @@ - - Every - - days, starting on - - {{ label }} - + + + + Every + + + − + + {{ intervalDays }} + + + + + + {{ intervalDays === 1 ? 'day' : 'days' }} + + + Starting on + + + + - Due by - + Deadline + + Anytime + + {{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }} + + + + + + Next occurrences: + {{ d }} @@ -87,8 +129,8 @@