Search State, URL Persistence and Mobile UX Patterns

Search state persistence and mobile UX are the details that separate a polished classified website from a rough prototype. Users on mobile expect a fast, thumb-friendly experience — large touch targets, filters in a bottom sheet, swipe-friendly cards. The SearchStateService two-way URL synchronisation means every search is shareable and bookmarkable. The RelativeTimePipe (“3 days ago”) communicates recency instantly, which matters in classifieds where fresh listings are more likely to still be available.

Search State and Mobile Optimisations

// ── shared/pipes/relative-time.pipe.ts ────────────────────────────────────
@Pipe({ name: 'relativeTime', standalone: true, pure: false })
export class RelativeTimePipe implements PipeTransform {
  private static readonly INTERVALS = [
    { label: 'year',   seconds: 31536000 },
    { label: 'month',  seconds: 2592000  },
    { label: 'week',   seconds: 604800   },
    { label: 'day',    seconds: 86400    },
    { label: 'hour',   seconds: 3600     },
    { label: 'minute', seconds: 60       },
  ] as const;

  transform(value: string | Date | null): string {
    if (!value) return '';
    const seconds = Math.floor(
      (Date.now() - new Date(value).getTime()) / 1000);
    if (seconds < 60)  return 'just now';
    if (seconds < 0)   return 'in the future';

    for (const { label, seconds: threshold } of RelativeTimePipe.INTERVALS) {
      const count = Math.floor(seconds / threshold);
      if (count >= 1)
        return `${count} ${label}${count !== 1 ? 's' : ''} ago`;
    }
    return 'a long time ago';
  }
}

// ── shared/services/saved-searches.service.ts ────────────────────────────
@Injectable({ providedIn: 'root' })
export class SavedSearchesService {
  private readonly STORAGE_KEY = 'classifiedapp_recent_searches';
  private readonly MAX_SAVES   = 5;

  private _searches = signal<SavedSearch[]>(this.load());

  readonly searches = this._searches.asReadonly();

  save(params: SearchParams): void {
    if (!params.keyword && !params.city && !params.category) return;

    const label = [params.keyword, params.category, params.city]
      .filter(Boolean).join(' · ');

    const updated = [
      { label, params, savedAt: new Date().toISOString() },
      ...this._searches().filter(s => s.label !== label),
    ].slice(0, this.MAX_SAVES);

    this._searches.set(updated);
    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(updated));
  }

  remove(label: string): void {
    const updated = this._searches().filter(s => s.label !== label);
    this._searches.set(updated);
    localStorage.setItem(this.STORAGE_KEY, JSON.stringify(updated));
  }

  private load(): SavedSearch[] {
    try {
      return JSON.parse(localStorage.getItem(this.STORAGE_KEY) ?? '[]');
    } catch { return []; }
  }
}

// ── Browse page: recent searches as quick chips ───────────────────────────
// <div class="recent-searches" *ngIf="savedSearches.searches().length > 0">
//   <span class="label">Recent:</span>
//   @for (search of savedSearches.searches(); track search.label) {
//     <mat-chip-row (click)="applySearch(search.params)"
//                   (removed)="savedSearches.remove(search.label)">
//       {{ search.label }}
//       <button matChipRemove aria-label="Remove recent search">
//         <mat-icon>cancel</mat-icon>
//       </button>
//     </mat-chip-row>
//   }
// </div>

// ── Mobile filters bottom sheet ───────────────────────────────────────────
// BrowseComponent — mobile: open filters in a MatBottomSheet
// In the filters toggle button:
// (click)="breakpointObserver.isMatched('(max-width: 768px)')
//           ? openFiltersSheet()
//           : filtersOpen.update(v => !v)"

// openFiltersSheet() {
//   const ref = this.bottomSheet.open(SearchFiltersBottomSheetComponent);
//   ref.afterDismissed().subscribe(filters => {
//     if (filters) this.onFiltersChanged(filters);
//   });
// }
Note: The RelativeTimePipe is marked pure: false because its output changes over time even if the input (the date string) hasn’t changed. A pure pipe only recomputes when its input changes — “2 days ago” is correct at 11pm but wrong at midnight when it becomes “3 days ago.” The pure: false setting re-runs the pipe on every change detection cycle, keeping it accurate. The performance cost is acceptable for a pipe used in listing cards — change detection runs infrequently on a mostly static list.
Tip: Use Angular CDK’s BreakpointObserver (from @angular/cdk/layout) to adapt the UI for mobile vs desktop without CSS-only media queries. breakpointObserver.isMatched('(max-width: 768px)') in component code allows Angular to render different template blocks or open different overlays (bottom sheet on mobile, sidebar on desktop) based on the viewport width. Combine with @if in templates for progressive disclosure patterns.
Warning: The SavedSearchesService reads from localStorage synchronously in the constructor (via this.load()). In SSR (Angular Universal/Server-Side Rendering) environments, localStorage is not available on the server — accessing it throws a ReferenceError. If the classified website uses SSR for SEO, guard all localStorage accesses with isPlatformBrowser(this.platformId) from @angular/common. For most client-only SPAs (no SSR), this is not an issue.

Frontend Feature Summary

Feature Component Key Pattern
Browse/Search BrowseComponent URL-persisted signals + switchMap
Listing card ListingCardComponent CurrencyPipe, RelativeTimePipe, CDN URL
Create listing CreateListingWizardComponent MatStepper + step-level FormGroups
Photo gallery ListingDetailComponent Computed signal + keyboard nav
Seller dashboard MyListingsComponent MatTable + SelectionModel + status @switch
Recent searches SavedSearchesService Signals + localStorage persistence

Common Mistakes

Mistake 1 — pure: true on a time-dependent pipe (stale “X days ago” display)

❌ Wrong — RelativeTimePipe with pure: true; “just now” shown for a 3-day-old listing after initial render; never updates.

✅ Correct — pure: false; pipe re-evaluates on every change detection; display stays current.

Mistake 2 — Desktop-only filter sidebar on mobile (filters inaccessible on small screens)

❌ Wrong — sidebar always rendered; on mobile takes entire viewport; no way to see listings.

✅ Correct — BreakpointObserver-driven conditional: sidebar on desktop, bottom sheet on mobile.

🧠 Test Yourself

The user searches for “guitar” in London and clicks a listing. They then click the browser back button. What URL does the browser navigate to?