diff --git a/backend/api/chore_schedule_api.py b/backend/api/chore_schedule_api.py index 3dd65d7..9d58352 100644 --- a/backend/api/chore_schedule_api.py +++ b/backend/api/chore_schedule_api.py @@ -60,6 +60,7 @@ def set_chore_schedule(child_id, task_id): return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400 default_hour = data.get('default_hour', 8) default_minute = data.get('default_minute', 0) + default_has_deadline = data.get('default_has_deadline', True) schedule = ChoreSchedule( child_id=child_id, task_id=task_id, @@ -67,6 +68,7 @@ def set_chore_schedule(child_id, task_id): day_configs=day_configs, default_hour=default_hour, default_minute=default_minute, + default_has_deadline=default_has_deadline, ) else: interval_days = data.get('interval_days', 2) diff --git a/backend/models/chore_schedule.py b/backend/models/chore_schedule.py index 5a748ee..f0bd97c 100644 --- a/backend/models/chore_schedule.py +++ b/backend/models/chore_schedule.py @@ -35,6 +35,7 @@ class ChoreSchedule(BaseModel): day_configs: list = field(default_factory=list) # list of DayConfig dicts default_hour: int = 8 # master deadline hour for 'days' mode default_minute: int = 0 # master deadline minute for 'days' mode + default_has_deadline: bool = True # False = 'Anytime', no expiry for 'days' mode # mode='interval' fields interval_days: int = 2 # 1–7 @@ -52,6 +53,7 @@ class ChoreSchedule(BaseModel): day_configs=d.get('day_configs', []), default_hour=d.get('default_hour', 8), default_minute=d.get('default_minute', 0), + default_has_deadline=d.get('default_has_deadline', True), interval_days=d.get('interval_days', 2), anchor_date=d.get('anchor_date', ''), interval_has_deadline=d.get('interval_has_deadline', True), @@ -71,6 +73,7 @@ class ChoreSchedule(BaseModel): 'day_configs': self.day_configs, 'default_hour': self.default_hour, 'default_minute': self.default_minute, + 'default_has_deadline': self.default_has_deadline, 'interval_days': self.interval_days, 'anchor_date': self.anchor_date, 'interval_has_deadline': self.interval_has_deadline, diff --git a/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts b/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts index ecf401e..92b9361 100644 --- a/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts +++ b/frontend/vue-app/src/__tests__/ScheduleModal.spec.ts @@ -450,18 +450,18 @@ describe('ScheduleModal save — interval mode', () => { // Interval form — next occurrence label // --------------------------------------------------------------------------- describe('ScheduleModal interval form — next occurrence', () => { - it('shows next occurrences label in interval mode', async () => { + it('shows next occurrence 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 () => { + it('next occurrence label contains "Next occurrence:"', async () => { const w = mountModal() await w.findAll('.mode-btn')[1].trigger('click') await nextTick() - expect(w.find('.next-occurrence-label').text()).toContain('Next occurrences:') + expect(w.find('.next-occurrence-label').text()).toContain('Next occurrence:') }) }) diff --git a/frontend/vue-app/src/common/models.ts b/frontend/vue-app/src/common/models.ts index 649dddf..55342b1 100644 --- a/frontend/vue-app/src/common/models.ts +++ b/frontend/vue-app/src/common/models.ts @@ -23,6 +23,7 @@ export interface ChoreSchedule { day_configs: DayConfig[] default_hour?: number // master deadline hour; present when mode='days' default_minute?: number // master deadline minute; present when mode='days' + default_has_deadline?: boolean // false = 'Anytime', no expiry for 'days' mode // mode='interval' interval_days: number // 1–7 anchor_date: string // ISO date string e.g. "2026-02-25"; "" means use today diff --git a/frontend/vue-app/src/common/scheduleUtils.ts b/frontend/vue-app/src/common/scheduleUtils.ts index dde6ef4..a2c3364 100644 --- a/frontend/vue-app/src/common/scheduleUtils.ts +++ b/frontend/vue-app/src/common/scheduleUtils.ts @@ -66,6 +66,8 @@ export function getDueTimeToday( localDate: Date, ): { hour: number; minute: number } | null { if (schedule.mode === 'days') { + // default_has_deadline === false means 'Anytime' — no expiry for scheduled days + if (schedule.default_has_deadline === false) return null const todayWeekday = getLocalWeekday(localDate) const dayConfig = schedule.day_configs.find((dc: DayConfig) => dc.day === todayWeekday) if (!dayConfig) return null diff --git a/frontend/vue-app/src/components/shared/DateInputField.vue b/frontend/vue-app/src/components/shared/DateInputField.vue index d43445f..ce457af 100644 --- a/frontend/vue-app/src/components/shared/DateInputField.vue +++ b/frontend/vue-app/src/components/shared/DateInputField.vue @@ -1,8 +1,22 @@ diff --git a/frontend/vue-app/src/components/shared/ScheduleModal.vue b/frontend/vue-app/src/components/shared/ScheduleModal.vue index 64289f9..1fc9614 100644 --- a/frontend/vue-app/src/components/shared/ScheduleModal.vue +++ b/frontend/vue-app/src/components/shared/ScheduleModal.vue @@ -28,28 +28,38 @@
Deadline - + + Anytime +
{{ DAY_LABELS[idx] }} - - +
+ + +
@@ -91,6 +101,11 @@ + +
+ Next occurrence: {{ nextOccurrence }} +
+
@@ -104,12 +119,6 @@ {{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
- - -
- Next occurrences: - {{ d }} -

{{ errorMsg }}

@@ -185,6 +194,7 @@ const { days: _days, base: _base, exMap: _exMap } = initDaysState(props.schedule const selectedDays = ref>(_days) const defaultTime = ref(_base) const exceptions = ref>(_exMap) +const hasDefaultDeadline = ref(props.schedule?.default_has_deadline ?? true) // ── helpers (date) ─────────────────────────────────────────────────────────── @@ -210,6 +220,7 @@ const errorMsg = ref(null) const origMode = props.schedule?.mode ?? 'days' const origDays = new Set(_days) const origDefaultTime: TimeValue = { ..._base } +const origHasDefaultDeadline = props.schedule?.default_has_deadline ?? true const origExceptions = new Map( Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]), ) @@ -227,32 +238,26 @@ const sortedSelectedDays = computed(() => Array.from(selectedDays.value).sort((a const todayISO = getTodayISO() -const nextOccurrences = computed((): string[] => { - if (!anchorDate.value) return [] +const nextOccurrence = computed((): string | null => { + if (!anchorDate.value) return null const [y, m, d] = anchorDate.value.split('-').map(Number) const anchor = new Date(y, m - 1, d, 0, 0, 0, 0) - if (isNaN(anchor.getTime())) return [] + if (isNaN(anchor.getTime())) return null const today = new Date() today.setHours(0, 0, 0, 0) const fmt = (dt: Date) => - dt.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) + dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }) - // Find the first hit date >= today - let first: Date | null = null - const candidate = new Date(anchor) - for (let i = 0; i <= 365; i++) { + // Find the first hit date strictly after today + for (let i = 1; i <= 365; i++) { const cur = new Date(anchor.getTime() + i * 86_400_000) const diffDays = Math.round((cur.getTime() - anchor.getTime()) / 86_400_000) if (diffDays % intervalDays.value === 0 && cur >= today) { - first = cur - break + return fmt(cur) } } - if (!first) return [] - void candidate // suppress unused warning - const second = new Date(first.getTime() + intervalDays.value * 86_400_000) - return [fmt(first), fmt(second)] + return null }) const formatDefaultTime = computed(() => { @@ -318,6 +323,8 @@ const isDirty = computed(() => { defaultTime.value.minute !== origDefaultTime.minute ) return true + // Default deadline toggled + if (hasDefaultDeadline.value !== origHasDefaultDeadline) return true // Exceptions changed if (exceptions.value.size !== origExceptions.size) return true for (const [day, t] of exceptions.value) { @@ -364,6 +371,7 @@ async function save() { day_configs, default_hour: defaultTime.value.hour, default_minute: defaultTime.value.minute, + default_has_deadline: hasDefaultDeadline.value, }) } else { res = await setChoreSchedule(props.childId, props.task.id, { @@ -468,8 +476,14 @@ async function save() { .exception-row { display: flex; align-items: center; - gap: 0.65rem; - flex-wrap: wrap; + gap: 0.4rem; +} + +.exception-right { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; } .exception-day-name { @@ -486,9 +500,13 @@ async function save() { } .link-btn { + display: inline-flex; + align-items: center; background: none; border: none; - padding: 0; + padding: 0.6rem 0.5rem; + margin: -0.6rem -0.5rem; + min-height: 44px; cursor: pointer; font-size: 0.82rem; font-weight: 600; @@ -496,6 +514,7 @@ async function save() { text-decoration: underline; font-family: inherit; transition: opacity 0.12s; + white-space: nowrap; } .link-btn:hover { @@ -550,8 +569,8 @@ async function save() { } .stepper-btn { - width: 2rem; - height: 2rem; + min-width: 2.75rem; + min-height: 2.75rem; background: transparent; border: none; color: var(--primary, #667eea); @@ -560,6 +579,9 @@ async function save() { cursor: pointer; transition: background 0.12s; font-family: inherit; + display: flex; + align-items: center; + justify-content: center; } .stepper-btn:hover:not(:disabled) { @@ -588,21 +610,13 @@ async function save() { .next-occurrence-row { display: flex; - flex-direction: column; - gap: 0.15rem; + align-items: center; } .next-occurrence-label { - font-size: 0.8rem; - color: var(--form-label, #888); - font-weight: 500; -} - -.next-occurrence-date { font-size: 0.85rem; color: var(--form-label, #888); font-style: italic; - padding-left: 0.5rem; } /* Error */ diff --git a/frontend/vue-app/src/components/shared/__tests__/DateInputField.spec.ts b/frontend/vue-app/src/components/shared/__tests__/DateInputField.spec.ts index 81da3fa..57dfe9c 100644 --- a/frontend/vue-app/src/components/shared/__tests__/DateInputField.spec.ts +++ b/frontend/vue-app/src/components/shared/__tests__/DateInputField.spec.ts @@ -9,7 +9,7 @@ describe('DateInputField', () => { expect(input.exists()).toBe(true) }) - it('reflects modelValue as the input value', () => { + it('reflects modelValue as the hidden input value', () => { const w = mount(DateInputField, { props: { modelValue: '2026-04-15' } }) const input = w.find('input[type="date"]') expect((input.element as HTMLInputElement).value).toBe('2026-04-15') @@ -39,7 +39,21 @@ describe('DateInputField', () => { it('renders without min prop when not provided', () => { const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } }) const input = w.find('input[type="date"]') - // min attribute should be absent or empty expect((input.element as HTMLInputElement).min).toBe('') }) + + it('shows "Select date" placeholder when modelValue is empty', () => { + const w = mount(DateInputField, { props: { modelValue: '' } }) + expect(w.find('.date-display-btn').text()).toBe('Select date') + }) + + it('shows formatted date with weekday when modelValue is set', () => { + const w = mount(DateInputField, { props: { modelValue: '2026-03-05' } }) + // Thu, Mar 5, 2026 + const text = w.find('.date-display-btn').text() + expect(text).toContain('Thu') + expect(text).toContain('Mar') + expect(text).toContain('5') + expect(text).toContain('2026') + }) })