Add TimeSelector and ScheduleModal components with tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m45s
- Implemented TimeSelector component for selecting time with AM/PM toggle and minute/hour increment/decrement functionality. - Created ScheduleModal component for scheduling chores with options for specific days or intervals. - Added utility functions for scheduling logic in scheduleUtils.ts. - Developed comprehensive tests for TimeSelector and scheduleUtils functions to ensure correct behavior.
This commit is contained in:
340
frontend/vue-app/src/components/shared/ScheduleModal.vue
Normal file
340
frontend/vue-app/src/components/shared/ScheduleModal.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<template>
|
||||
<ModalDialog
|
||||
:image-url="task.image_url"
|
||||
:title="'Schedule Chore'"
|
||||
:subtitle="task.name"
|
||||
@backdrop-click="$emit('cancelled')"
|
||||
>
|
||||
<!-- 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">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interval form -->
|
||||
<div v-else class="interval-form">
|
||||
<div class="interval-row">
|
||||
<label class="field-label">Every</label>
|
||||
<input v-model.number="intervalDays" type="number" min="2" max="7" class="interval-input" />
|
||||
<span class="field-label">days, starting on</span>
|
||||
<select v-model.number="anchorWeekday" class="anchor-select">
|
||||
<option v-for="(label, idx) in DAY_LABELS" :key="idx" :value="idx">{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="interval-time-row">
|
||||
<label class="field-label">Due by</label>
|
||||
<TimeSelector :modelValue="intervalTime" @update:modelValue="intervalTime = $event" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
|
||||
|
||||
<!-- 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">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import ModalDialog from './ModalDialog.vue'
|
||||
import TimeSelector from './TimeSelector.vue'
|
||||
import { setChoreSchedule, 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']
|
||||
|
||||
// ── local state ──────────────────────────────────────────────────────────────
|
||||
|
||||
const mode = ref<'days' | 'interval'>(props.schedule?.mode ?? 'days')
|
||||
|
||||
// days mode
|
||||
const dayTimes = ref<Map<number, TimeValue>>(buildDayTimes(props.schedule))
|
||||
|
||||
// interval mode
|
||||
const intervalDays = ref<number>(props.schedule?.interval_days ?? 2)
|
||||
const anchorWeekday = ref<number>(props.schedule?.anchor_weekday ?? 0)
|
||||
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)
|
||||
|
||||
// ── 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)
|
||||
} else {
|
||||
dayTimes.value.set(dayIdx, { hour: 8, minute: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
function setDayTime(dayIdx: number, val: TimeValue) {
|
||||
dayTimes.value.set(dayIdx, val)
|
||||
}
|
||||
|
||||
// ── validation ───────────────────────────────────────────────────────────────
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (mode.value === 'days') {
|
||||
return dayTimes.value.size > 0
|
||||
} else {
|
||||
return intervalDays.value >= 2 && intervalDays.value <= 7
|
||||
}
|
||||
})
|
||||
|
||||
// ── save ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function save() {
|
||||
if (!isValid.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 }
|
||||
} else {
|
||||
payload = {
|
||||
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) {
|
||||
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: 0.65rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.day-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
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;
|
||||
color: var(--secondary, #7257b3);
|
||||
}
|
||||
|
||||
.day-time-selector {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Interval form */
|
||||
.interval-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.interval-row,
|
||||
.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);
|
||||
}
|
||||
|
||||
.interval-input {
|
||||
width: 3.5rem;
|
||||
padding: 0.4rem 0.4rem;
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
color: var(--secondary, #7257b3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.anchor-select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--secondary, #7257b3);
|
||||
font-weight: 600;
|
||||
background: var(--modal-bg, #fff);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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>
|
||||
174
frontend/vue-app/src/components/shared/TimeSelector.vue
Normal file
174
frontend/vue-app/src/components/shared/TimeSelector.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="time-selector">
|
||||
<!-- Hour column -->
|
||||
<div class="time-col">
|
||||
<button class="arrow-btn" @click="incrementHour" aria-label="Increase hour">▲</button>
|
||||
<div class="time-value">{{ displayHour }}</div>
|
||||
<button class="arrow-btn" @click="decrementHour" aria-label="Decrease hour">▼</button>
|
||||
</div>
|
||||
|
||||
<div class="time-separator">:</div>
|
||||
|
||||
<!-- Minute column -->
|
||||
<div class="time-col">
|
||||
<button class="arrow-btn" @click="incrementMinute" aria-label="Increase minute">▲</button>
|
||||
<div class="time-value">{{ displayMinute }}</div>
|
||||
<button class="arrow-btn" @click="decrementMinute" aria-label="Decrease minute">▼</button>
|
||||
</div>
|
||||
|
||||
<!-- AM/PM toggle -->
|
||||
<button class="ampm-btn" @click="toggleAmPm">{{ period }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface TimeValue {
|
||||
hour: number // 0–23 (24h)
|
||||
minute: number // 0, 15, 30, or 45
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: TimeValue }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
|
||||
|
||||
const MINUTES = [0, 15, 30, 45]
|
||||
|
||||
// ── display helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
|
||||
|
||||
const displayHour = computed(() => {
|
||||
const h = props.modelValue.hour % 12
|
||||
return String(h === 0 ? 12 : h)
|
||||
})
|
||||
|
||||
const displayMinute = computed(() => String(props.modelValue.minute).padStart(2, '0'))
|
||||
|
||||
// ── mutations ────────────────────────────────────────────────────────────────
|
||||
|
||||
function incrementHour() {
|
||||
const next = (props.modelValue.hour + 1) % 24
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
function decrementHour() {
|
||||
const next = (props.modelValue.hour - 1 + 24) % 24
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
|
||||
function incrementMinute() {
|
||||
const idx = MINUTES.indexOf(props.modelValue.minute)
|
||||
if (idx === MINUTES.length - 1) {
|
||||
// Wrap minute to 0 and carry into next hour
|
||||
const nextHour = (props.modelValue.hour + 1) % 24
|
||||
emit('update:modelValue', { hour: nextHour, minute: 0 })
|
||||
} else {
|
||||
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx + 1] })
|
||||
}
|
||||
}
|
||||
|
||||
function decrementMinute() {
|
||||
const idx = MINUTES.indexOf(props.modelValue.minute)
|
||||
if (idx === 0) {
|
||||
// Wrap minute to 45 and borrow from previous hour
|
||||
const prevHour = (props.modelValue.hour - 1 + 24) % 24
|
||||
emit('update:modelValue', { hour: prevHour, minute: 45 })
|
||||
} else {
|
||||
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx - 1] })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAmPm() {
|
||||
const next = props.modelValue.hour < 12 ? props.modelValue.hour + 12 : props.modelValue.hour - 12
|
||||
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.arrow-btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--primary, #667eea);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.arrow-btn:hover {
|
||||
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
|
||||
}
|
||||
|
||||
.time-value {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
|
||||
border-radius: 6px;
|
||||
background: var(--modal-bg, #fff);
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary, #7257b3);
|
||||
padding-bottom: 0.1rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ampm-btn {
|
||||
width: 3rem;
|
||||
height: 2.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
border: 1.5px solid var(--btn-primary, #667eea);
|
||||
border-radius: 6px;
|
||||
background: var(--btn-primary, #667eea);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
margin-left: 0.35rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user