Files
gerbeur/api/services/community-service.ts
khannurien 51439f475d
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s
v4: the GeoCities marquee scrolls the community feed, what the site's members are doing — it crawled three canned lines, a welcome, "best viewed in Netscape Navigator" and "under construction", which said nothing of the site. The feed is a list any style may show (CommunityFeed.tsx), which GeoCities lays out along its marquee: who is online, the week's dumps, upvotes, comments and chat messages, linking to /community, its most upvoted dump, then the 12 latest things done in public, newest first, each ending on how long ago it was: a public dump posted, a public playlist created, a comment or an upvote on a public dump, someone joining and who invited them. Every name and title links to its page. A dump has one comment line and one upvote line at most, its latest, so that a thread or a popular dump doesn't fill the feed. GET /api/community/feed serves it, signed in only (community-service.ts): the events are read from the tables on each request, the week's figures are those /community already keeps, as of its last refresh. A signed-out visitor has no marquee. CommunityFeedProvider holds the feed around every page, so that it outlives the header each page renders anew, fetches it only while a component shows it, and fetches it again two seconds after the socket last told of a dump, a comment, a vote, a playlist, a rename, a deletion or a reconnection: the socket's messages don't carry a feed's lines, and the server's query stays the one place that decides what the feed holds. Someone joining comes with no message, and shows at the next of those. The crawl goes at 50 pixels a second however much the feed holds, Marquee.tsx measuring one copy for the stylesheet to work the loop's length out from, where a fixed 36 seconds would have sped up with every line. A loop shows what the feed held when it began: text that changed mid-crawl would jump, so what came meanwhile waits for the next loop, which also brings the times up to date. The next page's marquee carries on from where the last one's had got to, rather than from the start. The marquee was hidden from assistive technology as decoration; its first copy is now a labelled list of links, the second, which the seamless loop needs, hidden and out of the tab order. A link that takes the focus stops the crawl and the line scrolls to it, the pointer pauses it as before, and with reduced motion the line shows once, still, follows the feed as it changes and scrolls by hand. The sentences a notification also says, "posted", "upvoted", "commented on", share its translations. The tests' DOM gets an AnimationEvent before React loads (src/test/before-react.ts): finding none, React listened for the animation events under their WebKit names, and a test's "animationiteration" reached no handler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:38:44 +00:00

249 lines
8.2 KiB
TypeScript

/**
* Gathers what the whole site did over a period, for the `/community` page
* and the digests' shared sections. The digests ask {@link communityStatsOf}
* for each reader's own period. The page reads the figures of the week and
* the month ending at the last refresh, which {@link communityJob} redoes
* every {@link COMMUNITY_REFRESH_MINUTES}, so a visit runs no query.
*
* It also gathers the community feed ({@link getCommunityFeed}), the latest
* things done in public, which is read from the tables on each request: it
* has to show a dump the moment it is posted.
*
* @module
*/
import { db } from "../db/connection.ts";
import { COMMUNITY_REFRESH_MINUTES } from "../config.ts";
import type {
CommunityEvent,
CommunityFeed,
CommunityItem,
CommunityStats,
} from "../../shared/community.ts";
import {
DIGEST_CADENCES,
type DigestCadence,
PERIOD_MS,
} from "../../shared/settings.ts";
import type { Job } from "../lib/scheduler.ts";
import { parseRows } from "../model/rows.ts";
import { listTopDumps } from "./dump-service.ts";
import { listNewPublicPlaylists } from "./playlist-service.ts";
import { listNewUsers } from "./user-service.ts";
/** How many top dumps a period keeps, as many as the monthly recap shows. */
const TOP_MAX = 10;
/** Returns how many rows of `table` were created from `from` to `to`. */
function countOf(
table: string,
from: string,
to: string,
where = "1",
): number {
return (db.prepare(
`SELECT COUNT(*) AS n FROM ${table}
WHERE ${where} AND created_at >= ? AND created_at < ?;`,
).get(from, to) as { n: number }).n;
}
/**
* Returns what the site did from `from` to `to`: its most upvoted public
* dumps, who joined, the new public playlists, the chat's messages and the
* totals.
*/
export function communityStatsOf(from: Date, to: Date): CommunityStats {
const [f, t] = [from.toISOString(), to.toISOString()];
return {
from,
to,
top: listTopDumps(from, to, TOP_MAX),
members: listNewUsers(from, to),
playlists: listNewPublicPlaylists(from, to),
chatMessages: countOf("chat_messages", f, t),
totals: {
dumps: countOf("dumps", f, t, "is_private = 0"),
upvotes: countOf("votes", f, t),
comments: countOf("comments", f, t, "deleted = 0"),
},
};
}
/** The figures {@link getCommunityStats} serves, by period. */
const latest = new Map<DigestCadence, CommunityStats>();
/** Recomputes the figures of each period, ending at `now`. */
export function refreshCommunityStats(now: Date = new Date()): void {
for (const period of DIGEST_CADENCES) {
latest.set(
period,
communityStatsOf(new Date(now.getTime() - PERIOD_MS[period]), now),
);
}
}
/**
* Returns the figures of `period` as of the last refresh, for
* `GET /api/community`, refreshing them first if there are none yet.
*/
export function getCommunityStats(period: DigestCadence): CommunityStats {
if (!latest.has(period)) refreshCommunityStats();
return latest.get(period)!;
}
/**
* Refreshes the figures at startup, then every
* {@link COMMUNITY_REFRESH_MINUTES}. `main.ts` starts it.
*/
export const communityJob: Job = {
name: "community",
everyMs: COMMUNITY_REFRESH_MINUTES * 60_000,
firstAfterMs: 0,
run: () => refreshCommunityStats(),
};
/** How many events the feed holds, and how many of each kind it reads. */
const FEED_MAX = 12;
/**
* A row of {@link listCommunityEvents}'s query, whose five parts share these
* columns. `title`, `slug` and `owner_username` name the dump or the playlist
* of every event but a `user_joined`.
*/
interface EventRow {
type: CommunityEvent["type"];
/** The dump, the playlist or the new user. */
subject_id: string;
at: string;
username: string;
title: string | null;
slug: string | null;
owner_username: string | null;
comment_id: string | null;
invited_by: string | null;
}
/** Checks `row`'s columns that every event has. */
function isEventRow(row: unknown): row is EventRow {
const r = row as Partial<EventRow> | null;
return !!r && typeof r.type === "string" &&
typeof r.subject_id === "string" && typeof r.at === "string" &&
typeof r.username === "string" &&
(r.type === "user_joined" ||
(typeof r.title === "string" && typeof r.slug === "string" &&
typeof r.owner_username === "string"));
}
function eventOf(row: EventRow): CommunityEvent {
const base = {
id: `${row.type}:${row.subject_id}`,
at: new Date(row.at),
username: row.username,
};
// `isEventRow` has checked the three on every row that reads `item`.
const item: CommunityItem = {
title: row.title!,
slug: row.slug!,
ownerUsername: row.owner_username!,
};
switch (row.type) {
case "dump_posted":
case "dump_upvoted":
return { ...base, type: row.type, dump: item };
case "dump_commented":
return {
...base,
type: row.type,
dump: item,
commentId: row.comment_id!,
};
case "playlist_created":
return { ...base, type: row.type, playlist: item };
case "user_joined":
return {
...base,
type: row.type,
invitedByUsername: row.invited_by ?? undefined,
};
}
}
/**
* Returns the latest things done in public, newest first, {@link FEED_MAX} at
* most: the public dumps posted and playlists created, the comments and
* upvotes on public dumps, and who joined. A dump has one comment event and
* one upvote event at most, its latest, so that a thread or a popular dump
* doesn't fill the feed.
*
* Each part reads its {@link FEED_MAX} latest, which is all the whole may
* keep of it. The comments and the upvotes are grouped by dump first, and
* with `MAX()` SQLite fills a group's other columns from the row that holds
* the maximum: the latest comment's author and id, the latest voter.
*
* @throws {APIException} 500 when a row doesn't parse as an event.
*/
export function listCommunityEvents(): CommunityEvent[] {
const rows = db.prepare(
`SELECT * FROM (
SELECT 'dump_posted' AS type, d.id AS subject_id, d.created_at AS at,
u.username AS username, d.title AS title, d.slug AS slug,
u.username AS owner_username, NULL AS comment_id,
NULL AS invited_by
FROM dumps d JOIN users u ON u.id = d.user_id
WHERE d.is_private = 0
ORDER BY d.created_at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'playlist_created', p.id, p.created_at, u.username, p.title,
p.slug, u.username, NULL, NULL
FROM playlists p JOIN users u ON u.id = p.user_id
WHERE p.is_public = 1
ORDER BY p.created_at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'dump_commented', d.id, MAX(c.created_at) AS at, cu.username,
d.title, d.slug, ou.username, c.id, NULL
FROM comments c
JOIN dumps d ON d.id = c.dump_id
JOIN users cu ON cu.id = c.user_id
JOIN users ou ON ou.id = d.user_id
WHERE c.deleted = 0 AND d.is_private = 0
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'dump_upvoted', d.id, MAX(v.created_at) AS at, vu.username,
d.title, d.slug, ou.username, NULL, NULL
FROM votes v
JOIN dumps d ON d.id = v.dump_id
JOIN users vu ON vu.id = v.user_id
JOIN users ou ON ou.id = d.user_id
WHERE d.is_private = 0
GROUP BY d.id ORDER BY at DESC LIMIT ${FEED_MAX})
UNION ALL SELECT * FROM (
SELECT 'user_joined', u.id, u.created_at, u.username, NULL, NULL, NULL,
NULL, i.username
FROM users u LEFT JOIN users i ON i.id = u.invited_by
ORDER BY u.created_at DESC LIMIT ${FEED_MAX})
ORDER BY at DESC LIMIT ${FEED_MAX};`,
).all();
return parseRows(rows, isEventRow, eventOf);
}
/**
* Returns the community feed for `GET /api/community/feed`: the latest
* events, read now, and the last week's figures as of the last refresh.
*/
export function getCommunityFeed(): CommunityFeed {
const { totals, chatMessages, top } = getCommunityStats("weekly");
const [best] = top;
return {
events: listCommunityEvents(),
week: {
totals,
chatMessages,
top: best && {
title: best.title,
slug: best.slug,
ownerUsername: best.ownerUsername,
},
},
};
}