Compare commits

...
64 Commits
Author SHA1 Message Date
robin 9fb15d417f Merge pull request 'On list creation, no duplicate entry is shown anymore' (#30) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m13s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-14 13:35:59 +02:00
robin 8f3ece17ac fix: introduced clientId for lists, to avoid duplicate list after server sync
PR Checks / lint-and-test (pull_request) Successful in 1m28s
2026-09-14 13:30:17 +02:00
robin e0a8377bec Merge pull request 'Removed cv view entirely' (#29) from dev into main
Build and Deploy / build-and-deploy (push) Successful in 1m24s
Build and Deploy / sync-dev (push) Successful in 14s
2026-09-14 13:16:28 +02:00
robin 95dc8ecad2 feat: removed cv view entirely
PR Checks / lint-and-test (pull_request) Successful in 1m57s
2026-09-14 13:13:39 +02:00
robin 702bf9b8b8 Merge pull request 'Re-order lists' (#27) from dev into main
Build and Deploy / build-and-deploy (push) Failing after 1m4s
Build and Deploy / sync-dev (push) Skipped
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
robin 6a1624c616 Merge pull request 'List improvements' (#17) from dev into main 2026-09-01 16:00:12 +02:00
robin f35009822f fix: use new list item routes 2026-09-01 15:57:09 +02:00
robin 6196556012 fix: removed user_ids from create list payload; improved share button on invites 2026-09-01 15:29:31 +02:00
robin 3998679810 Merge pull request 'Added pagination to invites' (#16) from dev into main 2026-09-01 11:14:51 +02:00
robin 14563e5204 feat: pagination for invites 2026-09-01 11:13:33 +02:00
robin a30101f841 fix: styling for AccountsView on desktop and mobile 2026-09-01 08:58:38 +02:00
robin 9abd66fdba fix: viewport will no longer overflow in width 2026-09-01 08:44:39 +02:00
robin 936dbe4e3c Merge pull request 'Invite page will now no longer redirect to login' (#15) from dev into main 2026-08-31 22:42:23 +02:00
robin 1bacc58648 fix: invite links now will not redirect to login 2026-08-31 22:41:50 +02:00
robin 125a7bd6c5 Merge pull request 'Redesign fixes' (#14) from dev into main 2026-08-31 22:27:12 +02:00
robin 0b6a9231f9 fix: invite panel width 2026-08-31 22:26:14 +02:00
robin 4edfb64723 fix: dot color for sync status 2026-08-31 22:07:29 +02:00
robin 474f11a8ca Merge pull request 'Redesign' (#13) from dev into main 2026-08-31 22:01:48 +02:00
robin 182704b9cd feat: reverted "sync" stamps back to colored dots 2026-08-31 22:00:49 +02:00
robin 564c7c6338 fix: ellipse-trim invite codes 2026-08-31 21:56:13 +02:00
robin 5d0a2cfded feat: re-design 2026-08-31 21:53:59 +02:00
robin 327cfc1c5d Merge pull request 'Invite system and user registration' (#12) from dev into main 2026-08-31 21:37:37 +02:00
robin 2c45478478 feat: registration with invite codes 2026-08-31 21:35:37 +02:00
robin 68991a3722 feat: user invites in account panel 2026-08-31 21:08:47 +02:00
robin 73ef2b625d fix: updated /user/password route 2026-08-31 20:08:23 +02:00
robin 6b416eeb02 fix: added missing space 2026-08-31 19:36:00 +02:00
robin 69fe3651df Merge pull request 'Abstracted modal's common code to BaseModal' (#11) from dev into main 2026-08-31 18:11:42 +02:00
robin 10bf01046c fix: abstracted modal's common code to BaseModal 2026-08-31 18:10:57 +02:00
robin 946399c16c Merge pull request 'Add item input now doubles as search bar for the list' (#10) from dev into main 2026-08-31 18:03:23 +02:00
robin ca0317a01a feat: add item input now doubles as search bar for the list 2026-08-31 18:02:52 +02:00
robin 3369b49eb6 Merge pull request 'Added change password feature' (#9) from dev into main 2026-08-31 17:48:54 +02:00
robin 4188f4afee fix: added missing files for change password 2026-08-31 17:48:01 +02:00
robin f82dc34723 feat: added change password to about/account view 2026-08-31 17:46:37 +02:00
robin d981cde758 Merge pull request 'New icon with necessary pwa icon files' (#8) from dev into main 2026-08-31 17:46:08 +02:00
robin 46de339918 feat: new icon with necessary pwa icon files 2026-08-25 10:54:47 +02:00
robin af74c0deec Merge pull request 'AI audit improvements' (#7) from dev into main 2026-08-22 23:17:39 +02:00
robin 9b7c83532b fix: ai audit improvements 2026-08-22 23:16:06 +02:00
robin c6e45cd067 fix: debouncing sync and other improvements 2026-08-22 22:58:20 +02:00
robin e27e7a679e feat: lists are now sorted by modified_at desc 2026-08-22 22:08:09 +02:00
robin 0c81091252 Merge pull request 'Redirect to login/ when refresh token is invalid' (#6) from dev into main 2026-08-21 23:02:03 +02:00
robin 8c35899bd9 fix: redirect to login/ when refresh token is invalid 2026-08-21 23:01:32 +02:00
robin 00d54df194 Merge pull request 'Lists and list items deleted on another device are now deleted locally' (#5) from dev into main 2026-08-21 22:54:21 +02:00
robin 61d1b84fff fix: lists and list items deleted on another device are now deleted locally 2026-08-21 22:53:18 +02:00
robin b3ee69686e Merge pull request 'Adding user to list now only works online, and it never queued' (#4) from dev into main 2026-08-21 22:35:00 +02:00
robin 2634bd0db0 fix: adding user to list now only works online, and it never queued 2026-08-21 22:22:36 +02:00
robin ee360c6994 Merge pull request 'Major improvements, like deleting lists and list items' (#3) from dev into main 2026-08-21 21:53:15 +02:00
robin 0007f96c35 feat: added delete list & list items 2026-08-21 21:52:26 +02:00
76 changed files with 6007 additions and 430 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
+7 -1
View File
@@ -5,12 +5,18 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0a0e1a"> <meta name="theme-color" content="#12161d">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="dttmr"> <meta name="apple-mobile-web-app-title" content="dttmr">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Serif:wght@500;600;700&family=Special+Elite&display=swap"
rel="stylesheet"
>
<title>dttmr</title> <title>dttmr</title>
</head> </head>
<body> <body>
+9 -9
View File
@@ -33,7 +33,7 @@
"prettier": "3.9.5", "prettier": "3.9.5",
"typescript": "~6.0.0", "typescript": "~6.0.0",
"vite": "^8.1.5", "vite": "^8.1.5",
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.3.0",
"vite-plugin-vue-devtools": "^8.1.5", "vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.1", "vue-eslint-parser": "^10.4.1",
@@ -9563,17 +9563,17 @@
} }
}, },
"node_modules/vite-plugin-pwa": { "node_modules/vite-plugin-pwa": {
"version": "1.2.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
"integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.6", "debug": "^4.3.6",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"tinyglobby": "^0.2.10", "tinyglobby": "^0.2.10",
"workbox-build": "^7.4.0", "workbox-build": "^7.4.1",
"workbox-window": "^7.4.0" "workbox-window": "^7.4.1"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
@@ -9583,9 +9583,9 @@
}, },
"peerDependencies": { "peerDependencies": {
"@vite-pwa/assets-generator": "^1.0.0", "@vite-pwa/assets-generator": "^1.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"workbox-build": "^7.4.0", "workbox-build": "^7.4.1",
"workbox-window": "^7.4.0" "workbox-window": "^7.4.1"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@vite-pwa/assets-generator": { "@vite-pwa/assets-generator": {
+1 -1
View File
@@ -41,7 +41,7 @@
"prettier": "3.9.5", "prettier": "3.9.5",
"typescript": "~6.0.0", "typescript": "~6.0.0",
"vite": "^8.1.5", "vite": "^8.1.5",
"vite-plugin-pwa": "^1.2.0", "vite-plugin-pwa": "^1.3.0",
"vite-plugin-vue-devtools": "^8.1.5", "vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^4.1.10", "vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.1", "vue-eslint-parser": "^10.4.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg width="100%" viewBox="0 0 680 490" role="img" xmlns="http://www.w3.org/2000/svg" style=""><title style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">App icon concept in deep ink blue: a checkmark that doubles as a dumbbell</title><desc style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">A rounded deep navy square containing a white checkmark whose upward stroke carries two barbell plates, shown at large size and at 96, 64 and 40 pixels.</desc>
<g transform="translate(210 40)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<rect x="0" y="0" width="260" height="260" rx="60" fill="#13294B" style="fill:rgb(19, 41, 75);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M60 132 L98 170 L196 66" fill="none" stroke="#FFFFFF" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:26px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="120" y="110" width="14" height="58" rx="7" fill="#FFFFFF" transform="rotate(-45 127 139)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="189" y="34" width="14" height="64" rx="7" fill="#FFFFFF" transform="rotate(-45 196 66)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<g transform="translate(200 340) scale(0.369)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<rect x="0" y="0" width="260" height="260" rx="60" fill="#13294B" style="fill:rgb(19, 41, 75);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M60 132 L98 170 L196 66" fill="none" stroke="#FFFFFF" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:26px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="120" y="110" width="14" height="58" rx="7" fill="#FFFFFF" transform="rotate(-45 127 139)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="189" y="34" width="14" height="64" rx="7" fill="#FFFFFF" transform="rotate(-45 196 66)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<g transform="translate(336 372) scale(0.246)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<rect x="0" y="0" width="260" height="260" rx="60" fill="#13294B" style="fill:rgb(19, 41, 75);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M60 132 L98 170 L196 66" fill="none" stroke="#FFFFFF" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:26px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="120" y="110" width="14" height="58" rx="7" fill="#FFFFFF" transform="rotate(-45 127 139)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="189" y="34" width="14" height="64" rx="7" fill="#FFFFFF" transform="rotate(-45 196 66)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<g transform="translate(440 396) scale(0.154)" style="fill:rgb(0, 0, 0);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<rect x="0" y="0" width="260" height="260" rx="60" fill="#13294B" style="fill:rgb(19, 41, 75);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<path d="M60 132 L98 170 L196 66" fill="none" stroke="#FFFFFF" stroke-width="26" stroke-linecap="round" stroke-linejoin="round" style="fill:none;stroke:rgb(255, 255, 255);color:rgb(11, 11, 11);stroke-width:26px;stroke-linecap:round;stroke-linejoin:round;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="120" y="110" width="14" height="58" rx="7" fill="#FFFFFF" transform="rotate(-45 127 139)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<rect x="189" y="34" width="14" height="64" rx="7" fill="#FFFFFF" transform="rotate(-45 196 66)" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<text x="248" y="460" text-anchor="middle" style="fill:rgb(82, 81, 78);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">96 px</text>
<text x="368" y="460" text-anchor="middle" style="fill:rgb(82, 81, 78);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">64 px</text>
<text x="460" y="460" text-anchor="middle" style="fill:rgb(82, 81, 78);stroke:none;color:rgb(11, 11, 11);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;anthropic-sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:12px;font-weight:400;text-anchor:middle;dominant-baseline:auto">40 px</text>
</svg>

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+48 -2
View File
@@ -1,16 +1,18 @@
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 { fetchWithAuth, apiClient } from '../client' import { fetchWithAuth, apiClient, SessionExpiredError } from '../client'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import * as authApi from '@/api/auth' import * as authApi from '@/api/auth'
import router from '@/router'
describe('api client (fetchWithAuth)', () => { describe('api client (fetchWithAuth)', () => {
const originalFetch = global.fetch const originalFetch = global.fetch
beforeEach(() => { beforeEach(async () => {
setActivePinia(createPinia()) setActivePinia(createPinia())
localStorage.clear() localStorage.clear()
vi.restoreAllMocks() vi.restoreAllMocks()
await router.push('/')
}) })
afterEach(() => { afterEach(() => {
@@ -105,6 +107,50 @@ describe('api client (fetchWithAuth)', () => {
expect(res).toBe(secondResponse) expect(res).toBe(secondResponse)
}) })
it('redirects to login when a 401 occurs and no refresh token is available', async () => {
const authStore = useAuthStore()
authStore.setTokens({
access_token: 'expired-token',
refresh_token: 'valid-refresh-token',
})
authStore.clearTokens()
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
} as unknown as Response)
global.fetch = fetchMock
await expect(fetchWithAuth('/lists')).rejects.toThrow(SessionExpiredError)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.name).toBe('login')
})
it('redirects to login when refreshing the token fails', async () => {
const authStore = useAuthStore()
authStore.setTokens({
access_token: 'expired-token',
refresh_token: 'expired-refresh-token',
})
vi.spyOn(authApi, 'refreshApi').mockRejectedValueOnce(new Error('Refresh token expired'))
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
} as unknown as Response)
global.fetch = fetchMock
await expect(fetchWithAuth('/lists')).rejects.toThrow(SessionExpiredError)
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(authStore.accessToken).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
it('calls apiClient helper methods correctly', async () => { it('calls apiClient helper methods correctly', async () => {
const authStore = useAuthStore() const authStore = useAuthStore()
authStore.setTokens({ authStore.setTokens({
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '../invites'
import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/http'
describe('invites API', () => {
const originalFetch = global.fetch
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' })
})
afterEach(() => {
global.fetch = originalFetch
localStorage.clear()
})
it('getInvitesApi sends GET to /user/invites and returns the paginated result', async () => {
const mockResponse = { data: [{ id: 'invite-1', code: 'ABC123' }], total: 1, count: 1 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
} as unknown as Response)
global.fetch = fetchMock
const result = await getInvitesApi()
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites`,
expect.objectContaining({ method: 'GET' }),
)
expect(result).toEqual(mockResponse)
})
it('getInvitesApi sends page and count as query params when given', async () => {
const mockResponse = { data: [], total: 0, count: 10 }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => mockResponse,
} as unknown as Response)
global.fetch = fetchMock
await getInvitesApi({ page: 2, count: 10 })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites?page=2&count=10`,
expect.objectContaining({ method: 'GET' }),
)
})
it('getInvitesApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed' }),
} as unknown as Response)
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 () => {
const mockInvite = { id: 'invite-1', code: 'ABC123' }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => mockInvite,
} as unknown as Response)
global.fetch = fetchMock
const result = await createInviteApi()
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites`,
expect.objectContaining({ method: 'POST' }),
)
expect(result).toEqual(mockInvite)
})
it('createInviteApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed to create invite' }),
} as unknown as Response)
await expect(createInviteApi()).rejects.toThrow('Failed to create invite')
})
it('deleteInviteApi sends DELETE to /user/invites/:id', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await deleteInviteApi('invite-1')
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/invites/invite-1`,
expect.objectContaining({ method: 'DELETE' }),
)
})
it('deleteInviteApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed to delete invite' }),
} as unknown as Response)
await expect(deleteInviteApi('invite-1')).rejects.toThrow('Failed to delete invite')
})
})
+60 -10
View File
@@ -5,10 +5,12 @@ import {
createListApi, createListApi,
getListItemsApi, getListItemsApi,
createListItemApi, createListItemApi,
updateListItemApi, updateListItemTitleApi,
setListItemCompletedApi, setListItemCompletedApi,
addUserToListApi, addUserToListApi,
removeUserFromListApi, removeUserFromListApi,
deleteListApi,
deleteListItemApi,
} from '../lists' } from '../lists'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/auth' import { API_BASE_URL } from '@/api/auth'
@@ -75,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,
@@ -85,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' }),
) )
}) })
@@ -99,7 +101,7 @@ describe('lists API', () => {
} as unknown as Response) } as unknown as Response)
global.fetch = fetchMock global.fetch = fetchMock
const result = await createListApi({ name: 'Groceries', user_ids: [] }) const result = await createListApi({ name: 'Groceries' })
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists`, `${API_BASE_URL}/lists`,
@@ -117,7 +119,7 @@ describe('lists API', () => {
await expect(createListApi({ name: 'x' })).rejects.toThrow('Failed') await expect(createListApi({ name: 'x' })).rejects.toThrow('Failed')
}) })
it('createListItemApi sends POST to /lists/item and returns the created item with its server id', async () => { it('createListItemApi sends POST to /lists/items and returns the created item with its server id', async () => {
const mockItem = { id: 'item-1', list_id: '', title: 'Milk', is_completed: false } const mockItem = { id: 'item-1', list_id: '', title: 'Milk', is_completed: false }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({ const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true, ok: true,
@@ -129,24 +131,24 @@ describe('lists API', () => {
const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' }) const result = await createListItemApi({ list_id: 'list-1', title: 'Milk' })
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/item`, `${API_BASE_URL}/lists/items`,
expect.objectContaining({ method: 'POST' }), expect.objectContaining({ method: 'POST' }),
) )
expect(result).toEqual(mockItem) expect(result).toEqual(mockItem)
}) })
it('updateListItemApi sends PUT to /lists/item', 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/item`, `${API_BASE_URL}/lists/items/item-1/title`,
expect.objectContaining({ method: 'PUT' }), expect.objectContaining({ method: 'POST' }),
) )
}) })
@@ -179,4 +181,52 @@ describe('lists API', () => {
expect.objectContaining({ method: 'DELETE' }), expect.objectContaining({ method: 'DELETE' }),
) )
}) })
it('deleteListApi sends DELETE to /lists/{id}', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await deleteListApi('list-1')
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/list-1`,
expect.objectContaining({ method: 'DELETE' }),
)
})
it('deleteListApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'List not found' }),
} as unknown as Response)
await expect(deleteListApi('list-1')).rejects.toThrow('List not found')
})
it('deleteListItemApi sends DELETE to /lists/items/{id}', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await deleteListItemApi('item-1')
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/lists/items/item-1`,
expect.objectContaining({ method: 'DELETE' }),
)
})
it('deleteListItemApi throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Item not found' }),
} as unknown as Response)
await expect(deleteListItemApi('item-1')).rejects.toThrow('Item not found')
})
}) })
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { changePasswordApi, createUserApi } from '../users'
import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/http'
describe('users API', () => {
const originalFetch = global.fetch
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
})
afterEach(() => {
global.fetch = originalFetch
localStorage.clear()
})
describe('changePasswordApi', () => {
it('sends POST to /user/password with the old and new password', async () => {
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token-123', refresh_token: 'refresh-123' })
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 204,
} as unknown as Response)
global.fetch = fetchMock
await changePasswordApi({ old_password: 'old', new_password: 'new12345' })
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/user/password`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ old_password: 'old', new_password: 'new12345' }),
}),
)
})
it('throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Failed' }),
} as unknown as Response)
await expect(
changePasswordApi({ old_password: 'old', new_password: 'new12345' }),
).rejects.toThrow('Failed')
})
})
describe('createUserApi', () => {
it('sends POST to /users with the registration payload and returns the created user', async () => {
const mockUser = { id: 'user-1', email: 'new@example.com', name: 'New User' }
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: true,
status: 201,
json: async () => mockUser,
} as unknown as Response)
global.fetch = fetchMock
const payload = {
email: 'new@example.com',
password: 'password123',
name: 'New User',
invite_code: 'invite-abc',
}
const result = await createUserApi(payload)
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/users`,
expect.objectContaining({ method: 'POST', body: JSON.stringify(payload) }),
)
expect(result).toEqual(mockUser)
})
it('throws on failure', async () => {
global.fetch = vi.fn<typeof fetch>().mockResolvedValueOnce({
ok: false,
json: async () => ({ message: 'Invalid invite code' }),
} as unknown as Response)
await expect(
createUserApi({
email: 'new@example.com',
password: 'password123',
name: 'New User',
invite_code: 'bad-code',
}),
).rejects.toThrow('Invalid invite code')
})
})
})
+2 -10
View File
@@ -1,15 +1,7 @@
import type { LoginPayload, RefreshPayload, TokenPair } from '@/types/auth' import type { LoginPayload, RefreshPayload, TokenPair } from '@/types/auth'
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1' export { API_BASE_URL }
async function extractErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const errorData = await response.json()
return errorData.message || errorData.error || fallback
} catch {
return response.statusText || fallback
}
}
async function postJson( async function postJson(
path: string, path: string,
+37 -5
View File
@@ -1,8 +1,30 @@
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/auth' import router from '@/router'
import { API_BASE_URL, extractErrorMessage } from '@/api/http'
export { API_BASE_URL, extractErrorMessage }
let refreshPromise: Promise<unknown> | null = null let refreshPromise: Promise<unknown> | null = null
// Thrown instead of returning the stale 401 Response when redirecting to
// login, so callers show an accurate message rather than reading `!response.ok`
// and flashing an unrelated "Failed to ..." banner in the instant before
// navigation away completes.
export class SessionExpiredError extends Error {
constructor() {
super('Your session has expired. Please log in again.')
this.name = 'SessionExpiredError'
}
}
async function redirectToLogin(): Promise<void> {
const currentRoute = router.currentRoute.value
if (currentRoute.name === 'login') {
return
}
await router.push({ name: 'login', query: { redirect: currentRoute.fullPath } })
}
export interface FetchOptions extends RequestInit { export interface FetchOptions extends RequestInit {
skipAuth?: boolean skipAuth?: boolean
skipRefresh?: boolean skipRefresh?: boolean
@@ -43,7 +65,12 @@ export async function fetchWithAuth(
headers: buildHeaders(customOptions, skipAuth ? null : authStore.accessToken), headers: buildHeaders(customOptions, skipAuth ? null : authStore.accessToken),
}) })
if (response.status === 401 && !skipRefresh && authStore.refreshToken) { if (response.status === 401 && !skipRefresh) {
if (!authStore.refreshToken) {
await redirectToLogin()
throw new SessionExpiredError()
}
try { try {
if (!refreshPromise) { if (!refreshPromise) {
refreshPromise = authStore.refreshTokens().finally(() => { refreshPromise = authStore.refreshTokens().finally(() => {
@@ -57,7 +84,8 @@ export async function fetchWithAuth(
headers: buildHeaders(customOptions, authStore.accessToken), headers: buildHeaders(customOptions, authStore.accessToken),
}) })
} catch { } catch {
return response await redirectToLogin()
throw new SessionExpiredError()
} }
} }
@@ -79,6 +107,10 @@ export const apiClient = {
method: 'PUT', method: 'PUT',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}), }),
delete: (endpoint: string, options?: FetchOptions) => delete: (endpoint: string, body?: unknown, options?: FetchOptions) =>
fetchWithAuth(endpoint, { ...options, method: 'DELETE' }), fetchWithAuth(endpoint, {
...options,
method: 'DELETE',
body: body ? JSON.stringify(body) : undefined,
}),
} }
+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()
}
+10
View File
@@ -0,0 +1,10 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'
export async function extractErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const errorData = await response.json()
return errorData.message || errorData.error || fallback
} catch {
return response.statusText || fallback
}
}
+44
View File
@@ -0,0 +1,44 @@
import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { Invite, InviteStatusCounts, PaginatedInvites } from '@/types/invite'
export interface GetInvitesParams {
page?: number
count?: number
}
export async function getInvitesApi(params: GetInvitesParams = {}): Promise<PaginatedInvites> {
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(`/user/invites${queryString ? `?${queryString}` : ''}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to load invites'))
}
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> {
const response = await apiClient.post('/user/invites')
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to create invite'))
}
return response.json()
}
export async function deleteInviteApi(id: string): Promise<void> {
const response = await apiClient.delete(`/user/invites/${id}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to delete invite'))
}
}
+37 -22
View File
@@ -1,24 +1,17 @@
import { apiClient } from '@/api/client' import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { import type {
List, List,
ListItem, ListItem,
CreateListPayload, CreateListPayload,
CreateListItemPayload, CreateListItemPayload,
UpdateListItemPayload,
SetListItemCompletedPayload, SetListItemCompletedPayload,
SetListItemTitlePayload,
AddUserToListPayload, AddUserToListPayload,
RemoveUserFromListPayload, RemoveUserFromListPayload,
OrderListsPayload,
} from '@/types/list' } from '@/types/list'
async function extractErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const errorData = await response.json()
return errorData.message || errorData.error || fallback
} catch {
return response.statusText || fallback
}
}
export async function getListsApi(): Promise<List[]> { export async function getListsApi(): Promise<List[]> {
const response = await apiClient.get('/lists') const response = await apiClient.get('/lists')
if (!response.ok) { if (!response.ok) {
@@ -44,30 +37,33 @@ export async function getListItemsApi(listId: string): Promise<ListItem[]> {
} }
export async function createListItemApi(payload: CreateListItemPayload): Promise<ListItem> { export async function createListItemApi(payload: CreateListItemPayload): Promise<ListItem> {
const response = await apiClient.post('/lists/item', payload) const response = await apiClient.post('/lists/items', payload)
if (!response.ok) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to create list item')) throw new Error(await extractErrorMessage(response, 'Failed to create list item'))
} }
return response.json() return response.json()
} }
export async function updateListItemApi(payload: UpdateListItemPayload): Promise<void> {
const response = await apiClient.put('/lists/item', 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) {
@@ -76,10 +72,29 @@ export async function addUserToListApi(payload: AddUserToListPayload): Promise<v
} }
export async function removeUserFromListApi(payload: RemoveUserFromListPayload): Promise<void> { export async function removeUserFromListApi(payload: RemoveUserFromListPayload): Promise<void> {
const response = await apiClient.delete('/lists/user', { const response = await apiClient.delete('/lists/user', payload)
body: JSON.stringify(payload),
})
if (!response.ok) { if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to remove user from list')) throw new Error(await extractErrorMessage(response, 'Failed to remove user from list'))
} }
} }
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> {
const response = await apiClient.delete(`/lists/${listId}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to delete list'))
}
}
export async function deleteListItemApi(itemId: string): Promise<void> {
const response = await apiClient.delete(`/lists/items/${itemId}`)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to delete list item'))
}
}
+19
View File
@@ -0,0 +1,19 @@
import { apiClient } from '@/api/client'
import { extractErrorMessage } from '@/api/http'
import type { ChangePasswordPayload } from '@/types/auth'
import type { CreateUserPayload, User } from '@/types/user'
export async function changePasswordApi(payload: ChangePasswordPayload): Promise<void> {
const response = await apiClient.post('/user/password', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to change password'))
}
}
export async function createUserApi(payload: CreateUserPayload): Promise<User> {
const response = await apiClient.post('/users', payload)
if (!response.ok) {
throw new Error(await extractErrorMessage(response, 'Failed to create account'))
}
return response.json()
}
+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()
}
+47 -37
View File
@@ -1,33 +1,40 @@
/* dttmr color palette - dark, modern, blueish */ /* dttmr palette - field-ledger / stamped ink, cool slate ground, blue ink accent */
:root { :root {
--c-bg: #0a0e1a; --c-bg: #12161d;
--c-bg-soft: #101627; --c-bg-soft: #181e27;
--c-bg-mute: #161d33; --c-bg-mute: #202632;
--c-bg-elevated: #1a2338; --c-bg-elevated: #262e3c;
--c-border: rgba(120, 150, 220, 0.16); --c-border: rgba(74, 130, 196, 0.16);
--c-border-hover: rgba(120, 150, 220, 0.32); --c-border-hover: rgba(74, 130, 196, 0.36);
--c-text: #c7d0e6; --c-text: #aab2c0;
--c-text-soft: #8892b0; --c-text-soft: #707a8c;
--c-heading: #f1f4fc; --c-heading: #eef1f6;
--c-accent: #4f7dfa; --c-accent: #3d72b4;
--c-accent-soft: #3a5fd9; --c-accent-soft: #2f5c92;
--c-accent-strong: #6c93ff; --c-accent-strong: #62a0e0;
--c-accent-bg: rgba(79, 125, 250, 0.14); --c-accent-bg: rgba(61, 114, 180, 0.16);
--c-success: #34d399; --c-success: #6ba178;
--c-danger: #f87171; --c-success-bg: rgba(107, 161, 120, 0.14);
--c-danger-bg: rgba(248, 113, 113, 0.12); --c-danger: #c1614a;
--c-warning: #fbbf24; --c-danger-bg: rgba(193, 97, 74, 0.14);
--c-warning: #cf9d3f;
--radius-sm: 8px; --font-body:
--radius-md: 12px; 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--radius-lg: 18px; --font-display: 'IBM Plex Serif', Georgia, 'Times New Roman', serif;
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-stamp: 'Special Elite', var(--font-mono), monospace;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); --radius-sm: 3px;
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45); --radius-md: 5px;
--radius-lg: 8px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45);
--shadow-md: 0 10px 28px rgba(0, 0, 0, 0.5);
--nav-height: 60px; --nav-height: 60px;
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -56,21 +63,18 @@ html {
body { body {
min-height: 100vh; min-height: 100vh;
color: var(--c-text); color: var(--c-text);
background: radial-gradient(circle at top, #101a33 0%, var(--c-bg) 55%); background-color: var(--c-bg);
background-image:
repeating-linear-gradient(
to bottom,
rgba(74, 130, 196, 0.06) 0,
rgba(74, 130, 196, 0.06) 1px,
transparent 1px,
transparent 28px
),
radial-gradient(circle at 50% -10%, rgba(74, 130, 196, 0.08), transparent 55%);
line-height: 1.55; line-height: 1.55;
font-family: font-family: var(--font-body);
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px; font-size: 15px;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -85,6 +89,12 @@ h4 {
font-weight: 600; font-weight: 600;
} }
h1 {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.01em;
}
a { a {
color: var(--c-accent-strong); color: var(--c-accent-strong);
text-decoration: none; text-decoration: none;
+18 -5
View File
@@ -29,13 +29,14 @@
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, var(--c-accent) 0%, var(--c-accent-soft) 100%); background-color: var(--c-accent);
color: #ffffff; color: var(--c-bg);
font-weight: 600;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: linear-gradient(135deg, var(--c-accent-strong) 0%, var(--c-accent) 100%); background-color: var(--c-accent-strong);
} }
.btn-secondary { .btn-secondary {
@@ -52,7 +53,7 @@
.btn-danger { .btn-danger {
background-color: transparent; background-color: transparent;
color: var(--c-danger); color: var(--c-danger);
border-color: rgba(248, 113, 113, 0.35); border-color: rgba(193, 97, 74, 0.4);
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
@@ -113,11 +114,23 @@
.banner-error { .banner-error {
background-color: var(--c-danger-bg); background-color: var(--c-danger-bg);
border: 1px solid rgba(248, 113, 113, 0.4); border: 1px solid rgba(193, 97, 74, 0.4);
color: var(--c-danger); color: var(--c-danger);
} }
.banner-success {
background-color: var(--c-success-bg);
border: 1px solid rgba(127, 174, 120, 0.4);
color: var(--c-success);
}
.mono-num {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.page { .page {
width: 100%;
min-height: 100vh; min-height: 100vh;
padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem); padding: 1rem 1rem calc(var(--nav-height) + var(--safe-bottom) + 1.5rem);
padding-top: calc(1rem + var(--safe-top)); padding-top: calc(1rem + var(--safe-top));
+64 -19
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
@@ -11,24 +12,44 @@ async function handleLogout() {
await authStore.logout() await authStore.logout()
router.push('/login') router.push('/login')
} }
const stampClass = computed(() => {
if (!authStore.isAuthenticated) return ''
if (listsStore.pendingCount > 0 || listsStore.isSyncing) return 'stamp-pending'
return 'stamp-synced'
})
const stampTitle = computed(() => {
if (!authStore.isAuthenticated) return 'Not signed in'
if (listsStore.isSyncing) return 'Syncing…'
if (listsStore.pendingCount > 0) return `${listsStore.pendingCount} change(s) waiting to sync`
return 'Up to date'
})
</script> </script>
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="brand"> <div class="brand">
<span class="brand-dot"></span> <span class="brand-seal"></span>
<span class="brand-name">dttmr</span> <span class="brand-name">dttmr</span>
</div> </div>
<div class="status"> <div class="status">
<span <span
class="sync-dot" v-if="listsStore.error"
:class="{ offline: !authStore.isAuthenticated }" class="stamp stamp-danger"
:title="listsStore.isSyncing ? 'Syncing…' : 'Up to date'" :title="listsStore.error"
:aria-label="listsStore.error"
role="img"
></span>
<span
v-else
class="stamp"
:class="stampClass"
:title="stampTitle"
:aria-label="stampTitle"
role="img"
></span> ></span>
<span v-if="listsStore.pendingCount > 0" class="pending">
{{ listsStore.pendingCount }} pending
</span>
<button v-if="authStore.isAuthenticated" type="button" class="logout" @click="handleLogout"> <button v-if="authStore.isAuthenticated" type="button" class="logout" @click="handleLogout">
Log out Log out
</button> </button>
@@ -45,7 +66,7 @@ async function handleLogout() {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: calc(0.75rem + var(--safe-top)) 1rem 0.75rem; padding: calc(0.75rem + var(--safe-top)) 1rem 0.75rem;
background: linear-gradient(180deg, rgba(10, 14, 26, 0.92) 60%, rgba(10, 14, 26, 0)); background: linear-gradient(180deg, rgba(20, 24, 26, 0.92) 60%, rgba(20, 24, 26, 0));
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
@@ -53,17 +74,23 @@ async function handleLogout() {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-weight: 600;
color: var(--c-heading); color: var(--c-heading);
}
.brand-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 1rem;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.brand-dot { .brand-seal {
width: 10px; width: 16px;
height: 10px; height: 16px;
border-radius: 50%; border-radius: 3px;
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft)); background: var(--c-accent);
box-shadow: 0 0 12px var(--c-accent); transform: rotate(-8deg);
box-shadow: 0 0 0 2px var(--c-bg) inset;
} }
.status { .status {
@@ -74,15 +101,33 @@ async function handleLogout() {
color: var(--c-text-soft); color: var(--c-text-soft);
} }
.sync-dot { .stamp {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--c-text-soft);
}
.stamp::before {
content: '';
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background-color: var(--c-success); background-color: currentColor;
flex-shrink: 0;
} }
.sync-dot.offline { .stamp-pending {
background-color: var(--c-text-soft); color: var(--c-warning);
}
.stamp-synced {
color: var(--c-success);
}
.stamp-danger {
color: var(--c-danger);
cursor: help;
} }
.logout { .logout {
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { useEscapeKey } from '@/composables/useEscapeKey'
defineProps<{
title: string
titleId: string
}>()
const emit = defineEmits<{
close: []
}>()
useEscapeKey(handleClose)
function handleClose() {
emit('close')
}
</script>
<template>
<div class="modal-overlay" @click.self="handleClose">
<div class="modal-card card" role="dialog" aria-modal="true" :aria-labelledby="titleId">
<div class="modal-header">
<h3 :id="titleId">{{ title }}</h3>
<button type="button" class="close-btn" aria-label="Close modal" @click="handleClose">
</button>
</div>
<slot />
<div class="modal-footer">
<slot name="footer" />
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
animation: fadeIn 0.15s ease-out;
}
.modal-card {
width: 100%;
max-width: 440px;
background-color: var(--c-bg-soft);
border: 1px solid var(--c-border-hover);
border-radius: var(--radius-lg);
padding: 1.25rem 1.4rem;
box-shadow: var(--shadow-md);
animation: slideUp 0.15s ease-out;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.modal-header h3 {
font-size: 1.1rem;
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--c-text-soft);
font-size: 1.1rem;
cursor: pointer;
padding: 0.2rem 0.4rem;
border-radius: var(--radius-sm);
line-height: 1;
}
.close-btn:hover {
color: var(--c-heading);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 1rem;
}
.modal-footer .btn {
width: auto;
padding: 0.5rem 1.2rem;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>
+42 -13
View File
@@ -10,15 +10,42 @@ const listsStore = useListsStore()
<template> <template>
<nav v-if="authStore.isAuthenticated" class="bottom-nav"> <nav v-if="authStore.isAuthenticated" class="bottom-nav">
<RouterLink to="/" class="nav-item" active-class="is-active"> <RouterLink to="/" class="nav-item" active-class="is-active">
<span class="nav-icon"></span> <svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h1.5M4 12h1.5M4 18h1.5" stroke-linecap="round" />
<path d="M9 6h11M9 12h11M9 18h11" stroke-linecap="round" />
</svg>
<span class="nav-label">Lists</span> <span class="nav-label">Lists</span>
<span v-if="listsStore.pendingCount > 0" class="nav-badge">{{ <span v-if="listsStore.pendingCount > 0" class="nav-badge mono-num">{{
listsStore.pendingCount listsStore.pendingCount
}}</span> }}</span>
</RouterLink> </RouterLink>
<RouterLink to="/about" class="nav-item" active-class="is-active"> <RouterLink to="/exercises" class="nav-item" active-class="is-active">
<span class="nav-icon"></span> <svg
<span class="nav-label">About</span> 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">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3.2" />
<path
d="M12 3.5v2M12 18.5v2M20.5 12h-2M5.5 12h-2M17.7 6.3l-1.4 1.4M7.7 16.3l-1.4 1.4M17.7 17.7l-1.4-1.4M7.7 7.7 6.3 6.3"
stroke-linecap="round"
/>
</svg>
<span class="nav-label">Account</span>
</RouterLink> </RouterLink>
</nav> </nav>
</template> </template>
@@ -45,20 +72,21 @@ const listsStore = useListsStore()
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.15rem; gap: 0.2rem;
color: var(--c-text-soft); color: var(--c-text-soft);
font-size: 0.7rem; font-size: 0.68rem;
} transition: color 0.15s ease-in-out;
.nav-icon {
font-size: 1.25rem;
line-height: 1;
} }
.nav-item.is-active { .nav-item.is-active {
color: var(--c-accent-strong); color: var(--c-accent-strong);
} }
.nav-icon {
width: 21px;
height: 21px;
}
.nav-badge { .nav-badge {
position: absolute; position: absolute;
top: 0.35rem; top: 0.35rem;
@@ -68,8 +96,9 @@ const listsStore = useListsStore()
padding: 0 4px; padding: 0 4px;
border-radius: 999px; border-radius: 999px;
background-color: var(--c-accent); background-color: var(--c-accent);
color: #fff; color: var(--c-bg);
font-size: 0.6rem; font-size: 0.6rem;
font-weight: 600;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { changePasswordApi } from '@/api/users'
import BaseModal from '@/components/BaseModal.vue'
const emit = defineEmits<{
close: []
}>()
const passwordInput = ref<HTMLInputElement | null>(null)
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const isSubmitting = ref(false)
const error = ref('')
const successMessage = ref('')
onMounted(() => {
nextTick(() => {
passwordInput.value?.focus()
})
})
function handleClose() {
emit('close')
}
async function handleSubmit() {
error.value = ''
successMessage.value = ''
if (newPassword.value.length < 8) {
error.value = 'New password must be at least 8 characters.'
return
}
if (newPassword.value !== confirmPassword.value) {
error.value = 'New passwords do not match.'
return
}
isSubmitting.value = true
try {
await changePasswordApi({
old_password: currentPassword.value,
new_password: newPassword.value,
})
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
successMessage.value = 'Password changed successfully.'
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to change password'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<BaseModal title="Change password" title-id="change-password-modal-title" @close="handleClose">
<p class="modal-description">Enter your current password and choose a new one.</p>
<form class="password-form" @submit.prevent="handleSubmit">
<div class="field">
<input
ref="passwordInput"
v-model="currentPassword"
type="password"
placeholder="Current password"
autocomplete="current-password"
:disabled="isSubmitting"
/>
</div>
<div class="field">
<input
v-model="newPassword"
type="password"
placeholder="New password"
autocomplete="new-password"
:disabled="isSubmitting"
/>
</div>
<div class="field">
<input
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
autocomplete="new-password"
:disabled="isSubmitting"
/>
</div>
<button
type="submit"
class="btn btn-primary"
:disabled="isSubmitting || !currentPassword || !newPassword || !confirmPassword"
>
Change password
</button>
</form>
<p v-if="error" class="banner banner-error">{{ error }}</p>
<p v-if="successMessage" class="banner banner-success">{{ successMessage }}</p>
<template #footer>
<button type="button" class="btn btn-secondary" @click="handleClose">Done</button>
</template>
</BaseModal>
</template>
<style scoped>
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1rem;
}
.password-form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-bottom: 1rem;
}
.password-form .btn {
padding: 0.65rem 1.2rem;
}
.banner-success {
margin-top: 0.75rem;
}
.banner-error {
margin-top: 0.75rem;
}
</style>
+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>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { LocalList } from '@/database/db'
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'
const props = defineProps<{
list: LocalList
}>()
const emit = defineEmits<{
close: []
confirm: []
}>()
function handleClose() {
emit('close')
}
function handleConfirm() {
emit('confirm')
}
</script>
<template>
<ConfirmDeleteModal
:title="`Delete &quot;${props.list.name}&quot;?`"
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"
@confirm="handleConfirm"
/>
</template>
+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>
+622
View File
@@ -0,0 +1,622 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { getInvitesApi, getInviteStatusApi, createInviteApi, deleteInviteApi } from '@/api/invites'
import type { Invite, InviteStatusCounts } from '@/types/invite'
type InviteStatus = 'active' | 'used' | 'expired'
const PAGE_SIZE = 10
const expanded = ref(false)
const invites = ref<Invite[]>([])
const page = ref(1)
const total = ref(0)
const isLoading = ref(false)
const isCreating = ref(false)
const error = ref('')
const pendingDeleteId = ref<string | null>(null)
const deletingId = 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 sharedTimeout: ReturnType<typeof setTimeout> | undefined
const totalInvites = computed(() =>
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 showPagination = computed(() => totalPages.value > 1)
onMounted(() => {
void loadStatusCounts()
})
onBeforeUnmount(() => {
clearTimeout(sharedTimeout)
})
function isOffline(): boolean {
return typeof navigator !== 'undefined' && !navigator.onLine
}
async function toggleExpanded() {
expanded.value = !expanded.value
if (expanded.value && !hasLoaded) {
await loadInvites()
}
}
async function loadInvites() {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to manage invites.'
return
}
isLoading.value = true
try {
const response = await getInvitesApi({ page: page.value, count: PAGE_SIZE })
invites.value = response.data
total.value = response.total
hasLoaded = true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load invites'
} finally {
isLoading.value = false
}
}
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) {
if (target < 1 || target > totalPages.value || target === page.value || isLoading.value) {
return
}
page.value = target
await loadInvites()
}
async function handleCreate() {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to create an invite.'
return
}
isCreating.value = true
try {
const invite = await createInviteApi()
page.value = 1
await loadInvites()
await loadStatusCounts()
// Sharing is the whole point of an invite, so offer it immediately
// instead of making the user hunt for the Share button afterwards.
await shareInvite(invite)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create invite'
} finally {
isCreating.value = false
}
}
function requestDelete(id: string) {
error.value = ''
pendingDeleteId.value = id
}
function cancelDelete() {
pendingDeleteId.value = null
}
async function confirmDelete(id: string) {
error.value = ''
if (isOffline()) {
error.value = 'You must be online to delete an invite.'
pendingDeleteId.value = null
return
}
deletingId.value = id
try {
await deleteInviteApi(id)
// Deleted the only item on a page past the first: step back a page
// instead of reloading into a stranded, empty page.
if (invites.value.length === 1 && page.value > 1) {
page.value -= 1
}
await loadInvites()
await loadStatusCounts()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to delete invite'
} finally {
deletingId.value = null
pendingDeleteId.value = null
}
}
// The link that lands someone on the (otherwise unlinked) register page —
// see router.beforeEach, which redirects any URL carrying ?invite=... there.
function getInviteUrl(code: string): string {
const base = `${window.location.origin}${import.meta.env.BASE_URL}`
return `${base}?invite=${encodeURIComponent(code)}`
}
async function shareInvite(invite: Invite) {
const url = getInviteUrl(invite.code)
if (typeof navigator.share === 'function') {
try {
await navigator.share({
title: 'Join dttmr',
text: 'Use this link to create your account',
url,
})
return
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return
}
// Web Share unsupported in this context; fall through to clipboard copy.
}
}
try {
await navigator.clipboard.writeText(url)
sharedId.value = invite.id
clearTimeout(sharedTimeout)
sharedTimeout = setTimeout(() => {
sharedId.value = null
}, 1500)
} catch {
// Clipboard access denied or unavailable; nothing sensible to do.
}
}
function inviteStatus(invite: Invite): InviteStatus {
if (invite.consumed_at) return 'used'
if (invite.expires_at && new Date(invite.expires_at).getTime() < Date.now()) return 'expired'
return 'active'
}
function statusLabel(invite: Invite): string {
const status = inviteStatus(invite)
return status === 'used' ? 'Used' : status === 'expired' ? 'Expired' : 'Active'
}
// Standard "time ago"/"time until" formatter: walk unit divisions until the
// duration fits in one, so both past and future dates read naturally.
function formatRelative(dateStr: string): string {
const divisions: [number, Intl.RelativeTimeFormatUnit][] = [
[60, 'seconds'],
[60, 'minutes'],
[24, 'hours'],
[7, 'days'],
[4.34524, 'weeks'],
[12, 'months'],
[Number.POSITIVE_INFINITY, 'years'],
]
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
let duration = (new Date(dateStr).getTime() - Date.now()) / 1000
for (const [amount, unit] of divisions) {
if (Math.abs(duration) < amount) {
return rtf.format(Math.round(duration), unit)
}
duration /= amount
}
return rtf.format(Math.round(duration), 'years')
}
function inviteDetail(invite: Invite): string {
const status = inviteStatus(invite)
if (status === 'used') {
return invite.consumed_at ? `Used ${formatRelative(invite.consumed_at)}` : 'Used'
}
if (!invite.expires_at) {
return 'No expiry'
}
return `${status === 'expired' ? 'Expired' : 'Expires'} ${formatRelative(invite.expires_at)}`
}
</script>
<template>
<section class="card info-card invites-card">
<button
type="button"
class="invites-toggle"
:aria-expanded="expanded"
aria-controls="invites-panel"
@click="toggleExpanded"
>
<span class="invites-toggle-label">
<h4>Invites</h4>
<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 class="chevron" :class="{ 'is-open': expanded }" aria-hidden="true"></span>
</button>
<div v-if="expanded" id="invites-panel" class="invites-body">
<p class="invites-hint">
Invite codes let someone new create an account. Creating, listing, and deleting invites
requires an internet connection.
</p>
<p v-if="isLoading" class="invites-loading">Loading invites</p>
<ul v-else-if="invites.length > 0" class="invite-list">
<li
v-for="invite in invites"
:key="invite.id"
class="invite-ticket"
:class="`is-${inviteStatus(invite)}`"
>
<div class="invite-ticket-main">
<code class="invite-code" :title="invite.code">{{ invite.code }}</code>
<span class="invite-status-pill" :class="`pill-${inviteStatus(invite)}`">{{
statusLabel(invite)
}}</span>
</div>
<div class="invite-ticket-meta">
<span class="invite-detail">{{ inviteDetail(invite) }}</span>
<div v-if="pendingDeleteId !== invite.id" class="invite-actions">
<button
type="button"
class="ticket-btn"
:disabled="inviteStatus(invite) !== 'active'"
:title="
inviteStatus(invite) !== 'active' ? 'Only active invites can be shared' : ''
"
@click="shareInvite(invite)"
>
{{ sharedId === invite.id ? 'Copied!' : 'Share' }}
</button>
<button
type="button"
class="ticket-btn ticket-btn-danger"
:disabled="inviteStatus(invite) === 'used'"
:title="inviteStatus(invite) === 'used' ? 'Used invites cannot be deleted' : ''"
@click="requestDelete(invite.id)"
>
Delete
</button>
</div>
<div v-else class="invite-actions">
<span class="confirm-label">Delete this invite?</span>
<button type="button" class="ticket-btn" @click="cancelDelete">No</button>
<button
type="button"
class="ticket-btn ticket-btn-danger"
:disabled="deletingId === invite.id"
@click="confirmDelete(invite.id)"
>
{{ deletingId === invite.id ? 'Deleting…' : 'Yes' }}
</button>
</div>
</div>
</li>
</ul>
<p v-else class="invites-empty">No invites yet. Generate one to invite someone.</p>
<div v-if="showPagination" class="invites-pagination">
<button
type="button"
class="page-btn"
:disabled="page <= 1 || isLoading"
aria-label="Previous page"
@click="goToPage(page - 1)"
>
</button>
<span class="pagination-info">{{ total }} invites total</span>
<button
type="button"
class="page-btn"
:disabled="page >= totalPages || isLoading"
aria-label="Next page"
@click="goToPage(page + 1)"
>
</button>
</div>
<p v-if="error" class="banner banner-error">{{ error }}</p>
<button
type="button"
class="btn btn-primary generate-btn"
:disabled="isCreating"
@click="handleCreate"
>
{{ isCreating ? 'Generating…' : '+ Generate invite' }}
</button>
</div>
</section>
</template>
<style scoped>
.invites-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
padding: 0;
color: inherit;
cursor: pointer;
text-align: left;
}
.invites-toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.invites-toggle-label h4 {
font-size: 0.85rem;
margin: 0;
}
.invites-stats {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.invites-count-badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background-color: var(--c-accent-bg);
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 {
color: var(--c-text-soft);
transition: transform 0.15s ease-in-out;
}
.chevron.is-open {
transform: rotate(180deg);
}
.invites-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.85rem;
}
.invites-hint {
font-size: 0.8rem;
color: var(--c-text-soft);
margin: 0;
}
.invites-loading,
.invites-empty {
font-size: 0.85rem;
color: var(--c-text-soft);
margin: 0;
}
.invite-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.invite-ticket {
border: 1px solid var(--c-border);
border-left: 3px solid var(--c-text-soft);
border-radius: var(--radius-md);
background-color: var(--c-bg-mute);
padding: 0.65rem 0.85rem;
}
.invite-ticket.is-active {
border-left-color: var(--c-success);
}
.invite-ticket.is-expired {
border-left-color: var(--c-danger);
}
.invite-ticket.is-used {
border-left-color: var(--c-text-soft);
opacity: 0.75;
}
.invite-ticket-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.invite-code {
font-family: var(--font-mono);
font-size: 0.95rem;
letter-spacing: 0.06em;
color: var(--c-heading);
flex: 1 1 auto;
min-width: 0;
max-width: 24ch;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (min-width: 768px) {
.invite-code {
max-width: 80ch;
}
}
.invite-status-pill {
flex-shrink: 0;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.5rem;
border-radius: 999px;
}
.pill-active {
background-color: var(--c-success-bg);
color: var(--c-success);
}
.pill-expired {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.pill-used {
background-color: var(--c-bg-elevated);
color: var(--c-text-soft);
}
.invite-ticket-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem 0.5rem;
flex-wrap: wrap;
border-top: 1px dashed var(--c-border);
margin-top: 0.55rem;
padding-top: 0.5rem;
}
.invite-detail {
font-size: 0.75rem;
color: var(--c-text-soft);
min-width: 0;
}
.invite-actions {
display: flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
}
.confirm-label {
font-size: 0.75rem;
color: var(--c-text-soft);
}
.ticket-btn {
background: none;
border: 1px solid var(--c-border);
color: var(--c-text);
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: var(--radius-sm);
cursor: pointer;
}
.ticket-btn:hover:not(:disabled) {
border-color: var(--c-border-hover);
color: var(--c-heading);
}
.ticket-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ticket-btn-danger {
color: var(--c-danger);
border-color: rgba(193, 97, 74, 0.4);
}
.ticket-btn-danger:hover:not(:disabled) {
background-color: var(--c-danger-bg);
}
.generate-btn {
width: auto;
align-self: flex-start;
padding: 0.55rem 1.1rem;
}
.invites-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
}
.pagination-info {
font-size: 0.72rem;
color: var(--c-text-soft);
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
padding: 0;
background: none;
border: 1px solid var(--c-border);
border-radius: var(--radius-sm);
color: var(--c-text);
font-size: 0.85rem;
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>
+122 -40
View File
@@ -1,33 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import type { LocalList } from '@/database/db' import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ list: LocalList }>() const props = defineProps<{ list: LocalList; dragging?: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'share', list: LocalList): void share: [list: LocalList]
delete: [list: LocalList]
'handle-pointerdown': [event: PointerEvent]
}>() }>()
const listsStore = useListsStore() const listsStore = useListsStore()
const isMenuOpen = ref(false) const {
const menuContainerRef = ref<HTMLElement | null>(null) isOpen: isMenuOpen,
containerRef: menuContainerRef,
toggle: toggleMenu,
} = useDismissableMenu()
// The server now reports total_items/completed_items directly on the list, // The server always reports total_items/completed_items once a list has
// so the overview can show progress without having to load every item of // synced at least once, so the common case never touches the store's full
// every list. Fall back to counting locally cached items for lists that // item list at all - only a list that hasn't synced yet falls back to
// haven't synced to the server yet (e.g. just created while offline). // scanning its own items.
const items = computed(() => listsStore.itemsForList(props.list.id)) const totalCount = computed(() =>
const totalCount = computed(() => props.list.total_items ?? items.value.length) props.list.total_items !== undefined
const completedCount = computed( ? props.list.total_items
() => props.list.completed_items ?? items.value.filter((item) => item.is_completed).length, : listsStore.itemsForList(props.list.id).length,
)
const completedCount = computed(() =>
props.list.completed_items !== undefined
? props.list.completed_items
: listsStore.itemsForList(props.list.id).filter((item) => item.is_completed).length,
) )
function toggleMenu(event: Event) {
event.preventDefault()
event.stopPropagation()
isMenuOpen.value = !isMenuOpen.value
}
function handleShare(event: Event) { function handleShare(event: Event) {
event.preventDefault() event.preventDefault()
@@ -36,36 +41,38 @@ function handleShare(event: Event) {
emit('share', props.list) emit('share', props.list)
} }
function handleClickOutside(event: MouseEvent) { function handleDelete(event: Event) {
if (menuContainerRef.value && !menuContainerRef.value.contains(event.target as Node)) { event.preventDefault()
event.stopPropagation()
isMenuOpen.value = false isMenuOpen.value = false
emit('delete', props.list)
} }
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && isMenuOpen.value) {
isMenuOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
document.removeEventListener('keydown', handleKeydown)
})
</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>
<p class="meta"> <p class="meta">
{{ completedCount }}/{{ totalCount }} done <span class="mono-num">{{ completedCount }}/{{ totalCount }}</span> done
<span v-if="list.pendingSync" class="pending-tag">syncing</span> <span v-if="list.pendingSync" class="pending-tag">syncing</span>
</p> </p>
</div> </div>
@@ -108,6 +115,30 @@ onUnmounted(() => {
</svg> </svg>
<span>Share list</span> <span>Share list</span>
</button> </button>
<button
type="button"
class="submenu-item submenu-item-danger"
role="menuitem"
@click="handleDelete"
>
<svg
class="submenu-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
<span>Delete list</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -131,6 +162,48 @@ onUnmounted(() => {
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;
@@ -250,6 +323,15 @@ onUnmounted(() => {
color: var(--c-accent-strong); color: var(--c-accent-strong);
} }
.submenu-item-danger {
color: var(--c-danger);
}
.submenu-item-danger:hover {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.submenu-icon { .submenu-icon {
width: 15px; width: 15px;
height: 15px; height: 15px;
+208 -11
View File
@@ -2,29 +2,60 @@
import { ref } from 'vue' 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 DeleteListItemModal from '@/components/DeleteListItemModal.vue'
const props = defineProps<{ item: LocalListItem }>() const props = defineProps<{ item: LocalListItem }>()
const listsStore = useListsStore() const listsStore = useListsStore()
const isEditing = ref(false) const isEditing = ref(false)
const editedTitle = ref(props.item.title) const editedTitle = ref(props.item.title)
// Captured separately from `editedTitle` at the moment editing starts: if the
// item is updated remotely (another device) while the field is open,
// `props.item.title` moves but this doesn't, so saveTitle() can tell "user
// didn't touch it" apart from "server changed underneath us" instead of
// diffing against the live (possibly just-changed) prop and overwriting the
// remote edit with the untouched original text.
const originalTitle = ref(props.item.title)
const showDeleteModal = ref(false)
const {
isOpen: isMenuOpen,
containerRef: menuContainerRef,
toggle: toggleMenu,
} = useDismissableMenu()
function toggleCompleted() { function toggleCompleted() {
listsStore.setListItemCompleted(props.item.id, !props.item.is_completed) listsStore.setListItemCompleted(props.item.id, !props.item.is_completed)
} }
function startEditing() { function startEditing() {
isMenuOpen.value = false
editedTitle.value = props.item.title editedTitle.value = props.item.title
originalTitle.value = props.item.title
isEditing.value = true isEditing.value = true
} }
function saveTitle() { function saveTitle() {
const title = editedTitle.value.trim() const title = editedTitle.value.trim()
if (title && title !== props.item.title) { if (title && title !== originalTitle.value) {
listsStore.updateListItem(props.item.id, { title }) listsStore.updateListItemTitle(props.item.id, title)
} }
isEditing.value = false isEditing.value = false
} }
function handleOpenDelete() {
isMenuOpen.value = false
showDeleteModal.value = true
}
function handleCloseDelete() {
showDeleteModal.value = false
}
function handleConfirmDelete() {
showDeleteModal.value = false
listsStore.deleteListItem(props.item.id)
}
</script> </script>
<template> <template>
@@ -47,9 +78,75 @@ function saveTitle() {
@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>
<div ref="menuContainerRef" class="menu-container">
<button
type="button"
class="menu-trigger-btn"
aria-label="Item options"
aria-haspopup="true"
:aria-expanded="isMenuOpen"
title="More options"
@click="toggleMenu"
>
<svg class="dots-icon" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
<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
type="button"
class="submenu-item submenu-item-danger"
role="menuitem"
@click="handleOpenDelete"
>
<svg
class="submenu-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
<span>Delete item</span>
</button>
</div>
</div>
<DeleteListItemModal
v-if="showDeleteModal"
:item="item"
@close="handleCloseDelete"
@confirm="handleConfirmDelete"
/>
</li> </li>
</template> </template>
@@ -68,22 +165,23 @@ function saveTitle() {
.checkbox { .checkbox {
flex-shrink: 0; flex-shrink: 0;
width: 22px; width: 21px;
height: 22px; height: 21px;
border-radius: 6px; border-radius: var(--radius-sm);
border: 1px solid var(--c-border-hover); border: 1.5px solid var(--c-border-hover);
background: transparent; background: transparent;
color: #fff; color: var(--c-bg);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
font-size: 0.75rem; font-size: 0.8rem;
font-weight: 700;
} }
.item-row.completed .checkbox { .item-row.completed .checkbox {
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft)); background: var(--c-accent);
border-color: transparent; border-color: var(--c-accent);
} }
.title { .title {
@@ -116,4 +214,103 @@ function saveTitle() {
background-color: var(--c-warning); background-color: var(--c-warning);
flex-shrink: 0; flex-shrink: 0;
} }
.menu-container {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
}
.menu-trigger-btn {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out,
border-color 0.15s ease-in-out;
}
.menu-trigger-btn:hover,
.menu-trigger-btn[aria-expanded='true'] {
background-color: var(--c-bg-mute);
color: var(--c-heading);
border-color: var(--c-border);
}
.dots-icon {
display: block;
}
.submenu-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 140px;
background-color: var(--c-bg-elevated);
border: 1px solid var(--c-border-hover);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 0.35rem;
animation: dropdownIn 0.12s ease-out;
}
.submenu-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem 0.65rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--c-heading);
font-size: 0.85rem;
cursor: pointer;
text-align: left;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out;
}
.submenu-item:hover {
background-color: var(--c-bg-mute);
color: var(--c-accent-strong);
}
.submenu-item-danger {
color: var(--c-danger);
}
.submenu-item-danger:hover {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.submenu-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style> </style>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import type { LocalList } from '@/database/db'
import { useListsStore } from '@/stores/lists'
import BaseModal from '@/components/BaseModal.vue'
const props = defineProps<{
list: LocalList
}>()
const emit = defineEmits<{
close: []
}>()
const listsStore = useListsStore()
const emailInput = ref<HTMLInputElement | null>(null)
const newEmail = ref('')
const isSubmitting = ref(false)
const error = ref('')
const successMessage = ref('')
onMounted(() => {
nextTick(() => {
emailInput.value?.focus()
})
})
function handleClose() {
emit('close')
}
async function handleAddUser() {
const email = newEmail.value.trim()
if (!email) return
error.value = ''
successMessage.value = ''
isSubmitting.value = true
try {
await listsStore.addUserToList(props.list.id, email)
newEmail.value = ''
successMessage.value = `Shared with "${email}"!`
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to share list'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<BaseModal
:title="`Share &quot;${list.name}&quot;`"
title-id="share-modal-title"
@close="handleClose"
>
<p class="modal-description">Enter an email to invite them to collaborate on this list.</p>
<form class="share-form" @submit.prevent="handleAddUser">
<div class="field">
<input
ref="emailInput"
v-model="newEmail"
type="email"
placeholder="Email"
:disabled="isSubmitting"
/>
</div>
<button
type="submit"
class="btn btn-primary share-btn"
:disabled="isSubmitting || !newEmail.trim()"
>
Add
</button>
</form>
<p v-if="error" class="banner banner-error">{{ error }}</p>
<p v-if="successMessage" class="banner banner-success">{{ successMessage }}</p>
<template #footer>
<button type="button" class="btn btn-secondary" @click="handleClose">Done</button>
</template>
</BaseModal>
</template>
<style scoped>
.modal-description {
font-size: 0.85rem;
color: var(--c-text-soft);
margin-bottom: 1rem;
}
.share-form {
display: flex;
gap: 0.6rem;
margin-bottom: 1rem;
}
.share-form .field {
flex: 1;
}
.share-btn {
width: auto;
flex-shrink: 0;
padding: 0.65rem 1.2rem;
}
.banner-success {
margin-top: 0.75rem;
}
.banner-error {
margin-top: 0.75rem;
}
</style>
@@ -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()
})
})
@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DeleteListModal from '../DeleteListModal.vue'
import type { LocalList } from '@/database/db'
describe('DeleteListModal', () => {
const sampleList: LocalList = {
id: 'list-123',
name: 'Groceries',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
it('renders modal with list name and confirmation prompt', () => {
const wrapper = mount(DeleteListModal, {
props: {
list: sampleList,
},
})
expect(wrapper.text()).toContain('Delete "Groceries"?')
expect(wrapper.text()).toContain('Are you sure you want to delete this list?')
expect(wrapper.find('.confirm-delete-btn').text()).toBe('Delete list')
expect(wrapper.find('.cancel-btn').text()).toBe('Cancel')
})
it('emits confirm event when Delete button is clicked', async () => {
const wrapper = mount(DeleteListModal, {
props: {
list: sampleList,
},
})
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(DeleteListModal, {
props: {
list: sampleList,
},
})
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(DeleteListModal, {
props: {
list: sampleList,
},
})
await wrapper.find('.close-btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('emits close event when clicking overlay background', async () => {
const wrapper = mount(DeleteListModal, {
props: {
list: sampleList,
},
})
await wrapper.find('.modal-overlay').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
})
@@ -0,0 +1,422 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import InvitesPanel from '../InvitesPanel.vue'
import * as invitesApi from '@/api/invites'
import type { Invite, PaginatedInvites } from '@/types/invite'
function paginated(data: Invite[], total = data.length): PaginatedInvites {
return { data, total, count: data.length }
}
describe('InvitesPanel', () => {
beforeEach(() => {
vi.restoreAllMocks()
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: vi.fn<(text: string) => Promise<void>>().mockResolvedValue(undefined) },
configurable: true,
})
// jsdom has no Web Share API; tests that want it define it explicitly.
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', () => {
const getSpy = vi.spyOn(invitesApi, 'getInvitesApi')
const wrapper = mount(InvitesPanel)
expect(wrapper.text()).toContain('Invites')
expect(wrapper.find('#invites-panel').exists()).toBe(false)
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 () => {
const mockInvites: Invite[] = [
{ id: 'invite-1', code: 'ABC123', expires_at: '2099-01-01T00:00:00.000Z' },
{
id: 'invite-2',
code: 'USEDCODE',
expires_at: '2099-01-01T00:00:00.000Z',
consumed_at: '2026-01-01T00:00:00.000Z',
},
]
const getSpy = vi
.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(mockInvites))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(getSpy).toHaveBeenCalledWith({ page: 1, count: 10 })
expect(wrapper.text()).toContain('ABC123')
expect(wrapper.text()).toContain('USEDCODE')
expect(wrapper.text()).toContain('Active')
expect(wrapper.text()).toContain('Used')
})
it('shows an offline message instead of fetching when expanded offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const getSpy = vi.spyOn(invitesApi, 'getInvitesApi')
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
expect(getSpy).not.toHaveBeenCalled()
expect(wrapper.find('.banner-error').text()).toContain('must be online')
})
it('shows empty state when there are no invites', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([]))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No invites yet')
})
it('does not show pagination controls when everything fits on one page', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(
paginated([{ id: 'invite-1', code: 'ABC123' }], 1),
)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.find('.invites-pagination').exists()).toBe(false)
})
it('shows pagination controls and total count when there is more than one page', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated(page1, 15))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.find('.invites-pagination').text()).toContain('15 invites total')
const [prevBtn, nextBtn] = wrapper.findAll('.page-btn')
expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(true)
expect((nextBtn!.element as HTMLButtonElement).disabled).toBe(false)
})
it('navigates to the next page when the forward button is clicked', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
const getSpy = 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()
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
expect(getSpy).toHaveBeenLastCalledWith({ page: 2, count: 10 })
expect(wrapper.text()).toContain('CODE10')
const [prevBtn, nextBtnAfter] = wrapper.findAll('.page-btn')
expect((prevBtn!.element as HTMLButtonElement).disabled).toBe(false)
expect((nextBtnAfter!.element as HTMLButtonElement).disabled).toBe(true)
})
it('generates a new invite, reloads page one, and prepends it to the list', async () => {
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()
await wrapper.find('.generate-btn').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('NEWCODE1')
})
it('shares the newly created invite automatically', async () => {
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()
await wrapper.find('.generate-btn').trigger('click')
await flushPromises()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('?invite=NEWCODE1'),
)
})
it('shares an existing invite link via the clipboard when Web Share is unavailable', async () => {
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([mockInvite]))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn').trigger('click')
await flushPromises()
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining('?invite=ABC123'),
)
expect(wrapper.text()).toContain('Copied!')
})
it('uses the Web Share API instead of the clipboard when available', async () => {
const shareMock = vi.fn<(data: ShareData) => Promise<void>>().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'share', { value: shareMock, configurable: true })
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([mockInvite]))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn').trigger('click')
await flushPromises()
expect(shareMock).toHaveBeenCalledWith(
expect.objectContaining({ url: expect.stringContaining('?invite=ABC123') }),
)
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
})
it('deletes an invite after confirming', async () => {
const mockInvite: Invite = { id: 'invite-1', code: 'ABC123' }
vi.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated([mockInvite]))
.mockResolvedValueOnce(paginated([]))
const deleteSpy = vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
await wrapper.find('.ticket-btn-danger').trigger('click')
expect(wrapper.text()).toContain('Delete this invite?')
const confirmButtons = wrapper.findAll('.ticket-btn-danger')
await confirmButtons[confirmButtons.length - 1]?.trigger('click')
await flushPromises()
expect(deleteSpy).toHaveBeenCalledWith('invite-1')
expect(wrapper.text()).toContain('No invites yet')
})
it('steps back a page when deleting the last item on a page past the first', async () => {
const page1 = Array.from({ length: 10 }, (_, i) => ({ id: `invite-${i}`, code: `CODE${i}` }))
const page2 = [{ id: 'invite-10', code: 'CODE10' }]
const getSpy = vi
.spyOn(invitesApi, 'getInvitesApi')
.mockResolvedValueOnce(paginated(page1, 11))
.mockResolvedValueOnce(paginated(page2, 11))
.mockResolvedValueOnce(paginated(page1, 10))
vi.spyOn(invitesApi, 'deleteInviteApi').mockResolvedValueOnce()
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const [, nextBtn] = wrapper.findAll('.page-btn')
await nextBtn?.trigger('click')
await flushPromises()
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(getSpy).toHaveBeenLastCalledWith({ page: 1, count: 10 })
})
it('disables delete for already-used invites', async () => {
const usedInvite: Invite = {
id: 'invite-1',
code: 'USEDCODE',
consumed_at: '2026-01-01T00:00:00.000Z',
}
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(paginated([usedInvite]))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const deleteBtn = wrapper.find('.ticket-btn-danger')
expect((deleteBtn.element as HTMLButtonElement).disabled).toBe(true)
})
it('disables share for used and expired invites, but leaves expired invites deletable', async () => {
const usedInvite: Invite = {
id: 'invite-1',
code: 'USEDCODE',
consumed_at: '2026-01-01T00:00:00.000Z',
}
const expiredInvite: Invite = {
id: 'invite-2',
code: 'EXPCODE',
expires_at: '2020-01-01T00:00:00.000Z',
}
vi.spyOn(invitesApi, 'getInvitesApi').mockResolvedValueOnce(
paginated([usedInvite, expiredInvite]),
)
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
const shareButtons = wrapper.findAll('.ticket-btn:not(.ticket-btn-danger)')
expect((shareButtons[0]!.element as HTMLButtonElement).disabled).toBe(true)
expect((shareButtons[1]!.element as HTMLButtonElement).disabled).toBe(true)
const deleteButtons = wrapper.findAll('.ticket-btn-danger')
expect((deleteButtons[0]!.element as HTMLButtonElement).disabled).toBe(true)
expect((deleteButtons[1]!.element as HTMLButtonElement).disabled).toBe(false)
})
it('shows an error banner when loading invites fails', async () => {
vi.spyOn(invitesApi, 'getInvitesApi').mockRejectedValueOnce(new Error('Network error'))
const wrapper = mount(InvitesPanel)
await wrapper.find('.invites-toggle').trigger('click')
await flushPromises()
expect(wrapper.find('.banner-error').text()).toContain('Network error')
})
})
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve))
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ListCard from '../ListCard.vue'
import type { LocalList } from '@/database/db'
describe('ListCard', () => {
const sampleList: LocalList = {
id: 'list-123',
name: 'Groceries',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
it('renders list name and options button', () => {
const wrapper = mount(ListCard, {
props: {
list: sampleList,
},
global: {
stubs: {
RouterLink: {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
},
},
})
expect(wrapper.text()).toContain('Groceries')
expect(wrapper.find('.menu-trigger-btn').exists()).toBe(true)
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
it('toggles dropdown submenu when options button is clicked', async () => {
const wrapper = mount(ListCard, {
props: {
list: sampleList,
},
global: {
stubs: {
RouterLink: {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
},
},
})
await wrapper.find('.menu-trigger-btn').trigger('click')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(true)
expect(wrapper.find('.submenu-item').text()).toContain('Share list')
await wrapper.find('.menu-trigger-btn').trigger('click')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
it('emits share event when Share list is clicked in submenu', async () => {
const wrapper = mount(ListCard, {
props: {
list: sampleList,
},
global: {
stubs: {
RouterLink: {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
},
},
})
await wrapper.find('.menu-trigger-btn').trigger('click')
await wrapper.find('.submenu-item').trigger('click')
expect(wrapper.emitted('share')).toBeTruthy()
expect(wrapper.emitted('share')?.[0]).toEqual([sampleList])
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
it('emits delete event when Delete list is clicked in submenu', async () => {
const wrapper = mount(ListCard, {
props: {
list: sampleList,
},
global: {
stubs: {
RouterLink: {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
},
},
})
await wrapper.find('.menu-trigger-btn').trigger('click')
const deleteBtn = wrapper.find('.submenu-item-danger')
expect(deleteBtn.exists()).toBe(true)
expect(deleteBtn.text()).toContain('Delete list')
await deleteBtn.trigger('click')
expect(wrapper.emitted('delete')).toBeTruthy()
expect(wrapper.emitted('delete')?.[0]).toEqual([sampleList])
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
})
@@ -0,0 +1,134 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ListItemRow from '../ListItemRow.vue'
import { useListsStore } from '@/stores/lists'
import type { LocalListItem } from '@/database/db'
describe('ListItemRow', () => {
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',
}
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
it('renders item title and options menu button', () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
},
})
expect(wrapper.text()).toContain('Apples')
expect(wrapper.find('.menu-trigger-btn').exists()).toBe(true)
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
it('toggles dropdown when menu button is clicked', async () => {
const wrapper = mount(ListItemRow, {
props: {
item: sampleItem,
},
})
await wrapper.find('.menu-trigger-btn').trigger('click')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(true)
expect(wrapper.find('.submenu-item-danger').text()).toContain('Delete item')
await wrapper.find('.menu-trigger-btn').trigger('click')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(false)
})
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, {
props: {
item: sampleItem,
},
})
const listsStore = useListsStore()
const deleteSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue()
// 1st click: open menu
await wrapper.find('.menu-trigger-btn').trigger('click')
expect(wrapper.find('.submenu-dropdown').exists()).toBe(true)
// 2nd click: opens confirmation modal, doesn't delete yet
await wrapper.find('.submenu-item-danger').trigger('click')
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)
})
})
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ShareListModal from '../ShareListModal.vue'
import { useListsStore } from '@/stores/lists'
import type { LocalList } from '@/database/db'
describe('ShareListModal', () => {
const sampleList: LocalList = {
id: 'list-123',
name: 'Groceries',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
})
it('renders modal with list name and user input', () => {
const wrapper = mount(ShareListModal, {
props: {
list: sampleList,
},
})
expect(wrapper.text()).toContain('Share "Groceries"')
expect(wrapper.find('input[placeholder="Email"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe('Add')
})
it('submits form to share list with an email', async () => {
const listsStore = useListsStore()
const addSpy = vi.spyOn(listsStore, 'addUserToList').mockResolvedValueOnce()
const wrapper = mount(ShareListModal, {
props: {
list: sampleList,
},
})
await wrapper.find('input').setValue('user@example.com')
await wrapper.find('form').trigger('submit.prevent')
expect(addSpy).toHaveBeenCalledWith('list-123', 'user@example.com')
expect(wrapper.find('.banner-success').text()).toContain('Shared with "user@example.com"!')
})
it('displays error banner when sharing fails', async () => {
const listsStore = useListsStore()
vi.spyOn(listsStore, 'addUserToList').mockRejectedValueOnce(new Error('User not found'))
const wrapper = mount(ShareListModal, {
props: {
list: sampleList,
},
})
await wrapper.find('input').setValue('bad@example.com')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.find('.banner-error').text()).toContain('User not found')
})
it('emits close event when close button is clicked', async () => {
const wrapper = mount(ShareListModal, {
props: {
list: sampleList,
},
})
await wrapper.find('.close-btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
it('emits close event when Done button is clicked', async () => {
const wrapper = mount(ShareListModal, {
props: {
list: sampleList,
},
})
await wrapper.find('.modal-footer .btn').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
})
})
+37
View File
@@ -0,0 +1,37 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
type OutsideHandler = (event: MouseEvent) => void
const handlers = new Set<OutsideHandler>()
function dispatch(event: MouseEvent) {
for (const handler of handlers) {
handler(event)
}
}
// Backs every call with a single shared `document` click listener instead of
// one per component instance, so a page rendering many dismissable menus
// (e.g. one per row in a list) doesn't fan out into one global listener per
// row.
export function useClickOutside(target: Ref<HTMLElement | null>, onOutside: () => void): void {
function handler(event: MouseEvent) {
if (target.value && !target.value.contains(event.target as Node)) {
onOutside()
}
}
onMounted(() => {
handlers.add(handler)
if (handlers.size === 1) {
document.addEventListener('click', dispatch)
}
})
onUnmounted(() => {
handlers.delete(handler)
if (handlers.size === 0) {
document.removeEventListener('click', dispatch)
}
})
}
+28
View File
@@ -0,0 +1,28 @@
import { ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import { useEscapeKey } from '@/composables/useEscapeKey'
// Bundles the isOpen/containerRef/toggle wiring shared by every dropdown menu
// (list card, list item row, list detail header) so it isn't hand-rolled per
// component on top of useClickOutside/useEscapeKey.
export function useDismissableMenu() {
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
function close() {
isOpen.value = false
}
function toggle(event: Event) {
event.preventDefault()
event.stopPropagation()
isOpen.value = !isOpen.value
}
useClickOutside(containerRef, close)
useEscapeKey(() => {
if (isOpen.value) close()
})
return { isOpen, containerRef, toggle, close }
}
+30
View File
@@ -0,0 +1,30 @@
import { onMounted, onUnmounted } from 'vue'
type EscapeHandler = () => void
const handlers = new Set<EscapeHandler>()
function dispatch(event: KeyboardEvent) {
if (event.key !== 'Escape') return
for (const handler of handlers) {
handler()
}
}
// Backs every call with a single shared `document` keydown listener instead
// of one per component instance.
export function useEscapeKey(onEscape: EscapeHandler): void {
onMounted(() => {
handlers.add(onEscape)
if (handlers.size === 1) {
document.addEventListener('keydown', dispatch)
}
})
onUnmounted(() => {
handlers.delete(onEscape)
if (handlers.size === 0) {
document.removeEventListener('keydown', dispatch)
}
})
}
+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 }
}
+47 -11
View File
@@ -1,26 +1,38 @@
import Dexie, { type Table } from 'dexie' import Dexie, { type Table } from 'dexie'
import type { List, ListItem } from '@/types/list' import type {
List,
ListItem,
CreateListPayload,
CreateListItemPayload,
SetListItemCompletedPayload,
SetListItemTitlePayload,
OrderListsPayload,
} from '@/types/list'
export interface LocalList extends List { export interface LocalList extends List {
pendingSync?: boolean pendingSync?: boolean
// Stable identity for this list on this client, independent of `id`.
// `id` starts out as a client-generated placeholder and gets swapped for
// the server-assigned one once "createList" syncs (see remapListId) -
// clientId never changes, so UI code that needs a stable key across that
// swap (e.g. <TransitionGroup>'s :key) can use it instead of `id`.
clientId?: string
} }
export interface LocalListItem extends ListItem { export interface LocalListItem extends ListItem {
pendingSync?: boolean pendingSync?: boolean
} }
export type SyncOperationType = export interface DeleteListPayload {
| 'createList' id: string
| 'createListItem' }
| 'updateListItem'
| 'setListItemCompleted'
| 'addUserToList'
| 'removeUserFromList'
export interface SyncQueueEntry { export interface DeleteListItemPayload {
id: string
}
interface SyncQueueEntryBase {
id?: number id?: number
type: SyncOperationType
payload: unknown
localListId?: string localListId?: string
localListItemId?: string localListItemId?: string
createdAt: number createdAt: number
@@ -28,6 +40,30 @@ export interface SyncQueueEntry {
lastError?: string lastError?: string
} }
type SyncOperationPayloads = {
createList: CreateListPayload
createListItem: CreateListItemPayload
updateListItemTitle: SetListItemTitlePayload
setListItemCompleted: SetListItemCompletedPayload
deleteList: DeleteListPayload
deleteListItem: DeleteListItemPayload
orderLists: OrderListsPayload
}
export type SyncOperationType = keyof SyncOperationPayloads
// A discriminated union keyed on `type` instead of a single `payload: unknown`
// shape, so `processSyncEntry`'s switch narrows `entry.payload` to the right
// type per case without a manual cast - and adding/changing an operation type
// here is a compile error everywhere it's handled inconsistently.
export type SyncQueueEntry = {
[K in SyncOperationType]: SyncQueueEntryBase & { type: K; payload: SyncOperationPayloads[K] }
}[SyncOperationType]
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never
export type NewSyncQueueEntry = DistributiveOmit<SyncQueueEntry, 'id' | 'createdAt' | 'attempts'>
class AppDatabase extends Dexie { class AppDatabase extends Dexie {
lists!: Table<LocalList, string> lists!: Table<LocalList, string>
listItems!: Table<LocalListItem, string> listItems!: Table<LocalListItem, string>
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import router from '../index'
import { useAuthStore } from '@/stores/auth'
describe('router', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('redirects unauthenticated access to protected routes to login', async () => {
await router.push('/')
expect(router.currentRoute.value.name).toBe('login')
})
it('allows authenticated access to protected routes', async () => {
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token', refresh_token: 'refresh' })
await router.push('/')
expect(router.currentRoute.value.name).toBe('lists')
})
it('redirects any route carrying ?invite= to the (unlisted) register route', async () => {
await router.push('/?invite=abc123')
expect(router.currentRoute.value.name).toBe('register')
expect(router.currentRoute.value.query.invite).toBe('abc123')
})
it('redirects an authenticated route with an invite code to register too', async () => {
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'token', refresh_token: 'refresh' })
await router.push('/account?invite=abc123')
expect(router.currentRoute.value.name).toBe('register')
expect(router.currentRoute.value.query.invite).toBe('abc123')
})
it('does not redirect when already navigating to register with an invite code', async () => {
await router.push('/register?invite=xyz')
expect(router.currentRoute.value.name).toBe('register')
})
it('does not redirect to register when there is no invite code', async () => {
await router.push('/login')
expect(router.currentRoute.value.name).toBe('login')
})
})
+22 -6
View File
@@ -18,18 +18,28 @@ 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: '/about', path: '/account',
name: 'about', name: 'account',
// route level code-splitting component: () => import('../views/AccountView.vue'),
// this generates a separate chunk (About.[hash].js) for this route },
// which is lazy-loaded when the route is visited. // Intentionally not linked from anywhere in the UI — reached only via an
component: () => import('../views/AboutView.vue'), // invite link (see router.beforeEach below) or by typing the URL directly.
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue'),
}, },
], ],
}) })
@@ -37,6 +47,12 @@ const router = createRouter({
router.beforeEach((to) => { router.beforeEach((to) => {
const authStore = useAuthStore() const authStore = useAuthStore()
// An invite link may point anywhere (e.g. the app root) so it still works
// if the recipient doesn't have the app installed/bookmarked at /register.
if (to.name !== 'register' && typeof to.query.invite === 'string' && to.query.invite) {
return { name: 'register', query: { invite: to.query.invite } }
}
if (to.meta.requiresAuth && !authStore.isAuthenticated) { if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return { name: 'login', query: { redirect: to.fullPath } } return { name: 'login', query: { redirect: to.fullPath } }
} }
+397 -11
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia' import { setActivePinia, createPinia } from 'pinia'
type Record = { id?: unknown; [key: string]: unknown } type Record = { id?: unknown; [key: string]: unknown }
@@ -37,6 +37,17 @@ function createFakeTable(autoIncrement = false) {
async count() { async count() {
return store.size return store.size
}, },
async bulkPut(records: Record[]) {
for (const record of records) {
store.set(record.id, { ...record })
}
return records.map((record) => record.id)
},
async bulkDelete(ids: unknown[]) {
for (const id of ids) {
store.delete(id)
}
},
where(field: string) { where(field: string) {
return { return {
equals(value: unknown) { equals(value: unknown) {
@@ -46,6 +57,16 @@ function createFakeTable(autoIncrement = false) {
.filter((v) => v[field] === value) .filter((v) => v[field] === value)
.map((v) => ({ ...v })) .map((v) => ({ ...v }))
}, },
async delete() {
const matches = Array.from(store.entries()).filter(([, v]) => v[field] === value)
for (const [key] of matches) store.delete(key)
return matches.length
},
async modify(changes: Record) {
const matches = Array.from(store.entries()).filter(([, v]) => v[field] === value)
for (const [key, v] of matches) store.set(key, { ...v, ...changes })
return matches.length
},
} }
}, },
} }
@@ -88,10 +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>>(),
deleteListItemApi: vi.fn<() => Promise<unknown>>(),
orderListsApi: vi.fn<() => Promise<unknown>>(),
})) }))
vi.mock('@/api/lists', () => listsApiMocks) vi.mock('@/api/lists', () => listsApiMocks)
@@ -100,6 +124,10 @@ const { useListsStore } = await import('../lists')
describe('useListsStore', () => { describe('useListsStore', () => {
beforeEach(async () => { beforeEach(async () => {
// Mutations schedule a debounced sync() via setTimeout; faking timers
// keeps that pending timer from firing against a later test's mocks
// instead of the ones set up here (tests trigger sync() explicitly).
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
setActivePinia(createPinia()) setActivePinia(createPinia())
vi.restoreAllMocks() vi.restoreAllMocks()
Object.values(listsApiMocks).forEach((mock) => mock.mockReset()) Object.values(listsApiMocks).forEach((mock) => mock.mockReset())
@@ -113,24 +141,35 @@ describe('useListsStore', () => {
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }) Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })
// sync() is a no-op for anonymous visitors (see lists.ts); these tests
// exercise the authenticated sync path, so seed a logged-in session.
localStorage.setItem('access_token', 'test-access-token')
localStorage.setItem('refresh_token', 'test-refresh-token')
// Default the read endpoints to an empty result so that the pullFromServer() // Default the read endpoints to an empty result so that the pullFromServer()
// step chained onto every sync() call doesn't interfere with unrelated tests. // step chained onto every sync() call doesn't interfere with unrelated tests.
listsApiMocks.getListsApi.mockResolvedValue([]) listsApiMocks.getListsApi.mockResolvedValue([])
listsApiMocks.getListItemsApi.mockResolvedValue([]) listsApiMocks.getListItemsApi.mockResolvedValue([])
}) })
afterEach(() => {
vi.useRealTimers()
})
it('creates a list locally, queues a sync entry, and remaps the id after a successful sync', async () => { it('creates a list locally, queues a sync entry, and remaps the id after a successful sync', async () => {
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-id-1', name: 'Groceries' }) listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-id-1', name: 'Groceries' })
// The chained pullFromServer() call needs to report the just-created list
// back, otherwise it would look like the server deleted it.
listsApiMocks.getListsApi.mockResolvedValueOnce([{ id: 'server-id-1', name: 'Groceries' }])
const store = useListsStore() const store = useListsStore()
const localList = await store.createList('Groceries', []) const localList = await store.createList('Groceries')
// wait for the fire-and-forget sync triggered by createList to settle // wait for the fire-and-forget sync triggered by createList to settle
await store.sync() await store.sync()
expect(listsApiMocks.createListApi).toHaveBeenCalledWith({ expect(listsApiMocks.createListApi).toHaveBeenCalledWith({
name: 'Groceries', name: 'Groceries',
user_ids: [],
}) })
expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined() expect(store.lists.find((list) => list.id === localList.id)).toBeUndefined()
const synced = store.lists.find((list) => list.id === 'server-id-1') const synced = store.lists.find((list) => list.id === 'server-id-1')
@@ -173,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)
}) })
@@ -210,6 +248,16 @@ describe('useListsStore', () => {
expect(store.pendingCount).toBe(1) expect(store.pendingCount).toBe(1)
}) })
it('does not attempt to sync when logged out, e.g. an anonymous visitor on a public invite link', async () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
const store = useListsStore()
await store.sync()
expect(listsApiMocks.getListsApi).not.toHaveBeenCalled()
})
it('sets a list item completed locally and pushes it via the dedicated endpoint', async () => { it('sets a list item completed locally and pushes it via the dedicated endpoint', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({ listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-3', id: 'server-item-3',
@@ -262,6 +310,55 @@ describe('useListsStore', () => {
expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined() expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined()
}) })
it('deletes a previously synced list locally when it is missing from the server', async () => {
const store = useListsStore()
await fakeDb.lists.put({ id: 'server-list-1', name: 'Groceries', pendingSync: false })
await fakeDb.listItems.put({
id: 'server-item-1',
list_id: 'server-list-1',
title: 'Milk',
pendingSync: false,
})
await store.refresh()
listsApiMocks.getListsApi.mockResolvedValueOnce([])
await store.pullFromServer()
expect(store.lists.find((list) => list.id === 'server-list-1')).toBeUndefined()
expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeUndefined()
})
it('deletes a previously synced list item locally when it is missing from the server', async () => {
const store = useListsStore()
await fakeDb.listItems.put({
id: 'server-item-2',
list_id: 'server-list-1',
title: 'Bread',
pendingSync: false,
})
await store.refresh()
listsApiMocks.getListItemsApi.mockResolvedValueOnce([])
await store.pullListItems('server-list-1')
expect(store.listItems.find((item) => item.id === 'server-item-2')).toBeUndefined()
})
it('does not delete a locally pending list even when it is missing from the server', async () => {
listsApiMocks.createListApi.mockImplementation(() => new Promise(() => {}))
const store = useListsStore()
const localList = await store.createList('Local only')
listsApiMocks.getListsApi.mockResolvedValueOnce([])
await store.pullFromServer()
expect(store.lists.find((list) => list.id === localList.id)).toBeDefined()
})
it('does not overwrite a locally pending list with stale server data', async () => { it('does not overwrite a locally pending list with stale server data', async () => {
listsApiMocks.createListApi.mockImplementation(() => new Promise(() => {})) listsApiMocks.createListApi.mockImplementation(() => new Promise(() => {}))
@@ -276,4 +373,293 @@ describe('useListsStore', () => {
expect(stillLocal?.name).toBe('Local only') expect(stillLocal?.name).toBe('Local only')
expect(stillLocal?.pendingSync).toBe(true) expect(stillLocal?.pendingSync).toBe(true)
}) })
it('deletes a list locally and syncs deletion to the server', async () => {
listsApiMocks.deleteListApi.mockResolvedValueOnce(undefined)
const store = useListsStore()
await fakeDb.lists.put({ id: 'list-to-delete', name: 'Delete Me' })
await fakeDb.listItems.put({ id: 'item-in-list', list_id: 'list-to-delete', title: 'Item' })
await store.refresh()
expect(store.lists.find((l) => l.id === 'list-to-delete')).toBeDefined()
expect(store.listItems.find((i) => i.id === 'item-in-list')).toBeDefined()
await store.deleteList('list-to-delete')
expect(store.lists.find((l) => l.id === 'list-to-delete')).toBeUndefined()
expect(store.listItems.find((i) => i.id === 'item-in-list')).toBeUndefined()
await store.sync()
expect(listsApiMocks.deleteListApi).toHaveBeenCalledWith('list-to-delete')
expect(store.pendingCount).toBe(0)
})
it('deletes a list item locally and syncs deletion to the server', async () => {
listsApiMocks.deleteListItemApi.mockResolvedValueOnce(undefined)
const store = useListsStore()
await fakeDb.listItems.put({ id: 'item-to-delete', list_id: 'list-1', title: 'Delete Me' })
await store.refresh()
expect(store.listItems.find((i) => i.id === 'item-to-delete')).toBeDefined()
await store.deleteListItem('item-to-delete')
expect(store.listItems.find((i) => i.id === 'item-to-delete')).toBeUndefined()
await store.sync()
expect(listsApiMocks.deleteListItemApi).toHaveBeenCalledWith('item-to-delete')
expect(store.pendingCount).toBe(0)
})
it('shares list with server when online without adding to sync queue', async () => {
listsApiMocks.addUserToListApi.mockResolvedValueOnce(undefined)
const store = useListsStore()
await store.addUserToList('list-1', 'friend@example.com')
expect(listsApiMocks.addUserToListApi).toHaveBeenCalledWith({
list_id: 'list-1',
email: 'friend@example.com',
})
expect(store.pendingCount).toBe(0)
})
it('throws error when sharing list while offline without calling API or adding to sync queue', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const store = useListsStore()
await expect(store.addUserToList('list-1', 'friend@example.com')).rejects.toThrow(
'Cannot share list while offline',
)
expect(listsApiMocks.addUserToListApi).not.toHaveBeenCalled()
expect(store.pendingCount).toBe(0)
})
it('propagates error when sharing list fails on server without adding to sync queue', async () => {
listsApiMocks.addUserToListApi.mockRejectedValueOnce(new Error('User not found'))
const store = useListsStore()
await expect(store.addUserToList('list-1', 'unknown@example.com')).rejects.toThrow(
'User not found',
)
expect(store.pendingCount).toBe(0)
})
it('removes user from list on the server when online without adding to sync queue', async () => {
listsApiMocks.removeUserFromListApi.mockResolvedValueOnce(undefined)
const store = useListsStore()
await store.removeUserFromList('list-1', 'friend@example.com')
expect(listsApiMocks.removeUserFromListApi).toHaveBeenCalledWith({
list_id: 'list-1',
email: 'friend@example.com',
})
expect(store.pendingCount).toBe(0)
})
it('throws error when removing user from list while offline without calling API', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true })
const store = useListsStore()
await expect(store.removeUserFromList('list-1', 'friend@example.com')).rejects.toThrow(
'Cannot remove user from list while offline',
)
expect(listsApiMocks.removeUserFromListApi).not.toHaveBeenCalled()
expect(store.pendingCount).toBe(0)
})
it('propagates error when removing user from list fails on server', async () => {
listsApiMocks.removeUserFromListApi.mockRejectedValueOnce(new Error('User not found'))
const store = useListsStore()
await expect(store.removeUserFromList('list-1', 'unknown@example.com')).rejects.toThrow(
'User not found',
)
expect(store.pendingCount).toBe(0)
})
it('does not sync before the debounce delay elapses, then syncs once it does', async () => {
listsApiMocks.createListItemApi.mockResolvedValueOnce({
id: 'server-item-1',
list_id: 'list-1',
title: 'Milk',
is_completed: false,
})
const store = useListsStore()
await store.createListItem('list-1', 'Milk')
await vi.advanceTimersByTimeAsync(399)
expect(listsApiMocks.createListItemApi).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(listsApiMocks.createListItemApi).toHaveBeenCalledTimes(1)
})
it('collapses a burst of mutations within the debounce window into a single sync pass', async () => {
listsApiMocks.createListItemApi
.mockResolvedValueOnce({
id: 'server-item-a',
list_id: 'list-1',
title: 'A',
is_completed: false,
})
.mockResolvedValueOnce({
id: 'server-item-b',
list_id: 'list-1',
title: 'B',
is_completed: false,
})
const store = useListsStore()
await store.createListItem('list-1', 'A')
await store.createListItem('list-1', 'B')
expect(listsApiMocks.createListItemApi).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(400)
expect(listsApiMocks.createListItemApi).toHaveBeenCalledTimes(2)
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 () => {
let resolveGetLists!: (value: unknown[]) => void
listsApiMocks.getListsApi.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveGetLists = resolve
}),
)
listsApiMocks.getListItemsApi.mockResolvedValueOnce([
{ id: 'server-item-1', list_id: 'list-1', title: 'Milk', is_completed: false },
])
const store = useListsStore()
const syncPromise = store.sync()
const pullPromise = store.pullListItems('list-1')
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
// pullListItems is chained behind sync() via the shared operation queue,
// so its own (already-mocked, instantly resolvable) API call must not
// fire while sync()'s getListsApi call is still pending.
expect(listsApiMocks.getListItemsApi).not.toHaveBeenCalled()
resolveGetLists([])
await syncPromise
await pullPromise
expect(listsApiMocks.getListItemsApi).toHaveBeenCalledWith('list-1')
expect(store.listItems.find((item) => item.id === 'server-item-1')).toBeDefined()
})
}) })
+11 -1
View File
@@ -1,7 +1,8 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { loginApi, refreshApi, logoutApi, logoutAllApi } from '@/api/auth' import { loginApi, refreshApi, logoutApi, logoutAllApi } from '@/api/auth'
import type { LoginPayload, TokenPair } from '@/types/auth' import type { AccessTokenClaims, LoginPayload, TokenPair } from '@/types/auth'
import { decodeJwtPayload } from '@/utils/jwt'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const accessToken = ref<string | null>(localStorage.getItem('access_token')) const accessToken = ref<string | null>(localStorage.getItem('access_token'))
@@ -11,6 +12,12 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => !!accessToken.value) const isAuthenticated = computed(() => !!accessToken.value)
const currentUser = computed(() =>
accessToken.value ? decodeJwtPayload<AccessTokenClaims>(accessToken.value) : null,
)
const email = computed(() => currentUser.value?.email ?? null)
const username = computed(() => currentUser.value?.name ?? null)
function setTokens(tokens: TokenPair) { function setTokens(tokens: TokenPair) {
accessToken.value = tokens.access_token accessToken.value = tokens.access_token
refreshToken.value = tokens.refresh_token refreshToken.value = tokens.refresh_token
@@ -89,6 +96,9 @@ export const useAuthStore = defineStore('auth', () => {
isLoading, isLoading,
error, error,
isAuthenticated, isAuthenticated,
currentUser,
email,
username,
setTokens, setTokens,
clearTokens, clearTokens,
login, login,
-12
View File
@@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
+357 -95
View File
@@ -1,17 +1,33 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { db, type LocalList, type LocalListItem, type SyncQueueEntry } from '@/database/db' import { useAuthStore } from '@/stores/auth'
import {
db,
type LocalList,
type LocalListItem,
type SyncQueueEntry,
type NewSyncQueueEntry,
} from '@/database/db'
import { import {
getListsApi, getListsApi,
createListApi, createListApi,
getListItemsApi, getListItemsApi,
createListItemApi, createListItemApi,
updateListItemApi, updateListItemTitleApi,
setListItemCompletedApi, setListItemCompletedApi,
addUserToListApi, addUserToListApi,
removeUserFromListApi, removeUserFromListApi,
deleteListApi,
deleteListItemApi,
orderListsApi,
} from '@/api/lists' } from '@/api/lists'
// A burst of rapid edits (ticking off several items, typing then blurring a
// few titles) would otherwise trigger one full sync pass - queue drain plus
// a GET /lists - per edit. Debouncing collapses a burst into a single pass a
// short moment after the last edit.
const SYNC_DEBOUNCE_MS = 400
function generateId(): string { function generateId(): string {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID() return crypto.randomUUID()
@@ -27,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) {
@@ -37,6 +63,40 @@ export const useListsStore = defineStore('lists', () => {
.sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? '')) .sort((a, b) => (a.created_at ?? '').localeCompare(b.created_at ?? ''))
} }
// --- targeted local-state helpers -----------------------------------
// Mutations patch `lists`/`listItems` in place instead of reloading the
// whole table from Dexie after every write. This keeps unrelated rows'
// object identity stable (so unrelated components don't re-render) and
// avoids two full-table scans per keystroke-triggered save.
function upsertList(record: LocalList) {
const existing = lists.value.find((entry) => entry.id === record.id)
if (existing) {
Object.assign(existing, record)
} else {
lists.value.push(record)
}
}
function removeLocalList(id: string) {
const idx = lists.value.findIndex((entry) => entry.id === id)
if (idx !== -1) lists.value.splice(idx, 1)
}
function upsertListItem(record: LocalListItem) {
const existing = listItems.value.find((entry) => entry.id === record.id)
if (existing) {
Object.assign(existing, record)
} else {
listItems.value.push(record)
}
}
function removeLocalListItem(id: string) {
const idx = listItems.value.findIndex((entry) => entry.id === id)
if (idx !== -1) listItems.value.splice(idx, 1)
}
async function refresh() { async function refresh() {
lists.value = await db.lists.toArray() lists.value = await db.lists.toArray()
listItems.value = await db.listItems.toArray() listItems.value = await db.listItems.toArray()
@@ -44,29 +104,53 @@ export const useListsStore = defineStore('lists', () => {
isLoaded.value = true isLoaded.value = true
} }
async function loadLists() { // Dedupes concurrent first-load refreshes: loadLists() and loadListItems()
if (!isLoaded.value) { // are both called from ListDetailView's onMounted and would otherwise each
await refresh() // see isLoaded === false and kick off their own full-table Dexie scan.
let loadPromise: Promise<void> | null = null
async function ensureLoaded() {
if (isLoaded.value) return
if (!loadPromise) {
loadPromise = refresh().finally(() => {
loadPromise = null
})
} }
// The server is the source of truth: even though we already have a await loadPromise
// local snapshot to render instantly (including while offline), always }
// kick off a background sync/pull so views reflect the latest server
// state on every visit, not just on the first load of the session. async function loadLists() {
await ensureLoaded()
void sync() void sync()
} }
async function enqueue(entry: Omit<SyncQueueEntry, 'id' | 'createdAt' | 'attempts'>) { async function enqueue(entry: NewSyncQueueEntry) {
await db.syncQueue.add({ await db.syncQueue.add({
...entry, ...entry,
createdAt: Date.now(), createdAt: Date.now(),
attempts: 0, attempts: 0,
}) })
pendingCount.value++
} }
async function createList(name: string, userIds: string[] = []): Promise<LocalList> { let syncDebounceHandle: ReturnType<typeof setTimeout> | null = null
function scheduleSync() {
if (syncDebounceHandle !== null) {
clearTimeout(syncDebounceHandle)
}
syncDebounceHandle = setTimeout(() => {
syncDebounceHandle = null
void sync()
}, SYNC_DEBOUNCE_MS)
}
async function createList(name: string): Promise<LocalList> {
const now = new Date().toISOString() const now = new Date().toISOString()
const id = generateId()
const localList: LocalList = { const localList: LocalList = {
id: generateId(), id,
clientId: id,
name, name,
created_at: now, created_at: now,
modified_at: now, modified_at: now,
@@ -74,13 +158,13 @@ export const useListsStore = defineStore('lists', () => {
} }
await db.lists.add(localList) await db.lists.add(localList)
upsertList(localList)
await enqueue({ await enqueue({
type: 'createList', type: 'createList',
payload: { name, user_ids: userIds }, payload: { name },
localListId: localList.id, localListId: localList.id,
}) })
await refresh() scheduleSync()
void sync()
return localList return localList
} }
@@ -98,68 +182,122 @@ export const useListsStore = defineStore('lists', () => {
} }
await db.listItems.add(localItem) await db.listItems.add(localItem)
upsertListItem(localItem)
await enqueue({ await enqueue({
type: 'createListItem', type: 'createListItem',
payload: { list_id: listId, title }, payload: { list_id: listId, title },
localListItemId: localItem.id, localListItemId: localItem.id,
}) })
await refresh() scheduleSync()
void sync()
return localItem return localItem
} }
async function updateListItem( async function updateListItemTitle(itemId: string, title: string) {
itemId: string, const patch = {
changes: { title?: string; is_completed?: boolean }, title,
) {
await db.listItems.update(itemId, {
...changes,
modified_at: new Date().toISOString(), modified_at: new Date().toISOString(),
pendingSync: true, pendingSync: true,
}) }
await db.listItems.update(itemId, patch)
const existingItem = listItems.value.find((entry) => entry.id === itemId)
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,
}) })
await refresh() scheduleSync()
void sync()
} }
async function setListItemCompleted(itemId: string, isCompleted: boolean) { async function setListItemCompleted(itemId: string, isCompleted: boolean) {
await db.listItems.update(itemId, { const patch = {
is_completed: isCompleted, is_completed: isCompleted,
modified_at: new Date().toISOString(), modified_at: new Date().toISOString(),
pendingSync: true, pendingSync: true,
}) }
await db.listItems.update(itemId, patch)
const existingItem = listItems.value.find((entry) => entry.id === itemId)
if (existingItem) Object.assign(existingItem, patch)
await enqueue({ await enqueue({
type: 'setListItemCompleted', type: 'setListItemCompleted',
payload: { is_completed: isCompleted }, payload: { is_completed: isCompleted },
localListItemId: itemId, localListItemId: itemId,
}) })
await refresh() scheduleSync()
void sync()
} }
async function addUserToList(listId: string, email: string) { async function addUserToList(listId: string, email: string) {
await enqueue({ if (typeof navigator !== 'undefined' && !navigator.onLine) {
type: 'addUserToList', throw new Error('Cannot share list while offline')
payload: { list_id: listId, email }, }
localListId: listId, await addUserToListApi({ list_id: listId, email })
})
await refresh()
void sync()
} }
// Sharing/unsharing a list has no local representation to keep
// optimistically in sync (there's no cached "shared users" list), so
// there's nothing offline queuing would buy here - both directions of
// this mutation go straight to the server, same as addUserToList.
async function removeUserFromList(listId: string, email: string) { async function removeUserFromList(listId: string, email: string) {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
throw new Error('Cannot remove user from list while offline')
}
await removeUserFromListApi({ list_id: listId, email })
}
async function deleteList(listId: string) {
await db.lists.delete(listId)
await db.listItems.where('list_id').equals(listId).delete()
removeLocalList(listId)
listItems.value = listItems.value.filter((item) => item.list_id !== listId)
await enqueue({ await enqueue({
type: 'removeUserFromList', type: 'deleteList',
payload: { list_id: listId, email }, payload: { id: listId },
localListId: listId, localListId: listId,
}) })
await refresh() scheduleSync()
void sync() }
// 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) {
await db.listItems.delete(itemId)
removeLocalListItem(itemId)
await enqueue({
type: 'deleteListItem',
payload: { id: itemId },
localListItemId: itemId,
})
scheduleSync()
} }
// Remaps a client-generated temporary list id to the id assigned by the // Remaps a client-generated temporary list id to the id assigned by the
@@ -171,22 +309,43 @@ export const useListsStore = defineStore('lists', () => {
const existing = await db.lists.get(oldId) const existing = await db.lists.get(oldId)
if (existing) { if (existing) {
await db.lists.delete(oldId) await db.lists.delete(oldId)
await db.lists.put({ ...existing, id: newId, pendingSync: false }) const updated = { ...existing, id: newId, pendingSync: false }
await db.lists.put(updated)
removeLocalList(oldId)
upsertList(updated)
} }
const affectedItems = await db.listItems.where('list_id').equals(oldId).toArray() await db.listItems.where('list_id').equals(oldId).modify({ list_id: newId })
for (const item of affectedItems) { for (const item of listItems.value) {
await db.listItems.update(item.id, { list_id: newId }) if (item.list_id === oldId) item.list_id = newId
} }
const affectedQueueEntries = await db.syncQueue const affectedQueueEntries = await db.syncQueue
.filter((entry) => entry.localListId === oldId) .filter((entry) => entry.localListId === oldId)
.toArray() .toArray()
for (const entry of affectedQueueEntries) { for (const entry of affectedQueueEntries) {
const payload = entry.payload as { list_id?: string } const payload = entry.payload
const updatedPayload =
'list_id' in payload
? { ...payload, list_id: newId }
: 'id' in payload
? { ...payload, id: newId }
: payload
await db.syncQueue.update(entry.id!, { await db.syncQueue.update(entry.id!, {
localListId: newId, localListId: newId,
payload: payload?.list_id ? { ...payload, list_id: newId } : entry.payload, 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)),
},
}) })
} }
} }
@@ -201,25 +360,40 @@ export const useListsStore = defineStore('lists', () => {
const existing = await db.listItems.get(oldId) const existing = await db.listItems.get(oldId)
if (existing) { if (existing) {
await db.listItems.delete(oldId) await db.listItems.delete(oldId)
await db.listItems.put({ ...existing, id: newId, pendingSync: false }) const updated = { ...existing, id: newId, pendingSync: false }
await db.listItems.put(updated)
removeLocalListItem(oldId)
upsertListItem(updated)
} }
const affectedQueueEntries = await db.syncQueue const affectedQueueEntries = await db.syncQueue
.filter((queueEntry) => queueEntry.localListItemId === oldId) .filter((queueEntry) => queueEntry.localListItemId === oldId)
.toArray() .toArray()
for (const queueEntry of affectedQueueEntries) { for (const queueEntry of affectedQueueEntries) {
const payload = queueEntry.payload as { list_item_id?: string } const payload = queueEntry.payload
const updatedPayload =
'list_item_id' in payload
? { ...payload, list_item_id: newId }
: 'id' in payload
? { ...payload, id: newId }
: payload
await db.syncQueue.update(queueEntry.id!, { await db.syncQueue.update(queueEntry.id!, {
localListItemId: newId, localListItemId: newId,
payload: payload?.list_item_id ? { ...payload, list_item_id: newId } : queueEntry.payload, payload: updatedPayload,
}) })
} }
} }
async function markListItemSynced(itemId: string) {
await db.listItems.update(itemId, { pendingSync: false })
const existingItem = listItems.value.find((entry) => entry.id === itemId)
if (existingItem) existingItem.pendingSync = false
}
async function processSyncEntry(entry: SyncQueueEntry) { async function processSyncEntry(entry: SyncQueueEntry) {
switch (entry.type) { switch (entry.type) {
case 'createList': { case 'createList': {
const created = await createListApi(entry.payload as { name: string; user_ids?: string[] }) const created = await createListApi(entry.payload)
if (entry.localListId) { if (entry.localListId) {
await remapListId(entry.localListId, created.id) await remapListId(entry.localListId, created.id)
} }
@@ -230,43 +404,46 @@ export const useListsStore = defineStore('lists', () => {
// created item (with its own real id) in the response body, so we // created item (with its own real id) in the response body, so we
// remap our client-generated placeholder id to it instead of keeping // remap our client-generated placeholder id to it instead of keeping
// the made-up one around. // the made-up one around.
const created = await createListItemApi(entry.payload as { list_id: string; title: string }) const created = await createListItemApi(entry.payload)
if (entry.localListItemId) { if (entry.localListItemId) {
await remapListItemId(entry.localListItemId, created.id) await remapListItemId(entry.localListItemId, created.id)
} }
break break
} }
case 'updateListItem': { case 'updateListItemTitle': {
await updateListItemApi( if (!entry.localListItemId) break
entry.payload as { list_item_id: string; title?: string; is_completed?: boolean }, await updateListItemTitleApi(entry.localListItemId, entry.payload)
) await markListItemSynced(entry.localListItemId)
if (entry.localListItemId) {
await db.listItems.update(entry.localListItemId, { pendingSync: false })
}
break break
} }
case 'setListItemCompleted': { case 'setListItemCompleted': {
if (!entry.localListItemId) break if (!entry.localListItemId) break
await setListItemCompletedApi( await setListItemCompletedApi(entry.localListItemId, entry.payload)
entry.localListItemId, await markListItemSynced(entry.localListItemId)
entry.payload as { is_completed: boolean }, break
}
case 'deleteList': {
await deleteListApi(entry.payload.id)
break
}
case 'deleteListItem': {
await deleteListItemApi(entry.payload.id)
break
}
case 'orderLists': {
await orderListsApi(entry.payload)
await Promise.all(
entry.payload.list_ids.map((id) => db.lists.update(id, { pendingSync: false })),
) )
await db.listItems.update(entry.localListItemId, { pendingSync: false }) const affectedIds = new Set(entry.payload.list_ids)
break for (const list of lists.value) {
if (affectedIds.has(list.id)) list.pendingSync = false
} }
case 'addUserToList': {
await addUserToListApi(entry.payload as { list_id: string; email: string })
break
}
case 'removeUserFromList': {
await removeUserFromListApi(entry.payload as { list_id: string; email: string })
break break
} }
} }
} }
let ongoingSync: Promise<void> | null = null
async function runSync() { async function runSync() {
isSyncing.value = true isSyncing.value = true
error.value = null error.value = null
@@ -274,11 +451,20 @@ 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) {
await db.syncQueue.delete(entry.id) await db.syncQueue.delete(entry.id)
pendingCount.value = Math.max(0, pendingCount.value - 1)
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Sync failed' const message = err instanceof Error ? err.message : 'Sync failed'
@@ -296,10 +482,26 @@ export const useListsStore = defineStore('lists', () => {
} }
} finally { } finally {
isSyncing.value = false isSyncing.value = false
await refresh()
} }
} }
// Serializes sync() and pullListItems() against each other so a queued
// mutation is never pushed to the server (runSync) at the same moment a
// pull is merging fresh server state into the same rows - both touch
// Dexie and the reactive local state without any other coordination.
let operationChain: Promise<void> = Promise.resolve()
function enqueueOperation<T>(fn: () => Promise<T>): Promise<T> {
const result = operationChain.then(fn, fn)
operationChain = result.then(
() => undefined,
() => undefined,
)
return result
}
let ongoingSync: Promise<void> | null = null
// Ensures overlapping calls to sync() (e.g. one triggered automatically by // Ensures overlapping calls to sync() (e.g. one triggered automatically by
// a mutation while another is triggered by the "online" event) share the // a mutation while another is triggered by the "online" event) share the
// same in-flight run instead of silently no-oping. // same in-flight run instead of silently no-oping.
@@ -308,8 +510,9 @@ export const useListsStore = defineStore('lists', () => {
return ongoingSync return ongoingSync
} }
if (typeof navigator !== 'undefined' && !navigator.onLine) return if (typeof navigator !== 'undefined' && !navigator.onLine) return
if (!useAuthStore().isAuthenticated) return
ongoingSync = runSync().then(() => pullFromServer()) ongoingSync = enqueueOperation(() => runSync().then(() => pullFromServer()))
try { try {
await ongoingSync await ongoingSync
} finally { } finally {
@@ -329,46 +532,101 @@ export const useListsStore = defineStore('lists', () => {
if (typeof navigator !== 'undefined' && !navigator.onLine) return if (typeof navigator !== 'undefined' && !navigator.onLine) return
try { try {
const serverLists = await getListsApi() const [serverLists, localLists] = await Promise.all([getListsApi(), db.lists.toArray()])
const localById = new Map(localLists.map((list) => [list.id, list]))
const serverListIds = new Set(serverLists.map((serverList) => serverList.id))
const toPut: LocalList[] = []
for (const serverList of serverLists) { for (const serverList of serverLists) {
const existingList = await db.lists.get(serverList.id) const existingList = localById.get(serverList.id)
if (!existingList || !existingList.pendingSync) { if (!existingList || !existingList.pendingSync) {
await db.lists.put({ ...serverList, pendingSync: false }) toPut.push({
...serverList,
pendingSync: false,
clientId: existingList?.clientId ?? serverList.id,
})
} }
} }
if (toPut.length > 0) {
await db.lists.bulkPut(toPut)
}
// Lists that were already synced but are no longer reported by the
// server have been deleted there (e.g. from another device), so
// remove them locally too, along with their items. Lists that still
// have pending local changes (not yet synced, e.g. a not-yet-pushed
// "createList") are left alone since the server doesn't know about
// them yet.
const idsToDelete = localLists
.filter((list) => !list.pendingSync && !serverListIds.has(list.id))
.map((list) => list.id)
if (idsToDelete.length > 0) {
await db.lists.bulkDelete(idsToDelete)
for (const id of idsToDelete) {
await db.listItems.where('list_id').equals(id).delete()
}
}
for (const record of toPut) upsertList(record)
if (idsToDelete.length > 0) {
for (const id of idsToDelete) removeLocalList(id)
const deletedIds = new Set(idsToDelete)
listItems.value = listItems.value.filter((item) => !deletedIds.has(item.list_id))
}
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load lists from server' error.value = err instanceof Error ? err.message : 'Failed to load lists from server'
} finally {
await refresh()
} }
} }
// Pulls the authoritative items of a single list from the server and // Pulls the authoritative items of a single list from the server and
// merges them into local storage. Used by the list detail view, which is // merges them into local storage. Used by the list detail view, which is
// the only place that needs the full item set for a list. // the only place that needs the full item set for a list.
async function pullListItems(listId: string): Promise<void> { async function pullListItemsInternal(listId: string): Promise<void> {
if (typeof navigator !== 'undefined' && !navigator.onLine) return
try { try {
const serverItems = await getListItemsApi(listId) const [serverItems, localItemsForList] = await Promise.all([
getListItemsApi(listId),
db.listItems.where('list_id').equals(listId).toArray(),
])
const localById = new Map(localItemsForList.map((item) => [item.id, item]))
const serverItemIds = new Set(serverItems.map((item) => item.id))
const toPut: LocalListItem[] = []
for (const serverItem of serverItems) { for (const serverItem of serverItems) {
const existingItem = await db.listItems.get(serverItem.id) const existingItem = localById.get(serverItem.id)
if (!existingItem || !existingItem.pendingSync) { if (!existingItem || !existingItem.pendingSync) {
await db.listItems.put({ ...serverItem, pendingSync: false }) toPut.push({ ...serverItem, pendingSync: false })
} }
} }
if (toPut.length > 0) {
await db.listItems.bulkPut(toPut)
}
// Items that were already synced but are no longer reported by the
// server for this list have been deleted there, so remove them
// locally too. Items with pending local changes are left alone since
// the server doesn't know about them yet.
const idsToDelete = localItemsForList
.filter((item) => !item.pendingSync && !serverItemIds.has(item.id))
.map((item) => item.id)
if (idsToDelete.length > 0) {
await db.listItems.bulkDelete(idsToDelete)
}
for (const record of toPut) upsertListItem(record)
for (const id of idsToDelete) removeLocalListItem(id)
} catch (err) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load list items from server' error.value = err instanceof Error ? err.message : 'Failed to load list items from server'
} finally {
await refresh()
} }
} }
async function pullListItems(listId: string): Promise<void> {
if (typeof navigator !== 'undefined' && !navigator.onLine) return
return enqueueOperation(() => pullListItemsInternal(listId))
}
async function loadListItems(listId: string) { async function loadListItems(listId: string) {
if (!isLoaded.value) { await ensureLoaded()
await refresh()
}
void pullListItems(listId) void pullListItems(listId)
} }
@@ -381,15 +639,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,
deleteListItem,
reorderLists,
sync, sync,
pullFromServer, pullFromServer,
pullListItems, pullListItems,
+12
View File
@@ -11,3 +11,15 @@ export interface TokenPair {
access_token: string access_token: string
refresh_token: string refresh_token: string
} }
export interface AccessTokenClaims {
user_id: string
email: string
name: string
exp: number
}
export interface ChangePasswordPayload {
old_password: string
new_password: string
}
+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
}
+18
View File
@@ -0,0 +1,18 @@
export interface Invite {
id: string
code: string
expires_at?: string
consumed_at?: string
}
export interface PaginatedInvites {
data: Invite[]
total: number
count: number
}
export interface InviteStatusCounts {
active: number
expired: number
used: number
}
+9 -7
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 {
@@ -18,7 +19,6 @@ export interface ListItem {
export interface CreateListPayload { export interface CreateListPayload {
name: string name: string
user_ids?: string[]
} }
export interface CreateListItemPayload { export interface CreateListItemPayload {
@@ -26,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
@@ -45,3 +43,7 @@ export interface RemoveUserFromListPayload {
list_id: string list_id: string
email: string email: string
} }
export interface OrderListsPayload {
list_ids: string[]
}
+13
View File
@@ -0,0 +1,13 @@
export interface User {
id: string
email: string
name: string
created_at?: string
}
export interface CreateUserPayload {
email: string
password: string
name: string
invite_code: string
}
+5
View File
@@ -0,0 +1,5 @@
export interface VersionInfo {
version: string
commit: string
buildTime: string
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { fuzzyMatch } from '../fuzzyMatch'
describe('fuzzyMatch', () => {
it('matches an empty query against anything', () => {
expect(fuzzyMatch('', 'Groceries')).toBe(true)
expect(fuzzyMatch(' ', 'Groceries')).toBe(true)
})
it('matches an exact substring, case-insensitively', () => {
expect(fuzzyMatch('milk', 'Buy milk and eggs')).toBe(true)
expect(fuzzyMatch('MILK', 'buy milk and eggs')).toBe(true)
})
it('tolerates a couple of off characters', () => {
expect(fuzzyMatch('mikl', 'Buy milk and eggs')).toBe(true)
expect(fuzzyMatch('grocries', 'Groceries')).toBe(true)
})
it('does not match unrelated text', () => {
expect(fuzzyMatch('xyz', 'Groceries')).toBe(false)
expect(fuzzyMatch('banana', 'Groceries')).toBe(false)
})
})
+45
View File
@@ -0,0 +1,45 @@
function levenshtein(a: string, b: string): number {
const rows = a.length + 1
const cols = b.length + 1
let prev: number[] = Array.from({ length: cols }, (_, j) => j)
for (let i = 1; i < rows; i++) {
const curr: number[] = [i]
for (let j = 1; j < cols; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
curr.push(Math.min((curr[j - 1] ?? 0) + 1, (prev[j] ?? 0) + 1, (prev[j - 1] ?? 0) + cost))
}
prev = curr
}
return prev[cols - 1] ?? 0
}
function maxDistanceFor(queryLength: number): number {
if (queryLength <= 4) return 1
return 2
}
/**
* Cheap, local fuzzy substring match: true if `text` contains a run of
* characters within edit-distance of `query` (tolerating ~1-2 typos).
*/
export function fuzzyMatch(query: string, text: string): boolean {
const q = query.trim().toLowerCase()
if (!q) return true
const t = text.toLowerCase()
if (t.includes(q)) return true
const maxDistance = maxDistanceFor(q.length)
const minLen = Math.max(1, q.length - maxDistance)
const maxLen = q.length + maxDistance
for (let len = minLen; len <= maxLen; len++) {
for (let start = 0; start + len <= t.length; start++) {
if (levenshtein(q, t.substring(start, start + len)) <= maxDistance) return true
}
}
return false
}
+17
View File
@@ -0,0 +1,17 @@
export function decodeJwtPayload<T>(token: string): T | null {
const parts = token.split('.')
if (parts.length !== 3) return null
try {
const base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')
const json = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + c.charCodeAt(0).toString(16).padStart(2, '0'))
.join(''),
)
return JSON.parse(json) as T
} catch {
return null
}
}
-66
View File
@@ -1,66 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useListsStore } from '@/stores/lists'
const listsStore = useListsStore()
onMounted(() => {
listsStore.loadLists()
})
</script>
<template>
<main class="page about">
<h1>About dttmr</h1>
<p>
An offline-first progressive web app for shared lists. Every change you make is saved
instantly on this device and pushed to the server as soon as you're back online.
</p>
<section class="card info-card">
<h4>Sync status</h4>
<p class="row">
<span>Pending changes</span>
<strong>{{ listsStore.pendingCount }}</strong>
</p>
<p class="row">
<span>Syncing</span>
<strong>{{ listsStore.isSyncing ? 'Yes' : 'No' }}</strong>
</p>
</section>
</main>
</template>
<style scoped>
.about h1 {
font-size: 1.35rem;
margin-bottom: 0.75rem;
}
.about p {
color: var(--c-text-soft);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.info-card {
padding: 1rem 1.1rem;
}
.info-card h4 {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.row {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--c-text);
}
.row strong {
color: var(--c-heading);
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useListsStore } from '@/stores/lists'
import { useAuthStore } from '@/stores/auth'
import { getVersionApi } from '@/api/version'
import type { VersionInfo } from '@/types/version'
import ChangePasswordModal from '@/components/ChangePasswordModal.vue'
import InvitesPanel from '@/components/InvitesPanel.vue'
const listsStore = useListsStore()
const authStore = useAuthStore()
const showChangePassword = ref(false)
const versionInfo = ref<VersionInfo | null>(null)
const versionError = ref('')
const isLoadingVersion = ref(false)
onMounted(() => {
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>
<template>
<main class="page about">
<h1 v-if="authStore.username">Hi, {{ authStore.username }}</h1>
<h1 v-else>Account</h1>
<section class="card info-card">
<h4>Users</h4>
<p class="row">
<span>Signed in as </span>
<strong>{{ authStore.email ?? 'Unknown' }}</strong>
</p>
<button
type="button"
class="btn btn-secondary change-password-btn"
@click="showChangePassword = true"
>
Change password
</button>
</section>
<InvitesPanel />
<h1 class="section-heading">Pending changes</h1>
<section class="card info-card">
<h4>Sync status</h4>
<p class="row">
<span>Pending changes</span>
<strong class="mono-num">{{ listsStore.pendingCount }}</strong>
</p>
<p class="row">
<span>Syncing</span>
<strong>{{ listsStore.isSyncing ? 'Yes' : 'No' }}</strong>
</p>
</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" />
</main>
</template>
<style scoped>
@media (min-width: 768px) {
.page.about {
max-width: 780px;
}
}
.about h1 {
font-size: 1.35rem;
margin-bottom: 0.75rem;
}
.section-heading {
margin-top: 1.5rem;
}
.about p {
color: var(--c-text-soft);
font-size: 0.9rem;
margin-bottom: 1.25rem;
}
.info-card {
padding: 1rem 1.1rem;
}
.info-card + .info-card {
margin-top: 1rem;
}
.info-card h4 {
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.row {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
margin-bottom: 0.4rem;
color: var(--c-text);
}
.row strong {
color: var(--c-heading);
}
.change-password-btn {
width: auto;
margin-top: 0.6rem;
padding: 0.5rem 1rem;
}
.loading-text {
font-size: 0.85rem;
color: var(--c-text-soft);
margin: 0;
}
</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>
+234 -10
View File
@@ -1,37 +1,97 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useListsStore } from '@/stores/lists' import { useListsStore } from '@/stores/lists'
import type { LocalListItem } from '@/database/db'
import { fuzzyMatch } from '@/utils/fuzzyMatch'
import ListItemRow from '@/components/ListItemRow.vue' import ListItemRow from '@/components/ListItemRow.vue'
import DeleteListModal from '@/components/DeleteListModal.vue'
import { useDismissableMenu } from '@/composables/useDismissableMenu'
const props = defineProps<{ id: string }>() const props = defineProps<{ id: string }>()
const router = useRouter() const router = useRouter()
const listsStore = useListsStore() const listsStore = useListsStore()
const newItemTitle = ref('') // Doubles as the "add item" text box and the live filter query on the list below.
const itemInput = ref('')
const isAddingItem = ref(false) const isAddingItem = ref(false)
const itemError = ref('') const itemError = ref('')
const showDeleteModal = ref(false)
const {
isOpen: isMenuOpen,
containerRef: menuContainerRef,
toggle: toggleMenu,
} = useDismissableMenu()
onMounted(() => { onMounted(() => {
listsStore.loadLists() listsStore.loadLists()
listsStore.loadListItems(props.id) listsStore.loadListItems(props.id)
}) })
// Vue Router reuses this component instance when navigating between two
// list-detail routes, so the initial onMounted load alone would leave a
// newly-navigated-to list's items unfetched.
watch(
() => props.id,
(newId) => {
listsStore.loadListItems(newId)
},
)
const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id)) const list = computed(() => listsStore.lists.find((entry) => entry.id === props.id))
const items = computed(() => listsStore.itemsForList(props.id)) const items = computed(() => listsStore.itemsForList(props.id))
const pendingItems = computed(() => items.value.filter((item) => !item.is_completed))
const completedItems = computed(() => items.value.filter((item) => item.is_completed)) function byModifiedDesc(a: LocalListItem, b: LocalListItem) {
return (b.modified_at ?? '').localeCompare(a.modified_at ?? '')
}
// Filtering is local and fuzzy only, for display purposes — it never touches
// the server or the sync queue.
const filterQuery = computed(() => itemInput.value.trim())
const filteredItems = computed(() => {
const query = filterQuery.value
if (!query) return items.value
return items.value.filter((item) => fuzzyMatch(query, item.title))
})
const pendingItems = computed(() =>
filteredItems.value.filter((item) => !item.is_completed).sort(byModifiedDesc),
)
const completedItems = computed(() =>
filteredItems.value.filter((item) => item.is_completed).sort(byModifiedDesc),
)
function handleOpenDelete() {
isMenuOpen.value = false
showDeleteModal.value = true
}
function handleCloseDelete() {
showDeleteModal.value = false
}
async function handleConfirmDelete() {
if (!list.value) return
showDeleteModal.value = false
try {
await listsStore.deleteList(list.value.id)
router.push('/')
} catch (err) {
itemError.value = err instanceof Error ? err.message : 'Failed to delete list'
}
}
async function handleAddItem() { async function handleAddItem() {
const title = newItemTitle.value.trim() const title = filterQuery.value
if (!title) return if (!title) return
itemError.value = '' itemError.value = ''
isAddingItem.value = true isAddingItem.value = true
try { try {
await listsStore.createListItem(props.id, title) await listsStore.createListItem(props.id, title)
newItemTitle.value = '' itemInput.value = ''
} catch (err) { } catch (err) {
itemError.value = err instanceof Error ? err.message : 'Failed to add item' itemError.value = err instanceof Error ? err.message : 'Failed to add item'
} finally { } finally {
@@ -45,13 +105,60 @@ async function handleAddItem() {
<button type="button" class="back-link" @click="router.push('/')"> Lists</button> <button type="button" class="back-link" @click="router.push('/')"> Lists</button>
<template v-if="list"> <template v-if="list">
<div class="list-header">
<h1>{{ list.name }}</h1> <h1>{{ list.name }}</h1>
<div ref="menuContainerRef" class="menu-container">
<button
type="button"
class="menu-trigger-btn"
aria-label="List options"
aria-haspopup="true"
:aria-expanded="isMenuOpen"
title="More options"
@click="toggleMenu"
>
<svg class="dots-icon" viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
<circle cx="5" cy="12" r="2" />
<circle cx="12" cy="12" r="2" />
<circle cx="19" cy="12" r="2" />
</svg>
</button>
<div v-if="isMenuOpen" class="submenu-dropdown card" role="menu">
<button
type="button"
class="submenu-item submenu-item-danger"
role="menuitem"
@click="handleOpenDelete"
>
<svg
class="submenu-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
<span>Delete list</span>
</button>
</div>
</div>
</div>
<p v-if="list.pendingSync" class="pending-note">This list hasn't synced to the server yet.</p> <p v-if="list.pendingSync" class="pending-note">This list hasn't synced to the server yet.</p>
<form class="new-item-form" @submit.prevent="handleAddItem"> <form class="new-item-form" @submit.prevent="handleAddItem">
<div class="field"> <div class="field">
<input <input
v-model="newItemTitle" v-model="itemInput"
type="text" type="text"
placeholder="Add an item…" placeholder="Add an item…"
:disabled="isAddingItem" :disabled="isAddingItem"
@@ -60,7 +167,7 @@ async function handleAddItem() {
<button <button
type="submit" type="submit"
class="btn btn-primary add-btn" class="btn btn-primary add-btn"
:disabled="isAddingItem || !newItemTitle.trim()" :disabled="isAddingItem || !filterQuery"
> >
+ +
</button> </button>
@@ -82,6 +189,14 @@ async function handleAddItem() {
</section> </section>
<p v-if="items.length === 0" class="empty-hint">No items yet add your first one above.</p> <p v-if="items.length === 0" class="empty-hint">No items yet add your first one above.</p>
<p v-else-if="filteredItems.length === 0" class="empty-hint">No items match "{{ filterQuery }}".</p>
<DeleteListModal
v-if="showDeleteModal && list"
:list="list"
@close="handleCloseDelete"
@confirm="handleConfirmDelete"
/>
</template> </template>
<p v-else class="empty-hint">List not found on this device.</p> <p v-else class="empty-hint">List not found on this device.</p>
@@ -99,11 +214,22 @@ async function handleAddItem() {
cursor: pointer; cursor: pointer;
} }
h1 { .list-header {
font-size: 1.35rem; display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.list-header h1 {
font-size: 1.35rem;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pending-note { .pending-note {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--c-warning); color: var(--c-warning);
@@ -150,4 +276,102 @@ h1 {
text-align: center; text-align: center;
padding: 1.5rem 0; padding: 1.5rem 0;
} }
.menu-container {
position: relative;
display: flex;
align-items: center;
}
.menu-trigger-btn {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
cursor: pointer;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out,
border-color 0.15s ease-in-out;
}
.menu-trigger-btn:hover,
.menu-trigger-btn[aria-expanded='true'] {
background-color: var(--c-bg-mute);
color: var(--c-heading);
border-color: var(--c-border);
}
.dots-icon {
display: block;
}
.submenu-dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 30;
min-width: 140px;
background-color: var(--c-bg-elevated);
border: 1px solid var(--c-border-hover);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 0.35rem;
animation: dropdownIn 0.12s ease-out;
}
.submenu-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.5rem 0.65rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--c-heading);
font-size: 0.85rem;
cursor: pointer;
text-align: left;
transition:
background-color 0.15s ease-in-out,
color 0.15s ease-in-out;
}
.submenu-item:hover {
background-color: var(--c-bg-mute);
color: var(--c-accent-strong);
}
.submenu-item-danger {
color: var(--c-danger);
}
.submenu-item-danger:hover {
background-color: var(--c-danger-bg);
color: var(--c-danger);
}
.submenu-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style> </style>
+82 -5
View File
@@ -1,9 +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 { useListDragReorder } from '@/composables/useListDragReorder'
const listsStore = useListsStore() const listsStore = useListsStore()
@@ -11,6 +13,27 @@ const newListName = ref('')
const isCreating = ref(false) const isCreating = ref(false)
const createError = ref('') const createError = ref('')
const sharingList = ref<LocalList | null>(null) const sharingList = 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()
@@ -24,6 +47,25 @@ function handleCloseShare() {
sharingList.value = null sharingList.value = null
} }
function handleOpenDelete(list: LocalList) {
deletingList.value = list
}
function handleCloseDelete() {
deletingList.value = null
}
async function handleConfirmDelete() {
if (!deletingList.value) return
const listId = deletingList.value.id
deletingList.value = null
try {
await listsStore.deleteList(listId)
} catch (err) {
createError.value = err instanceof Error ? err.message : 'Failed to delete list'
}
}
async function handleCreateList() { async function handleCreateList() {
const name = newListName.value.trim() const name = newListName.value.trim()
if (!name) return if (!name) return
@@ -65,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)" /> v-for="list in displayedLists"
:key="list.clientId ?? 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>
@@ -77,6 +132,12 @@ async function handleCreateList() {
</div> </div>
<ShareListModal v-if="sharingList" :list="sharingList" @close="handleCloseShare" /> <ShareListModal v-if="sharingList" :list="sharingList" @close="handleCloseShare" />
<DeleteListModal
v-if="deletingList"
:list="deletingList"
@close="handleCloseDelete"
@confirm="handleConfirmDelete"
/>
</main> </main>
</template> </template>
@@ -118,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;
+19 -8
View File
@@ -46,7 +46,8 @@ async function handleSubmit() {
<div class="login-container"> <div class="login-container">
<div class="login-card card"> <div class="login-card card">
<div class="brand-mark"> <div class="brand-mark">
<span class="brand-dot"></span> <span class="brand-seal"></span>
<span class="brand-word">dttmr</span>
</div> </div>
<h2>Login</h2> <h2>Login</h2>
<p class="subtitle">Enter your credentials to access your account</p> <p class="subtitle">Enter your credentials to access your account</p>
@@ -119,16 +120,26 @@ async function handleSubmit() {
.brand-mark { .brand-mark {
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 1rem; gap: 0.55rem;
margin-bottom: 1.5rem;
} }
.brand-dot { .brand-seal {
width: 40px; width: 30px;
height: 40px; height: 30px;
border-radius: 12px; border-radius: 6px;
background: linear-gradient(135deg, var(--c-accent-strong), var(--c-accent-soft)); background: var(--c-accent);
box-shadow: 0 0 24px var(--c-accent-bg); transform: rotate(-8deg);
box-shadow: 0 0 0 3px var(--c-bg-soft) inset;
}
.brand-word {
font-family: var(--font-stamp);
font-size: 1.3rem;
letter-spacing: 0.04em;
color: var(--c-heading);
} }
h2 { h2 {
+229
View File
@@ -0,0 +1,229 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { createUserApi } from '@/api/users'
import { useAuthStore } from '@/stores/auth'
import { useListsStore } from '@/stores/lists'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const listsStore = useListsStore()
const inviteCode = computed(() => {
const invite = route.query.invite
return typeof invite === 'string' ? invite : ''
})
const name = ref('')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const isSubmitting = ref(false)
const error = ref('')
async function handleSubmit() {
error.value = ''
if (password.value.length < 8) {
error.value = 'Password must be at least 8 characters.'
return
}
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match.'
return
}
isSubmitting.value = true
try {
await createUserApi({
name: name.value,
email: email.value,
password: password.value,
invite_code: inviteCode.value,
})
await authStore.login({ email: email.value, password: password.value })
listsStore.sync().catch(() => {})
router.push('/')
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create account'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div class="register-container">
<div class="register-card card">
<div class="brand-mark">
<span class="brand-seal"></span>
<span class="brand-word">dttmr</span>
</div>
<h2>Create your account</h2>
<div v-if="authStore.isAuthenticated" class="already-logged-in">
<p>You're already logged in as {{ authStore.email }}.</p>
<div class="actions">
<button type="button" class="btn btn-secondary" @click="router.push('/')">
Go to Home
</button>
<button type="button" class="btn btn-danger" @click="authStore.logout()">Log Out</button>
</div>
</div>
<div v-else-if="!inviteCode" class="invalid-invite">
<p>This page requires a valid invite link. Ask whoever invited you for a new one.</p>
</div>
<form v-else @submit.prevent="handleSubmit">
<p class="subtitle">You've been invited to join dttmr. Set up your account below.</p>
<div v-if="error" class="error-banner banner banner-error">{{ error }}</div>
<div class="field">
<label for="name">Name</label>
<input
id="name"
v-model="name"
type="text"
placeholder="Jane Doe"
autocomplete="name"
required
:disabled="isSubmitting"
/>
</div>
<div class="field">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="name@example.com"
autocomplete="email"
required
:disabled="isSubmitting"
/>
</div>
<div class="field">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="At least 8 characters"
autocomplete="new-password"
required
:disabled="isSubmitting"
/>
</div>
<div class="field">
<label for="confirm-password">Confirm password</label>
<input
id="confirm-password"
v-model="confirmPassword"
type="password"
placeholder="Repeat your password"
autocomplete="new-password"
required
:disabled="isSubmitting"
/>
</div>
<button type="submit" class="btn btn-primary" :disabled="isSubmitting">
<span v-if="isSubmitting">Creating account...</span>
<span v-else>Create account</span>
</button>
</form>
</div>
</div>
</template>
<style scoped>
.register-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1.5rem;
}
.register-card {
width: 100%;
max-width: 400px;
padding: 2rem 1.75rem;
}
.brand-mark {
display: flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
margin-bottom: 1.5rem;
}
.brand-seal {
width: 30px;
height: 30px;
border-radius: 6px;
background: var(--c-accent);
transform: rotate(-8deg);
box-shadow: 0 0 0 3px var(--c-bg-soft) inset;
}
.brand-word {
font-family: var(--font-stamp);
font-size: 1.3rem;
letter-spacing: 0.04em;
color: var(--c-heading);
}
h2 {
margin: 0 0 0.4rem;
font-size: 1.4rem;
text-align: center;
}
.subtitle {
margin: 0 0 1.5rem;
font-size: 0.85rem;
color: var(--c-text-soft);
text-align: center;
}
.error-banner {
margin-bottom: 1.1rem;
}
.already-logged-in,
.invalid-invite {
text-align: center;
}
.already-logged-in p,
.invalid-invite p {
margin-bottom: 1.25rem;
color: var(--c-text);
}
.actions {
display: flex;
gap: 0.75rem;
justify-content: center;
}
form .field {
margin-bottom: 1.1rem;
}
@media (min-width: 768px) {
.register-card {
padding: 2.5rem 2.25rem;
}
}
</style>
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ListDetailView from '../ListDetailView.vue'
import { useListsStore } from '@/stores/lists'
import type { LocalList } from '@/database/db'
const pushMock = vi.fn<(to: string) => void>()
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRouter: () => ({
push: pushMock,
}),
}
})
describe('ListDetailView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
pushMock.mockClear()
const listsStore = useListsStore()
vi.spyOn(listsStore, 'loadLists').mockImplementation(async () => {})
vi.spyOn(listsStore, 'loadListItems').mockImplementation(async () => {})
})
it('renders list items without the share card', () => {
const listsStore = useListsStore()
const sampleList: LocalList = {
id: 'list-1',
name: 'Groceries',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
listsStore.lists = [sampleList]
const wrapper = mount(ListDetailView, {
props: {
id: 'list-1',
},
})
expect(wrapper.text()).toContain('Groceries')
expect(wrapper.text()).not.toContain('Share this list')
expect(wrapper.find('.share-card').exists()).toBe(false)
})
it('opens confirmation modal and deletes list upon confirmation, then redirects to /', async () => {
const listsStore = useListsStore()
const deleteSpy = vi.spyOn(listsStore, 'deleteList').mockResolvedValue()
const sampleList: LocalList = {
id: 'list-1',
name: 'Groceries',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
listsStore.lists = [sampleList]
const wrapper = mount(ListDetailView, {
props: {
id: 'list-1',
},
})
const headerMenuBtn = wrapper.find('.list-header .menu-trigger-btn')
expect(headerMenuBtn.exists()).toBe(true)
// 1st click: open list options menu
await headerMenuBtn.trigger('click')
const deleteBtn = wrapper.find('.list-header .submenu-item-danger')
expect(deleteBtn.exists()).toBe(true)
expect(deleteBtn.text()).toContain('Delete list')
// 2nd click: opens confirmation modal
await deleteBtn.trigger('click')
const modal = wrapper.findComponent({ name: 'DeleteListModal' })
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('Delete "Groceries"?')
expect(deleteSpy).not.toHaveBeenCalled()
expect(pushMock).not.toHaveBeenCalled()
// Confirm deletion in modal
await modal.find('.confirm-delete-btn').trigger('click')
expect(deleteSpy).toHaveBeenCalledWith('list-1')
expect(pushMock).toHaveBeenCalledWith('/')
})
it('cancels list deletion when cancel is clicked in confirmation modal', async () => {
const listsStore = useListsStore()
const deleteSpy = vi.spyOn(listsStore, 'deleteList').mockResolvedValue()
const sampleList: LocalList = {
id: 'list-1',
name: 'Groceries',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
listsStore.lists = [sampleList]
const wrapper = mount(ListDetailView, {
props: {
id: 'list-1',
},
})
const headerMenuBtn = wrapper.find('.list-header .menu-trigger-btn')
await headerMenuBtn.trigger('click')
await wrapper.find('.list-header .submenu-item-danger').trigger('click')
const modal = wrapper.findComponent({ name: 'DeleteListModal' })
expect(modal.exists()).toBe(true)
await modal.find('.cancel-btn').trigger('click')
expect(deleteSpy).not.toHaveBeenCalled()
expect(pushMock).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'DeleteListModal' }).exists()).toBe(false)
})
it('allows deleting list items via menu and confirmation modal', async () => {
const listsStore = useListsStore()
const deleteItemSpy = vi.spyOn(listsStore, 'deleteListItem').mockResolvedValue()
const sampleList: LocalList = {
id: 'list-1',
name: 'Groceries',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
listsStore.lists = [sampleList]
listsStore.listItems = [
{
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',
},
]
const wrapper = mount(ListDetailView, {
props: {
id: 'list-1',
},
})
const itemRow = wrapper.findComponent({ name: 'ListItemRow' })
expect(itemRow.exists()).toBe(true)
// 1st click: open item menu
await itemRow.find('.menu-trigger-btn').trigger('click')
const deleteItemBtn = itemRow.find('.submenu-item-danger')
expect(deleteItemBtn.exists()).toBe(true)
// 2nd click: opens confirmation modal
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')
})
})
+126
View File
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ListsView from '../ListsView.vue'
import { useListsStore } from '@/stores/lists'
import type { LocalList } from '@/database/db'
describe('ListsView', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
const listsStore = useListsStore()
vi.spyOn(listsStore, 'loadLists').mockImplementation(async () => {})
})
it('renders lists and opens share modal when list card emits share', async () => {
const listsStore = useListsStore()
const sampleList: LocalList = {
id: 'list-1',
name: 'Shopping',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
listsStore.lists = [sampleList]
const wrapper = mount(ListsView, {
global: {
stubs: {
RouterLink: {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
},
},
})
expect(wrapper.text()).toContain('Shopping')
expect(wrapper.findComponent({ name: 'ShareListModal' }).exists()).toBe(false)
// Trigger share from ListCard
await wrapper.find('.menu-trigger-btn').trigger('click')
await wrapper.find('.submenu-item').trigger('click')
expect(wrapper.findComponent({ name: 'ShareListModal' }).exists()).toBe(true)
expect(wrapper.find('#share-modal-title').text()).toBe('Share "Shopping"')
})
it('opens confirmation modal and deletes list upon confirmation', async () => {
const listsStore = useListsStore()
const deleteSpy = vi.spyOn(listsStore, 'deleteList').mockResolvedValue()
const sampleList: LocalList = {
id: 'list-1',
name: 'Shopping',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
listsStore.lists = [sampleList]
const wrapper = mount(ListsView, {
global: {
stubs: {
RouterLink: {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
},
},
})
expect(wrapper.findComponent({ name: 'DeleteListModal' }).exists()).toBe(false)
// Click 1: open menu
await wrapper.find('.menu-trigger-btn').trigger('click')
// Click 2: click delete list option in menu
await wrapper.find('.submenu-item-danger').trigger('click')
// Modal should now be open
const modal = wrapper.findComponent({ name: 'DeleteListModal' })
expect(modal.exists()).toBe(true)
expect(modal.text()).toContain('Delete "Shopping"?')
expect(deleteSpy).not.toHaveBeenCalled()
// Confirm deletion in modal
await modal.find('.confirm-delete-btn').trigger('click')
expect(deleteSpy).toHaveBeenCalledWith('list-1')
expect(wrapper.findComponent({ name: 'DeleteListModal' }).exists()).toBe(false)
})
it('cancels list deletion when cancel is clicked in confirmation modal', async () => {
const listsStore = useListsStore()
const deleteSpy = vi.spyOn(listsStore, 'deleteList').mockResolvedValue()
const sampleList: LocalList = {
id: 'list-1',
name: 'Shopping',
created_at: '2026-08-21T00:00:00.000Z',
modified_at: '2026-08-21T00:00:00.000Z',
}
listsStore.lists = [sampleList]
const wrapper = mount(ListsView, {
global: {
stubs: {
RouterLink: {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
},
},
})
// Click 1: open menu
await wrapper.find('.menu-trigger-btn').trigger('click')
// Click 2: click delete list option in menu
await wrapper.find('.submenu-item-danger').trigger('click')
const modal = wrapper.findComponent({ name: 'DeleteListModal' })
expect(modal.exists()).toBe(true)
// Cancel in modal
await modal.find('.cancel-btn').trigger('click')
expect(deleteSpy).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'DeleteListModal' }).exists()).toBe(false)
})
})
+6 -2
View File
@@ -5,14 +5,18 @@ import LoginView from '../LoginView.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const mockPush = vi.fn<(to: string) => void>() const mockPush = vi.fn<(to: string) => void>()
vi.mock('vue-router', () => ({ vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRouter: () => ({ useRouter: () => ({
push: mockPush, push: mockPush,
}), }),
useRoute: () => ({ useRoute: () => ({
query: {}, query: {},
}), }),
})) }
})
describe('LoginView', () => { describe('LoginView', () => {
beforeEach(() => { beforeEach(() => {
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import RegisterView from '../RegisterView.vue'
import { useAuthStore } from '@/stores/auth'
import { useListsStore } from '@/stores/lists'
import * as usersApi from '@/api/users'
const mockPush = vi.fn<(to: string) => void>()
let routeQuery: Record<string, string> = { invite: 'invite-code-123' }
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
return {
...actual,
useRouter: () => ({
push: mockPush,
}),
useRoute: () => ({
query: routeQuery,
}),
}
})
describe('RegisterView', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
mockPush.mockClear()
routeQuery = { invite: 'invite-code-123' }
})
it('shows an invalid-invite message when there is no invite code in the URL', () => {
routeQuery = {}
const wrapper = mount(RegisterView)
expect(wrapper.find('.invalid-invite').exists()).toBe(true)
expect(wrapper.find('form').exists()).toBe(false)
})
it('renders the registration form when an invite code is present', () => {
const wrapper = mount(RegisterView)
expect(wrapper.find('h2').text()).toBe('Create your account')
expect(wrapper.find('input#name').exists()).toBe(true)
expect(wrapper.find('input#email').exists()).toBe(true)
expect(wrapper.find('input#password').exists()).toBe(true)
expect(wrapper.find('input#confirm-password').exists()).toBe(true)
})
it('shows logged in status and hides the form if already authenticated', () => {
const authStore = useAuthStore()
authStore.setTokens({ access_token: 'active-token', refresh_token: 'active-refresh' })
const wrapper = mount(RegisterView)
expect(wrapper.find('.already-logged-in').exists()).toBe(true)
expect(wrapper.find('form').exists()).toBe(false)
})
it('rejects passwords that are too short', async () => {
const createSpy = vi.spyOn(usersApi, 'createUserApi')
const wrapper = mount(RegisterView)
await wrapper.find('#name').setValue('Jane Doe')
await wrapper.find('#email').setValue('jane@example.com')
await wrapper.find('#password').setValue('short')
await wrapper.find('#confirm-password').setValue('short')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.find('.error-banner').text()).toContain('at least 8 characters')
expect(createSpy).not.toHaveBeenCalled()
})
it('rejects mismatched passwords', async () => {
const createSpy = vi.spyOn(usersApi, 'createUserApi')
const wrapper = mount(RegisterView)
await wrapper.find('#name').setValue('Jane Doe')
await wrapper.find('#email').setValue('jane@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('#confirm-password').setValue('password456')
await wrapper.find('form').trigger('submit.prevent')
expect(wrapper.find('.error-banner').text()).toContain('do not match')
expect(createSpy).not.toHaveBeenCalled()
})
it('creates the account, logs in, syncs, and redirects home on success', async () => {
const createSpy = vi.spyOn(usersApi, 'createUserApi').mockResolvedValueOnce({
id: 'user-1',
email: 'jane@example.com',
name: 'Jane Doe',
})
const authStore = useAuthStore()
const loginSpy = vi.spyOn(authStore, 'login').mockResolvedValueOnce({
access_token: 'access-123',
refresh_token: 'refresh-456',
})
const listsStore = useListsStore()
const syncSpy = vi.spyOn(listsStore, 'sync').mockResolvedValueOnce()
const wrapper = mount(RegisterView)
await wrapper.find('#name').setValue('Jane Doe')
await wrapper.find('#email').setValue('jane@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('#confirm-password').setValue('password123')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(createSpy).toHaveBeenCalledWith({
name: 'Jane Doe',
email: 'jane@example.com',
password: 'password123',
invite_code: 'invite-code-123',
})
expect(loginSpy).toHaveBeenCalledWith({
email: 'jane@example.com',
password: 'password123',
})
expect(syncSpy).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/')
})
it('shows an error banner when account creation fails', async () => {
vi.spyOn(usersApi, 'createUserApi').mockRejectedValueOnce(new Error('Invalid invite code'))
const wrapper = mount(RegisterView)
await wrapper.find('#name').setValue('Jane Doe')
await wrapper.find('#email').setValue('jane@example.com')
await wrapper.find('#password').setValue('password123')
await wrapper.find('#confirm-password').setValue('password123')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.find('.error-banner').text()).toContain('Invalid invite code')
})
})
function flushPromises() {
return new Promise((resolve) => setTimeout(resolve))
}
+2 -2
View File
@@ -23,8 +23,8 @@ export default defineConfig({
description: 'dittmar.dev frontend app', description: 'dittmar.dev frontend app',
start_url: '/', start_url: '/',
scope: '/', scope: '/',
theme_color: '#0a0e1a', theme_color: '#12161d',
background_color: '#0a0e1a', background_color: '#12161d',
display: 'standalone', display: 'standalone',
icons: [ icons: [
{ {