Treat Brava-style SEMANA/Story/POST Excel as the official calendar template.

Harden week-grid parsing (placeholders, status rows, month wrap) and ship an October-shaped plantilla for agency uploads.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 16:46:17 +02:00
parent b395243ce7
commit d073df12d1
3 changed files with 103 additions and 32 deletions

Binary file not shown.

View File

@@ -56,17 +56,23 @@ export default function CalendarUpload({
Subir calendario (CSV, Excel o JSON) Subir calendario (CSV, Excel o JSON)
</h3> </h3>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Encaja las publicaciones por día en el mes. Acepta rejilla SEMANA / Plantilla oficial: Excel por mes con filas SEMANA / Story / POST
STORIES / POST o columnas fecha + título / copy. Si el mes ya (como Brava Octubre). También CSV con columnas fecha + título. Si
existía, se reemplaza. el mes ya existía, se reemplaza.
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<a <a
href="/api/calendar/upload" href="/plantillas/calendario-rrss-oficial.xlsx"
className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:border-brand-300" className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-700 hover:border-brand-300"
> >
Descargar plantilla CSV Plantilla Excel (oficial)
</a>
<a
href="/api/calendar/upload"
className="rounded-xl border border-gray-200 bg-white px-3 py-2 text-xs font-semibold text-gray-500 hover:border-brand-300"
>
CSV alternativo
</a> </a>
<button <button
type="button" type="button"

View File

@@ -146,18 +146,38 @@ export function parseFlexibleDate(raw: unknown, ymHint?: string): string | null
function parseDayHeader(h: string): number | null { function parseDayHeader(h: string): number | null {
const raw = String(h || '').trim(); const raw = String(h || '').trim();
if (!raw || /^x\b/i.test(raw) || /^columna/i.test(raw)) return null; // Placeholders: x, X 2, Columna N — not calendar days
if (!raw || /^x(\s+\d+)?$/i.test(raw) || /^columna/i.test(raw)) return null;
const m = raw.match(/(\d{1,2})\s*$/); const m = raw.match(/(\d{1,2})\s*$/);
if (!m) return null; if (!m) return null;
const n = Number(m[1]); const n = Number(m[1]);
return n >= 1 && n <= 31 ? n : null; return n >= 1 && n <= 31 ? n : null;
} }
function weekdayHits(row: (string | number | null)[]): number {
let n = 0;
for (let col = 1; col < Math.min(row.length, 10); col++) {
if (
/LUNES|MARTES|MI[EÉ]RCOLES|JUEVES|VIERNES|S[ÁA]BADO|DOMINGO/i.test(
cell(row[col])
)
) {
n += 1;
}
}
return n;
}
function looksLikeGrid(rows: SheetRows): boolean { function looksLikeGrid(rows: SheetRows): boolean {
let hits = 0; let hits = 0;
for (const row of rows.slice(0, 40)) { for (const row of rows.slice(0, 40)) {
const line = (row || []).map(cell).join(' ').toUpperCase(); const line = (row || []).map(cell).join(' ').toUpperCase();
if (line.includes('SEMANA') || /\bSTORIES\b/.test(line) || /^POST\b/.test(line)) { if (
line.includes('SEMANA') ||
/\bSTORIES?\b/.test(line) ||
/^POST\b/.test(line) ||
/\bSTORY\b/.test(line)
) {
hits += 1; hits += 1;
} }
if (/LUNES|MARTES|MIÉRCOLES|MIERCOLES/.test(line) && /\d/.test(line)) hits += 1; if (/LUNES|MARTES|MIÉRCOLES|MIERCOLES/.test(line) && /\d/.test(line)) hits += 1;
@@ -189,6 +209,14 @@ function kindFromFormat(format: string): string {
return 'post'; return 'post';
} }
/**
* Official editorial grid (Brava Octubre / BPS):
* SEMANA N | LUNES d | MARTES d | … | DOMINGO d
* Story | …content per day…
* POST | …content per day…
* Side columns (≥10) are notes/guiones and are ignored.
* PUBLICADO / CLIENTE status rows are ignored.
*/
export function parseWeekGrid( export function parseWeekGrid(
rows: SheetRows, rows: SheetRows,
year: number, year: number,
@@ -196,29 +224,48 @@ export function parseWeekGrid(
): RrssPiece[] { ): RrssPiece[] {
const pieces: RrssPiece[] = []; const pieces: RrssPiece[] = [];
let dayCols: { col: number; day: number }[] = []; let dayCols: { col: number; day: number }[] = [];
/** Index in dayCols where days wrap into the next month (e.g. 28…1). */
let wrapIdx: number | null = null;
let kind: 'STORIES' | 'POST' | null = null; let kind: 'STORIES' | 'POST' | null = null;
let weekWraps = false;
for (const row of rows) { for (const row of rows) {
if (!row || !row.some((c) => cell(c))) continue; if (!row || !row.some((c) => cell(c))) continue;
const c0 = cell(row[0]).toUpperCase(); const c0 = cell(row[0]).toUpperCase();
if ( const isStatus =
c0.startsWith('SEMANA') || c0.startsWith('PUBLICADO') ||
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(cell(row[1])) c0.startsWith('CLIENTE') ||
) { c0.startsWith('GUION') ||
c0.startsWith('APROB');
if (isStatus) continue;
const isContentRow =
c0.startsWith('STORIES') ||
c0 === 'STORY' ||
c0 === 'STORIE' ||
c0.startsWith('POST') ||
c0.startsWith('REEL') ||
c0.startsWith('FEED');
const isWeekHeader =
!isContentRow &&
(c0.startsWith('SEMANA') ||
weekdayHits(row) >= 2 ||
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(cell(row[1])));
if (isWeekHeader) {
dayCols = []; dayCols = [];
for (let col = 1; col < Math.min(row.length, 10); col++) { for (let col = 1; col < Math.min(row.length, 10); col++) {
const day = parseDayHeader(cell(row[col])); const day = parseDayHeader(cell(row[col]));
if (day) dayCols.push({ col, day }); if (day) dayCols.push({ col, day });
} }
weekWraps = dayCols.some( wrapIdx = null;
(d, i) => for (let i = 1; i < dayCols.length; i++) {
i > 0 && if (dayCols[i].day < dayCols[i - 1].day && dayCols[i - 1].day >= 20) {
d.day < dayCols[i - 1].day && wrapIdx = i;
dayCols[i - 1].day >= 28 && break;
d.day <= 7 }
); }
kind = null; kind = null;
continue; continue;
} }
@@ -231,25 +278,33 @@ export function parseWeekGrid(
continue; continue;
} }
for (const { col, day } of dayCols) { for (let i = 0; i < dayCols.length; i++) {
const { col, day } = dayCols[i];
const text = cell(row[col]); const text = cell(row[col]);
if (!text || text === 'X' || /^columna/i.test(text)) continue; if (!text || /^x(\s+\d+)?$/i.test(text) || /^columna/i.test(text)) continue;
let y = year; let y = year;
let m = month; let m = month;
if (weekWraps && day >= 28) { if (wrapIdx != null && i >= wrapIdx) {
m = month - 1; m = month + 1;
if (m < 1) { if (m > 12) {
m = 12; m = 1;
y = year - 1; y = year + 1;
} }
} }
const date = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const date = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dt = new Date(`${date}T12:00:00Z`); const dt = new Date(`${date}T12:00:00Z`);
if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue; if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue;
const format = kind === 'STORIES' ? 'Stories' : 'Post'; const format =
const k = kind === 'STORIES' ? 'story' : 'post'; kind === 'STORIES'
? 'Stories'
: /\breel\b/i.test(text)
? 'Reel'
: /carrusel|carousel/i.test(text)
? 'Carrusel'
: 'Post';
const k = kind === 'STORIES' ? 'story' : kindFromFormat(format);
pieces.push({ pieces.push({
id: `${date}-${k}-${slugify(text).slice(0, 28)}`, id: `${date}-${k}-${slugify(text).slice(0, 28)}`,
date, date,
@@ -372,7 +427,7 @@ export function buildPlan(
hero: { hero: {
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`, eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
title: `Calendario editorial · ${monthLabel(ym)}`, title: `Calendario editorial · ${monthLabel(ym)}`,
lede: 'Importado desde CSV / Excel. Cada pieza queda en su día del mes.', lede: 'Importado desde Excel (rejilla oficial SEMANA / Story / POST) o CSV. Cada pieza queda en su día del mes.',
chips: [ chips: [
`${sorted.length} piezas`, `${sorted.length} piezas`,
posts ? `${posts} posts` : '', posts ? `${posts} posts` : '',
@@ -512,8 +567,18 @@ export function sheetsToPlans(
return { plans, warnings, sheets: report }; return { plans, warnings, sheets: report };
} }
/** Fallback tabular CSV. Preferred official format = Excel week grid like Brava Octubre. */
export const SAMPLE_CSV = `fecha,plataforma,formato,titulo,hook,copy,cta,hashtags,pilar,hora,objetivo export const SAMPLE_CSV = `fecha,plataforma,formato,titulo,hook,copy,cta,hashtags,pilar,hora,objetivo
2026-10-01,instagram,Stories,Encuesta de arranque,¿De dónde bebes el agua en casa?,Sticker encuesta 3 opciones,Responde a la story,,Interactiva,09:00,Respuestas 2026-10-01,instagram,Stories,H1 · ENCUESTA Otoño: ¿qué le pides a tu piel?,Encuesta otoño,Sticker encuesta,Responde a la story,,stories,09:00,Respuestas
2026-10-02,instagram,Carrusel,El plástico que dejas de traer a casa,Cada semana subes botellas a casa,Copy del carrusel (slides 1-5),Guarda este post,#Marca,Sostenibilidad,10:30,Guardados 2026-10-03,instagram,Reel,15_REEL TREND · Cosas que nunca escucharás en Brava,Trend propio,Copy del reel,Guarda este post,#Brava,feed,10:30,Guardados
2026-10-03,linkedin,Post,Por qué las empresas cambian el agua de oficina,Menos bidones en la oficina,Cuerpo del post LinkedIn,Comentar,,B2B,09:30,DMs 2026-10-05,instagram,Carrusel,POST 1 · ¿Lista para un peeling?,Señales sí / aún no,Copy del carrusel,Pedir cita,,feed,10:30,Citas
`;
/** Minimal October-style grid as TSV (for docs / paste into Excel). */
export const SAMPLE_GRID_TSV = `SEMANA 1\tx\tx\tx\tJUEVES 1\tVIERNES 2\tSÁBADO 3\tDOMINGO 4
Story\t\t\t\tH1 · ENCUESTA\\nOtoño: ¿qué le pides a tu piel?\tH2 · V/F\\n¿Octubre ya es mes de peeling?\t\t
POST\t\t\t\t\t\t15_REEL TREND · Ejemplo reel\t
SEMANA 2\tLUNES 5\tMARTES 6\tMIERCOLES 7\tJUEVES 8\tVIERNES 9\tSÁBADO 10\tDOMINGO 11
Story\tH4 · ENCUESTA\t\tH5 · CAJÓN\t\tH6 · V/F\tH7 · BTS\t
POST\tPOST 1 · Título del carrusel · PLANT 4\t\tReel TREND: ejemplo\tPOST 2 · Debate · PLANT 2\t\t\t
`; `;