+ }
+ />
+
+
+ {entries > 0 && (
+
+ }>
+ {t('assistant.card.budget.unconverted', { count: entries, currencies: currencies.join(', ') })}
+
+
+ )}
+ {card.recorded > 0 && (
+
+ {t('costs.paymentsSettled', { count: card.recorded })}
+
+ )}
+
+ );
+}
diff --git a/client/src/components/Assistant/cards/budget/budgetHooks.ts b/client/src/components/Assistant/cards/budget/budgetHooks.ts
new file mode 100644
index 000000000..b5d3906b7
--- /dev/null
+++ b/client/src/components/Assistant/cards/budget/budgetHooks.ts
@@ -0,0 +1,19 @@
+import type { CardPerson } from '@trek/shared';
+
+import { useTranslation } from '../../../../i18n';
+import { useViewerId } from '../parts/useViewerId';
+
+/**
+ * The words the costs' cards say about people, as the Costs page says them:
+ * the signed-in user is "You", and "You paid" rather than "Ana paid".
+ */
+export function useBudgetPeople() {
+ const { t } = useTranslation();
+ const viewerId = useViewerId();
+ const isViewer = (person: CardPerson) => person.id === viewerId;
+ return {
+ nameOf: (person: CardPerson) => (isViewer(person) ? t('costs.you') : person.name),
+ paidBy: (person: CardPerson) =>
+ isViewer(person) ? t('costs.youPaid') : t('assistant.card.budget.paidBy', { name: person.name }),
+ };
+}
diff --git a/client/src/components/Assistant/cards/budget/budgetLook.ts b/client/src/components/Assistant/cards/budget/budgetLook.ts
index ad2670127..99355bc5f 100644
--- a/client/src/components/Assistant/cards/budget/budgetLook.ts
+++ b/client/src/components/Assistant/cards/budget/budgetLook.ts
@@ -1,25 +1,72 @@
-import { Wallet } from 'lucide-react';
+import type { AssistantApplied, TrekCard } from '@trek/shared';
+import { ArrowLeftRight, Wallet } from 'lucide-react';
import type { PackLook } from '../cardTypes';
+import { costsTarget } from './budgetModel';
/**
* What the costs bring to the chat's pure look: the verbs, tool names and steps
* they add, what an applied change of theirs opens, and the look of each of
- * their kinds. An applied expense has no way to open yet, as before. A look
- * stays pure, icons and literals only: the chat's main chunk reads every look,
- * so whatever a look imports ships with it.
+ * their kinds. An applied expense opens itself in the Costs tab, a removed
+ * one and a payment the tab where they were listed. A look stays pure, icons
+ * and literals only: the chat's main chunk reads every look, so whatever a
+ * look imports ships with it.
*/
+
+/** The expense an applied change leaves to open: none once it was removed. */
+function expenseLeft(applied: AssistantApplied, card: TrekCard | null): number | null {
+ const removed = card?.kind === 'expenses' && card.rows[0]?.change === 'willDelete';
+ return removed ? null : (applied.ids[0] ?? null);
+}
+
+/** Whether a card is one of the costs' change cards, which a lookup of the expenses only served. */
+function isBudgetChange(change: TrekCard): boolean {
+ return change.kind === 'expenses' || change.kind === 'payments';
+}
+
export const BUDGET_LOOK = {
- verbs: {},
- tools: {},
- steps: {},
+ verbs: {
+ addPayment: { Icon: ArrowLeftRight, tone: 'success', labelKey: 'assistant.change.verb.addPayment' },
+ updatePayment: { Icon: ArrowLeftRight, tone: 'info', labelKey: 'assistant.change.verb.updatePayment' },
+ },
+ tools: {
+ create_budget_item_with_members: { labelKey: 'assistant.tool.create_budget_item_with_members' },
+ set_budget_item_members: { labelKey: 'assistant.tool.set_budget_item_members' },
+ toggle_budget_member_paid: { labelKey: 'assistant.tool.toggle_budget_member_paid' },
+ create_settlement: { labelKey: 'assistant.tool.create_settlement' },
+ update_settlement: { labelKey: 'assistant.tool.update_settlement' },
+ delete_settlement: { labelKey: 'assistant.tool.delete_settlement' },
+ freeze_budget_rates: { labelKey: 'assistant.tool.freeze_budget_rates' },
+ },
+ steps: {
+ list_settlements: { stepKey: 'assistant.step.costs' },
+ },
entities: {
- budget: { Icon: Wallet },
+ budget: {
+ Icon: Wallet,
+ openLabelKey: 'assistant.card.budget.open',
+ open: (applied: AssistantApplied, card: TrekCard | null) => costsTarget(expenseLeft(applied, card)),
+ },
+ settlement: {
+ Icon: ArrowLeftRight,
+ openLabelKey: 'assistant.card.budget.open',
+ open: () => costsTarget(),
+ },
},
cards: {
- expenses: {},
- payments: {},
- settlement: {},
+ expenses: {
+ entity: () => 'budget',
+ followUps: [{ id: 'whoOwes', labelKey: 'assistant.followUp.whoOwes' }],
+ // The list read to find an expense gives way to the change it was read for.
+ yieldsToChange: (_answer, change) => isBudgetChange(change),
+ },
+ payments: {
+ entity: () => 'settlement',
+ yieldsToChange: (_answer, change) => change.kind === 'payments',
+ },
+ settlement: {
+ followUps: [{ id: 'recordPayments', labelKey: 'assistant.followUp.recordPayments', edits: true }],
+ },
costPerPerson: {},
},
} satisfies PackLook<'expenses' | 'payments' | 'settlement' | 'costPerPerson'>;
diff --git a/client/src/components/Assistant/cards/budget/budgetModel.test.ts b/client/src/components/Assistant/cards/budget/budgetModel.test.ts
new file mode 100644
index 000000000..7ede82939
--- /dev/null
+++ b/client/src/components/Assistant/cards/budget/budgetModel.test.ts
@@ -0,0 +1,137 @@
+/**
+ * FE-AI-CARD-BUD-001 to FE-AI-CARD-BUD-006
+ *
+ * The pure side of the costs' cards: pages of the expense list read as one
+ * list where they meet, the latest settle up wins, an even split of many reads as a
+ * stack, what a run of new expenses adds up to, how far a balance reaches,
+ * and what each card and receipt opens.
+ */
+import type { AssistantApplied, CostPerPersonCard, SettlementCard, TrekCard } from '@trek/shared';
+import { describe, expect, it } from 'vitest';
+
+import { ANA, changeItem, expense, expenses, payment, payments } from '../../../../../tests/helpers/assistantBudget';
+import { CARD_RULES, ENTITY_LOOK } from '../cardLooks';
+import {
+ balanceShare,
+ chipLimit,
+ costsTarget,
+ latestOf,
+ mergeExpenses,
+ mergePayments,
+ sharesAsChips,
+ stackLimit,
+ togetherOf,
+} from './budgetModel';
+
+const entry = (toolCallId: string, card: TrekCard) => ({ toolCallId, card });
+
+const settlement = (recorded: number): SettlementCard => ({
+ kind: 'settlement',
+ currency: 'EUR',
+ flows: [],
+ moreFlows: 0,
+ balances: [],
+ moreBalances: 0,
+ recorded,
+ unconverted: { entries: 0, currencies: [] },
+});
+
+describe('budgetModel', () => {
+ it('FE-AI-CARD-BUD-001: pages of the expense list read as one list where they meet, each expense once', () => {
+ const total = { amount: 90, currency: 'EUR' };
+ const first = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 2, total });
+ const second = expenses([expense({ id: 2 }), expense({ id: 3 })], { more: 2 });
+ const merged = mergeExpenses([entry('a', first), entry('b', second), entry('c', settlement(0))]);
+ expect(merged?.rows.map((row) => row.id)).toEqual([1, 2, 3]);
+ // Four in all: the pages show three of them.
+ expect(merged?.more).toBe(1);
+ expect(merged?.total).toEqual(total);
+ expect(mergeExpenses([entry('c', settlement(0))])).toBeNull();
+ // A page read first that comes later in the list still follows the one before it.
+ const later = expenses([expense({ id: 3 })], { offset: 2, more: 3 });
+ const start = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 2 });
+ expect(mergeExpenses([entry('a', later), entry('b', start)])?.rows.map((row) => row.id)).toEqual([1, 2, 3]);
+ // A page past a gap is counted with the rest instead of read as if it came next.
+ const head = expenses([expense({ id: 1 }), expense({ id: 2 })], { more: 19 });
+ const past = expenses([expense({ id: 21 })], { offset: 20, more: 20 });
+ const gapped = mergeExpenses([entry('a', head), entry('b', past)]);
+ expect(gapped?.rows.map((row) => row.id)).toEqual([1, 2]);
+ expect(gapped?.more).toBe(19);
+ const twice = mergePayments([entry('a', payments([payment()], 3)), entry('b', payments([payment()], 3))]);
+ expect(twice?.rows).toHaveLength(1);
+ expect(twice?.more).toBe(3);
+ });
+
+ it('FE-AI-CARD-BUD-002: settle up and the shares answer once, with the latest card', () => {
+ const shares: CostPerPersonCard = {
+ kind: 'costPerPerson',
+ currency: 'EUR',
+ rows: [{ person: ANA, share: 1, paid: 0, count: 1 }],
+ more: 0,
+ };
+ expect(latestOf([entry('a', settlement(1)), entry('b', settlement(4))], 'settlement')?.recorded).toBe(4);
+ expect(latestOf([entry('a', shares)], 'costPerPerson')).toBe(shares);
+ expect(latestOf([entry('a', shares)], 'settlement')).toBeNull();
+ });
+
+ it('FE-AI-CARD-BUD-003: an uneven split is a badge per person, an even one of many a stack', () => {
+ const many = { split: 'equal' as const, shares: expense().shares, moreShares: 3, highlight: [] };
+ expect(sharesAsChips(many, chipLimit('compact'))).toBe(false);
+ expect(sharesAsChips({ ...many, moreShares: 0 }, chipLimit('compact'))).toBe(true);
+ expect(sharesAsChips({ ...many, split: 'custom' }, chipLimit('compact'))).toBe(true);
+ expect(sharesAsChips(many, chipLimit('roomy'))).toBe(true);
+ expect([chipLimit(undefined), stackLimit(undefined), stackLimit('roomy')]).toEqual([4, 3, 5]);
+ // The person a change is about must stand out, so their split is never a stack.
+ expect(sharesAsChips({ ...many, highlight: [ANA.id] }, chipLimit('compact'))).toBe(true);
+ });
+
+ it('FE-AI-CARD-BUD-004: new expenses add up while two or more are included, in one currency', () => {
+ const fresh = (overrides: Parameters