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

@@ -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>