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:
parent
300756e553
commit
016d65ddb2
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in New Issue