All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s
- Add backend routines management with add, get, update, delete, and list functionalities. - Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension. - Develop event types for routine confirmation and modification. - Implement frontend components for routine assignment, confirmation dialog, and routine management views. - Add unit tests for routine API and integration tests for routine CRUD flow. - Create end-to-end test plan for routines feature covering parent and child interactions.
1054 lines
32 KiB
Vue
1054 lines
32 KiB
Vue
<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 RoutineConfirmDialog from './RoutineConfirmDialog.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,
|
|
ChildRoutine,
|
|
ChildTaskTriggeredEventPayload,
|
|
ChildRewardTriggeredEventPayload,
|
|
ChildRewardRequestEventPayload,
|
|
ChildTasksSetEventPayload,
|
|
ChildRewardsSetEventPayload,
|
|
ChildRoutinesSetEventPayload,
|
|
TaskModifiedEventPayload,
|
|
RewardModifiedEventPayload,
|
|
RoutineModifiedEventPayload,
|
|
ChildModifiedEventPayload,
|
|
ChoreScheduleModifiedPayload,
|
|
ChoreTimeExtendedPayload,
|
|
RoutineScheduleModifiedPayload,
|
|
ChildChoreConfirmationPayload,
|
|
ChildRoutineConfirmationPayload,
|
|
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 routines = 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)
|
|
const showRoutineConfirmDialog = ref(false)
|
|
const dialogRoutine = ref<ChildRoutine | null>(null)
|
|
const childRoutineListRef = ref()
|
|
|
|
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 handleChildRoutineSet(event: Event) {
|
|
const payload = event.payload as ChildRoutinesSetEventPayload
|
|
if (child.value && payload.child_id == child.value.id) {
|
|
routines.value = payload.routine_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.
|
|
}
|
|
|
|
const handleRoutineClick = (routine: ChildRoutine) => {
|
|
// Show routine confirmation dialog
|
|
dialogRoutine.value = routine
|
|
setTimeout(() => {
|
|
showRoutineConfirmDialog.value = true
|
|
}, 150)
|
|
}
|
|
|
|
function isChoreCompletedToday(item: ChildTask): boolean {
|
|
if (item.pending_status !== 'approved' || !item.approved_at) return false
|
|
const approvedDate = new Date(item.approved_at)
|
|
const today = new Date()
|
|
return (
|
|
approvedDate.getFullYear() === today.getFullYear() &&
|
|
approvedDate.getMonth() === today.getMonth() &&
|
|
approvedDate.getDate() === today.getDate()
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async function doConfirmRoutine() {
|
|
if (!child.value?.id || !dialogRoutine.value) return
|
|
try {
|
|
const resp = await fetch(`/api/child/${child.value.id}/confirm-routine`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ routine_id: dialogRoutine.value.id }),
|
|
})
|
|
if (!resp.ok) {
|
|
console.error('Failed to confirm routine')
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to confirm routine:', err)
|
|
} finally {
|
|
showRoutineConfirmDialog.value = false
|
|
dialogRoutine.value = null
|
|
}
|
|
}
|
|
|
|
function closeRoutineConfirmDialog() {
|
|
showRoutineConfirmDialog.value = false
|
|
dialogRoutine.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.pending_status === 'pending') return false
|
|
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('child_routines_set', handleChildRoutineSet)
|
|
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 || []
|
|
routines.value = data.routines || []
|
|
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('child_routines_set', handleChildRoutineSet)
|
|
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="isChoreCompletedToday(item)" class="chore-stamp completed-stamp"
|
|
>COMPLETED</span
|
|
>
|
|
<span v-else-if="isChoreExpired(item)" class="chore-stamp">TOO LATE</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>
|
|
<ScrollingList
|
|
title="Routines"
|
|
ref="childRoutineListRef"
|
|
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
|
|
:ids="routines"
|
|
itemKey="routines"
|
|
imageField="image_id"
|
|
:isParentAuthenticated="false"
|
|
:readyItemId="readyItemId"
|
|
@item-ready="handleItemReady"
|
|
@trigger-item="handleRoutineClick"
|
|
:getItemClass="
|
|
(item: ChildRoutine) => ({
|
|
good: true,
|
|
'routine-pending': item.pending_status === 'pending',
|
|
'routine-approved': item.pending_status === 'approved',
|
|
})
|
|
"
|
|
>
|
|
<template #item="{ item }: { item: ChildRoutine }">
|
|
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
|
|
>PENDING</span
|
|
>
|
|
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
|
|
>APPROVED</span
|
|
>
|
|
<div class="item-name">{{ item.name }}</div>
|
|
<img
|
|
v-if="item.image_url"
|
|
:src="item.image_url"
|
|
alt="Routine Image"
|
|
class="item-image"
|
|
/>
|
|
<div class="item-points good-points">
|
|
{{
|
|
item.custom_value !== undefined && item.custom_value !== null
|
|
? item.custom_value
|
|
: item.points
|
|
}}
|
|
Points
|
|
</div>
|
|
</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>
|
|
|
|
<!-- Routine confirm dialog -->
|
|
<RoutineConfirmDialog
|
|
:show="showRoutineConfirmDialog"
|
|
:routineName="dialogRoutine?.name ?? ''"
|
|
:imageUrl="dialogRoutine?.image_url"
|
|
:items="dialogRoutine?.items"
|
|
@confirm="doConfirmRoutine"
|
|
@cancel="closeRoutineConfirmDialog"
|
|
/>
|
|
</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>
|