Add detailed Copilot instructions and enhance child API logging
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 14s

- Introduced a comprehensive instructions document for the Reward project, outlining architecture, data flow, key patterns, and developer workflows.
- Enhanced logging in the child API to track points and reward costs, improving error handling for insufficient points.
- Updated Vue components to reflect changes in reward handling and improve user experience with pending rewards.
This commit is contained in:
2026-01-21 16:35:50 -05:00
parent 59b480621e
commit a47df7171c
6 changed files with 325 additions and 107 deletions

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ChildRewardList from '../reward/ChildRewardList.vue'
import ScrollingList from '../shared/ScrollingList.vue'
import { eventBus } from '@/common/eventBus'
import '@/assets/view-shared.css'
@@ -11,6 +10,7 @@ import type {
Event,
Task,
Reward,
RewardStatus,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
@@ -33,8 +33,6 @@ const showRewardDialog = ref(false)
const showCancelDialog = ref(false)
const dialogReward = ref<Reward | null>(null)
const childRewardListRef = ref()
const childChoreListRef = ref()
const childPenaltyListRef = ref()
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
@@ -47,7 +45,6 @@ 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()
}
}
@@ -62,7 +59,6 @@ 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()
}
}
@@ -171,26 +167,25 @@ const triggerTask = (task: Task) => {
}
}
const triggerReward = (reward: Reward) => {}
/*
const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) => {
const triggerReward = (reward: RewardStatus) => {
if ('speechSynthesis' in window && reward.name) {
const utterString =
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
reward.name +
(reward.points_needed <= 0 ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString)
window.speechSynthesis.speak(utter)
console.log('Reward data is:', reward)
if (reward.redeeming) {
dialogReward.value = reward
showCancelDialog.value = true
return // Do not allow redeeming if already pending
}
if (reward.points_needed <= 0) {
dialogReward.value = reward
showRewardDialog.value = true
}
}
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
@@ -258,7 +253,7 @@ function resetInactivityTimer() {
if (inactivityTimer) clearTimeout(inactivityTimer)
inactivityTimer = setTimeout(() => {
router.push({ name: 'ChildrenListView' })
}, 6000000) // 60 seconds
}, 60000) // 60 seconds
}
function setupInactivityListeners() {
@@ -272,6 +267,10 @@ function removeInactivityListeners() {
if (inactivityTimer) clearTimeout(inactivityTimer)
}
const hasPendingRewards = computed(() =>
childRewardListRef.value?.items.some((r: RewardStatus) => r.redeeming),
)
onMounted(async () => {
try {
eventBus.on('child_task_triggered', handleTaskTriggered)
@@ -379,19 +378,15 @@ onUnmounted(() => {
<ScrollingList
title="Rewards"
ref="childRewardListRef"
:fetchBaseUrl="`/api/reward/list?ids=${rewards.join(',')}`"
:ids="rewards"
itemKey="rewards"
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
@trigger-item="triggerReward"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return !item.is_good
}
:getItemClass="
(item) => ({ reward: true, disabled: hasPendingRewards && !item.redeeming })
"
>
<template #item="{ item }">
<template #item="{ item }: { item: RewardStatus }">
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
@@ -399,21 +394,13 @@ onUnmounted(() => {
alt="Reward Image"
class="item-image"
/>
<div
class="item-points"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
<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>
<ChildRewardList
ref="childRewardListRef"
:child-id="child ? child.id : null"
:child-points="child?.points ?? 0"
:is-parent-authenticated="false"
@trigger-reward="triggerReward"
/>
</div>
</div>
@@ -421,8 +408,8 @@ onUnmounted(() => {
<div class="modal">
<div class="reward-info">
<img
v-if="dialogReward.image_id"
:src="dialogReward.image_id"
v-if="dialogReward.image_url"
:src="dialogReward.image_url"
alt="Reward Image"
class="reward-image"
/>
@@ -445,8 +432,8 @@ onUnmounted(() => {
<div class="modal">
<div class="reward-info">
<img
v-if="dialogReward.image_id"
:src="dialogReward.image_id"
v-if="dialogReward.image_url"
:src="dialogReward.image_url"
alt="Reward Image"
class="reward-image"
/>
@@ -469,6 +456,13 @@ onUnmounted(() => {
</template>
<style scoped>
.assign-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
}
.back-btn {
background: var(--back-btn-bg);
border: 0;
@@ -480,19 +474,37 @@ onUnmounted(() => {
font-weight: 600;
}
.assign-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 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) {
@@ -509,4 +521,13 @@ onUnmounted(() => {
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);
}
</style>

View File

@@ -1,10 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { isParentAuthenticated } from '../../stores/auth'
import ChildDetailCard from './ChildDetailCard.vue'
import ChildTaskList from '../task/ChildTaskList.vue'
import ChildRewardList from '../reward/ChildRewardList.vue'
import ScrollingList from '../shared/ScrollingList.vue'
import { eventBus } from '@/common/eventBus'
import '@/assets/view-shared.css'
import type {
@@ -36,14 +34,13 @@ 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()
const showPendingRewardDialog = ref(false)
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()
}
}
@@ -66,7 +63,6 @@ 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()
}
}
@@ -105,6 +101,7 @@ function handleTaskModified(event: Event) {
if (data) {
tasks.value = data.tasks || []
}
loading.value = false
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
@@ -117,6 +114,7 @@ function handleTaskModified(event: Event) {
}
} catch (err) {
console.warn('Failed to fetch child after task modification:', err)
loading.value = false
}
}
}
@@ -154,9 +152,11 @@ function handleChildModified(event: Event) {
if (data) {
child.value = data
}
loading.value = false
})
} catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err)
loading.value = false
}
break
default:
@@ -178,7 +178,6 @@ async function fetchChildData(id: string | number) {
console.error(err)
return null
} finally {
loading.value = false
}
}
@@ -203,6 +202,7 @@ onMounted(async () => {
tasks.value = data.tasks || []
rewards.value = data.rewards || []
}
loading.value = false
})
}
}
@@ -222,10 +222,16 @@ onUnmounted(() => {
eventBus.off('reward_modified', handleRewardModified)
})
function getPendingRewardIds(): string[] {
const items = childRewardListRef.value?.items || []
return items.filter((item: RewardStatus) => item.redeeming).map((item: RewardStatus) => item.id)
}
const triggerTask = (task: Task) => {
selectedTask.value = task
const pendingRewardIds = childRewardListRef.value?.getPendingRewards()
if (pendingRewardIds && pendingRewardIds.length > 0) {
const pendingRewardIds = getPendingRewardIds()
console.log('Pending reward IDs:', pendingRewardIds)
if (pendingRewardIds.length > 0) {
showPendingRewardDialog.value = true
return
}
@@ -253,8 +259,8 @@ async function cancelPendingReward() {
return
}
try {
const pendingRewardIds = childRewardListRef.value?.getPendingRewards()
await Promise.all(pendingRewardIds?.map((id: string) => cancelRewardById(id)) || [])
const pendingRewardIds = getPendingRewardIds()
await Promise.all(pendingRewardIds.map((id: string) => cancelRewardById(id)))
childRewardListRef.value?.refresh()
} catch (err) {
console.error('Failed to cancel pending reward:', err)
@@ -286,8 +292,8 @@ const confirmTriggerTask = async () => {
}
}
const triggerReward = (reward: Reward, redeemable: boolean) => {
if (!redeemable) return
const triggerReward = (reward: RewardStatus) => {
if (reward.points_needed > 0) return
selectedReward.value = reward
showRewardConfirm.value = true
}
@@ -328,8 +334,6 @@ function goToAssignRewards() {
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
}
}
const childId = computed(() => child.value?.id ?? null)
</script>
<template>
@@ -340,31 +344,82 @@ const childId = computed(() => child.value?.id ?? null)
<div v-else class="layout">
<div class="main">
<ChildDetailCard :child="child" />
<ChildTaskList
<ScrollingList
title="Chores"
ref="childChoreListRef"
:task-ids="tasks"
:child-id="childId"
:is-parent-authenticated="isParentAuthenticated"
:filter-type="1"
@trigger-task="triggerTask"
/>
<ChildTaskList
title="Bad Habits"
ref="childHabitListRef"
:task-ids="tasks"
:child-id="childId"
:is-parent-authenticated="isParentAuthenticated"
:filter-type="2"
@trigger-task="triggerTask"
/>
<ChildRewardList
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return item.is_good
}
"
>
<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"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Penalties"
ref="childPenaltyListRef"
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:ids="tasks"
itemKey="tasks"
imageField="image_id"
@trigger-item="triggerTask"
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
:filter-fn="
(item) => {
return !item.is_good
}
"
>
<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"
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
>
{{ item.is_good ? item.points : -item.points }} Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Rewards"
ref="childRewardListRef"
:child-id="childId"
:child-points="child?.points ?? 0"
:is-parent-authenticated="false"
@trigger-reward="triggerReward"
/>
:fetchBaseUrl="`/api/child/${child?.id}/reward-status`"
itemKey="reward_status"
imageField="image_id"
@trigger-item="triggerReward"
:getItemClass="(item) => ({ reward: true })"
>
<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>
</div>
</div>
<div class="assign-buttons">
@@ -432,8 +487,8 @@ const childId = computed(() => child.value?.id ?? null)
<div class="modal">
<div class="reward-info">
<img
v-if="selectedReward.image_id"
:src="selectedReward.image_id"
v-if="selectedReward.image_url"
:src="selectedReward.image_url"
alt="Reward Image"
class="reward-image"
/>
@@ -478,4 +533,68 @@ const childId = computed(() => child.value?.id ?? null)
justify-content: center;
margin: 2rem 0;
}
.back-btn {
background: var(--back-btn-bg);
border: 0;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 1.5rem;
color: var(--back-btn-color);
font-weight: 600;
}
.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);
}
</style>

View File

@@ -5,7 +5,7 @@ import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
const props = defineProps<{
title: string
fetchBaseUrl: string
ids: readonly string[]
ids?: readonly string[]
itemKey: string
imageFields?: readonly string[]
isParentAuthenticated?: boolean
@@ -78,6 +78,16 @@ const fetchItems = async () => {
}
}
async function refresh() {
await fetchItems()
loading.value = false
}
defineExpose({
refresh,
items,
})
const centerItem = async (itemId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
@@ -231,6 +241,7 @@ onBeforeUnmount(() => {
flex-direction: column;
align-items: center;
cursor: pointer;
user-select: none; /* Prevent image selection */
}
.item-card:hover {