Files
chore/frontend/src/components/shared/ScheduleModal.vue
Ryan Kegel eb775ba7d8
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
feat: Implement routines feature with CRUD operations and child assignment
- Add backend routines management with add, get, update, delete, and list functionalities.
- Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension.
- Develop event types for routine confirmation and modification.
- Implement frontend components for routine assignment, confirmation dialog, and routine management views.
- Add unit tests for routine API and integration tests for routine CRUD flow.
- Create end-to-end test plan for routines feature covering parent and child interactions.
2026-05-05 09:08:19 -04:00

777 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ModalDialog :image-url="entity.image_url" :title="scheduleTitle" :subtitle="entity.name">
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
</div>
<div class="interval-row">
<label class="field-label">Starting on</label>
<DateInputField
:modelValue="anchorDate"
:min="todayISO"
@update:modelValue="anchorDate = $event"
/>
</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>
</div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
<!-- /schedule-body -->
<!-- Actions outside dim wrapper so they stay interactive -->
<div class="modal-actions">
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
Cancel
</button>
<button class="btn-primary" @click="save" :disabled="saving || !isValid || !isDirty">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ModalDialog from './ModalDialog.vue'
import TimePickerPopover from './TimePickerPopover.vue'
import DateInputField from './DateInputField.vue'
import {
setChoreSchedule,
deleteChoreSchedule,
setRoutineSchedule,
deleteRoutineSchedule,
parseErrorResponse,
} from '@/common/api'
import type {
ChildTask,
ChildRoutine,
ChoreSchedule,
RoutineSchedule,
DayConfig,
} from '@/common/models'
interface TimeValue {
hour: number
minute: number
}
const props = defineProps<{
entity: ChildTask | ChildRoutine
entityType: 'task' | 'routine'
childId: string
schedule: ChoreSchedule | RoutineSchedule | null
}>()
const scheduleTitle = computed(() =>
props.entityType === 'task' ? 'Schedule Chore' : 'Schedule Routine',
)
const emit = defineEmits<{
(e: 'saved'): void
(e: 'cancelled'): void
}>()
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const DAY_CHIP_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
// ── local state ──────────────────────────────────────────────────────────────
const mode = ref<'days' | 'interval'>(props.schedule?.mode ?? 'days')
// days mode — three-layer state
function initDaysState(schedule: ChoreSchedule | null): {
days: Set<number>
base: TimeValue
exMap: Map<number, TimeValue>
} {
const days = new Set<number>()
const exMap = new Map<number, TimeValue>()
let base: TimeValue = { hour: 8, minute: 0 }
if (schedule?.mode === 'days') {
// Load the persisted default time (falls back to 8:00 AM for old records)
base = { hour: schedule.default_hour ?? 8, minute: schedule.default_minute ?? 0 }
for (const dc of schedule.day_configs) {
days.add(dc.day)
if (dc.hour !== base.hour || dc.minute !== base.minute) {
exMap.set(dc.day, { hour: dc.hour, minute: dc.minute })
}
}
}
return { days, base, exMap }
}
const { days: _days, base: _base, exMap: _exMap } = initDaysState(props.schedule)
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) ───────────────────────────────────────────────────────────
function getTodayISO(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// interval mode
const intervalDays = ref<number>(Math.max(1, props.schedule?.interval_days ?? 1))
const anchorDate = ref<string>(props.schedule?.anchor_date || getTodayISO())
const hasDeadline = ref<boolean>(props.schedule?.interval_has_deadline ?? true)
const intervalTime = ref<TimeValue>({
hour: props.schedule?.interval_hour ?? 8,
minute: props.schedule?.interval_minute ?? 0,
})
const saving = ref(false)
const errorMsg = ref<string | null>(null)
// ── original snapshot (for dirty detection) ──────────────────────────────────
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<number, TimeValue>(
Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]),
)
const origIntervalDays = Math.max(1, props.schedule?.interval_days ?? 1)
const origAnchorDate = props.schedule?.anchor_date || getTodayISO()
const origHasDeadline = props.schedule?.interval_has_deadline ?? true
const origIntervalTime: TimeValue = {
hour: props.schedule?.interval_hour ?? 8,
minute: props.schedule?.interval_minute ?? 0,
}
const origEnabled = props.schedule?.enabled ?? true
// ── computed ─────────────────────────────────────────────────────────────────
const sortedSelectedDays = computed(() => Array.from(selectedDays.value).sort((a, b) => a - b))
const todayISO = getTodayISO()
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 null
const today = new Date()
today.setHours(0, 0, 0, 0)
const fmt = (dt: Date) =>
dt.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})
// 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) {
return fmt(cur)
}
}
return null
})
const formatDefaultTime = computed(() => {
const h = defaultTime.value.hour % 12 || 12
const m = String(defaultTime.value.minute).padStart(2, '0')
const period = defaultTime.value.hour < 12 ? 'AM' : 'PM'
return `${String(h).padStart(2, '0')}:${m} ${period}`
})
// ── helpers ──────────────────────────────────────────────────────────────────
function toggleDay(dayIdx: number) {
if (selectedDays.value.has(dayIdx)) {
selectedDays.value.delete(dayIdx)
exceptions.value.delete(dayIdx)
// trigger reactivity
selectedDays.value = new Set(selectedDays.value)
} else {
const next = new Set(selectedDays.value)
next.add(dayIdx)
selectedDays.value = next
}
}
function onDefaultTimeChanged(val: TimeValue) {
defaultTime.value = val
}
function decrementInterval() {
if (intervalDays.value > 1) intervalDays.value--
}
function incrementInterval() {
if (intervalDays.value < 7) intervalDays.value++
}
function toggleDeadline() {
hasDeadline.value = !hasDeadline.value
}
// ── validation + dirty ───────────────────────────────────────────────────────
const isValid = computed(() => {
// days mode: 0 selections is valid — means "unschedule" (always active)
if (mode.value === 'interval') {
return intervalDays.value >= 1 && intervalDays.value <= 7
}
return true
})
const isDirty = computed(() => {
if (scheduleEnabled.value !== origEnabled) return true
if (mode.value !== origMode) return true
if (mode.value === 'days') {
// Selected days set changed
if (selectedDays.value.size !== origDays.size) return true
for (const d of selectedDays.value) {
if (!origDays.has(d)) return true
}
// Default time changed
if (
defaultTime.value.hour !== origDefaultTime.hour ||
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) {
const orig = origExceptions.get(day)
if (!orig || orig.hour !== t.hour || orig.minute !== t.minute) return true
}
return false
} else {
if (intervalDays.value !== origIntervalDays) return true
if (anchorDate.value !== origAnchorDate) return true
if (hasDeadline.value !== origHasDeadline) return true
if (
hasDeadline.value &&
(intervalTime.value.hour !== origIntervalTime.hour ||
intervalTime.value.minute !== origIntervalTime.minute)
)
return true
return false
}
})
// ── save ─────────────────────────────────────────────────────────────────────
async function save() {
if (!isValid.value || !isDirty.value || saving.value) return
errorMsg.value = null
saving.value = true
const entityId = props.entity.id
const saveEntitySchedule = (payload: object) =>
props.entityType === 'task'
? setChoreSchedule(props.childId, entityId, payload)
: setRoutineSchedule(props.childId, entityId, payload)
const deleteEntitySchedule = () =>
props.entityType === 'task'
? deleteChoreSchedule(props.childId, entityId)
: deleteRoutineSchedule(props.childId, entityId)
let res: Response
if (mode.value === 'days' && selectedDays.value.size === 0) {
// 0 days = remove schedule entirely so the item becomes always active
res = await deleteEntitySchedule()
} else if (mode.value === 'days') {
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
const override = exceptions.value.get(day)
return {
day,
hour: override?.hour ?? defaultTime.value.hour,
minute: override?.minute ?? defaultTime.value.minute,
}
})
res = await saveEntitySchedule({
mode: 'days',
enabled: scheduleEnabled.value,
day_configs,
default_hour: defaultTime.value.hour,
default_minute: defaultTime.value.minute,
default_has_deadline: hasDefaultDeadline.value,
})
} else {
res = await saveEntitySchedule({
mode: 'interval',
enabled: scheduleEnabled.value,
interval_days: intervalDays.value,
anchor_date: anchorDate.value,
interval_has_deadline: hasDeadline.value,
interval_hour: intervalTime.value.hour,
interval_minute: intervalTime.value.minute,
})
}
saving.value = false
if (res.ok) {
emit('saved')
} else {
const { msg } = await parseErrorResponse(res)
errorMsg.value = msg
}
}
</script>
<style scoped>
.mode-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1.2rem;
}
.mode-btn {
flex: 1;
padding: 0.55rem 0.4rem;
border: 1.5px solid var(--primary, #667eea);
border-radius: 6px;
background: transparent;
color: var(--primary, #667eea);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.mode-btn.active {
background: var(--btn-primary, #667eea);
color: #fff;
}
/* Days form */
.days-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.day-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
min-width: 2.4rem;
padding: 0.4rem 0.5rem;
border: 1.5px solid var(--primary, #667eea);
border-radius: 999px;
background: transparent;
color: var(--primary, #667eea);
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
text-align: center;
transition:
background 0.15s,
color 0.15s;
font-family: inherit;
}
.chip:hover {
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
}
.chip.active {
background: var(--btn-primary, #667eea);
color: #fff;
}
.default-deadline-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.exception-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.exception-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.exception-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.exception-day-name {
font-size: 0.9rem;
font-weight: 700;
color: var(--secondary, #7257b3);
min-width: 75px;
}
.default-label {
font-size: 0.85rem;
color: var(--form-label, #888);
font-style: italic;
}
.link-btn {
display: inline-flex;
align-items: center;
background: none;
border: none;
padding: 0.6rem 0.5rem;
margin: -0.6rem -0.5rem;
min-height: 44px;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
color: var(--primary, #667eea);
text-decoration: underline;
font-family: inherit;
transition: opacity 0.12s;
white-space: nowrap;
}
.link-btn:hover {
opacity: 0.75;
}
.reset-btn {
color: var(--error, #e53e3e);
}
/* Interval form */
.interval-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.interval-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.interval-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.interval-time-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.field-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--secondary, #7257b3);
}
.stepper {
display: flex;
align-items: center;
gap: 0;
border: 1.5px solid var(--primary, #667eea);
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.stepper-btn {
min-width: 2.75rem;
min-height: 2.75rem;
background: transparent;
border: none;
color: var(--primary, #667eea);
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.12s;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
}
.stepper-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
}
.stepper-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.stepper-value {
min-width: 1.6rem;
text-align: center;
font-size: 1rem;
font-weight: 700;
color: var(--secondary, #7257b3);
padding: 0 0.25rem;
}
.anytime-label {
font-size: 0.85rem;
color: var(--form-label, #888);
font-style: italic;
}
.next-occurrence-row {
display: flex;
align-items: center;
}
.next-occurrence-label {
font-size: 0.85rem;
color: var(--form-label, #888);
font-style: italic;
}
/* Error */
.error-msg {
color: var(--error, #e53e3e);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
/* Actions */
.modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
.btn-primary {
background: var(--btn-primary, #667eea);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.4rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
}
.btn-primary:disabled {
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>