refactoring
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 12s

This commit is contained in:
2026-01-15 16:42:01 -05:00
parent dcac2742e9
commit 904185e5c8
9 changed files with 276 additions and 238 deletions

View File

@@ -285,7 +285,7 @@ def list_all_tasks(id):
tasks.append(task_dict)
tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
return jsonify({ 'tasks': tasks }), 200
return jsonify({ 'tasks': tasks, 'count': len(tasks), 'list_type': 'task' }), 200
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
@@ -373,7 +373,8 @@ def list_all_rewards(id):
return jsonify({
'rewards': rewards,
'rewards_count': len(rewards)
'rewards_count': len(rewards),
'list_type': 'reward'
}), 200
@@ -680,5 +681,5 @@ def list_pending_rewards():
)
reward_responses.append(response.to_dict())
return jsonify({'rewards': reward_responses}), 200
return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200

View File

@@ -6,6 +6,7 @@ export interface Task {
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export const TASK_FIELDS = ['id', 'name', 'points', 'is_good', 'image_id'] as const
export interface Child {
id: string
@@ -17,6 +18,7 @@ export interface Child {
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export const CHILD_FIELDS = ['id', 'name', 'age', 'tasks', 'rewards', 'points', 'image_id'] as const
export interface Reward {
id: string
@@ -26,6 +28,7 @@ export interface Reward {
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export const REWARD_FIELDS = ['id', 'name', 'cost', 'points_needed', 'image_id'] as const
export interface RewardStatus {
id: string
@@ -36,6 +39,14 @@ export interface RewardStatus {
image_id: string | null
image_url?: string | null // optional, for resolved URLs
}
export const REWARD_STATUS_FIELDS = [
'id',
'name',
'points_needed',
'cost',
'redeeming',
'image_id',
] as const
export interface PendingReward {
id: string
@@ -48,6 +59,15 @@ export interface PendingReward {
reward_image_id: string | null
reward_image_url?: string | null // optional, for resolved URLs
}
export const PENDING_REWARD_FIELDS = [
'id',
'child_id',
'child_name',
'child_image_id',
'reward_id',
'reward_name',
'reward_image_id',
] as const
export interface Event {
type: string

View File

@@ -10,11 +10,18 @@
ref="rewardListRef"
:fetchUrl="`/api/child/${childId}/list-all-rewards`"
itemKey="rewards"
:itemFields="['id', 'name', 'cost', 'image_id']"
:itemFields="REWARD_FIELDS"
imageField="image_id"
selectable
@loading-complete="(count) => (rewardCountRef = count)"
/>
:getItemClass="(item) => `reward`"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.cost }} pts</span>
</template>
</ItemList>
</div>
<div class="actions" v-if="rewardCountRef != 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
@@ -29,6 +36,7 @@ import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/actions-shared.css'
import { REWARD_FIELDS } from '@/common/models'
const route = useRoute()
const router = useRouter()
@@ -90,4 +98,21 @@ function onCancel() {
padding: 0;
min-height: 0;
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
:deep(.reward) {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
</style>

View File

@@ -10,11 +10,18 @@
ref="taskListRef"
:fetchUrl="`/api/child/${childId}/list-all-tasks?type=${typeFilter}`"
itemKey="tasks"
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
:itemFields="TASK_FIELDS"
imageField="image_id"
selectable
@loading-complete="(count) => (taskCountRef = count)"
/>
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.points }} pts</span>
</template>
</ItemList>
</div>
<div class="actions" v-if="taskCountRef > 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
@@ -29,6 +36,7 @@ import { useRoute, useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/actions-shared.css'
import { TASK_FIELDS } from '@/common/models'
const route = useRoute()
const router = useRouter()
@@ -96,4 +104,25 @@ function onCancel() {
padding: 0;
min-height: 0;
}
: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);
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
</style>

View File

@@ -1,141 +0,0 @@
<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 class="centered-list-container">
<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="listbox">
<div v-for="(item, idx) in notifications" :key="item.id">
<div class="list-item notification-centered" @click="handleItemClick(item)">
<div class="child-info">
<img
v-if="item.child_image_url"
:src="item.child_image_url"
alt="Child"
class="list-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="list-image"
/>
</div>
</div>
<div v-if="idx < notifications.length - 1" class="list-separator"></div>
</div>
</div>
</div>
</template>
<style scoped>
.centered-list-container {
width: 100%;
display: flex;
justify-content: center;
}
.notification-centered {
justify-content: center;
}
.child-info {
display: flex;
align-items: center;
gap: 0.7rem;
}
.child-name {
font-weight: 600;
color: var(--dialog-child-name);
}
.reward-info {
display: flex;
align-items: center;
gap: 0.7rem;
margin-bottom: 0rem;
}
.reward-name {
font-weight: 600;
color: var(--notification-reward-name);
}
.requested-text {
margin: 0 0.7rem;
font-weight: 500;
color: var(--dialog-message);
font-size: 1.05rem;
white-space: nowrap;
}
</style>

View File

@@ -1,20 +1,43 @@
<template>
<div class="notification-view">
<NotificationList @item-clicked="handleNotificationClick" />
<ItemList
:fetchUrl="`/api/pending-rewards`"
itemKey="rewards"
:itemFields="PENDING_REWARD_FIELDS"
:imageFields="['child_image_id', 'reward_image_id']"
@clicked="handleNotificationClick"
@loading-complete="(count) => (notificationListCountRef = count)"
>
<template #item="{ item }">
<div class="notification-centered">
<div class="child-info">
<img v-if="item.child_image_url" :src="item.child_image_url" alt="Child" />
<span>{{ item.child_name }}</span>
</div>
<span class="requested-text">requested</span>
<div class="reward-info">
<span>{{ item.reward_name }}</span>
<img v-if="item.reward_image_url" :src="item.reward_image_url" alt="Reward" />
</div>
</div>
</template>
</ItemList>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import NotificationList from './NotificationList.vue'
import ItemList from '../shared/ItemList.vue'
import type { PendingReward } from '@/common/models'
import { PENDING_REWARD_FIELDS } from '@/common/models'
const router = useRouter()
const notificationListCountRef = ref(-1)
function handleNotificationClick(item: PendingReward) {
if (item.child_id) {
router.push({ name: 'ParentView', params: { id: item.child_id } })
}
router.push({ name: 'ParentView', params: { id: item.child_id } })
}
</script>
@@ -29,4 +52,34 @@ function handleNotificationClick(item: PendingReward) {
padding: 0;
min-height: 0;
}
.notification-centered {
display: flex;
justify-content: center;
margin-inline: auto;
align-items: center;
}
.child-info {
display: flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
color: var(--dialog-child-name);
}
.reward-info {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0rem;
font-weight: 600;
color: var(--notification-reward-name);
}
.requested-text {
margin: 0 0.7rem;
font-weight: 500;
color: var(--dialog-message);
font-size: 1.05rem;
white-space: nowrap;
}
</style>

View File

@@ -8,13 +8,20 @@
v-else
fetchUrl="/api/reward/list"
itemKey="rewards"
:itemFields="['id', 'name', 'cost', 'description', 'image_id']"
:itemFields="REWARD_FIELDS"
imageField="image_id"
deletable
@edit="(rewardId) => $router.push({ name: 'EditReward', params: { id: rewardId } })"
@clicked="(reward: Reward) => $router.push({ name: 'EditReward', params: { id: reward.id } })"
@delete="confirmDeleteReward"
@loading-complete="(count) => (rewardCountRef = count)"
/>
:getItemClass="(item) => `reward`"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.cost }} pts</span>
</template>
</ItemList>
<FloatingActionButton aria-label="Create Reward" @click="createReward" />
@@ -35,6 +42,8 @@ import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
import type { Reward } from '@/common/models'
import { REWARD_FIELDS } from '@/common/models'
import '@/assets/view-shared.css'
@@ -83,4 +92,21 @@ const createReward = () => {
padding: 0;
min-height: 0;
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
:deep(.reward) {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
</style>

View File

@@ -1,20 +1,21 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { getCachedImageUrl } from '@/common/imageCache'
const props = defineProps<{
fetchUrl: string
itemKey: string
itemFields: string[]
imageField?: string
itemFields: readonly string[]
imageFields?: readonly string[]
selectable?: boolean
deletable?: boolean
onEdit?: (id: string) => void
onClicked?: (item: any) => void
onDelete?: (id: string) => void
filterFn?: (item: any) => boolean
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
}>()
const emit = defineEmits(['edit', 'delete', 'loading-complete'])
const emit = defineEmits(['clicked', 'delete', 'loading-complete'])
const items = ref<any[]>([])
const loading = ref(true)
@@ -32,24 +33,35 @@ const fetchItems = async () => {
console.log(`Fetching items from: ${props.fetchUrl}`)
try {
const resp = await fetch(props.fetchUrl)
console.log(`Fetch response status: ${resp.status}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
//console log all data
console.log('Fetched data:', data)
let itemList = data[props.itemKey] || []
let itemList = data[props.itemKey || 'items'] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn)
await Promise.all(
itemList.map(async (item: any) => {
if (props.imageField && item[props.imageField]) {
if (props.imageFields) {
for (const field of props.imageFields) {
if (item[field]) {
try {
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
} catch {
item[`${field.replace('_id', '_url')}`] = null
}
}
}
} else if (item.image_id) {
try {
item.image_url = await getCachedImageUrl(item[props.imageField])
item.image_url = await getCachedImageUrl(item.image_id)
} catch {
item.image_url = null
}
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
if (props.selectable && item.assigned === true) {
selectedItems.value.push(item.id)
}
}
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
if (props.selectable && item.assigned === true) {
selectedItems.value.push(item.id)
}
}),
)
@@ -64,25 +76,16 @@ const fetchItems = async () => {
}
}
const getItemType = (itemList) => {
if (itemList.length === 0) return 'reward'
if (items.value[0].points !== undefined) {
return items.value[0].is_good ? 'good' : 'bad'
}
return 'reward'
}
onMounted(fetchItems)
watch(() => props.fetchUrl, fetchItems)
const itemType = computed(() => getItemType(items))
const handleEdit = (id: string) => {
emit('edit', id)
props.onEdit?.(id)
const handleClicked = (item: any) => {
emit('clicked', item)
props.onClicked?.(item)
}
const handleDelete = (id: string) => {
emit('delete', id)
props.onDelete?.(id)
const handleDelete = (item: any) => {
emit('delete', item)
props.onDelete?.(item)
}
</script>
@@ -92,44 +95,49 @@ const handleDelete = (id: string) => {
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="items.length === 0" class="empty">No items found.</div>
<div v-else>
<div v-for="(item, idx) in items" :key="item.id">
<div class="list-item" :class="['list-item', itemType]" @click="handleEdit(item.id)">
<img v-if="item.image_url" :src="item.image_url" alt="Item" class="list-image" />
<span class="list-name">{{ item.name }}</span>
<span v-if="item.points !== undefined" class="value">{{ item.points }} pts</span>
<span v-if="item.cost !== undefined" class="value">{{ item.cost }} pts</span>
<input
v-if="props.selectable"
type="checkbox"
class="list-checkbox"
v-model="selectedItems"
:value="item.id"
@click.stop
/>
<button
v-if="props.deletable"
class="delete-btn"
@click.stop="handleDelete(item.id)"
aria-label="Delete item"
type="button"
>
<!-- SVG icon here -->
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<div v-for="(item, idx) in items" :key="item.id" class="list-row">
<div :class="['list-item', props.getItemClass?.(item)]" @click.stop="handleClicked(item)">
<slot name="item" :item="item">
<!-- Default rendering if no slot is provided -->
<img v-if="item.image_url" :src="item.image_url" />
<span class="list-name">List Item</span>
<span class="list-value">1</span>
</slot>
<div v-if="props.selectable || props.deletable" class="interact">
<input
v-if="props.selectable"
type="checkbox"
class="list-checkbox"
v-model="selectedItems"
:value="item"
@click.stop
/>
<button
v-if="props.deletable"
class="delete-btn"
@click.stop="handleDelete(item)"
aria-label="Delete item"
type="button"
>
<!-- SVG icon here -->
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div v-if="idx < items.length - 1" class="list-separator"></div>
</div>
<div v-if="idx < items.length - 1" class="list-separator"></div>
</div>
</div>
</div>
</template>
<style scoped>
.listbox {
flex: 0 1 auto;
@@ -160,22 +168,17 @@ const handleDelete = (id: string) => {
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
justify-content: space-between;
cursor: pointer;
}
.list-item.bad {
border-color: var(--list-item-border-bad);
background: var(--list-item-bg-bad);
}
.list-item.reward {
border-color: var(--list-item-border-reward);
background: var(--list-item-bg-reward);
}
.list-item.good {
border-color: var(--list-item-border-good);
background: var(--list-item-bg-good);
.list-item .interact {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Image styles */
.list-image {
:deep(.list-item img) {
width: 36px;
height: 36px;
object-fit: cover;
@@ -202,7 +205,6 @@ const handleDelete = (id: string) => {
background: transparent;
border: none;
border-radius: 50%;
padding: 0.15rem;
margin-left: 0.7rem;
cursor: pointer;
display: flex;
@@ -250,10 +252,6 @@ const handleDelete = (id: string) => {
border-radius: 8px;
padding: 1rem;
}
.value {
font-weight: 600;
margin-left: 1rem;
}
/* Separator (if needed) */
.list-separator {
height: 0px;

View File

@@ -8,13 +8,20 @@
v-else
fetchUrl="/api/task/list"
itemKey="tasks"
:itemFields="['id', 'name', 'points', 'is_good', 'image_id']"
:itemFields="TASK_FIELDS"
imageField="image_id"
deletable
@edit="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
@clicked="(task: Task) => $router.push({ name: 'EditTask', params: { id: task.id } })"
@delete="confirmDeleteTask"
@loading-complete="(count) => (taskCountRef = count)"
/>
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
>
<template #item="{ item }">
<img v-if="item.image_url" :src="item.image_url" />
<span class="name">{{ item.name }}</span>
<span class="value">{{ item.points }} pts</span>
</template>
</ItemList>
<FloatingActionButton aria-label="Create Task" @click="createTask" />
@@ -35,8 +42,8 @@ import MessageBlock from '@/components/shared/MessageBlock.vue'
import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
import '@/assets/view-shared.css'
import type { Task } from '@/common/models'
import { TASK_FIELDS } from '@/common/models'
const $router = useRouter()
@@ -86,4 +93,24 @@ const createTask = () => {
padding: 0;
min-height: 0;
}
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
:deep(.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);
}
</style>