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)
</h3>
<p className="text-xs text-gray-500 mt-1">
Encaja las publicaciones por día en el mes. Acepta rejilla SEMANA /
STORIES / POST o columnas fecha + título / copy. Si el mes ya
existía, se reemplaza.
Plantilla oficial: Excel por mes con filas SEMANA / Story / POST
(como Brava Octubre). También CSV con columnas fecha + título. Si
el mes ya existía, se reemplaza.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<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"
>
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>
<button
type="button"

View File

@@ -146,18 +146,38 @@ export function parseFlexibleDate(raw: unknown, ymHint?: string): string | null
function parseDayHeader(h: string): number | null {
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*$/);
if (!m) return null;
const n = Number(m[1]);
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 {
let hits = 0;
for (const row of rows.slice(0, 40)) {
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;
}
if (/LUNES|MARTES|MIÉRCOLES|MIERCOLES/.test(line) && /\d/.test(line)) hits += 1;
@@ -189,6 +209,14 @@ function kindFromFormat(format: string): string {
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(
rows: SheetRows,
year: number,
@@ -196,29 +224,48 @@ export function parseWeekGrid(
): RrssPiece[] {
const pieces: RrssPiece[] = [];
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 weekWraps = false;
for (const row of rows) {
if (!row || !row.some((c) => cell(c))) continue;
const c0 = cell(row[0]).toUpperCase();
if (
c0.startsWith('SEMANA') ||
/^(LUNES|MARTES|MI[EÉ]RCOLES)/i.test(cell(row[1]))
) {
const isStatus =
c0.startsWith('PUBLICADO') ||
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 = [];
for (let col = 1; col < Math.min(row.length, 10); col++) {
const day = parseDayHeader(cell(row[col]));
if (day) dayCols.push({ col, day });
}
weekWraps = dayCols.some(
(d, i) =>
i > 0 &&
d.day < dayCols[i - 1].day &&
dayCols[i - 1].day >= 28 &&
d.day <= 7
);
wrapIdx = null;
for (let i = 1; i < dayCols.length; i++) {
if (dayCols[i].day < dayCols[i - 1].day && dayCols[i - 1].day >= 20) {
wrapIdx = i;
break;
}
}
kind = null;
continue;
}
@@ -231,25 +278,33 @@ export function parseWeekGrid(
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]);
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 m = month;
if (weekWraps && day >= 28) {
m = month - 1;
if (m < 1) {
m = 12;
y = year - 1;
if (wrapIdx != null && i >= wrapIdx) {
m = month + 1;
if (m > 12) {
m = 1;
y = year + 1;
}
}
const date = `${y}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const dt = new Date(`${date}T12:00:00Z`);
if (Number.isNaN(dt.getTime()) || dt.getUTCMonth() + 1 !== m) continue;
const format = kind === 'STORIES' ? 'Stories' : 'Post';
const k = kind === 'STORIES' ? 'story' : 'post';
const format =
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({
id: `${date}-${k}-${slugify(text).slice(0, 28)}`,
date,
@@ -372,7 +427,7 @@ export function buildPlan(
hero: {
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
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: [
`${sorted.length} piezas`,
posts ? `${posts} posts` : '',
@@ -512,8 +567,18 @@ export function sheetsToPlans(
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
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-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,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-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-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-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
`;