From 72632902b62fd3d1ccf72c83eb27f812e34d4e72 Mon Sep 17 00:00:00 2001 From: PengfanZ Date: Sun, 9 Aug 2026 10:45:36 -0700 Subject: [PATCH] feat: export complete activity summaries --- README.md | 2 +- docs/ANALYTICS.md | 2 +- e2e/activity-lifecycle.spec.ts | 20 ++- src/App.test.tsx | 87 ++++++++- src/App.tsx | 2 +- .../sharing/ShareActivityMenu.test.tsx | 12 +- src/features/sharing/ShareActivityMenu.tsx | 2 +- src/features/sharing/shareActivity.ts | 167 ++++++++++++++---- src/features/sharing/useActivitySharing.ts | 4 +- src/i18n/localization.ts | 32 ++-- 10 files changed, 265 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 61d06f9..e249516 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/ANALYTICS.md b/docs/ANALYTICS.md index f605612..7253490 100644 --- a/docs/ANALYTICS.md +++ b/docs/ANALYTICS.md @@ -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. diff --git a/e2e/activity-lifecycle.spec.ts b/e2e/activity-lifecycle.spec.ts index 623f3aa..a7c5763 100644 --- a/e2e/activity-lifecycle.spec.ts +++ b/e2e/activity-lifecycle.spec.ts @@ -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 @@ -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) @@ -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 @@ -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() @@ -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/) diff --git a/src/App.test.tsx b/src/App.test.tsx index 7f9bacc..89b7e00 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -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' @@ -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)) @@ -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(), @@ -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') @@ -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: {} }), @@ -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') @@ -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]) @@ -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') @@ -1230,7 +1303,7 @@ describe('complete app workflows', () => { render() 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() @@ -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() diff --git a/src/App.tsx b/src/App.tsx index 9d8383b..d043e1d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} diff --git a/src/features/sharing/ShareActivityMenu.test.tsx b/src/features/sharing/ShareActivityMenu.test.tsx index 2c38df8..68d6edb 100644 --- a/src/features/sharing/ShareActivityMenu.test.tsx +++ b/src/features/sharing/ShareActivityMenu.test.tsx @@ -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() @@ -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() @@ -60,7 +61,10 @@ describe('ShareActivityMenu', () => { const onClose = vi.fn() const onCopyLink = vi.fn() const onShowQr = vi.fn() - const { rerender } = render() + const onShareSummary = vi.fn() + const { rerender } = render() + + expect(screen.getByText('Includes every expense, payment, and balance, plus the Live QR invite.')).toBeVisible() rerender() expect(screen.queryByRole('button', { name: 'Copy live invite link' })).not.toBeInTheDocument() diff --git a/src/features/sharing/ShareActivityMenu.tsx b/src/features/sharing/ShareActivityMenu.tsx index 92a19a9..cc3bf33 100644 --- a/src/features/sharing/ShareActivityMenu.tsx +++ b/src/features/sharing/ShareActivityMenu.tsx @@ -100,7 +100,7 @@ export function ShareActivityMenu({ groupName, live = false, onClose, onCollabor ) : null} - {onShareSummary ?
{t('shareMenu.otherTitle')}} title={t('shareMenu.summary')} description={t('shareMenu.summaryHelp')} onClick={() => run(onShareSummary)} />
: null} + {onShareSummary ?
{t('shareMenu.otherTitle')}} title={t('shareMenu.summary')} description={t(live ? 'shareMenu.summaryHelpLive' : 'shareMenu.summaryHelp')} onClick={() => run(onShareSummary)} />
: null} {live && onEndLive ? (
{t('shareMenu.endLive')}{t('shareMenu.endLiveHelp')} diff --git a/src/features/sharing/shareActivity.ts b/src/features/sharing/shareActivity.ts index fd2bc62..7e0125d 100644 --- a/src/features/sharing/shareActivity.ts +++ b/src/features/sharing/shareActivity.ts @@ -4,8 +4,20 @@ import type { ActivityGroup, Expense, Member } from '../../domain/models' import { translate, type AppLocale, type Translate } from '../../i18n/localization' export type ShareResult = 'shared' | 'copied' | 'downloaded' | 'cancelled' | 'failed' +export type ActivitySummaryExportOptions = { + locale?: AppLocale + liveUrl?: string +} + export const TALLY_PUBLIC_URL = 'https://pengfanz.github.io/splitbill/' +const CARD_WIDTH = 1080 +const MIN_CARD_HEIGHT = 1350 +const SETTLEMENT_PANEL_Y = 570 +const SETTLEMENT_ROW_HEIGHT = 62 +const EXPENSE_ROW_HEIGHT = 86 +const LIVE_QR_PANEL_HEIGHT = 300 + export const SHARE_MESSAGES: Record = { shared: 'PNG summary shared.', copied: 'Summary copied. Paste it into any chat.', @@ -19,7 +31,8 @@ function memberName(memberMap: Map, memberId: string | null, t: return memberMap.get(memberId)?.name ?? t('common.unknown') } -export function buildShareSummary(group: ActivityGroup, members: Member[], expenses: Expense[], locale: AppLocale = 'en') { +export function buildShareSummary(group: ActivityGroup, members: Member[], expenses: Expense[], options: ActivitySummaryExportOptions = {}) { + const { locale = 'en', liveUrl } = options const t: Translate = (key, variables) => translate(locale, key, variables) const currency = activityCurrency(group) const memberMap = new Map(members.map(member => [member.id, member])) @@ -57,24 +70,90 @@ export function buildShareSummary(group: ActivityGroup, members: Member[], expen '', t('share.suggestedPayments'), ...settlementLines, + ...(liveUrl ? ['', t('share.liveAccess'), liveUrl] : []), '', `${t('share.sharedFrom')} · ${TALLY_PUBLIC_URL}`, ].join('\n') } -export async function createSummaryCard(group: ActivityGroup, members: Member[], expenses: Expense[], locale: AppLocale = 'en') { +export function calculateSummaryCardLayout(expenseCount: number, settlementCount: number, hasLiveQr: boolean) { + const settlementRows = Math.max(1, settlementCount) + const expenseRows = Math.max(1, expenseCount) + const settlementPanelHeight = settlementRows * SETTLEMENT_ROW_HEIGHT + 32 + const expenseHeadingY = SETTLEMENT_PANEL_Y + settlementPanelHeight + 62 + const expensePanelY = expenseHeadingY + 28 + const expensePanelHeight = expenseRows * EXPENSE_ROW_HEIGHT + 32 + const expenseRowsStartY = expensePanelY + 54 + const expensePanelEndY = expensePanelY + expensePanelHeight + const liveQrPanelY = hasLiveQr ? expensePanelEndY + 42 : null + const contentEndY = liveQrPanelY === null ? expensePanelEndY : liveQrPanelY + LIVE_QR_PANEL_HEIGHT + const height = Math.max(MIN_CARD_HEIGHT, contentEndY + 130) + return { expenseHeadingY, expensePanelHeight, expensePanelY, expenseRowsStartY, height, liveQrPanelY, settlementPanelHeight } +} + +function fillRoundedRect(context: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) { + context.beginPath() + context.roundRect(x, y, width, height, radius) + context.fill() +} + +export async function renderLiveQrSvg(liveUrl: string) { + const [{ createElement }, { flushSync }, { createRoot }, { QRCodeSVG }] = await Promise.all([ + import('react'), + import('react-dom'), + import('react-dom/client'), + import('qrcode.react'), + ]) + const host = document.createElement('div') + const root = createRoot(host) + try { + flushSync(() => root.render(createElement(QRCodeSVG, { + value: liveUrl, + size: 256, + level: 'M', + marginSize: 4, + bgColor: '#ffffff', + fgColor: '#26231f', + }))) + const svg = host.querySelector('svg') + if (!svg) throw new Error('QR code rendering failed') + svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg') + return svg.outerHTML + } finally { + root.unmount() + } +} + +async function drawLiveQrCode(context: CanvasRenderingContext2D, liveUrl: string, x: number, y: number, size: number) { + const svg = await renderLiveQrSvg(liveUrl) + const objectUrl = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' })) + try { + const image = await new Promise((resolve, reject) => { + const qrImage = new Image() + qrImage.onload = () => resolve(qrImage) + qrImage.onerror = () => reject(new Error('QR code rendering failed')) + qrImage.src = objectUrl + }) + context.drawImage(image, x, y, size, size) + } finally { + URL.revokeObjectURL(objectUrl) + } +} + +export async function createSummaryCard(group: ActivityGroup, members: Member[], expenses: Expense[], options: ActivitySummaryExportOptions = {}) { + const { locale = 'en', liveUrl } = options const t: Translate = (key, variables) => translate(locale, key, variables) const currency = activityCurrency(group) + const settlements = calculateSettlements(members, expenses) + const layout = calculateSummaryCardLayout(expenses.length, settlements.length, Boolean(liveUrl)) const canvas = document.createElement('canvas') - canvas.width = 1080 - canvas.height = 1350 + canvas.width = CARD_WIDTH + canvas.height = layout.height const context = canvas.getContext('2d') if (!context) throw new Error('Canvas is unavailable') const total = spendingExpenses(expenses).reduce((sum, item) => sum + item.amount, 0) - const settlements = calculateSettlements(members, expenses) const memberMap = new Map(members.map(member => [member.id, member])) - const visibleEntries = expenses.slice(0, 5) context.fillStyle = '#f7f4ee' context.fillRect(0, 0, canvas.width, canvas.height) @@ -89,7 +168,7 @@ export async function createSummaryCard(group: ActivityGroup, members: Member[], context.fillText(t('share.cardSharing', { count: members.length, unit: t(members.length === 1 ? 'common.person' : 'common.people') }), 74, 225) context.fillStyle = '#ffffff' - context.fillRect(72, 278, 936, 190) + fillRoundedRect(context, 72, 278, 936, 190, 24) context.fillStyle = '#746e67' context.font = '600 22px Arial, sans-serif' context.fillText(t('dashboard.totalSpent').toUpperCase(), 112, 330) @@ -100,59 +179,77 @@ export async function createSummaryCard(group: ActivityGroup, members: Member[], context.fillStyle = '#26231f' context.font = '700 30px Arial, sans-serif' context.fillText(t('share.suggestedPayments'), 72, 540) - context.fillStyle = '#d8d1c8' - context.fillRect(72, 560, 936, 2) - context.font = '600 27px Arial, sans-serif' + context.fillStyle = '#fce9e6' + fillRoundedRect(context, 72, SETTLEMENT_PANEL_Y, 936, layout.settlementPanelHeight, 24) + context.font = '600 25px Arial, sans-serif' if (settlements.length) { - settlements.slice(0, 4).forEach((item, index) => { - const y = 620 + index * 58 + settlements.forEach((item, index) => { + const y = SETTLEMENT_PANEL_Y + 48 + index * SETTLEMENT_ROW_HEIGHT context.fillStyle = '#26231f' - context.fillText(t('settlement.parties', { from: item.from.name, to: item.to.name }), 82, y, 710) + context.fillText(t('settlement.parties', { from: item.from.name, to: item.to.name }), 108, y, 690) context.fillStyle = '#e8584f' context.textAlign = 'right' - context.fillText(money(item.amount, currency, locale), 998, y) + context.fillText(money(item.amount, currency, locale), 972, y) context.textAlign = 'left' }) } else { context.fillStyle = '#16724c' - context.fillText(t('dashboard.everyoneSettled'), 82, 620) + context.fillText(t('dashboard.everyoneSettled'), 108, SETTLEMENT_PANEL_Y + 48) } - const expenseHeadingY = 620 + Math.max(1, Math.min(4, settlements.length)) * 58 + 72 context.fillStyle = '#26231f' context.font = '700 30px Arial, sans-serif' - context.fillText(t('share.cardActivity'), 72, expenseHeadingY) - context.fillStyle = '#d8d1c8' - context.fillRect(72, expenseHeadingY + 20, 936, 2) - context.font = '500 24px Arial, sans-serif' - if (visibleEntries.length) { - visibleEntries.forEach((item, index) => { - const y = expenseHeadingY + 78 + index * 58 + context.fillText(t('share.cardActivity'), 72, layout.expenseHeadingY) + context.fillStyle = '#ffffff' + fillRoundedRect(context, 72, layout.expensePanelY, 936, layout.expensePanelHeight, 24) + if (expenses.length) { + expenses.forEach((item, index) => { + const y = layout.expenseRowsStartY + index * EXPENSE_ROW_HEIGHT const settlementPayment = isSettlementPayment(item) const payer = memberName(memberMap, item.payerId, t) const recipientId = getSettlementRecipientId(item) const recipient = memberName(memberMap, recipientId, t) + context.fillStyle = settlementPayment ? '#16724c' : '#e8584f' + fillRoundedRect(context, 104, y - 17, 8, 8, 4) context.fillStyle = '#26231f' - context.fillText(settlementPayment ? t('dashboard.paidPerson', { payer, recipient }) : item.title, 82, y, 460) + context.font = '600 24px Arial, sans-serif' + context.fillText(settlementPayment ? t('dashboard.paidPerson', { payer, recipient }) : item.title, 128, y, 650) context.fillStyle = '#746e67' - context.fillText(settlementPayment ? t('dashboard.settlementPayment') : t('share.cardPayerSplit', { payer, split: t(item.splitMethod === 'equal' ? 'dashboard.splitEqually' : 'dashboard.exactSplit') }), 390, y, 410) + context.font = '500 19px Arial, sans-serif' + context.fillText(settlementPayment ? t('dashboard.settlementPayment') : t('share.cardPayerSplit', { payer, split: t(item.splitMethod === 'equal' ? 'dashboard.splitEqually' : 'dashboard.exactSplit') }), 128, y + 28, 650) context.fillStyle = '#26231f' + context.font = '600 24px Arial, sans-serif' context.textAlign = 'right' - context.fillText(money(item.amount, currency, locale), 998, y) + context.fillText(money(item.amount, currency, locale), 972, y) context.textAlign = 'left' }) - if (expenses.length > visibleEntries.length) { - context.fillStyle = '#746e67' - context.fillText(t('share.cardMoreEntries', { count: expenses.length - visibleEntries.length }), 82, expenseHeadingY + 78 + visibleEntries.length * 58) - } } else { context.fillStyle = '#746e67' - context.fillText(t('share.cardNoActivity'), 82, expenseHeadingY + 78) + context.font = '500 24px Arial, sans-serif' + context.fillText(t('share.cardNoActivity'), 108, layout.expenseRowsStartY) + } + + if (liveUrl && layout.liveQrPanelY !== null) { + context.fillStyle = '#e4f0e9' + fillRoundedRect(context, 72, layout.liveQrPanelY, 936, LIVE_QR_PANEL_HEIGHT, 24) + await drawLiveQrCode(context, liveUrl, 98, layout.liveQrPanelY + 26, 248) + context.fillStyle = '#16724c' + context.font = '700 20px Arial, sans-serif' + context.fillText(t('share.cardLiveEyebrow').toUpperCase(), 390, layout.liveQrPanelY + 76, 560) + context.fillStyle = '#26231f' + context.font = '700 31px Arial, sans-serif' + context.fillText(t('share.cardLiveTitle'), 390, layout.liveQrPanelY + 124, 560) + context.fillStyle = '#746e67' + context.font = '500 22px Arial, sans-serif' + context.fillText(t('share.cardLiveHelp'), 390, layout.liveQrPanelY + 165, 560) + context.fillStyle = '#e8584f' + context.font = '600 20px Arial, sans-serif' + context.fillText(t('share.cardLiveWarning'), 390, layout.liveQrPanelY + 215, 560) } context.fillStyle = '#746e67' context.font = '500 21px Arial, sans-serif' - context.fillText(`${t('share.sharedFrom')} · ${TALLY_PUBLIC_URL}`, 72, 1290) + context.fillText(`${t('share.sharedFrom')} · ${TALLY_PUBLIC_URL}`, 72, layout.height - 50) return new Promise((resolve, reject) => { canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNG generation failed')), 'image/png') @@ -209,11 +306,11 @@ export async function shareActivitySummary(title: string, text: string, image: B return 'failed' } -export async function exportActivitySummary(group: ActivityGroup, members: Member[], expenses: Expense[], locale: AppLocale = 'en') { +export async function exportActivitySummary(group: ActivityGroup, members: Member[], expenses: Expense[], options: ActivitySummaryExportOptions = {}) { const title = `${group.name} — Tally` - const text = buildShareSummary(group, members, expenses, locale) + const text = buildShareSummary(group, members, expenses, options) try { - const image = await createSummaryCard(group, members, expenses, locale) + const image = await createSummaryCard(group, members, expenses, options) return shareActivitySummary(title, text, image) } catch { return shareActivitySummary(title, text, null) diff --git a/src/features/sharing/useActivitySharing.ts b/src/features/sharing/useActivitySharing.ts index a0ad46e..f9b7e4a 100644 --- a/src/features/sharing/useActivitySharing.ts +++ b/src/features/sharing/useActivitySharing.ts @@ -54,9 +54,11 @@ export function useActivitySharing({ members: Member[], expenses: Expense[], surface: AnalyticsSurface, + liveSession?: LiveSession | null, ) => { analyticsClient?.track('summary_export_clicked', surface, locale) - const result = await exportActivitySummary(group, members, expenses, locale) + const liveUrl = liveSession ? buildLiveActivityUrl(liveSession.credentials) : undefined + const result = await exportActivitySummary(group, members, expenses, { locale, liveUrl }) setActivityFeedback({ groupId: group.id, message: t(SUMMARY_MESSAGE_KEYS[result]) }) } diff --git a/src/i18n/localization.ts b/src/i18n/localization.ts index fe34cfe..dfc0682 100644 --- a/src/i18n/localization.ts +++ b/src/i18n/localization.ts @@ -91,7 +91,7 @@ const en = { 'changelog.item.liveTitle': 'Reliable live collaboration', 'changelog.item.liveDescription': 'Edit together while online, with a recovery copy kept on every device that opens the activity.', 'changelog.item.shareTitle': 'Clear sharing choices', - 'changelog.item.shareDescription': 'Invite everyone into one editable Live activity, or send a balance summary without activity access.', + 'changelog.item.shareDescription': 'Invite everyone into one editable Live activity, or export every expense and balance; Live summaries include the QR invite.', 'changelog.item.settleTitle': 'Settle up together', 'changelog.item.settleDescription': 'Record partial payments or settle balances in full, including inside a Live activity.', 'changelog.item.polishTitle': 'Polish where it matters', @@ -263,7 +263,7 @@ const en = { 'dashboard.liveRevision': 'Live · revision {revision}', 'dashboard.savedRevision': 'Saved · revision {revision}', 'shareMenu.title': 'Share activity', - 'shareMenu.description': 'Invite people to edit {name} together, or send a balance summary.', + 'shareMenu.description': 'Invite people to edit {name} together, or export a complete summary.', 'shareMenu.liveBadge': 'CAN EDIT · STAYS IN SYNC', 'shareMenu.liveTitle': 'Edit together live', 'shareMenu.currentLiveTitle': 'Invite people to edit live', @@ -271,9 +271,10 @@ const en = { 'shareMenu.startLive': 'Start live activity', 'shareMenu.copyLive': 'Copy live invite link', 'shareMenu.liveQr': 'Show live QR', - 'shareMenu.otherTitle': 'Just need to send the result?', - 'shareMenu.summary': 'Share balances only', - 'shareMenu.summaryHelp': 'Send totals and who owes whom without sharing activity access.', + 'shareMenu.otherTitle': 'Prefer a shareable summary?', + 'shareMenu.summary': 'Export full summary', + 'shareMenu.summaryHelp': 'Includes every expense, payment, total, and who owes whom.', + 'shareMenu.summaryHelpLive': 'Includes every expense, payment, and balance, plus the Live QR invite.', 'shareMenu.endLive': 'End live sharing', 'shareMenu.endLiveHelp': 'The current invite link will stop working. Recovery copies stay on devices that opened it.', 'shareMenu.endLiveAction': 'End live', @@ -396,14 +397,18 @@ const en = { 'share.suggestedPayments': 'Suggested payments', 'share.settlementLine': '• {from} pays {to} {amount}', 'share.everyoneSettled': '• Everyone is settled.', + 'share.liveAccess': 'Open and edit the Live activity:', 'share.sharedFrom': 'Shared from Tally', 'share.linkTitle': '{name} — Tally', 'share.liveLinkText': 'Join {name} and edit expenses together in Tally.', 'share.cardSharing': '{count} {unit} sharing expenses', - 'share.cardActivity': 'Activity', + 'share.cardActivity': 'All expenses & payments', 'share.cardNoActivity': 'No activity yet.', 'share.cardPayerSplit': '{payer} paid · {split}', - 'share.cardMoreEntries': '+ {count} more entries', + 'share.cardLiveEyebrow': 'Live activity', + 'share.cardLiveTitle': 'Scan to open the latest activity', + 'share.cardLiveHelp': 'View and edit together on another device.', + 'share.cardLiveWarning': 'Anyone with this QR code can edit.', } as const export type TranslationKey = keyof typeof en @@ -415,10 +420,13 @@ const zhCN: Record = { 'share.linkTitle': '{name} — Tally', 'share.liveLinkText': '加入 {name},在 Tally 中一起编辑支出。', 'share.cardSharing': '{count} {unit}一起分摊支出', - 'share.cardActivity': '支出记录', + 'share.cardActivity': '全部支出与还款', 'share.cardNoActivity': '还没有支出记录。', 'share.cardPayerSplit': '{payer} 付款 · {split}', - 'share.cardMoreEntries': '另有 {count} 条记录', + 'share.cardLiveEyebrow': '实时活动', + 'share.cardLiveTitle': '扫码打开最新活动', + 'share.cardLiveHelp': '在另一台设备查看并继续一起编辑。', + 'share.cardLiveWarning': '任何拿到二维码的人都可以编辑。', 'common.close': '关闭', 'common.cancel': '取消', 'common.unknown': '未知', 'common.you': '你', 'common.friend': '朋友', 'common.person': '人', 'common.people': '人', 'common.loading': '加载中…', 'nav.open': '打开导航', 'nav.close': '关闭导航', 'nav.newActivity': '新建活动', 'nav.joinActivity': '加入活动', 'nav.yourActivities': '你的活动', 'nav.openActivity': '打开活动:{name}', 'nav.deleteActivity': '删除活动:{name}', 'nav.deleteActivityTitle': '删除活动', 'nav.liveCode': '实时 · {code}', 'nav.memberCount': '{count} {unit}', 'nav.noActivities': '还没有活动。', 'nav.whatsNew': '最近更新', 'nav.newUpdates': '有新功能', 'nav.sourceFeedback': '源码与反馈', 'nav.resetData': '清空本地数据', 'topbar.searchLabel': '搜索支出', 'topbar.searchPlaceholder': '搜索当前活动…', 'topbar.clearSearch': '清除搜索', 'topbar.openSearch': '打开支出搜索', 'topbar.closeSearch': '关闭支出搜索', 'topbar.settings': '设置', @@ -426,14 +434,14 @@ const zhCN: Record = { 'identity.eyebrow': '你的本地身份', 'identity.settingsEyebrow': '偏好设置', 'identity.title': '怎么称呼你?', 'identity.settingsTitle': '设置', 'identity.displayName': '显示名称', 'identity.namePlaceholder': '例如:鹏帆', 'identity.storedLocally': '只保存在这个浏览器中', 'identity.explanation': '这个名字代表活动中的“你”,朋友打开分享链接时也能认出发送者。', 'identity.continue': '继续', 'identity.saveName': '保存', 'activityIdentity.choose': '选择你的身份', 'activityIdentity.current': '当前身份:{name}', 'activityIdentity.compact': '我 · {name}', 'activityIdentity.menu': '选择活动成员', 'activityIdentity.title': '你在这个活动中是谁?', 'activityIdentity.description': '选择当前浏览器中代表你的活动成员。', 'activityIdentity.localOnly': '只保存在当前浏览器', 'activityIdentity.aiReason': 'Tally 会用它来理解 AI 输入中的“我”。', 'activityIdentity.required': '请先选择你的身份,这样 Tally 才知道“我”是谁。', 'settings.language': '语言', 'settings.english': 'English', 'settings.chinese': '简体中文', 'settings.chooseLanguage': '语言:{language}', 'settings.languageMenu': '选择语言', 'settings.regionTitle': '语言与本地时间', 'settings.timeZone': '时间将按照 {timeZone} 显示。', - 'changelog.eyebrow': '最近更新', 'changelog.title': 'Tally 最近更新', 'changelog.confirm': '知道了', 'changelog.release.liveControlsTitle': '实时共享更可控', 'changelog.release.liveControlsSummary': '需要时可以随时结束邀请链接,已经加入过的设备仍会保留最后同步的恢复副本。', 'changelog.item.endLiveTitle': '结束实时活动', 'changelog.item.endLiveDescription': '让当前邀请链接对所有人立即失效,同时保留每台设备上的恢复副本。', 'changelog.item.saferSharingTitle': '共享数据更稳妥', 'changelog.item.saferSharingDescription': '更严格地检查参与者、支出、分摊、结算和时间信息,避免活动数据不一致。', 'changelog.release.aiTitle': '打字或说话,都能快速添加支出', 'changelog.release.aiSummary': '手动填写仍然是默认方式。需要更快时,Tally 现在可以把一段文字或录音整理成一笔或多笔草稿,保存前由你确认。', 'changelog.item.aiTextTitle': '一次描述多笔支出', 'changelog.item.aiTextDescription': '用你习惯的语言自然描述,Tally 会一次整理成多笔支出草稿。', 'changelog.item.aiVoiceTitle': '不想打字就直接说', 'changelog.item.aiVoiceDescription': '录音最长 60 秒,Tally 会把里面的支出信息整理成草稿。', 'changelog.item.aiReviewTitle': '最终由你确认', 'changelog.item.aiReviewDescription': '添加到活动前,每一笔草稿都可以检查、修改或删除。', 'changelog.release.liveTitle': '分享更清楚,也更安心', 'changelog.release.liveSummary': '现在可以更直观地选择分享方式,实时协作更可靠,在不同设备上使用也更顺手。', 'changelog.item.liveTitle': '更可靠的实时协作', 'changelog.item.liveDescription': '在线时大家可以一起编辑,每台打开过活动的设备都会保留一份恢复副本。', 'changelog.item.shareTitle': '分享方式一目了然', 'changelog.item.shareDescription': '邀请朋友进入同一个 Live 活动一起编辑,或只发送余额总结,不开放活动访问权限。', 'changelog.item.settleTitle': '一起记录结算', 'changelog.item.settleDescription': '支持部分还款或全部结清,在 Live 活动中也会同步更新。', 'changelog.item.polishTitle': '常用操作更顺手', 'changelog.item.polishDescription': '优化了货币与语言菜单、手机端弹窗,以及支出时间的显示。', + 'changelog.eyebrow': '最近更新', 'changelog.title': 'Tally 最近更新', 'changelog.confirm': '知道了', 'changelog.release.liveControlsTitle': '实时共享更可控', 'changelog.release.liveControlsSummary': '需要时可以随时结束邀请链接,已经加入过的设备仍会保留最后同步的恢复副本。', 'changelog.item.endLiveTitle': '结束实时活动', 'changelog.item.endLiveDescription': '让当前邀请链接对所有人立即失效,同时保留每台设备上的恢复副本。', 'changelog.item.saferSharingTitle': '共享数据更稳妥', 'changelog.item.saferSharingDescription': '更严格地检查参与者、支出、分摊、结算和时间信息,避免活动数据不一致。', 'changelog.release.aiTitle': '打字或说话,都能快速添加支出', 'changelog.release.aiSummary': '手动填写仍然是默认方式。需要更快时,Tally 现在可以把一段文字或录音整理成一笔或多笔草稿,保存前由你确认。', 'changelog.item.aiTextTitle': '一次描述多笔支出', 'changelog.item.aiTextDescription': '用你习惯的语言自然描述,Tally 会一次整理成多笔支出草稿。', 'changelog.item.aiVoiceTitle': '不想打字就直接说', 'changelog.item.aiVoiceDescription': '录音最长 60 秒,Tally 会把里面的支出信息整理成草稿。', 'changelog.item.aiReviewTitle': '最终由你确认', 'changelog.item.aiReviewDescription': '添加到活动前,每一笔草稿都可以检查、修改或删除。', 'changelog.release.liveTitle': '分享更清楚,也更安心', 'changelog.release.liveSummary': '现在可以更直观地选择分享方式,实时协作更可靠,在不同设备上使用也更顺手。', 'changelog.item.liveTitle': '更可靠的实时协作', 'changelog.item.liveDescription': '在线时大家可以一起编辑,每台打开过活动的设备都会保留一份恢复副本。', 'changelog.item.shareTitle': '分享方式一目了然', 'changelog.item.shareDescription': '邀请朋友进入同一个 Live 活动一起编辑,或导出全部支出与余额;Live 总结还会附上邀请二维码。', 'changelog.item.settleTitle': '一起记录结算', 'changelog.item.settleDescription': '支持部分还款或全部结清,在 Live 活动中也会同步更新。', 'changelog.item.polishTitle': '常用操作更顺手', 'changelog.item.polishDescription': '优化了货币与语言菜单、手机端弹窗,以及支出时间的显示。', 'group.newEyebrow': '新活动', 'group.newTitle': '这次要一起分摊什么?', 'group.name': '活动名称', 'group.namePlaceholder': '例如:周末海边', 'group.currency': '活动币种', 'group.currencyHelp': '此活动中的所有支出使用同一种币种。', 'group.chooseCurrency': '活动币种:{currency}', 'group.currencyMenu': '选择活动币种', 'group.addFriends': '添加朋友', 'group.addFriendsHelp': '用逗号分隔多个名字,之后也可以继续添加。', 'group.addFriendsPlaceholder': '小明,小红', 'group.included': '你会自动加入这个活动。', 'group.create': '创建活动', 'friend.eyebrow': '添加成员', 'friend.title': '谁要加入?', 'friend.names': '朋友姓名', 'friend.namesHelp': '多个名字请用逗号分隔。', 'friend.namesPlaceholder': '小明,小红', 'friend.futureOnly': '只影响之后的支出', 'friend.existingOne': '已有的 1 笔支出不会改变。', 'friend.existingMany': '已有的 {count} 笔支出不会改变。', 'friend.add': '添加朋友', 'settlement.title': '记录还款', 'settlement.pays': '付款', 'settlement.receives': '收款', 'settlement.parties': '{from} 支付给 {to}', 'settlement.amount': '还款金额', 'settlement.suggestedAmount': '建议金额:{amount}', 'settlement.invalid': '请输入 {minimum} 到 {amount} 之间的金额。', 'settlement.note': '这会记录全部或部分还款,并重新计算剩余欠款,但不会增加活动总支出。', 'settlement.record': '记录还款', 'expense.entryMethod': '支出录入方式', 'expense.aiTab': '文字 AI', 'expense.manualTab': '手动填写', 'expense.voiceTab': '语音 AI', 'expense.aiTitle': '告诉 Tally 发生了什么', 'expense.aiHelp': '一次描述一笔或多笔支出,Tally 会生成草稿供你确认。', 'expense.aiPrompt': '支出描述', 'expense.aiPlaceholder': '我付了 120 元午餐,小明付了 230 元买菜,两笔都由大家平分', 'expense.aiExample': '请说明每笔支出由谁付款、金额,以及哪些人参与分摊。', 'expense.aiGenerate': '生成草稿', 'expense.aiWorking': '正在生成草稿…', 'expense.aiPrivacy': '这段描述只会发送给当前配置的 AI 服务来生成草稿;确认之前不会保存任何支出。', 'expense.aiClarification': '还需要确认一下', 'expense.aiAnswer': '你的回答', 'expense.aiContinue': '更新草稿', 'expense.aiError': '免费 AI 模型暂时不可用,你可以稍后重试或改为手动填写。', 'expense.aiRateLimit': '免费 AI 模型现在比较忙,请稍后再试或改为手动填写。', 'expense.aiModelUnavailable': '免费 AI 模型和低成本备用模型都没有成功响应。请稍后重试,或改为手动填写。', 'expense.aiCredits': 'Tally 的 AI 额度暂时不可用。请稍后重试,或改为手动填写。', 'expense.aiInvalid': 'Tally 无法生成可靠的草稿。请重新说明每笔支出的金额、付款人和参与分摊的人,或改为手动填写。', 'expense.aiTryAgain': '知道了', 'expense.aiDraftReady': 'AI 草稿已生成', 'expense.aiDraftReview': '保存前请检查每一项信息。', 'expense.batchReady': '已生成 {count} 笔支出草稿', 'expense.batchReview': '保存全部支出前,你可以逐笔检查、修改或删除。', 'expense.batchPaidBy': '{payer} 付款', 'expense.batchPeople': '{count} 人参与', 'expense.batchEdit': '编辑“{title}”', 'expense.batchRemove': '删除“{title}”', 'expense.batchNothingSaved': '点击保存全部之前,不会添加任何支出。', 'expense.batchSave': '保存 {count} 笔支出', 'expense.batchEditing': '正在编辑第 {current}/{total} 笔草稿', 'expense.batchEditingHelp': '修改后会回到草稿列表,确认无误再一起保存。', 'expense.batchBack': '返回草稿列表', 'expense.batchUpdate': '更新草稿', 'expense.voiceTitle': '直接说出支出', 'expense.voiceHelp': '一次说出一笔或多笔支出,包括每笔由谁付款、金额和参与分摊的人。', 'expense.voiceStart': '开始录音', 'expense.voiceStop': '停止录音', 'expense.voiceCancelRequest': '取消麦克风请求', 'expense.voiceListening': '正在听…点击停止', 'expense.voiceProcessing': '正在把录音整理成草稿…', 'expense.voiceRequesting': '正在启动麦克风…', 'expense.voiceLimit': '最长 60 秒', 'expense.voicePermission': '没有获得麦克风权限。请在浏览器设置中允许访问,或改为手动填写。', 'expense.voiceStartTimeout': '所选麦克风未能启动。请在浏览器或系统输入设置中选择可用麦克风,然后刷新重试。文字 AI 和手动填写仍可使用。', 'expense.voiceUnsupported': '这个浏览器暂不支持语音录入,你仍可使用文字 AI 或手动填写。', 'expense.voiceEmpty': '没有听到足够清晰的内容。请简短清楚地再说一次,或改为手动填写。', 'expense.voiceError': '语音录入暂时不可用,请重试或改为手动填写。', 'expense.voiceNetwork': 'Tally 无法连接语音 AI 服务。请检查网络后重试,或改为手动填写。', 'expense.voiceRateLimit': '语音录入次数暂时已用完,文字 AI 和手动填写仍然可用。', 'expense.voiceModelUnavailable': '语音模型暂时没有响应。请稍后重试,或改用文字 AI、手动填写。', 'expense.voiceCredits': '语音 AI 已达到当前预算上限,文字 AI 和手动填写仍然可用。', 'expense.voiceInvalid': 'Tally 无法从这段录音生成可靠草稿。请重新说清每笔支出的金额、付款人和参与分摊的人,或改为手动填写。', 'expense.voicePrivacy': '录音只会发送给当前配置的 AI 服务来生成草稿,Tally 不会保存音频。', 'expense.voiceClarificationHelp': '这个补充问题可以直接输入文字,Tally 会保留录音和之前回答中的信息。', 'expense.addTitle': '添加共同支出', 'expense.editTitle': '编辑支出', 'expense.description': '说明', 'expense.descriptionPlaceholder': '例如:买菜', 'expense.amount': '金额', 'expense.paidBy': '付款人', 'expense.splitMethod': '分摊方式', 'expense.equally': '平均分摊', 'expense.exactAmounts': '指定金额', 'expense.splitBetween': '参与分摊的人', 'expense.selectedCount': '已选择 {selected}/{total} 人', 'expense.includeMember': '让 {name} 参与平均分摊', 'expense.eachShare': '每位已选成员的份额', 'expense.selectOne': '请至少选择一位参与分摊的人。', 'expense.enterShares': '输入每个人的份额', 'expense.left': '还差 {amount}', 'expense.over': '超出 {amount}', 'expense.memberShare': '{name} 的份额', 'expense.editEqualNote': '保存后,这笔支出会按照当前选择的成员重新平均分摊。', 'expense.editExactNote': '保存后,这笔支出会使用活动中当前的全部 {count} 位成员重新分摊。', 'expense.saveChanges': '保存修改', 'expense.save': '保存支出', 'expense.createdAt': '创建于 {date}', 'expense.editedAt': '编辑于 {date}', 'expense.timeUnavailable': '未记录时间', 'dashboard.totalSpent': '总支出', 'dashboard.paid': '{name} 已付款', 'dashboard.yourBalance': '你的余额', 'dashboard.memberBalance': '{name} 的余额', 'dashboard.memberIsOwed': '{name} 应收', 'dashboard.memberOwesBalance': '{name} 应付', 'dashboard.whoOwes': '谁欠谁', 'dashboard.suggestedSettlements': '建议结算方式', 'dashboard.youOwe': '你欠', 'dashboard.memberOwes': '{name} 欠', 'dashboard.owesPerson': '{from} 欠 {to}', 'dashboard.suggestedPayment': '建议付款', 'dashboard.settleUp': '结算', 'dashboard.everyoneSettled': '大家已经结清', 'dashboard.addExpensePrompt': '添加支出后,Tally 会计算谁应该付给谁。', 'dashboard.expenses': '支出记录', 'dashboard.entry': '条记录', 'dashboard.entries': '条记录', 'dashboard.settlementPayment': '还款记录', 'dashboard.paidPerson': '{payer} 支付给 {recipient}', 'dashboard.paidLabel': '{payer} 付款', 'dashboard.splitEqually': '平均分摊', 'dashboard.exactSplit': '指定金额', 'dashboard.editExpense': '编辑支出:{title}', 'dashboard.deleteExpense': '删除支出:{title}', 'dashboard.deletePayment': '删除 {payer} 给 {recipient} 的还款', 'dashboard.editExpenseTitle': '编辑支出', 'dashboard.deleteExpenseTitle': '删除支出', 'dashboard.deleteSettlementTitle': '删除还款', 'dashboard.noMatches': '没有符合搜索条件的支出。', 'dashboard.noExpenses': '还没有支出,添加第一笔吧。', 'dashboard.emptyTitle': '还没有支出', 'dashboard.emptyText': '添加第一笔支出后即可计算余额。', 'dashboard.people': '成员', 'dashboard.currentIdentity': '当前本地身份', 'dashboard.sharedRole': '共享成员', 'dashboard.howTitle': '分摊方式', 'dashboard.howText': '选择付款人,然后在选中的成员间平均分摊,或输入每个人的具体金额。Tally 会自动更新所有余额。', 'dashboard.activityTotal': '活动总额', 'dashboard.activityGroup': '活动群组', 'dashboard.sharing': '{count} {unit}一起分摊支出。', 'dashboard.readOnly': '只读', 'dashboard.editingPaused': '编辑已暂停', 'dashboard.share': '分享', 'dashboard.shareQr': '分享二维码', 'dashboard.shareLive': '实时共享', 'dashboard.shareSummary': '分享总结', 'dashboard.addFriend': '添加朋友', 'dashboard.addExpense': '添加支出', 'dashboard.showQr': '显示二维码', 'dashboard.creator': '活动创建者', 'dashboard.liveRevision': '实时 · 版本 {revision}', 'dashboard.savedRevision': '已保存 · 版本 {revision}', - 'shareMenu.title': '分享活动', 'shareMenu.description': '邀请朋友一起编辑{name},或只发送余额总结。', 'shareMenu.liveBadge': '可编辑 · 自动同步', 'shareMenu.liveTitle': '一起实时编辑', 'shareMenu.currentLiveTitle': '邀请朋友实时编辑', 'shareMenu.liveHelp': '对方可以添加或修改支出,所有人都会看到最新版本。', 'shareMenu.startLive': '创建实时活动', 'shareMenu.copyLive': '复制实时邀请链接', 'shareMenu.liveQr': '显示实时二维码', 'shareMenu.otherTitle': '只想发送结算结果?', 'shareMenu.summary': '仅分享余额总结', 'shareMenu.summaryHelp': '发送总额和谁欠谁,不开放活动访问权限。', 'shareMenu.endLive': '结束实时共享', 'shareMenu.endLiveHelp': '当前邀请链接会立即失效,已打开活动的设备仍会保留恢复副本。', 'shareMenu.endLiveAction': '结束共享', + 'shareMenu.title': '分享活动', 'shareMenu.description': '邀请朋友一起编辑{name},或导出完整总结。', 'shareMenu.liveBadge': '可编辑 · 自动同步', 'shareMenu.liveTitle': '一起实时编辑', 'shareMenu.currentLiveTitle': '邀请朋友实时编辑', 'shareMenu.liveHelp': '对方可以添加或修改支出,所有人都会看到最新版本。', 'shareMenu.startLive': '创建实时活动', 'shareMenu.copyLive': '复制实时邀请链接', 'shareMenu.liveQr': '显示实时二维码', 'shareMenu.otherTitle': '想直接分享一份总结?', 'shareMenu.summary': '导出完整总结', 'shareMenu.summaryHelp': '包含全部支出、还款、总额和谁欠谁。', 'shareMenu.summaryHelpLive': '包含全部支出、还款和余额,并附上实时活动二维码。', 'shareMenu.endLive': '结束实时共享', 'shareMenu.endLiveHelp': '当前邀请链接会立即失效,已打开活动的设备仍会保留恢复副本。', 'shareMenu.endLiveAction': '结束共享', 'live.label': '实时活动', 'live.title': '实时活动 · {code}', 'live.opening': '正在打开实时活动', 'live.saving': '正在保存修改…', 'live.loadingLatest': '正在加载最新版本…', 'live.everyoneCanEdit': '任何拥有这个私密链接的人都可以编辑。', 'live.back': '返回我的活动', 'live.syncedTitle': '实时同步中 · {code}', 'live.syncedText': '所有人都在编辑同一个活动,本设备也会保存一份恢复副本。', 'live.reconnectingTitle': '实时连接已暂停', 'live.offlineTitle': '当前处于离线状态', 'live.cachedText': '这是最后一次同步的副本。重新连接前无法编辑,你也可以创建一个独立副本继续修改。', 'live.endedTitle': '实时共享已结束', 'live.endedText': '最后同步的副本已保存在本设备。你可以继续在本地编辑,需要时再创建新的实时活动。', 'live.unavailableTitle': '无法打开实时活动', 'live.unavailableText': 'Tally 无法打开这个实时活动,并且本设备上没有已保存的副本。', 'live.refresh': '刷新最新内容', 'live.retry': '重试连接', 'live.duplicate': '复制并编辑', 'live.continueLocally': '继续在本地编辑', 'live.copyName': '{name}(副本)', 'live.copyEyebrow': '独立副本', 'live.copyTitle': '你是副本中的哪位成员?', 'live.copyExplanation': '这会创建一个独立的本地活动,之后的修改不会同步回原实时活动。', 'live.copySave': '创建可编辑副本', 'live.recoverEyebrow': '继续本地使用', 'live.recoverTitle': '你是活动中的哪位成员?', 'live.recoverExplanation': '实时共享已经结束。保存后,这个活动会成为可编辑的本地副本。', 'live.recoverSave': '保存可编辑活动', 'live.newChanges': '已自动加载新的共享修改。', 'live.latestLoaded': '已加载最新修改。', 'live.conflict': '有人保存了更新的版本。请先刷新活动,再重新提交修改。', 'live.notFound': '这个实时活动链接无效或已不可用。', 'live.rateLimit': '当前网络的实时活动请求过多,请等待几分钟后重试。', 'live.network': '无法连接实时活动服务,请检查网络后重试。', 'live.invalidInput': '活动里有字段过长,或金额超过支持范围。请修改后重试。', 'live.genericError': '无法更新实时活动,请重试。', 'live.conflictLoaded': '有人保存了更新的版本。最新修改已加载,请确认后重新保存。', 'live.notConfigured': '当前版本未配置实时共享。', 'live.ready': '实时活动 {code} 已创建。当前页面的修改会同步到共享活动。', 'live.addedExpense': '已将“{title}”添加到实时活动。', 'live.addedExpenses': '已将 {count} 笔支出添加到实时活动。', 'live.updatedExpense': '已更新“{title}”,分摊和余额已重新计算。', 'live.deletedExpense': '已从实时活动删除“{title}”。', 'live.creating': '正在创建私密实时活动链接…', 'live.endedByUser': '实时共享已结束,最后同步的副本仍安全保存在本设备。', 'feedback.updatedExpense': '已更新“{title}”,分摊和余额已重新计算。', 'feedback.addedExpenses': '已添加 {count} 笔支出。', 'feedback.currencyChanged': '活动币种已更改为{currency}。', 'feedback.settlement': '{from} 已向 {to} 支付 {amount},剩余余额已重新计算。', 'feedback.liveShared': '实时活动链接已分享,任何拥有链接的人都可以编辑。', 'feedback.liveCopied': '实时活动链接已复制,任何拥有链接的人都可以编辑。', 'feedback.cancelled': '已取消分享。', 'feedback.liveShareFailed': '无法分享实时活动链接,请重试。', 'feedback.liveCopyFailed': '无法复制实时活动链接,请改用“分享链接”。', 'feedback.summaryShared': 'PNG 总结已分享。', 'feedback.summaryCopied': '总结已复制,可以粘贴到聊天中。', 'feedback.summaryDownloaded': 'PNG 总结已下载。', 'feedback.summaryFailed': '无法导出总结,请重试。', 'confirm.deleteSettlementLabel': '这笔还款', 'confirm.deleteExpenseLabel': '“{title}”', 'confirm.eyebrow': '请确认', 'confirm.deleteExpenseTitle': '删除这条记录?', 'confirm.deleteActivityTitle': '删除这个活动?', 'confirm.resetTitle': '清空本地数据?', 'confirm.deleteAction': '删除', 'confirm.resetAction': '清空数据', 'confirm.deleteExpense': '删除{label}?删除后会重新计算所有人的余额。', 'confirm.deleteActivity': '删除“{name}”?这个浏览器中的活动和全部支出都会被删除,且无法恢复。', 'confirm.reset': '清空所有本地活动、朋友和支出?此操作无法撤销。', 'confirm.endLiveTitle': '结束实时共享?', 'confirm.endLive': '所有人会立即无法通过当前邀请链接访问活动。已经打开过活动的设备仍会保留恢复副本。', 'confirm.endLiveAction': '结束实时共享', @@ -442,7 +450,7 @@ const zhCN: Record = { 'handoff.label': '在已安装的 Tally 中继续', 'handoff.title': '已经安装 Tally?', 'handoff.default': 'Safari 无法自动把这个链接切换到已安装的 Web App。', 'handoff.copied': '链接已复制。打开 Tally,选择“加入活动”,然后粘贴链接。', 'handoff.manual': '复制当前页面 URL,然后打开 Tally 并选择“加入活动”。', 'handoff.copy': '复制到 App', 'qr.liveEyebrow': '实时活动{code}', 'qr.scanJoin': '扫码加入 {name}', 'qr.codeLabel': '{name} 的共享活动二维码', 'qr.scanPhone': '使用手机相机扫码', 'qr.liveDescription': '二维码会在 Tally 中打开同一个可编辑活动。', 'qr.livePrivacyTitle': '拥有链接的人都可以编辑', 'qr.livePrivacyText': '链接中包含私密编辑令牌,请只分享给活动成员。', 'qr.copy': '复制链接', 'qr.share': '分享链接', 'sharedIdentity.participant': '你的身份', 'sharedIdentity.becomesYou': '这位成员会成为“你”', - 'share.summaryTitle': 'Tally 总结 — {name}', 'share.totalSpent': '总支出:{amount}', 'share.expenses': '支出记录', 'share.expenseLine': '• {title} — {amount},由 {payer} 付款({split})', 'share.noExpenses': '• 还没有支出。', 'share.equalSplit': '平均分摊', 'share.exactSplit': '指定金额', 'share.recordedSettlements': '已记录的还款', 'share.paymentLine': '• {payer} 向 {recipient} 支付 {amount}', 'share.noPayments': '• 还没有还款记录。', 'share.suggestedPayments': '建议付款', 'share.settlementLine': '• {from} 向 {to} 支付 {amount}', 'share.everyoneSettled': '• 大家已经结清。', 'share.sharedFrom': '通过 Tally 分享', + 'share.summaryTitle': 'Tally 总结 — {name}', 'share.totalSpent': '总支出:{amount}', 'share.expenses': '支出记录', 'share.expenseLine': '• {title} — {amount},由 {payer} 付款({split})', 'share.noExpenses': '• 还没有支出。', 'share.equalSplit': '平均分摊', 'share.exactSplit': '指定金额', 'share.recordedSettlements': '已记录的还款', 'share.paymentLine': '• {payer} 向 {recipient} 支付 {amount}', 'share.noPayments': '• 还没有还款记录。', 'share.suggestedPayments': '建议付款', 'share.settlementLine': '• {from} 向 {to} 支付 {amount}', 'share.everyoneSettled': '• 大家已经结清。', 'share.liveAccess': '打开并编辑实时活动:', 'share.sharedFrom': '通过 Tally 分享', } const catalogs: Record> = { en, 'zh-CN': zhCN }