From 5d682c771ca037152142cb84d7db8dc2dca82908 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:00:31 +0300 Subject: [PATCH 01/12] feat(post): community sentiment mockup (L1 + L2) Adds a Community Sentiment section to the redesigned post page (PostFocusCard), behind the community_sentiment flag (dev-on, prod-off) and gated to the full page (not the preview modal). - Layer 1 surface: plain-language TL;DR + positive/mixed/skeptical breakdown bar, discussion count, and a Deep dive toggle. - Layer 2 (inline expand): modular blocks that render only when populated (pros/cons, the big debate, open questions, by-community with source badges + links to the real threads). Uses typed sample data so wiring a real aggregation query later is a one-line swap. --- .../post/focus/CommunitySentiment.tsx | 222 +++++++++++++++ .../focus/CommunitySentimentBreakdown.tsx | 267 ++++++++++++++++++ .../components/post/focus/PostFocusCard.tsx | 11 +- packages/shared/src/lib/featureManagement.ts | 4 + 4 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 packages/shared/src/components/post/focus/CommunitySentiment.tsx create mode 100644 packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx new file mode 100644 index 0000000000..f28ac4da8a --- /dev/null +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -0,0 +1,222 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyTag, + TypographyType, +} from '../../typography/Typography'; +import { DiscussIcon, ArrowIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { CommunitySentimentBreakdown } from './CommunitySentimentBreakdown'; + +export interface SentimentBreakdown { + /** Share of the community that is positive (0-100). */ + positive: number; + /** Share that is mixed/neutral (0-100). */ + mixed: number; + /** Share that is critical/skeptical (0-100). */ + critical: number; +} + +export type SourceLean = 'positive' | 'mixed' | 'skeptical' | 'heated'; + +export interface SourceSentiment { + /** External community name, e.g. "Hacker News". */ + source: string; + /** How that community leans overall. */ + lean: SourceLean; + /** One-line characterization of the conversation there. */ + note: string; + /** Link to the actual discussion, when it lives in one place (HN/Lobsters). */ + url?: string; +} + +export interface CommunitySentimentData { + /** How opinion splits across positive / mixed / skeptical — the surface metric. */ + breakdown: SentimentBreakdown; + /** One-to-two sentence plain-English summary of what the community thinks. */ + tldr: string; + /** Total discussions the take is aggregated from. */ + postCount: number; + /** External communities the take is drawn from. */ + sources: string[]; + /** Layer 2 — the case for (coalition). */ + pros: string[]; + /** Layer 2 — the pushback (opposition). */ + cons: string[]; + /** Layer 2 — how each external community leans. */ + bySource: SourceSentiment[]; + /** Layer 2 — the single biggest flashpoint, if the take is divisive. */ + hottestDebate: string; + /** Layer 2 — what the community is still asking. */ + openQuestions: string[]; +} + +/** + * Layer 1 + 2 mock data. Replaced by a real aggregation query once the design + * lands; kept as a typed default so wiring live data later is a one-line swap. + */ +export const SAMPLE_COMMUNITY_SENTIMENT: CommunitySentimentData = { + breakdown: { positive: 57, mixed: 27, critical: 16 }, + tldr: `Developers love the idea of collapsing their stack onto Postgres to cut ops overhead and complexity, but a vocal group warns that Redis, Kafka, and Elasticsearch exist for a reason once you hit real scale.`, + postCount: 410, + sources: ['X', 'Hacker News', 'Lobsters'], + pros: [ + 'One database to run, back up and monitor — a real ops win for small teams', + 'Extensions like pgvector, pg_search, PGMQ and TimescaleDB are genuinely capable now', + 'Transactions and joins across data that used to live in separate systems', + ], + cons: [ + 'Purpose-built tools still win at scale — Kafka throughput, ES relevance, Redis latency', + 'Overloading one primary turns it into a single point of failure and contention', + 'Extensions bring their own upgrade and operational pain', + ], + bySource: [ + { + source: 'Hacker News', + lean: 'heated', + note: `Classic "just use Postgres" vs "know your scale" flame war`, + url: 'https://news.ycombinator.com/item?id=39135501', + }, + { + source: 'X', + lean: 'positive', + note: 'Devs cheering the stack simplification and cost savings', + }, + { + source: 'Lobsters', + lean: 'skeptical', + note: `More measured — "it works until it doesn't; depends on scale"`, + url: 'https://lobste.rs/s/vtfkqh', + }, + ], + hottestDebate: `Is consolidating onto Postgres a smart simplification, or just deferring the scaling pain you'll pay for later?`, + openQuestions: [ + 'At what scale does the single-Postgres approach actually break down?', + 'Does pgvector / pg_search hold up against dedicated Elasticsearch on large corpora?', + 'Is the migration cost worth the ops savings for an existing stack?', + ], +}; + +const BREAKDOWN_SEGMENTS: Array<{ + key: keyof SentimentBreakdown; + label: string; + color: string; +}> = [ + { key: 'positive', label: 'positive', color: 'bg-accent-avocado-default' }, + { key: 'mixed', label: 'mixed', color: 'bg-accent-bun-default' }, + { key: 'critical', label: 'skeptical', color: 'bg-accent-ketchup-default' }, +]; + +interface CommunitySentimentProps { + data?: CommunitySentimentData; + onSeeBreakdown?: () => void; + className?: string; +} + +/** + * Community Sentiment — a light, at-a-glance read on what the developer community + * outside daily.dev thinks about a post. Layer 1 (surface) is a plain-English + * TL;DR + a breakdown bar; "Deep dive" expands the modular Layer 2 blocks in place. + */ +export const CommunitySentiment = ({ + data = SAMPLE_COMMUNITY_SENTIMENT, + onSeeBreakdown, + className, +}: CommunitySentimentProps): ReactElement => { + const { breakdown, tldr, postCount } = data; + const [isExpanded, setIsExpanded] = useState(false); + + const toggle = () => { + setIsExpanded((prev) => !prev); + onSeeBreakdown?.(); + }; + + return ( +
+
+ + + + Community take + + + + + {postCount} + + + discussions + + +
+ + + {tldr} + + +
+
+ {BREAKDOWN_SEGMENTS.map(({ key, color }) => ( + + ))} +
+
+ {BREAKDOWN_SEGMENTS.map(({ key, label, color }) => ( + + + + {breakdown[key]}% {label} + + + ))} + +
+
+ + {isExpanded && } +
+ ); +}; diff --git a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx new file mode 100644 index 0000000000..f6c78df7ed --- /dev/null +++ b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx @@ -0,0 +1,267 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../typography/Typography'; +import { HotIcon, OpenLinkIcon } from '../../icons'; +import { IconSize } from '../../Icon'; +import { anchorDefaultRel } from '../../../lib/strings'; +import type { + CommunitySentimentData, + SourceLean, + SourceSentiment, +} from './CommunitySentiment'; + +// Authentic-enough source marks: HN's real logo is an orange "Y" square, X the +// glyph, Lobsters a red square. `color` omitted => theme-adaptive mono badge. +const SOURCE_BADGE: Record = { + X: { label: '𝕏' }, + 'Hacker News': { label: 'Y', color: '#FF6600' }, + Lobsters: { label: 'L', color: '#A6291F' }, +}; + +const LEAN_CHIP: Record = { + positive: { + label: 'Positive', + className: 'bg-accent-avocado-flat text-accent-avocado-default', + }, + mixed: { + label: 'Mixed', + className: 'bg-accent-bun-flat text-accent-bun-default', + }, + skeptical: { + label: 'Skeptical', + className: 'bg-accent-water-flat text-accent-water-default', + }, + heated: { + label: 'Heated', + className: 'bg-accent-ketchup-flat text-accent-ketchup-default', + }, +}; + +const BlockTitle = ({ children }: { children: string }): ReactElement => ( + + {children} + +); + +const ArgumentList = ({ + title, + items, + tone, +}: { + title: string; + items: string[]; + tone: 'pro' | 'con'; +}): ReactElement => ( +
+ + {title} + +
    + {items.map((item) => ( +
  • + + + {item} + +
  • + ))} +
+
+); + +const Flashpoint = ({ text }: { text: string }): ReactElement => ( +
+ + + +
+ + The big debate + + + {text} + +
+
+); + +const SourceRow = ({ + source, + lean, + note, + url, +}: SourceSentiment): ReactElement => { + const chip = LEAN_CHIP[lean]; + const badge = SOURCE_BADGE[source]; + + const content = ( + <> + {badge && ( + + {badge.label} + + )} +
+
+ + {source} + + + {chip.label} + +
+ + {note} + +
+ {url && ( + + + + )} + + ); + + const rowClassName = '-mx-2 flex gap-2.5 rounded-10 px-2 py-1.5'; + + if (url) { + return ( + + {content} + + ); + } + + return
{content}
; +}; + +/** + * Community Sentiment — Layer 2. A modular set of blocks revealed by "Deep dive". + * Each block only renders when it has content, so the layer composes itself to + * the item's sentiment shape (a calm, consensus item shows fewer blocks than a + * divisive one). + */ +export const CommunitySentimentBreakdown = ({ + data, +}: { + data: CommunitySentimentData; +}): ReactElement => { + const { pros, cons, bySource, hottestDebate, openQuestions } = data; + + return ( +
+ {(pros.length > 0 || cons.length > 0) && ( +
+ {pros.length > 0 && ( + + )} + {cons.length > 0 && ( + + )} +
+ )} + + {hottestDebate && } + + {openQuestions.length > 0 && ( +
+ Open questions +
    + {openQuestions.map((question) => ( +
  • + + ? + + + {question} + +
  • + ))} +
+
+ )} + + {bySource.length > 0 && ( +
+ By community +
+ {bySource.map((item) => ( + + ))} +
+
+ )} +
+ ); +}; diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 9180f39988..2f2cc5316a 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -35,7 +35,10 @@ import { PostTagList } from '../tags/PostTagList'; import { TruncateText } from '../../utilities'; import { combinedClicks } from '../../../lib/click'; import { useFeature } from '../../GrowthBookProvider'; -import { feature } from '../../../lib/featureManagement'; +import { + feature, + featureCommunitySentiment, +} from '../../../lib/featureManagement'; import { SourceStrip } from '../reader/SourceStrip'; import Link from '../../utilities/Link'; import HoverCard from '../../cards/common/HoverCard'; @@ -50,6 +53,7 @@ import { PostMenuOptions } from '../PostMenuOptions'; import { FocusCardActionBar } from './FocusCardActionBar'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; +import { CommunitySentiment } from './CommunitySentiment'; const PostCodeSnippets = dynamic(() => import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( @@ -242,6 +246,9 @@ export const PostFocusCard = ({ const { isReaderEnabled } = useReaderModalEligibility(); const isReaderVariant = isReaderEnabled && post.type === PostType.Article; const showCodeSnippets = useFeature(feature.showCodeSnippets); + const communitySentimentEnabled = useFeature(featureCommunitySentiment); + // Only on the full post page, not the preview modal (which passes `onClose`). + const showCommunitySentiment = !onClose && communitySentimentEnabled; const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the @@ -526,6 +533,8 @@ export const PostFocusCard = ({ + {showCommunitySentiment && } + onShowUpvoted(post.id, upvotes)} diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 350fefb60b..75c6687000 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -38,6 +38,10 @@ export const featurePostPageHighlights = new Feature( false, ); export const featurePostRedesign = new Feature('post_redesign', false); +export const featureCommunitySentiment = new Feature( + 'community_sentiment', + isDevelopment, +); // @ts-expect-error stale feature without default export const plusTakeoverContent = new Feature<{ From 4f127d9f79ad36f6cf7f745d8465a632110966c8 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:17:27 +0300 Subject: [PATCH 02/12] feat(post): community sentiment layer 3 + refinements - Layer 3 'Top picks': verbatim quote cards with source badges, author, engagement metrics, and links to the originals; Load more reveals the rest in one step. - Reworked copy (breakdown bar, 'By community', 'The big debate' vs 'Open questions', 'Deep dive'). - Source rows: logos + links to the real threads, with the link slot reserved so unlinked rows stay aligned. --- .../post/focus/CommunitySentiment.tsx | 61 +++++++- .../focus/CommunitySentimentBreakdown.tsx | 135 +++++++++++++++--- 2 files changed, 174 insertions(+), 22 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx index f28ac4da8a..465eb09511 100644 --- a/packages/shared/src/components/post/focus/CommunitySentiment.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -33,6 +33,19 @@ export interface SourceSentiment { url?: string; } +export interface SentimentHighlight { + /** Verbatim, punchy quote worth reading. */ + quote: string; + /** Author handle or username. */ + author: string; + /** Where it was said. */ + source: string; + /** Link to the original post/comment. */ + url: string; + /** Engagement metrics for the platform, e.g. ["2.4k likes", "180 replies"]. */ + metrics: string[]; +} + export interface CommunitySentimentData { /** How opinion splits across positive / mixed / skeptical — the surface metric. */ breakdown: SentimentBreakdown; @@ -52,6 +65,8 @@ export interface CommunitySentimentData { hottestDebate: string; /** Layer 2 — what the community is still asking. */ openQuestions: string[]; + /** Layer 3 — a few verbatim receipts worth reading. */ + highlights: SentimentHighlight[]; } /** @@ -98,6 +113,50 @@ export const SAMPLE_COMMUNITY_SENTIMENT: CommunitySentimentData = { 'Does pgvector / pg_search hold up against dedicated Elasticsearch on large corpora?', 'Is the migration cost worth the ops savings for an existing stack?', ], + highlights: [ + { + quote: `Every service I've replaced with plain Postgres is one less thing paging me at 3am.`, + author: '@maya_builds', + source: 'X', + url: 'https://x.com/maya_builds/status/1750000000000000000', + metrics: ['2.4k likes', '180 replies', '410 reposts'], + }, + { + quote: `"Just use Postgres" is great advice, right up until your one primary is on fire and everything is down at once.`, + author: 'throwaway_42', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=39135777', + metrics: ['214 points', '96 comments'], + }, + { + quote: `pgvector is genuinely good now, but for serious search at scale, Elasticsearch still eats it for lunch.`, + author: 'pg_curious', + source: 'Lobsters', + url: 'https://lobste.rs/s/vtfkqh', + metrics: ['38 votes', '22 comments'], + }, + { + quote: `We moved our job queue from Redis to PGMQ and honestly haven't thought about it since.`, + author: '@kirsten_dev', + source: 'X', + url: 'https://x.com/kirsten_dev/status/1750000000000000001', + metrics: ['1.2k likes', '88 replies'], + }, + { + quote: `The "one database" crowd forgets that backups, failover and vacuum tuning get scarier as that single DB grows.`, + author: 'dba_ghost', + source: 'Hacker News', + url: 'https://news.ycombinator.com/item?id=39135888', + metrics: ['156 points', '74 comments'], + }, + { + quote: `TimescaleDB replacing our metrics stack was the single best infra decision we made last year.`, + author: 'metrics_maxi', + source: 'Lobsters', + url: 'https://lobste.rs/s/wq2m4p', + metrics: ['29 votes', '15 comments'], + }, + ], }; const BREAKDOWN_SEGMENTS: Array<{ @@ -205,7 +264,7 @@ export const CommunitySentiment = ({ type="button" onClick={toggle} aria-expanded={isExpanded} - className="ml-auto flex items-center gap-1 text-brand-default typo-footnote font-bold" + className="ml-auto flex items-center gap-1 font-bold text-brand-default typo-footnote" > {isExpanded ? 'Show less' : 'Deep dive'} = { Lobsters: { label: 'L', color: '#A6291F' }, }; +const SourceBadge = ({ + source, + className, +}: { + source: string; + className: string; +}): ReactElement | null => { + const badge = SOURCE_BADGE[source]; + if (!badge) { + return null; + } + return ( + + {badge.label} + + ); +}; + const LEAN_CHIP: Record = { positive: { label: 'Positive', @@ -127,21 +153,10 @@ const SourceRow = ({ url, }: SourceSentiment): ReactElement => { const chip = LEAN_CHIP[lean]; - const badge = SOURCE_BADGE[source]; const content = ( <> - {badge && ( - - {badge.label} - - )} +
@@ -167,11 +182,15 @@ const SourceRow = ({ {note}
- {url && ( - - - - )} + {/* Always reserve the link slot so rows without a link stay aligned. */} + + + ); @@ -197,18 +216,71 @@ const SourceRow = ({ return
{content}
; }; +const HighlightCard = ({ + quote, + author, + source, + url, + metrics, +}: SentimentHighlight): ReactElement => ( + + + “{quote}” + +
+ +
+ + {author} + + + · {metrics.join(' · ')} + +
+ + + +
+
+); + /** * Community Sentiment — Layer 2. A modular set of blocks revealed by "Deep dive". * Each block only renders when it has content, so the layer composes itself to * the item's sentiment shape (a calm, consensus item shows fewer blocks than a * divisive one). */ +const INITIAL_HIGHLIGHTS = 3; + export const CommunitySentimentBreakdown = ({ data, }: { data: CommunitySentimentData; }): ReactElement => { - const { pros, cons, bySource, hottestDebate, openQuestions } = data; + const { pros, cons, bySource, hottestDebate, openQuestions, highlights } = + data; + const [showAllHighlights, setShowAllHighlights] = useState(false); + + const visibleHighlights = showAllHighlights + ? highlights + : highlights.slice(0, INITIAL_HIGHLIGHTS); + const hasMoreHighlights = + !showAllHighlights && highlights.length > INITIAL_HIGHLIGHTS; return (
@@ -262,6 +334,27 @@ export const CommunitySentimentBreakdown = ({
)} + + {highlights.length > 0 && ( +
+ Top picks +
+ {visibleHighlights.map((item) => ( + + ))} +
+ {hasMoreHighlights && ( + + )} +
+ )} ); }; From dbed6fd787c8da0aae7270ef255faab9c9ea803e Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 8 Jul 2026 16:35:16 +0300 Subject: [PATCH 03/12] fix(post): wrap big-debate text and clip top-picks metrics on mobile --- .../src/components/post/focus/CommunitySentimentBreakdown.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx index 192c1fd870..fc1eda958a 100644 --- a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx @@ -128,7 +128,7 @@ const Flashpoint = ({ text }: { text: string }): ReactElement => ( -
+
· {metrics.join(' · ')} From 12786894dbe16d31dfbf6bcedb9f77489fa3c8cc Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:10:07 +0300 Subject: [PATCH 04/12] feat(post): make the whole community take widget toggle the deep dive --- .../post/focus/CommunitySentiment.tsx | 158 ++++++++++-------- 1 file changed, 88 insertions(+), 70 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx index 465eb09511..333f3126b0 100644 --- a/packages/shared/src/components/post/focus/CommunitySentiment.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -193,89 +193,107 @@ export const CommunitySentiment = ({ onSeeBreakdown?.(); }; + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle(); + } + }; + return (
-
- - - - Community take - - - - - {postCount} - - - discussions - - -
- - - {tldr} - - -
-
- {BREAKDOWN_SEGMENTS.map(({ key, color }) => ( - - ))} +
+ + + + Community take + + + + + {postCount} + + + discussions + +
-
- {BREAKDOWN_SEGMENTS.map(({ key, label, color }) => ( - - - - {breakdown[key]}% {label} - + + + {tldr} + + +
+
+ {BREAKDOWN_SEGMENTS.map(({ key, color }) => ( + + ))} +
+
+ {BREAKDOWN_SEGMENTS.map(({ key, label, color }) => ( + + + + {breakdown[key]}% {label} + + + ))} + + {isExpanded ? 'Show less' : 'Deep dive'} + - ))} - +
- {isExpanded && } + {isExpanded && ( +
+ +
+ )}
); }; From 3f236a714303da79eb6ce74efa758e680480ddca Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 14 Jul 2026 15:44:58 +0200 Subject: [PATCH 05/12] feat(post): wire real community sentiment data (types, fragment, mapping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add communitySentiment to Post's GraphQL contract (SHARED_POST_INFO_FRAGMENT + Post interface), mirroring the twitter-fields convention. The wire shape carries structured highlight metrics ({points, replies, likes}), per-discussion counts, and the take's own updatedAt; mapCommunitySentimentPost converts it to the display shape the mockup components already render. CommunitySentiment now renders nothing when data is null (a real post without a take), shows an "Updated Xh ago" stamp via the existing RelativeTime helper, and CommunitySentimentBreakdown surfaces per-source discussion counts ("points · comments") alongside the by-source narrative. daily-api doesn't expose the field yet (checked locally) — types are hand-declared against the contract in COMMUNITY_TAKES_PLAN.md pending the parallel daily-api work landing. --- .../post/focus/CommunitySentiment.tsx | 131 +++++++++++++++++- .../focus/CommunitySentimentBreakdown.tsx | 51 ++++++- packages/shared/src/graphql/fragments.ts | 38 +++++ packages/shared/src/graphql/posts.ts | 5 + 4 files changed, 218 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx index 333f3126b0..d5eef202f7 100644 --- a/packages/shared/src/components/post/focus/CommunitySentiment.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -9,6 +9,8 @@ import { } from '../../typography/Typography'; import { DiscussIcon, ArrowIcon } from '../../icons'; import { IconSize } from '../../Icon'; +import { RelativeTime } from '../../utilities/RelativeTime'; +import { largeNumberFormat } from '../../../lib/numberFormat'; import { CommunitySentimentBreakdown } from './CommunitySentimentBreakdown'; export interface SentimentBreakdown { @@ -46,6 +48,19 @@ export interface SentimentHighlight { metrics: string[]; } +/** A discussion thread the take is aggregated from — raw per-platform counts, + * separate from the `bySource` narrative lean. */ +export interface CommunitySentimentDiscussion { + /** Machine-friendly platform id, e.g. "hackernews" | "lobsters". */ + provider: string; + /** Link to the discussion thread. */ + url: string; + /** Upvotes/points on that platform. */ + points: number; + /** Comment count on that platform. */ + commentsCount: number; +} + export interface CommunitySentimentData { /** How opinion splits across positive / mixed / skeptical — the surface metric. */ breakdown: SentimentBreakdown; @@ -67,8 +82,84 @@ export interface CommunitySentimentData { openQuestions: string[]; /** Layer 3 — a few verbatim receipts worth reading. */ highlights: SentimentHighlight[]; + /** Raw per-platform discussion links/counts backing the take. */ + discussions?: CommunitySentimentDiscussion[]; + /** When the take was last (re)generated — distinct from the post's own + * `updatedAt`, which reflects the whole post row. */ + updatedAt?: string; +} + +/** Structured engagement counts for a highlight, exactly as the API returns + * them — apps formats these into the display strings `SentimentHighlight` + * expects (e.g. `{ points: 214 }` => "214 points"). */ +export interface SentimentHighlightMetrics { + /** Upvotes/points on the source platform (HN, Lobsters). */ + points?: number; + /** Reply/comment count. */ + replies?: number; + /** Likes/favorites — X-only; unused for HN/Lobsters in v1. */ + likes?: number; } +/** Raw highlight shape returned by the API: same as `SentimentHighlight` but + * with structured (not yet formatted) metrics. */ +export type CommunitySentimentHighlightPost = Omit< + SentimentHighlight, + 'metrics' +> & { + metrics: SentimentHighlightMetrics; +}; + +/** + * Wire shape of `Post.communitySentiment`: the same contract as + * `CommunitySentimentData`, but highlight metrics arrive structured rather + * than as formatted display strings. `mapCommunitySentimentPost` below + * converts one into the other — the only real work in wiring up real data. + */ +export type CommunitySentimentPost = Omit< + CommunitySentimentData, + 'highlights' +> & { + highlights: CommunitySentimentHighlightPost[]; +}; + +const HIGHLIGHT_METRIC_LABELS: Array<{ + key: keyof SentimentHighlightMetrics; + label: string; +}> = [ + { key: 'points', label: 'points' }, + { key: 'replies', label: 'comments' }, + { key: 'likes', label: 'likes' }, +]; + +/** `largeNumberFormat` formats "2.4K"; the design uses a lowercase "k". */ +const formatMetricCount = (value: number): string => + largeNumberFormat(value)?.toLowerCase() ?? `${value}`; + +const formatHighlightMetrics = (metrics: SentimentHighlightMetrics): string[] => + HIGHLIGHT_METRIC_LABELS.reduce((acc, { key, label }) => { + const value = metrics[key]; + if (typeof value === 'number') { + acc.push(`${formatMetricCount(value)} ${label}`); + } + return acc; + }, []); + +/** + * Maps the API's wire shape (structured highlight metrics) to the display + * shape the components in this file render. This is the "one-line swap" + * the mockup was designed for: ``. + */ +export const mapCommunitySentimentPost = ( + data: CommunitySentimentPost, +): CommunitySentimentData => ({ + ...data, + highlights: data.highlights.map((highlight) => ({ + ...highlight, + metrics: formatHighlightMetrics(highlight.metrics), + })), +}); + /** * Layer 1 + 2 mock data. Replaced by a real aggregation query once the design * lands; kept as a typed default so wiring live data later is a one-line swap. @@ -157,6 +248,22 @@ export const SAMPLE_COMMUNITY_SENTIMENT: CommunitySentimentData = { metrics: ['29 votes', '15 comments'], }, ], + discussions: [ + { + provider: 'hackernews', + url: 'https://news.ycombinator.com/item?id=39135501', + points: 329, + commentsCount: 172, + }, + { + provider: 'lobsters', + url: 'https://lobste.rs/s/vtfkqh', + points: 38, + commentsCount: 22, + }, + ], + // Kept relative to "now" so the local/storybook preview always reads as fresh. + updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), }; const BREAKDOWN_SEGMENTS: Array<{ @@ -170,7 +277,10 @@ const BREAKDOWN_SEGMENTS: Array<{ ]; interface CommunitySentimentProps { - data?: CommunitySentimentData; + /** `null`/`undefined` render nothing — a post without a take has no widget. + * Omitting the prop entirely (e.g. in storybook/local dev) falls back to the + * sample data below. */ + data?: CommunitySentimentData | null; onSeeBreakdown?: () => void; className?: string; } @@ -184,10 +294,17 @@ export const CommunitySentiment = ({ data = SAMPLE_COMMUNITY_SENTIMENT, onSeeBreakdown, className, -}: CommunitySentimentProps): ReactElement => { - const { breakdown, tldr, postCount } = data; +}: CommunitySentimentProps): ReactElement | null => { const [isExpanded, setIsExpanded] = useState(false); + // A caller may explicitly pass `null` (a real post without a take) — that + // bypasses the default param, so this guard is still needed. + if (!data) { + return null; + } + + const { breakdown, tldr, postCount, updatedAt } = data; + const toggle = () => { setIsExpanded((prev) => !prev); onSeeBreakdown?.(); @@ -230,6 +347,14 @@ export const CommunitySentiment = ({ > Community take + {updatedAt && ( + <> + · + + Updated + + + )} = { + 'Hacker News': 'hackernews', + Lobsters: 'lobsters', + X: 'x', +}; + +const formatDiscussionCount = (value: number): string => + largeNumberFormat(value)?.toLowerCase() ?? `${value}`; + // Authentic-enough source marks: HN's real logo is an orange "Y" square, X the // glyph, Lobsters a red square. `color` omitted => theme-adaptive mono badge. const SOURCE_BADGE: Record = { @@ -151,7 +164,10 @@ const SourceRow = ({ lean, note, url, -}: SourceSentiment): ReactElement => { + discussion, +}: SourceSentiment & { + discussion?: CommunitySentimentDiscussion; +}): ReactElement => { const chip = LEAN_CHIP[lean]; const content = ( @@ -174,6 +190,16 @@ const SourceRow = ({ > {chip.label} + {discussion && ( + + {formatDiscussionCount(discussion.points)} points ·{' '} + {formatDiscussionCount(discussion.commentsCount)} comments + + )}
{ - const { pros, cons, bySource, hottestDebate, openQuestions, highlights } = - data; + const { + pros, + cons, + bySource, + hottestDebate, + openQuestions, + highlights, + discussions, + } = data; const [showAllHighlights, setShowAllHighlights] = useState(false); const visibleHighlights = showAllHighlights @@ -282,6 +315,10 @@ export const CommunitySentimentBreakdown = ({ const hasMoreHighlights = !showAllHighlights && highlights.length > INITIAL_HIGHLIGHTS; + const discussionByProvider = new Map( + discussions?.map((discussion) => [discussion.provider, discussion]), + ); + return (
{(pros.length > 0 || cons.length > 0) && ( @@ -329,7 +366,13 @@ export const CommunitySentimentBreakdown = ({ By community
{bySource.map((item) => ( - + ))}
diff --git a/packages/shared/src/graphql/fragments.ts b/packages/shared/src/graphql/fragments.ts index 9fbff7cb17..d33ffb8559 100644 --- a/packages/shared/src/graphql/fragments.ts +++ b/packages/shared/src/graphql/fragments.ts @@ -460,6 +460,44 @@ export const SHARED_POST_INFO_FRAGMENT = gql` } numPollVotes endsAt + communitySentiment { + breakdown { + positive + mixed + critical + } + tldr + postCount + sources + pros + cons + bySource { + source + lean + note + url + } + hottestDebate + openQuestions + highlights { + quote + author + source + url + metrics { + points + replies + likes + } + } + discussions { + provider + url + points + commentsCount + } + updatedAt + } } ${PRIVILEGED_MEMBERS_FRAGMENT} ${SOURCE_BASE_FRAGMENT} diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index 95084b261a..8bcd747582 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -23,6 +23,7 @@ import { FEED_POST_CONNECTION_FRAGMENT } from './feed'; import { getPostByIdKey, RequestKey, StaleTime } from '../lib/query'; import type { LiveRoomPost } from './liveRooms'; import type { PostHero } from './types'; +import type { CommunitySentimentPost } from '../components/post/focus/CommunitySentiment'; export const ACCEPTED_TYPES = 'image/png,image/jpeg,image/webp,image/avif'; export const acceptedTypesList = ACCEPTED_TYPES.split(','); @@ -303,6 +304,10 @@ export interface Post { liveRoom?: LiveRoomPost | null; analytics?: Partial>; hero?: PostHero | null; + /** LLM-generated digest of what the developer community outside daily.dev + * (HN, Lobsters) thinks about this post. `null` when no take exists yet — + * gated by the `community_sentiment` experiment (see PostFocusCard). */ + communitySentiment?: CommunitySentimentPost | null; } export type RelatedPost = Pick< From cafe4f93d0c460c5cac73c73b679a3b877519398 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Tue, 14 Jul 2026 15:45:31 +0200 Subject: [PATCH 06/12] feat(post): gate community sentiment behind a GrowthBook experiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert community_sentiment from a plain (dev-on) feature flag into a proper experiment: the committed default is now always false (control/hidden), and PostFocusCard uses useConditionalFeature with shouldEvaluate tied to the post actually having a take (communitySentiment != null). This means GrowthBook is only queried — and exposure only logged — for posts with something to show, so take-less posts never dilute the treatment/control split. Backend keeps generating takes unconditionally; this flag gates rendering only. Local/dev preview still works via isDevelopment (never the committed default), matching the pattern documented in AGENTS.md. --- .../components/post/focus/PostFocusCard.tsx | 30 +++++++++++++++---- packages/shared/src/lib/featureManagement.ts | 9 +++++- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 2f2cc5316a..e643f5aa42 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -35,10 +35,12 @@ import { PostTagList } from '../tags/PostTagList'; import { TruncateText } from '../../utilities'; import { combinedClicks } from '../../../lib/click'; import { useFeature } from '../../GrowthBookProvider'; +import { useConditionalFeature } from '../../../hooks/useConditionalFeature'; import { feature, featureCommunitySentiment, } from '../../../lib/featureManagement'; +import { isDevelopment } from '../../../lib/constants'; import { SourceStrip } from '../reader/SourceStrip'; import Link from '../../utilities/Link'; import HoverCard from '../../cards/common/HoverCard'; @@ -53,7 +55,10 @@ import { PostMenuOptions } from '../PostMenuOptions'; import { FocusCardActionBar } from './FocusCardActionBar'; import { PostDiscussionPanel } from './PostDiscussionPanel'; import { CollectionSources } from './CollectionSources'; -import { CommunitySentiment } from './CommunitySentiment'; +import { + CommunitySentiment, + mapCommunitySentimentPost, +} from './CommunitySentiment'; const PostCodeSnippets = dynamic(() => import(/* webpackChunkName: "postCodeSnippets" */ '../PostCodeSnippets').then( @@ -246,9 +251,22 @@ export const PostFocusCard = ({ const { isReaderEnabled } = useReaderModalEligibility(); const isReaderVariant = isReaderEnabled && post.type === PostType.Article; const showCodeSnippets = useFeature(feature.showCodeSnippets); - const communitySentimentEnabled = useFeature(featureCommunitySentiment); - // Only on the full post page, not the preview modal (which passes `onClose`). - const showCommunitySentiment = !onClose && communitySentimentEnabled; + const communitySentimentData = article.communitySentiment + ? mapCommunitySentimentPost(article.communitySentiment) + : undefined; + // Conditional enrollment: only evaluate (and log exposure for) the + // community_sentiment experiment on posts that actually have a take, so + // take-less posts don't dilute the treatment/control split. Backend keeps + // generating the take for every eligible post regardless of this flag. + const { value: communitySentimentEnabled } = useConditionalFeature({ + feature: featureCommunitySentiment, + shouldEvaluate: !!communitySentimentData, + }); + // Only on the full post page, not the preview modal (which passes + // `onClose`). `isDevelopment` lets the design be previewed locally with the + // sample data without flipping the committed (always-`false`) flag default. + const showCommunitySentiment = + !onClose && (communitySentimentEnabled || isDevelopment); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the @@ -533,7 +551,9 @@ export const PostFocusCard = ({ - {showCommunitySentiment && } + {showCommunitySentiment && ( + + )} Date: Thu, 23 Jul 2026 10:41:28 +0200 Subject: [PATCH 07/12] fix: render real post.communitySentiment data instead of sample fallback Remove the SAMPLE_COMMUNITY_SENTIMENT default param so take-less posts render nothing, gate the surface on actual data presence, and guard the mapper against nullable highlight metrics from the API. Co-Authored-By: Claude Fable 5 --- .../post/focus/CommunitySentiment.tsx | 124 +----------------- .../components/post/focus/PostFocusCard.tsx | 9 +- 2 files changed, 13 insertions(+), 120 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx index d5eef202f7..96567c7f91 100644 --- a/packages/shared/src/components/post/focus/CommunitySentiment.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -77,7 +77,7 @@ export interface CommunitySentimentData { /** Layer 2 — how each external community leans. */ bySource: SourceSentiment[]; /** Layer 2 — the single biggest flashpoint, if the take is divisive. */ - hottestDebate: string; + hottestDebate?: string; /** Layer 2 — what the community is still asking. */ openQuestions: string[]; /** Layer 3 — a few verbatim receipts worth reading. */ @@ -107,7 +107,8 @@ export type CommunitySentimentHighlightPost = Omit< SentimentHighlight, 'metrics' > & { - metrics: SentimentHighlightMetrics; + /** Nullable on the wire — the API omits metrics when the source had none. */ + metrics?: SentimentHighlightMetrics | null; }; /** @@ -147,8 +148,7 @@ const formatHighlightMetrics = (metrics: SentimentHighlightMetrics): string[] => /** * Maps the API's wire shape (structured highlight metrics) to the display - * shape the components in this file render. This is the "one-line swap" - * the mockup was designed for: ``. + * shape the components in this file render. */ export const mapCommunitySentimentPost = ( data: CommunitySentimentPost, @@ -156,116 +156,10 @@ export const mapCommunitySentimentPost = ( ...data, highlights: data.highlights.map((highlight) => ({ ...highlight, - metrics: formatHighlightMetrics(highlight.metrics), + metrics: highlight.metrics ? formatHighlightMetrics(highlight.metrics) : [], })), }); -/** - * Layer 1 + 2 mock data. Replaced by a real aggregation query once the design - * lands; kept as a typed default so wiring live data later is a one-line swap. - */ -export const SAMPLE_COMMUNITY_SENTIMENT: CommunitySentimentData = { - breakdown: { positive: 57, mixed: 27, critical: 16 }, - tldr: `Developers love the idea of collapsing their stack onto Postgres to cut ops overhead and complexity, but a vocal group warns that Redis, Kafka, and Elasticsearch exist for a reason once you hit real scale.`, - postCount: 410, - sources: ['X', 'Hacker News', 'Lobsters'], - pros: [ - 'One database to run, back up and monitor — a real ops win for small teams', - 'Extensions like pgvector, pg_search, PGMQ and TimescaleDB are genuinely capable now', - 'Transactions and joins across data that used to live in separate systems', - ], - cons: [ - 'Purpose-built tools still win at scale — Kafka throughput, ES relevance, Redis latency', - 'Overloading one primary turns it into a single point of failure and contention', - 'Extensions bring their own upgrade and operational pain', - ], - bySource: [ - { - source: 'Hacker News', - lean: 'heated', - note: `Classic "just use Postgres" vs "know your scale" flame war`, - url: 'https://news.ycombinator.com/item?id=39135501', - }, - { - source: 'X', - lean: 'positive', - note: 'Devs cheering the stack simplification and cost savings', - }, - { - source: 'Lobsters', - lean: 'skeptical', - note: `More measured — "it works until it doesn't; depends on scale"`, - url: 'https://lobste.rs/s/vtfkqh', - }, - ], - hottestDebate: `Is consolidating onto Postgres a smart simplification, or just deferring the scaling pain you'll pay for later?`, - openQuestions: [ - 'At what scale does the single-Postgres approach actually break down?', - 'Does pgvector / pg_search hold up against dedicated Elasticsearch on large corpora?', - 'Is the migration cost worth the ops savings for an existing stack?', - ], - highlights: [ - { - quote: `Every service I've replaced with plain Postgres is one less thing paging me at 3am.`, - author: '@maya_builds', - source: 'X', - url: 'https://x.com/maya_builds/status/1750000000000000000', - metrics: ['2.4k likes', '180 replies', '410 reposts'], - }, - { - quote: `"Just use Postgres" is great advice, right up until your one primary is on fire and everything is down at once.`, - author: 'throwaway_42', - source: 'Hacker News', - url: 'https://news.ycombinator.com/item?id=39135777', - metrics: ['214 points', '96 comments'], - }, - { - quote: `pgvector is genuinely good now, but for serious search at scale, Elasticsearch still eats it for lunch.`, - author: 'pg_curious', - source: 'Lobsters', - url: 'https://lobste.rs/s/vtfkqh', - metrics: ['38 votes', '22 comments'], - }, - { - quote: `We moved our job queue from Redis to PGMQ and honestly haven't thought about it since.`, - author: '@kirsten_dev', - source: 'X', - url: 'https://x.com/kirsten_dev/status/1750000000000000001', - metrics: ['1.2k likes', '88 replies'], - }, - { - quote: `The "one database" crowd forgets that backups, failover and vacuum tuning get scarier as that single DB grows.`, - author: 'dba_ghost', - source: 'Hacker News', - url: 'https://news.ycombinator.com/item?id=39135888', - metrics: ['156 points', '74 comments'], - }, - { - quote: `TimescaleDB replacing our metrics stack was the single best infra decision we made last year.`, - author: 'metrics_maxi', - source: 'Lobsters', - url: 'https://lobste.rs/s/wq2m4p', - metrics: ['29 votes', '15 comments'], - }, - ], - discussions: [ - { - provider: 'hackernews', - url: 'https://news.ycombinator.com/item?id=39135501', - points: 329, - commentsCount: 172, - }, - { - provider: 'lobsters', - url: 'https://lobste.rs/s/vtfkqh', - points: 38, - commentsCount: 22, - }, - ], - // Kept relative to "now" so the local/storybook preview always reads as fresh. - updatedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), -}; - const BREAKDOWN_SEGMENTS: Array<{ key: keyof SentimentBreakdown; label: string; @@ -277,9 +171,7 @@ const BREAKDOWN_SEGMENTS: Array<{ ]; interface CommunitySentimentProps { - /** `null`/`undefined` render nothing — a post without a take has no widget. - * Omitting the prop entirely (e.g. in storybook/local dev) falls back to the - * sample data below. */ + /** `null`/`undefined` render nothing — a post without a take has no widget. */ data?: CommunitySentimentData | null; onSeeBreakdown?: () => void; className?: string; @@ -291,14 +183,12 @@ interface CommunitySentimentProps { * TL;DR + a breakdown bar; "Deep dive" expands the modular Layer 2 blocks in place. */ export const CommunitySentiment = ({ - data = SAMPLE_COMMUNITY_SENTIMENT, + data, onSeeBreakdown, className, }: CommunitySentimentProps): ReactElement | null => { const [isExpanded, setIsExpanded] = useState(false); - // A caller may explicitly pass `null` (a real post without a take) — that - // bypasses the default param, so this guard is still needed. if (!data) { return null; } diff --git a/packages/shared/src/components/post/focus/PostFocusCard.tsx b/packages/shared/src/components/post/focus/PostFocusCard.tsx index 81fd9eebb8..eb2a4bd4be 100644 --- a/packages/shared/src/components/post/focus/PostFocusCard.tsx +++ b/packages/shared/src/components/post/focus/PostFocusCard.tsx @@ -271,10 +271,13 @@ export const PostFocusCard = ({ shouldEvaluate: !!communitySentimentData, }); // Only on the full post page, not the preview modal (which passes - // `onClose`). `isDevelopment` lets the design be previewed locally with the - // sample data without flipping the committed (always-`false`) flag default. + // `onClose`), and only when the post actually has a take. `isDevelopment` + // lets the surface be previewed locally without flipping the committed + // (always-`false`) flag default. const showCommunitySentiment = - !onClose && (communitySentimentEnabled || isDevelopment); + !onClose && + !!communitySentimentData && + (communitySentimentEnabled || isDevelopment); const focusCommentRef = useRef<() => void>(() => {}); const discussionRef = useRef(null); // The video is a small floating preview on tablet/desktop and expands to the From 8af4e0d572371e198e3783f4c870b173f2a0507e Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 23 Jul 2026 11:06:48 +0200 Subject: [PATCH 08/12] fix(sentiment): normalize provider ids for labels, badges and links The take payload emits provider ids (hackernews, lobsters) while the UI was keyed on friendly names, leaving raw lowercase names, no badges and no discussion counts. Normalize both sides to the provider id, derive display labels from it, and fall back to the raw discussion url so by-community rows link to the actual thread. Co-Authored-By: Claude Fable 5 --- .../focus/CommunitySentimentBreakdown.tsx | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx index 28f4b0ebce..93a88961ef 100644 --- a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx @@ -8,7 +8,7 @@ import { } from '../../typography/Typography'; import { HotIcon, OpenLinkIcon, ArrowIcon } from '../../icons'; import { IconSize } from '../../Icon'; -import { anchorDefaultRel } from '../../../lib/strings'; +import { anchorDefaultRel, capitalize } from '../../../lib/strings'; import { largeNumberFormat } from '../../../lib/numberFormat'; import type { CommunitySentimentData, @@ -18,25 +18,37 @@ import type { SourceSentiment, } from './CommunitySentiment'; -// Maps the by-source narrative's friendly name to the discussion payload's -// machine-friendly provider id, so raw counts can be attached to the right row. -const SOURCE_TO_PROVIDER: Record = { - 'Hacker News': 'hackernews', - Lobsters: 'lobsters', - X: 'x', +// The take payload references communities loosely — the LLM may emit a +// provider id ("hackernews") or a friendly name ("Hacker News"). Normalize +// everything to the discussion payload's provider id so display labels, +// badges and raw counts all resolve from the same key. +const PROVIDER_ALIASES: Record = { + hn: 'hackernews', + twitter: 'x', }; -const formatDiscussionCount = (value: number): string => - largeNumberFormat(value)?.toLowerCase() ?? `${value}`; +const normalizeProvider = (source: string): string => { + const key = source.toLowerCase().replace(/[^a-z0-9]/g, ''); + return PROVIDER_ALIASES[key] ?? key; +}; // Authentic-enough source marks: HN's real logo is an orange "Y" square, X the // glyph, Lobsters a red square. `color` omitted => theme-adaptive mono badge. -const SOURCE_BADGE: Record = { - X: { label: '𝕏' }, - 'Hacker News': { label: 'Y', color: '#FF6600' }, - Lobsters: { label: 'L', color: '#A6291F' }, +const PROVIDER_META: Record< + string, + { label: string; badge: { glyph: string; color?: string } } +> = { + hackernews: { label: 'Hacker News', badge: { glyph: 'Y', color: '#FF6600' } }, + lobsters: { label: 'Lobsters', badge: { glyph: 'L', color: '#A6291F' } }, + x: { label: 'X', badge: { glyph: '𝕏' } }, }; +const providerLabel = (source: string): string => + PROVIDER_META[normalizeProvider(source)]?.label ?? capitalize(source); + +const formatDiscussionCount = (value: number): string => + largeNumberFormat(value)?.toLowerCase() ?? `${value}`; + const SourceBadge = ({ source, className, @@ -44,7 +56,7 @@ const SourceBadge = ({ source: string; className: string; }): ReactElement | null => { - const badge = SOURCE_BADGE[source]; + const badge = PROVIDER_META[normalizeProvider(source)]?.badge; if (!badge) { return null; } @@ -57,7 +69,7 @@ const SourceBadge = ({ )} style={badge.color ? { backgroundColor: badge.color } : undefined} > - {badge.label} + {badge.glyph} ); }; @@ -169,6 +181,10 @@ const SourceRow = ({ discussion?: CommunitySentimentDiscussion; }): ReactElement => { const chip = LEAN_CHIP[lean]; + const label = providerLabel(source); + // The narrative row may not carry its own url; the raw discussion entry for + // the same provider is the same thread, so use it as the link fallback. + const linkUrl = url ?? discussion?.url; const content = ( <> @@ -180,7 +196,7 @@ const SourceRow = ({ color={TypographyColor.Primary} bold > - {source} + {label}
@@ -222,13 +238,13 @@ const SourceRow = ({ const rowClassName = '-mx-2 flex gap-2.5 rounded-10 px-2 py-1.5'; - if (url) { + if (linkUrl) { return ( @@ -316,7 +332,10 @@ export const CommunitySentimentBreakdown = ({ !showAllHighlights && highlights.length > INITIAL_HIGHLIGHTS; const discussionByProvider = new Map( - discussions?.map((discussion) => [discussion.provider, discussion]), + discussions?.map((discussion) => [ + normalizeProvider(discussion.provider), + discussion, + ]), ); return ( @@ -370,7 +389,7 @@ export const CommunitySentimentBreakdown = ({ key={item.source} {...item} discussion={discussionByProvider.get( - SOURCE_TO_PROVIDER[item.source], + normalizeProvider(item.source), )} /> ))} From 6f1afbad989a5eafec0f449dc545822b0f201c80 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 23 Jul 2026 11:29:16 +0200 Subject: [PATCH 09/12] feat(sentiment): trim deep dive to pros/cons and per-community takes Drop the big debate, open questions and top picks blocks for now; the wire contract and mapper keep the fields so the blocks can return later. Co-Authored-By: Claude Fable 5 --- .../focus/CommunitySentimentBreakdown.tsx | 140 +----------------- 1 file changed, 3 insertions(+), 137 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx index 93a88961ef..005a338ef5 100644 --- a/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentimentBreakdown.tsx @@ -1,19 +1,18 @@ import type { ReactElement } from 'react'; -import React, { useState } from 'react'; +import React from 'react'; import classNames from 'classnames'; import { Typography, TypographyColor, TypographyType, } from '../../typography/Typography'; -import { HotIcon, OpenLinkIcon, ArrowIcon } from '../../icons'; +import { OpenLinkIcon } from '../../icons'; import { IconSize } from '../../Icon'; import { anchorDefaultRel, capitalize } from '../../../lib/strings'; import { largeNumberFormat } from '../../../lib/numberFormat'; import type { CommunitySentimentData, CommunitySentimentDiscussion, - SentimentHighlight, SourceLean, SourceSentiment, } from './CommunitySentiment'; @@ -148,29 +147,6 @@ const ArgumentList = ({
); -const Flashpoint = ({ text }: { text: string }): ReactElement => ( -
- - - -
- - The big debate - - - {text} - -
-
-); - const SourceRow = ({ source, lean, @@ -258,78 +234,18 @@ const SourceRow = ({ return
{content}
; }; -const HighlightCard = ({ - quote, - author, - source, - url, - metrics, -}: SentimentHighlight): ReactElement => ( -
- - “{quote}” - -
- -
- - {author} - - - · {metrics.join(' · ')} - -
- - - -
-
-); - /** * Community Sentiment — Layer 2. A modular set of blocks revealed by "Deep dive". * Each block only renders when it has content, so the layer composes itself to * the item's sentiment shape (a calm, consensus item shows fewer blocks than a * divisive one). */ -const INITIAL_HIGHLIGHTS = 3; - export const CommunitySentimentBreakdown = ({ data, }: { data: CommunitySentimentData; }): ReactElement => { - const { - pros, - cons, - bySource, - hottestDebate, - openQuestions, - highlights, - discussions, - } = data; - const [showAllHighlights, setShowAllHighlights] = useState(false); - - const visibleHighlights = showAllHighlights - ? highlights - : highlights.slice(0, INITIAL_HIGHLIGHTS); - const hasMoreHighlights = - !showAllHighlights && highlights.length > INITIAL_HIGHLIGHTS; + const { pros, cons, bySource, discussions } = data; const discussionByProvider = new Map( discussions?.map((discussion) => [ @@ -351,35 +267,6 @@ export const CommunitySentimentBreakdown = ({ )} - {hottestDebate && } - - {openQuestions.length > 0 && ( -
- Open questions -
    - {openQuestions.map((question) => ( -
  • - - ? - - - {question} - -
  • - ))} -
-
- )} - {bySource.length > 0 && (
By community @@ -396,27 +283,6 @@ export const CommunitySentimentBreakdown = ({
)} - - {highlights.length > 0 && ( -
- Top picks -
- {visibleHighlights.map((item) => ( - - ))} -
- {hasMoreHighlights && ( - - )} -
- )} ); }; From dcf2e38323fd7936a37d8bd71aa37bc9416cb9a1 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 23 Jul 2026 11:45:08 +0200 Subject: [PATCH 10/12] feat(sentiment): lead the header stat with total comments analyzed post_count is the number of source threads (one HN + one Lobsters thread = "2 discussions"), which undersells the take. Sum commentsCount from the raw discussions instead, falling back to the thread count when no discussion data exists. Co-Authored-By: Claude Fable 5 --- .../post/focus/CommunitySentiment.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx index 96567c7f91..c46d460993 100644 --- a/packages/shared/src/components/post/focus/CommunitySentiment.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -193,7 +193,20 @@ export const CommunitySentiment = ({ return null; } - const { breakdown, tldr, postCount, updatedAt } = data; + const { breakdown, tldr, postCount, discussions, updatedAt } = data; + + // `postCount` is the number of source threads (e.g. one HN + one Lobsters + // thread = 2), which undersells the analysis — the take is distilled from + // every comment in those threads, so lead with the comment total instead. + const totalComments = + discussions?.reduce( + (total, discussion) => total + discussion.commentsCount, + 0, + ) ?? 0; + const stat = + totalComments > 0 + ? { value: totalComments, label: 'comments' } + : { value: postCount, label: 'discussions' }; const toggle = () => { setIsExpanded((prev) => !prev); @@ -252,13 +265,13 @@ export const CommunitySentiment = ({ color={TypographyColor.Primary} bold > - {postCount} + {formatMetricCount(stat.value)} - discussions + {stat.label} From b2948b9ce080210e8108498201fc993506f72013 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Thu, 23 Jul 2026 11:56:37 +0200 Subject: [PATCH 11/12] fix(sentiment): keep header stat inside the card on mobile The header row had no shrinkable segment, so the comment stat pushed past the card edge on narrow screens. Let the title group shrink (min-w-0 flex-1) with the updated-time truncating first, and pin the stat with shrink-0. Co-Authored-By: Claude Fable 5 --- .../components/post/focus/CommunitySentiment.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx index c46d460993..bfae89f57c 100644 --- a/packages/shared/src/components/post/focus/CommunitySentiment.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -240,26 +240,32 @@ export const CommunitySentiment = ({ )} >
- - + {/* Shrinkable so the stat never overflows the card on mobile; the + "Updated Xh ago" segment truncates first. */} + + Community take {updatedAt && ( <> · - + Updated )} - + Date: Thu, 23 Jul 2026 12:19:56 +0200 Subject: [PATCH 12/12] fix(sentiment): stack the header stat on its own line on mobile Truncation alone still overflowed at phone widths; stack the header column-wise on mobile and go back to inline right-aligned from tablet up. Co-Authored-By: Claude Fable 5 --- .../src/components/post/focus/CommunitySentiment.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/components/post/focus/CommunitySentiment.tsx b/packages/shared/src/components/post/focus/CommunitySentiment.tsx index bfae89f57c..3b83838928 100644 --- a/packages/shared/src/components/post/focus/CommunitySentiment.tsx +++ b/packages/shared/src/components/post/focus/CommunitySentiment.tsx @@ -239,10 +239,10 @@ export const CommunitySentiment = ({ isExpanded && 'rounded-b-none', )} > -
- {/* Shrinkable so the stat never overflows the card on mobile; the - "Updated Xh ago" segment truncates first. */} - + {/* Stacked on mobile (the stat gets its own line), inline and + right-aligned from tablet up. */} +
+ )} - +