diff --git a/apps/ops/src/App.tsx b/apps/ops/src/App.tsx index b474ed8c..6c04fab8 100644 --- a/apps/ops/src/App.tsx +++ b/apps/ops/src/App.tsx @@ -164,6 +164,13 @@ interface BookingDoc { sessionType?: "recording" | "pre_meeting"; status?: string; slotStartAt?: string; + previousSlotStartAt?: string; + rescheduleReason?: string; + podcastSlug?: string; + podcastDocId?: string; + roughCutUrl?: string; + roughCutNotes?: string; + targetPublishDate?: string; recordingUrl?: string; preMeetingStatus?: "none" | "requested" | "scheduled" | "completed" | "bypassed"; preMeetingSlotStartAt?: string; @@ -254,14 +261,93 @@ const LEAD_STATUS_OPTIONS = [ ]; const BOOKING_STATUS_OPTIONS = [ - { value: "pending", label: "Pending Review (Hold)" }, - { value: "approved", label: "Approved" }, - { value: "scheduled", label: "Confirmed on Calendar" }, - { value: "recorded", label: "Recorded" }, - { value: "published", label: "Published (Archive)" }, - { value: "cancelled", label: "Declined / Cancelled (Archive)" }, + { value: "pending", label: "โณ Pending Review (Hold)" }, + { value: "needs_reschedule", label: "๐Ÿ”„ Needs Reschedule / On Hold" }, + { value: "approved", label: "โœ“ Approved (StreamYard Sent)" }, + { value: "scheduled", label: "๐Ÿ“… Confirmed on Calendar" }, + { value: "recorded", label: "๐ŸŽฌ Recorded (Raw)" }, + { value: "editing", label: "โœ‚๏ธ In Editing / Post-Production" }, + { value: "guest_review", label: "๐Ÿ‘€ Rough Cut / Guest Review" }, + { value: "ready_to_publish", label: "๐Ÿš€ Ready to Publish" }, + { value: "published", label: "โœ… Published (Archive)" }, + { value: "cancelled", label: "โœ• Declined / Cancelled (Archive)" }, ]; +function isPostProductionBookingStatus(status?: string): boolean { + return ( + status === "recorded" || + status === "editing" || + status === "guest_review" || + status === "ready_to_publish" || + status === "published" + ); +} + +function getBookingStatusBadge(status?: string): { + label: string; + bg: string; + color: string; +} { + switch (status) { + case "needs_reschedule": + return { + label: "๐Ÿ”„ Needs Reschedule / Awaiting Slot", + bg: "#fef3c7", + color: "#92400e", + }; + case "approved": + case "scheduled": + case "confirmed": + return { + label: "โœ“ Approved (StreamYard Sent)", + bg: "#dcfce7", + color: "#065f46", + }; + case "recorded": + return { + label: "๐ŸŽฌ Recorded (Entering Edit)", + bg: "#ede9fe", + color: "#5b21b6", + }; + case "editing": + return { + label: "โœ‚๏ธ In Editing / Post-Production", + bg: "#f3e8ff", + color: "#6b21a8", + }; + case "guest_review": + return { + label: "๐Ÿ‘€ Rough Cut / Guest Review", + bg: "#dbeafe", + color: "#1e40af", + }; + case "ready_to_publish": + return { + label: "๐Ÿš€ Ready to Publish", + bg: "#d1fae5", + color: "#065f46", + }; + case "published": + return { + label: "โœ… Published", + bg: "#dcfce7", + color: "#166534", + }; + case "cancelled": + return { + label: "โœ• Cancelled", + bg: "#fee2e2", + color: "#991b1b", + }; + default: + return { + label: "โณ Hold (Pending Approval)", + bg: "#fef3c7", + color: "#92400e", + }; + } +} + function isArchivedLeadStatus(status?: string): boolean { return ( status === "paid" || status === "cancelled" || status === "archived" @@ -375,6 +461,12 @@ export function App() { } }); const [pfChatInput, setPfChatInput] = useState>({}); + const [roughCutUrlInput, setRoughCutUrlInput] = useState< + Record + >({}); + const [rescheduleReasonInput, setRescheduleReasonInput] = useState< + Record + >({}); const [vaultTitleInput, setVaultTitleInput] = useState>( {}, ); @@ -837,6 +929,10 @@ export function App() { ); if (newStatus === "approved" || newStatus === "scheduled") { await handleBookingManage(bookingId, "approve_recording"); + } else if (newStatus === "needs_reschedule") { + await handleBookingManage(bookingId, "mark_needs_reschedule"); + } else if (isPostProductionBookingStatus(newStatus)) { + await handleAdvancePostProduction(bookingId, newStatus, false); } else if (newStatus === "cancelled") { await handleBookingManage(bookingId, "cancel_booking"); } else { @@ -847,6 +943,45 @@ export function App() { } }; + const handleAdvancePostProduction = async ( + bookingId: string, + stage: string, + notifyGuest: boolean, + ) => { + const actionKey = `${bookingId}:postprod:${stage}:${notifyGuest}`; + setBusyActionId(actionKey); + try { + const booking = bookings.find((b) => b._id === bookingId); + const roughCutUrl = + roughCutUrlInput[bookingId] !== undefined + ? roughCutUrlInput[bookingId] + : booking?.roughCutUrl || ""; + const res = await fetch(`${siteOrigin}/api/book/manage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bookingId, + action: "advance_post_production", + status: stage, + roughCutUrl, + notifyGuest, + }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || data.error || "Failed to update stage"); + } + await fetchAllData(); + showFlash( + `Updated ${booking?.guestName || "episode"} to "${stage}"${data.podcastDocId ? ` (synced ${data.podcastDocId})` : ""}${notifyGuest ? " and emailed guest in canonical thread!" : "."}`, + ); + } catch (err: any) { + showFlash(`Post-production error: ${err?.message || "Unknown error"}`); + } finally { + setBusyActionId(null); + } + }; + const handleBookingManage = async ( bookingId: string, action: @@ -854,14 +989,21 @@ export function App() { | "cancel_booking" | "request_pre_meeting" | "request_recording_slot" - | "bypass_pre_meeting", + | "bypass_pre_meeting" + | "mark_needs_reschedule", + extraPayload?: Record, ) => { setBusyActionId(`${bookingId}:${action}`); try { const res = await fetch(`${siteOrigin}/api/book/manage`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ bookingId, action }), + body: JSON.stringify({ + bookingId, + action, + rescheduleReason: rescheduleReasonInput[bookingId] || undefined, + ...(extraPayload || {}), + }), }); const data = await res.json(); if (!res.ok) { @@ -872,6 +1014,10 @@ export function App() { showFlash( `Approved recording, synced private block to alex.patterson@sanity.io, and sent StreamYard link (${data.recordingUrl || "https://streamyard.com/c4kv26is93"}) to guest.`, ); + } else if (action === "mark_needs_reschedule") { + showFlash( + "Moved booking to Needs Reschedule, cleared old calendar hold, and sent 1-click reschedule invite in the guest's canonical Gmail thread!", + ); } else if (action === "cancel_booking") { showFlash( "Cancelled booking, archived record, and removed calendar blocks from alex@codingcat.dev and alex.patterson@sanity.io.", @@ -2989,6 +3135,461 @@ export function App() { + {/* Pipeline Row: Needs Reschedule / On Hold + Post-Production & Editing */} +
+ {/* Card 3: Needs Reschedule / Awaiting Recording Slot */} +
+

+ ๐Ÿ”„ Needs Reschedule / Awaiting Recording Slot ( + { + bookings.filter( + (b) => + b.status === "needs_reschedule" || + (!isArchivedBookingStatus(b.status) && + !isPostProductionBookingStatus(b.status) && + b.slotStartAt && + Date.parse(b.slotStartAt) < Date.now() - 3600_000), + ).length + } + ) +

+

+ Guests who missed a slot (sick / postponed) or completed a + 15m pre-call and still need to pick their 45m recording slot. +

+
+ {bookings + .filter( + (b) => + b.status === "needs_reschedule" || + (!isArchivedBookingStatus(b.status) && + !isPostProductionBookingStatus(b.status) && + b.slotStartAt && + Date.parse(b.slotStartAt) < Date.now() - 3600_000), + ) + .map((b) => { + const token = + b.portalToken || + b._id.replace(/^(private\.)?booking-/, ""); + const portalUrl = `${siteOrigin}/portal/${token}#booked-sessions`; + const gmailUrl = b.gmailThreadId + ? `https://mail.google.com/mail/u/0/#inbox/${b.gmailThreadId}` + : ""; + return ( +
+
+
+
+ {b.guestName} ({b.company || "Independent"}) +
+
+ {b.rescheduleReason || + (b.slotStartAt + ? `Missed / Past Slot: ${formatDateTime(b.slotStartAt)}` + : "Awaiting 45m recording slot")} +
+
+ + {b.status === "needs_reschedule" + ? "๐Ÿ”„ Needs Reschedule" + : "โš ๏ธ Past Unrecorded Slot"} + +
+
+ + + Portal โ†— + + {gmailUrl && ( + + Gmail โ†— + + )} + +
+ {expandedScheduleBookingId === b._id && ( +
+ {renderBookingDetailsPanel(b)} +
+ )} +
+ ); + })} +
+
+ + {/* Card 4: Post-Production & Editing Pipeline */} +
+

+ ๐ŸŽฌ Post-Production, Unlisted YouTube & Draft Pipeline ( + { + bookings.filter( + (b) => + isPostProductionBookingStatus(b.status) && + b.status !== "published", + ).length + } + ) +

+

+ Track recorded episodes through Editing,{" "} + Rough-Cut Guest Review, and{" "} + Ready to Publish. Paste an Unlisted YouTube + link to embed it directly on the Guest Portal. +

+
+ {bookings + .filter( + (b) => + isPostProductionBookingStatus(b.status) && + b.status !== "published", + ) + .map((b) => { + const token = + b.portalToken || + b._id.replace(/^(private\.)?booking-/, ""); + const portalReviewUrl = `${siteOrigin}/portal/${token}#post-production-review`; + const draftEpUrl = b.podcastSlug + ? `${siteOrigin}/podcast/${b.podcastSlug}?sanity-preview-perspective=drafts` + : ""; + const badge = getBookingStatusBadge(b.status); + const currentRoughCut = + roughCutUrlInput[b._id] !== undefined + ? roughCutUrlInput[b._id] + : b.roughCutUrl || ""; + return ( +
+
+
+
+ {b.guestName} ({b.company || "Independent"}) +
+
+ Recorded: {formatDateTime(b.slotStartAt)} + {b.podcastDocId && ( + <> + {" ยท "} + {b.podcastDocId} + + )} +
+
+ + {badge.label} + +
+ +
+ + + setRoughCutUrlInput((prev) => ({ + ...prev, + [b._id]: e.target.value, + })) + } + placeholder="Unlisted YouTube URL (https://youtu.be/...)" + style={{ + flex: "1 1 200px", + minWidth: 0, + padding: "5px 8px", + borderRadius: 6, + border: "1px solid #d1d5db", + fontSize: 12, + }} + /> + + +
+ +
+ + Portal Review โ†— + + {draftEpUrl && ( + + ๐Ÿ‘๏ธ Preview Draft Podcast โ†— + + )} + +
+ {expandedScheduleBookingId === b._id && ( +
+ {renderBookingDetailsPanel(b)} +
+ )} +
+ ); + })} +
+
+
+ {/* Ongoing Master Calendar Timeline */}

@@ -3197,40 +3798,55 @@ export function App() { > {b ? (
- - {isApproved - ? "โœ“ Approved (StreamYard Sent)" - : "โณ Hold (Pending Approval)"} - + {(() => { + const badge = getBookingStatusBadge( + b.status, + ); + return ( + + {badge.label} + + ); + })()}
) : ( diff --git a/apps/sanity/components/TierListStudioInput.tsx b/apps/sanity/components/TierListStudioInput.tsx new file mode 100644 index 00000000..a25225fc --- /dev/null +++ b/apps/sanity/components/TierListStudioInput.tsx @@ -0,0 +1,1307 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { type ObjectInputProps, set } from "sanity"; + +export type TierRank = "S" | "A" | "B" | "C" | "D"; + +export interface StudioTierDetail { + _key: string; + _type: "tierDetail"; + label: string; + value: string; +} + +export interface StudioTierItem { + _key: string; + _type: "tierItem"; + tier: TierRank; + name: string; + category?: string; + summary?: string; + badge?: string; + url?: string; + details?: StudioTierDetail[]; +} + +export interface StudioTierListValue { + _type?: "tierList"; + _key?: string; + title?: string; + subtitle?: string; + ajComment?: string; + items?: StudioTierItem[]; +} + +const TIER_ORDER: TierRank[] = ["S", "A", "B", "C", "D"]; + +const TIER_CONFIG: Record< + TierRank, + { + rank: TierRank; + label: string; + bg: string; + text: string; + } +> = { + S: { rank: "S", label: "Purrfect", bg: "#FF7F7F", text: "#2A1155" }, + A: { rank: "A", label: "Strong Pick", bg: "#FFBF7F", text: "#2A1155" }, + B: { rank: "B", label: "Solid w/ Caveats", bg: "#FFDF7F", text: "#2A1155" }, + C: { rank: "C", label: "Situational", bg: "#BFFF7F", text: "#2A1155" }, + D: { rank: "D", label: "Pass / Caution", bg: "#7FFFFF", text: "#2A1155" }, +}; + +function makeKey(prefix = "ti"): string { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; +} + +function normalizeTierRank(raw?: string): TierRank { + const cleaned = (raw || "").trim().toUpperCase(); + const first = cleaned.charAt(0); + if ( + first === "S" || + first === "A" || + first === "B" || + first === "C" || + first === "D" + ) { + return first; + } + return "B"; +} + +function stripMarkdownFormatting(text: string): string { + return text + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/`([^`]+)`/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .trim(); +} + +/** + * Parses a GFM Markdown table (`| Tier | Platform | Camp | Verdict | ... |`) + * into structured `StudioTierItem[]`. + */ +export function parseMarkdownTableToTierItems( + markdown: string, +): StudioTierItem[] { + const lines = markdown + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.startsWith("|") && l.endsWith("|")); + + if (lines.length < 2) { + return []; + } + + const rows: string[][] = []; + for (const line of lines) { + const cells = line + .slice(1, -1) + .split("|") + .map((c) => stripMarkdownFormatting(c)); + const isSeparator = cells.every((c) => /^:?-{2,}:?$/.test(c)); + if (!isSeparator) { + rows.push(cells); + } + } + + if (rows.length < 2) { + return []; + } + + const [headers, ...bodyRows] = rows; + const verdictIdx = headers.findIndex((h) => + /verdict|why|summary|notes/i.test(h), + ); + const pricingIdx = headers.findIndex((h) => + /price|pricing|cost|quota/i.test(h), + ); + + return bodyRows.map((cells, rIdx) => { + const tier = normalizeTierRank(cells[0] ?? "B"); + const name = (cells[1] ?? `Platform ${rIdx + 1}`).trim(); + const category = (cells[2] ?? "").trim(); + + let summary = ""; + if (verdictIdx >= 3 && cells[verdictIdx]) { + summary = cells[verdictIdx].trim(); + } else if (cells[3]) { + summary = cells[3].trim(); + } + + const details: StudioTierDetail[] = []; + for (let cIdx = 3; cIdx < cells.length; cIdx++) { + const val = (cells[cIdx] ?? "").trim(); + if (!val || val === summary) { + continue; + } + const label = (headers[cIdx] ?? `Spec ${cIdx - 2}`).trim(); + details.push({ + _key: makeKey("td"), + _type: "tierDetail", + label, + value: val, + }); + } + + const badge = + pricingIdx >= 2 && cells[pricingIdx] + ? cells[pricingIdx].trim() + : undefined; + + return { + _key: makeKey("ti"), + _type: "tierItem", + tier, + name, + ...(category ? { category } : {}), + ...(summary ? { summary } : {}), + ...(badge ? { badge } : {}), + details, + }; + }); +} + +/** + * Serializes `StudioTierItem[]` back into a clean GFM Markdown table so the + * author can bulk-edit or copy-paste into blog posts/scripts. + */ +export function serializeTierItemsToMarkdownTable( + items: StudioTierItem[], +): string { + // Collect unique detail labels in order of appearance + const detailLabels: string[] = []; + for (const item of items) { + for (const d of item.details ?? []) { + if (d.label && !detailLabels.includes(d.label)) { + detailLabels.push(d.label); + } + } + } + + const headers = [ + "Tier", + "Platform", + "Camp", + "Alex's Architectural Verdict", + ...detailLabels, + ]; + const headerLine = `| ${headers.join(" | ")} |`; + const sepLine = `| ${headers.map(() => ":---").join(" | ")} |`; + + const bodyLines = items.map((item) => { + const detailMap = new Map( + (item.details ?? []).map((d) => [d.label, d.value]), + ); + const cells = [ + `**${item.tier}**`, + `**${item.name || "Untitled"}**`, + item.category || "", + item.summary || "", + ...detailLabels.map((lbl) => detailMap.get(lbl) || ""), + ]; + return `| ${cells.map((c) => c.replace(/\|/g, "\\|")).join(" | ")} |`; + }); + + return [headerLine, sepLine, ...bodyLines].join("\n"); +} + +// `props: any` matches BlockEditor/YouTubePreview: pnpm resolves two copies of +// sanity@6.20.0, so ObjectInputProps from this file is not assignable to the +// one `components.input` expects. +export function TierListStudioInput(props: any) { + const { onChange } = props as ObjectInputProps; + const value = props.value as StudioTierListValue | undefined; + const current: StudioTierListValue = value ?? { _type: "tierList" }; + const items: StudioTierItem[] = Array.isArray(current.items) + ? current.items + : []; + + const [editingKey, setEditingKey] = useState(null); + const [showMarkdownModal, setShowMarkdownModal] = useState(false); + const [markdownDraft, setMarkdownDraft] = useState(""); + const [filterTier, setFilterTier] = useState("ALL"); + + const updateRoot = (patch: Partial) => { + onChange( + set({ + ...current, + _type: "tierList", + ...patch, + }), + ); + }; + + const updateItems = (nextItems: StudioTierItem[]) => { + updateRoot({ items: nextItems }); + }; + + const handleChangeItemTier = (itemKey: string, nextTier: TierRank) => { + const next = items.map((it) => + it._key === itemKey ? { ...it, tier: nextTier } : it, + ); + updateItems(next); + }; + + const handleUpdateItem = ( + itemKey: string, + patch: Partial, + ) => { + const next = items.map((it) => + it._key === itemKey ? { ...it, ...patch } : it, + ); + updateItems(next); + }; + + const handleDeleteItem = (itemKey: string) => { + updateItems(items.filter((it) => it._key !== itemKey)); + if (editingKey === itemKey) { + setEditingKey(null); + } + }; + + const handleMoveItemWithinTier = (itemKey: string, dir: -1 | 1) => { + const idx = items.findIndex((it) => it._key === itemKey); + if (idx === -1) return; + const targetTier = items[idx].tier; + // Find previous or next item in the same tier + let swapIdx = -1; + for ( + let i = idx + dir; + i >= 0 && i < items.length; + i += dir + ) { + if (items[i].tier === targetTier) { + swapIdx = i; + break; + } + } + if (swapIdx === -1) return; + const copy = [...items]; + const temp = copy[idx]; + copy[idx] = copy[swapIdx]; + copy[swapIdx] = temp; + updateItems(copy); + }; + + const handleAddItem = (tier: TierRank = "S") => { + const newKey = makeKey("ti"); + const newItem: StudioTierItem = { + _key: newKey, + _type: "tierItem", + tier, + name: "New Platform", + category: "", + summary: "", + details: [ + { + _key: makeKey("td"), + _type: "tierDetail", + label: "VM & Security Boundary", + value: "", + }, + { + _key: makeKey("td"), + _type: "tierDetail", + label: "2026 Pricing & Quotas", + value: "", + }, + ], + }; + updateItems([newItem, ...items]); + setEditingKey(newKey); + }; + + const handleOpenMarkdownModal = () => { + setMarkdownDraft(serializeTierItemsToMarkdownTable(items)); + setShowMarkdownModal(true); + }; + + const handleApplyMarkdownImport = () => { + const parsed = parseMarkdownTableToTierItems(markdownDraft); + if (parsed.length > 0) { + updateItems(parsed); + setShowMarkdownModal(false); + } + }; + + const grouped = useMemo(() => { + const map: Record = { + S: [], + A: [], + B: [], + C: [], + D: [], + }; + for (const item of items) { + const r = normalizeTierRank(item.tier); + map[r].push(item); + } + return map; + }, [items]); + + const visibleTiers = + filterTier === "ALL" ? TIER_ORDER : [filterTier]; + + return ( +
+ {/* Top Bar: Header + AJ Sticky Note Preview + Action Buttons */} +
+
+
+ + ๐Ÿ† TIER LIST STUDIO + + + {items.length} Platforms Ranked + +
+ +
+ + +
+
+ + {/* Editable Title, Subtitle & AJ Sticky-Note Comment */} +
+
+ + updateRoot({ title: e.currentTarget.value })} + style={{ + width: "100%", + boxSizing: "border-box", + background: "#0B1326", + color: "#FFFFFF", + border: "1px solid rgba(255,255,255,0.22)", + borderRadius: "8px", + padding: "7px 10px", + fontSize: "13px", + fontWeight: 700, + }} + /> +
+ +
+ + updateRoot({ ajComment: e.currentTarget.value })} + style={{ + width: "100%", + boxSizing: "border-box", + background: "#FEF08A", + color: "#2A1155", + border: "2px solid #2A1155", + borderRadius: "8px", + padding: "6px 10px", + fontSize: "13px", + fontWeight: 800, + }} + /> +
+
+ +
+ + updateRoot({ subtitle: e.currentTarget.value })} + style={{ + width: "100%", + boxSizing: "border-box", + background: "#0B1326", + color: "#E2E8F0", + border: "1px solid rgba(255,255,255,0.18)", + borderRadius: "8px", + padding: "6px 10px", + fontSize: "12px", + }} + /> +
+ + {/* Quick Filter Bar */} +
+ + Filter Tier: + + + {TIER_ORDER.map((r) => ( + + ))} +
+
+ + {/* Bulk Markdown Import / Export Drawer */} + {showMarkdownModal && ( +
+
+ + Bulk Markdown Table Import / Export (`| Tier | Platform | Camp | Verdict | ... |`) + + +
+