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
51 changes: 34 additions & 17 deletions docs/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ The browser may send only these event names:
- `live_activity_opened`
- `settlement_recorded`
- `currency_selected`
- `expense_input_manual_selected`
- `expense_input_ai_text_selected`
- `expense_input_ai_voice_selected`
- `ai_text_requested`
- `ai_text_ready`
- `ai_text_clarification`
Expand Down Expand Up @@ -47,7 +50,9 @@ Opening the app records its initial surface. Successful product actions are meas

`summary_export_clicked` records when someone chooses **Export full summary**, 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, Live URL, QR code, or generated image data.

AI entry uses a separate four-step funnel for `text` and `voice`. `requested` is recorded immediately before each real Edge Function request, including model follow-ups. `ready`, `clarification`, or `failed` records the result of that request. Deterministic local clarification, microphone permission errors, unsupported browsers, and empty recordings do not count as AI requests because they never reach the service. These events contain only the event name, surface, locale, and anonymous session hash. Prompts, clarification answers, audio, model output, draft counts, latency, member data, and expense data are never sent to analytics.
Expense-input tab events measure exploration before any AI request. They are recorded only when someone deliberately switches to manual, AI text, or AI voice; rendering the default manual tab and clicking an already-selected tab do not count. `expense_input_ai_text_selected` and `expense_input_ai_voice_selected` therefore show anonymous sessions that explored each AI entry mode even if they never submitted a prompt or recording. `expense_input_manual_selected` shows sessions that returned to manual entry after exploring another mode.

AI entry then uses a separate four-step service funnel for `text` and `voice`. `requested` is recorded immediately before each real Edge Function request, including model follow-ups. `ready`, `clarification`, or `failed` records the result of that request. Deterministic local clarification, microphone permission errors, unsupported browsers, and empty recordings do not count as AI requests because they never reach the service. These events contain only the event name, surface, locale, and anonymous session hash. Prompts, clarification answers, audio, model output, draft counts, latency, member data, and expense data are never sent to analytics.

## Reports in Supabase

Expand Down Expand Up @@ -174,34 +179,46 @@ with usage as (
date_trunc('day', now() at time zone 'America/New_York')
at time zone 'America/New_York'
)
)::bigint as today,
count(*) filter (where occurred_at >= now() - interval '7 days')::bigint as last_7_days,
count(*) filter (where occurred_at >= now() - interval '30 days')::bigint as last_30_days
)::bigint as today_events,
count(distinct session_hash) filter (
where occurred_at >= (
date_trunc('day', now() at time zone 'America/New_York')
at time zone 'America/New_York'
)
)::bigint as today_sessions,
count(*) filter (where occurred_at >= now() - interval '7 days')::bigint as last_7_days_events,
count(distinct session_hash) filter (where occurred_at >= now() - interval '7 days')::bigint as last_7_days_sessions,
count(*) filter (where occurred_at >= now() - interval '30 days')::bigint as last_30_days_events,
count(distinct session_hash) filter (where occurred_at >= now() - interval '30 days')::bigint as last_30_days_sessions
from private.analytics_events
where event_name like 'ai\_%' escape '\'
or event_name like 'expense\_input\_%\_selected' escape '\'
group by event_name
), rows(event_name, sort_order, label) as (
), rows(event_name, sort_order, label, metric) as (
values
('ai_text_requested', 1, 'Text requests'),
('ai_voice_requested', 2, 'Voice requests'),
('ai_text_ready', 3, 'Text drafts ready'),
('ai_voice_ready', 4, 'Voice drafts ready'),
('ai_text_clarification', 5, 'Text clarifications'),
('ai_voice_clarification', 6, 'Voice clarifications'),
('ai_text_failed', 7, 'Text failures'),
('ai_voice_failed', 8, 'Voice failures')
('expense_input_ai_text_selected', 1, 'Text tab explorers (sessions)', 'sessions'),
('expense_input_ai_voice_selected', 2, 'Voice tab explorers (sessions)', 'sessions'),
('expense_input_manual_selected', 3, 'Returned to manual (sessions)', 'sessions'),
('ai_text_requested', 4, 'Text requests', 'events'),
('ai_voice_requested', 5, 'Voice requests', 'events'),
('ai_text_ready', 6, 'Text drafts ready', 'events'),
('ai_voice_ready', 7, 'Voice drafts ready', 'events'),
('ai_text_clarification', 8, 'Text clarifications', 'events'),
('ai_voice_clarification', 9, 'Voice clarifications', 'events'),
('ai_text_failed', 10, 'Text failures', 'events'),
('ai_voice_failed', 11, 'Voice failures', 'events')
)
select
rows.label as "AI outcome",
coalesce(usage.today, 0) as "Today",
coalesce(usage.last_7_days, 0) as "Last 7 days",
coalesce(usage.last_30_days, 0) as "Last 30 days"
case rows.metric when 'sessions' then coalesce(usage.today_sessions, 0) else coalesce(usage.today_events, 0) end as "Today",
case rows.metric when 'sessions' then coalesce(usage.last_7_days_sessions, 0) else coalesce(usage.last_7_days_events, 0) end as "Last 7 days",
case rows.metric when 'sessions' then coalesce(usage.last_30_days_sessions, 0) else coalesce(usage.last_30_days_events, 0) end as "Last 30 days"
from rows
left join usage using (event_name)
order by rows.sort_order;
```

`Text requests` and `Voice requests` are the provider-facing frequency metrics. Compare each with its ready, clarification, and failure rows to spot reliability changes. One conversational entry may make several requests when the model asks follow-up questions, so this view intentionally measures AI service usage rather than completed expenses.
The first two rows answer how many anonymous browser sessions explored AI text or voice, including people who stopped before submitting anything. `Text requests` and `Voice requests` are provider-facing frequency metrics. Compare explorers with requests to see discovery-to-attempt conversion, then compare requests with ready, clarification, and failure rows to spot reliability changes. One conversational entry may make several requests when the model asks follow-up questions.

For a chronological hourly usage chart, query the UTC hourly aggregate and convert the label to the reporting timezone. This example uses Eastern Time; replace `America/New_York` with `Asia/Shanghai` for China time:

Expand Down
2 changes: 1 addition & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ The workflow can also be started manually from `main` with **Run workflow**.
- Choose **Duplicate and edit** while offline and confirm the new independent local copy is editable without changing the Live activity.
- Choose **End live sharing**, confirm the old URL becomes unavailable in another browser, and verify both browsers retain their last synced read-only recovery copy with **Continue locally**.
- Create one local activity and one live activity, then confirm their allowlisted events appear separately in `private.analytics_daily` and `private.analytics_hourly`, and their resolved UI locale appears in `private.analytics_locale_daily`, without URL or activity fields.
- Create one text AI draft and one voice AI draft, then confirm the requested and ready events appear in the **SplitBill - AI Entry Usage** Home report without prompts, audio, or expense fields.
- Open the AI text and AI voice tabs, create one draft with each mode, then confirm the explorer-session, requested, and ready rows appear in the **SplitBill - AI Entry Usage** Home report without prompts, audio, or expense fields.
- Run Supabase Security Advisor and Performance Advisor after the first migration.
- Confirm the migration list is synchronized before the next release with `supabase migration list`.

Expand Down
2 changes: 2 additions & 0 deletions e2e-ai/ai-expense-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ test('turns a description into a reviewable draft before the user saves it', asy
await expect(page.locator('.expense-amount b')).toHaveText('$36.00')
expect(aiRequests).toBe(1)
expect(analyticsRequests.map(request => request.p_event_name)).toEqual(expect.arrayContaining([
'expense_input_ai_text_selected',
'ai_text_requested',
'ai_text_ready',
]))
Expand Down Expand Up @@ -458,6 +459,7 @@ test('turns a short voice recording into a reviewable expense batch', async ({ p
await expect(page.getByText('Voice taxi', { exact: true })).toBeVisible()
expect(aiRequests).toBe(1)
expect(analyticsRequests.map(request => request.p_event_name)).toEqual(expect.arrayContaining([
'expense_input_ai_voice_selected',
'ai_voice_requested',
'ai_voice_ready',
]))
Expand Down
2 changes: 2 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,7 @@ describe('complete app workflows', () => {
.toEqual([
['expense_added', 'local', 'en'],
])
expect(analyticsClient.track).toHaveBeenCalledWith('expense_input_ai_text_selected', 'local', 'en')
expect(analyticsClient.track.mock.calls.filter(([event]) => event.startsWith('ai_')))
.toEqual([
['ai_text_requested', 'local', 'en'],
Expand Down Expand Up @@ -1268,6 +1269,7 @@ describe('complete app workflows', () => {
expect(analyticsClient.track.mock.calls.filter(([event]) => event === 'expense_added')).toEqual([
['expense_added', 'live', 'en'],
])
expect(analyticsClient.track).toHaveBeenCalledWith('expense_input_ai_text_selected', 'live', 'en')
expect(analyticsClient.track.mock.calls.filter(([event]) => event.startsWith('ai_'))).toEqual([
['ai_text_requested', 'live', 'en'],
['ai_text_ready', 'live', 'en'],
Expand Down
11 changes: 9 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { CheckCircle2 } from 'lucide-react'
import { lazy, Suspense, useEffect, useMemo, useState } from 'react'
import type { AnalyticsClient, AnalyticsSurface } from './analytics'
import type { AnalyticsClient, AnalyticsEvent, AnalyticsSurface } from './analytics'
import { FreshStart, Sidebar, Topbar } from './components/AppShell'
import { ConfirmDialog } from './components/ConfirmDialog'
import { removeActivityIdentity, selectActivityIdentity } from './data/activityIdentity'
Expand All @@ -14,7 +14,7 @@ import type { ActivityGroup, Expense, Settlement } from './domain/models'
import type { AiExpenseClient } from './features/aiExpense/aiExpenseApi'
import { withAiExpenseAnalytics } from './features/aiExpense/aiExpenseAnalytics'
import { GroupDashboard } from './features/activity/ActivityDashboard'
import { AddFriendModal, CreateGroupModal, ExpenseModal, SettleUpModal } from './features/activity/ActivityModals'
import { AddFriendModal, CreateGroupModal, ExpenseModal, SettleUpModal, type ExpenseInputTab } from './features/activity/ActivityModals'
import {
hasSeenLatestChangelog,
LATEST_CHANGELOG_ID,
Expand Down Expand Up @@ -75,6 +75,12 @@ const ChangelogModal = lazy(() => import('./features/changelog/ChangelogModal').
const FeedbackModal = lazy(() => import('./features/feedback/FeedbackModal').then(module => ({ default: module.FeedbackModal })))
const RatingPrompt = lazy(() => import('./features/feedback/RatingPrompt').then(module => ({ default: module.RatingPrompt })))

const EXPENSE_INPUT_TAB_EVENTS: Record<ExpenseInputTab, AnalyticsEvent> = {
manual: 'expense_input_manual_selected',
'ai-text': 'expense_input_ai_text_selected',
'ai-voice': 'expense_input_ai_voice_selected',
}

function LocalizedApp({ aiExpenseClient = null, analyticsClient = null, feedbackClient = null, liveActivityClient }: AppProps = {}) {
const [state, setState] = usePersistedState()
const [identity, setIdentity] = useIdentity()
Expand Down Expand Up @@ -596,6 +602,7 @@ function LocalizedApp({ aiExpenseClient = null, analyticsClient = null, feedback
aiExpenseClient={trackedAiExpenseClient}
currentMemberId={activeMemberId}
onCurrentMemberChange={changeActiveMember}
onEntryTabSelect={tab => analyticsClient?.track(EXPENSE_INPUT_TAB_EVENTS[tab], analyticsSurface, locale)}
onClose={closeExpenseModal}
onSave={editingExpense ? updateExpense : addExpense}
onSaveMany={addExpenses}
Expand Down
27 changes: 27 additions & 0 deletions src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,33 @@ describe('first-party analytics', () => {
})
})

it.each([
'expense_input_manual_selected',
'expense_input_ai_text_selected',
'expense_input_ai_voice_selected',
] as const)('records the selected expense-input tab without expense data: %s', event => {
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
const client = createConfiguredAnalyticsClient({
VITE_SUPABASE_URL: 'https://project.supabase.co',
VITE_SUPABASE_PUBLISHABLE_KEY: 'publishable-key',
}, {
enabled: true,
fetcher,
storage: null,
crypto: deterministicCrypto(8),
})!

client.track(event, 'local', 'en')

expect(JSON.parse(fetcher.mock.calls[0][1].body as string)).toEqual({
p_event_name: event,
p_surface: 'local',
p_session_token: '08'.repeat(16),
p_locale: 'en',
p_currency: null,
})
})

it('uses browser fetch, crypto, and session storage by default', () => {
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
Object.defineProperty(window, 'fetch', { configurable: true, value: fetcher })
Expand Down
3 changes: 3 additions & 0 deletions src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export const ANALYTICS_EVENTS = [
'live_activity_opened',
'settlement_recorded',
'currency_selected',
'expense_input_manual_selected',
'expense_input_ai_text_selected',
'expense_input_ai_voice_selected',
'ai_text_requested',
'ai_text_ready',
'ai_text_clarification',
Expand Down
23 changes: 22 additions & 1 deletion src/features/activity/ActivityModals.ai.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,40 @@ function renderModal(
onSave = vi.fn(),
expense?: Expense,
onSaveMany = vi.fn(),
onEntryTabSelect = vi.fn(),
) {
return {
onSave,
onSaveMany,
onEntryTabSelect,
...render(
<LocalizationProvider>
<ExpenseModal group={group} members={members} expense={expense} aiExpenseClient={client} onClose={vi.fn()} onSave={onSave} onSaveMany={onSaveMany} />
<ExpenseModal group={group} members={members} expense={expense} aiExpenseClient={client} onEntryTabSelect={onEntryTabSelect} onClose={vi.fn()} onSave={onSave} onSaveMany={onSaveMany} />
</LocalizationProvider>,
),
}
}

describe('AI-assisted expense modal', () => {
it('reports only deliberate expense-input tab changes', async () => {
const user = userEvent.setup()
const { onEntryTabSelect } = renderModal({ parseBatch: vi.fn() })

await user.click(screen.getByRole('tab', { name: 'Enter manually' }))
expect(onEntryTabSelect).not.toHaveBeenCalled()

await user.click(screen.getByRole('tab', { name: 'Describe with AI' }))
await user.click(screen.getByRole('tab', { name: 'Describe with AI' }))
await user.click(screen.getByRole('tab', { name: 'Speak' }))
await user.click(screen.getByRole('tab', { name: 'Enter manually' }))

expect(onEntryTabSelect.mock.calls).toEqual([
['ai-text'],
['ai-voice'],
['manual'],
])
})

it('prefills an equal draft and still requires the normal save action', async () => {
const user = userEvent.setup()
const parseBatch = vi.fn().mockResolvedValue({
Expand Down
21 changes: 16 additions & 5 deletions src/features/activity/ActivityModals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,18 @@ export function SettleUpModal({ group, settlement, onClose, onSave, saving = fal
)
}

export function ExpenseModal({ group, members, expense, aiExpenseClient = null, currentMemberId = 'me', onCurrentMemberChange, onClose, onSave, onSaveMany, saving = false }: {
export type ExpenseInputTab = 'manual' | 'ai-text' | 'ai-voice'

type ExpenseEntryMode = ExpenseInputTab | 'ai-batch'

export function ExpenseModal({ group, members, expense, aiExpenseClient = null, currentMemberId = 'me', onCurrentMemberChange, onEntryTabSelect, onClose, onSave, onSaveMany, saving = false }: {
group: ActivityGroup
members: Member[]
expense?: Expense
aiExpenseClient?: Pick<AiExpenseClient, 'parseBatch'> | null
currentMemberId?: string | null
onCurrentMemberChange?: (memberId: string) => void
onEntryTabSelect?: (tab: ExpenseInputTab) => void
onClose: () => void
onSave: (expense: Expense) => void
onSaveMany?: (expenses: Expense[]) => void
Expand All @@ -142,7 +147,7 @@ export function ExpenseModal({ group, members, expense, aiExpenseClient = null,
const [method, setMethod] = useState<SplitMethod>(expense?.splitMethod ?? 'equal')
const aiAvailable = Boolean(aiExpenseClient && !expense)
const aiIdentityReady = Boolean(currentMemberId && members.some(member => member.id === currentMemberId))
const [entryMode, setEntryMode] = useState<'manual' | 'ai-text' | 'ai-voice' | 'ai-batch'>('manual')
const [entryMode, setEntryMode] = useState<ExpenseEntryMode>('manual')
const [aiDraftApplied, setAiDraftApplied] = useState(false)
const [aiBatchDrafts, setAiBatchDrafts] = useState<AiExpenseReadyDraft[]>([])
const [editingBatchIndex, setEditingBatchIndex] = useState<number | null>(null)
Expand Down Expand Up @@ -176,6 +181,12 @@ export function ExpenseModal({ group, members, expense, aiExpenseClient = null,
: [...current, memberId])
}

const selectEntryTab = (tab: ExpenseInputTab) => {
if (tab === entryMode) return
onEntryTabSelect?.(tab)
setEntryMode(tab)
}

const loadAiDraft = (draft: AiExpenseReadyDraft) => {
const exactSharesById = new Map(draft.exactSharesCents.map(share => [share.memberId, share.amountCents]))
setTitle(draft.title)
Expand Down Expand Up @@ -259,9 +270,9 @@ export function ExpenseModal({ group, members, expense, aiExpenseClient = null,
<ModalShell eyebrow={group.name} title={t(expense ? 'expense.editTitle' : 'expense.addTitle')} onClose={onClose}>
{aiAvailable && aiBatchDrafts.length === 0 ? (
<div className="expense-entry-tabs" role="tablist" aria-label={t('expense.entryMethod')}>
<button type="button" role="tab" aria-selected={entryMode === 'manual'} className={entryMode === 'manual' ? 'active' : ''} onClick={() => setEntryMode('manual')}><Pencil size={15} />{t('expense.manualTab')}</button>
<button type="button" role="tab" aria-selected={entryMode === 'ai-text'} className={entryMode === 'ai-text' ? 'active' : ''} onClick={() => setEntryMode('ai-text')}><Sparkles size={15} />{t('expense.aiTab')}</button>
<button type="button" role="tab" aria-selected={entryMode === 'ai-voice'} className={entryMode === 'ai-voice' ? 'active' : ''} onClick={() => setEntryMode('ai-voice')}><Mic size={15} />{t('expense.voiceTab')}</button>
<button type="button" role="tab" aria-selected={entryMode === 'manual'} className={entryMode === 'manual' ? 'active' : ''} onClick={() => selectEntryTab('manual')}><Pencil size={15} />{t('expense.manualTab')}</button>
<button type="button" role="tab" aria-selected={entryMode === 'ai-text'} className={entryMode === 'ai-text' ? 'active' : ''} onClick={() => selectEntryTab('ai-text')}><Sparkles size={15} />{t('expense.aiTab')}</button>
<button type="button" role="tab" aria-selected={entryMode === 'ai-voice'} className={entryMode === 'ai-voice' ? 'active' : ''} onClick={() => selectEntryTab('ai-voice')}><Mic size={15} />{t('expense.voiceTab')}</button>
</div>
) : null}
{aiAvailable && entryMode !== 'manual' && onCurrentMemberChange ? (
Expand Down
Loading