Add in-app editorial calendar creator with month zoom and preview.

Agency can pick a month, generate a brand/seasonality preview, and add/edit/delete pieces with the same fields as the creative pipeline, optionally syncing drafts there.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-08 18:12:10 +02:00
parent 501f8203e8
commit f316497650
7 changed files with 1700 additions and 8 deletions

View File

@@ -94,7 +94,18 @@ export function newPieceId(date: string, title: string): string {
return `${date}-${slugify(title || 'pieza')}-${Math.random().toString(36).slice(2, 7)}`;
}
export function emptyMonthPlan(ym: string, tenantLabel: string) {
export function emptyMonthPlan(ym: string, tenantLabel: string): {
kind: 'rrss_month';
month: string;
label: string;
hero: {
eyebrow: string;
title: string;
lede: string;
chips: string[];
};
pieces: RrssPiece[];
} {
return {
kind: 'rrss_month',
month: ym,
@@ -103,7 +114,7 @@ export function emptyMonthPlan(ym: string, tenantLabel: string) {
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
title: `Calendario editorial · ${monthLabel(ym)}`,
lede: 'Piezas del plan editorial.',
chips: [],
chips: [] as string[],
},
pieces: [] as RrssPiece[],
};

View File

@@ -0,0 +1,391 @@
/**
* Generate a month editorial preview from tenant history + brand + seasonality.
* Uses Anthropic when ANTHROPIC_API_KEY is set; otherwise heuristic fallback.
*/
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
import { monthLabel, slugify } from '@/lib/calendar-import';
export type TenantBrandBrief = {
slug: string;
label: string;
vertical: string;
tone: string;
pillars: string[];
formats: { post: number; reel: number; stories: number; carrusel: number };
notes: string;
};
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
'talento-y-deporte': {
vertical: 'Educación deportiva / becas USA',
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
pillars: [
'nuevo-alumno',
'beca-academica',
'beca-deportiva',
'campus',
'objeciones',
'tips-ingles',
],
formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 },
notes:
'Series Nuevo alumno, objeciones reales, campus USA, English tips. Evitar notas de producción en copy.',
},
'halal-safety': {
vertical: 'Certificación Halal / exportación',
tone: 'Profesional, claro, autoridad técnica',
pillars: ['certificacion', 'exportacion', 'educativo', 'cifras', 'casos'],
formats: { post: 0.45, reel: 0.15, stories: 0.25, carrusel: 0.15 },
notes: 'Cifras, procesos de certificación, mercados, cosmética/alimentación Halal.',
},
'brava-dermoestetica': {
vertical: 'Clínica dermoestética',
tone: 'Cercano, experto, estacional (piel / tratamientos)',
pillars: ['tratamiento', 'educativo', 'antes-despues', 'promos', 'equipo'],
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
notes: 'Peelings, rutinas, encuestas stories, trends reels, citas en bio.',
},
bps: {
vertical: 'Colegio / idiomas',
tone: 'Familiar, educativo, comunidad',
pillars: ['comunidad', 'educativo', 'eventos', 'familias', 'idiomas'],
formats: { post: 0.35, reel: 0.2, stories: 0.35, carrusel: 0.1 },
notes: 'Blue Parrot School: familias, inglés, eventos escolares.',
},
'blue-parrot-school': {
vertical: 'Colegio / idiomas',
tone: 'Familiar, educativo, comunidad',
pillars: ['comunidad', 'educativo', 'eventos', 'familias', 'idiomas'],
formats: { post: 0.35, reel: 0.2, stories: 0.35, carrusel: 0.1 },
notes: 'Blue Parrot School: familias, inglés, eventos escolares.',
},
noberplast: {
vertical: 'Plásticos industriales B2B',
tone: 'Técnico, B2B, LinkedIn-first',
pillars: ['producto', 'casos', 'industria', 'linkedin'],
formats: { post: 0.5, reel: 0.1, stories: 0.1, carrusel: 0.3 },
notes: 'Más LinkedIn y carruseles técnicos que reels lifestyle.',
},
'4ventures-hub': {
vertical: 'Agencia marketing',
tone: 'Directo, ops, thought leadership',
pillars: ['ops', 'casos', 'tips', 'equipo'],
formats: { post: 0.4, reel: 0.2, stories: 0.2, carrusel: 0.2 },
notes: 'Contenido de agencia y detrás de cámaras.',
},
};
export function brandBrief(slug: string, label: string): TenantBrandBrief {
const base = BRANDS[slug] || {
vertical: 'Marca / servicios',
tone: 'Profesional y claro',
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
formats: { post: 0.4, reel: 0.2, stories: 0.25, carrusel: 0.15 },
notes: 'Ajustar al histórico del cliente.',
};
return { slug, label, ...base };
}
function seasonHints(ym: string): string[] {
const m = Number(ym.slice(5, 7));
const map: Record<number, string[]> = {
1: ['año nuevo', 'propósitos', 'vuelta a la rutina'],
2: ['San Valentín', 'invierno', 'cuidado intensivo'],
3: ['primavera', 'renovación', 'marzo'],
4: ['primavera', 'Semana Santa / puente'],
5: ['mayo', 'comuniones / eventos', 'pre-verano'],
6: ['verano', 'fin de curso', 'export / ferias'],
7: ['verano', 'vacaciones', 'contenido ligero'],
8: ['agosto', 'poco ruido', 'evergreen + tips'],
9: ['vuelta al cole', 'septiembre', 'nuevos ciclos'],
10: ['otoño', 'Halloween', 'rutinas de temporada'],
11: ['Black Friday', 'puente', 'cierre de año comercial'],
12: ['Navidad', 'balance del año', 'agradecimiento'],
};
return map[m] || [];
}
function weekdaysInMonth(ym: string): string[] {
const [y, m] = ym.split('-').map(Number);
const days: string[] = [];
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
for (let d = 1; d <= dim; d++) {
const dt = new Date(Date.UTC(y, m - 1, d));
const wd = dt.getUTCDay(); // 0 Sun
if (wd >= 1 && wd <= 5) {
days.push(`${ym}-${String(d).padStart(2, '0')}`);
}
}
return days;
}
function pickFormat(
i: number,
mix: TenantBrandBrief['formats']
): { format: string; kind: string } {
const r = (i * 37) % 100;
const post = mix.post * 100;
const reel = post + mix.reel * 100;
const stories = reel + mix.stories * 100;
if (r < post) return { format: 'Post', kind: 'post' };
if (r < reel) return { format: 'Reel', kind: 'reel' };
if (r < stories) return { format: 'Stories', kind: 'story' };
return { format: 'Carrusel', kind: 'post' };
}
function analyzeHistory(plans: RrssMonthPlan[]): {
avgPerWeek: number;
topPillars: string[];
sampleTitles: string[];
} {
const pieces = plans.flatMap((p) => p.pieces || []);
if (!pieces.length) {
return { avgPerWeek: 4, topPillars: [], sampleTitles: [] };
}
const months = Math.max(1, plans.length);
const avgPerWeek = Math.max(2, Math.round(pieces.length / months / 4));
const pillarCount = new Map<string, number>();
for (const p of pieces) {
const key = (p.pillar || 'feed').toLowerCase();
pillarCount.set(key, (pillarCount.get(key) || 0) + 1);
}
const topPillars = [...pillarCount.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([k]) => k);
const sampleTitles = pieces
.slice(-12)
.map((p) => p.title)
.filter(Boolean);
return { avgPerWeek, topPillars, sampleTitles };
}
function colorFor(format: string, kind: string, platform: string): string {
if (kind === 'story' || /stor/i.test(format)) return '#374151';
if (/linkedin/i.test(platform)) return '#7C66B8';
if (/reel/i.test(format)) return '#7C66B8';
return '#2071cf';
}
export type GeneratePreviewInput = {
ym: string;
slug: string;
label: string;
history: RrssMonthPlan[];
};
export type GeneratePreviewResult = {
plan: RrssMonthPlan;
rationale: string;
source: 'anthropic' | 'heuristic';
};
function heuristicPreview(input: GeneratePreviewInput): GeneratePreviewResult {
const brand = brandBrief(input.slug, input.label);
const hist = analyzeHistory(input.history);
const season = seasonHints(input.ym);
const weekdays = weekdaysInMonth(input.ym);
const perWeek = Math.min(6, Math.max(3, hist.avgPerWeek));
const target = Math.min(weekdays.length, perWeek * 4);
const pillars =
hist.topPillars.length > 0 ? hist.topPillars : brand.pillars;
const pieces: RrssPiece[] = [];
const step = Math.max(1, Math.floor(weekdays.length / target));
for (let i = 0, n = 0; n < target && i < weekdays.length; i += step, n++) {
const date = weekdays[i];
const { format, kind } = pickFormat(n, brand.formats);
const pillar = pillars[n % pillars.length];
const seasonBit = season[n % Math.max(1, season.length)] || '';
let title: string;
if (kind === 'story') {
title = `STORIES · ${seasonBit || pillar}`.slice(0, 120);
} else if (/reel/i.test(format)) {
title = `REEL · ${pillar}${seasonBit ? ` · ${seasonBit}` : ''}`.slice(
0,
120
);
} else if (/carrusel/i.test(format)) {
title = `POST ${n + 1} · Carrusel ${pillar}`.slice(0, 120);
} else if (pillar.includes('nuevo-alumno') || pillar.includes('alumno')) {
title = `Nuevo alumno · historia ${n + 1}`.slice(0, 120);
} else {
title = `POST ${n + 1} · ${pillar}${seasonBit ? ` · ${seasonBit}` : ''}`.slice(
0,
120
);
}
const platform =
pillar.includes('linkedin') || /linkedin/i.test(title)
? 'linkedin'
: 'instagram';
const hook = `${brand.label}: ángulo «${pillar}»${
seasonBit ? ` en clave ${seasonBit}` : ''
}.`;
const body = [
`Borrador editorial para ${monthLabel(input.ym)}.`,
`Pilar: ${pillar}. Tono: ${brand.tone}.`,
seasonBit ? `Estacionalidad: ${seasonBit}.` : '',
'Sustituye este texto por el copy final. No incluyas notas internas (WhatsApp, PENDIENTE, nombres).',
]
.filter(Boolean)
.join('\n\n');
pieces.push({
id: newPieceId(date, title),
date,
time: kind === 'story' ? '09:00' : /reel/i.test(format) ? '18:00' : '10:30',
platform,
format,
pillar,
title,
hook,
body,
cta:
kind === 'story'
? 'Responde a la story'
: /reel/i.test(format)
? 'Guarda este post'
: 'Enlace en la bio',
status: 'planned',
kind,
color: colorFor(format, kind, platform),
});
}
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = `Preview ${brand.vertical}. Ajusta piezas antes de guardar.`;
plan.hero.chips = [
`${pieces.length} piezas`,
...season.slice(0, 2),
brand.vertical,
];
plan.pieces = pieces;
const rationale = [
`Marca: ${brand.label} (${brand.vertical}).`,
`Tono: ${brand.tone}.`,
hist.sampleTitles.length
? `Patrón histórico: ~${hist.avgPerWeek} piezas/semana; pilares frecuentes: ${pillars.join(', ')}.`
: `Sin histórico suficiente → mix por defecto de la vertical.`,
season.length
? `Estacionalidad ${monthLabel(input.ym)}: ${season.join(', ')}.`
: '',
brand.notes,
]
.filter(Boolean)
.join(' ');
return { plan, rationale, source: 'heuristic' };
}
async function anthropicPreview(
input: GeneratePreviewInput,
fallback: GeneratePreviewResult
): Promise<GeneratePreviewResult> {
const key = process.env.ANTHROPIC_API_KEY?.trim();
if (!key || key.startsWith('replace-')) return fallback;
const brand = brandBrief(input.slug, input.label);
const hist = analyzeHistory(input.history);
const season = seasonHints(input.ym);
const prompt = `Eres planner RRSS de 4Ventures. Genera un calendario editorial JSON para ${brand.label} (${input.ym}).
Vertical: ${brand.vertical}. Tono: ${brand.tone}.
Pilares: ${brand.pillars.join(', ')}.
Estacionalidad: ${season.join(', ') || 'ninguna especial'}.
Histórico: ~${hist.avgPerWeek} piezas/semana. Títulos recientes: ${hist.sampleTitles.slice(-8).join(' | ') || 'n/a'}.
Devuelve SOLO JSON: { "rationale": string, "pieces": [{ "date":"YYYY-MM-DD", "time":"HH:MM", "platform":"instagram|facebook|linkedin", "format":"Post|Reel|Stories|Carrusel", "pillar":string, "title":string, "hook":string, "body":string, "cta":string }] }
Reglas: 12-20 piezas en días laborables; body = copy público (sin WhatsApp/PENDIENTE/nombres internos); títulos con POST/REEL/STORIES cuando aplique; español.`;
try {
const Anthropic = (await import('@anthropic-ai/sdk')).default;
const client = new Anthropic({ apiKey: key });
const msg = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4000,
messages: [{ role: 'user', content: prompt }],
});
const text = msg.content
.map((b) => (b.type === 'text' ? b.text : ''))
.join('');
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return fallback;
const parsed = JSON.parse(jsonMatch[0]) as {
rationale?: string;
pieces?: Array<Partial<RrssPiece>>;
};
if (!Array.isArray(parsed.pieces) || !parsed.pieces.length) return fallback;
const pieces: RrssPiece[] = parsed.pieces
.map((p) => {
const date = String(p.date || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !date.startsWith(input.ym)) {
return null;
}
const format = String(p.format || 'Post');
const kind = /stor/i.test(format)
? 'story'
: /reel/i.test(format)
? 'reel'
: 'post';
const title = String(p.title || format).slice(0, 160);
const platform = String(p.platform || 'instagram').toLowerCase();
return {
id: newPieceId(date, title),
date,
time: String(p.time || '10:30').slice(0, 5),
platform,
format,
pillar: String(p.pillar || 'feed'),
title,
hook: String(p.hook || title),
body: String(p.body || title),
cta: String(p.cta || ''),
status: 'planned',
kind,
color: colorFor(format, kind, platform),
} as RrssPiece;
})
.filter(Boolean) as RrssPiece[];
if (!pieces.length) return fallback;
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = 'Preview generado con IA · revisa antes de guardar.';
plan.hero.chips = [`${pieces.length} piezas`, 'IA', brand.vertical];
plan.pieces = pieces;
return {
plan,
rationale: parsed.rationale || fallback.rationale,
source: 'anthropic',
};
} catch {
return fallback;
}
}
export async function generateMonthPreview(
input: GeneratePreviewInput
): Promise<GeneratePreviewResult> {
const fallback = heuristicPreview(input);
return anthropicPreview(input, fallback);
}
export function mergePreviewIntoPlan(
existing: RrssMonthPlan | null,
preview: RrssMonthPlan,
mode: 'replace' | 'fill_empty'
): RrssMonthPlan {
if (!existing || mode === 'replace' || !existing.pieces?.length) {
return preview;
}
const occupied = new Set(existing.pieces.map((p) => p.date.slice(0, 10)));
const extras = preview.pieces.filter((p) => !occupied.has(p.date.slice(0, 10)));
return {
...existing,
pieces: [...existing.pieces, ...extras].sort((a, b) =>
a.date.localeCompare(b.date)
),
};
}
export { slugify, monthLabel };