feat(transactions): highlight new transactions since last visit (#609)
## What Between visits, highlight the transactions that arrived since the user last opened the list. - The newest `created_at` from the initial page load is stored in `localStorage` (`transactions-last-visit`), frozen at mount. - Every transaction whose `created_at` is newer than that marker gets a subtle accent on the row (left border + faint tint). ## Why per-row instead of a single divider line The first cut drew one "Last visit" divider with new rows above it. A code review found this is structurally wrong: the list is sorted by `transaction_date` (when the money moved), but "new" is defined by `created_at` (when the row was inserted). The two don't correlate — a bank sync that imports a batch dated across several days scatters the new rows throughout the list, so a single boundary lands at the first older row and **hides every new row below it**, the exact miss the feature was meant to prevent. Per-row marking is correct regardless of sort order: no new transaction can be hidden. ## Notes / limitations - The marker is written **once at mount** from the initial payload. Rows that arrive mid-visit (load-more, a sync/refresh, AI categorization) do not advance it, so they are never recorded as "seen" before the user has seen them. The trade-off is they may re-appear as new next visit (errs on showing, never hiding). - Purely client-side (localStorage), so the marker is per-device. ## Tests - Unit tests for `isNewSince` and `newestCreatedAt`. - `LocalizationTest`, ESLint, Prettier all pass.
This commit is contained in:
parent
5ef3e01c89
commit
884038c13b
|
|
@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isNewSince, newestCreatedAt } from './new-transactions';
|
||||
|
||||
const tx = (created_at: string) => ({ created_at });
|
||||
|
||||
describe('isNewSince', () => {
|
||||
it('is true when created after the last visit', () => {
|
||||
expect(
|
||||
isNewSince(tx('2026-06-29T10:00:00Z'), '2026-06-25T00:00:00Z'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when created at or before the last visit', () => {
|
||||
expect(
|
||||
isNewSince(tx('2026-06-20T08:00:00Z'), '2026-06-25T00:00:00Z'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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(
|
||||
isNewSince(tx('2026-06-29T10:00:00Z'), '2026-06-25T00:00:00Z'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false on a first visit (no stored timestamp)', () => {
|
||||
expect(isNewSince(tx('2026-06-29T10:00:00Z'), null)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false on an unparseable timestamp', () => {
|
||||
expect(isNewSince(tx('2026-06-29T10:00:00Z'), 'not-a-date')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newestCreatedAt', () => {
|
||||
it('returns null for an empty list', () => {
|
||||
expect(newestCreatedAt([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the only created_at for a single item', () => {
|
||||
expect(newestCreatedAt([tx('2026-06-29T10:00:00Z')])).toBe(
|
||||
'2026-06-29T10:00:00Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('finds the latest regardless of input order', () => {
|
||||
expect(
|
||||
newestCreatedAt([
|
||||
tx('2026-06-20T08:00:00Z'),
|
||||
tx('2026-06-29T10:00:00Z'),
|
||||
tx('2026-06-25T09:00:00Z'),
|
||||
]),
|
||||
).toBe('2026-06-29T10:00:00Z');
|
||||
});
|
||||
|
||||
it('ignores unparseable timestamps', () => {
|
||||
expect(
|
||||
newestCreatedAt([tx('not-a-date'), tx('2026-06-20T08:00:00Z')]),
|
||||
).toBe('2026-06-20T08:00:00Z');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { type Transaction } from '@/types/transaction';
|
||||
|
||||
const LAST_VISIT_KEY = 'transactions-last-visit';
|
||||
|
||||
export function loadLastVisit(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
return localStorage.getItem(LAST_VISIT_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to load last visit:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveLastVisit(value: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(LAST_VISIT_KEY, value);
|
||||
} catch (error) {
|
||||
console.error('Failed to save last visit:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent `created_at` (insertion time) across the given transactions,
|
||||
* or null when there are none. Compared as timestamps so it does not rely on a
|
||||
* particular string format.
|
||||
*/
|
||||
export function newestCreatedAt(
|
||||
transactions: Pick<Transaction, 'created_at'>[],
|
||||
): string | null {
|
||||
let newest: string | null = null;
|
||||
let newestMs = -Infinity;
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const ms = Date.parse(transaction.created_at);
|
||||
if (!Number.isNaN(ms) && ms > newestMs) {
|
||||
newest = transaction.created_at;
|
||||
newestMs = ms;
|
||||
}
|
||||
}
|
||||
|
||||
return newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 isNewSince(
|
||||
transaction: Pick<Transaction, 'created_at'>,
|
||||
lastVisit: string | null,
|
||||
): boolean {
|
||||
if (!lastVisit) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastVisitMs = Date.parse(lastVisit);
|
||||
if (Number.isNaN(lastVisitMs)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const createdMs = Date.parse(transaction.created_at);
|
||||
|
||||
return !Number.isNaN(createdMs) && createdMs > lastVisitMs;
|
||||
}
|
||||
|
|
@ -83,6 +83,12 @@ import {
|
|||
type CursorPaginatedResponse,
|
||||
} from '@/lib/cursor-pagination';
|
||||
import { consoleDebug } from '@/lib/debug';
|
||||
import {
|
||||
isNewSince,
|
||||
loadLastVisit,
|
||||
newestCreatedAt,
|
||||
saveLastVisit,
|
||||
} from '@/lib/new-transactions';
|
||||
import { captureEvent } from '@/lib/posthog';
|
||||
import { getBulkDeleteConfirmationText } from '@/lib/transaction-delete-confirmation';
|
||||
import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation';
|
||||
|
|
@ -264,6 +270,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;
|
||||
|
|
@ -273,6 +280,7 @@ function TransactionRowComponent({
|
|||
row,
|
||||
virtualRow,
|
||||
rowVirtualizer,
|
||||
isNew,
|
||||
onEdit,
|
||||
onReEvaluateRules,
|
||||
onDelete,
|
||||
|
|
@ -300,7 +308,10 @@ function TransactionRowComponent({
|
|||
(row.getIsSelected() || contextMenuOpen) && 'selected'
|
||||
}
|
||||
data-index={virtualRow.index}
|
||||
className="cursor-pointer"
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
isNew && 'bg-blue-500/5 dark:bg-blue-500/10',
|
||||
)}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
{row
|
||||
|
|
@ -315,29 +326,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-blue-500',
|
||||
)}
|
||||
style={meta?.cellStyle}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</TableRow>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
|
|
@ -470,6 +489,21 @@ export default function Transactions({
|
|||
appliedFilters.sort || '-transaction_date',
|
||||
);
|
||||
|
||||
// Frozen at mount so per-row "new" marks stay stable for the whole visit.
|
||||
const [lastVisitAtMount] = useState<string | null>(loadLastVisit);
|
||||
|
||||
// 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,
|
||||
// refresh after a sync, categorization) must NOT advance the marker, or
|
||||
// they'd be recorded as already-seen and never flagged on the next visit.
|
||||
useEffect(() => {
|
||||
const latest = newestCreatedAt(serverTransactions.data);
|
||||
if (latest) {
|
||||
saveLastVisit(latest);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sync filter state when appliedFilters prop changes
|
||||
useEffect(() => {
|
||||
setFilters(serverToClientFilters(appliedFilters));
|
||||
|
|
@ -1304,13 +1338,14 @@ export default function Transactions({
|
|||
row={row}
|
||||
virtualRow={virtualRow}
|
||||
rowVirtualizer={rowVirtualizer}
|
||||
isNew={isNewSince(row.original, lastVisitAtMount)}
|
||||
onEdit={setEditTransaction}
|
||||
onReEvaluateRules={handleReEvaluateRules}
|
||||
onDelete={setDeleteTransaction}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleReEvaluateRules],
|
||||
[handleReEvaluateRules, lastVisitAtMount],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Reference in New Issue