Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s
This commit is contained in:
142
frontend/src/components/child/ChildDetailCard.vue
Normal file
142
frontend/src/components/child/ChildDetailCard.vue
Normal 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>
|
||||
179
frontend/src/components/child/ChildEditView.vue
Normal file
179
frontend/src/components/child/ChildEditView.vue
Normal 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>
|
||||
943
frontend/src/components/child/ChildView.vue
Normal file
943
frontend/src/components/child/ChildView.vue
Normal 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>
|
||||
78
frontend/src/components/child/ChoreApproveDialog.vue
Normal file
78
frontend/src/components/child/ChoreApproveDialog.vue
Normal 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>
|
||||
130
frontend/src/components/child/ChoreAssignView.vue
Normal file
130
frontend/src/components/child/ChoreAssignView.vue
Normal 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>
|
||||
71
frontend/src/components/child/ChoreConfirmDialog.vue
Normal file
71
frontend/src/components/child/ChoreConfirmDialog.vue
Normal 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>
|
||||
130
frontend/src/components/child/KindnessAssignView.vue
Normal file
130
frontend/src/components/child/KindnessAssignView.vue
Normal 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>
|
||||
1479
frontend/src/components/child/ParentView.vue
Normal file
1479
frontend/src/components/child/ParentView.vue
Normal file
File diff suppressed because it is too large
Load Diff
130
frontend/src/components/child/PenaltyAssignView.vue
Normal file
130
frontend/src/components/child/PenaltyAssignView.vue
Normal 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>
|
||||
37
frontend/src/components/child/PendingRewardDialog.vue
Normal file
37
frontend/src/components/child/PendingRewardDialog.vue
Normal 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>
|
||||
136
frontend/src/components/child/RewardAssignView.vue
Normal file
136
frontend/src/components/child/RewardAssignView.vue
Normal 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>
|
||||
48
frontend/src/components/child/RewardConfirmDialog.vue
Normal file
48
frontend/src/components/child/RewardConfirmDialog.vue
Normal 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>
|
||||
47
frontend/src/components/child/TaskConfirmDialog.vue
Normal file
47
frontend/src/components/child/TaskConfirmDialog.vue
Normal 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>
|
||||
657
frontend/src/components/child/__tests__/ChildView.spec.ts
Normal file
657
frontend/src/components/child/__tests__/ChildView.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
593
frontend/src/components/child/__tests__/ParentView.spec.ts
Normal file
593
frontend/src/components/child/__tests__/ParentView.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user