Skip to content
Open
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
94 changes: 94 additions & 0 deletions packages/app-elements/src/dictionaries/promotions.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
29 changes: 22 additions & 7 deletions packages/app-elements/src/dictionaries/promotions.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand All @@ -36,7 +30,7 @@ export function getPromotionDisplayStatus(
}
}

switch (eventDateInfo) {
switch (getPromotionTimeframe(promotion)) {
case "past":
return {
status: "expired",
Expand All @@ -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<Promotion, "starts_at" | "expires_at">,
): "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"
}
49 changes: 0 additions & 49 deletions packages/app-elements/src/helpers/date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
formatDate,
formatDateRange,
formatDateWithPredicate,
getEventDateInfo,
getIsoDateAtDayEdge,
getIsoDateAtDaysBefore,
} from "./date"
Expand Down Expand Up @@ -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")
Expand Down
38 changes: 0 additions & 38 deletions packages/app-elements/src/helpers/date.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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'
Expand Down
1 change: 0 additions & 1 deletion packages/app-elements/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export {
formatDate,
formatDateRange,
formatDateWithPredicate,
getEventDateInfo,
getIsoDateAtDayEdge,
getIsoDateAtDaysBefore,
isDateValid,
Expand Down
1 change: 0 additions & 1 deletion packages/docs/src/stories/utility/Currency.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
formatDate,
formatDateRange,
getEventDateInfo,
makeDateYearsRange
} from '#helpers/date'
import { Canvas, Title, Description, Meta, Source } from '@storybook/addon-docs/blocks'
Expand Down
31 changes: 1 addition & 30 deletions packages/docs/src/stories/utility/Date.data.tsx
Original file line number Diff line number Diff line change
@@ -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[] = [
{
Expand Down Expand Up @@ -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: () =>
Expand Down
12 changes: 0 additions & 12 deletions packages/docs/src/stories/utility/Date.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
formatDate,
formatDateRange,
getEventDateInfo,
makeDateYearsRange
} from '#helpers/date'
import { Canvas, Title, Description, Meta, Source } from '@storybook/addon-docs/blocks'
Expand Down Expand Up @@ -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 => (
<CodeSample {...props} />
))
}


### `makeDateYearsRange()`

Returns the specified `yearsAgo` date range (minus 1 second) from the specified `now` date.
Expand Down
1 change: 0 additions & 1 deletion packages/docs/src/stories/utility/Route.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
formatDate,
formatDateRange,
getEventDateInfo,
makeDateYearsRange
} from '#helpers/date'
import { Canvas, Title, Description, Meta, Source } from '@storybook/addon-docs/blocks'
Expand Down
Loading