feat: Implement drag-and-drop reordering for routine items and add corresponding E2E tests
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m41s

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-05-19 19:30:44 -04:00
parent ad8a8bf867
commit a6944ad59c
7 changed files with 324 additions and 83 deletions

67
CLAUDE.md Normal file
View File

@@ -0,0 +1,67 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates flow over Server-Sent Events.
## Commands
### Backend (run from `backend/`)
- Activate venv first: `source .venv/bin/activate` (mac/linux) — Python runs from `backend/.venv/`.
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `backend/main.py`).
- Required env vars at startup: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing.
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` directory (see `backend/config/paths.py`).
- Tests: `pytest tests/``tests/conftest.py` forces `DB_ENV=test` and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`.
- Create an admin user (cannot be done via signup): `python scripts/create_admin.py`.
### Frontend (run from `frontend/`)
- Dev: `npm run dev` (Vite, https://localhost:5173).
- Build: `npm run build`. Type-check: `npm run type-check`. Lint: `npm run lint`.
- Unit/component tests: `npm run test:unit` (Vitest). Single test: `npx vitest run path/to/file.spec.ts`.
- E2E: `npx playwright test` from `frontend/`. Config at `playwright.config.ts` auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e`, so test data lands in `backend/test_data/` and never touches production. The `globalSetup` seeds the DB and logs in; tests receive a pre-authenticated session via `storageState` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts` rather than hardcoding.
- E2E suite is split into Playwright "projects" in `playwright.config.ts` (`chromium-routines`, `chromium-task-assignment`, …) — each bucket targets a directory under `e2e/mode_parent/` and some use isolated users to avoid cross-bucket interference. Run a single bucket: `npx playwright test --project=chromium-routines`.
## Architecture
### API surface and nginx proxy
- Each entity has its own Flask blueprint in `backend/api/` (`child_api.py`, `chore_api.py`, `routine_api.py`, …). Registered in `backend/main.py`.
- The frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`** — backend defines `@app.route('/user')`, frontend calls `/api/user`.
- The `auth_api` blueprint is the only one mounted under a prefix (`/auth`).
- API errors return `{ error, code }`; codes live in `backend/api/error_codes.py`. Frontend extracts them via `parseErrorResponse(res)` in `frontend/src/common/api.ts`.
### Models — keep 1:1 parity
- Python `@dataclass`es live in `backend/models/`. TypeScript interfaces live in `frontend/src/common/models.ts`. Any model change requires updating **both**.
- Persistence is TinyDB (JSON files under `data/db/` or `test_data/db/`). All DB access goes through the thread-safe `LockedTable` wrapper in `backend/db/db.py`. Always operate on model instances using `from_dict()` / `to_dict()` — never raw dicts.
### SSE event bus (mandatory for every mutation)
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` (from `api/utils.py`). Event types live in `backend/events/types/` and `frontend/src/common/backendEvents.ts` (mirrored).
- Frontend state is event-driven: register listeners in `onMounted`, clean up in `onUnmounted`. See `components/BackendEventsListener.vue` and `src/common/backendEvents.ts`.
- The SSE endpoint is `/events`; per-user queues live in `backend/events/sse.py`.
### Background schedulers
Started in `backend/main.py` at boot:
- `start_deletion_scheduler` — runs hourly, deletes accounts that were marked-for-deletion at least `ACCOUNT_DELETION_THRESHOLD_HOURS` ago (default 720, min 24, max 720). Cleans pending rewards, children, tasks, rewards, images, then the user. Logs to `logs/account_deletion.log`.
- `start_digest_scheduler` — email digests.
- `start_state_expiry_scheduler` — expires stale state.
- `start_chore_expiry_notification_scheduler` — chore expiry notifications.
### Auth & security
- JWT in HttpOnly + Secure + SameSite=Strict cookies. Verification tokens expire in 4 hours; password-reset tokens in 10 minutes.
- Admin role is **never** assignable via signup — use `backend/scripts/create_admin.py`. Admin endpoints under `/admin/*` enforce role check.
### Frontend conventions
- Vue SFC file order: `<template>``<script>``<style scoped>`. TypeScript only inside `<script>`. **All styles must be `scoped`.**
- Use **only** `:root` CSS variables from `colors.css` for colors/spacing/tokens (e.g. `--btn-primary`, `--list-item-bg-good`). No hardcoded hex/px values for themed properties.
- Layout shells: `ParentLayout` for admin/management views, `ChildLayout` for child dashboard/focus views.
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
### Specs
Feature specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is considered complete.
## Gotchas
- Backend Python imports assume `backend/` is on `sys.path` (added by `conftest.py` for tests, by `flask run` cwd in dev). Run pytest from `backend/`.
- Don't replace code with comments; mirror changes across backend + frontend so model/event parity holds.
- E2E tests share a single seeded user by default — buckets that mutate shared state (default tasks, delete-account, create-child) deliberately use isolated users; preserve that pattern when adding new buckets.

View File

@@ -2,20 +2,20 @@
"cookies": [
{
"name": "refresh_token",
"value": "QCuLIgmPV2vnDfzmgvptt-mZAW1jh8UML_wwAee4OTQ",
"value": "GyPtMIGYCphOLBJzZ6jG87S9PKImCOhrVEvuVL0-Wu8",
"domain": "localhost",
"path": "/api/auth",
"expires": 1786912678.702645,
"expires": 1786993807.67933,
"httpOnly": true,
"secure": true,
"sameSite": "Strict"
},
{
"name": "access_token",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJlM2VmNGE2OC0zZWI3LTQ2MjQtYTI0Mi0yNmY1OTgyZDIwOWEiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzkxNDc0Nzh9.P7CDplAG3MBXgtEpuYFtp6gOV5pi-6QeAYRqmE-Dkys",
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiJjNWUzMTkwYi1hZWIzLTRlMTEtYmFiNS1hNjBkOTM5NmEyN2QiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzkyMjg2MDd9.f7dGIujexK9ffYWPM5ExUbL_UPRg0ULo778AR2J_yf0",
"domain": "localhost",
"path": "/",
"expires": 1779147478.701971,
"expires": 1779228607.679281,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
@@ -27,11 +27,11 @@
"localStorage": [
{
"name": "authSyncEvent",
"value": "{\"type\":\"logout\",\"at\":1779136678495}"
"value": "{\"type\":\"logout\",\"at\":1779217807539}"
},
{
"name": "parentAuth",
"value": "{\"expiresAt\":1779309478870}"
"value": "{\"expiresAt\":1779390607821}"
}
]
}

View File

@@ -0,0 +1,77 @@
import { test, expect, type APIRequestContext } from '@playwright/test'
const ROUTINE_NAME = 'ReorderTestRoutine'
const ROUTINE_POINTS = 10
const ITEM_A = 'Alpha Task'
const ITEM_B = 'Beta Task'
const ITEM_C = 'Gamma Task'
async function createRoutine(
request: APIRequestContext,
name: string,
points: number,
): Promise<string> {
const pre = await request.get('/api/routine/list')
for (const r of (await pre.json()).routines ?? []) {
if (r.name === name) await request.delete(`/api/routine/${r.id}`)
}
const res = await request.put('/api/routine/add', { data: { name, points } })
return (await res.json()).routine?.id ?? ''
}
test.describe('Routine item drag-to-reorder', () => {
test.describe.configure({ mode: 'serial' })
let routineId = ''
test.beforeAll(async ({ request }) => {
routineId = await createRoutine(request, ROUTINE_NAME, ROUTINE_POINTS)
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_A, order: 0 } })
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_B, order: 1 } })
await request.put(`/api/routine/${routineId}/item/add`, { data: { name: ITEM_C, order: 2 } })
})
test.afterAll(async ({ request }) => {
if (routineId) await request.delete(`/api/routine/${routineId}`)
})
test('items load in correct initial order', async ({ page }) => {
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
await page.locator('.item-row').first().waitFor({ state: 'visible' })
const names = await page.locator('.item-name').allTextContents()
expect(names).toEqual([ITEM_A, ITEM_B, ITEM_C])
})
test('dragging first item to last position reorders the list in the UI', async ({ page }) => {
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
await page.locator('.item-row').first().waitFor({ state: 'visible' })
await page.locator('.item-row').nth(0).dragTo(page.locator('.item-row').nth(2))
const names = await page.locator('.item-name').allTextContents()
expect(names).toEqual([ITEM_B, ITEM_C, ITEM_A])
})
test('reordered item order persists after saving', async ({ page }) => {
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
await page.locator('.item-row').first().waitFor({ state: 'visible' })
// Drag first to last: [A, B, C] → [B, C, A]
await page.locator('.item-row').nth(0).dragTo(page.locator('.item-row').nth(2))
// Confirm the drag updated the DOM before saving
const afterDrag = await page.locator('.item-name').allTextContents()
expect(afterDrag).toEqual([ITEM_B, ITEM_C, ITEM_A])
await page.getByRole('button', { name: 'Save' }).click()
await page.waitForURL(/\/parent\/tasks\/routines$/, { timeout: 5000 })
// Reload edit view — should now show the saved order
await page.goto(`/parent/tasks/routines/${routineId}/edit`)
await page.locator('.item-row').first().waitFor({ state: 'visible' })
const names = await page.locator('.item-name').allTextContents()
expect(names).toEqual([ITEM_B, ITEM_C, ITEM_A])
})
})

View File

@@ -709,6 +709,50 @@ onUnmounted(() => {
<div v-if="!loading && !error" class="layout">
<div class="main">
<ChildDetailCard :child="child" />
<ScrollingList
v-if="routines.length > 0"
title="Routines"
ref="childRoutineListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
:ids="routines"
itemKey="routines"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="handleRoutineClick"
:getItemClass="
(item: ChildRoutine) => ({
good: true,
'routine-pending': item.pending_status === 'pending',
'routine-approved': item.pending_status === 'approved',
})
"
>
<template #item="{ item }: { item: ChildRoutine }">
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
>PENDING</span
>
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
>APPROVED</span
>
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Routine Image"
class="item-image"
/>
<div class="item-points good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
</template>
</ScrollingList>
<ScrollingList
title="Chores"
ref="childChoreListRef"
@@ -783,6 +827,7 @@ onUnmounted(() => {
</template>
</ScrollingList>
<ScrollingList
v-show="childKindnessListRef?.items?.length > 0"
title="Kindness Acts"
ref="childKindnessListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
@@ -810,6 +855,7 @@ onUnmounted(() => {
</template>
</ScrollingList>
<ScrollingList
v-show="childPenaltyListRef?.items?.length > 0"
title="Penalties"
ref="childPenaltyListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-tasks`"
@@ -836,49 +882,6 @@ onUnmounted(() => {
</div>
</template>
</ScrollingList>
<ScrollingList
title="Routines"
ref="childRoutineListRef"
:fetchBaseUrl="`/api/child/${child?.id}/list-routines`"
:ids="routines"
itemKey="routines"
imageField="image_id"
:isParentAuthenticated="false"
:readyItemId="readyItemId"
@item-ready="handleItemReady"
@trigger-item="handleRoutineClick"
:getItemClass="
(item: ChildRoutine) => ({
good: true,
'routine-pending': item.pending_status === 'pending',
'routine-approved': item.pending_status === 'approved',
})
"
>
<template #item="{ item }: { item: ChildRoutine }">
<span v-if="item.pending_status === 'pending'" class="chore-stamp pending-stamp"
>PENDING</span
>
<span v-else-if="item.pending_status === 'approved'" class="chore-stamp completed-stamp"
>APPROVED</span
>
<div class="item-name">{{ item.name }}</div>
<img
v-if="item.image_url"
:src="item.image_url"
alt="Routine Image"
class="item-image"
/>
<div class="item-points good-points">
{{
item.custom_value !== undefined && item.custom_value !== null
? item.custom_value
: item.points
}}
Points
</div>
</template>
</ScrollingList>
</div>
</div>
</div>

View File

@@ -1585,16 +1585,16 @@ function goToAssignRoutines() {
</div>
<div class="assign-buttons">
<button v-if="child" class="btn btn-primary" @click="goToAssignTasks">Assign Chores</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
Assign Routines
</button>
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignKindness">
Assign Kindness Acts
</button>
<button v-if="child" class="btn btn-danger" @click="goToAssignBadHabits">
Assign Penalties
</button>
<button v-if="child" class="btn btn-primary" @click="goToAssignRoutines">
Assign Routines
</button>
<button v-if="child" class="btn btn-green" @click="goToAssignRewards">Assign Rewards</button>
</div>
<!-- Pending Reward Dialog -->

View File

@@ -12,15 +12,16 @@
class="routine-item"
@click="toggleRoutine(routine.id)"
>
<input
type="checkbox"
:checked="selectedIds.includes(routine.id)"
@change="toggleRoutine(routine.id)"
:aria-label="`Select ${routine.name}`"
/>
<img v-if="routine.image_url" :src="routine.image_url" :alt="routine.name" />
<span class="name">{{ routine.name }}</span>
<span class="value">{{ routine.points }} pts</span>
<input
type="checkbox"
v-model="selectedIds"
:value="routine.id"
:aria-label="`Select ${routine.name}`"
@click.stop
/>
</div>
</div>
</div>
@@ -38,6 +39,7 @@ import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { setChildRoutines } from '@/common/api'
import MessageBlock from '../shared/MessageBlock.vue'
import { getCachedImageUrl } from '@/common/imageCache'
import '@/assets/styles.css'
import type { Routine } from '@/common/models'
@@ -61,11 +63,23 @@ async function fetchRoutines() {
const childData = await childResp.json()
selectedIds.value = childData.routines || []
// Fetch all routines
// Fetch all routines and resolve images
const routinesResp = await fetch('/api/routine/list')
if (!routinesResp.ok) throw new Error('Failed to fetch routines')
const routinesData = await routinesResp.json()
routines.value = routinesData.routines || []
const rawRoutines: Routine[] = routinesData.routines || []
await Promise.all(
rawRoutines.map(async (r: any) => {
if (r.image_id) {
try {
r.image_url = await getCachedImageUrl(r.image_id)
} catch {
r.image_url = null
}
}
}),
)
routines.value = rawRoutines
} catch (error) {
console.error('Failed to fetch routines:', error)
}
@@ -135,52 +149,65 @@ function onCancel() {
}
.routine-selection {
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.5rem;
width: 100%;
max-width: 500px;
gap: 0.7rem;
background: var(--list-bg);
padding: 0.2rem;
border-radius: 12px;
}
.routine-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 2px solid var(--list-item-border-good);
justify-content: space-between;
border: 2px outset var(--list-item-border-good);
border-radius: 8px;
padding: 0.2rem 1rem;
background: var(--list-item-bg-good);
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;
cursor: pointer;
transition:
background 0.2s,
border-color 0.2s;
}
.routine-item:hover {
background: var(--list-item-bg-good-hover, var(--list-item-bg-good));
border-color: var(--list-item-border-good-hover, var(--list-item-border-good));
}
.routine-item input[type='checkbox'] {
cursor: pointer;
width: 1.25rem;
height: 1.25rem;
width: 1.2em;
height: 1.2em;
margin-left: 1rem;
accent-color: var(--checkbox-accent);
flex-shrink: 0;
}
.routine-item img {
width: 2.5rem;
height: 2.5rem;
border-radius: 6px;
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 0.7rem;
background: var(--list-image-bg);
flex-shrink: 0;
}
.routine-item .name {
.name {
flex: 1;
text-align: left;
font-weight: 600;
}
.routine-item .value {
.value {
min-width: 60px;
text-align: right;
font-weight: 600;

View File

@@ -22,7 +22,18 @@
</div>
<div v-else class="items-list">
<div v-for="(item, idx) in items" :key="item.id || `new-${idx}`" class="item-row">
<div
v-for="(item, idx) in items"
:key="item.id || `new-${idx}`"
class="item-row"
:class="{ 'drag-over': dragOverIdx === idx, dragging: draggingIdx === idx }"
draggable="true"
@dragstart="(e) => onDragStart(e, idx)"
@dragover.prevent="(e) => onDragOver(e, idx)"
@drop.prevent="onDrop(idx)"
@dragend="onDragEnd"
>
<span class="drag-handle" title="Drag to reorder"></span>
<div class="item-left">
<img
v-if="item.image_url"
@@ -150,6 +161,8 @@ const loading = ref(false)
const error = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const removedItemIds = ref<string[]>([])
const draggingIdx = ref<number | null>(null)
const dragOverIdx = ref<number | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
@@ -320,6 +333,36 @@ function cancelEditItem() {
newItemLocalFile.value = null
}
function onDragStart(e: DragEvent, idx: number) {
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
cancelEditItem()
draggingIdx.value = idx
}
function onDragOver(e: DragEvent, idx: number) {
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
if (draggingIdx.value === null || draggingIdx.value === idx) return
dragOverIdx.value = idx
}
function onDrop(idx: number) {
if (draggingIdx.value === null || draggingIdx.value === idx) {
draggingIdx.value = null
dragOverIdx.value = null
return
}
const moved = items.value.splice(draggingIdx.value, 1)[0]
items.value.splice(idx, 0, moved)
normalizeItemOrder()
draggingIdx.value = null
dragOverIdx.value = null
}
function onDragEnd() {
draggingIdx.value = null
dragOverIdx.value = null
}
async function uploadImage(file: File): Promise<string> {
const formData = new FormData()
formData.append('file', file)
@@ -473,6 +516,29 @@ function handleCancel() {
border: 1px solid var(--form-input-border, #e6e6e6);
border-radius: 10px;
padding: 0.55rem 0.7rem;
cursor: default;
}
.item-row.drag-over {
border-color: var(--btn-primary, #667eea);
background: var(--form-input-bg-hover, #f0f4ff);
}
.item-row.dragging {
opacity: 0.4;
}
.drag-handle {
cursor: grab;
color: var(--form-label, #aaa);
font-size: 1.15rem;
padding-right: 0.45rem;
user-select: none;
flex-shrink: 0;
}
.drag-handle:active {
cursor: grabbing;
}
.item-left {
@@ -480,6 +546,7 @@ function handleCancel() {
align-items: center;
min-width: 0;
gap: 0.6rem;
flex: 1;
}
.item-thumb {