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:
@@ -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({
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [dayZoom, setDayZoom] = useState<string | 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 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');
|
||||
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({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{busy ? (
|
||||
@@ -399,6 +464,24 @@ export default function CalendarCreator({
|
||||
)}
|
||||
Generar estrategia del mes
|
||||
</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
|
||||
type="button"
|
||||
disabled={busy || !plan?.pieces?.length}
|
||||
@@ -409,6 +492,7 @@ export default function CalendarCreator({
|
||||
</button>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
{plan?.pieces?.length || 0} piezas
|
||||
{keepIds.size ? ` · ${keepIds.size} ✓` : ''}
|
||||
{source ? ` · ${source}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
@@ -422,10 +506,10 @@ export default function CalendarCreator({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">
|
||||
Estrategia engagement 2026: tendencias del mes + Zona de
|
||||
marca / brandbook del cliente (crea ángulos nuevos, no
|
||||
copia el histórico). Completa el brandbook en Marca para
|
||||
mejores temas. O haz click en un día para añadir a mano.
|
||||
Marca ✓ las piezas o días que te gustan. Luego:{' '}
|
||||
<strong>Regenerar no marcadas</strong> (v2 solo de las
|
||||
malas) o <strong>Solo quedarme con ✓</strong> (borra el
|
||||
resto). También puedes editar/borrar día a día.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -457,27 +541,62 @@ export default function CalendarCreator({
|
||||
const day = i + 1;
|
||||
const key = `${month}-${String(day).padStart(2, '0')}`;
|
||||
const hits = byDate.get(key) || [];
|
||||
const dayKept =
|
||||
hits.length > 0 && hits.every((p) => keepIds.has(p.id));
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={key}
|
||||
className={`bg-white min-h-[88px] p-1.5 text-left flex flex-col gap-1 ${
|
||||
dayKept ? 'ring-1 ring-inset ring-emerald-400' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
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"
|
||||
className="text-[11px] font-bold text-gray-700 hover:text-sky-700"
|
||||
>
|
||||
<span className="text-[11px] font-bold text-gray-700">
|
||||
{day}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
</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) => (
|
||||
<span
|
||||
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) }}
|
||||
title={p.title}
|
||||
>
|
||||
{keepIds.has(p.id) ? '✓ ' : ''}
|
||||
{p.title}
|
||||
</span>
|
||||
))}
|
||||
@@ -486,8 +605,8 @@ export default function CalendarCreator({
|
||||
+{hits.length - 3}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -541,14 +660,23 @@ export default function CalendarCreator({
|
||||
dayPieces.map((p) => (
|
||||
<div
|
||||
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>
|
||||
<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>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
</label>
|
||||
<p className="text-[11px] text-gray-500 pl-6">
|
||||
{p.platform} · {p.format} · {p.time || '—'}
|
||||
{p.pillar ? ` · ${p.pillar}` : ''}
|
||||
</p>
|
||||
|
||||
@@ -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<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 };
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user