fix(transactions): mark new rows individually instead of one divider
A single "Last visit" line can't be placed correctly: the list is sorted by transaction_date while "new" is defined by created_at, and the two don't correlate. A bank sync importing a batch with dates spread across days scatters the new rows throughout the list, so the divider lands at the first older row and hides everything below it — the exact miss the feature was meant to prevent. Flag each row whose created_at is after the last visit (subtle accent border + tint) and show a 'N new since your last visit' count above the list. Correct regardless of sort order; the count covers loaded rows.
This commit is contained in:
parent
65a612b194
commit
66db843e41
|
|
@ -203,7 +203,9 @@
|
|||
"8. Your Rights Under GDPR": "8. Tus Derechos Bajo GDPR",
|
||||
"9. Cookies and Tracking": "9. Cookies y Rastreo",
|
||||
"9. Disclaimers and Warranties": "9. Descargos de Responsabilidad y Garantías",
|
||||
"1 new since your last visit": "1 nueva desde tu última visita",
|
||||
":count matching transaction(s)": ":count transacción(es) coincidente(s)",
|
||||
":count new since your last visit": ":count nuevas desde tu última visita",
|
||||
":count new transactions synced on Whisper Money": ":count nuevas transacciones sincronizadas en Whisper Money",
|
||||
":count transaction": ":count transacción",
|
||||
":count transaction(s) will be affected.": ":count transacción(es) serán afectadas.",
|
||||
|
|
@ -975,7 +977,6 @@
|
|||
"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:",
|
||||
|
|
|
|||
|
|
@ -1,42 +1,58 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { firstSeenTransactionIndex, newestCreatedAt } from './new-transactions';
|
||||
import { countNewSince, isNewSince, newestCreatedAt } 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', () => {
|
||||
describe('isNewSince', () => {
|
||||
it('is true when created after the last visit', () => {
|
||||
expect(
|
||||
firstSeenTransactionIndex(transactions, '2026-06-25T00:00:00Z'),
|
||||
).toBe(2);
|
||||
isNewSince(tx('2026-06-29T10:00:00Z'), '2026-06-25T00:00:00Z'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns -1 when nothing is new (first row already seen)', () => {
|
||||
it('is false when created at or before the last visit', () => {
|
||||
expect(
|
||||
firstSeenTransactionIndex(transactions, '2026-06-30T00:00:00Z'),
|
||||
).toBe(-1);
|
||||
isNewSince(tx('2026-06-20T08:00:00Z'), '2026-06-25T00:00:00Z'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns -1 when every loaded row is new', () => {
|
||||
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(
|
||||
firstSeenTransactionIndex(transactions, '2026-06-01T00:00:00Z'),
|
||||
).toBe(-1);
|
||||
isNewSince(tx('2026-06-29T10:00:00Z'), '2026-06-25T00:00:00Z'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns -1 on a first visit (no stored timestamp)', () => {
|
||||
expect(firstSeenTransactionIndex(transactions, null)).toBe(-1);
|
||||
it('is false on a first visit (no stored timestamp)', () => {
|
||||
expect(isNewSince(tx('2026-06-29T10:00:00Z'), null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns -1 on an unparseable timestamp', () => {
|
||||
expect(firstSeenTransactionIndex(transactions, 'not-a-date')).toBe(-1);
|
||||
it('is false on an unparseable timestamp', () => {
|
||||
expect(isNewSince(tx('2026-06-29T10:00:00Z'), 'not-a-date')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countNewSince', () => {
|
||||
const transactions = [
|
||||
tx('2026-06-29T10:00:00Z'),
|
||||
tx('2026-06-20T08:00:00Z'), // seen, interleaved
|
||||
tx('2026-06-28T09:00:00Z'),
|
||||
tx('2026-06-19T08:00:00Z'), // seen
|
||||
];
|
||||
|
||||
it('counts every new row regardless of position', () => {
|
||||
expect(countNewSince(transactions, '2026-06-25T00:00:00Z')).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 0 when nothing is new', () => {
|
||||
expect(countNewSince(transactions, '2026-06-30T00:00:00Z')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 on a first visit', () => {
|
||||
expect(countNewSince(transactions, null)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -46,30 +46,42 @@ export function newestCreatedAt(
|
|||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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 firstSeenTransactionIndex(
|
||||
transactions: Pick<Transaction, 'created_at'>[],
|
||||
export function isNewSince(
|
||||
transaction: Pick<Transaction, 'created_at'>,
|
||||
lastVisit: string | null,
|
||||
): number {
|
||||
): boolean {
|
||||
if (!lastVisit) {
|
||||
return -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastVisitMs = Date.parse(lastVisit);
|
||||
if (Number.isNaN(lastVisitMs)) {
|
||||
return -1;
|
||||
return false;
|
||||
}
|
||||
|
||||
const firstSeen = transactions.findIndex(
|
||||
(transaction) => Date.parse(transaction.created_at) <= lastVisitMs,
|
||||
);
|
||||
const createdMs = Date.parse(transaction.created_at);
|
||||
|
||||
return firstSeen > 0 ? firstSeen : -1;
|
||||
return !Number.isNaN(createdMs) && createdMs > lastVisitMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of the given transactions arrived since the last visit. Counts only
|
||||
* the loaded transactions, so it can undercount when newer rows live in a
|
||||
* not-yet-loaded page.
|
||||
*/
|
||||
export function countNewSince(
|
||||
transactions: Pick<Transaction, 'created_at'>[],
|
||||
lastVisit: string | null,
|
||||
): number {
|
||||
return transactions.reduce(
|
||||
(count, transaction) =>
|
||||
isNewSince(transaction, lastVisit) ? count + 1 : count,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ import {
|
|||
} from '@/lib/cursor-pagination';
|
||||
import { consoleDebug } from '@/lib/debug';
|
||||
import {
|
||||
firstSeenTransactionIndex,
|
||||
countNewSince,
|
||||
isNewSince,
|
||||
loadLastVisit,
|
||||
newestCreatedAt,
|
||||
saveLastVisit,
|
||||
|
|
@ -270,6 +271,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;
|
||||
|
|
@ -279,6 +281,7 @@ function TransactionRowComponent({
|
|||
row,
|
||||
virtualRow,
|
||||
rowVirtualizer,
|
||||
isNew,
|
||||
onEdit,
|
||||
onReEvaluateRules,
|
||||
onDelete,
|
||||
|
|
@ -306,7 +309,7 @@ function TransactionRowComponent({
|
|||
(row.getIsSelected() || contextMenuOpen) && 'selected'
|
||||
}
|
||||
data-index={virtualRow.index}
|
||||
className="cursor-pointer"
|
||||
className={cn('cursor-pointer', isNew && 'bg-primary/5')}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
{row
|
||||
|
|
@ -321,29 +324,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-primary',
|
||||
)}
|
||||
style={meta?.cellStyle}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</TableRow>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
|
|
@ -392,22 +403,6 @@ function getInitialColumnVisibility(): VisibilityState {
|
|||
return defaultVisibility;
|
||||
}
|
||||
|
||||
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());
|
||||
|
|
@ -492,18 +487,14 @@ export default function Transactions({
|
|||
appliedFilters.sort || '-transaction_date',
|
||||
);
|
||||
|
||||
// Frozen at mount so the "Last visit" divider stays put for the whole visit.
|
||||
// Frozen at mount so per-row "new" marks stay stable for the whole visit.
|
||||
const [lastVisitAtMount] = useState<string | null>(loadLastVisit);
|
||||
|
||||
// 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]);
|
||||
// Count of loaded transactions that arrived since the last visit.
|
||||
const newCount = useMemo(
|
||||
() => countNewSince(allTransactions, lastVisitAtMount),
|
||||
[allTransactions, lastVisitAtMount],
|
||||
);
|
||||
|
||||
// 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,
|
||||
|
|
@ -1345,32 +1336,20 @@ export default function Transactions({
|
|||
virtualRow: VirtualItem,
|
||||
rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
|
||||
) => {
|
||||
const transactionRow = (
|
||||
return (
|
||||
<TransactionRowComponent
|
||||
key={row.id}
|
||||
row={row}
|
||||
virtualRow={virtualRow}
|
||||
rowVirtualizer={rowVirtualizer}
|
||||
isNew={isNewSince(row.original, lastVisitAtMount)}
|
||||
onEdit={setEditTransaction}
|
||||
onReEvaluateRules={handleReEvaluateRules}
|
||||
onDelete={setDeleteTransaction}
|
||||
/>
|
||||
);
|
||||
|
||||
if (virtualRow.index !== newTransactionsBoundary) {
|
||||
return transactionRow;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<LastVisitDivider
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
/>
|
||||
{transactionRow}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[handleReEvaluateRules, newTransactionsBoundary, table],
|
||||
[handleReEvaluateRules, lastVisitAtMount],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -1455,6 +1434,17 @@ export default function Transactions({
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
{newCount > 0 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full bg-primary" />
|
||||
{newCount === 1
|
||||
? __('1 new since your last visit')
|
||||
: __(
|
||||
':count new since your last visit',
|
||||
{ count: newCount },
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DataTable
|
||||
table={table}
|
||||
columns={columns}
|
||||
|
|
|
|||
Loading…
Reference in New Issue