diff --git a/resources/js/components/ui/button.tsx b/resources/js/components/ui/button.tsx index ef985e95..10754905 100644 --- a/resources/js/components/ui/button.tsx +++ b/resources/js/components/ui/button.tsx @@ -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: diff --git a/resources/js/lib/new-transactions.test.ts b/resources/js/lib/new-transactions.test.ts new file mode 100644 index 00000000..fd548119 --- /dev/null +++ b/resources/js/lib/new-transactions.test.ts @@ -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'); + }); +}); diff --git a/resources/js/lib/new-transactions.ts b/resources/js/lib/new-transactions.ts new file mode 100644 index 00000000..a2f5bb2a --- /dev/null +++ b/resources/js/lib/new-transactions.ts @@ -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[], +): 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, + 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; +} diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 778573d5..7dd5c7dd 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -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; virtualRow: VirtualItem; rowVirtualizer: Virtualizer; + 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) => { - 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(), + )} + + ); + }, + )} @@ -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(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 (