@growth-labs/cms 0.8.19 → 0.8.20

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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Publication-timezone helpers — ONE implementation, shared by the schedule
3
+ * route and the Publish tab.
4
+ *
5
+ * Why this module exists: the editor types a wall-clock time and expects the
6
+ * post to go out at that time in the publication's timezone. That only holds
7
+ * if the digits the UI DISPLAYS and the zone it SAVES agree. When they
8
+ * disagreed, every resave shifted `publish_at` by the zone offset — including
9
+ * an incidental resave that never touched the field (the 2026-09-08 P0). So
10
+ * display and entry must use the same converter as the server, not a second
11
+ * copy of the arithmetic.
12
+ */
13
+
14
+ /** Fronts default; the server falls back to this when no zone is configured. */
15
+ export const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
16
+
17
+ /** `YYYY-MM-DDTHH:MM` exactly — the shape the date and time inputs produce. */
18
+ const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
19
+
20
+ type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
21
+
22
+ export function parseLocalDateTime(value: string): LocalDateTimeParts | null {
23
+ const match = LOCAL_DATE_TIME_RE.exec(value)
24
+ if (!match) return null
25
+ const [, y, m, d, h, min] = match
26
+ const parts: LocalDateTimeParts = {
27
+ year: Number.parseInt(y, 10),
28
+ month: Number.parseInt(m, 10),
29
+ day: Number.parseInt(d, 10),
30
+ hour: Number.parseInt(h, 10),
31
+ minute: Number.parseInt(min, 10),
32
+ }
33
+ if (
34
+ !Number.isInteger(parts.year) ||
35
+ parts.month < 1 ||
36
+ parts.month > 12 ||
37
+ parts.day < 1 ||
38
+ parts.day > 31 ||
39
+ parts.hour < 0 ||
40
+ parts.hour > 23 ||
41
+ parts.minute < 0 ||
42
+ parts.minute > 59
43
+ ) {
44
+ return null
45
+ }
46
+ const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
47
+ if (
48
+ dateCheck.getUTCFullYear() !== parts.year ||
49
+ dateCheck.getUTCMonth() + 1 !== parts.month ||
50
+ dateCheck.getUTCDate() !== parts.day
51
+ ) {
52
+ return null
53
+ }
54
+ return parts
55
+ }
56
+
57
+ export function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
58
+ if (!isValidTimeZone(timeZone)) return null
59
+ const formatter = new Intl.DateTimeFormat('en-US', {
60
+ timeZone,
61
+ hour12: false,
62
+ year: 'numeric',
63
+ month: '2-digit',
64
+ day: '2-digit',
65
+ hour: '2-digit',
66
+ minute: '2-digit',
67
+ second: '2-digit',
68
+ })
69
+ const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
70
+ for (const part of formatter.formatToParts(new Date(instantMs))) {
71
+ if (part.type in values) values[part.type as keyof typeof values] = part.value
72
+ }
73
+ const year = Number.parseInt(values.year, 10)
74
+ const month = Number.parseInt(values.month, 10)
75
+ const day = Number.parseInt(values.day, 10)
76
+ const hour = Number.parseInt(values.hour, 10)
77
+ const minute = Number.parseInt(values.minute, 10)
78
+ if (![year, month, day, hour, minute].every(Number.isFinite)) return null
79
+ return { year, month, day, hour, minute }
80
+ }
81
+
82
+ export function isValidTimeZone(timeZone: string): boolean {
83
+ try {
84
+ new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
85
+ return true
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ export function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
92
+ const zoned = getZonedParts(instantMs, timeZone)
93
+ if (!zoned) return null
94
+ const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
95
+ return asUtc - instantMs
96
+ }
97
+
98
+ export function parseLocalDateTimeInZoneToUnixSeconds(
99
+ value: string,
100
+ timeZone: string,
101
+ ): number | null {
102
+ const target = parseLocalDateTime(value)
103
+ if (!target) return null
104
+ const targetUtcMs = Date.UTC(
105
+ target.year,
106
+ target.month - 1,
107
+ target.day,
108
+ target.hour,
109
+ target.minute,
110
+ 0,
111
+ )
112
+ let candidateMs = targetUtcMs
113
+ for (let i = 0; i < 5; i += 1) {
114
+ const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
115
+ if (offsetMs === null) return null
116
+ const next = targetUtcMs - offsetMs
117
+ if (Math.abs(next - candidateMs) < 1000) {
118
+ candidateMs = next
119
+ break
120
+ }
121
+ candidateMs = next
122
+ }
123
+ const resolved = getZonedParts(candidateMs, timeZone)
124
+ if (
125
+ !resolved ||
126
+ resolved.year !== target.year ||
127
+ resolved.month !== target.month ||
128
+ resolved.day !== target.day ||
129
+ resolved.hour !== target.hour ||
130
+ resolved.minute !== target.minute
131
+ ) {
132
+ return null
133
+ }
134
+ return Math.floor(candidateMs / 1000)
135
+ }
136
+
137
+ /**
138
+ * Inverse of `parseLocalDateTimeInZoneToUnixSeconds`: render an instant as the
139
+ * wall-clock digits an editor in `timeZone` would read, split for the date and
140
+ * time inputs. Returns null when the zone is unusable, so callers can fall back
141
+ * rather than silently display a wrong hour.
142
+ */
143
+ export function formatInstantInZone(
144
+ instantMs: number,
145
+ timeZone: string,
146
+ ): { date: string; time: string } | null {
147
+ const parts = getZonedParts(instantMs, timeZone)
148
+ if (!parts) return null
149
+ const pad = (n: number, width = 2) => String(n).padStart(width, '0')
150
+ return {
151
+ date: `${pad(parts.year, 4)}-${pad(parts.month)}-${pad(parts.day)}`,
152
+ time: `${pad(parts.hour)}:${pad(parts.minute)}`,
153
+ }
154
+ }
@@ -66,6 +66,11 @@ import { invalidateMediaCaptureStatements, softDeleteContent } from '../engine/s
66
66
  import { normalizeSourceSections } from '../engine/source-sections.js'
67
67
  import { TagInputError } from '../engine/taxonomy.js'
68
68
  import type { ValidationError } from '../engine/validator/index.js'
69
+ import {
70
+ isValidTimeZone,
71
+ PUBLISH_TIMEZONE_FALLBACK,
72
+ parseLocalDateTimeInZoneToUnixSeconds,
73
+ } from '../lib/publish-timezone.js'
69
74
  import type { MediaSourceResolution } from '../providers/types.js'
70
75
  import { parseLayoutEnvelope } from '../schema/layout.js'
71
76
  import { parsePortableTextEnvelope } from '../schema/portable-text.js'
@@ -242,9 +247,6 @@ export interface ContentRouteConfig extends CmsRouteConfig {
242
247
  activeWorkspaceId?: string
243
248
  }
244
249
 
245
- const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
246
- const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
247
-
248
250
  type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
249
251
 
250
252
  /**
@@ -677,119 +679,6 @@ const ContributorsReplaceSchema = z.object({
677
679
 
678
680
  // --- timezone-aware local-datetime parsing (ported verbatim) --------------
679
681
 
680
- type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
681
-
682
- function parseLocalDateTime(value: string): LocalDateTimeParts | null {
683
- const match = LOCAL_DATE_TIME_RE.exec(value)
684
- if (!match) return null
685
- const [, y, m, d, h, min] = match
686
- const parts: LocalDateTimeParts = {
687
- year: Number.parseInt(y, 10),
688
- month: Number.parseInt(m, 10),
689
- day: Number.parseInt(d, 10),
690
- hour: Number.parseInt(h, 10),
691
- minute: Number.parseInt(min, 10),
692
- }
693
- if (
694
- !Number.isInteger(parts.year) ||
695
- parts.month < 1 ||
696
- parts.month > 12 ||
697
- parts.day < 1 ||
698
- parts.day > 31 ||
699
- parts.hour < 0 ||
700
- parts.hour > 23 ||
701
- parts.minute < 0 ||
702
- parts.minute > 59
703
- ) {
704
- return null
705
- }
706
- const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
707
- if (
708
- dateCheck.getUTCFullYear() !== parts.year ||
709
- dateCheck.getUTCMonth() + 1 !== parts.month ||
710
- dateCheck.getUTCDate() !== parts.day
711
- ) {
712
- return null
713
- }
714
- return parts
715
- }
716
-
717
- function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
718
- const formatter = new Intl.DateTimeFormat('en-US', {
719
- timeZone,
720
- hour12: false,
721
- year: 'numeric',
722
- month: '2-digit',
723
- day: '2-digit',
724
- hour: '2-digit',
725
- minute: '2-digit',
726
- second: '2-digit',
727
- })
728
- const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
729
- for (const part of formatter.formatToParts(new Date(instantMs))) {
730
- if (part.type in values) values[part.type as keyof typeof values] = part.value
731
- }
732
- const year = Number.parseInt(values.year, 10)
733
- const month = Number.parseInt(values.month, 10)
734
- const day = Number.parseInt(values.day, 10)
735
- const hour = Number.parseInt(values.hour, 10)
736
- const minute = Number.parseInt(values.minute, 10)
737
- if (![year, month, day, hour, minute].every(Number.isFinite)) return null
738
- return { year, month, day, hour, minute }
739
- }
740
-
741
- function isValidTimeZone(timeZone: string): boolean {
742
- try {
743
- new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
744
- return true
745
- } catch {
746
- return false
747
- }
748
- }
749
-
750
- function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
751
- const zoned = getZonedParts(instantMs, timeZone)
752
- if (!zoned) return null
753
- const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
754
- return asUtc - instantMs
755
- }
756
-
757
- function parseLocalDateTimeInZoneToUnixSeconds(value: string, timeZone: string): number | null {
758
- const target = parseLocalDateTime(value)
759
- if (!target) return null
760
- const targetUtcMs = Date.UTC(
761
- target.year,
762
- target.month - 1,
763
- target.day,
764
- target.hour,
765
- target.minute,
766
- 0,
767
- )
768
- let candidateMs = targetUtcMs
769
- for (let i = 0; i < 5; i += 1) {
770
- const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
771
- if (offsetMs === null) return null
772
- const next = targetUtcMs - offsetMs
773
- if (Math.abs(next - candidateMs) < 1000) {
774
- candidateMs = next
775
- break
776
- }
777
- candidateMs = next
778
- }
779
- const resolved = getZonedParts(candidateMs, timeZone)
780
- if (
781
- !resolved ||
782
- resolved.year !== target.year ||
783
- resolved.month !== target.month ||
784
- resolved.day !== target.day ||
785
- resolved.hour !== target.hour ||
786
- resolved.minute !== target.minute
787
- ) {
788
- return null
789
- }
790
- return Math.floor(candidateMs / 1000)
791
- }
792
-
793
682
  // --- read-side payload helpers (ported verbatim) --------------------------
794
683
 
795
684
  async function getContentPayload(ctx: RouteContext, type: string, id: string) {
@@ -10,7 +10,8 @@
10
10
  // Compile-gated (tsc, jsx: react-jsx). No vitest — pure shapers are in
11
11
  // inspector-data.ts and tested there.
12
12
 
13
- import { useCallback, useEffect, useState } from 'react'
13
+ import { useCallback, useEffect, useMemo, useState } from 'react'
14
+ import { formatInstantInZone, PUBLISH_TIMEZONE_FALLBACK } from '../../lib/publish-timezone.js'
14
15
  import type { ContentStatus } from '../../schema/types.js'
15
16
  import { apiErrorMessage } from '../api-error.js'
16
17
  import { Icon } from '../icons.js'
@@ -40,6 +41,12 @@ export interface PublishTabProps {
40
41
  authorId: string | null
41
42
  /** Current scheduled publish time (ISO string, null if not scheduled). */
42
43
  publishAt: string | null
44
+ /**
45
+ * Timezone the editor schedules in. The same zone is used to DISPLAY the
46
+ * stored instant and to SAVE what is typed, so a resave of untouched fields
47
+ * is a true no-op. Defaults to the publication timezone.
48
+ */
49
+ publishTimezone?: string
43
50
  /** Called after a status/field change so the parent can refresh. */
44
51
  onChanged?: () => void
45
52
  }
@@ -85,6 +92,7 @@ export function PublishTab({
85
92
  visibility,
86
93
  authorId,
87
94
  publishAt,
95
+ publishTimezone = PUBLISH_TIMEZONE_FALLBACK,
88
96
  onChanged,
89
97
  }: PublishTabProps): React.ReactElement {
90
98
  const [saving, setSaving] = useState(false)
@@ -92,16 +100,20 @@ export function PublishTab({
92
100
  const [actionError, setActionError] = useState<string | null>(null)
93
101
 
94
102
  // Local schedule date/time inputs.
95
- const [schedDate, setSchedDate] = useState(() => {
96
- if (!publishAt) return ''
97
- // publishAt is an ISO string; extract the date part.
98
- return publishAt.slice(0, 10)
99
- })
100
- const [schedTime, setSchedTime] = useState(() => {
101
- if (!publishAt) return '12:00'
102
- // Extract HH:MM from ISO.
103
- return publishAt.slice(11, 16) || '12:00'
104
- })
103
+ // The editor thinks in publication-local wall clock, so that is what these
104
+ // inputs show. Falling back to the raw UTC slice keeps an unusable zone
105
+ // readable rather than blank.
106
+ const scheduled = useMemo(() => {
107
+ if (!publishAt) return null
108
+ const ms = Date.parse(publishAt)
109
+ if (!Number.isFinite(ms)) return null
110
+ return formatInstantInZone(ms, publishTimezone)
111
+ }, [publishAt, publishTimezone])
112
+
113
+ const [schedDate, setSchedDate] = useState(() => scheduled?.date ?? publishAt?.slice(0, 10) ?? '')
114
+ const [schedTime, setSchedTime] = useState(
115
+ () => scheduled?.time ?? publishAt?.slice(11, 16) ?? '12:00',
116
+ )
105
117
 
106
118
  // Author list (fetched once on mount).
107
119
  const [authors, setAuthors] = useState<AuthorOption[]>([])
@@ -172,18 +184,14 @@ export function PublishTab({
172
184
  setSaving(true)
173
185
  setActionError(null)
174
186
  try {
175
- // schedDate/schedTime are sliced directly from the UTC ISO publishAt
176
- // (no zone conversion happens anywhere in this component -- see their
177
- // initializers above), so the digits shown here already ARE UTC. Tag
178
- // them 'UTC' on save, not a hardcoded zone: sending literal UTC digits
179
- // under a non-UTC zone label (this used 'Europe/Paris' unconditionally
180
- // until the Falklands P0, 2026-09-08) made the server reinterpret them
181
- // in that zone's offset, silently shifting publish_at by that zone's
182
- // current UTC offset on every resave -- including an incidental
183
- // resave that never touched these fields. 'UTC' makes display and
184
- // save agree, so a resave of untouched fields is a true no-op.
187
+ // These digits are publication-local wall clock (see the initializers),
188
+ // so they are saved under that SAME zone. Display and save must agree:
189
+ // sending digits under a zone they were not rendered in made the server
190
+ // reinterpret them and shift publish_at by that zone's offset on every
191
+ // resave, including an incidental one that never touched these fields
192
+ // (the 2026-09-08 P0). Agreement is what keeps a resave a no-op.
185
193
  const localDateTime = `${schedDate}T${schedTime}`
186
- const body = buildScheduleBody(localDateTime, 'UTC')
194
+ const body = buildScheduleBody(localDateTime, publishTimezone)
187
195
  await runAction(docId, 'schedule', body as unknown as Record<string, unknown>)
188
196
  setSelectedStatus('scheduled')
189
197
  onChanged?.()
@@ -192,7 +200,7 @@ export function PublishTab({
192
200
  } finally {
193
201
  setSaving(false)
194
202
  }
195
- }, [docId, schedDate, schedTime, saving, onChanged])
203
+ }, [docId, schedDate, schedTime, saving, onChanged, publishTimezone])
196
204
 
197
205
  const handleVisibilityChange = useCallback(
198
206
  async (newVis: 'free' | 'premium') => {
@@ -317,7 +325,7 @@ export function PublishTab({
317
325
  {/* Scheduled date + time inputs */}
318
326
  {showScheduleControls && (
319
327
  <div style={schedGrid} className="fade-only">
320
- <Field label="Date (UTC)">
328
+ <Field label={`Date (${publishTimezone})`}>
321
329
  <input
322
330
  className="input"
323
331
  type="date"
@@ -325,7 +333,7 @@ export function PublishTab({
325
333
  onChange={(e) => setSchedDate(e.target.value)}
326
334
  />
327
335
  </Field>
328
- <Field label="Time (UTC)">
336
+ <Field label={`Time (${publishTimezone})`}>
329
337
  <input
330
338
  className="input"
331
339
  type="time"