Add routine management features for child and parent views
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s

- Implemented routine child-mode flow tests to ensure proper functionality of routine assignment and task completion.
- Created notification tests for parent view to verify routine completion notifications for children.
- Developed ChildRoutineOverlay component for displaying routine tasks and handling user interactions.
- Added RoutineApproveDialog component for approving or rejecting completed routines.
- Created unit tests for ChildRoutineOverlay and RoutineEditView components to ensure correct behavior and rendering.
- Enhanced RoutineEditView with proper handling of task addition and form submission.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-17 23:47:12 -04:00
co-authored by Copilot
parent eb775ba7d8
commit 5392e5af70
31 changed files with 3859 additions and 265 deletions
+462 -12
View File
@@ -6,6 +6,8 @@ import PendingRewardDialog from './PendingRewardDialog.vue'
import TaskConfirmDialog from './TaskConfirmDialog.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
import ChoreApproveDialog from './ChoreApproveDialog.vue'
import RoutineConfirmDialog from './RoutineConfirmDialog.vue'
import RoutineApproveDialog from './RoutineApproveDialog.vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
@@ -17,6 +19,12 @@ import {
approveChore,
rejectChore,
resetChore,
extendRoutineTime,
setChildRoutineOverride,
approveRoutine,
rejectRoutine,
resetRoutine,
triggerRoutineAsParent,
} from '@/common/api'
import { eventBus } from '@/common/eventBus'
import '@/assets/styles.css'
@@ -27,6 +35,7 @@ import type {
Reward,
RewardStatus,
ChildTask,
ChildRoutine,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
@@ -40,6 +49,8 @@ import type {
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
ChildChoreConfirmationPayload,
RoutineScheduleModifiedPayload,
RoutineTimeExtendedPayload,
} from '@/common/models'
import {
isScheduledToday,
@@ -74,6 +85,12 @@ const lastEditedItem = ref<{ id: string; type: 'task' | 'reward' } | null>(null)
const showChoreApproveDialog = ref(false)
const approveDialogChore = ref<ChildTask | null>(null)
// Routine approve/reject
const showRoutineApproveDialog = ref(false)
const approveDialogRoutine = ref<ChildRoutine | null>(null)
const showRoutineConfirmDialog = ref(false)
const confirmDialogRoutine = ref<ChildRoutine | null>(null)
// Override editing
const showOverrideModal = ref(false)
const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(null)
@@ -95,6 +112,14 @@ const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
const showScheduleModal = ref(false)
const scheduleTarget = ref<ChildTask | null>(null)
// Routines
const childRoutineListRef = ref()
const selectedRoutineId = ref<string | null>(null)
const activeRoutineMenuFor = ref<string | null>(null)
const routineKebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
const showRoutineScheduleModal = ref(false)
const routineScheduleTarget = ref<ChildRoutine | null>(null)
const pendingDialogReward = computed<Reward | RewardStatus | null>(() => {
if (pendingEditOverrideTarget.value?.type === 'reward') {
return pendingEditOverrideTarget.value.entity as Reward
@@ -112,6 +137,7 @@ const lastFetchDate = ref<string>(toLocalISODate(new Date()))
function handleItemReady(itemId: string) {
readyItemId.value = itemId
selectedChoreId.value = null
selectedRoutineId.value = null
}
function handleChoreItemReady(itemId: string) {
@@ -263,6 +289,8 @@ function handleOverrideSet(event: Event) {
scrollAfterRefresh(childPenaltyListRef)
} else if (payload.override.entity_type === 'reward') {
scrollAfterRefresh(childRewardListRef)
} else if (payload.override.entity_type === 'routine') {
scrollAfterRefresh(childRoutineListRef)
}
lastEditedItem.value = null
}
@@ -305,6 +333,147 @@ function handleChoreConfirmation(event: Event) {
}
}
// ── Routine SSE handlers ──────────────────────────────────────────────────────
function handleRoutineScheduleModified(event: Event) {
const payload = event.payload as RoutineScheduleModifiedPayload
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
function handleRoutineTimeExtended(event: Event) {
const payload = event.payload as RoutineTimeExtendedPayload
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
function handleRoutineModified() {
childRoutineListRef.value?.refresh()
}
function handleChildRoutinesSet(event: Event) {
const payload = event.payload as { child_id: string }
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
function handleChildRoutineConfirmation(event: Event) {
const payload = event.payload as { child_id: string }
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
// ── Routine helpers ───────────────────────────────────────────────────────────
function isRoutineScheduledToday(item: ChildRoutine): boolean {
if (!item.schedule) return true
return isScheduledToday(item.schedule as any, new Date())
}
function isRoutineExpired(item: ChildRoutine): boolean {
if (item.pending_status === 'pending') return false
if (!item.schedule) return false
const now = new Date()
if (!isScheduledToday(item.schedule as any, now)) return false
const due = getDueTimeToday(item.schedule as any, now)
if (!due) return false
if (isExtendedToday(item.extension_date ?? null, now)) return false
return isPastTime(due.hour, due.minute, now)
}
function isRoutinePending(item: ChildRoutine): boolean {
return item.pending_status === 'pending'
}
function isRoutineApprovedToday(item: ChildRoutine): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = new Date(item.approved_at)
const today = new Date()
return (
approvedDate.getFullYear() === today.getFullYear() &&
approvedDate.getMonth() === today.getMonth() &&
approvedDate.getDate() === today.getDate()
)
}
function isRoutineInactive(item: ChildRoutine): boolean {
return !isRoutineScheduledToday(item) || isRoutineExpired(item)
}
function routineDueLabel(item: ChildRoutine): string | null {
if (!item.schedule) return null
const now = new Date()
if (!isScheduledToday(item.schedule as any, now)) return null
const due = getDueTimeToday(item.schedule as any, now)
if (!due) return null
if (isExtendedToday(item.extension_date ?? null, now)) return null
if (isPastTime(due.hour, due.minute, now)) return null
return `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
}
// ── Routine kebab menu ────────────────────────────────────────────────────────
function handleRoutineItemReady(itemId: string) {
readyItemId.value = itemId
selectedRoutineId.value = itemId || null
}
function openRoutineMenu(routineId: string, e: MouseEvent) {
e.stopPropagation()
const btn = routineKebabBtnRefs.value.get(routineId)
if (btn) {
const rect = btn.getBoundingClientRect()
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
}
activeRoutineMenuFor.value = routineId
}
function closeRoutineMenu() {
activeRoutineMenuFor.value = null
}
function openRoutineScheduleModal(item: ChildRoutine, e: MouseEvent) {
e.stopPropagation()
closeRoutineMenu()
routineScheduleTarget.value = item
showRoutineScheduleModal.value = true
}
function onRoutineScheduleSaved() {
showRoutineScheduleModal.value = false
routineScheduleTarget.value = null
}
function editRoutine(item: ChildRoutine) {
closeRoutineMenu()
router.push({ name: 'EditRoutine', params: { id: item.id } })
}
function editRoutinePoints(item: ChildRoutine) {
closeRoutineMenu()
overrideEditTarget.value = { entity: item as any, type: 'routine' as any }
const defaultValue = item.custom_value ?? item.points
overrideCustomValue.value = defaultValue
validateOverrideInput()
showOverrideModal.value = true
}
async function doExtendRoutineTime(item: ChildRoutine, e: MouseEvent) {
e.stopPropagation()
closeRoutineMenu()
if (!child.value) return
const today = toLocalISODate(new Date())
const res = await extendRoutineTime(child.value.id, item.id, today)
if (!res.ok) {
const { msg } = await parseErrorResponse(res)
alert(`Error: ${msg}`)
}
}
function isChoreCompletedToday(item: ChildTask): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = new Date(item.approved_at)
@@ -363,6 +532,111 @@ function cancelChoreApproveDialog() {
approveDialogChore.value = null
}
// ── Routine approve/reject ────────────────────────────────────────────────────
async function doApproveRoutine() {
if (!child.value || !approveDialogRoutine.value) return
try {
const confirmationId = approveDialogRoutine.value.pending_confirmation_id
if (!confirmationId) return
const resp = await approveRoutine(child.value.id, confirmationId)
if (resp.ok) {
const data = await resp.json()
if (child.value) child.value.points = data.points
} else {
const { msg } = await parseErrorResponse(resp)
alert(`Error: ${msg}`)
}
} catch (err) {
console.error('Failed to approve routine:', err)
} finally {
showRoutineApproveDialog.value = false
approveDialogRoutine.value = null
}
}
async function doRejectRoutine() {
if (!child.value || !approveDialogRoutine.value) return
const confirmationId = approveDialogRoutine.value.pending_confirmation_id
if (!confirmationId) return
try {
const resp = await rejectRoutine(child.value.id, confirmationId)
if (!resp.ok) {
const { msg } = await parseErrorResponse(resp)
alert(`Error: ${msg}`)
}
} catch (err) {
console.error('Failed to reject routine:', err)
} finally {
showRoutineApproveDialog.value = false
approveDialogRoutine.value = null
}
}
function cancelRoutineApproveDialog() {
showRoutineApproveDialog.value = false
approveDialogRoutine.value = null
}
async function doConfirmRoutine() {
if (!child.value || !confirmDialogRoutine.value) return
try {
const resp = await triggerRoutineAsParent(child.value.id, confirmDialogRoutine.value.id)
if (resp.ok) {
const data = await resp.json()
if (child.value) child.value.points = data.points
} else {
const { msg } = await parseErrorResponse(resp)
alert(`Error: ${msg}`)
}
} catch (err) {
console.error('Failed to confirm routine:', err)
} finally {
showRoutineConfirmDialog.value = false
confirmDialogRoutine.value = null
}
}
function cancelRoutineConfirmDialog() {
showRoutineConfirmDialog.value = false
confirmDialogRoutine.value = null
}
async function doResetRoutine(item: ChildRoutine, e: MouseEvent) {
e.stopPropagation()
closeRoutineMenu()
if (!child.value || !item.pending_confirmation_id) return
try {
const resp = await resetRoutine(child.value.id, item.pending_confirmation_id)
if (!resp.ok) {
const { msg } = await parseErrorResponse(resp)
alert(`Error: ${msg}`)
}
} catch (err) {
console.error('Failed to reset routine:', err)
}
}
function triggerRoutine(item: ChildRoutine) {
if (shouldIgnoreNextCardClick.value) {
shouldIgnoreNextCardClick.value = false
return
}
if (isRoutineApprovedToday(item)) return
if (isRoutineExpired(item)) return
if (isRoutinePending(item)) {
approveDialogRoutine.value = item
setTimeout(() => {
showRoutineApproveDialog.value = true
}, 150)
return
}
confirmDialogRoutine.value = item
setTimeout(() => {
showRoutineConfirmDialog.value = true
}, 150)
}
async function doResetChore(item: ChildTask, e: MouseEvent) {
e.stopPropagation()
closeChoreMenu()
@@ -381,7 +655,7 @@ async function doResetChore(item: ChildTask, e: MouseEvent) {
// ── Kebab menu ───────────────────────────────────────────────────────────────
const onDocClick = (e: MouseEvent) => {
if (activeMenuFor.value !== null) {
if (activeMenuFor.value !== null || activeRoutineMenuFor.value !== null) {
const path = (e.composedPath?.() ?? (e as any).path ?? []) as EventTarget[]
const inside = path.some((node) => {
if (!(node instanceof HTMLElement)) return false
@@ -393,11 +667,12 @@ const onDocClick = (e: MouseEvent) => {
})
if (!inside) {
activeMenuFor.value = null
activeRoutineMenuFor.value = null
selectedChoreId.value = null
selectedRoutineId.value = null
if (path.some((n) => n instanceof HTMLElement && n.classList.contains('item-card'))) {
shouldIgnoreNextCardClick.value = true
} else {
// Clicked fully outside any card — reset ready state so chore requires 2 clicks again
readyItemId.value = null
}
}
@@ -549,7 +824,7 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
}
overrideEditTarget.value = { entity: item, type }
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
overrideCustomValue.value = item.custom_value ?? defaultValue
overrideCustomValue.value = (item as any).custom_value ?? defaultValue
validateOverrideInput()
showOverrideModal.value = true
}
@@ -570,7 +845,7 @@ async function confirmPendingRewardAndEdit() {
overrideEditTarget.value = target
const defaultValue =
target.type === 'task' ? (target.entity as Task).points : (target.entity as Reward).cost
overrideCustomValue.value = target.entity.custom_value ?? defaultValue
overrideCustomValue.value = (target.entity as any).custom_value ?? defaultValue
validateOverrideInput()
showOverrideModal.value = true
}
@@ -590,12 +865,22 @@ watch(showOverrideModal, async (newVal) => {
async function saveOverride() {
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
const res = await setChildOverride(
child.value.id,
overrideEditTarget.value.entity.id,
overrideEditTarget.value.type,
overrideCustomValue.value,
)
const type = overrideEditTarget.value.type as string
let res: Response
if (type === 'routine') {
res = await setChildRoutineOverride(
child.value.id,
overrideEditTarget.value.entity.id,
overrideCustomValue.value,
)
} else {
res = await setChildOverride(
child.value.id,
overrideEditTarget.value.entity.id,
overrideEditTarget.value.type,
overrideCustomValue.value,
)
}
if (res.ok) {
lastEditedItem.value = {
@@ -604,7 +889,7 @@ async function saveOverride() {
}
showOverrideModal.value = false
} else {
const { msg } = parseErrorResponse(res)
const { msg } = await parseErrorResponse(res)
alert(`Error: ${msg}`)
}
}
@@ -683,6 +968,11 @@ onMounted(async () => {
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
eventBus.on('chore_time_extended', handleChoreTimeExtended)
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
eventBus.on('routine_schedule_modified', handleRoutineScheduleModified)
eventBus.on('routine_time_extended', handleRoutineTimeExtended)
eventBus.on('routine_modified', handleRoutineModified)
eventBus.on('child_routines_set', handleChildRoutinesSet)
eventBus.on('child_routine_confirmation', handleChildRoutineConfirmation)
document.addEventListener('click', onDocClick, true)
document.addEventListener('visibilitychange', onVisibilityChange)
@@ -722,6 +1012,9 @@ onMounted(async () => {
} else if (entityType === 'reward') {
childRewardListRef.value?.scrollToItem(scrollToId)
applyHighlightPulse(scrollToId)
} else if (entityType === 'routine') {
childRoutineListRef.value?.scrollToItem(scrollToId)
applyHighlightPulse(scrollToId)
}
const { scrollTo: _s, entityType: _e, ...remainingQuery } = route.query
router.replace({ query: remainingQuery })
@@ -749,6 +1042,11 @@ onUnmounted(() => {
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
eventBus.off('chore_time_extended', handleChoreTimeExtended)
eventBus.off('child_chore_confirmation', handleChoreConfirmation)
eventBus.off('routine_schedule_modified', handleRoutineScheduleModified)
eventBus.off('routine_time_extended', handleRoutineTimeExtended)
eventBus.off('routine_modified', handleRoutineModified)
eventBus.off('child_routines_set', handleChildRoutinesSet)
eventBus.off('child_routine_confirmation', handleChildRoutineConfirmation)
document.removeEventListener('click', onDocClick, true)
document.removeEventListener('visibilitychange', onVisibilityChange)
@@ -1058,6 +1356,127 @@ function goToAssignRoutines() {
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
</template>
</ScrollingList>
<ScrollingList
title="Routines"
ref="childRoutineListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
itemKey="routines"
imageField="image_id"
:enableEdit="false"
:childId="child?.id"
:readyItemId="readyItemId"
:isParentAuthenticated="true"
@item-ready="handleRoutineItemReady"
@trigger-item="triggerRoutine"
:getItemClass="
(item) => ({
good: true,
'chore-inactive': isRoutineInactive(item) || isRoutineApprovedToday(item),
})
"
:sort-fn="
(a: ChildRoutine, b: ChildRoutine) => {
if (isRoutinePending(a) !== isRoutinePending(b)) return isRoutinePending(a) ? -1 : 1
if (!isRoutineScheduledToday(a) !== !isRoutineScheduledToday(b))
return isRoutineScheduledToday(a) ? -1 : 1
if (isRoutineApprovedToday(a) !== isRoutineApprovedToday(b))
return isRoutineApprovedToday(a) ? 1 : -1
if (isRoutineExpired(a) !== isRoutineExpired(b)) return isRoutineExpired(a) ? 1 : -1
return 0
}
"
>
<template #item="{ item }: { item: ChildRoutine }">
<!-- Routine kebab menu -->
<div class="chore-kebab-wrap" @click.stop>
<button
v-show="selectedRoutineId === item.id"
class="kebab-btn"
:ref="
(el) => {
if (el) routineKebabBtnRefs.set(item.id, el as HTMLElement)
else routineKebabBtnRefs.delete(item.id)
}
"
@mousedown.stop.prevent
@click="openRoutineMenu(item.id, $event)"
:aria-expanded="activeRoutineMenuFor === item.id ? 'true' : 'false'"
aria-label="Options"
>
</button>
<Teleport to="body">
<div
v-if="activeRoutineMenuFor === item.id"
class="kebab-menu"
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
@mousedown.stop.prevent
@click.stop
>
<button class="menu-item" @mousedown.stop.prevent @click="editRoutine(item)">
Edit Routine
</button>
<button
class="menu-item"
@mousedown.stop.prevent
@click="editRoutinePoints(item)"
>
Edit Points
</button>
<button
class="menu-item"
@mousedown.stop.prevent
@click="openRoutineScheduleModal(item, $event)"
>
Schedule
</button>
<button
v-if="isRoutineExpired(item)"
class="menu-item"
@mousedown.stop.prevent
@click="doExtendRoutineTime(item, $event)"
>
Extend Time
</button>
<button
v-if="isRoutineApprovedToday(item)"
class="menu-item"
@mousedown.stop.prevent
@click="doResetRoutine(item, $event)"
>
Reset
</button>
</div>
</Teleport>
</div>
<!-- Status badges -->
<span v-if="isRoutineApprovedToday(item)" class="chore-stamp completed-stamp"
>COMPLETED</span
>
<span v-else-if="isRoutineExpired(item)" class="chore-stamp">TOO LATE</span>
<span v-else-if="isRoutinePending(item)" class="chore-stamp pending-stamp"
>PENDING</span
>
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Routine Image"
class="item-image"
/>
<div class="item-points good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
<div v-if="routineDueLabel(item)" class="due-label">{{ routineDueLabel(item) }}</div>
</template>
</ScrollingList>
<ScrollingList
title="Kindness Acts"
ref="childKindnessListRef"
@@ -1217,6 +1636,17 @@ function goToAssignRoutines() {
@cancelled="showScheduleModal = false"
/>
<!-- Schedule Modal (Routines) -->
<ScheduleModal
v-if="showRoutineScheduleModal && routineScheduleTarget && child"
:entity="routineScheduleTarget"
entityType="routine"
:childId="child.id"
:schedule="routineScheduleTarget.schedule ?? null"
@saved="onRoutineScheduleSaved"
@cancelled="showRoutineScheduleModal = false"
/>
<!-- Override Edit Modal -->
<ModalDialog
v-if="showOverrideModal && overrideEditTarget && child"
@@ -1263,7 +1693,7 @@ function goToAssignRoutines() {
<!-- Reward Confirm Dialog -->
<RewardConfirmDialog
v-if="showRewardConfirm"
:reward="selectedReward"
:reward="selectedReward as any"
:childName="child?.name"
@confirm="confirmTriggerReward"
@deny="denyRewardRequest"
@@ -1287,6 +1717,26 @@ function goToAssignRoutines() {
@reject="doRejectChore"
@cancel="cancelChoreApproveDialog"
/>
<!-- Routine Approve/Reject Dialog -->
<RoutineApproveDialog
v-if="showRoutineApproveDialog && approveDialogRoutine"
:show="showRoutineApproveDialog"
:childName="child?.name ?? ''"
:routineName="approveDialogRoutine.name"
:points="approveDialogRoutine.custom_value ?? approveDialogRoutine.points"
:imageUrl="approveDialogRoutine.image_url"
@approve="doApproveRoutine"
@reject="doRejectRoutine"
@cancel="cancelRoutineApproveDialog"
/>
<RoutineConfirmDialog
:routine="confirmDialogRoutine"
:childName="child?.name ?? ''"
@confirm="doConfirmRoutine"
@cancel="cancelRoutineConfirmDialog"
/>
</div>
</template>