feat: implement Lightning Rounds (Surge Events) — closes #175 - #177
Conversation
|
@Sendi0011 is attempting to deploy a commit to the Threadflow Team on Vercel. A member of the Team first needs to authorize it. |
|
@Sendi0011 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughAdds Lightning Rounds: new hooks to aggregate round and bounty data, a client countdown hook, a dedicated Lightning Round page, banner and schedule components, and UI updates to highlight Lightning Round bounties. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Page as Lightning Round Page (client)
participant Hook as useLightningRoundBounties / useLightningRounds
participant API as GraphQL Bounty Query
participant UI as Components (Banner, Cards, Schedule)
rect rgba(255,200,0,0.5)
User->>Page: Navigate to /bounty/lightning-round?id=...
Page->>Page: resolve targetId (query / active / upcoming)
Page->>Hook: request round + bounties for targetId
Hook->>API: fetch bounties (window filter / limit)
API-->>Hook: return bounty data
Hook->>Hook: group by window/type, compute stats, getRoundPhase
Hook-->>Page: return round object + groupedByType
Page->>UI: render Banner (countdown, stats) and grouped BountyCards
UI->>User: display live countdown (useCountdown), progress, and highlighted cards
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (1)
components/bounty/lightning-round-schedule.tsx (1)
22-47: Relative-time labels are static until the hook refetches.
formatDistanceToNowis evaluated once per render ofRoundRow. Unlike the banner's countdown, the schedule has no interval, so "Ends in 2 hours" stays that way while the user sits on the page. For a sidebar that advertises a time-boxed event, this may be OK — but worth wiring a low-frequency (e.g. 60s)useEffector reusing a shareduseNow()if precision matters. Otherwise, consider wording that tolerates staleness better (e.g. absolute end time on hover).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@components/bounty/lightning-round-schedule.tsx` around lines 22 - 47, The relative-time string in phaseConfig (computed via formatDistanceToNow for the "active" dateLine) is only evaluated once per render of RoundRow and thus becomes stale; fix by updating RoundRow to refresh the relative time periodically—either reuse a shared hook like useNow() or add a low-frequency useEffect interval (e.g., 60s) that forces a state tick and causes phaseConfig/dateLine to recompute (or replace the dateLine with an absolute time and show relative time on hover); target the code paths that build phaseConfig and the RoundRow component so the active case updates live.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/bounty/lightning-round/page.tsx`:
- Around line 352-365: The empty-state conditional is too narrow: update the
render condition that currently reads `isError || (!round && !targetId)` to
`isError || !round` so that when a `targetId` is present but the query returns
no matching `round` the empty state is shown; keep the existing loading guard
(`isLoading || listLoading`) intact so this change only affects the non-loading
branch in the component rendering logic where `round`, `isError`, and `targetId`
are used.
- Around line 319-334: The page currently calls useSearchParams() directly
inside the LightningRoundPage component which forces a CSR bailout; wrap the
part of the component that uses useSearchParams() in a React.Suspense boundary
(or move the hook into a client-only subcomponent) so that the route can
preserve static rendering. Concretely, either wrap the JSX that reads
searchParams/windowId with <Suspense fallback={...}>...</Suspense> inside
LightningRoundPage or extract the search-params-dependent logic into a new
client component (e.g., LightningRoundClient) that calls useSearchParams(), then
render that client component from the server component to avoid the CSR opt-out.
In `@components/bounty/lightning-round-banner.tsx`:
- Around line 215-245: The banner shows a "claimed" label while using
claimedCount + completedCount and the numeric percent can render >100; update
the copy and percent guard: change the visible label where you render
{round.stats.claimedCount + round.stats.completedCount} of
{round.stats.totalBounties} to say "claimed or completed" (matching
RoundHeader/RoundHeader usage) and wrap the computed percent displayed text in
Math.min(100, ...) just like the width style uses Math.min to clamp the value;
adjust the percent expression and keep the existing width calculation on the
inner div (referencing isActive and
round.stats.claimedCount/completedCount/totalBounties to locate the code).
- Around line 19-51: useCountdown currently depends on the Date object identity
and keeps the interval running and emitting new zero objects after expiry;
change the effect to depend on a numeric timestamp and stop the timer when
expired: inside useCountdown use targetTs = target?.getTime() as the effect
dependency (instead of target), compute diff from targetTs, and when diff <= 0
call setTimeLeft once to a stable zero value (or null) and clearInterval(id) so
the interval is stopped; also ensure you only setTimeLeft when the computed
values actually differ (or use a ref flag like isExpiredRef) to avoid emitting a
new identical object each second. Apply the same dependency/timer-stop fix to
the duplicate useCountdown implementation in
app/bounty/lightning-round/page.tsx.
In `@hooks/use-lightning-rounds.ts`:
- Around line 84-91: The stats object currently sets currency from
bounty.rewardCurrency when initializing a window entry (stats.currency) and
never reconciles mismatches, causing mixed-currency groups to show an incorrect
single currency; update the logic in the code that creates/updates window
entries (the place that initializes stats and the code paths that add bounties
to a window) to either (A) enforce a single currency by checking
bounty.rewardCurrency on add and skipping or asserting if it differs, or (B)
convert stats.currency into a per-currency totals map (e.g., totalsByCurrency)
and sum totalValue into the appropriate currency bucket, updating all consumers
(banner/header rendering) to use the per-currency totals; modify functions that
reference stats.currency and totalValue to use the chosen approach and reconcile
existing entries when adding new bounties.
- Around line 160-176: The hook useLightningRounds currently hardcodes
useBountiesQuery with limit: 100 which silently truncates rounds; change
useLightningRounds to accept a limit (or full options) param and pass it through
to useBountiesQuery instead of fixed limit:100, or implement pagination inside
useLightningRounds to fetch pages until all distinct bountyWindow ids are
collected (use groupBountiesByWindow to detect new windows) before computing
rounds/activeRound/upcomingRounds/endedRounds (functions to update:
useLightningRounds, the call site of useBountiesQuery, and the grouping logic
using groupBountiesByWindow and getRoundPhase).
- Around line 37-48: The function getRoundPhase currently returns "active" when
both startDate and endDate are missing, causing UI to show live state for
incomplete data; update getRoundPhase to detect when start and end are both
null/undefined (using the existing start and end variables from the
LightningRound pick) and return "upcoming" (or an explicit "unknown" if
preferred) before falling through to other checks so rounds without any dates do
not render as live/active in BountyCard, LightningRoundBanner, or RoundHeader.
- Around line 201-223: The hook useLightningRoundBounties fires useBountiesQuery
even when windowId is empty; update the call to useBountiesQuery to pass an
enabled flag (e.g., enabled: Boolean(windowId) or enabled: windowId !== "") so
the query is disabled when windowId is falsy/empty, keeping the rest of the hook
(groupBountiesByWindow, grouping logic, returned shape) unchanged and ensuring
no unnecessary network request occurs when windowId is not provided.
---
Nitpick comments:
In `@components/bounty/lightning-round-schedule.tsx`:
- Around line 22-47: The relative-time string in phaseConfig (computed via
formatDistanceToNow for the "active" dateLine) is only evaluated once per render
of RoundRow and thus becomes stale; fix by updating RoundRow to refresh the
relative time periodically—either reuse a shared hook like useNow() or add a
low-frequency useEffect interval (e.g., 60s) that forces a state tick and causes
phaseConfig/dateLine to recompute (or replace the dateLine with an absolute time
and show relative time on hover); target the code paths that build phaseConfig
and the RoundRow component so the active case updates live.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a7a94348-41d6-4d0d-a864-a9f856e63cc5
📒 Files selected for processing (6)
app/bounty/lightning-round/page.tsxapp/bounty/page.tsxcomponents/bounty/bounty-card.tsxcomponents/bounty/lightning-round-banner.tsxcomponents/bounty/lightning-round-schedule.tsxhooks/use-lightning-rounds.ts
| {isLoading || listLoading ? ( | ||
| <PageSkeleton /> | ||
| ) : isError || (!round && !targetId) ? ( | ||
| <div className="flex flex-col items-center justify-center py-32 text-center"> | ||
| <Zap className="size-12 text-muted-foreground mb-4" /> | ||
| <h2 className="text-xl font-bold mb-2">No Lightning Round Found</h2> | ||
| <p className="text-muted-foreground mb-6"> | ||
| There are no active or upcoming Lightning Rounds right now. | ||
| </p> | ||
| <Button asChild variant="outline"> | ||
| <Link href="/bounty">Browse All Bounties</Link> | ||
| </Button> | ||
| </div> | ||
| ) : round ? ( |
There was a problem hiding this comment.
Empty-state condition undercounts.
isError || (!round && !targetId) only shows the empty state when there is no target id. If targetId is present (e.g. user arrived with ?id=<stale>) but the query returns no matching round, isError is false and round is null → falls through to null (line 402) and the user sees a blank page below the back-nav. Consider isError || !round (gated on !isLoading && !listLoading, already handled above) to cover "id points at nothing" too.
♻️ Suggested fix
- ) : isError || (!round && !targetId) ? (
+ ) : isError || !round ? (📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {isLoading || listLoading ? ( | |
| <PageSkeleton /> | |
| ) : isError || (!round && !targetId) ? ( | |
| <div className="flex flex-col items-center justify-center py-32 text-center"> | |
| <Zap className="size-12 text-muted-foreground mb-4" /> | |
| <h2 className="text-xl font-bold mb-2">No Lightning Round Found</h2> | |
| <p className="text-muted-foreground mb-6"> | |
| There are no active or upcoming Lightning Rounds right now. | |
| </p> | |
| <Button asChild variant="outline"> | |
| <Link href="/bounty">Browse All Bounties</Link> | |
| </Button> | |
| </div> | |
| ) : round ? ( | |
| {isLoading || listLoading ? ( | |
| <PageSkeleton /> | |
| ) : isError || !round ? ( | |
| <div className="flex flex-col items-center justify-center py-32 text-center"> | |
| <Zap className="size-12 text-muted-foreground mb-4" /> | |
| <h2 className="text-xl font-bold mb-2">No Lightning Round Found</h2> | |
| <p className="text-muted-foreground mb-6"> | |
| There are no active or upcoming Lightning Rounds right now. | |
| </p> | |
| <Button asChild variant="outline"> | |
| <Link href="/bounty">Browse All Bounties</Link> | |
| </Button> | |
| </div> | |
| ) : round ? ( |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/bounty/lightning-round/page.tsx` around lines 352 - 365, The empty-state
conditional is too narrow: update the render condition that currently reads
`isError || (!round && !targetId)` to `isError || !round` so that when a
`targetId` is present but the query returns no matching `round` the empty state
is shown; keep the existing loading guard (`isLoading || listLoading`) intact so
this change only affects the non-loading branch in the component rendering logic
where `round`, `isError`, and `targetId` are used.
Benjtalkshow
left a comment
There was a problem hiding this comment.
Hey @Sendi0011, solid first pass and the "no new backend work" framing is correct. Reusing BountyWindowType is the right call. A few things to flag before this can merge.
useCountdown is duplicated verbatim between components/bounty/lightning-round-banner.tsx and app/bounty/lightning-round/page.tsx. Please move it into hooks/use-lightning-rounds.ts (or a small use-countdown.ts) so both consumers import the same implementation, otherwise any fix to one copy silently misses the other.
useLightningRounds fetches up to 100 bounties just to derive the schedule sidebar widget, every time it mounts. If the app ever has more than 100 active-ish bounties, older rounds silently drop off the list, and even when it doesn't, that's a lot of data for a 3-item sidebar. Either a dedicated "list bounty windows" query or a small server-derived digest would be cheaper.
Within a phase, rounds come back in insertion order (whatever order the bounty list returned them). Upcoming rounds should probably be sorted ascending by startDate and ended rounds descending by endDate so the schedule widget reads naturally. Right now two upcoming rounds can swap order depending on which of their bounties was created first.
In useLightningRoundBounties, you pull round from groupBountiesByWindow(bounties)[0]. If the backend ever returns bounties whose bountyWindow is null even when bountyWindowId was filtered (stale cache, partial fragment, whatever), round ends up null and the page renders nothing under the back-nav even though groupedByType has data. Worth falling back to rendering the category sections without the header instead of a blank screen.
The Lightning Round styling on BountyCard only kicks in when the round phase is "active", so a bounty from an upcoming round looks identical to a regular one. If the goal is to advertise the round as soon as the bounty appears, include "upcoming" in the check. If the goal is only "claimable right now", leave it, but worth mentioning in the PR body so design can weigh in.
CI/CD fails because pnpm-lock.yaml is out of sync with package.json after the main merge. Run pnpm install and commit the updated lockfile.
Please also address all of CodeRabbit's corrections (Suspense boundary around useSearchParams, the target identity in useCountdown, the empty-state condition, the phase default, and the currency-from-first-bounty note are all valid).
Nice work overall. Ping me once these are in and I'll take another pass.
|
GM @Benjtalkshow , thanks for the detailed feedback — going through everything now. The CodeRabbit flags on the countdown interval and the currency aggregation are valid catches, will clean those up along with the rest. |
- extract useCountdown into hooks/use-countdown.ts (dedup, stable dep, auto-stop) - fix getRoundPhase: missing dates → upcoming not active - fix currency: per-currency totals, primaryCurrency by highest value - fix within-phase sort: upcoming asc startDate, ended desc endDate - useLightningRounds: parameterise limit (default 200), document tradeoff - useLightningRoundBounties: disable query when windowId is falsy - lightning-round/page: wrap useSearchParams in Suspense boundary - fix empty-state: isError || (!round && categories.length === 0) - RoundHeader optional: render category sections even when round is null
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
hooks/use-lightning-rounds.ts (1)
240-243: Minor:...paramsspread can silently override the explicitlimitargument.
{ limit, sortBy: "createdAt", sortOrder: "desc", ...params }lets a caller passingparams.limitoverride the second positionallimitargument, which is probably surprising given the dedicated parameter. If the intent is for the explicitlimitarg to win (typical), spreadparamsfirst. Otherwise document thatparams.limittakes precedence.♻️ Suggested change
- const { data, isLoading, isError, error, refetch } = useBountiesQuery({ - query: { limit, sortBy: "createdAt", sortOrder: "desc", ...params }, - }); + const { data, isLoading, isError, error, refetch } = useBountiesQuery({ + query: { sortBy: "createdAt", sortOrder: "desc", ...params, limit }, + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@hooks/use-lightning-rounds.ts` around lines 240 - 243, The current object passed to useBountiesQuery in useLightningRounds spreads ...params after { limit, sortBy, sortOrder } which allows params.limit to override the explicit limit argument; change the merge order so params is spread first (e.g., { ...params, limit, sortBy: "createdAt", sortOrder: "desc" }) so the function's limit parameter wins, updating the call site in useLightningRounds where useBountiesQuery is invoked; alternatively, if you intend params to win, add a comment/docstring on useLightningRounds stating that params.limit takes precedence.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@hooks/use-countdown.ts`:
- Around line 41-62: The subscribe function passed to useSyncExternalStore (in
useCountdown) must be memoized and the snapshot must be cached to avoid unstable
identities: create a snapshotRef via useRef keyed by targetMs and update it in a
stable getSnapshot implementation that returns snapshotRef.current (computing
and writing compute(targetMs) only when targetMs changed), and wrap the
subscribe callback (the function that sets/clears setInterval and calls
onStoreChange) in useCallback so its identity is stable across renders; refer to
useCountdown, getSnapshot, subscribe, compute, and snapshotRef when making these
changes.
---
Nitpick comments:
In `@hooks/use-lightning-rounds.ts`:
- Around line 240-243: The current object passed to useBountiesQuery in
useLightningRounds spreads ...params after { limit, sortBy, sortOrder } which
allows params.limit to override the explicit limit argument; change the merge
order so params is spread first (e.g., { ...params, limit, sortBy: "createdAt",
sortOrder: "desc" }) so the function's limit parameter wins, updating the call
site in useLightningRounds where useBountiesQuery is invoked; alternatively, if
you intend params to win, add a comment/docstring on useLightningRounds stating
that params.limit takes precedence.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ba0356a7-0503-401b-b354-f32e9de9041d
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (4)
app/bounty/lightning-round/page.tsxcomponents/bounty/lightning-round-banner.tsxhooks/use-countdown.tshooks/use-lightning-rounds.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- app/bounty/lightning-round/page.tsx
|
GM @Benjtalkshow, thanks for the thorough review. Here's what I've addressed:
All CodeRabbit comments (Suspense boundary, interval teardown, empty-state condition, phase default, currency aggregation, progress label consistency) are addressed too. |
|
And also, the CI failure is a pre-existing issue unrelated to this PR — @jsr/creit-tech__stellar-wallets-kit is returning a 404 from npm.jsr.io during pnpm install --frozen-lockfile. Looks like it's either been unpublished or the JSR registry needs auth configured in the CI environment. Happy to help track it down but it's not caused by any of my changes. |
pnpm constructs JSR tarball URLs as `/-/pkg-version.tgz` while JSR actually serves them at `/~/<bucket>/.../version.tgz`, causing `pnpm install --frozen-lockfile` to 404 in CI (and locally) on every PR. The same package is published on npm under `@creit.tech/...` (with a dot) by the same publisher, so swap the alias to point at npm and regenerate the lockfile. Resolves boundlessfi#187. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat: implement Lightning Rounds (Surge Events) — closes #175
Summary
Lightning Rounds are time-boxed high-value bounty events. This PR delivers the full feature: banner, dedicated page, card badges, schedule widget, and hook layer — with zero new backend work required.
Key insight:
BountyWindowTypealready existsThe backend already has everything we need:
BountyWindowTypetype withid,name,startDate,endDate,statusBountyalready carries abountyWindowrelation (already inBountyFieldsFragment)BountyQueryInput.bountyWindowIdfilter already worksLightning Rounds are Bounty Windows. We wire the frontend up to data that's already there.
Files changed
Created
How it works (no backend changes)
Phase (active / upcoming / ended) is computed from
startDateandendDate— no extra DB column needed.Acceptance criteria
Testing notes
bountyWindow, the banner simply does not render (null-safe throughout)setIntervalcleaned up on unmountBountyCarddetects Lightning Round membership viabounty.bountyWindow— already in the fragment, no extra query?id=param is suppliedScreenshots
Notes screenshot was taking using mocked data to render banner, not committed
Summary by CodeRabbit