Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 39 additions & 16 deletions packages/common/src/models/Analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -725,6 +727,7 @@ type Share = {
source: ShareSource
id: string
url: string
channel?: 'copyLink' | 'shareSheet'
}

export type ShareToTwitter = {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand All @@ -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
Expand Down Expand Up @@ -1280,7 +1291,7 @@ export enum ModalSource {
// Modals
type ModalOpened = {
eventName: Name.MODAL_OPENED
source: ModalSource
source: ModalSource | ShareSource
name: string
} & Record<string, any> // For passing state values

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
37 changes: 37 additions & 0 deletions packages/common/src/store/ui/share-modal/sagas.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
23 changes: 20 additions & 3 deletions packages/common/src/store/ui/share-modal/sagas.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<typeof requestOpen>) {
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() {
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
78 changes: 78 additions & 0 deletions packages/common/src/utils/weeklyRotation.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
57 changes: 57 additions & 0 deletions packages/common/src/utils/weeklyRotation.ts
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 6 additions & 4 deletions packages/mobile/src/components/lineup-tile/TrackTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const TrackTileComponent = (props: TrackTileProps) => {
variant,
style,
showArtistPick = false,
favoriteSource = FavoriteSource.TILE,
repostSource = RepostSource.TILE,
...lineupTileProps
} = props

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading