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