diff --git a/.gitignore b/.gitignore index c73a5bed8..6a9c4e0ba 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ certificates .vscode SEED_STATISTICS.md +/scripts/seeded-balance-baseline.json # Agents .worktrees/ diff --git a/package.json b/package.json index 3f1d4dc9e..40cb99d01 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test:integration:watch": "jest --config jest.integration.config.ts --watch", "test:e2e": "PWTEST_CACHE_DIR=node_modules/.cache/playwright playwright test", "test:docker": "bash scripts/test-production-compose.sh", + "test:balances:baseline": "node --env-file-if-exists=.env --import tsx scripts/balance-baseline.ts", "prepare": "husky" }, "dependencies": { diff --git a/prisma/migrations/20261019220000_date_filterable_balances/migration.sql b/prisma/migrations/20261019220000_date_filterable_balances/migration.sql new file mode 100644 index 000000000..f4f380806 --- /dev/null +++ b/prisma/migrations/20261019220000_date_filterable_balances/migration.sql @@ -0,0 +1,19 @@ +-- #604: filter expense-date contributions before reducing balances. +CREATE VIEW public."BalanceEntryView" AS +SELECT ep."expenseId", ep."userId", e."paidBy" AS "friendId", e."groupId", + e.currency, ep.amount, e."expenseDate", e."createdAt", e."updatedAt" +FROM public."ExpenseParticipant" ep +JOIN public."Expense" e ON e.id = ep."expenseId" +WHERE ep."userId" <> e."paidBy" AND e."deletedAt" IS NULL +UNION ALL +SELECT ep."expenseId", e."paidBy", ep."userId", e."groupId", + e.currency, -ep.amount, e."expenseDate", e."createdAt", e."updatedAt" +FROM public."ExpenseParticipant" ep +JOIN public."Expense" e ON e.id = ep."expenseId" +WHERE ep."userId" <> e."paidBy" AND e."deletedAt" IS NULL; + +CREATE INDEX "ExpenseParticipant_userId_expenseId_idx" +ON public."ExpenseParticipant" ("userId", "expenseId") INCLUDE (amount); + +CREATE INDEX "Expense_expenseDate_live_idx" +ON public."Expense" ("expenseDate") WHERE "deletedAt" IS NULL; diff --git a/prisma/migrations/20261019223000_prisma_managed_balance_index/migration.sql b/prisma/migrations/20261019223000_prisma_managed_balance_index/migration.sql new file mode 100644 index 000000000..c39795f49 --- /dev/null +++ b/prisma/migrations/20261019223000_prisma_managed_balance_index/migration.sql @@ -0,0 +1,5 @@ +-- Prisma cannot represent PostgreSQL INCLUDE indexes. Keep the same covering +-- access path with amount as the final key so future migrations retain it. +CREATE INDEX "ExpenseParticipant_userId_expenseId_amount_idx" +ON public."ExpenseParticipant" ("userId", "expenseId", amount); +DROP INDEX public."ExpenseParticipant_userId_expenseId_idx"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85e6dfaf2..f11afc01f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -198,6 +198,7 @@ model Expense { @@index([groupId]) @@index([paidBy]) + // The partial live-expense date index is maintained by SQL migrations. @@schema("public") } @@ -209,6 +210,7 @@ model ExpenseParticipant { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([expenseId, userId]) + @@index([userId, expenseId, amount]) @@schema("public") } @@ -332,6 +334,21 @@ view BalanceView { @@schema("public") } +/// Directed live expense contributions; filter expenseDate before grouping. +view BalanceEntryView { + expenseId String @db.Uuid + userId Int + friendId Int + groupId Int? + currency String + amount BigInt + expenseDate DateTime + createdAt DateTime + updatedAt DateTime + + @@schema("public") +} + enum SplitType { EQUAL PERCENTAGE diff --git a/prisma/views/public/BalanceEntryView.sql b/prisma/views/public/BalanceEntryView.sql new file mode 100644 index 000000000..970eab7aa --- /dev/null +++ b/prisma/views/public/BalanceEntryView.sql @@ -0,0 +1,11 @@ +SELECT ep."expenseId", ep."userId", e."paidBy" AS "friendId", e."groupId", + e.currency, ep.amount, e."expenseDate", e."createdAt", e."updatedAt" +FROM public."ExpenseParticipant" ep +JOIN public."Expense" e ON e.id = ep."expenseId" +WHERE ep."userId" <> e."paidBy" AND e."deletedAt" IS NULL +UNION ALL +SELECT ep."expenseId", e."paidBy", ep."userId", e."groupId", + e.currency, -ep.amount, e."expenseDate", e."createdAt", e."updatedAt" +FROM public."ExpenseParticipant" ep +JOIN public."Expense" e ON e.id = ep."expenseId" +WHERE ep."userId" <> e."paidBy" AND e."deletedAt" IS NULL; diff --git a/scripts/balance-baseline.ts b/scripts/balance-baseline.ts new file mode 100644 index 000000000..546583c09 --- /dev/null +++ b/scripts/balance-baseline.ts @@ -0,0 +1,202 @@ +import { type BalanceView, type Prisma, PrismaClient } from '@prisma/client'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { parseArgs } from 'node:util'; +import { z } from 'zod'; + +import { createDatabaseAdapter } from '../src/server/databaseAdapter'; + +const { values } = parseArgs({ + options: { + capture: { type: 'boolean', default: false }, + entries: { type: 'boolean', default: false }, + snapshot: { type: 'string', default: 'scripts/seeded-balance-baseline.json' }, + }, +}); +assert(!(values.capture && values.entries), 'Capture the legacy implementation before migration'); +const databaseUrl = process.env.DATABASE_URL; +assert( + databaseUrl && ['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname), + 'Use the local seeded database', +); +const db = new PrismaClient({ adapter: createDatabaseAdapter(databaseUrl) }); +const timings: { scope: string; ms: number }[] = []; +const snapshotSchema = z.object({ + capturedAt: z.string(), + expenses: z.number(), + participants: z.number(), + queries: z.array( + z.object({ + name: z.string(), + where: z.object({ + userId: z.number().optional(), + friendId: z.number().optional(), + groupId: z.number().optional(), + }), + rows: z.number(), + digest: z.string(), + }), + ), +}); +const normalize = (rows: BalanceView[]) => + rows + .map((row) => ({ + userId: row.userId, + friendId: row.friendId, + groupId: row.groupId, + currency: row.currency, + amount: row.amount.toString(), + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + })) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); +const digest = (rows: BalanceView[]) => + createHash('sha256') + .update(JSON.stringify(normalize(rows))) + .digest('hex'); + +try { + const saved = values.capture + ? undefined + : snapshotSchema.parse(JSON.parse(await readFile(values.snapshot, 'utf8'))); + // Load the new service only when explicitly requested, after its migration exists. + const queryEntries = values.entries + ? (await import('../src/server/api/services/balanceService')).getBalances + : undefined; + const snapshot = await db.$transaction( + async (tx) => { + await tx.$executeRaw`SET TRANSACTION READ ONLY`; + const [expenses, participants, users, groups] = await Promise.all([ + tx.expense.count(), + tx.expenseParticipant.count(), + tx.user.findMany({ select: { id: true } }), + tx.group.findMany({ select: { id: true } }), + ]); + const all = await tx.balanceView.findMany(); + assert(all.length, 'Seed the database before capturing balances'); + const pairs = [ + ...new Map( + all.map((row) => [ + `${row.userId}/${row.friendId}`, + { userId: row.userId, friendId: row.friendId }, + ]), + ).values(), + ] + .sort((a, b) => a.userId - b.userId || a.friendId - b.friendId) + .slice(0, 50); + const queries = saved?.queries ?? [ + { name: 'all', where: {}, rows: 0, digest: '' }, + ...users.map(({ id }) => ({ + name: `user/${id}`, + where: { userId: id }, + rows: 0, + digest: '', + })), + ...groups.map(({ id }) => ({ + name: `group/${id}`, + where: { groupId: id }, + rows: 0, + digest: '', + })), + ...pairs.map((where) => ({ + name: `pair/${where.userId}/${where.friendId}`, + where, + rows: 0, + digest: '', + })), + ]; + const results = await queries.reduce>(async (pending, query) => { + const previous = await pending; + const where: Prisma.BalanceViewWhereInput = query.where; + const started = performance.now(); + const rows = queryEntries + ? await queryEntries(tx, { where: query.where }) + : await tx.balanceView.findMany({ where }); + timings.push({ scope: query.name.split('/')[0]!, ms: performance.now() - started }); + const result = { ...query, rows: rows.length, digest: digest(rows) }; + if (saved) { + assert.deepEqual(result, query, `Balance baseline changed: ${query.name}`); + } + return [...previous, result]; + }, Promise.resolve([])); + + // Independent arithmetic oracle: no balance SQL/view is used to construct it. + const records = await tx.expense.findMany({ + where: { deletedAt: null }, + include: { expenseParticipants: true }, + }); + const oracle = new Map(); + records.forEach((expense) => + expense.expenseParticipants.forEach((participant) => { + if (participant.userId === expense.paidBy) { + return; + } + [ + { userId: participant.userId, friendId: expense.paidBy, amount: participant.amount }, + { userId: expense.paidBy, friendId: participant.userId, amount: -participant.amount }, + ].forEach((entry) => { + const key = `${entry.userId}/${entry.friendId}/${expense.groupId}/${expense.currency}`; + const existing = oracle.get(key); + oracle.set(key, { + ...entry, + groupId: expense.groupId, + currency: expense.currency, + amount: (existing?.amount ?? 0n) + entry.amount, + createdAt: + existing && existing.createdAt < expense.createdAt + ? existing.createdAt + : expense.createdAt, + updatedAt: + existing && existing.updatedAt > expense.updatedAt + ? existing.updatedAt + : expense.updatedAt, + }); + }); + }), + ); + assert.deepEqual( + normalize(all), + normalize([...oracle.values()]), + 'Legacy view differs from participant arithmetic', + ); + assert.equal( + all.reduce((sum, row) => sum + row.amount, 0n), + 0n, + 'Mirrored amounts must cancel', + ); + if (saved) { + assert.equal(expenses, saved.expenses, 'Seeded expense count changed'); + assert.equal(participants, saved.participants, 'Seeded participant count changed'); + } + return { + capturedAt: saved?.capturedAt ?? new Date().toISOString(), + expenses, + participants, + queries: results, + }; + }, + { timeout: 120_000, isolationLevel: 'RepeatableRead' }, + ); + if (values.capture) { + await writeFile(values.snapshot, `${JSON.stringify(snapshot, null, 2)}\n`, { flag: 'wx' }); + } + console.info( + `${snapshot.queries.length} seeded balance query baselines passed (${values.entries ? 'entries service' : 'legacy view'}); independent monetary/metadata oracle passed.`, + ); + console.table( + ['all', 'user', 'group', 'pair'].map((scope) => { + const samples = timings + .filter((timing) => timing.scope === scope) + .map((timing) => timing.ms) + .sort((a, b) => a - b); + return { + scope, + queries: samples.length, + medianMs: Number(samples[Math.floor(samples.length / 2)]!.toFixed(3)), + }; + }), + ); +} finally { + await db.$disconnect(); +} diff --git a/src/hooks/useBalanceDateRefresh.ts b/src/hooks/useBalanceDateRefresh.ts new file mode 100644 index 000000000..03dc23385 --- /dev/null +++ b/src/hooks/useBalanceDateRefresh.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react'; + +import { getBalanceDay } from '~/lib/balanceDate'; +import { api } from '~/utils/api'; + +/** Header-derived dates do not change query keys, so invalidate on scope changes. */ +export const useBalanceDateRefresh = () => { + const utils = api.useUtils(); + useEffect(() => { + const current = () => getBalanceDay(Intl.DateTimeFormat().resolvedOptions().timeZone); + let scope = current(); + let timer: ReturnType | undefined = undefined; + const refresh = () => { + const next = current(); + if (scope.day !== next.day || scope.timeZone !== next.timeZone) { + Promise.all([ + utils.expense.getBalances.invalidate(), + utils.expense.getCumulatedBalances.invalidate(), + utils.user.getBalancesWithFriend.invalidate(), + utils.group.getAllGroupsWithBalances.invalidate(), + utils.group.getGroupDetails.invalidate(), + ]).catch(console.error); + } + scope = next; + clearTimeout(timer); + timer = setTimeout(refresh, Math.max(1, scope.toDateExclusive.getTime() - Date.now())); + }; + refresh(); + window.addEventListener('focus', refresh); + document.addEventListener('visibilitychange', refresh); + return () => { + clearTimeout(timer); + window.removeEventListener('focus', refresh); + document.removeEventListener('visibilitychange', refresh); + }; + }, [utils]); +}; diff --git a/src/lib/balanceDate.ts b/src/lib/balanceDate.ts new file mode 100644 index 000000000..360c1f112 --- /dev/null +++ b/src/lib/balanceDate.ts @@ -0,0 +1,46 @@ +export const BALANCE_TIME_ZONE_HEADER = 'x-splitpro-time-zone'; + +export const getBalanceDay = ( + timeZone = 'UTC', + now = new Date(), +): { + timeZone: string; + day: string; + toDateExclusive: Date; +} => { + const formatter = (() => { + try { + return new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } catch { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } + })(); + const day = formatter.format(now); + // Search for the next calendar-day boundary rather than adding 24 hours. + // This also handles offset changes at midnight and skipped calendar dates. + const [, end] = Array.from({ length: 28 }).reduce<[number, number]>( + ([start, finish]) => { + if (1 >= finish - start) { + return [start, finish]; + } + const middle = Math.floor((start + finish) / 2); + return formatter.format(new Date(middle)) === day ? [middle, finish] : [start, middle]; + }, + [now.getTime(), now.getTime() + 48 * 60 * 60 * 1000], + ); + return { + timeZone: formatter.resolvedOptions().timeZone, + day, + toDateExclusive: new Date(end), + }; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 330cc8e9e..e520f301a 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -15,6 +15,7 @@ import { ThemeProvider } from 'next-themes'; import { CurrencyHelpersProvider } from '~/contexts/CurrencyHelpersContext'; import { useAppStore } from '~/store/appStore'; import { api } from '~/utils/api'; +import { useBalanceDateRefresh } from '~/hooks/useBalanceDateRefresh'; import 'react-easy-crop/react-easy-crop.css'; import '~/styles/globals.css'; @@ -27,6 +28,7 @@ type AppPropsWithAuth = AppProps<{ session: Session | null }> & { Component: Pag const MyApp = ({ Component, pageProps: { session, ...pageProps } }: AppPropsWithAuth) => { const { t, ready } = useTranslation(); + useBalanceDateRefresh(); if (!ready) { return ( diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index ea821f392..558bc0a62 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -1,7 +1,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { simplifyDebts } from '~/lib/simplify'; import { createTRPCRouter, groupProcedure, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { BigMath, currencyConversion } from '~/utils/numbers'; @@ -19,118 +18,98 @@ import { isCurrencyCode } from '~/lib/currency'; import { SplitType } from '@prisma/client'; import { DEFAULT_CATEGORY } from '~/lib/category'; import { getUserMap } from './user'; +import { + balanceDateRangeSchema, + getBalanceDateRange, + getBalances, + getUserBalances, +} from '../services/balanceService'; export const expenseRouter = createTRPCRouter({ - getCumulatedBalances: protectedProcedure.query(async ({ ctx }) => { - const cumulatedBalances = await db.balanceView.groupBy({ - by: ['currency'], - _sum: { amount: true }, - where: { userId: ctx.session.user.id, amount: { not: 0 } }, - orderBy: { _sum: { amount: 'desc' } }, - }); + getCumulatedBalances: protectedProcedure + .input(balanceDateRangeSchema.optional()) + .query(async ({ ctx, input }) => { + const balances = await getBalances(ctx.db, { + ...getBalanceDateRange(ctx.balanceDay.toDateExclusive, input), + where: { userId: ctx.session.user.id }, + }); + const totals = new Map(); + balances.forEach(({ currency, amount }) => + totals.set(currency, (totals.get(currency) ?? 0n) + amount), + ); + const cumulatedBalances = [...totals] + .map(([currency, amount]) => ({ currency, amount })) + .sort((a, b) => (a.amount > b.amount ? -1 : a.amount < b.amount ? 1 : 0)); - // oxlint-disable no-underscore-dangle -- Prisma's generated aggregate API uses the `_sum` field name. - const youOwe = cumulatedBalances - .filter((b) => b._sum.amount && 0 > b._sum.amount) - .map((b) => ({ currency: b.currency, amount: b._sum.amount! })) - .reverse(); - // oxlint-enable no-underscore-dangle + const youOwe = cumulatedBalances.filter((b) => 0n > b.amount).reverse(); + const youGet = cumulatedBalances.filter((b) => 0n < b.amount); - // oxlint-disable no-underscore-dangle -- Prisma's generated aggregate API uses the `_sum` field name. - const youGet = cumulatedBalances - .filter((b) => b._sum.amount && 0 < b._sum.amount) - .map((b) => ({ currency: b.currency, amount: b._sum.amount! })); - // oxlint-enable no-underscore-dangle + return { youOwe, youGet }; + }), - return { youOwe, youGet }; - }), + getBalances: protectedProcedure + .input(balanceDateRangeSchema.optional()) + .query(async ({ ctx, input }) => { + const userBalances = await getUserBalances( + ctx.db, + ctx.session.user.id, + getBalanceDateRange(ctx.balanceDay.toDateExclusive, input), + ); + const processedBalances = userBalances.filter( + (row) => !ctx.session.user.hiddenFriendIds.includes(row.friendId), + ); - getBalances: protectedProcedure.query(async ({ ctx }) => { - const rawBalances = await db.balanceView.findMany({ - where: { - userId: ctx.session.user.id, - friendId: { notIn: ctx.session.user.hiddenFriendIds }, - }, - include: { - group: { - select: { - simplifyDebts: true, + // Group by friendId and fetch user details + const friendIds = [...new Set([...processedBalances].map((b) => b.friendId))]; + const userMap = await getUserMap(friendIds); + + // Aggregate by (friendId, currency) since same pair can appear in multiple groups + const aggregated = processedBalances + .filter((b) => 0n !== b.amount) + .reduce>( + (acc, { friendId, currency, amount }) => { + const key = `${friendId}-${currency}`; + const existing = acc.get(key); + if (existing) { + existing.amount += amount; + } else { + acc.set(key, { friendId, currency, amount }); + } + return acc; }, - }, - }, - }); - - const processedBalances = await Promise.all( - rawBalances.map(async ({ friendId, currency, amount, groupId, group }) => { - if (!group?.simplifyDebts || null === groupId) { - return { friendId, currency, amount }; - } - - const allGroupBalances = await db.balanceView.findMany({ - where: { groupId, currency }, - }); - - const simplified = simplifyDebts(allGroupBalances); - const simplifiedBalance = simplified.find( - (b) => - b.userId === ctx.session.user.id && b.friendId === friendId && b.currency === currency, + new Map(), ); - return { friendId, currency, amount: simplifiedBalance?.amount ?? 0n }; - }), - ); - - // Group by friendId and fetch user details - const friendIds = [...new Set([...processedBalances].map((b) => b.friendId))]; - const userMap = await getUserMap(friendIds); - - // Aggregate by (friendId, currency) since same pair can appear in multiple groups - const aggregated = processedBalances - .filter((b) => 0n !== b.amount) - .reduce>( - (acc, { friendId, currency, amount }) => { - const key = `${friendId}-${currency}`; - const existing = acc.get(key); - if (existing) { - existing.amount += amount; - } else { - acc.set(key, { friendId, currency, amount }); - } - return acc; - }, - new Map(), - ); - - const balancesByFriend = [...aggregated.values()].reduce< - Record - >((acc, { friendId, currency, amount }) => { - if (!acc[friendId]) { - acc[friendId] = []; - } - acc[friendId].push({ currency, amount }); - return acc; - }, {}); + const balancesByFriend = [...aggregated.values()].reduce< + Record + >((acc, { friendId, currency, amount }) => { + if (!acc[friendId]) { + acc[friendId] = []; + } + acc[friendId].push({ currency, amount }); + return acc; + }, {}); - friendIds.forEach((friendId) => { - if (!balancesByFriend[friendId]) { - balancesByFriend[friendId] = []; - } - }); + friendIds.forEach((friendId) => { + if (!balancesByFriend[friendId]) { + balancesByFriend[friendId] = []; + } + }); - const balances = Object.entries(balancesByFriend) - .map(([friendId, currencies]) => ({ - friendId: Number(friendId), - currencies, - friend: userMap[Number(friendId)]!, - maxAmount: currencies.reduce( - (max, curr) => (BigMath.abs(curr.amount) > BigMath.abs(max) ? curr.amount : max), - 0n, - ), - })) - .sort((a, b) => Number(BigMath.abs(b.maxAmount) - BigMath.abs(a.maxAmount))); - - return { balances }; - }), + const balances = Object.entries(balancesByFriend) + .map(([friendId, currencies]) => ({ + friendId: Number(friendId), + currencies, + friend: userMap[Number(friendId)]!, + maxAmount: currencies.reduce( + (max, curr) => (BigMath.abs(curr.amount) > BigMath.abs(max) ? curr.amount : max), + 0n, + ), + })) + .sort((a, b) => Number(BigMath.abs(b.maxAmount) - BigMath.abs(a.maxAmount))); + + return { balances }; + }), addOrEditExpense: protectedProcedure .input(arrayify(createExpenseSchema)) diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index 96a664438..009401ae3 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -12,6 +12,11 @@ import { parseSerializedDefaultSplit, serializeDefaultSplit, } from '~/lib/defaultSplit'; +import { + balanceDateRangeSchema, + getBalanceDateRange, + getBalances, +} from '../services/balanceService'; export const groupRouter = createTRPCRouter({ create: protectedProcedure @@ -68,9 +73,10 @@ export const groupRouter = createTRPCRouter({ }), getAllGroupsWithBalances: protectedProcedure - .input(z.object({ getArchived: z.boolean() }).optional()) + .input(z.object({ getArchived: z.boolean() }).merge(balanceDateRangeSchema).optional()) .query(async ({ ctx, input }) => { - const groups = await ctx.db.groupUser.findMany({ + const range = getBalanceDateRange(ctx.balanceDay.toDateExclusive, input); + const memberships = await ctx.db.groupUser.findMany({ where: { userId: ctx.session.user.id, group: { @@ -80,10 +86,6 @@ export const groupRouter = createTRPCRouter({ include: { group: { include: { - groupBalances: { - where: { userId: ctx.session.user.id }, - }, - // We can sort by group balance view instead expenses: { orderBy: { createdAt: 'desc', @@ -95,6 +97,24 @@ export const groupRouter = createTRPCRouter({ }, }); + const groupBalances = + 0 === memberships.length + ? [] + : await getBalances(ctx.db, { + ...range, + where: { + userId: ctx.session.user.id, + groupId: { in: memberships.map((row) => row.groupId) }, + }, + }); + const groups = memberships.map((membership) => ({ + userId: membership.userId, + groupId: membership.groupId, + group: Object.assign(membership.group, { + groupBalances: groupBalances.filter((row) => row.groupId === membership.groupId), + }), + })); + const sortedGroupsByLatestExpense = groups.sort((a, b) => { const aDate = a.group.expenses[0]?.createdAt ?? new Date(0); const bDate = b.group.expenses[0]?.createdAt ?? new Date(0); @@ -139,7 +159,7 @@ export const groupRouter = createTRPCRouter({ return group; }), - getGroupDetails: groupProcedure.query(async ({ input, ctx }) => { + getGroupDetails: groupProcedure.input(balanceDateRangeSchema).query(async ({ input, ctx }) => { const group = await ctx.db.group.findUnique({ where: { id: input.groupId, @@ -150,7 +170,6 @@ export const groupRouter = createTRPCRouter({ user: true, }, }, - groupBalances: true, groupDefaultSplit: true, }, }); @@ -159,9 +178,10 @@ export const groupRouter = createTRPCRouter({ return group; } - if (group.simplifyDebts) { - group.groupBalances = simplifyDebts(group.groupBalances); - } + const groupBalances = await getBalances(ctx.db, { + ...getBalanceDateRange(ctx.balanceDay.toDateExclusive, input), + where: { groupId: group.id }, + }); const defaultSplit = group.groupDefaultSplit && @@ -172,6 +192,7 @@ export const groupRouter = createTRPCRouter({ return { ...group, + groupBalances: group.simplifyDebts ? simplifyDebts(groupBalances) : groupBalances, defaultSplit, }; }), diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index ec36a6063..6d284e43f 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -9,13 +9,17 @@ import { } from '~/lib/defaultSplit'; import { claimInviteCooldown } from '~/lib/inviteCooldown'; import { InviteErrorCode } from '~/lib/error/invite'; -import { simplifyDebts } from '~/lib/simplify'; import { AppError } from '~/server/api/appError'; import { createTRPCRouter, protectedProcedure } from '~/server/api/trpc'; import { db } from '~/server/db'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types'; +import { + balanceDateRangeSchema, + getBalanceDateRange, + getUserBalances, +} from '../services/balanceService'; import { getSubscriptionEndpoint, sendPushNotificationToUsers, @@ -115,62 +119,22 @@ export const userRouter = createTRPCRouter({ }), getBalancesWithFriend: protectedProcedure - .input(z.object({ friendId: z.number() })) + .input(z.object({ friendId: z.number() }).merge(balanceDateRangeSchema)) .query(async ({ input, ctx }) => { - const rawBalances = await db.balanceView.findMany({ - where: { - userId: ctx.session.user.id, - friendId: input.friendId, - amount: { not: 0 }, - }, - include: { - group: { - select: { - name: true, - simplifyDebts: true, - }, - }, - }, - }); - - const processedBalances = await Promise.all( - rawBalances.map(async ({ groupId, currency, amount, group }) => { - // For non-simplifyDebts groups and non-group balances, use raw balance - if (!group?.simplifyDebts || null === groupId) { - return { - friendId: input.friendId, - currency, - amount, - groupId, - groupName: group?.name ?? null, - }; - } - - // For simplifyDebts groups, fetch all group balances and simplify - const allGroupBalances = await db.balanceView.findMany({ - where: { groupId, currency }, - }); - - const simplified = simplifyDebts(allGroupBalances); - - const simplifiedBalance = simplified.find( - (b) => - b.userId === ctx.session.user.id && - b.friendId === input.friendId && - b.currency === currency, - ); - - return { - friendId: input.friendId, - currency, - amount: simplifiedBalance?.amount ?? 0n, - groupId, - groupName: group.name, - }; - }), + const balances = await getUserBalances( + ctx.db, + ctx.session.user.id, + getBalanceDateRange(ctx.balanceDay.toDateExclusive, input), ); - - return processedBalances.filter((b) => 0n !== b.amount); + return balances + .filter((row) => row.friendId === input.friendId && 0n !== row.amount) + .map(({ friendId, currency, amount, groupId, group }) => ({ + friendId, + currency, + amount, + groupId, + groupName: group?.name ?? null, + })); }), updateUserDetail: protectedProcedure diff --git a/src/server/api/services/balanceService.ts b/src/server/api/services/balanceService.ts new file mode 100644 index 000000000..28e3a811e --- /dev/null +++ b/src/server/api/services/balanceService.ts @@ -0,0 +1,93 @@ +import { type BalanceView, type Prisma } from '@prisma/client'; +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { simplifyDebts } from '~/lib/simplify'; + +export const balanceDateRangeSchema = z.object({ + fromDate: z.date().optional(), + toDateExclusive: z.date().optional(), +}); +type BalanceDateRange = z.infer; +type BalanceScope = Pick< + Prisma.BalanceEntryViewWhereInput, + 'userId' | 'friendId' | 'groupId' | 'currency' +>; +type BalanceClient = Pick; + +export const getBalanceDateRange = ( + defaultToDateExclusive: Date, + input?: BalanceDateRange, +): BalanceDateRange => { + const toDateExclusive = input?.toDateExclusive ?? defaultToDateExclusive; + if (input?.fromDate && input.fromDate >= toDateExclusive) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'fromDate must precede toDateExclusive' }); + } + return { fromDate: input?.fromDate, toDateExclusive }; +}; + +/** No date bounds means all history, including future-dated obligations. */ +export const getBalances = async ( + client: Pick, + { where, fromDate, toDateExclusive }: BalanceDateRange & { where?: BalanceScope } = {}, +): Promise => { + const rows = await client.balanceEntryView.groupBy({ + by: ['userId', 'friendId', 'groupId', 'currency'], + where: { + ...where, + expenseDate: { gte: fromDate, lt: toDateExclusive }, + }, + _sum: { amount: true }, + _min: { createdAt: true }, + _max: { updatedAt: true }, + }); + return rows.map(({ userId, friendId, groupId, currency, _sum: sum, _min: min, _max: max }) => ({ + userId, + friendId, + groupId, + currency, + amount: sum.amount ?? 0n, + // Every group has a contribution with non-null timestamps. + createdAt: min.createdAt!, + updatedAt: max.updatedAt!, + })); +}; + +/** Batch full group graphs before simplification, then select the viewer's rows. */ +export const getUserBalances = async ( + client: BalanceClient, + userId: number, + range: BalanceDateRange, +) => { + const raw = await getBalances(client, { ...range, where: { userId } }); + const groupIds = [...new Set(raw.flatMap((row) => (null === row.groupId ? [] : [row.groupId])))]; + const groups = + 0 === groupIds.length + ? [] + : await client.group.findMany({ + where: { id: { in: groupIds } }, + select: { id: true, name: true, simplifyDebts: true }, + }); + const simplifiedIds = groups.filter((group) => group.simplifyDebts).map((group) => group.id); + const graphs = + 0 === simplifiedIds.length + ? [] + : await getBalances(client, { + ...range, + where: { groupId: { in: simplifiedIds } }, + }); + const processed = [ + ...raw.filter((row) => null === row.groupId || !simplifiedIds.includes(row.groupId)), + ...simplifiedIds.flatMap((groupId) => + simplifyDebts(graphs.filter((row) => row.groupId === groupId)).filter( + (row) => row.userId === userId, + ), + ), + ]; + const groupMap = new Map(groups.map((group) => [group.id, group])); + return processed.map((row) => + Object.assign(row, { + group: null === row.groupId ? null : (groupMap.get(row.groupId) ?? null), + }), + ); +}; diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 907849d7b..ec9268767 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -17,6 +17,7 @@ import { ZodError, z } from 'zod'; import { getAppErrorCode } from '~/server/api/appError'; import { getServerAuthSession } from '~/server/auth'; import { db } from '~/server/db'; +import { BALANCE_TIME_ZONE_HEADER, getBalanceDay } from '~/lib/balanceDate'; /** * 1. CONTEXT @@ -29,6 +30,8 @@ import { db } from '~/server/db'; export interface CreateContextOptions { session: Session | null; db?: PrismaClient; + timeZone?: string; + now?: Date; } /** @@ -44,6 +47,7 @@ export interface CreateContextOptions { export const createInnerTRPCContext = (opts: CreateContextOptions) => ({ session: opts.session, db: opts.db ?? db, + balanceDay: getBalanceDay(opts.timeZone, opts.now), }); /** @@ -60,6 +64,10 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { return createInnerTRPCContext({ session, + timeZone: + 'string' === typeof req.headers[BALANCE_TIME_ZONE_HEADER] + ? req.headers[BALANCE_TIME_ZONE_HEADER] + : undefined, }); }; diff --git a/src/tests/balanceDate.test.ts b/src/tests/balanceDate.test.ts new file mode 100644 index 000000000..e2a5ee99f --- /dev/null +++ b/src/tests/balanceDate.test.ts @@ -0,0 +1,29 @@ +import { getBalanceDay } from '~/lib/balanceDate'; + +describe('getBalanceDay', () => { + it.each([ + ['UTC', '2025-03-09T12:00:00Z', '2025-03-10T00:00:00.000Z'], + ['America/New_York', '2025-03-09T05:00:00Z', '2025-03-10T04:00:00.000Z'], + ['America/New_York', '2025-11-02T04:00:00Z', '2025-11-03T05:00:00.000Z'], + ['Asia/Kathmandu', '2025-01-01T00:00:00Z', '2025-01-01T18:15:00.000Z'], + ['Pacific/Apia', '2011-12-29T12:00:00Z', '2011-12-30T10:00:00.000Z'], + ['America/Santiago', '2025-09-06T12:00:00Z', '2025-09-07T04:00:00.000Z'], + ])('finds the next calendar day in %s at %s', (zone, now, expected) => { + expect(getBalanceDay(zone, new Date(now)).toDateExclusive.toISOString()).toBe(expected); + }); + + it('changes scope at exactly local midnight', () => { + const before = getBalanceDay('America/New_York', new Date('2025-03-10T03:59:59.999Z')); + const after = getBalanceDay('America/New_York', new Date('2025-03-10T04:00:00Z')); + expect(before.day).not.toBe(after.day); + expect(after.toDateExclusive.toISOString()).toBe('2025-03-11T04:00:00.000Z'); + }); + + it('defaults absent and invalid zones to UTC', () => { + const now = new Date('2025-01-01T23:00:00Z'); + expect(getBalanceDay('not-a-zone', now)).toEqual(getBalanceDay(undefined, now)); + expect(getBalanceDay(undefined, now).toDateExclusive.toISOString()).toBe( + '2025-01-02T00:00:00.000Z', + ); + }); +}); diff --git a/src/tests/balanceDateRefresh.test.tsx b/src/tests/balanceDateRefresh.test.tsx new file mode 100644 index 000000000..d862ccad3 --- /dev/null +++ b/src/tests/balanceDateRefresh.test.tsx @@ -0,0 +1,66 @@ +/** @jest-environment jsdom */ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { useBalanceDateRefresh } from '~/hooks/useBalanceDateRefresh'; + +const invalidate = jest.fn().mockResolvedValue(undefined); +const utils = { + expense: { getBalances: { invalidate }, getCumulatedBalances: { invalidate } }, + user: { getBalancesWithFriend: { invalidate } }, + group: { getAllGroupsWithBalances: { invalidate }, getGroupDetails: { invalidate } }, +}; +jest.mock('~/utils/api', () => ({ api: { useUtils: () => utils } })); + +const Refresh = () => { + useBalanceDateRefresh(); + return null; +}; + +const mockTimeZone = (getZone: () => string) => { + const DateTimeFormat = Intl.DateTimeFormat; + jest + .spyOn(Intl, 'DateTimeFormat') + .mockImplementation( + (locales, options) => + new DateTimeFormat(locales, { ...options, timeZone: options?.timeZone ?? getZone() }), + ); +}; + +describe('balance date cache refresh', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T23:59:59Z')); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + }); + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('refreshes balances at local midnight and cleans up timers on unmount', async () => { + mockTimeZone(() => 'UTC'); + const root = createRoot(document.createElement('div')); + await act(async () => root.render()); + expect(jest.getTimerCount()).toBeGreaterThan(0); + expect(invalidate).not.toHaveBeenCalled(); + await act(async () => jest.advanceTimersByTime(1000)); + expect(invalidate).toHaveBeenCalledTimes(5); + await act(async () => root.unmount()); + jest.runOnlyPendingTimers(); + expect(jest.getTimerCount()).toBe(0); + }); + + it('refreshes a suspended tab after a timezone change', async () => { + let timeZone = 'UTC'; + mockTimeZone(() => timeZone); + const root = createRoot(document.createElement('div')); + await act(async () => root.render()); + timeZone = 'Asia/Tokyo'; + await act(async () => window.dispatchEvent(new Event('focus'))); + expect(invalidate).toHaveBeenCalledTimes(5); + await act(async () => window.dispatchEvent(new Event('focus'))); + expect(invalidate).toHaveBeenCalledTimes(5); + await act(async () => root.unmount()); + }); +}); diff --git a/src/tests/integration/balanceDates.integration.test.ts b/src/tests/integration/balanceDates.integration.test.ts new file mode 100644 index 000000000..d0df5064c --- /dev/null +++ b/src/tests/integration/balanceDates.integration.test.ts @@ -0,0 +1,234 @@ +import { getBalances } from '~/server/api/services/balanceService'; + +import { db } from './database'; +import { expenseInput, testScenario } from './factories'; +import { callerFor } from './trpc'; + +const now = new Date('2025-03-09T12:00:00Z'); + +describe('date-aware balances (#604)', () => { + it('keeps Prisma aggregation exact above the JavaScript safe integer range', async () => { + const { owner, member, group } = await testScenario(); + const viewer = callerFor(owner.id, { now }); + await viewer.expense.addOrEditExpense( + expenseInput(owner.id, member.id, { + groupId: group.id, + amount: 18014398509481986n, + expenseDate: now, + }), + ); + expect((await viewer.user.getBalancesWithFriend({ friendId: member.id }))[0]?.amount).toBe( + 9007199254740993n, + ); + }); + + it('dates settlements and both legs of a currency conversion consistently', async () => { + const { owner, member, group, input } = await testScenario(); + const viewer = callerFor(owner.id, { now }); + await viewer.expense.addOrEditExpense({ ...input, expenseDate: now }); + await callerFor(member.id).expense.addOrEditExpense({ + ...expenseInput(member.id, owner.id, { groupId: group.id }), + splitType: SplitType.SETTLEMENT, + expenseDate: new Date('2025-04-01'), + }); + expect((await viewer.user.getBalancesWithFriend({ friendId: member.id }))[0]?.amount).toBe( + 1000n, + ); + expect( + (await getBalances(db, { where: { groupId: group.id, userId: owner.id } }))[0]?.amount, + ).toBe(0n); + await viewer.expense.addOrEditCurrencyConversion({ + amount: 1000n, + rate: 0.9, + from: 'USD', + to: 'EUR', + senderId: owner.id, + receiverId: member.id, + groupId: group.id, + }); + // Align both linked legs to a deterministic future date after the real-time write. + await db.expense.updateMany({ + where: { splitType: SplitType.CURRENCY_CONVERSION }, + data: { expenseDate: new Date('2025-04-01') }, + }); + expect(await viewer.user.getBalancesWithFriend({ friendId: member.id })).toEqual([ + expect.objectContaining({ currency: 'USD', amount: 1000n }), + ]); + const due = callerFor(owner.id, { now: new Date('2025-04-01') }); + expect(await due.user.getBalancesWithFriend({ friendId: member.id })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ currency: 'USD', amount: 1000n }), + expect.objectContaining({ currency: 'EUR', amount: -900n }), + ]), + ); + }); + + it('filters before summing, respects viewer-local days and exposes optional ranges', async () => { + const { owner, member, group } = await testScenario(); + const viewer = callerFor(owner.id, { timeZone: 'America/New_York', now }); + const dates = [ + '2025-03-08T12:00:00Z', + '2025-03-09T05:00:00Z', + '2025-03-10T03:59:59.999Z', + '2025-03-10T04:00:00Z', + ]; + await dates.reduce(async (pending, date) => { + await pending; + await viewer.expense.addOrEditExpense( + expenseInput(owner.id, member.id, { + groupId: group.id, + expenseDate: new Date(date), + }), + ); + }, Promise.resolve()); + expect((await viewer.expense.getBalances()).balances[0]?.currencies).toEqual([ + { currency: 'USD', amount: 3000n }, + ]); + expect(await viewer.expense.getCumulatedBalances()).toEqual({ + youOwe: [], + youGet: [{ currency: 'USD', amount: 3000n }], + }); + expect(await viewer.user.getBalancesWithFriend({ friendId: member.id })).toEqual([ + expect.objectContaining({ amount: 3000n, groupId: group.id }), + ]); + expect((await viewer.group.getAllGroupsWithBalances())[0]?.balances).toEqual({ USD: 3000n }); + expect((await viewer.group.getGroupDetails({ groupId: group.id }))?.groupBalances).toEqual( + expect.arrayContaining([expect.objectContaining({ userId: owner.id, amount: 3000n })]), + ); + const period = { + fromDate: new Date('2025-03-09T05:00:00Z'), + toDateExclusive: new Date('2025-03-10T04:00:00Z'), + }; + expect( + (await viewer.user.getBalancesWithFriend({ friendId: member.id, ...period }))[0]?.amount, + ).toBe(2000n); + const utc = callerFor(owner.id, { timeZone: 'UTC', now }); + expect((await utc.user.getBalancesWithFriend({ friendId: member.id }))[0]?.amount).toBe(2000n); + const tomorrow = callerFor(owner.id, { + timeZone: 'America/New_York', + now: period.toDateExclusive, + }); + expect((await tomorrow.user.getBalancesWithFriend({ friendId: member.id }))[0]?.amount).toBe( + 4000n, + ); + expect( + (await getBalances(db, { where: { groupId: group.id, userId: owner.id } }))[0]?.amount, + ).toBe(4000n); + await expect( + viewer.expense.getBalances({ fromDate: now, toDateExclusive: now }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('retains future obligations for mutation guards and friend discovery', async () => { + const { owner, member, group, input } = await testScenario(); + const viewer = callerFor(owner.id, { now }); + await viewer.expense.addOrEditExpense({ ...input, expenseDate: new Date('2025-04-01') }); + expect((await viewer.expense.getBalances()).balances).toEqual([]); + expect(await viewer.user.getBalancesWithFriend({ friendId: member.id })).toEqual([]); + expect(await viewer.user.getFriends()).toEqual( + expect.arrayContaining([expect.objectContaining({ id: member.id })]), + ); + await expect(viewer.user.deleteFriend({ friendId: member.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect(viewer.group.leaveGroup({ groupId: group.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect(viewer.group.toggleArchive({ groupId: group.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect(viewer.group.delete({ groupId: group.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('keeps a future recurring template out of balances until its expense day', async () => { + const { owner, member, input } = await testScenario(); + const viewer = callerFor(owner.id, { now }); + await viewer.expense.addOrEditExpense({ + ...input, + expenseDate: new Date('2025-04-30T12:00:00Z'), + cronExpression: '0 0 30 * *', + }); + expect(await db.expenseRecurrence.count()).toBe(1); + expect(await viewer.user.getBalancesWithFriend({ friendId: member.id })).toEqual([]); + const due = callerFor(owner.id, { now: new Date('2025-04-30T00:00:00Z') }); + expect((await due.user.getBalancesWithFriend({ friendId: member.id }))[0]?.amount).toBe(1000n); + }); + + it('recomputes edited/deleted contributions and retains a zero net row', async () => { + const { owner, member, input } = await testScenario(); + const viewer = callerFor(owner.id, { now }); + const current = { ...input, expenseDate: new Date('2025-03-09T12:00:00Z') }; + const [expense] = await viewer.expense.addOrEditExpense(current); + await viewer.expense.addOrEditExpense({ + ...current, + expenseId: expense!.id, + expenseDate: new Date('2025-04-01'), + }); + expect(await viewer.user.getBalancesWithFriend({ friendId: member.id })).toEqual([]); + await viewer.expense.addOrEditExpense({ ...current, expenseId: expense!.id }); + await callerFor(member.id).expense.addOrEditExpense( + expenseInput(member.id, owner.id, { + groupId: input.groupId, + expenseDate: current.expenseDate, + }), + ); + expect( + ( + await getBalances(db, { + where: { userId: owner.id }, + toDateExclusive: new Date('2025-03-10'), + }) + )[0]?.amount, + ).toBe(0n); + expect(await viewer.user.getBalancesWithFriend({ friendId: member.id })).toEqual([]); + await viewer.expense.deleteExpense({ expenseId: expense!.id }); + expect((await viewer.user.getBalancesWithFriend({ friendId: member.id }))[0]?.amount).toBe( + -1000n, + ); + }); + + it('simplifies the complete group graph at one cutoff, including newly implied friends', async () => { + const { owner, member, outsider, group } = await testScenario(); + const viewer = callerFor(owner.id, { now }); + await viewer.group.addMembers({ groupId: group.id, userIds: [outsider.id] }); + await viewer.expense.addOrEditExpense( + expenseInput(owner.id, member.id, { + groupId: group.id, + expenseDate: now, + }), + ); + await callerFor(member.id).expense.addOrEditExpense( + expenseInput(member.id, outsider.id, { + groupId: group.id, + expenseDate: now, + }), + ); + await callerFor(outsider.id).expense.addOrEditExpense( + expenseInput(outsider.id, owner.id, { + groupId: group.id, + expenseDate: new Date('2025-04-01'), + }), + ); + await viewer.group.toggleSimplifyDebts({ groupId: group.id }); + const details = await viewer.group.getGroupDetails({ groupId: group.id }); + expect(details?.groupBalances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: owner.id, friendId: outsider.id, amount: 1000n }), + ]), + ); + expect((await viewer.expense.getBalances()).balances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + friendId: outsider.id, + currencies: [{ currency: 'USD', amount: 1000n }], + }), + ]), + ); + expect((await viewer.user.getBalancesWithFriend({ friendId: outsider.id }))[0]?.amount).toBe( + 1000n, + ); + }); +}); +import { SplitType } from '@prisma/client'; diff --git a/src/tests/integration/trpc.ts b/src/tests/integration/trpc.ts index 1ab394b20..91bf130d3 100644 --- a/src/tests/integration/trpc.ts +++ b/src/tests/integration/trpc.ts @@ -16,7 +16,11 @@ export const sessionFor = (userId: number): Session => ({ expires: '2099-01-01T00:00:00.000Z', }); -export const callerFor = (userId: number | null) => +export const callerFor = (userId: number | null, options: { timeZone?: string; now?: Date } = {}) => appRouter.createCaller( - createInnerTRPCContext({ session: null === userId ? null : sessionFor(userId), db }), + createInnerTRPCContext({ + session: null === userId ? null : sessionFor(userId), + db, + ...options, + }), ); diff --git a/src/utils/api.ts b/src/utils/api.ts index 9e7c9a045..6528331a4 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -10,6 +10,7 @@ import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'; import superjson from 'superjson'; import { type AppRouter } from '~/server/api/root'; +import { BALANCE_TIME_ZONE_HEADER } from '~/lib/balanceDate'; export const getBaseUrl = () => { if ('undefined' !== typeof window) { @@ -38,6 +39,9 @@ export const api = createTRPCNext({ }), httpBatchLink({ url: `${getBaseUrl()}/api/trpc`, + headers: () => ({ + [BALANCE_TIME_ZONE_HEADER]: Intl.DateTimeFormat().resolvedOptions().timeZone, + }), transformer: superjson, }), ],