diff --git a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx index 9c2b7b6992..4886a5f163 100644 --- a/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx +++ b/packages/storybook/stories/features/feed-explain/FeedExplain.stories.tsx @@ -1,24 +1,38 @@ -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 { + defaultParams, + formatParam, + ParamGroup, + paramGroups, + paramMeta, + 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 +63,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 +96,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 +309,7 @@ const Section = ({ ); export const CardStates: Story = { - name: '5 · Card states', + name: '9 · Card states', render: () => (
@@ -223,15 +318,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 +344,238 @@ 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: 'Exact topic boost, down to Off', + status: Status.Missing, + today: 'Weights are computed per request', + needs: + 'A per-topic override that replaces the computed weight; 0 drops the topic', + }, + { + control: 'Exact source boost', + status: Status.Missing, + today: + 'Sources only have follow and block; the follow boost is one global value', + needs: 'A per-source multiplier that replaces 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', + }, + { + control: 'Connect your agent', + status: Status.Missing, + today: + 'The MCP server at api.daily.dev/mcp can already follow, unfollow and block topics and sources', + needs: + 'MCP tools for the rest of this page: read the profile and the left-out list, set boosts and ranking settings, run the resets', + }, +]; + +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}
+
+
+
+

+ Ranking settings and their config keys +

+

+ Each setting in the panel writes one retriever_config{' '} + value. Topic and per-source boosts show as the multiplier they give, + so a topic weight of 0.30 reads as ×1.30. +

+
+
+ + + + + + + + + + + {paramMeta + .filter(({ group }) => group !== ParamGroup.Adjustments) + .map((meta) => ( + + + + + + + ))} + +
GroupSettingKeyDefault
+ { + paramGroups.find(({ group }) => group === meta.group) + ?.title + } + + {meta.label} + + {meta.key} + + {formatParam(meta.format, defaultParams[meta.key])} +
+
+
+
+ ), +}; 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..945752ce89 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationContext.tsx @@ -0,0 +1,459 @@ +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, + ParamFormat, +} 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; + setSourceWeight: (source: NamedEntity, weight?: number) => 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 withoutKey = ( + record: Record | undefined, + key: string, +): Record | undefined => { + if (!record || !(key in record)) { + return record; + } + const next = { ...record }; + delete next[key]; + return next; +}; + +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)} is automatic again.` + : `${tagName(tag)} boost set to ${formatParam( + ParamFormat.Boost, + weight, + )}.`, + (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, + ), + sourceWeights: isFollowed + ? current.sourceWeights + : withoutKey(current.sourceWeights, source.id), + }), + ), + 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, + sourceWeights: withoutKey(current.sourceWeights, source.id), + }; + }, + ), + setSourceWeight: (source, weight) => + apply( + weight === undefined + ? `${source.name} is automatic again.` + : `${source.name} boost set to ${formatParam( + ParamFormat.Multiplier, + weight, + )}.`, + (current) => { + const sourceControls = { ...current.sourceControls }; + delete sourceControls[source.id]; + return { + ...current, + sourceControls, + sourceWeights: + weight === undefined + ? withoutKey(current.sourceWeights, source.id) + : { ...current.sourceWeights, [source.id]: weight }, + }; + }, + `source:${source.id}`, + ), + 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('Every boost you set is automatic again.', (current) => ({ + ...current, + controls: {}, + sourceControls: {}, + sourceWeights: {}, + })), + 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: {}, + sourceWeights: {}, + 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..41332f72b9 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/PersonalizationPanel.tsx @@ -0,0 +1,210 @@ +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 { 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'; +import { AgentSetupButton } from './panel/AgentSetup'; + +const navItems: Array<{ section: ControlSection; label: string }> = [ + { section: ControlSection.Interests, label: 'Topics' }, + { section: ControlSection.Sources, label: 'Sources and people' }, + { section: ControlSection.Ranking, label: 'Ranking' }, + { section: ControlSection.LeftOut, label: 'Your next page' }, + { section: ControlSection.Settings, label: 'Other settings' }, + { 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 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 } = store; + const [railTab, setRailTab] = useState( + section === ControlSection.LeftOut ? RailTab.LeftOut : RailTab.Next, + ); + const hasRail = () => !isCompact && window.matchMedia(railQuery).matches; + const goTo = (target: ControlSection) => { + if (target === ControlSection.LeftOut && hasRail()) { + setRailTab(RailTab.Next); + 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 shapes your For You feed, and how much each thing + counts. Change anything here and your feed follows. +

        +
        +
        + store.setLearning(!state.isLearning)} + compact={false} + > + Learn from my reading + + +
        +
        +
        + +
        +
        + + + + + + + + + +
        + {!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..0e223b3974 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/controls.tsx @@ -0,0 +1,218 @@ +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 { RefreshIcon } from '@dailydotdev/shared/src/components/icons/Refresh'; +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 slider with its value beside it, for a topic or source boost. */ +export const BoostSlider = ({ + label, + value, + min, + max, + step, + display, + isSet, + resetLabel, + onChange, + onReset, + className, +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + display: string; + /** The user set this value, so it no longer follows the feed. */ + isSet: boolean; + resetLabel: string; + onChange: (value: number) => void; + onReset: () => void; + className?: string; +}): ReactElement => ( + + onChange(next)} + /> + + {display} + + + )} + + {formatParam(meta.format, value)} + + +
        + setParam(paramKey, next)} + /> + {!isCompact && ( + {meta.help} + )} +
        + ); +}; 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/AgentSetup.tsx b/packages/storybook/stories/features/feed-explain/components/panel/AgentSetup.tsx new file mode 100644 index 0000000000..349418e257 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/AgentSetup.tsx @@ -0,0 +1,154 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Modal } from '@dailydotdev/shared/src/components/modals/common/Modal'; +import { CopyIcon } from '@dailydotdev/shared/src/components/icons/Copy'; +import { TerminalIcon } from '@dailydotdev/shared/src/components/icons/Terminal'; +import { VIcon } from '@dailydotdev/shared/src/components/icons/V'; +import { mcpServerDocs } from '@dailydotdev/shared/src/lib/constants'; +import { anchorDefaultRel } from '@dailydotdev/shared/src/lib/strings'; + +// The app reads this from `oauthEndpoints.mcpResource`; Storybook has no API URL. +const mcpServerUrl = 'https://api.daily.dev/mcp'; +const claudeCodeCommand = `claude mcp add --transport http daily.dev ${mcpServerUrl}`; +const starterPrompt = + 'Review my daily.dev feed settings and suggest changes. Ask me before you apply any.'; + +const CopyBlock = ({ + label, + text, + isCode, +}: { + label: string; + text: string; + isCode?: boolean; +}): ReactElement => { + const [isCopied, setIsCopied] = useState(false); + + useEffect(() => { + if (!isCopied) { + return undefined; + } + const timeout = setTimeout(() => setIsCopied(false), 2000); + return () => clearTimeout(timeout); + }, [isCopied]); + + return ( +
        + {label} +
        + + {text} + +
        +
        + ); +}; + +const Step = ({ + number, + title, + children, +}: { + number: number; + title: string; + children: ReactNode; +}): ReactElement => ( +
        + + {number} + +
        +

        {title}

        + {children} +
        +
        +); + +const AgentSetupModal = ({ + onClose, +}: { + onClose: () => void; +}): ReactElement => ( + + + +

        + Add the daily.dev MCP server to Claude, Cursor, Codex or any MCP client. + Your agent can then read and change everything on this page. +

        + + + + + + + +

        + Every change it makes shows up here, and you can undo any of it.{' '} + + Setup guide + +

        +
        +
        +); + +export const AgentSetupButton = (): ReactElement => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + {isOpen && setIsOpen(false)} />} + + ); +}; 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..58eefccd3a --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/InterestsSection.tsx @@ -0,0 +1,212 @@ +import type { ReactElement } from 'react'; +import React, { useMemo } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +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 } from '../../policy/profile'; +import { formatParam, ParamFormat } from '../../policy/params'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { BoostSlider } from '../controls'; +import { EntityRow, PanelList, PanelListEmpty, PanelSection } from './Section'; + +const maxWeight = 1.5; + +const formatBoost = (weight: number): string => + weight > 0 ? formatParam(ParamFormat.Boost, weight) : 'Off'; + +const formatWhen = (days: number): string => { + if (days < 1) { + return 'today'; + } + return days === 1 ? 'yesterday' : `${days} days ago`; +}; + +const describeReading = (evidence: TopicEvidence): string => + evidence.stories === 1 + ? `You read 1 post about it ${formatWhen(evidence.daysSinceLastAction)}.` + : `You read ${evidence.stories} posts about it, the last one ${formatWhen( + evidence.daysSinceLastAction, + )}.`; + +const describeInterest = (interest: Interest): string => { + const { evidence } = interest; + const isFollowed = interest.origin === InterestOrigin.Declared; + if (!evidence) { + return isFollowed ? "You follow this. You haven't read about it yet." : ''; + } + const reading = describeReading(evidence); + if (isFollowed) { + return `You follow this. ${reading}`; + } + return interest.strength === Strength.EarlySignal && !interest.control + ? `${reading} It counts once you read 2.` + : reading; +}; + +const topicIcon = ( + + + +); + +const InterestRow = ({ interest }: { interest: Interest }): ReactElement => { + const store = usePersonalization(); + const isFollowed = interest.origin === InterestOrigin.Declared; + + return ( + store.setTopicWeight(interest.tag, value)} + onReset={() => store.setTopicMode(interest.tag, undefined)} + /> + } + action={ + + } + /> + ); +}; + +const byWeight = (a: Interest, b: Interest): number => + b.weight - a.weight || a.tag.localeCompare(b.tag); + +export const InterestsSection = (): ReactElement => { + const store = usePersonalization(); + const { state, next, pool } = store; + const followed = next.interests + .filter(({ origin }) => origin === InterestOrigin.Declared) + .sort(byWeight); + const learned = next.interests + .filter(({ origin }) => origin === InterestOrigin.Inferred) + .sort(byWeight); + const suggestions = useMemo(() => { + const known = new Set([ + ...next.interests.map(({ tag }) => tag), + ...state.blockedTags, + ]); + 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, next.interests, state.blockedTags]); + + return ( + + + {followed.map((interest) => ( + + ))} + {!followed.length && ( + You don't follow any topics yet. + )} + + + {learned.map((interest) => ( + + ))} + {!learned.length && ( + + {state.isLearning + ? 'Nothing yet. Topics show up here as you read.' + : 'Learning from your reading is off, so only topics you follow count.'} + + )} + + {state.blockedTags.length > 0 && ( + + {state.blockedTags.map((tag) => ( + store.setTagBlocked(tag, false)} + > + Unblock + + } + /> + ))} + + )} + {suggestions.length > 0 && ( +
        +

        + Follow a topic +

        +
        + {suggestions.map((tag) => ( + + ))} +
        +
        + )} +
        + ); +}; 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..a9d228df72 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/Preview.tsx @@ -0,0 +1,327 @@ +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]: + 'These could show, but other posts ranked higher, mostly newer ones or ones that mix up your page.', + [Exclusion.NoInterestOrFollow]: + 'None of your topics, sources or people are in these.', + [Exclusion.SideTopicUnproven]: + "Your topic is only a side topic in these, and other readers haven't liked them 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} overall` + : (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 didn't make your next page. 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..82a6bbeb52 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/RankingSection.tsx @@ -0,0 +1,94 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { Switch } from '@dailydotdev/shared/src/components/fields/Switch'; +import type { ParamKey } from '../../policy/params'; +import { + defaultParams, + ParamGroup, + paramGroups, + paramMeta, +} from '../../policy/params'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { ParamSlider, ToggleRow } from '../controls'; +import { PanelCard, PanelSection } from './Section'; + +export const RankingSection = (): ReactElement => { + const store = usePersonalization(); + const { state } = store; + const [isAdvanced, setIsAdvanced] = useState(false); + const changedCount = + (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length + (state.isFillingWithPopular ? 0 : 1); + + return ( + + setIsAdvanced(!isAdvanced)} + > + Advanced + + {changedCount > 0 && ( + + )} +
    + } + > +
    + {paramGroups.map(({ group, title, description }) => { + const settings = paramMeta.filter( + (meta) => meta.group === group && (isAdvanced || !meta.isAdvanced), + ); + return ( + +
    +

    + {title} +

    +

    + {description} +

    +
    + {group === ParamGroup.Scope && ( + store.setFilling(!state.isFillingWithPopular)} + /> + )} + {settings.map(({ key }) => ( + + ))} +
    + ); + })} +
    + + ); +}; 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..e6179a57b5 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/Section.tsx @@ -0,0 +1,161 @@ +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} + +); + +/** A titled card of rows, like the topics you follow. */ +export const PanelList = ({ + title, + description, + children, +}: { + title: string; + description?: ReactNode; + children: ReactNode; +}): ReactElement => ( +
    +
    +

    {title}

    + {description && ( +

    {description}

    + )} +
    + +
      + {children} +
    +
    +
    +); + +export const PanelListEmpty = ({ + children, +}: { + children: ReactNode; +}): ReactElement => ( +
  • {children}
  • +); + +/** + * One topic, source or person: who it is, its boost and one action. The boost + * sits between them on a wide list and under them on a narrow one. + */ +export const EntityRow = ({ + media, + name, + description, + isStruck, + boost, + action, +}: { + media: ReactNode; + name: string; + description?: string; + isStruck?: boolean; + boost?: ReactNode; + action: ReactNode; +}): ReactElement => ( +
  • + + {media} + + + {name} + + {description && ( + + {description} + + )} + + + {boost && ( + + {boost} + + )} + + {action} + +
  • +); 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..a0865e2599 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/SettingsSection.tsx @@ -0,0 +1,259 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useState } from 'react'; +import { + Button, + ButtonColor, + ButtonSize, + ButtonVariant, +} from '@dailydotdev/shared/src/components/buttons/Button'; +import { AiIcon } from '@dailydotdev/shared/src/components/icons/Ai'; +import { ArrowIcon } from '@dailydotdev/shared/src/components/icons/Arrow'; +import { BlockIcon } from '@dailydotdev/shared/src/components/icons/Block'; +import { EyeIcon } from '@dailydotdev/shared/src/components/icons/Eye'; +import { FilterIcon } from '@dailydotdev/shared/src/components/icons/Filter'; +import { SquadIcon } from '@dailydotdev/shared/src/components/icons/Squad'; +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 SettingLink = ({ + icon, + title, + summary, + href, +}: { + icon: ReactNode; + title: string; + summary: string; + href: string; +}): ReactElement => ( +
  • + + + {icon} + + + + {title} + + {summary} + + + +
  • +); + +/** Filters that live in settings; 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 ( + +
      + } + title="Blocked" + summary={ + blockedCount + ? `${blockedCount} topics, sources or people you blocked, plus words in titles` + : 'Topics, sources, people and words in titles you never want to see' + } + href={`${settingsUrl}/feed/blocked`} + /> + } + title="Content types" + summary="Which kinds of posts can show up" + href={`${settingsUrl}/feed/preferences`} + /> + } + title="AI filters" + summary="How much AI-generated content and clickbait to allow" + href={`${settingsUrl}/feed/ai`} + /> + } + title="Squads" + summary="Squads you join count as sources you follow" + href={`${webappUrl}squads`} + /> + } + title="Reading history" + summary="The posts your topics from reading come from" + href={`${webappUrl}history`} + /> +
    +
    + ); +}; + +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 adjusted = new Set([ + ...Object.keys(state.controls), + ...Object.keys(state.sourceControls), + ...Object.keys(state.sourceWeights ?? {}), + ]).size; + const changedSettings = + (Object.keys(state.params) as ParamKey[]).filter( + (key) => state.params[key] !== defaultParams[key], + ).length + (state.isFillingWithPopular ? 0 : 1); + 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..4dde84eeb3 --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/components/panel/SourcesSection.tsx @@ -0,0 +1,246 @@ +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 { formatParam, ParamFormat } from '../../policy/params'; +import { SourceMode } from '../../policy/rank'; +import { usePersonalization } from '../PersonalizationContext'; +import { ControlSection } from '../FeedScreenContext'; +import { BoostSlider } from '../controls'; +import { EntityRow, PanelList, PanelListEmpty, PanelSection } from './Section'; + +type ListedSource = Source & { id: string }; + +const formatBoost = (value: number): string => + formatParam(ParamFormat.Multiplier, value); + +const SourceRow = ({ source }: { source: ListedSource }): ReactElement => { + const store = usePersonalization(); + const { state } = store; + const { params } = state; + const entity = { id: source.id, name: source.name ?? source.id }; + const isFollowed = state.followedSources.includes(source.id); + const isBlocked = state.blockedSources.includes(source.id); + const media = ( + + ); + + if (isBlocked) { + return ( + store.setSourceBlocked(entity, false)} + > + Unblock + + } + /> + ); + } + + const automatic = isFollowed ? params['follows.source_multiplier'] : 1; + const exact = state.sourceWeights?.[source.id]; + const isDemoted = state.sourceControls[source.id] === SourceMode.Demoted; + const value = + exact ?? automatic * (isDemoted ? params['interests.control_demote'] : 1); + + return ( + store.setSourceWeight(entity, next)} + onReset={() => store.setSourceWeight(entity, undefined)} + /> + } + action={ + + } + /> + ); +}; + +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 ListedSource); + } + }); + return map; + }, [pool]); + const authorById = useMemo(() => { + const map = new Map(); + pool.forEach(({ author }) => { + if (author?.id) { + map.set(author.id, author.name ?? author.id); + } + }); + return map; + }, [pool]); + const toSources = (ids: string[]) => + ids + .map((id) => sourceById.get(id)) + .filter((source): source is ListedSource => !!source); + const adjusted = [ + ...new Set([ + ...Object.keys(state.sourceControls), + ...Object.keys(state.sourceWeights ?? {}), + ]), + ].filter( + (id) => + !state.followedSources.includes(id) && !state.blockedSources.includes(id), + ); + const listed = toSources([...state.followedSources, ...adjusted]); + const blocked = toSources(state.blockedSources); + 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 ListedSource => !!source); + }, [pool, sourceById, state.followedSources, state.blockedSources]); + + return ( + + + {listed.map((source) => ( + + ))} + {!listed.length && ( + + You don't follow any sources yet. + + )} + + + {state.followedAuthors.map((id) => { + const author = { id, name: authorById.get(id) ?? id }; + return ( + store.setAuthorFollowed(author, false)} + > + Unfollow + + } + /> + ); + })} + {!state.followedAuthors.length && ( + + You don't follow anyone yet. Following a writer shows you every + post they write. + + )} + + {blocked.length > 0 && ( + + {blocked.map((source) => ( + + ))} + + )} + {suggestions.length > 0 && ( +
    +

    + Follow a source +

    +
    + {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/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..3ac410049c --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/params.ts @@ -0,0 +1,407 @@ +/** + * 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 { + Followed = 'followed', + Learning = 'learning', + Follows = 'follows', + Freshness = 'freshness', + Variety = 'variety', + Repeats = 'repeats', + Scope = 'scope', + /** The More and Less buttons on cards; the panel sets exact values. */ + Adjustments = 'adjustments', +} + +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', + /** A topic weight, shown as the multiplier it gives: 1 + weight. */ + Boost = 'boost', + /** Added to a topic weight. */ + Extra = 'extra', + 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, +}; + +const followedExtraHelp = + 'Added to the boost of a topic you follow as you read about it. It fades like reading does.'; + +export const paramMeta: ParamMeta[] = [ + { + key: 'interests.base_weight', + group: ParamGroup.Followed, + label: 'Boost for a topic you follow', + help: 'Before you read anything about it. Reading about it adds more.', + min: 0.05, + max: 1, + step: 0.05, + format: ParamFormat.Boost, + }, + { + key: 'interests.declared_boost.confirmed', + group: ParamGroup.Followed, + label: 'Extra after 1 post read', + help: followedExtraHelp, + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Extra, + isAdvanced: true, + }, + { + key: 'interests.declared_boost.moderate', + group: ParamGroup.Followed, + label: 'Extra after 2 to 5 posts read', + help: followedExtraHelp, + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Extra, + isAdvanced: true, + }, + { + key: 'interests.declared_boost.strong', + group: ParamGroup.Followed, + label: 'Extra after 6 or more posts read', + help: followedExtraHelp, + min: 0, + max: 2, + step: 0.05, + format: ParamFormat.Extra, + isAdvanced: true, + }, + { + key: 'interests.inferred_weight.moderate', + group: ParamGroup.Learning, + label: 'Boost after 2 to 5 posts read', + help: "A topic you've read 1 post about doesn't count yet.", + min: 0, + max: 1.5, + step: 0.05, + format: ParamFormat.Boost, + }, + { + key: 'interests.inferred_weight.strong', + group: ParamGroup.Learning, + label: 'Boost after 6 or more posts read', + help: 'For a topic you keep reading about without following it.', + min: 0, + max: 1.5, + step: 0.05, + format: ParamFormat.Boost, + }, + { + key: 'interests.inferred_recency_half_life_days', + group: ParamGroup.Learning, + label: 'Reading fades by half after', + help: 'Days without reading about a topic before its boost drops by half.', + min: 3, + max: 90, + step: 1, + format: ParamFormat.Days, + }, + { + key: 'interests.inferred_recency_floor', + group: ParamGroup.Learning, + label: 'Reading never fades below', + help: 'The share of its boost a topic keeps, however long ago you read about it.', + min: 0, + max: 1, + step: 0.05, + format: ParamFormat.Percent, + }, + { + key: 'follows.source_multiplier', + group: ParamGroup.Follows, + label: 'Boost for a source you follow', + help: 'Where every source and squad you follow starts. You can change a single source in Sources and people.', + min: 1, + max: 3, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'follows.author_multiplier', + group: ParamGroup.Follows, + label: 'Boost for a person you follow', + help: 'For every post written by someone you follow.', + min: 1, + max: 3, + step: 0.05, + format: ParamFormat.Multiplier, + }, + { + key: 'age.half_life_days', + group: ParamGroup.Freshness, + label: 'A post counts half as much after', + help: 'Lower means newer posts first.', + min: 1, + max: 30, + step: 1, + format: ParamFormat.Days, + }, + { + key: 'age.max_stretch', + group: ParamGroup.Freshness, + label: 'Posts readers like age slower, up to', + help: 'Posts other readers like drop up to this many times slower.', + min: 1, + max: 4, + step: 0.1, + format: ParamFormat.Multiplier, + }, + { + key: 'diversity.topic_repeat_discount', + group: ParamGroup.Variety, + label: 'Mix up topics', + help: 'Each post on a similar topic already on the page ranks this much lower.', + min: 0, + max: 0.6, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'diversity.source_repeat_discount', + group: ParamGroup.Variety, + label: 'Mix up sources', + help: 'Each post from a source already on the page ranks this much lower.', + min: 0, + max: 0.5, + step: 0.01, + format: ParamFormat.Percent, + }, + { + key: 'diversity.same_headline_discount', + group: ParamGroup.Variety, + label: 'Skip the same story', + help: 'Lowers a post whose headline matches 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: 'Give each topic a turn, up to', + help: "Lifts the first post on a topic you haven't seen near the top lately. At ×1.00, topics don't take turns.", + min: 0, + max: 3, + step: 0.1, + format: ParamFormat.Boost, + }, + { + key: 'diversity.waiting_half_life_days', + group: ParamGroup.Variety, + label: "A topic's turn comes back after", + help: 'Days until a topic is halfway due for another turn.', + min: 0.5, + max: 14, + step: 0.5, + format: ParamFormat.Days, + isAdvanced: true, + }, + { + key: 'history.seen_factor_per_show', + group: ParamGroup.Repeats, + label: 'Each time you scroll past, a post keeps', + help: '100% means scrolling past never lowers a post.', + 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: 'Times on your screen without a click before it leaves your feed.', + min: 1, + max: 12, + step: 1, + format: ParamFormat.Count, + }, + { + key: 'relevance.side_topic_weight', + group: ParamGroup.Scope, + label: 'Side topics count', + help: "When your topic is only a side topic of a post, its boost counts this much. At 0%, those posts don't show up.", + min: 0, + max: 1, + step: 0.02, + format: ParamFormat.Percent, + }, + { + key: 'feed.max_candidate_age_days', + group: ParamGroup.Scope, + label: 'Oldest post', + help: 'Posts older than this never show up. The feed keeps 60 days of posts.', + min: 3, + max: 60, + step: 1, + format: ParamFormat.Days, + }, + { + 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, + }, +]; + +export const paramGroups: Array<{ + group: ParamGroup; + title: string; + description: string; +}> = [ + { + group: ParamGroup.Followed, + title: 'Topics you follow', + description: 'How much following a topic boosts posts about it.', + }, + { + group: ParamGroup.Learning, + title: 'Topics from your reading', + description: + 'Topics you read about in the last 90 days without following them. Reading means opening, upvoting, saving, commenting on or sharing a post.', + }, + { + group: ParamGroup.Follows, + title: 'Sources and people you follow', + description: 'Every post from them shows up, with this boost.', + }, + { + group: ParamGroup.Freshness, + title: 'Newer posts', + description: 'How fast a post drops as it gets older.', + }, + { + group: ParamGroup.Variety, + title: 'Variety', + description: 'How much each page mixes things up.', + }, + { + group: ParamGroup.Repeats, + title: 'Posts you scroll past', + description: 'What happens to posts you see and skip.', + }, + { + group: ParamGroup.Scope, + title: 'Which posts can show up', + description: "Posts that don't pass these never make your feed.", + }, +]; + +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.Boost: + return `×${(1 + value).toFixed(2)}`; + case ParamFormat.Extra: + return `+${value.toFixed(2)}`; + case ParamFormat.Days: + return `${value} ${value === 1 ? 'day' : 'days'}`; + case ParamFormat.Count: + return `${value} ${value === 1 ? 'time' : 'times'}`; + default: + return value.toFixed(2); + } +}; 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..5709feb1db --- /dev/null +++ b/packages/storybook/stories/features/feed-explain/policy/rank.ts @@ -0,0 +1,521 @@ +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; + /** An exact boost the user set for a source, replacing follow and Less. */ + sourceWeights?: 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 sourceWeight = state.sourceWeights?.[sourceId]; + let follow = + Math.max(sourceFollow, authorFollow) * + (sourceMode === SourceMode.Demoted + ? params['interests.control_demote'] + : 1); + if (sourceWeight !== undefined) { + follow = isAuthorFollowed + ? Math.max(sourceWeight, authorFollow) + : sourceWeight; + } + const factors: ScoreFactors = { + base, + match: 1 + (best?.contribution ?? 0), + follow, + 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);