From a197f8e206085331584b5a825e73fc85c0b18c61 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Thu, 26 Feb 2026 15:16:46 -0500 Subject: [PATCH] feat: Refactor ScheduleModal to support interval scheduling with date input and deadline toggle - Updated ChoreSchedule model to include anchor_date and interval_has_deadline. - Refactored interval scheduling logic in scheduleUtils to use anchor_date. - Introduced DateInputField component for selecting anchor dates in ScheduleModal. - Enhanced ScheduleModal to include a stepper for interval days and a toggle for deadline. - Updated tests for ScheduleModal and scheduleUtils to reflect new interval scheduling logic. - Added DateInputField tests to ensure proper functionality and prop handling. --- .../feat-calendar-schedule-refactor-02.md | 79 ++++- backend/api/chore_schedule_api.py | 16 +- backend/models/chore_schedule.py | 11 +- backend/tests/test_chore_schedule_api.py | 94 ++++- .../src/__tests__/ScheduleModal.spec.ts | 324 ++++++++++++++---- .../src/__tests__/scheduleUtils.spec.ts | 65 +++- frontend/vue-app/src/common/models.ts | 5 +- frontend/vue-app/src/common/scheduleUtils.ts | 47 ++- .../src/components/shared/DateInputField.vue | 39 +++ .../src/components/shared/ScheduleModal.vue | 215 ++++++++++-- .../shared/__tests__/DateInputField.spec.ts | 45 +++ frontend/vue-app/vitest.config.ts | 29 +- 12 files changed, 797 insertions(+), 172 deletions(-) create mode 100644 frontend/vue-app/src/components/shared/DateInputField.vue create mode 100644 frontend/vue-app/src/components/shared/__tests__/DateInputField.spec.ts 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 `', + 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 @@
-
- - - days, starting on - + +
+
+ +
+ + {{ intervalDays }} + +
+ {{ intervalDays === 1 ? 'day' : 'days' }} +
+
+ + +
+ +
- - + + + Anytime + +
+ + +
+ Next occurrences: + {{ d }}
@@ -87,8 +129,8 @@