From 66db843e415f45351b6a6cf9a3eac05361392bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Mon, 29 Jun 2026 17:40:49 +0200 Subject: [PATCH] fix(transactions): mark new rows individually instead of one divider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lang/es.json | 3 +- resources/js/lib/new-transactions.test.ts | 62 +++++++---- resources/js/lib/new-transactions.ts | 44 +++++--- resources/js/pages/transactions/index.tsx | 122 ++++++++++------------ 4 files changed, 125 insertions(+), 106 deletions(-) diff --git a/lang/es.json b/lang/es.json index 9bbf18af..8a95468c 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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:", diff --git a/resources/js/lib/new-transactions.test.ts b/resources/js/lib/new-transactions.test.ts index 0e633649..bd0a15f3 100644 --- a/resources/js/lib/new-transactions.test.ts +++ b/resources/js/lib/new-transactions.test.ts @@ -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); }); }); diff --git a/resources/js/lib/new-transactions.ts b/resources/js/lib/new-transactions.ts index 40c6b449..07e2b719 100644 --- a/resources/js/lib/new-transactions.ts +++ b/resources/js/lib/new-transactions.ts @@ -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[], +export function isNewSince( + transaction: Pick, 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[], + lastVisit: string | null, +): number { + return transactions.reduce( + (count, transaction) => + isNewSince(transaction, lastVisit) ? count + 1 : count, + 0, + ); } diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 3e2a7f3d..a30bdfc6 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -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; virtualRow: VirtualItem; rowVirtualizer: Virtualizer; + 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) => { - const meta = cell.column.columnDef.meta as - | { - cellClassName?: string; - cellStyle?: React.CSSProperties; - } - | undefined; - return ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ); - })} + .map( + ( + cell: Cell, + cellIndex: number, + ) => { + const meta = cell.column.columnDef.meta as + | { + cellClassName?: string; + cellStyle?: React.CSSProperties; + } + | undefined; + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + }, + )} @@ -392,22 +403,6 @@ function getInitialColumnVisibility(): VisibilityState { return defaultVisibility; } -function LastVisitDivider({ colSpan }: { colSpan: number }) { - return ( - - -
-
- - {__('Last visit')} - -
-
- - - ); -} - 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(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, ) => { - const transactionRow = ( + return ( ); - - if (virtualRow.index !== newTransactionsBoundary) { - return transactionRow; - } - - return ( - <> - - {transactionRow} - - ); }, - [handleReEvaluateRules, newTransactionsBoundary, table], + [handleReEvaluateRules, lastVisitAtMount], ); return ( @@ -1455,6 +1434,17 @@ export default function Transactions({
) : ( <> + {newCount > 0 && ( +
+ + {newCount === 1 + ? __('1 new since your last visit') + : __( + ':count new since your last visit', + { count: newCount }, + )} +
+ )}