Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s

This commit is contained in:
2026-04-25 00:40:15 -04:00
parent db846f4e31
commit 127378797c
263 changed files with 88 additions and 79 deletions

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { toRefs, ref, watch, onBeforeUnmount } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Child {
id: string | number
name: string
age: number
points?: number
image_id: string | null
}
const props = defineProps<{
child: Child | null
}>()
const { child } = toRefs(props)
const imageUrl = ref<string | null>(null)
const imageCacheName = 'images-v1'
const fetchImage = async (imageId: string) => {
try {
const url = await getCachedImageUrl(imageId, imageCacheName)
imageUrl.value = url
} catch (err) {
console.error('Error fetching child image:', err)
}
}
watch(
() => child.value?.image_id,
(newImageId) => {
if (newImageId) {
fetchImage(newImageId)
}
},
{ immediate: true },
)
// Revoke created object URLs when component unmounts to avoid memory leaks
onBeforeUnmount(() => {
revokeAllImageUrls()
})
</script>
<template>
<div v-if="child" class="detail-card-horizontal">
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
<div class="main-info">
<div class="child-name">{{ child.name }}</div>
<div class="child-age">Age: {{ child.age }}</div>
</div>
<div class="points">
<span class="label">Points</span>
<span class="value">{{ child.points ?? '—' }}</span>
</div>
</div>
</template>
<style scoped>
.detail-card-horizontal {
display: flex;
align-items: center;
background: var(--detail-card-bg);
border-radius: 12px;
box-shadow: var(--detail-card-shadow);
padding: 0.7rem 1rem;
max-width: 420px;
width: 100%;
min-height: 64px;
box-sizing: border-box;
gap: 1rem;
}
.child-image {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 50%;
flex-shrink: 0;
}
.main-info {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1 1 auto;
min-width: 0;
}
.child-name {
font-size: 1.08rem;
font-weight: 600;
color: var(--child-name-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.child-age {
font-size: 0.97rem;
color: var(--child-age-color);
margin-top: 2px;
}
.points {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 54px;
margin-left: 0.7rem;
}
.points .label {
font-size: 0.85rem;
color: var(--points-label-color);
margin-bottom: 1px;
}
.points .value {
font-size: 1.6rem;
font-weight: 900;
color: var(--points-value-color);
}
@media (max-width: 480px) {
.detail-card-horizontal {
padding: 0.5rem 0.4rem;
max-width: 98vw;
gap: 0.6rem;
}
.child-image {
width: 38px;
height: 38px;
}
.points {
min-width: 38px;
margin-left: 0.3rem;
}
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="view">
<EntityEditForm
entityLabel="Child"
:fields="fields"
:initialData="initialData"
:isEdit="isEdit"
:requireDirty="isEdit"
:loading="loading"
:error="error"
@submit="handleSubmit"
@cancel="handleCancel"
@add-image="handleAddImage"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import '@/assets/styles.css'
const router = useRouter()
const props = defineProps<{ id?: string }>()
const isEdit = computed(() => !!props.id)
type Field = {
name: string
label: string
type: 'text' | 'number' | 'image' | 'custom'
required?: boolean
maxlength?: number
min?: number
max?: number
imageType?: number
}
type ChildForm = {
name: string
age: number | null
image_id: string | null
}
const fields: Field[] = [
{ name: 'name', label: 'Name', type: 'text', required: true, maxlength: 64 },
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120, maxlength: 3 },
{ name: 'image_id', label: 'Image', type: 'image', imageType: 1 },
]
const initialData = ref<ChildForm>({ name: '', age: null, image_id: null })
const localImageFile = ref<File | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
loading.value = true
try {
const resp = await fetch(`/api/child/${props.id}`)
if (!resp.ok) throw new Error('Failed to load child')
const data = await resp.json()
initialData.value = {
name: data.name ?? '',
age: data.age === null || data.age === undefined ? null : Number(data.age),
image_id: data.image_id ?? null,
}
} catch {
error.value = 'Could not load child.'
} finally {
loading.value = false
await nextTick()
}
} else {
try {
const resp = await fetch('/api/image/list?type=1')
if (resp.ok) {
const data = await resp.json()
const ids = data.ids || []
if (ids.length > 0) {
initialData.value = {
...initialData.value,
image_id: ids[0],
}
}
}
} catch {
// Ignore default image lookup failures and keep existing behavior.
}
}
})
function handleAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
async function handleSubmit(form: ChildForm) {
let imageId = form.image_id
error.value = null
if (!form.name.trim()) {
error.value = 'Child name is required.'
return
}
if (form.age === null || form.age < 0) {
error.value = 'Age must be a non-negative number.'
return
}
loading.value = true
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch {
error.value = 'Failed to upload image.'
loading.value = false
return
}
}
// Now update or create the child
try {
let resp
if (isEdit.value && props.id) {
resp = await fetch(`/api/child/${props.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
age: form.age,
image_id: imageId,
}),
})
} else {
resp = await fetch('/api/child/add', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
age: form.age,
image_id: imageId,
}),
})
}
if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ParentChildrenListView' })
} catch {
error.value = 'Failed to save child.'
}
loading.value = false
}
function handleCancel() {
router.back()
}
</script>
<style scoped>
.view {
max-width: 400px;
margin: 0 auto;
background: var(--form-bg);
border-radius: 12px;
box-shadow: 0 4px 24px var(--form-shadow);
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
</style>

View File

@@ -0,0 +1,943 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
import StatusMessage from '../shared/StatusMessage.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { eventBus } from '@/common/eventBus'
//import '@/assets/view-shared.css'
import '@/assets/styles.css'
import type {
Child,
Event,
Task,
RewardStatus,
ChildTask,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
ChildTasksSetEventPayload,
ChildRewardsSetEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
ChildModifiedEventPayload,
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
ChildChoreConfirmationPayload,
ChildOverrideSetPayload,
ChildOverrideDeletedPayload,
} from '@/common/models'
import { confirmChore, cancelConfirmChore } from '@/common/api'
import {
isScheduledToday,
isPastTime,
getDueTimeToday,
formatDueTimeLabel,
msUntilExpiry,
isExtendedToday,
toLocalISODate,
} from '@/common/scheduleUtils'
const route = useRoute()
const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const childRewardListRef = ref()
const showRewardDialog = ref(false)
const showCancelDialog = ref(false)
const dialogReward = ref<RewardStatus | null>(null)
const showChoreConfirmDialog = ref(false)
const showChoreCancelDialog = ref(false)
const dialogChore = ref<ChildTask | null>(null)
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
childRewardListRef.value?.refresh()
}
}
function handleRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
childRewardListRef.value?.refresh()
}
}
function handleChildTaskSet(event: Event) {
const payload = event.payload as ChildTasksSetEventPayload
if (child.value && payload.child_id == child.value.id) {
tasks.value = payload.task_ids
}
}
function handleChildRewardSet(event: Event) {
const payload = event.payload as ChildRewardsSetEventPayload
if (child.value && payload.child_id == child.value.id) {
rewards.value = payload.reward_ids
}
}
function handleRewardRequest(event: Event) {
const payload = event.payload as ChildRewardRequestEventPayload
const childId = payload.child_id
const rewardId = payload.reward_id
if (child.value && childId == child.value.id) {
if (rewards.value.find((r) => r === rewardId)) {
childRewardListRef.value?.refresh()
}
}
}
function handleChildModified(event: Event) {
const payload = event.payload as ChildModifiedEventPayload
if (child.value && payload.child_id == child.value.id) {
switch (payload.operation) {
case 'DELETE':
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
break
case 'ADD':
// A new child was added, this shouldn't affect the current child view
console.log('ADD operation received for child_modified, no action taken.')
break
case 'EDIT':
//our child was edited, refetch its data
try {
const dataPromise = fetchChildData(payload.child_id)
dataPromise.then((data) => {
if (data) {
child.value = data
}
loading.value = false
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
}
}
}
function handleTaskModified(event: Event) {
const payload = event.payload as TaskModifiedEventPayload
if (child.value) {
const task_id = payload.task_id
if (tasks.value.includes(task_id)) {
try {
switch (payload.operation) {
case 'DELETE':
// Remove the task from the list
tasks.value = tasks.value.filter((t) => t !== task_id)
return // No need to refetch
case 'ADD':
// A new task was added, this shouldn't affect the current task list
console.log('ADD operation received for task_modified, no action taken.')
return // No need to refetch
case 'EDIT':
try {
const dataPromise = fetchChildData(child.value.id)
dataPromise.then((data) => {
if (data) {
tasks.value = data.tasks || []
}
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
} finally {
loading.value = false
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
return // No need to refetch
}
} catch (err) {
console.warn('Failed to fetch child after task modification:', err)
}
}
}
}
function handleRewardModified(event: Event) {
const payload = event.payload as RewardModifiedEventPayload
if (child.value) {
const reward_id = payload.reward_id
if (rewards.value.includes(reward_id)) {
childRewardListRef.value?.refresh()
}
}
}
const triggerTask = async (task: ChildTask) => {
// Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel()
if (task.name) {
const utter = new window.SpeechSynthesisUtterance(task.name)
utter.rate = 1.0
utter.pitch = 1.0
utter.volume = 1.0
window.speechSynthesis.speak(utter)
}
}
// For chores, handle confirmation flow
if (task.type === 'chore') {
if (isChoreExpired(task)) return // Expired — no action
if (isChoreCompletedToday(task)) return // Already completed — no action
if (task.pending_status === 'pending') {
// Show cancel dialog
dialogChore.value = task
setTimeout(() => {
showChoreCancelDialog.value = true
}, 150)
return
}
// Available — show confirm dialog
dialogChore.value = task
setTimeout(() => {
showChoreConfirmDialog.value = true
}, 150)
return
}
// Kindness / Penalty: speech-only in child mode; point changes are handled in parent mode.
}
function isChoreCompletedToday(item: ChildTask): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = item.approved_at.substring(0, 10)
const today = new Date().toISOString().slice(0, 10)
return approvedDate === today
}
async function doConfirmChore() {
if (!child.value?.id || !dialogChore.value) return
try {
const resp = await confirmChore(child.value.id, dialogChore.value.id)
if (!resp.ok) {
console.error('Failed to confirm chore')
}
} catch (err) {
console.error('Failed to confirm chore:', err)
} finally {
showChoreConfirmDialog.value = false
dialogChore.value = null
}
}
function cancelChoreConfirmDialog() {
showChoreConfirmDialog.value = false
dialogChore.value = null
}
async function doCancelConfirmChore() {
if (!child.value?.id || !dialogChore.value) return
try {
const resp = await cancelConfirmChore(child.value.id, dialogChore.value.id)
if (!resp.ok) {
console.error('Failed to cancel chore confirmation')
}
} catch (err) {
console.error('Failed to cancel chore confirmation:', err)
} finally {
showChoreCancelDialog.value = false
dialogChore.value = null
}
}
function closeChoreCancelDialog() {
showChoreCancelDialog.value = false
dialogChore.value = null
}
const triggerReward = (reward: RewardStatus) => {
// Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel()
if (reward.name) {
const utterString =
reward.name +
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString)
utter.rate = 1.0
utter.pitch = 1.0
utter.volume = 1.0
window.speechSynthesis.speak(utter)
}
}
if (reward.redeeming) {
dialogReward.value = reward
setTimeout(() => {
showCancelDialog.value = true
}, 150)
return
}
if (reward.points_needed <= 0) {
dialogReward.value = reward
setTimeout(() => {
showRewardDialog.value = true
}, 150)
}
}
function cancelRedeemReward() {
showRewardDialog.value = false
dialogReward.value = null
}
function closeCancelDialog() {
showCancelDialog.value = false
dialogReward.value = null
}
async function confirmRedeemReward() {
if (!child.value?.id || !dialogReward.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/request-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: dialogReward.value.id }),
})
if (!resp.ok) return
} catch (err) {
console.error('Failed to redeem reward:', err)
} finally {
showRewardDialog.value = false
dialogReward.value = null
}
}
async function cancelPendingReward() {
if (!child.value?.id || !dialogReward.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/cancel-request-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: dialogReward.value.id }),
})
if (!resp.ok) throw new Error('Failed to cancel pending reward')
} catch (err) {
console.error('Failed to cancel pending reward:', err)
} finally {
showCancelDialog.value = false
dialogReward.value = null
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
error.value = null
return data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
return null
} finally {
}
}
let inactivityTimer: ReturnType<typeof setTimeout> | null = null
function resetInactivityTimer() {
if (inactivityTimer) clearTimeout(inactivityTimer)
inactivityTimer = setTimeout(() => {
router.push({ name: 'ChildrenListView' })
}, 60000) // 60 seconds
}
function setupInactivityListeners() {
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart']
events.forEach((evt) => window.addEventListener(evt, resetInactivityTimer))
}
function removeInactivityListeners() {
const events = ['mousemove', 'mousedown', 'keydown', 'touchstart']
events.forEach((evt) => window.removeEventListener(evt, resetInactivityTimer))
if (inactivityTimer) clearTimeout(inactivityTimer)
}
const childChoreListRef = ref()
const childKindnessListRef = ref()
const childPenaltyListRef = 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 handleChoreConfirmation(event: Event) {
const payload = event.payload as ChildChoreConfirmationPayload
if (child.value && payload.child_id === child.value.id) {
childChoreListRef.value?.refresh()
}
}
function handleOverrideSet(event: Event) {
const payload = event.payload as ChildOverrideSetPayload
if (child.value && payload.override.child_id === child.value.id) {
if (payload.override.entity_type === 'task') {
childChoreListRef.value?.refresh()
childKindnessListRef.value?.refresh()
childPenaltyListRef.value?.refresh()
} else if (payload.override.entity_type === 'reward') {
childRewardListRef.value?.refresh()
}
}
}
function handleOverrideDeleted(event: Event) {
const payload = event.payload as ChildOverrideDeletedPayload
if (child.value && payload.child_id === child.value.id) {
if (payload.entity_type === 'task') {
childChoreListRef.value?.refresh()
childKindnessListRef.value?.refresh()
childPenaltyListRef.value?.refresh()
} else if (payload.entity_type === 'reward') {
childRewardListRef.value?.refresh()
}
}
}
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 `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
}
// ── Sorting ───────────────────────────────────────────────────────────────────
function childChoreSortPriority(item: ChildTask): number {
if (isChoreCompletedToday(item)) return 3
if (item.pending_status === 'pending') return 2
if (!item.schedule) return 1 // general chore
return 0 // scheduled for today
}
function childChoreSort(a: ChildTask, b: ChildTask): number {
const pa = childChoreSortPriority(a)
const pb = childChoreSortPriority(b)
if (pa !== pb) return pa - pb
// Both scheduled: sort by earliest deadline
if (pa === 0) {
const now = new Date()
const dueA = getDueTimeToday(a.schedule!, now)
const dueB = getDueTimeToday(b.schedule!, now)
const minsA = dueA ? dueA.hour * 60 + dueA.minute : Infinity
const minsB = dueB ? dueB.hour * 60 + dueB.minute : Infinity
return minsA - minsB
}
return 0
}
function childRewardSort(a: RewardStatus, b: RewardStatus): number {
if (a.redeeming !== b.redeeming) return a.redeeming ? -1 : 1
return a.points_needed - b.points_needed
}
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),
)
onMounted(async () => {
try {
eventBus.on('child_task_triggered', handleTaskTriggered)
eventBus.on('child_reward_triggered', handleRewardTriggered)
eventBus.on('child_tasks_set', handleChildTaskSet)
eventBus.on('child_rewards_set', handleChildRewardSet)
eventBus.on('task_modified', handleTaskModified)
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)
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
eventBus.on('child_override_set', handleOverrideSet)
eventBus.on('child_override_deleted', handleOverrideDeleted)
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) {
const promise = fetchChildData(idParam)
promise.then((data) => {
if (data) {
child.value = data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
}
loading.value = false
setTimeout(() => resetExpiryTimers(), 300)
})
}
}
setupInactivityListeners()
resetInactivityTimer()
} catch (err) {
console.error('Error in onMounted:', err)
}
})
onUnmounted(() => {
eventBus.off('child_task_triggered', handleTaskTriggered)
eventBus.off('child_reward_triggered', handleRewardTriggered)
eventBus.off('child_tasks_set', handleChildTaskSet)
eventBus.off('child_rewards_set', handleChildRewardSet)
eventBus.off('task_modified', handleTaskModified)
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)
eventBus.off('child_chore_confirmation', handleChoreConfirmation)
eventBus.off('child_override_set', handleOverrideSet)
eventBus.off('child_override_deleted', handleOverrideDeleted)
document.removeEventListener('visibilitychange', onVisibilityChange)
clearExpiryTimers()
removeInactivityListeners()
})
</script>
<template>
<div>
<StatusMessage :loading="loading" :error="error" />
<div v-if="!loading && !error" class="layout">
<div class="main">
<ChildDetailCard :child="child" />
<ScrollingList
title="Chores"
ref="childChoreListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask"
:getItemClass="
(item: ChildTask) => ({
bad: item.type === 'penalty',
good: item.type !== 'penalty',
'chore-inactive':
item.type === 'chore' && (isChoreExpired(item) || isChoreCompletedToday(item)),
})
"
:filter-fn="(item: ChildTask) => item.type === 'chore' && isChoreScheduledToday(item)"
:sort-fn="childChoreSort"
>
<template #item="{ item }: { item: ChildTask }">
<span v-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</span>
<span v-else-if="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
>COMPLETED</span
>
<span v-else-if="item.pending_status === 'pending'" 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="Task 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="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
</template>
</ScrollingList>
<ScrollingList
title="Rewards"
ref="childRewardListRef"
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerReward"
:getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
"
:sort-fn="childRewardSort"
>
<template #item="{ item }: { item: RewardStatus }">
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Reward Image"
class="item-image"
/>
<div class="item-points">
<span v-if="item.redeeming" class="pending">PENDING</span>
<span v-if="item.points_needed <= 0" class="ready">REWARD READY</span>
<span v-else>{{ item.points_needed }} more points</span>
</div>
</template>
</ScrollingList>
<ScrollingList
title="Kindness Acts"
ref="childKindnessListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask"
:getItemClass="() => ({ good: true })"
:filter-fn="(item: ChildTask) => item.type === 'kindness'"
>
<template #item="{ item }">
<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 good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Penalties"
ref="childPenaltyListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="triggerTask"
:getItemClass="() => ({ bad: true })"
:filter-fn="(item: ChildTask) => item.type === 'penalty'"
>
<template #item="{ item }">
<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 bad-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? -item.custom_value
: -item.points
}}
Points
</div>
</template>
</ScrollingList>
</div>
</div>
</div>
<!-- Redeem reward dialog -->
<RewardConfirmDialog
v-if="showRewardDialog"
:reward="dialogReward"
:childName="child?.name"
@confirm="confirmRedeemReward"
@cancel="cancelRedeemReward"
/>
<!-- Cancel pending reward dialog -->
<ModalDialog
v-if="showCancelDialog && dialogReward"
:imageUrl="dialogReward.image_url"
:title="dialogReward.name"
subtitle="Reward Pending"
@backdrop-click="closeCancelDialog"
>
<div class="modal-message">
This reward is pending.<br />Would you like to cancel the request?
</div>
<div class="modal-actions">
<button @click="cancelPendingReward" class="btn btn-primary">Yes</button>
<button @click="closeCancelDialog" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
<!-- Chore confirm dialog -->
<ChoreConfirmDialog
:show="showChoreConfirmDialog"
:choreName="dialogChore?.name ?? ''"
:imageUrl="dialogChore?.image_url"
@confirm="doConfirmChore"
@cancel="cancelChoreConfirmDialog"
/>
<!-- Cancel chore confirmation dialog -->
<ModalDialog
v-if="showChoreCancelDialog && dialogChore"
:title="dialogChore.name"
subtitle="Chore Pending"
@backdrop-click="closeChoreCancelDialog"
>
<div class="modal-message">
This chore is pending confirmation.<br />Would you like to cancel?
</div>
<div class="modal-actions">
<button @click="doCancelConfirmChore" class="btn btn-primary">Yes</button>
<button @click="closeChoreCancelDialog" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
</template>
<style scoped>
.layout {
display: flex;
justify-content: center;
align-items: flex-start;
}
.main {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
width: 100%;
}
/* Responsive Adjustments */
@media (max-width: 900px) {
.layout {
flex-direction: column;
align-items: stretch;
}
}
@media (max-width: 480px) {
.main {
gap: 1rem;
}
.modal {
padding: 1rem;
min-width: 0;
}
}
.item-points {
color: var(--item-points-color, #ffd166);
font-size: 1rem;
font-weight: 900;
text-shadow: var(--item-points-shadow);
}
.ready {
color: var(--item-points-ready-color, #38c172);
letter-spacing: 0.5px;
}
.pending {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: var(--pending-block-bg, #222b);
color: var(--pending-block-color, #62ff7a);
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;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.item-points {
font-size: 0.78rem;
}
}
:deep(.good) {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
:deep(.bad) {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
:deep(.reward) {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
:deep(.disabled) {
opacity: 0.5;
pointer-events: none;
filter: grayscale(0.7);
}
:deep(.chore-inactive) {
position: relative;
}
:deep(.chore-inactive::before) {
content: '';
position: absolute;
inset: 0;
background: rgba(160, 160, 160, 0.45);
filter: grayscale(80%);
z-index: 1;
pointer-events: none;
border-radius: inherit;
}
.chore-stamp {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: rgba(34, 34, 34, 0.65);
color: var(--text-bad-color, #ef4444);
text-shadow: var(--item-points-shadow);
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;
}
.pending-stamp {
color: #fbbf24;
}
.completed-stamp {
color: #22c55e;
}
@media (max-width: 480px) {
.chore-stamp {
font-size: 0.82rem;
letter-spacing: 1px;
padding: 0.3rem 0;
}
}
.due-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-bad-color, #ef4444);
margin-top: 0.15rem;
letter-spacing: 0.3px;
}
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,78 @@
<template>
<ModalDialog v-if="show" @backdrop-click="$emit('cancel')">
<template #default>
<div class="approve-dialog">
<img v-if="imageUrl" :src="imageUrl" alt="Chore" class="chore-image" />
<p class="child-label">{{ childName }}</p>
<p class="message">completed <strong>{{ choreName }}</strong></p>
<p class="message">Will be awarded <strong>{{ points }} points</strong></p>
<div class="actions">
<button class="btn btn-primary" @click="$emit('approve')">Approve</button>
<button class="btn btn-secondary" @click="$emit('reject')">Reject</button>
</div>
</div>
</template>
</ModalDialog>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
defineProps<{
show: boolean
childName: string
choreName: string
points: number
imageUrl?: string | null
}>()
defineEmits<{
approve: []
reject: []
cancel: []
}>()
</script>
<style scoped>
.approve-dialog {
text-align: center;
padding: 0.5rem;
}
.chore-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
margin-bottom: 0.75rem;
}
.child-label {
font-size: 1.2rem;
font-weight: 700;
color: var(--dialog-child-name);
margin-bottom: 0.15rem;
}
.message {
font-size: 1rem;
color: var(--dialog-message);
margin-bottom: 0.15rem;
}
.message:last-of-type {
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.actions button {
padding: 0.7rem 1.8rem;
border-radius: 10px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.05rem;
transition: background 0.18s;
min-width: 100px;
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="assign-view">
<h2>Assign Chores{{ childName ? ` for ${childName}` : '' }}</h2>
<div class="list-container">
<MessageBlock v-if="countRef === 0" message="No chores">
<span> <button class="round-btn" @click="goToCreate">Create</button> a chore </span>
</MessageBlock>
<ItemList
v-else
ref="listRef"
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=chore`"
itemKey="tasks"
:itemFields="TASK_FIELDS"
imageField="image_id"
selectable
@loading-complete="(count) => (countRef = count)"
:getItemClass="() => ({ good: true })"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.points }} pts</span>
</template>
</ItemList>
</div>
<div class="actions" v-if="countRef > 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/styles.css'
import { TASK_FIELDS } from '@/common/models'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const childName = typeof route.query.name === 'string' ? route.query.name : ''
const listRef = ref()
const countRef = ref(-1)
function goToCreate() {
router.push({ name: 'CreateChore' })
}
async function onSubmit() {
const selectedIds = listRef.value?.selectedItems ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'chore', task_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update chores')
router.back()
} catch {
alert('Failed to update chores.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.list-container {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
:deep(.good) {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
.actions button {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<ModalDialog v-if="show" @backdrop-click="$emit('cancel')">
<template #default>
<div class="confirm-dialog">
<img v-if="imageUrl" :src="imageUrl" alt="Chore" class="chore-image" />
<p class="message">Did you finish</p>
<p class="chore-name">{{ choreName }}?</p>
<div class="actions">
<button class="btn btn-primary" @click="$emit('confirm')">Yes!</button>
<button class="btn btn-secondary" @click="$emit('cancel')">No</button>
</div>
</div>
</template>
</ModalDialog>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
defineProps<{
show: boolean
choreName: string
imageUrl?: string | null
}>()
defineEmits<{
confirm: []
cancel: []
}>()
</script>
<style scoped>
.confirm-dialog {
text-align: center;
padding: 0.5rem;
}
.chore-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
margin-bottom: 0.75rem;
}
.message {
font-size: 1.1rem;
color: var(--dialog-message);
margin-bottom: 0.25rem;
}
.chore-name {
font-size: 1.3rem;
font-weight: 700;
color: var(--dialog-child-name);
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.actions button {
padding: 0.7rem 1.8rem;
border-radius: 10px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.05rem;
transition: background 0.18s;
min-width: 100px;
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="assign-view">
<h2>Assign Kindness Acts{{ childName ? ` for ${childName}` : '' }}</h2>
<div class="list-container">
<MessageBlock v-if="countRef === 0" message="No kindness acts">
<span> <button class="round-btn" @click="goToCreate">Create</button> a kindness act </span>
</MessageBlock>
<ItemList
v-else
ref="listRef"
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=kindness`"
itemKey="tasks"
:itemFields="TASK_FIELDS"
imageField="image_id"
selectable
@loading-complete="(count) => (countRef = count)"
:getItemClass="() => ({ good: true })"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.points }} pts</span>
</template>
</ItemList>
</div>
<div class="actions" v-if="countRef > 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/styles.css'
import { TASK_FIELDS } from '@/common/models'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const childName = typeof route.query.name === 'string' ? route.query.name : ''
const listRef = ref()
const countRef = ref(-1)
function goToCreate() {
router.push({ name: 'CreateKindness' })
}
async function onSubmit() {
const selectedIds = listRef.value?.selectedItems ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'kindness', task_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update kindness acts')
router.back()
} catch {
alert('Failed to update kindness acts.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.list-container {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
:deep(.good) {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
.actions button {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
<template>
<div class="assign-view">
<h2>Assign Penalties{{ childName ? ` for ${childName}` : '' }}</h2>
<div class="list-container">
<MessageBlock v-if="countRef === 0" message="No penalties">
<span> <button class="round-btn" @click="goToCreate">Create</button> a penalty </span>
</MessageBlock>
<ItemList
v-else
ref="listRef"
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=penalty`"
itemKey="tasks"
:itemFields="TASK_FIELDS"
imageField="image_id"
selectable
@loading-complete="(count) => (countRef = count)"
:getItemClass="() => ({ bad: true })"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.points }} pts</span>
</template>
</ItemList>
</div>
<div class="actions" v-if="countRef > 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/styles.css'
import { TASK_FIELDS } from '@/common/models'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const childName = typeof route.query.name === 'string' ? route.query.name : ''
const listRef = ref()
const countRef = ref(-1)
function goToCreate() {
router.push({ name: 'CreatePenalty' })
}
async function onSubmit() {
const selectedIds = listRef.value?.selectedItems ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'penalty', task_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update penalties')
router.back()
} catch {
alert('Failed to update penalties.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.list-container {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
:deep(.bad) {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
.actions button {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<ModalDialog title="Warning!" @backdrop-click="$emit('cancel')">
<div class="modal-message">
{{ message }}
</div>
<div class="modal-actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
<button @click="$emit('cancel')" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
withDefaults(
defineProps<{
message?: string
}>(),
{
message: 'A reward is currently pending. It will be cancelled. Would you like to proceed?',
},
)
defineEmits<{
confirm: []
cancel: []
}>()
</script>
<style scoped>
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="reward-assign-view">
<h2>Assign Rewards{{ childName ? ` for ${childName}` : '' }}</h2>
<div class="reward-view">
<MessageBlock v-if="rewardCountRef === 0" message="No rewards">
<span> <button class="round-btn" @click="goToCreateReward">Create</button> a reward </span>
</MessageBlock>
<ItemList
v-else
ref="rewardListRef"
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
itemKey="rewards"
:itemFields="REWARD_FIELDS"
imageField="image_id"
selectable
@loading-complete="(count) => (rewardCountRef = count)"
:getItemClass="(item) => `reward`"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.cost }} pts</span>
</template>
</ItemList>
</div>
<div class="actions" v-if="rewardCountRef != 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/styles.css'
import { REWARD_FIELDS } from '@/common/models'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const childName = typeof route.query.name === 'string' ? route.query.name : ''
const rewardListRef = ref()
const rewardCountRef = ref(-1)
function goToCreateReward() {
router.push({ name: 'CreateReward' })
}
async function onSubmit() {
const selectedIds = rewardListRef.value?.selectedItems ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update rewards')
router.back()
} catch (err) {
alert('Failed to update rewards.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.reward-assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.reward-assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.reward-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
:deep(.reward) {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
.actions button {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<ModalDialog
v-if="reward"
:imageUrl="reward.image_url"
:title="reward.name"
:subtitle="reward.points_needed === 0 ? 'Reward Ready!' : reward.points_needed + ' more points'"
@backdrop-click="$emit('cancel')"
>
<div class="modal-message">
Redeem this reward for <span class="child-name">{{ childName }}</span
>?
</div>
<div class="modal-actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
<button v-if="reward?.redeeming" @click="$emit('deny')" class="btn btn-danger">Deny</button>
<button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
import type { RewardStatus } from '@/common/models'
defineProps<{
reward: RewardStatus | null
childName?: string
}>()
defineEmits<{
confirm: []
deny: []
cancel: []
}>()
</script>
<style scoped>
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
.child-name {
font-weight: 600;
color: var(--text-primary, #333);
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<ModalDialog
v-if="task"
title="Confirm Task"
:subtitle="task.name"
:imageUrl="task.image_url"
@backdrop-click="$emit('cancel')"
>
<div class="modal-message">
{{ task.type === 'penalty' ? 'Subtract' : 'Add' }} these points
{{ task.type === 'penalty' ? 'from' : 'to' }}
<span class="child-name">{{ childName }}</span>
</div>
<div class="modal-actions">
<button @click="$emit('confirm')" class="btn btn-primary">Yes</button>
<button @click="$emit('cancel')" class="btn btn-secondary">Cancel</button>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
import type { Task } from '@/common/models'
defineProps<{
task: Task | null
childName?: string
}>()
defineEmits<{
confirm: []
cancel: []
}>()
</script>
<style scoped>
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
.child-name {
font-weight: 600;
color: var(--text-primary, #333);
}
</style>

View File

@@ -0,0 +1,657 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import ChildView from '../ChildView.vue'
import { eventBus } from '@/common/eventBus'
// Mock dependencies
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: { id: 'child-123' },
})),
useRouter: vi.fn(() => ({
push: vi.fn(),
})),
}))
global.fetch = vi.fn()
describe('ChildView', () => {
let wrapper: VueWrapper<any>
const mockChild = {
id: 'child-123',
name: 'Test Child',
age: 8,
points: 50,
tasks: ['task-1', 'task-2'],
rewards: ['reward-1'],
image_id: 'boy01',
}
const mockChore = {
id: 'task-1',
name: 'Clean Room',
points: 10,
type: 'chore' as const,
image_url: '/images/task.png',
custom_value: null,
}
const mockChoreWithOverride = {
id: 'task-1',
name: 'Clean Room',
points: 10,
type: 'chore' as const,
image_url: '/images/task.png',
custom_value: 15,
}
const mockPenalty = {
id: 'task-2',
name: 'Hit Sibling',
points: 5,
type: 'penalty' as const,
image_url: '/images/penalty.png',
custom_value: null,
}
const mockPenaltyWithOverride = {
id: 'task-2',
name: 'Hit Sibling',
points: 5,
type: 'penalty' as const,
image_url: '/images/penalty.png',
custom_value: 8,
}
beforeEach(() => {
vi.clearAllMocks()
// Mock fetch responses
;(global.fetch as any).mockImplementation((url: string) => {
if (url.includes('/child/child-123')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockChild),
})
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ tasks: [], reward_status: [] }),
})
})
// Mock speech synthesis
global.window.speechSynthesis = {
speak: vi.fn(),
cancel: vi.fn(),
} as any
global.window.SpeechSynthesisUtterance = vi.fn() as any
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('Component Mounting', () => {
it('loads and displays child data on mount', async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(global.fetch).toHaveBeenCalledWith('/api/child/child-123')
})
it('registers SSE event listeners on mount', async () => {
const onSpy = vi.spyOn(eventBus, 'on')
wrapper = mount(ChildView)
await nextTick()
expect(onSpy).toHaveBeenCalledWith('child_task_triggered', expect.any(Function))
expect(onSpy).toHaveBeenCalledWith('child_reward_triggered', expect.any(Function))
expect(onSpy).toHaveBeenCalledWith('child_reward_request', expect.any(Function))
})
it('sets up inactivity timer on mount', async () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout')
wrapper = mount(ChildView)
await nextTick()
// Should set up inactivity timer (60 seconds)
expect(setTimeoutSpy).toHaveBeenCalled()
})
it('cleans up inactivity timer on unmount', async () => {
wrapper = mount(ChildView)
await nextTick()
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
wrapper.unmount()
expect(clearTimeoutSpy).toHaveBeenCalled()
})
})
describe('Custom Value Display - Chores', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('displays default points for chore without override', () => {
// The template should display mockChore.points (10) when custom_value is null
// Template logic: item.custom_value !== undefined && item.custom_value !== null ? item.custom_value : item.points
const expectedValue = mockChore.points
expect(expectedValue).toBe(10)
})
it('displays custom_value for chore with override', () => {
// The template should display mockChoreWithOverride.custom_value (15)
const expectedValue = mockChoreWithOverride.custom_value
expect(expectedValue).toBe(15)
})
})
describe('Custom Value Display - Penalties', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('displays negative default points for penalty without override', () => {
// The template should display -mockPenalty.points (-5)
// Template logic: item.custom_value !== undefined && item.custom_value !== null ? -item.custom_value : -item.points
const expectedValue = -mockPenalty.points
expect(expectedValue).toBe(-5)
})
it('displays negative custom_value for penalty with override', () => {
// The template should display -mockPenaltyWithOverride.custom_value (-8)
const expectedValue = -mockPenaltyWithOverride.custom_value!
expect(expectedValue).toBe(-8)
})
})
describe('Task Triggering', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('speaks task name when triggered', () => {
wrapper.vm.triggerTask(mockChore)
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
it('does not call trigger-task API in child mode', async () => {
await wrapper.vm.triggerTask(mockChore)
expect(
(global.fetch as any).mock.calls.some((call: [string]) =>
call[0].includes('/trigger-task'),
),
).toBe(false)
})
it('does not crash if speechSynthesis is not available', () => {
const originalSpeechSynthesis = global.window.speechSynthesis
delete (global.window as any).speechSynthesis
expect(() => wrapper.vm.triggerTask(mockChore)).not.toThrow()
// Restore for other tests
global.window.speechSynthesis = originalSpeechSynthesis
})
})
describe('Reward Triggering', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('speaks reward text when triggered', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 10,
redeeming: false,
})
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
it('does not call reward request/cancel APIs in child mode', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
})
const requestCalls = (global.fetch as any).mock.calls.filter(
(call: [string]) =>
call[0].includes('/request-reward') || call[0].includes('/cancel-request-reward'),
)
expect(requestCalls.length).toBe(0)
})
it('opens redeem dialog when reward is ready and not pending', async () => {
vi.useFakeTimers()
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
})
vi.advanceTimersByTime(200)
await nextTick()
expect(wrapper.vm.showRewardDialog).toBe(true)
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward?.id).toBe('reward-1')
vi.useRealTimers()
})
it('does not open redeem dialog when reward is not yet ready', () => {
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 10,
redeeming: false,
})
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.showCancelDialog).toBe(false)
})
it('opens cancel dialog when reward is already pending', async () => {
vi.useFakeTimers()
wrapper.vm.triggerReward({
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: true,
})
vi.advanceTimersByTime(200)
await nextTick()
expect(wrapper.vm.showCancelDialog).toBe(true)
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward?.id).toBe('reward-1')
vi.useRealTimers()
})
})
describe('Reward Redeem Dialog', () => {
const readyReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: false,
}
beforeEach(async () => {
vi.useFakeTimers()
wrapper = mount(ChildView)
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
wrapper.vm.triggerReward(readyReward)
vi.advanceTimersByTime(200)
await nextTick()
vi.useRealTimers()
})
it('closes redeem dialog on cancelRedeemReward', async () => {
expect(wrapper.vm.showRewardDialog).toBe(true)
wrapper.vm.cancelRedeemReward()
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
it('calls request-reward API on confirmRedeemReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.confirmRedeemReward()
expect(global.fetch).toHaveBeenCalledWith(
`/api/child/child-123/request-reward`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reward_id: 'reward-1' }),
}),
)
})
it('closes redeem dialog after confirmRedeemReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.confirmRedeemReward()
await nextTick()
expect(wrapper.vm.showRewardDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
})
describe('Cancel Pending Reward Dialog', () => {
const pendingReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 50,
points_needed: 0,
redeeming: true,
}
beforeEach(async () => {
vi.useFakeTimers()
wrapper = mount(ChildView)
await nextTick()
vi.advanceTimersByTime(100)
await nextTick()
wrapper.vm.triggerReward(pendingReward)
vi.advanceTimersByTime(200)
await nextTick()
vi.useRealTimers()
})
it('closes cancel dialog on closeCancelDialog', async () => {
expect(wrapper.vm.showCancelDialog).toBe(true)
wrapper.vm.closeCancelDialog()
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
it('calls cancel-request-reward API on cancelPendingReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.cancelPendingReward()
expect(global.fetch).toHaveBeenCalledWith(
`/api/child/child-123/cancel-request-reward`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reward_id: 'reward-1' }),
}),
)
})
it('closes cancel dialog after cancelPendingReward', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
await wrapper.vm.cancelPendingReward()
await nextTick()
expect(wrapper.vm.showCancelDialog).toBe(false)
expect(wrapper.vm.dialogReward).toBe(null)
})
})
describe('SSE Event Handlers', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('handles child_task_triggered event and refreshes reward list', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleTaskTriggered({
type: 'child_task_triggered',
payload: { child_id: 'child-123', points: 60, task_id: 'task-1' },
})
expect(wrapper.vm.child.points).toBe(60)
expect(mockRefresh).toHaveBeenCalled()
})
it('handles child_reward_triggered event', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleRewardTriggered({
type: 'child_reward_triggered',
payload: { child_id: 'child-123', points: 40, reward_id: 'reward-1' },
})
expect(wrapper.vm.child.points).toBe(40)
expect(mockRefresh).toHaveBeenCalled()
})
it('handles child_tasks_set event', () => {
wrapper.vm.handleChildTaskSet({
type: 'child_tasks_set',
payload: { child_id: 'child-123', task_ids: ['task-1', 'task-3'] },
})
expect(wrapper.vm.tasks).toEqual(['task-1', 'task-3'])
})
it('handles child_rewards_set event', () => {
wrapper.vm.handleChildRewardSet({
type: 'child_rewards_set',
payload: { child_id: 'child-123', reward_ids: ['reward-1', 'reward-2'] },
})
expect(wrapper.vm.rewards).toEqual(['reward-1', 'reward-2'])
})
it('handles reward_modified event and refreshes reward list', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleRewardModified({
type: 'reward_modified',
payload: { reward_id: 'reward-1', operation: 'EDIT' },
})
expect(mockRefresh).toHaveBeenCalled()
})
})
describe('Inactivity Timer', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('resets timer on user interaction', () => {
const setTimeoutSpy = vi.spyOn(global, 'setTimeout')
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout')
wrapper.vm.resetInactivityTimer()
expect(clearTimeoutSpy).toHaveBeenCalled()
expect(setTimeoutSpy).toHaveBeenCalled()
})
})
describe('Reward Request Handling', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('handles reward request event and refreshes list', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleRewardRequest({
type: 'child_reward_request',
payload: { child_id: 'child-123', reward_id: 'reward-1' },
})
expect(mockRefresh).toHaveBeenCalled()
})
it('does not refresh if reward not in child rewards', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleRewardRequest({
type: 'child_reward_request',
payload: { child_id: 'child-123', reward_id: 'reward-999' },
})
expect(mockRefresh).not.toHaveBeenCalled()
})
})
describe('Item Ready State Management', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('initializes readyItemId to null', () => {
expect(wrapper.vm.readyItemId).toBe(null)
})
it('updates readyItemId when handleItemReady is called with an item ID', () => {
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
wrapper.vm.handleItemReady('reward-2')
expect(wrapper.vm.readyItemId).toBe('reward-2')
})
it('clears readyItemId when handleItemReady is called with empty string', () => {
wrapper.vm.readyItemId = 'task-1'
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
it('passes readyItemId prop to Chores ScrollingList', async () => {
wrapper.vm.readyItemId = 'task-1'
await nextTick()
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
expect(choresScrollingList.props('readyItemId')).toBe('task-1')
})
it('passes readyItemId prop to Penalties ScrollingList', async () => {
wrapper.vm.readyItemId = 'task-2'
await nextTick()
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
expect(penaltiesScrollingList.props('readyItemId')).toBe('task-2')
})
it('passes readyItemId prop to Rewards ScrollingList', async () => {
wrapper.vm.readyItemId = 'reward-1'
await nextTick()
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
expect(rewardsScrollingList.props('readyItemId')).toBe('reward-1')
})
it('handles item-ready event from Chores ScrollingList', async () => {
const choresScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[0]
choresScrollingList.vm.$emit('item-ready', 'task-1')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('task-1')
})
it('handles item-ready event from Penalties ScrollingList', async () => {
const penaltiesScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[1]
penaltiesScrollingList.vm.$emit('item-ready', 'task-2')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('task-2')
})
it('handles item-ready event from Rewards ScrollingList', async () => {
const rewardsScrollingList = wrapper.findAllComponents({ name: 'ScrollingList' })[2]
rewardsScrollingList.vm.$emit('item-ready', 'reward-1')
await nextTick()
expect(wrapper.vm.readyItemId).toBe('reward-1')
})
it('maintains 2-step click workflow: first click sets ready, second click triggers', async () => {
// Initial state
expect(wrapper.vm.readyItemId).toBe(null)
// First click - item should become ready
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
// Second click would trigger the item (tested via ScrollingList component)
// After trigger, ready state should be cleared
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
})
describe('choreDueLabel', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('returns null when item has no schedule', () => {
const result = wrapper.vm.choreDueLabel({
id: 'task-1',
name: 'Test',
points: 5,
type: 'chore' as const,
schedule: null,
})
expect(result).toBe(null)
})
it('returns "Due by ..." prefix format when chore has a future due time today', () => {
vi.useFakeTimers()
// Feb 24 2026 is a Tuesday (day index 2) at 10:00am
vi.setSystemTime(new Date('2026-02-24T10:00:00'))
try {
const item = {
id: 'task-1',
name: 'Test',
points: 5,
type: 'chore' as const,
schedule: {
mode: 'days' as const,
day_configs: [{ day: 2, hour: 14, minute: 30 }], // Tuesday 2:30pm — future
interval_days: null,
anchor_weekday: null,
interval_hour: null,
interval_minute: null,
},
extension_date: null,
}
const result = wrapper.vm.choreDueLabel(item)
expect(result).toMatch(/^Due by /)
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -0,0 +1,593 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick, defineComponent } from 'vue'
import ParentView from '../ParentView.vue'
import { eventBus } from '@/common/eventBus'
// Mock dependencies
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: { id: 'child-123' },
query: {},
})),
useRouter: vi.fn(() => ({
push: vi.fn(),
})),
}))
vi.mock('@/common/api', () => ({
setChildOverride: vi.fn(),
parseErrorResponse: vi.fn(() => ({ msg: 'Test error', code: 'TEST_ERROR' })),
extendChoreTime: vi.fn(),
approveChore: vi.fn(),
rejectChore: vi.fn(),
resetChore: vi.fn(),
}))
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(() => Promise.resolve('/mock-image.png')),
revokeAllImageUrls: vi.fn(),
}))
global.fetch = vi.fn()
global.alert = vi.fn()
import { setChildOverride, parseErrorResponse } from '@/common/api'
// Stub for ScrollingList that exposes the same interface
const ScrollingListStub = defineComponent({
name: 'ScrollingList',
props: [
'title',
'fetchBaseUrl',
'ids',
'itemKey',
'imageField',
'imageFields',
'enableEdit',
'childId',
'readyItemId',
'isParentAuthenticated',
'filterFn',
'sortFn',
'getItemClass',
],
emits: ['trigger-item', 'edit-item', 'item-ready'],
setup(_, { expose }) {
const refresh = vi.fn()
const scrollToItem = vi.fn()
const centerItem = vi.fn()
expose({ refresh, items: [], centerItem, scrollToItem })
return { refresh, scrollToItem, centerItem }
},
template: '<div class="scrolling-list-stub"><slot /></div>',
})
const mountOptions = {
global: {
stubs: {
ScrollingList: ScrollingListStub,
ChildDetailCard: { template: '<div class="child-detail-stub" />' },
StatusMessage: { template: '<div class="status-stub" />' },
ModalDialog: { template: '<div class="modal-stub"><slot /></div>' },
ScheduleModal: { template: '<div class="schedule-stub" />' },
PendingRewardDialog: { template: '<div class="pending-reward-stub" />' },
TaskConfirmDialog: { template: '<div class="task-confirm-stub" />' },
RewardConfirmDialog: { template: '<div class="reward-confirm-stub" />' },
ChoreApproveDialog: { template: '<div class="chore-approve-stub" />' },
},
},
}
describe('ParentView', () => {
let wrapper: VueWrapper<any>
const mockChild = {
id: 'child-123',
name: 'Test Child',
age: 8,
points: 50,
tasks: ['task-1', 'task-2'],
rewards: ['reward-1'],
image_id: 'boy01',
}
const mockTask = {
id: 'task-1',
name: 'Clean Room',
points: 10,
type: 'chore' as const,
image_url: '/images/task.png',
custom_value: null,
}
const mockPenalty = {
id: 'task-2',
name: 'Hit Sibling',
points: 5,
type: 'penalty' as const,
image_url: '/images/penalty.png',
custom_value: null,
}
const mockReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 100,
points_needed: 50,
redeeming: false,
image_url: '/images/reward.png',
custom_value: null,
}
beforeEach(() => {
vi.clearAllMocks()
// Mock fetch responses
;(global.fetch as any).mockImplementation((url: string) => {
if (url.includes('/child/child-123')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockChild),
})
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ tasks: [], rewards: [], reward_status: [] }),
})
})
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('Component Mounting', () => {
it('loads and displays child data on mount', async () => {
wrapper = mount(ParentView, mountOptions)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(global.fetch).toHaveBeenCalledWith('/api/child/child-123')
})
it('registers SSE event listeners on mount', async () => {
const onSpy = vi.spyOn(eventBus, 'on')
wrapper = mount(ParentView, mountOptions)
await nextTick()
expect(onSpy).toHaveBeenCalledWith('child_task_triggered', expect.any(Function))
expect(onSpy).toHaveBeenCalledWith('child_reward_triggered', expect.any(Function))
expect(onSpy).toHaveBeenCalledWith('child_override_set', expect.any(Function))
expect(onSpy).toHaveBeenCalledWith('child_override_deleted', expect.any(Function))
})
it('unregisters SSE event listeners on unmount', async () => {
const offSpy = vi.spyOn(eventBus, 'off')
wrapper = mount(ParentView, mountOptions)
await nextTick()
wrapper.unmount()
expect(offSpy).toHaveBeenCalledWith('child_task_triggered', expect.any(Function))
expect(offSpy).toHaveBeenCalledWith('child_override_set', expect.any(Function))
})
})
describe('Override Modal', () => {
beforeEach(async () => {
wrapper = mount(ParentView, mountOptions)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('opens override modal when edit-item event is emitted for task', async () => {
const taskItem = { ...mockTask, custom_value: 15 }
wrapper.vm.handleEditItem(taskItem, 'task')
await nextTick()
expect(wrapper.vm.showOverrideModal).toBe(true)
expect(wrapper.vm.overrideEditTarget).toEqual({
entity: taskItem,
type: 'task',
})
expect(wrapper.vm.overrideCustomValue).toBe(15)
})
it('opens override modal with default value when no override exists', async () => {
wrapper.vm.handleEditItem(mockTask, 'task')
await nextTick()
expect(wrapper.vm.showOverrideModal).toBe(true)
expect(wrapper.vm.overrideCustomValue).toBe(mockTask.points)
})
it('opens override modal for reward with correct default', async () => {
wrapper.vm.handleEditItem(mockReward, 'reward')
await nextTick()
expect(wrapper.vm.showOverrideModal).toBe(true)
expect(wrapper.vm.overrideCustomValue).toBe(mockReward.cost)
expect(wrapper.vm.overrideEditTarget?.type).toBe('reward')
})
it('validates override input correctly', async () => {
wrapper.vm.overrideCustomValue = 50
wrapper.vm.validateOverrideInput()
expect(wrapper.vm.isOverrideValid).toBe(true)
wrapper.vm.overrideCustomValue = -1
wrapper.vm.validateOverrideInput()
expect(wrapper.vm.isOverrideValid).toBe(false)
wrapper.vm.overrideCustomValue = 10001
wrapper.vm.validateOverrideInput()
expect(wrapper.vm.isOverrideValid).toBe(false)
wrapper.vm.overrideCustomValue = 0
wrapper.vm.validateOverrideInput()
expect(wrapper.vm.isOverrideValid).toBe(true)
wrapper.vm.overrideCustomValue = 10000
wrapper.vm.validateOverrideInput()
expect(wrapper.vm.isOverrideValid).toBe(true)
})
})
describe('Save Override', () => {
beforeEach(async () => {
wrapper = mount(ParentView, mountOptions)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('calls setChildOverride API with correct parameters', async () => {
;(setChildOverride as any).mockResolvedValue({ ok: true })
wrapper.vm.handleEditItem(mockTask, 'task')
wrapper.vm.overrideCustomValue = 25
await nextTick()
await wrapper.vm.saveOverride()
expect(setChildOverride).toHaveBeenCalledWith('child-123', 'task-1', 'task', 25)
expect(wrapper.vm.showOverrideModal).toBe(false)
})
it('handles API error gracefully', async () => {
;(setChildOverride as any).mockResolvedValue({ ok: false, status: 400 })
wrapper.vm.handleEditItem(mockTask, 'task')
wrapper.vm.overrideCustomValue = 25
await nextTick()
await wrapper.vm.saveOverride()
expect(parseErrorResponse).toHaveBeenCalled()
expect(global.alert).toHaveBeenCalledWith('Error: Test error')
})
it('does not save if validation fails', async () => {
wrapper.vm.handleEditItem(mockTask, 'task')
wrapper.vm.overrideCustomValue = -5
wrapper.vm.validateOverrideInput()
await nextTick()
await wrapper.vm.saveOverride()
expect(setChildOverride).not.toHaveBeenCalled()
})
})
describe('SSE Event Handlers', () => {
beforeEach(async () => {
wrapper = mount(ParentView, mountOptions)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('handles child_task_triggered event and refreshes reward list', async () => {
const mockRefresh = vi.fn()
wrapper.vm.childRewardListRef = { refresh: mockRefresh }
wrapper.vm.handleTaskTriggered({
type: 'child_task_triggered',
payload: { child_id: 'child-123', points: 60, task_id: 'task-1' },
})
expect(wrapper.vm.child.points).toBe(60)
expect(mockRefresh).toHaveBeenCalled()
})
it('handles child_override_set event and refreshes appropriate lists', async () => {
const mockChoreRefresh = vi.fn().mockResolvedValue(undefined)
const mockPenaltyRefresh = vi.fn().mockResolvedValue(undefined)
const mockRewardRefresh = vi.fn().mockResolvedValue(undefined)
const mockKindnessRefresh = vi.fn().mockResolvedValue(undefined)
wrapper.vm.childChoreListRef = { refresh: mockChoreRefresh, scrollToItem: vi.fn() }
wrapper.vm.childKindnessListRef = { refresh: mockKindnessRefresh, scrollToItem: vi.fn() }
wrapper.vm.childPenaltyListRef = { refresh: mockPenaltyRefresh, scrollToItem: vi.fn() }
wrapper.vm.childRewardListRef = { refresh: mockRewardRefresh, scrollToItem: vi.fn() }
// Test task override
wrapper.vm.handleOverrideSet({
type: 'child_override_set',
payload: {
override: {
child_id: 'child-123',
entity_id: 'task-1',
entity_type: 'task',
custom_value: 15,
},
},
})
expect(mockChoreRefresh).toHaveBeenCalled()
expect(mockPenaltyRefresh).toHaveBeenCalled()
expect(mockRewardRefresh).not.toHaveBeenCalled()
// Reset mocks
mockChoreRefresh.mockClear()
mockPenaltyRefresh.mockClear()
// Test reward override
wrapper.vm.handleOverrideSet({
type: 'child_override_set',
payload: {
override: {
child_id: 'child-123',
entity_id: 'reward-1',
entity_type: 'reward',
custom_value: 75,
},
},
})
expect(mockChoreRefresh).not.toHaveBeenCalled()
expect(mockPenaltyRefresh).not.toHaveBeenCalled()
expect(mockRewardRefresh).toHaveBeenCalled()
})
it('handles child_override_deleted event', async () => {
const mockChoreRefresh = vi.fn()
const mockPenaltyRefresh = vi.fn()
wrapper.vm.childChoreListRef = { refresh: mockChoreRefresh }
wrapper.vm.childPenaltyListRef = { refresh: mockPenaltyRefresh }
wrapper.vm.handleOverrideDeleted({
type: 'child_override_deleted',
payload: {
child_id: 'child-123',
entity_id: 'task-1',
entity_type: 'task',
},
})
expect(mockChoreRefresh).toHaveBeenCalled()
expect(mockPenaltyRefresh).toHaveBeenCalled()
})
})
describe('Ready Item State Management', () => {
beforeEach(async () => {
wrapper = mount(ParentView, mountOptions)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('updates readyItemId when item-ready event is emitted', () => {
expect(wrapper.vm.readyItemId).toBeNull()
wrapper.vm.handleItemReady('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
wrapper.vm.handleItemReady('reward-1')
expect(wrapper.vm.readyItemId).toBe('reward-1')
wrapper.vm.handleItemReady('')
expect(wrapper.vm.readyItemId).toBe('')
})
})
describe('Penalty Display', () => {
it('displays penalty values as negative in template', async () => {
wrapper = mount(ParentView, mountOptions)
await nextTick()
// The template should show -custom_value or -points for penalties
// This is tested through the template logic, which we've verified manually
// The key is that penalties (type: 'penalty') show negative values
expect(true).toBe(true) // Placeholder - template logic verified
})
})
describe('Override Edit - Pending Reward Guard', () => {
const pendingReward = {
id: 'reward-1',
name: 'Ice Cream',
cost: 100,
points_needed: 0,
redeeming: true,
image_url: '/images/reward.png',
custom_value: null,
}
beforeEach(async () => {
wrapper = mount(ParentView, mountOptions)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('shows PendingRewardDialog instead of override modal when editing a pending reward', async () => {
wrapper.vm.handleEditItem(pendingReward, 'reward')
await nextTick()
expect(wrapper.vm.showPendingRewardDialog).toBe(true)
expect(wrapper.vm.showOverrideModal).toBe(false)
expect(wrapper.vm.pendingEditOverrideTarget).toEqual({
entity: pendingReward,
type: 'reward',
})
})
it('does not show PendingRewardDialog when editing a non-pending reward', async () => {
wrapper.vm.handleEditItem(mockReward, 'reward')
await nextTick()
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
expect(wrapper.vm.showOverrideModal).toBe(true)
})
it('does not show PendingRewardDialog when editing a task regardless of pending rewards', async () => {
wrapper.vm.handleEditItem(mockTask, 'task')
await nextTick()
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
expect(wrapper.vm.showOverrideModal).toBe(true)
})
it('cancels pending reward and opens override modal on confirmPendingRewardAndEdit', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
wrapper.vm.handleEditItem(pendingReward, 'reward')
await nextTick()
await wrapper.vm.confirmPendingRewardAndEdit()
await nextTick()
expect(global.fetch).toHaveBeenCalledWith(
`/api/child/child-123/cancel-request-reward`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ reward_id: 'reward-1' }),
}),
)
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
expect(wrapper.vm.showOverrideModal).toBe(true)
expect(wrapper.vm.pendingEditOverrideTarget).toBe(null)
})
it('sets overrideCustomValue to reward cost when no custom_value on confirmPendingRewardAndEdit', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
wrapper.vm.handleEditItem(pendingReward, 'reward')
await wrapper.vm.confirmPendingRewardAndEdit()
expect(wrapper.vm.overrideCustomValue).toBe(pendingReward.cost)
expect(wrapper.vm.overrideEditTarget?.entity).toEqual(pendingReward)
expect(wrapper.vm.overrideEditTarget?.type).toBe('reward')
})
it('sets overrideCustomValue to custom_value when present on confirmPendingRewardAndEdit', async () => {
;(global.fetch as any).mockResolvedValueOnce({ ok: true })
const pendingWithOverride = { ...pendingReward, custom_value: 75 }
wrapper.vm.handleEditItem(pendingWithOverride, 'reward')
await wrapper.vm.confirmPendingRewardAndEdit()
expect(wrapper.vm.overrideCustomValue).toBe(75)
})
it('clears pendingEditOverrideTarget when cancel is clicked on PendingRewardDialog', async () => {
wrapper.vm.handleEditItem(pendingReward, 'reward')
await nextTick()
// Simulate cancel
wrapper.vm.showPendingRewardDialog = false
wrapper.vm.pendingEditOverrideTarget = null
await nextTick()
expect(wrapper.vm.showPendingRewardDialog).toBe(false)
expect(wrapper.vm.pendingEditOverrideTarget).toBe(null)
expect(wrapper.vm.showOverrideModal).toBe(false)
})
})
describe('Chore Card Selection (selectedChoreId)', () => {
beforeEach(async () => {
wrapper = mount(ParentView, mountOptions)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('selectedChoreId is null initially', () => {
expect(wrapper.vm.selectedChoreId).toBe(null)
})
it('handleChoreItemReady sets selectedChoreId and readyItemId', () => {
wrapper.vm.handleChoreItemReady('task-1')
expect(wrapper.vm.selectedChoreId).toBe('task-1')
expect(wrapper.vm.readyItemId).toBe('task-1')
})
it('handleItemReady clears selectedChoreId when another list card is selected', () => {
wrapper.vm.handleChoreItemReady('task-1')
wrapper.vm.handleItemReady('reward-1')
expect(wrapper.vm.selectedChoreId).toBe(null)
expect(wrapper.vm.readyItemId).toBe('reward-1')
})
it('handleChoreItemReady with empty string clears selectedChoreId', () => {
wrapper.vm.handleChoreItemReady('task-1')
wrapper.vm.handleChoreItemReady('')
expect(wrapper.vm.selectedChoreId).toBe(null)
})
})
describe('Highlight pulse animation', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('adds highlight-pulse class to element with matching data-item-id', async () => {
const el = document.createElement('div')
el.setAttribute('data-item-id', 'task-1')
document.body.appendChild(el)
wrapper = mount(ParentView, mountOptions)
wrapper.vm.applyHighlightPulse('task-1')
vi.advanceTimersByTime(200)
expect(el.classList.contains('highlight-pulse')).toBe(true)
document.body.removeChild(el)
})
it('removes highlight-pulse class when animationend fires', async () => {
const el = document.createElement('div')
el.setAttribute('data-item-id', 'task-1')
document.body.appendChild(el)
wrapper = mount(ParentView, mountOptions)
wrapper.vm.applyHighlightPulse('task-1')
vi.advanceTimersByTime(200)
expect(el.classList.contains('highlight-pulse')).toBe(true)
el.dispatchEvent(new Event('animationend'))
expect(el.classList.contains('highlight-pulse')).toBe(false)
document.body.removeChild(el)
})
it('does nothing when no element with matching data-item-id exists', () => {
wrapper = mount(ParentView, mountOptions)
// Should not throw
expect(() => {
wrapper.vm.applyHighlightPulse('nonexistent-id')
vi.advanceTimersByTime(200)
}).not.toThrow()
})
})
})