+ // 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 && (
-
}
- onClick={onUndo}
- >
- Undo
-
+ 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',
)}
-
+
+
- {hasChanges ? 'Refresh feed' : 'Done'}
-
+ {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 }) => (
+ onTabChange(id)}
+ className={classNames(
+ 'h-8 rounded-10 px-3 font-bold typo-footnote',
+ tab === id
+ ? 'bg-surface-float text-text-primary'
+ : 'text-text-tertiary hover:text-text-primary',
+ )}
+ >
+ {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 (
+
+
+ }
+ onClick={onBack}
+ >
+ For You
+
+
+
+
+ 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
+
+
+
+
+
+
+ {navItems.map((item) => (
+ goTo(item.section)}
+ className="flex h-8 shrink-0 items-center rounded-10 px-3 font-bold text-text-tertiary typo-footnote hover:bg-surface-float hover:text-text-primary"
+ >
+ {item.label}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!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 (
+ onChange(option.value)}
+ className={classNames(
+ 'h-7 rounded-8 px-2.5 font-bold typo-footnote transition-colors',
+ isActive
+ ? classNames(
+ 'bg-background-default shadow-2',
+ option.activeClassName ?? 'text-text-primary',
+ )
+ : 'text-text-tertiary hover:text-text-primary',
+ )}
+ >
+ {option.label}
+
+ );
+ })}
+
+);
+
+export const ToggleRow = ({
+ id,
+ label,
+ description,
+ checked,
+ onToggle,
+}: {
+ id: string;
+ label: string;
+ description?: ReactNode;
+ checked: boolean;
+ onToggle: () => void;
+}): ReactElement => (
+
+
+
+ {label}
+
+ {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}
+
+ }
+ aria-label={resetLabel}
+ title={resetLabel}
+ className={classNames('shrink-0', !isSet && 'invisible')}
+ onClick={onReset}
+ />
+
+);
+
+/** A labelled slider for one ranking setting, with a reset once changed. */
+export const ParamSlider = ({
+ paramKey,
+ isCompact,
+}: {
+ paramKey: ParamKey;
+ isCompact?: boolean;
+}): ReactElement => {
+ const { state, setParam, resetParam } = usePersonalization();
+ const meta = getParamMeta(paramKey);
+ const value = state.params[paramKey];
+ const isDefault = value === defaultParams[paramKey];
+
+ return (
+
+
+
+ {meta.label}
+
+
+ {!isDefault && (
+ resetParam(paramKey)}
+ >
+ Reset to {formatParam(meta.format, defaultParams[paramKey])}
+
+ )}
+
+ {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}
+
+ : }
+ aria-label={isCopied ? 'Copied' : `Copy: ${label}`}
+ className="-my-1 shrink-0"
+ onClick={() => {
+ navigator.clipboard?.writeText(text);
+ setIsCopied(true);
+ }}
+ />
+
+
+ );
+};
+
+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 (
+ <>
+ }
+ onClick={() => setIsOpen(true)}
+ >
+ Connect your agent
+
+ {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={
+ store.setTagFollowed(interest.tag, !isFollowed)}
+ >
+ {isFollowed ? 'Unfollow' : 'Follow'}
+
+ }
+ />
+ );
+};
+
+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) => (
+ }
+ onClick={() => store.setTagFollowed(tag, true)}
+ >
+ {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 && (
+ onOpenDetails(decision)}
+ >
+ Why
+
+ )}
+ >
+ }
+ 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) => (
+
+ {label}
+
+ );
+
+ 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 (
+
+ setIsOpen(!isOpen)}
+ className="flex items-center justify-between gap-3 py-2 text-left"
+ >
+
+
+ {exclusionLabel[exclusion]}
+
+ {exclusionHelp[exclusion] && (
+
+ {exclusionHelp[exclusion]}
+
+ )}
+
+
+ {decisions.length}
+
+
+
+ {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 && (
+
+
+ Forget what I scrolled past
+
+
+ )}
+
+ )}
+
+ );
+};
+
+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 && (
+
+ Reset all
+
+ )}
+
+ }
+ >
+
+ {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}
+ )}
+
+
+
+
+
+);
+
+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 ? (
+
+ setIsConfirming(false)}
+ >
+ Cancel
+
+ {
+ onReset();
+ setIsConfirming(false);
+ }}
+ >
+ Yes, reset
+
+
+ ) : (
+ setIsConfirming(true)}
+ >
+ Reset
+
+ )}
+
+ );
+};
+
+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={
+ store.setSourceFollowed(entity, !isFollowed)}
+ >
+ {isFollowed ? 'Unfollow' : 'Follow'}
+
+ }
+ />
+ );
+};
+
+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) => (
+ }
+ onClick={() =>
+ store.setSourceFollowed(
+ { id: source.id, name: source.name ?? source.id },
+ true,
+ )
+ }
+ >
+ {source.name}
+
+ ))}
+
+
+ )}
+
+ );
+};
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);