Add CSV/Excel calendar upload that maps posts into the monthly RRSS view.

Agency can import editorial calendars per tenant; pieces land on each day with the same month grid and day zoom as existing plans.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 16:26:34 +02:00
parent ed275f475a
commit b395243ce7
7 changed files with 883 additions and 10 deletions

519
src/lib/calendar-import.ts Normal file
View File

@@ -0,0 +1,519 @@
/** 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();
if (!raw || /^x\b/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 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)) {
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';
}
export function parseWeekGrid(
rows: SheetRows,
year: number,
month: number
): RrssPiece[] {
const pieces: RrssPiece[] = [];
let dayCols: { col: number; day: number }[] = [];
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]))
) {
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
);
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 (const { col, day } of dayCols) {
const text = cell(row[col]);
if (!text || text === 'X' || /^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;
}
}
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';
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 CSV / Excel. 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 };
}
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
`;