Add routine management features for child and parent views
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m8s

- Implemented routine child-mode flow tests to ensure proper functionality of routine assignment and task completion.
- Created notification tests for parent view to verify routine completion notifications for children.
- Developed ChildRoutineOverlay component for displaying routine tasks and handling user interactions.
- Added RoutineApproveDialog component for approving or rejecting completed routines.
- Created unit tests for ChildRoutineOverlay and RoutineEditView components to ensure correct behavior and rendering.
- Enhanced RoutineEditView with proper handling of task addition and form submission.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-17 23:47:12 -04:00
parent eb775ba7d8
commit 5392e5af70
31 changed files with 3859 additions and 265 deletions

View File

@@ -0,0 +1,362 @@
<template>
<div v-if="show && routine" class="overlay-backdrop" @click.self="$emit('close')">
<section class="overlay-panel" aria-modal="true" role="dialog">
<header class="overlay-header">
<img
v-if="routine.image_url"
:src="routine.image_url"
:alt="routine.name"
class="routine-image"
/>
<p class="routine-question">
{{ isPending ? 'This routine is pending' : 'Did you complete' }}
</p>
<h3 class="routine-name">{{ routine.name }}{{ isPending ? '' : '?' }}</h3>
</header>
<!-- Pending-cancel view -->
<template v-if="isPending">
<div class="overlay-body pending-body">
<p class="pending-message">
This routine is pending confirmation.<br />Would you like to cancel?
</p>
</div>
<footer class="overlay-actions">
<button type="button" class="btn btn-secondary" @click="$emit('close')">No</button>
<button type="button" class="btn btn-primary" @click="$emit('cancel-pending')">
Yes
</button>
</footer>
</template>
<!-- Normal complete view -->
<template v-else>
<div class="overlay-body">
<div v-if="loading" class="empty-state">Loading tasks...</div>
<div v-else-if="routine.items.length === 0" class="empty-state">
No routine tasks found.
</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="task-scroll">
<button
v-for="item in resolvedItems"
:key="item.id"
type="button"
class="task-card"
:title="item.name"
@click="$emit('task-click', item)"
>
<img
v-if="item.image_url"
:src="item.image_url"
:alt="item.name"
class="task-image"
/>
<span class="task-name">{{ item.name }}</span>
</button>
</div>
</div>
</div>
<footer class="overlay-actions">
<button type="button" class="btn btn-secondary" @click="$emit('close')">No</button>
<button
v-if="canComplete"
type="button"
class="btn btn-primary"
@click="$emit('complete')"
>
Yes!
</button>
</footer>
</template>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { ChildRoutine, RoutineItem } from '@/common/models'
import { getCachedImageUrl } from '@/common/imageCache'
const props = defineProps<{
show: boolean
routine: ChildRoutine | null
canComplete: boolean
isPending?: boolean
}>()
defineEmits<{
close: []
complete: []
'cancel-pending': []
'task-click': [item: RoutineItem]
}>()
const loading = ref(false)
const resolvedItems = ref<(RoutineItem & { image_url?: string })[]>([])
// Resolve images when routine changes
watch(
() => props.routine,
async (newRoutine) => {
if (!newRoutine?.items.length) {
resolvedItems.value = []
return
}
loading.value = true
const itemsWithImages = await Promise.all(
newRoutine.items.map(async (item) => {
let image_url: string | undefined
if (item.image_id) {
try {
image_url = await getCachedImageUrl(item.image_id)
} catch {
image_url = undefined
}
}
return { ...item, image_url }
}),
)
resolvedItems.value = itemsWithImages
loading.value = false
},
{ immediate: true },
)
</script>
<style scoped>
.overlay-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1100;
}
.overlay-panel {
width: min(92vw, 600px);
max-height: min(85vh, 720px);
display: flex;
flex-direction: column;
background: var(--modal-bg, #fff);
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
overflow: hidden;
}
.overlay-header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.5rem;
padding: 1.5rem 1.25rem 1rem;
border-bottom: 1px solid var(--form-input-border, rgba(0, 0, 0, 0.08));
}
.routine-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 14px;
background: var(--info-image-bg);
margin-bottom: 0.25rem;
}
.routine-question {
margin: 0;
color: var(--modal-message-color, #555);
font-size: 1rem;
font-weight: 500;
}
.routine-name {
margin: 0.15rem 0 0;
color: var(--btn-primary, #667eea);
font-size: 1.2rem;
font-weight: 700;
}
.overlay-body {
flex: 1 1 auto;
overflow: hidden;
padding: 1rem 1.25rem;
}
.pending-body {
overflow: visible;
}
.pending-message {
text-align: center;
color: var(--modal-message-color, #333);
font-size: 1rem;
line-height: 1.6;
padding: 0.5rem 0;
margin: 0;
}
.tasks-heading {
display: none;
}
.empty-state {
color: var(--loading-color, #888);
text-align: center;
padding: 2rem 1rem;
}
.scroll-wrapper {
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
width: 100%;
-webkit-overflow-scrolling: touch;
}
.scroll-wrapper::-webkit-scrollbar {
height: 6px;
}
.scroll-wrapper::-webkit-scrollbar-track {
background: transparent;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
.task-scroll {
display: flex;
gap: 0.75rem;
min-width: min-content;
padding: 0.5rem 0;
}
.task-card {
position: relative;
background: var(--list-item-bg-good, rgba(200, 230, 201, 0.3));
border-radius: 8px;
padding: 0.75rem;
min-width: 120px;
max-width: 160px;
width: 100%;
text-align: center;
flex-shrink: 0;
cursor: pointer;
user-select: none;
border: 1px solid var(--list-item-border-good, rgba(100, 180, 100, 0.3));
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
.task-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.12);
}
}
.task-card:active {
transform: scale(0.97);
}
.task-image {
width: 70px;
height: 70px;
object-fit: cover;
border-radius: 6px;
display: block;
}
.task-name {
display: block;
color: var(--form-label, #222);
font-size: 0.9rem;
font-weight: 700;
line-height: 1.2;
word-break: break-word;
}
.overlay-actions {
display: flex;
justify-content: center;
gap: 1rem;
padding: 1rem 1.25rem 1.25rem;
border-top: 1px solid var(--form-input-border, rgba(0, 0, 0, 0.08));
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: opacity 0.2s;
}
.btn-secondary {
background: var(--btn-secondary, #f0f0f0);
color: var(--btn-secondary-text, #333);
}
.btn-secondary:hover {
opacity: 0.8;
}
.btn-primary {
background: var(--btn-primary, #667eea);
color: #fff;
}
.btn-primary:hover {
opacity: 0.9;
}
@media (max-width: 480px) {
.overlay-panel {
width: min(94vw, 600px);
max-height: 88vh;
}
.overlay-header {
align-items: flex-start;
}
.overlay-actions {
gap: 0.75rem;
}
.task-card {
min-width: 100px;
max-width: 140px;
padding: 0.6rem;
}
.task-image {
width: 50px;
height: 50px;
}
.task-name {
font-size: 0.8rem;
}
}
</style>

View File

@@ -6,7 +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 ChildRoutineOverlay from './ChildRoutineOverlay.vue'
import ModalDialog from '../shared/ModalDialog.vue'
import { eventBus } from '@/common/eventBus'
//import '@/assets/view-shared.css'
@@ -14,10 +14,11 @@ import '@/assets/styles.css'
import type {
Child,
Event,
Task,
ChoreSchedule,
RewardStatus,
ChildTask,
ChildRoutine,
RoutineItem,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
@@ -26,17 +27,19 @@ import type {
ChildRoutinesSetEventPayload,
TaskModifiedEventPayload,
RewardModifiedEventPayload,
RoutineModifiedEventPayload,
ChildModifiedEventPayload,
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
RoutineScheduleModifiedPayload,
ChildChoreConfirmationPayload,
ChildRoutineConfirmationPayload,
ChildOverrideSetPayload,
ChildOverrideDeletedPayload,
} from '@/common/models'
import { confirmChore, cancelConfirmChore } from '@/common/api'
import {
confirmChore,
cancelConfirmChore,
confirmRoutine,
cancelRoutineConfirmation,
} from '@/common/api'
import {
isScheduledToday,
isPastTime,
@@ -63,7 +66,7 @@ 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 showRoutineOverlay = ref(false)
const dialogRoutine = ref<ChildRoutine | null>(null)
const childRoutineListRef = ref()
@@ -240,13 +243,27 @@ const triggerTask = async (task: ChildTask) => {
}
const handleRoutineClick = (routine: ChildRoutine) => {
// Show routine confirmation dialog
dialogRoutine.value = routine
setTimeout(() => {
showRoutineConfirmDialog.value = true
showRoutineOverlay.value = true
}, 150)
}
function speakText(text: string) {
if (!('speechSynthesis' in window)) return
window.speechSynthesis.cancel()
if (!text) return
const utter = new window.SpeechSynthesisUtterance(text)
utter.rate = 1.0
utter.pitch = 1.0
utter.volume = 1.0
window.speechSynthesis.speak(utter)
}
function handleRoutineTaskClick(item: RoutineItem) {
speakText(item.name)
}
function isChoreCompletedToday(item: ChildTask): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = new Date(item.approved_at)
@@ -301,27 +318,45 @@ function closeChoreCancelDialog() {
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 }),
})
const resp = await confirmRoutine(child.value.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
showRoutineOverlay.value = false
dialogRoutine.value = null
}
}
function closeRoutineConfirmDialog() {
showRoutineConfirmDialog.value = false
function handleChildRoutineConfirmation(event: Event) {
const payload = event.payload as { child_id: string }
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
function closeRoutineOverlay() {
showRoutineOverlay.value = false
dialogRoutine.value = null
}
async function doCancelConfirmRoutine() {
if (!child.value?.id || !dialogRoutine.value) return
try {
const resp = await cancelRoutineConfirmation(child.value.id, dialogRoutine.value.id)
if (!resp.ok) {
console.error('Failed to cancel routine confirmation')
}
} catch (err) {
console.error('Failed to cancel routine confirmation:', err)
} finally {
showRoutineOverlay.value = false
dialogRoutine.value = null
}
}
const triggerReward = (reward: RewardStatus) => {
// Cancel any pending speech to avoid conflicts
if ('speechSynthesis' in window) {
@@ -510,6 +545,22 @@ function isChoreExpired(item: ChildTask): boolean {
return isPastTime(due.hour, due.minute, today)
}
function isRoutineExpired(item: ChildRoutine): boolean {
if (item.pending_status === 'pending') return false
if (!item.schedule) return false
const today = new Date()
const due = getDueTimeToday(item.schedule as unknown as ChoreSchedule, today)
if (!due) return false
if (item.extension_date && isExtendedToday(item.extension_date, today)) return false
return isPastTime(due.hour, due.minute, today)
}
const canCompleteRoutine = computed(() => {
const routine = dialogRoutine.value
if (!routine) return false
return routine.pending_status == null && !isRoutineExpired(routine)
})
function choreDueLabel(item: ChildTask): string | null {
if (!item.schedule) return null
const today = new Date()
@@ -602,6 +653,7 @@ onMounted(async () => {
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
eventBus.on('chore_time_extended', handleChoreTimeExtended)
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
eventBus.on('child_routine_confirmation', handleChildRoutineConfirmation)
eventBus.on('child_override_set', handleOverrideSet)
eventBus.on('child_override_deleted', handleOverrideDeleted)
document.addEventListener('visibilitychange', onVisibilityChange)
@@ -641,6 +693,7 @@ onUnmounted(() => {
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
eventBus.off('chore_time_extended', handleChoreTimeExtended)
eventBus.off('child_chore_confirmation', handleChoreConfirmation)
eventBus.off('child_routine_confirmation', handleChildRoutineConfirmation)
eventBus.off('child_override_set', handleOverrideSet)
eventBus.off('child_override_deleted', handleOverrideDeleted)
document.removeEventListener('visibilitychange', onVisibilityChange)
@@ -882,13 +935,15 @@ onUnmounted(() => {
</ModalDialog>
<!-- Routine confirm dialog -->
<RoutineConfirmDialog
:show="showRoutineConfirmDialog"
:routineName="dialogRoutine?.name ?? ''"
:imageUrl="dialogRoutine?.image_url"
:items="dialogRoutine?.items"
@confirm="doConfirmRoutine"
@cancel="closeRoutineConfirmDialog"
<ChildRoutineOverlay
:show="showRoutineOverlay"
:routine="dialogRoutine"
:canComplete="canCompleteRoutine"
:isPending="dialogRoutine?.pending_status === 'pending'"
@task-click="handleRoutineTaskClick"
@complete="doConfirmRoutine"
@cancel-pending="doCancelConfirmRoutine"
@close="closeRoutineOverlay"
/>
</template>

View File

@@ -6,6 +6,8 @@ import PendingRewardDialog from './PendingRewardDialog.vue'
import TaskConfirmDialog from './TaskConfirmDialog.vue'
import RewardConfirmDialog from './RewardConfirmDialog.vue'
import ChoreApproveDialog from './ChoreApproveDialog.vue'
import RoutineConfirmDialog from './RoutineConfirmDialog.vue'
import RoutineApproveDialog from './RoutineApproveDialog.vue'
import { useRoute, useRouter } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue'
import ScrollingList from '../shared/ScrollingList.vue'
@@ -17,6 +19,12 @@ import {
approveChore,
rejectChore,
resetChore,
extendRoutineTime,
setChildRoutineOverride,
approveRoutine,
rejectRoutine,
resetRoutine,
triggerRoutineAsParent,
} from '@/common/api'
import { eventBus } from '@/common/eventBus'
import '@/assets/styles.css'
@@ -27,6 +35,7 @@ import type {
Reward,
RewardStatus,
ChildTask,
ChildRoutine,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
ChildRewardRequestEventPayload,
@@ -40,6 +49,8 @@ import type {
ChoreScheduleModifiedPayload,
ChoreTimeExtendedPayload,
ChildChoreConfirmationPayload,
RoutineScheduleModifiedPayload,
RoutineTimeExtendedPayload,
} from '@/common/models'
import {
isScheduledToday,
@@ -74,6 +85,12 @@ const lastEditedItem = ref<{ id: string; type: 'task' | 'reward' } | null>(null)
const showChoreApproveDialog = ref(false)
const approveDialogChore = ref<ChildTask | null>(null)
// Routine approve/reject
const showRoutineApproveDialog = ref(false)
const approveDialogRoutine = ref<ChildRoutine | null>(null)
const showRoutineConfirmDialog = ref(false)
const confirmDialogRoutine = ref<ChildRoutine | null>(null)
// Override editing
const showOverrideModal = ref(false)
const overrideEditTarget = ref<{ entity: Task | Reward; type: 'task' | 'reward' } | null>(null)
@@ -95,6 +112,14 @@ const kebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
const showScheduleModal = ref(false)
const scheduleTarget = ref<ChildTask | null>(null)
// Routines
const childRoutineListRef = ref()
const selectedRoutineId = ref<string | null>(null)
const activeRoutineMenuFor = ref<string | null>(null)
const routineKebabBtnRefs = ref<Map<string, HTMLElement>>(new Map())
const showRoutineScheduleModal = ref(false)
const routineScheduleTarget = ref<ChildRoutine | null>(null)
const pendingDialogReward = computed<Reward | RewardStatus | null>(() => {
if (pendingEditOverrideTarget.value?.type === 'reward') {
return pendingEditOverrideTarget.value.entity as Reward
@@ -112,6 +137,7 @@ const lastFetchDate = ref<string>(toLocalISODate(new Date()))
function handleItemReady(itemId: string) {
readyItemId.value = itemId
selectedChoreId.value = null
selectedRoutineId.value = null
}
function handleChoreItemReady(itemId: string) {
@@ -263,6 +289,8 @@ function handleOverrideSet(event: Event) {
scrollAfterRefresh(childPenaltyListRef)
} else if (payload.override.entity_type === 'reward') {
scrollAfterRefresh(childRewardListRef)
} else if (payload.override.entity_type === 'routine') {
scrollAfterRefresh(childRoutineListRef)
}
lastEditedItem.value = null
}
@@ -305,6 +333,147 @@ function handleChoreConfirmation(event: Event) {
}
}
// ── Routine SSE handlers ──────────────────────────────────────────────────────
function handleRoutineScheduleModified(event: Event) {
const payload = event.payload as RoutineScheduleModifiedPayload
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
function handleRoutineTimeExtended(event: Event) {
const payload = event.payload as RoutineTimeExtendedPayload
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
function handleRoutineModified() {
childRoutineListRef.value?.refresh()
}
function handleChildRoutinesSet(event: Event) {
const payload = event.payload as { child_id: string }
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
function handleChildRoutineConfirmation(event: Event) {
const payload = event.payload as { child_id: string }
if (child.value && payload.child_id === child.value.id) {
childRoutineListRef.value?.refresh()
}
}
// ── Routine helpers ───────────────────────────────────────────────────────────
function isRoutineScheduledToday(item: ChildRoutine): boolean {
if (!item.schedule) return true
return isScheduledToday(item.schedule as any, new Date())
}
function isRoutineExpired(item: ChildRoutine): boolean {
if (item.pending_status === 'pending') return false
if (!item.schedule) return false
const now = new Date()
if (!isScheduledToday(item.schedule as any, now)) return false
const due = getDueTimeToday(item.schedule as any, now)
if (!due) return false
if (isExtendedToday(item.extension_date ?? null, now)) return false
return isPastTime(due.hour, due.minute, now)
}
function isRoutinePending(item: ChildRoutine): boolean {
return item.pending_status === 'pending'
}
function isRoutineApprovedToday(item: ChildRoutine): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = new Date(item.approved_at)
const today = new Date()
return (
approvedDate.getFullYear() === today.getFullYear() &&
approvedDate.getMonth() === today.getMonth() &&
approvedDate.getDate() === today.getDate()
)
}
function isRoutineInactive(item: ChildRoutine): boolean {
return !isRoutineScheduledToday(item) || isRoutineExpired(item)
}
function routineDueLabel(item: ChildRoutine): string | null {
if (!item.schedule) return null
const now = new Date()
if (!isScheduledToday(item.schedule as any, now)) return null
const due = getDueTimeToday(item.schedule as any, now)
if (!due) return null
if (isExtendedToday(item.extension_date ?? null, now)) return null
if (isPastTime(due.hour, due.minute, now)) return null
return `Due by ${formatDueTimeLabel(due.hour, due.minute)}`
}
// ── Routine kebab menu ────────────────────────────────────────────────────────
function handleRoutineItemReady(itemId: string) {
readyItemId.value = itemId
selectedRoutineId.value = itemId || null
}
function openRoutineMenu(routineId: string, e: MouseEvent) {
e.stopPropagation()
const btn = routineKebabBtnRefs.value.get(routineId)
if (btn) {
const rect = btn.getBoundingClientRect()
menuPosition.value = { top: rect.bottom, left: rect.right - 140 }
}
activeRoutineMenuFor.value = routineId
}
function closeRoutineMenu() {
activeRoutineMenuFor.value = null
}
function openRoutineScheduleModal(item: ChildRoutine, e: MouseEvent) {
e.stopPropagation()
closeRoutineMenu()
routineScheduleTarget.value = item
showRoutineScheduleModal.value = true
}
function onRoutineScheduleSaved() {
showRoutineScheduleModal.value = false
routineScheduleTarget.value = null
}
function editRoutine(item: ChildRoutine) {
closeRoutineMenu()
router.push({ name: 'EditRoutine', params: { id: item.id } })
}
function editRoutinePoints(item: ChildRoutine) {
closeRoutineMenu()
overrideEditTarget.value = { entity: item as any, type: 'routine' as any }
const defaultValue = item.custom_value ?? item.points
overrideCustomValue.value = defaultValue
validateOverrideInput()
showOverrideModal.value = true
}
async function doExtendRoutineTime(item: ChildRoutine, e: MouseEvent) {
e.stopPropagation()
closeRoutineMenu()
if (!child.value) return
const today = toLocalISODate(new Date())
const res = await extendRoutineTime(child.value.id, item.id, today)
if (!res.ok) {
const { msg } = await parseErrorResponse(res)
alert(`Error: ${msg}`)
}
}
function isChoreCompletedToday(item: ChildTask): boolean {
if (item.pending_status !== 'approved' || !item.approved_at) return false
const approvedDate = new Date(item.approved_at)
@@ -363,6 +532,111 @@ function cancelChoreApproveDialog() {
approveDialogChore.value = null
}
// ── Routine approve/reject ────────────────────────────────────────────────────
async function doApproveRoutine() {
if (!child.value || !approveDialogRoutine.value) return
try {
const confirmationId = approveDialogRoutine.value.pending_confirmation_id
if (!confirmationId) return
const resp = await approveRoutine(child.value.id, confirmationId)
if (resp.ok) {
const data = await resp.json()
if (child.value) child.value.points = data.points
} else {
const { msg } = await parseErrorResponse(resp)
alert(`Error: ${msg}`)
}
} catch (err) {
console.error('Failed to approve routine:', err)
} finally {
showRoutineApproveDialog.value = false
approveDialogRoutine.value = null
}
}
async function doRejectRoutine() {
if (!child.value || !approveDialogRoutine.value) return
const confirmationId = approveDialogRoutine.value.pending_confirmation_id
if (!confirmationId) return
try {
const resp = await rejectRoutine(child.value.id, confirmationId)
if (!resp.ok) {
const { msg } = await parseErrorResponse(resp)
alert(`Error: ${msg}`)
}
} catch (err) {
console.error('Failed to reject routine:', err)
} finally {
showRoutineApproveDialog.value = false
approveDialogRoutine.value = null
}
}
function cancelRoutineApproveDialog() {
showRoutineApproveDialog.value = false
approveDialogRoutine.value = null
}
async function doConfirmRoutine() {
if (!child.value || !confirmDialogRoutine.value) return
try {
const resp = await triggerRoutineAsParent(child.value.id, confirmDialogRoutine.value.id)
if (resp.ok) {
const data = await resp.json()
if (child.value) child.value.points = data.points
} else {
const { msg } = await parseErrorResponse(resp)
alert(`Error: ${msg}`)
}
} catch (err) {
console.error('Failed to confirm routine:', err)
} finally {
showRoutineConfirmDialog.value = false
confirmDialogRoutine.value = null
}
}
function cancelRoutineConfirmDialog() {
showRoutineConfirmDialog.value = false
confirmDialogRoutine.value = null
}
async function doResetRoutine(item: ChildRoutine, e: MouseEvent) {
e.stopPropagation()
closeRoutineMenu()
if (!child.value || !item.pending_confirmation_id) return
try {
const resp = await resetRoutine(child.value.id, item.pending_confirmation_id)
if (!resp.ok) {
const { msg } = await parseErrorResponse(resp)
alert(`Error: ${msg}`)
}
} catch (err) {
console.error('Failed to reset routine:', err)
}
}
function triggerRoutine(item: ChildRoutine) {
if (shouldIgnoreNextCardClick.value) {
shouldIgnoreNextCardClick.value = false
return
}
if (isRoutineApprovedToday(item)) return
if (isRoutineExpired(item)) return
if (isRoutinePending(item)) {
approveDialogRoutine.value = item
setTimeout(() => {
showRoutineApproveDialog.value = true
}, 150)
return
}
confirmDialogRoutine.value = item
setTimeout(() => {
showRoutineConfirmDialog.value = true
}, 150)
}
async function doResetChore(item: ChildTask, e: MouseEvent) {
e.stopPropagation()
closeChoreMenu()
@@ -381,7 +655,7 @@ async function doResetChore(item: ChildTask, e: MouseEvent) {
// ── Kebab menu ───────────────────────────────────────────────────────────────
const onDocClick = (e: MouseEvent) => {
if (activeMenuFor.value !== null) {
if (activeMenuFor.value !== null || activeRoutineMenuFor.value !== null) {
const path = (e.composedPath?.() ?? (e as any).path ?? []) as EventTarget[]
const inside = path.some((node) => {
if (!(node instanceof HTMLElement)) return false
@@ -393,11 +667,12 @@ const onDocClick = (e: MouseEvent) => {
})
if (!inside) {
activeMenuFor.value = null
activeRoutineMenuFor.value = null
selectedChoreId.value = null
selectedRoutineId.value = null
if (path.some((n) => n instanceof HTMLElement && n.classList.contains('item-card'))) {
shouldIgnoreNextCardClick.value = true
} else {
// Clicked fully outside any card — reset ready state so chore requires 2 clicks again
readyItemId.value = null
}
}
@@ -549,7 +824,7 @@ function handleEditItem(item: Task | Reward, type: 'task' | 'reward') {
}
overrideEditTarget.value = { entity: item, type }
const defaultValue = type === 'task' ? (item as Task).points : (item as Reward).cost
overrideCustomValue.value = item.custom_value ?? defaultValue
overrideCustomValue.value = (item as any).custom_value ?? defaultValue
validateOverrideInput()
showOverrideModal.value = true
}
@@ -570,7 +845,7 @@ async function confirmPendingRewardAndEdit() {
overrideEditTarget.value = target
const defaultValue =
target.type === 'task' ? (target.entity as Task).points : (target.entity as Reward).cost
overrideCustomValue.value = target.entity.custom_value ?? defaultValue
overrideCustomValue.value = (target.entity as any).custom_value ?? defaultValue
validateOverrideInput()
showOverrideModal.value = true
}
@@ -590,12 +865,22 @@ watch(showOverrideModal, async (newVal) => {
async function saveOverride() {
if (!isOverrideValid.value || !overrideEditTarget.value || !child.value) return
const res = await setChildOverride(
child.value.id,
overrideEditTarget.value.entity.id,
overrideEditTarget.value.type,
overrideCustomValue.value,
)
const type = overrideEditTarget.value.type as string
let res: Response
if (type === 'routine') {
res = await setChildRoutineOverride(
child.value.id,
overrideEditTarget.value.entity.id,
overrideCustomValue.value,
)
} else {
res = await setChildOverride(
child.value.id,
overrideEditTarget.value.entity.id,
overrideEditTarget.value.type,
overrideCustomValue.value,
)
}
if (res.ok) {
lastEditedItem.value = {
@@ -604,7 +889,7 @@ async function saveOverride() {
}
showOverrideModal.value = false
} else {
const { msg } = parseErrorResponse(res)
const { msg } = await parseErrorResponse(res)
alert(`Error: ${msg}`)
}
}
@@ -683,6 +968,11 @@ onMounted(async () => {
eventBus.on('chore_schedule_modified', handleChoreScheduleModified)
eventBus.on('chore_time_extended', handleChoreTimeExtended)
eventBus.on('child_chore_confirmation', handleChoreConfirmation)
eventBus.on('routine_schedule_modified', handleRoutineScheduleModified)
eventBus.on('routine_time_extended', handleRoutineTimeExtended)
eventBus.on('routine_modified', handleRoutineModified)
eventBus.on('child_routines_set', handleChildRoutinesSet)
eventBus.on('child_routine_confirmation', handleChildRoutineConfirmation)
document.addEventListener('click', onDocClick, true)
document.addEventListener('visibilitychange', onVisibilityChange)
@@ -722,6 +1012,9 @@ onMounted(async () => {
} else if (entityType === 'reward') {
childRewardListRef.value?.scrollToItem(scrollToId)
applyHighlightPulse(scrollToId)
} else if (entityType === 'routine') {
childRoutineListRef.value?.scrollToItem(scrollToId)
applyHighlightPulse(scrollToId)
}
const { scrollTo: _s, entityType: _e, ...remainingQuery } = route.query
router.replace({ query: remainingQuery })
@@ -749,6 +1042,11 @@ onUnmounted(() => {
eventBus.off('chore_schedule_modified', handleChoreScheduleModified)
eventBus.off('chore_time_extended', handleChoreTimeExtended)
eventBus.off('child_chore_confirmation', handleChoreConfirmation)
eventBus.off('routine_schedule_modified', handleRoutineScheduleModified)
eventBus.off('routine_time_extended', handleRoutineTimeExtended)
eventBus.off('routine_modified', handleRoutineModified)
eventBus.off('child_routines_set', handleChildRoutinesSet)
eventBus.off('child_routine_confirmation', handleChildRoutineConfirmation)
document.removeEventListener('click', onDocClick, true)
document.removeEventListener('visibilitychange', onVisibilityChange)
@@ -1058,6 +1356,127 @@ function goToAssignRoutines() {
<div v-if="choreDueLabel(item)" class="due-label">{{ choreDueLabel(item) }}</div>
</template>
</ScrollingList>
<ScrollingList
title="Routines"
ref="childRoutineListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
itemKey="routines"
imageField="image_id"
:enableEdit="false"
:childId="child?.id"
:readyItemId="readyItemId"
:isParentAuthenticated="true"
@item-ready="handleRoutineItemReady"
@trigger-item="triggerRoutine"
:getItemClass="
(item) => ({
good: true,
'chore-inactive': isRoutineInactive(item) || isRoutineApprovedToday(item),
})
"
:sort-fn="
(a: ChildRoutine, b: ChildRoutine) => {
if (isRoutinePending(a) !== isRoutinePending(b)) return isRoutinePending(a) ? -1 : 1
if (!isRoutineScheduledToday(a) !== !isRoutineScheduledToday(b))
return isRoutineScheduledToday(a) ? -1 : 1
if (isRoutineApprovedToday(a) !== isRoutineApprovedToday(b))
return isRoutineApprovedToday(a) ? 1 : -1
if (isRoutineExpired(a) !== isRoutineExpired(b)) return isRoutineExpired(a) ? 1 : -1
return 0
}
"
>
<template #item="{ item }: { item: ChildRoutine }">
<!-- Routine kebab menu -->
<div class="chore-kebab-wrap" @click.stop>
<button
v-show="selectedRoutineId === item.id"
class="kebab-btn"
:ref="
(el) => {
if (el) routineKebabBtnRefs.set(item.id, el as HTMLElement)
else routineKebabBtnRefs.delete(item.id)
}
"
@mousedown.stop.prevent
@click="openRoutineMenu(item.id, $event)"
:aria-expanded="activeRoutineMenuFor === item.id ? 'true' : 'false'"
aria-label="Options"
>
</button>
<Teleport to="body">
<div
v-if="activeRoutineMenuFor === item.id"
class="kebab-menu"
:style="{ top: menuPosition.top + 'px', left: menuPosition.left + 'px' }"
@mousedown.stop.prevent
@click.stop
>
<button class="menu-item" @mousedown.stop.prevent @click="editRoutine(item)">
Edit Routine
</button>
<button
class="menu-item"
@mousedown.stop.prevent
@click="editRoutinePoints(item)"
>
Edit Points
</button>
<button
class="menu-item"
@mousedown.stop.prevent
@click="openRoutineScheduleModal(item, $event)"
>
Schedule
</button>
<button
v-if="isRoutineExpired(item)"
class="menu-item"
@mousedown.stop.prevent
@click="doExtendRoutineTime(item, $event)"
>
Extend Time
</button>
<button
v-if="isRoutineApprovedToday(item)"
class="menu-item"
@mousedown.stop.prevent
@click="doResetRoutine(item, $event)"
>
Reset
</button>
</div>
</Teleport>
</div>
<!-- Status badges -->
<span v-if="isRoutineApprovedToday(item)" class="chore-stamp completed-stamp"
>COMPLETED</span
>
<span v-else-if="isRoutineExpired(item)" class="chore-stamp">TOO LATE</span>
<span v-else-if="isRoutinePending(item)" class="chore-stamp pending-stamp"
>PENDING</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>
<div v-if="routineDueLabel(item)" class="due-label">{{ routineDueLabel(item) }}</div>
</template>
</ScrollingList>
<ScrollingList
title="Kindness Acts"
ref="childKindnessListRef"
@@ -1217,6 +1636,17 @@ function goToAssignRoutines() {
@cancelled="showScheduleModal = false"
/>
<!-- Schedule Modal (Routines) -->
<ScheduleModal
v-if="showRoutineScheduleModal && routineScheduleTarget && child"
:entity="routineScheduleTarget"
entityType="routine"
:childId="child.id"
:schedule="routineScheduleTarget.schedule ?? null"
@saved="onRoutineScheduleSaved"
@cancelled="showRoutineScheduleModal = false"
/>
<!-- Override Edit Modal -->
<ModalDialog
v-if="showOverrideModal && overrideEditTarget && child"
@@ -1263,7 +1693,7 @@ function goToAssignRoutines() {
<!-- Reward Confirm Dialog -->
<RewardConfirmDialog
v-if="showRewardConfirm"
:reward="selectedReward"
:reward="selectedReward as any"
:childName="child?.name"
@confirm="confirmTriggerReward"
@deny="denyRewardRequest"
@@ -1287,6 +1717,26 @@ function goToAssignRoutines() {
@reject="doRejectChore"
@cancel="cancelChoreApproveDialog"
/>
<!-- Routine Approve/Reject Dialog -->
<RoutineApproveDialog
v-if="showRoutineApproveDialog && approveDialogRoutine"
:show="showRoutineApproveDialog"
:childName="child?.name ?? ''"
:routineName="approveDialogRoutine.name"
:points="approveDialogRoutine.custom_value ?? approveDialogRoutine.points"
:imageUrl="approveDialogRoutine.image_url"
@approve="doApproveRoutine"
@reject="doRejectRoutine"
@cancel="cancelRoutineApproveDialog"
/>
<RoutineConfirmDialog
:routine="confirmDialogRoutine"
:childName="child?.name ?? ''"
@confirm="doConfirmRoutine"
@cancel="cancelRoutineConfirmDialog"
/>
</div>
</template>

View File

@@ -0,0 +1,82 @@
<template>
<ModalDialog v-if="show" @backdrop-click="$emit('cancel')">
<template #default>
<div class="approve-dialog">
<img v-if="imageUrl" :src="imageUrl" alt="Routine" class="routine-image" />
<p class="child-label">{{ childName }}</p>
<p class="message">
completed <strong>{{ routineName }}</strong>
</p>
<p class="message">
Will be awarded <strong>{{ points }} points</strong>
</p>
<div class="actions">
<button class="btn btn-primary" @click="$emit('approve')">Approve</button>
<button class="btn btn-secondary" @click="$emit('reject')">Reject</button>
</div>
</div>
</template>
</ModalDialog>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
defineProps<{
show: boolean
childName: string
routineName: string
points: number
imageUrl?: string | null
}>()
defineEmits<{
approve: []
reject: []
cancel: []
}>()
</script>
<style scoped>
.approve-dialog {
text-align: center;
padding: 0.5rem;
}
.routine-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
margin-bottom: 0.75rem;
}
.child-label {
font-size: 1.2rem;
font-weight: 700;
color: var(--dialog-child-name);
margin-bottom: 0.15rem;
}
.message {
font-size: 1rem;
color: var(--dialog-message);
margin-bottom: 0.15rem;
}
.message:last-of-type {
margin-bottom: 1.5rem;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.actions button {
padding: 0.7rem 1.8rem;
border-radius: 10px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.05rem;
transition: background 0.18s;
min-width: 100px;
}
</style>

View File

@@ -36,6 +36,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { setChildRoutines } from '@/common/api'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/styles.css'
import type { Routine } from '@/common/models'
@@ -86,11 +87,7 @@ function goToCreate() {
async function onSubmit() {
isLoading.value = true
try {
const resp = await fetch(`/api/child/${childId}/set-routines`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ routine_ids: selectedIds.value }),
})
const resp = await setChildRoutines(childId, selectedIds.value)
if (!resp.ok) throw new Error('Failed to update routines')
router.back()
} catch (error) {
@@ -114,27 +111,35 @@ function onCancel() {
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 1rem;
gap: 1rem;
padding: 0;
min-height: 0;
}
h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.5rem;
.assign-view h2 {
font-size: 1.15rem;
color: var(--assign-heading-color);
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.list-container {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
max-width: 500px;
overflow-y: auto;
height: 100%;
padding: 0;
min-height: 0;
}
.routine-selection {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 500px;
}
.routine-item {
@@ -171,42 +176,32 @@ h2 {
.routine-item .name {
flex: 1;
text-align: left;
font-weight: 600;
color: var(--text-primary);
}
.routine-item .value {
min-width: 60px;
text-align: right;
font-weight: 600;
color: var(--text-secondary);
}
.actions {
display: flex;
gap: 1rem;
padding: 1rem;
width: 100%;
max-width: 500px;
justify-content: flex-end;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
.btn {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 6px;
font-weight: 600;
.actions button {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--btn-primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
.btn-primary:disabled {
@@ -214,15 +209,6 @@ h2 {
cursor: not-allowed;
}
.btn-secondary {
background: var(--btn-secondary);
color: var(--btn-secondary-text);
}
.btn-secondary:hover {
opacity: 0.8;
}
.round-btn {
background: none;
border: none;

View File

@@ -1,35 +1,29 @@
<template>
<ModalDialog v-if="show" @backdrop-click="$emit('cancel')">
<template #default>
<div class="confirm-dialog">
<img v-if="imageUrl" :src="imageUrl" alt="Routine" class="routine-image" />
<p class="message">Confirm routine</p>
<p class="routine-name">{{ routineName }}?</p>
<div v-if="items && items.length > 0" class="items-preview">
<p class="items-label">Items:</p>
<ul class="items-list">
<li v-for="item in items.slice(0, 3)" :key="item.id">{{ item.name }}</li>
<li v-if="items.length > 3" class="more-items">+ {{ items.length - 3 }} more</li>
</ul>
</div>
<div class="actions">
<button class="btn btn-primary" @click="$emit('confirm')">Confirm</button>
<button class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
</div>
</div>
</template>
<ModalDialog
v-if="routine"
title="Confirm Routine"
:subtitle="routine.name"
:imageUrl="routine.image_url"
@backdrop-click="$emit('cancel')"
>
<div class="modal-message">
Add these points to
<span class="child-name">{{ childName }}</span>
</div>
<div class="modal-actions">
<button class="btn btn-primary" @click="$emit('confirm')">Yes</button>
<button class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
import type { RoutineItem } from '@/common/models'
import type { ChildRoutine } from '@/common/models'
defineProps<{
show: boolean
routineName: string
imageUrl?: string | null
items?: RoutineItem[]
routine: ChildRoutine | null
childName?: string
}>()
defineEmits<{
@@ -39,88 +33,14 @@ defineEmits<{
</script>
<style scoped>
.confirm-dialog {
text-align: center;
padding: 0.5rem;
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
.routine-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
margin-bottom: 0.75rem;
}
.message {
font-size: 1.1rem;
color: var(--dialog-message);
margin-bottom: 0.25rem;
}
.routine-name {
font-size: 1.3rem;
font-weight: 700;
color: var(--dialog-child-name);
margin-bottom: 1rem;
}
.items-preview {
margin-bottom: 1rem;
text-align: left;
background: var(--form-bg);
padding: 0.75rem;
border-radius: 6px;
}
.items-label {
font-size: 0.9rem;
.child-name {
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.items-list {
list-style: none;
padding-left: 1rem;
margin: 0;
font-size: 0.95rem;
color: var(--text-primary);
}
.items-list li {
padding: 0.25rem 0;
}
.items-list li.more-items {
color: var(--text-secondary);
font-style: italic;
}
.actions {
display: flex;
gap: 1.5rem;
justify-content: center;
margin-top: 1.5rem;
}
.actions button {
padding: 0.7rem 1.8rem;
border-radius: 10px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.05rem;
transition: background 0.18s;
min-width: 100px;
}
.btn-primary {
background: var(--btn-primary);
color: #fff;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
background: var(--btn-secondary);
color: var(--btn-secondary-text);
}
.btn-secondary:hover {
opacity: 0.8;
color: var(--text-primary, #333);
}
</style>

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ChildRoutineOverlay from '../ChildRoutineOverlay.vue'
// Mock getCachedImageUrl to return a simple data URL
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(() => Promise.resolve('data:image/svg+xml,%3Csvg%3E%3C/svg%3E')),
}))
describe('ChildRoutineOverlay', () => {
const routine = {
id: 'routine-1',
name: 'Morning Routine',
points: 12,
image_id: 'routine-image',
image_url: '/images/routine.png',
items: [
{
id: 'item-1',
routine_id: 'routine-1',
name: 'Brush Teeth',
image_id: null,
order: 0,
},
{
id: 'item-2',
routine_id: 'routine-1',
name: 'Make Bed',
image_id: null,
order: 1,
},
],
}
it('emits close when the backdrop is clicked', async () => {
const wrapper = mount(ChildRoutineOverlay, {
props: {
show: true,
routine,
canComplete: true,
},
})
await wrapper.find('.overlay-backdrop').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('emits task-click and complete from the appropriate controls', async () => {
const wrapper = mount(ChildRoutineOverlay, {
props: {
show: true,
routine,
canComplete: true,
},
})
// Wait for async image resolution to complete
await wrapper.vm.$nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
await wrapper.find('.task-card').trigger('click')
await wrapper.find('.btn-primary').trigger('click')
expect(wrapper.emitted('task-click')).toEqual([[routine.items[0]]])
expect(wrapper.emitted('complete')).toHaveLength(1)
})
})

View File

@@ -65,6 +65,23 @@ describe('ChildView', () => {
custom_value: 8,
}
const mockRoutine = {
id: 'routine-1',
name: 'Morning Routine',
points: 12,
image_id: 'routine-image',
image_url: '/images/routine.png',
items: [
{
id: 'routine-item-1',
routine_id: 'routine-1',
name: 'Make Bed',
image_id: null,
order: 0,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
@@ -212,6 +229,34 @@ describe('ChildView', () => {
})
})
describe('Routine Overlay', () => {
beforeEach(async () => {
wrapper = mount(ChildView)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('opens the routine overlay when a routine is clicked', async () => {
vi.useFakeTimers()
wrapper.vm.handleRoutineClick(mockRoutine)
vi.advanceTimersByTime(200)
await nextTick()
expect(wrapper.vm.showRoutineOverlay).toBe(true)
expect(wrapper.vm.dialogRoutine?.id).toBe('routine-1')
vi.useRealTimers()
})
it('speaks the routine task name when a routine task is clicked', () => {
wrapper.vm.handleRoutineTaskClick(mockRoutine.items[0])
expect(window.speechSynthesis.cancel).toHaveBeenCalled()
expect(window.speechSynthesis.speak).toHaveBeenCalled()
})
})
describe('Reward Triggering', () => {
beforeEach(async () => {
wrapper = mount(ChildView)

View File

@@ -20,7 +20,9 @@
<span>{{ item.child_name }}</span>
</div>
<span class="requested-text">{{
item.entity_type === 'chore' ? 'completed' : 'requested'
item.entity_type === 'chore' || item.entity_type === 'routine'
? 'completed'
: 'requested'
}}</span>
<div class="reward-info">
<span>{{ item.entity_name }}</span>

View File

@@ -7,33 +7,149 @@
:isEdit="isEdit"
:loading="loading"
:error="error"
:requireDirty="false"
:submitDisabled="items.length === 0"
@submit="handleSubmit"
@cancel="handleCancel"
@add-image="handleAddImage"
/>
@add-image="handleAddMainImage"
>
<template #before-actions>
<section class="items-panel">
<h3>Routine Tasks</h3>
<div v-if="items.length === 0" class="items-empty">
Add at least one task to save this routine.
</div>
<div v-else class="items-list">
<div v-for="(item, idx) in items" :key="item.id || `new-${idx}`" class="item-row">
<div class="item-left">
<img
v-if="item.image_url"
:src="item.image_url"
:alt="item.name"
class="item-thumb"
/>
<span class="item-name">{{ item.name }}</span>
</div>
<div class="item-actions">
<button
type="button"
class="btn btn-secondary small-btn"
@click="startEditItem(idx)"
>
Edit
</button>
<button type="button" class="btn btn-secondary small-btn" @click="removeItem(idx)">
Delete
</button>
</div>
</div>
</div>
<button v-if="!addFormOpen" type="button" class="add-task-trigger" @click="openAddForm">
<span class="add-task-plus"></span> Add Task
</button>
<div v-else class="add-item-form">
<div class="add-form-header">
<h4>{{ editingItemIdx !== null ? 'Edit Task' : 'New Task' }}</h4>
<button
type="button"
class="close-form-btn"
@click="cancelEditItem"
aria-label="Close"
>
</button>
</div>
<div class="group">
<input
id="item-name"
v-model="newItem.name"
type="text"
maxlength="64"
placeholder="Task name, e.g. Make bed"
@input="error = null"
autofocus
/>
</div>
<div v-if="showImagePicker" class="group">
<ImagePicker
id="item-image"
v-model="newItem.image_id"
:image-type="2"
@add-image="handleAddItemImage"
/>
</div>
<button v-else type="button" class="toggle-image-btn" @click="showImagePicker = true">
+ Add image (optional)
</button>
<div class="item-form-actions">
<button
type="button"
class="btn btn-primary"
@click="addOrUpdateItem"
:disabled="!newItem.name.trim()"
>
{{ editingItemIdx !== null ? 'Update' : 'Add' }}
</button>
<button type="button" class="btn btn-secondary" @click="cancelEditItem">
Cancel
</button>
</div>
</div>
</section>
</template>
</EntityEditForm>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import EntityEditForm from '@/components/shared/EntityEditForm.vue'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import { getCachedImageUrl } from '@/common/imageCache'
import type { RoutineItem } from '@/common/models'
import '@/assets/styles.css'
const props = defineProps<{ id?: string }>()
const router = useRouter()
const isEdit = computed(() => !!props.id)
type EditableRoutineItem = RoutineItem & {
image_url?: string | null
local_file?: File | null
}
const fields = [
{ name: 'name', label: 'Routine Name', type: 'text' as const, required: true, maxlength: 64 },
{ name: 'points', label: 'Points', type: 'number' as const, required: true, min: 1, max: 1000 },
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 4 },
{ name: 'image_id', label: 'Image', type: 'image' as const, imageType: 2 },
]
const initialData = ref({ name: '', points: 1, image_id: null })
const localImageFile = ref<File | null>(null)
const initialData = ref({
name: '',
points: 1,
image_id: null as string | null,
})
const items = ref<EditableRoutineItem[]>([])
const newItem = ref({
name: '',
image_id: null as string | null,
})
const newItemLocalFile = ref<File | null>(null)
const editingItemIdx = ref<number | null>(null)
const addFormOpen = ref(false)
const showImagePicker = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const removedItemIds = ref<string[]>([])
onMounted(async () => {
if (isEdit.value && props.id) {
@@ -47,21 +163,182 @@ onMounted(async () => {
points: Number(data.points) || 1,
image_id: data.image_id ?? null,
}
const itemsResp = await fetch(`/api/routine/${props.id}/items`)
if (itemsResp.ok) {
const itemsData = await itemsResp.json()
const loadedItems = (itemsData.items || []) as RoutineItem[]
items.value = await Promise.all(
loadedItems.map(async (item, idx) => {
let imageUrl: string | null = null
if (item.image_id) {
try {
imageUrl = await getCachedImageUrl(item.image_id)
} catch {
imageUrl = null
}
}
return {
...item,
order: idx,
image_url: imageUrl,
local_file: null,
}
}),
)
}
} catch {
error.value = 'Could not load routine.'
} finally {
loading.value = false
await nextTick()
}
}
})
function handleAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') localImageFile.value = file
function handleAddMainImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
} else {
localImageFile.value = null
}
}
async function handleAddItemImage({ id, file }: { id: string; file: File }) {
if (id !== 'local-upload') {
newItemLocalFile.value = null
return
}
newItemLocalFile.value = file
try {
const imageUrl = await getCachedImageUrl(id)
if (editingItemIdx.value !== null) {
const editingItem = items.value[editingItemIdx.value]
if (editingItem) {
editingItem.image_url = imageUrl
}
}
} catch {
// No-op: preview still works through ImagePicker.
}
}
function openAddForm() {
editingItemIdx.value = null
newItem.value = { name: '', image_id: null }
newItemLocalFile.value = null
showImagePicker.value = false
addFormOpen.value = true
}
function startEditItem(idx: number) {
const item = items.value[idx]
if (!item) return
editingItemIdx.value = idx
newItem.value = {
name: item.name,
image_id: item.image_id,
}
newItemLocalFile.value = null
showImagePicker.value = !!item.image_id
addFormOpen.value = true
}
function normalizeItemOrder() {
items.value = items.value.map((item, idx) => ({
...item,
order: idx,
}))
}
function addOrUpdateItem() {
if (!newItem.value.name.trim()) {
error.value = 'Item name is required.'
return
}
if (editingItemIdx.value !== null) {
const current = items.value[editingItemIdx.value]
if (!current) {
cancelEditItem()
return
}
items.value[editingItemIdx.value] = {
id: current.id || '',
routine_id: props.id || '',
name: newItem.value.name,
image_id: newItem.value.image_id,
image_url: current.image_url,
local_file: newItemLocalFile.value,
order: editingItemIdx.value,
}
} else {
items.value.push({
id: `temp-${Date.now()}`,
routine_id: props.id || '',
name: newItem.value.name,
image_id: newItem.value.image_id,
image_url: null,
local_file: newItemLocalFile.value,
order: items.value.length,
})
}
normalizeItemOrder()
editingItemIdx.value = null
addFormOpen.value = false
showImagePicker.value = false
newItem.value = { name: '', image_id: null }
newItemLocalFile.value = null
error.value = null
}
function removeItem(idx: number) {
const item = items.value[idx]
if (!item) return
if (item.id && !item.id.startsWith('temp-')) {
removedItemIds.value.push(item.id)
}
items.value.splice(idx, 1)
normalizeItemOrder()
if (editingItemIdx.value === idx) {
cancelEditItem()
} else if (editingItemIdx.value !== null && editingItemIdx.value > idx) {
editingItemIdx.value -= 1
}
}
function cancelEditItem() {
editingItemIdx.value = null
addFormOpen.value = false
showImagePicker.value = false
newItem.value = { name: '', image_id: null }
newItemLocalFile.value = null
}
async function uploadImage(file: File): Promise<string> {
const formData = new FormData()
formData.append('file', file)
formData.append('type', '4')
formData.append('permanent', 'false')
const resp = await fetch('/api/image/upload', { method: 'POST', body: formData })
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
return data.id
}
async function resolveItemImageId(item: EditableRoutineItem): Promise<string | null> {
if (item.image_id === 'local-upload' && item.local_file) {
return uploadImage(item.local_file)
}
return item.image_id ?? null
}
async function handleSubmit(form: { name: string; points: number; image_id: string | null }) {
let imageId = form.image_id
error.value = null
if (!form.name.trim()) {
error.value = 'Routine name is required.'
@@ -71,26 +348,22 @@ async function handleSubmit(form: { name: string; points: number; image_id: stri
error.value = 'Points must be at least 1.'
return
}
loading.value = true
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '4')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', { method: 'POST', body: formData })
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch {
error.value = 'Failed to upload image.'
loading.value = false
return
}
if (items.value.length === 0) {
error.value = 'Routine must have at least one item.'
return
}
loading.value = true
try {
let imageId = form.image_id
if (imageId === null && localImageFile.value) {
imageId = await uploadImage(localImageFile.value)
}
if (imageId === 'local-upload' && localImageFile.value) {
imageId = await uploadImage(localImageFile.value)
}
const url = isEdit.value && props.id ? `/api/routine/${props.id}/edit` : '/api/routine/add'
const resp = await fetch(url, {
method: 'PUT',
@@ -98,11 +371,41 @@ async function handleSubmit(form: { name: string; points: number; image_id: stri
body: JSON.stringify({ name: form.name, points: form.points, image_id: imageId }),
})
if (!resp.ok) throw new Error('Failed to save routine')
const routineData = await resp.json()
const routineId = routineData.id || props.id
for (const itemId of removedItemIds.value) {
await fetch(`/api/routine/${routineId}/item/${itemId}`, {
method: 'DELETE',
})
}
for (const item of items.value) {
const resolvedImageId = await resolveItemImageId(item)
const payload = { name: item.name, image_id: resolvedImageId, order: item.order }
if (item.id?.startsWith('temp-') || !item.id) {
await fetch(`/api/routine/${routineId}/item/add`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
} else {
await fetch(`/api/routine/${routineId}/item/${item.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
}
await router.push({ name: 'RoutineView' })
} catch {
} catch (err) {
error.value = 'Failed to save routine.'
console.error(err)
} finally {
loading.value = false
}
loading.value = false
}
function handleCancel() {
@@ -119,4 +422,188 @@ function handleCancel() {
box-shadow: 0 4px 24px var(--form-shadow);
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
.items-panel {
margin-top: 1rem;
border-top: 1px solid var(--form-input-border, #e6e6e6);
padding-top: 1rem;
}
.items-panel h3 {
color: var(--form-label, #444);
font-size: 1.1rem;
margin: 0 0 0.75rem;
}
.items-empty {
color: var(--loading-color, #888);
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
.items-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.item-row {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--form-input-bg, #f8fafc);
border: 1px solid var(--form-input-border, #e6e6e6);
border-radius: 10px;
padding: 0.55rem 0.7rem;
}
.item-left {
display: flex;
align-items: center;
min-width: 0;
gap: 0.6rem;
}
.item-thumb {
width: 34px;
height: 34px;
border-radius: 8px;
object-fit: cover;
}
.item-name {
font-weight: 600;
color: var(--form-label, #333);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-actions {
display: flex;
gap: 0.5rem;
}
.add-task-trigger {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.6rem 0.9rem;
background: none;
border: 1.5px dashed var(--form-input-border, #c8d0db);
border-radius: 10px;
color: var(--btn-primary, #667eea);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.add-task-trigger:hover {
background: var(--form-input-bg, #f0f4ff);
border-color: var(--btn-primary, #667eea);
}
.add-task-plus {
font-size: 1.1rem;
line-height: 1;
}
.add-item-form {
margin-top: 0.25rem;
background: var(--form-input-bg, #f8fafc);
border: 1px solid var(--form-input-border, #e6e6e6);
border-radius: 10px;
padding: 0.8rem;
}
.add-form-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.65rem;
}
.add-form-header h4 {
color: var(--form-label, #444);
font-size: 1rem;
margin: 0;
}
.close-form-btn {
background: none;
border: none;
color: var(--form-label, #888);
font-size: 1rem;
cursor: pointer;
line-height: 1;
padding: 0.1rem 0.3rem;
border-radius: 4px;
}
.close-form-btn:hover {
background: rgba(0, 0, 0, 0.06);
}
.toggle-image-btn {
background: none;
border: none;
color: var(--btn-primary, #667eea);
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 0.8rem;
display: block;
}
.toggle-image-btn:hover {
text-decoration: underline;
}
.group {
margin-bottom: 0.8rem;
}
.group input[type='text'] {
width: 100%;
padding: 0.6rem;
border-radius: 7px;
border: 1px solid var(--form-input-border, #e6e6e6);
font-size: 1rem;
background: var(--form-input-bg, #fff);
box-sizing: border-box;
}
.item-form-actions {
display: flex;
gap: 0.5rem;
}
.small-btn {
padding: 0.4rem 0.8rem;
font-size: 0.85rem;
min-width: 0;
}
@media (max-width: 600px) {
.view {
padding: 1.5rem 1.2rem;
}
.item-row {
align-items: flex-start;
flex-direction: column;
gap: 0.6rem;
}
.item-actions {
width: 100%;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,183 @@
// Unit tests for routine components.
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import RoutineEditView from '@/components/routine/RoutineEditView.vue'
import RoutineConfirmDialog from '@/components/child/RoutineConfirmDialog.vue'
import ModalDialog from '@/components/shared/ModalDialog.vue'
// Mock fetch globally
global.fetch = vi.fn()
describe('RoutineEditView.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
;(global.fetch as ReturnType<typeof vi.fn>).mockClear()
})
it('renders create form heading when no id prop provided', async () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
expect(wrapper.text()).toContain('Create Routine')
})
it('renders name and points inputs', () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
// EntityEditForm renders inputs with id matching field.name
expect(wrapper.find('input[id="name"]').exists()).toBe(true)
expect(wrapper.find('input[id="points"]').exists()).toBe(true)
})
it('shows Routine Tasks section', () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
expect(wrapper.text()).toContain('Routine Tasks')
})
it('shows empty state message when no tasks added', () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
expect(wrapper.text()).toContain('Add at least one task')
})
it('shows Add Task button by default', () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
expect(wrapper.text()).toContain('Add Task')
})
it('opens item form when Add Task button clicked', async () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
const addBtn = wrapper.find('.add-task-trigger')
expect(addBtn.exists()).toBe(true)
await addBtn.trigger('click')
// Item form should appear with a task name input
expect(wrapper.find('input[id="item-name"]').exists()).toBe(true)
})
it('allows typing a task name in the add form', async () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
await wrapper.find('.add-task-trigger').trigger('click')
const itemInput = wrapper.find('input[id="item-name"]')
await itemInput.setValue('Make Bed')
expect((itemInput.element as HTMLInputElement).value).toBe('Make Bed')
})
it('adds item to list when Add button clicked with valid name', async () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
await wrapper.find('.add-task-trigger').trigger('click')
await wrapper.find('input[id="item-name"]').setValue('Make Bed')
await wrapper.find('.item-form-actions .btn-primary').trigger('click')
await wrapper.vm.$nextTick()
// The item name should now appear in the list
expect(wrapper.text()).toContain('Make Bed')
})
it('renders Cancel and Create buttons', () => {
const wrapper = mount(RoutineEditView, {
props: { id: undefined },
})
const text = wrapper.text()
expect(text).toContain('Cancel')
expect(text).toContain('Create')
})
it('renders edit heading when id prop provided', async () => {
;(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: 'r1',
name: 'Morning Routine',
points: 50,
image_id: null,
image_url: null,
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ items: [] }),
})
const wrapper = mount(RoutineEditView, {
props: { id: 'r1' },
})
await wrapper.vm.$nextTick()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Edit Routine')
})
})
describe('RoutineConfirmDialog.vue', () => {
const routine = {
id: 'r1',
name: 'Morning Routine',
points: 20,
custom_value: null,
image_id: null,
image_url: null,
pending_status: null,
pending_confirmation_id: null,
schedule: null,
items: [],
}
it('displays routine name', () => {
const wrapper = mount(RoutineConfirmDialog, {
props: { routine, childName: 'Timmy' },
global: { components: { ModalDialog } },
})
expect(wrapper.text()).toContain('Morning Routine')
})
it('displays child name', () => {
const wrapper = mount(RoutineConfirmDialog, {
props: { routine, childName: 'Timmy' },
global: { components: { ModalDialog } },
})
expect(wrapper.text()).toContain('Timmy')
})
it('emits confirm event when Yes button clicked', async () => {
const wrapper = mount(RoutineConfirmDialog, {
props: { routine, childName: 'Timmy' },
global: { components: { ModalDialog } },
})
const buttons = wrapper.findAll('button')
const confirmBtn = buttons.find((b) => b.text().toLowerCase().includes('yes'))
if (confirmBtn) {
await confirmBtn.trigger('click')
expect(wrapper.emitted('confirm')).toBeTruthy()
}
})
it('emits cancel event when Cancel button clicked', async () => {
const wrapper = mount(RoutineConfirmDialog, {
props: { routine, childName: 'Timmy' },
global: { components: { ModalDialog } },
})
const buttons = wrapper.findAll('button')
const cancelBtn = buttons.find((b) => b.text().toLowerCase().includes('cancel'))
if (cancelBtn) {
await cancelBtn.trigger('click')
expect(wrapper.emitted('cancel')).toBeTruthy()
}
})
})

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import RoutineEditView from '../RoutineEditView.vue'
vi.mock('vue-router', () => ({
useRoute: vi.fn(() => ({
params: {},
query: { name: 'Timmy' },
})),
useRouter: vi.fn(() => ({
push: vi.fn(),
back: vi.fn(),
})),
}))
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(),
}))
vi.mock('@/assets/styles.css', () => ({}))
const EntityEditFormStub = defineComponent({
name: 'EntityEditForm',
props: [
'entityLabel',
'fields',
'initialData',
'isEdit',
'loading',
'error',
'requireDirty',
'submitDisabled',
],
emits: ['submit', 'cancel', 'add-image'],
template: `
<div class="entity-edit-form-stub">
<button type="submit" class="submit-btn" :disabled="submitDisabled">Submit</button>
<slot name="before-actions" />
</div>
`,
})
const ImagePickerStub = defineComponent({
name: 'ImagePicker',
template: '<div class="image-picker-stub" />',
})
describe('RoutineEditView', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('disables Create when there are no routine tasks', async () => {
const wrapper = mount(RoutineEditView, {
props: {},
global: {
stubs: {
EntityEditForm: EntityEditFormStub,
ImagePicker: ImagePickerStub,
},
},
})
await nextTick()
const submitButton = wrapper.find('.submit-btn')
expect(submitButton.exists()).toBe(true)
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.findComponent(EntityEditFormStub).props('submitDisabled')).toBe(true)
})
})

View File

@@ -61,6 +61,7 @@
</div>
</template>
<div v-if="error" class="error">{{ error }}</div>
<slot name="before-actions" />
<div class="actions">
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
Cancel
@@ -68,7 +69,7 @@
<button
type="submit"
class="btn btn-primary"
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
:disabled="loading || !isValid || (props.requireDirty && !isDirty) || props.submitDisabled"
>
{{ isEdit ? 'Save' : 'Create' }}
</button>
@@ -107,9 +108,11 @@ const props = withDefaults(
title?: string
requireDirty?: boolean
fieldErrors?: Record<string, string>
submitDisabled?: boolean
}>(),
{
requireDirty: true,
submitDisabled: false,
},
)