diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 074bcddf..0f31f58e 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -30,6 +30,8 @@ class TransactionController extends Controller $user = $request->user(); $validated = $request->validated(); + $lastVisitAt = $user->transactions_last_visited_at; + $perPage = (int) ($validated['per_page'] ?? 50); $sortParam = $validated['sort'] ?? '-transaction_date'; @@ -77,6 +79,11 @@ class TransactionController extends Controller ->append('ai_categorized'); }); + $newestServed = $transactions->getCollection()->max('created_at'); + if ($newestServed && (! $lastVisitAt || $newestServed->gt($lastVisitAt))) { + $user->forceFill(['transactions_last_visited_at' => $newestServed])->save(); + } + $appliedFilters = [ 'date_from' => $validated['date_from'] ?? null, 'date_to' => $validated['date_to'] ?? null, @@ -128,6 +135,7 @@ class TransactionController extends Controller 'labels' => $labels, 'automationRules' => $automationRules, 'hasAiConsent' => $user->hasActiveAiConsent(), + 'lastVisitAt' => $lastVisitAt?->toISOString(), ]); } diff --git a/app/Models/User.php b/app/Models/User.php index c9be818b..507ab806 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -27,6 +27,7 @@ use Laravel\Pennant\Concerns\HasFeatures; /** * @property ?Carbon $last_logged_in_at * @property ?Carbon $last_active_at + * @property ?Carbon $transactions_last_visited_at */ class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail { @@ -77,6 +78,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'paywall_seen_at' => 'datetime', 'last_logged_in_at' => 'datetime', 'last_active_at' => 'datetime', + 'transactions_last_visited_at' => 'datetime', ]; } diff --git a/database/migrations/2026_06_29_161916_add_transactions_last_visited_at_to_users_table.php b/database/migrations/2026_06_29_161916_add_transactions_last_visited_at_to_users_table.php new file mode 100644 index 00000000..41195ff4 --- /dev/null +++ b/database/migrations/2026_06_29_161916_add_transactions_last_visited_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('transactions_last_visited_at')->nullable()->after('last_active_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('transactions_last_visited_at'); + }); + } +}; diff --git a/resources/js/lib/new-transactions.test.ts b/resources/js/lib/new-transactions.test.ts index fd548119..eb5b65a2 100644 --- a/resources/js/lib/new-transactions.test.ts +++ b/resources/js/lib/new-transactions.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { isNewSince, newestCreatedAt } from './new-transactions'; +import { isNewSince } from './new-transactions'; const tx = (created_at: string) => ({ created_at }); @@ -34,31 +34,3 @@ describe('isNewSince', () => { ); }); }); - -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 index a2f5bb2a..59576a06 100644 --- a/resources/js/lib/new-transactions.ts +++ b/resources/js/lib/new-transactions.ts @@ -1,50 +1,5 @@ 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 diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 7dd5c7dd..950e8c63 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -83,12 +83,7 @@ import { type CursorPaginatedResponse, } from '@/lib/cursor-pagination'; import { consoleDebug } from '@/lib/debug'; -import { - isNewSince, - loadLastVisit, - newestCreatedAt, - saveLastVisit, -} from '@/lib/new-transactions'; +import { isNewSince } from '@/lib/new-transactions'; import { captureEvent } from '@/lib/posthog'; import { getBulkDeleteConfirmationText } from '@/lib/transaction-delete-confirmation'; import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation'; @@ -142,6 +137,7 @@ interface Props { labels: Label[]; automationRules: AutomationRule[]; hasAiConsent: boolean; + lastVisitAt: string | null; } const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility'; @@ -433,6 +429,7 @@ export default function Transactions({ labels: initialLabels, automationRules, hasAiConsent, + lastVisitAt, }: Props) { const locale = useLocale(); const { auth, features } = usePage().props; @@ -490,19 +487,9 @@ export default function Transactions({ ); // 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 - }, []); + // The marker is server-side (per user), so it's shared across devices; the + // controller advances it on load using the newest created_at it served. + const [lastVisitAtMount] = useState(() => lastVisitAt); // Sync filter state when appliedFilters prop changes useEffect(() => { diff --git a/tests/Feature/NewTransactionsMarkerTest.php b/tests/Feature/NewTransactionsMarkerTest.php new file mode 100644 index 00000000..7affe98b --- /dev/null +++ b/tests/Feature/NewTransactionsMarkerTest.php @@ -0,0 +1,65 @@ +user = User::factory()->onboarded()->create(); + $this->account = Account::factory()->create(['user_id' => $this->user->id]); +}); + +test('first visit exposes a null marker and stores the newest served created_at', function () { + $newest = Carbon::parse('2026-06-29T10:00:00Z'); + + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'created_at' => Carbon::parse('2026-06-20T08:00:00Z'), + ]); + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'created_at' => $newest, + ]); + + $response = actingAs($this->user)->get(route('transactions.index')); + + $response->assertInertia(fn ($page) => $page->where('lastVisitAt', null)); + expect($this->user->fresh()->transactions_last_visited_at->equalTo($newest))->toBeTrue(); +}); + +test('a later visit sees the previous marker and advances it forward', function () { + $previousVisit = Carbon::parse('2026-06-25T00:00:00Z'); + $this->user->forceFill(['transactions_last_visited_at' => $previousVisit])->save(); + + $newest = Carbon::parse('2026-06-29T10:00:00Z'); + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'created_at' => $newest, + ]); + + $response = actingAs($this->user)->get(route('transactions.index')); + + $response->assertInertia(fn ($page) => $page->where('lastVisitAt', $previousVisit->toISOString())); + expect($this->user->fresh()->transactions_last_visited_at->equalTo($newest))->toBeTrue(); +}); + +test('the marker never moves backward when nothing newer was served', function () { + $marker = Carbon::parse('2026-06-29T23:00:00Z'); + $this->user->forceFill(['transactions_last_visited_at' => $marker])->save(); + + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'created_at' => Carbon::parse('2026-06-20T08:00:00Z'), + ]); + + actingAs($this->user)->get(route('transactions.index'))->assertSuccessful(); + + expect($this->user->fresh()->transactions_last_visited_at->equalTo($marker))->toBeTrue(); +}); diff --git a/tests/Performance/PageQueryCountTest.php b/tests/Performance/PageQueryCountTest.php index 14f5f4a5..abc16d39 100644 --- a/tests/Performance/PageQueryCountTest.php +++ b/tests/Performance/PageQueryCountTest.php @@ -57,7 +57,7 @@ test('account show page does not exceed query threshold', function () { }); test('transactions index page does not exceed query threshold', function () { - assertMaxQueries(20, function () { + assertMaxQueries(21, function () { $this->get(route('transactions.index'))->assertOk(); }, 'Transactions Index'); }); @@ -164,7 +164,7 @@ test('transactions page query count does not scale with number of transactions', ]); // Same threshold — paginated queries should not scale - assertMaxQueries(20, function () { + assertMaxQueries(21, function () { $this->get(route('transactions.index'))->assertOk(); }, 'Transactions with 120 records'); });