@12-apps/discounts 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +7 -6
  2. package/prisma/discounts.prisma +23 -0
  3. package/prisma/migrations/20260830120000_discount_weekly_schedule/migration.sql +40 -0
  4. package/src/engine/combo-offer.ts +21 -3
  5. package/src/engine/eligibility.ts +76 -0
  6. package/src/engine/en-US.ts +1 -0
  7. package/src/engine/evaluate.ts +8 -2
  8. package/src/engine/kinds.ts +7 -0
  9. package/src/engine/preview.ts +32 -6
  10. package/src/engine/pt-BR.ts +8 -0
  11. package/src/engine/schedule.ts +191 -0
  12. package/src/engine/teaser.ts +150 -0
  13. package/src/engine/types.ts +36 -0
  14. package/src/index.ts +32 -0
  15. package/src/react/api.ts +13 -0
  16. package/src/react/copy.ts +3 -0
  17. package/src/react/create-web-discounts.tsx +39 -1
  18. package/src/react/discount-actions-menu.tsx +4 -0
  19. package/src/react/discount-form-fields.tsx +90 -6
  20. package/src/react/discount-form-validate.ts +40 -0
  21. package/src/react/discount-form.tsx +38 -2
  22. package/src/react/discounts-screen.tsx +12 -28
  23. package/src/react/en-US.ts +44 -0
  24. package/src/react/format.ts +15 -0
  25. package/src/react/pt-BR.ts +44 -0
  26. package/src/react/row.ts +64 -0
  27. package/src/react/schedule-builder.tsx +253 -0
  28. package/src/react/schedule-copy.ts +74 -0
  29. package/src/react/schedule-summary.ts +100 -0
  30. package/src/react/screen-bindings.ts +57 -0
  31. package/src/react/table-config.ts +15 -0
  32. package/src/server/copy.ts +3 -0
  33. package/src/server/en-US.ts +1 -0
  34. package/src/server/mcp.ts +32 -0
  35. package/src/server/pt-BR.ts +1 -0
  36. package/src/server/store.ts +8 -0
  37. package/src/server/validate.ts +43 -0
  38. package/src/timezone.ts +96 -0
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The weekly schedule editor and the sentence it reads back (FUT-996).
3
+ *
4
+ * The read-back is the highest-value thing on this screen — a grid of chips and
5
+ * two clocks is data, and "Toda sexta, das 16:00 às 20:00" is the promise the
6
+ * operator is actually making. It follows the free-units builder's rule: the
7
+ * sentence appears only once the values make a real offer, so it is never
8
+ * confirmation of something that cannot be saved.
9
+ *
10
+ * Day names are three separate records because Portuguese needs them to be.
11
+ * `dayShort` is a chip ("Seg"), `dayLong` is a list item ("segunda"), and
12
+ * `dayEvery` is a sentence opener whose article follows the day's GENDER
13
+ * ("Toda segunda", "Todo sábado"). Deriving the third from the second would
14
+ * mean encoding Portuguese grammar in a package that must not know which
15
+ * language it is rendering.
16
+ */
17
+ export interface DiscountsScheduleCopy {
18
+ /** Section heading over the period and the repetition. */
19
+ readonly sectionTitle: string;
20
+ readonly periodTitle: string;
21
+ readonly periodHint: string;
22
+ readonly repetitionTitle: string;
23
+ /** The two repetition choices. */
24
+ readonly always: string;
25
+ readonly specific: string;
26
+ /** The builder. */
27
+ readonly builderTitle: string;
28
+ readonly addWindow: string;
29
+ readonly removeWindow: string;
30
+ readonly fromLabel: string;
31
+ readonly toLabel: string;
32
+ /** Preset buttons that write into the chips rather than replacing them. */
33
+ readonly presetEveryDay: string;
34
+ readonly presetWeekdays: string;
35
+ readonly presetWeekend: string;
36
+ /** Monday-first, 0..6. */
37
+ readonly dayShort: readonly string[];
38
+ readonly dayLong: readonly string[];
39
+ readonly dayEvery: readonly string[];
40
+ /** Joining a day list: "segunda, terça e quarta". */
41
+ readonly listSeparator: string;
42
+ readonly listLast: string;
43
+ /** Collapsed day sets, so a common week reads as a phrase not a list. */
44
+ readonly allDays: string;
45
+ readonly weekdays: string;
46
+ readonly weekend: string;
47
+ /** The read-back. `{days}`, `{from}`, `{to}`. */
48
+ readonly summary: string;
49
+ /** The same, when the window runs past midnight. */
50
+ readonly summaryOvernight: string;
51
+ /** Whose clock these times are in. `{timezone}`. */
52
+ readonly timezoneNote: string;
53
+ /**
54
+ * What the shopper is promised — stated where the merchant promises it.
55
+ * FUT-996 decision 2: the price follows the moment the item entered the cart.
56
+ */
57
+ readonly guaranteeNote: string;
58
+ /**
59
+ * The ORDER-scope asymmetry, shown ONLY for that combination because it is
60
+ * otherwise invisible: an order-wide rule has no line to anchor to, so it is
61
+ * judged at checkout while every other scope is judged per line.
62
+ */
63
+ readonly orderScopeNote: string;
64
+ /** Grid: the campaign window and the schedule in one cell. `{window}`, `{schedule}`. */
65
+ readonly windowWithSchedule: string;
66
+ /** Grid: a compact schedule, plus "+N" when there are more rows. `{count}`. */
67
+ readonly moreWindows: string;
68
+ /** Grid + card: this rule's hours are running at this moment. */
69
+ readonly activeNow: string;
70
+ /** Refusals. */
71
+ readonly daysRequired: string;
72
+ readonly timesRequired: string;
73
+ readonly windowsRequired: string;
74
+ }
@@ -0,0 +1,100 @@
1
+ import type { DiscountSchedule, DiscountScheduleWindow } from "../engine/schedule";
2
+ import { toMinutes } from "../engine/schedule";
3
+
4
+ import { fill, type DiscountsWebCopy } from "./copy";
5
+
6
+ /**
7
+ * The read-back sentence: "Toda sexta, das 16:00 às 20:00." (FUT-996)
8
+ *
9
+ * The highest-value thing on the schedule editor. A row of day chips and two
10
+ * clocks is DATA; this is the promise the operator is actually making, in the
11
+ * words they would use to describe it out loud, and it is the only thing on the
12
+ * screen they can check against what they meant.
13
+ *
14
+ * It follows `summaryOf` in the free-units builder exactly: **nothing is
15
+ * rendered until the values make a real offer.** A sentence over a half-built
16
+ * window would read as confirmation of something the validator is about to
17
+ * refuse, which is worse than no sentence at all.
18
+ *
19
+ * Its own module because it is pure string assembly the builder, the grid cell
20
+ * and the card all want, and a copy of it in each is how three surfaces start
21
+ * describing one promotion three ways.
22
+ */
23
+
24
+ const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
25
+ const WEEKDAYS = [0, 1, 2, 3, 4];
26
+ const WEEKEND = [5, 6];
27
+
28
+ function sameDays(days: readonly number[], expected: readonly number[]): boolean {
29
+ const unique = [...new Set(days)].sort((a, b) => a - b);
30
+ return unique.length === expected.length && unique.every((day, i) => day === expected[i]);
31
+ }
32
+
33
+ /**
34
+ * The day set as a phrase.
35
+ *
36
+ * Collapsed sets come first so a common week reads as a phrase rather than a
37
+ * list — "De segunda a sexta" beats "segunda, terça, quarta, quinta e sexta",
38
+ * which is the same fact spelled at four times the length.
39
+ *
40
+ * A SINGLE day uses the pack's `dayEvery` form ("Toda sexta", "Todo sábado")
41
+ * rather than the bare name, because that is how a merchant says it. The
42
+ * article follows the day's gender in Portuguese, which is why the pack carries
43
+ * all seven rather than the code prefixing a word.
44
+ */
45
+ function formatDays(days: readonly number[], copy: DiscountsWebCopy): string | null {
46
+ const unique = [...new Set(days)].sort((a, b) => a - b);
47
+ if (unique.length === 0) return null;
48
+ if (unique.some((day) => day < 0 || day > 6)) return null;
49
+ const { schedule } = copy;
50
+ if (sameDays(unique, ALL_DAYS)) return schedule.allDays;
51
+ if (sameDays(unique, WEEKDAYS)) return schedule.weekdays;
52
+ if (sameDays(unique, WEEKEND)) return schedule.weekend;
53
+ if (unique.length === 1) return schedule.dayEvery[unique[0] as number] ?? null;
54
+ const names = unique.map((day) => schedule.dayLong[day] ?? String(day));
55
+ const last = names[names.length - 1] as string;
56
+ return names.slice(0, -1).join(schedule.listSeparator) + schedule.listLast + last;
57
+ }
58
+
59
+ /**
60
+ * One window as a sentence, or null when it is not yet an offer.
61
+ *
62
+ * An overnight window gets its OWN sentence rather than the same one, because
63
+ * "das 22:00 às 02:00" is ambiguous on a screen and unambiguous only once it
64
+ * says which day the 02:00 belongs to. An operator who typed it by mistake sees
65
+ * it; one who meant it gets confirmation.
66
+ */
67
+ export function formatScheduleWindow(
68
+ window: DiscountScheduleWindow,
69
+ copy: DiscountsWebCopy,
70
+ ): string | null {
71
+ const days = formatDays(window.days, copy);
72
+ const from = toMinutes(window.from);
73
+ const to = toMinutes(window.to);
74
+ if (days === null || from === null || to === null || from === to) return null;
75
+ const template = to < from ? copy.schedule.summaryOvernight : copy.schedule.summary;
76
+ return fill(template, { days, from: window.from, to: window.to });
77
+ }
78
+
79
+ /**
80
+ * The COMPACT form for a grid cell: "sex 16:00–20:00", plus "+N" when the rule
81
+ * has more windows than the column can show.
82
+ *
83
+ * Short day names rather than the sentence, because a table cell is read in a
84
+ * column of forty and the sentence is read once, deliberately, in a form.
85
+ */
86
+ export function formatScheduleCell(
87
+ schedule: DiscountSchedule | null | undefined,
88
+ copy: DiscountsWebCopy,
89
+ ): string | null {
90
+ if (schedule === null || schedule === undefined) return null;
91
+ const [first, ...rest] = schedule.windows;
92
+ if (first === undefined) return null;
93
+ const days = [...new Set(first.days)]
94
+ .sort((a, b) => a - b)
95
+ .map((day) => copy.schedule.dayShort[day] ?? String(day))
96
+ .join(" ");
97
+ const head = `${days} ${first.from}–${first.to}`;
98
+ if (rest.length === 0) return head;
99
+ return `${head} ${fill(copy.schedule.moreWindows, { count: rest.length })}`;
100
+ }
@@ -0,0 +1,57 @@
1
+ "use client";
2
+
3
+ import { useServerDataViews } from "@12-apps/app-shell/react";
4
+
5
+ import type { DiscountsPagination, WireTargetGroup } from "./api";
6
+ import type { MenuBinding, DiscountsScreenProps } from "./discounts-screen";
7
+ import { discountsQueryToParams } from "./table-config";
8
+
9
+ /**
10
+ * The two bindings {@link DiscountsScreen} hands down — pagination and the row
11
+ * menu — lifted out of it.
12
+ *
13
+ * Neither is a second concern; they live here because that component is at both
14
+ * the size and the complexity ceilings, and these are the parts of it that are
15
+ * pure plumbing rather than layout.
16
+ */
17
+
18
+ /**
19
+ * The grid's server-side pagination binding, with the defaults a page that has
20
+ * not loaded yet needs.
21
+ *
22
+ * Its own hook so the three `??` fallbacks are not branches inside
23
+ * {@link DiscountsScreen}, which sits at the complexity ceiling.
24
+ */
25
+ export function usePagedServer(
26
+ pagination: DiscountsPagination | undefined,
27
+ ): ReturnType<typeof useServerDataViews> {
28
+ return useServerDataViews({
29
+ totalCount: pagination?.total ?? 0,
30
+ page: pagination?.page ?? 1,
31
+ pageSize: pagination?.pageSize ?? 20,
32
+ toParams: discountsQueryToParams,
33
+ });
34
+ }
35
+
36
+ /**
37
+ * The binding every row menu, tile and card receives — built once so an edit
38
+ * opened from any of the three offers the same pickers.
39
+ *
40
+ * A helper rather than an inline literal: the optional `timezoneLabel` is a
41
+ * conditional spread, and {@link DiscountsScreen} is at the complexity ceiling.
42
+ */
43
+ export function toMenuBinding(
44
+ props: DiscountsScreenProps,
45
+ groups: readonly WireTargetGroup[] | undefined,
46
+ ): MenuBinding {
47
+ const { api, copy, formatters, currencyField, onError, timezoneLabel } = props;
48
+ return {
49
+ api,
50
+ copy,
51
+ formatters,
52
+ currencyField,
53
+ groups,
54
+ onError,
55
+ ...(timezoneLabel ? { timezoneLabel } : {}),
56
+ };
57
+ }
@@ -76,6 +76,21 @@ export function discountColumns(copy: DiscountsWebCopy): DataViewColumn<Discount
76
76
  searchable: true,
77
77
  },
78
78
  { id: "window", header: copy.screen.columns.window, accessor: (row) => row.windowLabel },
79
+ {
80
+ // The live answer to "is my happy hour on right now?" (FUT-996).
81
+ //
82
+ // Its own column rather than a widening of the vigência pill beside it:
83
+ // those three values are resolved by a store-side SQL predicate, and a
84
+ // recurrence predicate is not expressible there. This one is computed in
85
+ // the browser from the store's timezone, so it can be SHOWN but not
86
+ // FILTERED — and a cell is honest about that in a way a pill would not be.
87
+ //
88
+ // Blank, not "Não", for a rule with no schedule: there is nothing
89
+ // intermittent to report, and a column of "Não" would read as a fault.
90
+ id: "activeNow",
91
+ header: copy.schedule.activeNow,
92
+ accessor: (row) => (row.activeNow ? copy.schedule.activeNow : EMPTY),
93
+ },
79
94
  {
80
95
  id: "usageCount",
81
96
  header: copy.screen.columns.usageCount,
@@ -34,6 +34,8 @@ export interface DiscountsServerCopy {
34
34
  readonly invalidDate: string;
35
35
  /** 422 — the window closes at or before it opens, so it is never open. */
36
36
  readonly endsBeforeStarts: string;
37
+ /** The weekly schedule is not one the engine can act on (FUT-996). */
38
+ readonly invalidSchedule: string;
37
39
  /** 422 — a minimum-basket threshold of zero or less. */
38
40
  readonly invalidMinSubtotal: string;
39
41
  /** 422 — a global redemption cap of zero or less. */
@@ -79,6 +81,7 @@ const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
79
81
  "itemTargetRequired",
80
82
  "invalidDate",
81
83
  "endsBeforeStarts",
84
+ "invalidSchedule",
82
85
  "invalidMinSubtotal",
83
86
  "invalidUsageLimit",
84
87
  "invalidPerBuyerLimit",
@@ -23,6 +23,7 @@ export const EN_US_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
23
23
  // would send an operator to type something the endpoint rejects.
24
24
  invalidDate: "Invalid date. Use the format YYYY-MM-DD.",
25
25
  endsBeforeStarts: "The end date must be after the start date.",
26
+ invalidSchedule: "Invalid promotion schedule. Pick at least one day, and a start and end time that differ.",
26
27
  invalidMinSubtotal: "The minimum order must be greater than zero (or left blank).",
27
28
  invalidUsageLimit: "The usage limit must be greater than zero (or left blank).",
28
29
  invalidPerBuyerLimit: "The per-customer limit must be greater than zero (or left blank).",
package/src/server/mcp.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  MAX_COMBO_SLOT_QUANTITY,
18
18
  MAX_PERCENT_OFF_BP,
19
19
  } from "../engine/kinds";
20
+ import { MAX_SCHEDULE_WINDOWS } from "../engine/schedule";
20
21
  import { discountSearchConfig } from "./search";
21
22
 
22
23
  /**
@@ -66,6 +67,35 @@ export const comboRequirementSchema = z.object({
66
67
  quantity: z.number().int().min(1).max(MAX_COMBO_SLOT_QUANTITY),
67
68
  });
68
69
 
70
+ /**
71
+ * A `HH:MM` clock time, 24-hour — the schedule's own wire format (FUT-996).
72
+ *
73
+ * A string rather than minutes-since-midnight because it is what an operator
74
+ * typed and what a card reads back; converting at the edge would mean two
75
+ * representations of one fact and a conversion to get wrong in each direction.
76
+ */
77
+ const clockTimeSchema = z
78
+ .string()
79
+ .regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:MM, 24-hour");
80
+
81
+ /**
82
+ * One "these days, these hours" row. `days` is **Monday-first, 0..6** — the
83
+ * same axis every hours surface uses, and NOT `Date#getDay()`'s Sunday-first.
84
+ *
85
+ * `to` earlier than `from` is legal and means the window runs past midnight: a
86
+ * bar shutting at 02:00 is the most common happy hour there is.
87
+ */
88
+ const scheduleWindowSchema = z.object({
89
+ days: z.array(z.number().int().min(0).max(6)).min(1).max(7),
90
+ from: clockTimeSchema,
91
+ to: clockTimeSchema,
92
+ });
93
+
94
+ /** The weekly schedule. Null = always, within the campaign window. */
95
+ const discountScheduleSchema = z.object({
96
+ windows: z.array(scheduleWindowSchema).min(1).max(MAX_SCHEDULE_WINDOWS),
97
+ });
98
+
69
99
  /**
70
100
  * A discount as the API returns it (JSON-serialized: dates become ISO
71
101
  * strings). The two join tables are flattened into id arrays.
@@ -93,6 +123,7 @@ export const discountSchema = z.object({
93
123
  code: z.string().nullable(),
94
124
  startsAt: z.string().nullable(),
95
125
  endsAt: z.string().nullable(),
126
+ schedule: discountScheduleSchema.nullable().optional(),
96
127
  minSubtotalCents: z.number().int().nullable(),
97
128
  usageLimit: z.number().int().nullable(),
98
129
  perBuyerLimit: z.number().int().nullable(),
@@ -150,6 +181,7 @@ const discountWriteShape = {
150
181
  code: z.string().min(1).max(64).nullable().optional(),
151
182
  startsAt: calendarDateSchema.nullable().optional(),
152
183
  endsAt: calendarDateSchema.nullable().optional(),
184
+ schedule: discountScheduleSchema.nullable().optional(),
153
185
  minSubtotalCents: z.number().int().positive().nullable().optional(),
154
186
  usageLimit: z.number().int().positive().nullable().optional(),
155
187
  perBuyerLimit: z.number().int().positive().nullable().optional(),
@@ -20,6 +20,7 @@ export const PT_BR_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
20
20
  itemTargetRequired: "Selecione ao menos um produto para este desconto.",
21
21
  invalidDate: "Data inválida. Use o formato AAAA-MM-DD.",
22
22
  endsBeforeStarts: "A data de término deve ser posterior à data de início.",
23
+ invalidSchedule: "Horários da promoção inválidos. Escolha ao menos um dia e um horário de início e fim diferentes.",
23
24
  invalidMinSubtotal: "O pedido mínimo deve ser maior que zero (ou deixe em branco).",
24
25
  invalidUsageLimit: "O limite de usos deve ser maior que zero (ou deixe em branco).",
25
26
  invalidPerBuyerLimit: "O limite por cliente deve ser maior que zero (ou deixe em branco).",
@@ -1,6 +1,7 @@
1
1
  import type { PaginatedResult, ParsedSearchInput } from "@12-apps/shared-helpers/search";
2
2
 
3
3
  import type { DiscountScope, DiscountTrigger, DiscountType } from "../engine/kinds";
4
+ import type { DiscountSchedule } from "../engine/schedule";
4
5
  import type { ComboRequirement } from "../engine/types";
5
6
  import type { DiscountScalars, DiscountTargets } from "./validate";
6
7
 
@@ -33,6 +34,13 @@ export interface DiscountRecord {
33
34
  code: string | null;
34
35
  startsAt: Date | null;
35
36
  endsAt: Date | null;
37
+ /**
38
+ * The WEEKLY schedule (FUT-996), OPTIONAL for the reason the combo columns
39
+ * below are: a host that has not added the column keeps compiling and keeps
40
+ * answering the record it always did. Null or absent = always, within the
41
+ * campaign window.
42
+ */
43
+ schedule?: DiscountSchedule | null;
36
44
  minSubtotalCents: number | null;
37
45
  usageLimit: number | null;
38
46
  perBuyerLimit: number | null;
@@ -5,6 +5,7 @@ import {
5
5
  type DiscountTrigger,
6
6
  type DiscountType,
7
7
  } from "../engine/kinds";
8
+ import { isUsableSchedule, type DiscountSchedule } from "../engine/schedule";
8
9
  import type { ComboRequirement } from "../engine/types";
9
10
  import type { DiscountsServerCopy } from "./copy";
10
11
  import { comboRequirementsForScope, toComboColumns } from "./validate-combo";
@@ -50,6 +51,15 @@ export interface DiscountWriteInput {
50
51
  /** Calendar dates (`YYYY-MM-DD`) as the form sends them, or null. */
51
52
  startsAt: string | null;
52
53
  endsAt: string | null;
54
+ /**
55
+ * The WEEKLY schedule inside that window (FUT-996), or null for "always".
56
+ * Validated here rather than trusted: it is stored as JSON, so the database
57
+ * cannot check its shape and this is the only gate in front of it.
58
+ *
59
+ * Optional so a caller assembling a write input by hand — a test, a script,
60
+ * an older adopter — does not have to name a field it has no opinion about.
61
+ */
62
+ schedule?: DiscountSchedule | null;
53
63
  minSubtotalCents: number | null;
54
64
  usageLimit: number | null;
55
65
  perBuyerLimit: number | null;
@@ -168,6 +178,32 @@ function assertWindow(
168
178
  return { startsAt, endsAt };
169
179
  }
170
180
 
181
+ /**
182
+ * The weekly schedule, or null (FUT-996).
183
+ *
184
+ * Every failure here is ONE message rather than a per-window field error,
185
+ * because the operator does not edit this as fields: they edit a row of day
186
+ * chips and two clocks, and "a linha 2 não tem dias" points at something the
187
+ * form can highlight while `schedule.windows[1].days` does not.
188
+ *
189
+ * A `to` earlier than `from` is NOT an error — it is a bar shutting at 02:00,
190
+ * which is the most common happy hour there is. The only refusals are the ones
191
+ * that cannot mean anything: no windows at all, a row naming no day, a day off
192
+ * the Monday-first axis, a malformed clock, and a zero-length window.
193
+ */
194
+ function assertSchedule(
195
+ input: DiscountWriteInput,
196
+ copy: DiscountsServerCopy,
197
+ ): DiscountSchedule | null {
198
+ const { schedule } = input;
199
+ // `== null` catches BOTH, deliberately: the field is required on the type,
200
+ // but a caller assembling a write input by hand omits what it does not use,
201
+ // and "absent" and "explicitly none" are the same promotion.
202
+ if (schedule == null) return null;
203
+ if (!isUsableSchedule(schedule)) throw invalid("schedule", copy.invalidSchedule);
204
+ return { windows: schedule.windows.map((w) => ({ days: [...w.days], from: w.from, to: w.to })) };
205
+ }
206
+
171
207
  /** A cap of zero is not a cap, it is a discount that can never be used. */
172
208
  function assertPositiveOrNull(value: number | null, field: string, message: string): void {
173
209
  if (value === null) return;
@@ -194,6 +230,8 @@ export interface DiscountScalars {
194
230
  code: string | null;
195
231
  startsAt: Date | null;
196
232
  endsAt: Date | null;
233
+ /** The weekly schedule, or null. Persisted as a JSON column. */
234
+ schedule: DiscountSchedule | null;
197
235
  minSubtotalCents: number | null;
198
236
  usageLimit: number | null;
199
237
  perBuyerLimit: number | null;
@@ -233,6 +271,7 @@ export function toDiscountScalars(
233
271
  const code = assertTrigger(input, copy);
234
272
  assertTargets(input, copy);
235
273
  const { startsAt, endsAt } = assertWindow(input, copy);
274
+ const schedule = assertSchedule(input, copy);
236
275
  assertLimits(input, copy);
237
276
  const combo = toComboColumns(input, copy);
238
277
 
@@ -249,6 +288,7 @@ export function toDiscountScalars(
249
288
  code,
250
289
  startsAt,
251
290
  endsAt,
291
+ schedule,
252
292
  minSubtotalCents: input.minSubtotalCents,
253
293
  usageLimit: input.usageLimit,
254
294
  perBuyerLimit: input.perBuyerLimit,
@@ -277,6 +317,8 @@ export interface DiscountWriteBody {
277
317
  code?: string | null;
278
318
  startsAt?: string | null;
279
319
  endsAt?: string | null;
320
+ /** The weekly schedule, or null/omitted for "always" (FUT-996). */
321
+ schedule?: DiscountSchedule | null;
280
322
  minSubtotalCents?: number | null;
281
323
  usageLimit?: number | null;
282
324
  perBuyerLimit?: number | null;
@@ -311,6 +353,7 @@ export function toDiscountWriteInput(body: DiscountWriteBody): DiscountWriteInpu
311
353
  code: orNull(body.code),
312
354
  startsAt: orNull(body.startsAt),
313
355
  endsAt: orNull(body.endsAt),
356
+ schedule: orNull(body.schedule),
314
357
  minSubtotalCents: orNull(body.minSubtotalCents),
315
358
  usageLimit: orNull(body.usageLimit),
316
359
  perBuyerLimit: orNull(body.perBuyerLimit),
@@ -0,0 +1,96 @@
1
+ import type { LocalClock } from "./engine/schedule";
2
+
3
+ /**
4
+ * Resolve an instant into a store's own wall clock (FUT-996).
5
+ *
6
+ * Deliberately NOT part of the engine, and exported from its own subpath rather
7
+ * than the root. The engine's contract is that it reaches no clock and no
8
+ * locale data — it runs in an offline tool generator, a browser bundle and a
9
+ * worker — and this function reads `Intl`, which is exactly the dependency that
10
+ * contract exists to keep out. Everything here produces the two integers the
11
+ * engine consumes; nothing here decides anything about a discount.
12
+ *
13
+ * It lives in the PACKAGE rather than in each host because there must be one
14
+ * implementation. The admin grid's "ativa agora" dot runs in a browser, the
15
+ * price a shopper is charged is computed on a server, and a second copy of
16
+ * "which weekday is it in São Paulo at 23:50" is how those two start
17
+ * disagreeing about the same minute — silently, and only for the hour that
18
+ * matters.
19
+ */
20
+
21
+ /**
22
+ * `Intl` reports a Sunday-first weekday; the schedule axis is Monday-first, the
23
+ * same one `WeekHours` and every hours screen uses. This table is the whole
24
+ * conversion, written as data so it cannot be got subtly wrong by arithmetic.
25
+ */
26
+ const MONDAY_FIRST: Readonly<Record<string, number>> = {
27
+ Mon: 0,
28
+ Tue: 1,
29
+ Wed: 2,
30
+ Thu: 3,
31
+ Fri: 4,
32
+ Sat: 5,
33
+ Sun: 6,
34
+ };
35
+
36
+ /**
37
+ * The store's weekday and minute-of-day at `instant`, or null when the zone is
38
+ * not one this runtime knows.
39
+ *
40
+ * NULL rather than a fallback, and the CALLER decides what that means — which
41
+ * is the only honest split, because the two callers need opposite things. A
42
+ * cart must keep pricing (a scheduled rule fires; see `scheduleCovers`), and a
43
+ * teaser must stay quiet rather than promise an hour it cannot name. A default
44
+ * chosen here would silently impose one of those on both.
45
+ */
46
+ export function resolveLocalClock(instant: Date, timeZone: string): LocalClock | null {
47
+ try {
48
+ const parts = new Intl.DateTimeFormat("en-US", {
49
+ timeZone,
50
+ weekday: "short",
51
+ hour: "2-digit",
52
+ minute: "2-digit",
53
+ hour12: false,
54
+ }).formatToParts(instant);
55
+ const read = (type: string): string =>
56
+ parts.find((part) => part.type === type)?.value ?? "";
57
+ const weekday = MONDAY_FIRST[read("weekday")];
58
+ const hour = Number(read("hour"));
59
+ const minute = Number(read("minute"));
60
+ if (weekday === undefined || !Number.isFinite(hour) || !Number.isFinite(minute)) return null;
61
+ // `hour12: false` yields 24 for midnight in some ICU versions — a real,
62
+ // once-a-day discrepancy that would put every midnight instant outside
63
+ // every window.
64
+ return { weekday, minutes: (hour % 24) * 60 + minute };
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The zone as a PERSON would read it, in their own language — "Horário Padrão
72
+ * de Brasília", "Brasilia Standard Time".
73
+ *
74
+ * Derived rather than configured, and that is the point: a host passing a
75
+ * display name would be passing COPY, which this repo's own rules say must
76
+ * ship bilingual. `Intl` already knows every zone's name in every locale, so
77
+ * asking it is both less to maintain and correct in languages nobody here
78
+ * speaks.
79
+ *
80
+ * Falls back to the IANA id with its separators softened ("America/Sao_Paulo"
81
+ * → "Sao Paulo"), which is wrong-looking but never blank — a caption is not
82
+ * worth an exception for.
83
+ */
84
+ export function timeZoneLabel(timeZone: string, locale: string): string {
85
+ try {
86
+ const parts = new Intl.DateTimeFormat(locale, {
87
+ timeZone,
88
+ timeZoneName: "longGeneric",
89
+ }).formatToParts(new Date());
90
+ const name = parts.find((part) => part.type === "timeZoneName")?.value;
91
+ if (name !== undefined && name !== "") return name;
92
+ } catch {
93
+ // fall through
94
+ }
95
+ return (timeZone.split("/").pop() ?? timeZone).replace(/_/g, " ");
96
+ }