Skip to content

feat(frontend): per-tab persisted table state + returnUrl round-trip#114

Merged
skynet2 merged 22 commits intomasterfrom
table-improvements
Apr 19, 2026
Merged

feat(frontend): per-tab persisted table state + returnUrl round-trip#114
skynet2 merged 22 commits intomasterfrom
table-improvements

Conversation

@skynet2
Copy link
Copy Markdown
Member

@skynet2 skynet2 commented Apr 19, 2026

Summary

  • Every list/table (accounts, tags, categories, currencies, transactions, service-tokens) now persists its filter/sort/pagination/global-search state per-tab in localStorage with a 24 h TTL.
  • Create/edit pages (accounts, tags, categories, currencies, rules, schedules, transactions + transaction-editor) accept a returnUrl query param and go back to it after save/cancel/delete instead of a hardcoded route.
  • Returning via returnUrl carries a transient ?restore=1 marker; the list applies the stored state once, clears the entry, and strips the flag — so opening the same page from the menu always gives a fresh view.
  • URL stays clean — no serialized filter blob. Deep-link support via ?filters=...&sort=... (optional JSON query string) still works.
  • Per-tab isolation via sessionStorage UUID so two tabs don't clobber each other.
  • Cancel buttons added to upsert pages that lacked them; set type="button" on all action buttons inside <form (ngSubmit)> to prevent double-fire.

Touched areas

  • frontend/src/app/shared/helpers/return-url.helper.ts — build/safe/withRestoreFlag.
  • frontend/src/app/shared/helpers/table-query-state.helper.ts — URL ↔ state encode/decode (handles menu-mode FilterMetadata[], JSON validation).
  • frontend/src/app/shared/helpers/table-state-persistence.helper.ts — localStorage r/w with TTL + GC.
  • frontend/src/app/shared/services/tab-session.service.ts — per-tab UUID.
  • 6 list components + shared transactions-table — read on ?restore=1, write on change.
  • 8 upsert components — navigateAfterSave() uses returnUrl + ?restore=1, falls back to existing hardcoded route when absent.

Test plan

  • /accounts — apply tag filter → edit → save → filter restores.
  • /accounts → menu click back → fresh view (no filters).
  • /accounts/liabilities route-data preselect still applied.
  • /transactions/deposits lazy table — filter/sort/page round-trip.
  • Legacy deep-link /transactions?title=rent&ignoreDateFilter=true still works.
  • Reload without ?restore=1 = fresh view.
  • Two tabs on same list page — independent state via per-tab sessionStorage UUID.
  • npx ng build passes.
  • Manual QA on other list pages (tags, categories, currencies, rules, schedules) with the same flow.

🤖 Generated with Claude Code

skynet2 added 20 commits April 19, 2026 12:12
# Conflicts:
#	frontend/src/app/pages/accounts/accounts-upsert.component.ts
@skynet2
Copy link
Copy Markdown
Member Author

skynet2 commented Apr 19, 2026

@gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a comprehensive system for persisting and restoring table states (filters, sorting, and pagination) across the application's list and upsert views. It adds several utility helpers and services, such as TableStatePersistence and ReturnUrlHelper, to manage state synchronization and navigation with return URLs. The feedback highlights opportunities to reduce code duplication by centralizing shared logic in services or base classes, improving the performance of the local storage garbage collection, and ensuring standard form usability by using proper button types for submission.

Comment thread frontend/src/app/shared/helpers/table-state-persistence.helper.ts
Comment thread frontend/src/app/pages/accounts/accounts-upsert.component.html Outdated
Comment thread frontend/src/app/pages/accounts/accounts-list.component.ts
Comment thread frontend/src/app/pages/accounts/accounts-upsert.component.ts Outdated
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.80%. Comparing base (b37813c) to head (a4f74dc).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #114   +/-   ##
=======================================
  Coverage   86.80%   86.80%           
=======================================
  Files          86       86           
  Lines        7153     7153           
=======================================
  Hits         6209     6209           
  Misses        717      717           
  Partials      227      227           
Flag Coverage Δ
unittests 86.80% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a comprehensive table state persistence and restoration system across various list components, including Accounts, Categories, and Transactions. It introduces utility helpers for managing return URLs and synchronizing table configurations like filters and sorting with LocalStorage and query parameters. Review feedback identifies a logic error in filter merging where URL parameters are incorrectly overwritten by persisted state, a memory leak in the Transactions table component due to an unmanaged subscription, and a performance concern regarding the frequency of garbage collection in the state persistence helper.

Comment thread frontend/src/app/shared/helpers/table-state-persistence.helper.ts
@skynet2
Copy link
Copy Markdown
Member Author

skynet2 commented Apr 19, 2026

Addressed bot review comments in b7a0476:

  • C1 (GC on every write): added 60s throttle on TableStatePersistence.gc().
  • C2 (Enter-to-submit broken): save button reverted to type="submit" on accounts/categories/currencies upsert (dropped the explicit (click)); Cancel stays type="button". Form (ngSubmit) handles Enter.
  • C4 (duplicated navigateAfterSave): moved to ReturnUrlHelper.navigateAfterSave(router, route, fallback); 8 upsert components each drop 7 lines for 1 call site.
  • C3 (duplicated restore block + router.navigate in constructor): not addressed. The per-component shape diverges (accounts has extra.tagIds, lazy transactions has first/rows, service-tokens has no filters); a clean abstraction isn't obvious — refactoring to a helper would need 4-5 callbacks per call site. Constructor navigation is intentional to strip ?restore=1 before the first render instead of flashing it. Deferring.

@skynet2
Copy link
Copy Markdown
Member Author

skynet2 commented Apr 19, 2026

@gemini review

@skynet2
Copy link
Copy Markdown
Member Author

skynet2 commented Apr 19, 2026

Round 2 in a4f74dc:

  • transactions-table merge order (high): flipped spread — URL-decoded filters now override the preselectedFilter + storage-restored state. Deep-links like /transactions/deposits?filters=... now apply correctly.
  • subscription memory leak (medium): wrapped both routeSnapshot.data and routeSnapshot.queryParams subscriptions with takeUntilDestroyed().
  • GC on every write: already throttled to 60 s in b7a0476 — the bot re-raised it on the re-read of the file; no additional change needed.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a robust mechanism for persisting and restoring table states—including filters, sorting, and pagination—across navigations using localStorage and URL query parameters. It adds several utility helpers and a session service to manage this state. The review feedback highlights missing property bindings in the transactions table and an incorrect merge order for filters that prevents URL parameters from overriding persisted state. Additionally, it recommends standardizing button types in upsert forms to use native submit behavior and moving navigation logic from component constructors to the ngOnInit lifecycle hook to follow Angular best practices.

Comment thread frontend/src/app/pages/rules/rules-upsert.component.html
Comment thread frontend/src/app/pages/rules/schedules-upsert.component.html
Comment thread frontend/src/app/pages/accounts/accounts-list.component.ts
@skynet2
Copy link
Copy Markdown
Member Author

skynet2 commented Apr 19, 2026

Round 3 triage:

  • transactions-table.html missing [filters]/[multiSortMeta] (high) — false positive: both bindings exist on lines 12 and 18 of the current file. The bot suggested lines identical to what's there.
  • transactions-table.ts:135 merge order (high) — already fixed in a4f74dc before this review ran. Current line: this.filters = { ...this.filters, ...(decoded.filters ...) }; (URL wins).
  • rules-upsert / schedules-upsert type="submit" (medium) — wrong recommendation: neither template is inside a <form> (template-driven with [(ngModel)] only, save is a plain (click)="create()/update()"). type="submit" would be a no-op.
  • accounts-list.ts constructor navigation → ngOnInit (medium) — duplicate of an earlier C3. The router.navigate(..., { replaceUrl: true }) to strip restore=1 is passive, runs after microtask; moving to ngOnInit adds no correctness benefit and delays URL cleanup to after the first render. Defer.

No code changes.

@skynet2
Copy link
Copy Markdown
Member Author

skynet2 commented Apr 19, 2026

@gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a robust system for persisting table states (filters, sorting, and pagination) and managing return URLs across various list and upsert pages. It introduces several utility helpers and a session service to handle state storage in localStorage and sessionStorage. The review feedback identifies several valid improvement opportunities: refactoring duplicated state restoration logic into a shared helper to reduce maintenance overhead, moving navigation logic out of constructors to avoid Angular lifecycle issues, and addressing potential ExpressionChangedAfterItHasBeenCheckedError risks when programmatically filtering tables. Additionally, the garbage collection logic in the persistence helper could be improved to handle full storage scenarios more effectively.

Comment on lines +123 to +146
if (route.snapshot.queryParamMap.get('restore') === '1') {
const stored = TableStatePersistence.read(this.stateKey, this.tabSession.id);
if (stored) {
if (stored.filters) this.filters = { ...this.filters, ...(stored.filters as { [s: string]: FilterMetadata }) };
if (stored.sort && stored.sort.length > 0) this.multiSortMeta = stored.sort;
if (stored.global) this.initialGlobalFilter = stored.global;
const tagIds = stored.extra?.['tagIds'];
if (Array.isArray(tagIds)) this.selectedTagIds = tagIds as number[];
}
TableStatePersistence.clear(this.stateKey, this.tabSession.id);
this.router.navigate([], { relativeTo: route, queryParams: { restore: null }, queryParamsHandling: 'merge', replaceUrl: true });
}

const queryState = TableQueryStateHelper.decode(route.snapshot.queryParams);
if (queryState.filters) {
this.filters = { ...this.filters, ...(queryState.filters as { [s: string]: FilterMetadata }) };
}
if (queryState.sort && queryState.sort.length > 0) {
this.multiSortMeta = queryState.sort;
}
if (queryState.global) {
this.initialGlobalFilter = queryState.global;
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for restoring table state from localStorage and query parameters is duplicated across multiple list components (Accounts, Categories, Currencies, etc.). This increases maintenance overhead and the risk of inconsistent behavior. Consider refactoring this into a shared helper method or a base class that handles state restoration consistently across all list pages.

if (Array.isArray(tagIds)) this.selectedTagIds = tagIds as number[];
}
TableStatePersistence.clear(this.stateKey, this.tabSession.id);
this.router.navigate([], { relativeTo: route, queryParams: { restore: null }, queryParamsHandling: 'merge', replaceUrl: true });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing navigation via this.router.navigate inside a component constructor is generally discouraged in Angular. The component is not yet fully initialized, which can lead to race conditions or unexpected behavior with the router's lifecycle. It is recommended to move this logic to ngOnInit.

Comment on lines +150 to +157
ngAfterViewInit() {
if (this.initialGlobalFilter && this.table) {
if (this.filter?.nativeElement) {
this.filter.nativeElement.value = this.initialGlobalFilter;
}
this.table.filterGlobal(this.initialGlobalFilter, 'contains');
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling this.table.filterGlobal inside ngAfterViewInit can trigger an ExpressionChangedAfterItHasBeenCheckedError because it modifies the table's state after it has been checked by Angular's change detection. Wrapping the call in setTimeout or using a microtask is a safer approach to ensure it runs in a subsequent cycle.

    ngAfterViewInit() {
        if (this.initialGlobalFilter && this.table) {
            setTimeout(() => {
                if (this.filter?.nativeElement) {
                    this.filter.nativeElement.value = this.initialGlobalFilter;
                }
                this.table.filterGlobal(this.initialGlobalFilter, 'contains');
            });
        }
    }

Comment on lines +48 to +72
private static gc(): void {
const now = Date.now();
if (now - this.lastGcAt < GC_MIN_INTERVAL_MS) return;
this.lastGcAt = now;
const toDrop: string[] = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (!k || !k.startsWith(KEY_PREFIX)) continue;
const raw = localStorage.getItem(k);
if (!raw) continue;
try {
const entry = JSON.parse(raw) as StoredEntry;
if (typeof entry.expiresAt !== 'number' || entry.expiresAt < now) {
toDrop.push(k);
}
} catch {
toDrop.push(k);
}
}
} catch {
return;
}
for (const k of toDrop) this.removeItem(k);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The garbage collection process iterates over all keys in localStorage. While throttled, this can still be a performance concern if the origin has many items in storage. Additionally, gc() is only called after a successful write. If localStorage is full, setItem will throw and gc() will be skipped. Consider calling gc() before attempting to write to help free up space.

if (stored.rows != null) this.initialRows = stored.rows;
}
TableStatePersistence.clear(this.stateKey, this.tabSession.id);
this.router.navigate([], { relativeTo: this.routeSnapshot, queryParams: { restore: null }, queryParamsHandling: 'merge', replaceUrl: true });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Performing navigation via this.router.navigate inside a component constructor is generally discouraged in Angular. It is recommended to move this logic to ngOnInit to ensure the component is properly initialized and to avoid potential issues with the router's lifecycle.

@skynet2 skynet2 merged commit 5d68592 into master Apr 19, 2026
8 checks passed
@skynet2 skynet2 deleted the table-improvements branch April 19, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant