From 55dd00d685a61ad2379382590082ae072a799ab0 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:57:43 +0300 Subject: [PATCH 1/7] feat(storybook): policy-backed card reasons and a personalization center Explain mode now runs a copy of the For You challenger/5.0 policy over the real /history posts, so every reason, chip and score comes from the same math the feed uses, and adds a Personalization panel for full control. - Cards keep their exact layout: the explanation overlays the real grid or list card with the source and title at the card's own offsets, and the summary bar is gone. - Reason chips show what moved each post (topic match, follow, reader response, freshness, seen before, rotation, variety) and open a popover with the math and the policy knob behind it. - The Personalization panel shows the full inferred profile with every weight editable, sources and people, every ranking knob with its raw retriever_config key, what was left out and why, links to explicit settings, and resets, with a live preview of the next page. - A spec story maps each control to what daily-feed supports today. --- .../feed-explain/FeedExplain.stories.tsx | 455 +++++++++++---- .../components/AnchoredPopover.tsx | 99 ++++ .../feed-explain/components/ChangesDock.tsx | 72 +++ .../feed-explain/components/ExplainBar.tsx | 268 --------- .../components/ExplainDetails.tsx | 337 ++++------- .../feed-explain/components/ExplainPanel.tsx | 444 +++++++++------ .../feed-explain/components/FactorChips.tsx | 427 ++++++++++++++ .../components/FeedExplainPage.tsx | 536 +++++++++--------- .../components/FeedScreenContext.ts | 43 ++ .../components/FeedTuningContext.tsx | 188 ------ .../components/PersonalizationContext.tsx | 407 +++++++++++++ .../components/PersonalizationPanel.tsx | 293 ++++++++++ .../feed-explain/components/controls.tsx | 166 ++++++ .../feed-explain/components/feedExplain.ts | 201 ------- .../components/panel/InterestsSection.tsx | 518 +++++++++++++++++ .../feed-explain/components/panel/Preview.tsx | 323 +++++++++++ .../components/panel/RankingSection.tsx | 209 +++++++ .../feed-explain/components/panel/Section.tsx | 81 +++ .../components/panel/SettingsSection.tsx | 240 ++++++++ .../components/panel/SourcesSection.tsx | 203 +++++++ .../feed-explain/feedExplain.mocks.ts | 63 -- .../stories/features/feed-explain/personas.ts | 107 ++++ .../feed-explain/policy/challengerConfig.ts | 82 +++ .../features/feed-explain/policy/describe.ts | 279 +++++++++ .../features/feed-explain/policy/params.ts | 419 ++++++++++++++ .../features/feed-explain/policy/profile.ts | 200 +++++++ .../features/feed-explain/policy/rank.ts | 512 +++++++++++++++++ 27 files changed, 5690 insertions(+), 1482 deletions(-) create mode 100644 packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx delete mode 100644 packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/FactorChips.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts delete mode 100644 packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/controls.tsx delete mode 100644 packages/storybook/stories/features/feed-explain/components/feedExplain.ts create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/Section.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx create mode 100644 packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx delete mode 100644 packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts create mode 100644 packages/storybook/stories/features/feed-explain/personas.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/describe.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/params.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/profile.ts create mode 100644 packages/storybook/stories/features/feed-explain/policy/rank.ts diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx index 9c2b7b6992..1a71b2916d 100644 --- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx +++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx @@ -1,24 +1,31 @@ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import type { ReactElement, ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { waitFor } from 'storybook/test'; import { graphql, HttpResponse } from 'msw'; import ExtensionProviders from '../../extension/_providers'; import { providerHandlers } from '../why-recommended/whyRecommended.mocks'; -import type { TuningSeed } from './components/FeedTuningContext'; -import { FeedTuningProvider } from './components/FeedTuningContext'; -import { ExplainDetailsHost } from './components/ExplainDetails'; +import type { Decision, PersonalizationState } from './policy/rank'; +import { getExclusion } from './policy/rank'; +import { TopicMode } from './policy/profile'; +import { policyVersion } from './policy/params'; import { + PersonalizationProvider, + usePersonalization, +} from './components/PersonalizationContext'; +import { + ControlSection, + FeedScreenContext, + getSignalCounts, +} from './components/FeedScreenContext'; +import { ExplainDetailsModal } from './components/ExplainDetails'; +import { + AppView, CardFrame, FeedExplainPage, FeedLayout, } from './components/FeedExplainPage'; -import { TuneLevel } from './components/feedExplain'; -import { - feedItem, - forYouFeed, - newViewer, - reserveFeed, -} from './feedExplain.mocks'; +import { candidatePool, PersonaId, personas } from './personas'; // Follow and block in the full explanation update the feed settings cache // optimistically and roll back on error, so the mutations have to succeed. @@ -49,23 +56,24 @@ export default meta; type Story = StoryObj; const Screen = ({ + persona = PersonaId.TwoWeeksIn, layout = FeedLayout.Grid, - initialExplain, - initialDetailsId, + ...props }: { + persona?: PersonaId; layout?: FeedLayout; initialExplain?: boolean; + initialView?: AppView; + initialSection?: ControlSection; initialDetailsId?: string; }): ReactElement => (
@@ -81,124 +89,204 @@ export const ExplainOn: Story = { render: () => , }; +export const ReasonControls: Story = { + name: '3 · Reason controls', + render: () => , + play: async ({ canvasElement }) => { + await waitFor(() => { + const chip = canvasElement.querySelector( + 'ul[aria-label="What moved this post"] button', + ); + if (!chip) { + throw new Error('Waiting for reason chips'); + } + chip.click(); + }); + }, +}; + export const FullExplanation: Story = { - name: '3 · Full explanation', - render: () => , + name: '4 · Full explanation', + render: () => , +}; + +export const Controls: Story = { + name: '5 · Personalization controls', + render: () => , +}; + +export const JustSignedUp: Story = { + name: '6 · Just signed up', + render: () => , }; export const Phone: Story = { - name: '4 · Phone', + name: '7 · Phone', globals: { viewport: { value: 'mobile2', isRotated: false } }, render: () => , }; -const feedCounts = forYouFeed.reduce>( - (counts, { signals }) => { - signals.forEach(({ key }) => { - counts[key] = (counts[key] ?? 0) + 1; - }); - return counts; - }, - {}, -); +export const PhoneControls: Story = { + name: '8 · Phone controls', + globals: { viewport: { value: 'mobile2', isRotated: false } }, + render: () => ( + + ), +}; interface CardState { title: string; note: string; id: string; - seed?: TuningSeed; + persona?: PersonaId; + change?: (state: PersonalizationState) => PersonalizationState; layout?: FeedLayout; } +const withControl = (tag: string, mode: TopicMode) => ( + state: PersonalizationState, +): PersonalizationState => ({ + ...state, + controls: { ...state.controls, [tag]: { mode } }, +}); + const cardStates: CardState[] = [ { - title: 'Topic you picked', - note: 'The reason names the onboarding pick that matched.', - id: 'XCmmeq8ZC', + title: 'Topic you follow', + note: 'A followed topic you read often: the strongest kind of match.', + id: 'EYFcaEcVd', }, { title: 'Source you follow', - note: 'Follows outrank topics, so the source row leads.', - id: 'Hd2eBXFkz', + note: 'A follow brings every post in and multiplies it.', + id: 'Rvo54Shm1', }, { - title: 'From your reading', - note: 'Nothing followed matched; a topic you keep opening did.', - id: 'VNv8IMYss', + title: 'Learned from reading', + note: 'Never followed; six stories in two weeks made it strong.', + id: 'MsFCKRbGk', }, { - title: 'Popular pick', - note: 'No match at all. New feeds lean on these until they learn more.', - id: 'BM2onQaux', + title: "A topic's turn", + note: 'Docker had not been on the first screen lately, so it jumped ahead.', + id: 'JKsEou23l', }, { - title: 'Showing more', - note: 'Plus pressed on #rust. The row turns green.', - id: '9tLNItvh5', - seed: { levels: { 'topic:rust': TuneLevel.More } }, + title: 'Variety pushed it down', + note: 'The third Fireship post on the page, so it gives way.', + id: '1XMQ9zlpA', }, { - title: 'Showing less', - note: 'Minus pressed on #ai. The post stays, its topic ranks lower.', - id: 'AzxnBHN3V', - seed: { levels: { 'topic:ai': TuneLevel.Less } }, + title: 'Popular fill-in', + note: 'A new viewer with two topics; the popular lane tops up the page.', + id: 'JKsEou23l', + persona: PersonaId.JustSignedUp, + }, + { + title: 'Showing more', + note: 'More pressed on #rust. Its row turns green and it ranks higher next refresh.', + id: 'BjYAz50EA', + change: withControl('rust', TopicMode.Boosted), }, { - title: 'Blocked topic', - note: 'Blocking anything on the post marks it to leave on refresh.', - id: 'BM2onQaux', - seed: { levels: { 'topic:gaming': TuneLevel.Blocked } }, + title: 'Muted a learned topic', + note: 'Reading about #career stops counting, so this card leaves.', + id: 'MsFCKRbGk', + change: withControl('career', TopicMode.Muted), }, { - title: 'Blocked source', - note: 'Blocked from another card: every XDA post shows this at once.', - id: 'j4duNN9S2', - seed: { levels: { 'source:xda-developers': TuneLevel.Blocked } }, + title: 'Blocked a source', + note: 'Every XDA Developers card shows this at once.', + id: 'DFJTpz2Tw', + change: (state) => ({ + ...state, + blockedSources: [...state.blockedSources, 'xda-developers'], + }), }, { title: 'Fewer like this', - note: 'The post-level escape hatch when no single topic is the problem.', - id: 'DVZBShH7e', - seed: { dismissed: ['DVZBShH7e'] }, + note: 'The post-level escape hatch when no single reason is the problem.', + id: 'AzxnBHN3V', + change: (state) => ({ ...state, hidden: [...state.hidden, 'AzxnBHN3V'] }), }, ]; const listStates: CardState[] = [ { title: 'List card', - note: 'On phones the card folds the same way, so a few posts fit on one screen.', + note: 'On phones the explanation covers the real list card, so nothing in the feed moves.', id: 'DFJTpz2Tw', layout: FeedLayout.List, }, { title: 'List card, leaving', - note: 'The title fades; the notice keeps its Undo.', - id: 'CrehqCUVO', + note: 'The title fades; the notice offers the matching fix.', + id: 'DFJTpz2Tw', layout: FeedLayout.List, - seed: { levels: { 'source:xda-developers': TuneLevel.Blocked } }, + change: (state) => ({ + ...state, + blockedSources: [...state.blockedSources, 'xda-developers'], + }), }, ]; -const StateCard = ({ state }: { state: CardState }): ReactElement => ( -
-
- - {state.title} - - {state.note} -
- - - - - -
-); +const StateCardBody = ({ + id, + layout, +}: { + id: string; + layout: FeedLayout; +}): ReactElement => { + const store = usePersonalization(); + const [details, setDetails] = useState(null); + const counts = useMemo(() => getSignalCounts(store.served), [store.served]); + const screen = useMemo(() => ({ counts, onOpenDetails: setDetails }), [counts]); + const decision = store.served.find(({ post }) => post.id === id); + if (!decision) { + return

Not on the page.

; + } + + return ( + + + {details && ( + setDetails(null)} /> + )} + + ); +}; + +const StateCard = ({ state }: { state: CardState }): ReactElement => { + const base = personas[state.persona ?? PersonaId.TwoWeeksIn].state; + + return ( +
+
+ + {state.title} + + {state.note} +
+ + + +
+ ); +}; const Section = ({ title, @@ -214,7 +302,7 @@ const Section = ({ ); export const CardStates: Story = { - name: '5 · Card states', + name: '9 · Card states', render: () => (
@@ -223,15 +311,12 @@ export const CardStates: Story = { Explain mode, card by card

- The Explain switch on For You keeps every card where it is and - folds it to its source and title, then says why the post is here: - one sentence for the reason, then the post's top topics and - source, each with less, more and block. Full explanation opens the - "Why am I seeing this?" view with every topic and the - score breakdown. Changes save as you tap, every card sharing a - topic or source updates at once, and Refresh feed swaps out what - you blocked. Less and more are new: today the API only has follow - and block. + Each card keeps its source and title and says why it is here: one + sentence for the reason, chips for what moved it up or down (tap + one to see the math and change that part of the policy), and the + post's topics and source with less, more and block. Full + explanation opens the "Why am I seeing this?" view with + the score breakdown.

@@ -252,3 +337,183 @@ export const CardStates: Story = { ), }; + +enum Status { + Exists = 'exists', + Config = 'config', + Missing = 'missing', +} + +const statusLabel: Record = { + [Status.Exists]: { + label: 'Exists', + className: 'bg-accent-avocado-flat text-accent-avocado-default', + }, + [Status.Config]: { + label: 'In the policy, not per user', + className: 'bg-accent-cheese-flat text-accent-cheese-default', + }, + [Status.Missing]: { + label: 'New', + className: 'bg-accent-bun-flat text-accent-bun-default', + }, +}; + +const specRows: Array<{ + control: string; + status: Status; + today: string; + needs: string; +}> = [ + { + control: 'Follow and unfollow topics, sources, squads, people', + status: Status.Exists, + today: 'allowed_tags, followed_sources, squad_ids, followed_user_ids', + needs: 'Nothing', + }, + { + control: 'Block topics, sources, people', + status: Status.Exists, + today: 'blocked_tags, blocked_sources, blocked_author_ids', + needs: 'Nothing', + }, + { + control: 'Fewer like this', + status: Status.Exists, + today: 'Hide; remove_engaged_posts drops it', + needs: 'Nothing', + }, + { + control: 'More and Less on a topic', + status: Status.Config, + today: + 'control_boost and control_demote are in the config but BuildInterests never applies them', + needs: 'Per-user, per-topic controls stored and applied in BuildInterests', + }, + { + control: 'Mute a learned topic', + status: Status.Missing, + today: 'Only in the proof-of-concept notebook', + needs: 'Same storage as More and Less; drop the inferred interest', + }, + { + control: 'Exact topic weight', + status: Status.Missing, + today: 'Weights are computed per request', + needs: 'A per-topic override that replaces the computed weight', + }, + { + control: 'Less from a source', + status: Status.Missing, + today: 'Sources only have follow and block', + needs: 'A per-source demote on the follow factor', + }, + { + control: 'Ranking knobs', + status: Status.Config, + today: + 'retriever_config is global per cohort; it travels inline on every request and is part of the cache key', + needs: 'daily-api or lofn merges the user’s overrides into retriever_config, clamped to policy.Parse ranges', + }, + { + control: 'Learn from my reading', + status: Status.Config, + today: 'inferred_interests.enabled is per provider', + needs: 'Set it per user', + }, + { + control: 'Popular fill-ins', + status: Status.Config, + today: 'The cs and po lanes fill what the challenger leaves empty', + needs: 'Drop those lanes per user', + }, + { + control: 'Forget what the feed learned', + status: Status.Missing, + today: + 'The delta job rebuilds 90 days of evidence every 5 minutes, so deleting the profile does not stick', + needs: 'A per-user evidence cutoff date the job respects', + }, + { + control: 'Forget what you scrolled past', + status: Status.Exists, + today: 'Impression counts in Redis per user', + needs: 'An endpoint that clears them', + }, + { + control: 'Reasons and factors on each card', + status: Status.Missing, + today: + 'The policy computes them and logs them to fss_events provider_metadata; the API returns only post ids', + needs: 'Return the decision with each post, or look it up by request id', + }, + { + control: 'Left out list', + status: Status.Missing, + today: 'feed_candidates logs up to 500 eligible cards per request', + needs: 'Log exclusion reasons too, and an endpoint to read them', + }, + { + control: 'Your interest profile', + status: Status.Missing, + today: 'Stored per user in Redis and Postgres; no API returns it', + needs: 'An endpoint that returns the labelled, weighted profile', + }, +]; + +export const Spec: Story = { + name: '10 · What it needs', + render: () => ( +
+
+

+ Full control over For You: what exists and what it needs +

+

+ The story runs a copy of {policyVersion}, the challenger + policy For You uses, over real posts. Every reason, chip, weight and + left-out group comes from that copy, so changing a control re-ranks + the page the same way the feed would. It simplifies four things: + reader response compares posts here instead of using the source + prior, the first tag is the main topic, topic similarity is tag + overlap instead of tag embeddings, and placement swaps are left out. +

+
+
+ + + + + + + + + + + {specRows.map((row) => ( + + + + + + + ))} + +
ControlStatusTodayNeeds
+ {row.control} + + + {statusLabel[row.status].label} + + {row.today}{row.needs}
+
+
+ ), +}; diff --git a/packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx b/packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx new file mode 100644 index 0000000000..323c0dc8d8 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/AnchoredPopover.tsx @@ -0,0 +1,99 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import classNames from 'classnames'; + +interface AnchoredPopoverProps { + anchor: HTMLElement | null; + onClose: () => void; + children: ReactNode; + label: string; + className?: string; +} + +const gutter = 16; +const gap = 8; + +/** + * A small dialog pinned to its trigger. Portaled, so card overlays with + * overflow hidden don't clip it; it flips above the trigger near the bottom + * of the screen. + */ +export const AnchoredPopover = ({ + anchor, + onClose, + children, + label, + className, +}: AnchoredPopoverProps): ReactElement | null => { + const ref = useRef(null); + const [position, setPosition] = useState<{ top: number; left: number }>(); + + useLayoutEffect(() => { + if (!anchor) { + return undefined; + } + const place = () => { + const popover = ref.current; + if (!popover) { + return; + } + const rect = anchor.getBoundingClientRect(); + const { width, height } = popover.getBoundingClientRect(); + const below = rect.bottom + gap; + const top = + below + height > window.innerHeight - gutter && rect.top - gap > height + ? rect.top - gap - height + : below; + const left = Math.min( + Math.max(gutter, rect.left), + window.innerWidth - width - gutter, + ); + setPosition({ top, left }); + }; + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if (!ref.current?.contains(target) && !anchor.contains(target)) { + onClose(); + } + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + anchor.focus(); + } + }; + place(); + window.addEventListener('resize', place); + window.addEventListener('scroll', place, true); + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('resize', place); + window.removeEventListener('scroll', place, true); + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [anchor, onClose]); + + if (!anchor) { + return null; + } + + return createPortal( +
+ {children} +
, + document.body, + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx b/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx new file mode 100644 index 0000000000..d469d0cbca --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx @@ -0,0 +1,72 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; +import { SettingsIcon } from '@dailydotdev/shared/src/components/icons/Settings'; +import { usePersonalization } from './PersonalizationContext'; + +/** Floats at the bottom: the last change, Undo, and the screen's next step. */ +export const ChangesDock = ({ + primaryLabel, + onPrimary, + defaultMessage, + onOpenControls, + isCompact, +}: { + primaryLabel: string; + onPrimary: () => void; + defaultMessage: string; + onOpenControls?: () => void; + isCompact?: boolean; +}): ReactElement => { + const { lastMessage, notice, pendingCount, undo } = usePersonalization(); + + return ( +
+
+ + {lastMessage ?? notice ?? defaultMessage} + + {pendingCount > 0 && ( + + )} + {onOpenControls && ( + + )} + +
+
+ ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx deleted file mode 100644 index 8010f374dc..0000000000 --- a/packages/storybook/stories/features/feed-explain/components/ExplainBar.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import type { ReactElement } from 'react'; -import React from 'react'; -import classNames from 'classnames'; -import { - Button, - ButtonSize, - ButtonVariant, -} from '@dailydotdev/shared/src/components/buttons/Button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuOptions, - DropdownMenuTrigger, -} from '@dailydotdev/shared/src/components/dropdown/DropdownMenu'; -import type { MenuItemProps } from '@dailydotdev/shared/src/components/dropdown/common'; -import { MenuIcon } from '@dailydotdev/shared/src/components/MenuIcon'; -import { BlockIcon } from '@dailydotdev/shared/src/components/icons/Block'; -import { MinusIcon } from '@dailydotdev/shared/src/components/icons/Minus'; -import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; -import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; -import type { ExplainedPost, FeedSignal } from './feedExplain'; -import { - FeedReason, - getSignalName, - levelLabel, - levelTextClassName, - reasonMeta, - reasonOrder, - SignalKind, - TuneLevel, -} from './feedExplain'; -import { useFeedTuning } from './FeedTuningContext'; - -const topicLimit = 8; - -const getTopTopics = (items: ExplainedPost[]) => { - const byKey = new Map(); - items.forEach(({ signals }) => - signals - .filter(({ kind }) => kind === SignalKind.Topic) - .forEach((signal) => { - const entry = byKey.get(signal.key); - byKey.set(signal.key, { - signal, - count: (entry?.count ?? 0) + 1, - }); - }), - ); - return [...byKey.values()] - .filter(({ count }) => count > 1) - .sort((a, b) => b.count - a.count) - .slice(0, topicLimit); -}; - -const TopicChip = ({ - signal, - count, -}: { - signal: FeedSignal; - count: number; -}): ReactElement => { - const { levels, setLevel, setHoverKey } = useFeedTuning(); - const level = levels[signal.key]; - const name = getSignalName(signal); - const options: MenuItemProps[] = [ - { - icon: , - label: `Show more ${name}`, - action: () => setLevel(signal, TuneLevel.More), - }, - { - icon: , - label: `Show less ${name}`, - action: () => setLevel(signal, TuneLevel.Less), - }, - { - icon: , - label: `Block ${name}`, - action: () => setLevel(signal, TuneLevel.Blocked), - }, - ...(level - ? [ - { - icon: , - label: level === TuneLevel.Blocked ? 'Unblock' : 'Back to normal', - action: () => setLevel(signal, undefined), - }, - ] - : []), - ]; - - return ( - - - - - - - - - ); -}; - -interface ExplainBarProps { - items: ExplainedPost[]; - isCompact?: boolean; - onPickTopics: () => void; -} - -export const ExplainBar = ({ - items, - isCompact, - onPickTopics, -}: ExplainBarProps): ReactElement => { - const { focus, setFocus, getLeaveReason } = useFeedTuning(); - const staying = items.filter((item) => !getLeaveReason(item)); - const counts = reasonOrder - .map((reason) => ({ - reason, - count: staying.filter((item) => item.reason === reason).length, - })) - .filter(({ count }) => count > 0); - const popularCount = - counts.find(({ reason }) => reason === FeedReason.Popular)?.count ?? 0; - const isLearning = staying.length > 0 && popularCount / staying.length >= 0.25; - const topics = getTopTopics(staying); - const focusedReason = focus?.type === 'reason' ? focus.reason : null; - - return ( -
-
-

- Why these posts are in your feed -

- {!isCompact && ( -

- Every post now shows what put it here. Use{' '} - or{' '} - to see less or - more of something, or block it. -

- )} -
-
-
- {counts.map(({ reason, count }) => ( - - ))} -
-
    - {counts.map(({ reason, count }) => ( -
  • - -
  • - ))} -
-
- {isLearning && ( -
-

- - Your feed is still learning what you like. - {' '} - {popularCount} of these {staying.length} posts are popular picks. - Pick a few more topics and they'll make room for posts that fit - you. -

- -
- )} - {topics.length > 0 && ( -
-

- Most shown topics -

-
- {topics.map(({ signal, count }) => ( - - ))} -
-
- )} -
- ); -}; diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainDetails.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainDetails.tsx index c1dc23ea2b..4807ec6761 100644 --- a/packages/storybook/stories/features/feed-explain/components/ExplainDetails.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ExplainDetails.tsx @@ -1,5 +1,5 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { ReactElement } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; import useFeedSettings, { @@ -19,146 +19,98 @@ import { RecommendationMatchOrigin, RecommendationMatchRole, } from '../../why-recommended/components/whyRecommended'; -import type { ExplainedPost, ViewerProfile } from './feedExplain'; -import { - FeedReason, - getSignalKey, - SignalKind, - SignalOrigin, - TuneLevel, -} from './feedExplain'; -import { useFeedTuning } from './FeedTuningContext'; -import { FeedScreenContext } from './ExplainPanel'; - -const matchOrigin: Partial> = - { - [SignalOrigin.Picked]: RecommendationMatchOrigin.Selected, - [SignalOrigin.Following]: RecommendationMatchOrigin.Following, - [SignalOrigin.Reading]: RecommendationMatchOrigin.Reading, - }; - -const matchPoints: Partial> = { - [SignalOrigin.Following]: 120, - [SignalOrigin.Picked]: 100, - [SignalOrigin.Reading]: 70, -}; - -const topicWeight: Partial> = { - [SignalOrigin.Picked]: 0.9, - [SignalOrigin.Reading]: 0.55, -}; - -const levelMultiplier: Partial> = { - [TuneLevel.More]: 1.25, - [TuneLevel.Less]: 0.6, -}; - -/** Ages are measured from the day the history posts were captured. */ -const capturedAt = Date.parse('2026-10-15'); +import type { Decision, PersonalizationState } from '../policy/rank'; +import { FollowKind, Lane, TopicRole } from '../policy/rank'; +import type { Interest } from '../policy/profile'; +import { InterestOrigin } from '../policy/profile'; +import { usePersonalization } from './PersonalizationContext'; const round = (value: number): number => Math.round(value * 1000) / 1000; /** - * The ranking explanation the feed service would return for this post, so - * the full view shows the same matches and the tuning applied in the feed. + * The served decision as the explanation the feed service would return. The + * breakdown shows each multiplier on a log scale, so they add up to the log + * of the card's pick score. */ const buildExplanation = ( - item: ExplainedPost, - levels: Record, - boost: number, + decision: Decision, + interests: Interest[], ): RecommendationExplanation | undefined => { - const matched = item.signals.filter( - ({ origin }) => origin !== SignalOrigin.Post, - ); - if (!matched.length) { + if (decision.lane === Lane.Popular) { return undefined; } - - const matches = matched.map( - (signal, index): RecommendationMatch => ({ - id: signal.id, - label: signal.label, - kind: - signal.kind === SignalKind.Topic - ? RecommendationMatchKind.Topic - : RecommendationMatchKind.Source, - origin: matchOrigin[signal.origin] ?? RecommendationMatchOrigin.Reading, - ...(signal.kind === SignalKind.Topic && { + const matches: RecommendationMatch[] = [ + ...(decision.followedBy === FollowKind.Source && decision.post.source?.id + ? [ + { + id: decision.post.source.id, + label: decision.post.source.name ?? decision.post.source.id, + kind: RecommendationMatchKind.Source, + origin: RecommendationMatchOrigin.Following, + points: Math.round((decision.factors.follow - 1) * 100), + }, + ] + : []), + ...decision.matches.map((match): RecommendationMatch => { + const interest = interests.find(({ tag }) => tag === match.tag); + return { + id: match.tag, + label: match.tag, + kind: RecommendationMatchKind.Topic, + origin: + match.origin === InterestOrigin.Declared + ? RecommendationMatchOrigin.Selected + : RecommendationMatchOrigin.Reading, role: - signal.key === item.reasonSignal?.key + match.role === TopicRole.Main ? RecommendationMatchRole.Main : RecommendationMatchRole.Supporting, - }), - points: Math.round( - (matchPoints[signal.origin] ?? 0) * - (index ? 0.7 : 1) * - (levelMultiplier[levels[signal.key]] ?? 1), - ), - }), - ); - const { post } = item; - const daysOld = Math.max( - 0, - (capturedAt - Date.parse(post.createdAt ?? '')) / 86_400_000, - ); - const factors = [ - { - label: 'interest match', - value: round( - matched.reduce( - (sum, { origin, key }) => - sum + - (topicWeight[origin] ?? 0) * (levelMultiplier[levels[key]] ?? 1), - 0, + points: Math.round( + (interest ? match.contribution : match.contribution) * 100, ), - ), - }, - { - label: 'followed source', - value: item.reason === FeedReason.Following ? 1.2 : 0, - }, - { - label: 'community signal', - value: round(Math.log10((post.numUpvotes ?? 0) + 1) * 0.12), - }, - { label: 'your tuning', value: round(boost * 0.5) }, - { label: 'age', value: -round(Math.min(0.4, (daysOld || 0) * 0.012)) }, + }; + }), + ]; + if (!matches.length) { + return undefined; + } + const { factors, slot } = decision; + const log = (value: number) => round(Math.log(value)); + const breakdown = [ + { label: 'reader response', value: log(factors.base) }, + { label: 'topic match', value: log(factors.match) }, + { label: 'follow', value: log(factors.follow) }, + { label: 'freshness', value: log(factors.age) }, + { label: 'seen before', value: log(factors.seen) }, + ...(slot + ? [ + { label: 'interest rotation', value: log(slot.waiting) }, + { label: 'source variety', value: log(slot.sourceRepeat) }, + { label: 'topic variety', value: log(slot.topicRepeat) }, + { label: 'same story', value: log(slot.sameHeadline) }, + ] + : []), ]; return { matches, - factors, - finalScore: round(factors.reduce((sum, { value }) => sum + value, 0)), + factors: breakdown, + finalScore: round(breakdown.reduce((sum, { value }) => sum + value, 0)), }; }; const toFeedSettings = ( - item: ExplainedPost, - viewer: ViewerProfile, - levels: Record, + decision: Decision, + state: PersonalizationState, ): FeedSettings => { - const topicsAt = (level: TuneLevel) => - Object.keys(levels) - .filter((key) => levels[key] === level && key.startsWith('topic:')) - .map((key) => key.slice('topic:'.length)); - const blockedTags = topicsAt(TuneLevel.Blocked); - const { source } = item.post; - const sourceLevel = source?.id - ? levels[getSignalKey(SignalKind.Source, source.id)] - : undefined; - const isFollowingSource = - !!source?.id && - (viewer.followedSources.includes(source.id) || - sourceLevel === TuneLevel.More); - + const source = decision.post.source as Source | undefined; return { - includeTags: [ - ...new Set([...viewer.pickedTags, ...topicsAt(TuneLevel.More)]), - ].filter((tag) => !blockedTags.includes(tag)), - blockedTags, - includeSources: isFollowingSource && source ? [source as Source] : [], + includeTags: state.followedTags, + blockedTags: state.blockedTags, + includeSources: + source && state.followedSources.includes(source.id) ? [source] : [], excludeSources: - sourceLevel === TuneLevel.Blocked && source ? [source as Source] : [], + source && state.blockedSources.includes(source.id) ? [source] : [], advancedSettings: [], }; }; @@ -168,54 +120,54 @@ const diff = (before: string[] = [], after: string[] = []) => ({ removed: before.filter((value) => !after.includes(value)), }); -const sourceIds = (sources?: Array>) => +const ids = (sources?: Array>) => (sources ?? []).map(({ id }) => id); -interface ExplainDetailsModalProps { - item: ExplainedPost; - viewer: ViewerProfile; - onClose: () => void; -} - /** - * The production "Why am I seeing this?" modal over the tuned feed. Follow - * and block in the modal write the feed settings cache; they flow back into - * the tuning state, so the cards behind it update too. + * The production "Why am I seeing this?" modal, fed the served decision. + * Follow and block in it write the feed settings cache, and those writes go + * back into the personalization state so the feed behind it updates. */ -const ExplainDetailsModal = ({ - item, - viewer, +export const ExplainDetailsModal = ({ + decision, onClose, -}: ExplainDetailsModalProps): ReactElement | null => { +}: { + decision: Decision; + onClose: () => void; +}): ReactElement | null => { const client = useQueryClient(); const { user } = useAuthContext(); - const tuning = useFeedTuning(); + const store = usePersonalization(); const [explanation] = useState(() => - buildExplanation(item, tuning.levels, tuning.getBoost(item)), + buildExplanation(decision, store.next.interests), ); const [isSeeded, setIsSeeded] = useState(false); const seenRef = useRef(); const { feedSettings } = useFeedSettings({ enabled: isSeeded }); + const source = { + id: decision.post.source?.id ?? '', + name: decision.post.source?.name ?? 'this source', + }; useEffect(() => { if (!user || isSeeded) { return; } - const settings = toFeedSettings(item, viewer, tuning.levels); + const settings = toFeedSettings(decision, store.state); seenRef.current = settings; client.setQueryData(getFeedSettingsQueryKey(user), { tagsCategories: [], feedSettings: settings, advancedSettings: [], }); - client.setQueryData(getPostByIdKey(item.post.id), { + client.setQueryData(getPostByIdKey(decision.post.id), { post: - item.reason === FeedReason.Popular - ? { ...item.post, trending: item.post.trending || 1 } - : item.post, + decision.lane === Lane.Popular + ? { ...decision.post, trending: decision.post.trending || 1 } + : decision.post, }); setIsSeeded(true); - }, [client, user, item, viewer, tuning.levels, isSeeded]); + }, [client, user, decision, store.state, isSeeded]); useEffect(() => { const seen = seenRef.current; @@ -223,56 +175,34 @@ const ExplainDetailsModal = ({ return; } seenRef.current = feedSettings; - - const next = new Map(); - const unfollow = (key: string) => - next.set( - key, - tuning.levels[key] === TuneLevel.More ? undefined : TuneLevel.Less, - ); - const tags = diff(seen.includeTags, feedSettings.includeTags); - tags.added.forEach((tag) => - next.set(getSignalKey(SignalKind.Topic, tag), TuneLevel.More), - ); - tags.removed.forEach((tag) => - unfollow(getSignalKey(SignalKind.Topic, tag)), - ); - const sources = diff( - sourceIds(seen.includeSources), - sourceIds(feedSettings.includeSources), - ); - sources.added.forEach((id) => - next.set(getSignalKey(SignalKind.Source, id), TuneLevel.More), - ); - sources.removed.forEach((id) => - unfollow(getSignalKey(SignalKind.Source, id)), - ); - // Blocks win over the follow changes the same action makes. const blocked = diff(seen.blockedTags, feedSettings.blockedTags); - blocked.added.forEach((tag) => - next.set(getSignalKey(SignalKind.Topic, tag), TuneLevel.Blocked), - ); - blocked.removed.forEach((tag) => - next.set(getSignalKey(SignalKind.Topic, tag), undefined), - ); + const followed = diff(seen.includeTags, feedSettings.includeTags); + // Blocking a followed tag also unfollows it; the block says it all. + blocked.added.forEach((tag) => store.setTagBlocked(tag, true)); + blocked.removed.forEach((tag) => store.setTagBlocked(tag, false)); + followed.added.forEach((tag) => store.setTagFollowed(tag, true)); + followed.removed + .filter((tag) => !blocked.added.includes(tag)) + .forEach((tag) => store.setTagFollowed(tag, false)); const excluded = diff( - sourceIds(seen.excludeSources), - sourceIds(feedSettings.excludeSources), - ); - excluded.added.forEach((id) => - next.set(getSignalKey(SignalKind.Source, id), TuneLevel.Blocked), + ids(seen.excludeSources), + ids(feedSettings.excludeSources), ); - excluded.removed.forEach((id) => - next.set(getSignalKey(SignalKind.Source, id), undefined), + const included = diff( + ids(seen.includeSources), + ids(feedSettings.includeSources), ); - - next.forEach((level, key) => { - const signal = item.signals.find((candidate) => candidate.key === key); - if (signal) { - tuning.setLevel(signal, level); - } - }); - }, [feedSettings, isSeeded, item, tuning]); + if (excluded.added.length) { + store.setSourceBlocked(source, true); + } else if (excluded.removed.length) { + store.setSourceBlocked(source, false); + } + if (included.added.length) { + store.setSourceFollowed(source, true); + } else if (included.removed.length && !excluded.added.length) { + store.setSourceFollowed(source, false); + } + }, [feedSettings, isSeeded, source, store]); if (!isSeeded) { return null; @@ -281,42 +211,11 @@ const ExplainDetailsModal = ({ return ( tuning.setDismissed(item.post.id, true)} + onShowFewer={() => store.setHidden(decision.post.id, true)} onRequestClose={onClose} /> ); }; - -interface ExplainDetailsHostProps { - counts: Record; - viewer: ViewerProfile; - initialItem?: ExplainedPost; -} - -/** Gives cards their on-screen counts and the way into the full view. */ -export const ExplainDetailsHost = ({ - counts, - viewer, - initialItem, - children, -}: PropsWithChildren): ReactElement => { - const [item, setItem] = useState(initialItem ?? null); - const value = useMemo(() => ({ counts, onOpenDetails: setItem }), [counts]); - - return ( - - {children} - {item && ( - setItem(null)} - /> - )} - - ); -}; diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx index 521fef1f2f..fb2d16ea0d 100644 --- a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx @@ -1,5 +1,5 @@ -import type { ReactElement, ReactNode } from 'react'; -import React, { createContext, useContext } from 'react'; +import type { ReactElement } from 'react'; +import React from 'react'; import classNames from 'classnames'; import { Button, @@ -18,98 +18,109 @@ import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; -import type { ExplainedPost, FeedSignal } from './feedExplain'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import type { Decision } from '../policy/rank'; +import { Exclusion, FollowKind, SourceMode } from '../policy/rank'; +import { InterestOrigin, TopicMode } from '../policy/profile'; import { - FeedReason, - getCardSignals, - getSignalName, - levelLabel, - levelTextClassName, - originLabel, - reasonMeta, - SignalKind, - SignalOrigin, - TuneLevel, -} from './feedExplain'; -import type { LeaveReason } from './FeedTuningContext'; -import { useFeedTuning } from './FeedTuningContext'; - -interface FeedScreenValue { - /** How many posts on screen carry each signal key. */ - counts: Record; - /** Opens the full "Why am I seeing this?" explanation. */ - onOpenDetails?: (item: ExplainedPost) => void; -} - -export const FeedScreenContext = createContext({ - counts: {}, -}); + getCategory, + getCategoryDot, + getLeaveMessage, + getReason, +} from '../policy/describe'; +import { defaultParams } from '../policy/params'; +import { usePersonalization } from './PersonalizationContext'; +import { useFeedScreen } from './FeedScreenContext'; +import { FactorChips } from './FactorChips'; +import { Strong } from './controls'; export enum ExplainVariant { Grid = 'grid', List = 'list', } -const Strong = ({ children }: { children: ReactNode }): ReactElement => ( - {children} -); +enum SignalKind { + Topic = 'topic', + Source = 'source', +} + +interface CardSignal { + key: string; + kind: SignalKind; + id: string; + label: string; + source?: Source; +} + +const getCardSignals = (decision: Decision, limit: number): CardSignal[] => { + const { post } = decision; + const matched = decision.matches.map(({ tag }) => tag); + const topics = [ + ...matched, + ...(post.tags ?? []).filter((tag) => !matched.includes(tag)), + ].map( + (tag): CardSignal => ({ + key: `topic:${tag}`, + kind: SignalKind.Topic, + id: tag, + label: `#${tag}`, + }), + ); + if (!post.source?.id) { + return topics.slice(0, limit); + } + const source: CardSignal = { + key: `source:${post.source.id}`, + kind: SignalKind.Source, + id: post.source.id, + label: post.source.name ?? post.source.id, + source: post.source as Source, + }; + return decision.followedBy === FollowKind.Source + ? [source, ...topics.slice(0, limit - 1)] + : [...topics.slice(0, limit - 1), source]; +}; export const ReasonLine = ({ - item, + decision, isClamped, }: { - item: ExplainedPost; + decision: Decision; isClamped?: boolean; }): ReactElement => { - const name = item.reasonSignal && getSignalName(item.reasonSignal); - const copy: Record = { - [FeedReason.Following]: ( - <> - You follow {name}. - - ), - [FeedReason.Picked]: ( - <> - You picked {name} when you signed up. - - ), - [FeedReason.Reading]: ( - <> - You've been reading about {name}. - - ), - [FeedReason.Popular]: ( - <> - Popular this week. Shown while your feed learns what - you like. - - ), - }; + const { next } = usePersonalization(); + const { lead, name, tail } = getReason(decision, next.interests); return (

- {copy[item.reason]} + {name ? ( + <> + {lead} + {name} + {tail} + + ) : ( + <> + {lead} + {tail} + + )}

); }; -const kindLabel: Record = { - [SignalKind.Topic]: 'Topic', - [SignalKind.Source]: 'Source', -}; - -const SignalGlyph = ({ signal }: { signal: FeedSignal }): ReactElement => - signal.kind === SignalKind.Source && signal.source ? ( +const SignalGlyph = ({ signal }: { signal: CardSignal }): ReactElement => + signal.source ? ( ); -const getActionLabels = (signal: FeedSignal) => { - const name = getSignalName(signal); - return signal.kind === SignalKind.Source - ? { - less: `Less from ${name}`, - more: `More from ${name}`, - block: `Block ${name}`, - } - : { less: `Less ${name}`, more: `More ${name}`, block: `Block ${name}` }; -}; - -export const SignalRow = ({ +const SignalRow = ({ signal, - size = ButtonSize.XSmall, + size, }: { - signal: FeedSignal; - size?: ButtonSize; + signal: CardSignal; + size: ButtonSize; }): ReactElement => { - const { levels, setLevel, setHoverKey } = useFeedTuning(); - const { counts } = useContext(FeedScreenContext); - const level = levels[signal.key]; + const store = usePersonalization(); + const { state, next, setHoverKey } = store; + const { counts } = useFeedScreen(); const count = counts[signal.key] ?? 0; - const labels = getActionLabels(signal); - const isBlocked = level === TuneLevel.Blocked; - const meta = [ - signal.origin === SignalOrigin.Post - ? kindLabel[signal.kind] - : originLabel[signal.origin], - count > 1 && `in ${count} posts`, - ] - .filter(Boolean) - .join(' · '); - const toggle = (next: TuneLevel) => - setLevel(signal, level === next ? undefined : next); + const isTopic = signal.kind === SignalKind.Topic; + const entity = { id: signal.id, name: signal.label }; + const isBlocked = isTopic + ? state.blockedTags.includes(signal.id) + : state.blockedSources.includes(signal.id); + const mode = isTopic ? state.controls[signal.id]?.mode : undefined; + const isFollowedSource = + !isTopic && state.followedSources.includes(signal.id); + const isDemotedSource = + !isTopic && state.sourceControls[signal.id] === SourceMode.Demoted; + const interest = next.interests.find(({ tag }) => tag === signal.id); + const evidence = state.evidence.find(({ topic }) => topic === signal.id); + let meta: string; + let metaClassName = 'text-text-quaternary'; + + if (isBlocked) { + meta = 'Blocked'; + metaClassName = 'text-accent-ketchup-default'; + } else if (mode === TopicMode.Muted) { + meta = 'Muted'; + metaClassName = 'text-accent-ketchup-default'; + } else if (mode === TopicMode.Boosted) { + meta = 'Showing more'; + metaClassName = 'text-accent-avocado-default'; + } else if (mode === TopicMode.Demoted || isDemotedSource) { + meta = 'Showing less'; + metaClassName = 'text-accent-bun-default'; + } else { + let origin = isTopic ? 'Topic' : 'Source'; + if (isFollowedSource) { + origin = 'You follow it'; + } else if (interest?.origin === InterestOrigin.Declared) { + origin = 'You follow it'; + } else if (interest && interest.weight > 0) { + origin = `Learned · ${interest.evidence?.stories} stories`; + } else if (evidence) { + origin = 'Early signal · 1 story'; + } + meta = [origin, count > 1 && `in ${count} posts`].filter(Boolean).join(' · '); + } + + const onBlock = () => + isTopic + ? store.setTagBlocked(signal.id, !isBlocked) + : store.setSourceBlocked(entity, !isBlocked); return (
  • - {getSignalName(signal)} + {signal.label} - - {level ? levelLabel[level] : meta} + + {meta} - {isBlocked ? ( + {isBlocked || mode === TopicMode.Muted ? ( ) : ( @@ -203,10 +233,20 @@ export const SignalRow = ({ color={ButtonColor.Bun} size={size} icon={} - pressed={level === TuneLevel.Less} - aria-label={labels.less} - title={labels.less} - onClick={() => toggle(TuneLevel.Less)} + pressed={mode === TopicMode.Demoted || isDemotedSource} + aria-label={isTopic ? `Less ${signal.label}` : `Less from ${signal.label}`} + title={isTopic ? `Less ${signal.label}` : `Less from ${signal.label}`} + onClick={() => + isTopic + ? store.setTopicMode( + signal.id, + mode === TopicMode.Demoted ? undefined : TopicMode.Demoted, + ) + : store.setSourceMode( + entity, + isDemotedSource ? undefined : SourceMode.Demoted, + ) + } /> + {fix && ( + + )}
  • ); }; interface ExplainPanelProps { - item: ExplainedPost; + decision: Decision; + /** Set when the current settings would drop this card. */ + leaving?: Decision; variant: ExplainVariant; className?: string; } -const cardRows: Record = { +const rowLimit: Record = { + [ExplainVariant.Grid]: 3, + [ExplainVariant.List]: 3, +}; + +const chipLimit: Record = { [ExplainVariant.Grid]: 4, [ExplainVariant.List]: 3, }; export const ExplainPanel = ({ - item, + decision, + leaving, variant, className, }: ExplainPanelProps): ReactElement => { - const { getLeaveReason, setDismissed } = useFeedTuning(); - const { onOpenDetails } = useContext(FeedScreenContext); - const leaveReason = getLeaveReason(item); + const { setHidden } = usePersonalization(); + const { onOpenDetails } = useFeedScreen(); const isGrid = variant === ExplainVariant.Grid; - if (leaveReason) { + if (leaving) { return ( -
    - +
    +
    ); } @@ -331,14 +418,15 @@ export const ExplainPanel = ({ return (
    - -
      - {getCardSignals(item, cardRows[variant]).map((signal) => ( + + +
        + {getCardSignals(decision, rowLimit[variant]).map((signal) => ( } className="!text-text-tertiary" - onClick={() => setDismissed(item.post.id, true)} + onClick={() => setHidden(decision.post.id, true)} > Fewer like this @@ -365,7 +453,7 @@ export const ExplainPanel = ({ icon={} iconPosition={ButtonIconPosition.Right} aria-haspopup="dialog" - onClick={() => onOpenDetails(item)} + onClick={() => onOpenDetails(decision)} > Full explanation diff --git a/packages/storybook/stories/features/feed-explain/components/FactorChips.tsx b/packages/storybook/stories/features/feed-explain/components/FactorChips.tsx new file mode 100644 index 0000000000..6f9b05c01e --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/FactorChips.tsx @@ -0,0 +1,427 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { Decision } from '../policy/rank'; +import { TopicRole } from '../policy/rank'; +import type { ParamKey } from '../policy/params'; +import { defaultParams } from '../policy/params'; +import { InterestOrigin, strengthLabel, TopicMode } from '../policy/profile'; +import type { FactorChip } from '../policy/describe'; +import { FactorId, getFactorChips } from '../policy/describe'; +import { usePersonalization } from './PersonalizationContext'; +import { AnchoredPopover } from './AnchoredPopover'; +import { Segmented, Strong, ToggleRow, ParamSlider } from './controls'; +import { ControlSection, useFeedScreen } from './FeedScreenContext'; + +export const formatMultiplier = (value: number): string => + `×${value.toFixed(value >= 10 ? 0 : 2)}`; + +const getTone = (chip: FactorChip): 'up' | 'down' | 'neutral' => { + if (chip.id === FactorId.Popular || Math.abs(Math.log(chip.value)) < 0.05) { + return 'neutral'; + } + return chip.value > 1 ? 'up' : 'down'; +}; + +const toneClassName = { + up: 'text-accent-avocado-default', + down: 'text-accent-bun-default', + neutral: 'text-text-tertiary', +}; + +/** A knob that is either at its default or switched off. */ +const ParamToggle = ({ + id, + paramKey, + offValue, + label, + description, +}: { + id: string; + paramKey: ParamKey; + offValue: number; + label: string; + description?: ReactNode; +}): ReactElement => { + const { state, setParam } = usePersonalization(); + const isOn = state.params[paramKey] !== offValue; + + return ( + + setParam(paramKey, isOn ? offValue : defaultParams[paramKey]) + } + /> + ); +}; + +const freshnessOptions = [ + { value: 3, label: 'Newest' }, + { value: 7, label: 'Balanced' }, + { value: 21, label: 'Evergreen' }, +]; + +const FactorDetails = ({ + chip, + decision, +}: { + chip: FactorChip; + decision: Decision; +}): ReactElement => { + const store = usePersonalization(); + const { state, next } = store; + const { onOpenControls } = useFeedScreen(); + const { params } = state; + const sourceName = decision.post.source?.name ?? 'this source'; + const id = `${decision.post.id}-${chip.id}`; + let body: ReactNode; + let section = ControlSection.Ranking; + + switch (chip.id) { + case FactorId.Match: { + const best = decision.best!; + const interest = next.interests.find(({ tag }) => tag === best.tag); + const mode = state.controls[best.tag]?.mode; + const isFollowed = state.followedTags.includes(best.tag); + section = ControlSection.Interests; + body = ( + <> +

        + #{best.tag} is a{' '} + {best.origin === InterestOrigin.Declared ? 'topic you follow' : 'topic learned from your reading'} + {interest?.strength && ( + <> + {' '} + ({strengthLabel[interest.strength].toLowerCase()},{' '} + {interest.evidence?.stories}{' '} + {interest.evidence?.stories === 1 ? 'story' : 'stories'}) + + )} + . Its weight, {best.weight.toFixed(2)} + {best.role === TopicRole.Side && + `, counted at ${Math.round(best.roleWeight * 100)}% as a side topic`} + , lifts this post {formatMultiplier(decision.factors.match)}. +

        + store.setTopicMode(best.tag, value)} + options={[ + ...(isFollowed + ? [] + : [{ value: TopicMode.Muted, label: 'Mute' }]), + { + value: TopicMode.Demoted, + label: 'Less', + activeClassName: 'text-accent-bun-default', + }, + { value: undefined, label: 'Normal' }, + { + value: TopicMode.Boosted, + label: 'More', + activeClassName: 'text-accent-avocado-default', + }, + ]} + /> +
        + + +
        + + ); + break; + } + case FactorId.Follow: + body = ( + <> +

        + You follow {sourceName}, so every post from it is + in and multiplied by {formatMultiplier(decision.factors.follow)}. +

        + + + + ); + break; + case FactorId.Base: + body = ( +

        + Per view, readers upvoted and commented on this post{' '} + {formatMultiplier(decision.factors.base)} as much as + the typical post here. Well-liked posts also stay fresh longer. This + one is about other readers, so there is nothing to turn off. +

        + ); + break; + case FactorId.Age: + body = ( + <> +

        + Posted {Math.round(decision.ageDays)} days ago. A post loses half + its pull every {params['age.half_life_days']} days + , stretched up to {params['age.max_stretch']}× for well-liked + posts. +

        + store.setParam('age.half_life_days', value)} + options={freshnessOptions} + /> + + ); + break; + case FactorId.Seen: + body = ( + <> +

        + It was on your screen {decision.impressions}{' '} + {decision.impressions === 1 ? 'time' : 'times'} without a click. + Each view keeps{' '} + {Math.round(params['history.seen_factor_per_show'] * 100)}% of its + pull, and at {params['history.max_impressions_without_action']}{' '} + views it leaves. +

        + + + + ); + break; + case FactorId.Waiting: + body = ( + <> +

        + #{decision.slot?.waitingTag} had not been on your + first screen lately, so its first post gets a turn and jumps ahead + of topics you just saw. +

        + + + ); + break; + case FactorId.SourceRepeat: + body = ( + <> +

        + {decision.slot?.sourceCount}{' '} + {decision.slot?.sourceCount === 1 ? 'post' : 'posts'} from{' '} + {sourceName} already sit above it. Each one lowers the next by{' '} + {Math.round(params['diversity.source_repeat_discount'] * 100)}%. +

        + + + ); + break; + case FactorId.TopicRepeat: + body = ( + <> +

        + Posts on similar topics already sit above it, so it moves down to + mix the page. +

        + + + ); + break; + case FactorId.SameHeadline: + body = ( + <> +

        + Its headline overlaps a post above it, so it is likely the same + story. +

        + + + ); + break; + default: + section = ControlSection.Interests; + body = ( + <> +

        + Your topics and follows found fewer posts than a page, so popular + posts fill the rest. Following a few more topics replaces them. +

        + store.setFilling(!state.isFillingWithPopular)} + /> + + ); + } + + return ( + <> +
        + + {chip.label} + + {chip.id !== FactorId.Popular && ( + + {formatMultiplier(chip.value)} on this post + + )} +
        + {body} +
        + Changes apply when you refresh. + {onOpenControls && ( + + )} +
        + + ); +}; + +const Chip = ({ + chip, + decision, +}: { + chip: FactorChip; + decision: Decision; +}): ReactElement => { + const [anchor, setAnchor] = useState(null); + const tone = getTone(chip); + + return ( + <> + + {anchor && ( + setAnchor(null)} + > + + + )} + + ); +}; + +export const FactorChips = ({ + decision, + limit, +}: { + decision: Decision; + limit: number; +}): ReactElement => ( +
          + {getFactorChips(decision) + .slice(0, limit) + .map((chip) => ( +
        • + +
        • + ))} +
        +); diff --git a/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx b/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx index 5fd2de5dab..88b4d34cc4 100644 --- a/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx +++ b/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx @@ -1,7 +1,8 @@ import type { MouseEvent, ReactElement } from 'react'; -import React, { useContext, useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import classNames from 'classnames'; import { fn } from 'storybook/test'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; import { Button, ButtonSize, @@ -9,22 +10,28 @@ import { } from '@dailydotdev/shared/src/components/buttons/Button'; import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; import { FilterIcon } from '@dailydotdev/shared/src/components/icons/Filter'; -import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; import { ArticleGrid } from '@dailydotdev/shared/src/components/cards/article/ArticleGrid'; import { ArticleList } from '@dailydotdev/shared/src/components/cards/article/ArticleList'; import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; +import { AuthorSourceStack } from '@dailydotdev/shared/src/components/cards/common/AuthorSourceStack'; +import { TypeLabel } from '@dailydotdev/shared/src/components/cards/common/list/TypeLabel'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; -import type { ExplainedPost, ViewerProfile } from './feedExplain'; -import { FeedReason, TuneLevel } from './feedExplain'; -import type { TuningSeed } from './FeedTuningContext'; +import type { Decision, PersonalizationState } from '../policy/rank'; +import { getExclusion, Lane } from '../policy/rank'; import { - describeChange, - FeedTuningProvider, - useFeedTuning, -} from './FeedTuningContext'; -import { ExplainPanel, ExplainVariant, FeedScreenContext } from './ExplainPanel'; -import { ExplainBar } from './ExplainBar'; -import { ExplainDetailsHost } from './ExplainDetails'; + PersonalizationProvider, + usePersonalization, +} from './PersonalizationContext'; +import type { ControlSection } from './FeedScreenContext'; +import { + FeedScreenContext, + getSignalCounts, + useFeedScreen, +} from './FeedScreenContext'; +import { ExplainPanel, ExplainVariant } from './ExplainPanel'; +import { ExplainDetailsModal } from './ExplainDetails'; +import { PersonalizationPanel } from './PersonalizationPanel'; +import { ChangesDock } from './ChangesDock'; export enum FeedLayout { Grid = 'grid', @@ -44,127 +51,175 @@ const cardHandlers = { }; /** Card links point at app.daily.dev; keep the canvas on the story. */ -const preventNavigation = (event: MouseEvent) => { +export const preventNavigation = (event: MouseEvent): void => { if ((event.target as HTMLElement).closest('a')) { event.preventDefault(); } }; -/** In Explain mode a card keeps only where the post is from and its title. */ +/** + * In Explain mode a card keeps only where the post is from and its title, + * drawn at the exact offsets the real card uses so nothing on it moves. + */ const CardHead = ({ - item, + decision, layout, isLeaving, }: { - item: ExplainedPost; + decision: Decision; layout: FeedLayout; isLeaving: boolean; }): ReactElement => { const isGrid = layout === FeedLayout.Grid; + const { post } = decision; + const position = ( + + #{decision.position} + + ); return (
        - - - - {item.post.source?.name} + {isGrid ? ( + + + + {post.source?.name} + + {position} + + ) : ( + + {post.author ? ( + + ) : ( + + )} + + {post.source?.name} + + {position} - + )}

        - {item.post.title} + {post.title}

        ); }; interface CardFrameProps { - item: ExplainedPost; + decision: Decision; + leaving?: Decision; index: number; layout: FeedLayout; isExplain: boolean; } export const CardFrame = ({ - item, + decision, + leaving, index, layout, isExplain, }: CardFrameProps): ReactElement => { - const { focus, hoverKey, getLeaveReason } = useFeedTuning(); - const { counts } = useContext(FeedScreenContext); - const isLeaving = isExplain && !!getLeaveReason(item); - const isDimmed = - isExplain && focus?.type === 'reason' && focus.reason !== item.reason; + const { hoverKey } = usePersonalization(); + const { counts } = useFeedScreen(); + const isLeaving = isExplain && !!leaving; + const isGrid = layout === FeedLayout.Grid; + const { post } = decision; + const keys = [ + ...(post.tags ?? []).map((tag) => `topic:${tag}`), + `source:${post.source?.id}`, + ]; // Only worth pointing at when the signal is on more than this card. const isHighlighted = isExplain && !!hoverKey && (counts[hoverKey] ?? 0) > 1 && - item.signals.some(({ key }) => key === hoverKey); - const frameClassName = classNames( - 'relative rounded-16 transition-[opacity,box-shadow] duration-200', - isDimmed && 'opacity-30', - isHighlighted && - 'ring-2 ring-accent-cabbage-default ring-offset-2 ring-offset-background-default', - ); + keys.includes(hoverKey); - // A phone fits one list card per screen, so Explain trades the cover and - // action bar for the reason, letting you triage a few posts per screen. - if (layout === FeedLayout.List) { - return ( -
        - {isExplain ? ( -
        - - -
        + // The real card stays underneath so the feed keeps every size and gap; the + // overlay covers it with the explanation. + return ( +
        +
        + {isGrid ? ( + ) : ( - + )}
        - ); - } - - // The real card stays underneath so the grid keeps its size; the overlay - // lines its source and title up with the card's own. - return ( -
        -
        - -
        - - + {isExplain && ( + <> + {!isGrid && ( + + )} + + + + )}
        ); @@ -187,17 +242,15 @@ const FeedTab = ({ ); -interface FeedHeaderProps { - layout: FeedLayout; - isExplain: boolean; - onToggleExplain: () => void; -} - const FeedHeader = ({ layout, isExplain, onToggleExplain, -}: FeedHeaderProps): ReactElement => { +}: { + layout: FeedLayout; + isExplain: boolean; + onToggleExplain: () => void; +}): ReactElement => { const isList = layout === FeedLayout.List; return ( @@ -237,230 +290,173 @@ const FeedHeader = ({ ); }; -interface DockProps { - message: string; - canUndo: boolean; - hasChanges: boolean; - onUndo: () => void; - onRefresh: () => void; - onDone: () => void; -} +const FeedExplainScreen = ({ + layout, + isExplain, + onToggleExplain, +}: { + layout: FeedLayout; + isExplain: boolean; + onToggleExplain: () => void; +}): ReactElement => { + const { served, pool, state, pendingCount, refresh } = usePersonalization(); + const { onOpenControls } = useFeedScreen(); + const isList = layout === FeedLayout.List; + const leaving = useMemo( + () => + new Map( + served.map((decision) => [ + decision.post.id, + getExclusion(decision, pool, state), + ]), + ), + [served, pool, state], + ); -const ExplainDock = ({ - message, - canUndo, - hasChanges, - onUndo, - onRefresh, - onDone, -}: DockProps): ReactElement => ( -
        + return (
        - - {message} - - {canUndo && ( - + className={classNames( + 'flex min-h-screen flex-col bg-background-default', + isList ? 'gap-4 px-4 py-4' : 'gap-6 px-6 py-6 laptop:px-10', )} - + {served.map((decision, index) => ( + + ))} +
        + {isExplain && ( + onOpenControls())} + isCompact={isList} + /> + )}
        -
        -); + ); +}; + +export enum AppView { + Feed = 'feed', + Controls = 'controls', +} -interface FeedExplainScreenProps { - items: ExplainedPost[]; - reserve: ExplainedPost[]; - viewer: ViewerProfile; +interface AppProps { layout: FeedLayout; initialExplain?: boolean; - /** Opens the full explanation for this post on load. */ + initialView?: AppView; + initialSection?: ControlSection; initialDetailsId?: string; } -const FeedExplainScreen = ({ - items: initialItems, - reserve: initialReserve, - viewer, +const App = ({ layout, initialExplain = false, + initialView = AppView.Feed, + initialSection, initialDetailsId, -}: FeedExplainScreenProps): ReactElement => { - const tuning = useFeedTuning(); - const { changes, getLeaveReason, getBoost } = tuning; +}: AppProps): ReactElement => { + const store = usePersonalization(); + const [view, setView] = useState(initialView); + const [section, setSection] = useState(initialSection); const [isExplain, setIsExplain] = useState(initialExplain); - const [items, setItems] = useState(initialItems); - const [reserve, setReserve] = useState(initialReserve); - const [notice, setNotice] = useState(null); - const isList = layout === FeedLayout.List; - - const rank = useMemo( + const [details, setDetails] = useState( () => - new Map( - [...initialItems, ...initialReserve].map((item, index) => [ - item.post.id, - index, - ]), - ), - [initialItems, initialReserve], + store.served.find(({ post }) => post.id === initialDetailsId) ?? null, + ); + const counts = useMemo(() => getSignalCounts(store.served), [store.served]); + const screen = useMemo( + () => ({ + counts, + onOpenDetails: setDetails, + onOpenControls: (nextSection?: ControlSection) => { + setSection(nextSection); + setView(AppView.Controls); + window.scrollTo({ top: 0 }); + }, + }), + [counts], ); - const counts = useMemo(() => { - const byKey: Record = {}; - items.forEach(({ signals }) => - signals.forEach(({ key }) => { - byKey[key] = (byKey[key] ?? 0) + 1; - }), - ); - return byKey; - }, [items]); - - const refresh = () => { - const score = (item: ExplainedPost) => - getBoost(item) * 4 - (rank.get(item.post.id) ?? 0); - const byScore = (a: ExplainedPost, b: ExplainedPost) => score(b) - score(a); - const staying = items.filter((item) => !getLeaveReason(item)); - const candidates = reserve - .filter((item) => !getLeaveReason(item)) - .sort(byScore); - const boosted = candidates.filter((item) => getBoost(item) > 0); - // Posts you asked for more of take the popular picks' slots first. - const replaceable = staying - .filter( - (item) => item.reason === FeedReason.Popular && getBoost(item) <= 0, - ) - .reverse() - .slice(0, boosted.length); - const kept = staying.filter((item) => !replaceable.includes(item)); - const incoming = candidates.slice(0, items.length - kept.length); - - setItems([...kept, ...incoming].sort(byScore)); - setReserve(reserve.filter((item) => !incoming.includes(item))); - tuning.commit(); - tuning.setFocus(null); - const outCount = items.length - kept.length; - setNotice( - outCount - ? `Feed refreshed: ${outCount} ${ - outCount === 1 ? 'post' : 'posts' - } out, ${incoming.length} new in.` - : 'Feed refreshed with your changes.', - ); - }; const onToggleExplain = () => { - if (isExplain && changes.length) { - refresh(); + if (isExplain && store.pendingCount) { + store.refresh(); } - tuning.setFocus(null); - tuning.setHoverKey(null); - setNotice(null); + store.setHoverKey(null); setIsExplain(!isExplain); }; - const lastChange = changes[changes.length - 1]; - const getDockMessage = (): string => { - if (!lastChange) { - return notice ?? 'Tune anything that looks off. Changes save as you go.'; + const onBack = () => { + if (store.pendingCount) { + store.refresh(); } - const message = describeChange(lastChange); - if (lastChange.type === 'level' && lastChange.to === TuneLevel.Blocked) { - const leaving = items.filter((item) => { - const reason = getLeaveReason(item); - return ( - reason?.type === 'blocked' && - reason.signal.key === lastChange.signal.key - ); - }).length; - return leaving - ? `${message} ${leaving} ${ - leaving === 1 ? 'post leaves' : 'posts leave' - } on refresh.` - : message; - } - return message; + setView(AppView.Feed); + setIsExplain(true); + window.scrollTo({ top: 0 }); }; return ( - post.id === initialDetailsId)} - > + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} -
        - - {isExplain && ( - - )} -
        - {items.map((item, index) => ( - - ))} -
        - {isExplain && ( - 0} - hasChanges={changes.length > 0} - onUndo={tuning.undo} - onRefresh={refresh} - onDone={onToggleExplain} +
        + {view === AppView.Feed ? ( + + ) : ( + )}
        - + {details && ( + setDetails(null)} + /> + )} + ); }; -export interface FeedExplainPageProps extends FeedExplainScreenProps { - seed?: TuningSeed; +export interface FeedExplainPageProps extends AppProps { + pool: Post[]; + initialState: PersonalizationState; } export const FeedExplainPage = ({ - seed, + pool, + initialState, ...props }: FeedExplainPageProps): ReactElement => ( - - - + + + ); diff --git a/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts new file mode 100644 index 0000000000..7d86e5d24e --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts @@ -0,0 +1,43 @@ +import { createContext, useContext } from 'react'; +import type { Decision } from '../policy/rank'; + +export enum ControlSection { + Overview = 'overview', + Interests = 'interests', + Sources = 'sources', + Ranking = 'ranking', + LeftOut = 'left-out', + Settings = 'settings', + Reset = 'reset', +} + +interface FeedScreenValue { + /** How many posts on screen carry each signal key. */ + counts: Record; + /** Opens the full "Why am I seeing this?" explanation. */ + onOpenDetails?: (decision: Decision) => void; + /** Opens the personalization controls, at a section. */ + onOpenControls?: (section?: ControlSection) => void; +} + +export const FeedScreenContext = createContext({ + counts: {}, +}); + +export const useFeedScreen = (): FeedScreenValue => + useContext(FeedScreenContext); + +export const getSignalCounts = ( + decisions: Decision[], +): Record => { + const counts: Record = {}; + decisions.forEach(({ post }) => { + [ + ...(post.tags ?? []).map((tag) => `topic:${tag}`), + ...(post.source?.id ? [`source:${post.source.id}`] : []), + ].forEach((key) => { + counts[key] = (counts[key] ?? 0) + 1; + }); + }); + return counts; +}; diff --git a/packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx b/packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx deleted file mode 100644 index bd573ab821..0000000000 --- a/packages/storybook/stories/features/feed-explain/components/FeedTuningContext.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import type { PropsWithChildren, ReactElement } from 'react'; -import React, { createContext, useContext, useMemo, useState } from 'react'; -import type { ExplainedPost, FeedReason, FeedSignal } from './feedExplain'; -import { getSignalKey, getSignalName, SignalKind, TuneLevel } from './feedExplain'; - -interface LevelChange { - type: 'level'; - signal: FeedSignal; - from?: TuneLevel; - to?: TuneLevel; -} - -interface DismissChange { - type: 'dismiss'; - postId: string; - isDismissed: boolean; -} - -export type TuningChange = LevelChange | DismissChange; - -export type LeaveReason = - | { type: 'blocked'; signal: Pick } - | { type: 'dismissed' }; - -export type FeedFocus = - | { type: 'reason'; reason: FeedReason } - | { type: 'signal'; key: string }; - -export interface TuningSeed { - levels?: Record; - dismissed?: string[]; -} - -interface FeedTuningValue { - levels: Record; - /** Changes since the feed last refreshed, oldest first. */ - changes: TuningChange[]; - focus: FeedFocus | null; - hoverKey: string | null; - setLevel: (signal: FeedSignal, level?: TuneLevel) => void; - setDismissed: (postId: string, isDismissed: boolean) => void; - undo: () => void; - commit: () => void; - setFocus: (focus: FeedFocus | null) => void; - setHoverKey: (key: string | null) => void; - getLeaveReason: (item: ExplainedPost) => LeaveReason | undefined; - getBoost: (item: ExplainedPost) => number; -} - -const FeedTuningContext = createContext(null); - -const getPostKeys = (item: ExplainedPost) => [ - ...(item.post.source?.id - ? [getSignalKey(SignalKind.Source, item.post.source.id)] - : []), - ...(item.post.tags ?? []).map((tag) => getSignalKey(SignalKind.Topic, tag)), -]; - -export const FeedTuningProvider = ({ - seed, - children, -}: PropsWithChildren<{ seed?: TuningSeed }>): ReactElement => { - const [levels, setLevels] = useState>( - () => seed?.levels ?? {}, - ); - const [dismissed, setDismissedState] = useState>( - () => - Object.fromEntries((seed?.dismissed ?? []).map((id) => [id, true])), - ); - const [changes, setChanges] = useState([]); - const [focus, setFocus] = useState(null); - const [hoverKey, setHoverKey] = useState(null); - - const value = useMemo((): FeedTuningValue => { - const applyLevel = (key: string, level?: TuneLevel) => - setLevels((current) => { - const next = { ...current }; - if (level) { - next[key] = level; - } else { - delete next[key]; - } - return next; - }); - - return { - levels, - changes, - focus, - hoverKey, - setLevel: (signal, level) => { - const from = levels[signal.key]; - if (from === level) { - return; - } - applyLevel(signal.key, level); - // The hovered row unmounts when blocked, so it never sees mouseleave. - if (level === TuneLevel.Blocked) { - setHoverKey(null); - } - setChanges((current) => [ - ...current, - { type: 'level', signal, from, to: level }, - ]); - }, - setDismissed: (postId, isDismissed) => { - setDismissedState((current) => ({ ...current, [postId]: isDismissed })); - setChanges((current) => [ - ...current, - { type: 'dismiss', postId, isDismissed }, - ]); - }, - undo: () => { - const last = changes[changes.length - 1]; - if (!last) { - return; - } - if (last.type === 'level') { - applyLevel(last.signal.key, last.from); - } else { - setDismissedState((current) => ({ - ...current, - [last.postId]: !last.isDismissed, - })); - } - setChanges(changes.slice(0, -1)); - }, - commit: () => setChanges([]), - setFocus, - setHoverKey, - getLeaveReason: (item) => { - if (dismissed[item.post.id]) { - return { type: 'dismissed' }; - } - const blockedKey = getPostKeys(item).find( - (key) => levels[key] === TuneLevel.Blocked, - ); - if (!blockedKey) { - return undefined; - } - const signal = item.signals.find(({ key }) => key === blockedKey); - return signal ? { type: 'blocked', signal } : undefined; - }, - getBoost: (item) => - getPostKeys(item).reduce((boost, key) => { - if (levels[key] === TuneLevel.More) { - return boost + 1; - } - return levels[key] === TuneLevel.Less ? boost - 1 : boost; - }, 0), - }; - }, [levels, dismissed, changes, focus, hoverKey]); - - return ( - - {children} - - ); -}; - -export const useFeedTuning = (): FeedTuningValue => { - const value = useContext(FeedTuningContext); - if (!value) { - throw new Error('useFeedTuning must be used inside FeedTuningProvider'); - } - return value; -}; - -export const describeChange = (change: TuningChange): string => { - if (change.type === 'dismiss') { - return change.isDismissed - ? 'Got it, fewer posts like this one.' - : 'Post is back in your feed.'; - } - const name = getSignalName(change.signal); - switch (change.to) { - case TuneLevel.More: - return `You'll see more ${name}.`; - case TuneLevel.Less: - return `You'll see less ${name}.`; - case TuneLevel.Blocked: - return `Blocked ${name}.`; - default: - return change.from === TuneLevel.Blocked - ? `Unblocked ${name}.` - : `${name} is back to normal.`; - } -}; diff --git a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx new file mode 100644 index 0000000000..42307e5dde --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx @@ -0,0 +1,407 @@ +import type { PropsWithChildren, ReactElement } from 'react'; +import React, { createContext, useContext, useMemo, useState } from 'react'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import type { ParamKey } from '../policy/params'; +import { defaultParams, formatParam, getParamMeta } from '../policy/params'; +import type { TopicControl } from '../policy/profile'; +import { TopicMode } from '../policy/profile'; +import type { + Decision, + PersonalizationState, + RankResult, +} from '../policy/rank'; +import { rankFeed, SourceMode } from '../policy/rank'; + +interface Change { + message: string; + previous: PersonalizationState; + /** Consecutive changes with the same key, like a slider drag, merge. */ + mergeKey?: string; +} + +interface StoreState { + state: PersonalizationState; + changes: Change[]; + /** How many changes the served page already reflects. */ + refreshedAt: number; + served: Decision[]; + notice?: string; +} + +interface NamedEntity { + id: string; + name: string; +} + +interface PersonalizationValue { + state: PersonalizationState; + pool: Post[]; + /** The page as served at the last refresh. */ + served: Decision[]; + /** The page the current settings would serve. */ + next: RankResult; + pendingCount: number; + lastMessage?: string; + notice?: string; + undo: () => void; + refresh: () => void; + hoverKey: string | null; + setHoverKey: (key: string | null) => void; + setTopicMode: (tag: string, mode?: TopicMode) => void; + setTopicWeight: (tag: string, weight?: number) => void; + setTagFollowed: (tag: string, isFollowed: boolean) => void; + setTagBlocked: (tag: string, isBlocked: boolean) => void; + setSourceFollowed: (source: NamedEntity, isFollowed: boolean) => void; + setSourceMode: (source: NamedEntity, mode?: SourceMode) => void; + setSourceBlocked: (source: NamedEntity, isBlocked: boolean) => void; + setAuthorFollowed: (author: NamedEntity, isFollowed: boolean) => void; + setAuthorBlocked: (author: NamedEntity, isBlocked: boolean) => void; + setParam: (key: ParamKey, value: number) => void; + resetParam: (key: ParamKey) => void; + setLearning: (isLearning: boolean) => void; + setFilling: (isFilling: boolean) => void; + setHidden: (postId: string, isHidden: boolean) => void; + resetLearned: () => void; + resetControls: () => void; + resetParams: () => void; + clearSeen: () => void; + resetAll: () => void; +} + +const PersonalizationContext = createContext( + null, +); + +const toggle = (list: string[], value: string, isOn: boolean): string[] => + isOn + ? [...new Set([...list, value])] + : list.filter((item) => item !== value); + +const setControl = ( + controls: Record, + tag: string, + control: TopicControl, +): Record => { + const next = { ...controls }; + if (control.mode === undefined && control.weight === undefined) { + delete next[tag]; + } else { + next[tag] = control; + } + return next; +}; + +const modeMessage: Record string> = { + [TopicMode.Boosted]: (name) => `You'll see more ${name}.`, + [TopicMode.Demoted]: (name) => `You'll see less ${name}.`, + [TopicMode.Muted]: (name) => `Muted ${name}. Reading about it no longer counts.`, +}; + +export const PersonalizationProvider = ({ + pool, + initialState, + servedState, + children, +}: PropsWithChildren<{ + pool: Post[]; + initialState: PersonalizationState; + /** Serves the page from these settings instead, so changes show as pending. */ + servedState?: PersonalizationState; +}>): ReactElement => { + const [store, setStore] = useState(() => ({ + state: initialState, + changes: [], + refreshedAt: 0, + served: rankFeed(pool, servedState ?? initialState).served, + })); + const [hoverKey, setHoverKey] = useState(null); + const { state, changes, refreshedAt, served, notice } = store; + const next = useMemo(() => rankFeed(pool, state), [pool, state]); + + const value = useMemo((): PersonalizationValue => { + const apply = ( + message: string, + update: (current: PersonalizationState) => PersonalizationState, + mergeKey?: string, + ) => + setStore((current) => { + const last = current.changes[current.changes.length - 1]; + const isMerge = + !!mergeKey && + last?.mergeKey === mergeKey && + current.changes.length > current.refreshedAt; + return { + ...current, + notice: undefined, + state: update(current.state), + changes: [ + ...(isMerge ? current.changes.slice(0, -1) : current.changes), + { + message, + previous: isMerge ? last.previous : current.state, + mergeKey, + }, + ], + }; + }); + const tagName = (tag: string) => `#${tag}`; + const paramMessage = (key: ParamKey, value: number) => { + const meta = getParamMeta(key); + return `${meta.label}: ${formatParam(meta.format, value)}.`; + }; + + return { + state, + pool, + served, + next, + pendingCount: changes.length - refreshedAt, + lastMessage: + changes.length > refreshedAt + ? changes[changes.length - 1].message + : undefined, + notice, + undo: () => + setStore((current) => { + const last = current.changes[current.changes.length - 1]; + if (!last) { + return current; + } + return { + ...current, + state: last.previous, + changes: current.changes.slice(0, -1), + refreshedAt: Math.min( + current.refreshedAt, + current.changes.length - 1, + ), + }; + }), + refresh: () => + setStore((current) => { + const result = rankFeed(pool, current.state); + const servedIds = new Set(current.served.map(({ post }) => post.id)); + const incoming = result.served.filter( + ({ post }) => !servedIds.has(post.id), + ).length; + return { + ...current, + served: result.served, + refreshedAt: current.changes.length, + notice: incoming + ? `Feed refreshed: ${incoming} new ${ + incoming === 1 ? 'post' : 'posts' + } in.` + : 'Feed refreshed with your changes.', + }; + }), + hoverKey, + setHoverKey, + setTopicMode: (tag, mode) => + apply( + mode ? modeMessage[mode](tagName(tag)) : `${tagName(tag)} is back to normal.`, + (current) => ({ + ...current, + controls: setControl(current.controls, tag, { mode }), + }), + ), + setTopicWeight: (tag, weight) => + apply( + weight === undefined + ? `${tagName(tag)} uses its learned weight again.` + : `${tagName(tag)} weight set to ${weight.toFixed(2)}.`, + (current) => ({ + ...current, + controls: setControl(current.controls, tag, { weight }), + }), + `weight:${tag}`, + ), + setTagFollowed: (tag, isFollowed) => + apply( + isFollowed ? `Following ${tagName(tag)}.` : `Unfollowed ${tagName(tag)}.`, + (current) => ({ + ...current, + followedTags: toggle(current.followedTags, tag, isFollowed), + controls: isFollowed + ? setControl(current.controls, tag, { + ...current.controls[tag], + mode: + current.controls[tag]?.mode === TopicMode.Muted + ? undefined + : current.controls[tag]?.mode, + }) + : current.controls, + }), + ), + setTagBlocked: (tag, isBlocked) => + apply( + isBlocked ? `Blocked ${tagName(tag)}.` : `Unblocked ${tagName(tag)}.`, + (current) => ({ + ...current, + blockedTags: toggle(current.blockedTags, tag, isBlocked), + followedTags: isBlocked + ? toggle(current.followedTags, tag, false) + : current.followedTags, + }), + ), + setSourceFollowed: (source, isFollowed) => + apply( + isFollowed ? `Following ${source.name}.` : `Unfollowed ${source.name}.`, + (current) => ({ + ...current, + followedSources: toggle( + current.followedSources, + source.id, + isFollowed, + ), + }), + ), + setSourceMode: (source, mode) => + apply( + mode ? `You'll see less from ${source.name}.` : `${source.name} is back to normal.`, + (current) => { + const sourceControls = { ...current.sourceControls }; + if (mode) { + sourceControls[source.id] = mode; + } else { + delete sourceControls[source.id]; + } + return { ...current, sourceControls }; + }, + ), + setSourceBlocked: (source, isBlocked) => + apply( + isBlocked ? `Blocked ${source.name}.` : `Unblocked ${source.name}.`, + (current) => ({ + ...current, + blockedSources: toggle( + current.blockedSources, + source.id, + isBlocked, + ), + followedSources: isBlocked + ? toggle(current.followedSources, source.id, false) + : current.followedSources, + }), + ), + setAuthorFollowed: (author, isFollowed) => + apply( + isFollowed ? `Following ${author.name}.` : `Unfollowed ${author.name}.`, + (current) => ({ + ...current, + followedAuthors: toggle( + current.followedAuthors, + author.id, + isFollowed, + ), + }), + ), + setAuthorBlocked: (author, isBlocked) => + apply( + isBlocked ? `Blocked ${author.name}.` : `Unblocked ${author.name}.`, + (current) => ({ + ...current, + blockedAuthors: toggle( + current.blockedAuthors, + author.id, + isBlocked, + ), + }), + ), + setParam: (key, paramValue) => + apply( + paramMessage(key, paramValue), + (current) => ({ + ...current, + params: { ...current.params, [key]: paramValue }, + }), + `param:${key}`, + ), + resetParam: (key) => + apply( + `${getParamMeta(key).label} is back to its default.`, + (current) => ({ + ...current, + params: { ...current.params, [key]: defaultParams[key] }, + }), + ), + setLearning: (isLearning) => + apply( + isLearning + ? 'Learning from your reading again.' + : 'Stopped learning from your reading. Only follows count.', + (current) => ({ ...current, isLearning }), + ), + setFilling: (isFillingWithPopular) => + apply( + isFillingWithPopular + ? 'Popular posts fill a short feed again.' + : 'No more popular fill-ins. A short feed stays short.', + (current) => ({ ...current, isFillingWithPopular }), + ), + setHidden: (postId, isHidden) => + apply( + isHidden ? 'Got it, fewer posts like this one.' : 'Post is back in your feed.', + (current) => ({ + ...current, + hidden: toggle(current.hidden, postId, isHidden), + }), + ), + resetLearned: () => + apply( + 'Forgot everything learned from your reading. Follows and blocks stay.', + (current) => ({ ...current, evidence: [], tagShownDays: {} }), + ), + resetControls: () => + apply('Cleared your More, Less, Mute and exact weights.', (current) => ({ + ...current, + controls: {}, + sourceControls: {}, + })), + resetParams: () => + apply('Ranking is back to the defaults.', (current) => ({ + ...current, + params: defaultParams, + isFillingWithPopular: true, + isLearning: true, + })), + clearSeen: () => + apply('Forgot what you scrolled past.', (current) => ({ + ...current, + impressions: {}, + })), + resetAll: () => + apply( + 'Personalization reset. Only your follows, blocks and filters remain.', + (current) => ({ + ...current, + evidence: [], + tagShownDays: {}, + controls: {}, + sourceControls: {}, + params: defaultParams, + isFillingWithPopular: true, + isLearning: true, + impressions: {}, + hidden: [], + }), + ), + }; + }, [state, pool, served, next, changes, refreshedAt, notice, hoverKey]); + + return ( + + {children} + + ); +}; + +export const usePersonalization = (): PersonalizationValue => { + const value = useContext(PersonalizationContext); + if (!value) { + throw new Error( + 'usePersonalization must be used inside PersonalizationProvider', + ); + } + return value; +}; diff --git a/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx new file mode 100644 index 0000000000..a8312d4bab --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx @@ -0,0 +1,293 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; +import type { ParamKey } from '../policy/params'; +import { defaultParams, policyVersion } from '../policy/params'; +import { InterestOrigin, Strength } from '../policy/profile'; +import { usePersonalization } from './PersonalizationContext'; +import { ControlSection } from './FeedScreenContext'; +import { ChangesDock } from './ChangesDock'; +import { PanelCard, PanelSection, sectionId } from './panel/Section'; +import { InterestsSection } from './panel/InterestsSection'; +import { SourcesSection } from './panel/SourcesSection'; +import { RankingSection } from './panel/RankingSection'; +import { ResetSection, SettingsSection } from './panel/SettingsSection'; +import { LeftOutList, NextPageList } from './panel/Preview'; + +const navItems: Array<{ section: ControlSection; label: string }> = [ + { section: ControlSection.Overview, label: 'Overview' }, + { section: ControlSection.Interests, label: 'Interests' }, + { section: ControlSection.Sources, label: 'Sources and people' }, + { section: ControlSection.Ranking, label: 'Ranking' }, + { section: ControlSection.LeftOut, label: 'Left out' }, + { section: ControlSection.Settings, label: 'Your choices' }, + { section: ControlSection.Reset, label: 'Start over' }, +]; + +enum RailTab { + Next = 'next', + LeftOut = 'left-out', +} + +const railQuery = '(min-width: 1020px)'; + +const scrollTo = (section: ControlSection) => + document + .getElementById(sectionId(section)) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + +const Tile = ({ + label, + value, + detail, + onClick, +}: { + label: string; + value: string | number; + detail: string; + onClick: () => void; +}): ReactElement => ( + +); + +const Rail = ({ + tab, + onTabChange, +}: { + tab: RailTab; + onTabChange: (tab: RailTab) => void; +}): ReactElement => { + const { next } = usePersonalization(); + + return ( + +
        + {[ + { id: RailTab.Next, label: 'Your next page' }, + { id: RailTab.LeftOut, label: `Left out (${next.leftOut.length})` }, + ].map(({ id, label }) => ( + + ))} +
        + {tab === RailTab.Next ? : } +
        + ); +}; + +export const PersonalizationPanel = ({ + section, + isCompact, + onBack, +}: { + section?: ControlSection; + isCompact?: boolean; + onBack: () => void; +}): ReactElement => { + const store = usePersonalization(); + const { state, next } = store; + const [railTab, setRailTab] = useState( + section === ControlSection.LeftOut ? RailTab.LeftOut : RailTab.Next, + ); + const hasRail = () => !isCompact && window.matchMedia(railQuery).matches; + const followedCount = next.interests.filter( + ({ origin }) => origin === InterestOrigin.Declared, + ).length; + const learnedCount = next.interests.filter( + ({ origin, weight }) => origin === InterestOrigin.Inferred && weight > 0, + ).length; + const earlyCount = next.interests.filter( + ({ strength }) => strength === Strength.EarlySignal, + ).length; + const changedKnobs = (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length; + + const goTo = (target: ControlSection) => { + if (target === ControlSection.LeftOut && hasRail()) { + setRailTab(RailTab.LeftOut); + window.scrollTo({ top: 0, behavior: 'smooth' }); + return; + } + scrollTo(target); + }; + + useEffect(() => { + if (section && section !== ControlSection.Overview) { + if (section === ControlSection.LeftOut && hasRail()) { + return; + } + document.getElementById(sectionId(section))?.scrollIntoView(); + } + // Only where the panel opened, not on every change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
        +
        + +
        +
        +

        + Personalization +

        +

        + Everything that decides what For You shows you, and every knob + behind it. Ranked by {policyVersion}; your profile + updates within minutes of what you read. +

        +
        + store.setLearning(!state.isLearning)} + compact={false} + > + Learn from my reading + +
        +
        + +
        +
        + +
        + goTo(ControlSection.Interests)} + /> + goTo(ControlSection.Sources)} + /> + goTo(ControlSection.Ranking)} + /> + goTo(ControlSection.LeftOut)} + /> +
        +
        + {isCompact && ( + + + + + )} + + + + {!isCompact && ( + + + + )} + + +
        + {!isCompact && ( + + )} +
        + +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/controls.tsx b/packages/storybook/stories/features/feed-explain/components/controls.tsx new file mode 100644 index 0000000000..837ddec2ae --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/controls.tsx @@ -0,0 +1,166 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; +import { Slider } from '@dailydotdev/shared/src/components/fields/Slider'; +import type { ParamKey } from '../policy/params'; +import { defaultParams, formatParam, getParamMeta } from '../policy/params'; +import { usePersonalization } from './PersonalizationContext'; + +export const Strong = ({ children }: { children: ReactNode }): ReactElement => ( + {children} +); + +export interface SegmentOption { + value: T; + label: string; + activeClassName?: string; +} + +/** One choice out of a few, as a row of pills. */ +export const Segmented = ({ + label, + value, + options, + onChange, + className, +}: { + label: string; + value: T; + options: SegmentOption[]; + onChange: (value: T) => void; + className?: string; +}): ReactElement => ( +
        + {options.map((option) => { + const isActive = option.value === value; + return ( + + ); + })} +
        +); + +export const ToggleRow = ({ + id, + label, + description, + checked, + onToggle, +}: { + id: string; + label: string; + description?: ReactNode; + checked: boolean; + onToggle: () => void; +}): ReactElement => ( +
        + + + {description && ( + {description} + )} + + +
        +); + +/** A labelled slider for one policy knob, with its default and raw key. */ +export const ParamSlider = ({ + paramKey, + isCompact, +}: { + paramKey: ParamKey; + isCompact?: boolean; +}): ReactElement => { + const { state, setParam, resetParam } = usePersonalization(); + const meta = getParamMeta(paramKey); + const value = state.params[paramKey]; + const isDefault = value === defaultParams[paramKey]; + + return ( +
        +
        + + {meta.label} + + + {formatParam(meta.format, value)} + +
        + setParam(paramKey, next)} + /> + {!isCompact && ( + {meta.help} + )} + + {paramKey} + {isDefault ? ( + Default + ) : ( + + )} + +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/feedExplain.ts b/packages/storybook/stories/features/feed-explain/components/feedExplain.ts deleted file mode 100644 index 39b5e5e8d2..0000000000 --- a/packages/storybook/stories/features/feed-explain/components/feedExplain.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { Post } from '@dailydotdev/shared/src/graphql/posts'; -import type { Source } from '@dailydotdev/shared/src/graphql/sources'; - -/** Why the ranker put a post in For You, in the order it checks them. */ -export enum FeedReason { - Following = 'following', - Picked = 'picked', - Reading = 'reading', - Popular = 'popular', -} - -export enum SignalKind { - Topic = 'topic', - Source = 'source', -} - -/** Where a signal came from for this viewer. `Post` means it only appears. */ -export enum SignalOrigin { - Following = 'following', - Picked = 'picked', - Reading = 'reading', - Post = 'post', -} - -/** - * Proposed per-topic and per-source weight. Today's API only has follow and - * block, so `Less` and `More` are new. - */ -export enum TuneLevel { - Less = 'less', - More = 'more', - Blocked = 'blocked', -} - -export interface FeedSignal { - key: string; - kind: SignalKind; - id: string; - label: string; - origin: SignalOrigin; - source?: Source; -} - -export interface ExplainedPost { - post: Post; - reason: FeedReason; - /** The signal the reason sentence names; absent for popular picks. */ - reasonSignal?: FeedSignal; - /** Every topic and the source, matched ones first. */ - signals: FeedSignal[]; -} - -/** What a new viewer told us, or what we inferred, a few days in. */ -export interface ViewerProfile { - pickedTags: string[]; - readingTags: string[]; - followedSources: string[]; -} - -export const getSignalKey = (kind: SignalKind, id: string): string => - `${kind}:${id}`; - -export const getSignalName = (signal: Pick) => - signal.kind === SignalKind.Topic ? `#${signal.label}` : signal.label; - -const originRank: Record = { - [SignalOrigin.Following]: 0, - [SignalOrigin.Picked]: 1, - [SignalOrigin.Reading]: 2, - [SignalOrigin.Post]: 3, -}; - -const reasonByOrigin: Partial> = { - [SignalOrigin.Following]: FeedReason.Following, - [SignalOrigin.Picked]: FeedReason.Picked, - [SignalOrigin.Reading]: FeedReason.Reading, -}; - -export const explainPost = ( - post: Post, - profile: ViewerProfile, -): ExplainedPost => { - const getTagOrigin = (tag: string): SignalOrigin => { - if (profile.pickedTags.includes(tag)) { - return SignalOrigin.Picked; - } - return profile.readingTags.includes(tag) - ? SignalOrigin.Reading - : SignalOrigin.Post; - }; - const topics = (post.tags ?? []).map( - (tag): FeedSignal => ({ - key: getSignalKey(SignalKind.Topic, tag), - kind: SignalKind.Topic, - id: tag, - label: tag, - origin: getTagOrigin(tag), - }), - ); - const { source } = post; - const sourceSignal: FeedSignal[] = source?.id - ? [ - { - key: getSignalKey(SignalKind.Source, source.id), - kind: SignalKind.Source, - id: source.id, - label: source.name ?? source.id, - origin: profile.followedSources.includes(source.id) - ? SignalOrigin.Following - : SignalOrigin.Post, - source, - }, - ] - : []; - const signals = [...sourceSignal, ...topics].sort( - (a, b) => originRank[a.origin] - originRank[b.origin], - ); - const reasonSignal = signals.find( - ({ origin }) => origin !== SignalOrigin.Post, - ); - - return { - post, - reason: reasonSignal - ? reasonByOrigin[reasonSignal.origin] ?? FeedReason.Popular - : FeedReason.Popular, - reasonSignal, - signals, - }; -}; - -/** - * The rows a card has room for: matched topics, then the post's other - * topics, and the source last so it can always be blocked. - */ -export const getCardSignals = ( - item: ExplainedPost, - limit: number, -): FeedSignal[] => { - const source = item.signals.find(({ kind }) => kind === SignalKind.Source); - const topics = item.signals.filter(({ kind }) => kind === SignalKind.Topic); - const isSourceLed = source && source.origin !== SignalOrigin.Post; - - if (!source) { - return topics.slice(0, limit); - } - - return isSourceLed - ? [source, ...topics.slice(0, limit - 1)] - : [...topics.slice(0, limit - 1), source]; -}; - -interface ReasonMeta { - label: string; - dotClassName: string; -} - -export const reasonMeta: Record = { - [FeedReason.Picked]: { - label: 'Topics you picked', - dotClassName: 'bg-accent-cabbage-default', - }, - [FeedReason.Reading]: { - label: 'Like what you read', - dotClassName: 'bg-accent-blueCheese-default', - }, - [FeedReason.Following]: { - label: 'Sources you follow', - dotClassName: 'bg-accent-water-default', - }, - [FeedReason.Popular]: { - label: 'Popular picks', - dotClassName: 'bg-accent-cheese-default', - }, -}; - -export const reasonOrder: FeedReason[] = [ - FeedReason.Picked, - FeedReason.Reading, - FeedReason.Following, - FeedReason.Popular, -]; - -export const originLabel: Record = { - [SignalOrigin.Following]: 'You follow it', - [SignalOrigin.Picked]: 'You picked it', - [SignalOrigin.Reading]: 'From your reading', - [SignalOrigin.Post]: '', -}; - -export const levelLabel: Record = { - [TuneLevel.More]: 'Showing more', - [TuneLevel.Less]: 'Showing less', - [TuneLevel.Blocked]: 'Blocked', -}; - -export const levelTextClassName: Record = { - [TuneLevel.More]: 'text-accent-avocado-default', - [TuneLevel.Less]: 'text-accent-bun-default', - [TuneLevel.Blocked]: 'text-accent-ketchup-default', -}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx new file mode 100644 index 0000000000..2442291a23 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx @@ -0,0 +1,518 @@ +import type { ReactElement } from 'react'; +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Slider } from '@dailydotdev/shared/src/components/fields/Slider'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { HashtagIcon } from '@dailydotdev/shared/src/components/icons/Hashtag'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import type { Interest, TopicEvidence } from '../../policy/profile'; +import { + InterestOrigin, + Strength, + strengthLabel, + strengthRule, + TopicMode, +} from '../../policy/profile'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { Segmented } from '../controls'; +import { Badge, PanelCard, PanelSection } from './Section'; + +enum RowStatus { + Followed = 'followed', + Learned = 'learned', + Early = 'early', + Muted = 'muted', + Blocked = 'blocked', +} + +enum Filter { + All = 'all', + Followed = 'followed', + Learned = 'learned', + Early = 'early', + Off = 'off', +} + +interface ProfileRow { + tag: string; + status: RowStatus; + interest?: Interest; + evidence?: TopicEvidence; +} + +const statusBadge: Record = { + [RowStatus.Followed]: { + label: 'You follow', + className: 'bg-accent-cabbage-flat text-accent-cabbage-default', + }, + [RowStatus.Learned]: { + label: 'Learned', + className: 'bg-accent-blueCheese-flat text-accent-blueCheese-default', + }, + [RowStatus.Early]: { + label: 'Not counted yet', + className: 'bg-surface-float text-text-tertiary', + }, + [RowStatus.Muted]: { + label: 'Muted', + className: 'bg-accent-ketchup-flat text-accent-ketchup-default', + }, + [RowStatus.Blocked]: { + label: 'Blocked', + className: 'bg-accent-ketchup-flat text-accent-ketchup-default', + }, +}; + +const filterStatuses: Record = { + [Filter.All]: Object.values(RowStatus), + [Filter.Followed]: [RowStatus.Followed], + [Filter.Learned]: [RowStatus.Learned], + [Filter.Early]: [RowStatus.Early], + [Filter.Off]: [RowStatus.Muted, RowStatus.Blocked], +}; + +const maxWeight = 1.5; + +const formatDays = (days: number): string => { + if (days < 1) { + return 'today'; + } + return days === 1 ? 'yesterday' : `${days} days ago`; +}; + +const WeightBar = ({ interest }: { interest?: Interest }): ReactElement => { + const weight = interest?.weight ?? 0; + const computed = interest?.computedWeight ?? 0; + const isAdjusted = !!interest?.control && weight !== computed; + + return ( + + + + {isAdjusted && ( + + )} + + + {weight.toFixed(2)} + + + ); +}; + +const Formula = ({ row }: { row: ProfileRow }): ReactElement => { + const { state } = usePersonalization(); + const { params } = state; + const { interest } = row; + if (!interest || row.status === RowStatus.Blocked) { + return ( +

        + Blocked topics are not interests. Posts with this topic never show. +

        + ); + } + const recency = interest.recency.toFixed(2); + let base: string; + if (row.status === RowStatus.Early) { + base = `1 story so far. It starts counting at 2 stories, or right away if you follow it.`; + } else if (interest.origin === InterestOrigin.Declared) { + const boostKey = interest.strength + ? `interests.declared_boost.${interest.strength}` + : undefined; + base = + interest.strength && boostKey && interest.strength !== Strength.EarlySignal + ? `${params['interests.base_weight'].toFixed(2)} for following + ${( + params[boostKey as 'interests.declared_boost.strong'] ?? 0 + ).toFixed(2)} (${strengthLabel[interest.strength].toLowerCase()}) × ${recency} freshness = ${interest.computedWeight.toFixed(2)}` + : `${params['interests.base_weight'].toFixed(2)} for following. Reading about it adds more.`; + } else { + const weightKey = `interests.inferred_weight.${interest.strength}` as + | 'interests.inferred_weight.strong' + | 'interests.inferred_weight.moderate'; + base = `${(params[weightKey] ?? 0).toFixed(2)} (${strengthLabel[ + interest.strength ?? Strength.Moderate + ].toLowerCase()}) × ${recency} freshness = ${interest.computedWeight.toFixed(2)}`; + } + const { control } = interest; + let adjustment: string | undefined; + if (control?.weight !== undefined) { + adjustment = `You set it to ${control.weight.toFixed(2)}.`; + } else if (control?.mode === TopicMode.Boosted) { + adjustment = `× ${params['interests.control_boost'].toFixed(2)} for More = ${interest.weight.toFixed(2)}`; + } else if (control?.mode === TopicMode.Demoted) { + adjustment = `× ${params['interests.control_demote'].toFixed(2)} for Less = ${interest.weight.toFixed(2)}`; + } else if (control?.mode === TopicMode.Muted) { + adjustment = 'Muted, so it counts as 0.'; + } + + return ( +

        + {base} + {adjustment && {adjustment}} +

        + ); +}; + +const RowDetail = ({ row }: { row: ProfileRow }): ReactElement => { + const store = usePersonalization(); + const { interest } = row; + const isFollowed = row.status === RowStatus.Followed; + const isBlocked = row.status === RowStatus.Blocked; + + return ( +
        + + {interest && !isBlocked && ( +
        + + + Exact weight + + + {interest.weight.toFixed(2)} + + + store.setTopicWeight(row.tag, value)} + /> + + A post whose main topic is #{row.tag} gets × + {(1 + interest.weight).toFixed(2)}; as a side topic, × + {( + 1 + + interest.weight * store.state.params['relevance.side_topic_weight'] + ).toFixed(2)} + . + +
        + )} +
        + {!isBlocked && ( + + )} + + {interest?.control && ( + + )} +
        +
        + ); +}; + +const ProfileRowItem = ({ + row, + onScreen, + isCompact, +}: { + row: ProfileRow; + onScreen: number; + isCompact?: boolean; +}): ReactElement => { + const store = usePersonalization(); + const [isOpen, setIsOpen] = useState(false); + const { interest, evidence } = row; + const badge = statusBadge[row.status]; + const isOff = row.status === RowStatus.Blocked; + const mode = store.state.controls[row.tag]?.mode; + const facts = [ + evidence + ? `${evidence.stories} ${evidence.stories === 1 ? 'story' : 'stories'}` + : 'No reading yet', + evidence?.strongStories + ? `${evidence.strongStories} upvoted, saved or shared` + : undefined, + evidence ? `last ${formatDays(evidence.daysSinceLastAction)}` : undefined, + interest?.evidence ? `${Math.round(interest.recency * 100)}% fresh` : undefined, + onScreen ? `in ${onScreen} of your next 12` : undefined, + ].filter(Boolean); + + return ( +
      • +
        + +
        + + {!isOff && ( + store.setTopicMode(row.tag, value)} + options={[ + ...(row.status === RowStatus.Followed + ? [] + : [ + { + value: TopicMode.Muted, + label: 'Mute', + activeClassName: 'text-accent-ketchup-default', + }, + ]), + { + value: TopicMode.Demoted, + label: 'Less', + activeClassName: 'text-accent-bun-default', + }, + { value: undefined, label: 'Auto' }, + { + value: TopicMode.Boosted, + label: 'More', + activeClassName: 'text-accent-avocado-default', + }, + ]} + /> + )} +
        +
        + {isOpen && } +
      • + ); +}; + +const filterLabels: Record = { + [Filter.All]: 'All', + [Filter.Followed]: 'Followed', + [Filter.Learned]: 'Learned', + [Filter.Early]: 'Not counted yet', + [Filter.Off]: 'Muted and blocked', +}; + +export const InterestsSection = ({ + isCompact, +}: { + isCompact?: boolean; +}): ReactElement => { + const store = usePersonalization(); + const { state, next, pool } = store; + const [filter, setFilter] = useState(Filter.All); + const rows = useMemo((): ProfileRow[] => { + const fromInterests = next.interests.map((interest): ProfileRow => { + let status = RowStatus.Learned; + if (interest.control?.mode === TopicMode.Muted) { + status = RowStatus.Muted; + } else if (interest.origin === InterestOrigin.Declared) { + status = RowStatus.Followed; + } else if (interest.strength === Strength.EarlySignal) { + status = RowStatus.Early; + } + return { + tag: interest.tag, + status, + interest, + evidence: state.evidence.find(({ topic }) => topic === interest.tag), + }; + }); + const blocked = state.blockedTags.map( + (tag): ProfileRow => ({ + tag, + status: RowStatus.Blocked, + evidence: state.evidence.find(({ topic }) => topic === tag), + }), + ); + const order = Object.values(RowStatus); + return [...fromInterests, ...blocked].sort( + (a, b) => + order.indexOf(a.status) - order.indexOf(b.status) || + (b.interest?.weight ?? 0) - (a.interest?.weight ?? 0), + ); + }, [next.interests, state.blockedTags, state.evidence]); + const onScreen = (tag: string) => + next.served.filter(({ post }) => post.tags?.includes(tag)).length; + const suggestions = useMemo(() => { + const known = new Set(rows.map(({ tag }) => tag)); + const counts = new Map(); + pool.forEach(({ tags }) => + (tags ?? []).forEach((tag) => counts.set(tag, (counts.get(tag) ?? 0) + 1)), + ); + return [...counts.entries()] + .filter(([tag]) => !known.has(tag)) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([tag]) => tag); + }, [pool, rows]); + const visible = rows.filter(({ status }) => + filterStatuses[filter].includes(status), + ); + const countFor = (value: Filter) => + rows.filter(({ status }) => filterStatuses[value].includes(status)).length; + + return ( + + {!state.isLearning && ( +

        + Learning from reading is off, so only followed topics count right now. +

        + )} +
        + {Object.values(Filter).map((value) => ( + + ))} +
        +
          + {visible.map((row) => ( + + ))} + {!visible.length && ( +
        • + Nothing here. +
        • + )} +
        + + + Follow a topic + + + Topics in posts you could see, most common first. + +
        + {suggestions.map((tag) => ( + + ))} +
        +
        +
        + {Object.values(Strength).map((strength) => ( +
        +
        + {strengthLabel[strength]} +
        +
        {strengthRule[strength]}
        +
        + ))} +
        +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx b/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx new file mode 100644 index 0000000000..1168361a85 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx @@ -0,0 +1,323 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; +import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; +import type { Decision } from '../../policy/rank'; +import { Exclusion, getMainTopic } from '../../policy/rank'; +import { + exclusionLabel, + getCategory, + getCategoryDot, + getFactorChips, +} from '../../policy/describe'; +import { defaultParams } from '../../policy/params'; +import { usePersonalization } from '../PersonalizationContext'; +import { useFeedScreen } from '../FeedScreenContext'; +import { Badge } from './Section'; + +const Movement = ({ decision }: { decision: Decision }): ReactElement | null => { + const { served } = usePersonalization(); + const before = served.find(({ post }) => post.id === decision.post.id); + if (!before) { + return ( + + New + + ); + } + const delta = (before.position ?? 0) - (decision.position ?? 0); + if (!delta) { + return null; + } + return ( + 0 ? 'text-accent-avocado-default' : 'text-accent-bun-default', + )} + > + 0 ? undefined : 'rotate-180'} + /> + {Math.abs(delta)} + + ); +}; + +const PostLine = ({ + decision, + meta, + aside, + action, +}: { + decision: Decision; + meta: ReactNode; + aside?: ReactNode; + action?: ReactNode; +}): ReactElement => ( +
      • + + + + {decision.post.title} + + + {meta} + + {action} + + {aside} +
      • +); + +export const NextPageList = ({ + limit, +}: { + limit?: number; +}): ReactElement => { + const { next, served } = usePersonalization(); + const { onOpenDetails } = useFeedScreen(); + const servedIds = new Set(served.map(({ post }) => post.id)); + const incoming = next.served.filter(({ post }) => !servedIds.has(post.id)); + const decisions = limit ? next.served.slice(0, limit) : next.served; + + return ( +
        +

        + {incoming.length + ? `If you refreshed now, ${incoming.length} new ${ + incoming.length === 1 ? 'post comes' : 'posts come' + } in.` + : 'If you refreshed now, the same posts would show.'} +

        +
          + {decisions.map((decision) => { + const chip = getFactorChips(decision)[0]; + return ( + + + #{decision.position} + + + + {chip?.label} + + {onOpenDetails && ( + + )} + + } + aside={} + /> + ); + })} +
        +
        + ); +}; + +const exclusionOrder = [ + Exclusion.BelowThePage, + Exclusion.NoInterestOrFollow, + Exclusion.SideTopicUnproven, + Exclusion.SideTopicsOff, + Exclusion.BlockedTag, + Exclusion.BlockedSource, + Exclusion.BlockedAuthor, + Exclusion.Hidden, + Exclusion.ShownTooOften, + Exclusion.TooOld, +]; + +const exclusionHelp: Partial> = { + [Exclusion.BelowThePage]: + 'Eligible, but other posts scored higher. Freshness and variety decide most of these.', + [Exclusion.NoInterestOrFollow]: + "A post needs a topic you follow or read, or a source you follow. These don't have one.", + [Exclusion.SideTopicUnproven]: + 'Your topic is only a side topic here, and readers have not responded to it enough yet.', + [Exclusion.ShownTooOften]: 'On your screen too many times without a click.', +}; + +const LeftOutFix = ({ decision }: { decision: Decision }): ReactElement | null => { + const store = usePersonalization(); + const { post } = decision; + const source = { id: post.source?.id ?? '', name: post.source?.name ?? '' }; + const mainTopic = getMainTopic(post); + const link = (label: string, run: () => void) => ( + + ); + + switch (decision.exclusion) { + case Exclusion.NoInterestOrFollow: + case Exclusion.SideTopicUnproven: + return mainTopic + ? link(`Follow #${mainTopic}`, () => + store.setTagFollowed(mainTopic, true), + ) + : null; + case Exclusion.BlockedTag: + return link(`Unblock #${decision.exclusionTag}`, () => + store.setTagBlocked(decision.exclusionTag ?? '', false), + ); + case Exclusion.BlockedSource: + return link(`Unblock ${source.name}`, () => + store.setSourceBlocked(source, false), + ); + case Exclusion.Hidden: + return link('Show it again', () => store.setHidden(post.id, false)); + case Exclusion.SideTopicsOff: + return link('Turn side topics back on', () => + store.setParam( + 'relevance.side_topic_weight', + defaultParams['relevance.side_topic_weight'], + ), + ); + default: + return null; + } +}; + +const LeftOutGroup = ({ + exclusion, + decisions, +}: { + exclusion: Exclusion; + decisions: Decision[]; +}): ReactElement => { + const store = usePersonalization(); + const [isOpen, setIsOpen] = useState(exclusion !== Exclusion.BelowThePage); + const shown = isOpen ? decisions.slice(0, 6) : []; + + return ( +
      • + + {isOpen && ( +
          + {shown.map((decision) => ( + + {exclusion === Exclusion.BelowThePage + ? `Ranked #${decision.eligibleRank} before page variety` + : (decision.post.tags ?? []) + .slice(0, 3) + .map((tag) => `#${tag}`) + .join(' ')} + + } + action={} + /> + ))} + {decisions.length > shown.length && ( +
        • + And {decisions.length - shown.length} more +
        • + )} + {exclusion === Exclusion.ShownTooOften && ( +
        • + +
        • + )} +
        + )} +
      • + ); +}; + +export const LeftOutList = (): ReactElement => { + const { next } = usePersonalization(); + const groups = exclusionOrder + .map((exclusion) => ({ + exclusion, + decisions: next.leftOut.filter((decision) => decision.exclusion === exclusion), + })) + .filter(({ decisions }) => decisions.length); + + return ( +
        +

        + {next.leftOut.length} posts were considered and left out. Each group + says why. +

        +
          + {groups.map(({ exclusion, decisions }) => ( + + ))} +
        +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx new file mode 100644 index 0000000000..e94d23a2d3 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx @@ -0,0 +1,209 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; +import { CopyIcon } from '@dailydotdev/shared/src/components/icons/Copy'; +import type { ParamKey } from '../../policy/params'; +import { + defaultParams, + ParamGroup, + paramGroups, + paramMeta, + policyVersion, + toRetrieverConfig, +} from '../../policy/params'; +import { challengerRetrieverConfig } from '../../policy/challengerConfig'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { ParamSlider, ToggleRow } from '../controls'; +import { PanelCard, PanelSection } from './Section'; + +const fixedKnobs = [ + { + title: 'Placement coin', + text: 'Two nearly tied cards swap places on a fair coin, so the team can measure how much position alone matters. It only reorders cards already picked.', + }, + { + title: 'Reader response math', + text: "How a post's upvotes, comments, saves and shares per view become its base score, and when a post is disliked enough to drop. It is about other readers, not you.", + }, + { + title: 'Title rules', + text: 'Posts with unusable titles, like "Untitled" or cookie banners, never show.', + }, +]; + +const ConfigView = (): ReactElement => { + const { state } = usePersonalization(); + const changed = (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ); + const json = JSON.stringify( + toRetrieverConfig(state.params, challengerRetrieverConfig), + null, + 2, + ); + + return ( +
        +
        + + {changed.length + ? `Changed: ${changed.join(', ')}` + : 'Everything is at the default.'} + + +
        +
        +        {json}
        +      
        +
        + ); +}; + +export const RankingSection = (): ReactElement => { + const store = usePersonalization(); + const { state } = store; + const [isAdvanced, setIsAdvanced] = useState(false); + const [isConfigOpen, setIsConfigOpen] = useState(false); + const changedCount = + (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length + + (state.isLearning ? 0 : 1) + + (state.isFillingWithPopular ? 0 : 1); + + return ( + + The knobs of {policyVersion}, the For You policy. A + post's score is reader response × topic match × follow × + freshness × seen before; each page then mixes topics and sources and + gives waiting interests a turn. + + } + actions={ +
        + setIsAdvanced(!isAdvanced)} + > + Advanced + + {changedCount > 0 && ( + + )} +
        + } + > +
        + {paramGroups.map(({ group, title, description }) => { + const knobs = paramMeta.filter( + (meta) => meta.group === group && (isAdvanced || !meta.isAdvanced), + ); + return ( + +
        +

        + {title} +

        +

        {description}

        +
        + {group === ParamGroup.Learning && ( + store.setLearning(!state.isLearning)} + /> + )} + {group === ParamGroup.Scope && ( + store.setFilling(!state.isFillingWithPopular)} + /> + )} + {knobs.map(({ key }) => ( + + ))} +
        + ); + })} +
        + + + {isConfigOpen && } + +
        +

        + Not adjustable +

        +
          + {fixedKnobs.map(({ title, text }) => ( +
        • + + {title} + + {text} +
        • + ))} +
        +
        +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx b/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx new file mode 100644 index 0000000000..ceff3ddffa --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx @@ -0,0 +1,81 @@ +import type { ReactElement, ReactNode } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import type { ControlSection } from '../FeedScreenContext'; + +export const sectionId = (section: ControlSection): string => + `personalization-${section}`; + +export const PanelSection = ({ + section, + title, + description, + actions, + children, + className, +}: { + section: ControlSection; + title: string; + description?: ReactNode; + actions?: ReactNode; + children: ReactNode; + className?: string; +}): ReactElement => ( +
        +
        +
        +

        + {title} +

        + {description && ( +

        + {description} +

        + )} +
        + {actions} +
        + {children} +
        +); + +export const PanelCard = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement => ( +
        + {children} +
        +); + +export const Badge = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}): ReactElement => ( + + {children} + +); diff --git a/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx new file mode 100644 index 0000000000..e4007d69ee --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx @@ -0,0 +1,240 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { settingsUrl, webappUrl } from '@dailydotdev/shared/src/lib/constants'; +import type { ParamKey } from '../../policy/params'; +import { defaultParams } from '../../policy/params'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { PanelCard, PanelSection } from './Section'; + +const LinkRow = ({ + title, + summary, + href, +}: { + title: string; + summary: ReactNode; + href: string; +}): ReactElement => ( +
      • + + + {title} + + {summary} + + + + +
      • +); + +const list = (items: string[], empty: string): string => + items.length ? items.join(', ') : empty; + +/** Where the explicit choices already live; this page only links to them. */ +export const SettingsSection = (): ReactElement => { + const { state } = usePersonalization(); + const blockedCount = + state.blockedTags.length + + state.blockedSources.length + + state.blockedAuthors.length; + + return ( + + +
          + `#${tag}`), + 'None yet', + )} + href={`${settingsUrl}/feed/tags`} + /> + + + `#${tag}`), + ...state.blockedSources, + ], + 'Topics, sources, people and title words you blocked', + )} + href={`${settingsUrl}/feed/blocked`} + /> + + + +
        +
        +
        + ); +}; + +const ResetRow = ({ + title, + description, + isDisabled, + onReset, + isDanger, +}: { + title: string; + description: string; + isDisabled?: boolean; + onReset: () => void; + isDanger?: boolean; +}): ReactElement => { + const [isConfirming, setIsConfirming] = useState(false); + + return ( +
      • + + {title} + {description} + + {isConfirming ? ( + + + + + ) : ( + + )} +
      • + ); +}; + +export const ResetSection = (): ReactElement => { + const store = usePersonalization(); + const { state } = store; + const learned = state.evidence.filter( + ({ topic }) => !state.followedTags.includes(topic), + ).length; + const adjustments = + Object.keys(state.controls).length + Object.keys(state.sourceControls).length; + const changedKnobs = (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length; + const seen = Object.keys(state.impressions).length; + + return ( + + +
          + + + + + +
        +
        +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx b/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx new file mode 100644 index 0000000000..0407f983c7 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx @@ -0,0 +1,203 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; +import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; +import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; +import type { Source } from '@dailydotdev/shared/src/graphql/sources'; +import { SourceMode } from '../../policy/rank'; +import { formatMultiplier } from '../FactorChips'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { Badge, PanelCard, PanelSection } from './Section'; + +const SourceRow = ({ source }: { source: Source }): ReactElement => { + const store = usePersonalization(); + const { state } = store; + const entity = { id: source.id, name: source.name ?? source.id }; + const isBlocked = state.blockedSources.includes(source.id); + const isDemoted = state.sourceControls[source.id] === SourceMode.Demoted; + const onScreen = store.next.served.filter( + ({ post }) => post.source?.id === source.id, + ).length; + let effect = formatMultiplier(state.params['follows.source_multiplier']); + if (isDemoted) { + effect = `${effect} × ${state.params['interests.control_demote'].toFixed(2)} for Less`; + } + + return ( +
      • + + + + + {source.name} + + {isBlocked ? ( + + Blocked + + ) : ( + + {effect} + + )} + + + {isBlocked + ? 'None of its posts show.' + : `Every post from it comes in. ${ + onScreen ? `${onScreen} in your next 12.` : '' + }`} + + + + {isBlocked ? ( + + ) : ( + <> + + + + + )} + +
      • + ); +}; + +export const SourcesSection = (): ReactElement => { + const store = usePersonalization(); + const { state, pool } = store; + const sourceById = useMemo(() => { + const map = new Map(); + pool.forEach(({ source }) => { + if (source?.id) { + map.set(source.id, source as Source); + } + }); + return map; + }, [pool]); + const followed = state.followedSources + .map((id) => sourceById.get(id)) + .filter((source): source is Source => !!source); + const blocked = state.blockedSources + .map((id) => sourceById.get(id)) + .filter((source): source is Source => !!source); + const suggestions = useMemo(() => { + const counts = new Map(); + pool.forEach(({ source }) => { + if (source?.id) { + counts.set(source.id, (counts.get(source.id) ?? 0) + 1); + } + }); + return [...counts.entries()] + .filter( + ([id]) => + !state.followedSources.includes(id) && + !state.blockedSources.includes(id), + ) + .sort((a, b) => b[1] - a[1]) + .slice(0, 6) + .map(([id]) => sourceById.get(id)) + .filter((source): source is Source => !!source); + }, [pool, sourceById, state.followedSources, state.blockedSources]); + + return ( + + +
          + {[...followed, ...blocked].map((source) => ( + + ))} + {!followed.length && !blocked.length && ( +
        • + You don't follow or block any source yet. +
        • + )} +
        • + {state.followedAuthors.length + ? `${state.followedAuthors.length} people you follow, ${formatMultiplier( + state.params['follows.author_multiplier'], + )} each.` + : `No people followed. Following a writer multiplies their posts by ${formatMultiplier( + state.params['follows.author_multiplier'], + )}.`} +
        • +
        +
        + {suggestions.length > 0 && ( +
        + + Sources in posts you could see, most common first. + +
        + {suggestions.map((source) => ( + + ))} +
        +
        + )} +
        + ); +}; diff --git a/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts b/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts deleted file mode 100644 index b036e4354f..0000000000 --- a/packages/storybook/stories/features/feed-explain/feedExplain.mocks.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { fullPost } from '../../history/posts'; -import type { ExplainedPost, ViewerProfile } from './components/feedExplain'; -import { explainPost } from './components/feedExplain'; - -/** - * A viewer a few days in: three topics picked at sign-up, two more inferred - * from what they opened, and one source followed. The posts are real ones - * from the /history story, so every title, source and tag is what the API - * returned. - */ -export const newViewer: ViewerProfile = { - pickedTags: ['webdev', 'react', 'ai'], - readingTags: ['rust', 'docker'], - followedSources: ['fireship'], -}; - -const explain = (ids: string[]): ExplainedPost[] => - ids.map((id) => explainPost(fullPost(id), newViewer)); - -export const forYouFeed = explain([ - '3wLIGsLJr', - 'Hd2eBXFkz', - 'XCmmeq8ZC', - 'BM2onQaux', - 'VNv8IMYss', - 'oBDUfGZ7N', - 'j4duNN9S2', - 'RFRxDV6yP', - 'DVZBShH7e', - 'DFJTpz2Tw', - 'IGmufOLb8', - 'AzxnBHN3V', - '9tLNItvh5', - 'CrehqCUVO', - '8YmexVcM0', - 'PFMO5JfPF', -]); - -/** What a refresh can bring in, best ranked first. */ -export const reserveFeed = explain([ - 'BjYAz50EA', - '4I7VllOCg', - 'Rvo54Shm1', - '7p9nRlTLf', - 'hsSp9jc9T', - 'Lfny6jDqe', - 'jvlaswdix', - '8eOv3hbFq', - 'B2K4Imgfa', - 'tbbNjYj52', -]); - -const byId = new Map( - [...forYouFeed, ...reserveFeed].map((item) => [item.post.id, item]), -); - -export const feedItem = (id: string): ExplainedPost => { - const item = byId.get(id); - if (!item) { - throw new Error(`No feed item ${id}`); - } - return item; -}; diff --git a/packages/storybook/stories/features/feed-explain/personas.ts b/packages/storybook/stories/features/feed-explain/personas.ts new file mode 100644 index 0000000000..768fdb4761 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/personas.ts @@ -0,0 +1,107 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { fullPost } from '../../history/posts'; +import postPages from '../../history/postPages.json'; +import { defaultParams } from './policy/params'; +import type { PersonalizationState } from './policy/rank'; + +/** + * Every article and video from the /history story: real titles, sources, + * tags, reactions and views as the API returned them. The policy picks the + * For You page from these. + */ +export const candidatePool: Post[] = Object.keys(postPages) + .map(fullPost) + .filter(({ type }) => type === 'article' || type === 'video:youtube'); + +export enum PersonaId { + TwoWeeksIn = 'two_weeks_in', + JustSignedUp = 'just_signed_up', +} + +export interface Persona { + id: PersonaId; + label: string; + description: string; + state: PersonalizationState; +} + +const baseState: PersonalizationState = { + followedTags: [], + blockedTags: [], + followedSources: [], + blockedSources: [], + followedAuthors: [], + blockedAuthors: [], + evidence: [], + isLearning: true, + controls: {}, + sourceControls: {}, + params: defaultParams, + isFillingWithPopular: true, + hidden: [], + impressions: {}, + tagShownDays: {}, +}; + +export const personas: Record = { + [PersonaId.TwoWeeksIn]: { + id: PersonaId.TwoWeeksIn, + label: 'Two weeks in', + description: + 'Followed #webdev, #react and #ai at sign-up, then read a lot about careers and some Rust and Docker.', + state: { + ...baseState, + followedTags: ['webdev', 'react', 'ai'], + followedSources: ['fireship'], + evidence: [ + { topic: 'ai', stories: 7, strongStories: 2, daysSinceLastAction: 1 }, + { + topic: 'career', + stories: 6, + strongStories: 1, + daysSinceLastAction: 2, + }, + { + topic: 'ai-coding', + stories: 4, + strongStories: 0, + daysSinceLastAction: 3, + }, + { + topic: 'webdev', + stories: 3, + strongStories: 0, + daysSinceLastAction: 4, + }, + { topic: 'rust', stories: 3, strongStories: 1, daysSinceLastAction: 6 }, + { + topic: 'docker', + stories: 2, + strongStories: 0, + daysSinceLastAction: 12, + }, + { + topic: 'productivity', + stories: 2, + strongStories: 0, + daysSinceLastAction: 30, + }, + { topic: 'react', stories: 1, strongStories: 0, daysSinceLastAction: 9 }, + { topic: 'linux', stories: 1, strongStories: 0, daysSinceLastAction: 5 }, + { topic: 'css', stories: 1, strongStories: 0, daysSinceLastAction: 20 }, + ], + impressions: { DVZBShH7e: 2, KfPEpQdOl: 1, mGfmauFls: 6 }, + tagShownDays: { ai: 0.3, career: 0.5, webdev: 1 }, + }, + }, + [PersonaId.JustSignedUp]: { + id: PersonaId.JustSignedUp, + label: 'Just signed up', + description: + 'Picked #react and #rust in onboarding and has not read anything yet.', + state: { + ...baseState, + followedTags: ['react', 'rust'], + }, + }, +}; diff --git a/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts b/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts new file mode 100644 index 0000000000..89dec53fca --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/challengerConfig.ts @@ -0,0 +1,82 @@ +/** `retriever_config` from daily-feed's `feed_configs/challenger_v2.json`. */ +export const challengerRetrieverConfig = { + policy_version: 'challenger/5.0', + feed: { + page_size: 12, + pages: 2, + first_screen_posts: 8, + max_candidate_age_days: 60, + }, + interests: { + base_weight: 0.3, + declared_boost: { + confirmed: 0.25, + early_signal: 0, + moderate: 0.4, + strong: 1, + }, + inferred_weight: { + confirmed: 0.4, + early_signal: 0, + moderate: 0.5, + strong: 0.85, + }, + inferred_recency_half_life_days: 25, + inferred_recency_floor: 0.2, + control_boost: 1.15, + control_demote: 0.8, + }, + relevance: { + main_topic_weight: 1, + side_topic_weight: 0.56, + }, + engagement: { + proof_viewers: 200, + caution_sd: 2, + lift_floor: 0.02, + disliked_below: 0.75, + reaction_weights: { + comment: 2, + downvote: -1.5, + open: 1, + report: -2, + save: 2, + share: 2, + upvote: 2, + }, + prior: 'source', + source_prior_viewers: 2000, + evidence: { + proof_viewers: 2000, + caution_sd: 2, + lift_floor: 0.2, + }, + }, + age: { + half_life_days: 7, + max_stretch: 2, + }, + follows: { + source_multiplier: 1.7, + author_multiplier: 1.6, + }, + history: { + max_impressions_without_action: 6, + seen_factor_per_show: 0.69, + }, + diversity: { + topic_repeat_discount: 0.15, + topic_similarity_floor: 0.35, + source_repeat_discount: 0.09, + same_headline_discount: 0.175, + same_headline_jaccard: 0.35, + same_headline_min_tokens: 3, + waiting_interest_lift: 1, + waiting_half_life_days: 3, + }, + placement: { + share: 1, + near_tie_ratio: 0.95, + swap_probability: 0.5, + }, +}; diff --git a/packages/storybook/stories/features/feed-explain/policy/describe.ts b/packages/storybook/stories/features/feed-explain/policy/describe.ts new file mode 100644 index 0000000000..85e12b7e94 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/describe.ts @@ -0,0 +1,279 @@ +import type { Decision, PersonalizationState } from './rank'; +import { Exclusion, FollowKind, Lane, TopicRole } from './rank'; +import { InterestOrigin, Strength, TopicMode } from './profile'; +import type { Interest } from './profile'; + +/** What put a card on the page, for the mix bar and the reason dot. */ +export enum ReasonCategory { + Followed = 'followed', + Learned = 'learned', + Following = 'following', + Popular = 'popular', +} + +export const reasonCategories: Array<{ + category: ReasonCategory; + label: string; + dotClassName: string; +}> = [ + { + category: ReasonCategory.Followed, + label: 'Topics you follow', + dotClassName: 'bg-accent-cabbage-default', + }, + { + category: ReasonCategory.Learned, + label: 'Learned from reading', + dotClassName: 'bg-accent-blueCheese-default', + }, + { + category: ReasonCategory.Following, + label: 'Sources you follow', + dotClassName: 'bg-accent-water-default', + }, + { + category: ReasonCategory.Popular, + label: 'Popular fill-ins', + dotClassName: 'bg-accent-cheese-default', + }, +]; + +export const getCategory = (decision: Decision): ReasonCategory => { + if (decision.lane === Lane.Popular) { + return ReasonCategory.Popular; + } + if (decision.followedBy) { + return ReasonCategory.Following; + } + return decision.best?.origin === InterestOrigin.Inferred + ? ReasonCategory.Learned + : ReasonCategory.Followed; +}; + +export const getCategoryDot = (category: ReasonCategory): string => + reasonCategories.find((item) => item.category === category)?.dotClassName ?? + ''; + +export enum FactorId { + Match = 'match', + Follow = 'follow', + Base = 'base', + Age = 'age', + Seen = 'seen', + Waiting = 'waiting', + SourceRepeat = 'source_repeat', + TopicRepeat = 'topic_repeat', + SameHeadline = 'same_headline', + Popular = 'popular', +} + +export interface FactorChip { + id: FactorId; + label: string; + value: number; +} + +const notable = (value: number): boolean => Math.abs(Math.log(value)) >= 0.05; + +const formatAge = (days: number): string => { + if (days < 1) { + return 'Today'; + } + const rounded = Math.round(days); + return `${rounded} ${rounded === 1 ? 'day' : 'days'} old`; +}; + +/** The multipliers that moved this card, biggest first. */ +export const getFactorChips = (decision: Decision): FactorChip[] => { + if (decision.lane === Lane.Popular) { + return [ + { id: FactorId.Popular, label: 'Feed ran short', value: 1 }, + { + id: FactorId.Base, + label: + decision.factors.base >= 1 ? 'Readers liked it' : 'Few reactions yet', + value: decision.factors.base, + }, + { + id: FactorId.Age, + label: formatAge(decision.ageDays), + value: decision.factors.age, + }, + ]; + } + const { factors, slot, best } = decision; + const sourceName = decision.post.source?.name ?? 'this source'; + const chips: FactorChip[] = [ + ...(best + ? [ + { + id: FactorId.Match, + label: `#${best.tag}${best.role === TopicRole.Side ? ' (side)' : ''}`, + value: factors.match, + }, + ] + : []), + { + id: FactorId.Follow, + label: + decision.followedBy === FollowKind.Author + ? 'Author you follow' + : `Following ${sourceName}`, + value: factors.follow, + }, + { + id: FactorId.Base, + label: factors.base >= 1 ? 'Readers liked it' : 'Few reactions yet', + value: factors.base, + }, + { id: FactorId.Age, label: formatAge(decision.ageDays), value: factors.age }, + { + id: FactorId.Seen, + label: `Seen ${decision.impressions}×`, + value: factors.seen, + }, + ...(slot + ? [ + { + id: FactorId.Waiting, + label: `#${slot.waitingTag}'s turn`, + value: slot.waiting, + }, + { + id: FactorId.SourceRepeat, + label: `More from ${sourceName}`, + value: slot.sourceRepeat, + }, + { + id: FactorId.TopicRepeat, + label: 'Similar above', + value: slot.topicRepeat, + }, + { + id: FactorId.SameHeadline, + label: 'Same story above', + value: slot.sameHeadline, + }, + ] + : []), + ]; + + return chips + .filter((chip) => chip.id === FactorId.Match || notable(chip.value)) + .sort((a, b) => Math.abs(Math.log(b.value)) - Math.abs(Math.log(a.value))); +}; + +const strengthPhrase: Partial> = { + [Strength.Strong]: 'and read about it often', + [Strength.Moderate]: 'and read about it sometimes', + [Strength.Confirmed]: 'and opened a story on it', +}; + +export interface ReasonParts { + lead: string; + name?: string; + tail: string; +} + +/** The one-sentence reason, split so the name can be bold. */ +export const getReason = ( + decision: Decision, + interests: Interest[], +): ReasonParts => { + if (decision.lane === Lane.Popular) { + return { + lead: 'Popular this week.', + tail: " Your interests didn't fill the page, so this tops it up.", + }; + } + const { best } = decision; + const interest = interests.find(({ tag }) => tag === best?.tag); + if (decision.followedBy === FollowKind.Source) { + return { + lead: 'You follow ', + name: decision.post.source?.name, + tail: best ? `. It's also about #${best.tag}.` : '.', + }; + } + if (decision.followedBy === FollowKind.Author) { + return { lead: 'Written by someone you follow', tail: '.' }; + } + if (!best) { + return { lead: 'Picked for you', tail: '.' }; + } + const side = best.role === TopicRole.Side ? ', here a side topic' : ''; + if (best.origin === InterestOrigin.Declared) { + const phrase = interest?.strength ? strengthPhrase[interest.strength] : ''; + return { + lead: 'You follow ', + name: `#${best.tag}`, + tail: `${phrase ? ` ${phrase}` : ''}${side}.`, + }; + } + return interest?.strength === Strength.Strong + ? { lead: "You've read a lot about ", name: `#${best.tag}`, tail: `${side}.` } + : { + lead: "You've read a few posts about ", + name: `#${best.tag}`, + tail: `${side}.`, + }; +}; + +export const exclusionLabel: Record = { + [Exclusion.Hidden]: 'You asked for fewer like it', + [Exclusion.BlockedSource]: 'Source you blocked', + [Exclusion.BlockedAuthor]: 'Author you blocked', + [Exclusion.BlockedTag]: 'Has a topic you blocked', + [Exclusion.TooOld]: 'Older than your limit', + [Exclusion.ShownTooOften]: 'Scrolled past too many times', + [Exclusion.NoInterestOrFollow]: 'Nothing you follow or read', + [Exclusion.SideTopicUnproven]: 'Side topic, not proven yet', + [Exclusion.SideTopicsOff]: 'Side topics are off', + [Exclusion.PopularFillOff]: 'Popular fill-ins are off', + [Exclusion.BelowThePage]: "Ranked lower, didn't make the page", +}; + +/** Why a card on screen would leave, in the viewer's terms. */ +export const getLeaveMessage = ( + served: Decision, + now: Decision, + state: PersonalizationState, +): string => { + const sourceName = served.post.source?.name ?? 'this source'; + switch (now.exclusion) { + case Exclusion.Hidden: + return 'You asked for fewer posts like this.'; + case Exclusion.BlockedSource: + return `You blocked ${sourceName}.`; + case Exclusion.BlockedAuthor: + return 'You blocked its author.'; + case Exclusion.BlockedTag: + return `You blocked #${now.exclusionTag}.`; + case Exclusion.TooOld: + return `Older than your ${state.params['feed.max_candidate_age_days']}-day limit.`; + case Exclusion.ShownTooOften: + return `You scrolled past it ${now.impressions} times.`; + case Exclusion.SideTopicsOff: + return 'You turned off side-topic matches.'; + case Exclusion.SideTopicUnproven: + return 'It only matches a side topic and readers have not proven it yet.'; + case Exclusion.PopularFillOff: + return 'You turned off popular fill-ins.'; + default: { + const tag = served.best?.tag; + if (tag && state.controls[tag]?.mode === TopicMode.Muted) { + return `You muted #${tag}.`; + } + if (tag && !state.isLearning) { + return 'You stopped learning from reading.'; + } + if (served.followedBy && !now.followedBy) { + return `You unfollowed ${sourceName}.`; + } + if (tag && served.best?.origin === InterestOrigin.Declared) { + return `You unfollowed #${tag}.`; + } + return 'None of its topics are in your interests anymore.'; + } + } +}; diff --git a/packages/storybook/stories/features/feed-explain/policy/params.ts b/packages/storybook/stories/features/feed-explain/policy/params.ts new file mode 100644 index 0000000000..c25ed78a43 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/params.ts @@ -0,0 +1,419 @@ +/** + * The For You ranking knobs, named after `retriever_config` in daily-feed's + * `feed_configs/challenger_v2.json` (policy `challenger/5.0`). Defaults are + * that config's values; ranges stay inside what `policy.Parse` accepts. + */ + +export const policyVersion = 'challenger/5.0'; + +export enum ParamGroup { + Relevance = 'relevance', + Learning = 'learning', + Freshness = 'freshness', + Follows = 'follows', + Variety = 'variety', + Repeats = 'repeats', + Adjustments = 'adjustments', + Scope = 'scope', +} + +export type ParamKey = + | 'interests.base_weight' + | 'interests.declared_boost.strong' + | 'interests.declared_boost.moderate' + | 'interests.declared_boost.confirmed' + | 'interests.inferred_weight.strong' + | 'interests.inferred_weight.moderate' + | 'interests.inferred_recency_half_life_days' + | 'interests.inferred_recency_floor' + | 'interests.control_boost' + | 'interests.control_demote' + | 'relevance.side_topic_weight' + | 'age.half_life_days' + | 'age.max_stretch' + | 'follows.source_multiplier' + | 'follows.author_multiplier' + | 'history.max_impressions_without_action' + | 'history.seen_factor_per_show' + | 'diversity.topic_repeat_discount' + | 'diversity.source_repeat_discount' + | 'diversity.same_headline_discount' + | 'diversity.waiting_interest_lift' + | 'diversity.waiting_half_life_days' + | 'feed.max_candidate_age_days'; + +export type ParamValues = Record; + +export enum ParamFormat { + Weight = 'weight', + Multiplier = 'multiplier', + Percent = 'percent', + Days = 'days', + Count = 'count', +} + +export interface ParamMeta { + key: ParamKey; + group: ParamGroup; + label: string; + help: string; + min: number; + max: number; + step: number; + format: ParamFormat; + /** Shown only with advanced settings on. */ + isAdvanced?: boolean; +} + +export const defaultParams: ParamValues = { + 'interests.base_weight': 0.3, + 'interests.declared_boost.strong': 1, + 'interests.declared_boost.moderate': 0.4, + 'interests.declared_boost.confirmed': 0.25, + 'interests.inferred_weight.strong': 0.85, + 'interests.inferred_weight.moderate': 0.5, + 'interests.inferred_recency_half_life_days': 25, + 'interests.inferred_recency_floor': 0.2, + 'interests.control_boost': 1.15, + 'interests.control_demote': 0.8, + 'relevance.side_topic_weight': 0.56, + 'age.half_life_days': 7, + 'age.max_stretch': 2, + 'follows.source_multiplier': 1.7, + 'follows.author_multiplier': 1.6, + 'history.max_impressions_without_action': 6, + 'history.seen_factor_per_show': 0.69, + 'diversity.topic_repeat_discount': 0.15, + 'diversity.source_repeat_discount': 0.09, + 'diversity.same_headline_discount': 0.175, + 'diversity.waiting_interest_lift': 1, + 'diversity.waiting_half_life_days': 3, + 'feed.max_candidate_age_days': 60, +}; + +export const paramMeta: ParamMeta[] = [ + { + key: 'interests.base_weight', + group: ParamGroup.Relevance, + label: 'Followed topic, before any reading', + help: 'What a topic you follow is worth on its own. Reading about it adds to this.', + min: 0.05, + max: 1, + step: 0.05, + format: ParamFormat.Weight, + }, + { + key: 'relevance.side_topic_weight', + group: ParamGroup.Relevance, + label: 'Side topics', + help: 'How much a match counts when your topic is only a side topic of the post. At 0, side-topic matches never bring a post in.', + min: 0, + max: 1, + step: 0.02, + format: ParamFormat.Percent, + }, + { + key: 'interests.inferred_weight.strong', + group: ParamGroup.Learning, + label: 'Topics you read a lot (6+ stories)', + help: "Weight of a topic you haven't followed but keep reading.", + min: 0, + max: 1.5, + step: 0.05, + format: ParamFormat.Weight, + }, + { + key: 'interests.inferred_weight.moderate', + group: ParamGroup.Learning, + label: 'Topics you read sometimes (2 to 5)', + help: "Weight of a topic you haven't followed and opened a few times.", + min: 0, + max: 1.5, + step: 0.05, + format: ParamFormat.Weight, + }, + { + key: 'interests.inferred_recency_half_life_days', + group: ParamGroup.Learning, + label: 'How fast reading fades', + help: 'Reading evidence loses half its pull after this many days without new activity on the topic.', + min: 3, + max: 90, + step: 1, + format: ParamFormat.Days, + }, + { + key: 'interests.inferred_recency_floor', + group: ParamGroup.Learning, + label: 'Fading stops at', + help: 'Old reading never drops below this share of its weight.', + min: 0, + max: 1, + step: 0.05, + format: ParamFormat.Percent, + }, + { + key: 'interests.declared_boost.strong', + group: ParamGroup.Learning, + label: 'Extra for a followed topic you read a lot', + help: 'Added to a followed topic once you have read 6 or more stories on it.', + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Weight, + isAdvanced: true, + }, + { + key: 'interests.declared_boost.moderate', + group: ParamGroup.Learning, + label: 'Extra for a followed topic you read sometimes', + help: 'Added to a followed topic after 2 to 5 stories.', + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Weight, + isAdvanced: true, + }, + { + key: 'interests.declared_boost.confirmed', + group: ParamGroup.Learning, + label: 'Extra for a followed topic you read once', + help: 'Added to a followed topic after its first story.', + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Weight, + isAdvanced: true, + }, + { + key: 'age.half_life_days', + group: ParamGroup.Freshness, + label: 'Freshness', + help: 'A post loses half its pull every this many days. Lower means newer posts first.', + min: 1, + max: 30, + step: 1, + format: ParamFormat.Days, + }, + { + key: 'age.max_stretch', + group: ParamGroup.Freshness, + label: 'Well-liked posts age slower, up to', + help: 'Posts readers respond well to stretch the freshness window by up to this much.', + min: 1, + max: 4, + step: 0.1, + format: ParamFormat.Multiplier, + }, + { + key: 'follows.source_multiplier', + group: ParamGroup.Follows, + label: 'Sources and squads you follow', + help: 'Multiplies every post from a source or squad you follow.', + min: 1, + max: 3, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'follows.author_multiplier', + group: ParamGroup.Follows, + label: 'People you follow', + help: 'Multiplies every post written by someone you follow.', + min: 1, + max: 3, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'diversity.topic_repeat_discount', + group: ParamGroup.Variety, + label: 'Spread out similar topics', + help: 'Each similar post already on the page lowers the next one by this much.', + min: 0, + max: 0.6, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'diversity.source_repeat_discount', + group: ParamGroup.Variety, + label: 'Spread out sources', + help: 'Each post from the same source already on the page lowers the next one by this much.', + min: 0, + max: 0.5, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'diversity.same_headline_discount', + group: ParamGroup.Variety, + label: 'Same story, different headline', + help: 'Lowers a post whose headline overlaps one already on the page.', + min: 0, + max: 0.6, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'diversity.waiting_interest_lift', + group: ParamGroup.Variety, + label: 'Rotate my interests', + help: "Lifts the first post on a topic that hasn't been on your first screen lately. At 0, topics don't take turns.", + min: 0, + max: 3, + step: 0.1, + format: ParamFormat.Weight, + }, + { + key: 'diversity.waiting_half_life_days', + group: ParamGroup.Variety, + label: 'A topic is due again after', + help: "Days until a topic's turn is half due again.", + min: 0.5, + max: 14, + step: 0.5, + format: ParamFormat.Days, + isAdvanced: true, + }, + { + key: 'history.seen_factor_per_show', + group: ParamGroup.Repeats, + label: 'Posts I scrolled past', + help: 'Each time a post was on your screen without you acting on it, it keeps this much of its pull. 100% means no penalty.', + min: 0.2, + max: 1, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'history.max_impressions_without_action', + group: ParamGroup.Repeats, + label: 'Stop showing a post after', + help: 'Views without any action before a post leaves your feed for good.', + min: 1, + max: 12, + step: 1, + format: ParamFormat.Count, + }, + { + key: 'interests.control_boost', + group: ParamGroup.Adjustments, + label: 'What More does', + help: 'Multiplies a topic you pressed More on.', + min: 1, + max: 2, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'interests.control_demote', + group: ParamGroup.Adjustments, + label: 'What Less does', + help: 'Multiplies a topic you pressed Less on.', + min: 0.1, + max: 1, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'feed.max_candidate_age_days', + group: ParamGroup.Scope, + label: 'Oldest post I want to see', + help: 'Posts older than this never make it in. The index keeps 60 days.', + min: 3, + max: 60, + step: 1, + format: ParamFormat.Days, + }, +]; + +export const paramGroups: Array<{ + group: ParamGroup; + title: string; + description: string; +}> = [ + { + group: ParamGroup.Relevance, + title: 'Relevance', + description: + "A post's best matching topic multiplies its score by 1 + that topic's weight.", + }, + { + group: ParamGroup.Learning, + title: 'Learning from your reading', + description: + 'Topics you open, upvote, save, comment on or share in the last 90 days.', + }, + { + group: ParamGroup.Freshness, + title: 'Freshness', + description: 'How quickly a post loses its place as it gets older.', + }, + { + group: ParamGroup.Follows, + title: 'Follows', + description: 'A follow brings every post in and multiplies its score.', + }, + { + group: ParamGroup.Variety, + title: 'Variety', + description: 'How much each page mixes topics, sources and stories.', + }, + { + group: ParamGroup.Repeats, + title: 'Repeats', + description: 'What happens to posts you keep scrolling past.', + }, + { + group: ParamGroup.Adjustments, + title: 'Your More and Less', + description: 'How hard the More and Less buttons push a topic.', + }, + { + group: ParamGroup.Scope, + title: 'Scope', + description: 'Which posts are considered at all.', + }, +]; + +export const getParamMeta = (key: ParamKey): ParamMeta => { + const meta = paramMeta.find((item) => item.key === key); + if (!meta) { + throw new Error(`Unknown param ${key}`); + } + return meta; +}; + +export const formatParam = (format: ParamFormat, value: number): string => { + switch (format) { + case ParamFormat.Percent: + return `${Math.round(value * 100)}%`; + case ParamFormat.Multiplier: + return `×${value.toFixed(2)}`; + case ParamFormat.Days: + return `${value} ${value === 1 ? 'day' : 'days'}`; + case ParamFormat.Count: + return `${value} ${value === 1 ? 'view' : 'views'}`; + default: + return value.toFixed(2); + } +}; + +/** Writes changed values into the config the policy reads. */ +export const toRetrieverConfig = ( + params: ParamValues, + base: Record, +): Record => { + const config = structuredClone(base); + (Object.keys(params) as ParamKey[]).forEach((key) => { + const path = key.split('.'); + let node = config as Record; + path.slice(0, -1).forEach((part) => { + node[part] = { ...(node[part] as Record) }; + node = node[part] as Record; + }); + node[path[path.length - 1]] = params[key]; + }); + return config; +}; diff --git a/packages/storybook/stories/features/feed-explain/policy/profile.ts b/packages/storybook/stories/features/feed-explain/policy/profile.ts new file mode 100644 index 0000000000..f95349571e --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/profile.ts @@ -0,0 +1,200 @@ +import type { ParamValues } from './params'; + +/** + * The inferred profile as daily-feed stores it (`services/inferred_interests`): + * per topic, the distinct stories with a positive action in the last 90 days, + * how many of them were upvoted, saved, commented on or shared, and when the + * latest action was. Labels and weights are computed per request. + */ +export interface TopicEvidence { + topic: string; + stories: number; + strongStories: number; + daysSinceLastAction: number; +} + +export enum Strength { + Strong = 'strong', + Moderate = 'moderate', + Confirmed = 'confirmed', + EarlySignal = 'early_signal', +} + +export enum InterestOrigin { + Declared = 'declared', + Inferred = 'inferred', +} + +/** Proposed per-topic user controls; the policy has the knobs, not the UI. */ +export enum TopicMode { + Boosted = 'boosted', + Demoted = 'demoted', + Muted = 'muted', +} + +export interface TopicControl { + mode?: TopicMode; + /** An exact weight the user set, replacing the computed one. */ + weight?: number; +} + +export interface Interest { + tag: string; + origin: InterestOrigin; + strength?: Strength; + evidence?: TopicEvidence; + recency: number; + /** The policy's weight before the user's control. */ + computedWeight: number; + weight: number; + control?: TopicControl; + /** Has reading behind it, so it can take a rotation turn. */ + isBacked: boolean; +} + +const strongMinStories = 6; +const moderateMinStories = 2; + +export const getStrength = ( + evidence: TopicEvidence | undefined, + isDeclared: boolean, +): Strength | undefined => { + if (!evidence || evidence.stories < 1) { + return undefined; + } + if (evidence.stories >= strongMinStories) { + return Strength.Strong; + } + if (evidence.stories >= moderateMinStories) { + return Strength.Moderate; + } + return isDeclared ? Strength.Confirmed : Strength.EarlySignal; +}; + +export const getRecency = (days: number, params: ParamValues): number => + Math.max( + params['interests.inferred_recency_floor'], + Math.min( + 1, + 0.5 ** (days / params['interests.inferred_recency_half_life_days']), + ), + ); + +export const strengthLabel: Record = { + [Strength.Strong]: 'Strong', + [Strength.Moderate]: 'Moderate', + [Strength.Confirmed]: 'Confirmed', + [Strength.EarlySignal]: 'Early signal', +}; + +export const strengthRule: Record = { + [Strength.Strong]: '6 or more stories', + [Strength.Moderate]: '2 to 5 stories', + [Strength.Confirmed]: '1 story on a topic you follow', + [Strength.EarlySignal]: '1 story; counts once there are 2', +}; + +interface BuildInterestsInput { + followedTags: string[]; + blockedTags: string[]; + evidence: TopicEvidence[]; + controls: Record; + isLearning: boolean; + params: ParamValues; +} + +const declaredBoost = (strength: Strength, params: ParamValues): number => { + switch (strength) { + case Strength.Strong: + return params['interests.declared_boost.strong']; + case Strength.Moderate: + return params['interests.declared_boost.moderate']; + case Strength.Confirmed: + return params['interests.declared_boost.confirmed']; + default: + return 0; + } +}; + +const inferredWeight = (strength: Strength, params: ParamValues): number => { + switch (strength) { + case Strength.Strong: + return params['interests.inferred_weight.strong']; + case Strength.Moderate: + return params['interests.inferred_weight.moderate']; + default: + return 0; + } +}; + +const applyControl = ( + weight: number, + control: TopicControl | undefined, + params: ParamValues, +): number => { + if (control?.weight !== undefined) { + return control.weight; + } + switch (control?.mode) { + case TopicMode.Boosted: + return weight * params['interests.control_boost']; + case TopicMode.Demoted: + return weight * params['interests.control_demote']; + case TopicMode.Muted: + return 0; + default: + return weight; + } +}; + +/** + * `BuildInterests` from daily-feed's challenger policy, plus the controls its + * proof of concept defined: boosted and demoted multiply the weight, muted + * drops it. + */ +export const buildInterests = ({ + followedTags, + blockedTags, + evidence, + controls, + isLearning, + params, +}: BuildInterestsInput): Interest[] => { + const evidenceByTopic = new Map( + (isLearning ? evidence : []).map((item) => [item.topic, item]), + ); + const tags = [ + ...new Set([...followedTags, ...evidenceByTopic.keys()]), + ].filter((tag) => !blockedTags.includes(tag)); + + return tags.map((tag): Interest => { + const isDeclared = followedTags.includes(tag); + const topicEvidence = evidenceByTopic.get(tag); + const strength = getStrength(topicEvidence, isDeclared); + const recency = topicEvidence + ? getRecency(topicEvidence.daysSinceLastAction, params) + : 1; + const computedWeight = isDeclared + ? params['interests.base_weight'] + + (strength ? declaredBoost(strength, params) * recency : 0) + : (strength ? inferredWeight(strength, params) : 0) * recency; + const control = controls[tag]; + + return { + tag, + origin: isDeclared ? InterestOrigin.Declared : InterestOrigin.Inferred, + strength, + evidence: topicEvidence, + recency, + computedWeight, + weight: Math.max(0, applyControl(computedWeight, control, params)), + control, + isBacked: + !!strength && strength !== Strength.EarlySignal && !!topicEvidence, + }; + }); +}; + +/** The interests the policy actually ranks with. */ +export const getActiveInterests = (interests: Interest[]): Interest[] => + interests.filter(({ weight }) => weight > 0); diff --git a/packages/storybook/stories/features/feed-explain/policy/rank.ts b/packages/storybook/stories/features/feed-explain/policy/rank.ts new file mode 100644 index 0000000000..4f4e924ade --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/rank.ts @@ -0,0 +1,512 @@ +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import type { ParamValues } from './params'; +import type { Interest, TopicControl, TopicEvidence } from './profile'; +import { buildInterests, getActiveInterests, InterestOrigin } from './profile'; + +/** + * A client-side copy of daily-feed's challenger/5.0 policy (`policy/gate.go`, + * `score.go`, `pick.go`), run over the posts in the story so every number on + * a card comes from the same math the feed uses. Simplified where the real + * inputs aren't in the story: reader response compares each post's + * reactions per view with the other posts here, the first tag is the main + * topic, topic similarity is tag overlap instead of tag embeddings, and + * placement swaps are left out. + */ + +export enum SourceMode { + Demoted = 'demoted', +} + +export interface PersonalizationState { + followedTags: string[]; + blockedTags: string[]; + followedSources: string[]; + blockedSources: string[]; + followedAuthors: string[]; + blockedAuthors: string[]; + evidence: TopicEvidence[]; + isLearning: boolean; + controls: Record; + sourceControls: Record; + params: ParamValues; + isFillingWithPopular: boolean; + hidden: string[]; + /** Views without an action, per post. */ + impressions: Record; + /** Days since each topic was the main topic on your first screen. */ + tagShownDays: Record; +} + +export enum Lane { + Challenger = 'chal', + Popular = 'po', +} + +export enum Exclusion { + Hidden = 'hidden', + BlockedSource = 'blocked_source', + BlockedAuthor = 'blocked_author', + BlockedTag = 'blocked_tag', + TooOld = 'older_than_max_age', + ShownTooOften = 'shown_too_often_without_action', + NoInterestOrFollow = 'no_interest_or_follow', + SideTopicUnproven = 'side_topic_on_an_unproven_post', + SideTopicsOff = 'side_topics_off', + PopularFillOff = 'popular_fill_off', + BelowThePage = 'below_the_page', +} + +export enum TopicRole { + Main = 'main', + Side = 'side', +} + +export interface Match { + tag: string; + origin: InterestOrigin; + role: TopicRole; + weight: number; + roleWeight: number; + contribution: number; +} + +export enum FollowKind { + Source = 'source', + Author = 'author', +} + +export interface ScoreFactors { + base: number; + match: number; + follow: number; + age: number; + seen: number; +} + +export interface SlotFactors { + sourceRepeat: number; + sourceCount: number; + topicRepeat: number; + similarAbove: number; + sameHeadline: number; + waiting: number; + waitingTag?: string; +} + +export interface Decision { + post: Post; + lane: Lane; + exclusion?: Exclusion; + /** The tag behind a blocked_tag exclusion. */ + exclusionTag?: string; + followedBy?: FollowKind; + sourceMode?: SourceMode; + best?: Match; + matches: Match[]; + ageDays: number; + impressions: number; + earned: number; + factors: ScoreFactors; + score: number; + slot?: SlotFactors; + pickScore?: number; + /** 1-based place on the page. */ + position?: number; + /** 1-based place among every eligible post, before page variety. */ + eligibleRank?: number; +} + +export interface RankResult { + served: Decision[]; + leftOut: Decision[]; + interests: Interest[]; +} + +export const pageSize = 12; +const firstScreenSize = 8; +const topicSimilarityFloor = 0.35; +const sameHeadlineJaccard = 0.35; +const proofFloor = 0.2; +const dayMs = 86_400_000; +/** The day the posts in the story were captured. */ +export const storyNow = Date.parse('2026-10-15T12:00:00Z'); + +const stopWords = new Set([ + 'the', + 'and', + 'for', + 'you', + 'your', + 'with', + 'how', + 'why', + 'this', + 'that', + 'are', + 'was', + 'not', + 'just', +]); + +const titleTokens = (title = ''): Set => + new Set( + title + .toLowerCase() + .split(/[^a-z0-9+#]+/) + .filter((word) => word.length >= 3 && !stopWords.has(word)), + ); + +const jaccard = (a: Set, b: Set): number => { + const shared = [...a].filter((word) => b.has(word)).length; + const union = new Set([...a, ...b]).size; + return union ? shared / union : 0; +}; + +const tagSimilarity = (a: string[], b: string[]): number => { + if (!a.length || !b.length) { + return 0; + } + const shared = a.filter((tag) => b.includes(tag)).length; + return shared / Math.sqrt(a.length * b.length); +}; + +const reactionRate = (post: Post): number => + (2 * (post.numUpvotes ?? 0) + 2 * (post.numComments ?? 0)) / + Math.max(1, post.analytics?.impressions ?? 1); + +const medianRates = new WeakMap(); + +const getMedianRate = (pool: Post[]): number => { + const cached = medianRates.get(pool); + if (cached !== undefined) { + return cached; + } + const rates = pool.map(reactionRate).sort((a, b) => a - b); + const median = rates[Math.floor(rates.length / 2)] || 1; + medianRates.set(pool, median); + return median; +}; + +export const getMainTopic = (post: Post): string | undefined => post.tags?.[0]; + +interface Context { + state: PersonalizationState; + interests: Map; + medianRate: number; + now: number; +} + +const assess = (post: Post, context: Context): Decision => { + const { state, interests, medianRate, now } = context; + const { params } = state; + const tags = post.tags ?? []; + const sourceId = post.source?.id ?? ''; + const authorId = post.author?.id; + const base = Math.max(0.02, reactionRate(post) / medianRate); + const earned = Math.max(0, base - 0.5); + const ageDays = Math.max( + 0, + (now - Date.parse(post.createdAt ?? '')) / dayMs || 0, + ); + const impressions = state.impressions[post.id] ?? 0; + const matches = tags + .map((tag, index): Match | undefined => { + const interest = interests.get(tag); + if (!interest) { + return undefined; + } + const role = index === 0 ? TopicRole.Main : TopicRole.Side; + const roleWeight = + role === TopicRole.Main ? 1 : params['relevance.side_topic_weight']; + return { + tag, + origin: interest.origin, + role, + weight: interest.weight, + roleWeight, + contribution: interest.weight * roleWeight, + }; + }) + .filter((match): match is Match => !!match && match.roleWeight > 0) + .sort((a, b) => b.contribution - a.contribution); + const best = matches[0]; + const isSourceFollowed = state.followedSources.includes(sourceId); + const isAuthorFollowed = + !!authorId && state.followedAuthors.includes(authorId); + const sourceFollow = isSourceFollowed + ? params['follows.source_multiplier'] + : 1; + const authorFollow = isAuthorFollowed + ? params['follows.author_multiplier'] + : 1; + let followedBy: FollowKind | undefined; + if (isSourceFollowed || isAuthorFollowed) { + followedBy = + sourceFollow >= authorFollow ? FollowKind.Source : FollowKind.Author; + } + const sourceMode = state.sourceControls[sourceId]; + const factors: ScoreFactors = { + base, + match: 1 + (best?.contribution ?? 0), + follow: + Math.max(sourceFollow, authorFollow) * + (sourceMode === SourceMode.Demoted + ? params['interests.control_demote'] + : 1), + age: + 0.5 ** + (ageDays / + (params['age.half_life_days'] * + Math.min(params['age.max_stretch'], Math.max(1, base)))), + seen: params['history.seen_factor_per_show'] ** impressions, + }; + const decision: Decision = { + post, + lane: Lane.Challenger, + followedBy, + sourceMode, + best, + matches, + ageDays, + impressions, + earned, + factors, + score: + factors.base * factors.match * factors.follow * factors.age * factors.seen, + }; + const exclude = (exclusion: Exclusion, exclusionTag?: string): Decision => ({ + ...decision, + exclusion, + exclusionTag, + }); + const blockedTag = tags.find((tag) => state.blockedTags.includes(tag)); + + if (state.hidden.includes(post.id)) { + return exclude(Exclusion.Hidden); + } + if (state.blockedSources.includes(sourceId)) { + return exclude(Exclusion.BlockedSource); + } + if (authorId && state.blockedAuthors.includes(authorId)) { + return exclude(Exclusion.BlockedAuthor); + } + if (blockedTag) { + return exclude(Exclusion.BlockedTag, blockedTag); + } + if (ageDays > params['feed.max_candidate_age_days']) { + return exclude(Exclusion.TooOld); + } + if (impressions >= params['history.max_impressions_without_action']) { + return exclude(Exclusion.ShownTooOften); + } + if (!followedBy && !matches.length) { + const hasSideOnly = + params['relevance.side_topic_weight'] === 0 && + tags.slice(1).some((tag) => interests.has(tag)); + return exclude( + hasSideOnly ? Exclusion.SideTopicsOff : Exclusion.NoInterestOrFollow, + ); + } + if ( + !followedBy && + !matches.some(({ role }) => role === TopicRole.Main) && + earned <= proofFloor + ) { + return exclude(Exclusion.SideTopicUnproven); + } + return decision; +}; + +const createContext = ( + pool: Post[], + state: PersonalizationState, + now: number, +): Context & { allInterests: Interest[] } => { + const allInterests = buildInterests({ + followedTags: state.followedTags, + blockedTags: state.blockedTags, + evidence: state.evidence, + controls: state.controls, + isLearning: state.isLearning, + params: state.params, + }); + + return { + state, + allInterests, + interests: new Map( + getActiveInterests(allInterests).map((interest) => [ + interest.tag, + interest, + ]), + ), + medianRate: getMedianRate(pool), + now, + }; +}; + +const fillableExclusions = [ + Exclusion.NoInterestOrFollow, + Exclusion.SideTopicUnproven, + Exclusion.SideTopicsOff, +]; + +/** Why a post on screen would not come back, under the current state. */ +export const getExclusion = ( + served: Decision, + pool: Post[], + state: PersonalizationState, + now = storyNow, +): Decision | undefined => { + const decision = assess(served.post, createContext(pool, state, now)); + if (served.lane === Lane.Popular) { + if (decision.exclusion && fillableExclusions.includes(decision.exclusion)) { + return state.isFillingWithPopular + ? undefined + : { ...decision, exclusion: Exclusion.PopularFillOff }; + } + } + return decision.exclusion ? decision : undefined; +}; + +export const rankFeed = ( + pool: Post[], + state: PersonalizationState, + now = storyNow, +): RankResult => { + const context = createContext(pool, state, now); + const { params } = state; + const assessed = pool.map((post) => assess(post, context)); + const eligible = assessed + .filter(({ exclusion }) => !exclusion) + .sort((a, b) => b.score - a.score) + .map((decision, index) => ({ ...decision, eligibleRank: index + 1 })); + const rotation = getActiveInterests(context.allInterests) + .filter(({ isBacked }) => isBacked) + .map(({ tag, weight }) => ({ + tag, + evidence: Math.min(1, weight - params['interests.base_weight']), + })) + .filter(({ evidence }) => evidence > 0); + const maxEvidence = Math.max(0, ...rotation.map(({ evidence }) => evidence)); + const evidenceByTag = new Map( + rotation.map(({ tag, evidence }) => [tag, evidence / maxEvidence]), + ); + const tookTurn = new Set(); + const sourceCounts = new Map(); + const served: Decision[] = []; + let remaining = eligible; + + const slotFor = (decision: Decision): SlotFactors => { + const { post } = decision; + const sourceCount = sourceCounts.get(post.source?.id ?? '') ?? 0; + const similarity = served.slice(-50).reduce((sum, picked) => { + const cos = tagSimilarity(post.tags ?? [], picked.post.tags ?? []); + return ( + sum + + Math.min( + 1, + Math.max(0, (cos - topicSimilarityFloor) / (1 - topicSimilarityFloor)), + ) + ); + }, 0); + const tokens = titleTokens(post.title); + const isSameHeadline = served.some( + (picked) => + jaccard(tokens, titleTokens(picked.post.title)) >= sameHeadlineJaccard, + ); + const mainTopic = getMainTopic(post); + const evidence = + mainTopic && !tookTurn.has(mainTopic) + ? evidenceByTag.get(mainTopic) + : undefined; + let waiting = 1; + if (evidence !== undefined) { + const shownDays = state.tagShownDays[mainTopic ?? '']; + const wait = + shownDays === undefined + ? 1 + : 1 - 0.5 ** (shownDays / params['diversity.waiting_half_life_days']); + waiting = + 1 + + params['diversity.waiting_interest_lift'] * + wait * + Math.max(evidence, Math.min(1, decision.factors.base)); + } + return { + sourceCount, + sourceRepeat: + (1 - params['diversity.source_repeat_discount']) ** sourceCount, + similarAbove: similarity, + topicRepeat: (1 - params['diversity.topic_repeat_discount']) ** similarity, + sameHeadline: isSameHeadline + ? 1 - params['diversity.same_headline_discount'] + : 1, + waiting, + waitingTag: waiting > 1 ? mainTopic : undefined, + }; + }; + + while (served.length < pageSize && remaining.length) { + const scored = remaining.map((decision) => { + const slot = slotFor(decision); + return { + ...decision, + slot, + pickScore: + decision.score * + slot.sourceRepeat * + slot.topicRepeat * + slot.sameHeadline * + slot.waiting, + }; + }); + const next = scored.reduce((top, decision) => + (decision.pickScore ?? 0) > (top.pickScore ?? 0) ? decision : top, + ); + served.push({ ...next, position: served.length + 1 }); + const sourceId = next.post.source?.id ?? ''; + sourceCounts.set(sourceId, (sourceCounts.get(sourceId) ?? 0) + 1); + if (next.slot?.waitingTag) { + tookTurn.add(next.slot.waitingTag); + } + remaining = remaining.filter(({ post }) => post.id !== next.post.id); + } + + // The challenger only fills what it can; the popular lane tops up the rest. + const fillable = assessed.filter( + ({ exclusion }) => exclusion && fillableExclusions.includes(exclusion), + ); + const fill = + state.isFillingWithPopular && served.length < pageSize + ? [...fillable] + .sort( + (a, b) => b.factors.base * b.factors.age - a.factors.base * a.factors.age, + ) + .slice(0, pageSize - served.length) + : []; + fill.forEach((decision) => + served.push({ + ...decision, + exclusion: undefined, + lane: Lane.Popular, + position: served.length + 1, + }), + ); + + const servedIds = new Set(served.map(({ post }) => post.id)); + const leftOut = [ + ...remaining.map((decision) => ({ + ...decision, + exclusion: Exclusion.BelowThePage, + })), + ...assessed.filter( + ({ exclusion, post }) => exclusion && !servedIds.has(post.id), + ), + ]; + + return { served, leftOut, interests: context.allInterests }; +}; + +export const firstScreen = (decisions: Decision[]): Decision[] => + decisions.slice(0, firstScreenSize); From eb4917c97c77b0c98b597989af26a9e3f71cdc3e Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:06:40 +0300 Subject: [PATCH 2/7] feat(storybook): plainer, roomier Explain cards Drop the reason chips from the card, the hover ring that pointed at other cards sharing a topic, and the signal counts behind both. Rows now say what a topic or source means to the reader (You follow it, You've read 3 posts on it, Not one of your topics, You don't follow it) and get more height and spacing. Reason and leave copy loses the policy terms (side topic, fill-ins). Story 3 now shows pressing less on a topic, since the chip popover it opened is gone, and the two card states only the chips explained are removed. --- .../feed-explain/FeedExplain.stories.tsx | 38 +++++----------- .../feed-explain/components/ExplainPanel.tsx | 44 +++++++------------ .../components/FeedExplainPage.tsx | 29 ++---------- .../components/FeedScreenContext.ts | 21 +-------- .../features/feed-explain/policy/describe.ts | 39 +++++++++------- 5 files changed, 54 insertions(+), 117 deletions(-) diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx index 1a71b2916d..d59d4cd2d7 100644 --- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx +++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx @@ -16,7 +16,6 @@ import { import { ControlSection, FeedScreenContext, - getSignalCounts, } from './components/FeedScreenContext'; import { ExplainDetailsModal } from './components/ExplainDetails'; import { @@ -89,18 +88,18 @@ export const ExplainOn: Story = { render: () => , }; -export const ReasonControls: Story = { - name: '3 · Reason controls', +export const CardControls: Story = { + name: '3 · Showing less', render: () => , play: async ({ canvasElement }) => { await waitFor(() => { - const chip = canvasElement.querySelector( - 'ul[aria-label="What moved this post"] button', + const less = canvasElement.querySelector( + 'button[aria-label^="Less #"]', ); - if (!chip) { - throw new Error('Waiting for reason chips'); + if (!less) { + throw new Error('Waiting for the topic rows'); } - chip.click(); + less.click(); }); }, }; @@ -170,16 +169,6 @@ const cardStates: CardState[] = [ note: 'Never followed; six stories in two weeks made it strong.', id: 'MsFCKRbGk', }, - { - title: "A topic's turn", - note: 'Docker had not been on the first screen lately, so it jumped ahead.', - id: 'JKsEou23l', - }, - { - title: 'Variety pushed it down', - note: 'The third Fireship post on the page, so it gives way.', - id: '1XMQ9zlpA', - }, { title: 'Popular fill-in', note: 'A new viewer with two topics; the popular lane tops up the page.', @@ -243,8 +232,7 @@ const StateCardBody = ({ }): ReactElement => { const store = usePersonalization(); const [details, setDetails] = useState(null); - const counts = useMemo(() => getSignalCounts(store.served), [store.served]); - const screen = useMemo(() => ({ counts, onOpenDetails: setDetails }), [counts]); + const screen = useMemo(() => ({ onOpenDetails: setDetails }), []); const decision = store.served.find(({ post }) => post.id === id); if (!decision) { return

        Not on the page.

        ; @@ -311,12 +299,10 @@ export const CardStates: Story = { Explain mode, card by card

        - Each card keeps its source and title and says why it is here: one - sentence for the reason, chips for what moved it up or down (tap - one to see the math and change that part of the policy), and the - post's topics and source with less, more and block. Full - explanation opens the "Why am I seeing this?" view with - the score breakdown. + Each card keeps its source and title and says why it is here in + one sentence, then lists the post's topics and source with + less, more and block. Full explanation opens the "Why am I + seeing this?" view with the score breakdown.

        diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx index fb2d16ea0d..0f5425873d 100644 --- a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx @@ -31,7 +31,6 @@ import { import { defaultParams } from '../policy/params'; import { usePersonalization } from './PersonalizationContext'; import { useFeedScreen } from './FeedScreenContext'; -import { FactorChips } from './FactorChips'; import { Strong } from './controls'; export enum ExplainVariant { @@ -140,9 +139,7 @@ const SignalRow = ({ size: ButtonSize; }): ReactElement => { const store = usePersonalization(); - const { state, next, setHoverKey } = store; - const { counts } = useFeedScreen(); - const count = counts[signal.key] ?? 0; + const { state, next } = store; const isTopic = signal.kind === SignalKind.Topic; const entity = { id: signal.id, name: signal.label }; const isBlocked = isTopic @@ -170,18 +167,15 @@ const SignalRow = ({ } else if (mode === TopicMode.Demoted || isDemotedSource) { meta = 'Showing less'; metaClassName = 'text-accent-bun-default'; + } else if (isFollowedSource || interest?.origin === InterestOrigin.Declared) { + meta = 'You follow it'; + } else if (isTopic) { + const stories = interest?.evidence?.stories ?? evidence?.stories ?? 0; + meta = stories + ? `You've read ${stories} ${stories === 1 ? 'post' : 'posts'} on it` + : 'Not one of your topics'; } else { - let origin = isTopic ? 'Topic' : 'Source'; - if (isFollowedSource) { - origin = 'You follow it'; - } else if (interest?.origin === InterestOrigin.Declared) { - origin = 'You follow it'; - } else if (interest && interest.weight > 0) { - origin = `Learned · ${interest.evidence?.stories} stories`; - } else if (evidence) { - origin = 'Early signal · 1 story'; - } - meta = [origin, count > 1 && `in ${count} posts`].filter(Boolean).join(' · '); + meta = "You don't follow it"; } const onBlock = () => @@ -192,16 +186,14 @@ const SignalRow = ({ return (
      • setHoverKey(signal.key)} - onMouseLeave={() => setHoverKey(null)} > - + {signal.label} - + {meta} @@ -392,11 +384,6 @@ const rowLimit: Record = { [ExplainVariant.List]: 3, }; -const chipLimit: Record = { - [ExplainVariant.Grid]: 4, - [ExplainVariant.List]: 3, -}; - export const ExplainPanel = ({ decision, leaving, @@ -418,14 +405,13 @@ export const ExplainPanel = ({ return (
        - -
          +
            {getCardSignals(decision, rowLimit[variant]).map((signal) => ( { - const { hoverKey } = usePersonalization(); - const { counts } = useFeedScreen(); const isLeaving = isExplain && !!leaving; const isGrid = layout === FeedLayout.Grid; const { post } = decision; - const keys = [ - ...(post.tags ?? []).map((tag) => `topic:${tag}`), - `source:${post.source?.id}`, - ]; - // Only worth pointing at when the signal is on more than this card. - const isHighlighted = - isExplain && - !!hoverKey && - (counts[hoverKey] ?? 0) > 1 && - keys.includes(hoverKey); // The real card stays underneath so the feed keeps every size and gap; the // overlay covers it with the explanation. return (
            @@ -346,7 +328,7 @@ const FeedExplainScreen = ({
            {isExplain && ( onOpenControls())} @@ -385,10 +367,8 @@ const App = ({ () => store.served.find(({ post }) => post.id === initialDetailsId) ?? null, ); - const counts = useMemo(() => getSignalCounts(store.served), [store.served]); const screen = useMemo( () => ({ - counts, onOpenDetails: setDetails, onOpenControls: (nextSection?: ControlSection) => { setSection(nextSection); @@ -396,14 +376,13 @@ const App = ({ window.scrollTo({ top: 0 }); }, }), - [counts], + [], ); const onToggleExplain = () => { if (isExplain && store.pendingCount) { store.refresh(); } - store.setHoverKey(null); setIsExplain(!isExplain); }; diff --git a/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts index 7d86e5d24e..42a4620d14 100644 --- a/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts +++ b/packages/storybook/stories/features/feed-explain/components/FeedScreenContext.ts @@ -12,32 +12,13 @@ export enum ControlSection { } interface FeedScreenValue { - /** How many posts on screen carry each signal key. */ - counts: Record; /** Opens the full "Why am I seeing this?" explanation. */ onOpenDetails?: (decision: Decision) => void; /** Opens the personalization controls, at a section. */ onOpenControls?: (section?: ControlSection) => void; } -export const FeedScreenContext = createContext({ - counts: {}, -}); +export const FeedScreenContext = createContext({}); export const useFeedScreen = (): FeedScreenValue => useContext(FeedScreenContext); - -export const getSignalCounts = ( - decisions: Decision[], -): Record => { - const counts: Record = {}; - decisions.forEach(({ post }) => { - [ - ...(post.tags ?? []).map((tag) => `topic:${tag}`), - ...(post.source?.id ? [`source:${post.source.id}`] : []), - ].forEach((key) => { - counts[key] = (counts[key] ?? 0) + 1; - }); - }); - return counts; -}; diff --git a/packages/storybook/stories/features/feed-explain/policy/describe.ts b/packages/storybook/stories/features/feed-explain/policy/describe.ts index 85e12b7e94..2a44e6da0d 100644 --- a/packages/storybook/stories/features/feed-explain/policy/describe.ts +++ b/packages/storybook/stories/features/feed-explain/policy/describe.ts @@ -166,7 +166,7 @@ export const getFactorChips = (decision: Decision): FactorChip[] => { const strengthPhrase: Partial> = { [Strength.Strong]: 'and read about it often', [Strength.Moderate]: 'and read about it sometimes', - [Strength.Confirmed]: 'and opened a story on it', + [Strength.Confirmed]: 'and read a post on it', }; export interface ReasonParts { @@ -183,7 +183,7 @@ export const getReason = ( if (decision.lane === Lane.Popular) { return { lead: 'Popular this week.', - tail: " Your interests didn't fill the page, so this tops it up.", + tail: " Your topics didn't have enough new posts, so we added it.", }; } const { best } = decision; @@ -201,22 +201,27 @@ export const getReason = ( if (!best) { return { lead: 'Picked for you', tail: '.' }; } - const side = best.role === TopicRole.Side ? ', here a side topic' : ''; + const name = `#${best.tag}`; + const isStrong = interest?.strength === Strength.Strong; if (best.origin === InterestOrigin.Declared) { const phrase = interest?.strength ? strengthPhrase[interest.strength] : ''; + const follow = `${phrase ? ` ${phrase}` : ''}.`; + return best.role === TopicRole.Side + ? { lead: 'Partly about ', name, tail: `. You follow it${follow}` } + : { lead: 'You follow ', name, tail: follow }; + } + if (best.role === TopicRole.Side) { return { - lead: 'You follow ', - name: `#${best.tag}`, - tail: `${phrase ? ` ${phrase}` : ''}${side}.`, + lead: 'Partly about ', + name, + tail: isStrong + ? ". You've read a lot about it." + : ". You've read a few posts on it.", }; } - return interest?.strength === Strength.Strong - ? { lead: "You've read a lot about ", name: `#${best.tag}`, tail: `${side}.` } - : { - lead: "You've read a few posts about ", - name: `#${best.tag}`, - tail: `${side}.`, - }; + return isStrong + ? { lead: "You've read a lot about ", name, tail: '.' } + : { lead: "You've read a few posts about ", name, tail: '.' }; }; export const exclusionLabel: Record = { @@ -254,18 +259,18 @@ export const getLeaveMessage = ( case Exclusion.ShownTooOften: return `You scrolled past it ${now.impressions} times.`; case Exclusion.SideTopicsOff: - return 'You turned off side-topic matches.'; + return 'You turned off posts that are only partly about your topics.'; case Exclusion.SideTopicUnproven: - return 'It only matches a side topic and readers have not proven it yet.'; + return "It's only partly about your topics and hasn't caught on with readers yet."; case Exclusion.PopularFillOff: - return 'You turned off popular fill-ins.'; + return 'You turned off topping up your feed with popular posts.'; default: { const tag = served.best?.tag; if (tag && state.controls[tag]?.mode === TopicMode.Muted) { return `You muted #${tag}.`; } if (tag && !state.isLearning) { - return 'You stopped learning from reading.'; + return 'You turned off learning from your reading.'; } if (served.followedBy && !now.followedBy) { return `You unfollowed ${sourceName}.`; From 65857983146e8bde97fe0cee18a5ebdfeb9c0329 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:17:12 +0300 Subject: [PATCH 3/7] feat(storybook): follow, block and an exact value on Explain card rows Each topic and source row now has an icon-only follow toggle (green when followed, label in the tooltip) and block, replacing less and more. Every topic row shows how much it counts in the profile; tapping the number opens the profile's 0 to 1.5 slider with a reset and a link to the profile. An exact value now puts a topic in the profile on its own, so a topic you neither follow nor read about can be raised from 0. Its reason reads "You set #tag to 0.81." Setting a card's main topic to 0 shows the leave notice with Undo. maxTopicWeight moves to profile.ts so the panel can share it. Story 3 opens the value popover, and the card states swap Showing more and Muted for an exact value and a topic set to 0. --- .../feed-explain/FeedExplain.stories.tsx | 35 ++-- .../feed-explain/components/ExplainPanel.tsx | 198 +++++++++++++----- .../components/FeedExplainPage.tsx | 2 +- .../features/feed-explain/policy/describe.ts | 13 ++ .../features/feed-explain/policy/profile.ts | 11 +- 5 files changed, 189 insertions(+), 70 deletions(-) diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx index d59d4cd2d7..9aa8588c0e 100644 --- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx +++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx @@ -7,7 +7,7 @@ import ExtensionProviders from '../../extension/_providers'; import { providerHandlers } from '../why-recommended/whyRecommended.mocks'; import type { Decision, PersonalizationState } from './policy/rank'; import { getExclusion } from './policy/rank'; -import { TopicMode } from './policy/profile'; +import type { TopicControl } from './policy/profile'; import { policyVersion } from './policy/params'; import { PersonalizationProvider, @@ -88,18 +88,18 @@ export const ExplainOn: Story = { render: () => , }; -export const CardControls: Story = { - name: '3 · Showing less', +export const TopicValue: Story = { + name: '3 · Topic value', render: () => , play: async ({ canvasElement }) => { await waitFor(() => { - const less = canvasElement.querySelector( - 'button[aria-label^="Less #"]', + const value = canvasElement.querySelector( + 'button[aria-label^="How much #"]', ); - if (!less) { + if (!value) { throw new Error('Waiting for the topic rows'); } - less.click(); + value.click(); }); }, }; @@ -146,11 +146,11 @@ interface CardState { layout?: FeedLayout; } -const withControl = (tag: string, mode: TopicMode) => ( +const withControl = (tag: string, control: TopicControl) => ( state: PersonalizationState, ): PersonalizationState => ({ ...state, - controls: { ...state.controls, [tag]: { mode } }, + controls: { ...state.controls, [tag]: control }, }); const cardStates: CardState[] = [ @@ -176,16 +176,16 @@ const cardStates: CardState[] = [ persona: PersonaId.JustSignedUp, }, { - title: 'Showing more', - note: 'More pressed on #rust. Its row turns green and it ranks higher next refresh.', + title: 'Set an exact value', + note: '#rust set to 1.20 from its number on the card. It ranks higher next refresh.', id: 'BjYAz50EA', - change: withControl('rust', TopicMode.Boosted), + change: withControl('rust', { weight: 1.2 }), }, { - title: 'Muted a learned topic', - note: 'Reading about #career stops counting, so this card leaves.', + title: 'Set a topic to 0', + note: '#career stops counting, so this card leaves. Undo puts the learned value back.', id: 'MsFCKRbGk', - change: withControl('career', TopicMode.Muted), + change: withControl('career', { weight: 0 }), }, { title: 'Blocked a source', @@ -301,8 +301,9 @@ export const CardStates: Story = {

            Each card keeps its source and title and says why it is here in one sentence, then lists the post's topics and source with - less, more and block. Full explanation opens the "Why am I - seeing this?" view with the score breakdown. + follow and block. A topic in your profile shows how much it + counts; tap the number to set it. Full explanation opens the + "Why am I seeing this?" view with the score breakdown.

            diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx index 0f5425873d..cf20833198 100644 --- a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx @@ -1,5 +1,5 @@ import type { ReactElement } from 'react'; -import React from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import { Button, @@ -12,16 +12,17 @@ import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; import { BlockIcon } from '@dailydotdev/shared/src/components/icons/Block'; import { EyeCancelIcon } from '@dailydotdev/shared/src/components/icons/EyeCancel'; import { HashtagIcon } from '@dailydotdev/shared/src/components/icons/Hashtag'; -import { MinusIcon } from '@dailydotdev/shared/src/components/icons/Minus'; import { PlusIcon } from '@dailydotdev/shared/src/components/icons/Plus'; import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; +import { Slider } from '@dailydotdev/shared/src/components/fields/Slider'; import { SourceAvatar } from '@dailydotdev/shared/src/components/profile/source/SourceAvatar'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; import type { Source } from '@dailydotdev/shared/src/graphql/sources'; import type { Decision } from '../policy/rank'; import { Exclusion, FollowKind, SourceMode } from '../policy/rank'; -import { InterestOrigin, TopicMode } from '../policy/profile'; +import type { Interest } from '../policy/profile'; +import { maxTopicWeight, TopicMode } from '../policy/profile'; import { getCategory, getCategoryDot, @@ -30,8 +31,9 @@ import { } from '../policy/describe'; import { defaultParams } from '../policy/params'; import { usePersonalization } from './PersonalizationContext'; -import { useFeedScreen } from './FeedScreenContext'; +import { ControlSection, useFeedScreen } from './FeedScreenContext'; import { Strong } from './controls'; +import { AnchoredPopover } from './AnchoredPopover'; export enum ExplainVariant { Grid = 'grid', @@ -131,6 +133,106 @@ const SignalGlyph = ({ signal }: { signal: CardSignal }): ReactElement => ); +const TopicValue = ({ + tag, + interest, +}: { + tag: string; + /** Unset when the topic is not in the profile yet. */ + interest?: Interest; +}): ReactElement => { + const store = usePersonalization(); + const { onOpenControls } = useFeedScreen(); + const [anchor, setAnchor] = useState(null); + const weight = interest?.weight ?? 0; + const isSet = interest?.control?.weight !== undefined; + const label = `How much #${tag} counts`; + const valueClassName = isSet + ? 'text-accent-cabbage-default' + : 'text-text-secondary'; + + return ( + <> + + {anchor && ( + setAnchor(null)} + > +
            + + {label} + + + {weight.toFixed(2)} + +
            + store.setTopicWeight(tag, value)} + /> +

            + {interest + ? `Higher brings more posts about #${tag}. At 0 it stops counting.` + : `Not in your profile yet. Raise it to get posts about #${tag}.`} +

            +
            + {isSet ? ( + + ) : ( + Changes apply when you refresh. + )} + {onOpenControls && ( + + )} +
            +
            + )} + + ); +}; + const SignalRow = ({ signal, size, @@ -146,11 +248,14 @@ const SignalRow = ({ ? state.blockedTags.includes(signal.id) : state.blockedSources.includes(signal.id); const mode = isTopic ? state.controls[signal.id]?.mode : undefined; - const isFollowedSource = - !isTopic && state.followedSources.includes(signal.id); + const isFollowed = isTopic + ? state.followedTags.includes(signal.id) + : state.followedSources.includes(signal.id); const isDemotedSource = !isTopic && state.sourceControls[signal.id] === SourceMode.Demoted; - const interest = next.interests.find(({ tag }) => tag === signal.id); + const interest = isTopic + ? next.interests.find(({ tag }) => tag === signal.id) + : undefined; const evidence = state.evidence.find(({ topic }) => topic === signal.id); let meta: string; let metaClassName = 'text-text-quaternary'; @@ -167,21 +272,30 @@ const SignalRow = ({ } else if (mode === TopicMode.Demoted || isDemotedSource) { meta = 'Showing less'; metaClassName = 'text-accent-bun-default'; - } else if (isFollowedSource || interest?.origin === InterestOrigin.Declared) { - meta = 'You follow it'; } else if (isTopic) { const stories = interest?.evidence?.stories ?? evidence?.stories ?? 0; - meta = stories - ? `You've read ${stories} ${stories === 1 ? 'post' : 'posts'} on it` - : 'Not one of your topics'; + if (stories) { + meta = `You've read ${stories} ${stories === 1 ? 'post' : 'posts'} on it`; + } else if (isFollowed) { + meta = 'You follow it'; + } else if (interest?.control?.weight !== undefined) { + meta = 'You set its value'; + } else { + meta = 'Not one of your topics'; + } } else { - meta = "You don't follow it"; + meta = isFollowed ? 'You follow it' : "You don't follow it"; } const onBlock = () => isTopic ? store.setTagBlocked(signal.id, !isBlocked) : store.setSourceBlocked(entity, !isBlocked); + const followLabel = `${isFollowed ? 'Unfollow' : 'Follow'} ${signal.label}`; + const onFollow = () => + isTopic + ? store.setTagFollowed(signal.id, !isFollowed) + : store.setSourceFollowed(entity, !isFollowed); return (
          • - + + {signal.label} + + {isTopic && !isBlocked && ( + )} - > - {signal.label} {meta} @@ -219,44 +340,16 @@ const SignalRow = ({ ) : ( -
          • {isExplain && ( onOpenControls())} diff --git a/packages/storybook/stories/features/feed-explain/policy/describe.ts b/packages/storybook/stories/features/feed-explain/policy/describe.ts index 2a44e6da0d..316bfe0734 100644 --- a/packages/storybook/stories/features/feed-explain/policy/describe.ts +++ b/packages/storybook/stories/features/feed-explain/policy/describe.ts @@ -203,6 +203,16 @@ export const getReason = ( } const name = `#${best.tag}`; const isStrong = interest?.strength === Strength.Strong; + if ( + best.origin === InterestOrigin.Inferred && + !interest?.evidence && + interest?.control?.weight !== undefined + ) { + const value = interest.weight.toFixed(2); + return best.role === TopicRole.Side + ? { lead: 'Partly about ', name, tail: `. You set it to ${value}.` } + : { lead: 'You set ', name, tail: ` to ${value}.` }; + } if (best.origin === InterestOrigin.Declared) { const phrase = interest?.strength ? strengthPhrase[interest.strength] : ''; const follow = `${phrase ? ` ${phrase}` : ''}.`; @@ -266,6 +276,9 @@ export const getLeaveMessage = ( return 'You turned off topping up your feed with popular posts.'; default: { const tag = served.best?.tag; + if (tag && state.controls[tag]?.weight === 0) { + return `You set #${tag} to 0.`; + } if (tag && state.controls[tag]?.mode === TopicMode.Muted) { return `You muted #${tag}.`; } diff --git a/packages/storybook/stories/features/feed-explain/policy/profile.ts b/packages/storybook/stories/features/feed-explain/policy/profile.ts index f95349571e..ca704e2d63 100644 --- a/packages/storybook/stories/features/feed-explain/policy/profile.ts +++ b/packages/storybook/stories/features/feed-explain/policy/profile.ts @@ -52,6 +52,9 @@ export interface Interest { isBacked: boolean; } +/** The most an exact topic weight can be set to. */ +export const maxTopicWeight = 1.5; + const strongMinStories = 6; const moderateMinStories = 2; @@ -164,7 +167,13 @@ export const buildInterests = ({ (isLearning ? evidence : []).map((item) => [item.topic, item]), ); const tags = [ - ...new Set([...followedTags, ...evidenceByTopic.keys()]), + ...new Set([ + ...followedTags, + ...evidenceByTopic.keys(), + ...Object.keys(controls).filter( + (tag) => controls[tag].weight !== undefined, + ), + ]), ].filter((tag) => !blockedTags.includes(tag)); return tags.map((tag): Interest => { From 3c79759e18347357eee8e108f057674ef01e9b23 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:44:42 +0300 Subject: [PATCH 4/7] feat(storybook): one-line Explain dock The dock keeps to one line: the hint is now "Follow, block, or tune any topic.", longer change messages truncate with the full text in the tooltip, and All controls becomes Personalize to match the Personalization page. On phones Undo and Personalize are icons, Refresh feed is Refresh and the text drops to footnote size, which fits every card action's message. The fewer-like-this confirmation is shortened to fit, and the value popover links to All topics instead of Your profile. --- .../feed-explain/components/ChangesDock.tsx | 79 +++++++++++-------- .../feed-explain/components/ExplainPanel.tsx | 2 +- .../components/FeedExplainPage.tsx | 8 +- .../components/PersonalizationContext.tsx | 2 +- 4 files changed, 56 insertions(+), 35 deletions(-) diff --git a/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx b/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx index d469d0cbca..72fe4e98f4 100644 --- a/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ChangesDock.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React from 'react'; +import classNames from 'classnames'; import { Button, ButtonSize, @@ -9,7 +10,7 @@ import { UndoIcon } from '@dailydotdev/shared/src/components/icons/Undo'; import { SettingsIcon } from '@dailydotdev/shared/src/components/icons/Settings'; import { usePersonalization } from './PersonalizationContext'; -/** Floats at the bottom: the last change, Undo, and the screen's next step. */ +/** One floating line: the last change, Undo, and the screen's next step. */ export const ChangesDock = ({ primaryLabel, onPrimary, @@ -24,48 +25,64 @@ export const ChangesDock = ({ isCompact?: boolean; }): ReactElement => { const { lastMessage, notice, pendingCount, undo } = usePersonalization(); + const message = lastMessage ?? notice ?? defaultMessage; return (
            - - {lastMessage ?? notice ?? defaultMessage} + + {message} - {pendingCount > 0 && ( - - )} - {onOpenControls && ( + + {pendingCount > 0 && ( + + )} + {onOpenControls && ( + + )} - )} - +
            ); diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx index cf20833198..3ea6016f7a 100644 --- a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx @@ -223,7 +223,7 @@ const TopicValue = ({ className="font-bold text-text-link hover:underline" onClick={() => onOpenControls(ControlSection.Interests)} > - Your profile + All topics )} diff --git a/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx b/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx index 8dc76b8d52..fa75771097 100644 --- a/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx +++ b/packages/storybook/stories/features/feed-explain/components/FeedExplainPage.tsx @@ -284,6 +284,10 @@ const FeedExplainScreen = ({ const { served, pool, state, pendingCount, refresh } = usePersonalization(); const { onOpenControls } = useFeedScreen(); const isList = layout === FeedLayout.List; + let refreshLabel = 'Done'; + if (pendingCount) { + refreshLabel = isList ? 'Refresh' : 'Refresh feed'; + } const leaving = useMemo( () => new Map( @@ -328,8 +332,8 @@ const FeedExplainScreen = ({
        {isExplain && ( onOpenControls())} isCompact={isList} diff --git a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx index 42307e5dde..26af5bb92b 100644 --- a/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx @@ -341,7 +341,7 @@ export const PersonalizationProvider = ({ ), setHidden: (postId, isHidden) => apply( - isHidden ? 'Got it, fewer posts like this one.' : 'Post is back in your feed.', + isHidden ? 'Got it, fewer like this.' : 'Post is back in your feed.', (current) => ({ ...current, hidden: toggle(current.hidden, postId, isHidden), From 77505fa75dd52f0b6b041a1594b84be18312be62 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:53:14 +0300 Subject: [PATCH 5/7] feat(storybook): show card topic values as boosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the Personalization page: a topic's value reads as the boost it gives a post (×1.36), or Off at 0, in the row tag and the popover. The popover is titled "Boost for #tag", steps by 0.05 like the page, and resets with "Back to automatic (×1.36)". Reasons and leave notices follow ("You gave #nginx a ×1.81 boost.", "You turned #career off."). formatBoost lives in describe.ts until the page's ParamFormat.Boost lands on this branch. --- .../feed-explain/FeedExplain.stories.tsx | 18 ++++++------ .../feed-explain/components/ExplainPanel.tsx | 28 ++++++++++--------- .../features/feed-explain/policy/describe.ts | 12 +++++--- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx index 9aa8588c0e..d66d5a03ff 100644 --- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx +++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx @@ -88,13 +88,13 @@ export const ExplainOn: Story = { render: () => , }; -export const TopicValue: Story = { - name: '3 · Topic value', +export const TopicBoost: Story = { + name: '3 · Topic boost', render: () => , play: async ({ canvasElement }) => { await waitFor(() => { const value = canvasElement.querySelector( - 'button[aria-label^="How much #"]', + 'button[aria-label^="Boost for #"]', ); if (!value) { throw new Error('Waiting for the topic rows'); @@ -176,14 +176,14 @@ const cardStates: CardState[] = [ persona: PersonaId.JustSignedUp, }, { - title: 'Set an exact value', - note: '#rust set to 1.20 from its number on the card. It ranks higher next refresh.', + title: 'Boosted a topic', + note: '#rust raised to ×2.20 from its boost on the card. It ranks higher next refresh.', id: 'BjYAz50EA', change: withControl('rust', { weight: 1.2 }), }, { - title: 'Set a topic to 0', - note: '#career stops counting, so this card leaves. Undo puts the learned value back.', + title: 'Turned a topic off', + note: '#career dragged all the way left, so this card leaves. Undo brings it back.', id: 'MsFCKRbGk', change: withControl('career', { weight: 0 }), }, @@ -301,8 +301,8 @@ export const CardStates: Story = {

        Each card keeps its source and title and says why it is here in one sentence, then lists the post's topics and source with - follow and block. A topic in your profile shows how much it - counts; tap the number to set it. Full explanation opens the + follow and block. Each topic shows its boost; tap it to change + how much it counts. Full explanation opens the "Why am I seeing this?" view with the score breakdown.

        diff --git a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx index 3ea6016f7a..2444aa10ac 100644 --- a/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx +++ b/packages/storybook/stories/features/feed-explain/components/ExplainPanel.tsx @@ -24,6 +24,7 @@ import { Exclusion, FollowKind, SourceMode } from '../policy/rank'; import type { Interest } from '../policy/profile'; import { maxTopicWeight, TopicMode } from '../policy/profile'; import { + formatBoost, getCategory, getCategoryDot, getLeaveMessage, @@ -133,7 +134,7 @@ const SignalGlyph = ({ signal }: { signal: CardSignal }): ReactElement =>
        ); -const TopicValue = ({ +const TopicBoost = ({ tag, interest, }: { @@ -145,8 +146,9 @@ const TopicValue = ({ const { onOpenControls } = useFeedScreen(); const [anchor, setAnchor] = useState(null); const weight = interest?.weight ?? 0; - const isSet = interest?.control?.weight !== undefined; - const label = `How much #${tag} counts`; + const boost = formatBoost(weight); + const isSet = !!interest?.control; + const label = `Boost for #${tag}`; const valueClassName = isSet ? 'text-accent-cabbage-default' : 'text-text-secondary'; @@ -157,7 +159,7 @@ const TopicValue = ({ type="button" aria-haspopup="dialog" aria-expanded={!!anchor} - aria-label={`${label}: ${weight.toFixed(2)}`} + aria-label={`${label}: ${boost}`} title={label} onClick={(event) => setAnchor(anchor ? null : (event.currentTarget as HTMLButtonElement)) @@ -168,7 +170,7 @@ const TopicValue = ({ valueClassName, )} > - {weight.toFixed(2)} + {boost} {anchor && ( - {weight.toFixed(2)} + {boost}
        store.setTopicWeight(tag, value)} />

        {interest - ? `Higher brings more posts about #${tag}. At 0 it stops counting.` - : `Not in your profile yet. Raise it to get posts about #${tag}.`} + ? `Posts about #${tag} get this boost. All the way left turns it off.` + : `Not in your profile yet. Drag right to get posts about #${tag}.`}