Skip to content

Commit c7afd93

Browse files
Add live requester activity badges
1 parent 5db22d9 commit c7afd93

24 files changed

+825
-12
lines changed

CHANGELOG.md

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

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

5+
## [0.7.0] - 2026-04-14
6+
7+
### Added
8+
- Playlist entries now show when a requester has gone AFK, making it easier for streamers and viewers to spot requests that may need a quick check-in.
9+
10+
### Changed
11+
- Requester activity on the playlist now updates live as chat messages come in, so AFK badges clear automatically when someone is active again.
12+
513
## [0.6.0] - 2026-04-14
614

715
### Added
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
CREATE TABLE `channel_chatter_activity` (
2+
`channel_id` text NOT NULL,
3+
`twitch_user_id` text NOT NULL,
4+
`login` text NOT NULL,
5+
`display_name` text NOT NULL,
6+
`last_chat_at` integer NOT NULL,
7+
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
8+
`updated_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
9+
PRIMARY KEY(`channel_id`, `twitch_user_id`),
10+
FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE no action
11+
);
12+
CREATE INDEX `channel_chatter_activity_channel_last_chat_idx` ON `channel_chatter_activity` (`channel_id`,`last_chat_at`);
13+
CREATE INDEX `channel_chatter_activity_channel_login_idx` ON `channel_chatter_activity` (`channel_id`,`login`);

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: 2 additions & 2 deletions
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.6.0",
5+
"version": "0.7.0",
66
"engines": {
77
"node": ">=22"
88
},
@@ -28,7 +28,7 @@
2828
"deploy": "node scripts/run-remote-operation.mjs deploy",
2929
"cf-typegen": "wrangler types",
3030
"db:generate": "drizzle-kit generate",
31-
"db:migrate": "wrangler d1 migrations apply request_bot --local",
31+
"db:migrate": "node scripts/run-db-migrate.mjs --local",
3232
"db:migrate:remote": "node scripts/run-remote-operation.mjs db:migrate:remote",
3333
"db:reset:local": "node scripts/reset-local-d1.mjs",
3434
"db:seed:sample:local": "node scripts/seed-sample-catalog.mjs local",

scripts/run-db-migrate.mjs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { spawn } from "node:child_process";
2+
import { resolve } from "node:path";
3+
4+
const args = process.argv.slice(2);
5+
const wranglerEntry = resolve(
6+
process.cwd(),
7+
"node_modules",
8+
"wrangler",
9+
"bin",
10+
"wrangler.js"
11+
);
12+
const child = spawn(
13+
process.execPath,
14+
[wranglerEntry, "d1", "migrations", "apply", "request_bot", ...args],
15+
{
16+
cwd: process.cwd(),
17+
env: process.env,
18+
stdio: ["inherit", "pipe", "pipe"],
19+
}
20+
);
21+
22+
// biome-ignore lint/complexity/useRegexLiterals: the constructor avoids the control-character lint on the ANSI pattern.
23+
const ansiPattern = new RegExp(String.raw`\u001B\[[0-9;?]*[ -/]*[@-~]`, "g");
24+
const migrationNames = [];
25+
const reportedStatuses = new Map();
26+
let stdoutBuffer = "";
27+
let stderrBuffer = "";
28+
let appliedCount = 0;
29+
let totalMigrations = 0;
30+
let printedSummary = false;
31+
32+
function stripAnsi(value) {
33+
return value.replace(ansiPattern, "");
34+
}
35+
36+
function printLine(line) {
37+
process.stdout.write(`${line}\n`);
38+
}
39+
40+
function maybePrintSummary() {
41+
if (printedSummary || totalMigrations < 1) {
42+
return;
43+
}
44+
45+
printedSummary = true;
46+
printLine(`Applying ${totalMigrations} local migrations...`);
47+
}
48+
49+
function handleStdoutLine(rawLine) {
50+
const line = stripAnsi(rawLine).trimEnd();
51+
const trimmed = line.trim();
52+
53+
if (!trimmed) {
54+
return;
55+
}
56+
57+
if (trimmed.includes("No migrations to apply")) {
58+
printLine("No migrations to apply.");
59+
return;
60+
}
61+
62+
const aboutToApplyMatch = trimmed.match(/About to apply (\d+) migration/);
63+
if (aboutToApplyMatch) {
64+
totalMigrations = Number(aboutToApplyMatch[1]);
65+
maybePrintSummary();
66+
return;
67+
}
68+
69+
const listMatch = trimmed.match(/^\s+([0-9]{4}_[^]+?\.sql)\s+$/u);
70+
if (listMatch) {
71+
const name = listMatch[1];
72+
if (name && !migrationNames.includes(name)) {
73+
migrationNames.push(name);
74+
totalMigrations = Math.max(totalMigrations, migrationNames.length);
75+
}
76+
return;
77+
}
78+
79+
const statusMatch = trimmed.match(
80+
/^\s+([0-9]{4}_[^]+?\.sql)\s+\s+(.+?)\s+$/u
81+
);
82+
if (statusMatch) {
83+
const [, name, status] = statusMatch;
84+
const normalizedStatus = status.trim();
85+
const previousStatus = reportedStatuses.get(name);
86+
87+
if (normalizedStatus === "✅" && previousStatus !== "✅") {
88+
reportedStatuses.set(name, "✅");
89+
appliedCount += 1;
90+
maybePrintSummary();
91+
const index =
92+
migrationNames.indexOf(name) >= 0
93+
? migrationNames.indexOf(name) + 1
94+
: appliedCount;
95+
const total = totalMigrations || migrationNames.length || index;
96+
printLine(`[${index}/${total}] ${name}`);
97+
return;
98+
}
99+
100+
if (normalizedStatus === "❌" && previousStatus !== "❌") {
101+
reportedStatuses.set(name, "❌");
102+
maybePrintSummary();
103+
const index =
104+
migrationNames.indexOf(name) >= 0
105+
? migrationNames.indexOf(name) + 1
106+
: appliedCount + 1;
107+
const total = totalMigrations || migrationNames.length || index;
108+
printLine(`[${index}/${total}] ${name} failed`);
109+
return;
110+
}
111+
112+
return;
113+
}
114+
115+
if (
116+
trimmed.startsWith("Migration ") ||
117+
trimmed.startsWith("bad port") ||
118+
trimmed.startsWith("Logs were written to ")
119+
) {
120+
printLine(trimmed);
121+
}
122+
}
123+
124+
function flushBuffer(buffer, handler) {
125+
const normalized = buffer.replace(/\r/g, "\n");
126+
const lines = normalized.split("\n");
127+
const remainder = lines.pop() ?? "";
128+
129+
for (const line of lines) {
130+
handler(line);
131+
}
132+
133+
return remainder;
134+
}
135+
136+
child.stdout.on("data", (chunk) => {
137+
stdoutBuffer += chunk.toString();
138+
stdoutBuffer = flushBuffer(stdoutBuffer, handleStdoutLine);
139+
});
140+
141+
child.stderr.on("data", (chunk) => {
142+
stderrBuffer += chunk.toString();
143+
stderrBuffer = flushBuffer(stderrBuffer, handleStdoutLine);
144+
});
145+
146+
child.on("close", (code) => {
147+
if (stdoutBuffer) {
148+
handleStdoutLine(stdoutBuffer);
149+
}
150+
if (stderrBuffer) {
151+
handleStdoutLine(stderrBuffer);
152+
}
153+
154+
process.exit(code ?? 1);
155+
});

src/components/playlist-management-surface.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import {
9393
getUpdatedPositionsAfterSetCurrent,
9494
getUpdatedQueuedPositionsAfterKindChange,
9595
} from "~/lib/playlist/order";
96+
import { isRequesterInactive } from "~/lib/playlist/requester-activity";
9697
import { areChannelRequestsOpen } from "~/lib/request-availability";
9798
import { formatPathLabel } from "~/lib/request-policy";
9899
import {
@@ -154,6 +155,7 @@ export type PlaylistItem = {
154155
regularPosition?: number | null;
155156
status: string;
156157
requestKind?: "regular" | "vip";
158+
requesterLastChatAt?: number | null;
157159
};
158160

159161
export type PlaylistCandidate = {
@@ -2997,6 +2999,10 @@ function PlaylistQueueItem(props: {
29972999
editedTimestamp != null && editedTimestamp > props.item.createdAt
29983000
? t("row.edited", { time: formatTimeAgo(t, editedTimestamp) })
29993001
: null;
3002+
const requesterLastChatAt = props.item.requesterLastChatAt ?? null;
3003+
const requesterInactive = isRequesterInactive(requesterLastChatAt);
3004+
const requesterActivityLabel =
3005+
requesterLastChatAt != null ? formatTimeAgo(t, requesterLastChatAt) : null;
30003006

30013007
useEffect(() => {
30023008
const element = itemRef.current;
@@ -3243,6 +3249,14 @@ function PlaylistQueueItem(props: {
32433249
{t("management.item.playingBadge")}
32443250
</Badge>
32453251
) : null}
3252+
{requesterInactive ? (
3253+
<StatusPill
3254+
icon={AlertTriangle}
3255+
className="border-amber-400/40 bg-amber-500/15 text-amber-200"
3256+
>
3257+
{t("management.item.inactiveBadge")}
3258+
</StatusPill>
3259+
) : null}
32463260
{props.item.warningMessage ? (
32473261
<StatusPill
32483262
icon={AlertTriangle}
@@ -3318,6 +3332,17 @@ function PlaylistQueueItem(props: {
33183332
) : null}
33193333
</p>
33203334
</div>
3335+
{getRequesterLabel(props.item) && requesterActivityLabel ? (
3336+
<p
3337+
className={cn(
3338+
"inline-flex items-center gap-1.5 text-sm",
3339+
requesterInactive ? "text-amber-100" : "text-(--muted)"
3340+
)}
3341+
>
3342+
<Clock3 className="h-3.5 w-3.5" />
3343+
<span>{requesterActivityLabel}</span>
3344+
</p>
3345+
) : null}
33213346
{props.item.requestedQuery &&
33223347
!getRequestedPathLabel(props.item) ? (
33233348
<p className="text-xs text-amber-200">
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// biome-ignore format: generated file must stay single-line for sync checks
2-
export const LATEST_MIGRATION_NAME = "0039_preferred_charters.sql";
2+
export const LATEST_MIGRATION_NAME = "0040_channel_chatter_activity.sql";

0 commit comments

Comments
 (0)