diff --git a/packages/common/src/hooks/useCollectionAccessTypeLabel.ts b/packages/common/src/hooks/useCollectionAccessTypeLabel.ts index b3c00f28f36..6982880c8fe 100644 --- a/packages/common/src/hooks/useCollectionAccessTypeLabel.ts +++ b/packages/common/src/hooks/useCollectionAccessTypeLabel.ts @@ -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 }) @@ -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() diff --git a/packages/common/src/hooks/useScheduledReleaseLabels.test.ts b/packages/common/src/hooks/useScheduledReleaseLabels.test.ts new file mode 100644 index 00000000000..7db756b8b27 --- /dev/null +++ b/packages/common/src/hooks/useScheduledReleaseLabels.test.ts @@ -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) + }) + }) +} diff --git a/packages/common/src/hooks/useTrackAccessTypeLabel.ts b/packages/common/src/hooks/useTrackAccessTypeLabel.ts index 4cbb12e0163..78c08dfde0b 100644 --- a/packages/common/src/hooks/useTrackAccessTypeLabel.ts +++ b/packages/common/src/hooks/useTrackAccessTypeLabel.ts @@ -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 }) @@ -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 = null let isUnlocked = false diff --git a/packages/sdk/src/sdk/api/generated/default/models/CreateTrackRequestBody.ts b/packages/sdk/src/sdk/api/generated/default/models/CreateTrackRequestBody.ts index 93592ed6176..e22bbe2a8fc 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/CreateTrackRequestBody.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/CreateTrackRequestBody.ts @@ -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} @@ -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'], @@ -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, diff --git a/packages/sdk/src/sdk/api/generated/default/models/UpdateTrackRequestBody.ts b/packages/sdk/src/sdk/api/generated/default/models/UpdateTrackRequestBody.ts index 7ce1ec6988b..78987ec41a4 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/UpdateTrackRequestBody.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/UpdateTrackRequestBody.ts @@ -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} @@ -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'], @@ -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, diff --git a/packages/sdk/src/sdk/api/tracks/scheduledReleaseSerialization.test.ts b/packages/sdk/src/sdk/api/tracks/scheduledReleaseSerialization.test.ts new file mode 100644 index 00000000000..1576ca9fbf4 --- /dev/null +++ b/packages/sdk/src/sdk/api/tracks/scheduledReleaseSerialization.test.ts @@ -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().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) + } + } + } + }) +}) diff --git a/packages/web/src/components/edit/fields/DatePickerField.tsx b/packages/web/src/components/edit/fields/DatePickerField.tsx index a0b325692c9..688deda1e62 100644 --- a/packages/web/src/components/edit/fields/DatePickerField.tsx +++ b/packages/web/src/components/edit/fields/DatePickerField.tsx @@ -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) @@ -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} diff --git a/packages/web/src/components/edit/fields/datePickerUtils.test.ts b/packages/web/src/components/edit/fields/datePickerUtils.test.ts new file mode 100644 index 00000000000..1d2e9289994 --- /dev/null +++ b/packages/web/src/components/edit/fields/datePickerUtils.test.ts @@ -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) + } + }) +}) diff --git a/packages/web/src/components/edit/fields/datePickerUtils.ts b/packages/web/src/components/edit/fields/datePickerUtils.ts new file mode 100644 index 00000000000..02103b2f01f --- /dev/null +++ b/packages/web/src/components/edit/fields/datePickerUtils.ts @@ -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() +}