feat(transactions): highlight new transactions since last visit (#609)

## What

Between visits, highlight the transactions that arrived since the user
last opened the list.

- The newest `created_at` from the initial page load is stored in
`localStorage` (`transactions-last-visit`), frozen at mount.
- Every transaction whose `created_at` is newer than that marker gets a
subtle accent on the row (left border + faint tint).

## Why per-row instead of a single divider line

The first cut drew one "Last visit" divider with new rows above it. A
code review found this is structurally wrong: the list is sorted by
`transaction_date` (when the money moved), but "new" is defined by
`created_at` (when the row was inserted). The two don't correlate — a
bank sync that imports a batch dated across several days scatters the
new rows throughout the list, so a single boundary lands at the first
older row and **hides every new row below it**, the exact miss the
feature was meant to prevent.

Per-row marking is correct regardless of sort order: no new transaction
can be hidden.

## Notes / limitations

- The marker is written **once at mount** from the initial payload. Rows
that arrive mid-visit (load-more, a sync/refresh, AI categorization) do
not advance it, so they are never recorded as "seen" before the user has
seen them. The trade-off is they may re-appear as new next visit (errs
on showing, never hiding).
- Purely client-side (localStorage), so the marker is per-device.

## Tests

- Unit tests for `isNewSince` and `newestCreatedAt`.
- `LocalizationTest`, ESLint, Prettier all pass.
This commit is contained in:
Víctor Falcón 2026-06-29 18:08:14 +02:00 committed by GitHub
parent 5ef3e01c89
commit 884038c13b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 196 additions and 26 deletions

View File

@ -13,7 +13,7 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { isNewSince, newestCreatedAt } from './new-transactions';
const tx = (created_at: string) => ({ created_at });
describe('isNewSince', () => {
it('is true when created after the last visit', () => {
expect(
isNewSince(tx('2026-06-29T10:00:00Z'), '2026-06-25T00:00:00Z'),
).toBe(true);
});
it('is false when created at or before the last visit', () => {
expect(
isNewSince(tx('2026-06-20T08:00:00Z'), '2026-06-25T00:00:00Z'),
).toBe(false);
});
it('does not rely on list order (a new row can be back-dated)', () => {
// A row synced now but dated weeks ago is still "new".
expect(
isNewSince(tx('2026-06-29T10:00:00Z'), '2026-06-25T00:00:00Z'),
).toBe(true);
});
it('is false on a first visit (no stored timestamp)', () => {
expect(isNewSince(tx('2026-06-29T10:00:00Z'), null)).toBe(false);
});
it('is false on an unparseable timestamp', () => {
expect(isNewSince(tx('2026-06-29T10:00:00Z'), 'not-a-date')).toBe(
false,
);
});
});
describe('newestCreatedAt', () => {
it('returns null for an empty list', () => {
expect(newestCreatedAt([])).toBeNull();
});
it('returns the only created_at for a single item', () => {
expect(newestCreatedAt([tx('2026-06-29T10:00:00Z')])).toBe(
'2026-06-29T10:00:00Z',
);
});
it('finds the latest regardless of input order', () => {
expect(
newestCreatedAt([
tx('2026-06-20T08:00:00Z'),
tx('2026-06-29T10:00:00Z'),
tx('2026-06-25T09:00:00Z'),
]),
).toBe('2026-06-29T10:00:00Z');
});
it('ignores unparseable timestamps', () => {
expect(
newestCreatedAt([tx('not-a-date'), tx('2026-06-20T08:00:00Z')]),
).toBe('2026-06-20T08:00:00Z');
});
});

View File

@ -0,0 +1,71 @@
import { type Transaction } from '@/types/transaction';
const LAST_VISIT_KEY = 'transactions-last-visit';
export function loadLastVisit(): string | null {
if (typeof window === 'undefined') return null;
try {
return localStorage.getItem(LAST_VISIT_KEY);
} catch (error) {
console.error('Failed to load last visit:', error);
return null;
}
}
export function saveLastVisit(value: string): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(LAST_VISIT_KEY, value);
} catch (error) {
console.error('Failed to save last visit:', error);
}
}
/**
* The most recent `created_at` (insertion time) across the given transactions,
* or null when there are none. Compared as timestamps so it does not rely on a
* particular string format.
*/
export function newestCreatedAt(
transactions: Pick<Transaction, 'created_at'>[],
): string | null {
let newest: string | null = null;
let newestMs = -Infinity;
for (const transaction of transactions) {
const ms = Date.parse(transaction.created_at);
if (!Number.isNaN(ms) && ms > newestMs) {
newest = transaction.created_at;
newestMs = ms;
}
}
return newest;
}
/**
* Whether a transaction was inserted after the given visit timestamp, i.e. it
* arrived since the user last opened the list. Compared per-row (not as a
* positional boundary) because the list is sorted by transaction_date, which
* does not correlate with created_at a newly synced row can have an old date
* and sit anywhere in the list.
*/
export function isNewSince(
transaction: Pick<Transaction, 'created_at'>,
lastVisit: string | null,
): boolean {
if (!lastVisit) {
return false;
}
const lastVisitMs = Date.parse(lastVisit);
if (Number.isNaN(lastVisitMs)) {
return false;
}
const createdMs = Date.parse(transaction.created_at);
return !Number.isNaN(createdMs) && createdMs > lastVisitMs;
}

View File

@ -83,6 +83,12 @@ import {
type CursorPaginatedResponse,
} from '@/lib/cursor-pagination';
import { consoleDebug } from '@/lib/debug';
import {
isNewSince,
loadLastVisit,
newestCreatedAt,
saveLastVisit,
} from '@/lib/new-transactions';
import { captureEvent } from '@/lib/posthog';
import { getBulkDeleteConfirmationText } from '@/lib/transaction-delete-confirmation';
import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation';
@ -264,6 +270,7 @@ interface TransactionRowProps {
row: Row<DecryptedTransaction>;
virtualRow: VirtualItem;
rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
isNew: boolean;
onEdit: (transaction: DecryptedTransaction) => void;
onReEvaluateRules: (transaction: DecryptedTransaction) => void;
onDelete: (transaction: DecryptedTransaction) => void;
@ -273,6 +280,7 @@ function TransactionRowComponent({
row,
virtualRow,
rowVirtualizer,
isNew,
onEdit,
onReEvaluateRules,
onDelete,
@ -300,7 +308,10 @@ function TransactionRowComponent({
(row.getIsSelected() || contextMenuOpen) && 'selected'
}
data-index={virtualRow.index}
className="cursor-pointer"
className={cn(
'cursor-pointer',
isNew && 'bg-blue-500/5 dark:bg-blue-500/10',
)}
onClick={handleRowClick}
>
{row
@ -315,29 +326,37 @@ function TransactionRowComponent({
| undefined;
return !meta?.isVirtual;
})
.map((cell: Cell<DecryptedTransaction, unknown>) => {
const meta = cell.column.columnDef.meta as
| {
cellClassName?: string;
cellStyle?: React.CSSProperties;
}
| undefined;
return (
<TableCell
key={cell.id}
className={cn(
meta?.cellClassName,
'pt-2.5 pb-2',
)}
style={meta?.cellStyle}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
);
})}
.map(
(
cell: Cell<DecryptedTransaction, unknown>,
cellIndex: number,
) => {
const meta = cell.column.columnDef.meta as
| {
cellClassName?: string;
cellStyle?: React.CSSProperties;
}
| undefined;
return (
<TableCell
key={cell.id}
className={cn(
meta?.cellClassName,
'pt-2.5 pb-2',
isNew &&
cellIndex === 0 &&
'border-l-2 border-l-blue-500',
)}
style={meta?.cellStyle}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
);
},
)}
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
@ -470,6 +489,21 @@ export default function Transactions({
appliedFilters.sort || '-transaction_date',
);
// Frozen at mount so per-row "new" marks stay stable for the whole visit.
const [lastVisitAtMount] = useState<string | null>(loadLastVisit);
// Mark this visit as seen, once, using the newest created_at from the
// initial payload. Rows that arrive later in the same visit (load-more,
// refresh after a sync, categorization) must NOT advance the marker, or
// they'd be recorded as already-seen and never flagged on the next visit.
useEffect(() => {
const latest = newestCreatedAt(serverTransactions.data);
if (latest) {
saveLastVisit(latest);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync filter state when appliedFilters prop changes
useEffect(() => {
setFilters(serverToClientFilters(appliedFilters));
@ -1304,13 +1338,14 @@ export default function Transactions({
row={row}
virtualRow={virtualRow}
rowVirtualizer={rowVirtualizer}
isNew={isNewSince(row.original, lastVisitAtMount)}
onEdit={setEditTransaction}
onReEvaluateRules={handleReEvaluateRules}
onDelete={setDeleteTransaction}
/>
);
},
[handleReEvaluateRules],
[handleReEvaluateRules, lastVisitAtMount],
);
return (