Skip to content

Commit 9cac309

Browse files
Merge pull request #92 from Jamesllllllllll/feature/homepage-community-surfaces
Add homepage community pulse and stabilize the Twitch panel
2 parents 260b9a5 + 8c62d97 commit 9cac309

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+3173
-590
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
All notable changes to this project are documented here.
44

5+
## [0.8.0] - 2026-04-15
6+
7+
### Added
8+
- The homepage now highlights live streamers with a community pulse that shows daily request activity, artists in rotation, and songs currently being played, with quick links out to Twitch.
9+
10+
### Changed
11+
- The live homepage section is easier to scan, with a stronger featured streamer layout and more compact supporting activity widgets.
12+
- The Twitch panel feels lighter and more focused, with quieter search behavior and faster playlist updates while a stream is live.
13+
14+
### Fixed
15+
- Search and playlist views stay more responsive during broader song lookups and panel refreshes.
16+
517
## [0.7.0] - 2026-04-14
618

719
### Added

docs/community-homepage-plan.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Community Homepage Plan
2+
3+
## Goal
4+
5+
Make the home page feel like an active Rocksmith community instead of just a product landing page.
6+
7+
The tone should stay celebratory and discovery-focused:
8+
9+
- help viewers find active streamers quickly
10+
- help streamers feel momentum in their request flow
11+
- avoid turning the page into a hard leaderboard
12+
13+
## Shipped MVP
14+
15+
This pass adds a lightweight public community surface on the home page:
16+
17+
- show all live RockList.Live channels instead of cutting the page off after a small fixed set
18+
- show the current song or next queued request on every live channel card
19+
- show `played today` counts on each live channel card
20+
- add a `Community pulse` section with:
21+
- requests played today
22+
- requesters active today
23+
- songs in rotation today
24+
- channels active today
25+
- add `Most played today` and `Artists in rotation` lists
26+
27+
## Why This MVP Works
28+
29+
- It uses data the app already exposes publicly through live channels and played-song history.
30+
- It adds movement and energy to the homepage without asking streamers to configure anything new.
31+
- It gives viewers a reason to browse beyond a single featured channel.
32+
33+
## Good Next Expansions
34+
35+
### Time ranges
36+
37+
- add `Today / 7d / 30d` toggles to the public community module
38+
- keep `Today` as the default homepage view, and move heavier history to a dedicated community page later
39+
40+
### Live discovery modules
41+
42+
- `Now on stream`: a compact strip of the songs currently being played across live channels
43+
- `Freshly played`: the most recent completed songs from live channels
44+
- `Busy rooms`: channels with the most songs played today, framed as activity rather than ranking
45+
46+
### Viewer-energy modules
47+
48+
- `Requesters active today`: highlight how many distinct viewers participated
49+
- `First requests today`: celebrate newcomers joining the queue
50+
- `Returners`: light-touch recognition for viewers who keep showing up without creating a public leaderboard
51+
52+
### Channel momentum modules
53+
54+
- songs played this stream
55+
- current queue depth
56+
- how long the streamer has been live
57+
- whether the queue is moving quickly right now
58+
59+
### Community flavor modules
60+
61+
- top artists this week
62+
- most requested artists this week
63+
- genre or tuning waves when the catalog metadata makes that feel reliable
64+
- rotating highlights like `Acoustic hour`, `Bass-heavy night`, or `Throwback rotation` when enough signals exist
65+
66+
## Guardrails
67+
68+
- keep public stats limited to data that is already intentionally public
69+
- prefer “activity” and “discovery” framing over channel-vs-channel competition
70+
- avoid cluttering live channel cards with too many numbers
71+
- keep the home page fast even when many channels are live
72+
73+
## Suggested Follow-up Split
74+
75+
1. Home page community pulse and unlimited live channel discovery
76+
2. Dedicated `/community` or `/stats` page with time-range filters
77+
3. Private dashboard analytics for streamers
78+
4. Optional richer public channel modules on `/slug`
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
CREATE INDEX `catalog_songs_grouped_project_idx` ON `catalog_songs` (`grouped_project_id`);
2+
CREATE INDEX `catalog_songs_grouping_fallback_idx` ON `catalog_songs` (
3+
CASE
4+
WHEN trim(lower(coalesce(`artist_name`, ''))) LIKE 'the %'
5+
THEN substr(trim(lower(coalesce(`artist_name`, ''))), 5)
6+
ELSE trim(lower(coalesce(`artist_name`, '')))
7+
END,
8+
trim(lower(coalesce(`title`, '')))
9+
);

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "request-bot",
33
"private": true,
44
"type": "module",
5-
"version": "0.7.0",
5+
"version": "0.8.0",
66
"engines": {
77
"node": ">=22"
88
},

src/components/playlist-management-surface.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ export type PlaylistManagementSurfaceProps = {
367367
playlistData?: PlaylistManagementSurfaceData | null;
368368
refetchIntervalMs?: number | false;
369369
staleTimeMs?: number;
370+
invalidateOnMutationSuccess?: boolean;
370371
headerTitle?: string;
371372
headerDescription?: string;
372373
showAncillaryPanels?: boolean;
@@ -488,14 +489,16 @@ export function PlaylistManagementSurface(
488489

489490
const manualSearchQuery = useQuery<ManualSearchData>({
490491
queryKey: ["playlist-manual-search", debouncedManualQuery],
491-
queryFn: async () => {
492+
queryFn: async ({ signal }) => {
492493
const params = new URLSearchParams({
493494
query: debouncedManualQuery.trim(),
494495
page: "1",
495496
pageSize: "6",
496497
field: "any",
497498
});
498-
const response = await fetch(`/api/search?${params.toString()}`);
499+
const response = await fetch(`/api/search?${params.toString()}`, {
500+
signal,
501+
});
499502
const body = (await response.json().catch(() => null)) as
500503
| SearchResponse
501504
| { message?: string }
@@ -776,9 +779,11 @@ export function PlaylistManagementSurface(
776779
setDebouncedManualQuery("");
777780
setManualRequesterLogin("");
778781
}
779-
void queryClient.invalidateQueries({
780-
queryKey: playlistQueryKey,
781-
});
782+
if (props.invalidateOnMutationSuccess ?? true) {
783+
void queryClient.invalidateQueries({
784+
queryKey: playlistQueryKey,
785+
});
786+
}
782787
},
783788
onSettled: () => {
784789
setPendingRowAction(null);
@@ -822,9 +827,11 @@ export function PlaylistManagementSurface(
822827
},
823828
onSuccess: () => {
824829
setPlaylistActionError(null);
825-
void queryClient.invalidateQueries({
826-
queryKey: playlistQueryKey,
827-
});
830+
if (props.invalidateOnMutationSuccess ?? true) {
831+
void queryClient.invalidateQueries({
832+
queryKey: playlistQueryKey,
833+
});
834+
}
828835
},
829836
});
830837

src/components/song-search-panel.tsx

Lines changed: 60 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,13 @@ type SearchResponse = {
8787
};
8888

8989
type SearchFilterOptionsResponse = {
90+
catalogTotal: number;
9091
tunings: TuningOption[];
9192
years: number[];
9293
};
9394

9495
type SearchFilterOptionsWireResponse = {
96+
catalogTotal?: number;
9597
tunings: Array<TuningOption | string>;
9698
years: number[];
9799
};
@@ -125,6 +127,7 @@ function normalizeSearchFilterOptionsResponse(
125127
): SearchFilterOptionsResponse {
126128
if (!input) {
127129
return {
130+
catalogTotal: 0,
128131
tunings: [],
129132
years: [],
130133
};
@@ -156,6 +159,11 @@ function normalizeSearchFilterOptionsResponse(
156159
}
157160

158161
return {
162+
catalogTotal:
163+
typeof input.catalogTotal === "number" &&
164+
Number.isFinite(input.catalogTotal)
165+
? input.catalogTotal
166+
: 0,
159167
tunings: [...tuningOptionsById.values()].sort((left, right) =>
160168
compareTuningIds(left.id, right.id)
161169
),
@@ -314,6 +322,20 @@ export function SongSearchPanel(props: {
314322
[props.extraSearchParams]
315323
);
316324
const activePathFilters = debouncedAdvancedFilters.parts;
325+
const hasCustomPathFilters = useMemo(() => {
326+
const activePathKey = [...new Set(activePathFilters)].sort().join(",");
327+
const defaultPathKey = [...normalizedDefaultPathFilters].sort().join(",");
328+
329+
return (
330+
activePathKey !== defaultPathKey ||
331+
debouncedAdvancedFilters.partsMatchMode !== defaultPathFilterMatchMode
332+
);
333+
}, [
334+
activePathFilters,
335+
debouncedAdvancedFilters.partsMatchMode,
336+
defaultPathFilterMatchMode,
337+
normalizedDefaultPathFilters,
338+
]);
317339
const activeNonPathFilterCount = useMemo(
318340
() =>
319341
[
@@ -402,15 +424,10 @@ export function SongSearchPanel(props: {
402424
() =>
403425
Boolean(
404426
debouncedQuery.trim() ||
405-
Object.entries(debouncedAdvancedFilters).some(([key, value]) =>
406-
key === "partsMatchMode"
407-
? false
408-
: Array.isArray(value)
409-
? value.length > 0
410-
: value.trim()
411-
)
427+
activeNonPathFilterCount > 0 ||
428+
hasCustomPathFilters
412429
),
413-
[debouncedAdvancedFilters, debouncedQuery]
430+
[activeNonPathFilterCount, debouncedQuery, hasCustomPathFilters]
414431
);
415432
const hasAdvancedFilter = useMemo(
416433
() =>
@@ -430,9 +447,9 @@ export function SongSearchPanel(props: {
430447
const searchEnabled = props.searchEnabled ?? true;
431448

432449
const filterOptionsQuery = useQuery<SearchFilterOptionsResponse>({
433-
queryKey: ["search-filter-options-v2"],
434-
queryFn: async () => {
435-
const response = await fetch("/api/search/filters");
450+
queryKey: ["search-filter-options-v3"],
451+
queryFn: async ({ signal }) => {
452+
const response = await fetch("/api/search/filters", { signal });
436453
const body = (await response.json().catch(() => null)) as
437454
| SearchFilterOptionsWireResponse
438455
| { message?: string }
@@ -454,38 +471,29 @@ export function SongSearchPanel(props: {
454471
staleTime: 60 * 60 * 1000,
455472
gcTime: 60 * 60 * 1000,
456473
});
457-
458-
const shouldLoadCatalogTotal = Boolean(props.infoNote?.includes("{count}"));
459-
const catalogTotalQuery = useQuery<Pick<SearchResponse, "total">>({
460-
queryKey: ["song-search-total-count"],
461-
queryFn: async () => {
462-
const response = await fetch("/api/search?page=1&pageSize=1&field=any");
463-
const body = (await response.json().catch(() => null)) as
464-
| SearchResponse
465-
| { message?: string }
466-
| null;
467-
468-
if (!response.ok) {
469-
throw new Error(
470-
getUserFriendlySearchErrorMessage(
471-
body && "message" in body ? body.message : null,
472-
t("errors.searchFailed")
474+
const hasMeaningfulExtraSearchParams = useMemo(
475+
() =>
476+
Boolean(
477+
props.extraSearchParams &&
478+
Object.entries(props.extraSearchParams).some(
479+
([key, value]) =>
480+
value !== undefined &&
481+
value !== false &&
482+
key !== "channelSlug" &&
483+
key !== "showBlacklisted"
473484
)
474-
);
475-
}
476-
477-
return { total: (body as SearchResponse).total };
478-
},
479-
enabled: shouldLoadCatalogTotal,
480-
staleTime: 60 * 60 * 1000,
481-
gcTime: 60 * 60 * 1000,
482-
});
485+
),
486+
[props.extraSearchParams]
487+
);
488+
const hasSearchIntent = hasSearchInput || hasMeaningfulExtraSearchParams;
483489

484490
const searchQuery = useQuery<SearchResponse>({
485491
queryKey: ["song-search", searchParamsBaseKey, page],
486-
enabled: searchEnabled && !queryTooShort,
487-
queryFn: async (): Promise<SearchResponse> => {
488-
const response = await fetch(`/api/search?${searchParams.toString()}`);
492+
enabled: searchEnabled && !queryTooShort && hasSearchIntent,
493+
queryFn: async ({ signal }): Promise<SearchResponse> => {
494+
const response = await fetch(`/api/search?${searchParams.toString()}`, {
495+
signal,
496+
});
489497
const body = (await response.json().catch(() => null)) as
490498
| SearchResponse
491499
| { message?: string }
@@ -557,7 +565,7 @@ export function SongSearchPanel(props: {
557565
);
558566
const resolvedInfoNote = props.infoNote?.replace(
559567
"{count}",
560-
String(catalogTotalQuery.data?.total ?? 0)
568+
String(filterOptionsQuery.data?.catalogTotal ?? 0)
561569
);
562570
const summaryCount = props.useTotalForSummary
563571
? (data?.total ?? 0)
@@ -1185,9 +1193,19 @@ export function SongSearchPanel(props: {
11851193
</div>
11861194
) : null}
11871195

1188-
{!isLoading && !queryTooShort && visibleResults.length === 0 ? (
1196+
{!isLoading && !queryTooShort && !error && !hasSearchIntent ? (
1197+
<div className="px-5 py-8 text-sm text-(--muted)">
1198+
{t("states.startSearch")}
1199+
</div>
1200+
) : null}
1201+
1202+
{!isLoading &&
1203+
!queryTooShort &&
1204+
!error &&
1205+
hasSearchIntent &&
1206+
visibleResults.length === 0 ? (
11891207
<div className="px-5 py-8 text-sm text-(--muted)">
1190-
{hasSearchInput
1208+
{hasSearchInput || hasMeaningfulExtraSearchParams
11911209
? t("states.emptyFiltered")
11921210
: t("states.emptyCatalog")}
11931211
</div>

0 commit comments

Comments
 (0)