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
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user