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