diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3b1f058..3db4822 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,19 +2,18 @@ name: Deploy to GitHub Pages on: push: - branches: [main] + branches-ignore: + - gh-pages permissions: - contents: read - pages: write - id-token: write + contents: write concurrency: - group: pages + group: pages-${{ github.ref == 'refs/heads/main' && 'main' || 'preview' }} cancel-in-progress: true jobs: - build: + deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -29,22 +28,36 @@ jobs: working-directory: pwa run: npm ci - - name: Build + - name: Build (production) + if: github.ref == 'refs/heads/main' working-directory: pwa run: npm run build - - uses: actions/configure-pages@v5 + - name: Build (preview) + if: github.ref != 'refs/heads/main' + working-directory: pwa + env: + VITE_BASE_PATH: /tag-notes/preview/ + VITE_APP_VARIANT: preview + run: npm run build - - uses: actions/upload-pages-artifact@v4 + # Both jobs publish to the same gh-pages branch: production deploys to + # its root, every other branch overwrites the shared /preview/ subpath. + # keep_files keeps each deploy from wiping the other's directory. + - name: Deploy (production) + if: github.ref == 'refs/heads/main' + uses: peaceiris/actions-gh-pages@v4 with: - path: pwa/dist - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: pwa/dist + destination_dir: . + keep_files: true + + - name: Deploy (preview) + if: github.ref != 'refs/heads/main' + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: pwa/dist + destination_dir: preview + keep_files: true diff --git a/pwa/index.html b/pwa/index.html index 08ed754..a502938 100644 --- a/pwa/index.html +++ b/pwa/index.html @@ -11,7 +11,6 @@ - diff --git a/pwa/public/manifest.webmanifest b/pwa/public/manifest.webmanifest deleted file mode 100644 index 9cbf0ff..0000000 --- a/pwa/public/manifest.webmanifest +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "Tag Notes", - "short_name": "Notes", - "description": "Capture freeform notes with embedded titles and tags.", - "start_url": "/tag-notes/", - "scope": "/tag-notes/", - "display": "standalone", - "theme_color": "#2f6f4f", - "background_color": "#fefefe", - "icons": [ - { - "src": "/tag-notes/pwa-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/tag-notes/pwa-512x512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "/tag-notes/maskable-icon-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/pwa/src/App.tsx b/pwa/src/App.tsx index c0e7251..112518d 100644 --- a/pwa/src/App.tsx +++ b/pwa/src/App.tsx @@ -20,7 +20,7 @@ function Shell(props: { children?: JSX.Element }) { function App() { return ( - + } /> diff --git a/pwa/src/lib/foods/store.test.ts b/pwa/src/lib/foods/store.test.ts index 8c1ca98..6e6ac4a 100644 --- a/pwa/src/lib/foods/store.test.ts +++ b/pwa/src/lib/foods/store.test.ts @@ -65,6 +65,47 @@ describe('createIndexedDbFoodStore', () => { expect(entries.map((e) => e.id)).toEqual([b.id, a.id]); }); + it('update() overwrites fields while preserving id and createdAt', async () => { + const entry = await store.create({ + name: 'A', + quantity: 1, + protein: 1, + calories: 1, + carbs: 1, + date: '2026-06-27', + }); + + const updated = await store.update(entry.id, { + name: 'A (revised)', + quantity: 2, + protein: 5, + calories: 50, + carbs: 10, + date: '2026-06-27', + }); + + expect(updated.id).toBe(entry.id); + expect(updated.createdAt).toBe(entry.createdAt); + expect(updated.name).toBe('A (revised)'); + expect(updated.quantity).toBe(2); + + const [stored] = await store.list(); + expect(stored).toEqual(updated); + }); + + it('update() throws for an unknown id', async () => { + await expect( + store.update('missing-id', { + name: 'A', + quantity: 1, + protein: 1, + calories: 1, + carbs: 1, + date: '2026-06-27', + }), + ).rejects.toThrow(); + }); + it('remove() deletes a food entry', async () => { const entry = await store.create({ name: 'A', diff --git a/pwa/src/lib/foods/store.ts b/pwa/src/lib/foods/store.ts index 5ee0695..8b18e17 100644 --- a/pwa/src/lib/foods/store.ts +++ b/pwa/src/lib/foods/store.ts @@ -50,6 +50,24 @@ export function createIndexedDbFoodStore(): FoodStore { return record; } + async function update(id: string, entry: Omit): Promise { + const db = await getDb(); + const tx = db.transaction(FOODS_STORE, 'readwrite'); + const store = tx.objectStore(FOODS_STORE); + + const existing = await requestToPromise(store.get(id)); + if (!existing) { + tx.abort(); + throw new Error(`Food entry not found: ${id}`); + } + + const updated: FoodEntry = { ...entry, id: existing.id, createdAt: existing.createdAt }; + store.put(updated); + await transactionDone(tx); + + return updated; + } + async function remove(id: string): Promise { const db = await getDb(); const tx = db.transaction(FOODS_STORE, 'readwrite'); @@ -74,6 +92,7 @@ export function createIndexedDbFoodStore(): FoodStore { list, listByDate, create, + update, remove, listRecentDistinct, }; diff --git a/pwa/src/lib/foods/types.ts b/pwa/src/lib/foods/types.ts index 0f2128c..160ecc2 100644 --- a/pwa/src/lib/foods/types.ts +++ b/pwa/src/lib/foods/types.ts @@ -13,6 +13,7 @@ export interface FoodStore { list(): Promise; listByDate(date: string): Promise; create(entry: Omit): Promise; + update(id: string, entry: Omit): Promise; remove(id: string): Promise; /** Most recently logged entry for each distinct food name, newest first. */ listRecentDistinct(): Promise; diff --git a/pwa/src/routes/Food.module.css b/pwa/src/routes/Food.module.css index e99c739..6789bc2 100644 --- a/pwa/src/routes/Food.module.css +++ b/pwa/src/routes/Food.module.css @@ -107,6 +107,16 @@ border-radius: 8px; } +.cancelButton { + padding: 0.6rem 1rem; + font-size: 1rem; + font-weight: 600; + color: #007aff; + background: #fff; + border: 1px solid #d1d1d6; + border-radius: 8px; +} + .sectionTitle { font-size: 0.85rem; font-weight: 600; @@ -149,11 +159,20 @@ border-radius: 8px; } +.rowEditing { + background: #e5f1ff; +} + .rowMain { flex: 1; display: flex; flex-direction: column; + align-items: flex-start; gap: 0.2rem; + padding: 0; + background: none; + border: none; + text-align: left; } .rowName { diff --git a/pwa/src/routes/Food.tsx b/pwa/src/routes/Food.tsx index 5879acc..86ffdd7 100644 --- a/pwa/src/routes/Food.tsx +++ b/pwa/src/routes/Food.tsx @@ -20,6 +20,7 @@ function Food() { const [recentFoods, setRecentFoods] = createSignal([]); const [targets, setTargets] = createSignal(getFoodTargets()); const [form, setForm] = createSignal(EMPTY_FORM); + const [editingId, setEditingId] = createSignal(); const refresh = async () => { const [today, recent] = await Promise.all([ @@ -54,6 +55,7 @@ function Food() { }; const handleSelectRecent = (entry: FoodEntry) => { + setEditingId(undefined); setForm({ name: entry.name, protein: String(entry.protein), @@ -63,26 +65,51 @@ function Food() { }); }; + const handleEdit = (entry: FoodEntry) => { + setEditingId(entry.id); + setForm({ + name: entry.name, + protein: String(entry.protein), + calories: String(entry.calories), + carbs: String(entry.carbs), + quantity: String(entry.quantity), + }); + }; + + const handleCancelEdit = () => { + setEditingId(undefined); + setForm(EMPTY_FORM); + }; + const handleSubmit = async (event: Event) => { event.preventDefault(); const current = form(); const name = current.name.trim(); if (!name) return; - await foodStore.create({ + const record = { name, quantity: Number(current.quantity) || 1, protein: Number(current.protein) || 0, calories: Number(current.calories) || 0, carbs: Number(current.carbs) || 0, date: todayDateKey(), - }); + }; + + const id = editingId(); + if (id) { + await foodStore.update(id, record); + } else { + await foodStore.create(record); + } + setEditingId(undefined); setForm(EMPTY_FORM); await refresh(); }; const handleRemove = async (id: string) => { + if (editingId() === id) handleCancelEdit(); await foodStore.remove(id); await refresh(); }; @@ -156,8 +183,13 @@ function Food() { onInput={(event) => updateField('quantity', event.currentTarget.value)} /> + + + @@ -187,8 +219,8 @@ function Food() {
{(entry) => ( -
-
+
+
+