Skip to content

fix(gigs): make boost visible — opaque menu, feedback, badge, top placement#480

Merged
ralyodio merged 1 commit into
masterfrom
fix/boost-ux
Jun 14, 2026
Merged

fix(gigs): make boost visible — opaque menu, feedback, badge, top placement#480
ralyodio merged 1 commit into
masterfrom
fix/boost-ux

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Follow-up to #479. Fixes three issues reported after merge.

Problems

  1. Transparent dropdown menu — the gig actions menu used bg-popover, but no --color-popover token is defined in globals.css, so it rendered see-through.
  2. No boost feedback — boosting silently router.refresh()'d with no success/failure indication.
  3. No "boosted" flag, and boosts didn't surface/gigs and /for-hire used their own inline queries sorted by created_at, so boosting had no visible effect on those pages.

Changes

  • Opaque menu: GigActions dropdown now uses bg-card (a defined, opaque token).
  • Feedback: handleBoost shows an explicit dialog alert on both success ("pinned to the top of the listing for the next week") and failure (the API error).
  • Boosted badge: a 🚀 "Boosted" badge + amber ring on GigCard, and a badge on the dashboard My Gigs list, while a boost is active.
  • Top placement for a week: extracted listing fetch into src/lib/gigs/fetch-gigs.ts, shared by /gigs and /for-hire. For the default newest sort it pins active boosts (boosted within the 7-day window) ahead of everything else, then the rest by recency, with correct pagination. Explicit non-default sorts (oldest/budget) are respected without pinning.
  • src/lib/boost.ts: added isGigBoosted() and BOOST_ACTIVE_MS (window == cooldown == 7 days).

Verification

pnpm type-check clean; pnpm lint 0 errors; pre-commit suite (full) + build pass. New isGigBoosted unit tests. Validated the PostgREST or() timestamp filter against the live REST endpoint (boosted-active + not-boosted buckets return correct counts/ranges).

🤖 Generated with Claude Code

Addresses three issues with the boost feature:

- The actions dropdown used bg-popover, which has no theme token defined,
  so the menu rendered transparent. Switched to bg-card (opaque).
- Boosting gave no success/failure indication. handleBoost now surfaces an
  explicit dialog alert on both outcomes.
- Boosted gigs are now visibly distinct and prioritized: a "Boosted" badge
  on the gig card + dashboard, and boosted gigs (within the 7-day window)
  are pinned to the top of /gigs and /for-hire ahead of everything else for
  the duration of the boost.

The /gigs and /for-hire listings previously sorted purely by created_at via
their own inline queries, so boosting had no effect there. Extracted that
into src/lib/gigs/fetch-gigs.ts, which applies the shared filters and, for
the default newest sort, pins active boosts on top (then the rest by
recency) with correct pagination. isGigBoosted()/BOOST_ACTIVE_MS added to
src/lib/boost.ts (window == cooldown == 7 days).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes three post-merge regressions from #479: an opaque menu background (bg-popoverbg-card), silent boost feedback (now shows an alert dialog on success and failure), and boosted gigs having no visible effect on the public listings. The listing behaviour is the most substantial change.

  • src/lib/boost.ts: isGigBoosted() and BOOST_ACTIVE_MS added; unit tests cover null, within-window, at-boundary, and invalid-timestamp cases.
  • src/lib/gigs/fetch-gigs.ts: New shared fetch utility that, for the default "newest" sort, issues a count query for active boosts then stitches a boosted-first page from two separate Supabase queries; explicit sorts (oldest/budget) fall through to a single query as before. Both /gigs and /for-hire now delegate to this function.
  • GigCard / dashboard: Boosted badge (amber ring + 🚀 label) rendered when isGigBoosted() is true; the dashboard My Gigs list gets the same badge.

Confidence Score: 4/5

Safe to merge; all three reported regressions are addressed and the boost-pinning pagination logic is correct for standard navigation patterns.

The boost-pinning path in fetch-gigs.ts makes 3 sequential PostgREST round-trips on every default-sort page load — queries 2 and 3 could be issued in parallel after query 1 resolves, halving the added latency. The FetchGigsResult return type is Record<string, unknown>[] which forces double type-casting at both call sites and removes compile-time field safety. Neither issue causes wrong behaviour today, but both will be easy to trip over as the codebase grows.

src/lib/gigs/fetch-gigs.ts — the new shared fetch function warrants a second look for the sequential-query pattern and the loose return type.

Important Files Changed

Filename Overview
src/lib/gigs/fetch-gigs.ts New shared fetch utility that pins boosted gigs atop the default listing via a 3-query strategy (count → boosted slice → normal slice); the sequential nature doubles latency vs one query, and the return type uses Record<string, unknown>[] which loses type safety at call sites.
src/lib/boost.ts Added BOOST_ACTIVE_MS constant and isGigBoosted() helper; boundary conditions handled correctly (strict-less-than on the cutoff, NaN guard for unparseable timestamps).
src/lib/boost.test.ts New tests for isGigBoosted cover null, within-window, at-boundary, and invalid-timestamp cases; boundary logic aligns with the implementation.
src/components/gigs/GigActions.tsx Boost now shows explicit success/error dialogs via alert(); menu closes and loading resets before the await so the UI is not stuck; bg-popover→bg-card fix is correct.
src/components/gigs/GigCard.tsx GigCardData type extracted and exported; Boosted badge and amber ring added conditional on isGigBoosted(); logic is straightforward and correct.
src/app/dashboard/gigs/page.tsx Boosted badge added to My Gigs list using isGigBoosted(); the existing select("*") query already returns boosted_at so the check works correctly.
src/app/gigs/[[...tags]]/page.tsx Inline query replaced with fetchGigs() call; filters correctly forwarded; requires as unknown as GigCardData[] cast due to loose return type in FetchGigsResult.
src/app/for-hire/[[...tags]]/page.tsx Same refactor as /gigs — inline query removed, fetchGigs() used; budgetType is correctly omitted (was never in the for-hire query); same type-cast issue.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant GigsPage as /gigs or /for-hire
    participant fetchGigs
    participant DB as Supabase / PostgREST

    Browser->>GigsPage: "GET ?sort=newest (default)"
    GigsPage->>fetchGigs: "fetchGigs(supabase, { sort: undefined, ... })"

    fetchGigs->>DB: "HEAD — count of boosted gigs (boosted_at >= cutoff)"
    DB-->>fetchGigs: boostedTotal

    fetchGigs->>DB: SELECT boosted slice (range boostedStart..boostedEnd-1)
    DB-->>fetchGigs: boosted[]

    fetchGigs->>DB: "SELECT normal slice OR(boosted_at IS NULL, boosted_at < cutoff) + count"
    DB-->>fetchGigs: normal[], normalTotal

    fetchGigs-->>GigsPage: "{ gigs: [...boosted, ...normal], count: boostedTotal + normalTotal }"
    GigsPage-->>Browser: Rendered page (boosted gigs pinned to top)

    Note over fetchGigs,DB: Non-default sorts (oldest/budget_high/budget_low) use a single query — no boost pinning
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(gigs): visible boost — opaque menu, ..." | Re-trigger Greptile

Comment on lines +129 to +163
// How many active-boosted gigs match the filters (drives where the page splits).
const { count: boostedCount } = await base({ select: "id", head: true }).gte(
"boosted_at",
cutoff
);
const boostedTotal = boostedCount ?? 0;

// Boosted slice for this page.
const boostedStart = Math.min(offset, boostedTotal);
const boostedEnd = Math.min(offset + limit, boostedTotal); // exclusive
let boosted: Record<string, unknown>[] = [];
if (boostedEnd > boostedStart) {
const { data } = await base()
.gte("boosted_at", cutoff)
.order("boosted_at", { ascending: false })
.range(boostedStart, boostedEnd - 1);
boosted = data ?? [];
}

// Remaining slots filled from the non-boosted stream by recency.
const needed = limit - boosted.length;
const normalStart = Math.max(0, offset - boostedTotal);
let normal: Record<string, unknown>[] = [];
let normalTotal = 0;
if (needed > 0) {
const { data, count } = await base()
.or(notBoosted)
.order("created_at", { ascending: false })
.range(normalStart, normalStart + needed - 1);
normal = data ?? [];
normalTotal = count ?? 0;
} else {
const { count } = await base({ select: "id", head: true }).or(notBoosted);
normalTotal = count ?? 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Sequential queries can be partially parallelised

The default-sort path issues 3 serial PostgREST round-trips: (1) count of boosted gigs, (2) boosted data slice, (3) normal data slice. Queries 2 and 3 both depend only on boostedTotal from query 1, but neither depends on the other — they can be Promise.all-ed to cut the sequential latency from 3× to 2× per page load. Additionally, the boosted data query uses base() which always sets count: "exact", but the returned count is never destructured — only data is used — so PostgREST computes and transmits an unneeded count header on that request.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

Comment on lines +38 to +41
export interface FetchGigsResult {
gigs: Record<string, unknown>[];
count: number;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 FetchGigsResult.gigs typed too loosely

gigs: Record<string, unknown>[] erases all field-level type information, requiring both call sites to escape through as unknown as GigCardData[]. If a column is renamed in GIG_LIST_SELECT or the poster join shape changes, TypeScript will not surface the mismatch — it will only show at runtime. Typing the field as GigCardData[] (or a compatible Supabase-generated type) propagates safety through to the component without any runtime cost.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

@ralyodio ralyodio merged commit b435532 into master Jun 14, 2026
6 checks passed
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