Compare commits

...
18 Commits
Author SHA1 Message Date
robin 702bf9b8b8 Merge pull request 'Re-order lists' (#27) from dev into main
Build and Deploy / sync-dev (push) Skipped
Build and Deploy / build-and-deploy (push) Failing after 1m4s
2026-09-11 20:08:56 +02:00
robin df0bcee42b feat: re-order lists 2026-09-11 19:14:25 +02:00
robin 0dea32191b Merge pull request 'CI now runs when opening a PR' (#25) from dev into main 2026-09-09 17:11:02 +02:00
robin 06583f1a5a feat: added PR checks (lint & test) and removed them from the deploy step 2026-09-09 17:00:54 +02:00
robin 66f99e3a4a Merge pull request 'CI now syncs dev to main' (#24) from dev into main 2026-09-06 17:36:14 +02:00
robin 608199b8b8 fix: ci now syncs dev to main 2026-09-06 17:34:10 +02:00
robin 2144cb4bf1 Merge pull request 'Added deploy.yml for automatically building and deploying after merging to main' (#23) from dev into main 2026-09-06 16:35:15 +02:00
robin e976baa8fb feat: added deploy.yml for automatically building and deploying after merging to main 2026-09-06 16:34:03 +02:00
robin b7e273afd1 Merge pull request 'Added cv view (not indexed)' (#21) from dev into main 2026-09-05 10:46:11 +02:00
robin 9136381936 feat: Added cv view (not indexed) 2026-09-05 10:45:26 +02:00
robin 17585dc2b6 Merge pull request 'Added exercises; Lists improvements' (#20) from dev into main 2026-09-04 12:31:29 +02:00
robin 7185730a87 feat: Added first iteration of exercises view 2026-09-04 12:29:25 +02:00
robin 47034e111c fix: List item UI improvements 2026-09-04 10:35:06 +02:00
robin 73663543fd Merge pull request 'Visiting account view no longer fetches /lists' (#19) from dev into main 2026-09-01 17:26:20 +02:00
robin c0dc4a2fe0 fix: visiting account view no longer fetches /lists 2026-09-01 17:25:39 +02:00
robin 20a000319d Merge pull request 'Always display invites status and new API information' (#18) from dev into main 2026-09-01 17:06:25 +02:00
robin 8df92a470e feat: use new version endpoint to display api information 2026-09-01 17:03:23 +02:00
robin 16405ec5f7 feat: use new invite status endpoint to display different invite type counts 2026-09-01 16:34:13 +02:00
33 changed files with 1565 additions and 104 deletions
+73
View File
@@ -0,0 +1,73 @@
name: Build and Deploy
# Required secrets
# DEPLOY_SSH_KEY - private key for the deploy user
# DEPLOY_HOST - production host, e.g. YOUR_PROD_HOST
# DEPLOY_USER - ssh user, e.g. deploy
# DEPLOY_PATH - destination dir, e.g. /var/www/dttmr-fe
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Install system dependencies
run: apt-get update && apt-get install -y --no-install-recommends git openssh-client rsync ca-certificates
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts
- name: Deploy via rsync
env:
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
run: |
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key -o UserKnownHostsFile=~/.ssh/known_hosts" \
dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH"
sync-dev:
needs: build-and-deploy
runs-on: ubuntu-latest
container:
image: node:24-bookworm
permissions:
contents: write
steps:
- name: Install system dependencies
run: apt-get update && apt-get install -y --no-install-recommends git
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fast-forward dev to main
run: |
git fetch origin dev:dev
git checkout dev
git merge --ff-only origin/main
git push origin dev
+24
View File
@@ -0,0 +1,24 @@
name: PR Checks
on:
pull_request:
branches:
- main
jobs:
lint-and-test:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run unit tests
run: npm run test:unit
+28 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
import { getInvitesApi, createInviteApi, deleteInviteApi } from '../invites' import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '../invites'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/http' import { API_BASE_URL } from '@/api/http'
@@ -65,6 +65,33 @@ describe('invites API', () => {
await expect(getInvitesApi()).rejects.toThrow('Failed') await expect(getInvitesApi()).rejects.toThrow('Failed')
}) })
it('getInviteStatusApi sends GET to /user/invites/status and returns the counts', async () => {
const mockCounts = { active: 12, expired: 2, used: 8 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockCounts,
} as unknown as Response)
global.fetch = fetchMock
const result = await getInviteStatusApi()
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites/status`,
expect.objectContaining({ method: 'GET' }),
)
expect(result).toEqual(mockCounts)
})
it('getInviteStatusApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed to load invite counts' }),
} as unknown as Response)
await expect(getInviteStatusApi()).rejects.toThrow('Failed to load invite counts')
})
it('createInviteApi sends POST to /user/invites and returns the created invite', async () => { it('createInviteApi sends POST to /user/invites and returns the created invite', async () => {
const mockInvite = { id: 'invite-1', code: 'ABC123' } const mockInvite = { id: 'invite-1', code: 'ABC123' }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
+7 -7
View File
@@ -5,7 +5,7 @@ import {
createListApi, createListApi,
getListItemsApi, getListItemsApi,
createListItemApi, createListItemApi,
updateListItemApi, updateListItemTitleApi,
setListItemCompletedApi, setListItemCompletedApi,
addUserToListApi, addUserToListApi,
removeUserFromListApi, removeUserFromListApi,
@@ -77,7 +77,7 @@ describe('lists API', () => {
expect(result).toEqual(mockItems) expect(result).toEqual(mockItems)
}) })
it('setListItemCompletedApi sends POST to /lists/items/{id}', async () => { it('setListItemCompletedApi sends POST to /lists/items/{id}/complete', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true, ok: true,
status: 204, status: 204,
@@ -87,7 +87,7 @@ describe('lists API', () => {
await setListItemCompletedApi('item-1', { is_completed: true }) await setListItemCompletedApi('item-1', { is_completed: true })
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/items/item-1`, `${API_BASE_URL}/lists/items/item-1/complete`,
expect.objectContaining({ method: 'POST' }), expect.objectContaining({ method: 'POST' }),
) )
}) })
@@ -137,18 +137,18 @@ describe('lists API', () => {
expect(result).toEqual(mockItem) expect(result).toEqual(mockItem)
}) })
it('updateListItemApi sends PUT to /lists/items', async () => { it('updateListItemTitleApi sends POST to /lists/items/{id}/title', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true, ok: true,
status: 204, status: 204,
} as unknown as Response) } as unknown as Response)
global.fetch = fetchMock global.fetch = fetchMock
await updateListItemApi({ list_item_id: 'item-1', is_completed: true }) await updateListItemTitleApi('item-1', { title: 'Free-range eggs' })
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/items`, `${API_BASE_URL}/lists/items/item-1/title`,
expect.objectContaining({ method: 'PUT' }), expect.objectContaining({ method: 'POST' }),
) )
}) })
+23
View File
@@ -0,0 +1,23 @@
import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { PaginatedExercises } from '@/types/exercise'
export interface GetExercisesParams {
page?: number
count?: number
}
export async function getExercisesApi(
params: GetExercisesParams = {},
): Promise<PaginatedExercises> {
const query = new URLSearchParams()
if (params.page !== undefined) query.set('page', String(params.page))
if (params.count !== undefined) query.set('count', String(params.count))
const queryString = query.toString()
const response = await apiClient.get(`/exercises${queryString ? `?${queryString}` : ''}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load exercises'))
}
return response.json()
}
+9 -1
View File
@@ -1,6 +1,6 @@
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http' import { extractErrorMessage } from '@/api/http'
import type { Invite, PaginatedInvites } from '@/types/invite' import type { Invite, InviteStatusCounts, PaginatedInvites } from '@/types/invite'
export interface GetInvitesParams { export interface GetInvitesParams {
page?: number page?: number
@@ -20,6 +20,14 @@ export async function getInvitesApi(params: GetInvitesParams = {}): Promise<Pagi
return response.json() return response.json()
} }
export async function getInviteStatusApi(): Promise<InviteStatusCounts> {
const response = await apiClient.get('/user/invites/status')
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load invite counts'))
}
return response.json()
}
export async function createInviteApi(): Promise<Invite> { export async function createInviteApi(): Promise<Invite> {
const response = await apiClient.post('/user/invites') const response = await apiClient.post('/user/invites')
if (!response.ok) { if (!response.ok) {
+20 -9
View File
@@ -5,10 +5,11 @@ import type {
ListItem, ListItem,
CreateListPayload, CreateListPayload,
CreateListItemPayload, CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload, SetListItemCompletedPayload,
SetListItemTitlePayload,
AddUserToListPayload, AddUserToListPayload,
RemoveUserFromListPayload, RemoveUserFromListPayload,
OrderListsPayload,
} from '@/types/list' } from '@/types/list'
export async function getListsApi(): Promise<List[]> { export async function getListsApi(): Promise<List[]> {
@@ -43,23 +44,26 @@ export async function createListItemApi(payload: CreateListItemPayload): Promise
return response.json() return response.json()
} }
export async function updateListItemApi(payload: UpdateListItemPayload): Promise<void> {
const response = await apiClient.put('/lists/items', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to update list item'))
}
}
export async function setListItemCompletedApi( export async function setListItemCompletedApi(
itemId: string, itemId: string,
payload: SetListItemCompletedPayload, payload: SetListItemCompletedPayload,
): Promise<void> { ): Promise<void> {
const response = await apiClient.post(`/lists/items/${itemId}`, payload) const response = await apiClient.post(`/lists/items/${itemId}/complete`, payload)
if (!response.ok) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to update list item status')) throw new Error(await extractErrorMessage(response, 'Failed to update list item status'))
} }
} }
export async function updateListItemTitleApi(
itemId: string,
payload: SetListItemTitlePayload,
): Promise<void> {
const response = await apiClient.post(`/lists/items/${itemId}/title`, payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to update list item title'))
}
}
export async function addUserToListApi(payload: AddUserToListPayload): Promise<void> { export async function addUserToListApi(payload: AddUserToListPayload): Promise<void> {
const response = await apiClient.post('/lists/user', payload) const response = await apiClient.post('/lists/user', payload)
if (!response.ok) { if (!response.ok) {
@@ -74,6 +78,13 @@ export async function removeUserFromListApi(payload: RemoveUserFromListPayload):
} }
} }
export async function orderListsApi(payload: OrderListsPayload): Promise<void> {
const response = await apiClient.post('/lists/order', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to reorder lists'))
}
}
export async function deleteListApi(listId: string): Promise<void> { export async function deleteListApi(listId: string): Promise<void> {
const response = await apiClient.delete(`/lists/${listId}`) const response = await apiClient.delete(`/lists/${listId}`)
if (!response.ok) { if (!response.ok) {
+10
View File
@@ -0,0 +1,10 @@
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
import type { VersionInfo } from '@/types/version'
export async function getVersionApi(): Promise<VersionInfo> {
const response = await fetch(`${API_BASE_URL}/version`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load API version'))
}
return response.json()
}
+18
View File
@@ -19,6 +19,24 @@ const listsStore = useListsStore()
listsStore.pendingCount listsStore.pendingCount
}}</span> }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/exercises" class="nav-item" active-class="is-active">
<svg
class="nav-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="1" y="9" width="3" height="6" rx="1" />
<rect x="4.5" y="7" width="2" height="10" rx="1" />
<line x1="6.5" y1="12" x2="17.5" y2="12" />
<rect x="17.5" y="7" width="2" height="10" rx="1" />
<rect x="20" y="9" width="3" height="6" rx="1" />
</svg>
<span class="nav-label">Exercises</span>
</RouterLink>
<RouterLink to="/account" class="nav-item" active-class="is-active"> <RouterLink to="/account" class="nav-item" active-class="is-active">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3.2" /> <circle cx="12" cy="12" r="3.2" />
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import BaseModal from '@/components/BaseModal.vue'
withDefaults(
defineProps<{
title: string
description: string
confirmLabel?: string
}>(),
{
confirmLabel: 'Delete',
},
)
const emit = defineEmits<{
close: []
confirm: []
}>()
function handleClose() {
emit('close')
}
function handleConfirm() {
emit('confirm')
}
</script>
<template>
<BaseModal :title="title" title-id="confirm-delete-modal-title" @close="handleClose">
<p class="modal-description">{{ description }}</p>
<template #footer>
<button type="button" class="btn btn-secondary cancel-btn" @click="handleClose">
Cancel
</button>
<button type="button" class="btn btn-danger confirm-delete-btn" @click="handleConfirm">
{{ confirmLabel }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1.25rem;
line-height: 1.4;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { LocalListItem } from '@/database/db'
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'
const props = defineProps<{
item: LocalListItem
}>()
const emit = defineEmits<{
close: []
confirm: []
}>()
function handleClose() {
emit('close')
}
function handleConfirm() {
emit('confirm')
}
</script>
<template>
<ConfirmDeleteModal
:title="`Delete &quot;${props.item.title}&quot;?`"
description="Are you sure you want to delete this item? This action cannot be undone."
confirm-label="Delete item"
@close="handleClose"
@confirm="handleConfirm"
/>
</template>
+8 -29
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { LocalList } from '@/database/db' import type { LocalList } from '@/database/db'
import BaseModal from '@/components/BaseModal.vue' import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'
defineProps<{ const props = defineProps<{
list: LocalList list: LocalList
}>() }>()
@@ -21,32 +21,11 @@ function handleConfirm() {
</script> </script>
<template> <template>
<BaseModal <ConfirmDeleteModal
:title="`Delete &quot;${list.name}&quot;?`" :title="`Delete &quot;${props.list.name}&quot;?`"
title-id="delete-modal-title" description="Are you sure you want to delete this list? This action cannot be undone and all items in this list will be deleted."
confirm-label="Delete list"
@close="handleClose" @close="handleClose"
> @confirm="handleConfirm"
<p class="modal-description"> />
Are you sure you want to delete this list? This action cannot be undone and all items in this
list will be deleted.
</p>
<template #footer>
<button type="button" class="btn btn-secondary cancel-btn" @click="handleClose">
Cancel
</button>
<button type="button" class="btn btn-danger confirm-delete-btn" @click="handleConfirm">
Delete list
</button>
</template> </template>
</BaseModal>
</template>
<style scoped>
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1.25rem;
line-height: 1.4;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Exercise } from '@/types/exercise'
import { EQUIPMENT_LABELS, LOAD_LABELS, METRIC_LABELS } from '@/types/exercise'
const props = defineProps<{ exercise: Exercise }>()
const emit = defineEmits<{
select: [id: string]
}>()
const equipmentLabels = computed(
() => props.exercise.equipment?.map((equipment) => EQUIPMENT_LABELS[equipment]) ?? [],
)
const loadLabel = computed(() =>
props.exercise.load !== undefined ? LOAD_LABELS[props.exercise.load] : null,
)
const metricLabel = computed(() =>
props.exercise.metric !== undefined ? METRIC_LABELS[props.exercise.metric] : null,
)
function handleClick() {
emit('select', props.exercise.id)
}
</script>
<template>
<button type="button" class="exercise-card card" @click="handleClick">
<div class="exercise-card-main">
<h3>{{ exercise.name }}</h3>
<p v-if="exercise.notes" class="notes">{{ exercise.notes }}</p>
<div v-if="loadLabel || metricLabel || equipmentLabels.length > 0" class="tags">
<span v-if="loadLabel" class="tag tag-accent">{{ loadLabel }}</span>
<span v-if="metricLabel" class="tag tag-accent">{{ metricLabel }}</span>
<span v-for="equipment in equipmentLabels" :key="equipment" class="tag">{{
equipment
}}</span>
</div>
</div>
<span class="chevron"></span>
</button>
</template>
<style scoped>
.exercise-card {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem;
color: inherit;
background-color: var(--c-bg-soft);
font: inherit;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease-in-out;
}
.exercise-card:hover {
border-color: var(--c-border-hover);
}
.exercise-card-main {
flex: 1;
min-width: 0;
}
.exercise-card-main h3 {
font-size: 1.02rem;
margin-bottom: 0.2rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notes {
font-size: 0.8rem;
color: var(--c-text-soft);
margin-bottom: 0.4rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
font-size: 0.68rem;
font-weight: 500;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background-color: var(--c-bg-mute);
color: var(--c-text-soft);
}
.tag-accent {
background-color: var(--c-accent-bg);
color: var(--c-accent-strong);
}
.chevron {
flex-shrink: 0;
color: var(--c-text-soft);
font-size: 1.3rem;
line-height: 1;
padding-right: 0.25rem;
}
</style>
+54 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getInvitesApi, createInviteApi, deleteInviteApi } from '@/api/invites' import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '@/api/invites'
import type { Invite } from '@/types/invite' import type { Invite, InviteStatusCounts } from '@/types/invite'
type InviteStatus = 'active' | 'used' | 'expired' type InviteStatus = 'active' | 'used' | 'expired'
@@ -17,16 +17,24 @@ const error = ref('')
const pendingDeleteId = ref<string | null>(null) const pendingDeleteId = ref<string | null>(null)
const deletingId = ref<string | null>(null) const deletingId = ref<string | null>(null)
const sharedId = ref<string | null>(null) const sharedId = ref<string | null>(null)
// Counts across ALL invites (not just the current page)
const statusCounts = ref<InviteStatusCounts | null>(null)
let hasLoaded = false let hasLoaded = false
let sharedTimeout: ReturnType<typeof setTimeout> | undefined let sharedTimeout: ReturnType<typeof setTimeout> | undefined
const activeCount = computed( const totalInvites = computed(() =>
() => invites.value.filter((invite) => inviteStatus(invite) === 'active').length, statusCounts.value
? statusCounts.value.active + statusCounts.value.expired + statusCounts.value.used
: null,
) )
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const showPagination = computed(() => totalPages.value > 1) const showPagination = computed(() => totalPages.value > 1)
onMounted(() => {
void loadStatusCounts()
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearTimeout(sharedTimeout) clearTimeout(sharedTimeout)
}) })
@@ -62,6 +70,16 @@ async function loadInvites() {
} }
} }
async function loadStatusCounts() {
if (isOffline()) return
try {
statusCounts.value = await getInviteStatusApi()
} catch {
// Non-critical: the header badges just stay hidden until the next
// successful fetch instead of blocking the rest of the panel.
}
}
async function goToPage(target: number) { async function goToPage(target: number) {
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) { if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
return return
@@ -82,6 +100,7 @@ async function handleCreate() {
const invite = await createInviteApi() const invite = await createInviteApi()
page.value = 1 page.value = 1
await loadInvites() await loadInvites()
await loadStatusCounts()
// Sharing is the whole point of an invite, so offer it immediately // Sharing is the whole point of an invite, so offer it immediately
// instead of making the user hunt for the Share button afterwards. // instead of making the user hunt for the Share button afterwards.
await shareInvite(invite) await shareInvite(invite)
@@ -118,6 +137,7 @@ async function confirmDelete(id: string) {
page.value -= 1 page.value -= 1
} }
await loadInvites() await loadInvites()
await loadStatusCounts()
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete invite' error.value = err instanceof Error ? err.message : 'Failed to delete invite'
} finally { } finally {
@@ -222,7 +242,12 @@ function inviteDetail(invite: Invite): string {
> >
<span class="invites-toggle-label"> <span class="invites-toggle-label">
<h4>Invites</h4> <h4>Invites</h4>
<span v-if="activeCount > 0" class="invites-count-badge">{{ activeCount }} active</span> <span v-if="statusCounts" class="invites-stats">
<span class="invites-count-badge">{{ statusCounts.active }} active</span>
<span class="invites-count-badge badge-expired">{{ statusCounts.expired }} expired</span>
<span class="invites-count-badge badge-used">{{ statusCounts.used }} used</span>
<span class="invites-total-label">{{ totalInvites }} total</span>
</span>
</span> </span>
<span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true"></span> <span class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true"></span>
</button> </button>
@@ -345,6 +370,7 @@ function inviteDetail(invite: Invite): string {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
} }
.invites-toggle-label h4 { .invites-toggle-label h4 {
@@ -352,6 +378,13 @@ function inviteDetail(invite: Invite): string {
margin: 0; margin: 0;
} }
.invites-stats {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.invites-count-badge { .invites-count-badge {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 600; font-weight: 600;
@@ -361,6 +394,21 @@ function inviteDetail(invite: Invite): string {
color: var(--c-accent-strong); color: var(--c-accent-strong);
} }
.invites-count-badge.badge-expired {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.invites-count-badge.badge-used {
background-color: var(--c-bg-elevated);
color: var(--c-text-soft);
}
.invites-total-label {
font-size: 0.65rem;
color: var(--c-text-soft);
}
.chevron { .chevron {
color: var(--c-text-soft); color: var(--c-text-soft);
transition: transform 0.15s ease-in-out; transition: transform 0.15s ease-in-out;
+62 -2
View File
@@ -5,10 +5,11 @@ import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu' import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ list: LocalList }>() const props = defineProps<{ list: LocalList; dragging?: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
share: [list: LocalList] share: [list: LocalList]
delete: [list: LocalList] delete: [list: LocalList]
'handle-pointerdown': [event: PointerEvent]
}>() }>()
const listsStore = useListsStore() const listsStore = useListsStore()
@@ -49,7 +50,24 @@ function handleDelete(event: Event) {
</script> </script>
<template> <template>
<div class="list-card card"> <div class="list-card card" :class="{ 'is-dragging': dragging }">
<button
type="button"
class="grab-handle"
aria-label="Reorder list"
title="Drag to reorder"
@pointerdown="emit('handle-pointerdown', $event)"
>
<svg class="grab-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
<circle cx="9" cy="6" r="1.6" />
<circle cx="15" cy="6" r="1.6" />
<circle cx="9" cy="12" r="1.6" />
<circle cx="15" cy="12" r="1.6" />
<circle cx="9" cy="18" r="1.6" />
<circle cx="15" cy="18" r="1.6" />
</svg>
</button>
<RouterLink :to="`/lists/${list.id}`" class="list-card-link"> <RouterLink :to="`/lists/${list.id}`" class="list-card-link">
<div class="list-card-main"> <div class="list-card-main">
<h3>{{ list.name }}</h3> <h3>{{ list.name }}</h3>
@@ -144,6 +162,48 @@ function handleDelete(event: Event) {
border-color: var(--c-border-hover); border-color: var(--c-border-hover);
} }
.list-card.is-dragging {
border-color: var(--c-accent-strong);
box-shadow: var(--shadow-md);
}
.grab-handle {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 44px;
margin: -0.5rem -0.2rem -0.5rem -0.5rem;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
cursor: grab;
touch-action: none;
-webkit-user-select: none;
user-select: none;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out;
}
.grab-handle:hover {
background-color: var(--c-bg-mute);
color: var(--c-heading);
}
.list-card.is-dragging .grab-handle {
cursor: grabbing;
color: var(--c-accent-strong);
}
.grab-icon {
display: block;
pointer-events: none;
}
.list-card-link { .list-card-link {
display: flex; display: flex;
align-items: center; align-items: center;
+37 -6
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue'
import type { LocalListItem } from '@/database/db' import type { LocalListItem } from '@/database/db'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu' import { useDismissableMenu } from '@/composables/useDismissableMenu'
import DeleteListItemModal from '@/components/DeleteListItemModal.vue'
const props = defineProps<{ item: LocalListItem }>() const props = defineProps<{ item: LocalListItem }>()
@@ -16,6 +17,7 @@ const editedTitle = ref(props.item.title)
// diffing against the live (possibly just-changed) prop and overwriting the // diffing against the live (possibly just-changed) prop and overwriting the
// remote edit with the untouched original text. // remote edit with the untouched original text.
const originalTitle = ref(props.item.title) const originalTitle = ref(props.item.title)
const showDeleteModal = ref(false)
const { const {
isOpen: isMenuOpen, isOpen: isMenuOpen,
containerRef: menuContainerRef, containerRef: menuContainerRef,
@@ -27,6 +29,7 @@ function toggleCompleted() {
} }
function startEditing() { function startEditing() {
isMenuOpen.value = false
editedTitle.value = props.item.title editedTitle.value = props.item.title
originalTitle.value = props.item.title originalTitle.value = props.item.title
isEditing.value = true isEditing.value = true
@@ -35,15 +38,22 @@ function startEditing() {
function saveTitle() { function saveTitle() {
const title = editedTitle.value.trim() const title = editedTitle.value.trim()
if (title && title !== originalTitle.value) { if (title && title !== originalTitle.value) {
listsStore.updateListItem(props.item.id, { title }) listsStore.updateListItemTitle(props.item.id, title)
} }
isEditing.value = false isEditing.value = false
} }
function handleDelete(event: Event) { function handleOpenDelete() {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = false isMenuOpen.value = false
showDeleteModal.value = true
}
function handleCloseDelete() {
showDeleteModal.value = false
}
function handleConfirmDelete() {
showDeleteModal.value = false
listsStore.deleteListItem(props.item.id) listsStore.deleteListItem(props.item.id)
} }
</script> </script>
@@ -68,7 +78,7 @@ function handleDelete(event: Event) {
@keyup.escape="isEditing = false" @keyup.escape="isEditing = false"
@blur="saveTitle" @blur="saveTitle"
/> />
<span v-else class="title" @click="startEditing">{{ item.title }}</span> <span v-else class="title" @click="toggleCompleted">{{ item.title }}</span>
<span v-if="item.pendingSync" class="pending-dot" title="Not yet synced"></span> <span v-if="item.pendingSync" class="pending-dot" title="Not yet synced"></span>
@@ -90,11 +100,25 @@ function handleDelete(event: Event) {
</button> </button>
<div v-if="isMenuOpen" class="submenu-dropdown card" role="menu"> <div v-if="isMenuOpen" class="submenu-dropdown card" role="menu">
<button type="button" class="submenu-item" role="menuitem" @click="startEditing">
<svg
class="submenu-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 3a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L17 3z" />
</svg>
<span>Edit title</span>
</button>
<button <button
type="button" type="button"
class="submenu-item submenu-item-danger" class="submenu-item submenu-item-danger"
role="menuitem" role="menuitem"
@click="handleDelete" @click="handleOpenDelete"
> >
<svg <svg
class="submenu-icon" class="submenu-icon"
@@ -116,6 +140,13 @@ function handleDelete(event: Event) {
</button> </button>
</div> </div>
</div> </div>
<DeleteListItemModal
v-if="showDeleteModal"
:item="item"
@close="handleCloseDelete"
@confirm="handleConfirmDelete"
/>
</li> </li>
</template> </template>
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ConfirmDeleteModal from '../ConfirmDeleteModal.vue'
describe('ConfirmDeleteModal', () => {
it('renders title, description, and default confirm label', () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
expect(wrapper.text()).toContain('Delete "Groceries"?')
expect(wrapper.text()).toContain('Are you sure?')
expect(wrapper.find('.confirm-delete-btn').text()).toBe('Delete')
expect(wrapper.find('.cancel-btn').text()).toBe('Cancel')
})
it('renders a custom confirm label', () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Apples"?',
description: 'Are you sure?',
confirmLabel: 'Delete item',
},
})
expect(wrapper.find('.confirm-delete-btn').text()).toBe('Delete item')
})
it('emits confirm event when Delete button is clicked', async () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
await wrapper.find('.confirm-delete-btn').trigger('click')
expect(wrapper.emitted('confirm')).toBeTruthy()
})
it('emits close event when Cancel button is clicked', async () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
await wrapper.find('.cancel-btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('emits close event when close icon button is clicked', async () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
await wrapper.find('.close-btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('emits close event when clicking overlay background', async () => {
const wrapper = mount(ConfirmDeleteModal, {
props: {
title: 'Delete "Groceries"?',
description: 'Are you sure?',
},
})
await wrapper.find('.modal-overlay').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
})
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DeleteListItemModal from '../DeleteListItemModal.vue'
import type { LocalListItem } from '@/database/db'
describe('DeleteListItemModal', () => {
const sampleItem: LocalListItem = {
id: 'item-1',
list_id: 'list-1',
title: 'Apples',
is_completed: false,
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
it('renders modal with item title and confirmation prompt', () => {
const wrapper = mount(DeleteListItemModal, {
props: {
item: sampleItem,
},
})
expect(wrapper.text()).toContain('Delete "Apples"?')
expect(wrapper.text()).toContain('Are you sure you want to delete this item?')
expect(wrapper.find('.confirm-delete-btn').text()).toBe('Delete item')
expect(wrapper.find('.cancel-btn').text()).toBe('Cancel')
})
it('emits confirm event when Delete button is clicked', async () => {
const wrapper = mount(DeleteListItemModal, {
props: {
item: sampleItem,
},
})
await wrapper.find('.confirm-delete-btn').trigger('click')
expect(wrapper.emitted('confirm')).toBeTruthy()
})
it('emits close event when Cancel button is clicked', async () => {
const wrapper = mount(DeleteListItemModal, {
props: {
item: sampleItem,
},
})
await wrapper.find('.cancel-btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
})
@@ -18,6 +18,13 @@ describe('InvitesPanel', () => {
}) })
// jsdom has no Web Share API; tests that want it define it explicitly. // jsdom has no Web Share API; tests that want it define it explicitly.
Reflect.deleteProperty(navigator, 'share') Reflect.deleteProperty(navigator, 'share')
// Every mount fetches status counts once on mount; give it a harmless
// default so tests that don't care about counts don't hit real fetch.
vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 0,
expired: 0,
used: 0,
})
}) })
it('renders collapsed by default without loading invites', () => { it('renders collapsed by default without loading invites', () => {
@@ -30,6 +37,115 @@ describe('InvitesPanel', () => {
expect(getSpy).not.toHaveBeenCalled() expect(getSpy).not.toHaveBeenCalled()
}) })
it('fetches invite status counts on mount and shows them in the header while collapsed', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValueOnce({
active: 12,
expired: 2,
used: 8,
})
const wrapper = mount(InvitesPanel)
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
expect(wrapper.find('#invites-panel').exists()).toBe(false)
expect(wrapper.text()).toContain('12 active')
expect(wrapper.text()).toContain('2 expired')
expect(wrapper.text()).toContain('8 used')
expect(wrapper.text()).toContain('22 total')
})
it('does not fetch status counts on mount when offline', () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi')
mount(InvitesPanel)
expect(statusSpy).not.toHaveBeenCalled()
})
it('hides status badges without an error banner when the status endpoint fails', async () => {
vi.spyOn(invitesApi, 'getInviteStatusApi').mockRejectedValueOnce(new Error('boom'))
const wrapper = mount(InvitesPanel)
await flushPromises()
expect(wrapper.find('.invites-stats').exists()).toBe(false)
expect(wrapper.find('.banner-error').exists()).toBe(false)
})
it('does not refetch status counts when paginating', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 11,
expired: 0,
used: 0,
})
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(page1, 11))
.mockResolvedValueOnce(paginated(page2, 11))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
})
it('refetches status counts after creating an invite', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 1,
expired: 0,
used: 0,
})
const newInvite: Invite = { id: 'invite-new', code: 'NEWCODE1' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([]))
.mockResolvedValueOnce(paginated([newInvite]))
vi.spyOn(invitesApi, 'createInviteApi').mockResolvedValueOnce(newInvite)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
await wrapper.find('.generate-btn').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(2)
})
it('refetches status counts after deleting an invite', async () => {
const statusSpy = vi.spyOn(invitesApi, 'getInviteStatusApi').mockResolvedValue({
active: 0,
expired: 0,
used: 0,
})
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([mockInvite]))
.mockResolvedValueOnce(paginated([]))
vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(1)
await wrapper.find('.ticket-btn-danger').trigger('click')
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
await flushPromises()
expect(statusSpy).toHaveBeenCalledTimes(2)
})
it('loads and displays invites on expand', async () => { it('loads and displays invites on expand', async () => {
const mockInvites: Invite[] = [ const mockInvites: Invite[] = [
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' }, { id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
+67 -3
View File
@@ -47,7 +47,39 @@ describe('ListItemRow', () => {
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
}) })
it('deletes item when Delete item is clicked in submenu', async () => { it('toggles completed state when the title text is clicked', async () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
},
})
const listsStore = useListsStore()
const toggleSpy = vi.spyOn(listsStore, 'setListItemCompleted').mockResolvedValue()
await wrapper.find('.title').trigger('click')
expect(toggleSpy).toHaveBeenCalledWith('item-1', true)
})
it('opens the title editor via the Edit title submenu item', async () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
},
})
await wrapper.find('.menu-trigger-btn').trigger('click')
const editBtn = wrapper.find('.submenu-item:not(.submenu-item-danger)')
expect(editBtn.text()).toContain('Edit title')
await editBtn.trigger('click')
expect(wrapper.find('.title-input').exists()).toBe(true)
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
it('opens a confirmation modal when Delete item is clicked in submenu', async () => {
const wrapper = mount(ListItemRow, { const wrapper = mount(ListItemRow, {
props: { props: {
item: sampleItem, item: sampleItem,
@@ -61,10 +93,42 @@ describe('ListItemRow', () => {
await wrapper.find('.menu-trigger-btn').trigger('click') await wrapper.find('.menu-trigger-btn').trigger('click')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(true) expect(wrapper.find('.submenu-dropdown').exists()).toBe(true)
// 2nd click: delete item // 2nd click: opens confirmation modal, doesn't delete yet
await wrapper.find('.submenu-item-danger').trigger('click') await wrapper.find('.submenu-item-danger').trigger('click')
expect(deleteSpy).toHaveBeenCalledWith('item-1')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false) expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
expect(deleteSpy).not.toHaveBeenCalled()
const modal = wrapper.findComponent({ name: 'DeleteListItemModal' })
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('Delete "Apples"?')
// Confirm deletion in modal
await modal.find('.confirm-delete-btn').trigger('click')
expect(deleteSpy).toHaveBeenCalledWith('item-1')
expect(wrapper.findComponent({ name: 'DeleteListItemModal' }).exists()).toBe(false)
})
it('cancels item deletion when cancel is clicked in confirmation modal', async () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
},
})
const listsStore = useListsStore()
const deleteSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue()
await wrapper.find('.menu-trigger-btn').trigger('click')
await wrapper.find('.submenu-item-danger').trigger('click')
const modal = wrapper.findComponent({ name: 'DeleteListItemModal' })
expect(modal.exists()).toBe(true)
await modal.find('.cancel-btn').trigger('click')
expect(deleteSpy).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'DeleteListItemModal' }).exists()).toBe(false)
}) })
}) })
+129
View File
@@ -0,0 +1,129 @@
import { ref, type Ref } from 'vue'
import type { LocalList } from '@/database/db'
// Matches the CSS transition duration on `.list-row` (see ListsView.vue) -
// how long the dragged row's "snap into place" animation takes after drop,
// before handing the final order back to the caller.
const SETTLE_MS = 220
interface SlotRect {
top: number
height: number
}
// Drag-to-reorder for the lists overview page. Deliberately built on raw
// Pointer Events rather than HTML5 drag-and-drop, which has no usable touch
// story - this needs to work as a one-finger drag on a phone first.
//
// The dragged row stays in the v-for (rather than becoming a floating
// clone): its transform is computed each pointermove as the delta between
// where the pointer says it should be and where it currently sits in the
// (already reordered) array. Because `items` gets live-spliced as the
// pointer crosses sibling midpoints, that natural position jumps in whole
// slot-steps while the transform absorbs the remainder - the row tracks the
// finger with no lag and no double-counted offset. Every other row's shift
// is left entirely to <TransitionGroup>'s built-in FLIP move animation.
export function useListDragReorder(
items: Ref<LocalList[]>,
onReorder: (orderedIds: string[]) => void,
) {
const draggingId = ref<string | null>(null)
const isPointerActive = ref(false)
const dragOffsetPx = ref(0)
const itemEls = new Map<string, HTMLElement>()
let slots: SlotRect[] = []
let startClientY = 0
let startTop = 0
let startHeight = 0
let activePointerId: number | null = null
let settleTimeout: ReturnType<typeof setTimeout> | null = null
function setItemRef(id: string, el: Element | null) {
if (el) itemEls.set(id, el as HTMLElement)
else itemEls.delete(id)
}
function onPointerDown(id: string, event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return
const el = itemEls.get(id)
if (!el) return
if (settleTimeout !== null) {
clearTimeout(settleTimeout)
settleTimeout = null
}
event.preventDefault()
activePointerId = event.pointerId
// Fixed physical slot positions for this drag, captured once up front.
// Reordering `items` mid-drag doesn't shift these - slot i always means
// "the i-th row's on-screen position", independent of which id currently
// occupies it - so the target-slot math below never has to re-measure a
// layout our own reordering just changed.
slots = items.value.map((item) => {
const itemEl = itemEls.get(item.id)
const rect = itemEl?.getBoundingClientRect()
return { top: rect?.top ?? 0, height: rect?.height ?? 0 }
})
const rect = el.getBoundingClientRect()
startTop = rect.top
startHeight = rect.height
startClientY = event.clientY
draggingId.value = id
isPointerActive.value = true
dragOffsetPx.value = 0
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerUp)
}
function onPointerMove(event: PointerEvent) {
if (!isPointerActive.value || event.pointerId !== activePointerId) return
event.preventDefault()
const desiredTop = startTop + (event.clientY - startClientY)
const draggedCenter = desiredTop + startHeight / 2
let targetIndex = slots.findIndex((slot) => draggedCenter < slot.top + slot.height / 2)
if (targetIndex === -1) targetIndex = slots.length - 1
const currentIndex = items.value.findIndex((item) => item.id === draggingId.value)
if (currentIndex !== -1 && targetIndex !== currentIndex) {
const reordered = [...items.value]
const moved = reordered.splice(currentIndex, 1)[0]
if (moved) {
reordered.splice(targetIndex, 0, moved)
items.value = reordered
}
}
const settledIndex = items.value.findIndex((item) => item.id === draggingId.value)
dragOffsetPx.value = desiredTop - (slots[settledIndex]?.top ?? startTop)
}
function onPointerUp(event: PointerEvent) {
if (!isPointerActive.value || event.pointerId !== activePointerId) return
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
activePointerId = null
isPointerActive.value = false
// Let the row's CSS transition animate the residual offset back to 0
// (see ListsView.vue: transitions are suppressed only while
// isPointerActive), then hand back the final order.
dragOffsetPx.value = 0
const finalIds = items.value.map((item) => item.id)
settleTimeout = setTimeout(() => {
settleTimeout = null
draggingId.value = null
onReorder(finalIds)
}, SETTLE_MS)
}
return { draggingId, isPointerActive, dragOffsetPx, setItemRef, onPointerDown }
}
+4 -2
View File
@@ -4,8 +4,9 @@ import type {
ListItem, ListItem,
CreateListPayload, CreateListPayload,
CreateListItemPayload, CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload, SetListItemCompletedPayload,
SetListItemTitlePayload,
OrderListsPayload,
} from '@/types/list' } from '@/types/list'
export interface LocalList extends List { export interface LocalList extends List {
@@ -36,10 +37,11 @@ interface SyncQueueEntryBase {
type SyncOperationPayloads = { type SyncOperationPayloads = {
createList: CreateListPayload createList: CreateListPayload
createListItem: CreateListItemPayload createListItem: CreateListItemPayload
updateListItem: UpdateListItemPayload updateListItemTitle: SetListItemTitlePayload
setListItemCompleted: SetListItemCompletedPayload setListItemCompleted: SetListItemCompletedPayload
deleteList: DeleteListPayload deleteList: DeleteListPayload
deleteListItem: DeleteListItemPayload deleteListItem: DeleteListItemPayload
orderLists: OrderListsPayload
} }
export type SyncOperationType = keyof SyncOperationPayloads export type SyncOperationType = keyof SyncOperationPayloads
+11
View File
@@ -18,11 +18,22 @@ const router = createRouter({
meta: { requiresAuth: true }, meta: { requiresAuth: true },
props: true, props: true,
}, },
{
path: '/exercises',
name: 'exercises',
component: () => import('../views/ExercisesView.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/login', path: '/login',
name: 'login', name: 'login',
component: () => import('../views/LoginView.vue'), component: () => import('../views/LoginView.vue'),
}, },
{
path: '/cv',
name: 'cv',
component: () => import('../views/CvView.vue'),
},
{ {
path: '/account', path: '/account',
name: 'account', name: 'account',
+105 -8
View File
@@ -109,12 +109,13 @@ const listsApiMocks = vi.hoisted(() => ({
createListApi: vi.fn<() => Promise<unknown>>(), createListApi: vi.fn<() => Promise<unknown>>(),
getListItemsApi: vi.fn<() => Promise<unknown>>(), getListItemsApi: vi.fn<() => Promise<unknown>>(),
createListItemApi: vi.fn<() => Promise<unknown>>(), createListItemApi: vi.fn<() => Promise<unknown>>(),
updateListItemApi: vi.fn<() => Promise<unknown>>(), updateListItemTitleApi: vi.fn<() => Promise<unknown>>(),
setListItemCompletedApi: vi.fn<() => Promise<unknown>>(), setListItemCompletedApi: vi.fn<() => Promise<unknown>>(),
addUserToListApi: vi.fn<() => Promise<unknown>>(), addUserToListApi: vi.fn<() => Promise<unknown>>(),
removeUserFromListApi: vi.fn<() => Promise<unknown>>(), removeUserFromListApi: vi.fn<() => Promise<unknown>>(),
deleteListApi: vi.fn<() => Promise<unknown>>(), deleteListApi: vi.fn<() => Promise<unknown>>(),
deleteListItemApi: vi.fn<() => Promise<unknown>>(), deleteListItemApi: vi.fn<() => Promise<unknown>>(),
orderListsApi: vi.fn<() => Promise<unknown>>(),
})) }))
vi.mock('@/api/lists', () => listsApiMocks) vi.mock('@/api/lists', () => listsApiMocks)
@@ -211,29 +212,28 @@ describe('useListsStore', () => {
expect(store.error).toBe('Network error') expect(store.error).toBe('Network error')
}) })
it('updates a list item locally and pushes the change to the server', async () => { it('updates a list item title locally and pushes the change via the dedicated endpoint', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({ listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-2', id: 'server-item-2',
list_id: '', list_id: '',
title: 'Eggs', title: 'Eggs',
is_completed: false, is_completed: false,
}) })
listsApiMocks.updateListItemApi.mockResolvedValueOnce(undefined) listsApiMocks.updateListItemTitleApi.mockResolvedValueOnce(undefined)
const store = useListsStore() const store = useListsStore()
await store.createListItem('list-1', 'Eggs') await store.createListItem('list-1', 'Eggs')
await store.sync() await store.sync()
const created = store.listItems.find((entry) => entry.title === 'Eggs')! const created = store.listItems.find((entry) => entry.title === 'Eggs')!
await store.updateListItem(created.id, { is_completed: true }) await store.updateListItemTitle(created.id, 'Free-range eggs')
await store.sync() await store.sync()
expect(listsApiMocks.updateListItemApi).toHaveBeenCalledWith({ expect(listsApiMocks.updateListItemTitleApi).toHaveBeenCalledWith(created.id, {
list_item_id: created.id, title: 'Free-range eggs',
is_completed: true,
}) })
const updated = store.listItems.find((entry) => entry.id === created.id) const updated = store.listItems.find((entry) => entry.id === created.id)
expect(updated?.is_completed).toBe(true) expect(updated?.title).toBe('Free-range eggs')
expect(updated?.pendingSync).toBe(false) expect(updated?.pendingSync).toBe(false)
}) })
@@ -532,6 +532,103 @@ describe('useListsStore', () => {
expect(store.pendingCount).toBe(0) expect(store.pendingCount).toBe(0)
}) })
it('sorts lists by position, breaking ties (e.g. two lists both at position 0) by newest first', async () => {
const store = useListsStore()
await fakeDb.lists.put({
id: 'list-b',
name: 'B',
position: 1,
created_at: '2024-01-01T00:00:00.000Z',
pendingSync: false,
})
await fakeDb.lists.put({
id: 'list-a',
name: 'A',
position: 0,
created_at: '2024-01-02T00:00:00.000Z',
pendingSync: false,
})
// Newly created lists always come back from the server at position 0 (so
// a new list is always on top), so a not-yet-synced local list (no
// position of its own yet, defaulting to 0) must also win the tiebreak
// against any existing position-0 list by virtue of being newest.
await fakeDb.lists.put({
id: 'list-new',
name: 'New',
created_at: '2024-01-03T00:00:00.000Z',
pendingSync: true,
})
await store.refresh()
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-new', 'list-a', 'list-b'])
})
it('reorders lists optimistically and pushes the new order via the dedicated endpoint', async () => {
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
listsApiMocks.getListsApi.mockResolvedValueOnce([
{ id: 'list-a', name: 'A', position: 1 },
{ id: 'list-b', name: 'B', position: 0 },
])
const store = useListsStore()
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
await store.refresh()
await store.reorderLists(['list-b', 'list-a'])
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-b', 'list-a'])
expect(store.lists.every((list) => list.pendingSync)).toBe(true)
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-b', 'list-a'] })
expect(store.lists.find((list) => list.id === 'list-a')?.pendingSync).toBe(false)
expect(store.lists.find((list) => list.id === 'list-b')?.pendingSync).toBe(false)
expect(store.pendingCount).toBe(0)
})
it('supersedes a stale queued reorder instead of replaying both', async () => {
listsApiMocks.orderListsApi.mockResolvedValue(undefined)
const store = useListsStore()
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
await store.refresh()
await store.reorderLists(['list-b', 'list-a'])
await store.reorderLists(['list-a', 'list-b'])
expect(store.pendingCount).toBe(1)
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledTimes(1)
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-a', 'list-b'] })
})
it('remaps a queued reorder entry when one of its lists gets its server id assigned mid-flight', async () => {
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-list-1', name: 'New list' })
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
listsApiMocks.getListsApi.mockResolvedValueOnce([
{ id: 'server-list-1', name: 'New list', position: 0 },
{ id: 'list-a', name: 'A', position: 1 },
])
const store = useListsStore()
const localList = await store.createList('New list')
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
await store.refresh()
// Queues an "orderLists" entry referencing the not-yet-synced localList.id,
// created after the still-pending "createList" entry.
await store.reorderLists([localList.id, 'list-a'])
await store.sync()
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({
list_ids: ['server-list-1', 'list-a'],
})
})
it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => { it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => {
let resolveGetLists!: (value: unknown[]) => void let resolveGetLists!: (value: unknown[]) => void
listsApiMocks.getListsApi.mockImplementationOnce( listsApiMocks.getListsApi.mockImplementationOnce(
+86 -15
View File
@@ -13,12 +13,13 @@ import {
createListApi, createListApi,
getListItemsApi, getListItemsApi,
createListItemApi, createListItemApi,
updateListItemApi, updateListItemTitleApi,
setListItemCompletedApi, setListItemCompletedApi,
addUserToListApi, addUserToListApi,
removeUserFromListApi, removeUserFromListApi,
deleteListApi, deleteListApi,
deleteListItemApi, deleteListItemApi,
orderListsApi,
} from '@/api/lists' } from '@/api/lists'
// A burst of rapid edits (ticking off several items, typing then blurring a // A burst of rapid edits (ticking off several items, typing then blurring a
@@ -42,8 +43,18 @@ export const useListsStore = defineStore('lists', () => {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const pendingCount = ref(0) const pendingCount = ref(0)
// Lists carry a server-assigned `position`, rearranged via reorderLists().
// New lists (local-only or freshly synced) always come back as position 0
// - by design, so a new list always lands at the top - which means several
// lists can share a position. created_at desc breaks that tie, newest
// first, and also covers a local list created but not yet synced (no
// position of its own yet: defaults to 0 below).
const sortedLists = computed(() => const sortedLists = computed(() =>
[...lists.value].sort((a, b) => (b.modified_at ?? '').localeCompare(a.modified_at ?? '')), [...lists.value].sort((a, b) => {
const positionDiff = (a.position ?? 0) - (b.position ?? 0)
if (positionDiff !== 0) return positionDiff
return (b.created_at ?? '').localeCompare(a.created_at ?? '')
}),
) )
function itemsForList(listId: string) { function itemsForList(listId: string) {
@@ -180,12 +191,9 @@ export const useListsStore = defineStore('lists', () => {
return localItem return localItem
} }
async function updateListItem( async function updateListItemTitle(itemId: string, title: string) {
itemId: string,
changes: { title?: string; is_completed?: boolean },
) {
const patch = { const patch = {
...changes, title,
modified_at: new Date().toISOString(), modified_at: new Date().toISOString(),
pendingSync: true, pendingSync: true,
} }
@@ -193,8 +201,8 @@ export const useListsStore = defineStore('lists', () => {
const existingItem = listItems.value.find((entry) => entry.id === itemId) const existingItem = listItems.value.find((entry) => entry.id === itemId)
if (existingItem) Object.assign(existingItem, patch) if (existingItem) Object.assign(existingItem, patch)
await enqueue({ await enqueue({
type: 'updateListItem', type: 'updateListItemTitle',
payload: { list_item_id: itemId, ...changes }, payload: { title },
localListItemId: itemId, localListItemId: itemId,
}) })
scheduleSync() scheduleSync()
@@ -248,6 +256,37 @@ export const useListsStore = defineStore('lists', () => {
scheduleSync() scheduleSync()
} }
// Applies a full reordering of the user's lists (e.g. from a drag-and-drop
// gesture). Positions are reassigned optimistically to every list in
// `orderedIds`, and marked pendingSync so a pull racing the queued
// "orderLists" entry can't clobber the optimistic order before it syncs.
async function reorderLists(orderedIds: string[]) {
await Promise.all(
orderedIds.map((id, index) => db.lists.update(id, { position: index, pendingSync: true })),
)
for (const [index, id] of orderedIds.entries()) {
const existing = lists.value.find((entry) => entry.id === id)
if (existing) {
existing.position = index
existing.pendingSync = true
}
}
// Only the latest requested order matters, so any not-yet-synced
// "orderLists" entry is superseded rather than left to also replay.
const staleEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
if (staleEntries.length > 0) {
await db.syncQueue.bulkDelete(staleEntries.map((entry) => entry.id!))
pendingCount.value = Math.max(0, pendingCount.value - staleEntries.length)
}
await enqueue({
type: 'orderLists',
payload: { list_ids: orderedIds },
})
scheduleSync()
}
async function deleteListItem(itemId: string) { async function deleteListItem(itemId: string) {
await db.listItems.delete(itemId) await db.listItems.delete(itemId)
removeLocalListItem(itemId) removeLocalListItem(itemId)
@@ -295,6 +334,18 @@ export const useListsStore = defineStore('lists', () => {
payload: updatedPayload, payload: updatedPayload,
}) })
} }
// "orderLists" entries aren't tied to a single localListId (they carry
// every list's id in payload.list_ids), so they need their own remap pass.
const affectedOrderEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
for (const orderEntry of affectedOrderEntries) {
if (orderEntry.type !== 'orderLists' || !orderEntry.payload.list_ids.includes(oldId)) continue
await db.syncQueue.update(orderEntry.id!, {
payload: {
list_ids: orderEntry.payload.list_ids.map((id) => (id === oldId ? newId : id)),
},
})
}
} }
// Remaps a client-generated temporary list item id to the id assigned by // Remaps a client-generated temporary list item id to the id assigned by
@@ -357,11 +408,10 @@ export const useListsStore = defineStore('lists', () => {
} }
break break
} }
case 'updateListItem': { case 'updateListItemTitle': {
await updateListItemApi(entry.payload) if (!entry.localListItemId) break
if (entry.localListItemId) { await updateListItemTitleApi(entry.localListItemId, entry.payload)
await markListItemSynced(entry.localListItemId) await markListItemSynced(entry.localListItemId)
}
break break
} }
case 'setListItemCompleted': { case 'setListItemCompleted': {
@@ -378,6 +428,17 @@ export const useListsStore = defineStore('lists', () => {
await deleteListItemApi(entry.payload.id) await deleteListItemApi(entry.payload.id)
break break
} }
case 'orderLists': {
await orderListsApi(entry.payload)
await Promise.all(
entry.payload.list_ids.map((id) => db.lists.update(id, { pendingSync: false })),
)
const affectedIds = new Set(entry.payload.list_ids)
for (const list of lists.value) {
if (affectedIds.has(list.id)) list.pendingSync = false
}
break
}
} }
} }
@@ -388,7 +449,15 @@ export const useListsStore = defineStore('lists', () => {
try { try {
const queue = await db.syncQueue.orderBy('createdAt').toArray() const queue = await db.syncQueue.orderBy('createdAt').toArray()
for (const entry of queue) { for (const snapshotEntry of queue) {
// Re-read the entry rather than trusting the queue snapshot: an
// earlier iteration this same pass may have remapped a temporary id
// referenced in this entry's payload (e.g. remapListId rewriting a
// queued "orderLists" entry after its "createList" entry synced).
const entry =
snapshotEntry.id !== undefined
? ((await db.syncQueue.get(snapshotEntry.id)) ?? snapshotEntry)
: snapshotEntry
try { try {
await processSyncEntry(entry) await processSyncEntry(entry)
if (entry.id !== undefined) { if (entry.id !== undefined) {
@@ -564,17 +633,19 @@ export const useListsStore = defineStore('lists', () => {
pendingCount, pendingCount,
error, error,
itemsForList, itemsForList,
ensureLoaded,
loadLists, loadLists,
loadListItems, loadListItems,
refresh, refresh,
createList, createList,
createListItem, createListItem,
updateListItem, updateListItemTitle,
setListItemCompleted, setListItemCompleted,
addUserToList, addUserToList,
removeUserFromList, removeUserFromList,
deleteList, deleteList,
deleteListItem, deleteListItem,
reorderLists,
sync, sync,
pullFromServer, pullFromServer,
pullListItems, pullListItems,
+62
View File
@@ -0,0 +1,62 @@
export enum Equipment {
Unknown = 0,
Floor = 1,
Rings = 2,
PullUpBar = 3,
ParallelBars = 4,
LowBar = 5,
Parallettes = 6,
ResistanceBand = 7,
}
export enum Load {
Unknown = 0,
Bodyweight = 1,
External = 2,
}
export enum Metric {
Unknown = 0,
Reps = 1,
Seconds = 2,
}
export const EQUIPMENT_LABELS: Record<Equipment, string> = {
[Equipment.Unknown]: 'Unknown',
[Equipment.Floor]: 'Floor',
[Equipment.Rings]: 'Rings',
[Equipment.PullUpBar]: 'Pull-up bar',
[Equipment.ParallelBars]: 'Parallel bars',
[Equipment.LowBar]: 'Low bar',
[Equipment.Parallettes]: 'Parallettes',
[Equipment.ResistanceBand]: 'Resistance band',
}
export const LOAD_LABELS: Record<Load, string> = {
[Load.Unknown]: 'Unknown',
[Load.Bodyweight]: 'Bodyweight',
[Load.External]: 'External',
}
export const METRIC_LABELS: Record<Metric, string> = {
[Metric.Unknown]: 'Unknown',
[Metric.Reps]: 'Reps',
[Metric.Seconds]: 'Seconds',
}
export interface Exercise {
id: string
name: string
notes?: string
equipment?: Equipment[]
load?: Load
metric?: Metric
tags?: string[]
modified_at?: string
}
export interface PaginatedExercises {
data: Exercise[]
total: number
count: number
}
+6
View File
@@ -10,3 +10,9 @@ export interface PaginatedInvites {
total: number total: number
count: number count: number
} }
export interface InviteStatusCounts {
active: number
expired: number
used: number
}
+9 -6
View File
@@ -5,6 +5,7 @@ export interface List {
modified_at?: string modified_at?: string
total_items?: number total_items?: number
completed_items?: number completed_items?: number
position?: number
} }
export interface ListItem { export interface ListItem {
@@ -25,16 +26,14 @@ export interface CreateListItemPayload {
title: string title: string
} }
export interface UpdateListItemPayload {
list_item_id: string
title?: string
is_completed?: boolean
}
export interface SetListItemCompletedPayload { export interface SetListItemCompletedPayload {
is_completed: boolean is_completed: boolean
} }
export interface SetListItemTitlePayload {
title: string
}
export interface AddUserToListPayload { export interface AddUserToListPayload {
list_id: string list_id: string
email: string email: string
@@ -44,3 +43,7 @@ export interface RemoveUserFromListPayload {
list_id: string list_id: string
email: string email: string
} }
export interface OrderListsPayload {
list_ids: string[]
}
+5
View File
@@ -0,0 +1,5 @@
export interface VersionInfo {
version: string
commit: string
buildTime: string
}
+47 -1
View File
@@ -2,6 +2,8 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getVersionApi } from '@/api/version'
import type { VersionInfo } from '@/types/version'
import ChangePasswordModal from '@/components/ChangePasswordModal.vue' import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
import InvitesPanel from '@/components/InvitesPanel.vue' import InvitesPanel from '@/components/InvitesPanel.vue'
@@ -9,10 +11,27 @@ const listsStore = useListsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const showChangePassword = ref(false) const showChangePassword = ref(false)
const versionInfo = ref<VersionInfo | null>(null)
const versionError = ref('')
const isLoadingVersion = ref(false)
onMounted(() => { onMounted(() => {
listsStore.loadLists() listsStore.ensureLoaded()
loadVersion()
}) })
async function loadVersion() {
isLoadingVersion.value = true
versionError.value = ''
try {
versionInfo.value = await getVersionApi()
} catch (err) {
versionError.value = err instanceof Error ? err.message : 'Failed to load API information'
} finally {
isLoadingVersion.value = false
}
}
</script> </script>
<template> <template>
@@ -51,6 +70,27 @@ onMounted(() => {
</p> </p>
</section> </section>
<h1 class="section-heading">API information</h1>
<section class="card info-card">
<p v-if="isLoadingVersion" class="loading-text">Loading</p>
<template v-else-if="versionInfo">
<p class="row">
<span>Version</span>
<strong class="mono-num">{{ versionInfo.version }}</strong>
</p>
<p class="row">
<span>Commit</span>
<strong class="mono-num">{{ versionInfo.commit }}</strong>
</p>
<p class="row">
<span>Build time</span>
<strong>{{ versionInfo.buildTime }}</strong>
</p>
</template>
<p v-else class="banner banner-error">{{ versionError }}</p>
</section>
<ChangePasswordModal v-if="showChangePassword" @close="showChangePassword = false" /> <ChangePasswordModal v-if="showChangePassword" @close="showChangePassword = false" />
</main> </main>
</template> </template>
@@ -107,4 +147,10 @@ onMounted(() => {
margin-top: 0.6rem; margin-top: 0.6rem;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
.loading-text {
font-size: 0.85rem;
color: var(--c-text-soft);
margin: 0;
}
</style> </style>
+157
View File
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { getExercisesApi } from '@/api/exercises'
import type { Exercise } from '@/types/exercise'
import ExerciseCard from '@/components/ExerciseCard.vue'
const PAGE_SIZE = 20
const exercises = ref<Exercise[]>([])
const page = ref(1)
const total = ref(0)
const isLoading = ref(false)
const error = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const showPagination = computed(() => totalPages.value > 1)
onMounted(() => {
void loadExercises()
})
async function loadExercises() {
error.value = ''
isLoading.value = true
try {
const response = await getExercisesApi({ page: page.value, count: PAGE_SIZE })
exercises.value = response.data
total.value = response.total
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load exercises'
} finally {
isLoading.value = false
}
}
async function goToPage(target: number) {
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
return
}
page.value = target
await loadExercises()
}
// No workout/workout-template flow exists yet to receive a selection; this
// is the attachment point future "add exercise to workout" UI will use.
function handleSelectExercise() {}
</script>
<template>
<main class="page">
<h1>Exercises</h1>
<p v-if="error" class="banner banner-error">{{ error }}</p>
<p v-if="isLoading && exercises.length === 0" class="loading-text">Loading exercises</p>
<ul v-else-if="exercises.length > 0" class="exercises">
<li v-for="exercise in exercises" :key="exercise.id">
<ExerciseCard :exercise="exercise" @select="handleSelectExercise" />
</li>
</ul>
<div v-else class="empty-state">
<p>No exercises yet</p>
</div>
<div v-if="showPagination" class="pagination">
<button
type="button"
class="page-btn"
:disabled="page <= 1 || isLoading"
aria-label="Previous page"
@click="goToPage(page - 1)"
>
</button>
<span class="pagination-info mono-num"
>Page {{ page }} of {{ totalPages }} · {{ total }} total</span
>
<button
type="button"
class="page-btn"
:disabled="page >= totalPages || isLoading"
aria-label="Next page"
@click="goToPage(page + 1)"
>
</button>
</div>
</main>
</template>
<style scoped>
h1 {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.loading-text,
.empty-state {
color: var(--c-text-soft);
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
}
.exercises {
display: flex;
flex-direction: column;
gap: 0.6rem;
list-style: none;
padding: 0;
margin: 0;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1.25rem;
}
.pagination-info {
font-size: 0.78rem;
color: var(--c-text-soft);
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
background: none;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
color: var(--c-text);
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.page-btn:hover:not(:disabled) {
border-color: var(--c-border-hover);
color: var(--c-heading);
}
.page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+55 -5
View File
@@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import type { LocalList } from '@/database/db' import type { LocalList } from '@/database/db'
import ListCard from '@/components/ListCard.vue' import ListCard from '@/components/ListCard.vue'
import ShareListModal from '@/components/ShareListModal.vue' import ShareListModal from '@/components/ShareListModal.vue'
import DeleteListModal from '@/components/DeleteListModal.vue' import DeleteListModal from '@/components/DeleteListModal.vue'
import { useListDragReorder } from '@/composables/useListDragReorder'
const listsStore = useListsStore() const listsStore = useListsStore()
@@ -14,6 +15,26 @@ const createError = ref('')
const sharingList = ref<LocalList | null>(null) const sharingList = ref<LocalList | null>(null)
const deletingList = ref<LocalList | null>(null) const deletingList = ref<LocalList | null>(null)
// Local, reorderable copy of the store's list order. Kept in sync with
// listsStore.sortedLists except while a drag is in progress, so a
// mid-sync-pass update (e.g. total_items ticking over) can't yank a row out
// from under the user's finger.
const displayedLists = ref<LocalList[]>([])
const { draggingId, isPointerActive, dragOffsetPx, setItemRef, onPointerDown } = useListDragReorder(
displayedLists,
(orderedIds) => {
void listsStore.reorderLists(orderedIds)
},
)
watch(
() => listsStore.sortedLists,
(next) => {
if (draggingId.value === null) displayedLists.value = [...next]
},
{ immediate: true },
)
onMounted(() => { onMounted(() => {
listsStore.loadLists() listsStore.loadLists()
}) })
@@ -86,11 +107,24 @@ async function handleCreateList() {
<p v-if="createError" class="banner banner-error">{{ createError }}</p> <p v-if="createError" class="banner banner-error">{{ createError }}</p>
<ul v-if="listsStore.sortedLists.length > 0" class="lists"> <TransitionGroup v-if="displayedLists.length > 0" tag="ul" name="list-reorder" class="lists">
<li v-for="list in listsStore.sortedLists" :key="list.id"> <li
<ListCard :list="list" @share="handleOpenShare(list)" @delete="handleOpenDelete(list)" /> v-for="list in displayedLists"
:key="list.id"
:ref="(el) => setItemRef(list.id, el as Element | null)"
class="list-row"
:class="{ 'no-transition': isPointerActive && draggingId === list.id }"
:style="draggingId === list.id ? { transform: `translateY(${dragOffsetPx}px)` } : undefined"
>
<ListCard
:list="list"
:dragging="draggingId === list.id"
@share="handleOpenShare(list)"
@delete="handleOpenDelete(list)"
@handle-pointerdown="onPointerDown(list.id, $event)"
/>
</li> </li>
</ul> </TransitionGroup>
<div v-else class="empty-state"> <div v-else class="empty-state">
<p>No lists yet</p> <p>No lists yet</p>
@@ -145,6 +179,22 @@ h1 {
margin: 0; margin: 0;
} }
.list-row {
transition:
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
z-index 0s;
}
.list-row.no-transition {
transition: none;
z-index: 2;
position: relative;
}
.list-reorder-move {
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.empty-state { .empty-state {
text-align: center; text-align: center;
padding: 3rem 1rem; padding: 3rem 1rem;
+9 -2
View File
@@ -121,7 +121,7 @@ describe('ListDetailView', () => {
expect(wrapper.findComponent({ name: 'DeleteListModal' }).exists()).toBe(false) expect(wrapper.findComponent({ name: 'DeleteListModal' }).exists()).toBe(false)
}) })
it('allows deleting list items via two clicks on item row menu', async () => { it('allows deleting list items via menu and confirmation modal', async () => {
const listsStore = useListsStore() const listsStore = useListsStore()
const deleteItemSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue() const deleteItemSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue()
const sampleList: LocalList = { const sampleList: LocalList = {
@@ -156,9 +156,16 @@ describe('ListDetailView', () => {
const deleteItemBtn = itemRow.find('.submenu-item-danger') const deleteItemBtn = itemRow.find('.submenu-item-danger')
expect(deleteItemBtn.exists()).toBe(true) expect(deleteItemBtn.exists()).toBe(true)
// 2nd click: delete item // 2nd click: opens confirmation modal
await deleteItemBtn.trigger('click') await deleteItemBtn.trigger('click')
const modal = itemRow.findComponent({ name: 'DeleteListItemModal' })
expect(modal.exists()).toBe(true)
expect(deleteItemSpy).not.toHaveBeenCalled()
// Confirm deletion in modal
await modal.find('.confirm-delete-btn').trigger('click')
expect(deleteItemSpy).toHaveBeenCalledWith('item-1') expect(deleteItemSpy).toHaveBeenCalledWith('item-1')
}) })
}) })