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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user