From fe82484680896b6480ed394695f56c820350ca7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Sat, 3 Oct 2026 15:30:13 +0200 Subject: [PATCH 1/4] fix: never round percentage --- src/components/SplitEditor.tsx | 4 ++-- src/lib/balances.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/SplitEditor.tsx b/src/components/SplitEditor.tsx index 553afe5..d49bf8e 100644 --- a/src/components/SplitEditor.tsx +++ b/src/components/SplitEditor.tsx @@ -45,7 +45,7 @@ export const SplitEditor = withForm({ if (excludedIds.includes(members[i].id)) { form.setFieldValue(`splits[${i}].percentage`, 0); } else if (!lockedSet.has(i)) { - form.setFieldValue(`splits[${i}].percentage`, Math.round(perUnlocked * 100) / 100); + form.setFieldValue(`splits[${i}].percentage`, perUnlocked); } } }; @@ -106,7 +106,7 @@ export const SplitEditor = withForm({ const perUnlocked = unlocked.length > 0 ? remainder / unlocked.length : 0; for (const i of unlocked) { - form.setFieldValue(`splits[${i}].percentage`, Math.round(perUnlocked * 100) / 100); + form.setFieldValue(`splits[${i}].percentage`, perUnlocked); } }; diff --git a/src/lib/balances.ts b/src/lib/balances.ts index 817efb4..388588d 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -40,8 +40,9 @@ export function computeBalances( // permanently owed to the payer). const totalPct = expenseSplits.reduce((sum, s) => sum + s.percentage, 0); if (totalPct === 0) continue; + const divisor = Math.abs(totalPct - 100) < 1e-6 ? 100 : totalPct; for (const split of expenseSplits) { - const owed = (split.percentage / totalPct) * total; + const owed = (split.percentage / divisor) * total; net[split.user] = (net[split.user] ?? 0) - owed; } } @@ -61,7 +62,7 @@ export function computeBalances( // distribute the rounding drift via the largest-remainder method. function roundToZeroSum(members: UsersResponse[], net: Record): Balance[] { const entries = members.map((m) => { - const exactOre = (net[m.id] ?? 0) * 100; + const exactOre = Math.round((net[m.id] ?? 0) * 100 * 1e8) / 1e8; const floorOre = Math.floor(exactOre); return { member: m, ore: floorOre, frac: exactOre - floorOre }; }); From 901bab5bdfb7579355b59080a82f9862e211b946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Sat, 3 Oct 2026 21:00:15 +0200 Subject: [PATCH 2/4] fix: spliteditor display --- src/components/RecurringExpenseForm.tsx | 11 ++- src/components/SplitEditor.tsx | 109 ++++++++++++++++++------ 2 files changed, 88 insertions(+), 32 deletions(-) diff --git a/src/components/RecurringExpenseForm.tsx b/src/components/RecurringExpenseForm.tsx index 7e91de5..7ff72a1 100644 --- a/src/components/RecurringExpenseForm.tsx +++ b/src/components/RecurringExpenseForm.tsx @@ -1,7 +1,7 @@ import { CategoryPicker } from "@/components/CategoryPicker"; -import { ProjectPicker } from "@/components/ProjectPicker"; import { CurrencyPicker } from "@/components/CurrencyPicker"; -import { SplitEditor, type Member } from "@/components/SplitEditor"; +import { ProjectPicker } from "@/components/ProjectPicker"; +import { SplitEditor } from "@/components/SplitEditor"; import { Button } from "@/components/ui/button"; import type { RecurringExpense } from "@/hooks/useApi"; import { useAppForm } from "@/hooks/useAppForm"; @@ -14,7 +14,10 @@ import { todayInput, type RecurringSplit, } from "@/lib/recurring"; -import { RecurringExpensesFrequencyOptions as Frequency } from "@/types/pocketbase-types.gen"; +import { + RecurringExpensesFrequencyOptions as Frequency, + type UsersResponse, +} from "@/types/pocketbase-types.gen"; import { ArrowLeft, Check } from "lucide-react"; import { useState, type ReactNode } from "react"; @@ -37,7 +40,7 @@ export type RecurringExpenseValues = { type Props = { groupId: string; - members: Member[]; + members: UsersResponse[]; /** Group base currency. */ base: string; /** Existing template to edit; omit to create a new one. */ diff --git a/src/components/SplitEditor.tsx b/src/components/SplitEditor.tsx index d49bf8e..24d56c1 100644 --- a/src/components/SplitEditor.tsx +++ b/src/components/SplitEditor.tsx @@ -4,28 +4,72 @@ import { withForm } from "@/hooks/useAppForm"; import { expenseFormDefaults } from "@/lib/expense-form"; import { formatAmount } from "@/lib/format"; import { getAvatarUrl } from "@/lib/pocketbase"; +import type { UsersResponse } from "@/types/pocketbase-types.gen"; import { cn } from "cn"; import { Check, RotateCcw } from "lucide-react"; import { useState } from "react"; -export type Member = { - id: string; - collectionId: string; - collectionName: string; - name: string; - username: string; - avatar: string; -}; +function computeInitialLocked( + splits: { user: string; percentage: number }[] | undefined, + excluded: string[] | undefined, + members: UsersResponse[], +): Set { + const excludedSet = new Set(excluded || []); + const included = members.map((_, i) => i).filter((i) => !excludedSet.has(members[i].id)); + + if (included.length < 2 || !splits || splits.length === 0) return new Set(); + + const pcts = included.map((i) => splits[i]?.percentage ?? 0); + const minPct = Math.min(...pcts); + const maxPct = Math.max(...pcts); + + // If all included members have essentially the same percentage (equal split), + // none are locked. + if (maxPct - minPct < 0.05) { + return new Set(); + } + + // Check if multiple members share the exact same percentage (unlocked remainder). + // E.g. Alice: 50%, Bob: 25%, Charlie: 25% -> Bob & Charlie shared the remainder. + const counts = new Map(); + for (const i of included) { + const rounded = Math.round((splits[i]?.percentage ?? 0) * 100) / 100; + const list = counts.get(rounded) || []; + list.push(i); + counts.set(rounded, list); + } + + for (const list of counts.values()) { + if (list.length >= 2 && list.length < included.length) { + const lockedSet = new Set(); + const unlockedSet = new Set(list); + for (const i of included) { + if (!unlockedSet.has(i)) lockedSet.add(i); + } + return lockedSet; + } + } + + // All included members have distinct percentages: the last included member + // was the sole remainder; all earlier included members were locked. + const lockedSet = new Set(); + for (let idx = 0; idx < included.length - 1; idx++) { + lockedSet.add(included[idx]); + } + return lockedSet; +} export const SplitEditor = withForm({ defaultValues: expenseFormDefaults, props: { - members: [] as Member[], + members: [] as UsersResponse[], currentUserId: "", }, render: function SplitEditorRender({ form, members }) { const totalAmount = parseFloat(form.state.values.amount) || 0; - const [locked, setLocked] = useState>(() => new Set()); + const [locked, setLocked] = useState>(() => + computeInitialLocked(form.state.values.splits, form.state.values.excluded, members), + ); const [displayMode, setDisplayMode] = useState<"percentage" | "value">("percentage"); const [editing, setEditing] = useState<{ index: number; raw: string } | null>(null); @@ -75,9 +119,9 @@ export const SplitEditor = withForm({ }).format((totalAmount * pct) / 100); } return new Intl.NumberFormat(undefined, { - maximumFractionDigits: 1, + maximumFractionDigits: 2, useGrouping: false, - }).format(Math.round(pct * 10) / 10); + }).format(Math.round(pct * 100) / 100); }; const parseLocaleNumber = (raw: string) => parseFloat(raw.replace(",", ".")) || 0; @@ -208,47 +252,56 @@ export const SplitEditor = withForm({ > {isIncluded && } - + - {(member.name || member.username).charAt(0).toUpperCase()} + {(member.name || member.email || "U").charAt(0).toUpperCase()} - {member.name || member.username} + {member.name || member.email?.split("@")[0] || "User"} {isIncluded ? ( isSoleRemainder ? ( - { + const next = new Set(locked); + next.add(i); + setLocked(next); + setEditing({ index: i, raw: formatValue(pct) }); + }} className=" - flex h-10 min-w-20 items-center justify-end px-1 text-lg - text-muted-foreground tabular-nums + flex h-10 w-24 shrink-0 cursor-text items-center justify-end px-1 + text-lg text-muted-foreground tabular-nums transition-colors + hover:text-foreground " + title="Calculated automatically. Click to edit." > {formatValue(pct)} - + ) : ( @@ -272,7 +325,7 @@ export const SplitEditor = withForm({ )} - + {displayMode === "percentage" ? "%" : ""} @@ -311,9 +364,9 @@ export const SplitEditor = withForm({ 0.1 && `text-destructive`)}> {displayMode === "percentage" ? `${new Intl.NumberFormat(undefined, { - maximumFractionDigits: 1, + maximumFractionDigits: 2, useGrouping: false, - }).format(Math.round(totalPct * 10) / 10)}%` + }).format(Math.round(totalPct * 100) / 100)}%` : formatAmount(totalAmount, form.state.values.currency)} From 0ea34267fb32ae6edb573c8e62e1ed7642673be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Sat, 3 Oct 2026 21:02:43 +0200 Subject: [PATCH 3/4] skip the threshold --- src/lib/balances.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/balances.ts b/src/lib/balances.ts index 388588d..03d9d89 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -40,7 +40,7 @@ export function computeBalances( // permanently owed to the payer). const totalPct = expenseSplits.reduce((sum, s) => sum + s.percentage, 0); if (totalPct === 0) continue; - const divisor = Math.abs(totalPct - 100) < 1e-6 ? 100 : totalPct; + const divisor = totalPct; for (const split of expenseSplits) { const owed = (split.percentage / divisor) * total; net[split.user] = (net[split.user] ?? 0) - owed; From 1f947c47ac8d4df52d2d7bfbc6f1ec35ca6232d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emrik=20=C3=96stling?= Date: Sat, 3 Oct 2026 21:18:34 +0200 Subject: [PATCH 4/4] revert all changes in balances --- src/lib/balances.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/balances.ts b/src/lib/balances.ts index 03d9d89..817efb4 100644 --- a/src/lib/balances.ts +++ b/src/lib/balances.ts @@ -40,9 +40,8 @@ export function computeBalances( // permanently owed to the payer). const totalPct = expenseSplits.reduce((sum, s) => sum + s.percentage, 0); if (totalPct === 0) continue; - const divisor = totalPct; for (const split of expenseSplits) { - const owed = (split.percentage / divisor) * total; + const owed = (split.percentage / totalPct) * total; net[split.user] = (net[split.user] ?? 0) - owed; } } @@ -62,7 +61,7 @@ export function computeBalances( // distribute the rounding drift via the largest-remainder method. function roundToZeroSum(members: UsersResponse[], net: Record): Balance[] { const entries = members.map((m) => { - const exactOre = Math.round((net[m.id] ?? 0) * 100 * 1e8) / 1e8; + const exactOre = (net[m.id] ?? 0) * 100; const floorOre = Math.floor(exactOre); return { member: m, ore: floorOre, frac: exactOre - floorOre }; });