Files
4ventures-client-portal/src/lib/calendar-import.ts
4Ventures d073df12d1 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>
2026-10-06 16:46:17 +02:00

585 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** Parse CSV / Excel / JSON editorial calendars into rrss_month plans. */
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
export type SheetRows = (string | number | null)[][];
export type ImportResult = {
plans: RrssMonthPlan[];
warnings: string[];
sheets: { name: string; pieces: number; mode: string }[];
};
const MONTHS: Record<string, number> = {
enero: 1,
february: 2,
febrero: 2,
marzo: 3,
march: 3,
abril: 4,
april: 4,
mayo: 5,
may: 5,
junio: 6,
june: 6,
julio: 7,
july: 7,
agosto: 8,
august: 8,
septiembre: 9,
septimebre: 9,
september: 9,
octubre: 10,
october: 10,
noviembre: 11,
november: 11,
diciembre: 12,
december: 12,
ene: 1,
feb: 2,
mar: 3,
abr: 4,
jun: 6,
jul: 7,
ago: 8,
sep: 9,
oct: 10,
nov: 11,
dic: 12,
};
export function slugify(s: string) {
return s
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 48);
}
export function monthLabel(ym: string) {
const [y, m] = ym.split('-').map(Number);
const names = [
'',
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
return `${names[m] || ym} ${y}`;
}
export function inferYmFromSheetName(name: string, fallbackYear: number): string | null {
const raw = name.replace(/\s*\(prov\)\s*/i, '').trim();
const iso = raw.match(/(20\d{2})[-_/ ](\d{1,2})/);
if (iso) {
const m = Number(iso[2]);
if (m >= 1 && m <= 12) return `${iso[1]}-${String(m).padStart(2, '0')}`;
}
const folded = raw
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
let month: number | null = null;
for (const [k, v] of Object.entries(MONTHS)) {
if (folded.includes(k)) {
month = v;
break;
}
}
if (!month) return null;
const yMatch = folded.match(/20\d{2}/);
let year = yMatch ? Number(yMatch[0]) : fallbackYear;
const two = folded.match(/\b(\d{2})\s*$/);
if (!yMatch && two) {
const n = Number(two[1]);
if (n >= 20 && n <= 40) year = 2000 + n;
}
return `${year}-${String(month).padStart(2, '0')}`;
}
function cell(v: unknown): string {
if (v == null) return '';
if (v instanceof Date && !Number.isNaN(v.getTime())) {
return v.toISOString().slice(0, 10);
}
return String(v).trim();
}
function excelSerialToIso(n: number): string | null {
if (!Number.isFinite(n) || n < 20000 || n > 80000) return null;
const utc = new Date(Date.UTC(1899, 11, 30) + Math.round(n) * 86400000);
if (Number.isNaN(utc.getTime())) return null;
return utc.toISOString().slice(0, 10);
}
export function parseFlexibleDate(raw: unknown, ymHint?: string): string | null {
if (raw instanceof Date && !Number.isNaN(raw.getTime())) {
return raw.toISOString().slice(0, 10);
}
if (typeof raw === 'number') return excelSerialToIso(raw);
const s = cell(raw);
if (!s) return null;
const iso = s.match(/(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})/);
if (iso) {
return `${iso[1]}-${String(iso[2]).padStart(2, '0')}-${String(iso[3]).padStart(2, '0')}`;
}
const dmy = s.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](20\d{2})$/);
if (dmy) {
return `${dmy[3]}-${String(dmy[2]).padStart(2, '0')}-${String(dmy[1]).padStart(2, '0')}`;
}
const dayOnly = s.match(/^(\d{1,2})$/);
if (dayOnly && ymHint) {
return `${ymHint}-${String(dayOnly[1]).padStart(2, '0')}`;
}
return null;
}
function parseDayHeader(h: string): number | null {
const raw = String(h || '').trim();
// 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) ||
/\bSTORY\b/.test(line)
) {
hits += 1;
}
if (/LUNES|MARTES|MIÉRCOLES|MIERCOLES/.test(line) && /\d/.test(line)) hits += 1;
}
return hits >= 2;
}
function colorFor(platform: string, format: string, kind?: string): string {
const f = `${format} ${kind}`.toLowerCase();
if (f.includes('stor')) return '#374151';
if ((platform || '').toLowerCase().includes('linkedin')) return '#7C66B8';
if (f.includes('reel')) return '#7C66B8';
return '#2071cf';
}
function normalizePlatform(raw: string, format: string): string {
const s = `${raw} ${format}`.toLowerCase();
if (s.includes('linkedin')) return 'linkedin';
if (s.includes('tiktok')) return 'tiktok';
if (s.includes('facebook') || s.includes('fb')) return 'facebook';
if (s.includes('blog')) return 'blog';
return 'instagram';
}
function kindFromFormat(format: string): string {
const f = format.toLowerCase();
if (f.includes('stor')) return 'story';
if (f.includes('reel')) return 'reel';
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,
month: number
): 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;
for (const row of rows) {
if (!row || !row.some((c) => cell(c))) continue;
const c0 = cell(row[0]).toUpperCase();
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 });
}
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;
}
if (c0.startsWith('STORIES') || c0 === 'STORY' || c0 === 'STORIE') {
kind = 'STORIES';
} else if (c0.startsWith('POST') || c0.startsWith('REEL') || c0.startsWith('FEED')) {
kind = 'POST';
} else if (!kind) {
continue;
}
for (let i = 0; i < dayCols.length; i++) {
const { col, day } = dayCols[i];
const text = cell(row[col]);
if (!text || /^x(\s+\d+)?$/i.test(text) || /^columna/i.test(text)) continue;
let y = year;
let m = month;
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'
: /\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,
platform: 'instagram',
format,
pillar: k === 'story' ? 'stories' : 'feed',
title: text.split('\n')[0].slice(0, 140),
hook: text.split('\n')[0].slice(0, 180),
body: text,
status: 'planned',
kind: k,
color: colorFor('instagram', format, k),
});
}
}
const seen = new Set<string>();
return pieces.filter((p) => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
}
function headerIndex(headers: string[]): Record<string, number> {
const map: Record<string, number> = {};
const aliases: Record<string, string[]> = {
date: ['fecha', 'date', 'dia', 'día', 'day'],
platform: ['plataforma', 'platform', 'red', 'canal', 'network'],
format: ['formato', 'format', 'tipo', 'type'],
title: ['titulo', 'título', 'title', 'pieza', 'nombre'],
hook: ['hook', 'gancho'],
body: ['copy', 'body', 'caption', 'texto', 'descripcion', 'descripción', 'contenido'],
cta: ['cta', 'llamada'],
hashtags: ['hashtags', 'hashtag', 'tags'],
pillar: ['pilar', 'pillar', 'linea', 'línea', 'tema'],
time: ['hora', 'time'],
objective: ['objetivo', 'objective', 'metric', 'kpi'],
status: ['estado', 'status'],
};
headers.forEach((h, i) => {
const k = h
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
for (const [field, list] of Object.entries(aliases)) {
if (list.includes(k) && map[field] == null) map[field] = i;
}
});
return map;
}
export function parseTabular(rows: SheetRows, ymHint?: string): RrssPiece[] {
let headerRow = -1;
let idx: Record<string, number> = {};
for (let i = 0; i < Math.min(rows.length, 12); i++) {
const headers = (rows[i] || []).map(cell);
const found = headerIndex(headers);
if (found.date != null && (found.title != null || found.body != null || found.format != null)) {
headerRow = i;
idx = found;
break;
}
}
if (headerRow < 0) return [];
const pieces: RrssPiece[] = [];
for (const row of rows.slice(headerRow + 1)) {
if (!row) continue;
const get = (k: string) => (idx[k] != null ? cell(row[idx[k]]) : '');
const date = parseFlexibleDate(idx.date != null ? row[idx.date] : '', ymHint);
if (!date) continue;
const format = get('format') || 'Post';
const title = get('title') || get('body') || get('hook') || format;
if (!title.trim()) continue;
const platform = normalizePlatform(get('platform'), format);
const kind = kindFromFormat(format);
const statusRaw = get('status').toLowerCase();
const status = /publi|subido|posted/.test(statusRaw)
? 'published'
: /aprob/.test(statusRaw)
? 'approved'
: 'planned';
pieces.push({
id: `${date}-${kind}-${slugify(title).slice(0, 28)}`,
date,
time: get('time') || undefined,
platform,
format,
pillar: get('pillar') || undefined,
title: title.split('\n')[0].slice(0, 160),
hook: get('hook') || title.split('\n')[0],
body: get('body') || title,
cta: get('cta') || undefined,
hashtags: get('hashtags') || undefined,
objective_metric: get('objective') || undefined,
status,
kind,
color: colorFor(platform, format, kind),
});
}
return pieces;
}
export function buildPlan(
ym: string,
pieces: RrssPiece[],
tenantLabel: string
): RrssMonthPlan {
const sorted = [...pieces].sort((a, b) => a.date.localeCompare(b.date));
const posts = sorted.filter((p) => p.kind !== 'story').length;
const stories = sorted.filter((p) => p.kind === 'story').length;
const ig = sorted.filter((p) => (p.platform || '').toLowerCase().includes('insta')).length;
const li = sorted.filter((p) => (p.platform || '').toLowerCase().includes('linkedin')).length;
return {
kind: 'rrss_month',
month: ym,
label: monthLabel(ym),
hero: {
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
title: `Calendario editorial · ${monthLabel(ym)}`,
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` : '',
stories ? `${stories} stories` : '',
ig ? `${ig} IG` : '',
li ? `${li} LinkedIn` : '',
].filter(Boolean),
},
pieces: sorted,
};
}
function asPlan(raw: unknown): RrssMonthPlan | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
if (o.kind === 'rrss_month' && Array.isArray(o.pieces) && typeof o.month === 'string') {
return o as unknown as RrssMonthPlan;
}
return null;
}
export function parseJsonCalendar(text: string, tenantLabel: string): ImportResult | null {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return null;
}
const warnings: string[] = [];
const plans: RrssMonthPlan[] = [];
const single = asPlan(raw);
if (single) {
plans.push(single);
return {
plans,
warnings,
sheets: [{ name: 'json', pieces: single.pieces.length, mode: 'json' }],
};
}
if (Array.isArray(raw)) {
if (raw.length && asPlan(raw[0])) {
for (const item of raw) {
const p = asPlan(item);
if (p) plans.push(p);
}
return {
plans,
warnings,
sheets: [
{
name: 'json',
pieces: plans.reduce((a, p) => a + p.pieces.length, 0),
mode: 'json',
},
],
};
}
const pieces = raw as RrssPiece[];
if (pieces[0] && typeof pieces[0] === 'object' && 'date' in pieces[0]) {
const by = new Map<string, RrssPiece[]>();
for (const p of pieces) {
const d = String(p.date || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) continue;
const ym = d.slice(0, 7);
const list = by.get(ym) || [];
list.push(p);
by.set(ym, list);
}
for (const [ym, list] of by) plans.push(buildPlan(ym, list, tenantLabel));
return {
plans,
warnings,
sheets: [{ name: 'json-pieces', pieces: pieces.length, mode: 'json' }],
};
}
}
return { plans, warnings: ['JSON no reconocido como calendario RRSS'], sheets: [] };
}
export function sheetsToPlans(
sheets: { name: string; rows: SheetRows }[],
tenantLabel: string,
fallbackYear = new Date().getFullYear()
): ImportResult {
const warnings: string[] = [];
const byMonth = new Map<string, RrssPiece[]>();
const report: ImportResult['sheets'] = [];
for (const sh of sheets) {
if (!sh.rows?.length) continue;
const ym = inferYmFromSheetName(sh.name, fallbackYear);
let mode = 'empty';
let pieces: RrssPiece[] = [];
if (looksLikeGrid(sh.rows) && ym) {
const [y, m] = ym.split('-').map(Number);
pieces = parseWeekGrid(sh.rows, y, m);
mode = 'grid';
}
if (!pieces.length) {
const tabular = parseTabular(sh.rows, ym || undefined);
if (tabular.length) {
pieces = tabular;
mode = 'tabla';
}
}
if (!pieces.length && looksLikeGrid(sh.rows) && !ym) {
warnings.push(
`Hoja «${sh.name}»: parece calendario semanal pero no se pudo leer el mes. Renómbrala (ej. Octubre 2026).`
);
}
report.push({ name: sh.name, pieces: pieces.length, mode });
for (const p of pieces) {
const key = p.date.slice(0, 7);
const list = byMonth.get(key) || [];
list.push(p);
byMonth.set(key, list);
}
}
const plans = [...byMonth.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([ym, pieces]) => {
const seen = new Set<string>();
const uniq = pieces.filter((p) => {
if (seen.has(p.id)) return false;
seen.add(p.id);
return true;
});
return buildPlan(ym, uniq, tenantLabel);
});
if (!plans.length) {
warnings.push(
'No se encontraron piezas. Usa columnas fecha + título (o copy), o una rejilla SEMANA / STORIES / POST como Blue Parrot.'
);
}
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,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
`;