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

@@ -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');
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({
<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}
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={`bg-white min-h-[88px] p-1.5 text-left flex flex-col gap-1 ${
dayKept ? 'ring-1 ring-inset ring-emerald-400' : ''
}`}
>
<span className="text-[11px] font-bold text-gray-700">
{day}
</span>
<div className="flex flex-col gap-0.5 flex-1">
<div className="flex items-center justify-between gap-1">
<button
type="button"
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) => (
<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>
</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>