diff --git a/lang/es.json b/lang/es.json index 93f89771..9bbf18af 100644 --- a/lang/es.json +++ b/lang/es.json @@ -975,6 +975,7 @@ "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 new file mode 100644 index 00000000..984d9c51 --- /dev/null +++ b/resources/js/lib/new-transactions.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; + +import { firstSeenTransactionIndex } 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', () => { + expect( + firstSeenTransactionIndex(transactions, '2026-06-25T00:00:00Z'), + ).toBe(2); + }); + + it('returns -1 when nothing is new (first row already seen)', () => { + expect( + firstSeenTransactionIndex(transactions, '2026-06-30T00:00:00Z'), + ).toBe(-1); + }); + + it('returns -1 when every loaded row is new', () => { + expect( + firstSeenTransactionIndex(transactions, '2026-06-01T00:00:00Z'), + ).toBe(-1); + }); + + it('returns -1 on a first visit (no stored timestamp)', () => { + expect(firstSeenTransactionIndex(transactions, null)).toBe(-1); + }); + + it('returns -1 on an unparseable timestamp', () => { + expect(firstSeenTransactionIndex(transactions, 'not-a-date')).toBe(-1); + }); +}); diff --git a/resources/js/lib/new-transactions.ts b/resources/js/lib/new-transactions.ts new file mode 100644 index 00000000..a6ef22e7 --- /dev/null +++ b/resources/js/lib/new-transactions.ts @@ -0,0 +1,30 @@ +import { type Transaction } from '@/types/transaction'; + +/** + * 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). + */ +export function firstSeenTransactionIndex( + transactions: Pick[], + lastVisit: string | null, +): number { + if (!lastVisit) { + return -1; + } + + const lastVisitMs = Date.parse(lastVisit); + if (Number.isNaN(lastVisitMs)) { + return -1; + } + + const firstSeen = transactions.findIndex( + (transaction) => Date.parse(transaction.created_at) <= lastVisitMs, + ); + + return firstSeen > 0 ? firstSeen : -1; +} diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 778573d5..ca49f8f4 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -83,6 +83,7 @@ import { type CursorPaginatedResponse, } from '@/lib/cursor-pagination'; import { consoleDebug } from '@/lib/debug'; +import { firstSeenTransactionIndex } from '@/lib/new-transactions'; import { captureEvent } from '@/lib/posthog'; import { getBulkDeleteConfirmationText } from '@/lib/transaction-delete-confirmation'; import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation'; @@ -139,6 +140,7 @@ interface Props { } const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility'; +const LAST_VISIT_KEY = 'transactions-last-visit'; function serverToClientFilters(applied: AppliedFilters): Filters { return { @@ -386,6 +388,33 @@ function getInitialColumnVisibility(): VisibilityState { return defaultVisibility; } +function getStoredLastVisit(): string | null { + if (typeof window === 'undefined') { + return null; + } + try { + return localStorage.getItem(LAST_VISIT_KEY); + } catch { + return null; + } +} + +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()); @@ -470,6 +499,36 @@ export default function Transactions({ appliedFilters.sort || '-transaction_date', ); + // Frozen at mount so the "Last visit" divider stays put for the whole visit. + const [lastVisitAtMount] = useState(getStoredLastVisit); + + // 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]); + + // Mark this visit as seen: store the newest created_at currently loaded. + useEffect(() => { + if (allTransactions.length === 0) { + return; + } + const latest = allTransactions.reduce( + (max, transaction) => + transaction.created_at > max ? transaction.created_at : max, + allTransactions[0].created_at, + ); + try { + localStorage.setItem(LAST_VISIT_KEY, latest); + } catch (error) { + console.error('Failed to save last visit to localStorage:', error); + } + }, [allTransactions]); + // Sync filter state when appliedFilters prop changes useEffect(() => { setFilters(serverToClientFilters(appliedFilters)); @@ -1298,7 +1357,7 @@ export default function Transactions({ virtualRow: VirtualItem, rowVirtualizer: Virtualizer, ) => { - return ( + const transactionRow = ( ); + + if (virtualRow.index !== newTransactionsBoundary) { + return transactionRow; + } + + return ( + <> + + {transactionRow} + + ); }, - [handleReEvaluateRules], + [handleReEvaluateRules, newTransactionsBoundary, table], ); return (