diff --git a/packages/app-elements/src/dictionaries/promotions.test.ts b/packages/app-elements/src/dictionaries/promotions.test.ts new file mode 100644 index 000000000..0ae3f4fdc --- /dev/null +++ b/packages/app-elements/src/dictionaries/promotions.test.ts @@ -0,0 +1,94 @@ +import type { Promotion } from "@commercelayer/sdk" +import { getPromotionDisplayStatus } from "./promotions" + +/** The attributes the display status is derived from, and nothing else. */ +function promotion( + attributes: Partial< + Pick< + Promotion, + | "starts_at" + | "expires_at" + | "disabled_at" + | "total_usage_limit" + | "total_usage_count" + > + > = {}, +): Promotion { + return { + starts_at: "2026-10-01T22:00:00.000Z", + expires_at: "2026-10-06T09:30:00.000Z", + ...attributes, + } as Promotion +} + +describe("getPromotionDisplayStatus", () => { + const originalTimezone = process.env.TZ + + beforeEach(() => { + vi.useFakeTimers().setSystemTime("2026-10-06T07:53:00.000Z") + }) + + afterEach(() => { + vi.useRealTimers() + process.env.TZ = originalTimezone + }) + + it("is upcoming until the promotion starts", () => { + expect( + getPromotionDisplayStatus( + promotion({ starts_at: "2026-10-06T08:00:00.000Z" }), + ).status, + ).toBe("upcoming") + }) + + it("is active between the start and the expiration", () => { + expect(getPromotionDisplayStatus(promotion()).status).toBe("active") + }) + + it("is expired once the expiration is past", () => { + expect( + getPromotionDisplayStatus( + promotion({ expires_at: "2026-10-06T07:30:00.000Z" }), + ).status, + ).toBe("expired") + }) + + it("does not depend on the browser timezone", () => { + // https://github.com/commercelayer/issues-app/issues/752 + // East of UTC the promotion was shown as expired as many hours early as + // the offset, west of UTC it was shown as active as many hours late. + const timezones = ["UTC", "Europe/Copenhagen", "America/New_York"] + + timezones.forEach((timezone) => { + process.env.TZ = timezone + + expect(getPromotionDisplayStatus(promotion()).status).toBe("active") + expect( + getPromotionDisplayStatus( + promotion({ expires_at: "2026-10-06T07:30:00.000Z" }), + ).status, + ).toBe("expired") + expect( + getPromotionDisplayStatus( + promotion({ starts_at: "2026-10-06T08:00:00.000Z" }), + ).status, + ).toBe("upcoming") + }) + }) + + it("is disabled whenever `disabled_at` is set", () => { + expect( + getPromotionDisplayStatus( + promotion({ disabled_at: "2026-10-05T00:00:00.000Z" }), + ).status, + ).toBe("disabled") + }) + + it("is used once the usage limit is reached", () => { + expect( + getPromotionDisplayStatus( + promotion({ total_usage_limit: 3, total_usage_count: 3 }), + ).status, + ).toBe("used") + }) +}) diff --git a/packages/app-elements/src/dictionaries/promotions.ts b/packages/app-elements/src/dictionaries/promotions.ts index 8b6b3f420..924190acc 100644 --- a/packages/app-elements/src/dictionaries/promotions.ts +++ b/packages/app-elements/src/dictionaries/promotions.ts @@ -1,6 +1,5 @@ import type { Promotion } from "@commercelayer/sdk" import { t } from "i18next" -import { getEventDateInfo } from "#helpers/date" import type { DisplayStatus } from "./types" interface PromotionDisplayStatus extends DisplayStatus { @@ -19,11 +18,6 @@ export function getPromotionDisplayStatus( } } - const eventDateInfo = getEventDateInfo({ - startsAt: promotion.starts_at, - expiresAt: promotion.expires_at, - }) - if ( promotion.total_usage_limit != null && promotion.total_usage_count === promotion.total_usage_limit @@ -36,7 +30,7 @@ export function getPromotionDisplayStatus( } } - switch (eventDateInfo) { + switch (getPromotionTimeframe(promotion)) { case "past": return { status: "expired", @@ -62,3 +56,24 @@ export function getPromotionDisplayStatus( } } } + +/** + * Whether the promotion has yet to start, is running or is over. + * `starts_at` and `expires_at` are absolute instants, so they are compared to + * the current time as they are: the browser timezone must not play any role. + */ +function getPromotionTimeframe( + promotion: Pick, +): "upcoming" | "active" | "past" { + const now = Date.now() + + if (new Date(promotion.starts_at).getTime() > now) { + return "upcoming" + } + + if (new Date(promotion.expires_at).getTime() < now) { + return "past" + } + + return "active" +} diff --git a/packages/app-elements/src/helpers/date.test.ts b/packages/app-elements/src/helpers/date.test.ts index 8494931b6..6e449f626 100644 --- a/packages/app-elements/src/helpers/date.test.ts +++ b/packages/app-elements/src/helpers/date.test.ts @@ -6,7 +6,6 @@ import { formatDate, formatDateRange, formatDateWithPredicate, - getEventDateInfo, getIsoDateAtDayEdge, getIsoDateAtDaysBefore, } from "./date" @@ -501,54 +500,6 @@ describe("getIsoDateAtDaysBefore", () => { }) }) -describe("getEventDateInfo", () => { - beforeEach(() => { - vi.useFakeTimers().setSystemTime("2023-12-25T14:30:00.000Z") - }) - - afterEach(() => { - vi.useRealTimers() - }) - - test("should throw an error when the startsAt date comes after the expiresAt date", () => { - expect(() => { - getEventDateInfo({ - startsAt: "2024-01-31T14:30:00.000Z", - expiresAt: "2024-01-01T14:30:00.000Z", - }) - }).toThrowError( - "The expiration date/time of the event must be after the activation (startsAt).", - ) - }) - - test('should return "upcoming" when the event is in the future', () => { - expect( - getEventDateInfo({ - startsAt: "2024-01-01T14:30:00.000Z", - expiresAt: "2024-01-31T14:30:00.000Z", - }), - ).toEqual("upcoming") - }) - - test('should return "expired" when the event is in the past', () => { - expect( - getEventDateInfo({ - startsAt: "2023-01-01T14:30:00.000Z", - expiresAt: "2023-01-31T14:30:00.000Z", - }), - ).toEqual("past") - }) - - test('should return "active" when the event is actually happening', () => { - expect( - getEventDateInfo({ - startsAt: "2023-12-01T14:30:00.000Z", - expiresAt: "2023-12-31T14:30:00.000Z", - }), - ).toEqual("active") - }) -}) - describe("formatDateRange should return the proper date format", () => { beforeEach(() => { vi.useFakeTimers().setSystemTime("2024-01-10T14:30:00.000Z") diff --git a/packages/app-elements/src/helpers/date.ts b/packages/app-elements/src/helpers/date.ts index c66bea276..bf306a20b 100644 --- a/packages/app-elements/src/helpers/date.ts +++ b/packages/app-elements/src/helpers/date.ts @@ -1,9 +1,6 @@ import { endOfDay } from "date-fns/endOfDay" import { format } from "date-fns/format" import { formatDistance } from "date-fns/formatDistance" -import { isBefore } from "date-fns/isBefore" -import { isFuture } from "date-fns/isFuture" -import { isPast } from "date-fns/isPast" import { isSameMonth } from "date-fns/isSameMonth" import { isSameYear } from "date-fns/isSameYear" import { isThisYear } from "date-fns/isThisYear" @@ -286,41 +283,6 @@ export function getIsoDateAtDaysBefore({ return sub(new Date(startOfDay), { days }).toISOString() } -/** - * Given the event date (`startsAt` and `expiresAt`) it returns whether the the event is `active`, `past` or `upcoming`. - */ -export function getEventDateInfo({ - startsAt, - expiresAt, - timezone = "UTC", -}: { - /** The activation date/time of the event (ISO date string. Example '2022-10-06T11:59:30.371Z'). */ - startsAt: DateISOString - /** The expiration date/time of the promotion (must be after startsAt) (ISO date string. Example '2022-10-06T11:59:30.371Z'). */ - expiresAt: DateISOString - /** Set a specific timezone, when not passed default value is 'UTC' */ - timezone?: string -}): "active" | "past" | "upcoming" { - const zonedStartsAt = toZonedTime(new Date(startsAt), timezone) - const zonedExpiresAt = toZonedTime(new Date(expiresAt), timezone) - - if (isBefore(zonedExpiresAt, zonedStartsAt)) { - throw new Error( - "The expiration date/time of the event must be after the activation (startsAt).", - ) - } - - if (isFuture(zonedStartsAt)) { - return "upcoming" - } - - if (isPast(zonedExpiresAt)) { - return "past" - } - - return "active" -} - /** * Format a date range as a nice string also specifying a custom timezone * @returns a nice string representation. Example: '1-21 Jul, 2022' or 'Jul 21, 2022 - Jan 12, 2023' diff --git a/packages/app-elements/src/main.ts b/packages/app-elements/src/main.ts index 80be7efa2..5cb708722 100644 --- a/packages/app-elements/src/main.ts +++ b/packages/app-elements/src/main.ts @@ -37,7 +37,6 @@ export { formatDate, formatDateRange, formatDateWithPredicate, - getEventDateInfo, getIsoDateAtDayEdge, getIsoDateAtDaysBefore, isDateValid, diff --git a/packages/docs/src/stories/utility/Currency.mdx b/packages/docs/src/stories/utility/Currency.mdx index 5654495b6..660232930 100644 --- a/packages/docs/src/stories/utility/Currency.mdx +++ b/packages/docs/src/stories/utility/Currency.mdx @@ -1,7 +1,6 @@ import { formatDate, formatDateRange, - getEventDateInfo, makeDateYearsRange } from '#helpers/date' import { Canvas, Title, Description, Meta, Source } from '@storybook/addon-docs/blocks' diff --git a/packages/docs/src/stories/utility/Date.data.tsx b/packages/docs/src/stories/utility/Date.data.tsx index 307a124b4..32376160a 100644 --- a/packages/docs/src/stories/utility/Date.data.tsx +++ b/packages/docs/src/stories/utility/Date.data.tsx @@ -1,10 +1,5 @@ import type { CodeSampleProps } from "src/components/CodeSample" -import { - formatDate, - formatDateRange, - getEventDateInfo, - makeDateYearsRange, -} from "#helpers/date" +import { formatDate, formatDateRange, makeDateYearsRange } from "#helpers/date" export const formatDateExamples: CodeSampleProps[] = [ { @@ -272,30 +267,6 @@ export const formatDateRangeExamples: CodeSampleProps[] = [ }, ] -export const getEventDateInfoExamples: CodeSampleProps[] = [ - { - fn: () => - getEventDateInfo({ - startsAt: "2024-01-01T14:30:00.000Z", - expiresAt: "3024-01-31T14:30:00.000Z", - }), - }, - { - fn: () => - getEventDateInfo({ - startsAt: "2023-01-01T14:30:00.000Z", - expiresAt: "2023-01-31T14:30:00.000Z", - }), - }, - { - fn: () => - getEventDateInfo({ - startsAt: "3023-01-01T14:30:00.000Z", - expiresAt: "3023-01-31T14:30:00.000Z", - }), - }, -] - export const makeDateYearsRangeExamples: CodeSampleProps[] = [ { fn: () => diff --git a/packages/docs/src/stories/utility/Date.mdx b/packages/docs/src/stories/utility/Date.mdx index 84c3786da..139494325 100644 --- a/packages/docs/src/stories/utility/Date.mdx +++ b/packages/docs/src/stories/utility/Date.mdx @@ -1,7 +1,6 @@ import { formatDate, formatDateRange, - getEventDateInfo, makeDateYearsRange } from '#helpers/date' import { Canvas, Title, Description, Meta, Source } from '@storybook/addon-docs/blocks' @@ -35,17 +34,6 @@ Format a date range as a nice string also specifying a custom timezone. } -### `getEventDateInfo()` - -Given the event date (`startsAt` and `expiresAt`) it returns whether the the event is `active`, `past` or `upcoming`. - -{ - Examples.getEventDateInfoExamples.map(props => ( - - )) -} - - ### `makeDateYearsRange()` Returns the specified `yearsAgo` date range (minus 1 second) from the specified `now` date. diff --git a/packages/docs/src/stories/utility/Route.mdx b/packages/docs/src/stories/utility/Route.mdx index d68646695..a074c4b3d 100644 --- a/packages/docs/src/stories/utility/Route.mdx +++ b/packages/docs/src/stories/utility/Route.mdx @@ -1,7 +1,6 @@ import { formatDate, formatDateRange, - getEventDateInfo, makeDateYearsRange } from '#helpers/date' import { Canvas, Title, Description, Meta, Source } from '@storybook/addon-docs/blocks'