This commit is contained in:
2025-12-05 17:40:57 -05:00
parent 6423d1c1a2
commit fa9fabcd9f
43 changed files with 1506 additions and 529 deletions

View File

@@ -8,10 +8,12 @@ export interface Task {
}
export interface Child {
id: string | number
id: string
name: string
age: number
points?: number
tasks: string[]
rewards: string[]
points: number
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
@@ -30,91 +32,73 @@ export interface RewardStatus {
name: string
points_needed: number
cost: number
redeeming: boolean
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export interface PendingReward {
id: string
child_id: string
child_name: string
child_image_id: string | null
child_image_url?: string | null // optional, for resolved URLs
reward_id: string
reward_name: string
reward_image_id: string | null
reward_image_url?: string | null // optional, for resolved URLs
}
export interface Event {
type: string
payload:
| TaskUpdateEventPayload
| RewardUpdateEventPayload
| ChildUpdateEventPayload
| ChildDeleteEventPayload
| TaskCreatedEventPayload
| TaskDeletedEventPayload
| TaskEditedEventPayload
| RewardCreatedEventPayload
| RewardDeletedEventPayload
| RewardEditedEventPayload
| RewardSetEventPayload
| TaskSetEventPayload
| ChildAddEventPayload
| ChildModifiedEventPayload
| ChildTaskTriggeredEventPayload
| ChildRewardTriggeredEventPayload
| ChildRewardRequestEventPayload
| ChildTasksSetEventPayload
| ChildRewardsSetEventPayload
| TaskModifiedEventPayload
| RewardModifiedEventPayload
}
export interface TaskUpdateEventPayload {
export interface ChildModifiedEventPayload {
child_id: string
operation: 'ADD' | 'DELETE' | 'EDIT'
}
export interface ChildTaskTriggeredEventPayload {
task_id: string
child_id: string
status: string
points: number
}
export interface TaskSetEventPayload {
export interface ChildRewardTriggeredEventPayload {
task_id: string
child_id: string
status: string
}
export interface RewardUpdateEventPayload {
reward_id: string
child_id: string
status: string
points: number
}
export interface RewardSetEventPayload {
export interface ChildRewardRequestEventPayload {
child_id: string
status: string
}
export interface ChildAddEventPayload {
child_id: string
status: string
}
export interface ChildUpdateEventPayload {
child_id: string
status: string
}
export interface ChildDeleteEventPayload {
child_id: string
status: string
}
export interface TaskCreatedEventPayload {
task_id: string
status: string
}
export interface TaskDeletedEventPayload {
task_id: string
status: string
}
export interface TaskEditedEventPayload {
task_id: string
status: string
}
export interface RewardCreatedEventPayload {
reward_id: string
operation: 'GRANTED' | 'CREATED' | 'CANCELLED'
}
export interface RewardDeletedEventPayload {
reward_id: string
status: string
export interface ChildTasksSetEventPayload {
child_id: string
task_ids: string[]
}
export interface RewardEditedEventPayload {
reward_id: string
status: string
export interface ChildRewardsSetEventPayload {
child_id: string
reward_ids: string[]
}
export interface TaskModifiedEventPayload {
task_id: string
operation: 'ADD' | 'DELETE' | 'EDIT'
}
export interface RewardModifiedEventPayload {
reward_id: string
operation: 'ADD' | 'DELETE' | 'EDIT'
}

View File

@@ -1,10 +1,16 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
import { isParentAuthenticated } from '../stores/auth'
import { eventBus } from '@/common/eventBus'
import type { Child, Event } from '@/common/models'
import type {
Child,
ChildModifiedEventPayload,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
Event,
} from '@/common/models'
const router = useRouter()
const children = ref<Child[]>([])
@@ -25,8 +31,70 @@ const openChildEditor = (child: Child, evt?: Event) => {
router.push({ name: 'ChildEditView', params: { id: child.id } })
}
function handleServerChange(event: Event) {
fetchChildren()
async function handleChildModified(event: Event) {
const payload = event.payload as ChildModifiedEventPayload
const childId = payload.child_id
switch (payload.operation) {
case 'DELETE':
children.value = children.value.filter((c) => c.id !== childId)
break
case 'ADD':
try {
const list = await fetchChildren()
children.value = list
} catch (err) {
console.warn('Failed to fetch children after ADD operation:', err)
}
break
case 'EDIT':
try {
const list = await fetchChildren()
const updatedChild = list.find((c) => c.id === childId)
if (updatedChild) {
const idx = children.value.findIndex((c) => c.id === childId)
if (idx !== -1) {
children.value[idx] = updatedChild
} else {
console.warn(`EDIT operation: child with id ${childId} not found in current list.`)
}
} else {
console.warn(
`EDIT operation: updated child with id ${childId} not found in fetched list.`,
)
}
} catch (err) {
console.warn('Failed to fetch children after EDIT operation:', err)
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
}
}
function handleChildTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
const childId = payload.child_id
const child = children.value.find((c) => c.id === childId)
if (child) {
child.points = payload.points
} else {
console.warn(`Child with id ${childId} not found when updating points.`)
}
}
function handleChildRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
const childId = payload.child_id
const child = children.value.find((c) => c.id === childId)
if (child) {
child.points = payload.points
} else {
console.warn(`Child with id ${childId} not found when updating points.`)
}
}
// points update state
@@ -41,8 +109,7 @@ const fetchImage = async (imageId: string) => {
}
}
// extracted fetch so we can refresh after delete / points edit
const fetchChildren = async () => {
const fetchChildren = async (): Promise<Child[]> => {
loading.value = true
error.value = null
images.value.clear()
@@ -53,20 +120,22 @@ const fetchChildren = async () => {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
children.value = data.children || []
const childList = data.children || []
// Fetch images for each child (shared cache util)
await Promise.all(
children.value.map((child) => {
childList.map((child) => {
if (child.image_id) {
return fetchImage(child.image_id)
}
return Promise.resolve()
}),
)
return childList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
console.error('Error fetching children:', err)
return []
} finally {
loading.value = false
}
@@ -77,21 +146,22 @@ const createChild = () => {
}
onMounted(async () => {
eventBus.on('child_update', handleServerChange)
eventBus.on('task_update', handleServerChange)
eventBus.on('reward_update', handleServerChange)
eventBus.on('child_delete', handleServerChange)
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_task_triggered', handleChildTaskTriggered)
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
await fetchChildren()
const listPromise = fetchChildren()
listPromise.then((list) => {
children.value = list
})
// listen for outside clicks to auto-close any open kebab menu
document.addEventListener('click', onDocClick, true)
})
onBeforeUnmount(() => {
eventBus.off('child_update', handleServerChange)
eventBus.off('task_update', handleServerChange)
eventBus.off('reward_update', handleServerChange)
eventBus.off('child_delete', handleServerChange)
onUnmounted(() => {
eventBus.off('child_modified', handleChildModified)
eventBus.off('child_task_triggered', handleChildTaskTriggered)
eventBus.off('child_reward_triggered', handleChildRewardTriggered)
})
const shouldIgnoreNextCardClick = ref(false)
@@ -188,8 +258,7 @@ const deletePoints = async (childId: string | number, evt?: Event) => {
if (!resp.ok) {
throw new Error(`Failed to update points: ${resp.status}`)
}
// refresh the list so points reflect the change
await fetchChildren()
// no need to refresh since we update optimistically via eventBus
} catch (err) {
console.error('Failed to delete points for child', childId, err)
} finally {
@@ -204,13 +273,22 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="container">
<div v-if="loading" class="loading">Loading...</div>
<div>
<div v-if="children.length === 0" class="no-children-message">
<div>No children</div>
<div class="sub-message">
<template v-if="!isParentAuthenticated">
<button class="sign-in-btn" @click="eventBus.emit('open-login')">Sign in</button> to
create a child
</template>
<span v-else><button class="sign-in-btn" @click="createChild">Create</button> a child</span>
</div>
</div>
<div v-else-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="children.length === 0" class="empty">No children found</div>
<div v-else class="grid">
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
<!-- kebab menu shown only for authenticated parent -->
@@ -547,4 +625,37 @@ h1 {
.fab:active {
background: #4c51bf;
}
.no-children-message {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: #fdfdfd;
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: #b5ccff;
}
.sign-in-btn {
background: #fff;
color: #2563eb;
border: 2px solid #2563eb;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
margin-right: 0.1rem;
cursor: pointer;
transition:
background 0.18s,
color 0.18s;
}
.sign-in-btn:hover {
background: #2563eb;
color: #fff;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { eventBus } from '@/common/eventBus'
import { authenticateParent, isParentAuthenticated, logout } from '../stores/auth'
const router = useRouter()
@@ -39,6 +40,13 @@ const handleLogout = () => {
logout()
router.push('/child')
}
onMounted(() => {
eventBus.on('open-login', open)
})
onUnmounted(() => {
eventBus.off('open-login', open)
})
</script>
<template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineProps, toRefs, ref, onMounted, onBeforeUnmount } from 'vue'
import { defineProps, toRefs, ref, watch, onBeforeUnmount } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Child {
@@ -28,11 +28,15 @@ const fetchImage = async (imageId: string) => {
}
}
onMounted(() => {
if (child.value && child.value.image_id) {
fetchImage(child.value.image_id)
}
})
watch(
() => child.value?.image_id,
(newImageId) => {
if (newImageId) {
fetchImage(newImageId)
}
},
{ immediate: true },
)
// Revoke created object URLs when component unmounts to avoid memory leaks
onBeforeUnmount(() => {

View File

@@ -141,7 +141,7 @@ const submit = async () => {
})
}
if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ChildrenListView' })
await router.push({ name: 'ParentChildrenListView' })
} catch (err) {
alert('Failed to save child.')
}

View File

@@ -10,16 +10,14 @@ import type {
Event,
Task,
Reward,
TaskUpdateEventPayload,
RewardUpdateEventPayload,
ChildUpdateEventPayload,
ChildDeleteEventPayload,
TaskCreatedEventPayload,
TaskDeletedEventPayload,
TaskEditedEventPayload,
RewardCreatedEventPayload,
RewardDeletedEventPayload,
RewardEditedEventPayload,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
ChildTasksSetEventPayload,
ChildRewardsSetEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
ChildModifiedEventPayload,
} from '@/common/models'
const route = useRoute()
@@ -30,73 +28,205 @@ const tasks = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const showRewardDialog = ref(false)
const showCancelDialog = ref(false)
const dialogReward = ref<Reward | null>(null)
const childRewardListRef = ref()
const childChoreListRef = ref()
const childHabitListRef = ref()
function handlePointsUpdate(event: Event) {
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
}
}
function handleServerChange(event: Event) {
const payload = event.payload as
| TaskUpdateEventPayload
| RewardUpdateEventPayload
| ChildUpdateEventPayload
function handleRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
fetchChildData(child.value.id)
child.value.points = payload.points
childRewardListRef.value?.refresh()
}
}
function handleChildDeletion(event: Event) {
const payload = event.payload as ChildDeleteEventPayload
function handleChildTaskSet(event: Event) {
const payload = event.payload as ChildTasksSetEventPayload
if (child.value && payload.child_id == child.value.id) {
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
tasks.value = payload.task_ids
}
}
function handleTaskChanged(event: Event) {
const payload = event.payload as
| TaskCreatedEventPayload
| TaskDeletedEventPayload
| TaskEditedEventPayload
function handleChildRewardSet(event: Event) {
const payload = event.payload as ChildRewardsSetEventPayload
if (child.value && payload.child_id == child.value.id) {
rewards.value = payload.reward_ids
childRewardListRef.value?.refresh()
}
}
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
}
})
} 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)) {
fetchChildData(child.value.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)
}
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 handleRewardChanged(event: Event) {
const payload = event.payload as
| RewardCreatedEventPayload
| RewardDeletedEventPayload
| RewardEditedEventPayload
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)) {
fetchChildData(child.value.id)
childRewardListRef.value?.refresh()
}
}
}
const handleTriggerTask = (task: Task) => {
const triggerTask = (task: Task) => {
if ('speechSynthesis' in window && task.name) {
const utter = new window.SpeechSynthesisUtterance(task.name)
window.speechSynthesis.speak(utter)
}
}
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) => {
if ('speechSynthesis' in window && reward.name) {
console.log('Handle trigger reward:', reward, redeemable)
const utterString =
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString)
window.speechSynthesis.speak(utter)
}
if (pending) {
dialogReward.value = reward
showCancelDialog.value = true
return // Do not allow redeeming if already pending
}
if (redeemable) {
dialogReward.value = reward
showRewardDialog.value = true
}
}
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
}
}
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 fetchChildData(id: string | number) {
@@ -105,13 +235,12 @@ async function fetchChildData(id: string | number) {
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
child.value = data.children ? data.children : data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
error.value = null
return data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
return null
} finally {
loading.value = false
}
@@ -119,23 +248,25 @@ async function fetchChildData(id: string | number) {
onMounted(async () => {
try {
eventBus.on('task_update', handlePointsUpdate)
eventBus.on('reward_update', handlePointsUpdate)
eventBus.on('task_set', handleServerChange)
eventBus.on('reward_set', handleServerChange)
eventBus.on('child_update', handleServerChange)
eventBus.on('child_delete', handleChildDeletion)
eventBus.on('task_created', handleTaskChanged)
eventBus.on('task_deleted', handleTaskChanged)
eventBus.on('task_edited', handleTaskChanged)
eventBus.on('reward_created', handleRewardChanged)
eventBus.on('reward_deleted', handleRewardChanged)
eventBus.on('reward_edited', handleRewardChanged)
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)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
fetchChildData(idParam)
const promise = fetchChildData(idParam)
promise.then((data) => {
if (data) {
child.value = data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
}
})
}
}
} catch (err) {
@@ -144,18 +275,14 @@ onMounted(async () => {
})
onUnmounted(() => {
eventBus.off('task_update', handlePointsUpdate)
eventBus.off('reward_update', handlePointsUpdate)
eventBus.off('task_set', handleServerChange)
eventBus.off('reward_set', handleServerChange)
eventBus.off('child_update', handleServerChange)
eventBus.off('child_delete', handleChildDeletion)
eventBus.off('task_created', handleTaskChanged)
eventBus.off('task_deleted', handleTaskChanged)
eventBus.off('task_edited', handleTaskChanged)
eventBus.off('reward_created', handleRewardChanged)
eventBus.off('reward_deleted', handleRewardChanged)
eventBus.off('reward_edited', handleRewardChanged)
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)
})
</script>
@@ -169,33 +296,80 @@ onUnmounted(() => {
<ChildDetailCard :child="child" />
<ChildTaskList
title="Chores"
ref="childChoreListRef"
:task-ids="tasks"
:child-id="child ? child.id : null"
:is-parent-authenticated="false"
:filter-type="1"
@trigger-task="handleTriggerTask"
@trigger-task="triggerTask"
/>
<ChildTaskList
title="Bad Habits"
ref="childHabitListRef"
:task-ids="tasks"
:child-id="child ? child.id : null"
:is-parent-authenticated="false"
:filter-type="2"
@trigger-task="handleTriggerTask"
@trigger-task="triggerTask"
/>
<ChildRewardList
ref="childRewardListRef"
:child-id="child ? child.id : null"
:child-points="child?.points ?? 0"
:is-parent-authenticated="false"
@trigger-reward="handleTriggerReward"
@points-updated="
({ id, points }) => {
if (child && child.id === id) child.points = points
}
"
@trigger-reward="triggerReward"
/>
</div>
</div>
<div v-if="showRewardDialog && dialogReward" class="modal-backdrop">
<div class="modal">
<div class="reward-info">
<img
v-if="dialogReward.image_id"
:src="dialogReward.image_id"
alt="Reward Image"
class="reward-image"
/>
<div class="reward-details">
<div class="reward-name">{{ dialogReward.name }}</div>
<div class="reward-points">{{ dialogReward.cost }} pts</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
Would you like to redeem this reward?
</div>
<div class="actions">
<button @click="confirmRedeemReward">Yes</button>
<button @click="cancelRedeemReward">No</button>
</div>
</div>
</div>
<div v-if="showCancelDialog && dialogReward" class="modal-backdrop">
<div class="modal">
<div class="reward-info">
<img
v-if="dialogReward.image_id"
:src="dialogReward.image_id"
alt="Reward Image"
class="reward-image"
/>
<div class="reward-details">
<div class="reward-name">{{ dialogReward.name }}</div>
<div class="reward-points">{{ dialogReward.cost }} pts</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
This reward is pending.<br />
Would you like to cancel the pending reward request?
</div>
<div class="actions">
<button @click="cancelPendingReward">Yes</button>
<button @click="closeCancelDialog">No</button>
</div>
</div>
</div>
</div>
</template>
@@ -266,6 +440,84 @@ onUnmounted(() => {
justify-content: center;
text-align: center;
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px #667eea22;
padding: 2rem 2.2rem 1.5rem 2.2rem;
min-width: 320px;
max-width: 90vw;
}
.reward-info {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.reward-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: #eee;
}
.reward-details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.reward-name {
font-weight: 600;
font-size: 1.1rem;
}
.reward-points {
color: #667eea;
font-weight: 500;
font-size: 1rem;
}
.dialog-message {
font-size: 1.05rem;
margin-bottom: 1.2rem;
text-align: center;
}
.actions {
display: flex;
gap: 0.7rem;
justify-content: center;
margin-top: 0.5rem;
}
.actions button {
padding: 0.5rem 1.1rem;
border-radius: 8px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1rem;
transition: background 0.18s;
}
.actions button:first-child {
background: #667eea;
color: white;
}
.actions button:last-child {
background: #f3f3f3;
color: #666;
}
.actions button:last-child:hover {
background: #e2e8f0;
}
/* Mobile adjustments */
@media (max-width: 900px) {

View File

@@ -11,10 +11,14 @@ import type {
Child,
Event,
Reward,
TaskUpdateEventPayload,
RewardUpdateEventPayload,
ChildUpdateEventPayload,
ChildDeleteEventPayload,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
ChildTasksSetEventPayload,
ChildRewardsSetEventPayload,
ChildModifiedEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
} from '@/common/models'
const route = useRoute()
@@ -22,36 +26,140 @@ 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 rewardListRef = ref()
const showConfirm = ref(false)
const selectedTask = ref<Task | null>(null)
const showRewardConfirm = ref(false)
const selectedReward = ref<Reward | null>(null)
const childRewardListRef = ref()
const childChoreListRef = ref()
const childHabitListRef = ref()
function handlePointsUpdate(event: Event) {
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
}
}
function handleServerChange(event: Event) {
const payload = event.payload as
| TaskUpdateEventPayload
| RewardUpdateEventPayload
| ChildUpdateEventPayload
function handleRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
if (child.value && payload.child_id == child.value.id) {
fetchChildData(child.value.id)
child.value.points = payload.points
childRewardListRef.value?.refresh()
}
}
function handleChildDeletion(event: Event) {
const payload = event.payload as ChildDeleteEventPayload
function handleChildTaskSet(event: Event) {
console.log('handleChildTaskSet called')
const payload = event.payload as ChildTasksSetEventPayload
if (child.value && payload.child_id == child.value.id) {
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
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
childRewardListRef.value?.refresh()
}
}
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 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)
}
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()
}
}
}
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
}
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
}
}
}
@@ -61,12 +169,12 @@ async function fetchChildData(id: string | number) {
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
child.value = data.children ? data.children : data
tasks.value = data.tasks || []
error.value = null
return data
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
return null
} finally {
loading.value = false
}
@@ -74,17 +182,26 @@ async function fetchChildData(id: string | number) {
onMounted(async () => {
try {
eventBus.on('task_update', handlePointsUpdate)
eventBus.on('reward_update', handlePointsUpdate)
eventBus.on('task_set', handleServerChange)
eventBus.on('reward_set', handleServerChange)
eventBus.on('child_update', handleServerChange)
eventBus.on('child_delete', handleChildDeletion)
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)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
fetchChildData(idParam)
const promise = fetchChildData(idParam)
promise.then((data) => {
if (data) {
child.value = data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
}
})
}
}
} catch (err) {
@@ -93,19 +210,17 @@ onMounted(async () => {
})
onUnmounted(() => {
eventBus.off('task_update', handlePointsUpdate)
eventBus.off('reward_update', handlePointsUpdate)
eventBus.off('task_set', handleServerChange)
eventBus.off('reward_set', handleServerChange)
eventBus.off('child_update', handleServerChange)
eventBus.off('child_delete', handleChildDeletion)
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_modified', handleChildModified)
eventBus.off('child_reward_request', handleRewardRequest)
eventBus.off('task_modified', handleTaskModified)
eventBus.off('reward_modified', handleRewardModified)
})
const refreshRewards = () => {
rewardListRef.value?.refresh()
}
const handleTriggerTask = (task: Task) => {
const triggerTask = (task: Task) => {
selectedTask.value = task
showConfirm.value = true
}
@@ -130,7 +245,7 @@ const confirmTriggerTask = async () => {
}
}
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
const triggerReward = (reward: Reward, redeemable: boolean) => {
console.log('Handle trigger reward:', reward, redeemable)
if (!redeemable) return
selectedReward.value = reward
@@ -174,14 +289,6 @@ function goToAssignRewards() {
}
}
const handleTaskPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
if (child.value && child.value.id === id) child.value.points = points
refreshRewards()
}
const handleRewardPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
if (child.value && child.value.id === id) child.value.points = points
}
const childId = computed(() => child.value?.id ?? null)
</script>
@@ -195,29 +302,28 @@ const childId = computed(() => child.value?.id ?? null)
<ChildDetailCard :child="child" />
<ChildTaskList
title="Chores"
ref="childChoreListRef"
:task-ids="tasks"
:child-id="childId"
:is-parent-authenticated="isParentAuthenticated"
:filter-type="1"
@points-updated="handleTaskPointsUpdated"
@trigger-task="handleTriggerTask"
@trigger-task="triggerTask"
/>
<ChildTaskList
title="Bad Habits"
ref="childHabitListRef"
:task-ids="tasks"
:child-id="childId"
:is-parent-authenticated="isParentAuthenticated"
:filter-type="2"
@points-updated="handleTaskPointsUpdated"
@trigger-task="handleTriggerTask"
@trigger-task="triggerTask"
/>
<ChildRewardList
ref="rewardListRef"
ref="childRewardListRef"
:child-id="childId"
:child-points="child?.points ?? 0"
:is-parent-authenticated="false"
@points-updated="handleRewardPointsUpdated"
@trigger-reward="handleTriggerReward"
@trigger-reward="triggerReward"
/>
</div>
</div>

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
import type { PendingReward, Event, ChildRewardRequestEventPayload } from '@/common/models'
import { eventBus } from '@/common/eventBus'
const emit = defineEmits(['item-clicked'])
const loading = ref(true)
const error = ref<string | null>(null)
const notifications = ref<PendingReward[]>([])
const fetchNotifications = async () => {
loading.value = true
error.value = null
try {
const resp = await fetch('/api/pending-rewards')
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const rewards: PendingReward[] = data.rewards || []
// Fetch images for child and reward
await Promise.all(
rewards.map(async (item) => {
if (item.child_image_id) {
try {
item.child_image_url = await getCachedImageUrl(item.child_image_id)
} catch (e) {
item.child_image_url = null
}
}
if (item.reward_image_id) {
try {
item.reward_image_url = await getCachedImageUrl(item.reward_image_id)
} catch (e) {
item.reward_image_url = null
}
}
}),
)
notifications.value = rewards
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch notifications'
notifications.value = []
} finally {
loading.value = false
}
}
function handleRewardRequest(event: Event) {
const payload = event.payload as ChildRewardRequestEventPayload
const childId = payload.child_id
const rewardId = payload.reward_id
// Todo: Have event carry more info to avoid full refresh
fetchNotifications()
}
onMounted(async () => {
eventBus.on('child_reward_request', handleRewardRequest)
await fetchNotifications()
})
onUnmounted(() => {
eventBus.off('child_reward_request', handleRewardRequest)
})
function handleItemClick(item: PendingReward) {
emit('item-clicked', item)
}
</script>
<template>
<div>
<div v-if="loading" class="loading">Loading notifications...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="notifications.length === 0" class="empty">No Notifications</div>
<div v-else class="notification-listbox">
<div v-for="(item, idx) in notifications" :key="item.id">
<div class="notification-list-item" @click="handleItemClick(item)">
<div class="child-info">
<img
v-if="item.child_image_url"
:src="item.child_image_url"
alt="Child"
class="child-image"
/>
<span class="child-name">{{ item.child_name }}</span>
</div>
<span class="requested-text">requested</span>
<div class="reward-info">
<span class="reward-name">{{ item.reward_name }}</span>
<img
v-if="item.reward_image_url"
:src="item.reward_image_url"
alt="Reward"
class="reward-image"
/>
</div>
</div>
<div v-if="idx < notifications.length - 1" class="notification-separator"></div>
</div>
</div>
</div>
</template>
<style scoped>
.notification-listbox {
flex: 1 1 auto;
width: auto;
max-width: 480px;
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
background: #fff5;
padding: 0.2rem 0.2rem 0.2rem;
border-radius: 12px;
}
.notification-list-item {
display: flex;
align-items: center;
border: 2px outset #ef4444;
border-radius: 8px;
padding: 0.2rem 1rem;
background: #f8fafc;
font-size: 1.05rem;
font-weight: 500;
transition: border 0.18s;
margin-bottom: 0.2rem;
margin-left: 0.2rem;
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
justify-content: space-between;
}
.child-info {
display: flex;
align-items: center;
gap: 0.7rem;
}
.child-image {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
background: #eee;
flex-shrink: 0;
}
.child-name {
font-weight: 600;
color: #667eea;
}
.reward-info {
display: flex;
align-items: center;
gap: 0.7rem;
}
.reward-name {
font-weight: 600;
color: #ef4444;
}
.reward-image {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
background: #eee;
flex-shrink: 0;
}
.loading,
.error,
.empty {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: #fdfdfd;
line-height: 1.5;
}
.error {
color: #ef4444; /* Red-500 for errors */
background: #fff1f2; /* Red-50 for error background */
}
.notification-list-item:last-child {
margin-bottom: 0;
}
.notification-separator {
height: 0px;
background: #0000;
margin: 0rem 0.2rem;
border-radius: 0px;
}
.requested-text {
margin: 0 0.7rem;
font-weight: 500;
color: #444;
font-size: 1.05rem;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="notification-view">
<NotificationList @item-clicked="handleNotificationClick" />
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import NotificationList from './NotificationList.vue'
import type { PendingReward } from '@/common/models'
const router = useRouter()
function handleNotificationClick(item: PendingReward) {
if (item.child_id) {
router.push({ name: 'ParentView', params: { id: item.child_id } })
}
}
</script>
<style scoped>
.notification-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
/* Optional: Floating Action Button styles if you add one */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #ef4444;
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 24px;
z-index: 1300;
}
.fab:hover {
background: #dc2626;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { ref, onBeforeUnmount, watch, nextTick, computed } from 'vue'
import { defineProps, defineEmits, defineExpose } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import type { RewardStatus } from '@/common/models'
@@ -7,11 +7,11 @@ import type { RewardStatus } from '@/common/models'
const imageCacheName = 'images-v1'
const props = defineProps<{
childId: string | number | null
childId: string | null
childPoints: number
isParentAuthenticated: boolean
}>()
const emit = defineEmits(['points-updated', 'trigger-reward'])
const emit = defineEmits(['trigger-reward'])
const rewards = ref<RewardStatus[]>([])
const loading = ref(true)
@@ -107,21 +107,20 @@ const handleRewardClick = async (rewardId: string) => {
const triggerReward = (rewardId: string) => {
const reward = rewards.value.find((rew) => rew.id === rewardId)
if (!reward) return // Don't trigger if not allowed
emit('trigger-reward', reward, reward.points_needed <= 0)
emit('trigger-reward', reward, reward.points_needed <= 0, reward.redeeming)
}
onMounted(() => fetchRewards(props.childId))
watch(
() => props.childId,
(v) => fetchRewards(v),
(newId) => {
fetchRewards(newId)
},
{ immediate: true },
)
watch(
() => props.childPoints,
() => {
// Option 1: If reward eligibility depends on points, recompute eligibility here
// Option 2: If you need to refetch from the backend, call fetchRewards(props.childId)
// For most cases, just recompute eligibility locally
// Example:
rewards.value.forEach((reward) => {
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
})
@@ -135,6 +134,8 @@ onBeforeUnmount(() => {
// expose refresh method for parent component
defineExpose({ refresh: () => fetchRewards(props.childId) })
const isAnyPending = computed(() => rewards.value.some((r) => r.redeeming))
</script>
<template>
@@ -153,6 +154,7 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
class="reward-card"
:class="{
ready: readyRewardId === r.id,
disabled: isAnyPending && !r.redeeming,
}"
:ref="(el) => (rewardRefs[r.id] = el)"
@click="() => handleRewardClick(r.id)"
@@ -163,6 +165,8 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
<template v-if="r.points_needed === 0"> REWARD READY </template>
<template v-else> {{ r.points_needed }} pts needed </template>
</div>
<!-- PENDING block if redeeming is true -->
<div v-if="r.redeeming" class="pending-block">PENDING</div>
</div>
</div>
</div>
@@ -232,6 +236,7 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
}
.reward-card {
position: relative; /* Add this for overlay positioning */
background: rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 0.75rem;
@@ -330,4 +335,26 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
border-width: 1px;
}
}
.pending-block {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
background: #222b;
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;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
import type { Reward } from '@/common/models'
const props = defineProps<{
childId?: string | number
@@ -8,7 +9,7 @@ const props = defineProps<{
deletable?: boolean
selectable?: boolean
}>()
const emit = defineEmits(['edit-reward', 'delete-reward'])
const emit = defineEmits(['edit-reward', 'delete-reward', 'loading-complete'])
const rewards = ref<
{
@@ -35,11 +36,11 @@ const fetchRewards = async () => {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const assigned = (data.assigned_rewards || []).map((reward: any) => ({
const assigned = (data.assigned_rewards || []).map((reward: Reward) => ({
...reward,
assigned: true,
}))
const assignable = (data.assignable_rewards || []).map((reward: any) => ({
const assignable = (data.assignable_rewards || []).map((reward: Reward) => ({
...reward,
assigned: false,
}))
@@ -69,13 +70,14 @@ const fetchRewards = async () => {
// If selectable, pre-select assigned rewards
if (props.selectable) {
selectedRewards.value = assigned.map((reward: any) => String(reward.id))
selectedRewards.value = assigned.map((reward: Reward) => String(reward.id))
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
if (props.selectable) selectedRewards.value = []
} finally {
emit('loading-complete', rewards.value.length)
loading.value = false
}
} else {

View File

@@ -1,11 +1,21 @@
<template>
<div class="reward-view">
<RewardList
ref="rewardListRef"
:deletable="true"
@edit-reward="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
@delete-reward="confirmDeleteReward"
/>
<div class="task-view">
<div v-if="rewardCountRef === 0" class="no-rewards-message">
<div>No Rewards</div>
<div class="sub-message">
<button class="create-btn" @click="createReward">Create</button> a reward
</div>
</div>
<div class="reward-view" v-else>
<RewardList
ref="rewardListRef"
:deletable="true"
@edit-reward="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
@delete-reward="confirmDeleteReward"
@loading-complete="(count) => (rewardCountRef = count)"
/>
</div>
<!-- Floating Action Button -->
<button class="fab" @click="createReward" aria-label="Create Reward">
@@ -37,6 +47,7 @@ const $router = useRouter()
const showConfirm = ref(false)
const rewardToDelete = ref<string | null>(null)
const rewardListRef = ref()
const rewardCountRef = ref<number>(0)
function confirmDeleteReward(rewardId: string) {
rewardToDelete.value = rewardId
@@ -142,4 +153,37 @@ const createReward = () => {
.fab:hover {
background: #5a67d8;
}
.no-rewards-message {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: #fdfdfd;
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: #b5ccff;
}
.create-btn {
background: #fff;
color: #2563eb;
border: 2px solid #2563eb;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
margin-right: 0.1rem;
cursor: pointer;
transition:
background 0.18s,
color 0.18s;
}
.create-btn:hover {
background: #2563eb;
color: #fff;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
import { defineProps, defineEmits } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import type { Task } from '@/common/models'
@@ -14,7 +14,6 @@ const props = defineProps<{
filterType?: number | null
}>()
const emit = defineEmits<{
(e: 'points-updated', payload: { id: string; points: number }): void
(e: 'trigger-task', task: Task): void
}>()
@@ -119,7 +118,18 @@ const filteredTasks = computed(() => {
return tasks.value
})
onMounted(fetchTasks)
watch(
() => props.taskIds,
(newTaskIds) => {
if (newTaskIds && newTaskIds.length > 0) {
fetchTasks()
} else {
tasks.value = []
loading.value = false
}
},
{ immediate: true },
)
// revoke all created object URLs when component unmounts
onBeforeUnmount(() => {

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
import type { Task } from '@/common/models'
const props = defineProps<{
childId?: string | number
@@ -9,7 +10,7 @@ const props = defineProps<{
deletable?: boolean
selectable?: boolean
}>()
const emit = defineEmits(['edit-task', 'delete-task'])
const emit = defineEmits(['edit-task', 'delete-task', 'loading-complete'])
const tasks = ref<
{
@@ -36,12 +37,15 @@ const fetchTasks = async () => {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const assigned = (data.assigned_tasks || []).map((task: any) => ({ ...task, assigned: true }))
const assignable = (data.assignable_tasks || []).map((task: any) => ({
const assigned = (data.assigned_tasks || []).map((task: Task) => ({
...task,
assigned: true,
}))
const assignable = (data.assignable_tasks || []).map((task: Task) => ({
...task,
assigned: false,
}))
let taskList: any[] = []
let taskList: Task[] = []
if (props.assignFilter === 'assignable') {
taskList = assignable
} else if (props.assignFilter === 'assigned') {
@@ -53,7 +57,7 @@ const fetchTasks = async () => {
}
// Fetch images for each task if image_id is present
await Promise.all(
taskList.map(async (task: any) => {
taskList.map(async (task: Task) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
@@ -67,7 +71,7 @@ const fetchTasks = async () => {
// If selectable, pre-select assigned tasks
if (props.selectable) {
selectedTasks.value = assigned.map((task: any) => String(task.id))
selectedTasks.value = assigned.map((task: Task) => String(task.id))
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
@@ -84,7 +88,7 @@ const fetchTasks = async () => {
const data = await resp.json()
const taskList = data.tasks || []
await Promise.all(
taskList.map(async (task: any) => {
taskList.map(async (task: Task) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
@@ -101,6 +105,7 @@ const fetchTasks = async () => {
tasks.value = []
if (props.selectable) selectedTasks.value = []
} finally {
emit('loading-complete', tasks.value.length)
loading.value = false
}
}

View File

@@ -1,10 +1,19 @@
<template>
<div class="task-view">
<div v-if="taskCountRef === 0" class="no-tasks-message">
<div>No Tasks</div>
<div class="sub-message">
<button class="create-btn" @click="createTask">Create</button> a task
</div>
</div>
<TaskList
v-else
ref="taskListRef"
:deletable="true"
@edit-task="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
@delete-task="confirmDeleteTask"
@loading-complete="(count) => (taskCountRef = count)"
/>
<!-- Floating Action Button -->
@@ -37,6 +46,7 @@ const $router = useRouter()
const showConfirm = ref(false)
const taskToDelete = ref<string | null>(null)
const taskListRef = ref()
const taskCountRef = ref<number>(0)
function confirmDeleteTask(taskId: string) {
taskToDelete.value = taskId
@@ -145,4 +155,37 @@ const createTask = () => {
.fab:hover {
background: #5a67d8;
}
.no-tasks-message {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: #fdfdfd;
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: #b5ccff;
}
.create-btn {
background: #fff;
color: #2563eb;
border: 2px solid #2563eb;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
margin-right: 0.1rem;
cursor: pointer;
transition:
background 0.18s,
color 0.18s;
}
.create-btn:hover {
background: #2563eb;
color: #fff;
}
</style>

View File

@@ -15,7 +15,13 @@ const handleBack = () => {
}
const showBack = computed(
() => !(route.path === '/parent' || route.name === 'TaskView' || route.name === 'RewardView'),
() =>
!(
route.path === '/parent' ||
route.name === 'TaskView' ||
route.name === 'RewardView' ||
route.name === 'NotificationView'
),
)
</script>
@@ -40,7 +46,7 @@ const showBack = computed(
aria-label="Children"
title="Children"
>
<!-- Children Icon: Two user portraits -->
<!-- Children Icon -->
<svg
width="24"
height="24"
@@ -105,6 +111,29 @@ const showBack = computed(
<rect x="7" y="2" width="10" height="15" rx="5" />
</svg>
</button>
<button
:class="{ active: ['NotificationView'].includes(String(route.name)) }"
@click="router.push({ name: 'NotificationView' })"
aria-label="Notifications"
title="Notifications"
>
<!-- Notification/Bell Icon -->
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 16v-5a6 6 0 1 0-12 0v5" />
<path d="M2 16h20" />
<path d="M8 20a4 4 0 0 0 8 0" />
<circle cx="19" cy="7" r="2" fill="#ef4444" stroke="none" />
</svg>
</button>
</nav>
<LoginButton class="login-btn" />
</header>

View File

@@ -11,6 +11,7 @@ import RewardEditView from '@/components/reward/RewardEditView.vue'
import ChildEditView from '@/components/child/ChildEditView.vue'
import TaskAssignView from '@/components/child/TaskAssignView.vue'
import RewardAssignView from '@/components/child/RewardAssignView.vue'
import NotificationView from '@/components/notification/NotificationView.vue'
const routes = [
{
@@ -103,6 +104,12 @@ const routes = [
component: RewardAssignView,
props: true,
},
{
path: 'notifications',
name: 'NotificationView',
component: NotificationView,
props: false,
},
],
},
{