From 2b57b93220547725eb4cea122f6eabee9e2420a5 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 13 Oct 2026 13:57:13 +0200 Subject: [PATCH] Allow keeping selected calendar pieces/days and regenerating the rest. Agency can mark good stories, keep only those days, or request a partial v2 for unmarked slots without replacing the whole month. Co-authored-by: Cursor --- src/app/api/calendar/preview/route.ts | 12 +- src/components/CalendarCreator.tsx | 184 ++++++++++++++++++++++---- src/lib/calendar-generate.ts | 82 ++++++++++++ src/lib/calendar-strategy.ts | 10 +- 4 files changed, 258 insertions(+), 30 deletions(-) diff --git a/src/app/api/calendar/preview/route.ts b/src/app/api/calendar/preview/route.ts index 4e411bb..45652c6 100644 --- a/src/app/api/calendar/preview/route.ts +++ b/src/app/api/calendar/preview/route.ts @@ -41,11 +41,20 @@ export async function POST(req: NextRequest) { ); } - const body = (await req.json().catch(() => ({}))) as { month?: string }; + const body = (await req.json().catch(() => ({}))) as { + month?: string; + avoid_titles?: string[]; + }; const month = String(body.month || '').slice(0, 7); if (!/^\d{4}-\d{2}$/.test(month)) { return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 }); } + const avoidTitles = Array.isArray(body.avoid_titles) + ? body.avoid_titles + .filter((t): t is string => typeof t === 'string' && t.trim().length > 0) + .map((t) => t.trim().slice(0, 160)) + .slice(0, 40) + : []; let slug = 'cliente'; const t = await restGet<{ slug?: string }[]>( @@ -122,6 +131,7 @@ export async function POST(req: NextRequest) { history, brandbook, publishedPosts, + avoidTitles, }); return NextResponse.json({ diff --git a/src/components/CalendarCreator.tsx b/src/components/CalendarCreator.tsx index 6eddbeb..76e1a5e 100644 --- a/src/components/CalendarCreator.tsx +++ b/src/components/CalendarCreator.tsx @@ -13,6 +13,10 @@ import { } from 'lucide-react'; import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; import { monthLabel } from '@/lib/calendar-import'; +import { + keepOnlyPieces, + mergeKeepAndRegenerate, +} from '@/lib/calendar-generate'; const MONTH_NAMES = [ 'Enero', @@ -122,6 +126,8 @@ export default function CalendarCreator({ const [err, setErr] = useState(null); const [dayZoom, setDayZoom] = useState(null); const [form, setForm] = useState(null); + /** Piezas a conservar (✓) en selección parcial */ + const [keepIds, setKeepIds] = useState>(new Set()); const monthChoices = useMemo(() => { const base = ymNow(); @@ -162,6 +168,7 @@ export default function CalendarCreator({ setRationale(''); setSource(''); } + setKeepIds(new Set()); setMonth(ym); setStep('edit'); } catch (e) { @@ -171,20 +178,64 @@ export default function CalendarCreator({ } } - async function runPreview() { + function toggleKeep(id: string) { + setKeepIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function toggleKeepDay(date: string) { + const ids = (byDate.get(date) || []).map((p) => p.id); + if (!ids.length) return; + setKeepIds((prev) => { + const next = new Set(prev); + const allOn = ids.every((id) => next.has(id)); + if (allOn) ids.forEach((id) => next.delete(id)); + else ids.forEach((id) => next.add(id)); + return next; + }); + } + + async function runPreview(mode: 'replace' | 'regen_unkept' = 'replace') { setBusy(true); setErr(null); try { + const keptPieces = (plan?.pieces || []).filter((p) => keepIds.has(p.id)); const r = await fetch('/api/calendar/preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ month }), + body: JSON.stringify({ + month, + avoid_titles: keptPieces + .map((p) => p.title || p.hook || '') + .filter(Boolean), + }), }); const j = await r.json(); if (!r.ok) throw new Error(j.error || 'No se pudo generar preview'); - setPlan(j.plan); - setRationale(j.rationale || ''); - setSource(j.source || 'heuristic'); + if (mode === 'regen_unkept' && plan?.pieces?.length && keepIds.size > 0) { + if (keepIds.size >= plan.pieces.length) { + throw new Error( + 'Desmarca al menos una pieza (o un día) para regenerar solo las malas.' + ); + } + const merged = mergeKeepAndRegenerate(plan, j.plan, keepIds); + setPlan(merged); + setRationale( + `${j.rationale || ''}\n\nSelección: ${keepIds.size} conservadas · ${ + merged.pieces.length - keepIds.size + } regeneradas.` + ); + setSource(`${j.source || 'heuristic'}+parcial`); + } else { + setPlan(j.plan); + setRationale(j.rationale || ''); + setSource(j.source || 'heuristic'); + setKeepIds(new Set()); + } } catch (e) { setErr(e instanceof Error ? e.message : 'Error preview'); } finally { @@ -192,6 +243,20 @@ export default function CalendarCreator({ } } + function keepOnlySelected() { + if (!plan?.pieces?.length) return; + if (keepIds.size === 0) { + setErr('Marca al menos una pieza o un día para quedarte solo con eso.'); + return; + } + setPlan(keepOnlyPieces(plan, keepIds)); + setRationale( + `Selección manual: te quedas con ${keepIds.size} pieza(s). Guarda el mes cuando quieras.` + ); + setSource('seleccion'); + setErr(null); + } + async function savePlan(force = false) { if (!plan) return; setBusy(true); @@ -389,7 +454,7 @@ export default function CalendarCreator({ + + + {hits.length > 0 ? ( + + ) : null} + + + + ); })} @@ -541,14 +660,23 @@ export default function CalendarCreator({ dayPieces.map((p) => (
-

+

-

+ +

{p.platform} · {p.format} · {p.time || '—'} {p.pillar ? ` · ${p.pillar}` : ''}

diff --git a/src/lib/calendar-generate.ts b/src/lib/calendar-generate.ts index 3420faa..1ca86aa 100644 --- a/src/lib/calendar-generate.ts +++ b/src/lib/calendar-generate.ts @@ -35,6 +35,8 @@ export type GeneratePreviewInput = { brandbook?: Brandbook | null; /** Publicaciones reales (pipeline / IG) además del histórico de calendarios. */ publishedPosts?: PublishedNetworkPost[]; + /** Títulos / hooks a no repetir (piezas que el usuario conserva). */ + avoidTitles?: string[]; }; export type GeneratePreviewResult = { @@ -88,6 +90,7 @@ async function anthropicStrategist( label: input.label, dna, trends, + avoidTitles: input.avoidTitles || [], }); try { @@ -216,4 +219,83 @@ export function mergePreviewIntoPlan( }; } +/** + * Conserva piezas marcadas y sustituye el resto con un preview nuevo + * (prioriza mismo día; si faltan, rellena con otras del preview). + */ +export function mergeKeepAndRegenerate( + existing: RrssMonthPlan, + preview: RrssMonthPlan, + keepIds: Iterable +): RrssMonthPlan { + const keep = new Set(keepIds); + const kept = (existing.pieces || []).filter((p) => keep.has(p.id)); + const keptDates = new Set(kept.map((p) => p.date.slice(0, 10))); + + const needByDate = new Map(); + for (const p of existing.pieces || []) { + if (keep.has(p.id)) continue; + const d = p.date.slice(0, 10); + needByDate.set(d, (needByDate.get(d) || 0) + 1); + } + + const usedPreview = new Set(); + const replacements: RrssPiece[] = []; + + const takeFromPreview = (preferDate: string | null, n: number): RrssPiece[] => { + const pool = (preview.pieces || []).filter((p) => !usedPreview.has(p.id)); + const same = preferDate + ? pool.filter((p) => p.date.slice(0, 10) === preferDate) + : []; + const rest = pool.filter((p) => p.date.slice(0, 10) !== preferDate); + const picked = [...same, ...rest].slice(0, n); + for (const p of picked) usedPreview.add(p.id); + return picked; + }; + + for (const [date, n] of needByDate) { + const picked = takeFromPreview(date, n); + picked.forEach((p, i) => { + replacements.push({ + ...p, + id: newPieceId(date, `${p.title}-${i}`), + date, + }); + }); + } + + return { + ...existing, + pieces: [...kept, ...replacements].sort((a, b) => + a.date.localeCompare(b.date) + ), + hero: { + ...existing.hero, + ...(preview.hero || {}), + chips: [ + `${kept.length} conservadas`, + `${replacements.length} regeneradas`, + ...(preview.hero?.chips || []).slice(0, 2), + ], + }, + }; +} + +/** Deja solo las piezas/días marcados. */ +export function keepOnlyPieces( + existing: RrssMonthPlan, + keepIds: Iterable +): RrssMonthPlan { + const keep = new Set(keepIds); + const pieces = (existing.pieces || []).filter((p) => keep.has(p.id)); + return { + ...existing, + pieces, + hero: { + ...existing.hero, + chips: [`${pieces.length} piezas`, 'selección manual'], + }, + }; +} + export { monthLabel }; diff --git a/src/lib/calendar-strategy.ts b/src/lib/calendar-strategy.ts index 7b1aa57..b48f4a3 100644 --- a/src/lib/calendar-strategy.ts +++ b/src/lib/calendar-strategy.ts @@ -860,6 +860,7 @@ export function strategistSystemPrompt(input: { label: string; dna: ClientDna; trends: TrendBeat[]; + avoidTitles?: string[]; }): string { const netBlock = input.dna.networkSamples.length > 0 @@ -867,6 +868,13 @@ export function strategistSystemPrompt(input: { .map((s) => `- ${s}`) .join('\n')}\n` : ''; + const avoid = (input.avoidTitles || []).filter(Boolean).slice(0, 40); + const avoidBlock = + avoid.length > 0 + ? `\nPIEZAS QUE EL CLIENTE YA CONSERVA (no repitas estos títulos ni el mismo ángulo):\n${avoid + .map((t) => `- ${t}`) + .join('\n')}\n` + : ''; return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}). NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo: 1) Zona de marca / brandbook @@ -876,7 +884,7 @@ ADN series: ${input.dna.signatureSeries.join(', ')}. Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}. Volumen guía: ~${input.dna.avgPerWeek} piezas/semana · Stories ratio hist. ${(input.dna.storiesRatio * 100).toFixed(0)}%. ${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''} -${netBlock} +${netBlock}${avoidBlock} Playbook 2026 ES: ${PLAYBOOK_2026.principle} Tendencias del mes: ${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}