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)
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|||||||
Reference in New Issue
Block a user