diff --git a/frontend/src/lib/components/AddToHome/AddToHomeButton.tsx b/frontend/src/lib/components/AddToHome/AddToHomeButton.tsx
new file mode 100644
index 000000000000..fe8d5d55e600
--- /dev/null
+++ b/frontend/src/lib/components/AddToHome/AddToHomeButton.tsx
@@ -0,0 +1,22 @@
+import { useActions, useValues } from 'kea'
+
+import { LemonButton } from 'lib/lemon-ui/LemonButton'
+
+import { HomePinInsight, homePinsLogic } from './homePinsLogic'
+
+export function AddToHomeButton({ insight }: { insight: HomePinInsight }): JSX.Element {
+ const { homePinState } = useValues(homePinsLogic)
+ const { toggleHomePin } = useActions(homePinsLogic)
+ const { pinned, disabledReason } = homePinState(insight)
+
+ return (
+ toggleHomePin(insight, 'dashboard_card')}
+ disabledReason={disabledReason}
+ fullWidth
+ data-attr="insight-card-add-to-home"
+ >
+ {pinned ? 'Remove from home' : 'Add to home'}
+
+ )
+}
diff --git a/frontend/src/lib/components/AddToHome/homePinsLogic.test.ts b/frontend/src/lib/components/AddToHome/homePinsLogic.test.ts
new file mode 100644
index 000000000000..e1c213d90a54
--- /dev/null
+++ b/frontend/src/lib/components/AddToHome/homePinsLogic.test.ts
@@ -0,0 +1,83 @@
+import { expectLogic } from 'kea-test-utils'
+import posthog from 'posthog-js'
+
+import { useMocks } from '~/mocks/jest'
+import { initKeaTests } from '~/test/init'
+import { InsightShortId } from '~/types'
+
+import type { PulsePinApi } from 'products/today/frontend/generated/api.schemas'
+
+import { homePinsLogic } from './homePinsLogic'
+
+const pin = (shortId: string): PulsePinApi => ({ key: `insight:${shortId}`, name: shortId, dashboard_id: null })
+const insight = { short_id: 'new' as InsightShortId, name: 'Signups' }
+
+describe('homePinsLogic', () => {
+ let storedPins: PulsePinApi[]
+ let savedPins: PulsePinApi[][]
+
+ beforeEach(() => {
+ savedPins = []
+ useMocks({
+ get: { '/api/projects/:team_id/today/pulse_pins/': () => [200, { pins: storedPins }] },
+ put: {
+ '/api/projects/:team_id/today/pulse_pins/': async ({ request }) => {
+ const { pins } = (await request.json()) as { pins: PulsePinApi[] }
+ savedPins.push(pins)
+ return [200, { pins }]
+ },
+ },
+ })
+ initKeaTests()
+ jest.spyOn(posthog, 'capture').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ it.each([
+ {
+ name: 'adds the insight after the existing pins',
+ stored: [pin('a')],
+ label: { pinned: false, disabledReason: undefined },
+ saved: [[pin('a'), { key: 'insight:new', name: 'Signups', dashboard_id: null }]],
+ event: 'today pulse number pinned',
+ },
+ {
+ name: 'removes a pinned insight even when the home is full',
+ stored: [pin('a'), pin('new'), pin('c'), pin('d')],
+ label: { pinned: true, disabledReason: undefined },
+ saved: [[pin('a'), pin('c'), pin('d')]],
+ event: 'today pulse number unpinned',
+ },
+ {
+ name: 'does not add a fifth number',
+ stored: [pin('a'), pin('b'), pin('c'), pin('d')],
+ label: { pinned: false, disabledReason: 'Your home already shows 4 numbers. Remove one first.' },
+ saved: [],
+ event: null,
+ },
+ ])('$name', async ({ stored, label, saved, event }) => {
+ storedPins = stored
+ const logic = homePinsLogic()
+ logic.mount()
+ await expectLogic(logic).toFinishAllListeners()
+
+ expect(logic.values.homePinState(insight)).toEqual(label)
+
+ logic.actions.toggleHomePin(insight, 'dashboard_card')
+ await expectLogic(logic).toFinishAllListeners()
+
+ expect(savedPins).toEqual(saved)
+ if (event) {
+ expect(posthog.capture).toHaveBeenCalledWith(event, {
+ kind: 'insight',
+ source: 'dashboard_card',
+ pinned_count_before: stored.length,
+ })
+ } else {
+ expect(posthog.capture).not.toHaveBeenCalled()
+ }
+ })
+})
diff --git a/frontend/src/lib/components/AddToHome/homePinsLogic.ts b/frontend/src/lib/components/AddToHome/homePinsLogic.ts
new file mode 100644
index 000000000000..be9e38dcc24b
--- /dev/null
+++ b/frontend/src/lib/components/AddToHome/homePinsLogic.ts
@@ -0,0 +1,178 @@
+import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
+import { loaders } from 'kea-loaders'
+import posthog from 'posthog-js'
+
+import { toast } from '@posthog/quill'
+
+import { ApiError } from 'lib/api'
+import { teamLogic } from 'scenes/teamLogic'
+
+import { isInsightVizNode, isTrendsQuery } from '~/queries/schema/node-guards'
+import { ChartDisplayType, type InsightModel } from '~/types'
+
+import { todayPulsePinsRetrieve, todayPulsePinsUpdate } from 'products/today/frontend/generated/api'
+import type { PulsePinApi } from 'products/today/frontend/generated/api.schemas'
+
+export const HOME_PIN_LIMIT = 4
+
+const HOME_PIN_DISPLAYS: ChartDisplayType[] = [
+ ChartDisplayType.ActionsLineGraph,
+ ChartDisplayType.ActionsBar,
+ ChartDisplayType.ActionsStackedBar,
+ ChartDisplayType.ActionsAreaGraph,
+]
+
+export type HomePinSource = 'dashboard_card' | 'insight_menu'
+
+export type HomePinInsight = Pick & Partial>
+
+export interface HomePinState {
+ pinned: boolean
+ disabledReason: string | undefined
+}
+
+export function isHomePinnable(insight: Partial): insight is Partial & HomePinInsight {
+ const query = insight.query
+ return (
+ !!insight.short_id &&
+ isInsightVizNode(query) &&
+ isTrendsQuery(query.source) &&
+ HOME_PIN_DISPLAYS.includes(query.source.trendsFilter?.display ?? ChartDisplayType.ActionsLineGraph)
+ )
+}
+
+function homePinKey(insight: HomePinInsight): string {
+ return `insight:${insight.short_id}`
+}
+
+// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
+export interface homePinsLogicValues {
+ currentProjectId: number | null
+ homePinState: (insight: HomePinInsight) => HomePinState
+ homePins: PulsePinApi[] | null
+ homePinsLoading: boolean
+ homePinsSaving: boolean
+}
+
+// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
+export interface homePinsLogicActions {
+ loadHomePins: () => any
+ loadHomePinsFailure: (
+ error: string,
+ errorObject?: any
+ ) => {
+ error: string
+ errorObject?: any
+ }
+ loadHomePinsSuccess: (
+ homePins: PulsePinApi[] | null,
+ payload?: any
+ ) => {
+ homePins: PulsePinApi[] | null
+ payload?: any
+ }
+ setHomePinsSaving: (saving: boolean) => {
+ saving: boolean
+ }
+ toggleHomePin: (
+ insight: HomePinInsight,
+ source: HomePinSource
+ ) => {
+ insight: HomePinInsight
+ source: HomePinSource
+ }
+}
+
+export type homePinsLogicType = MakeLogicType
+
+function nextHomePins(pins: PulsePinApi[], insight: HomePinInsight, pinned: boolean): PulsePinApi[] {
+ const key = homePinKey(insight)
+ if (pinned) {
+ return pins.filter((pin) => pin.key !== key)
+ }
+ return [...pins, { key, name: insight.name || insight.derived_name || 'Untitled insight', dashboard_id: null }]
+}
+
+export const homePinsLogic = kea([
+ path(['lib', 'components', 'AddToHome', 'homePinsLogic']),
+ connect(() => ({
+ values: [teamLogic, ['currentProjectId']],
+ })),
+ actions({
+ toggleHomePin: (insight: HomePinInsight, source: HomePinSource) => ({ insight, source }),
+ setHomePinsSaving: (saving: boolean) => ({ saving }),
+ }),
+ loaders(({ values }) => ({
+ homePins: [
+ null as PulsePinApi[] | null,
+ {
+ loadHomePins: async (): Promise => {
+ try {
+ const { pins } = await todayPulsePinsRetrieve(String(values.currentProjectId))
+ return pins
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 404) {
+ return []
+ }
+ throw error
+ }
+ },
+ },
+ ],
+ })),
+ reducers({
+ homePinsSaving: [false, { setHomePinsSaving: (_, { saving }) => saving }],
+ }),
+ selectors({
+ homePinState: [
+ (s) => [s.homePins, s.homePinsLoading, s.homePinsSaving],
+ (homePins: PulsePinApi[] | null, homePinsLoading: boolean, homePinsSaving: boolean) =>
+ (insight: HomePinInsight): HomePinState => {
+ const pinned = !!homePins?.some((pin) => pin.key === homePinKey(insight))
+ const disabledReason =
+ homePinsLoading || homePinsSaving
+ ? 'Loading your home numbers'
+ : homePins === null
+ ? "Couldn't load your home numbers. Open this menu again to retry."
+ : !pinned && homePins.length >= HOME_PIN_LIMIT
+ ? `Your home already shows ${HOME_PIN_LIMIT} numbers. Remove one first.`
+ : undefined
+ return { pinned, disabledReason }
+ },
+ ],
+ }),
+ listeners(({ actions, values, cache }) => ({
+ toggleHomePin: async ({ insight, source }) => {
+ const pins = values.homePins
+ const { pinned, disabledReason } = values.homePinState(insight)
+ if (!pins || disabledReason) {
+ return
+ }
+ const next = nextHomePins(pins, insight, pinned)
+ actions.setHomePinsSaving(true)
+ let saved: PulsePinApi[]
+ try {
+ saved = (await todayPulsePinsUpdate(String(values.currentProjectId), { pins: next })).pins
+ } catch {
+ toast.error({ title: "Couldn't update your home. Try again." })
+ if (!cache.disposables.isDisposed) {
+ actions.setHomePinsSaving(false)
+ }
+ return
+ }
+ posthog.capture(pinned ? 'today pulse number unpinned' : 'today pulse number pinned', {
+ kind: 'insight',
+ source,
+ pinned_count_before: pins.length,
+ })
+ toast.success({ title: pinned ? 'Removed from home' : 'Added to home' })
+ if (!cache.disposables.isDisposed) {
+ actions.loadHomePinsSuccess(saved)
+ actions.setHomePinsSaving(false)
+ }
+ },
+ })),
+ afterMount(({ actions }) => {
+ actions.loadHomePins()
+ }),
+])
diff --git a/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx b/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx
index f95486f57e6d..ce40046e461d 100644
--- a/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx
+++ b/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx
@@ -5,6 +5,8 @@ import React, { useMemo, useState } from 'react'
import { IconChevronRight, IconClock, IconPulse, IconThumbsDown, IconThumbsUp, IconWarning } from '@posthog/icons'
import { lemonToast } from '@posthog/lemon-ui'
+import { AddToHomeButton } from 'lib/components/AddToHome/AddToHomeButton'
+import { isHomePinnable } from 'lib/components/AddToHome/homePinsLogic'
import { CardMeta } from 'lib/components/Cards/CardMeta'
import { CardMetaRefreshButton } from 'lib/components/Cards/CardMetaRefreshButton'
import { CompactInfoPopover } from 'lib/components/Cards/InsightCard/CompactInfoPopover'
@@ -623,6 +625,9 @@ function InsightMetaBody({
Duplicate
)}
+ {featureFlags[FEATURE_FLAGS.TODAY_RAIL_NAV] && isHomePinnable(insight) && (
+
+ )}
{showDashboardAlertsMenuItem && insight.id ? (
{
diff --git a/products/product_analytics/frontend/insights/insightScene/AddToHomeMenuItem.tsx b/products/product_analytics/frontend/insights/insightScene/AddToHomeMenuItem.tsx
new file mode 100644
index 000000000000..f96f73d09d2b
--- /dev/null
+++ b/products/product_analytics/frontend/insights/insightScene/AddToHomeMenuItem.tsx
@@ -0,0 +1,23 @@
+import { useActions, useValues } from 'kea'
+
+import { IconHome } from '@posthog/icons'
+import { DropdownMenuItem } from '@posthog/quill'
+
+import { HomePinInsight, homePinsLogic } from 'lib/components/AddToHome/homePinsLogic'
+
+export function AddToHomeMenuItem({ insight }: { insight: HomePinInsight }): JSX.Element {
+ const { homePinState } = useValues(homePinsLogic)
+ const { toggleHomePin } = useActions(homePinsLogic)
+ const { pinned, disabledReason } = homePinState(insight)
+
+ return (
+ toggleHomePin(insight, 'insight_menu')}
+ disabledReason={disabledReason}
+ >
+
+ {pinned ? 'Remove from home' : 'Add to home'}
+
+ )
+}
diff --git a/products/product_analytics/frontend/insights/insightScene/QuillInsightPageHeaderActions.tsx b/products/product_analytics/frontend/insights/insightScene/QuillInsightPageHeaderActions.tsx
index de57cb726754..9e27d83f25ea 100644
--- a/products/product_analytics/frontend/insights/insightScene/QuillInsightPageHeaderActions.tsx
+++ b/products/product_analytics/frontend/insights/insightScene/QuillInsightPageHeaderActions.tsx
@@ -11,6 +11,7 @@ import {
} from '@posthog/quill'
import { AccessControlAction } from 'lib/components/AccessControlAction'
+import { isHomePinnable } from 'lib/components/AddToHome/homePinsLogic'
import { InsightSubscribeProminentButton } from 'lib/components/Scenes/InsightSubscribeProminentButton'
import { useFeatureFlag } from 'lib/hooks/useFeatureFlag'
import { insightDataLogic } from 'scenes/insights/insightDataLogic'
@@ -26,6 +27,7 @@ import { AccessControlLevel, AccessControlResourceType, InsightLogicProps, ItemM
import { insightModalsLogic } from 'products/product_analytics/frontend/insights/insightScene/insightModalsLogic'
import { urlForSubscriptions } from 'products/subscriptions/frontend/components/Subscriptions/utils'
+import { AddToHomeMenuItem } from './AddToHomeMenuItem'
import { InsightShareMenu, useInsightShareItems } from './InsightShareMenu'
import { openInsightEditor } from './openInsightEditor'
import { QuillInsightSaveButton } from './QuillInsightSaveButton'
@@ -112,6 +114,7 @@ export function QuillInsightPageHeaderActions({
Add to dashboard
+ {isHomePinnable(insight) && }
{canCreateAlertForInsight && (