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 @@ -17,7 +17,7 @@ Production uses privacy-preserving first-party analytics through Supabase for bo
Tally supports two deliberately different sharing choices:

- **Start live activity** creates a short capability URL for one canonical activity in Supabase. Trusted recipients with the complete link can load and edit the same revision-checked data from different browsers. The Live invite can be copied, opened from a QR code, or sent through the device share sheet. Any holder of the complete link can explicitly end that capability; previously opened browsers keep their last recovery copy.
- **Share balances only** exports a PNG summary with totals and suggested payments without granting access to the activity.
- **Export full summary** creates a PNG with every expense and payment, totals, and suggested payments. Active Live activities also include a QR invite.
- If Safari opens a Live link outside the installed PWA, **Join activity** safely transfers the copied link into the existing Tally app session.

Live links keep their secret edit token in the fragment; Supabase stores only its SHA-256 hash. Every browser that successfully opens a Live link keeps the latest full activity state as a recovery copy, while Supabase remains the source of truth for as long as that Live session is available. Tally never loads third-party analytics. See [the live sharing architecture](docs/LIVE_SHARING_EXPERIMENT.md) and [production deployment guide](docs/DEPLOYMENT.md).
Expand Down
2 changes: 1 addition & 1 deletion docs/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Opening the app records its initial surface. Successful product actions are meas

`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.
`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.

Expand Down
20 changes: 17 additions & 3 deletions e2e/activity-lifecycle.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test, type BrowserContext, type Page, type Route } from '@playwright/test'
import { readFile } from 'node:fs/promises'

type AnalyticsPayload = {
p_event_name: string
Expand Down Expand Up @@ -339,7 +340,7 @@ test('keeps share and add expense together in the mobile action row', async ({ p
const shareDialog = page.getByRole('dialog', { name: 'Share activity' })
await expect(shareDialog).toBeVisible()
await expect(shareDialog.getByRole('button', { name: 'Start live activity' })).toBeVisible()
await expect(shareDialog.getByRole('button', { name: /^Share balances only/ })).toBeVisible()
await expect(shareDialog.getByRole('button', { name: /^Export full summary/ })).toBeVisible()
await expect(shareDialog.getByText(/snapshot/i)).toHaveCount(0)
await page.keyboard.press('Escape')
await expect(shareDialog).toHaveCount(0)
Expand Down Expand Up @@ -453,7 +454,7 @@ test('tracks local outcomes without sending local activity data or loading third
const summaryDownload = page.waitForEvent('download')
await page
.getByRole('dialog', { name: 'Share activity' })
.getByRole('button', { name: /^Share balances only/ })
.getByRole('button', { name: /^Export full summary/ })
.click()
await summaryDownload

Expand Down Expand Up @@ -635,7 +636,7 @@ test('shares one editable backend activity across isolated browser sessions', as
await page.getByRole('button', { name: 'Share', exact: true }).click()
const shareDialog = page.getByRole('dialog', { name: 'Share activity' })
await expect(shareDialog.getByRole('button', { name: 'Start live activity' })).toBeVisible()
await expect(shareDialog.getByRole('button', { name: /^Share balances only/ })).toBeVisible()
await expect(shareDialog.getByRole('button', { name: /^Export full summary/ })).toBeVisible()
await expect(shareDialog.getByText(/snapshot/i)).toHaveCount(0)
await shareDialog.getByRole('button', { name: 'Start live activity' }).click()
await expect(page.getByRole('dialog', { name: 'Scan to join Shared cabin' })).toBeVisible()
Expand All @@ -661,6 +662,19 @@ test('shares one editable backend activity across isolated browser sessions', as
await expect(page.getByText('Groceries', { exact: true })).toBeVisible()
await expect(page.getByText('Live · revision 2')).toBeVisible()

await page.getByRole('button', { name: 'Share', exact: true }).click()
const liveExportDialog = page.getByRole('dialog', { name: 'Share activity' })
await expect(liveExportDialog.getByText('Includes every expense, payment, and balance, plus the Live QR invite.')).toBeVisible()
const liveSummaryDownloadPromise = page.waitForEvent('download')
await liveExportDialog.getByRole('button', { name: /^Export full summary/ }).click()
const liveSummaryDownload = await liveSummaryDownloadPromise
const liveSummaryPath = await liveSummaryDownload.path()
expect(liveSummaryPath).not.toBeNull()
const liveSummaryPng = await readFile(liveSummaryPath!)
expect(liveSummaryPng.subarray(1, 4).toString('ascii')).toBe('PNG')
expect(liveSummaryPng.readUInt32BE(16)).toBe(1080)
expect(liveSummaryPng.readUInt32BE(20)).toBeGreaterThanOrEqual(1350)

await expect(page.getByRole('button', { name: 'Back to my activities' })).toHaveCount(0)
await expect(page.getByText(`Live · ${code}`, { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'Open Shared cabin activity' }).locator('..')).toHaveClass(/is-selected/)
Expand Down
87 changes: 81 additions & 6 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { liveActivityErrorMessage } from './features/liveSharing/useLiveActivity
import { LIVE_ACTIVITY_BOOKMARKS_KEY } from './features/liveSharing/useLiveActivityBookmarks'
import { LIVE_ACTIVITY_MIRRORS_KEY, createLiveActivityMirror } from './features/liveSharing/useLiveActivityMirrors'
import { LIVE_ACTIVITY_POLL_INTERVAL_MS } from './features/liveSharing/liveActivityQuery'
import { buildShareSummary, createSummaryCard, exportActivitySummary, SHARE_MESSAGES, shareActivitySummary } from './features/sharing/shareActivity'
import { buildShareSummary, calculateSummaryCardLayout, createSummaryCard, exportActivitySummary, renderLiveQrSvg, SHARE_MESSAGES, shareActivitySummary } from './features/sharing/shareActivity'
import { LiveActivityIdentityModal } from './features/sharing/LiveActivityIdentityModal'
import { createSharedActivity, type SharedActivity } from './features/sharing/sharedActivity'
import { LocalizationProvider } from './i18n/LocalizationContext'
Expand Down Expand Up @@ -68,8 +68,12 @@ function mockCanvas(blob: Blob | null = new Blob(['png'], { type: 'image/png' })
fillStyle: '',
font: '',
textAlign: 'left',
beginPath: vi.fn(),
drawImage: vi.fn(),
fill: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
roundRect: vi.fn(),
}
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as unknown as CanvasRenderingContext2D)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(callback => callback(blob))
Expand Down Expand Up @@ -142,6 +146,7 @@ describe('state and formatting helpers', () => {
expect(empty).toContain('• No settlement payments recorded.')
expect(empty).toContain('• Everyone is settled.')
expect(empty).toContain('Shared from Tally · https://pengfanz.github.io/splitbill/')
expect(empty).not.toContain('Open and edit the Live activity:')

const populated = buildShareSummary(group, [CURRENT_USER, maya, jordan], [
expense(),
Expand All @@ -154,6 +159,9 @@ describe('state and formatting helpers', () => {
expect(populated).toContain('Maya Chen paid You $5.00')
expect(populated).toContain('Maya Chen pays You $5.00')
expect(populated).toContain('Jordan pays You $10.00')
const liveUrl = `https://example.com/splitbill/#live=A1B2C3D4E5.${'a'.repeat(64)}`
const liveSummary = buildShareSummary(group, [CURRENT_USER, maya, jordan], [expense()], { liveUrl })
expect(liveSummary).toContain(`Open and edit the Live activity:\n${liveUrl}`)
expect(buildShareSummary({ ...group, currency: 'CNY' }, [CURRENT_USER, maya, jordan], [expense()]))
.toContain('Dinner — ¥30.00')

Expand Down Expand Up @@ -190,7 +198,10 @@ describe('state and formatting helpers', () => {
expect(drawnText).toContain('Unknown paid · Exact split')
expect(drawnText).toContain('Maya Chen paid You')
expect(drawnText).toContain('Settlement payment')
expect(drawnText).toContain('+ 3 more entries')
expect(drawnText).toEqual(expect.arrayContaining(['Extra 0', 'Extra 1', 'Extra 2', 'Extra 3', 'Extra 4']))
expect(populatedContext.fillRect.mock.calls.filter(([, , , height]) => height <= 2)).toHaveLength(0)
expect(populatedContext.roundRect).toHaveBeenCalled()
expect(calculateSummaryCardLayout(manyExpenses.length, 2, false).height).toBeGreaterThan(1350)

await createSummaryCard(group, [CURRENT_USER], [
expense({ id: 'missing-payer', kind: 'settlement', title: 'Settlement payment', amount: 5, payerId: 'missing', splitMethod: 'exact', shares: {} }),
Expand All @@ -200,6 +211,68 @@ describe('state and formatting helpers', () => {
expect(drawnText.filter(text => text === 'Unknown paid Unknown')).toHaveLength(2)
})

it('adds a scannable Live QR panel only when a Live URL is provided', async () => {
const context = mockCanvas()
const createObjectURL = vi.fn().mockReturnValue('blob:live-qr')
const revokeObjectURL = vi.fn()
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectURL })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revokeObjectURL })
const OriginalImage = globalThis.Image
class LoadedImage {
onload: (() => void) | null = null
onerror: (() => void) | null = null
set src(_value: string) {
queueMicrotask(() => this.onload?.())
}
}
Object.defineProperty(globalThis, 'Image', { configurable: true, value: LoadedImage })

try {
const liveUrl = `https://example.com/splitbill/#live=A1B2C3D4E5.${'a'.repeat(64)}`
await createSummaryCard(group, [CURRENT_USER, maya, jordan], [expense()], { liveUrl })
const drawnText = context.fillText.mock.calls.map(call => call[0])
expect(context.drawImage).toHaveBeenCalledOnce()
expect(drawnText).toContain('Scan to open the latest activity')
expect(drawnText).toContain('Anyone with this QR code can edit.')
expect(calculateSummaryCardLayout(1, 1, true).liveQrPanelY).not.toBeNull()
expect(calculateSummaryCardLayout(1, 1, false).liveQrPanelY).toBeNull()
expect(createObjectURL).toHaveBeenCalledWith(expect.objectContaining({ type: 'image/svg+xml' }))
expect(revokeObjectURL).toHaveBeenCalledWith('blob:live-qr')
} finally {
Object.defineProperty(globalThis, 'Image', { configurable: true, value: OriginalImage })
}
})

it('rejects invalid or unloadable Live QR images without leaking object URLs', async () => {
const liveUrl = `https://example.com/splitbill/#live=A1B2C3D4E5.${'a'.repeat(64)}`
const querySelector = vi.spyOn(HTMLDivElement.prototype, 'querySelector').mockReturnValueOnce(null)
await expect(renderLiveQrSvg(liveUrl)).rejects.toThrow('QR code rendering failed')
querySelector.mockRestore()

mockCanvas()
const createObjectURL = vi.fn().mockReturnValue('blob:broken-live-qr')
const revokeObjectURL = vi.fn()
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: createObjectURL })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: revokeObjectURL })
const OriginalImage = globalThis.Image
class BrokenImage {
onload: (() => void) | null = null
onerror: (() => void) | null = null
set src(_value: string) {
queueMicrotask(() => this.onerror?.())
}
}
Object.defineProperty(globalThis, 'Image', { configurable: true, value: BrokenImage })

try {
await expect(createSummaryCard(group, [CURRENT_USER, maya, jordan], [expense()], { liveUrl }))
.rejects.toThrow('QR code rendering failed')
expect(revokeObjectURL).toHaveBeenCalledWith('blob:broken-live-qr')
} finally {
Object.defineProperty(globalThis, 'Image', { configurable: true, value: OriginalImage })
}
})

it('reports unavailable canvas and failed PNG encoding', async () => {
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
await expect(createSummaryCard(group, [CURRENT_USER], [])).rejects.toThrow('Canvas is unavailable')
Expand Down Expand Up @@ -492,7 +565,7 @@ describe('small UI building blocks', () => {
expect(screen.getByRole('status')).toHaveTextContent('Summary copied.')
expect(screen.getByText('Local')).toBeVisible()
await chooseShareAction(user, 'Start live activity')
await chooseShareAction(user, 'Share balances only')
await chooseShareAction(user, 'Export full summary')
await user.click(screen.getByRole('button', { name: 'Add friend' }))
await user.click(screen.getByRole('button', { name: 'Add expense' }))
await user.click(screen.getAllByRole('button', { name: 'Settle up' })[0])
Expand Down Expand Up @@ -998,7 +1071,7 @@ describe('complete app workflows', () => {
expect(screen.getAllByText('$45.00').some(element => element.matches('.expense-amount b'))).toBe(true)
expect(screen.getByText(/^Edited /)).toBeVisible()

await chooseShareAction(user, 'Share balances only')
await chooseShareAction(user, 'Export full summary')
expect(await screen.findByRole('status')).toHaveTextContent('Summary copied')
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('Maya pays You $15.00'))
expect(analyticsClient.track).toHaveBeenCalledWith('summary_export_clicked', 'local', 'en')
Expand Down Expand Up @@ -1230,7 +1303,7 @@ describe('complete app workflows', () => {
render(<App />)
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
await chooseShareAction(user, 'Share balances only')
await chooseShareAction(user, 'Export full summary')
expect(await screen.findByRole('status')).toHaveTextContent('Summary copied')
await user.click(screen.getByRole('button', { name: 'Open Home activity' }))
expect(screen.getByRole('heading', { name: 'Home' })).toBeVisible()
Expand Down Expand Up @@ -1977,8 +2050,10 @@ describe('complete app workflows', () => {
await user.click(screen.getByRole('button', { name: 'Copy link' }))
expect(screen.getAllByRole('status').some(status => status.textContent?.includes('Anyone with it can edit'))).toBe(true)

await chooseShareAction(user, 'Share balances only')
await chooseShareAction(user, 'Export full summary')
expect(analyticsClient.track).toHaveBeenCalledWith('summary_export_clicked', 'live', 'en')
expect(writeText).toHaveBeenLastCalledWith(expect.stringContaining(`Open and edit the Live activity:\n${buildLiveActivityUrl(credentials)}`))
expect(writeText).toHaveBeenLastCalledWith(expect.stringContaining('Dinner — $30.00'))

await user.click(screen.getByRole('button', { name: 'Refresh latest' }))
expect(await screen.findByText('Latest changes loaded.')).toBeVisible()
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ function LocalizedApp({ aiExpenseClient = null, analyticsClient = null, liveActi
onShareQr={live.editable && liveSession ? () => sharing.openCurrentLiveQr(liveSession) : undefined}
onCopyShareLink={live.editable && liveSession ? () => sharing.copyCurrentLiveLink(liveSession) : undefined}
onEndLive={live.editable && liveEnd ? () => endLiveActivity(liveEnd) : undefined}
onShareSummary={() => sharing.shareGroup(liveActivity.group, liveMembers, liveActivity.expenses, 'live')}
onShareSummary={() => sharing.shareGroup(liveActivity.group, liveMembers, liveActivity.expenses, 'live', liveSession)}
onAddFriend={live.editable ? () => setModal('friend') : undefined}
onAddExpense={live.editable ? openNewExpense : undefined}
onSettleUp={live.editable ? openSettleUp : undefined}
Expand Down
12 changes: 8 additions & 4 deletions src/features/sharing/ShareActivityMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { ShareActivityMenu } from './ShareActivityMenu'

describe('ShareActivityMenu', () => {
it('presents only Live collaboration and a balance summary for local activities', async () => {
it('presents Live collaboration and a complete export for local activities', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onCollaborateLive = vi.fn()
Expand All @@ -17,12 +17,13 @@ describe('ShareActivityMenu', () => {
/>)

expect(screen.getByRole('dialog', { name: 'Share activity' })).toBeVisible()
expect(screen.getByText('Invite people to edit Weekend trip together, or send a balance summary.')).toBeVisible()
expect(screen.getByText('Invite people to edit Weekend trip together, or export a complete summary.')).toBeVisible()
expect(screen.getByText('CAN EDIT · STAYS IN SYNC')).toBeVisible()
expect(screen.getByText('everyone sees the latest version.', { exact: false })).toBeVisible()
expect(screen.getByText('Includes every expense, payment, total, and who owes whom.')).toBeVisible()
expect(screen.queryByText(/snapshot/i)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Start live activity' }))
await user.click(screen.getByRole('button', { name: /^Share balances only/ }))
await user.click(screen.getByRole('button', { name: /^Export full summary/ }))

expect(onCollaborateLive).toHaveBeenCalledOnce()
expect(onShareSummary).toHaveBeenCalledOnce()
Expand Down Expand Up @@ -60,7 +61,10 @@ describe('ShareActivityMenu', () => {
const onClose = vi.fn()
const onCopyLink = vi.fn()
const onShowQr = vi.fn()
const { rerender } = render(<ShareActivityMenu groupName="Cabin" live onClose={onClose} onCopyLink={onCopyLink} />)
const onShareSummary = vi.fn()
const { rerender } = render(<ShareActivityMenu groupName="Cabin" live onClose={onClose} onCopyLink={onCopyLink} onShareSummary={onShareSummary} />)

expect(screen.getByText('Includes every expense, payment, and balance, plus the Live QR invite.')).toBeVisible()

rerender(<ShareActivityMenu groupName="Cabin" live onClose={onClose} onShowQr={onShowQr} />)
expect(screen.queryByRole('button', { name: 'Copy live invite link' })).not.toBeInTheDocument()
Expand Down
Loading