Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,18 +71,20 @@ export default async function DashboardPage() {
// same bank marks the accounts page does, rather than falling back to generic
// type glyphs for accounts it has a logo for.
const accounts = accountGroups.flatMap((group) =>
group.accounts
.filter((a) => !a.isHidden)
.map((a) => ({
id: a.id,
name: a.name,
type: a.type,
currentBalance: a.currentBalance,
currency: a.currency,
institutionName: group.institutionName,
logoBase64: group.logoBase64,
primaryColor: group.primaryColor,
})),
group.accounts.map((a) => ({
id: a.id,
name: a.name,
type: a.type,
currentBalance: a.currentBalance,
currency: a.currency,
// Carried rather than filtered here: the balances widget regroups these
// with groupAccountsByType, which drops hidden accounts itself so its
// subtotals match the ones on the Accounts page.
isHidden: a.isHidden,
institutionName: group.institutionName,
logoBase64: group.logoBase64,
primaryColor: group.primaryColor,
})),
);

// The Spending tile reports `spendingMonth`, which is the latest month with
Expand Down Expand Up @@ -129,6 +131,8 @@ export default async function DashboardPage() {
<TransferReviewNudge suggestedCount={suggestedTransferCount} />
<NetWorthHero
netWorth={summary.netWorth}
assets={summary.assets}
liabilities={summary.liabilities}
initialHistory={netWorthHistory}
initialRange={heroRange}
fullCoverageSince={fullCoverageSince}
Expand Down
4 changes: 3 additions & 1 deletion src/components/molecules/entity-avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface EntityAvatarProps {
primaryColor?: string | null;
pfcPrimary?: string | null;
size?: "sm" | "md";
className?: string;
}

const fallbackTextClass = {
Expand All @@ -23,12 +24,13 @@ export function EntityAvatar({
primaryColor,
pfcPrimary,
size = "md",
className,
}: EntityAvatarProps) {
const resolved = resolveEntityLogo({ logoUrl, logoBase64, name, primaryColor, pfcPrimary });
const { initial, backgroundColor } = getInitials(name, primaryColor);

return (
<Avatar size={size === "sm" ? "sm" : "default"} aria-hidden="true">
<Avatar size={size === "sm" ? "sm" : "default"} className={className} aria-hidden="true">
{resolved.type === "image" && (
<AvatarImage src={resolved.src} alt="" className="bg-white" />
)}
Expand Down
22 changes: 13 additions & 9 deletions src/components/molecules/transaction-date-header.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import { AmountDisplay } from "@/components/atoms/amount-display";
import { dayCountLabel, type DaySummary } from "@/lib/transaction-day-summary";

interface TransactionDateHeaderProps {
date: string;
transactionCount: number;
netAmount: number;
summary: DaySummary;
currency?: string;
}

export function TransactionDateHeader({
date,
transactionCount,
netAmount,
summary,
currency = "USD",
}: TransactionDateHeaderProps) {
const formatted = new Date(date + "T00:00:00").toLocaleDateString("en-US", {
Expand All @@ -22,11 +21,16 @@ export function TransactionDateHeader({
return (
<div className="sticky top-0 z-10 flex items-center gap-2 h-8 px-2 bg-background border-b group-data-[bulk-active]/list:top-14">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{formatted}</span>
<span className="text-xs text-muted-foreground">
· {transactionCount} transaction{transactionCount !== 1 ? "s" : ""}
</span>
<span className="text-xs text-muted-foreground">·</span>
<AmountDisplay amount={netAmount} currency={currency} className="text-xs" />
<span className="text-xs text-muted-foreground">· {dayCountLabel(summary)}</span>
{/* A day of nothing but transfers has no spending to net, and "+$0.00"
beside "2 transfers" reads as a day that earned nothing rather than a
day that was never counted. */}
{summary.spendingCount > 0 && (
<>
<span className="text-xs text-muted-foreground">·</span>
<AmountDisplay amount={summary.net} currency={currency} className="text-xs" />
</>
)}
</div>
);
}
21 changes: 19 additions & 2 deletions src/components/molecules/transaction-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,18 @@ export const TransactionRow = memo(function TransactionRow({
name={txn.merchantName ?? txn.name}
pfcPrimary={txn.pfcPrimary}
size="sm"
className={cn(txn.isTransfer && "opacity-60")}
/>
<div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
{txn.pending && <Clock className="size-3 text-muted-foreground shrink-0" />}
<span className="font-medium truncate">{txn.name}</span>
<span
className={cn(
"truncate",
txn.isTransfer ? "font-normal text-muted-foreground" : "font-medium",
)}
>
{txn.name}
</span>
{txn.originalName !== txn.name && (
<span className="text-xs text-muted-foreground hidden group-hover/row:inline truncate">
({txn.originalName})
Expand Down Expand Up @@ -133,8 +141,17 @@ export const TransactionRow = memo(function TransactionRow({
/>
</div>

{/* Transfers recede by colour, not by `opacity-60` — that is already spent
on `pending`, and stacking the two leaves a pending transfer barely
readable. Muting the amount also drops its `text-positive` green,
which a transfer into an account never earned. */}
<div className="text-right">
<AmountDisplay amount={txn.normalizedAmount} currency={txn.currency} pending={txn.pending} />
<AmountDisplay
amount={txn.normalizedAmount}
currency={txn.currency}
pending={txn.pending}
className={cn(txn.isTransfer && "font-normal text-muted-foreground")}
/>
</div>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions src/components/organisms/dashboard-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface DashboardData {
type: AccountType;
currentBalance: number | null;
currency: string | null;
isHidden: boolean | null;
institutionName: string;
logoBase64: string | null;
primaryColor: string | null;
Expand Down
33 changes: 33 additions & 0 deletions src/components/organisms/net-worth-hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useTransition, useMemo } from "react";
import { NetWorthAreaChart } from "@/components/atoms/net-worth-area-chart";
import { BalanceDisplay } from "@/components/atoms/balance-display";
import { DateRangeSelector } from "@/components/molecules/date-range-selector";
import { centsToDisplay } from "@/lib/money";
import { formatDateShort } from "@/lib/date-utils";
Expand All @@ -20,6 +21,9 @@ const RANGE_LABELS: Record<string, string> = {

interface NetWorthHeroProps {
netWorth: number;
/** Signed totals behind `netWorth`; liabilities arrive negative. */
assets: number;
liabilities: number;
initialHistory: NetWorthPoint[];
initialRange?: string;
/**
Expand All @@ -31,6 +35,8 @@ interface NetWorthHeroProps {

export function NetWorthHero({
netWorth,
assets,
liabilities,
initialHistory,
initialRange,
fullCoverageSince = null,
Expand Down Expand Up @@ -71,6 +77,7 @@ export function NetWorthHero({
const coverage = coverageBoundary(trimmed);
const trimmedTo = trimmed !== history ? fullBoundary : null;
const [dollars, cents] = centsToDisplay(netWorth).split(".");
const hasPosition = assets !== 0 || liabilities !== 0;

function handleRangeChange(next: string) {
setRange(next);
Expand Down Expand Up @@ -126,6 +133,32 @@ export function NetWorthHero({
: "history incomplete"}
</span>
)}
{/* The two sides of the number beside them, on the same row: net
worth alone cannot tell a paid-off house from a leveraged
brokerage account. No border or fill — these are its operands,
not separate figures, and the hero keeps its height because the
row already had the width. Hidden only when there is nothing to
divide, so an empty household is not shown two zeroes. */}
{hasPosition && (
<dl className="flex items-baseline gap-6 sm:ml-3">
{[
{ label: "Assets", amount: assets },
{ label: "Debts", amount: liabilities },
].map(({ label, amount }) => (
<div key={label}>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</dt>
<dd>
{/* BalanceDisplay, as the Accounts page totals its groups
with -- it already tints a negative, so debts read as
debts here without a second colour convention. */}
<BalanceDisplay amount={amount} size="md" className="font-semibold" />
</dd>
</div>
))}
</dl>
)}
</div>
</div>
<DateRangeSelector value={range} onChange={handleRangeChange} support={support} />
Expand Down
9 changes: 3 additions & 6 deletions src/components/organisms/transaction-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { BulkActionBar } from "@/components/molecules/bulk-action-bar";
import { TransactionDetailPanel } from "@/components/organisms/transaction-detail-panel";
import { loadMoreTransactions } from "@/actions/transactions";
import { groupByDate } from "@/lib/transactions";
import { summarizeDay } from "@/lib/transaction-day-summary";
import { useSelectedTransaction } from "@/hooks/use-selected-transaction";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -143,14 +144,10 @@ export function TransactionList({
</div>

{groups.map((group) => {
const netAmount = group.rows.reduce((sum, r) => sum + r.normalizedAmount, 0);
const summary = summarizeDay(group.rows);
return (
<div key={group.date}>
<TransactionDateHeader
date={group.date}
transactionCount={group.rows.length}
netAmount={netAmount}
/>
<TransactionDateHeader date={group.date} summary={summary} />
{group.rows.map((txn) => (
<TransactionRow
key={txn.id}
Expand Down
79 changes: 54 additions & 25 deletions src/components/organisms/widgets/account-balances.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import Link from "next/link";
import { accountDisplayName } from "@/lib/account-name";
import { groupAccountsByType } from "@/lib/group-accounts-by-type";
import { EntityAvatar } from "@/components/molecules/entity-avatar";
import { BalanceDisplay } from "@/components/atoms/balance-display";
import type { AccountType } from "@/db/schema/accounts";
Expand All @@ -12,6 +13,7 @@ interface AccountBalanceRow {
type: AccountType;
currentBalance: number | null;
currency: string | null;
isHidden: boolean | null;
institutionName: string;
logoBase64: string | null;
primaryColor: string | null;
Expand All @@ -22,7 +24,14 @@ interface AccountBalancesWidgetProps {
}

export function AccountBalancesWidget({ data }: AccountBalancesWidgetProps) {
if (data.length === 0) {
// The same grouping and the same subtotal component the Accounts page uses
// (organisms/account-list.tsx), so the widget and the page it links to name,
// order and total their sections identically. Flat, every balance read as the
// same kind of number: an $18,240 savings balance and a -$8,400 loan sat in
// one undifferentiated run with nothing marking which side each was on.
const groups = groupAccountsByType(data);

if (groups.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
<Link href="/accounts" className="text-primary hover:underline">Connect an account</Link>
Expand All @@ -36,12 +45,14 @@ export function AccountBalancesWidget({ data }: AccountBalancesWidgetProps) {
visible headers, so they are screen-reader only -- without them the
balances read as an undifferentiated run of numbers. */}
<div className="flex-1 overflow-y-auto overflow-x-hidden">
{/* table-fixed pins the balance column to a fixed width so it can
never be pushed past the card's edge -- without it the balance
column grows to fit its content and drags a horizontal scrollbar
(and the card's own overflow-hidden) along with it. */}
{/* Deliberately not ui/table: shadcn's <Table> wraps itself in an
`overflow-x-auto` container, which is the horizontal scrollbar #159
removed from this widget. table-fixed plus a pinned balance column
is what keeps a long balance from pushing past the card's edge, and
the clipping parent above is what absorbs it when one still does.
Group headers are ordinary rows of this same table for that reason. */}
<table className="w-full table-fixed">
<caption className="sr-only">Account balances</caption>
<caption className="sr-only">Account balances by type</caption>
<colgroup>
<col />
<col className="w-24" />
Expand All @@ -52,30 +63,48 @@ export function AccountBalancesWidget({ data }: AccountBalancesWidgetProps) {
<th scope="col">Balance</th>
</tr>
</thead>
<tbody>
{data.map((account) => (
<tr key={account.id}>
<td className="px-1 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<EntityAvatar
logoBase64={account.logoBase64}
name={account.institutionName}
primaryColor={account.primaryColor}
size="sm"
/>
<span className="truncate text-sm">{accountDisplayName(account.name)}</span>
</div>
</td>
<td className="px-1 py-1.5 text-right whitespace-nowrap">
{groups.map((group) => (
<tbody key={group.key}>
<tr className="bg-muted/50">
<th
scope="rowgroup"
className="px-1.5 py-1 text-left text-xs font-semibold"
>
{group.label}
</th>
<td className="px-1.5 py-1 text-right whitespace-nowrap">
<BalanceDisplay
amount={account.currentBalance}
currency={account.currency ?? "USD"}
amount={group.subtotal}
currency={group.accounts[0]?.currency ?? "USD"}
size="sm"
className="text-xs font-semibold"
/>
</td>
</tr>
))}
</tbody>
{group.accounts.map((account) => (
<tr key={account.id}>
<td className="px-1 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<EntityAvatar
logoBase64={account.logoBase64}
name={account.institutionName}
primaryColor={account.primaryColor}
size="sm"
/>
<span className="truncate text-sm">{accountDisplayName(account.name)}</span>
</div>
</td>
<td className="px-1 py-1.5 text-right whitespace-nowrap">
<BalanceDisplay
amount={account.currentBalance}
currency={account.currency ?? "USD"}
size="sm"
/>
</td>
</tr>
))}
</tbody>
))}
</table>
</div>
<Link
Expand Down
Loading
Loading