fix(transactions): mark new rows individually instead of one divider

A single "Last visit" line can't be placed correctly: the list is
sorted by transaction_date while "new" is defined by created_at, and the
two don't correlate. A bank sync importing a batch with dates spread
across days scatters the new rows throughout the list, so the divider
lands at the first older row and hides everything below it — the exact
miss the feature was meant to prevent.

Flag each row whose created_at is after the last visit (subtle accent
border + tint) and show a 'N new since your last visit' count above the
list. Correct regardless of sort order; the count covers loaded rows.
This commit is contained in:
Víctor Falcón 2026-06-29 17:40:49 +02:00
parent 65a612b194
commit 66db843e41
4 changed files with 125 additions and 106 deletions

View File

@ -203,7 +203,9 @@
"8. Your Rights Under GDPR": "8. Tus Derechos Bajo GDPR",
"9. Cookies and Tracking": "9. Cookies y Rastreo",
"9. Disclaimers and Warranties": "9. Descargos de Responsabilidad y Garantías",
"1 new since your last visit": "1 nueva desde tu última visita",
":count matching transaction(s)": ":count transacción(es) coincidente(s)",
":count new since your last visit": ":count nuevas desde tu última visita",
":count new transactions synced on Whisper Money": ":count nuevas transacciones sincronizadas en Whisper Money",
":count transaction": ":count transacción",
":count transaction(s) will be affected.": ":count transacción(es) serán afectadas.",
@ -975,7 +977,6 @@
"Land": "Terreno",
"Language": "Idioma",
"Last Login": "Último acceso",
"Last visit": "Última visita",
"Last period:": "Período anterior:",
"Last synced": "Última sincronización",
"Last updated:": "Última actualización:",

View File

@ -1,42 +1,58 @@
import { describe, expect, it } from 'vitest';
import { firstSeenTransactionIndex, newestCreatedAt } from './new-transactions';
import { countNewSince, isNewSince, newestCreatedAt } from './new-transactions';
const tx = (created_at: string) => ({ created_at });
// Newest-first, like the transactions table default sort.
const transactions = [
tx('2026-06-29T10:00:00Z'), // new
tx('2026-06-29T09:00:00Z'), // new
tx('2026-06-20T08:00:00Z'), // seen
tx('2026-06-19T08:00:00Z'), // seen
];
describe('firstSeenTransactionIndex', () => {
it('returns the boundary between new and seen rows', () => {
describe('isNewSince', () => {
it('is true when created after the last visit', () => {
expect(
firstSeenTransactionIndex(transactions, '2026-06-25T00:00:00Z'),
).toBe(2);
isNewSince(tx('2026-06-29T10:00:00Z'), '2026-06-25T00:00:00Z'),
).toBe(true);
});
it('returns -1 when nothing is new (first row already seen)', () => {
it('is false when created at or before the last visit', () => {
expect(
firstSeenTransactionIndex(transactions, '2026-06-30T00:00:00Z'),
).toBe(-1);
isNewSince(tx('2026-06-20T08:00:00Z'), '2026-06-25T00:00:00Z'),
).toBe(false);
});
it('returns -1 when every loaded row is new', () => {
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(
firstSeenTransactionIndex(transactions, '2026-06-01T00:00:00Z'),
).toBe(-1);
isNewSince(tx('2026-06-29T10:00:00Z'), '2026-06-25T00:00:00Z'),
).toBe(true);
});
it('returns -1 on a first visit (no stored timestamp)', () => {
expect(firstSeenTransactionIndex(transactions, null)).toBe(-1);
it('is false on a first visit (no stored timestamp)', () => {
expect(isNewSince(tx('2026-06-29T10:00:00Z'), null)).toBe(false);
});
it('returns -1 on an unparseable timestamp', () => {
expect(firstSeenTransactionIndex(transactions, 'not-a-date')).toBe(-1);
it('is false on an unparseable timestamp', () => {
expect(isNewSince(tx('2026-06-29T10:00:00Z'), 'not-a-date')).toBe(
false,
);
});
});
describe('countNewSince', () => {
const transactions = [
tx('2026-06-29T10:00:00Z'),
tx('2026-06-20T08:00:00Z'), // seen, interleaved
tx('2026-06-28T09:00:00Z'),
tx('2026-06-19T08:00:00Z'), // seen
];
it('counts every new row regardless of position', () => {
expect(countNewSince(transactions, '2026-06-25T00:00:00Z')).toBe(2);
});
it('returns 0 when nothing is new', () => {
expect(countNewSince(transactions, '2026-06-30T00:00:00Z')).toBe(0);
});
it('returns 0 on a first visit', () => {
expect(countNewSince(transactions, null)).toBe(0);
});
});

View File

@ -46,30 +46,42 @@ export function newestCreatedAt(
}
/**
* Index of the first transaction that was already present at the given visit
* timestamp, assuming the list is sorted newest-first. Transactions above this
* index arrived since the last visit and sit above the "Last visit" divider.
*
* Returns -1 when there is no meaningful divider to draw: no recorded visit, an
* unparseable timestamp, nothing new (the first row is already seen), or every
* loaded row is new (the boundary lives in a not-yet-loaded page).
* 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 firstSeenTransactionIndex(
transactions: Pick<Transaction, 'created_at'>[],
export function isNewSince(
transaction: Pick<Transaction, 'created_at'>,
lastVisit: string | null,
): number {
): boolean {
if (!lastVisit) {
return -1;
return false;
}
const lastVisitMs = Date.parse(lastVisit);
if (Number.isNaN(lastVisitMs)) {
return -1;
return false;
}
const firstSeen = transactions.findIndex(
(transaction) => Date.parse(transaction.created_at) <= lastVisitMs,
);
const createdMs = Date.parse(transaction.created_at);
return firstSeen > 0 ? firstSeen : -1;
return !Number.isNaN(createdMs) && createdMs > lastVisitMs;
}
/**
* How many of the given transactions arrived since the last visit. Counts only
* the loaded transactions, so it can undercount when newer rows live in a
* not-yet-loaded page.
*/
export function countNewSince(
transactions: Pick<Transaction, 'created_at'>[],
lastVisit: string | null,
): number {
return transactions.reduce(
(count, transaction) =>
isNewSince(transaction, lastVisit) ? count + 1 : count,
0,
);
}

View File

@ -84,7 +84,8 @@ import {
} from '@/lib/cursor-pagination';
import { consoleDebug } from '@/lib/debug';
import {
firstSeenTransactionIndex,
countNewSince,
isNewSince,
loadLastVisit,
newestCreatedAt,
saveLastVisit,
@ -270,6 +271,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;
@ -279,6 +281,7 @@ function TransactionRowComponent({
row,
virtualRow,
rowVirtualizer,
isNew,
onEdit,
onReEvaluateRules,
onDelete,
@ -306,7 +309,7 @@ function TransactionRowComponent({
(row.getIsSelected() || contextMenuOpen) && 'selected'
}
data-index={virtualRow.index}
className="cursor-pointer"
className={cn('cursor-pointer', isNew && 'bg-primary/5')}
onClick={handleRowClick}
>
{row
@ -321,29 +324,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-primary',
)}
style={meta?.cellStyle}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
);
},
)}
</TableRow>
</ContextMenuTrigger>
<ContextMenuContent>
@ -392,22 +403,6 @@ function getInitialColumnVisibility(): VisibilityState {
return defaultVisibility;
}
function LastVisitDivider({ colSpan }: { colSpan: number }) {
return (
<tr className="hover:bg-transparent">
<td colSpan={colSpan} className="px-4 py-1.5">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-red-500/50" />
<span className="text-xs font-medium tracking-wide text-red-600 uppercase dark:text-red-400">
{__('Last visit')}
</span>
<div className="h-px flex-1 bg-red-500/50" />
</div>
</td>
</tr>
);
}
function DateHeader({ date, colSpan }: { date: string; colSpan: number }) {
const parsedDate = parseISO(date);
const currentYear = getYear(new Date());
@ -492,18 +487,14 @@ export default function Transactions({
appliedFilters.sort || '-transaction_date',
);
// Frozen at mount so the "Last visit" divider stays put for the whole visit.
// Frozen at mount so per-row "new" marks stay stable for the whole visit.
const [lastVisitAtMount] = useState<string | null>(loadLastVisit);
// Index of the first already-seen row. Rows above it arrived since the last
// visit and sit above the "Last visit" divider. Only meaningful under the
// default newest-first sort.
const newTransactionsBoundary = useMemo(() => {
if (sortParam !== '-transaction_date') {
return -1;
}
return firstSeenTransactionIndex(allTransactions, lastVisitAtMount);
}, [allTransactions, sortParam, lastVisitAtMount]);
// Count of loaded transactions that arrived since the last visit.
const newCount = useMemo(
() => countNewSince(allTransactions, lastVisitAtMount),
[allTransactions, lastVisitAtMount],
);
// 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,
@ -1345,32 +1336,20 @@ export default function Transactions({
virtualRow: VirtualItem,
rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
) => {
const transactionRow = (
return (
<TransactionRowComponent
key={row.id}
row={row}
virtualRow={virtualRow}
rowVirtualizer={rowVirtualizer}
isNew={isNewSince(row.original, lastVisitAtMount)}
onEdit={setEditTransaction}
onReEvaluateRules={handleReEvaluateRules}
onDelete={setDeleteTransaction}
/>
);
if (virtualRow.index !== newTransactionsBoundary) {
return transactionRow;
}
return (
<>
<LastVisitDivider
colSpan={table.getVisibleLeafColumns().length}
/>
{transactionRow}
</>
);
},
[handleReEvaluateRules, newTransactionsBoundary, table],
[handleReEvaluateRules, lastVisitAtMount],
);
return (
@ -1455,6 +1434,17 @@ export default function Transactions({
</div>
) : (
<>
{newCount > 0 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="inline-block size-2 rounded-full bg-primary" />
{newCount === 1
? __('1 new since your last visit')
: __(
':count new since your last visit',
{ count: newCount },
)}
</div>
)}
<DataTable
table={table}
columns={columns}