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;