feat: Implement routines feature with CRUD operations and child assignment
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s

- Add backend routines management with add, get, update, delete, and list functionalities.
- Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension.
- Develop event types for routine confirmation and modification.
- Implement frontend components for routine assignment, confirmation dialog, and routine management views.
- Add unit tests for routine API and integration tests for routine CRUD flow.
- Create end-to-end test plan for routines feature covering parent and child interactions.
This commit is contained in:
2026-05-05 09:08:19 -04:00
parent 082097b4f9
commit eb775ba7d8
41 changed files with 2847 additions and 29 deletions

View File

@@ -6,6 +6,7 @@ import ScrollingList from '../shared/ScrollingList.vue'
import StatusMessage from '../shared/StatusMessage.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
import ChoreConfirmDialog from './ChoreConfirmDialog.vue'
import RoutineConfirmDialog from './RoutineConfirmDialog.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { eventBus } from '@/common/eventBus'
//import '@/assets/view-shared.css'
@@ -16,17 +17,22 @@ import type {
Task,
RewardStatus,
ChildTask,
ChildRoutine,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
ChildTasksSetEventPayload,
ChildRewardsSetEventPayload,
ChildRoutinesSetEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
RoutineModifiedEventPayload,
ChildModifiedEventPayload,
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
RoutineScheduleModifiedPayload,
ChildChoreConfirmationPayload,
ChildRoutineConfirmationPayload,
ChildOverrideSetPayload,
ChildOverrideDeletedPayload,
} from '@/common/models'
@@ -46,6 +52,7 @@ const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const routines = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
@@ -56,6 +63,9 @@ const dialogReward = ref<RewardStatus | null>(null)
const showChoreConfirmDialog = ref(false)
const showChoreCancelDialog = ref(false)
const dialogChore = ref<ChildTask | null>(null)
const showRoutineConfirmDialog = ref(false)
const dialogRoutine = ref<ChildRoutine | null>(null)
const childRoutineListRef = ref()
function handleTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
@@ -87,6 +97,13 @@ function handleChildRewardSet(event: Event) {
}
}
function handleChildRoutineSet(event: Event) {
const payload = event.payload as ChildRoutinesSetEventPayload
if (child.value && payload.child_id == child.value.id) {
routines.value = payload.routine_ids
}
}
function handleRewardRequest(event: Event) {
const payload = event.payload as ChildRewardRequestEventPayload
const childId = payload.child_id
@@ -222,6 +239,14 @@ const triggerTask = async (task: ChildTask) => {
// Kindness / Penalty: speech-only in child mode; point changes are handled in parent mode.
}
const handleRoutineClick = (routine: ChildRoutine) => {
// Show routine confirmation dialog
dialogRoutine.value = routine
setTimeout(() => {
showRoutineConfirmDialog.value = true
}, 150)
}
function isChoreCompletedToday(item: ChildTask): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = new Date(item.approved_at)
@@ -273,6 +298,30 @@ function closeChoreCancelDialog() {
dialogChore.value = null
}
async function doConfirmRoutine() {
if (!child.value?.id || !dialogRoutine.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/confirm-routine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_id: dialogRoutine.value.id }),
})
if (!resp.ok) {
console.error('Failed to confirm routine')
}
} catch (err) {
console.error('Failed to confirm routine:', err)
} finally {
showRoutineConfirmDialog.value = false
dialogRoutine.value = null
}
}
function closeRoutineConfirmDialog() {
showRoutineConfirmDialog.value = false
dialogRoutine.value = null
}
const triggerReward = (reward: RewardStatus) => {
// Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
@@ -545,6 +594,7 @@ onMounted(async () => {
eventBus.on('child_reward_triggered', handleRewardTriggered)
eventBus.on('child_tasks_set', handleChildTaskSet)
eventBus.on('child_rewards_set', handleChildRewardSet)
eventBus.on('child_routines_set', handleChildRoutineSet)
eventBus.on('task_modified', handleTaskModified)
eventBus.on('reward_modified', handleRewardModified)
eventBus.on('child_modified', handleChildModified)
@@ -563,6 +613,7 @@ onMounted(async () => {
if (data) {
child.value = data
tasks.value = data.tasks || []
routines.value = data.routines || []
rewards.value = data.rewards || []
}
loading.value = false
@@ -582,6 +633,7 @@ onUnmounted(() => {
eventBus.off('child_reward_triggered', handleRewardTriggered)
eventBus.off('child_tasks_set', handleChildTaskSet)
eventBus.off('child_rewards_set', handleChildRewardSet)
eventBus.off('child_routines_set', handleChildRoutineSet)
eventBus.off('task_modified', handleTaskModified)
eventBus.off('reward_modified', handleRewardModified)
eventBus.off('child_modified', handleChildModified)
@@ -731,6 +783,49 @@ onUnmounted(() => {
</div>
</template>
</ScrollingList>
<ScrollingList
title="Routines"
ref="childRoutineListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
:ids="routines"
itemKey="routines"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="handleRoutineClick"
:getItemClass="
(item: ChildRoutine) => ({
good: true,
'routine-pending': item.pending_status === 'pending',
'routine-approved': item.pending_status === 'approved',
})
"
>
<template #item="{ item }: { item: ChildRoutine }">
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
>PENDING</span
>
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
>APPROVED</span
>
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Routine Image"
class="item-image"
/>
<div class="item-points good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
</template>
</ScrollingList>
</div>
</div>
</div>
@@ -785,6 +880,16 @@ onUnmounted(() => {
<button @click="closeChoreCancelDialog" class="btn btn-secondary">No</button>
</div>
</ModalDialog>
<!-- Routine confirm dialog -->
<RoutineConfirmDialog
:show="showRoutineConfirmDialog"
:routineName="dialogRoutine?.name ?? ''"
:imageUrl="dialogRoutine?.image_url"
:items="dialogRoutine?.items"
@confirm="doConfirmRoutine"
@cancel="closeRoutineConfirmDialog"
/>
</template>
<style scoped>