Ship monthly goals, calendar creative zoom, and legal pages.
Editable targets vs actual, pipeline review-only with client reject notes, and published pieces opening from the calendar day. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
183
src/lib/pipeline-calendar.ts
Normal file
183
src/lib/pipeline-calendar.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/** Link creative_pipeline rows to a calendar day. */
|
||||
|
||||
export type PipelineCalendarRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
format?: string | null;
|
||||
month?: string | null;
|
||||
status?: string | null;
|
||||
cover_path?: string | null;
|
||||
slide_paths?: string[] | unknown;
|
||||
caption?: string | null;
|
||||
notes?: string | null;
|
||||
created_at?: string | null;
|
||||
publish_date?: string | null;
|
||||
external_ref?: string | null;
|
||||
};
|
||||
|
||||
export function instagramUrlFromNotes(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const m = notes.match(/https?:\/\/(?:www\.)?instagram\.com\S+/i);
|
||||
return m ? m[0].replace(/[),.;]+$/, '') : null;
|
||||
}
|
||||
|
||||
export function clientNoteFromNotes(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const m = notes.match(/CLIENT_NOTE:\s*([^\n]+)/i);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
export function slidesOf(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string');
|
||||
}
|
||||
|
||||
/** Day a published pipeline piece belongs on the calendar. */
|
||||
export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
|
||||
if (it.publish_date && /^\d{4}-\d{2}-\d{2}/.test(it.publish_date)) {
|
||||
return it.publish_date.slice(0, 10);
|
||||
}
|
||||
const st = (it.status || '').toLowerCase();
|
||||
const ref = (it.external_ref || '').toLowerCase();
|
||||
if (st === 'publicado' || ref.startsWith('ig-')) {
|
||||
const d = (it.created_at || '').slice(0, 10);
|
||||
return d || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function pipelineMediaUrl(path: string | null | undefined): string | null {
|
||||
if (!path) return null;
|
||||
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
export function normPermalink(u?: string | null): string {
|
||||
return (u || '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
.split('?')[0]
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const MONTH_LABELS = [
|
||||
'Enero',
|
||||
'Febrero',
|
||||
'Marzo',
|
||||
'Abril',
|
||||
'Mayo',
|
||||
'Junio',
|
||||
'Julio',
|
||||
'Agosto',
|
||||
'Septiembre',
|
||||
'Octubre',
|
||||
'Noviembre',
|
||||
'Diciembre',
|
||||
];
|
||||
|
||||
export type CalendarPieceLike = {
|
||||
id: string;
|
||||
date: string;
|
||||
title: string;
|
||||
platform?: string;
|
||||
format?: string;
|
||||
body?: string;
|
||||
status?: string;
|
||||
meta_line?: string;
|
||||
pipeline_id?: string;
|
||||
cover_path?: string | null;
|
||||
slide_paths?: string[];
|
||||
};
|
||||
|
||||
export type CalendarPlanLike = {
|
||||
kind?: string;
|
||||
month: string;
|
||||
label: string;
|
||||
hero: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
lede: string;
|
||||
chips?: string[];
|
||||
};
|
||||
pieces: CalendarPieceLike[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function monthLabel(ym: string) {
|
||||
const m = Number(ym.slice(5, 7)) - 1;
|
||||
const y = ym.slice(0, 4);
|
||||
return `${MONTH_LABELS[m] || ym} ${y}`;
|
||||
}
|
||||
|
||||
/** Overlay published pipeline creatives onto RRSS month plans. */
|
||||
export function mergePipelineIntoPlans<T extends CalendarPlanLike>(
|
||||
plans: T[],
|
||||
items: PipelineCalendarRow[]
|
||||
): T[] {
|
||||
const out = plans.map((p) => ({
|
||||
...p,
|
||||
pieces: [...(p.pieces || [])],
|
||||
}));
|
||||
const byMonth = new Map(out.map((p) => [p.month, p]));
|
||||
|
||||
for (const it of items) {
|
||||
const day = pipelinePublishDay(it);
|
||||
if (!day) continue;
|
||||
const ym = day.slice(0, 7);
|
||||
let plan = byMonth.get(ym);
|
||||
if (!plan) {
|
||||
const label = monthLabel(ym);
|
||||
plan = {
|
||||
kind: 'rrss_month',
|
||||
month: ym,
|
||||
label,
|
||||
hero: {
|
||||
eyebrow: '4VENTURES · PUBLICADO',
|
||||
title: label,
|
||||
lede: 'Piezas reales del pipeline en el día en que se publicaron. Clic en el día para zoom (preview + métricas).',
|
||||
chips: ['Pipeline', 'Instagram'],
|
||||
},
|
||||
pieces: [],
|
||||
} as T;
|
||||
out.push(plan);
|
||||
byMonth.set(ym, plan);
|
||||
}
|
||||
const ig = instagramUrlFromNotes(it.notes);
|
||||
const igKey = normPermalink(ig);
|
||||
const existing = plan.pieces.find((p) => {
|
||||
if ((p.date || '').slice(0, 10) !== day) return false;
|
||||
if (igKey && normPermalink(p.meta_line) === igKey) return true;
|
||||
const a = (p.title || '').toLowerCase().slice(0, 28);
|
||||
const b = (it.title || '').toLowerCase().slice(0, 28);
|
||||
return a && b && (a.includes(b) || b.includes(a));
|
||||
});
|
||||
const slides = slidesOf(it.slide_paths);
|
||||
if (existing) {
|
||||
existing.pipeline_id = it.id;
|
||||
existing.cover_path = it.cover_path || existing.cover_path;
|
||||
existing.slide_paths = slides.length ? slides : existing.slide_paths;
|
||||
existing.body = existing.body || it.caption || undefined;
|
||||
existing.meta_line = existing.meta_line || (ig ? `Instagram: ${ig}` : undefined);
|
||||
existing.status = existing.status || 'published';
|
||||
} else {
|
||||
plan.pieces.push({
|
||||
id: `pipe-${it.id}`,
|
||||
pipeline_id: it.id,
|
||||
date: day,
|
||||
platform: 'instagram',
|
||||
format: it.format || 'post',
|
||||
title: it.title,
|
||||
body: it.caption || undefined,
|
||||
status: 'published',
|
||||
meta_line: ig ? `Instagram: ${ig}` : undefined,
|
||||
cover_path: it.cover_path,
|
||||
slide_paths: slides,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.month.localeCompare(b.month));
|
||||
for (const p of out) {
|
||||
p.pieces.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function n(v: unknown): number {
|
||||
function matchChannel(platform: string | undefined, ch: SimChannel): boolean {
|
||||
if (ch === 'blended') return true;
|
||||
const p = normalizePlatform(platform);
|
||||
if (ch === 'meta') return p === 'meta' || p === 'other';
|
||||
if (ch === 'meta') return p === 'meta';
|
||||
if (ch === 'google') return p === 'google';
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user