@growth-labs/cms 0.8.19 → 0.8.23

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 (182) hide show
  1. package/dist/cli/foundry-emit.d.ts +14 -0
  2. package/dist/cli/foundry-emit.d.ts.map +1 -0
  3. package/dist/cli/foundry-emit.js +69 -0
  4. package/dist/cli/foundry-emit.js.map +1 -0
  5. package/dist/engine/fronts-publish-intent.d.ts +1 -1
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  7. package/dist/engine/fronts-publish-intent.js +12 -1
  8. package/dist/engine/fronts-publish-intent.js.map +1 -1
  9. package/dist/engine/publication-receipt/receipt.d.ts +20 -20
  10. package/dist/engine/publisher.d.ts +16 -1
  11. package/dist/engine/publisher.d.ts.map +1 -1
  12. package/dist/engine/publisher.js +55 -4
  13. package/dist/engine/publisher.js.map +1 -1
  14. package/dist/engine/tags.d.ts.map +1 -1
  15. package/dist/engine/tags.js +18 -1
  16. package/dist/engine/tags.js.map +1 -1
  17. package/dist/engine/topics.d.ts.map +1 -1
  18. package/dist/engine/topics.js +4 -1
  19. package/dist/engine/topics.js.map +1 -1
  20. package/dist/engine/video-source.d.ts +21 -0
  21. package/dist/engine/video-source.d.ts.map +1 -1
  22. package/dist/engine/video-source.js +40 -0
  23. package/dist/engine/video-source.js.map +1 -1
  24. package/dist/foundry-emit.d.ts +146 -0
  25. package/dist/foundry-emit.d.ts.map +1 -0
  26. package/dist/foundry-emit.js +189 -0
  27. package/dist/foundry-emit.js.map +1 -0
  28. package/dist/integration/index.d.ts.map +1 -1
  29. package/dist/integration/index.js +9 -0
  30. package/dist/integration/index.js.map +1 -1
  31. package/dist/integration/options.d.ts +24 -0
  32. package/dist/integration/options.d.ts.map +1 -1
  33. package/dist/integration/options.js +22 -0
  34. package/dist/integration/options.js.map +1 -1
  35. package/dist/lib/publish-timezone.d.ts +38 -0
  36. package/dist/lib/publish-timezone.d.ts.map +1 -0
  37. package/dist/lib/publish-timezone.js +135 -0
  38. package/dist/lib/publish-timezone.js.map +1 -0
  39. package/dist/providers/null.d.ts.map +1 -1
  40. package/dist/providers/null.js +6 -0
  41. package/dist/providers/null.js.map +1 -1
  42. package/dist/providers/types.d.ts +56 -0
  43. package/dist/providers/types.d.ts.map +1 -1
  44. package/dist/providers/types.js.map +1 -1
  45. package/dist/routes/ai.d.ts.map +1 -1
  46. package/dist/routes/ai.js +21 -13
  47. package/dist/routes/ai.js.map +1 -1
  48. package/dist/routes/analytics.d.ts +1 -0
  49. package/dist/routes/analytics.d.ts.map +1 -1
  50. package/dist/routes/analytics.js +20 -0
  51. package/dist/routes/analytics.js.map +1 -1
  52. package/dist/routes/content.d.ts +27 -0
  53. package/dist/routes/content.d.ts.map +1 -1
  54. package/dist/routes/content.js +60 -114
  55. package/dist/routes/content.js.map +1 -1
  56. package/dist/routes/foundry-publish.d.ts +50 -0
  57. package/dist/routes/foundry-publish.d.ts.map +1 -1
  58. package/dist/routes/foundry-publish.js +540 -27
  59. package/dist/routes/foundry-publish.js.map +1 -1
  60. package/dist/routes/machine-context.d.ts +6 -0
  61. package/dist/routes/machine-context.d.ts.map +1 -0
  62. package/dist/routes/machine-context.js +20 -0
  63. package/dist/routes/machine-context.js.map +1 -0
  64. package/dist/routes/surveys.d.ts +14 -1
  65. package/dist/routes/surveys.d.ts.map +1 -1
  66. package/dist/routes/surveys.js +515 -10
  67. package/dist/routes/surveys.js.map +1 -1
  68. package/dist/schema/insights-ingest.d.ts +8 -8
  69. package/dist/schema/migrations.d.ts.map +1 -1
  70. package/dist/schema/migrations.js +15 -0
  71. package/dist/schema/migrations.js.map +1 -1
  72. package/dist/schema/tables.d.ts +1 -1
  73. package/dist/schema/tables.d.ts.map +1 -1
  74. package/dist/schema/tables.js +2 -0
  75. package/dist/schema/tables.js.map +1 -1
  76. package/dist/surveys/access.d.ts +1 -0
  77. package/dist/surveys/access.d.ts.map +1 -1
  78. package/dist/surveys/access.js +13 -4
  79. package/dist/surveys/access.js.map +1 -1
  80. package/dist/surveys/auto-label.d.ts +100 -0
  81. package/dist/surveys/auto-label.d.ts.map +1 -0
  82. package/dist/surveys/auto-label.js +325 -0
  83. package/dist/surveys/auto-label.js.map +1 -0
  84. package/dist/surveys/schema.d.ts +38 -12
  85. package/dist/surveys/schema.d.ts.map +1 -1
  86. package/dist/surveys/schema.js +18 -1
  87. package/dist/surveys/schema.js.map +1 -1
  88. package/dist/ui/api/_content-config.d.ts.map +1 -1
  89. package/dist/ui/api/_content-config.js +4 -0
  90. package/dist/ui/api/_content-config.js.map +1 -1
  91. package/dist/ui/api/analytics.d.ts.map +1 -1
  92. package/dist/ui/api/analytics.js +5 -2
  93. package/dist/ui/api/analytics.js.map +1 -1
  94. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  95. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  96. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  97. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  98. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  99. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  100. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  101. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  102. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  103. package/dist/ui/api/surveys/[surveyId].js +28 -1
  104. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  105. package/dist/ui/commands.d.ts.map +1 -1
  106. package/dist/ui/commands.js +2 -0
  107. package/dist/ui/commands.js.map +1 -1
  108. package/dist/ui/hash-router.d.ts +1 -1
  109. package/dist/ui/hash-router.d.ts.map +1 -1
  110. package/dist/ui/hash-router.js +1 -0
  111. package/dist/ui/hash-router.js.map +1 -1
  112. package/dist/ui/inspector/PublishTab.d.ts +7 -1
  113. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  114. package/dist/ui/inspector/PublishTab.js +24 -26
  115. package/dist/ui/inspector/PublishTab.js.map +1 -1
  116. package/dist/ui/nav.d.ts.map +1 -1
  117. package/dist/ui/nav.js +1 -0
  118. package/dist/ui/nav.js.map +1 -1
  119. package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
  120. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  121. package/dist/ui/screens/AnalyticsScreen.js +13 -8
  122. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  123. package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
  124. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
  125. package/dist/ui/screens/PodcastListeningScreen.js +244 -0
  126. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
  127. package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
  128. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  129. package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
  130. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  131. package/dist/ui/screens/analytics-data.d.ts +14 -0
  132. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  133. package/dist/ui/screens/analytics-data.js +16 -0
  134. package/dist/ui/screens/analytics-data.js.map +1 -1
  135. package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
  136. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
  137. package/dist/ui/screens/podcast-listening-data.js +98 -0
  138. package/dist/ui/screens/podcast-listening-data.js.map +1 -0
  139. package/dist/ui/screens/registry.d.ts.map +1 -1
  140. package/dist/ui/screens/registry.js +2 -0
  141. package/dist/ui/screens/registry.js.map +1 -1
  142. package/docs/foundry-publish.md +208 -0
  143. package/migrations/0033_survey_answer_labels.sql +15 -0
  144. package/package.json +9 -3
  145. package/src/cli/foundry-emit.ts +85 -0
  146. package/src/engine/fronts-publish-intent.ts +23 -1
  147. package/src/engine/publisher.ts +80 -4
  148. package/src/engine/tags.ts +21 -1
  149. package/src/engine/topics.ts +4 -1
  150. package/src/engine/video-source.ts +44 -0
  151. package/src/foundry-emit.ts +255 -0
  152. package/src/integration/index.ts +9 -0
  153. package/src/integration/options.ts +23 -0
  154. package/src/lib/publish-timezone.ts +154 -0
  155. package/src/providers/null.ts +6 -0
  156. package/src/providers/types.ts +52 -0
  157. package/src/routes/ai.ts +29 -17
  158. package/src/routes/analytics.ts +21 -0
  159. package/src/routes/content.ts +86 -131
  160. package/src/routes/foundry-publish.ts +746 -29
  161. package/src/routes/machine-context.ts +24 -0
  162. package/src/routes/surveys.ts +587 -12
  163. package/src/schema/migrations.ts +15 -0
  164. package/src/schema/tables.ts +2 -0
  165. package/src/surveys/access.ts +14 -4
  166. package/src/surveys/auto-label.ts +420 -0
  167. package/src/surveys/schema.ts +25 -1
  168. package/src/ui/api/_content-config.ts +6 -0
  169. package/src/ui/api/analytics.ts +5 -2
  170. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  171. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  172. package/src/ui/api/surveys/[surveyId].ts +37 -3
  173. package/src/ui/commands.ts +2 -0
  174. package/src/ui/hash-router.ts +1 -0
  175. package/src/ui/inspector/PublishTab.tsx +33 -25
  176. package/src/ui/nav.ts +1 -0
  177. package/src/ui/screens/AnalyticsScreen.tsx +34 -6
  178. package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
  179. package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
  180. package/src/ui/screens/analytics-data.ts +18 -0
  181. package/src/ui/screens/podcast-listening-data.ts +114 -0
  182. package/src/ui/screens/registry.tsx +2 -0
@@ -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"
package/src/ui/nav.ts CHANGED
@@ -26,6 +26,7 @@ export const NAV_MODEL: NavGroup[] = [
26
26
  { id: 'calendar', label: 'Calendar', icon: 'calendar' },
27
27
  { id: 'media', label: 'Media', icon: 'media' },
28
28
  { id: 'analytics', label: 'Analytics', icon: 'chart' },
29
+ { id: 'podcasts', label: 'Podcasts', icon: 'mic' },
29
30
  ],
30
31
  },
31
32
  {
@@ -32,7 +32,9 @@ import {
32
32
  normalizeAnalyticsData,
33
33
  normalizeArticleAnalyticsDetail,
34
34
  type ReadsPoint,
35
+ TRAFFIC_SOURCES_CAPTION,
35
36
  type TrafficSource,
37
+ uniqueVisitorsKpiLabel,
36
38
  } from './analytics-data.js'
37
39
  import {
38
40
  formatKpi,
@@ -201,7 +203,9 @@ export function AnalyticsScreen() {
201
203
  </div>
202
204
  )}
203
205
 
204
- {!s.loading && !s.error && s.data && <AnalyticsBody data={s.data} isPartial={isPartial} />}
206
+ {!s.loading && !s.error && s.data && (
207
+ <AnalyticsBody data={s.data} isPartial={isPartial} rangeDays={Number.parseInt(range, 10)} />
208
+ )}
205
209
  </div>
206
210
  )
207
211
  }
@@ -579,7 +583,15 @@ function RangeSegment({
579
583
  // AnalyticsBody
580
584
  // ---------------------------------------------------------------------------
581
585
 
582
- function AnalyticsBody({ data, isPartial }: { data: AnalyticsData; isPartial: boolean }) {
586
+ function AnalyticsBody({
587
+ data,
588
+ isPartial,
589
+ rangeDays,
590
+ }: {
591
+ data: AnalyticsData
592
+ isPartial: boolean
593
+ rangeDays: number
594
+ }) {
583
595
  return (
584
596
  <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
585
597
  {isPartial && (
@@ -587,7 +599,7 @@ function AnalyticsBody({ data, isPartial }: { data: AnalyticsData; isPartial: bo
587
599
  )}
588
600
 
589
601
  {/* KPI cards */}
590
- <KpiCards kpis={data.kpis} />
602
+ <KpiCards kpis={data.kpis} rangeDays={rangeDays} />
591
603
 
592
604
  {/* Reads over time + traffic sources */}
593
605
  <div className="grid-split-wide">
@@ -638,7 +650,13 @@ const KPI_DEFS: Array<{
638
650
  },
639
651
  ]
640
652
 
641
- export function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>> }) {
653
+ export function KpiCards({
654
+ kpis,
655
+ rangeDays = 7,
656
+ }: {
657
+ kpis: ProviderResult<Record<string, number | null>>
658
+ rangeDays?: number
659
+ }) {
642
660
  const kpiStatus = selectKpiState(kpis)
643
661
 
644
662
  if (kpiStatus === 'empty') {
@@ -666,7 +684,7 @@ export function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number
666
684
  return (
667
685
  <KpiCard
668
686
  key={def.key}
669
- label={def.label}
687
+ label={def.key === 'uniqueVisitors' ? uniqueVisitorsKpiLabel(rangeDays) : def.label}
670
688
  status={isUnmeasured ? 'partial' : kpiStatus}
671
689
  value={displayValue}
672
690
  note={displayNote}
@@ -813,7 +831,7 @@ function ReadsChart({ series }: { series: ProviderResult<ReadsPoint[]> }) {
813
831
  // TrafficSources
814
832
  // ---------------------------------------------------------------------------
815
833
 
816
- function TrafficSources({ result }: { result: ProviderResult<TrafficSource[]> }) {
834
+ export function TrafficSources({ result }: { result: ProviderResult<TrafficSource[]> }) {
817
835
  if (!result || result.status === 'empty' || !result.data || result.data.length === 0) {
818
836
  return <EmptyPanel icon="globe" message="No traffic source data yet." />
819
837
  }
@@ -836,6 +854,16 @@ function TrafficSources({ result }: { result: ProviderResult<TrafficSource[]> })
836
854
  <span style={{ fontSize: 11, fontWeight: 600, color: 'var(--amber)' }}>Partial</span>
837
855
  )}
838
856
  </div>
857
+ <div
858
+ style={{
859
+ fontSize: 11,
860
+ color: 'var(--ink-faint)',
861
+ marginTop: -10,
862
+ marginBottom: 14,
863
+ }}
864
+ >
865
+ {TRAFFIC_SOURCES_CAPTION}
866
+ </div>
839
867
  {sources.map((src) => {
840
868
  const pct = (src.sessions / maxSessions) * 100
841
869
  return (