diff --git a/public/plantillas/calendario-rrss-oficial.xlsx b/public/plantillas/calendario-rrss-oficial.xlsx
new file mode 100644
index 0000000..f7616d7
Binary files /dev/null and b/public/plantillas/calendario-rrss-oficial.xlsx differ
diff --git a/src/components/CalendarUpload.tsx b/src/components/CalendarUpload.tsx
index 4c8ae0e..e38d411 100644
--- a/src/components/CalendarUpload.tsx
+++ b/src/components/CalendarUpload.tsx
@@ -56,17 +56,23 @@ export default function CalendarUpload({
Subir calendario (CSV, Excel o JSON)
- 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.
- Descargar plantilla CSV
+ Plantilla Excel (oficial)
+
+
+ CSV alternativo
= 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
`;