///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
type DraftPriority = "high" | "medium" | "low";
type DraftStage =
| "ideation"
| "drafting"
| "review"
| "ready"
| "scheduled"
| "published";
interface DraftSeed {
id?: string;
title?: string;
summary?: string;
priority?: string;
stage?: string;
scheduledDate?: string;
assignedEditor?: string;
}
interface DraftEntry extends DraftSeed {
id: string;
title: string;
summary: string;
priority: DraftPriority;
stage: DraftStage;
scheduledDate: string;
assignedEditor: string;
}
interface ContentPublishingWorkflowArgs {
drafts: Default;
}
interface AddDraftEvent {
id?: string;
title?: string;
summary?: string;
priority?: string;
stage?: string;
scheduledDate?: string;
assignedEditor?: string;
}
interface AdvanceStageEvent {
id?: string;
stage?: string;
}
interface UpdateScheduleEvent {
id?: string;
scheduledDate?: string;
}
interface UpdatePriorityEvent {
id?: string;
priority?: string;
}
const defaultDrafts: DraftEntry[] = [
{
id: "draft-launch-announcement",
title: "Launch Announcement",
summary: "Feature launch hero article.",
priority: "high",
stage: "review",
scheduledDate: "2024-07-02",
assignedEditor: "Noah",
},
{
id: "draft-customer-story",
title: "Finch Story",
summary: "Spotlight on Finch pilot results.",
priority: "medium",
stage: "ready",
scheduledDate: "2024-07-04",
assignedEditor: "Ravi",
},
{
id: "draft-quarterly-recap",
title: "Quarterly Recap",
summary: "Q2 product summary newsletter.",
priority: "medium",
stage: "drafting",
scheduledDate: "2024-07-06",
assignedEditor: "Amelia",
},
];
const stageOrder: readonly DraftStage[] = [
"ideation",
"drafting",
"review",
"ready",
"scheduled",
"published",
];
const priorityRank: Record = {
high: 0,
medium: 1,
low: 2,
};
const stageRank: Record = {
ideation: 0,
drafting: 1,
review: 2,
ready: 3,
scheduled: 4,
published: 5,
};
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const sanitizeIdentifier = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim();
const normalized = trimmed
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
if (normalized.length > 0) return normalized;
}
return fallback;
};
const sanitizeText = (value: unknown, fallback: string): string => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
if (trimmed.length === 0) return fallback;
return trimmed.replace(/\s+/g, " ");
};
const sanitizeTitle = (value: unknown, fallback: string): string => {
const base = sanitizeText(value, fallback);
if (base.length === 0) return fallback;
return base[0].toUpperCase() + base.slice(1);
};
const sanitizeSummary = (value: unknown, fallback: string): string => {
return sanitizeText(value, fallback);
};
const sanitizeEditor = (value: unknown, fallback: string): string => {
const base = sanitizeText(value, fallback);
return base.length === 0 ? fallback : base;
};
const sanitizePriority = (
value: unknown,
fallback: DraftPriority,
): DraftPriority => {
if (value === "high" || value === "medium" || value === "low") {
return value;
}
if (typeof value === "string") {
const lower = value.toLowerCase();
if (lower === "high" || lower === "medium" || lower === "low") {
return lower as DraftPriority;
}
}
return fallback;
};
const sanitizeStage = (
value: unknown,
fallback: DraftStage,
): DraftStage => {
if (typeof value === "string") {
const lower = value.trim().toLowerCase();
if (stageOrder.includes(lower as DraftStage)) {
return lower as DraftStage;
}
}
return fallback;
};
const sanitizeDate = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim().replace(/\//g, "-");
if (datePattern.test(trimmed)) {
return trimmed;
}
}
if (datePattern.test(fallback)) {
return fallback;
}
return "2024-07-31";
};
const ensureUniqueId = (candidate: string, used: Set): string => {
if (!used.has(candidate)) {
used.add(candidate);
return candidate;
}
let index = 2;
let id = `${candidate}-${index}`;
while (used.has(id)) {
index += 1;
id = `${candidate}-${index}`;
}
used.add(id);
return id;
};
const sanitizeDraft = (
seed: DraftSeed | undefined,
fallback: DraftSeed,
index: number,
used: Set,
): DraftEntry => {
const fallbackId = typeof fallback.id === "string" && fallback.id.length > 0
? fallback.id
: `draft-${index + 1}`;
const id = ensureUniqueId(
sanitizeIdentifier(seed?.id, fallbackId),
used,
);
const fallbackTitle = typeof fallback.title === "string" &&
fallback.title.length > 0
? fallback.title
: `Draft ${index + 1}`;
const title = sanitizeTitle(seed?.title, fallbackTitle);
const summary = sanitizeSummary(
seed?.summary,
typeof fallback.summary === "string"
? fallback.summary
: "Workflow draft placeholder.",
);
const fallbackPriority = sanitizePriority(
fallback.priority,
"medium",
);
const priority = sanitizePriority(seed?.priority, fallbackPriority);
const fallbackStage = sanitizeStage(fallback.stage, "drafting");
const stage = sanitizeStage(seed?.stage, fallbackStage);
const fallbackDate = sanitizeDate(
fallback.scheduledDate,
`2024-07-${String(index + 1).padStart(2, "0")}`,
);
const scheduledDate = sanitizeDate(seed?.scheduledDate, fallbackDate);
const assignedEditor = sanitizeEditor(
seed?.assignedEditor,
sanitizeEditor(fallback.assignedEditor, "Unassigned"),
);
return {
id,
title,
summary,
priority,
stage,
scheduledDate,
assignedEditor,
};
};
const sanitizeDraftList = (
value: readonly DraftSeed[] | undefined,
): DraftEntry[] => {
const seeds = Array.isArray(value) && value.length > 0
? value
: defaultDrafts;
const used = new Set();
const drafts: DraftEntry[] = [];
for (let index = 0; index < seeds.length; index += 1) {
const fallback = defaultDrafts[index % defaultDrafts.length];
drafts.push(sanitizeDraft(seeds[index], fallback, index, used));
}
return drafts;
};
const compareSchedule = (a: string, b: string): number => {
if (a === b) return 0;
const [aYear, aMonth, aDay] = a.split("-").map((segment) => Number(segment));
const [bYear, bMonth, bDay] = b.split("-").map((segment) => Number(segment));
if (aYear !== bYear) return aYear - bYear;
if (aMonth !== bMonth) return aMonth - bMonth;
return aDay - bDay;
};
const buildQueue = (entries: readonly DraftEntry[]): DraftEntry[] => {
return entries
.filter((entry) =>
entry.stage !== "scheduled" && entry.stage !== "published"
)
.map((entry) => ({ ...entry }))
.sort((a, b) => {
const priorityDiff = priorityRank[a.priority] - priorityRank[b.priority];
if (priorityDiff !== 0) return priorityDiff;
const scheduleDiff = compareSchedule(a.scheduledDate, b.scheduledDate);
if (scheduleDiff !== 0) return scheduleDiff;
const stageDiff = stageRank[a.stage] - stageRank[b.stage];
if (stageDiff !== 0) return stageDiff;
return a.title.localeCompare(b.title);
});
};
const sortDrafts = (entries: readonly DraftEntry[]): DraftEntry[] => {
return entries.slice().sort((a, b) => {
const priorityDiff = priorityRank[a.priority] - priorityRank[b.priority];
if (priorityDiff !== 0) return priorityDiff;
const scheduleDiff = compareSchedule(a.scheduledDate, b.scheduledDate);
if (scheduleDiff !== 0) return scheduleDiff;
const stageDiff = stageRank[a.stage] - stageRank[b.stage];
if (stageDiff !== 0) return stageDiff;
return a.title.localeCompare(b.title);
});
};
const buildStageTotals = (
entries: readonly DraftEntry[],
): Record => {
const totals: Record = {
ideation: 0,
drafting: 0,
review: 0,
ready: 0,
scheduled: 0,
published: 0,
};
for (const entry of entries) {
totals[entry.stage] += 1;
}
return totals;
};
const formatQueuePreview = (entries: readonly DraftEntry[]): string => {
if (entries.length === 0) {
return "No drafts awaiting scheduling";
}
const preview = entries.slice(0, 3).map((entry) =>
`${entry.title} (${entry.priority} @ ${entry.scheduledDate})`
);
return preview.join(" | ");
};
const appendHistory = (
history: readonly string[],
entry: string,
): string[] => {
const next = [...history, entry];
return next.length > 6 ? next.slice(next.length - 6) : next;
};
const stageActionLabel = (stage: DraftStage): string => {
switch (stage) {
case "ideation":
return "ideation";
case "drafting":
return "drafting";
case "review":
return "review";
case "ready":
return "ready for scheduling";
case "scheduled":
return "scheduled";
case "published":
return "published";
}
};
const suggestSchedule = (sequence: number): string => {
const day = ((sequence - 1) % 27) + 1;
return `2024-07-${day.toString().padStart(2, "0")}`;
};
type WorkflowContext = {
drafts: Cell;
draftsView: Cell;
sequence: Cell;
activityLog: Cell;
};
const getSanitizedDrafts = (context: WorkflowContext): DraftEntry[] => {
return sanitizeDraftList(context.drafts.get());
};
export const contentPublishingWorkflow = recipe(
"Content Publishing Workflow",
({ drafts }) => {
const sequence = cell(0);
const activityLog = cell(["Workflow initialized"]);
const draftsView = lift(sanitizeDraftList)(drafts);
const queue = lift(buildQueue)(draftsView);
const queueCount = lift((entries: DraftEntry[]) => entries.length)(queue);
const scheduledCount = lift((entries: DraftEntry[]) =>
entries.filter((entry) => entry.stage === "scheduled").length
)(draftsView);
const statusLine =
str`${queueCount} drafts awaiting, ${scheduledCount} scheduled`;
const queuePreview = lift(formatQueuePreview)(queue);
const nextDraft = lift((entries: DraftEntry[]) =>
entries.length > 0 ? entries[0] : null
)(queue);
const stageTotals = lift(buildStageTotals)(draftsView);
const priorityScheduleOrder = lift((entries: DraftEntry[]) =>
sortDrafts(entries).map((entry) => ({
id: entry.id,
title: entry.title,
priority: entry.priority,
scheduledDate: entry.scheduledDate,
}))
)(draftsView);
const context = {
drafts,
draftsView,
sequence,
activityLog,
} as const;
const addDraft = handler(
(event: AddDraftEvent | undefined, ctx: WorkflowContext) => {
const current = getSanitizedDrafts(ctx);
const used = new Set(current.map((draft) => draft.id));
const nextIndex = Math.max(ctx.sequence.get() ?? 0, current.length) + 1;
ctx.sequence.set(nextIndex);
const fallback = defaultDrafts[(nextIndex - 1) % defaultDrafts.length];
const id = ensureUniqueId(
sanitizeIdentifier(event?.id, `draft-${nextIndex}`),
used,
);
const title = sanitizeTitle(
event?.title,
fallback.title ?? `Draft ${nextIndex}`,
);
const summary = sanitizeSummary(
event?.summary,
fallback.summary ?? "Workflow intake submission.",
);
const priority = sanitizePriority(
event?.priority,
fallback.priority ?? "medium",
);
const stage = sanitizeStage(event?.stage, "drafting");
const scheduledDate = sanitizeDate(
event?.scheduledDate,
fallback.scheduledDate ?? suggestSchedule(nextIndex),
);
const assignedEditor = sanitizeEditor(
event?.assignedEditor,
fallback.assignedEditor ?? "Unassigned",
);
const entry: DraftEntry = {
id,
title,
summary,
priority,
stage,
scheduledDate,
assignedEditor,
};
ctx.drafts.set(sortDrafts([...current, entry]));
const message =
`${title} queued as ${priority} priority due ${scheduledDate}`;
ctx.activityLog.set(appendHistory(ctx.activityLog.get(), message));
},
);
const rescheduleDraft = handler(
(event: UpdateScheduleEvent | undefined, ctx: WorkflowContext) => {
const id = sanitizeIdentifier(event?.id, "");
if (id.length === 0) return;
const current = getSanitizedDrafts(ctx);
const index = current.findIndex((draft) => draft.id === id);
if (index === -1) return;
const draft = current[index];
const scheduledDate = sanitizeDate(
event?.scheduledDate,
draft.scheduledDate,
);
if (scheduledDate === draft.scheduledDate) return;
const next = current.slice();
next[index] = { ...draft, scheduledDate };
ctx.drafts.set(sortDrafts(next));
const message = `${draft.title} rescheduled for ${scheduledDate}`;
ctx.activityLog.set(appendHistory(ctx.activityLog.get(), message));
},
);
const reprioritizeDraft = handler(
(event: UpdatePriorityEvent | undefined, ctx: WorkflowContext) => {
const id = sanitizeIdentifier(event?.id, "");
if (id.length === 0) return;
const current = getSanitizedDrafts(ctx);
const index = current.findIndex((draft) => draft.id === id);
if (index === -1) return;
const priority = sanitizePriority(
event?.priority,
current[index].priority,
);
if (priority === current[index].priority) return;
const next = current.slice();
next[index] = { ...current[index], priority };
ctx.drafts.set(sortDrafts(next));
const message = `${next[index].title} reprioritized to ${priority}`;
ctx.activityLog.set(appendHistory(ctx.activityLog.get(), message));
},
);
const advanceStage = handler(
(event: AdvanceStageEvent | undefined, ctx: WorkflowContext) => {
const id = sanitizeIdentifier(event?.id, "");
if (id.length === 0) return;
const current = getSanitizedDrafts(ctx);
const index = current.findIndex((draft) => draft.id === id);
if (index === -1) return;
const draft = current[index];
const requested = sanitizeStage(event?.stage, draft.stage);
const nextStage = requested !== draft.stage
? requested
: stageOrder[stageRank[draft.stage] + 1] ?? draft.stage;
if (nextStage === draft.stage) return;
const next = current.slice();
next[index] = { ...draft, stage: nextStage };
ctx.drafts.set(sortDrafts(next));
const message = `${draft.title} moved to ${
stageActionLabel(nextStage)
}`;
ctx.activityLog.set(appendHistory(ctx.activityLog.get(), message));
},
);
return {
drafts,
queue,
nextDraft,
stageTotals,
statusLine,
queuePreview,
activityLog,
priorityScheduleOrder,
addDraft: addDraft(context as never),
rescheduleDraft: rescheduleDraft(context as never),
reprioritizeDraft: reprioritizeDraft(context as never),
advanceStage: advanceStage(context as never),
};
},
);