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:
4Ventures
2026-10-06 15:17:15 +02:00
parent d411de0086
commit 965a58540e
15 changed files with 2150 additions and 438 deletions

View 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;
}

View File

@@ -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;
}