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:
BIN
public/plantillas/calendario-rrss-oficial.xlsx
Normal file
BIN
public/plantillas/calendario-rrss-oficial.xlsx
Normal file
Binary file not shown.
@@ -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"
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user