diff --git a/packages/common/src/models/Analytics.ts b/packages/common/src/models/Analytics.ts index 400faca5c0f..8b2f08d1062 100644 --- a/packages/common/src/models/Analytics.ts +++ b/packages/common/src/models/Analytics.ts @@ -685,7 +685,8 @@ export enum RepostSource { LIBRARY_PAGE = 'library page', OVERFLOW = 'overflow', TRACK_LIST = 'track list', - PURCHASE = 'purchase' + PURCHASE = 'purchase', + WEEKLY_ROTATION = 'weekly rotation' } export enum FavoriteSource { TILE = 'tile', @@ -703,7 +704,8 @@ export enum FavoriteSource { // you had a smart collection and it was favorited so it // shows in your left-nav. IMPLICIT = 'implicit', - NAVIGATOR = 'navigator' + NAVIGATOR = 'navigator', + WEEKLY_ROTATION = 'weekly rotation' } export enum FollowSource { INBOX_UNAVAILABLE_MODAL = 'inbox unavailable modal', @@ -725,6 +727,7 @@ type Share = { source: ShareSource id: string url: string + channel?: 'copyLink' | 'shareSheet' } export type ShareToTwitter = { @@ -1064,6 +1067,10 @@ type NotificationsOpenPushNotification = { title?: string body?: string notificationCampaignId?: string + /** The push payload's `data.type`, e.g. `WeeklyRotation` */ + type?: string + /** The push payload's `data.id`, when it has one */ + id?: string } type NotificationsClickTile = { eventName: Name.NOTIFICATIONS_CLICK_TILE @@ -1172,7 +1179,8 @@ export enum PlaybackSource { CHAT_TRACK = 'chat_track', CHAT_PLAYLIST_TRACK = 'chat_playlist_track', SEARCH_PAGE = 'search page', - EXCLUSIVE_TRACKS_PAGE = 'exclusive tracks page' + EXCLUSIVE_TRACKS_PAGE = 'exclusive tracks page', + WEEKLY_ROTATION = 'weekly rotation' } type PlaybackPlay = { @@ -1184,6 +1192,9 @@ type PlaybackPlay = { // Which feed view the play originated from (matches FEED_CHANGE_VIEW's // `view` values). Only present for plays coming from the feed lineup. feed_type?: FeedTab + // Owner of the Weekly Rotation mix the track was queued from. Set on + // passive plays so auto-advance through a mix is attributable to it. + weeklyRotationOwnerId?: string } type PlaybackPause = { eventName: Name.PLAYBACK_PAUSE @@ -1280,7 +1291,7 @@ export enum ModalSource { // Modals type ModalOpened = { eventName: Name.MODAL_OPENED - source: ModalSource + source: ModalSource | ShareSource name: string } & Record // For passing state values @@ -1359,31 +1370,35 @@ type ExploreSectionClick = { /** Surface the banner was rendered on, so events can be split by entry point. */ export type WeeklyRotationSurface = 'explore' | 'feed' -type WeeklyRotationBannerView = { - eventName: Name.WEEKLY_ROTATION_BANNER_VIEW - surface: WeeklyRotationSurface +type WeeklyRotationMixProperties = { source: 'web' | 'mobile' trackCount: number + /** The mix's period, e.g. `2026-38` (see getWeeklyRotationPeriod) */ + period: string + /** Whether the viewer is looking at their own mix rather than a shared one */ + isOwnMix: boolean + ownerUserId?: string } +type WeeklyRotationBannerView = { + eventName: Name.WEEKLY_ROTATION_BANNER_VIEW + surface: WeeklyRotationSurface +} & WeeklyRotationMixProperties + type WeeklyRotationBannerClick = { eventName: Name.WEEKLY_ROTATION_BANNER_CLICK surface: WeeklyRotationSurface - source: 'web' | 'mobile' - trackCount: number -} +} & WeeklyRotationMixProperties type WeeklyRotationPageView = { eventName: Name.WEEKLY_ROTATION_PAGE_VIEW - source: 'web' | 'mobile' - trackCount: number -} + status: 'success' | 'empty' | 'error' + isSignedIn: boolean +} & WeeklyRotationMixProperties type WeeklyRotationPlayAll = { eventName: Name.WEEKLY_ROTATION_PLAY_ALL - source: 'web' | 'mobile' - trackCount: number -} +} & WeeklyRotationMixProperties type BrowserNotificationSetting = { eventName: Name.BROWSER_NOTIFICATION_SETTINGS @@ -2081,6 +2096,14 @@ type ChatReportUser = { type ChatEntryPoint = { eventName: Name.CHAT_ENTRY_POINT source: 'banner' | 'navmenu' | 'share' | 'profile' + /** What was shared, when source is `share` */ + kind?: + | 'profile' + | 'album' + | 'playlist' + | 'track' + | 'contest' + | 'weeklyRotation' } type ChatWebsocketError = { diff --git a/packages/common/src/store/ui/share-modal/sagas.test.ts b/packages/common/src/store/ui/share-modal/sagas.test.ts new file mode 100644 index 00000000000..7bd873957da --- /dev/null +++ b/packages/common/src/store/ui/share-modal/sagas.test.ts @@ -0,0 +1,37 @@ +import { expectSaga } from 'redux-saga-test-plan' +import * as matchers from 'redux-saga-test-plan/matchers' +import { describe, it, vitest } from 'vitest' + +import { Name, ShareSource } from '~/models/Analytics' + +import { setVisibility } from '../modals/parentSlice' + +import sagas from './sagas' +import { requestOpen } from './slice' + +describe('share modal sagas', () => { + it('opens the modal and records what is being shared', async () => { + const track = vitest.fn() + const make = vitest.fn((event) => event) + const [watchRequestOpen] = sagas() + + await expectSaga(watchRequestOpen) + .provide([[matchers.getContext('analytics'), { track, make }]]) + .dispatch( + requestOpen({ + type: 'weeklyRotation', + userId: 7, + source: ShareSource.PAGE + }) + ) + .put(setVisibility({ modal: 'Share', visible: true })) + .call(track, { + eventName: Name.MODAL_OPENED, + name: 'Share', + source: ShareSource.PAGE, + kind: 'weeklyRotation', + userId: 7 + }) + .silentRun() + }) +}) diff --git a/packages/common/src/store/ui/share-modal/sagas.ts b/packages/common/src/store/ui/share-modal/sagas.ts index 770282f7a7b..c5b61a6473c 100644 --- a/packages/common/src/store/ui/share-modal/sagas.ts +++ b/packages/common/src/store/ui/share-modal/sagas.ts @@ -1,4 +1,7 @@ -import { takeEvery, put } from 'typed-redux-saga' +import { takeEvery, put, call } from 'typed-redux-saga' + +import { Name } from '~/models/Analytics' +import { getContext } from '~/store/effects' import { setVisibility } from '../modals/parentSlice' @@ -7,9 +10,23 @@ import { requestOpen } from './slice' // The previous saga fetched track/user/collection data before opening the // modal. That work now lives in `useShareContent` so the modal loads its own // data via TanStack Query. All that remains is to flip the nice-modal -// visibility once a share request lands in the slice. -function* handleRequestOpen() { +// visibility and record the open once a share request lands in the slice. +function* handleRequestOpen({ payload }: ReturnType) { yield put(setVisibility({ modal: 'Share', visible: true })) + + // `ids` is the request's entity id, e.g. `trackId` or `userId`. + const { source, type, ...ids } = payload + const { track, make } = yield* getContext('analytics') + yield* call( + track, + make({ + eventName: Name.MODAL_OPENED, + name: 'Share', + source, + kind: type, + ...ids + }) + ) } function* watchHandleRequestOpen() { diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 50ab9d84c28..126e2a704cb 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -38,6 +38,7 @@ export { default as dayjs } from './dayjs' export { Dayjs } from './dayjs' export * from './promiseWithTimeout' export * from './handleReservedStatus' +export * from './weeklyRotation' export * from './email' export * from './commonPasswordCheck' export * from './restrictedHandles' diff --git a/packages/common/src/utils/weeklyRotation.test.ts b/packages/common/src/utils/weeklyRotation.test.ts new file mode 100644 index 00000000000..55f2a9ac04c --- /dev/null +++ b/packages/common/src/utils/weeklyRotation.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import { + formatWeeklyRotationPeriod, + getWeeklyRotationOwnerIdFromQueueSource, + getWeeklyRotationPeriod, + getWeeklyRotationQueueSource +} from './weeklyRotation' + +const utc = (y: number, m: number, d: number, h = 0) => + new Date(Date.UTC(y, m - 1, d, h)) + +// Same cases as TestPeriod in the api repo (weeklyrotation/period_test.go). +describe('getWeeklyRotationPeriod', () => { + it('opens ISO week 37 at the Wednesday rollover', () => { + // 2026-09-09 is a Wednesday. + expect(getWeeklyRotationPeriod(utc(2026, 9, 9))).toEqual({ + year: 2026, + week: 37 + }) + }) + + it('keeps Monday and Tuesday in the period that started the previous Wednesday', () => { + expect(getWeeklyRotationPeriod(utc(2026, 9, 7, 12))).toEqual({ + year: 2026, + week: 36 + }) + expect(getWeeklyRotationPeriod(utc(2026, 9, 8, 23))).toEqual({ + year: 2026, + week: 36 + }) + }) + + it('works in UTC regardless of the caller timezone offset', () => { + // 2026-09-08 20:00 PDT is 2026-09-09 03:00 UTC. + expect( + getWeeklyRotationPeriod(new Date('2026-09-08T20:00:00-07:00')) + ).toEqual({ year: 2026, week: 37 }) + }) + + it('handles the year boundary', () => { + // ISO week 1 of 2027 starts Monday 2027-01-04, so its period starts + // Wednesday 2027-01-06; the days before belong to 2026's week 53. + expect(getWeeklyRotationPeriod(utc(2027, 1, 6))).toEqual({ + year: 2027, + week: 1 + }) + expect(getWeeklyRotationPeriod(utc(2027, 1, 5, 23))).toEqual({ + year: 2026, + week: 53 + }) + }) +}) + +describe('formatWeeklyRotationPeriod', () => { + it('zero-pads the week', () => { + expect(formatWeeklyRotationPeriod({ year: 2027, week: 1 })).toBe('2027-01') + expect(formatWeeklyRotationPeriod({ year: 2026, week: 37 })).toBe('2026-37') + }) +}) + +describe('weekly rotation queue source', () => { + it('round-trips the owner id', () => { + const source = getWeeklyRotationQueueSource(123) + expect(source).toBe('WEEKLY_ROTATION_TRACKS:123') + expect(getWeeklyRotationOwnerIdFromQueueSource(source)).toBe(123) + }) + + it('returns null for other sources and a missing owner', () => { + expect(getWeeklyRotationOwnerIdFromQueueSource('trending-week')).toBeNull() + expect(getWeeklyRotationOwnerIdFromQueueSource(null)).toBeNull() + expect( + getWeeklyRotationOwnerIdFromQueueSource( + getWeeklyRotationQueueSource(undefined) + ) + ).toBeNull() + }) +}) diff --git a/packages/common/src/utils/weeklyRotation.ts b/packages/common/src/utils/weeklyRotation.ts new file mode 100644 index 00000000000..89264e0d28b --- /dev/null +++ b/packages/common/src/utils/weeklyRotation.ts @@ -0,0 +1,57 @@ +/** + * The Weekly Rotation period, as the API defines it: identified by an ISO + * (year, week) pair but rolling over on Wednesday 00:00 UTC rather than + * Monday. Mirrors `weeklyrotation.Period` in the api repo + * (weeklyrotation/period.go). + * + * Kept dependency-free because the web SSR bundle imports it. + */ + +const ROLLOVER_OFFSET_DAYS = 2 // ISO Monday -> Wednesday +const MS_PER_DAY = 86_400_000 + +export type WeeklyRotationPeriod = { year: number; week: number } + +export const getWeeklyRotationPeriod = ( + date: Date = new Date() +): WeeklyRotationPeriod => { + // Shift back so a period that started on Wednesday maps onto the ISO week + // whose Monday it belongs to, then do the standard ISO week calculation: + // the ISO week of a date is the week of that date's Thursday. + const d = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + ) + d.setUTCDate(d.getUTCDate() - ROLLOVER_OFFSET_DAYS) + const isoWeekday = d.getUTCDay() || 7 + d.setUTCDate(d.getUTCDate() + 4 - isoWeekday) + const year = d.getUTCFullYear() + const yearStart = Date.UTC(year, 0, 1) + const week = Math.ceil(((d.getTime() - yearStart) / MS_PER_DAY + 1) / 7) + return { year, week } +} + +/** `2026-37`: stable, sortable, safe in a query string. */ +export const formatWeeklyRotationPeriod = ({ + year, + week +}: WeeklyRotationPeriod) => `${year}-${String(week).padStart(2, '0')}` + +const WEEKLY_ROTATION_QUEUE_SOURCE = 'WEEKLY_ROTATION_TRACKS' + +/** + * Playback queue source for a user's mix. Per owner so your own mix and a + * shared one don't share play state. + */ +export const getWeeklyRotationQueueSource = ( + ownerUserId: number | null | undefined +) => `${WEEKLY_ROTATION_QUEUE_SOURCE}:${ownerUserId ?? ''}` + +/** The mix owner's id if `source` is a Weekly Rotation queue source. */ +export const getWeeklyRotationOwnerIdFromQueueSource = ( + source: string | null | undefined +): number | null => { + const prefix = `${WEEKLY_ROTATION_QUEUE_SOURCE}:` + if (!source?.startsWith(prefix)) return null + const ownerUserId = Number(source.slice(prefix.length)) + return Number.isInteger(ownerUserId) && ownerUserId > 0 ? ownerUserId : null +} diff --git a/packages/mobile/src/components/lineup-tile/TrackTile.tsx b/packages/mobile/src/components/lineup-tile/TrackTile.tsx index 6ebdda6f62c..ae53d8830c1 100644 --- a/packages/mobile/src/components/lineup-tile/TrackTile.tsx +++ b/packages/mobile/src/components/lineup-tile/TrackTile.tsx @@ -64,6 +64,8 @@ const TrackTileComponent = (props: TrackTileProps) => { variant, style, showArtistPick = false, + favoriteSource = FavoriteSource.TILE, + repostSource = RepostSource.TILE, ...lineupTileProps } = props @@ -261,17 +263,17 @@ const TrackTileComponent = (props: TrackTileProps) => { const handlePressSave = useToggleFavoriteTrack({ trackId: track?.track_id as number, - source: FavoriteSource.TILE + source: favoriteSource }) const handlePressRepost = useCallback(() => { if (!track) return if (track.has_current_user_reposted) { - dispatch(undoRepostTrack(track.track_id, RepostSource.TILE)) + dispatch(undoRepostTrack(track.track_id, repostSource)) } else { - dispatch(repostTrack(track.track_id, RepostSource.TILE)) + dispatch(repostTrack(track.track_id, repostSource)) } - }, [track, dispatch]) + }, [track, dispatch, repostSource]) const publish = useCallback(() => { if (!track) return diff --git a/packages/mobile/src/components/lineup-tile/types.ts b/packages/mobile/src/components/lineup-tile/types.ts index f5794ff8416..a85a665cee3 100644 --- a/packages/mobile/src/components/lineup-tile/types.ts +++ b/packages/mobile/src/components/lineup-tile/types.ts @@ -1,6 +1,13 @@ import type { ReactNode } from 'react' -import type { PlaybackSource, Collection, ID, UID } from '@audius/common/models' +import type { + PlaybackSource, + Collection, + FavoriteSource, + ID, + RepostSource, + UID +} from '@audius/common/models' import type { EnhancedCollectionTrack } from '@audius/common/store' import type { StyleProp, ViewStyle } from 'react-native' @@ -37,6 +44,8 @@ export type TrackTileProps = { style?: StyleProp source?: LineupTileSource showArtistPick?: boolean + favoriteSource?: FavoriteSource + repostSource?: RepostSource } export type CollectionTileProps = { diff --git a/packages/mobile/src/components/lineup/TrackLineup.tsx b/packages/mobile/src/components/lineup/TrackLineup.tsx index 93265fd2dce..f95ded43ac5 100644 --- a/packages/mobile/src/components/lineup/TrackLineup.tsx +++ b/packages/mobile/src/components/lineup/TrackLineup.tsx @@ -12,8 +12,11 @@ import type { LineupData } from '@audius/common/api' import { useCollections } from '@audius/common/api' import { Kind, + Name, + type FavoriteSource, type ID, type PlaybackSource, + type RepostSource, type UID } from '@audius/common/models' import { playbackActions, playbackSelectors } from '@audius/common/store' @@ -37,6 +40,7 @@ import { LineupTileSkeleton } from 'app/components/lineup-tile' import { useScrollToTop } from 'app/hooks/useScrollToTop' +import { make, track } from 'app/services/analytics' const { makeGetCurrent } = playbackSelectors const { getPlaying } = playbackSelectors @@ -124,7 +128,10 @@ export type TrackLineupProps = { pullToRefresh?: boolean disableTopTabScroll?: boolean onPressItem?: (id: ID) => void + /** When set, tile plays and pauses record Playback events with it */ playbackSource?: PlaybackSource + favoriteSource?: FavoriteSource + repostSource?: RepostSource /** * Map of indices (into `trackIds`) to JSX elements rendered after the @@ -168,6 +175,9 @@ export const TrackLineup = ({ pullToRefresh, disableTopTabScroll, onPressItem, + playbackSource, + favoriteSource, + repostSource, delineatorMap }: TrackLineupProps) => { const dispatch = useDispatch() @@ -235,12 +245,25 @@ export const TrackLineup = ({ const currentTrackId = currentLegacy?.trackId ?? null const currentSource = currentLegacy?.source ?? null const isSameTile = currentTrackId === id && currentSource === source + const recordPlayback = (play: boolean) => { + if (!playbackSource) return + const properties = { id: `${id}`, source: playbackSource } + track( + make( + play + ? { eventName: Name.PLAYBACK_PLAY, ...properties } + : { eventName: Name.PLAYBACK_PAUSE, ...properties } + ) + ) + } if (isSameTile && isPlaying) { dispatch(playbackActions.togglePlay()) + recordPlayback(false) return } if (isSameTile && !isPlaying) { dispatch(playbackActions.play()) + recordPlayback(true) return } // For a collection tile, locate the track within that collection's queue @@ -261,6 +284,7 @@ export const TrackLineup = ({ querySource: querySource ?? null }) ) + recordPlayback(true) }, [ dispatch, @@ -269,7 +293,8 @@ export const TrackLineup = ({ currentLegacy?.trackId, currentLegacy?.source, source, - isPlaying + isPlaying, + playbackSource ] ) @@ -348,6 +373,8 @@ export const TrackLineup = ({ togglePlay={togglePlay} onPress={onPressItem} showArtistPick={showArtistPick} + favoriteSource={favoriteSource} + repostSource={repostSource} /> ) : ( { presetMessage: getContentUrl(content), defaultUserList: 'chats' }) - track(make({ eventName: Name.CHAT_ENTRY_POINT, source: 'share' })) + track( + make({ + eventName: Name.CHAT_ENTRY_POINT, + source: 'share', + kind: content.type + }) + ) if (source === ShareSource.NOW_PLAYING) { onCloseNowPlaying() } @@ -123,7 +129,18 @@ export const ShareDrawer = NiceModal.create(() => { } else { console.error(`Can't open: ${xShareUrl}`) } - }, [content]) + if (content.type === 'weeklyRotation' && source) { + track( + make({ + eventName: Name.SHARE_TO_TWITTER, + kind: 'weeklyRotation', + id: content.user.user_id, + url: getContentUrl(content), + source + }) + ) + } + }, [content, source]) const { handleShareToStoryStickerLoad, @@ -143,7 +160,19 @@ export const ShareDrawer = NiceModal.create(() => { type: 'info', timeout: shareToastTimeout }) - }, [toast, content, shareType]) + if (content.type === 'weeklyRotation' && source) { + track( + make({ + eventName: Name.SHARE, + kind: 'weeklyRotation', + id: `${content.user.user_id}`, + url: link, + source, + channel: 'copyLink' + }) + ) + } + }, [toast, content, shareType, source]) const handleOpenShareSheet = useCallback(() => { if (!source || !content) return @@ -174,7 +203,8 @@ export const ShareDrawer = NiceModal.create(() => { kind: 'weeklyRotation', id: `${content.user.user_id}`, url, - source + source, + channel: 'shareSheet' }) ) break diff --git a/packages/mobile/src/notifications.ts b/packages/mobile/src/notifications.ts index 163f67e459a..036638238f6 100644 --- a/packages/mobile/src/notifications.ts +++ b/packages/mobile/src/notifications.ts @@ -114,12 +114,20 @@ class PushNotifications { const { title, body, payload } = notification const notificationCampaignId = extractNotificationCampaignIdFromPayload(payload) + let data = payload?.data?.data ?? payload?.data ?? payload + // On Android, FCM delivers all data values as strings, breaking + // numeric ID fields and nested objects. Parse them back. + if (Platform.OS === MobileOS.ANDROID && data && typeof data === 'object') { + data = parseAndroidNotificationData(data) + } track( make({ eventName: EventNames.NOTIFICATIONS_OPEN_PUSH_NOTIFICATION, title, body, - notificationCampaignId + notificationCampaignId, + type: typeof data?.type === 'string' ? data.type : undefined, + id: data?.id != null ? `${data.id}` : undefined }) ) if (notificationCampaignId) { @@ -127,12 +135,6 @@ class PushNotifications { reportNotificationCampaignPushOpen(notificationCampaignId) ).catch(() => {}) } - let data = payload?.data?.data ?? payload?.data ?? payload - // On Android, FCM delivers all data values as strings, breaking - // numeric ID fields and nested objects. Parse them back. - if (Platform.OS === MobileOS.ANDROID && data && typeof data === 'object') { - data = parseAndroidNotificationData(data) - } this.navigation?.navigate(data) } diff --git a/packages/mobile/src/screens/explore-screen/components/WeeklyRotation.tsx b/packages/mobile/src/screens/explore-screen/components/WeeklyRotation.tsx index b1693716435..9ba7f5addda 100644 --- a/packages/mobile/src/screens/explore-screen/components/WeeklyRotation.tsx +++ b/packages/mobile/src/screens/explore-screen/components/WeeklyRotation.tsx @@ -1,10 +1,14 @@ -import React, { useCallback, useEffect, useRef } from 'react' +import React, { useCallback, useEffect, useMemo, useRef } from 'react' -import { useWeeklyRotation } from '@audius/common/api' +import { useCurrentUserId, useWeeklyRotation } from '@audius/common/api' import { useAnalytics, useFeatureFlag } from '@audius/common/hooks' import { exploreMessages as messages } from '@audius/common/messages' import { Name, type WeeklyRotationSurface } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' +import { + formatWeeklyRotationPeriod, + getWeeklyRotationPeriod +} from '@audius/common/utils' import { Image } from 'react-native' import { Flex, Paper, Text } from '@audius/harmony-native' @@ -33,6 +37,7 @@ export const WeeklyRotation = ({ const { InViewWrapper, inView } = useExploreSectionTracking('Weekly Rotation') const navigation = useNavigation() const { trackEvent } = useAnalytics() + const { data: currentUserId } = useCurrentUserId() const { isEnabled: isWeeklyRotationEnabled } = useFeatureFlag( FeatureFlags.WEEKLY_ROTATION ) @@ -41,6 +46,19 @@ export const WeeklyRotation = ({ { enabled: inView && isWeeklyRotationEnabled } ) + // The banner always promotes the viewer's own mix. + const mixProperties = useMemo( + () => ({ + surface, + source: 'mobile' as const, + trackCount: trackIds.length, + period: formatWeeklyRotationPeriod(getWeeklyRotationPeriod()), + isOwnMix: true, + ownerUserId: currentUserId ? `${currentUserId}` : undefined + }), + [surface, trackIds.length, currentUserId] + ) + // Fire the impression once, and only once there's a real mix behind it. const hasTrackedView = useRef(false) useEffect(() => { @@ -48,21 +66,17 @@ export const WeeklyRotation = ({ hasTrackedView.current = true trackEvent({ eventName: Name.WEEKLY_ROTATION_BANNER_VIEW, - surface, - source: 'mobile', - trackCount: trackIds.length + ...mixProperties }) - }, [inView, trackIds.length, surface, trackEvent]) + }, [inView, trackIds.length, mixProperties, trackEvent]) const handlePress = useCallback(() => { trackEvent({ eventName: Name.WEEKLY_ROTATION_BANNER_CLICK, - surface, - source: 'mobile', - trackCount: trackIds.length + ...mixProperties }) navigation.navigate('WeeklyRotationScreen') - }, [navigation, trackEvent, surface, trackIds.length]) + }, [navigation, trackEvent, mixProperties]) // Hidden when flagged off or when there's no mix. Gates both the Explore and // feed placements. diff --git a/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx b/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx index 0160bea0c2a..70f9f267a35 100644 --- a/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx +++ b/packages/mobile/src/screens/notifications-screen/Notifications/WeeklyRotationNotification.tsx @@ -2,8 +2,11 @@ import { useCallback } from 'react' import { useFeatureFlag } from '@audius/common/hooks' import { weeklyRotationNotificationMessages as messages } from '@audius/common/messages' +import { Name } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' import type { WeeklyRotationNotification as WeeklyRotationNotificationType } from '@audius/common/store' +import { route } from '@audius/common/utils' +import { make, useRecord } from 'common/store/analytics/actions' import { IconArrowRotate } from '@audius/harmony-native' import { useNotificationNavigation } from 'app/hooks/useNotificationNavigation' @@ -28,13 +31,20 @@ export const WeeklyRotationNotification = ( ) => { const { notification } = props const navigation = useNotificationNavigation() + const record = useRecord() const { isEnabled: isWeeklyRotationEnabled } = useFeatureFlag( FeatureFlags.WEEKLY_ROTATION ) const handlePress = useCallback(() => { navigation.navigate(notification) - }, [navigation, notification]) + record( + make(Name.NOTIFICATIONS_CLICK_TILE, { + kind: notification.type, + link_to: route.WEEKLY_ROTATION_PAGE + }) + ) + }, [navigation, notification, record]) if (!isWeeklyRotationEnabled) return null diff --git a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx index 05fe4f15a2d..ae83c7d2819 100644 --- a/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx +++ b/packages/mobile/src/screens/weekly-rotation-screen/WeeklyRotationScreen.tsx @@ -7,13 +7,24 @@ import { } from '@audius/common/api' import { useAnalytics } from '@audius/common/hooks' import { exploreMessages } from '@audius/common/messages' -import { Name, ShareSource } from '@audius/common/models' +import { + FavoriteSource, + Name, + PlaybackSource, + RepostSource, + ShareSource +} from '@audius/common/models' import { playbackActions, playbackSelectors, shareModalUIActions } from '@audius/common/store' import type { PlaybackTrack } from '@audius/common/store' +import { + formatWeeklyRotationPeriod, + getWeeklyRotationPeriod, + getWeeklyRotationQueueSource +} from '@audius/common/utils' import { Image } from 'react-native' import { useDispatch, useSelector } from 'react-redux' @@ -31,6 +42,7 @@ import weeklyRotationArt from 'app/assets/images/weeklyRotation.jpg' import { Screen, ScreenContent } from 'app/components/core' import { TrackLineup } from 'app/components/lineup/TrackLineup' import { useRoute } from 'app/hooks/useRoute' +import { make, track } from 'app/services/analytics' const { requestOpen: requestOpenShareModal } = shareModalUIActions @@ -40,7 +52,6 @@ const messages = { } const ART_SIZE = 120 -const WEEKLY_ROTATION_SOURCE = 'WEEKLY_ROTATION_TRACKS' /** * The full Weekly Rotation mix. Mirrors the web page: artwork header, then the @@ -59,16 +70,26 @@ export const WeeklyRotationScreen = () => { const targetUserId = handle ? handleUser?.user_id : currentUserId const isOwnMix = !handle || (handleUser != null && handleUser.user_id === currentUserId) - // Per-owner source so your own mix and a shared one don't share play state. - const playbackSource = `${WEEKLY_ROTATION_SOURCE}:${targetUserId ?? ''}` + const playbackSource = getWeeklyRotationQueueSource(targetUserId) - const { trackIds, isPending, isFetching } = useWeeklyRotation( - { limit: 30, userId: targetUserId }, - { enabled: !!targetUserId } - ) + const { trackIds, isPending, isFetching, isSuccess, isError } = + useWeeklyRotation( + { limit: 30, userId: targetUserId }, + { enabled: !!targetUserId } + ) const { trackEvent } = useAnalytics() const dispatch = useDispatch() + const mixProperties = useMemo( + () => ({ + source: 'mobile' as const, + period: formatWeeklyRotationPeriod(getWeeklyRotationPeriod()), + isOwnMix, + ownerUserId: targetUserId ? `${targetUserId}` : undefined + }), + [isOwnMix, targetUserId] + ) + const isPlaybackActive = useSelector(playbackSelectors.getPlaying) const currentPlaybackTrackId = useSelector( playbackSelectors.getCurrentTrackId @@ -100,12 +121,19 @@ export const WeeklyRotationScreen = () => { dispatch( isPlaying ? playbackActions.togglePlay() : playbackActions.play() ) + track( + make({ + eventName: isPlaying ? Name.PLAYBACK_PAUSE : Name.PLAYBACK_PLAY, + id: `${currentPlaybackTrackId}`, + source: PlaybackSource.WEEKLY_ROTATION + }) + ) return } trackEvent({ eventName: Name.WEEKLY_ROTATION_PLAY_ALL, - source: 'mobile', + ...mixProperties, trackCount: playbackQueue.length }) dispatch( @@ -115,7 +143,22 @@ export const WeeklyRotationScreen = () => { querySource: null }) ) - }, [dispatch, isPlaying, isQueued, playbackQueue, trackEvent]) + track( + make({ + eventName: Name.PLAYBACK_PLAY, + id: `${playbackQueue[0].trackId}`, + source: PlaybackSource.WEEKLY_ROTATION + }) + ) + }, [ + dispatch, + isPlaying, + isQueued, + currentPlaybackTrackId, + playbackQueue, + trackEvent, + mixProperties + ]) const handleShare = useCallback(() => { if (!targetUserId) return @@ -128,18 +171,26 @@ export const WeeklyRotationScreen = () => { ) }, [dispatch, targetUserId]) - // Fired once the mix resolves, so trackCount is real and a failed load - // doesn't register as a page view. + // Fired once the mix resolves, so trackCount and status are real. const hasTrackedView = useRef(false) useEffect(() => { - if (hasTrackedView.current || !trackIds.length) return + if (hasTrackedView.current || (!isSuccess && !isError)) return hasTrackedView.current = true trackEvent({ eventName: Name.WEEKLY_ROTATION_PAGE_VIEW, - source: 'mobile', - trackCount: trackIds.length + ...mixProperties, + trackCount: trackIds.length, + status: isError ? 'error' : trackIds.length ? 'success' : 'empty', + isSignedIn: !!currentUserId }) - }, [trackIds.length, trackEvent]) + }, [ + isSuccess, + isError, + trackIds.length, + mixProperties, + currentUserId, + trackEvent + ]) const title = isOwnMix ? exploreMessages.weeklyRotation @@ -199,6 +250,9 @@ export const WeeklyRotationScreen = () => { { onCancelAction: setVisibility({ modal: 'Share', visible: true }), defaultUserList: 'chats' }) - dispatch(make(Name.CHAT_ENTRY_POINT, { source: 'share' })) + dispatch( + make(Name.CHAT_ENTRY_POINT, { source: 'share', kind: content.type }) + ) }, [openCreateChatModal, dispatch, onClose, content]) const handleShareToX = useCallback(async () => { @@ -117,7 +119,8 @@ export const ShareModal = NiceModal.create(() => { kind: 'weeklyRotation', id: `${content.user.user_id}`, url: link, - source + source, + channel: 'copyLink' }) ) break diff --git a/packages/web/src/components/tracks-table/TrackTableLineup.tsx b/packages/web/src/components/tracks-table/TrackTableLineup.tsx index 44e95c007f4..95f0347f2e2 100644 --- a/packages/web/src/components/tracks-table/TrackTableLineup.tsx +++ b/packages/web/src/components/tracks-table/TrackTableLineup.tsx @@ -35,6 +35,8 @@ type TrackTableLineupProps = Omit< | 'data' > & { playingSource?: PlaybackSource + favoriteSource?: FavoriteSource + repostSource?: RepostSource // Source tag for the playback queue (also used for stable UID generation). source: string // Ordered list of track IDs to display. @@ -50,6 +52,8 @@ type TrackTableLineupProps = Omit< export const TrackTableLineup = ({ playingSource = PlaybackSource.TRACK_TILE, + favoriteSource = FavoriteSource.TRACK_PAGE, + repostSource = RepostSource.TRACK_PAGE, source, trackIds, isInitialLoading, @@ -116,32 +120,24 @@ export const TrackTableLineup = ({ (track: TrackWithUID) => { const trackId = track.track_id if (!track.has_current_user_saved) { - dispatch( - tracksSocialActions.saveTrack(trackId, FavoriteSource.TRACK_PAGE) - ) + dispatch(tracksSocialActions.saveTrack(trackId, favoriteSource)) } else { - dispatch( - tracksSocialActions.unsaveTrack(trackId, FavoriteSource.TRACK_PAGE) - ) + dispatch(tracksSocialActions.unsaveTrack(trackId, favoriteSource)) } }, - [dispatch] + [dispatch, favoriteSource] ) const onClickRepost = useCallback( (track: TrackWithUID) => { const trackId = track.track_id if (!track.has_current_user_reposted) { - dispatch( - tracksSocialActions.repostTrack(trackId, RepostSource.TRACK_PAGE) - ) + dispatch(tracksSocialActions.repostTrack(trackId, repostSource)) } else { - dispatch( - tracksSocialActions.undoRepostTrack(trackId, RepostSource.TRACK_PAGE) - ) + dispatch(tracksSocialActions.undoRepostTrack(trackId, repostSource)) } }, - [dispatch] + [dispatch, repostSource] ) const onClickRow = useCallback( diff --git a/packages/web/src/components/weekly-rotation/WeeklyRotationBanner.tsx b/packages/web/src/components/weekly-rotation/WeeklyRotationBanner.tsx index 5de5a8a9a2d..486e47e83c4 100644 --- a/packages/web/src/components/weekly-rotation/WeeklyRotationBanner.tsx +++ b/packages/web/src/components/weekly-rotation/WeeklyRotationBanner.tsx @@ -1,11 +1,15 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' -import { useWeeklyRotation } from '@audius/common/api' +import { useCurrentUserId, useWeeklyRotation } from '@audius/common/api' import { useAnalytics, useFeatureFlag } from '@audius/common/hooks' import { exploreMessages as messages } from '@audius/common/messages' import { Name, type WeeklyRotationSurface } from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' -import { route } from '@audius/common/utils' +import { + formatWeeklyRotationPeriod, + getWeeklyRotationPeriod, + route +} from '@audius/common/utils' import { Artwork, Button, @@ -42,6 +46,7 @@ export const WeeklyRotationBanner = ({ const navigate = useNavigate() const isMobile = useIsMobile() const { trackEvent } = useAnalytics() + const { data: currentUserId } = useCurrentUserId() const { ref, inView } = useInView({ threshold: 0, @@ -59,6 +64,19 @@ export const WeeklyRotationBanner = ({ { enabled: inView && isWeeklyRotationEnabled } ) + // The banner always promotes the viewer's own mix. + const mixProperties = useMemo( + () => ({ + surface, + source: isMobile ? ('mobile' as const) : ('web' as const), + trackCount: trackIds.length, + period: formatWeeklyRotationPeriod(getWeeklyRotationPeriod()), + isOwnMix: true, + ownerUserId: currentUserId ? `${currentUserId}` : undefined + }), + [surface, isMobile, trackIds.length, currentUserId] + ) + // Fire once, only when the mix is non-empty, so hidden banners don't count // as impressions. const hasTrackedView = useRef(false) @@ -67,21 +85,17 @@ export const WeeklyRotationBanner = ({ hasTrackedView.current = true trackEvent({ eventName: Name.WEEKLY_ROTATION_BANNER_VIEW, - surface, - source: isMobile ? 'mobile' : 'web', - trackCount: trackIds.length + ...mixProperties }) - }, [inView, trackIds.length, surface, isMobile, trackEvent]) + }, [inView, trackIds.length, mixProperties, trackEvent]) const handleClick = useCallback(() => { trackEvent({ eventName: Name.WEEKLY_ROTATION_BANNER_CLICK, - surface, - source: isMobile ? 'mobile' : 'web', - trackCount: trackIds.length + ...mixProperties }) navigate(WEEKLY_ROTATION_PAGE) - }, [navigate, trackEvent, surface, isMobile, trackIds.length]) + }, [navigate, trackEvent, mixProperties]) // Hidden when flagged off or when there's no mix. Gates every surface that // renders the banner. diff --git a/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx b/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx index 8f8a044fb20..50b52f90823 100644 --- a/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx +++ b/packages/web/src/pages/weekly-rotation-page/WeeklyRotationPage.tsx @@ -7,7 +7,14 @@ import { } from '@audius/common/api' import { useAnalytics, useFeatureFlag } from '@audius/common/hooks' import { exploreMessages } from '@audius/common/messages' -import { ID, Name, PlaybackSource, ShareSource } from '@audius/common/models' +import { + FavoriteSource, + ID, + Name, + PlaybackSource, + RepostSource, + ShareSource +} from '@audius/common/models' import { FeatureFlags } from '@audius/common/services' import { playbackActions, @@ -15,7 +22,12 @@ import { shareModalUIActions } from '@audius/common/store' import type { PlaybackTrack } from '@audius/common/store' -import { route } from '@audius/common/utils' +import { + formatWeeklyRotationPeriod, + getWeeklyRotationPeriod, + getWeeklyRotationQueueSource, + route +} from '@audius/common/utils' import { Artwork, Button, @@ -48,7 +60,6 @@ const messages = { const { EXPLORE_PAGE } = route const { requestOpen: requestOpenShareModal } = shareModalUIActions -const WEEKLY_ROTATION_SOURCE = 'WEEKLY_ROTATION_TRACKS' const PAGE_SIZE = 30 const ARTWORK_SIZE = 200 @@ -94,26 +105,45 @@ export const WeeklyRotationPage = () => { const targetUserId = handle ? handleUser?.user_id : currentUserId const isOwnMix = !handle || (handleUser != null && handleUser.user_id === currentUserId) - // Per-owner source so your own mix and a shared one don't share play state. - const playbackSource = `${WEEKLY_ROTATION_SOURCE}:${targetUserId ?? ''}` + const playbackSource = getWeeklyRotationQueueSource(targetUserId) + + const { trackIds, isPending, isFetching, isLoading, isSuccess, isError } = + useWeeklyRotation( + { limit: PAGE_SIZE, userId: targetUserId }, + { enabled: isWeeklyRotationEnabled && !!targetUserId } + ) - const { trackIds, isPending, isFetching, isLoading } = useWeeklyRotation( - { limit: PAGE_SIZE, userId: targetUserId }, - { enabled: isWeeklyRotationEnabled && !!targetUserId } + const mixProperties = useMemo( + () => ({ + source: isMobile ? ('mobile' as const) : ('web' as const), + period: formatWeeklyRotationPeriod(getWeeklyRotationPeriod()), + isOwnMix, + ownerUserId: targetUserId ? `${targetUserId}` : undefined + }), + [isMobile, isOwnMix, targetUserId] ) - // Fired once the mix resolves rather than on mount, so trackCount is real - // and a failed load doesn't register as a page view. + // Fired once the mix resolves rather than on mount, so trackCount and status + // are real. const hasTrackedView = useRef(false) useEffect(() => { - if (hasTrackedView.current || !trackIds.length) return + if (hasTrackedView.current || (!isSuccess && !isError)) return hasTrackedView.current = true trackEvent({ eventName: Name.WEEKLY_ROTATION_PAGE_VIEW, - source: isMobile ? 'mobile' : 'web', - trackCount: trackIds.length + ...mixProperties, + trackCount: trackIds.length, + status: isError ? 'error' : trackIds.length ? 'success' : 'empty', + isSignedIn: !!currentUserId }) - }, [trackIds.length, isMobile, trackEvent]) + }, [ + isSuccess, + isError, + trackIds.length, + mixProperties, + currentUserId, + trackEvent + ]) const isPlaybackActive = useSelector(playbackSelectors.getPlaying) const currentPlaybackTrackId = useSelector( @@ -150,7 +180,7 @@ export const WeeklyRotationPage = () => { dispatch( make(isPlaying ? Name.PLAYBACK_PAUSE : Name.PLAYBACK_PLAY, { id: `${currentPlaybackTrackId}`, - source: PlaybackSource.PLAYLIST_PAGE + source: PlaybackSource.WEEKLY_ROTATION }) ) return @@ -158,7 +188,7 @@ export const WeeklyRotationPage = () => { trackEvent({ eventName: Name.WEEKLY_ROTATION_PLAY_ALL, - source: isMobile ? 'mobile' : 'web', + ...mixProperties, trackCount: playbackQueue.length }) dispatch( @@ -171,7 +201,7 @@ export const WeeklyRotationPage = () => { dispatch( make(Name.PLAYBACK_PLAY, { id: `${firstId}`, - source: PlaybackSource.PLAYLIST_PAGE + source: PlaybackSource.WEEKLY_ROTATION }) ) }, [ @@ -181,7 +211,7 @@ export const WeeklyRotationPage = () => { currentPlaybackTrackId, playbackQueue, trackEvent, - isMobile + mixProperties ]) const handleShare = useCallback(() => { @@ -273,6 +303,9 @@ export const WeeklyRotationPage = () => { new Date(Date.UTC(y, m - 1, d, h)) -// Same cases as TestPeriod in the api repo (weeklyrotation/period_test.go). -describe('getWeeklyRotationPeriod', () => { - it('opens ISO week 37 at the Wednesday rollover', () => { - // 2026-09-09 is a Wednesday. - expect(getWeeklyRotationPeriod(utc(2026, 9, 9))).toEqual({ - year: 2026, - week: 37 - }) - }) - - it('keeps Monday and Tuesday in the period that started the previous Wednesday', () => { - expect(getWeeklyRotationPeriod(utc(2026, 9, 7, 12))).toEqual({ - year: 2026, - week: 36 - }) - expect(getWeeklyRotationPeriod(utc(2026, 9, 8, 23))).toEqual({ - year: 2026, - week: 36 - }) - }) - - it('works in UTC regardless of the caller timezone offset', () => { - // 2026-09-08 20:00 PDT is 2026-09-09 03:00 UTC. - expect( - getWeeklyRotationPeriod(new Date('2026-09-08T20:00:00-07:00')) - ).toEqual({ year: 2026, week: 37 }) - }) - - it('handles the year boundary', () => { - // ISO week 1 of 2027 starts Monday 2027-01-04, so its period starts - // Wednesday 2027-01-06; the days before belong to 2026's week 53. - expect(getWeeklyRotationPeriod(utc(2027, 1, 6))).toEqual({ - year: 2027, - week: 1 - }) - expect(getWeeklyRotationPeriod(utc(2027, 1, 5, 23))).toEqual({ - year: 2026, - week: 53 - }) - }) -}) - -describe('formatWeeklyRotationPeriod', () => { - it('zero-pads the week', () => { - expect(formatWeeklyRotationPeriod({ year: 2027, week: 1 })).toBe('2027-01') - expect(formatWeeklyRotationPeriod({ year: 2026, week: 37 })).toBe('2026-37') - }) -}) - describe('getWeeklyRotationOgImageUrl', () => { it('stamps the period into the card URL', () => { expect(getWeeklyRotationOgImageUrl('dylan', utc(2026, 9, 9))).toBe( diff --git a/packages/web/src/utils/weeklyRotationPeriod.ts b/packages/web/src/utils/weeklyRotationPeriod.ts index e00f0a715ea..645c37f5202 100644 --- a/packages/web/src/utils/weeklyRotationPeriod.ts +++ b/packages/web/src/utils/weeklyRotationPeriod.ts @@ -1,41 +1,9 @@ -/** - * The Weekly Rotation period, as the API defines it: identified by an ISO - * (year, week) pair but rolling over on Wednesday 00:00 UTC rather than - * Monday. Mirrors `weeklyrotation.Period` in the api repo - * (weeklyrotation/period.go). - * - * Kept dependency-free because the SSR bundle imports it (no - * `@audius/common/utils` or dayjs). - */ - -const ROLLOVER_OFFSET_DAYS = 2 // ISO Monday -> Wednesday -const MS_PER_DAY = 86_400_000 - -export type WeeklyRotationPeriod = { year: number; week: number } - -export const getWeeklyRotationPeriod = ( - date: Date = new Date() -): WeeklyRotationPeriod => { - // Shift back so a period that started on Wednesday maps onto the ISO week - // whose Monday it belongs to, then do the standard ISO week calculation: - // the ISO week of a date is the week of that date's Thursday. - const d = new Date( - Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) - ) - d.setUTCDate(d.getUTCDate() - ROLLOVER_OFFSET_DAYS) - const isoWeekday = d.getUTCDay() || 7 - d.setUTCDate(d.getUTCDate() + 4 - isoWeekday) - const year = d.getUTCFullYear() - const yearStart = Date.UTC(year, 0, 1) - const week = Math.ceil(((d.getTime() - yearStart) / MS_PER_DAY + 1) / 7) - return { year, week } -} - -/** `2026-37`: stable, sortable, safe in a query string. */ -export const formatWeeklyRotationPeriod = ({ - year, - week -}: WeeklyRotationPeriod) => `${year}-${String(week).padStart(2, '0')}` +// Imported by path rather than `@audius/common/utils` so the SSR bundle +// doesn't pull in the rest of common. +import { + formatWeeklyRotationPeriod, + getWeeklyRotationPeriod +} from '@audius/common/src/utils/weeklyRotation' const OG_BASE_URL = 'https://og.audius.co'