feat(transactions): show a 'Last visit' divider above new transactions

Between visits, mark which transactions arrived since the user last
opened the list. The newest loaded created_at is remembered in
localStorage; the value is frozen at mount to position the divider and
rewritten afterwards so the line is gone on the next visit.

firstSeenTransactionIndex() locates the boundary (first already-seen row)
and the divider renders before it, gated to the default newest-first
sort. No divider on first visit, when nothing is new, or on other sorts.
This commit is contained in:
Víctor Falcón 2026-06-29 17:22:03 +02:00
parent 300756e553
commit 016d65ddb2
4 changed files with 146 additions and 2 deletions

View File

@ -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:",

View File

@ -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);
});
});

View File

@ -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<Transaction, 'created_at'>[],
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;
}

View File

@ -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 (
<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());
@ -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<string | null>(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<HTMLDivElement, Element>,
) => {
return (
const transactionRow = (
<TransactionRowComponent
key={row.id}
row={row}
@ -1309,8 +1368,21 @@ export default function Transactions({
onDelete={setDeleteTransaction}
/>
);
if (virtualRow.index !== newTransactionsBoundary) {
return transactionRow;
}
return (
<>
<LastVisitDivider
colSpan={table.getVisibleLeafColumns().length}
/>
{transactionRow}
</>
);
},
[handleReEvaluateRules],
[handleReEvaluateRules, newTransactionsBoundary, table],
);
return (