feat: add enable/disable toggle for chore scheduling in ScheduleModal
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s

- Introduced a toggle button to enable or disable chores in the scheduler.
- The toggle will be available in both types of schedulers.
- Added UI design proposal and considerations for mobile dimensions.
- Included E2E tests to ensure toggle state persistence and correct behavior in child view.
This commit is contained in:
2026-03-20 16:42:13 -04:00
parent db6e0a7ce8
commit ef9cb01d92
45 changed files with 3543 additions and 232 deletions

View File

@@ -109,6 +109,64 @@ describe('isScheduledToday', () => {
const thursday = new Date(2025, 0, 9)
expect(isScheduledToday(intervalSchedule, thursday)).toBe(false)
})
it('returns true when enabled is false on a scheduled day (days mode) — paused = always shown', () => {
const pausedSchedule: ChoreSchedule = {
...daysSchedule,
enabled: false,
}
const monday = new Date(2025, 0, 6) // Monday — scheduled day
expect(isScheduledToday(pausedSchedule, monday)).toBe(true)
})
it('returns true when enabled is false on an unscheduled day (days mode) — paused = always shown', () => {
const pausedSchedule: ChoreSchedule = {
...daysSchedule,
enabled: false,
}
const tuesday = new Date(2025, 0, 7) // Tuesday — NOT a scheduled day normally
expect(isScheduledToday(pausedSchedule, tuesday)).toBe(true)
})
it('returns true when enabled is false on a hit day (interval mode) — paused = always shown', () => {
const pausedInterval: ChoreSchedule = {
child_id: 'c1',
task_id: 't1',
mode: 'interval',
day_configs: [],
interval_days: 2,
anchor_date: '2025-01-08',
interval_has_deadline: true,
interval_hour: 8,
interval_minute: 0,
enabled: false,
}
const wednesday = new Date(2025, 0, 8) // would normally hit
expect(isScheduledToday(pausedInterval, wednesday)).toBe(true)
})
it('returns true when enabled is false on a non-hit day (interval mode) — paused = always shown', () => {
const pausedInterval: ChoreSchedule = {
child_id: 'c1',
task_id: 't1',
mode: 'interval',
day_configs: [],
interval_days: 2,
anchor_date: '2025-01-08',
interval_has_deadline: true,
interval_hour: 8,
interval_minute: 0,
enabled: false,
}
const thursday = new Date(2025, 0, 9) // would NOT normally hit
expect(isScheduledToday(pausedInterval, thursday)).toBe(true)
})
it('treats enabled=undefined as enabled (backward compat)', () => {
// daysSchedule has no enabled field — should behave as enabled
const monday = new Date(2025, 0, 6)
expect(isScheduledToday(daysSchedule, monday)).toBe(true)
})
})
// ---------------------------------------------------------------------------
@@ -185,6 +243,25 @@ describe('getDueTimeToday', () => {
// Even on a hit day, Anytime means no deadline → null
expect(getDueTimeToday(intervalSchedule, wednesday)).toBeNull()
})
it('returns null when schedule is paused (enabled=false) — paused = no deadline', () => {
const pausedSchedule: ChoreSchedule = {
child_id: 'c1',
task_id: 't1',
mode: 'days',
day_configs: [
{ day: 1, has_deadline: true, hour: 8, minute: 30 }, // Monday with deadline
],
interval_days: null,
anchor_date: null,
interval_has_deadline: false,
interval_hour: null,
interval_minute: null,
enabled: false,
}
const monday = new Date(2025, 0, 6) // Monday — would normally return { hour: 8, minute: 30 }
expect(getDueTimeToday(pausedSchedule, monday)).toBeNull()
})
})
// ---------------------------------------------------------------------------

View File

@@ -32,6 +32,7 @@ export interface ChoreSchedule {
interval_has_deadline: boolean // false = "Anytime" (no deadline)
interval_hour: number
interval_minute: number
enabled?: boolean // false = schedule paused; chore won't appear for child
created_at: number
updated_at: number
}

View File

@@ -44,8 +44,10 @@ export function intervalHitsToday(
* Returns true if the schedule applies today (localDate).
* - 'days' mode: today's weekday is in day_configs
* - 'interval' mode: intervalHitsToday
* - paused (enabled === false): always returns true (chore shows every day, like no schedule)
*/
export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): boolean {
if (schedule.enabled === false) return true // paused = show always, like an unscheduled chore
if (schedule.mode === 'days') {
const todayWeekday = getLocalWeekday(localDate)
return schedule.day_configs.some((dc: DayConfig) => dc.day === todayWeekday)
@@ -56,6 +58,7 @@ export function isScheduledToday(schedule: ChoreSchedule, localDate: Date): bool
/**
* Returns the due time {hour, minute} for today, or null if:
* - the schedule is paused (enabled === false) → no deadline, acts like Anytime
* - the day is not scheduled, OR
* - the schedule has no deadline (interval_has_deadline === false → "Anytime")
*
@@ -65,6 +68,7 @@ export function getDueTimeToday(
schedule: ChoreSchedule,
localDate: Date,
): { hour: number; minute: number } | null {
if (schedule.enabled === false) return null // paused = no deadline, no expiry
if (schedule.mode === 'days') {
// default_has_deadline === false means 'Anytime' — no expiry for scheduled days
if (schedule.default_has_deadline === false) return null

View File

@@ -1,135 +1,154 @@
<template>
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
<!-- Mode toggle -->
<div class="mode-toggle">
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
Specific Days
</button>
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
Every X Days
<!-- Enable/disable toggle row -->
<div class="schedule-toggle-row">
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
<button
type="button"
class="toggle-track"
:class="{ on: scheduleEnabled }"
role="switch"
:aria-checked="scheduleEnabled"
@click="scheduleEnabled = !scheduleEnabled"
>
<span class="toggle-thumb" />
</button>
</div>
<!-- Specific Days form -->
<div v-if="mode === 'days'" class="days-form">
<!-- Day chips -->
<div class="day-chips">
<button
v-for="(label, idx) in DAY_CHIP_LABELS"
:key="idx"
type="button"
:class="['chip', { active: selectedDays.has(idx) }]"
@click="toggleDay(idx)"
>
{{ label }}
<!-- Form body dims when paused -->
<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">
<!-- Mode toggle -->
<div class="mode-toggle">
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
Specific Days
</button>
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
Every X Days
</button>
</div>
<!-- Default deadline -->
<div v-if="selectedDays.size > 0" class="default-deadline-row">
<span class="field-label">Deadline</span>
<TimePickerPopover
v-if="hasDefaultDeadline"
:modelValue="defaultTime"
@update:modelValue="onDefaultTimeChanged"
/>
<span v-else class="anytime-label">Anytime</span>
<button type="button" class="link-btn" @click="hasDefaultDeadline = !hasDefaultDeadline">
{{ hasDefaultDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
</button>
<!-- Specific Days form -->
<div v-if="mode === 'days'" class="days-form">
<!-- Day chips -->
<div class="day-chips">
<button
v-for="(label, idx) in DAY_CHIP_LABELS"
:key="idx"
type="button"
:class="['chip', { active: selectedDays.has(idx) }]"
@click="toggleDay(idx)"
>
{{ label }}
</button>
</div>
<!-- Default deadline -->
<div v-if="selectedDays.size > 0" class="default-deadline-row">
<span class="field-label">Deadline</span>
<TimePickerPopover
v-if="hasDefaultDeadline"
:modelValue="defaultTime"
@update:modelValue="onDefaultTimeChanged"
/>
<span v-else class="anytime-label">Anytime</span>
<button type="button" class="link-btn" @click="hasDefaultDeadline = !hasDefaultDeadline">
{{ hasDefaultDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
</button>
</div>
<!-- Selected day exception list -->
<div v-if="selectedDays.size > 0" class="exception-list">
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
<div class="exception-right">
<template v-if="exceptions.has(idx)">
<TimePickerPopover
:modelValue="exceptions.get(idx)!"
@update:modelValue="(val) => exceptions.set(idx, val)"
/>
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
Reset to default
</button>
</template>
<template v-else>
<span class="default-label">{{
hasDefaultDeadline ? formatDefaultTime : 'Anytime'
}}</span>
<button
type="button"
class="link-btn"
@click="exceptions.set(idx, { ...defaultTime })"
>
Set different time
</button>
</template>
</div>
</div>
</div>
</div>
<!-- Selected day exception list -->
<div v-if="selectedDays.size > 0" class="exception-list">
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
<div class="exception-right">
<template v-if="exceptions.has(idx)">
<TimePickerPopover
:modelValue="exceptions.get(idx)!"
@update:modelValue="(val) => exceptions.set(idx, val)"
/>
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
Reset to default
</button>
</template>
<template v-else>
<span class="default-label">{{
hasDefaultDeadline ? formatDefaultTime : 'Anytime'
}}</span>
<!-- Interval form -->
<div v-else class="interval-form">
<!-- Frequency stepper + anchor date -->
<div class="interval-group">
<div class="interval-row">
<label class="field-label">Every</label>
<div class="stepper">
<button
type="button"
class="link-btn"
@click="exceptions.set(idx, { ...defaultTime })"
class="stepper-btn"
@click="decrementInterval"
:disabled="intervalDays <= 1"
>
Set different time
</button>
</template>
<span class="stepper-value">{{ intervalDays }}</span>
<button
type="button"
class="stepper-btn"
@click="incrementInterval"
:disabled="intervalDays >= 7"
>
+
</button>
</div>
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
</div>
<div class="interval-row">
<label class="field-label">Starting on</label>
<DateInputField
:modelValue="anchorDate"
:min="todayISO"
@update:modelValue="anchorDate = $event"
/>
</div>
</div>
</div>
</div>
<!-- Interval form -->
<div v-else class="interval-form">
<!-- Frequency stepper + anchor date -->
<div class="interval-group">
<div class="interval-row">
<label class="field-label">Every</label>
<div class="stepper">
<button
type="button"
class="stepper-btn"
@click="decrementInterval"
:disabled="intervalDays <= 1"
>
</button>
<span class="stepper-value">{{ intervalDays }}</span>
<button
type="button"
class="stepper-btn"
@click="incrementInterval"
:disabled="intervalDays >= 7"
>
+
</button>
</div>
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
<!-- Next occurrence preview -->
<div v-if="nextOccurrence" class="next-occurrence-row">
<span class="next-occurrence-label">Next occurrence: {{ nextOccurrence }}</span>
</div>
<div class="interval-row">
<label class="field-label">Starting on</label>
<DateInputField
:modelValue="anchorDate"
:min="todayISO"
@update:modelValue="anchorDate = $event"
<!-- Deadline row -->
<div class="interval-time-row">
<label class="field-label">Deadline</label>
<TimePickerPopover
v-if="hasDeadline"
:modelValue="intervalTime"
@update:modelValue="intervalTime = $event"
/>
<span v-else class="anytime-label">Anytime</span>
<button type="button" class="link-btn" @click="toggleDeadline">
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
</button>
</div>
</div>
<!-- Next occurrence preview -->
<div v-if="nextOccurrence" class="next-occurrence-row">
<span class="next-occurrence-label">Next occurrence: {{ nextOccurrence }}</span>
</div>
<!-- Deadline row -->
<div class="interval-time-row">
<label class="field-label">Deadline</label>
<TimePickerPopover
v-if="hasDeadline"
:modelValue="intervalTime"
@update:modelValue="intervalTime = $event"
/>
<span v-else class="anytime-label">Anytime</span>
<button type="button" class="link-btn" @click="toggleDeadline">
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
</button>
</div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
<!-- /schedule-body -->
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
<!-- Actions -->
<!-- Actions outside dim wrapper so they stay interactive -->
<div class="modal-actions">
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
Cancel
@@ -201,6 +220,7 @@ const selectedDays = ref<Set<number>>(_days)
const defaultTime = ref<TimeValue>(_base)
const exceptions = ref<Map<number, TimeValue>>(_exMap)
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)
// ── helpers (date) ───────────────────────────────────────────────────────────
@@ -237,6 +257,7 @@ const origIntervalTime: TimeValue = {
hour: props.schedule?.interval_hour ?? 8,
minute: props.schedule?.interval_minute ?? 0,
}
const origEnabled = props.schedule?.enabled ?? true
// ── computed ─────────────────────────────────────────────────────────────────
@@ -320,6 +341,7 @@ const isValid = computed(() => {
})
const isDirty = computed(() => {
if (scheduleEnabled.value !== origEnabled) return true
if (mode.value !== origMode) return true
if (mode.value === 'days') {
@@ -379,6 +401,7 @@ async function save() {
})
res = await setChoreSchedule(props.childId, props.task.id, {
mode: 'days',
enabled: scheduleEnabled.value,
day_configs,
default_hour: defaultTime.value.hour,
default_minute: defaultTime.value.minute,
@@ -387,6 +410,7 @@ async function save() {
} else {
res = await setChoreSchedule(props.childId, props.task.id, {
mode: 'interval',
enabled: scheduleEnabled.value,
interval_days: intervalDays.value,
anchor_date: anchorDate.value,
interval_has_deadline: hasDeadline.value,
@@ -660,4 +684,66 @@ async function save() {
opacity: 0.5;
cursor: not-allowed;
}
/* ── 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;
}
</style>