@growth-labs/cms 0.7.0 → 0.8.2

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 (70) hide show
  1. package/dist/engine/fronts-publish-intent.d.ts +25 -1
  2. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  3. package/dist/engine/fronts-publish-intent.js +64 -11
  4. package/dist/engine/fronts-publish-intent.js.map +1 -1
  5. package/dist/engine/index.d.ts +1 -0
  6. package/dist/engine/index.d.ts.map +1 -1
  7. package/dist/engine/index.js +1 -0
  8. package/dist/engine/index.js.map +1 -1
  9. package/dist/engine/video-source.d.ts +26 -0
  10. package/dist/engine/video-source.d.ts.map +1 -0
  11. package/dist/engine/video-source.js +31 -0
  12. package/dist/engine/video-source.js.map +1 -0
  13. package/dist/integration/index.d.ts.map +1 -1
  14. package/dist/integration/index.js +7 -0
  15. package/dist/integration/index.js.map +1 -1
  16. package/dist/providers/types.d.ts +40 -3
  17. package/dist/providers/types.d.ts.map +1 -1
  18. package/dist/providers/types.js +17 -0
  19. package/dist/providers/types.js.map +1 -1
  20. package/dist/routes/calendar.d.ts +26 -0
  21. package/dist/routes/calendar.d.ts.map +1 -1
  22. package/dist/routes/calendar.js +41 -0
  23. package/dist/routes/calendar.js.map +1 -1
  24. package/dist/routes/content-insights.d.ts +9 -0
  25. package/dist/routes/content-insights.d.ts.map +1 -1
  26. package/dist/routes/content-insights.js +35 -0
  27. package/dist/routes/content-insights.js.map +1 -1
  28. package/dist/routes/content.d.ts +2 -0
  29. package/dist/routes/content.d.ts.map +1 -1
  30. package/dist/routes/content.js.map +1 -1
  31. package/dist/routes/fronts-publish.d.ts +14 -0
  32. package/dist/routes/fronts-publish.d.ts.map +1 -1
  33. package/dist/routes/fronts-publish.js +8 -2
  34. package/dist/routes/fronts-publish.js.map +1 -1
  35. package/dist/ui/api/_content-config.d.ts.map +1 -1
  36. package/dist/ui/api/_content-config.js +1 -0
  37. package/dist/ui/api/_content-config.js.map +1 -1
  38. package/dist/ui/api/calendar.d.ts.map +1 -1
  39. package/dist/ui/api/calendar.js +5 -1
  40. package/dist/ui/api/calendar.js.map +1 -1
  41. package/dist/ui/api/content/insights-inputs.d.ts +3 -0
  42. package/dist/ui/api/content/insights-inputs.d.ts.map +1 -0
  43. package/dist/ui/api/content/insights-inputs.js +15 -0
  44. package/dist/ui/api/content/insights-inputs.js.map +1 -0
  45. package/dist/ui/screens/CalendarScreen.d.ts.map +1 -1
  46. package/dist/ui/screens/CalendarScreen.js +10 -1
  47. package/dist/ui/screens/CalendarScreen.js.map +1 -1
  48. package/dist/ui/screens/calendar-data.d.ts +3 -0
  49. package/dist/ui/screens/calendar-data.d.ts.map +1 -1
  50. package/dist/ui/screens/calendar-data.js.map +1 -1
  51. package/dist/ui/screens/go-publish-badge.d.ts +11 -0
  52. package/dist/ui/screens/go-publish-badge.d.ts.map +1 -0
  53. package/dist/ui/screens/go-publish-badge.js +47 -0
  54. package/dist/ui/screens/go-publish-badge.js.map +1 -0
  55. package/package.json +1 -1
  56. package/src/engine/fronts-publish-intent.ts +104 -15
  57. package/src/engine/index.ts +5 -0
  58. package/src/engine/video-source.ts +45 -0
  59. package/src/integration/index.ts +7 -0
  60. package/src/providers/types.ts +51 -1
  61. package/src/routes/calendar.ts +72 -0
  62. package/src/routes/content-insights.ts +61 -0
  63. package/src/routes/content.ts +2 -0
  64. package/src/routes/fronts-publish.ts +8 -2
  65. package/src/ui/api/_content-config.ts +2 -0
  66. package/src/ui/api/calendar.ts +5 -1
  67. package/src/ui/api/content/insights-inputs.ts +26 -0
  68. package/src/ui/screens/CalendarScreen.tsx +22 -0
  69. package/src/ui/screens/calendar-data.ts +4 -0
  70. package/src/ui/screens/go-publish-badge.ts +55 -0
@@ -409,6 +409,11 @@ export {
409
409
  validateArticleBody,
410
410
  warnErrors,
411
411
  } from './validator/index.js'
412
+ export {
413
+ type FrontsVideoSourceSnapshot,
414
+ frontsVideoSourceGeneration,
415
+ videoSourceIngestableOverHttps,
416
+ } from './video-source.js'
412
417
  // Webhook signer + default dispatch impl — HMAC-SHA256 via Web Crypto (P7 Task 9).
413
418
  export {
414
419
  buildDefaultDispatchWebhook,
@@ -0,0 +1,45 @@
1
+ /** Current CMS evidence used by the signed schedule and media callback guards. */
2
+ export interface FrontsVideoSourceSnapshot {
3
+ content_id: string
4
+ slug: string
5
+ canonical_version: number
6
+ processing_source_url: string | null
7
+ processing_source_kind: string | null
8
+ processing_trigger_token: string | null
9
+ }
10
+
11
+ /**
12
+ * Stable over polling, machine media callbacks and rescheduling; canonical
13
+ * editor/source changes invalidate the old production. The raw trigger token
14
+ * and URL never need to appear in callback or notification identities. The
15
+ * Go consumer's contract is `fronts-video-source.v1` (foundry
16
+ * `publishplane/masthead/videosource.schema.json`, exported by
17
+ * `foundry-capability-probe --contract`).
18
+ */
19
+ export async function frontsVideoSourceGeneration(
20
+ input: FrontsVideoSourceSnapshot,
21
+ ): Promise<string> {
22
+ const bytes = new TextEncoder().encode(
23
+ JSON.stringify([
24
+ 'fronts-video-source.v1',
25
+ input.content_id,
26
+ input.slug,
27
+ input.canonical_version,
28
+ input.processing_source_url,
29
+ input.processing_source_kind,
30
+ input.processing_trigger_token,
31
+ ]),
32
+ )
33
+ const digest = await crypto.subtle.digest('SHA-256', bytes)
34
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
35
+ }
36
+
37
+ /**
38
+ * Source kinds the Go media.pipeline can ingest over plain HTTPS
39
+ * (`ingest.https.verified`): a direct URL and a Dropbox share link, both of
40
+ * which are fetched as-is. A Trello attachment needs the board credential
41
+ * and stays on the host's own dispatch path.
42
+ */
43
+ export function videoSourceIngestableOverHttps(kind: string | null | undefined): boolean {
44
+ return kind === 'http_url' || kind === 'dropbox'
45
+ }
@@ -111,6 +111,13 @@ export default function growthLabsCms(userOptions: CmsIntegrationOptions): Astro
111
111
  pattern: `${base}/api/content/insights-ingest`,
112
112
  entrypoint: resolveEntrypoint('../ui/api/content/insights-ingest'),
113
113
  })
114
+ // Author denylist + existing-content coverage index for foundryd's
115
+ // weekly content-insights funnel (W1-07 PR 6) — M2M poll, HMAC-guarded
116
+ // same as fronts/schedule below, sibling of insights-ingest.
117
+ injectRoute({
118
+ pattern: `${base}/api/content/insights-inputs`,
119
+ entrypoint: resolveEntrypoint('../ui/api/content/insights-inputs'),
120
+ })
114
121
  // Fronts publish path (additive, flag-gated) — M2M siblings of the
115
122
  // foundry-callback / insights-ingest routes. HMAC-guarded.
116
123
  injectRoute({
@@ -402,9 +402,59 @@ export interface FoundryDispatchResult {
402
402
  /** Return source evidence; the CMS owns its atomic capture with the token. */
403
403
  podcastSource?: { url: string; kind: 'https' }
404
404
  }
405
+ /**
406
+ * The Go publish state of one media item as the host reads it from its own
407
+ * receipts (W1-08b): the state bucket the schedule badge renders plus what
408
+ * the badge shows on hover. Every field but `state` is optional so a host
409
+ * that still answers `{ state }` keeps working; `state` is one of
410
+ * `goPublishStateValues` for a Go-plane host and free text otherwise.
411
+ */
412
+ export const goPublishStateValues = [
413
+ 'pending',
414
+ 'captured',
415
+ 'processing',
416
+ 'media_ready',
417
+ 'publishing',
418
+ 'published',
419
+ 'late',
420
+ 'failed',
421
+ ] as const
422
+ export type GoPublishState = (typeof goPublishStateValues)[number]
423
+
424
+ export interface FoundryJobStatus {
425
+ state: GoPublishState | (string & {})
426
+ kind?: 'video' | 'podcast'
427
+ contentId?: string
428
+ contentStatus?: string
429
+ /** The id callbacks arrive under; null before the source is captured. */
430
+ correlationId?: string | null
431
+ /** The host's raw processing state (video_content.processing_state), when it has one. */
432
+ processingState?: string | null
433
+ /** True when the media the site serves exists (HLS ready / audio present). */
434
+ mediaReady?: boolean
435
+ /** Latest production or publish receipt kind, e.g. `hls.ready`, `publish:live`. */
436
+ eventKind?: string | null
437
+ /** Unix seconds of that latest receipt. */
438
+ receiptAt?: number | null
439
+ publishStatus?: 'queued' | 'publishing' | 'live' | 'late' | 'failed' | null
440
+ /** Why the run is blocked (a production error, the last publish error). */
441
+ blockedReason?: string | null
442
+ placement?: {
443
+ storageClass: 'public' | 'private'
444
+ verified: boolean
445
+ verifiedAt: number
446
+ } | null
447
+ }
448
+
405
449
  export interface FoundryDispatch {
406
450
  dispatchVideo(jobSpec: FoundryJobSpec): Promise<FoundryDispatchResult>
407
- getJob(correlationId: string): Promise<{ state: string } | null>
451
+ /**
452
+ * Read a media item's processing / Go publish state. `reference` is the
453
+ * production correlation id the host handed back from dispatchVideo, or
454
+ * a content id (video or podcast) so the schedule views can ask per row.
455
+ * Null when the host knows nothing about the reference.
456
+ */
457
+ getJob(reference: string): Promise<FoundryJobStatus | null>
408
458
  }
409
459
 
410
460
  export type MediaSourceKind = 'video' | 'podcast'
@@ -9,11 +9,19 @@
9
9
  //
10
10
  // No provider calls: the editorial calendar is fully first-party D1.
11
11
 
12
+ import type { FoundryDispatch, FoundryJobStatus } from '../providers/types.js'
12
13
  import type { Authz, RouteContext } from './context.js'
13
14
  import { json } from './context.js'
14
15
 
15
16
  export interface CalendarRouteConfig {
16
17
  authz: Authz
18
+ /**
19
+ * W1-08b: when the host supplies its Foundry provider, every scheduled
20
+ * video/podcast item in the range carries its Go publish state
21
+ * (`goPublish`) read through `getJob(contentId)` -- the badge the
22
+ * calendar chip renders. Absent provider → `goPublish` is omitted.
23
+ */
24
+ foundryVideo?: Pick<FoundryDispatch, 'getJob'>
17
25
  }
18
26
 
19
27
  export interface CalendarRouteHandlers {
@@ -29,6 +37,60 @@ export interface CalendarRouteItem {
29
37
  slug: string
30
38
  /** ISO-8601 date string derived from publish_at (scheduled) or published_at (published). */
31
39
  date: string
40
+ /**
41
+ * Go publish state of a scheduled video/podcast item (W1-08b). `null`
42
+ * when the host provider knows nothing about the item; a `state` of
43
+ * `unavailable` with `blockedReason` when the provider itself failed --
44
+ * the badge says so rather than rendering nothing.
45
+ */
46
+ goPublish?: CalendarGoPublish | null
47
+ }
48
+
49
+ export interface CalendarGoPublish {
50
+ state: FoundryJobStatus['state'] | 'unavailable'
51
+ eventKind: string | null
52
+ receiptAt: number | null
53
+ correlationId: string | null
54
+ blockedReason: string | null
55
+ processingState: string | null
56
+ mediaReady: boolean
57
+ }
58
+
59
+ const GO_PUBLISH_TYPES = new Set(['video', 'podcast'])
60
+
61
+ /** Fold a provider answer into the calendar's badge shape. */
62
+ export function toCalendarGoPublish(status: FoundryJobStatus | null): CalendarGoPublish | null {
63
+ if (!status) return null
64
+ return {
65
+ state: status.state,
66
+ eventKind: status.eventKind ?? null,
67
+ receiptAt: status.receiptAt ?? null,
68
+ correlationId: status.correlationId ?? null,
69
+ blockedReason: status.blockedReason ?? null,
70
+ processingState: status.processingState ?? null,
71
+ mediaReady: status.mediaReady ?? false,
72
+ }
73
+ }
74
+
75
+ async function readGoPublish(
76
+ provider: Pick<FoundryDispatch, 'getJob'>,
77
+ contentId: string,
78
+ ): Promise<CalendarGoPublish | null> {
79
+ try {
80
+ return toCalendarGoPublish(await provider.getJob(contentId))
81
+ } catch (error) {
82
+ const reason = error instanceof Error ? error.message : String(error)
83
+ console.warn('calendar: Go publish state unavailable', { contentId, reason })
84
+ return {
85
+ state: 'unavailable',
86
+ eventKind: null,
87
+ receiptAt: null,
88
+ correlationId: null,
89
+ blockedReason: reason,
90
+ processingState: null,
91
+ mediaReady: false,
92
+ }
93
+ }
32
94
  }
33
95
 
34
96
  /**
@@ -121,6 +183,16 @@ export function createCalendarRoutes(config: CalendarRouteConfig): CalendarRoute
121
183
  date: toIso(row.date_unix),
122
184
  }))
123
185
 
186
+ const provider = config.foundryVideo
187
+ if (provider) {
188
+ await Promise.all(
189
+ items.map(async (item) => {
190
+ if (item.status !== 'scheduled' || !GO_PUBLISH_TYPES.has(item.type)) return
191
+ item.goPublish = await readGoPublish(provider, item.id)
192
+ }),
193
+ )
194
+ }
195
+
124
196
  return json({ items })
125
197
  },
126
198
  }
@@ -13,6 +13,10 @@ import { InsightDismissalReasonCodeSchema } from '../schema/types.js'
13
13
  import type { CmsRouteConfig } from './config.js'
14
14
  import { resolveConfig } from './config.js'
15
15
  import { json, type RouteContext } from './context.js'
16
+ import {
17
+ resolveFoundryHmacSecret as resolveFoundryHmacSecretForInputs,
18
+ verifyFoundrySignedRequest,
19
+ } from './fronts-publish.js'
16
20
 
17
21
  const DismissSchema = z
18
22
  .object({
@@ -72,8 +76,32 @@ export interface ContentInsightsRouteHandlers {
72
76
  undismiss(ctx: RouteContext): Promise<Response>
73
77
  /** POST /content/insights-ingest — HMAC machine ingest (replay-safe replace). */
74
78
  ingest(ctx: RouteContext): Promise<Response>
79
+ /**
80
+ * GET /content/insights-inputs?site=<id> — the funnel's author denylist +
81
+ * existing-content coverage index for this site, read by foundryd's
82
+ * sitebrain.insights.run schedule (W1-07 PR 6). Same M2M signing family as
83
+ * fronts/schedule (X-Foundry-Timestamp + X-Foundry-Signature over the raw
84
+ * query string, verified against FOUNDRY_HMAC_SECRET) -- a sibling poll,
85
+ * not a new auth scheme.
86
+ */
87
+ insightsInputs(ctx: RouteContext): Promise<Response>
75
88
  }
76
89
 
90
+ /** authors row shape this route returns: just enough for the author denylist,
91
+ * never email/bio/social -- the funnel matches on name/slug only. */
92
+ const AUTHORS_QUERY = 'SELECT name, slug FROM authors ORDER BY name LIMIT 1000'
93
+
94
+ /**
95
+ * Existing-content index for lexical coverage: every content item an editor
96
+ * could currently be working on or has already shipped, so the funnel can
97
+ * skip a candidate that duplicates it. status/deleted_at match the-foundry's
98
+ * own SiteContentItemSchema (content-insights/ledger.ts, read-only) exactly,
99
+ * so this row shape ports verbatim.
100
+ */
101
+ const EXISTING_CONTENT_QUERY = `SELECT title, slug FROM content_items
102
+ WHERE status IN ('published', 'draft', 'scheduled', 'review') AND deleted_at IS NULL
103
+ LIMIT 5000`
104
+
77
105
  export function createContentInsightsRoutes(
78
106
  config: ContentInsightsRouteConfig,
79
107
  ): ContentInsightsRouteHandlers {
@@ -181,5 +209,38 @@ export function createContentInsightsRoutes(
181
209
  throw error
182
210
  }
183
211
  },
212
+
213
+ // HMAC-guarded machine poll -- NOT behind requirePublisher, same M2M
214
+ // family as fronts/schedule (timestamp-bound signature over the query
215
+ // string; a GET has no body to bind freshness to instead).
216
+ async insightsInputs(ctx) {
217
+ const hmacSecret = await resolveFoundryHmacSecretForInputs(ctx.env)
218
+ if (!hmacSecret) return json({ error: 'FOUNDRY_HMAC_SECRET is not configured' }, 500)
219
+
220
+ const url = new URL(ctx.request.url)
221
+ const denied = await verifyFoundrySignedRequest(
222
+ ctx.request,
223
+ hmacSecret,
224
+ url.search,
225
+ 'content/insights-inputs',
226
+ )
227
+ if (denied) return denied
228
+
229
+ const requestedSite = url.searchParams.get('site')
230
+ const authorsResult = await ctx.db.prepare(AUTHORS_QUERY).all<{
231
+ name: string
232
+ slug: string
233
+ }>()
234
+ const existingResult = await ctx.db.prepare(EXISTING_CONTENT_QUERY).all<{
235
+ title: string
236
+ slug: string
237
+ }>()
238
+ return json({
239
+ ok: true,
240
+ site: requestedSite ?? siteId,
241
+ authors: authorsResult.results ?? [],
242
+ existing: existingResult.results ?? [],
243
+ })
244
+ },
184
245
  }
185
246
  }
@@ -199,6 +199,8 @@ export interface ContentRouteConfig extends CmsRouteConfig {
199
199
  publishReadiness?: PublishReadinessHook
200
200
  /** Host's existing production rollback control; omitted means enabled. */
201
201
  podcastSourceProductionEnabled?: boolean
202
+ /** The video twin (FRONTS_VIDEO_DISPATCH_TARGET on the host); omitted means enabled. */
203
+ videoSourceProductionEnabled?: boolean
202
204
  /** Internal machine-route constraint: atomically update only in this status. */
203
205
  updateStatusConstraint?: ContentStatus
204
206
  /**
@@ -44,7 +44,12 @@ import { json, type RouteContext } from './context.js'
44
44
  * (test injection / per-Worker secret) or a Secrets Store binding `{ get() }`.
45
45
  * Returns null → the route answers 500 "not configured".
46
46
  */
47
- async function resolveFoundryHmacSecret(
47
+ // Exported (W1-07 PR: content/insights-inputs) so a sibling M2M route can
48
+ // reuse the SAME timestamp+signature verification without duplicating it --
49
+ // content-insights.ts's insightsInputs handler is a GET poll exactly like
50
+ // `schedule` below, and belongs on the same FOUNDRY_HMAC_SECRET/verification
51
+ // family rather than inventing a second one.
52
+ export async function resolveFoundryHmacSecret(
48
53
  env: Record<string, unknown> | undefined,
49
54
  ): Promise<string | null> {
50
55
  const raw = env?.FOUNDRY_HMAC_SECRET
@@ -78,7 +83,7 @@ function parseTimestampSeconds(raw: string): number | null {
78
83
  * = HMAC over `${timestamp}\n${payload}`. Returns null when authentic, or a 401
79
84
  * Response (with a loud warn log) to short-circuit the handler.
80
85
  */
81
- async function verifyFoundrySignedRequest(
86
+ export async function verifyFoundrySignedRequest(
82
87
  request: Request,
83
88
  secret: string,
84
89
  payload: string,
@@ -199,6 +204,7 @@ export function createFrontsPublishRoutes(config: ContentRouteConfig): FrontsPub
199
204
  nowSeconds: now,
200
205
  mediaPublicDomain: resolved.mediaPublicDomain,
201
206
  podcastSourceProductionEnabled: config.podcastSourceProductionEnabled,
207
+ videoSourceProductionEnabled: config.videoSourceProductionEnabled,
202
208
  // Site-RELATIVE paths: the CMS does not know the site origin, and the
203
209
  // consumer joins its own trusted configured origin. A host-supplied
204
210
  // origin is deliberately deferred (packages-docs/cms.md).
@@ -26,6 +26,7 @@ type CmsHooksLocals = ContentRouteConfig['hooks'] & {
26
26
  dispatchWebhook?: DispatchWebhookHook
27
27
  publishReadiness?: PublishReadinessHook
28
28
  podcastSourceProductionEnabled?: boolean
29
+ videoSourceProductionEnabled?: boolean
29
30
  }
30
31
 
31
32
  /**
@@ -84,5 +85,6 @@ export function toContentConfig(c: APIContext): ContentRouteConfig {
84
85
  dispatchWebhook: hooks.dispatchWebhook,
85
86
  publishReadiness: hooks.publishReadiness,
86
87
  podcastSourceProductionEnabled: hooks.podcastSourceProductionEnabled,
88
+ videoSourceProductionEnabled: hooks.videoSourceProductionEnabled,
87
89
  }
88
90
  }
@@ -1,6 +1,7 @@
1
1
  // src/ui/api/calendar.ts — Astro endpoint adapter for GET /admin/api/calendar.
2
2
  // Maps the APIContext onto a RouteContext and delegates to createCalendarRoutes().month.
3
- // The calendar is first-party D1 (no provider injection needed).
3
+ // The calendar is first-party D1; the host's Foundry provider (locals.cmsHooks.foundryVideo)
4
+ // adds the Go publish badge state to scheduled media items when present (W1-08b).
4
5
 
5
6
  import type { APIContext } from 'astro'
6
7
  import { type CalendarRouteConfig, createCalendarRoutes } from '../../routes/calendar.js'
@@ -13,8 +14,11 @@ function toCtx(c: APIContext): RouteContext {
13
14
  }
14
15
 
15
16
  function calendarConfig(c: APIContext): CalendarRouteConfig {
17
+ const hooks = (c.locals as { cmsHooks?: { foundryVideo?: CalendarRouteConfig['foundryVideo'] } })
18
+ .cmsHooks
16
19
  return {
17
20
  authz: buildSiteAuthz(c),
21
+ foundryVideo: hooks?.foundryVideo,
18
22
  }
19
23
  }
20
24
 
@@ -0,0 +1,26 @@
1
+ // src/ui/api/content/insights-inputs.ts
2
+ // GET /admin/api/content/insights-inputs?site=<id> — the funnel's author
3
+ // denylist + existing-content coverage index. Machine-to-machine poll (same
4
+ // X-Foundry-Timestamp + X-Foundry-Signature family as fronts/schedule) for
5
+ // foundryd's sitebrain.insights.run schedule (W1-07 PR 6). NOT behind
6
+ // requirePublisher. Sibling of insights-ingest under /content/.
7
+ import type { APIContext } from 'astro'
8
+ import { createContentInsightsRoutes } from '../../../routes/content-insights.js'
9
+ import type { RouteContext } from '../../../routes/context.js'
10
+ import { buildSiteAuthz } from '../_authz.js'
11
+
12
+ function toCtx(c: APIContext): RouteContext {
13
+ const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
14
+ return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
15
+ }
16
+
17
+ function siteId(c: APIContext): string {
18
+ const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
19
+ return (env.ACTIVE_WORKSPACE_ID as string | undefined) ?? 'default'
20
+ }
21
+
22
+ export const GET = (c: APIContext) =>
23
+ createContentInsightsRoutes({
24
+ authz: buildSiteAuthz(c),
25
+ activeWorkspaceId: siteId(c),
26
+ }).insightsInputs(toCtx(c))
@@ -18,6 +18,7 @@
18
18
  import { useCallback, useEffect, useReducer, useState } from 'react'
19
19
  import { Icon } from '../icons.js'
20
20
  import { buildCalendarMonth, type CalendarCell, type CalendarItem } from './calendar-data.js'
21
+ import { goPublishBadge } from './go-publish-badge.js'
21
22
 
22
23
  // ---------------------------------------------------------------------------
23
24
  // Props
@@ -483,6 +484,10 @@ function CalendarItemChip({
483
484
  const border = TYPE_COLOR[item.type] ?? 'var(--accent)'
484
485
  const statusIcon = STATUS_ICON[item.status]
485
486
  const statusColor = STATUS_ICON_COLOR[item.status] ?? 'var(--ink-faint)'
487
+ // W1-08b: the Go publish state of a scheduled media item, from the
488
+ // host's receipts (route: goPublish). Hover shows the receipt and the
489
+ // correlation id; the pill vocabulary is the Foundry tab's.
490
+ const badge = goPublishBadge(item.goPublish)
486
491
 
487
492
  return (
488
493
  <div
@@ -520,6 +525,23 @@ function CalendarItemChip({
520
525
  >
521
526
  {item.title || '(Untitled)'}
522
527
  </button>
528
+ {badge && (
529
+ <span
530
+ className={
531
+ badge.pill === 'ready'
532
+ ? 'pill published'
533
+ : badge.pill === 'failed'
534
+ ? 'pill review'
535
+ : 'pill processing'
536
+ }
537
+ title={badge.tooltip}
538
+ data-go-publish={item.goPublish?.state}
539
+ style={{ flex: 'none', fontSize: 9, padding: '1px 5px', whiteSpace: 'nowrap' }}
540
+ >
541
+ <span className="pdot" />
542
+ {badge.label}
543
+ </span>
544
+ )}
523
545
  {statusIcon &&
524
546
  (item.status === 'scheduled' ? (
525
547
  <button
@@ -13,6 +13,8 @@
13
13
  // Types
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
+ import type { CalendarGoPublish } from '../../routes/calendar.js'
17
+
16
18
  /**
17
19
  * A dated content item as returned by the `/admin/api/calendar?from=&to=` route.
18
20
  * `date` is an ISO-8601 string (date-only or with time component, UTC-assumed).
@@ -24,6 +26,8 @@ export interface CalendarItem {
24
26
  slug: string
25
27
  title: string
26
28
  date: string
29
+ /** Go publish state of a scheduled video/podcast item (W1-08b); see go-publish-badge.ts. */
30
+ goPublish?: CalendarGoPublish | null
27
31
  }
28
32
 
29
33
  /** A leading blank cell before the first day of the month. */
@@ -0,0 +1,55 @@
1
+ // src/ui/screens/go-publish-badge.ts — pure badge model for a scheduled media
2
+ // item's Go publish state (W1-08b).
3
+ //
4
+ // goPublishBadge(goPublish) → { label, pill, tooltip } | null
5
+ // label — the word the chip shows ("Captured", "Processing", …)
6
+ // pill — the FoundryPillState bucket the broadsheet pill classes render
7
+ // (working / ready / failed), reused from foundry-stages
8
+ // tooltip — the hover text: label, the newest receipt and its time, the
9
+ // correlation id, and the blocked reason when there is one
10
+ //
11
+ // Pure — no DOM, no React, fully vitest-testable.
12
+
13
+ import type { CalendarGoPublish } from '../../routes/calendar.js'
14
+ import type { FoundryPillState } from '../inspector/foundry-stages.js'
15
+
16
+ export interface GoPublishBadge {
17
+ label: string
18
+ pill: FoundryPillState
19
+ tooltip: string
20
+ }
21
+
22
+ const LABELS: Record<string, { label: string; pill: FoundryPillState }> = {
23
+ pending: { label: 'Not captured', pill: 'working' },
24
+ captured: { label: 'Captured', pill: 'working' },
25
+ processing: { label: 'Processing', pill: 'working' },
26
+ media_ready: { label: 'Media ready', pill: 'ready' },
27
+ publishing: { label: 'Publishing', pill: 'working' },
28
+ published: { label: 'Published', pill: 'ready' },
29
+ late: { label: 'Late', pill: 'failed' },
30
+ failed: { label: 'Blocked', pill: 'failed' },
31
+ unavailable: { label: 'State unavailable', pill: 'failed' },
32
+ }
33
+
34
+ /** Format a unix-second receipt time for the tooltip (UTC, to the minute). */
35
+ export function formatReceiptTime(unixSeconds: number): string {
36
+ return `${new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace('T', ' ')} UTC`
37
+ }
38
+
39
+ export function goPublishBadge(
40
+ goPublish: CalendarGoPublish | null | undefined,
41
+ ): GoPublishBadge | null {
42
+ if (!goPublish) return null
43
+ const entry = LABELS[goPublish.state] ?? { label: goPublish.state, pill: 'working' as const }
44
+ const lines = [`Go publish: ${entry.label}`]
45
+ if (goPublish.eventKind && goPublish.receiptAt !== null) {
46
+ lines.push(`Last receipt: ${goPublish.eventKind} at ${formatReceiptTime(goPublish.receiptAt)}`)
47
+ } else if (goPublish.eventKind) {
48
+ lines.push(`Last receipt: ${goPublish.eventKind}`)
49
+ } else {
50
+ lines.push('No receipt yet')
51
+ }
52
+ if (goPublish.correlationId) lines.push(`Correlation: ${goPublish.correlationId}`)
53
+ if (goPublish.blockedReason) lines.push(`Reason: ${goPublish.blockedReason}`)
54
+ return { label: entry.label, pill: entry.pill, tooltip: lines.join('\n') }
55
+ }