Refactor Time Selector and Scheduler UI; Implement TimePickerPopover Component
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 1m5s

- Updated TimeSelector.vue styles for smaller dimensions and font sizes.
- Added new API proxy for '/events' in vite.config.ts.
- Created bug specifications for various UI issues and fixes in bugs-1.0.5-001.md and bugs-1.0.5-002.md.
- Introduced TimePickerPopover.vue for a new time selection interface in the chore scheduler.
- Refactored ScheduleModal.vue to replace checkbox rows with a chip-based design for selecting specific days.
- Enhanced chore scheduling logic to ensure proper handling of time extensions and UI updates.
This commit is contained in:
2026-02-25 19:45:31 -05:00
parent a41a357f50
commit 91a52c1973
19 changed files with 1250 additions and 176 deletions

View File

@@ -1,10 +1,5 @@
<template>
<ModalDialog
:image-url="task.image_url"
:title="'Schedule Chore'"
:subtitle="task.name"
@backdrop-click="$emit('cancelled')"
>
<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'">
@@ -17,17 +12,45 @@
<!-- Specific Days form -->
<div v-if="mode === 'days'" class="days-form">
<div v-for="(label, idx) in DAY_LABELS" :key="idx" class="day-row">
<label class="day-check">
<input type="checkbox" :checked="isDayChecked(idx)" @change="toggleDay(idx)" />
<span class="day-label">{{ label }}</span>
</label>
<TimeSelector
v-if="isDayChecked(idx)"
:modelValue="getDayTime(idx)"
@update:modelValue="setDayTime(idx, $event)"
class="day-time-selector"
/>
<!-- 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 :modelValue="defaultTime" @update:modelValue="onDefaultTimeChanged" />
</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>
<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">{{ formatDefaultTime }}</span>
<button type="button" class="link-btn" @click="exceptions.set(idx, { ...defaultTime })">
Set different time
</button>
</template>
</div>
</div>
</div>
@@ -51,8 +74,10 @@
<!-- Actions -->
<div class="modal-actions">
<button class="btn-secondary" @click="$emit('cancelled')" :disabled="saving">Cancel</button>
<button class="btn-primary" @click="save" :disabled="saving || !isValid">
<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>
@@ -63,7 +88,8 @@
import { ref, computed } from 'vue'
import ModalDialog from './ModalDialog.vue'
import TimeSelector from './TimeSelector.vue'
import { setChoreSchedule, parseErrorResponse } from '@/common/api'
import TimePickerPopover from './TimePickerPopover.vue'
import { setChoreSchedule, deleteChoreSchedule, parseErrorResponse } from '@/common/api'
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
interface TimeValue {
@@ -83,13 +109,40 @@ const emit = defineEmits<{
}>()
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
const dayTimes = ref<Map<number, TimeValue>>(buildDayTimes(props.schedule))
// 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)
// interval mode
const intervalDays = ref<number>(props.schedule?.interval_days ?? 2)
@@ -102,74 +155,130 @@ const intervalTime = ref<TimeValue>({
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 origExceptions = new Map<number, TimeValue>(
Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]),
)
const origIntervalDays = props.schedule?.interval_days ?? 2
const origAnchorWeekday = props.schedule?.anchor_weekday ?? 0
const origIntervalTime: TimeValue = {
hour: props.schedule?.interval_hour ?? 8,
minute: props.schedule?.interval_minute ?? 0,
}
// ── computed ─────────────────────────────────────────────────────────────────
const sortedSelectedDays = computed(() => Array.from(selectedDays.value).sort((a, b) => a - b))
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 buildDayTimes(schedule: ChoreSchedule | null): Map<number, TimeValue> {
const map = new Map<number, TimeValue>()
if (schedule?.mode === 'days') {
for (const dc of schedule.day_configs) {
map.set(dc.day, { hour: dc.hour, minute: dc.minute })
}
}
return map
}
function isDayChecked(dayIdx: number): boolean {
return dayTimes.value.has(dayIdx)
}
function getDayTime(dayIdx: number): TimeValue {
return dayTimes.value.get(dayIdx) ?? { hour: 8, minute: 0 }
}
function toggleDay(dayIdx: number) {
if (dayTimes.value.has(dayIdx)) {
dayTimes.value.delete(dayIdx)
if (selectedDays.value.has(dayIdx)) {
selectedDays.value.delete(dayIdx)
exceptions.value.delete(dayIdx)
// trigger reactivity
selectedDays.value = new Set(selectedDays.value)
} else {
dayTimes.value.set(dayIdx, { hour: 8, minute: 0 })
const next = new Set(selectedDays.value)
next.add(dayIdx)
selectedDays.value = next
}
}
function setDayTime(dayIdx: number, val: TimeValue) {
dayTimes.value.set(dayIdx, val)
function onDefaultTimeChanged(val: TimeValue) {
defaultTime.value = val
}
// ── validation ───────────────────────────────────────────────────────────────
// ── validation + dirty ───────────────────────────────────────────────────────
const isValid = computed(() => {
if (mode.value === 'days') {
return dayTimes.value.size > 0
} else {
// days mode: 0 selections is valid — means "unschedule" (always active)
if (mode.value === 'interval') {
return intervalDays.value >= 2 && intervalDays.value <= 7
}
return true
})
const isDirty = computed(() => {
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
// 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 (anchorWeekday.value !== origAnchorWeekday) return true
if (
intervalTime.value.hour !== origIntervalTime.hour ||
intervalTime.value.minute !== origIntervalTime.minute
)
return true
return false
}
})
// ── save ─────────────────────────────────────────────────────────────────────
async function save() {
if (!isValid.value || saving.value) return
if (!isValid.value || !isDirty.value || saving.value) return
errorMsg.value = null
saving.value = true
let payload: object
if (mode.value === 'days') {
const day_configs: DayConfig[] = Array.from(dayTimes.value.entries()).map(([day, t]) => ({
day,
hour: t.hour,
minute: t.minute,
}))
payload = { mode: 'days', day_configs }
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',
day_configs,
default_hour: defaultTime.value.hour,
default_minute: defaultTime.value.minute,
})
} else {
payload = {
res = await setChoreSchedule(props.childId, props.task.id, {
mode: 'interval',
interval_days: intervalDays.value,
anchor_weekday: anchorWeekday.value,
interval_hour: intervalTime.value.hour,
interval_minute: intervalTime.value.minute,
}
})
}
const res = await setChoreSchedule(props.childId, props.task.id, payload)
saving.value = false
if (res.ok) {
@@ -212,40 +321,93 @@ async function save() {
.days-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
gap: 1rem;
margin-bottom: 1rem;
}
.day-row {
.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.65rem;
flex-wrap: wrap;
}
.day-check {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
min-width: 110px;
}
.day-check input[type='checkbox'] {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
accent-color: var(--btn-primary, #667eea);
}
.day-label {
font-size: 0.95rem;
font-weight: 600;
.exception-day-name {
font-size: 0.9rem;
font-weight: 700;
color: var(--secondary, #7257b3);
min-width: 75px;
}
.day-time-selector {
margin-left: auto;
.default-label {
font-size: 0.85rem;
color: var(--form-label, #888);
font-style: italic;
}
.link-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
color: var(--primary, #667eea);
text-decoration: underline;
font-family: inherit;
transition: opacity 0.12s;
}
.link-btn:hover {
opacity: 0.75;
}
.reset-btn {
color: var(--error, #e53e3e);
}
/* Interval form */
@@ -256,7 +418,12 @@ async function save() {
margin-bottom: 1rem;
}
.interval-row,
.interval-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.interval-time-row {
display: flex;
align-items: center;
@@ -321,20 +488,4 @@ async function save() {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: transparent;
color: var(--secondary, #7257b3);
border: 1.5px solid var(--secondary, #7257b3);
border-radius: 8px;
padding: 0.55rem 1.4rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,261 @@
<template>
<div ref="rootEl" class="time-picker-popover">
<button type="button" class="time-display" @click="toggleOpen" :class="{ open: isOpen }">
{{ formattedTime }}
</button>
<div v-if="isOpen" class="popover-panel">
<div class="columns">
<!-- Hours column -->
<div class="col">
<div class="col-header">Hour</div>
<div class="col-scroll">
<button
v-for="h in HOURS"
:key="h"
type="button"
class="col-item"
:class="{ selected: displayHour === h }"
@click="selectHour(h)"
>
{{ h }}
</button>
</div>
</div>
<div class="col-divider">:</div>
<!-- Minutes column -->
<div class="col">
<div class="col-header">Min</div>
<div class="col-scroll">
<button
v-for="m in MINUTES"
:key="m"
type="button"
class="col-item"
:class="{ selected: props.modelValue.minute === m }"
@click="selectMinute(m)"
>
{{ String(m).padStart(2, '0') }}
</button>
</div>
</div>
<!-- AM/PM column -->
<div class="col ampm-col">
<div class="col-header">AM/PM</div>
<div class="col-scroll">
<button
type="button"
class="col-item"
:class="{ selected: period === 'AM' }"
@click="selectPeriod('AM')"
>
AM
</button>
<button
type="button"
class="col-item"
:class="{ selected: period === 'PM' }"
@click="selectPeriod('PM')"
>
PM
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
interface TimeValue {
hour: number // 023
minute: number // 0, 15, 30, 45
}
const props = defineProps<{ modelValue: TimeValue }>()
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const MINUTES = [0, 15, 30, 45]
const rootEl = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
const displayHour = computed(() => {
const h = props.modelValue.hour % 12
return h === 0 ? 12 : h
})
const formattedTime = computed(() => {
const h = displayHour.value
const m = String(props.modelValue.minute).padStart(2, '0')
return `${String(h).padStart(2, '0')}:${m} ${period.value}`
})
function toggleOpen() {
if (isOpen.value) {
close()
} else {
isOpen.value = true
document.addEventListener('mousedown', onDocumentMouseDown, { capture: true })
}
}
function close() {
isOpen.value = false
document.removeEventListener('mousedown', onDocumentMouseDown, { capture: true })
}
function onDocumentMouseDown(e: MouseEvent) {
if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
close()
}
}
function selectHour(h: number) {
// h is 112 display; convert to 023 preserving period
const isPm = period.value === 'PM'
let hour24: number
if (h === 12) {
hour24 = isPm ? 12 : 0
} else {
hour24 = isPm ? h + 12 : h
}
emit('update:modelValue', { hour: hour24, minute: props.modelValue.minute })
}
function selectMinute(m: number) {
emit('update:modelValue', { hour: props.modelValue.hour, minute: m })
}
function selectPeriod(p: 'AM' | 'PM') {
const currentPeriod = period.value
if (p === currentPeriod) return
const newHour = p === 'PM' ? props.modelValue.hour + 12 : props.modelValue.hour - 12
emit('update:modelValue', { hour: newHour, minute: props.modelValue.minute })
}
onUnmounted(() => {
document.removeEventListener('mousedown', onDocumentMouseDown, { capture: true })
})
</script>
<style scoped>
.time-picker-popover {
position: relative;
display: inline-block;
}
.time-display {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.75rem;
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 8px;
background: var(--modal-bg, #fff);
color: var(--secondary, #7257b3);
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s;
white-space: nowrap;
font-family: inherit;
}
.time-display:hover,
.time-display.open {
border-color: var(--btn-primary, #667eea);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--btn-primary, #667eea) 20%, transparent);
}
.popover-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 200;
background: var(--modal-bg, #fff);
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 10px;
box-shadow: 0 4px 18px var(--modal-shadow, rgba(0, 0, 0, 0.15));
padding: 0.5rem;
}
.columns {
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
.col {
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 3rem;
}
.ampm-col {
min-width: 3.2rem;
}
.col-header {
font-size: 0.7rem;
font-weight: 700;
text-align: center;
color: var(--primary, #667eea);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.35rem;
padding: 0 0.15rem;
}
.col-scroll {
display: flex;
flex-direction: column;
gap: 0.18rem;
max-height: 12rem;
overflow-y: auto;
scrollbar-width: thin;
}
.col-item {
padding: 0.3rem 0.4rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--secondary, #7257b3);
font-size: 0.9rem;
font-weight: 600;
text-align: center;
cursor: pointer;
transition:
background 0.12s,
color 0.12s;
font-family: inherit;
}
.col-item:hover {
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.1));
}
.col-item.selected {
background: var(--btn-primary, #667eea);
color: #fff;
}
.col-divider {
font-size: 1.1rem;
font-weight: 700;
color: var(--secondary, #7257b3);
padding-top: 1.6rem;
align-self: flex-start;
}
</style>

View File

@@ -101,8 +101,8 @@ function toggleAmPm() {
}
.arrow-btn {
width: 2.5rem;
height: 2.5rem;
width: 1.8rem;
height: 1.8rem;
display: flex;
align-items: center;
justify-content: center;
@@ -110,7 +110,7 @@ function toggleAmPm() {
border: 1px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-size: 0.75rem;
color: var(--primary, #667eea);
transition: background 0.15s;
}
@@ -120,12 +120,12 @@ function toggleAmPm() {
}
.time-value {
width: 2.5rem;
height: 2.5rem;
width: 1.8rem;
height: 1.8rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
font-size: 1rem;
font-weight: 700;
color: var(--secondary, #7257b3);
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
@@ -134,7 +134,7 @@ function toggleAmPm() {
}
.time-separator {
font-size: 1.6rem;
font-size: 1rem;
font-weight: 700;
color: var(--secondary, #7257b3);
padding-bottom: 0.1rem;
@@ -142,9 +142,9 @@ function toggleAmPm() {
}
.ampm-btn {
width: 3rem;
height: 2.5rem;
font-size: 0.95rem;
width: 2.2rem;
height: 1.8rem;
font-size: 0.8rem;
font-weight: 700;
border: 1.5px solid var(--btn-primary, #667eea);
border-radius: 6px;
@@ -158,17 +158,4 @@ function toggleAmPm() {
.ampm-btn:hover {
opacity: 0.85;
}
@media (max-width: 480px) {
.arrow-btn,
.time-value {
width: 2.8rem;
height: 2.8rem;
}
.ampm-btn {
width: 3.2rem;
height: 2.8rem;
}
}
</style>