Refactored frontend directory
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Failing after 2m46s

This commit is contained in:
2026-04-25 00:40:15 -04:00
parent db846f4e31
commit 127378797c
263 changed files with 88 additions and 79 deletions

View File

@@ -0,0 +1,530 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
import { isParentAuthenticated } from '../../stores/auth'
import { eventBus } from '@/common/eventBus'
import type {
Child,
ChildModifiedEventPayload,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
Event,
} from '@/common/models'
import MessageBlock from '@/components/shared/MessageBlock.vue'
//import '@/assets/button-shared.css'
import FloatingActionButton from '../shared/FloatingActionButton.vue'
import DeleteModal from '../shared/DeleteModal.vue'
const router = useRouter()
const children = ref<Child[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const images = ref<Map<string, string>>(new Map()) // Store image URLs
const imageCacheName = 'images-v1'
// UI state for kebab menus & delete confirmation
const activeMenuFor = ref<string | number | null>(null) // which child card shows menu
const confirmDeleteVisible = ref(false)
const deletingChildId = ref<string | number | null>(null)
const deleting = ref(false)
const openChildEditor = (child: Child, evt?: Event) => {
evt?.stopPropagation()
router.push({ name: 'ChildEditView', params: { id: child.id } })
}
async function handleChildModified(event: Event) {
const payload = event.payload as ChildModifiedEventPayload
const childId = payload.child_id
switch (payload.operation) {
case 'DELETE':
children.value = children.value.filter((c) => c.id !== childId)
break
case 'ADD':
try {
const list = await fetchChildren()
children.value = list
} catch (err) {
console.warn('Failed to fetch children after ADD operation:', err)
}
break
case 'EDIT':
try {
const list = await fetchChildren()
const updatedChild = list.find((c) => c.id === childId)
if (updatedChild) {
const idx = children.value.findIndex((c) => c.id === childId)
if (idx !== -1) {
children.value[idx] = updatedChild
} else {
console.warn(`EDIT operation: child with id ${childId} not found in current list.`)
}
} else {
console.warn(
`EDIT operation: updated child with id ${childId} not found in fetched list.`,
)
}
} catch (err) {
console.warn('Failed to fetch children after EDIT operation:', err)
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
}
}
function handleChildTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
const childId = payload.child_id
const child = children.value.find((c) => c.id === childId)
if (child) {
child.points = payload.points
} else {
console.warn(`Child with id ${childId} not found when updating points.`)
}
}
function handleChildRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
const childId = payload.child_id
const child = children.value.find((c) => c.id === childId)
if (child) {
child.points = payload.points
} else {
console.warn(`Child with id ${childId} not found when updating points.`)
}
}
// points update state
const updatingPointsFor = ref<string | number | null>(null)
const fetchImage = async (imageId: string) => {
try {
const url = await getCachedImageUrl(imageId, imageCacheName)
images.value.set(imageId, url)
} catch (err) {
console.warn('Failed to load child image', imageId, err)
}
}
const fetchChildren = async (): Promise<Child[]> => {
loading.value = true
error.value = null
images.value.clear()
try {
const response = await fetch('/api/child/list')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
const childList = data.children || []
// Fetch images for each child (shared cache util)
await Promise.all(
childList.map((child) => {
if (child.image_id) {
return fetchImage(child.image_id)
}
return Promise.resolve()
}),
)
return childList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
console.error('Error fetching children:', err)
return []
} finally {
loading.value = false
}
}
const createChild = () => {
router.push({ name: 'CreateChild' })
}
onMounted(async () => {
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_task_triggered', handleChildTaskTriggered)
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
const listPromise = fetchChildren()
listPromise.then((list) => {
children.value = list
})
// listen for outside clicks to auto-close any open kebab menu
document.addEventListener('click', onDocClick, true)
})
onUnmounted(() => {
eventBus.off('child_modified', handleChildModified)
eventBus.off('child_task_triggered', handleChildTaskTriggered)
eventBus.off('child_reward_triggered', handleChildRewardTriggered)
})
const shouldIgnoreNextCardClick = ref(false)
const onDocClick = (e: MouseEvent) => {
if (activeMenuFor.value !== null) {
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
const clickedInsideKebab = path.some((node: unknown) => {
if (!(node instanceof HTMLElement)) return false
return (
node.classList.contains('kebab-wrap') ||
node.classList.contains('kebab-btn') ||
node.classList.contains('kebab-menu')
)
})
if (!clickedInsideKebab) {
activeMenuFor.value = null
// If the click was on a card, set the flag to ignore the next card click
if (
path.some((node: unknown) => node instanceof HTMLElement && node.classList.contains('card'))
) {
shouldIgnoreNextCardClick.value = true
}
}
}
}
const selectChild = (childId: string | number) => {
if (shouldIgnoreNextCardClick.value) {
shouldIgnoreNextCardClick.value = false
return
}
if (activeMenuFor.value !== null) {
// If kebab menu is open, ignore card clicks
return
}
if (isParentAuthenticated.value) {
router.push(`/parent/${childId}`)
} else {
router.push(`/child/${childId}`)
}
}
// kebab menu helpers
const openMenu = (childId: string | number, evt?: Event) => {
evt?.stopPropagation()
activeMenuFor.value = childId
}
const closeMenu = () => {
activeMenuFor.value = null
}
// delete flow
const askDelete = (childId: string | number, evt?: Event) => {
evt?.stopPropagation()
deletingChildId.value = childId
confirmDeleteVisible.value = true
closeMenu()
}
const performDelete = async () => {
if (!deletingChildId.value) return
deleting.value = true
try {
const resp = await fetch(`/api/child/${deletingChildId.value}`, {
method: 'DELETE',
})
if (!resp.ok) {
throw new Error(`Delete failed: ${resp.status}`)
}
// refresh list
await fetchChildren()
} catch (err) {
console.error('Failed to delete child', deletingChildId.value, err)
} finally {
deleting.value = false
confirmDeleteVisible.value = false
deletingChildId.value = null
}
}
// Delete Points flow: set points to 0 via API and refresh points display
const deletePoints = async (childId: string | number, evt?: Event) => {
evt?.stopPropagation()
closeMenu()
updatingPointsFor.value = childId
try {
const resp = await fetch(`/api/child/${childId}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ points: 0 }),
})
if (!resp.ok) {
throw new Error(`Failed to update points: ${resp.status}`)
}
// no need to refresh since we update optimistically via eventBus
} catch (err) {
console.error('Failed to delete points for child', childId, err)
} finally {
updatingPointsFor.value = null
}
}
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick, true)
revokeAllImageUrls()
})
</script>
<template>
<div>
<MessageBlock v-if="children.length === 0" message="No children">
<span v-if="!isParentAuthenticated">
<button class="round-btn" @click="eventBus.emit('open-login')">Switch</button> to parent
mode to create a child
</span>
<span v-else> <button class="round-btn" @click="createChild">Create</button> a child </span>
</MessageBlock>
<div v-else-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else class="grid">
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
<!-- kebab menu shown only for authenticated parent -->
<div v-if="isParentAuthenticated" class="kebab-wrap" @click.stop>
<!-- kebab button -->
<button
class="kebab-btn"
@mousedown.stop.prevent
@click="openMenu(child.id, $event)"
:aria-expanded="activeMenuFor === child.id ? 'true' : 'false'"
aria-label="Options"
>
</button>
<!-- menu items -->
<div
v-if="activeMenuFor === child.id"
class="kebab-menu"
@mousedown.stop.prevent
@click.stop
>
<button
class="menu-item"
@mousedown.stop.prevent
@click="openChildEditor(child, $event)"
>
Edit Child
</button>
<button
class="menu-item"
@mousedown.stop.prevent
@click="deletePoints(child.id, $event)"
:disabled="updatingPointsFor === child.id"
>
{{ updatingPointsFor === child.id ? 'Updating…' : 'Delete Points' }}
</button>
<button class="menu-item danger" @mousedown.stop.prevent @click="askDelete(child.id)">
Delete Child
</button>
</div>
</div>
<div class="card-content">
<h2>{{ child.name }}</h2>
<img
v-if="images.get(child.image_id)"
:src="images.get(child.image_id)"
alt="Child Image"
class="child-image"
/>
<p class="age">Age: {{ child.age }}</p>
<p class="points">Points: {{ child.points ?? 0 }}</p>
</div>
</div>
</div>
<DeleteModal
:show="confirmDeleteVisible"
message="Are you sure you want to delete this child?"
@confirm="performDelete"
@cancel="confirmDeleteVisible = false"
/>
<FloatingActionButton
v-if="isParentAuthenticated"
aria-label="Add Child"
@click="createChild"
/>
</div>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.2rem 1.2rem;
justify-items: center;
}
.card {
background: var(--card-bg);
box-shadow: var(--card-shadow);
border-radius: 12px;
overflow: visible; /* allow menu to overflow */
transition: all 0.3s ease;
display: flex;
flex-direction: column;
cursor: pointer;
position: relative; /* for kebab positioning */
width: 250px;
}
/* kebab button / menu (fixed-size button, absolutely positioned menu) */
.kebab-wrap {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
/* keep the wrapper only as a positioning context */
}
.kebab-btn {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
color: var(--kebab-icon-color);
border-radius: 6px;
box-sizing: border-box;
font-size: 1.5rem;
}
/* consistent focus ring without changing layout */
.kebab-btn:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
}
/* Menu overlays the card and does NOT alter flow */
.kebab-menu {
position: absolute;
top: 44px;
right: 0;
margin: 0;
min-width: 150px;
background: var(--kebab-menu-bg);
border: 1.5px solid var(--kebab-menu-border);
box-shadow: var(--kebab-menu-shadow);
backdrop-filter: blur(var(--kebab-menu-blur));
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 30;
}
.menu-item {
padding: 1.1rem 0.9rem; /* Increase vertical padding for bigger touch area */
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
font-weight: 600;
color: var(--menu-item-color);
font-size: 1.1rem; /* Slightly larger text for readability */
}
.menu-item + .menu-item {
margin-top: 0.5rem; /* Add space between menu items */
}
@media (max-width: 600px) {
.menu-item {
padding: 0.85rem 0.7rem;
font-size: 1rem;
}
.menu-item + .menu-item {
margin-top: 0.35rem;
}
}
.menu-item:hover {
background: var(--menu-item-hover-bg);
}
.menu-item.danger {
color: var(--menu-item-danger);
}
/* card content */
.card-content {
padding: 1.5rem;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.card h2 {
font-size: 1.5rem;
color: var(--card-title);
margin-bottom: 0.5rem;
word-break: break-word;
text-align: center;
}
.age {
font-size: 1.1rem;
color: var(--age-color);
font-weight: 500;
text-align: center;
}
.child-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
margin: 0 auto 1rem auto;
background: var(--child-image-bg);
}
.points {
font-size: 1.05rem;
color: var(--points-color);
margin-top: 0.4rem;
font-weight: 600;
text-align: center;
}
/* Loading, error, empty states */
.loading,
.empty {
margin: 1.2rem 0;
color: var(--list-loading-color);
font-size: 1.15rem;
font-weight: 600;
text-align: center;
line-height: 1.5;
}
.error {
color: var(--error);
margin-top: 0.7rem;
text-align: center;
background: var(--error-bg);
border-radius: 8px;
padding: 1rem;
}
.value {
font-weight: 600;
margin-left: 1rem;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="date-input-wrapper">
<button type="button" class="date-display-btn" @click="openPicker">
{{ displayLabel }}
</button>
<input
ref="pickerRef"
class="date-input-hidden"
type="date"
:value="modelValue"
:min="min"
@change="onChanged"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
modelValue: string
min?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const pickerRef = ref<HTMLInputElement | null>(null)
const displayLabel = computed(() => {
if (!props.modelValue) return 'Select date'
const [y, m, d] = props.modelValue.split('-').map(Number)
const date = new Date(y, m - 1, d)
if (isNaN(date.getTime())) return props.modelValue
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})
})
function openPicker() {
if (pickerRef.value) {
if (typeof pickerRef.value.showPicker === 'function') {
pickerRef.value.showPicker()
} else {
pickerRef.value.click()
}
}
}
function onChanged(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>
<style scoped>
.date-input-wrapper {
position: relative;
display: inline-flex;
}
.date-display-btn {
padding: 0.4rem 0.6rem;
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px;
background: var(--modal-bg, #fff);
color: var(--secondary, #7257b3);
font-size: 0.95rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
white-space: nowrap;
}
.date-display-btn:hover,
.date-display-btn:focus {
border-color: var(--btn-primary, #667eea);
}
.date-input-hidden {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
pointer-events: none;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,60 @@
<template>
<div class="modal-backdrop" v-if="show">
<div class="modal">
<p>{{ message }}</p>
<div class="actions">
<button @click="handleDelete" class="btn btn-danger" :disabled="deleting">
{{ deleting ? 'Deleting' : 'Delete' }}
</button>
<button @click="handleCancel" class="btn btn-secondary" :disabled="deleting">Cancel</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import '@/assets/styles.css'
const props = defineProps<{
show: boolean
message?: string
}>()
const emit = defineEmits(['confirm', 'cancel'])
const deleting = ref(false)
function handleDelete() {
deleting.value = true
emit('confirm')
}
function handleCancel() {
if (!deleting.value) emit('cancel')
}
// Reset deleting state when modal is closed
watch(
() => props.show,
(val) => {
if (!val) deleting.value = false
},
)
</script>
<style>
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
margin-bottom: 0.4rem;
}
.actions .btn {
padding: 1rem 2.2rem;
font-weight: 700;
font-size: 1.25rem;
min-width: 120px;
}
</style>

View File

@@ -0,0 +1,302 @@
<template>
<h2>{{ title ?? (isEdit ? `Edit ${entityLabel}` : `Create ${entityLabel}`) }}</h2>
<div v-if="loading" class="loading-message">Loading {{ entityLabel.toLowerCase() }}...</div>
<form v-else @submit.prevent="submit" class="entity-form" ref="formRef">
<template v-for="field in fields" :key="field.name">
<div class="group">
<ToggleField
v-if="field.type === 'toggle'"
:label="field.label"
:modelValue="formData[field.name]"
@update:modelValue="(val: boolean) => (formData[field.name] = val)"
:disabled="field.disabled"
:description="field.description"
:error="props.fieldErrors?.[field.name]"
/>
<label v-else :for="field.name">
{{ field.label }}
<!-- Custom field slot -->
<slot
:name="`custom-field-${field.name}`"
:modelValue="formData[field.name]"
:update="(val: unknown) => (formData[field.name] = val)"
>
<!-- Default rendering if no slot provided -->
<input
v-if="field.type === 'text'"
:id="field.name"
v-model="formData[field.name]"
type="text"
:required="field.required"
:maxlength="field.maxlength"
/>
<input
v-else-if="field.type === 'number'"
:id="field.name"
v-model="formData[field.name]"
type="number"
:required="field.required"
:min="field.min"
:max="field.max"
inputmode="numeric"
pattern="\\d{1,3}"
@input="
(e) => {
if (field.maxlength && e.target.value.length > field.maxlength) {
e.target.value = e.target.value.slice(0, field.maxlength)
formData[field.name] = e.target.value
}
}
"
/>
<ImagePicker
v-else-if="field.type === 'image'"
:id="field.name"
v-model="formData[field.name]"
:image-type="field.imageType || 1"
@add-image="onAddImage"
/>
</slot>
</label>
</div>
</template>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button type="button" class="btn btn-secondary" @click="onCancel" :disabled="loading">
Cancel
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="loading || !isValid || (props.requireDirty && !isDirty)"
>
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, watch, computed } from 'vue'
import ImagePicker from '@/components/utils/ImagePicker.vue'
import ToggleField from './ToggleField.vue'
import { useRouter } from 'vue-router'
import '@/assets/styles.css'
type Field = {
name: string
label: string
type: 'text' | 'number' | 'image' | 'custom' | 'toggle'
required?: boolean
maxlength?: number
min?: number
max?: number
imageType?: number
description?: string
disabled?: boolean
}
const props = withDefaults(
defineProps<{
entityLabel: string
fields: Field[]
initialData?: Record<string, any>
isEdit?: boolean
loading?: boolean
error?: string | null
title?: string
requireDirty?: boolean
fieldErrors?: Record<string, string>
}>(),
{
requireDirty: true,
},
)
const emit = defineEmits(['submit', 'cancel', 'add-image'])
const router = useRouter()
const formData = ref<Record<string, any>>({ ...props.initialData })
const baselineData = ref<Record<string, any>>({ ...props.initialData })
const formRef = ref<HTMLFormElement | null>(null)
async function focusFirstInput() {
await nextTick()
const firstInput = formRef.value?.querySelector<HTMLElement>('input, select, textarea')
firstInput?.focus()
}
onMounted(async () => {
await nextTick()
isDirty.value = false
if (!props.loading) {
focusFirstInput()
}
})
watch(
() => props.loading,
(newVal, oldVal) => {
if (!newVal && oldVal === true) {
focusFirstInput()
}
},
)
function onAddImage({ id, file }: { id: string; file: File }) {
emit('add-image', { id, file })
}
function onCancel() {
emit('cancel')
}
function submit() {
emit('submit', { ...formData.value })
// After submit, reset isDirty so Save button is disabled until next change
isDirty.value = false
}
// Editable field names (exclude custom fields that are not editable)
const editableFieldNames = props.fields
.filter((f) => f.type !== 'custom' || f.name === 'type')
.map((f) => f.name)
const isDirty = ref(false)
function getFieldByName(name: string): Field | undefined {
return props.fields.find((field) => field.name === name)
}
function valuesEqualForDirtyCheck(
fieldName: string,
currentValue: unknown,
initialValue: unknown,
): boolean {
const field = getFieldByName(fieldName)
if (field?.type === 'number') {
const currentEmpty = currentValue === '' || currentValue === null || currentValue === undefined
const initialEmpty = initialValue === '' || initialValue === null || initialValue === undefined
if (currentEmpty && initialEmpty) return true
if (currentEmpty !== initialEmpty) return false
return Number(currentValue) === Number(initialValue)
}
return JSON.stringify(currentValue) === JSON.stringify(initialValue)
}
function checkDirty() {
isDirty.value = editableFieldNames.some((key) => {
return !valuesEqualForDirtyCheck(key, formData.value[key], baselineData.value[key])
})
}
// Validation logic
const isValid = computed(() => {
return props.fields.every((field) => {
if (!field.required) return true
const value = formData.value[field.name]
if (field.type === 'text') {
return typeof value === 'string' && value.trim().length > 0
}
if (field.type === 'number') {
if (value === '' || value === null || value === undefined) return false
const numValue = Number(value)
if (isNaN(numValue)) return false
if (field.min !== undefined && numValue < field.min) return false
if (field.max !== undefined && numValue > field.max) return false
return true
}
// For other types, just check it's not null/undefined
return value != null
})
})
watch(
() => ({ ...formData.value }),
() => {
checkDirty()
},
{ deep: true },
)
watch(
() => props.initialData,
(newVal) => {
if (newVal) {
formData.value = { ...newVal }
baselineData.value = { ...newVal }
isDirty.value = false
}
},
{ immediate: true, deep: true },
)
</script>
<style scoped>
h2 {
text-align: center;
margin-bottom: 1.5rem;
color: var(--form-heading);
}
.entity-edit-view {
max-width: 420px;
margin: 0 auto;
background: var(--edit-view-bg, #fff);
border-radius: 14px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 2.2rem 2.2rem 1.5rem 2.2rem;
}
.entity-edit-view h2 {
text-align: center;
margin-bottom: 1.5rem;
color: var(--form-heading);
}
.entity-form .group {
margin-bottom: 1.2rem;
}
.entity-form label {
display: block;
font-weight: 600;
color: var(--form-label, #444);
margin-bottom: 0.4rem;
}
.entity-form input[type='text'],
.entity-form input[type='number'] {
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;
}
.actions {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
margin-bottom: 0.4rem;
}
.actions .btn {
padding: 1rem 2.2rem;
font-weight: 700;
font-size: 1.25rem;
min-width: 120px;
}
.error {
color: var(--error-color, #e53e3e);
margin-bottom: 1rem;
font-size: 1rem;
}
.loading-message {
color: var(--loading-color, #888);
margin-bottom: 1.2rem;
font-size: 1rem;
}
</style>

View File

@@ -0,0 +1,6 @@
<template>
<div v-if="message" class="error-message" aria-live="polite">{{ message }}</div>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>

View File

@@ -0,0 +1,44 @@
<template>
<button class="fab" @click="$emit('click')" :aria-label="ariaLabel">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</template>
<script setup lang="ts">
defineProps<{ ariaLabel?: string }>()
</script>
<style scoped>
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--fab-bg);
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 24px;
z-index: 1300;
}
.fab:hover {
background: var(--fab-hover-bg);
}
.fab:active {
background: var(--fab-active-bg);
}
svg {
display: block;
}
</style>

View File

@@ -0,0 +1,280 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { getCachedImageUrl } from '@/common/imageCache'
const props = defineProps<{
fetchUrl: string
itemKey: string
itemFields: readonly string[]
imageFields?: readonly string[]
selectable?: boolean
deletable?: boolean
onClicked?: (item: any) => void
onDelete?: (id: string) => void
filterFn?: (item: any) => boolean
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
testItems?: any[] // <-- for test injection
}>()
const emit = defineEmits(['clicked', 'delete', 'loading-complete'])
const items = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const selectedItems = ref<string[]>([])
function refresh() {
fetchItems()
}
defineExpose({
items,
selectedItems,
refresh,
})
const fetchItems = async () => {
loading.value = true
error.value = null
try {
// Use testItems if provided
let itemList = props.testItems ?? []
if (!itemList.length) {
const resp = await fetch(props.fetchUrl)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
itemList = data[props.itemKey || 'items'] || []
}
if (props.filterFn) itemList = itemList.filter(props.filterFn)
const initiallySelected: string[] = []
await Promise.all(
itemList.map(async (item: any) => {
if (props.imageFields) {
for (const field of props.imageFields) {
if (item[field]) {
try {
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
} catch {
console.error('Error fetching image for item', item.id)
item[`${field.replace('_id', '_url')}`] = null
}
}
}
} else if (item.image_id) {
try {
item.image_url = await getCachedImageUrl(item.image_id)
} catch {
item.image_url = null
}
}
if (props.selectable && item.assigned === true) {
initiallySelected.push(item.id)
}
}),
)
items.value = itemList
if (props.selectable) {
selectedItems.value = initiallySelected
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = []
if (props.selectable) selectedItems.value = []
} finally {
emit('loading-complete', items.value.length)
loading.value = false
}
}
onMounted(fetchItems)
watch(() => props.fetchUrl, fetchItems)
const handleClicked = (item: any) => {
if (props.selectable) {
const idx = selectedItems.value.indexOf(item.id)
if (idx === -1) {
selectedItems.value.push(item.id)
} else {
selectedItems.value.splice(idx, 1)
}
}
emit('clicked', item)
props.onClicked?.(item)
}
const handleDelete = (item: any) => {
emit('delete', item)
props.onDelete?.(item)
}
</script>
<template>
<div class="listbox">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="items.length === 0" class="empty">No items found.</div>
<div v-else>
<div v-for="(item, idx) in items" :key="item.id" class="list-row">
<div :class="['list-item', props.getItemClass?.(item)]" @click.stop="handleClicked(item)">
<slot name="item" :item="item">
<!-- Default rendering if no slot is provided -->
<img v-if="item.image_url" :src="item.image_url" />
<span class="list-name">List Item</span>
<span class="list-value">1</span>
</slot>
<div v-if="props.selectable || props.deletable" class="interact">
<input
v-if="props.selectable"
type="checkbox"
class="list-checkbox"
v-model="selectedItems"
:value="item.id"
@click.stop
/>
<button
v-if="props.deletable && item.user_id"
class="delete-btn"
@click.stop="handleDelete(item)"
aria-label="Delete item"
type="button"
>
<!-- SVG icon here -->
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div v-if="idx < items.length - 1" class="list-separator"></div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.listbox {
flex: 0 1 auto;
max-width: 480px;
width: 100%;
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
background: var(--list-bg);
padding: 0.2rem 0.2rem 0.2rem;
border-radius: 12px;
}
.list-item {
display: flex;
align-items: center;
border: 2px outset var(--list-item-border-reward);
border-radius: 8px;
padding: 0.2rem 1rem;
background: var(--list-item-bg);
font-size: 1.05rem;
font-weight: 500;
transition: border 0.18s;
margin-bottom: 0.2rem;
margin-left: 0.2rem;
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
justify-content: space-between;
cursor: pointer;
}
.list-item .interact {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Image styles */
:deep(.list-item img) {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 0.7rem;
background: var(--list-image-bg);
flex-shrink: 0;
}
/* Name/label styles */
.list-name {
flex: 1;
text-align: left;
font-weight: 600;
}
/* Points/cost/requested text */
.list-value {
min-width: 60px;
text-align: right;
font-weight: 600;
}
.delete-btn {
background: transparent;
border: none;
border-radius: 50%;
margin-left: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
transition:
background 0.15s,
box-shadow 0.15s;
width: 2rem;
height: 2rem;
opacity: 0.92;
}
.delete-btn:hover {
background: var(--delete-btn-hover-bg);
box-shadow: 0 0 0 2px var(--delete-btn-hover-shadow);
opacity: 1;
}
.delete-btn svg {
display: block;
}
/* Checkbox */
.list-checkbox {
margin-left: 1rem;
width: 1.2em;
height: 1.2em;
accent-color: var(--checkbox-accent);
cursor: pointer;
}
/* Loading, error, empty states */
.loading,
.empty {
margin: 1.2rem 0;
color: var(--list-loading-color);
font-size: 1.15rem;
font-weight: 600;
text-align: center;
line-height: 1.5;
}
.error {
color: var(--error);
margin-top: 0.7rem;
text-align: center;
background: var(--error-bg);
border-radius: 8px;
padding: 1rem;
}
/* Separator (if needed) */
.list-separator {
height: 0px;
background: #0000;
margin: 0rem 0.2rem;
border-radius: 0px;
}
</style>

View File

@@ -0,0 +1,641 @@
<script setup lang="ts">
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { eventBus } from '@/common/eventBus'
import {
authenticateParent,
isParentAuthenticated,
isParentPersistent,
logoutParent,
logoutUser,
consumePendingReturnUrl,
hasPendingReturnUrl,
clearPendingReturnUrl,
} from '../../stores/auth'
import { getCachedImageBlob } from '@/common/imageCache'
import '@/assets/styles.css'
import ModalDialog from './ModalDialog.vue'
import {
subscribeToPushWithResult,
unsubscribeFromPush,
isPushOptedOut,
} from '@/services/pushSubscription'
const router = useRouter()
const show = ref(false)
const pin = ref('')
const error = ref('')
const stayInParentMode = ref(false)
const pinInput = ref<HTMLInputElement | null>(null)
const dropdownOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const avatarButtonRef = ref<HTMLButtonElement | null>(null)
const focusedMenuIndex = ref(0)
// User profile data
const userImageId = ref<string | null>(null)
const userFirstName = ref<string>('')
const userEmail = ref<string>('')
const profileLoading = ref(true)
const avatarImageUrl = ref<string | null>(null)
const dropdownAvatarImageUrl = ref<string | null>(null)
// Compute avatar initial
const avatarInitial = ref<string>('?')
// Fetch user profile
async function fetchUserProfile() {
try {
const res = await fetch('/api/user/profile', { credentials: 'include' })
if (!res.ok) {
console.error('Failed to fetch user profile')
profileLoading.value = false
return
}
const data = await res.json()
userImageId.value = data.image_id || null
userFirstName.value = data.first_name || ''
userEmail.value = data.email || ''
// Update avatar initial
avatarInitial.value = userFirstName.value ? userFirstName.value.charAt(0).toUpperCase() : '?'
profileLoading.value = false
// Load cached image if available
if (userImageId.value) {
await loadAvatarImages(userImageId.value)
}
} catch (e) {
console.error('Error fetching user profile:', e)
profileLoading.value = false
}
}
async function loadAvatarImages(imageId: string) {
try {
const blob = await getCachedImageBlob(imageId)
// Clean up previous URLs
if (avatarImageUrl.value) URL.revokeObjectURL(avatarImageUrl.value)
if (dropdownAvatarImageUrl.value) URL.revokeObjectURL(dropdownAvatarImageUrl.value)
avatarImageUrl.value = URL.createObjectURL(blob)
dropdownAvatarImageUrl.value = URL.createObjectURL(blob)
} catch (e) {
avatarImageUrl.value = null
dropdownAvatarImageUrl.value = null
}
}
const open = async () => {
// Check if user has a pin
try {
const res = await fetch('/api/user/has-pin', { credentials: 'include' })
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Error checking PIN')
if (!data.has_pin) {
console.log('No PIN set, redirecting to setup')
// Route to PIN setup view
router.push('/parent/pin-setup')
return
}
} catch (e) {
error.value = 'Network error'
return
}
pin.value = ''
error.value = ''
show.value = true
await nextTick()
pinInput.value?.focus()
}
const close = () => {
show.value = false
error.value = ''
stayInParentMode.value = false
clearPendingReturnUrl()
}
const submit = async () => {
const isDigits = /^\d{4,6}$/.test(pin.value)
if (!isDigits) {
error.value = 'Enter 46 digits'
return
}
try {
const res = await fetch('/api/user/check-pin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: pin.value }),
credentials: 'include',
})
const data = await res.json()
if (!res.ok) {
error.value = data.error || 'Error validating PIN'
return
}
if (!data.valid) {
error.value = 'Incorrect PIN'
pin.value = ''
await nextTick()
pinInput.value?.focus()
return
}
// Authenticate parent and navigate
authenticateParent(stayInParentMode.value)
if (!isPushOptedOut()) {
subscribeToPushWithResult() // fire-and-forget — browser gesture is satisfied by the PIN button click
}
const returnUrl = consumePendingReturnUrl()
close()
router.push(returnUrl || '/parent')
} catch (e) {
error.value = 'Network error'
}
}
function handlePinInput(event: Event) {
const target = event.target as HTMLInputElement
pin.value = target.value.replace(/\D/g, '').slice(0, 6)
}
const handleLogout = () => {
unsubscribeFromPush()
logoutParent()
router.push('/child')
}
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
if (dropdownOpen.value) {
focusedMenuIndex.value = 0
}
}
function closeDropdown() {
dropdownOpen.value = false
focusedMenuIndex.value = 0
avatarButtonRef.value?.focus()
}
function handleKeyDown(event: KeyboardEvent) {
if (!dropdownOpen.value) {
// Handle avatar button keyboard
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
if (isParentAuthenticated.value) {
toggleDropdown()
} else {
open()
}
}
return
}
// Handle dropdown keyboard navigation
const menuItems = 3 // Profile, Child Mode, Sign Out
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusedMenuIndex.value = (focusedMenuIndex.value + 1) % menuItems
break
case 'ArrowUp':
event.preventDefault()
focusedMenuIndex.value = (focusedMenuIndex.value - 1 + menuItems) % menuItems
break
case 'Enter':
case ' ':
event.preventDefault()
executeMenuItem(focusedMenuIndex.value)
break
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'Tab':
closeDropdown()
break
}
}
function executeMenuItem(index: number) {
switch (index) {
case 0:
goToProfile()
break
case 1:
handleLogout()
closeDropdown()
break
case 2:
signOut()
break
}
}
async function signOut() {
try {
await fetch('/api/auth/logout', { method: 'POST' })
logoutParent()
logoutUser()
router.push('/')
} catch {
// Optionally show error
}
closeDropdown()
}
function goToProfile() {
router.push('/parent/profile')
closeDropdown()
}
function handleClickOutside(event: MouseEvent) {
if (
dropdownOpen.value &&
dropdownRef.value &&
!dropdownRef.value.contains(event.target as Node)
) {
closeDropdown()
}
}
onMounted(() => {
eventBus.on('open-login', open)
eventBus.on('profile_updated', fetchUserProfile)
document.addEventListener('mousedown', handleClickOutside)
fetchUserProfile()
if (!isParentAuthenticated.value && hasPendingReturnUrl()) {
open()
}
})
onUnmounted(() => {
eventBus.off('open-login', open)
eventBus.off('profile_updated', fetchUserProfile)
document.removeEventListener('mousedown', handleClickOutside)
// Revoke object URL to free memory
if (avatarImageUrl.value) URL.revokeObjectURL(avatarImageUrl.value)
if (dropdownAvatarImageUrl.value) URL.revokeObjectURL(dropdownAvatarImageUrl.value)
})
</script>
<template>
<div style="position: relative">
<button
ref="avatarButtonRef"
@click="isParentAuthenticated ? toggleDropdown() : open()"
@keydown="handleKeyDown"
:aria-label="isParentAuthenticated ? 'Parent menu' : 'Parent login'"
aria-haspopup="menu"
:aria-expanded="isParentAuthenticated && dropdownOpen ? 'true' : 'false'"
class="avatar-btn"
>
<img
v-if="avatarImageUrl && !profileLoading"
:src="avatarImageUrl"
:alt="userFirstName || 'User avatar'"
class="avatar-image"
/>
<span v-else class="avatar-text">{{ profileLoading ? '...' : avatarInitial }}</span>
</button>
<span
v-if="isParentAuthenticated && isParentPersistent"
class="persistent-badge"
aria-label="Persistent parent mode active"
>🔒</span
>
<Transition name="slide-fade">
<div
v-if="isParentAuthenticated && dropdownOpen"
ref="dropdownRef"
role="menu"
class="dropdown-menu"
>
<div class="dropdown-header">
<img
v-if="avatarImageUrl"
:src="avatarImageUrl"
:alt="userFirstName || 'User avatar'"
class="dropdown-avatar"
/>
<span v-else class="dropdown-avatar-initial">{{ avatarInitial }}</span>
<div class="dropdown-user-info">
<div class="dropdown-user-name">{{ userFirstName || 'User' }}</div>
<div v-if="userEmail" class="dropdown-user-email">{{ userEmail }}</div>
</div>
</div>
<button
role="menuitem"
:aria-selected="focusedMenuIndex === 0"
:class="['menu-item', { focused: focusedMenuIndex === 0 }]"
@mouseenter="focusedMenuIndex = 0"
@click="goToProfile"
>
<span class="menu-icon-stub">
<img
src="/profile.png"
alt="Profile"
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
/>
</span>
<span>Profile</span>
</button>
<button
role="menuitem"
:aria-selected="focusedMenuIndex === 1"
:class="['menu-item', { focused: focusedMenuIndex === 1 }]"
@mouseenter="focusedMenuIndex = 1"
@click="
() => {
handleLogout()
closeDropdown()
}
"
>
<span class="menu-icon-stub">
<img
src="/child-mode.png"
alt="Child Mode"
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
/>
</span>
<span>Child Mode</span>
</button>
<button
role="menuitem"
:aria-selected="focusedMenuIndex === 2"
:class="['menu-item', 'danger', { focused: focusedMenuIndex === 2 }]"
@mouseenter="focusedMenuIndex = 2"
@click="signOut"
>
<span class="menu-icon-stub">
<img
src="/sign-out.png"
alt="Sign out"
style="width: 20px; height: 20px; object-fit: contain; vertical-align: middle"
/>
</span>
<span>Sign out</span>
</button>
</div>
</Transition>
<ModalDialog v-if="show" title="Enter parent PIN" @click.self="close" @close="close">
<form @submit.prevent="submit">
<input
ref="pinInput"
v-model="pin"
@input="handlePinInput"
inputmode="numeric"
pattern="\d*"
maxlength="6"
placeholder="46 digits"
class="pin-input"
/>
<label class="stay-label">
<input type="checkbox" v-model="stayInParentMode" class="stay-checkbox" />
Stay in parent mode on this device
</label>
<div class="actions modal-actions">
<button type="button" class="btn btn-secondary" @click="close">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="pin.length < 4">OK</button>
</div>
</form>
<div v-if="error" class="error modal-message">{{ error }}</div>
</ModalDialog>
</div>
</template>
<style scoped>
.error {
color: var(--error);
}
.avatar-btn {
width: 44px;
min-width: 44px;
max-width: 44px;
height: 44px;
min-height: 44px;
max-height: 44px;
margin: 0;
background: var(--button-bg, #fff);
border: 0;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: hidden;
padding: 0;
transition:
transform 0.18s,
box-shadow 0.18s;
}
.avatar-btn:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.avatar-btn:focus-visible {
outline: 3px solid var(--primary, #667eea);
outline-offset: 2px;
}
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.avatar-text {
font-size: 1.5rem;
font-weight: 700;
color: var(--button-text, #667eea);
user-select: none;
}
@media (max-width: 480px) {
.avatar-text {
font-size: 1.2rem;
}
}
.pin-input {
width: 100%;
padding: 0.5rem 0.6rem;
font-size: 1rem;
border-radius: 8px;
border: 1px solid #e6e6e6;
margin-bottom: 0.8rem;
box-sizing: border-box;
text-align: center;
}
.stay-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--form-label, #444);
cursor: pointer;
margin-bottom: 1rem;
user-select: none;
}
.stay-checkbox {
width: 16px;
height: 16px;
accent-color: var(--btn-primary, #667eea);
cursor: pointer;
flex-shrink: 0;
}
.persistent-badge {
position: absolute;
bottom: -2px;
left: -2px;
font-size: 10px;
line-height: 1;
pointer-events: none;
user-select: none;
}
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 8px);
background: var(--form-bg, #fff);
border: 1px solid var(--form-input-border, #cbd5e1);
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
min-width: 240px;
z-index: 100;
overflow: hidden;
}
.dropdown-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--header-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
color: #fff;
}
.dropdown-avatar,
.dropdown-avatar-initial {
width: 48px;
height: 48px;
min-width: 48px;
min-height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.3);
}
.dropdown-avatar-initial {
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
font-size: 1.5rem;
font-weight: 700;
color: #fff;
}
.dropdown-user-info {
flex: 1;
min-width: 0;
}
.dropdown-user-name {
font-weight: 700;
font-size: 1.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dropdown-user-email {
font-size: 0.85rem;
opacity: 0.9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
font-weight: 600;
color: var(--form-label, #444);
font-size: 0.95rem;
transition: background 0.15s;
}
.menu-item.focused {
background: var(--btn-secondary-hover, #e2e8f0);
}
.menu-item:focus-visible {
outline: 2px solid var(--primary, #667eea);
outline-offset: -2px;
}
.menu-item.danger {
color: var(--btn-danger, #ef4444);
}
.menu-icon-stub {
width: 20px;
height: 20px;
background: var(--form-input-bg, #f8fafc);
border-radius: 4px;
flex-shrink: 0;
}
/* Slide fade animation */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.2s ease;
}
.slide-fade-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.slide-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
@media (max-width: 600px) {
.menu-item {
padding: 12px 14px;
font-size: 1rem;
}
.dropdown-menu {
min-width: 200px;
}
}
</style>

View File

@@ -0,0 +1,27 @@
<template>
<div class="message-block">
<div>{{ message }}</div>
<div class="sub-message">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>
<style scoped>
.message-block {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: var(--message-block-color);
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: var(--sub-message-color);
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<div class="modal-backdrop" @click.self="$emit('backdrop-click')">
<div class="modal-dialog">
<div class="modal-heading">
<img v-if="imageUrl" :src="imageUrl" alt="Dialog Image" class="modal-image" />
<div class="modal-details">
<div class="modal-title">{{ title }}</div>
<div class="modal-subtitle">{{ subtitle }}</div>
</div>
</div>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
imageUrl?: string | null | undefined
title?: string
subtitle?: string | null | undefined
}>()
defineEmits<{
'backdrop-click': []
}>()
</script>
<style scoped>
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: var(--modal-bg, #fff);
padding: 2rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
max-width: 340px;
text-align: center;
}
.modal-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: var(--info-image-bg);
}
.modal-heading {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.modal-details {
display: flex;
flex-direction: column;
align-items: center;
margin: auto;
}
.modal-title {
font-weight: 600;
font-size: 1.1rem;
}
.modal-subtitle {
color: var(--info-points);
font-weight: 500;
font-size: 1rem;
}
/* Encapsulated slot content styles */
.modal-message {
margin-bottom: 1.2rem;
font-size: 1rem;
color: var(--modal-message-color, #333);
}
:deep(.modal-actions) {
display: flex;
gap: 3rem;
justify-content: center;
margin-top: 0.5rem;
}
:deep(.modal-actions button) {
padding: 1rem 2.2rem;
border-radius: 12px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1.25rem;
transition: background 0.18s;
min-width: 120px;
}
@media (max-width: 480px) {
:deep(.modal-actions) {
gap: 1.2rem;
}
:deep(.modal-actions button) {
padding: 0.8rem 1.2rem;
font-size: 1.05rem;
min-width: 90px;
}
}
</style>

View File

@@ -0,0 +1,749 @@
<template>
<ModalDialog :image-url="task.image_url" :title="'Schedule Chore'" :subtitle="task.name">
<!-- Enable/disable toggle row -->
<div class="schedule-toggle-row">
<span class="toggle-label">{{ scheduleEnabled ? 'Enabled' : 'Paused' }}</span>
<button
type="button"
class="toggle-track"
:class="{ on: scheduleEnabled }"
role="switch"
:aria-checked="scheduleEnabled"
@click="scheduleEnabled = !scheduleEnabled"
>
<span class="toggle-thumb" />
</button>
</div>
<!-- Form body dims when paused -->
<div class="schedule-body" :class="{ disabled: !scheduleEnabled }">
<!-- Mode toggle -->
<div class="mode-toggle">
<button :class="['mode-btn', { active: mode === 'days' }]" @click="mode = 'days'">
Specific Days
</button>
<button :class="['mode-btn', { active: mode === 'interval' }]" @click="mode = 'interval'">
Every X Days
</button>
</div>
<!-- Specific Days form -->
<div v-if="mode === 'days'" class="days-form">
<!-- Day chips -->
<div class="day-chips">
<button
v-for="(label, idx) in DAY_CHIP_LABELS"
:key="idx"
type="button"
:class="['chip', { active: selectedDays.has(idx) }]"
@click="toggleDay(idx)"
>
{{ label }}
</button>
</div>
<!-- Default deadline -->
<div v-if="selectedDays.size > 0" class="default-deadline-row">
<span class="field-label">Deadline</span>
<TimePickerPopover
v-if="hasDefaultDeadline"
:modelValue="defaultTime"
@update:modelValue="onDefaultTimeChanged"
/>
<span v-else class="anytime-label">Anytime</span>
<button type="button" class="link-btn" @click="hasDefaultDeadline = !hasDefaultDeadline">
{{ hasDefaultDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
</button>
</div>
<!-- Selected day exception list -->
<div v-if="selectedDays.size > 0" class="exception-list">
<div v-for="idx in sortedSelectedDays" :key="idx" class="exception-row">
<span class="exception-day-name">{{ DAY_LABELS[idx] }}</span>
<div class="exception-right">
<template v-if="exceptions.has(idx)">
<TimePickerPopover
:modelValue="exceptions.get(idx)!"
@update:modelValue="(val) => exceptions.set(idx, val)"
/>
<button type="button" class="link-btn reset-btn" @click="exceptions.delete(idx)">
Reset to default
</button>
</template>
<template v-else>
<span class="default-label">{{
hasDefaultDeadline ? formatDefaultTime : 'Anytime'
}}</span>
<button
type="button"
class="link-btn"
@click="exceptions.set(idx, { ...defaultTime })"
>
Set different time
</button>
</template>
</div>
</div>
</div>
</div>
<!-- Interval form -->
<div v-else class="interval-form">
<!-- Frequency stepper + anchor date -->
<div class="interval-group">
<div class="interval-row">
<label class="field-label">Every</label>
<div class="stepper">
<button
type="button"
class="stepper-btn"
@click="decrementInterval"
:disabled="intervalDays <= 1"
>
</button>
<span class="stepper-value">{{ intervalDays }}</span>
<button
type="button"
class="stepper-btn"
@click="incrementInterval"
:disabled="intervalDays >= 7"
>
+
</button>
</div>
<span class="field-label">{{ intervalDays === 1 ? 'day' : 'days' }}</span>
</div>
<div class="interval-row">
<label class="field-label">Starting on</label>
<DateInputField
:modelValue="anchorDate"
:min="todayISO"
@update:modelValue="anchorDate = $event"
/>
</div>
</div>
<!-- Next occurrence preview -->
<div v-if="nextOccurrence" class="next-occurrence-row">
<span class="next-occurrence-label">Next occurrence: {{ nextOccurrence }}</span>
</div>
<!-- Deadline row -->
<div class="interval-time-row">
<label class="field-label">Deadline</label>
<TimePickerPopover
v-if="hasDeadline"
:modelValue="intervalTime"
@update:modelValue="intervalTime = $event"
/>
<span v-else class="anytime-label">Anytime</span>
<button type="button" class="link-btn" @click="toggleDeadline">
{{ hasDeadline ? 'Clear (Anytime)' : 'Set deadline' }}
</button>
</div>
</div>
<p v-if="errorMsg" class="error-msg">{{ errorMsg }}</p>
</div>
<!-- /schedule-body -->
<!-- Actions outside dim wrapper so they stay interactive -->
<div class="modal-actions">
<button class="btn btn-secondary" @click="$emit('cancelled')" :disabled="saving">
Cancel
</button>
<button class="btn-primary" @click="save" :disabled="saving || !isValid || !isDirty">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
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'
interface TimeValue {
hour: number
minute: number
}
const props = defineProps<{
task: ChildTask
childId: string
schedule: ChoreSchedule | null
}>()
const emit = defineEmits<{
(e: 'saved'): void
(e: 'cancelled'): void
}>()
const DAY_LABELS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const DAY_CHIP_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
// ── local state ──────────────────────────────────────────────────────────────
const mode = ref<'days' | 'interval'>(props.schedule?.mode ?? 'days')
// days mode — three-layer state
function initDaysState(schedule: ChoreSchedule | null): {
days: Set<number>
base: TimeValue
exMap: Map<number, TimeValue>
} {
const days = new Set<number>()
const exMap = new Map<number, TimeValue>()
let base: TimeValue = { hour: 8, minute: 0 }
if (schedule?.mode === 'days') {
// Load the persisted default time (falls back to 8:00 AM for old records)
base = { hour: schedule.default_hour ?? 8, minute: schedule.default_minute ?? 0 }
for (const dc of schedule.day_configs) {
days.add(dc.day)
if (dc.hour !== base.hour || dc.minute !== base.minute) {
exMap.set(dc.day, { hour: dc.hour, minute: dc.minute })
}
}
}
return { days, base, exMap }
}
const { days: _days, base: _base, exMap: _exMap } = initDaysState(props.schedule)
const selectedDays = ref<Set<number>>(_days)
const defaultTime = ref<TimeValue>(_base)
const exceptions = ref<Map<number, TimeValue>>(_exMap)
const hasDefaultDeadline = ref<boolean>(props.schedule?.default_has_deadline ?? true)
const scheduleEnabled = ref<boolean>(props.schedule?.enabled ?? true)
// ── helpers (date) ───────────────────────────────────────────────────────────
function getTodayISO(): string {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
// interval mode
const intervalDays = ref<number>(Math.max(1, props.schedule?.interval_days ?? 1))
const anchorDate = ref<string>(props.schedule?.anchor_date || getTodayISO())
const hasDeadline = ref<boolean>(props.schedule?.interval_has_deadline ?? true)
const intervalTime = ref<TimeValue>({
hour: props.schedule?.interval_hour ?? 8,
minute: props.schedule?.interval_minute ?? 0,
})
const saving = ref(false)
const errorMsg = ref<string | null>(null)
// ── original snapshot (for dirty detection) ──────────────────────────────────
const origMode = props.schedule?.mode ?? 'days'
const origDays = new Set(_days)
const origDefaultTime: TimeValue = { ..._base }
const origHasDefaultDeadline = props.schedule?.default_has_deadline ?? true
const origExceptions = new Map<number, TimeValue>(
Array.from(_exMap.entries()).map(([k, v]) => [k, { ...v }]),
)
const origIntervalDays = Math.max(1, props.schedule?.interval_days ?? 1)
const origAnchorDate = props.schedule?.anchor_date || getTodayISO()
const origHasDeadline = props.schedule?.interval_has_deadline ?? true
const origIntervalTime: TimeValue = {
hour: props.schedule?.interval_hour ?? 8,
minute: props.schedule?.interval_minute ?? 0,
}
const origEnabled = props.schedule?.enabled ?? true
// ── computed ─────────────────────────────────────────────────────────────────
const sortedSelectedDays = computed(() => Array.from(selectedDays.value).sort((a, b) => a - b))
const todayISO = getTodayISO()
const nextOccurrence = computed((): string | null => {
if (!anchorDate.value) return null
const [y, m, d] = anchorDate.value.split('-').map(Number)
const anchor = new Date(y, m - 1, d, 0, 0, 0, 0)
if (isNaN(anchor.getTime())) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const fmt = (dt: Date) =>
dt.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})
// Find the first hit date strictly after today
for (let i = 1; i <= 365; i++) {
const cur = new Date(anchor.getTime() + i * 86_400_000)
const diffDays = Math.round((cur.getTime() - anchor.getTime()) / 86_400_000)
if (diffDays % intervalDays.value === 0 && cur >= today) {
return fmt(cur)
}
}
return null
})
const formatDefaultTime = computed(() => {
const h = defaultTime.value.hour % 12 || 12
const m = String(defaultTime.value.minute).padStart(2, '0')
const period = defaultTime.value.hour < 12 ? 'AM' : 'PM'
return `${String(h).padStart(2, '0')}:${m} ${period}`
})
// ── helpers ──────────────────────────────────────────────────────────────────
function toggleDay(dayIdx: number) {
if (selectedDays.value.has(dayIdx)) {
selectedDays.value.delete(dayIdx)
exceptions.value.delete(dayIdx)
// trigger reactivity
selectedDays.value = new Set(selectedDays.value)
} else {
const next = new Set(selectedDays.value)
next.add(dayIdx)
selectedDays.value = next
}
}
function onDefaultTimeChanged(val: TimeValue) {
defaultTime.value = val
}
function decrementInterval() {
if (intervalDays.value > 1) intervalDays.value--
}
function incrementInterval() {
if (intervalDays.value < 7) intervalDays.value++
}
function toggleDeadline() {
hasDeadline.value = !hasDeadline.value
}
// ── validation + dirty ───────────────────────────────────────────────────────
const isValid = computed(() => {
// days mode: 0 selections is valid — means "unschedule" (always active)
if (mode.value === 'interval') {
return intervalDays.value >= 1 && intervalDays.value <= 7
}
return true
})
const isDirty = computed(() => {
if (scheduleEnabled.value !== origEnabled) return true
if (mode.value !== origMode) return true
if (mode.value === 'days') {
// Selected days set changed
if (selectedDays.value.size !== origDays.size) return true
for (const d of selectedDays.value) {
if (!origDays.has(d)) return true
}
// Default time changed
if (
defaultTime.value.hour !== origDefaultTime.hour ||
defaultTime.value.minute !== origDefaultTime.minute
)
return true
// Default deadline toggled
if (hasDefaultDeadline.value !== origHasDefaultDeadline) return true
// Exceptions changed
if (exceptions.value.size !== origExceptions.size) return true
for (const [day, t] of exceptions.value) {
const orig = origExceptions.get(day)
if (!orig || orig.hour !== t.hour || orig.minute !== t.minute) return true
}
return false
} else {
if (intervalDays.value !== origIntervalDays) return true
if (anchorDate.value !== origAnchorDate) return true
if (hasDeadline.value !== origHasDeadline) return true
if (
hasDeadline.value &&
(intervalTime.value.hour !== origIntervalTime.hour ||
intervalTime.value.minute !== origIntervalTime.minute)
)
return true
return false
}
})
// ── save ─────────────────────────────────────────────────────────────────────
async function save() {
if (!isValid.value || !isDirty.value || saving.value) return
errorMsg.value = null
saving.value = true
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)
} else if (mode.value === 'days') {
const day_configs: DayConfig[] = Array.from(selectedDays.value).map((day) => {
const override = exceptions.value.get(day)
return {
day,
hour: override?.hour ?? defaultTime.value.hour,
minute: override?.minute ?? defaultTime.value.minute,
}
})
res = await setChoreSchedule(props.childId, props.task.id, {
mode: 'days',
enabled: scheduleEnabled.value,
day_configs,
default_hour: defaultTime.value.hour,
default_minute: defaultTime.value.minute,
default_has_deadline: hasDefaultDeadline.value,
})
} else {
res = await setChoreSchedule(props.childId, props.task.id, {
mode: 'interval',
enabled: scheduleEnabled.value,
interval_days: intervalDays.value,
anchor_date: anchorDate.value,
interval_has_deadline: hasDeadline.value,
interval_hour: intervalTime.value.hour,
interval_minute: intervalTime.value.minute,
})
}
saving.value = false
if (res.ok) {
emit('saved')
} else {
const { msg } = await parseErrorResponse(res)
errorMsg.value = msg
}
}
</script>
<style scoped>
.mode-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1.2rem;
}
.mode-btn {
flex: 1;
padding: 0.55rem 0.4rem;
border: 1.5px solid var(--primary, #667eea);
border-radius: 6px;
background: transparent;
color: var(--primary, #667eea);
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.mode-btn.active {
background: var(--btn-primary, #667eea);
color: #fff;
}
/* Days form */
.days-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.day-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
min-width: 2.4rem;
padding: 0.4rem 0.5rem;
border: 1.5px solid var(--primary, #667eea);
border-radius: 999px;
background: transparent;
color: var(--primary, #667eea);
font-size: 0.85rem;
font-weight: 700;
cursor: pointer;
text-align: center;
transition:
background 0.15s,
color 0.15s;
font-family: inherit;
}
.chip:hover {
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
}
.chip.active {
background: var(--btn-primary, #667eea);
color: #fff;
}
.default-deadline-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.exception-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.exception-row {
display: flex;
align-items: center;
gap: 0.4rem;
}
.exception-right {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
}
.exception-day-name {
font-size: 0.9rem;
font-weight: 700;
color: var(--secondary, #7257b3);
min-width: 75px;
}
.default-label {
font-size: 0.85rem;
color: var(--form-label, #888);
font-style: italic;
}
.link-btn {
display: inline-flex;
align-items: center;
background: none;
border: none;
padding: 0.6rem 0.5rem;
margin: -0.6rem -0.5rem;
min-height: 44px;
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
color: var(--primary, #667eea);
text-decoration: underline;
font-family: inherit;
transition: opacity 0.12s;
white-space: nowrap;
}
.link-btn:hover {
opacity: 0.75;
}
.reset-btn {
color: var(--error, #e53e3e);
}
/* Interval form */
.interval-form {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.interval-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.interval-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.interval-time-row {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.field-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--secondary, #7257b3);
}
.stepper {
display: flex;
align-items: center;
gap: 0;
border: 1.5px solid var(--primary, #667eea);
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.stepper-btn {
min-width: 2.75rem;
min-height: 2.75rem;
background: transparent;
border: none;
color: var(--primary, #667eea);
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
transition: background 0.12s;
font-family: inherit;
display: flex;
align-items: center;
justify-content: center;
}
.stepper-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--btn-primary, #667eea) 12%, transparent);
}
.stepper-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.stepper-value {
min-width: 1.6rem;
text-align: center;
font-size: 1rem;
font-weight: 700;
color: var(--secondary, #7257b3);
padding: 0 0.25rem;
}
.anytime-label {
font-size: 0.85rem;
color: var(--form-label, #888);
font-style: italic;
}
.next-occurrence-row {
display: flex;
align-items: center;
}
.next-occurrence-label {
font-size: 0.85rem;
color: var(--form-label, #888);
font-style: italic;
}
/* Error */
.error-msg {
color: var(--error, #e53e3e);
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
/* Actions */
.modal-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1rem;
}
.btn-primary {
background: var(--btn-primary, #667eea);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.4rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Enable/Disable Toggle ────────────────────── */
.schedule-toggle-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.6rem;
margin-bottom: 1rem;
min-height: 44px;
}
.toggle-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--form-label, #444);
user-select: none;
}
.toggle-track {
position: relative;
width: 48px;
height: 24px;
border-radius: 999px;
border: none;
background: var(--form-input-border, #cbd5e1);
cursor: pointer;
transition: background 0.2s ease;
padding: 0;
flex-shrink: 0;
}
.toggle-track.on {
background: var(--btn-primary, #667eea);
}
.toggle-thumb {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease;
}
.toggle-track.on .toggle-thumb {
transform: translateX(24px);
}
/* ── Dim wrapper when paused ──────────────────── */
.schedule-body {
transition: opacity 0.2s ease;
}
.schedule-body.disabled {
opacity: 0.45;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,430 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
const props = defineProps<{
title: string
fetchBaseUrl: string
ids?: readonly string[]
itemKey: string
imageFields?: readonly string[]
isParentAuthenticated?: boolean
filterFn?: (item: any) => boolean
sortFn?: (a: any, b: any) => number
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
enableEdit?: boolean
childId?: string
readyItemId?: string | null
}>()
// Compute the fetch URL with ids if present
const fetchUrl = computed(() => {
if (props.ids && props.ids.length > 0) {
const separator = props.fetchBaseUrl.includes('?') ? '&' : '?'
return `${props.fetchBaseUrl}${separator}ids=${props.ids.join(',')}`
}
return props.fetchBaseUrl
})
const emit = defineEmits<{
(e: 'trigger-item', item: any): void
(e: 'edit-item', item: any): void
(e: 'item-ready', itemId: string): void
}>()
const items = ref<any[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const scrollWrapper = ref<HTMLDivElement | null>(null)
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
const lastCenteredItemId = ref<string | null>(null)
let fetchGeneration = 0
const fetchItems = async () => {
const thisGeneration = ++fetchGeneration
loading.value = true
error.value = null
try {
const resp = await fetch(fetchUrl.value)
if (thisGeneration !== fetchGeneration) return
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
if (thisGeneration !== fetchGeneration) return
// Try to use 'tasks', 'reward_status', or 'items' as fallback
let itemList = data[props.itemKey || 'items'] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn)
if (props.sortFn) itemList = [...itemList].sort(props.sortFn)
items.value = itemList
// Fetch images for each item
await Promise.all(
itemList.map(async (item: any) => {
if (props.imageFields) {
for (const field of props.imageFields) {
if (item[field]) {
try {
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
} catch {
item[`${field.replace('_id', '_url')}`] = null
}
}
}
} else if (item.image_id) {
try {
item.image_url = await getCachedImageUrl(item.image_id)
} catch {
console.error('Error fetching image for item', item.id)
item.image_url = null
}
}
}),
)
if (thisGeneration !== fetchGeneration) return
// Re-assign to trigger Vue reactivity for image URLs set on raw objects
items.value = [...itemList]
} catch (err) {
if (thisGeneration !== fetchGeneration) return
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = []
console.error('Error fetching items:', err)
} finally {
if (thisGeneration === fetchGeneration) {
loading.value = false
}
}
}
async function refresh() {
await fetchItems()
}
const scrollToItem = async (itemId: string) => {
await nextTick()
const card = itemRefs.value[itemId]
if (card) {
;(card as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
await centerItem(itemId)
}
const centerItem = async (itemId: string) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = itemRefs.value[itemId]
if (wrapper && card) {
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const wrapperScrollLeft = wrapper.scrollLeft
const cardCenter = cardRect.left + cardRect.width / 2
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
const scrollOffset = cardCenter - wrapperCenter
wrapper.scrollTo({
left: wrapperScrollLeft + scrollOffset,
behavior: 'smooth',
})
}
}
defineExpose({
refresh,
items,
centerItem,
scrollToItem,
})
const handleClicked = async (item: any) => {
await nextTick()
const wrapper = scrollWrapper.value
const card = itemRefs.value[item.id]
if (!wrapper || !card) return
// If this item is already ready (has edit button showing)
if (props.readyItemId === item.id) {
// Second click - trigger the item and reset
emit(
'trigger-item',
items.value.find((i) => i.id === item.id),
)
emit('item-ready', '')
lastCenteredItemId.value = null
return
}
// First click or different item clicked
// Check if item needs to be centered
const wrapperRect = wrapper.getBoundingClientRect()
const cardRect = card.getBoundingClientRect()
const cardCenter = cardRect.left + cardRect.width / 2
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
if (!cardFullyVisible) {
// Center the item first
await centerItem(item.id)
}
// Mark this item as ready (show edit button)
lastCenteredItemId.value = item.id
emit('item-ready', item.id)
}
const handleEditClick = (item: any, event: Event) => {
event.stopPropagation()
emit('edit-item', item)
// Reset the 2-step process after opening edit modal
emit('item-ready', '')
lastCenteredItemId.value = null
}
watch(
() => [props.ids],
() => {
fetchItems()
},
{ immediate: true },
)
onBeforeUnmount(() => {
revokeAllImageUrls()
})
</script>
<template>
<div class="child-list-container">
<h3>{{ title }}</h3>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="items.length === 0" class="empty">No {{ title }}</div>
<div v-else class="scroll-wrapper" ref="scrollWrapper">
<div class="item-scroll">
<div
v-for="item in items"
:key="item.id"
:class="[
'item-card',
props.getItemClass?.(item),
{ 'item-ready': props.readyItemId === item.id },
]"
:data-item-id="item.id"
:ref="(el) => (itemRefs[item.id] = el)"
@click.stop="handleClicked(item)"
>
<button
v-if="enableEdit && readyItemId === item.id"
class="edit-button"
@click="handleEditClick(item, $event)"
title="Edit custom value"
>
<img src="/edit.png" alt="Edit" />
</button>
<span
v-if="
isParentAuthenticated && item.custom_value !== undefined && item.custom_value !== null
"
class="override-badge"
>⭐</span
>
<slot name="item" :item="item">
<div class="item-name">{{ item.name }}</div>
</slot>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.child-list-container {
background: var(--child-list-bg, rgba(255, 255, 255, 0.1));
border-radius: 12px;
padding: 1rem;
color: var(--child-list-title-color, #fff);
width: 100%;
box-sizing: border-box;
}
.scroll-wrapper {
overflow-x: auto;
overflow-y: hidden;
scroll-behavior: smooth;
width: 100%;
-webkit-overflow-scrolling: touch;
}
.scroll-wrapper::-webkit-scrollbar {
height: 8px;
}
.scroll-wrapper::-webkit-scrollbar-track {
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
border-radius: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
background: var(
--child-list-scrollbar-thumb,
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
);
border-radius: 10px;
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
}
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
background: var(
--child-list-scrollbar-thumb-hover,
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
);
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
}
.item-scroll {
display: flex;
gap: 0.75rem;
min-width: min-content;
padding: 0.5rem 0;
}
/* Fallback for browsers that don't support flex gap */
.item-card + .item-card {
margin-left: 0.75rem;
}
.item-card {
position: relative;
background: var(--item-card-bg, rgba(255, 255, 255, 0.12));
border-radius: 8px;
padding: 0.75rem;
min-width: 140px;
max-width: 220px;
width: 100%;
text-align: center;
flex-shrink: 0;
transition: transform 0.18s ease;
border: var(--item-card-border, 1px solid rgba(255, 255, 255, 0.08));
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
user-select: none; /* Prevent image selection */
-webkit-tap-highlight-color: transparent; /* Suppress native mobile tap flash */
}
/* Only apply hover lift on true pointer (non-touch) devices */
@media (hover: hover) {
.item-card:hover {
transform: translateY(-4px);
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
}
}
/* Brief press feedback on touch devices */
.item-card:active {
transform: scale(0.97);
transition: transform 0.08s ease;
}
.item-card.item-ready {
animation: ready-glow 0.25s ease forwards;
}
.edit-button {
position: absolute;
top: 4px;
right: 4px;
width: 34px;
height: 34px;
border: none;
background-color: var(--btn-primary);
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition:
opacity 0.2s,
transform 0.1s;
z-index: 10;
}
.edit-button:hover {
opacity: 0.9;
transform: scale(1.05);
}
.edit-button img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
.override-badge {
position: absolute;
top: 4px;
left: 4px;
font-size: 12px;
z-index: 5;
}
@keyframes ready-glow {
0% {
box-shadow: 0 0 0 0 #667eea00;
border-color: inherit;
}
100% {
box-shadow: var(--item-card-ready-shadow, 0 0 0 3px #667eea88, 0 0 12px #667eea44);
border-color: var(--item-card-ready-border, #667eea);
}
}
:deep(.item-name) {
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 0.4rem;
color: var(--item-name-color, #fff);
line-height: 1.2;
word-break: break-word;
}
/* Image styling */
:deep(.item-image) {
width: 70px;
height: 70px;
object-fit: cover;
border-radius: 6px;
margin: 0 auto 0.4rem auto;
display: block;
}
/* Mobile tweaks */
@media (max-width: 480px) {
.item-card {
min-width: 110px;
max-width: 150px;
padding: 0.6rem;
}
:deep(.item-name) {
font-size: 0.86rem;
}
:deep(.item-image) {
width: 50px;
height: 50px;
margin: 0 auto 0.3rem auto;
}
.scroll-wrapper::-webkit-scrollbar {
height: 10px;
}
.scroll-wrapper::-webkit-scrollbar-thumb {
border-width: 1px;
}
}
.loading,
.error,
.empty {
text-align: center;
padding: 2rem 0;
color: #d6d6d6;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{
loading: boolean
error: string | null
}>()
</script>
<template>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
</template>
<style scoped>
.loading {
color: var(--loading-color);
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.error {
color: var(--error-color);
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 1rem;
}
</style>

View File

@@ -0,0 +1,6 @@
<template>
<div v-if="message" class="success-message" aria-live="polite">{{ message }}</div>
</template>
<script setup lang="ts">
defineProps<{ message: string }>()
</script>

View File

@@ -0,0 +1,261 @@
<template>
<div ref="rootEl" class="time-picker-popover">
<button type="button" class="time-display" @click="toggleOpen" :class="{ open: isOpen }">
{{ formattedTime }}
</button>
<div v-if="isOpen" class="popover-panel">
<div class="columns">
<!-- Hours column -->
<div class="col">
<div class="col-header">Hour</div>
<div class="col-scroll">
<button
v-for="h in HOURS"
:key="h"
type="button"
class="col-item"
:class="{ selected: displayHour === h }"
@click="selectHour(h)"
>
{{ h }}
</button>
</div>
</div>
<div class="col-divider">:</div>
<!-- Minutes column -->
<div class="col">
<div class="col-header">Min</div>
<div class="col-scroll">
<button
v-for="m in MINUTES"
:key="m"
type="button"
class="col-item"
:class="{ selected: props.modelValue.minute === m }"
@click="selectMinute(m)"
>
{{ String(m).padStart(2, '0') }}
</button>
</div>
</div>
<!-- AM/PM column -->
<div class="col ampm-col">
<div class="col-header">AM/PM</div>
<div class="col-scroll">
<button
type="button"
class="col-item"
:class="{ selected: period === 'AM' }"
@click="selectPeriod('AM')"
>
AM
</button>
<button
type="button"
class="col-item"
:class="{ selected: period === 'PM' }"
@click="selectPeriod('PM')"
>
PM
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
interface TimeValue {
hour: number // 023
minute: number // 0, 15, 30, 45
}
const props = defineProps<{ modelValue: TimeValue }>()
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const MINUTES = [0, 15, 30, 45]
const rootEl = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
const displayHour = computed(() => {
const h = props.modelValue.hour % 12
return h === 0 ? 12 : h
})
const formattedTime = computed(() => {
const h = displayHour.value
const m = String(props.modelValue.minute).padStart(2, '0')
return `${String(h).padStart(2, '0')}:${m} ${period.value}`
})
function toggleOpen() {
if (isOpen.value) {
close()
} else {
isOpen.value = true
document.addEventListener('mousedown', onDocumentMouseDown, { capture: true })
}
}
function close() {
isOpen.value = false
document.removeEventListener('mousedown', onDocumentMouseDown, { capture: true })
}
function onDocumentMouseDown(e: MouseEvent) {
if (rootEl.value && !rootEl.value.contains(e.target as Node)) {
close()
}
}
function selectHour(h: number) {
// h is 112 display; convert to 023 preserving period
const isPm = period.value === 'PM'
let hour24: number
if (h === 12) {
hour24 = isPm ? 12 : 0
} else {
hour24 = isPm ? h + 12 : h
}
emit('update:modelValue', { hour: hour24, minute: props.modelValue.minute })
}
function selectMinute(m: number) {
emit('update:modelValue', { hour: props.modelValue.hour, minute: m })
}
function selectPeriod(p: 'AM' | 'PM') {
const currentPeriod = period.value
if (p === currentPeriod) return
const newHour = p === 'PM' ? props.modelValue.hour + 12 : props.modelValue.hour - 12
emit('update:modelValue', { hour: newHour, minute: props.modelValue.minute })
}
onUnmounted(() => {
document.removeEventListener('mousedown', onDocumentMouseDown, { capture: true })
})
</script>
<style scoped>
.time-picker-popover {
position: relative;
display: inline-block;
}
.time-display {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.75rem;
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 8px;
background: var(--modal-bg, #fff);
color: var(--secondary, #7257b3);
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
transition:
border-color 0.15s,
box-shadow 0.15s;
white-space: nowrap;
font-family: inherit;
}
.time-display:hover,
.time-display.open {
border-color: var(--btn-primary, #667eea);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--btn-primary, #667eea) 20%, transparent);
}
.popover-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 200;
background: var(--modal-bg, #fff);
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 10px;
box-shadow: 0 4px 18px var(--modal-shadow, rgba(0, 0, 0, 0.15));
padding: 0.5rem;
}
.columns {
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
.col {
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 3rem;
}
.ampm-col {
min-width: 3.2rem;
}
.col-header {
font-size: 0.7rem;
font-weight: 700;
text-align: center;
color: var(--primary, #667eea);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.35rem;
padding: 0 0.15rem;
}
.col-scroll {
display: flex;
flex-direction: column;
gap: 0.18rem;
max-height: 12rem;
overflow-y: auto;
scrollbar-width: thin;
}
.col-item {
padding: 0.3rem 0.4rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--secondary, #7257b3);
font-size: 0.9rem;
font-weight: 600;
text-align: center;
cursor: pointer;
transition:
background 0.12s,
color 0.12s;
font-family: inherit;
}
.col-item:hover {
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.1));
}
.col-item.selected {
background: var(--btn-primary, #667eea);
color: #fff;
}
.col-divider {
font-size: 1.1rem;
font-weight: 700;
color: var(--secondary, #7257b3);
padding-top: 1.6rem;
align-self: flex-start;
}
</style>

View File

@@ -0,0 +1,161 @@
<template>
<div class="time-selector">
<!-- Hour column -->
<div class="time-col">
<button class="arrow-btn" @click="incrementHour" aria-label="Increase hour"></button>
<div class="time-value">{{ displayHour }}</div>
<button class="arrow-btn" @click="decrementHour" aria-label="Decrease hour"></button>
</div>
<div class="time-separator">:</div>
<!-- Minute column -->
<div class="time-col">
<button class="arrow-btn" @click="incrementMinute" aria-label="Increase minute"></button>
<div class="time-value">{{ displayMinute }}</div>
<button class="arrow-btn" @click="decrementMinute" aria-label="Decrease minute"></button>
</div>
<!-- AM/PM toggle -->
<button class="ampm-btn" @click="toggleAmPm">{{ period }}</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface TimeValue {
hour: number // 023 (24h)
minute: number // 0, 15, 30, or 45
}
const props = defineProps<{ modelValue: TimeValue }>()
const emit = defineEmits<{ (e: 'update:modelValue', val: TimeValue): void }>()
const MINUTES = [0, 15, 30, 45]
// ── display helpers ──────────────────────────────────────────────────────────
const period = computed(() => (props.modelValue.hour < 12 ? 'AM' : 'PM'))
const displayHour = computed(() => {
const h = props.modelValue.hour % 12
return String(h === 0 ? 12 : h)
})
const displayMinute = computed(() => String(props.modelValue.minute).padStart(2, '0'))
// ── mutations ────────────────────────────────────────────────────────────────
function incrementHour() {
const next = (props.modelValue.hour + 1) % 24
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
}
function decrementHour() {
const next = (props.modelValue.hour - 1 + 24) % 24
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
}
function incrementMinute() {
const idx = MINUTES.indexOf(props.modelValue.minute)
if (idx === MINUTES.length - 1) {
// Wrap minute to 0 and carry into next hour
const nextHour = (props.modelValue.hour + 1) % 24
emit('update:modelValue', { hour: nextHour, minute: 0 })
} else {
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx + 1] })
}
}
function decrementMinute() {
const idx = MINUTES.indexOf(props.modelValue.minute)
if (idx === 0) {
// Wrap minute to 45 and borrow from previous hour
const prevHour = (props.modelValue.hour - 1 + 24) % 24
emit('update:modelValue', { hour: prevHour, minute: 45 })
} else {
emit('update:modelValue', { hour: props.modelValue.hour, minute: MINUTES[idx - 1] })
}
}
function toggleAmPm() {
const next = props.modelValue.hour < 12 ? props.modelValue.hour + 12 : props.modelValue.hour - 12
emit('update:modelValue', { hour: next, minute: props.modelValue.minute })
}
</script>
<style scoped>
.time-selector {
display: inline-flex;
align-items: center;
gap: 0.25rem;
user-select: none;
}
.time-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.arrow-btn {
width: 1.8rem;
height: 1.8rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px;
cursor: pointer;
font-size: 0.75rem;
color: var(--primary, #667eea);
transition: background 0.15s;
}
.arrow-btn:hover {
background: var(--menu-item-hover-bg, rgba(102, 126, 234, 0.08));
}
.time-value {
width: 1.8rem;
height: 1.8rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 700;
color: var(--secondary, #7257b3);
border: 1.5px solid var(--kebab-menu-border, #bcc1c9);
border-radius: 6px;
background: var(--modal-bg, #fff);
}
.time-separator {
font-size: 1rem;
font-weight: 700;
color: var(--secondary, #7257b3);
padding-bottom: 0.1rem;
align-self: center;
}
.ampm-btn {
width: 2.2rem;
height: 1.8rem;
font-size: 0.8rem;
font-weight: 700;
border: 1.5px solid var(--btn-primary, #667eea);
border-radius: 6px;
background: var(--btn-primary, #667eea);
color: #fff;
cursor: pointer;
margin-left: 0.35rem;
transition: opacity 0.15s;
}
.ampm-btn:hover {
opacity: 0.85;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<div class="toggle-field-row">
<span class="toggle-field-label">{{ label }}</span>
<button
type="button"
:class="['toggle-btn', { active: modelValue }]"
:aria-pressed="modelValue"
:disabled="disabled"
@click="$emit('update:modelValue', !modelValue)"
>
<span class="toggle-knob" />
</button>
<button
v-if="description"
type="button"
class="toggle-expand-btn"
:aria-expanded="expanded"
@click.stop="expanded = !expanded"
:aria-label="expanded ? 'Collapse info' : 'Show info'"
>
<span class="toggle-chevron" :class="{ open: expanded }"></span>
</button>
</div>
<p v-if="description && expanded" class="toggle-desc-text">{{ description }}</p>
<p v-if="error" class="toggle-error">{{ error }}</p>
</template>
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
label: string
modelValue: boolean
disabled?: boolean
description?: string
error?: string
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
const expanded = ref(false)
</script>
<style scoped>
.toggle-field-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.toggle-field-label {
flex: 1;
font-size: 0.95rem;
color: var(--form-label, #444);
}
.toggle-expand-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted, #888);
padding: 0 0.15rem;
font-size: 1.2rem;
line-height: 1;
display: flex;
align-items: center;
}
.toggle-chevron {
display: inline-block;
transition: transform 0.2s;
}
.toggle-chevron.open {
transform: rotate(90deg);
}
.toggle-desc-text {
font-size: 0.83rem;
color: var(--text-muted, #666);
margin: 0.3rem 0 0;
line-height: 1.4;
}
.toggle-error {
font-size: 0.9rem;
color: var(--error, #e53e3e);
margin: 0.2rem 0 0;
}
.toggle-btn {
flex-shrink: 0;
width: 44px;
height: 24px;
border-radius: 12px;
border: none;
background: var(--form-input-border, #ccc);
cursor: pointer;
position: relative;
transition: background 0.2s;
padding: 0;
}
.toggle-btn.active {
background: var(--btn-primary, #4a90e2);
}
.toggle-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.toggle-knob {
display: block;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
position: absolute;
top: 3px;
left: 3px;
transition: left 0.2s;
pointer-events: none;
}
.toggle-btn.active .toggle-knob {
left: 23px;
}
</style>

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DateInputField from '../DateInputField.vue'
describe('DateInputField', () => {
it('renders a native date input', () => {
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
const input = w.find('input[type="date"]')
expect(input.exists()).toBe(true)
})
it('reflects modelValue as the hidden input value', () => {
const w = mount(DateInputField, { props: { modelValue: '2026-04-15' } })
const input = w.find<HTMLInputElement>('input[type="date"]')
expect((input.element as HTMLInputElement).value).toBe('2026-04-15')
})
it('emits update:modelValue with the new ISO string when changed', async () => {
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
const input = w.find<HTMLInputElement>('input[type="date"]')
await input.setValue('2026-05-20')
expect(w.emitted('update:modelValue')).toBeTruthy()
expect(w.emitted('update:modelValue')![0]).toEqual(['2026-05-20'])
})
it('does not emit when no change is triggered', () => {
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
expect(w.emitted('update:modelValue')).toBeFalsy()
})
it('passes min prop to the native input', () => {
const w = mount(DateInputField, {
props: { modelValue: '2026-03-10', min: '2026-02-26' },
})
const input = w.find<HTMLInputElement>('input[type="date"]')
expect((input.element as HTMLInputElement).min).toBe('2026-02-26')
})
it('renders without min prop when not provided', () => {
const w = mount(DateInputField, { props: { modelValue: '2026-03-01' } })
const input = w.find<HTMLInputElement>('input[type="date"]')
expect((input.element as HTMLInputElement).min).toBe('')
})
it('shows "Select date" placeholder when modelValue is empty', () => {
const w = mount(DateInputField, { props: { modelValue: '' } })
expect(w.find('.date-display-btn').text()).toBe('Select date')
})
it('shows formatted date with weekday when modelValue is set', () => {
const w = mount(DateInputField, { props: { modelValue: '2026-03-05' } })
// Thu, Mar 5, 2026
const text = w.find('.date-display-btn').text()
expect(text).toContain('Thu')
expect(text).toContain('Mar')
expect(text).toContain('5')
expect(text).toContain('2026')
})
})

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import EntityEditForm from '../EntityEditForm.vue'
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
back: vi.fn(),
})),
}))
describe('EntityEditForm', () => {
it('keeps Create disabled when required number field is empty', async () => {
const wrapper = mount(EntityEditForm, {
props: {
entityLabel: 'Child',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
],
initialData: {
name: '',
age: null,
},
isEdit: false,
loading: false,
requireDirty: false,
},
})
const nameInput = wrapper.find('#name')
const ageInput = wrapper.find('#age')
await nameInput.setValue('Sam')
await ageInput.setValue('')
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(true)
})
it('enables Create when required Name and Age are both valid', async () => {
const wrapper = mount(EntityEditForm, {
props: {
entityLabel: 'Child',
fields: [
{ name: 'name', label: 'Name', type: 'text', required: true },
{ name: 'age', label: 'Age', type: 'number', required: true, min: 0, max: 120 },
],
initialData: {
name: '',
age: null,
},
isEdit: false,
loading: false,
requireDirty: false,
},
})
const nameInput = wrapper.find('#name')
const ageInput = wrapper.find('#age')
await nameInput.setValue('Sam')
await ageInput.setValue('8')
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
})

View File

@@ -0,0 +1,486 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import LoginButton from '../LoginButton.vue'
import { authenticateParent, logoutParent } from '../../../stores/auth'
vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}))
// Mock imageCache module
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn(async (imageId: string) => `blob:mock-url-${imageId}`),
revokeImageUrl: vi.fn(),
revokeAllImageUrls: vi.fn(),
}))
// Create real Vue refs for isParentAuthenticated and isParentPersistent using vi.hoisted.
// Real Vue refs are required so Vue templates auto-unwrap them correctly in v-if conditions.
const { isParentAuthenticatedRef, isParentPersistentRef } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
isParentAuthenticatedRef: ref(false),
isParentPersistentRef: ref(false),
}
})
vi.mock('../../../stores/auth', () => ({
authenticateParent: vi.fn(),
isParentAuthenticated: isParentAuthenticatedRef,
isParentPersistent: isParentPersistentRef,
logoutParent: vi.fn(),
logoutUser: vi.fn(),
consumePendingReturnUrl: vi.fn(() => null),
hasPendingReturnUrl: vi.fn(() => false),
clearPendingReturnUrl: vi.fn(),
}))
global.fetch = vi.fn()
describe('LoginButton', () => {
let wrapper: VueWrapper<any>
beforeEach(() => {
vi.clearAllMocks()
isParentAuthenticatedRef.value = false
isParentPersistentRef.value = false
;(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => ({
image_id: 'test-image-id',
first_name: 'John',
email: 'john@example.com',
}),
})
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('Avatar Rendering', () => {
it('renders avatar with image when image_id is available', async () => {
wrapper = mount(LoginButton)
await nextTick()
// Wait for fetchUserProfile to complete
await new Promise((resolve) => setTimeout(resolve, 100))
// Component should be mounted and functional
expect(wrapper.exists()).toBe(true)
// Should have attempted to load user profile with credentials
expect(global.fetch).toHaveBeenCalledWith('/api/user/profile', {
credentials: 'include',
})
})
it('renders avatar with initial when no image_id', async () => {
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ first_name: 'Jane' }),
})
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const avatarText = wrapper.find('.avatar-text')
expect(avatarText.exists()).toBe(true)
expect(avatarText.text()).toBe('J')
})
it('renders ? when no first_name available', async () => {
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({}),
})
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const avatarText = wrapper.find('.avatar-text')
expect(avatarText.exists()).toBe(true)
expect(avatarText.text()).toBe('?')
})
it('shows loading state initially', async () => {
wrapper = mount(LoginButton)
const loading = wrapper.find('.avatar-text')
expect(loading.exists()).toBe(true)
expect(loading.text()).toBe('...')
})
})
describe('Dropdown Interactions', () => {
beforeEach(async () => {
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('opens dropdown on click when authenticated', async () => {
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
const dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(true)
})
it('closes dropdown on Escape key', async () => {
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
let dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(true)
await button.trigger('keydown', { key: 'Escape' })
await nextTick()
dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(false)
})
it('closes dropdown on outside click', async () => {
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
let dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(true)
// Simulate outside click by directly calling the handler
const outsideClick = new MouseEvent('mousedown', {
bubbles: true,
cancelable: true,
})
document.dispatchEvent(outsideClick)
await nextTick()
dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(false)
})
})
describe('Keyboard Navigation', () => {
beforeEach(async () => {
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('opens dropdown on Enter key', async () => {
const button = wrapper.find('.avatar-btn')
await button.trigger('keydown', { key: 'Enter' })
await nextTick()
const dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(true)
})
it('opens dropdown on Space key', async () => {
const button = wrapper.find('.avatar-btn')
await button.trigger('keydown', { key: ' ' })
await nextTick()
const dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(true)
})
it('navigates menu items with arrow keys', async () => {
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
const dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(true)
// First item should be focused
let menuItems = wrapper.findAll('.menu-item')
expect(menuItems[0].attributes('aria-selected')).toBe('true')
// Press down arrow
await button.trigger('keydown', { key: 'ArrowDown' })
await nextTick()
menuItems = wrapper.findAll('.menu-item')
expect(menuItems[1].attributes('aria-selected')).toBe('true')
})
})
describe('ARIA Attributes', () => {
it('has correct ARIA attributes', async () => {
// Note: Due to vi.mock hoisting limitations, isParentAuthenticated
// value is set when the mock is first created
wrapper = mount(LoginButton)
await nextTick()
const button = wrapper.find('.avatar-btn')
// Should have ar haspopup regardless of auth state
expect(button.attributes('aria-haspopup')).toBe('menu')
expect(button.attributes('aria-expanded')).toBe('false')
// aria-label will vary based on the initial mock state
expect(button.attributes('aria-label')).toBeTruthy()
})
it('has correct ARIA attributes when authenticated', async () => {
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const button = wrapper.find('.avatar-btn')
expect(button.attributes('aria-label')).toBe('Parent menu')
expect(button.attributes('aria-haspopup')).toBe('menu')
expect(button.attributes('aria-expanded')).toBe('false')
})
it('updates aria-expanded when dropdown opens', async () => {
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
expect(button.attributes('aria-expanded')).toBe('true')
})
it('menu items have correct roles', async () => {
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
const dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.attributes('role')).toBe('menu')
const menuItems = wrapper.findAll('.menu-item')
menuItems.forEach((item) => {
expect(item.attributes('role')).toBe('menuitem')
})
})
})
describe('Mode Logic', () => {
it('button click handler works correctly', async () => {
// Due to vi.mock limitations with reactivity, we test that
// click handler is wired up correctly
wrapper = mount(LoginButton, {
global: {
mocks: {
$router: {
push: vi.fn(),
},
},
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
// Verify button is clickable and doesn't throw
expect(button.exists()).toBe(true)
})
it('dropdown visibility controlled by auth state and open/close', async () => {
//Test dropdown behavior when authenticated
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
// Initially closed
let dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(false)
// Click to open
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
dropdown = wrapper.find('.dropdown-menu')
expect(dropdown.exists()).toBe(true)
})
})
describe('Focus Ring', () => {
it('shows focus ring on keyboard focus', async () => {
wrapper = mount(LoginButton)
await nextTick()
const button = wrapper.find('.avatar-btn')
await button.trigger('focus')
await nextTick()
// Check for focus ring styles
expect(button.classes()).toContain('avatar-btn')
})
})
describe('Stub Icons', () => {
it('renders stub icons for menu items', async () => {
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
const stubs = wrapper.findAll('.menu-icon-stub')
expect(stubs.length).toBe(3)
})
})
describe('PIN Modal - checkbox and persistent mode', () => {
beforeEach(async () => {
isParentAuthenticatedRef.value = false
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('checkbox is unchecked by default when modal opens', async () => {
// Open modal by triggering the open-login event path
// Mock has-pin response
;(global.fetch as any)
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
const vm = wrapper.vm as any
await vm.open()
await nextTick()
const checkbox = wrapper.find('.stay-checkbox')
expect(checkbox.exists()).toBe(true)
expect((checkbox.element as HTMLInputElement).checked).toBe(false)
})
it('submitting with checkbox checked calls authenticateParent(true)', async () => {
const { authenticateParent } = await import('../../../stores/auth')
;(global.fetch as any)
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
const vm = wrapper.vm as any
await vm.open()
await nextTick()
const checkbox = wrapper.find('.stay-checkbox')
await checkbox.setValue(true)
await nextTick()
const pinInput = wrapper.find('.pin-input')
await pinInput.setValue('1234')
await wrapper.find('form').trigger('submit')
await nextTick()
expect(authenticateParent).toHaveBeenCalledWith(true)
})
it('submitting without checking checkbox calls authenticateParent(false)', async () => {
const { authenticateParent } = await import('../../../stores/auth')
;(global.fetch as any)
.mockResolvedValueOnce({ ok: true, json: async () => ({ has_pin: true }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ valid: true }) })
const vm = wrapper.vm as any
await vm.open()
await nextTick()
const pinInput = wrapper.find('.pin-input')
await pinInput.setValue('1234')
await wrapper.find('form').trigger('submit')
await nextTick()
expect(authenticateParent).toHaveBeenCalledWith(false)
})
})
describe('Lock badge visibility', () => {
it('lock badge is hidden when not authenticated', async () => {
isParentAuthenticatedRef.value = false
isParentPersistentRef.value = false
wrapper = mount(LoginButton)
await nextTick()
const badge = wrapper.find('.persistent-badge')
expect(badge.exists()).toBe(false)
})
it('lock badge is hidden when authenticated but non-persistent', async () => {
isParentAuthenticatedRef.value = true
isParentPersistentRef.value = false
wrapper = mount(LoginButton)
await nextTick()
const badge = wrapper.find('.persistent-badge')
expect(badge.exists()).toBe(false)
})
it('lock badge is visible when authenticated and persistent', async () => {
isParentAuthenticatedRef.value = true
isParentPersistentRef.value = true
wrapper = mount(LoginButton)
await nextTick()
const badge = wrapper.find('.persistent-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toBe('🔒')
})
})
describe('User Email Display', () => {
it('displays email in dropdown header when available', async () => {
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
const email = wrapper.find('.dropdown-user-email')
expect(email.exists()).toBe(true)
expect(email.text()).toBe('john@example.com')
})
it('does not display email element when email is not available', async () => {
;(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ image_id: 'test-image-id', first_name: 'Jane' }),
})
isParentAuthenticatedRef.value = true
wrapper = mount(LoginButton)
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const button = wrapper.find('.avatar-btn')
await button.trigger('click')
await nextTick()
const email = wrapper.find('.dropdown-user-email')
expect(email.exists()).toBe(false)
})
})
})

View File

@@ -0,0 +1,432 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import ScrollingList from '../ScrollingList.vue'
// Mock image cache
vi.mock('@/common/imageCache', () => ({
getCachedImageUrl: vi.fn((id: string) => Promise.resolve(`/cached/${id}.png`)),
revokeAllImageUrls: vi.fn(),
}))
global.fetch = vi.fn()
describe('ScrollingList', () => {
let wrapper: VueWrapper<any>
const defaultProps = {
title: 'Test List',
fetchBaseUrl: '/api/test',
ids: ['item-1', 'item-2'],
itemKey: 'items',
}
const mockItems = [
{ id: 'item-1', name: 'Item One', points: 10, image_id: 'img1' },
{ id: 'item-2', name: 'Item Two', points: 20, image_id: 'img2' },
]
const mockItemsWithOverride = [
{ id: 'item-1', name: 'Item One', points: 10, image_id: 'img1', custom_value: 15 },
{ id: 'item-2', name: 'Item Two', points: 20, image_id: 'img2', custom_value: null },
]
beforeEach(() => {
vi.clearAllMocks()
// Mock fetch responses
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItems }),
})
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('Component Mounting', () => {
it('renders with title', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
expect(wrapper.find('h3').text()).toBe('Test List')
})
it('fetches items on mount', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
})
it('displays loading state initially', () => {
wrapper = mount(ScrollingList, { props: defaultProps })
expect(wrapper.find('.loading').exists()).toBe(true)
})
it('displays items after loading', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.find('.loading').exists()).toBe(false)
expect(wrapper.findAll('.item-card').length).toBe(2)
})
it('displays empty message when no items', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: [] }),
})
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.find('.empty').text()).toContain('No Test List')
})
it('displays error message on fetch failure', async () => {
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.find('.error').exists()).toBe(true)
})
})
describe('Override Badge Display', () => {
it('shows override badge when custom_value exists and isParentAuthenticated is true', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItemsWithOverride }),
})
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const badges = wrapper.findAll('.override-badge')
expect(badges.length).toBe(1) // Only item-1 has custom_value
expect(badges[0].text()).toBe('⭐')
})
it('does not show badge when custom_value is null', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItems }),
})
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.override-badge').length).toBe(0)
})
it('does not show badge when custom_value is undefined', async () => {
const itemsWithUndefined = [{ id: 'item-1', name: 'Item One', points: 10 }]
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: itemsWithUndefined }),
})
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: true } })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.override-badge').length).toBe(0)
})
it('does not show badge in child mode even when custom_value exists', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItemsWithOverride }),
})
wrapper = mount(ScrollingList, { props: { ...defaultProps, isParentAuthenticated: false } })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.override-badge').length).toBe(0)
})
it('does not show badge when isParentAuthenticated is undefined', async () => {
;(global.fetch as any).mockResolvedValue({
ok: true,
json: () => Promise.resolve({ items: mockItemsWithOverride }),
})
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.override-badge').length).toBe(0)
})
})
describe('Two-Step Click Interaction', () => {
beforeEach(async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: false, // No edit button for basic click test
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
})
it('emits item-ready on first click', async () => {
const cards = wrapper.findAll('.item-card')
await cards[0].trigger('click')
expect(wrapper.emitted('item-ready')).toBeTruthy()
expect(wrapper.emitted('item-ready')![0]).toEqual(['item-1'])
})
it('emits trigger-item on second click of same item', async () => {
const cards = wrapper.findAll('.item-card')
// First click - select item
await cards[0].trigger('click')
await nextTick()
// Update prop to simulate parent setting readyItemId
await wrapper.setProps({ readyItemId: 'item-1' })
await nextTick()
// Second click - trigger item
await cards[0].trigger('click')
expect(wrapper.emitted('trigger-item')).toBeTruthy()
expect(wrapper.emitted('trigger-item')![0][0].id).toBe('item-1')
})
it('resets ready state after triggering', async () => {
const cards = wrapper.findAll('.item-card')
// First click
await cards[0].trigger('click')
await wrapper.setProps({ readyItemId: 'item-1' })
// Second click
await cards[0].trigger('click')
const itemReadyEvents = wrapper.emitted('item-ready')
expect(itemReadyEvents![itemReadyEvents!.length - 1]).toEqual([''])
})
})
describe('Edit Button Display', () => {
it('shows edit button only for readyItemId when enableEdit is true', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: true,
readyItemId: 'item-1',
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const editButtons = wrapper.findAll('.edit-button')
expect(editButtons.length).toBe(1)
})
it('does not show edit button when enableEdit is false', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: false,
readyItemId: 'item-1',
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.edit-button').length).toBe(0)
})
it('does not show edit button when readyItemId is null', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: true,
readyItemId: null,
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.edit-button').length).toBe(0)
})
it('emits edit-item when edit button is clicked', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: true,
readyItemId: 'item-1',
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const editButton = wrapper.find('.edit-button')
await editButton.trigger('click')
expect(wrapper.emitted('edit-item')).toBeTruthy()
expect(wrapper.emitted('edit-item')![0][0].id).toBe('item-1')
})
it('resets ready state after edit button click', async () => {
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
enableEdit: true,
readyItemId: 'item-1',
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const editButton = wrapper.find('.edit-button')
await editButton.trigger('click')
const itemReadyEvents = wrapper.emitted('item-ready')
expect(itemReadyEvents![itemReadyEvents!.length - 1]).toEqual([''])
})
})
describe('Filter Function', () => {
it('filters items using provided filterFn', async () => {
const filterFn = (item: any) => item.points > 15
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
filterFn,
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(wrapper.findAll('.item-card').length).toBe(1) // Only item-2 with 20 points
})
})
describe('Refresh Method', () => {
it('exposes refresh method', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
expect(wrapper.vm.refresh).toBeDefined()
expect(typeof wrapper.vm.refresh).toBe('function')
})
it('refetches items when refresh is called', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
vi.clearAllMocks()
await wrapper.vm.refresh()
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
})
it('discards stale fetch when concurrent refreshes occur', async () => {
const { getCachedImageUrl } = await import('@/common/imageCache')
const staleItems = [{ id: 'stale-1', name: 'Stale', points: 1, image_id: 'stale-img' }]
const freshItems = [{ id: 'fresh-1', name: 'Fresh', points: 2, image_id: 'fresh-img' }]
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
// Set up two concurrent fetches: first one is slow, second one is fast
let resolveFirst: (v: any) => void
const firstFetchPromise = new Promise((r) => {
resolveFirst = r
})
vi.clearAllMocks()
;(global.fetch as any)
.mockReturnValueOnce(firstFetchPromise) // slow call
.mockResolvedValueOnce({
// fast call
ok: true,
json: () => Promise.resolve({ items: freshItems }),
})
// Start first refresh (slow)
const first = wrapper.vm.refresh()
// Start second refresh (fast) — should supersede the first
const second = wrapper.vm.refresh()
// Resolve the fast call first (already resolved via mockResolvedValueOnce)
await second
await nextTick()
// Items should be from the fast (second) call
expect(wrapper.vm.items).toHaveLength(1)
expect(wrapper.vm.items[0].name).toBe('Fresh')
// Now resolve the slow (stale) call
resolveFirst!({ ok: true, json: () => Promise.resolve({ items: staleItems }) })
await first
await nextTick()
// Items should still be from the fast (second) call — stale result discarded
expect(wrapper.vm.items).toHaveLength(1)
expect(wrapper.vm.items[0].name).toBe('Fresh')
// Image was loaded for fresh item, not stale
expect(getCachedImageUrl).toHaveBeenCalledWith('fresh-img')
})
})
describe('Image Loading', () => {
it('loads images for items with image_id', async () => {
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const { getCachedImageUrl } = await import('@/common/imageCache')
expect(getCachedImageUrl).toHaveBeenCalledWith('img1')
expect(getCachedImageUrl).toHaveBeenCalledWith('img2')
})
})
describe('Custom Item Classes', () => {
it('applies custom classes from getItemClass prop', async () => {
const getItemClass = (item: any) => ({
good: item.points > 15,
bad: item.points <= 15,
})
wrapper = mount(ScrollingList, {
props: {
...defaultProps,
getItemClass,
},
})
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
const cards = wrapper.findAll('.item-card')
expect(cards[0].classes()).toContain('bad') // item-1 with 10 points
expect(cards[1].classes()).toContain('good') // item-2 with 20 points
})
})
})