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

This commit is contained in:
2026-01-18 21:56:19 -05:00
parent 904185e5c8
commit 59b480621e
6 changed files with 428 additions and 26 deletions

View File

@@ -148,9 +148,6 @@ def set_child_tasks(id):
if not isinstance(task_ids, list): if not isinstance(task_ids, list):
return jsonify({'error': 'task_ids must be a list'}), 400 return jsonify({'error': 'task_ids must be a list'}), 400
# Deduplicate and drop falsy values
new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid]
ChildQuery = Query() ChildQuery = Query()
result = child_db.search(ChildQuery.id == id) result = child_db.search(ChildQuery.id == id)
if not result: if not result:

View File

@@ -38,7 +38,11 @@ def get_task(id):
@task_api.route('/task/list', methods=['GET']) @task_api.route('/task/list', methods=['GET'])
def list_tasks(): def list_tasks():
ids_param = request.args.get('ids')
tasks = task_db.all() tasks = task_db.all()
if ids_param:
ids = set(ids_param.split(','))
tasks = [task for task in tasks if task.get('id') in ids]
return jsonify({'tasks': tasks}), 200 return jsonify({'tasks': tasks}), 200
@task_api.route('/task/<id>', methods=['DELETE']) @task_api.route('/task/<id>', methods=['DELETE'])

View File

@@ -2,8 +2,8 @@
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue' import ChildDetailCard from './ChildDetailCard.vue'
import ChildTaskList from '../task/ChildTaskList.vue'
import ChildRewardList from '../reward/ChildRewardList.vue' import ChildRewardList from '../reward/ChildRewardList.vue'
import ScrollingList from '../shared/ScrollingList.vue'
import { eventBus } from '@/common/eventBus' import { eventBus } from '@/common/eventBus'
import '@/assets/view-shared.css' import '@/assets/view-shared.css'
import type { import type {
@@ -34,7 +34,7 @@ const showCancelDialog = ref(false)
const dialogReward = ref<Reward | null>(null) const dialogReward = ref<Reward | null>(null)
const childRewardListRef = ref() const childRewardListRef = ref()
const childChoreListRef = ref() const childChoreListRef = ref()
const childHabitListRef = ref() const childPenaltyListRef = ref()
function handleTaskTriggered(event: Event) { function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload const payload = event.payload as ChildTaskTriggeredEventPayload
@@ -99,6 +99,7 @@ function handleChildModified(event: Event) {
if (data) { if (data) {
child.value = data child.value = data
} }
loading.value = false
}) })
} catch (err) { } catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err) console.warn('Failed to fetch child after EDIT operation:', err)
@@ -137,6 +138,8 @@ function handleTaskModified(event: Event) {
}) })
} catch (err) { } catch (err) {
console.warn('Failed to fetch child after EDIT operation:', err) console.warn('Failed to fetch child after EDIT operation:', err)
} finally {
loading.value = false
} }
break break
@@ -168,6 +171,9 @@ const triggerTask = (task: Task) => {
} }
} }
const triggerReward = (reward: Reward) => {}
/*
const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) => { const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) => {
if ('speechSynthesis' in window && reward.name) { if ('speechSynthesis' in window && reward.name) {
const utterString = const utterString =
@@ -184,7 +190,7 @@ const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) =>
dialogReward.value = reward dialogReward.value = reward
showRewardDialog.value = true showRewardDialog.value = true
} }
} }*/
async function cancelPendingReward() { async function cancelPendingReward() {
if (!child.value?.id || !dialogReward.value) return if (!child.value?.id || !dialogReward.value) return
@@ -243,7 +249,6 @@ async function fetchChildData(id: string | number) {
console.error(err) console.error(err)
return null return null
} finally { } finally {
loading.value = false
} }
} }
@@ -253,7 +258,7 @@ function resetInactivityTimer() {
if (inactivityTimer) clearTimeout(inactivityTimer) if (inactivityTimer) clearTimeout(inactivityTimer)
inactivityTimer = setTimeout(() => { inactivityTimer = setTimeout(() => {
router.push({ name: 'ChildrenListView' }) router.push({ name: 'ChildrenListView' })
}, 60000) // 60 seconds }, 6000000) // 60 seconds
} }
function setupInactivityListeners() { function setupInactivityListeners() {
@@ -287,6 +292,7 @@ onMounted(async () => {
tasks.value = data.tasks || [] tasks.value = data.tasks || []
rewards.value = data.rewards || [] rewards.value = data.rewards || []
} }
loading.value = false
}) })
} }
} }
@@ -318,24 +324,89 @@ onUnmounted(() => {
<div v-else class="layout"> <div v-else class="layout">
<div class="main"> <div class="main">
<ChildDetailCard :child="child" /> <ChildDetailCard :child="child" />
<ChildTaskList <ScrollingList
title="Chores" title="Chores"
ref="childChoreListRef" ref="childChoreListRef"
:task-ids="tasks" :fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
:child-id="child ? child.id : null" :ids="tasks"
:is-parent-authenticated="false" itemKey="tasks"
:filter-type="1" imageField="image_id"
@trigger-task="triggerTask" @trigger-item="triggerTask"
/> :getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
<ChildTaskList :filter-fn="
title="Bad Habits" (item) => {
ref="childHabitListRef" return item.is_good
:task-ids="tasks" }
:child-id="child ? child.id : null" "
:is-parent-authenticated="false" >
:filter-type="2" <template #item="{ item }">
@trigger-task="triggerTask" <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"
:fetchBaseUrl="`/api/reward/list?ids=${rewards.join(',')}`"
:ids="rewards"
itemKey="rewards"
imageField="image_id"
@trigger-item="triggerReward"
: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="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>
</template>
</ScrollingList>
<ChildRewardList <ChildRewardList
ref="childRewardListRef" ref="childRewardListRef"
:child-id="child ? child.id : null" :child-id="child ? child.id : null"
@@ -415,4 +486,27 @@ onUnmounted(() => {
justify-content: center; justify-content: center;
margin: 2rem 0; margin: 2rem 0;
} }
.item-points {
color: var(--item-points-color, #ffd166);
font-size: 1rem;
font-weight: 900;
text-shadow: var(--item-points-shadow);
}
/* 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);
}
</style> </style>

View File

@@ -58,6 +58,7 @@ function goToCreateTask() {
async function onSubmit() { async function onSubmit() {
const selectedIds = taskListRef.value?.selectedItems ?? [] const selectedIds = taskListRef.value?.selectedItems ?? []
try { try {
console.log('selectedIds:', selectedIds)
const resp = await fetch(`/api/child/${childId}/set-tasks`, { const resp = await fetch(`/api/child/${childId}/set-tasks`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -40,6 +40,7 @@ const fetchItems = async () => {
console.log('Fetched data:', data) console.log('Fetched data:', data)
let itemList = data[props.itemKey || 'items'] || [] let itemList = data[props.itemKey || 'items'] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn) if (props.filterFn) itemList = itemList.filter(props.filterFn)
const initiallySelected: string[] = []
await Promise.all( await Promise.all(
itemList.map(async (item: any) => { itemList.map(async (item: any) => {
if (props.imageFields) { if (props.imageFields) {
@@ -48,6 +49,7 @@ const fetchItems = async () => {
try { try {
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field]) item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
} catch { } catch {
console.error('Error fetching image for item', item.id)
item[`${field.replace('_id', '_url')}`] = null item[`${field.replace('_id', '_url')}`] = null
} }
} }
@@ -61,11 +63,14 @@ const fetchItems = async () => {
} }
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox //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) { if (props.selectable && item.assigned === true) {
selectedItems.value.push(item.id) initiallySelected.push(item.id)
} }
}), }),
) )
items.value = itemList items.value = itemList
if (props.selectable) {
selectedItems.value = initiallySelected
}
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch items' error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = [] items.value = []
@@ -109,7 +114,7 @@ const handleDelete = (item: any) => {
type="checkbox" type="checkbox"
class="list-checkbox" class="list-checkbox"
v-model="selectedItems" v-model="selectedItems"
:value="item" :value="item.id"
@click.stop @click.stop
/> />
<button <button

View File

@@ -0,0 +1,301 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
const props = defineProps<{
title: string
fetchBaseUrl: string
ids: readonly string[]
itemKey: string
imageFields?: readonly string[]
isParentAuthenticated?: boolean
filterFn?: (item: any) => boolean
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
}>()
// Compute the fetch URL with ids if present
const fetchUrl = computed(() => {
if (props.ids && props.ids.length > 0) {
const separator = props.fetchBaseUrl.includes('?') ? '&' : '?'
return `${props.fetchBaseUrl}${separator}ids=${props.ids.join(',')}`
}
return props.fetchBaseUrl
})
const emit = defineEmits<{
(e: 'trigger-item', item: any): void
}>()
const items = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const scrollWrapper = ref<HTMLDivElement | null>(null)
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
const lastCenteredItemId = ref<string | null>(null)
const readyItemId = ref<string | null>(null)
const fetchItems = async () => {
loading.value = true
error.value = null
try {
const resp = await fetch(fetchUrl.value)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
// Try to use 'tasks', 'reward_status', or 'items' as fallback
let itemList = data[props.itemKey || 'items'] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn)
items.value = itemList
// Fetch images for each item
await Promise.all(
itemList.map(async (item: any) => {
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.image_id)
} catch {
console.error('Error fetching image for item', item.id)
item.image_url = null
}
}
}),
)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = []
console.error('Error fetching items:', err)
} finally {
loading.value = false
}
}
const centerItem = async (itemId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = itemRefs.value[itemId]
if (wrapper && card) {
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const wrapperScrollLeft = wrapper.scrollLeft
const cardCenter = cardRect.left + cardRect.width / 2
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
const scrollOffset = cardCenter - wrapperCenter
wrapper.scrollTo({
left: wrapperScrollLeft + scrollOffset,
behavior: 'smooth',
})
}
}
const handleClicked = async (item: any) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = itemRefs.value[item.id]
if (!wrapper || !card) return
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const cardCenter = cardRect.left + cardRect.width / 2
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
if (!cardFullyVisible || lastCenteredItemId.value !== item.id) {
// Center the item, but don't trigger
await centerItem(item.id)
lastCenteredItemId.value = item.id
readyItemId.value = item.id
return
}
emit(
'trigger-item',
items.value.find((i) => i.id === item.id),
)
readyItemId.value = null
}
watch(
() => [props.ids],
() => {
fetchItems()
},
{ immediate: true },
)
onBeforeUnmount(() => {
revokeAllImageUrls()
})
</script>
<template>
<div class="child-list-container">
<h3>{{ title }}</h3>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="items.length === 0" class="empty">No {{ title }}</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="item-scroll">
<div
v-for="item in items"
:key="item.id"
:class="['item-card', props.getItemClass?.(item)]"
:ref="(el) => (itemRefs[item.id] = el)"
@click.stop="handleClicked(item)"
>
<slot name="item" :item="item">
<div class="item-name">{{ item.name }}</div>
</slot>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.child-list-container {
background: var(--child-list-bg, rgba(255, 255, 255, 0.1));
border-radius: 12px;
padding: 1rem;
color: var(--child-list-title-color, #fff);
width: 100%;
box-sizing: border-box;
}
.scroll-wrapper {
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
width: 100%;
-webkit-overflow-scrolling: touch;
}
.scroll-wrapper::-webkit-scrollbar {
height: 8px;
}
.scroll-wrapper::-webkit-scrollbar-track {
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
border-radius: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
background: var(
--child-list-scrollbar-thumb,
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
);
border-radius: 10px;
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
}
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
background: var(
--child-list-scrollbar-thumb-hover,
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
);
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
}
.item-scroll {
display: flex;
gap: 0.75rem;
min-width: min-content;
padding: 0.5rem 0;
}
/* Fallback for browsers that don't support flex gap */
.item-card + .item-card {
margin-left: 0.75rem;
}
.item-card {
position: relative;
background: var(--item-card-bg, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 0.75rem;
min-width: 140px;
max-width: 220px;
width: 100%;
text-align: center;
flex-shrink: 0;
transition: transform 0.18s ease;
border: var(--item-card-border, 1px solid rgba(255, 255, 255, 0.08));
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.item-card:hover {
transform: translateY(-4px);
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
}
@keyframes ready-glow {
0% {
box-shadow: 0 0 0 0 #667eea00;
border-color: inherit;
}
100% {
box-shadow: var(--item-card-ready-shadow, 0 0 0 3px #667eea88, 0 0 12px #667eea44);
border-color: var(--item-card-ready-border, #667eea);
}
}
:deep(.item-name) {
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 0.4rem;
color: var(--item-name-color, #fff);
line-height: 1.2;
word-break: break-word;
}
/* Image styling */
:deep(.item-image) {
width: 70px;
height: 70px;
object-fit: cover;
border-radius: 6px;
margin: 0 auto 0.4rem auto;
display: block;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.item-card {
min-width: 110px;
max-width: 150px;
padding: 0.6rem;
}
:deep(.item-name) {
font-size: 0.86rem;
}
:deep(.item-image) {
width: 50px;
height: 50px;
margin: 0 auto 0.3rem auto;
}
.scroll-wrapper::-webkit-scrollbar {
height: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
border-width: 1px;
}
}
.loading,
.error,
.empty {
text-align: center;
padding: 2rem 0;
color: #888;
}
</style>