Files
chore/frontend/src/components/shared/ScheduleModal.vue
Ryan Kegel e2bb9cd6b9
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 3m4s
feat: add onboarding tutorial for new users
- Introduced a modular tutorial layer to guide new parents through the app setup process.
- Implemented a 3-step forced intro after first sign-in (PIN setup → child creation → chore creation).
- Added just-in-time contextual hints for various features as users encounter them.
- Persisted user progress on the backend with new fields in the User model.
- Created a new tutorial controller and step registry in the frontend for managing tutorial states.
- Added Help button for easy access to tutorial tips and a restart option in the user profile.
- Ensured accessibility and mobile responsiveness for the tutorial overlay.
- Included tests for backend and frontend functionalities related to the tutorial.
2026-06-19 17:27:35 -04:00

812 lines
22 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, i) in sortedSelectedDays"
:key="idx"
class="exception-row"
:data-tutorial="i === 0 ? 'schedule-days-exception' : undefined"
>
<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" data-tutorial="schedule-interval-start">
<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, onMounted, onUnmounted, nextTick, watch } from 'vue'
import ModalDialog from './ModalDialog.vue'
import TimePickerPopover from './TimePickerPopover.vue'
import DateInputField from './DateInputField.vue'
import { maybeShow as tutorialMaybeShow, modalTutorialStepId } from '@/tutorial/controller'
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)
function triggerScheduleTutorial(isDays: boolean) {
modalTutorialStepId.value = isDays ? 'schedule-days-chips' : 'schedule-interval-frequency'
nextTick(() => {
if (isDays) {
tutorialMaybeShow(
'schedule-days-chips',
() => document.querySelector('.day-chips') as HTMLElement | null,
)
} else {
tutorialMaybeShow(
'schedule-interval-frequency',
() => document.querySelector('.stepper') as HTMLElement | null,
)
}
})
}
onMounted(() => {
triggerScheduleTutorial(mode.value === 'days')
})
onUnmounted(() => {
modalTutorialStepId.value = null
})
watch(mode, (newMode) => {
triggerScheduleTutorial(newMode === 'days')
})
// ── 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>