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
6 changes: 5 additions & 1 deletion packages/common/src/hooks/useCollectionAccessTypeLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const useCollectionAccessTypeLabel = (
select: (collection) => ({
streamConditions: collection?.stream_conditions,
releaseDate: collection?.release_date,
isScheduledRelease: collection?.is_scheduled_release,
isPrivate: collection?.is_private,
playlistOwnerId: collection?.playlist_owner_id
})
Expand All @@ -30,7 +31,10 @@ export const useCollectionAccessTypeLabel = (
collection?.streamConditions
)
const releaseDate = collection?.releaseDate
const isScheduledRelease = releaseDate && new Date(releaseDate) > new Date()
const isScheduledRelease =
collection?.isScheduledRelease &&
releaseDate &&
new Date(releaseDate) > new Date()
const isPrivate = collection?.isPrivate

const { data: accountUserId } = useCurrentUserId()
Expand Down
76 changes: 76 additions & 0 deletions packages/common/src/hooks/useScheduledReleaseLabels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { AccessType } from '~/models/AccessType'

import { useCollectionAccessTypeLabel } from './useCollectionAccessTypeLabel'
import { useTrackAccessTypeLabel } from './useTrackAccessTypeLabel'

const { metadata } = vi.hoisted(() => ({
metadata: {
is_scheduled_release: false,
release_date: undefined as string | undefined,
is_unlisted: false,
is_private: false
}
}))

vi.mock('~/api', () => ({
useCurrentUserId: () => ({ data: 1 }),
useTrack: (_id: number, options: { select: (data: unknown) => unknown }) => ({
data: options.select(metadata)
}),
useCollection: (
_id: number,
options: { select: (data: unknown) => unknown }
) => ({ data: options.select(metadata) })
}))
vi.mock('./useGatedContent', () => ({
useGatedTrackAccess: () => ({}),
useGatedCollectionAccess: () => ({})
}))

for (const [name, useLabel] of [
['track', useTrackAccessTypeLabel],
['collection', useCollectionAccessTypeLabel]
] as const) {
describe(`${name} scheduled release label`, () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-10-19T19:00:00Z'))
Object.assign(metadata, {
is_scheduled_release: false,
release_date: '2026-10-20T07:00:00Z',
is_unlisted: false,
is_private: false
})
})
afterEach(() => vi.useRealTimers())

it('does not label a public future-dated release as scheduled', () => {
expect(useLabel(1).type).toBeNull()
expect(useLabel(1).scheduledReleaseDate).toBeUndefined()
})

it('labels an upcoming scheduled release', () => {
metadata.is_scheduled_release = true
expect(useLabel(1).type).toBe(AccessType.SCHEDULED_RELEASE)
expect(useLabel(1).scheduledReleaseDate).toBe(metadata.release_date)
})

it.each(['2026-10-18T07:00:00Z', '2026-10-19T19:00:00Z', undefined])(
'does not label a released or undated item as upcoming (%s)',
(releaseDate) => {
metadata.is_scheduled_release = true
metadata.release_date = releaseDate
expect(useLabel(1).type).toBeNull()
expect(useLabel(1).scheduledReleaseDate).toBeUndefined()
}
)

it('preserves the hidden label for an unscheduled future-dated item', () => {
metadata.is_unlisted = true
metadata.is_private = true
expect(useLabel(1).type).toBe(AccessType.HIDDEN)
})
})
}
5 changes: 4 additions & 1 deletion packages/common/src/hooks/useTrackAccessTypeLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const useTrackAccessTypeLabel = (trackId: ID): TrackAccessType => {
stream_conditions: track?.stream_conditions,
download_conditions: track?.download_conditions,
release_date: track?.release_date,
is_scheduled_release: track?.is_scheduled_release,
is_downloadable: track?.is_downloadable,
is_unlisted: track?.is_unlisted
})
Expand All @@ -40,7 +41,9 @@ export const useTrackAccessTypeLabel = (trackId: ID): TrackAccessType => {
const isFollowGated = isContentFollowGated(track?.stream_conditions)
const isDownloadGated = isContentUSDCPurchaseGated(track?.download_conditions)
const isScheduledRelease =
track?.release_date && new Date(track.release_date) > new Date()
track?.is_scheduled_release &&
track?.release_date &&
new Date(track.release_date) > new Date()

let type: Nullable<AccessType> = null
let isUnlocked = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,17 @@ export interface CreateTrackRequestBody {
*/
iswc?: string | null;
/**
* Release date
* Release date and time
* @type {Date}
* @memberof CreateTrackRequestBody
*/
releaseDate?: Date;
/**
* Whether the track is scheduled for release
* @type {boolean}
* @memberof CreateTrackRequestBody
*/
isScheduledRelease?: boolean;
/**
* CID for the track audio file (required)
* @type {string}
Expand Down Expand Up @@ -383,6 +389,7 @@ export function CreateTrackRequestBodyFromJSONTyped(json: any, ignoreDiscriminat
'isrc': !exists(json, 'isrc') ? undefined : json['isrc'],
'iswc': !exists(json, 'iswc') ? undefined : json['iswc'],
'releaseDate': !exists(json, 'release_date') ? undefined : (new Date(json['release_date'])),
'isScheduledRelease': !exists(json, 'is_scheduled_release') ? undefined : json['is_scheduled_release'],
'trackCid': json['track_cid'],
'origFileCid': !exists(json, 'orig_file_cid') ? undefined : json['orig_file_cid'],
'origFilename': !exists(json, 'orig_filename') ? undefined : json['orig_filename'],
Expand Down Expand Up @@ -438,7 +445,8 @@ export function CreateTrackRequestBodyToJSON(value?: CreateTrackRequestBody | nu
'license': value.license,
'isrc': value.isrc,
'iswc': value.iswc,
'release_date': value.releaseDate === undefined ? undefined : (value.releaseDate.toISOString().substr(0,10)),
'release_date': value.releaseDate === undefined ? undefined : (value.releaseDate.toISOString()),
'is_scheduled_release': value.isScheduledRelease,
'track_cid': value.trackCid,
'orig_file_cid': value.origFileCid,
'orig_filename': value.origFilename,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,17 @@ export interface UpdateTrackRequestBody {
*/
iswc?: string | null;
/**
* Release date
* Release date and time
* @type {Date}
* @memberof UpdateTrackRequestBody
*/
releaseDate?: Date;
/**
* Whether the track is scheduled for release
* @type {boolean}
* @memberof UpdateTrackRequestBody
*/
isScheduledRelease?: boolean;
/**
* CID for the track audio file
* @type {string}
Expand Down Expand Up @@ -277,6 +283,7 @@ export function UpdateTrackRequestBodyFromJSONTyped(json: any, ignoreDiscriminat
'isrc': !exists(json, 'isrc') ? undefined : json['isrc'],
'iswc': !exists(json, 'iswc') ? undefined : json['iswc'],
'releaseDate': !exists(json, 'release_date') ? undefined : (new Date(json['release_date'])),
'isScheduledRelease': !exists(json, 'is_scheduled_release') ? undefined : json['is_scheduled_release'],
'trackCid': !exists(json, 'track_cid') ? undefined : json['track_cid'],
'origFileCid': !exists(json, 'orig_file_cid') ? undefined : json['orig_file_cid'],
'origFilename': !exists(json, 'orig_filename') ? undefined : json['orig_filename'],
Expand Down Expand Up @@ -319,7 +326,8 @@ export function UpdateTrackRequestBodyToJSON(value?: UpdateTrackRequestBody | nu
'license': value.license,
'isrc': value.isrc,
'iswc': value.iswc,
'release_date': value.releaseDate === undefined ? undefined : (value.releaseDate.toISOString().substr(0,10)),
'release_date': value.releaseDate === undefined ? undefined : (value.releaseDate.toISOString()),
'is_scheduled_release': value.isScheduledRelease,
'track_cid': value.trackCid,
'orig_file_cid': value.origFileCid,
'orig_filename': value.origFilename,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest'

import { TracksApi } from '../generated/default/apis/TracksApi'
import { Configuration } from '../generated/default/runtime'

// Exercise real HTTP serialization, including explicit false to cancel scheduling.
describe('track scheduling over REST', () => {
it('preserves scheduling and the exact release time on create and update', async () => {
for (const operation of ['create', 'update'] as const) {
for (const isScheduledRelease of [true, false, undefined]) {
const fetchApi = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ data: {} }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
})
)
const api = new TracksApi(new Configuration({ fetchApi }))
const metadata = {
title: 'Scheduled track',
genre: 'Electronic',
trackCid: 'test-cid',
isScheduledRelease,
releaseDate: new Date('2026-10-20T00:00:00-07:00')
}
if (operation === 'create') {
await api.createTrack({ userId: 'owner', metadata })
} else {
await api.updateTrack({ userId: 'owner', trackId: 'track', metadata })
}
expect(fetchApi).toHaveBeenCalledOnce()
const body = JSON.parse(String(fetchApi.mock.calls[0]?.[1]?.body))
expect(body.release_date).toBe('2026-10-20T07:00:00.000Z')
if (isScheduledRelease === undefined) {
expect(body).not.toHaveProperty('is_scheduled_release')
} else {
expect(body.is_scheduled_release).toBe(isScheduledRelease)
}
}
}
})
})
3 changes: 2 additions & 1 deletion packages/web/src/components/edit/fields/DatePickerField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useField, useFormikContext } from 'formik'
import lazyWithPreload from 'utils/lazyWithPreload'

import styles from './DatePickerField.module.css'
import { isDateOnOrBeforeToday } from './datePickerUtils'

// Lazy load react-datepicker since it's only used when the popup is open
// Preload when the date field is focused to minimize delay (200ms auto-preload)
Expand Down Expand Up @@ -195,7 +196,7 @@ export const DatePicker = (props: DatePickerProps) => {
dayjs().add(1, 'year').add(1, 'day')
)
} else {
return dateDayjs.isBefore(dayjs().add(1, 'day'))
return isDateOnOrBeforeToday(date)
}
}}
open={isFocused}
Expand Down
21 changes: 21 additions & 0 deletions packages/web/src/components/edit/fields/datePickerUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'

import { isDateOnOrBeforeToday } from './datePickerUtils'

describe('Advanced release date calendar', () => {
it.each([
[2026, 9, 19],
[2026, 9, 31],
[2026, 11, 31],
[2026, 2, 8], // Daylight saving begins in Los Angeles.
[2026, 10, 1] // Daylight saving ends in Los Angeles.
])('allows today and earlier but rejects tomorrow (%i-%i-%i)', (y, m, d) => {
for (const hour of [0, 12, 23]) {
const now = new Date(y, m, d, hour, 30)
expect(isDateOnOrBeforeToday(new Date(y, m, d - 1), now)).toBe(true)
expect(isDateOnOrBeforeToday(new Date(y, m, d, 23, 59), now)).toBe(true)
expect(isDateOnOrBeforeToday(new Date(y, m, d + 1), now)).toBe(false)
expect(isDateOnOrBeforeToday(new Date(y, m, d + 7), now)).toBe(false)
}
})
})
10 changes: 10 additions & 0 deletions packages/web/src/components/edit/fields/datePickerUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** Compare local calendar dates so tomorrow is never allowed by a rolling 24-hour window. */
export const isDateOnOrBeforeToday = (date: Date, now = new Date()) => {
const selectedDay = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate()
)
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
return selectedDay.getTime() <= today.getTime()
}
Loading