Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s

This commit is contained in:
2026-04-25 00:40:15 -04:00
parent db846f4e31
commit 127378797c
263 changed files with 88 additions and 79 deletions

View File

@@ -0,0 +1,749 @@
<template>
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.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, parseErrorResponse } from '@/common/api'
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
interface TimeValue {
hour: number
minute: number
}
const props = defineProps<{
task: ChildTask
childId: string
schedule: ChoreSchedule | null
}>()
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
let res: Response
if (mode.value === 'days' && selectedDays.value.size === 0) {
// 0 days = remove schedule entirely → chore becomes always active
res = await deleteChoreSchedule(props.childId, props.task.id)
} 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 setChoreSchedule(props.childId, props.task.id, {
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 setChoreSchedule(props.childId, props.task.id, {
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>