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 <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-13 13:57:13 +02:00
parent 714f250eca
commit 2b57b93220
4 changed files with 258 additions and 30 deletions

View File

@@ -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); const month = String(body.month || '').slice(0, 7);
if (!/^\d{4}-\d{2}$/.test(month)) { if (!/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 }); 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'; let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>( const t = await restGet<{ slug?: string }[]>(
@@ -122,6 +131,7 @@ export async function POST(req: NextRequest) {
history, history,
brandbook, brandbook,
publishedPosts, publishedPosts,
avoidTitles,
}); });
return NextResponse.json({ return NextResponse.json({

View File

@@ -13,6 +13,10 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView'; import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { monthLabel } from '@/lib/calendar-import'; import { monthLabel } from '@/lib/calendar-import';
import {
keepOnlyPieces,
mergeKeepAndRegenerate,
} from '@/lib/calendar-generate';
const MONTH_NAMES = [ const MONTH_NAMES = [
'Enero', 'Enero',
@@ -122,6 +126,8 @@ export default function CalendarCreator({
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [dayZoom, setDayZoom] = useState<string | null>(null); const [dayZoom, setDayZoom] = useState<string | null>(null);
const [form, setForm] = useState<PieceForm | null>(null); const [form, setForm] = useState<PieceForm | null>(null);
/** Piezas a conservar (✓) en selección parcial */
const [keepIds, setKeepIds] = useState<Set<string>>(new Set());
const monthChoices = useMemo(() => { const monthChoices = useMemo(() => {
const base = ymNow(); const base = ymNow();
@@ -162,6 +168,7 @@ export default function CalendarCreator({
setRationale(''); setRationale('');
setSource(''); setSource('');
} }
setKeepIds(new Set());
setMonth(ym); setMonth(ym);
setStep('edit'); setStep('edit');
} catch (e) { } 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); setBusy(true);
setErr(null); setErr(null);
try { try {
const keptPieces = (plan?.pieces || []).filter((p) => keepIds.has(p.id));
const r = await fetch('/api/calendar/preview', { const r = await fetch('/api/calendar/preview', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(); const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo generar preview'); if (!r.ok) throw new Error(j.error || 'No se pudo generar preview');
setPlan(j.plan); if (mode === 'regen_unkept' && plan?.pieces?.length && keepIds.size > 0) {
setRationale(j.rationale || ''); if (keepIds.size >= plan.pieces.length) {
setSource(j.source || 'heuristic'); 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) { } catch (e) {
setErr(e instanceof Error ? e.message : 'Error preview'); setErr(e instanceof Error ? e.message : 'Error preview');
} finally { } 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) { async function savePlan(force = false) {
if (!plan) return; if (!plan) return;
setBusy(true); setBusy(true);
@@ -389,7 +454,7 @@ export default function CalendarCreator({
<button <button
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => void runPreview()} onClick={() => void runPreview('replace')}
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-60" className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-60"
> >
{busy ? ( {busy ? (
@@ -399,6 +464,24 @@ export default function CalendarCreator({
)} )}
Generar estrategia del mes Generar estrategia del mes
</button> </button>
<button
type="button"
disabled={busy || !plan?.pieces?.length || keepIds.size === 0}
onClick={() => void runPreview('regen_unkept')}
title="Conserva las ✓ y pide ángulos nuevos para el resto"
className="inline-flex items-center gap-1.5 rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-700 disabled:opacity-60"
>
Regenerar no marcadas
</button>
<button
type="button"
disabled={busy || keepIds.size === 0}
onClick={() => keepOnlySelected()}
title="Borra del preview todo lo que no esté marcado"
className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-900 disabled:opacity-60"
>
Solo quedarme con ✓
</button>
<button <button
type="button" type="button"
disabled={busy || !plan?.pieces?.length} disabled={busy || !plan?.pieces?.length}
@@ -409,6 +492,7 @@ export default function CalendarCreator({
</button> </button>
<span className="text-[11px] text-gray-500"> <span className="text-[11px] text-gray-500">
{plan?.pieces?.length || 0} piezas {plan?.pieces?.length || 0} piezas
{keepIds.size ? ` · ${keepIds.size} ✓` : ''}
{source ? ` · ${source}` : ''} {source ? ` · ${source}` : ''}
</span> </span>
</div> </div>
@@ -422,10 +506,10 @@ export default function CalendarCreator({
</div> </div>
) : ( ) : (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Estrategia engagement 2026: tendencias del mes + Zona de Marca ✓ las piezas o días que te gustan. Luego:{' '}
marca / brandbook del cliente (crea ángulos nuevos, no <strong>Regenerar no marcadas</strong> (v2 solo de las
copia el histórico). Completa el brandbook en Marca para malas) o <strong>Solo quedarme con ✓</strong> (borra el
mejores temas. O haz click en un día para añadir a mano. resto). También puedes editar/borrar día a día.
</p> </p>
)} )}
@@ -457,27 +541,62 @@ export default function CalendarCreator({
const day = i + 1; const day = i + 1;
const key = `${month}-${String(day).padStart(2, '0')}`; const key = `${month}-${String(day).padStart(2, '0')}`;
const hits = byDate.get(key) || []; const hits = byDate.get(key) || [];
const dayKept =
hits.length > 0 && hits.every((p) => keepIds.has(p.id));
return ( return (
<button <div
key={key} key={key}
type="button" className={`bg-white min-h-[88px] p-1.5 text-left flex flex-col gap-1 ${
onClick={() => { dayKept ? 'ring-1 ring-inset ring-emerald-400' : ''
setDayZoom(key); }`}
setForm(null);
}}
className="bg-white min-h-[88px] p-1.5 text-left hover:bg-sky-50/80 transition flex flex-col gap-1"
> >
<span className="text-[11px] font-bold text-gray-700"> <div className="flex items-center justify-between gap-1">
{day} <button
</span> type="button"
<div className="flex flex-col gap-0.5 flex-1"> onClick={() => {
setDayZoom(key);
setForm(null);
}}
className="text-[11px] font-bold text-gray-700 hover:text-sky-700"
>
{day}
</button>
{hits.length > 0 ? (
<label
className="flex items-center gap-0.5 text-[9px] text-emerald-700 cursor-pointer"
title="Marcar/desmarcar todo el día"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={dayKept}
onChange={() => toggleKeepDay(key)}
className="h-3 w-3"
/>
día
</label>
) : null}
</div>
<button
type="button"
onClick={() => {
setDayZoom(key);
setForm(null);
}}
className="flex flex-col gap-0.5 flex-1 text-left hover:bg-sky-50/80 rounded"
>
{hits.slice(0, 3).map((p) => ( {hits.slice(0, 3).map((p) => (
<span <span
key={p.id} key={p.id}
className="truncate rounded px-1 py-0.5 text-[9px] font-semibold text-white" className={`truncate rounded px-1 py-0.5 text-[9px] font-semibold text-white ${
keepIds.has(p.id)
? 'outline outline-1 outline-offset-[-1px] outline-white'
: ''
}`}
style={{ background: colorFor(p) }} style={{ background: colorFor(p) }}
title={p.title} title={p.title}
> >
{keepIds.has(p.id) ? '✓ ' : ''}
{p.title} {p.title}
</span> </span>
))} ))}
@@ -486,8 +605,8 @@ export default function CalendarCreator({
+{hits.length - 3} +{hits.length - 3}
</span> </span>
) : null} ) : null}
</div> </button>
</button> </div>
); );
})} })}
</div> </div>
@@ -541,14 +660,23 @@ export default function CalendarCreator({
dayPieces.map((p) => ( dayPieces.map((p) => (
<div <div
key={p.id} key={p.id}
className="rounded-xl border border-gray-200 p-3 space-y-2" className={`rounded-xl border p-3 space-y-2 ${
keepIds.has(p.id)
? 'border-emerald-300 bg-emerald-50/40'
: 'border-gray-200'
}`}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<p className="text-sm font-bold text-gray-900"> <label className="flex items-center gap-2 text-sm font-bold text-gray-900 cursor-pointer">
<input
type="checkbox"
checked={keepIds.has(p.id)}
onChange={() => toggleKeep(p.id)}
/>
{p.title} {p.title}
</p> </label>
<p className="text-[11px] text-gray-500"> <p className="text-[11px] text-gray-500 pl-6">
{p.platform} · {p.format} · {p.time || '—'} {p.platform} · {p.format} · {p.time || '—'}
{p.pillar ? ` · ${p.pillar}` : ''} {p.pillar ? ` · ${p.pillar}` : ''}
</p> </p>

View File

@@ -35,6 +35,8 @@ export type GeneratePreviewInput = {
brandbook?: Brandbook | null; brandbook?: Brandbook | null;
/** Publicaciones reales (pipeline / IG) además del histórico de calendarios. */ /** Publicaciones reales (pipeline / IG) además del histórico de calendarios. */
publishedPosts?: PublishedNetworkPost[]; publishedPosts?: PublishedNetworkPost[];
/** Títulos / hooks a no repetir (piezas que el usuario conserva). */
avoidTitles?: string[];
}; };
export type GeneratePreviewResult = { export type GeneratePreviewResult = {
@@ -88,6 +90,7 @@ async function anthropicStrategist(
label: input.label, label: input.label,
dna, dna,
trends, trends,
avoidTitles: input.avoidTitles || [],
}); });
try { 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<string>
): 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<string, number>();
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<string>();
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<string>
): 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 }; export { monthLabel };

View File

@@ -860,6 +860,7 @@ export function strategistSystemPrompt(input: {
label: string; label: string;
dna: ClientDna; dna: ClientDna;
trends: TrendBeat[]; trends: TrendBeat[];
avoidTitles?: string[];
}): string { }): string {
const netBlock = const netBlock =
input.dna.networkSamples.length > 0 input.dna.networkSamples.length > 0
@@ -867,6 +868,13 @@ export function strategistSystemPrompt(input: {
.map((s) => `- ${s}`) .map((s) => `- ${s}`)
.join('\n')}\n` .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}). 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: NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo:
1) Zona de marca / brandbook 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}. 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)}%. 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` : ''} ${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
${netBlock} ${netBlock}${avoidBlock}
Playbook 2026 ES: ${PLAYBOOK_2026.principle} Playbook 2026 ES: ${PLAYBOOK_2026.principle}
Tendencias del mes: Tendencias del mes:
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')} ${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}