News Stream
A single feed for product updates and community announcements, so a debater doesn't have to separately check the Daily Best Card page, the Contributor Awards page, and the Tools page's fine print to find out what's new.
- Route:
/news - Nav: the Tools page's Community & Progress group; the Reason Editor's
Workspace menu (
t newsin Ctrl/Cmd-Shift-Space's command palette) - Package:
debate-community
What it shows
Every item, newest first, filterable by category:
- Product Updates — hand-maintained posts about shipped features and
tools (
lib/news-stream.ts'sPRODUCT_NEWS). There's no build step that generates these from commits; a feature worth surfacing gets an entry added by hand, the same wayfeature-catalog.ts'sAPP_FEATURESis maintained. EveryAPP_FEATURESentry whose route no hand-writtenPRODUCT_NEWSitem names yet — most of the catalog, since a hand post gets written per noteworthy change rather than per tool — additionally gets a generic "Tool spotlight: …" post synthesized bylib/news-stream.ts'sbuildAutoFeatureNews(), so the feed always mentions every tool somewhere even before someone writes a real announcement for it. These spotlights all share one timestamp (older than every hand-written post) and so always sort below real updates. - Daily Best Card — one item per day announced through
/cards/best-card, rendered withlib/daily-best-card.ts's existingbuildDailyBestCardHighlight. - Contributor Awards — one item per day announced through
/cards/awards, rendered withlib/contributor-awards.ts's existingbuildAwardsAnnouncementText. - Community — five categories, none needing a separate "announce" step
since each is derived straight from its own feature's already-persisted
history:
- A Quest Streaks milestone, the exact day a
contributor's streak first reaches it (
dailyMissionResults.ts'sbuildQuestStreakMilestoneEvents). - A Group Challenge the moment its goal is reached
(
challengeWinEvents.ts'sbuildCompletedGroupChallengeEvents, timed to thetargetCount-th matching contribution or win event). - Each UTC day's top Revision Incentives earner,
when at least one revision that day earned a nonzero reward
(
revisionHistory.ts'sbuildDailyTopReviserAnnouncements). - A Team Collaboration Mode prep note, the
moment it's logged on a topic sprint at
/cards/collaboration(sprintNotes.ts'slistSprintNotes, rendered viateam-collaboration-mode.ts'sbuildSprintNoteAnnouncementText) — the first Community source that needs no derivation at all, since aSprintNoteis already the atomic event; capped to the 20 most recent notes (see Known gaps). - A new Argument Library submission — a card or
analytic block — the moment it's saved and live (not held back by an
in-progress peer review) at
/cards/library(evidenceLibraryEntries.ts'slistEvidenceLibraryEntries, rendered viashared-evidence-library.ts'sbuildEvidenceEntryAnnouncementText) — like a prep note, needs no derivation either, since a saved entry is already the atomic event, and is likewise capped to the 20 most recent entries; only entries saved after this shipped carry thecreatedAttimestamp it's sourced from. - A new AI Coach Mode session, the moment one is
generated for a round at
/coaching— like a prep note or Argument Library entry, needs no derivation, since a generated session is already the atomic event. Unlike the other five sources, this one isn't produced by this package:debate-round'sstate/coachingSessions.tsexports its owncoachingSessionNews()(this package can't depend back ondebate-round, which already depends on this package — see "Data flow" below), composed into the feed viabuildNewsFeed'sextraItemsparameter at the app layer. Only sessions generated after this shipped carry thecreatedAttimestamp it's sourced from. - A contributor's fully completed Daily Quests board,
the day they complete it at
/cards/quests(dailyMissionResults.ts'sbuildDailyQuestCompletionEvents, rendered viagamified-quests.ts'sbuildDailyQuestCompletionAnnouncementText) — no separate announce step, since "Record today's mission" already persists the day's result. Unlike the Quest Streaks milestone source above (bounded to rare milestone crossings), a board can be completed every day, so this shares the sprint-note/Argument-Library sources'MAX_COMMUNITY_ITEMS_PER_SOURCEvolume cap instead of posting every completion unbounded.
- A Quest Streaks milestone, the exact day a
contributor's streak first reaches it (
Each item can be liked and is marked read on hover; unread items get a
highlighted left border and a "New" badge. Read/like state is a viewer-local
newsStreamViewerState localStorage key — it does not feed back into either
source announcement's own data, or into a card's community like count in the
Contributions Feed. For a signed-in user, that local state is additionally
synced to the account's user_settings row (newsRead/newsLiked columns)
via /api/settings, so it follows them to another device instead of
resetting to "everything unread" there — see "Account sync" below.
Data flow
lib/news-stream.ts — NewsItem type, NEWS_CATEGORY_LABELS, PRODUCT_NEWS (hand-maintained),
buildAutoFeatureNews() (reads debate-feature-catalog's APP_FEATURES)
state/dailyBestCardAnnouncements.ts — existing store, read via listAnnouncedDailyBestCards()
state/contributorAwardAnnouncements.ts — existing store, read via listAnnouncedContributorAwards()
state/dailyMissionResults.ts — existing store, read via buildQuestStreakMilestoneEvents()
and buildDailyQuestCompletionEvents()
state/challengeWinEvents.ts — existing store, read via buildCompletedGroupChallengeEvents()
state/revisionHistory.ts — existing store, read via buildDailyTopReviserAnnouncements()
state/sprintNotes.ts — existing store, read via listSprintNotes()
state/evidenceLibraryEntries.ts — existing store, read via listEvidenceLibraryEntries()
→ state/newsStream.ts — buildNewsFeed(extraItems?) merges PRODUCT_NEWS,
buildAutoFeatureNews()'s synthesized spotlights, and all
eight in-package stores (mapped to NewsItem via each
store's own highlight/announcement-text helper —
sprintNotes.ts's via team-collaboration-mode.ts's
buildSprintNoteAnnouncementText, evidenceLibraryEntries.ts's
via shared-evidence-library.ts's buildEvidenceEntryAnnouncementText,
dailyMissionResults.ts's completion events via
gamified-quests.ts's buildDailyQuestCompletionAnnouncementText)
plus any caller-supplied extraItems, sorted newest first
— isNewsItemRead/markNewsItemRead/isNewsItemLiked/
toggleNewsItemLiked (localStorage, "newsStreamViewerState")
→ panels/NewsStreamPanel.tsx — category filter tabs, per-item read/like UI,
cross-tab live update; threads an optional extraItems
prop straight into buildNewsFeed(), and an optional
syncRemote adapter (hydrate/pushRead/pushLiked) around
mergeRemoteViewerState()/listReadIds()/listLikedIds()
(a package boundary this diagram can't show in one straight line:)
debate-round's state/coachingSessions.ts — its own store, read via coachingSessionNews()
(debate-round already depends on debate-research-evidence,
so it can't be a source *inside* newsStream.ts above
without a cycle — it produces NewsItems itself instead)
→ apps/debate-ai.com/app/news/NewsPageContent.tsx — the one place that depends on both
packages; calls coachingSessionNews() and passes the
result as NewsStreamPanel's extraItems prop, plus
useNewsStreamSync() as its syncRemote prop
lib/news-stream-sync.ts — NewsSyncPayload type, normalizeNewsSyncPatch/
serializeNewsIdList/parseNewsIdList (same
package-boundary split as state/favoriteTools.ts:
validation here, localStorage read/write in
state/newsStream.ts)
→ apps/debate-ai.com/app/api/settings/route.ts — GET/PUT the user_settings row's
news_read/news_liked columns alongside every other
account-linked preference field
→ apps/debate-ai.com/lib/hooks/useNewsStreamSync.ts — fetchUserSettings()/
saveUserSettings() (debate-round's client, shared
with UserSettingsPanel/useFavoriteTools) wrapped as
a NewsStreamSyncAdapter
→ apps/debate-ai.com/app/news/NewsPageContent.tsx — wires the hook into
NewsStreamPanel's syncRemote propCross-tab live update
NewsStreamPanel subscribes to the browser's storage event (fires only in
other same-origin tabs/windows, never the one that made the write) via
state/live-update.ts's isNewsStreamLiveUpdateStorageEvent — mirroring the
mechanism already used by
Daily Best Card/Contributor Awards
and the other panels listed in
shared-flow-sync.md. It rebuilds the feed and
re-derives read/liked state whenever another tab announces a Daily Best Card
or Contributor Awards winner, or toggles read/like state on a news item
("dailyBestCardAnnouncements", "contributorAwardAnnouncements",
"newsStreamViewerState"), so a second tab no longer needs a manual reload
to see it.
state/newsStream.ts introduces no new persisted event data of its own —
it only re-shapes what dailyBestCardAnnouncements.ts,
contributorAwardAnnouncements.ts, dailyMissionResults.ts,
challengeWinEvents.ts, revisionHistory.ts, sprintNotes.ts, and
evidenceLibraryEntries.ts already persist into the feed's common
NewsItem type — nor does debate-round's coachingSessionNews(), which
re-shapes state/coachingSessions.ts's own persisted records the same way.
The Daily Best Card and Contributor Awards categories need an explicit
"announce" action in their own panel; the six Community categories added
afterward (quest streak milestones, group challenge completions, Revision
Incentives standings, Team Collaboration Mode prep notes, Argument Library
submissions, AI Coach Mode sessions) don't — each is derived fresh every
time straight from its source feature's own history (mission results,
challenge contributions/win events, revision records, logged sprint notes,
saved evidence-library entries, generated coaching sessions), so completing
a mission, winning a challenge, revising a card, logging a prep note,
submitting a card/block, or generating a coaching session (from those
features' own panels) is what makes it appear here, with no separate
"announce" step and no risk of re-reporting the same event on a later day.
The prep-note, Argument Library, and coaching-session sources need no
derivation at all over that history — every streak/challenge/revision
Community source computes an event from a longer record (a streak crossing
a milestone, a challenge's Nth contribution, a day's top reviser), but a
logged SprintNote, saved EvidenceLibraryEntry, or generated
CoachingSessionRecord already is the event, so sprintNoteNews(),
argumentLibraryNews(), and coachingSessionNews() just map
listSprintNotes()/listEvidenceLibraryEntries()/listCoachingSessions()
straight to NewsItems (the latter two filtered to entries/sessions that
carry a createdAt, stamped on first submission/generation by
EvidenceLibraryPanel.tsx's handleSubmit and
CoachingSessionsPanel.tsx's buildAndSaveCoachingSession call
respectively).
Account sync
Closes the "Read/like state is per-browser" Known gap this doc used to
record: local-first, matching every other account-linked preference in this
repo (UserSettingsPanel, useFavoriteTools, useThemeState) —
NewsStreamPanel's own localStorage viewer state stays the source of
truth for the current browser, whether signed in or not, and account sync
is a best-effort layer on top rather than a replacement for it.
- On mount,
NewsPageContent.tsxpassesuseNewsStreamSync()'shydratefunction assyncRemote.hydrate. The panel calls it once; a signed-in user's savednewsRead/newsLikedid lists (fromGET /api/settings) are merged into local state viamergeRemoteViewerState— a union, not a replacement: an id already read/liked in this browser stays that way even if the account row hasn't caught up yet, and vice versa. A signed-out user, or a failed fetch, getsnullback and the panel behaves exactly as it did before this prop existed. - On each new read/like, the panel calls
syncRemote.pushRead/pushLikedwith the full current local id list (listReadIds()/listLikedIds()), whichuseNewsStreamSyncPUTs to/api/settings— the same "resend the whole list" shapefavoriteToolsalready uses, not a diff. Fire-and-forget: a failed push is swallowed, matchinguseFavoriteTools's "local apply is never blocked by a sync failure" convention, so a flaky connection never blocks marking something read. - Validated server-side by
normalizeNewsSyncPatch(lib/news-stream-sync.ts): each field is a JSON array of up toMAX_NEWS_SYNC_ITEMS(500) unique, non-empty, printable-ASCII ids — generous but bounded, the same postureMAX_FAVORITE_TOOLSuses, against a buggy or malicious client growing the row without limit.
Known gaps
- The auto-generated "Tool spotlight" post is a generic, one-line
restatement of
feature-catalog.ts's own description — it can't tell a brand-new tool from one that's simply never been individually announced, so it doesn't distinguish "just shipped" from "always been here." Writing a realPRODUCT_NEWSentry for a tool remains the way to say something more specific than that. - No follow-ups remain open on the "auto-spotlight misses tools added only
to the app's copy of the catalog" gap:
buildAutoFeatureNews()(lib/news-stream.ts) now importsAPP_FEATURESfrom the single shareddebate-feature-catalogpackage — the same catalog the dock, the Tools page, and the Reason Editor all read — rather than a copy of its own, so a tool added to the catalog is automatically reachable from this feed too. Seefeatures-page.mdx's "One shared catalog" for the history of that unification. pushRead/pushLikedsend a singlerecordNewsRead/addNewsLiked/removeNewsLikedop per mark-read/like-toggle, resolved against the account's current stored list server-side (news-stream-sync.ts'sapplyNewsReadOp/applyNewsLikedOp) rather than a client-computed whole-list replace — the same lost-update fixfavoriteTools/recentToolsalready use (see User Settings's Known gaps). Before this, every push resent the browser's entire current read/liked list, so two tabs or devices marking different items read/liked close together could silently drop each other's change, and an unrelated like/unlike toggle on one device could accidentally re-add or drop an id it never touched as a side effect of resending that device's whole list. A plain whole-listnewsRead/newsLikedreplace is still accepted by the route for a caller that genuinely needs one, butuseNewsStreamSync.tsno longer sends one.- Because the read/like account sync above is a union merge rather than a
full two-way sync, unliking an item on one device still doesn't clear
that like already merged onto another device's own local viewer state —
mergeRemoteViewerState's hydrate-on-load only ever adds ids, never removes them, an accepted tradeoff for never destructively clobbering a browser's local state on hydration. The op-based fix above closes the write-side race (the account's own stored list now always reflects every like/unlike instead of whichever full-list push happened to land last); this read-side asymmetry — a device that already merged a like keeps showing it liked until its own next toggle of that same item — is a separate, still-open limitation. Marking something read has no equivalent "unread" action, so this asymmetry doesn't apply there. - The stored
newsRead/newsLikedcolumns grow linearly with how much of the feed a heavy user has read over time — an op only ever appends or removes one id, but never trims the list back down on its own — fine at today's feed size (bounded byPRODUCT_NEWSplus each community source's ownMAX_COMMUNITY_ITEMS_PER_SOURCEcap), same tradeofffavoriteToolsalready accepts at a smaller scale. - Sprint notes and Argument Library entries are capped to the 20 most
recent (by
createdAt) each —state/newsStream.ts'sMAX_COMMUNITY_ITEMS_PER_SOURCE— so a very active topic sprint or a busy submission period can no longer flood the whole feed. This is a feed-projection cap only (nothing is deleted fromsprintNotes.ts/evidenceLibraryEntries.ts, and both tools' own pages still list every record); a sprint or submission period busier than 20 items still pushes its own older items out of the feed before a viewer necessarily sees them, and the cap is per source rather than per topic/contributor, so one very active topic can crowd out a quieter one's notes entirely. - An
EvidenceLibraryEntrysaved before thecreatedAtfield existed has none, so it never appears here — only entries saved after this shipped are backfilled; there's no migration that stamps one onto pre-existing localStorage records.
