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

- 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:
2026-02-23 15:44:55 -05:00
parent d8822b44be
commit 234adbe05f
26 changed files with 2880 additions and 60 deletions

View File

@@ -14,6 +14,7 @@ import type {
Event,
Task,
RewardStatus,
ChildTask,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
@@ -22,7 +23,18 @@ import type {
TaskModifiedEventPayload,
RewardModifiedEventPayload,
ChildModifiedEventPayload,
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
} from '@/common/models'
import {
isScheduledToday,
isPastTime,
getDueTimeToday,
formatDueTimeLabel,
msUntilExpiry,
isExtendedToday,
toLocalISODate,
} from '@/common/scheduleUtils'
const route = useRoute()
const router = useRouter()
@@ -290,12 +302,90 @@ function removeInactivityListeners() {
if (inactivityTimer) clearTimeout(inactivityTimer)
}
const childChoreListRef = ref()
const readyItemId = ref<string | null>(null)
const expiryTimers = ref<number[]>([])
const lastFetchDate = ref<string>(toLocalISODate(new Date()))
function handleItemReady(itemId: string) {
readyItemId.value = itemId
}
function handleChoreScheduleModified(event: Event) {
const payload = event.payload as ChoreScheduleModifiedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
setTimeout(() => resetExpiryTimers(), 300)
}
}
function handleChoreTimeExtended(event: Event) {
const payload = event.payload as ChoreTimeExtendedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
setTimeout(() => resetExpiryTimers(), 300)
}
}
function isChoreScheduledToday(item: ChildTask): boolean {
if (!item.schedule) return true
const today = new Date()
return isScheduledToday(item.schedule, today)
}
function isChoreExpired(item: ChildTask): boolean {
if (!item.schedule) return false
const today = new Date()
const due = getDueTimeToday(item.schedule, today)
if (!due) return false
if (item.extension_date && isExtendedToday(item.extension_date, today)) return false
return isPastTime(due.hour, due.minute, today)
}
function choreDueLabel(item: ChildTask): string | null {
if (!item.schedule) return null
const today = new Date()
const due = getDueTimeToday(item.schedule, today)
if (!due) return null
if (item.extension_date && isExtendedToday(item.extension_date, today)) return null
return formatDueTimeLabel(due.hour, due.minute)
}
function clearExpiryTimers() {
expiryTimers.value.forEach((t) => clearTimeout(t))
expiryTimers.value = []
}
function resetExpiryTimers() {
clearExpiryTimers()
const items: ChildTask[] = childChoreListRef.value?.items ?? []
const now = new Date()
for (const item of items) {
if (!item.schedule) continue
const due = getDueTimeToday(item.schedule, now)
if (!due) continue
if (item.extension_date && isExtendedToday(item.extension_date, now)) continue
const ms = msUntilExpiry(due.hour, due.minute, now)
if (ms > 0) {
const tid = window.setTimeout(() => {
childChoreListRef.value?.refresh()
}, ms)
expiryTimers.value.push(tid)
}
}
}
function onVisibilityChange() {
if (document.visibilityState === 'visible') {
const today = toLocalISODate(new Date())
if (today !== lastFetchDate.value) {
lastFetchDate.value = today
childChoreListRef.value?.refresh()
setTimeout(() => resetExpiryTimers(), 300)
}
}
}
const hasPendingRewards = computed(() =>
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
)
@@ -310,6 +400,9 @@ onMounted(async () => {
eventBus.on('reward_modified', handleRewardModified)
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_reward_request', handleRewardRequest)
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
eventBus.on('chore_time_extended', handleChoreTimeExtended)
document.addEventListener('visibilitychange', onVisibilityChange)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
@@ -321,6 +414,7 @@ onMounted(async () => {
rewards.value = data.rewards || []
}
loading.value = false
setTimeout(() => resetExpiryTimers(), 300)
})
}
}
@@ -340,6 +434,10 @@ onUnmounted(() => {
eventBus.off('reward_modified', handleRewardModified)
eventBus.off('child_modified', handleChildModified)
eventBus.off('child_reward_request', handleRewardRequest)
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
eventBus.off('chore_time_extended', handleChoreTimeExtended)
document.removeEventListener('visibilitychange', onVisibilityChange)
clearExpiryTimers()
removeInactivityListeners()
})
</script>
@@ -362,14 +460,17 @@ onUnmounted(() => {
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return item.is_good
}
:getItemClass="
(item: ChildTask) => ({
bad: !item.is_good,
good: item.is_good,
'chore-inactive': isChoreExpired(item),
})
"
:filter-fn="(item: ChildTask) => item.is_good && isChoreScheduledToday(item)"
>
<template #item="{ item }">
<template #item="{ item }: { item: ChildTask }">
<span v-if="isChoreExpired(item)" class="pending">TOO LATE</span>
<div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div
@@ -383,6 +484,7 @@ onUnmounted(() => {
}}
Points
</div>
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
</template>
</ScrollingList>
<ScrollingList
@@ -565,6 +667,18 @@ onUnmounted(() => {
pointer-events: none;
filter: grayscale(0.7);
}
:deep(.chore-inactive) {
opacity: 0.45;
filter: grayscale(0.6);
}
.due-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--due-label-color, #aaa);
margin-top: 0.15rem;
letter-spacing: 0.3px;
}
.modal-message {
margin-bottom: 1.2rem;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import ModalDialog from '../shared/ModalDialog.vue'
import ScheduleModal from '../shared/ScheduleModal.vue'
import PendingRewardDialog from './PendingRewardDialog.vue'
import TaskConfirmDialog from './TaskConfirmDialog.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
@@ -8,7 +9,7 @@ import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
import StatusMessage from '../shared/StatusMessage.vue'
import { setChildOverride, parseErrorResponse } from '@/common/api'
import { setChildOverride, parseErrorResponse, extendChoreTime } from '@/common/api'
import { eventBus } from '@/common/eventBus'
import '@/assets/styles.css'
import type {
@@ -17,6 +18,7 @@ import type {
Event,
Reward,
RewardStatus,
ChildTask,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
@@ -27,7 +29,18 @@ import type {
RewardModifiedEventPayload,
ChildOverrideSetPayload,
ChildOverrideDeletedPayload,
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
} from '@/common/models'
import {
isScheduledToday,
isPastTime,
getDueTimeToday,
formatDueTimeLabel,
msUntilExpiry,
isExtendedToday,
toLocalISODate,
} from '@/common/scheduleUtils'
const route = useRoute()
const router = useRouter()
@@ -56,6 +69,20 @@ const pendingEditOverrideTarget = ref<{ entity: Task | Reward; type: 'task' | 'r
null,
)
// Kebab menu
const activeMenuFor = ref<string | null>(null)
const shouldIgnoreNextCardClick = ref(false)
// Schedule modal
const showScheduleModal = ref(false)
const scheduleTarget = ref<ChildTask | null>(null)
// Expiry timers
const expiryTimers = ref<number[]>([])
// Last fetch date (for overnight detection)
const lastFetchDate = ref<string>(toLocalISODate(new Date()))
function handleItemReady(itemId: string) {
readyItemId.value = itemId
}
@@ -216,6 +243,155 @@ function handleOverrideDeleted(event: Event) {
}
}
function handleChoreScheduleModified(event: Event) {
const payload = event.payload as ChoreScheduleModifiedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
resetExpiryTimers()
}
}
function handleChoreTimeExtended(event: Event) {
const payload = event.payload as ChoreTimeExtendedPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
resetExpiryTimers()
}
}
// ── Kebab menu ───────────────────────────────────────────────────────────────
const onDocClick = (e: MouseEvent) => {
if (activeMenuFor.value !== null) {
const path = (e.composedPath?.() ?? (e as any).path ?? []) as EventTarget[]
const inside = path.some((node) => {
if (!(node instanceof HTMLElement)) return false
return (
node.classList.contains('chore-kebab-wrap') ||
node.classList.contains('kebab-btn') ||
node.classList.contains('kebab-menu')
)
})
if (!inside) {
activeMenuFor.value = null
if (path.some((n) => n instanceof HTMLElement && n.classList.contains('item-card'))) {
shouldIgnoreNextCardClick.value = true
}
}
}
}
function openChoreMenu(taskId: string, e: MouseEvent) {
e.stopPropagation()
activeMenuFor.value = taskId
}
function closeChoreMenu() {
activeMenuFor.value = null
}
// ── Schedule modal ────────────────────────────────────────────────────────────
function openScheduleModal(item: ChildTask, e: MouseEvent) {
e.stopPropagation()
closeChoreMenu()
scheduleTarget.value = item
showScheduleModal.value = true
}
function onScheduleSaved() {
showScheduleModal.value = false
scheduleTarget.value = null
childChoreListRef.value?.refresh()
resetExpiryTimers()
}
// ── Extend Time ───────────────────────────────────────────────────────────────
async function doExtendTime(item: ChildTask, e: MouseEvent) {
e.stopPropagation()
closeChoreMenu()
if (!child.value) return
const today = toLocalISODate(new Date())
const res = await extendChoreTime(child.value.id, item.id, today)
if (!res.ok) {
const { msg } = await parseErrorResponse(res)
alert(`Error: ${msg}`)
}
// SSE chore_time_extended event will trigger a refresh
}
// ── Schedule state helpers (for item-slot use) ────────────────────────────────
function isChoreScheduledToday(item: ChildTask): boolean {
if (!item.schedule) return true // no schedule = always active
return isScheduledToday(item.schedule, new Date())
}
function isChoreExpired(item: ChildTask): boolean {
if (!item.schedule) return false
const now = new Date()
if (!isScheduledToday(item.schedule, now)) return false
const due = getDueTimeToday(item.schedule, now)
if (!due) return false
if (isExtendedToday(item.extension_date, now)) return false
return isPastTime(due.hour, due.minute, now)
}
function choreDueLabel(item: ChildTask): string | null {
if (!item.schedule) return null
const now = new Date()
if (!isScheduledToday(item.schedule, now)) return null
const due = getDueTimeToday(item.schedule, now)
if (!due) return null
if (isExtendedToday(item.extension_date, now)) return null
if (isPastTime(due.hour, due.minute, now)) return null
return `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
}
function isChoreInactive(item: ChildTask): boolean {
return !isChoreScheduledToday(item) || isChoreExpired(item)
}
// ── Expiry timers ─────────────────────────────────────────────────────────────
function clearExpiryTimers() {
expiryTimers.value.forEach(clearTimeout)
expiryTimers.value = []
}
function resetExpiryTimers() {
clearExpiryTimers()
const items: ChildTask[] = childChoreListRef.value?.items ?? []
const now = new Date()
for (const item of items) {
if (!item.schedule || !item.is_good) continue
if (!isScheduledToday(item.schedule, now)) continue
const due = getDueTimeToday(item.schedule, now)
if (!due) continue
if (isExtendedToday(item.extension_date, now)) continue
if (isPastTime(due.hour, due.minute, now)) continue
const ms = msUntilExpiry(due.hour, due.minute, now)
const handle = setTimeout(() => {
// trigger a reactive update by refreshing the list
childChoreListRef.value?.refresh()
}, ms) as unknown as number
expiryTimers.value.push(handle)
}
}
// ── Midnight detection (tab left open overnight) ──────────────────────────────
function onVisibilityChange() {
if (document.visibilityState !== 'visible') return
const today = toLocalISODate(new Date())
if (today !== lastFetchDate.value) {
lastFetchDate.value = today
childChoreListRef.value?.refresh()
resetExpiryTimers()
}
}
function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
// If editing a pending reward, warn first
if (type === 'reward' && (item as any).redeeming) {
@@ -230,6 +406,11 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
showOverrideModal.value = true
}
function editChorePoints(item: Task) {
handleEditItem(item, 'task')
closeChoreMenu()
}
async function confirmPendingRewardAndEdit() {
if (!pendingEditOverrideTarget.value) return
const item = pendingEditOverrideTarget.value.entity as any
@@ -304,6 +485,11 @@ onMounted(async () => {
eventBus.on('child_reward_request', handleRewardRequest)
eventBus.on('child_override_set', handleOverrideSet)
eventBus.on('child_override_deleted', handleOverrideDeleted)
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
eventBus.on('chore_time_extended', handleChoreTimeExtended)
document.addEventListener('click', onDocClick, true)
document.addEventListener('visibilitychange', onVisibilityChange)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
@@ -335,6 +521,12 @@ onUnmounted(() => {
eventBus.off('reward_modified', handleRewardModified)
eventBus.off('child_override_set', handleOverrideSet)
eventBus.off('child_override_deleted', handleOverrideDeleted)
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
eventBus.off('chore_time_extended', handleChoreTimeExtended)
document.removeEventListener('click', onDocClick, true)
document.removeEventListener('visibilitychange', onVisibilityChange)
clearExpiryTimers()
})
function getPendingRewardIds(): string[] {
@@ -470,27 +662,66 @@ function goToAssignRewards() {
:ids="tasks"
itemKey="tasks"
imageField="image_id"
:enableEdit="true"
:enableEdit="false"
:childId="child?.id"
:readyItemId="readyItemId"
:isParentAuthenticated="true"
@trigger-item="triggerTask"
@edit-item="(item) => handleEditItem(item, 'task')"
@item-ready="handleItemReady"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return item.is_good
}
:getItemClass="
(item) => ({
bad: !item.is_good,
good: item.is_good,
'chore-inactive': isChoreInactive(item),
})
"
:filter-fn="(item) => item.is_good"
>
<template #item="{ item }">
<template #item="{ item }: { item: ChildTask }">
<!-- Kebab menu -->
<div class="chore-kebab-wrap" @click.stop>
<button
class="kebab-btn"
@mousedown.stop.prevent
@click="openChoreMenu(item.id, $event)"
:aria-expanded="activeMenuFor === item.id ? 'true' : 'false'"
aria-label="Options"
>
</button>
<div
v-if="activeMenuFor === item.id"
class="kebab-menu"
@mousedown.stop.prevent
@click.stop
>
<button class="menu-item" @mousedown.stop.prevent @click="editChorePoints(item)">
Edit Points
</button>
<button
class="menu-item"
@mousedown.stop.prevent
@click="openScheduleModal(item, $event)"
>
Schedule
</button>
<button
v-if="isChoreExpired(item)"
class="menu-item"
@mousedown.stop.prevent
@click="doExtendTime(item, $event)"
>
Extend Time
</button>
</div>
</div>
<!-- TOO LATE badge -->
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
<div class="item-name">{{ item.name }}</div>
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
<div
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
<div class="item-points good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
@@ -498,6 +729,7 @@ function goToAssignRewards() {
}}
Points
</div>
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
</template>
</ScrollingList>
<ScrollingList
@@ -595,6 +827,16 @@ function goToAssignRewards() {
"
/>
<!-- Schedule Modal -->
<ScheduleModal
v-if="showScheduleModal && scheduleTarget && child"
:task="scheduleTarget"
:childId="child.id"
:schedule="scheduleTarget.schedule ?? null"
@saved="onScheduleSaved"
@cancelled="showScheduleModal = false"
/>
<!-- Override Edit Modal -->
<ModalDialog
v-if="showOverrideModal && overrideEditTarget && child"
@@ -730,6 +972,101 @@ function goToAssignRewards() {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
:deep(.chore-inactive) {
opacity: 0.45;
filter: grayscale(60%);
}
/* Chore kebab menu (inside item-card which is position:relative in ScrollingList) */
.chore-kebab-wrap {
position: absolute;
top: 4px;
right: 4px;
z-index: 20;
}
.kebab-btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
color: var(--kebab-icon-color, #4a4a6a);
border-radius: 6px;
font-size: 1.4rem;
}
.kebab-btn:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
}
.kebab-menu {
position: absolute;
top: 36px;
right: 0;
min-width: 140px;
background: var(--kebab-menu-bg, #f7fafc);
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
box-shadow: var(--kebab-menu-shadow);
backdrop-filter: blur(var(--kebab-menu-blur));
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 30;
border-radius: 6px;
}
.menu-item {
padding: 0.85rem 0.9rem;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
font-weight: 600;
color: var(--menu-item-color, #333);
font-size: 1rem;
}
.menu-item:hover {
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
}
/* TOO LATE stamp on expired chores */
.chore-stamp {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: rgba(34, 34, 43, 0.85);
color: var(--btn-danger, #ef4444);
font-weight: 700;
font-size: 1.05rem;
text-align: center;
border-radius: 6px;
padding: 0.4rem 0;
letter-spacing: 2px;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
opacity: 0.95;
pointer-events: none;
}
/* Due time sub-text */
.due-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--item-points-color, #ffd166);
margin-top: 0.2rem;
letter-spacing: 0.3px;
}
/* Override modal styles */
.override-content {

View 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>

View 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 // 023 (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>