From ebf8db32253738e2c7d1378fbd18d734333efdd1 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 19 Oct 2026 21:48:20 -0700 Subject: [PATCH 1/3] fix: preserve scheduled releases and constrain advanced release dates --- .../src/hooks/useCollectionAccessTypeLabel.ts | 6 +- .../hooks/useScheduledReleaseLabels.test.ts | 76 +++++++++++++++++++ .../src/hooks/useTrackAccessTypeLabel.ts | 5 +- .../default/models/CreateTrackRequestBody.ts | 12 ++- .../default/models/UpdateTrackRequestBody.ts | 12 ++- .../scheduledReleaseSerialization.test.ts | 41 ++++++++++ .../edit/fields/DatePickerField.tsx | 3 +- .../edit/fields/datePickerUtils.test.ts | 21 +++++ .../components/edit/fields/datePickerUtils.ts | 6 ++ 9 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 packages/common/src/hooks/useScheduledReleaseLabels.test.ts create mode 100644 packages/sdk/src/sdk/api/tracks/scheduledReleaseSerialization.test.ts create mode 100644 packages/web/src/components/edit/fields/datePickerUtils.test.ts create mode 100644 packages/web/src/components/edit/fields/datePickerUtils.ts 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..1358bd74572 --- /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 the real HTTP request serialization, including explicit false to cancel scheduling. +describe('track scheduling over REST', () => { + for (const operation of ['create', 'update'] as const) { + it.each([true, false, undefined])( + `${operation} preserves scheduling=%s and the exact release time`, + async (isScheduledRelease) => { + 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 }) + } + const body = JSON.parse(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..7de83bd2480 --- /dev/null +++ b/packages/web/src/components/edit/fields/datePickerUtils.ts @@ -0,0 +1,6 @@ +/** 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() +} From b32ac3f2b06488e41ce7b1cba00d0d6e5ef827ae Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 19 Oct 2026 21:58:21 -0700 Subject: [PATCH 2/3] test: make scheduling serialization coverage compatible with lint rules --- .../scheduledReleaseSerialization.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/sdk/src/sdk/api/tracks/scheduledReleaseSerialization.test.ts b/packages/sdk/src/sdk/api/tracks/scheduledReleaseSerialization.test.ts index 1358bd74572..1576ca9fbf4 100644 --- a/packages/sdk/src/sdk/api/tracks/scheduledReleaseSerialization.test.ts +++ b/packages/sdk/src/sdk/api/tracks/scheduledReleaseSerialization.test.ts @@ -3,13 +3,12 @@ import { describe, expect, it, vi } from 'vitest' import { TracksApi } from '../generated/default/apis/TracksApi' import { Configuration } from '../generated/default/runtime' -// Exercise the real HTTP request serialization, including explicit false to cancel scheduling. +// Exercise real HTTP serialization, including explicit false to cancel scheduling. describe('track scheduling over REST', () => { - for (const operation of ['create', 'update'] as const) { - it.each([true, false, undefined])( - `${operation} preserves scheduling=%s and the exact release time`, - async (isScheduledRelease) => { - const fetchApi = vi.fn().mockResolvedValue( + 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' } @@ -28,7 +27,8 @@ describe('track scheduling over REST', () => { } else { await api.updateTrack({ userId: 'owner', trackId: 'track', metadata }) } - const body = JSON.parse(fetchApi.mock.calls[0][1].body) + 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') @@ -36,6 +36,6 @@ describe('track scheduling over REST', () => { expect(body.is_scheduled_release).toBe(isScheduledRelease) } } - ) - } + } + }) }) From ee5be8fab309dfd7b54483a6151443c61b7a75c4 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 19 Oct 2026 22:05:48 -0700 Subject: [PATCH 3/3] style: format release date calendar helper --- packages/web/src/components/edit/fields/datePickerUtils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/edit/fields/datePickerUtils.ts b/packages/web/src/components/edit/fields/datePickerUtils.ts index 7de83bd2480..02103b2f01f 100644 --- a/packages/web/src/components/edit/fields/datePickerUtils.ts +++ b/packages/web/src/components/edit/fields/datePickerUtils.ts @@ -1,6 +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 selectedDay = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ) const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) return selectedDay.getTime() <= today.getTime() }