Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ The complete contract is documented in [TESTING.md](TESTING.md).

Every pull request is type-checked, linted, tested, and built by GitHub Actions. A successful `main` release applies pending Supabase migrations, deploys the AI Edge Function, and then publishes the configured frontend artifact to GitHub Pages. Follow [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for the one-time environment setup and release procedure.

The conversational expense flow was validated through an isolated preview before production. Its model strategy, privacy boundary, rate limits, synchronization behavior, test checklist, and preview deployment runbook live in [docs/AI_EXPENSE_PREVIEW.md](docs/AI_EXPENSE_PREVIEW.md).
The conversational expense flow keeps manual entry as the default and uses separate client/server kill switches. Its model strategy, privacy boundary, rate limits, synchronization behavior, test checklist, and isolated preview runbook for future AI changes live in [docs/AI_EXPENSE_PREVIEW.md](docs/AI_EXPENSE_PREVIEW.md).

## Contributing

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ Local activities and identities are stored in browser `localStorage`. Live activ

Anyone with a complete live URL can read and edit that activity. There are no accounts, participant-level permissions, token revocation, or audit trail in this release. Treat leaked URLs as compromised and avoid regulated or highly sensitive data.

Anonymous RPCs use a secret-peppered request identifier for throttling, and expected invalid input consumes rate-limit budget without being stored. Shared snapshot URLs are decoded with strict compressed-input and expanded-output limits. The static host cannot set `frame-ancestors` headers, so the app also refuses to render interactive controls while embedded in another page.
Anonymous RPCs use a secret-peppered request identifier for throttling, and expected invalid input consumes rate-limit budget without being stored. Live activity payloads are validated against bounded input and snapshot sizes before storage. The static host cannot set `frame-ancestors` headers, so the app also refuses to render interactive controls while embedded in another page.

Reports involving capability leakage, RPC privilege escalation, rate-limit bypasses, browser storage exposure, exported summaries, dependency vulnerabilities, or deployment configuration are in scope. Never include a real live activity URL, database password, service-role key, or Supabase access token in a report.
30 changes: 15 additions & 15 deletions docs/AI_EXPENSE_PREVIEW.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Conversational expense entry preview
# Conversational expense entry

This experiment keeps manual expense entry as the default and adds two optional shortcuts: a typed description or a voice recording. Either shortcut can describe one expense or several expenses at once. Tally preserves their order, presents every draft for review, and saves nothing until the user confirms.
Conversational expense entry is available in production as an optional shortcut alongside the default manual form. A typed description or voice recording can describe one expense or several expenses at once. Tally preserves their order, presents every draft for review, and saves nothing until the user confirms.

## Deployment boundary
## Release boundary

Before production release, the experiment on `codex/ai-expense-entry` uses:
The feature was validated on an isolated frontend and Supabase preview before its production release. Future model, prompt, quota, or audio changes should use the same boundary:

- a separate frontend URL and origin, so preview local storage and PWA caches cannot affect production;
- a separate Supabase preview project, so migrations, rate limits, logs, and Edge Function secrets cannot affect production;
- a preview-only OpenRouter API key with a low account limit;
- both feature switches described below.
- both feature switches described below, so either deployment can disable AI entry without affecting manual entry.

Production enables the feature only after the preview gates pass, the production Edge Function secrets are configured, and `VITE_AI_EXPENSE_ENABLED=true` is added to the GitHub `production` environment. Without that exact client value—or with `AI_EXPENSE_ENABLED=false` on the serverthe stable manual expense flow remains available while AI entry is disabled.
Production enables the feature only when the Edge Function secrets are configured, `VITE_AI_EXPENSE_ENABLED=true` is present in the GitHub `production` environment, and `AI_EXPENSE_ENABLED=true` is set on the server. Without both exact flag values, the stable manual expense flow remains available while AI entry is disabled.

## Why this design is safe to trial

Expand Down Expand Up @@ -53,15 +53,15 @@ Use the local Supabase URL and publishable key printed by `npm run backend:start
VITE_AI_EXPENSE_ENABLED=true
```

Copy `supabase/functions/.env.example` to the ignored `supabase/functions/.env.local`, set a preview OpenRouter key, then serve the function:
Copy `supabase/functions/.env.example` to the ignored `supabase/functions/.env.local`, set a development OpenRouter key, then serve the function:

```bash
npx supabase functions serve parse-expense --env-file supabase/functions/.env.local
```

The Edge Function also requires `AI_EXPENSE_ENABLED=true`; either switch disables the feature independently.

Run the complete gates before sharing the preview:
Run the complete gates before sharing a local or hosted preview:

```bash
npm run typecheck
Expand All @@ -71,11 +71,11 @@ npm run test:backend
npm run test:e2e
```

## Separate preview deployment
## Isolated preview for future AI changes

### 1. Backend

Create a dedicated Supabase preview project. Apply this branchs migrations and deploy only `parse-expense` to that project. Configure these Edge Function secrets in the preview project:
Create a dedicated Supabase preview project. Apply the candidate branch's migrations and deploy only `parse-expense` to that project. Configure these Edge Function secrets in the preview project:

```text
AI_EXPENSE_ENABLED=true
Expand All @@ -91,7 +91,7 @@ If voice recording stops normally but the app reports that its AI budget was rea

### 2. Frontend

Create a separate Cloudflare Pages project connected to the same GitHub repository, with `codex/ai-expense-entry` as that projects production branch.
Create or reuse a separate Cloudflare Pages project connected to the same GitHub repository, with the candidate AI branch as that project's production branch.

Use:

Expand All @@ -111,13 +111,13 @@ This yields a stable `*.pages.dev` preview origin while the existing GitHub Page
- Frontend stop: set `VITE_AI_EXPENSE_ENABLED=false` and rebuild the preview.
- Full preview rollback: redeploy the previous preview commit. In production, use the independent server and client kill switches documented in [DEPLOYMENT.md](DEPLOYMENT.md).

## Keeping the experiment current
## Keeping a future AI experiment current

Regular customer fixes continue to land on `main`. Before each preview deployment, bring them into the experiment and rerun every gate:
Regular customer fixes continue to land on `main`. Before each preview deployment, bring them into the candidate branch and rerun every gate:

```bash
git fetch origin main
git switch codex/ai-expense-entry
git switch <candidate-ai-branch>
git merge --no-edit origin/main
npm run typecheck
npm run lint
Expand All @@ -126,7 +126,7 @@ npm run test:backend
npm run test:e2e
```

Resolve any conflict in favor of the current `main` behavior first, then reapply the smallest AI integration. Keep the preview as a pull request so GitHub continuously shows whether it is mergeable and whether CI remains green. Merge only after model quality, cost, privacy copy, user feedback, production secrets, and every automated gate are acceptable.
Resolve any conflict in favor of the current `main` behavior first, then reapply the smallest AI integration. Keep the candidate as a pull request so GitHub continuously shows whether it is mergeable and whether CI remains green. Merge only after model quality, cost, privacy copy, user feedback, production secrets, and every automated gate are acceptable.

## Trial checklist

Expand Down
2 changes: 2 additions & 0 deletions docs/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ Opening the app records its initial surface. Successful product actions are meas

`friend_added` records one event after a successful friend-add action, including activity creation when at least one initial friend is supplied. Adding several friends in one submission still records one event. Failed Live saves do not count, and the request never includes friend names, IDs, or a friend count.

`expense_added` records one event after a successful add action. Saving an AI-generated batch still records one event, matching the single confirmation and atomic state update rather than sending one analytics request per expense. The request never includes the batch size or any expense data.

`live_share_clicked` is also an intentional interaction event. It records when someone chooses **Start live activity**, before the backend request begins. Compare it with `live_activity_created` to distinguish sharing intent from successful Live activity creation. It contains no activity or link data.

`summary_export_clicked` records when someone chooses **Share balances only**, before PNG generation or any share, download, or clipboard fallback begins. It measures export intent rather than successful delivery and contains no activity name, participants, expenses, balances, or generated image data.
Expand Down
38 changes: 20 additions & 18 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,13 @@
"vitest": "^4.1.10"
},
"overrides": {
"brace-expansion": "5.0.8",
"brace-expansion": "5.0.9",
"ejs": "6.0.1",
"fast-uri": "3.1.4",
"fast-uri": "3.1.5",
"nanoid": "3.3.15",
"sharp": "0.35.3"
"postcss": "8.5.25",
"sharp": "0.35.3",
"undici": "7.29.0"
},
"type": "module"
}
5 changes: 2 additions & 3 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { StrictMode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App'
import type { AnalyticsClient } from './analytics'
import { Avatar, FreshStart, ModalShell, Sidebar, Topbar } from './components/AppShell'
import { Avatar, FreshStart, Sidebar, Topbar } from './components/AppShell'
import { ModalShell } from './components/Dialog'
import { ACTIVITY_IDENTITY_KEY } from './data/activityIdentity'
import { IDENTITY_KEY } from './data/identity'
import { EMPTY_STATE, loadState, parseState, saveState, STORAGE_KEY } from './data/storage'
Expand Down Expand Up @@ -879,7 +880,6 @@ describe('complete app workflows', () => {
expect(analyticsClient.track.mock.calls.filter(([event]) => event === 'expense_added'))
.toEqual([
['expense_added', 'local', 'en'],
['expense_added', 'local', 'en'],
])
expect(analyticsClient.track.mock.calls.filter(([event]) => event.startsWith('ai_')))
.toEqual([
Expand Down Expand Up @@ -1185,7 +1185,6 @@ describe('complete app workflows', () => {
expect(screen.getByRole('status')).toHaveTextContent('2 expenses were added to the live activity.')
expect(analyticsClient.track.mock.calls.filter(([event]) => event === 'expense_added')).toEqual([
['expense_added', 'live', 'en'],
['expense_added', 'live', 'en'],
])
expect(analyticsClient.track.mock.calls.filter(([event]) => event.startsWith('ai_'))).toEqual([
['ai_text_requested', 'live', 'en'],
Expand Down
4 changes: 2 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,13 @@ function LocalizedApp({ aiExpenseClient = null, analyticsClient = null, liveActi
JSON.stringify(['add-expenses', expenses.map(item => [item.title, item.amount, item.payerId, item.splitMethod, item.shares])]),
)
if (saved) {
expenses.forEach(() => analyticsClient?.track('expense_added', 'live', locale))
analyticsClient?.track('expense_added', 'live', locale)
closeExpenseModal()
}
return
}
setState(current => addLocalExpenses(current, expenses))
expenses.forEach(() => analyticsClient?.track('expense_added', 'local', locale))
analyticsClient?.track('expense_added', 'local', locale)
setActivityFeedback({ groupId: expenses[0].groupId, message: t('feedback.addedExpenses', { count: expenses.length }) })
closeExpenseModal()
}
Expand Down
2 changes: 0 additions & 2 deletions src/components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import type { ActivityGroup, Member } from '../domain/models'
import { useLocalization } from '../i18n/LocalizationContext'
import { Button, IconButton } from './Button'

export { ModalShell } from './Dialog'

const EMPTY_LIVE_ACTIVITY_CODES: Record<string, string> = {}

export function Avatar({ member, size = 'md' }: { member: Member; size?: 'sm' | 'md' | 'lg' }) {
Expand Down
3 changes: 2 additions & 1 deletion src/features/activity/ActivityModals.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, type FormEvent } from 'react'
import { ArrowRight, CircleDollarSign, Mic, Pencil, Sparkles, Users } from 'lucide-react'
import { Avatar, ModalShell } from '../../components/AppShell'
import { Avatar } from '../../components/AppShell'
import { ModalShell } from '../../components/Dialog'
import { Button } from '../../components/Button'
import { SelectMenu, type SelectMenuOption } from '../../components/SelectMenu'
import { activityCurrency, currencyLabel, currencySymbol, defaultCurrencyForLocale, SUPPORTED_CURRENCIES, type CurrencyCode } from '../../domain/currency'
Expand Down
2 changes: 1 addition & 1 deletion src/features/changelog/ChangelogModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
SlidersHorizontal,
type LucideIcon,
} from 'lucide-react'
import { ModalShell } from '../../components/AppShell'
import { ModalShell } from '../../components/Dialog'
import { Button } from '../../components/Button'
import { useLocalization } from '../../i18n/LocalizationContext'
import {
Expand Down
2 changes: 1 addition & 1 deletion src/features/identity/IdentityModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { Globe2, UserRound } from 'lucide-react'
import { ModalShell } from '../../components/AppShell'
import { ModalShell } from '../../components/Dialog'
import { Button } from '../../components/Button'
import { useLocalization } from '../../i18n/LocalizationContext'
import { LanguageControl } from './LanguageControl'
Expand Down
2 changes: 1 addition & 1 deletion src/features/sharing/JoinActivityModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { ClipboardPaste, Link2, Smartphone } from 'lucide-react'
import { ModalShell } from '../../components/AppShell'
import { ModalShell } from '../../components/Dialog'
import { Button } from '../../components/Button'
import { useLocalization } from '../../i18n/LocalizationContext'
import { copyLink } from './shareLink'
Expand Down
2 changes: 1 addition & 1 deletion src/features/sharing/LiveActivityIdentityModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react'
import { UserRoundCheck } from 'lucide-react'
import { ModalShell } from '../../components/AppShell'
import { ModalShell } from '../../components/Dialog'
import { Button } from '../../components/Button'
import { SelectMenu } from '../../components/SelectMenu'
import type { Member } from '../../domain/models'
Expand Down
2 changes: 1 addition & 1 deletion src/features/sharing/LiveActivityQrModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Copy, ScanQrCode, Share2, ShieldCheck } from 'lucide-react'
import { QRCodeSVG } from 'qrcode.react'
import { ModalShell } from '../../components/AppShell'
import { ModalShell } from '../../components/Dialog'
import { Button } from '../../components/Button'
import { useLocalization } from '../../i18n/LocalizationContext'

Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/parse-expense/deno.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2",
"@supabase/server": "npm:@supabase/server@^1",
"@supabase/functions-js": "jsr:@supabase/functions-js@2.110.8",
"@supabase/server": "npm:@supabase/server@1.4.1",
"zod": "npm:zod@4.4.3"
}
}