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>

View File

@@ -937,6 +937,16 @@ function goToAssignRewards() {
})
}
}
function goToAssignRoutines() {
if (child.value?.id) {
router.push({
name: 'RoutineAssignView',
params: { id: child.value.id },
query: { name: child.value.name },
})
}
}
</script>
<template>
@@ -1162,6 +1172,9 @@ function goToAssignRewards() {
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
Assign Penalties
</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
Assign Routines
</button>
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
</div>
@@ -1196,7 +1209,8 @@ function goToAssignRewards() {
<!-- Schedule Modal -->
<ScheduleModal
v-if="showScheduleModal && scheduleTarget && child"
:task="scheduleTarget"
:entity="scheduleTarget"
entityType="task"
:childId="child.id"
:schedule="scheduleTarget.schedule ?? null"
@saved="onScheduleSaved"

View File

@@ -0,0 +1,238 @@
<template>
<div class="assign-view">
<h2>Assign Routines{{ childName ? ` for ${childName}` : '' }}</h2>
<div class="list-container">
<MessageBlock v-if="routines.length === 0" message="No routines">
<span> <button class="round-btn" @click="goToCreate">Create</button> a routine </span>
</MessageBlock>
<div v-else class="routine-selection">
<div
v-for="routine in routines"
:key="routine.id"
class="routine-item"
@click="toggleRoutine(routine.id)"
>
<input
type="checkbox"
:checked="selectedIds.includes(routine.id)"
@change="toggleRoutine(routine.id)"
:aria-label="`Select ${routine.name}`"
/>
<img v-if="routine.image_url" :src="routine.image_url" :alt="routine.name" />
<span class="name">{{ routine.name }}</span>
<span class="value">{{ routine.points }} pts</span>
</div>
</div>
</div>
<div class="actions" v-if="routines.length > 0">
<button class="btn btn-secondary" @click="onCancel">Cancel</button>
<button class="btn btn-primary" @click="onSubmit" :disabled="isLoading">
{{ isLoading ? 'Saving...' : 'Submit' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MessageBlock from '../shared/MessageBlock.vue'
import '@/assets/styles.css'
import type { Routine } from '@/common/models'
const route = useRoute()
const router = useRouter()
const childId = route.params.id as string
const childName = typeof route.query.name === 'string' ? route.query.name : ''
const routines = ref<Routine[]>([])
const selectedIds = ref<string[]>([])
const isLoading = ref(false)
onMounted(async () => {
await fetchRoutines()
})
async function fetchRoutines() {
try {
// Fetch child to get currently assigned routines
const childResp = await fetch(`/api/child/${childId}`)
if (!childResp.ok) throw new Error('Failed to fetch child')
const childData = await childResp.json()
selectedIds.value = childData.routines || []
// Fetch all routines
const routinesResp = await fetch('/api/routine/list')
if (!routinesResp.ok) throw new Error('Failed to fetch routines')
const routinesData = await routinesResp.json()
routines.value = routinesData.routines || []
} catch (error) {
console.error('Failed to fetch routines:', error)
}
}
function toggleRoutine(routineId: string) {
const index = selectedIds.value.indexOf(routineId)
if (index > -1) {
selectedIds.value.splice(index, 1)
} else {
selectedIds.value.push(routineId)
}
}
function goToCreate() {
router.push({ name: 'CreateRoutine' })
}
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 }),
})
if (!resp.ok) throw new Error('Failed to update routines')
router.back()
} catch (error) {
console.error('Failed to update routines:', error)
alert('Failed to update routines.')
} finally {
isLoading.value = false
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 1rem;
gap: 1rem;
}
h2 {
margin: 0;
color: var(--text-primary);
font-size: 1.5rem;
}
.list-container {
flex: 1 1 auto;
width: 100%;
max-width: 500px;
overflow-y: auto;
}
.routine-selection {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.routine-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 2px solid var(--list-item-border-good);
border-radius: 8px;
background: var(--list-item-bg-good);
cursor: pointer;
transition:
background 0.2s,
border-color 0.2s;
}
.routine-item:hover {
background: var(--list-item-bg-good-hover, var(--list-item-bg-good));
border-color: var(--list-item-border-good-hover, var(--list-item-border-good));
}
.routine-item input[type='checkbox'] {
cursor: pointer;
width: 1.25rem;
height: 1.25rem;
}
.routine-item img {
width: 2.5rem;
height: 2.5rem;
border-radius: 6px;
object-fit: cover;
}
.routine-item .name {
flex: 1;
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;
}
.btn {
padding: 0.5rem 1.5rem;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: var(--btn-primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.6;
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;
color: var(--btn-primary);
font-weight: 600;
cursor: pointer;
text-decoration: underline;
}
.round-btn:hover {
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,126 @@
<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>
</template>
<script setup lang="ts">
import ModalDialog from '../shared/ModalDialog.vue'
import type { RoutineItem } from '@/common/models'
defineProps<{
show: boolean
routineName: string
imageUrl?: string | null
items?: RoutineItem[]
}>()
defineEmits<{
confirm: []
cancel: []
}>()
</script>
<style scoped>
.confirm-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;
}
.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;
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;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="view">
<EntityEditForm
entityLabel="Routine"
:fields="fields"
:initialData="initialData"
:isEdit="isEdit"
:loading="loading"
:error="error"
@submit="handleSubmit"
@cancel="handleCancel"
@add-image="handleAddImage"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import EntityEditForm from '../shared/EntityEditForm.vue'
import '@/assets/styles.css'
const props = defineProps<{ id?: string }>()
const router = useRouter()
const isEdit = computed(() => !!props.id)
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 },
]
const initialData = ref({ name: '', points: 1, image_id: null })
const localImageFile = ref<File | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
loading.value = true
try {
const resp = await fetch(`/api/routine/${props.id}`)
if (!resp.ok) throw new Error('Failed to load routine')
const data = await resp.json()
initialData.value = {
name: data.name ?? '',
points: Number(data.points) || 1,
image_id: data.image_id ?? 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
}
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.'
return
}
if (form.points < 1) {
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
}
}
try {
const url = isEdit.value && props.id ? `/api/routine/${props.id}/edit` : '/api/routine/add'
const resp = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: form.name, points: form.points, image_id: imageId }),
})
if (!resp.ok) throw new Error('Failed to save routine')
await router.push({ name: 'RoutineView' })
} catch {
error.value = 'Failed to save routine.'
}
loading.value = false
}
function handleCancel() {
router.back()
}
</script>
<style scoped>
.view {
max-width: 400px;
margin: 0 auto;
background: var(--form-bg);
border-radius: 12px;
box-shadow: 0 4px 24px var(--form-shadow);
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div class="routine-view">
<MessageBlock v-if="countRef === 0" message="No routines">
<span> <button class="round-btn" @click="create">Create</button> a routine </span>
</MessageBlock>
<ItemList
v-else
ref="listRef"
fetchUrl="/api/routine/list"
itemKey="routines"
:itemFields="ROUTINE_FIELDS"
imageField="image_id"
deletable
@clicked="(item: Routine) => $router.push({ name: 'EditRoutine', params: { id: item.id } })"
@delete="confirmDelete"
@loading-complete="(count) => (countRef = count)"
:getItemClass="() => ({ good: true })"
>
<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 Routine" @click="create" />
<DeleteModal
:show="showConfirm"
message="Are you sure you want to delete this routine?"
@confirm="deleteItem"
@cancel="showConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import ItemList from '../shared/ItemList.vue'
import MessageBlock from '@/components/shared/MessageBlock.vue'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
import type { Routine } from '@/common/models'
import { ROUTINE_FIELDS } from '@/common/models'
import { eventBus } from '@/common/eventBus'
const $router = useRouter()
const showConfirm = ref(false)
const itemToDelete = ref<string | null>(null)
const listRef = ref()
const countRef = ref<number>(-1)
function handleModified() {
listRef.value?.refresh()
}
onMounted(() => {
eventBus.on('routine_modified', handleModified)
})
onUnmounted(() => {
eventBus.off('routine_modified', handleModified)
})
function confirmDelete(id: string) {
itemToDelete.value = id
showConfirm.value = true
}
const deleteItem = async () => {
const id =
typeof itemToDelete.value === 'object' && itemToDelete.value !== null
? (itemToDelete.value as any).id
: itemToDelete.value
if (!id) return
try {
const resp = await fetch(`/api/routine/${id}`, { method: 'DELETE' })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
} catch (err) {
console.error('Failed to delete routine:', err)
} finally {
showConfirm.value = false
itemToDelete.value = null
}
}
const create = () => {
$router.push({ name: 'CreateRoutine' })
}
</script>
<style scoped>
.routine-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
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);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
<ModalDialog :image-url="entity.image_url" :title="scheduleTitle" :subtitle="entity.name">
<!-- Enable/disable toggle row -->
<div class="schedule-toggle-row">
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
@@ -165,8 +165,20 @@ import { ref, computed } from 'vue'
import ModalDialog from './ModalDialog.vue'
import TimePickerPopover from './TimePickerPopover.vue'
import DateInputField from './DateInputField.vue'
import { setChoreSchedule, deleteChoreSchedule, parseErrorResponse } from '@/common/api'
import type { ChildTask, ChoreSchedule, DayConfig } from '@/common/models'
import {
setChoreSchedule,
deleteChoreSchedule,
setRoutineSchedule,
deleteRoutineSchedule,
parseErrorResponse,
} from '@/common/api'
import type {
ChildTask,
ChildRoutine,
ChoreSchedule,
RoutineSchedule,
DayConfig,
} from '@/common/models'
interface TimeValue {
hour: number
@@ -174,11 +186,16 @@ interface TimeValue {
}
const props = defineProps<{
task: ChildTask
entity: ChildTask | ChildRoutine
entityType: 'task' | 'routine'
childId: string
schedule: ChoreSchedule | null
schedule: ChoreSchedule | RoutineSchedule | null
}>()
const scheduleTitle = computed(() =>
props.entityType === 'task' ? 'Schedule Chore' : 'Schedule Routine',
)
const emit = defineEmits<{
(e: 'saved'): void
(e: 'cancelled'): void
@@ -386,10 +403,20 @@ async function save() {
errorMsg.value = null
saving.value = true
const entityId = props.entity.id
const saveEntitySchedule = (payload: object) =>
props.entityType === 'task'
? setChoreSchedule(props.childId, entityId, payload)
: setRoutineSchedule(props.childId, entityId, payload)
const deleteEntitySchedule = () =>
props.entityType === 'task'
? deleteChoreSchedule(props.childId, entityId)
: deleteRoutineSchedule(props.childId, entityId)
let res: Response
if (mode.value === 'days' && selectedDays.value.size === 0) {
// 0 days = remove schedule entirely → chore becomes always active
res = await deleteChoreSchedule(props.childId, props.task.id)
// 0 days = remove schedule entirely so the item becomes always active
res = await deleteEntitySchedule()
} else if (mode.value === 'days') {
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
const override = exceptions.value.get(day)
@@ -399,7 +426,7 @@ async function save() {
minute: override?.minute ?? defaultTime.value.minute,
}
})
res = await setChoreSchedule(props.childId, props.task.id, {
res = await saveEntitySchedule({
mode: 'days',
enabled: scheduleEnabled.value,
day_configs,
@@ -408,7 +435,7 @@ async function save() {
default_has_deadline: hasDefaultDeadline.value,
})
} else {
res = await setChoreSchedule(props.childId, props.task.id, {
res = await saveEntitySchedule({
mode: 'interval',
enabled: scheduleEnabled.value,
interval_days: intervalDays.value,

View File

@@ -19,6 +19,12 @@
>
Penalties
</button>
<button
:class="{ active: activeTab === 'routines' }"
@click="$router.push({ name: 'RoutineView' })"
>
Routines
</button>
</nav>
<div class="sub-content">
<router-view :key="$route.fullPath" />
@@ -34,6 +40,8 @@ const route = useRoute()
const activeTab = computed(() => {
const name = String(route.name)
if (name.startsWith('Routine') || name === 'CreateRoutine' || name === 'EditRoutine')
return 'routines'
if (name.startsWith('Kindness') || name === 'CreateKindness' || name === 'EditKindness')
return 'kindness'
if (name.startsWith('Penalty') || name === 'CreatePenalty' || name === 'EditPenalty')