fix(gigs): make boost visible — opaque menu, feedback, badge, top placement#480
Conversation
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>
vu1nz Security Review0 finding(s) in PR #? No security issues found. |
Greptile SummaryThis PR fixes three post-merge regressions from #479: an opaque menu background (
Confidence Score: 4/5Safe 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
Sequence DiagramsequenceDiagram
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
Reviews (1): Last reviewed commit: "fix(gigs): visible boost — opaque menu, ..." | Re-trigger Greptile |
| // 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; | ||
| } |
There was a problem hiding this comment.
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!
| export interface FetchGigsResult { | ||
| gigs: Record<string, unknown>[]; | ||
| count: number; | ||
| } |
There was a problem hiding this comment.
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!
Follow-up to #479. Fixes three issues reported after merge.
Problems
bg-popover, but no--color-popovertoken is defined inglobals.css, so it rendered see-through.router.refresh()'d with no success/failure indication./gigsand/for-hireused their own inline queries sorted bycreated_at, so boosting had no visible effect on those pages.Changes
GigActionsdropdown now usesbg-card(a defined, opaque token).handleBoostshows an explicit dialogalerton both success ("pinned to the top of the listing for the next week") and failure (the API error).GigCard, and a badge on the dashboard My Gigs list, while a boost is active.src/lib/gigs/fetch-gigs.ts, shared by/gigsand/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: addedisGigBoosted()andBOOST_ACTIVE_MS(window == cooldown == 7 days).Verification
pnpm type-checkclean;pnpm lint0 errors; pre-commit suite (full) + build pass. NewisGigBoostedunit tests. Validated the PostgRESTor()timestamp filter against the live REST endpoint (boosted-active + not-boosted buckets return correct counts/ranges).🤖 Generated with Claude Code