Todas las entradas

No todas las semanas empiezan en domingo: calendarios con Intl.Locale

Un calendario en React y TypeScript con fechas nativas. Cómo calcular las celdas vacías iniciales según el locale con getWeekInfo(), con un fallback.

Hace poco construí un calendario desde cero. React y TypeScript, sin librerías de calendario, solo fechas nativas de JavaScript.

Me sirvió para repasar detalles de fechas que se olvidan fácil cuando dejas de lidiar con ellos a diario. Quiero escribir sobre uno que parece menor y rompe muchísimos calendarios: la semana no empieza el mismo día en todos lados.

¿Domingo o lunes?

En Estados Unidos la semana empieza en domingo. En España, Alemania, Reino Unido o Argentina, en lunes. En Egipto o Arabia Saudita puede ser sábado o domingo.

Ni siquiera es consistente dentro del mismo idioma. Con los datos de CLDR que usan los navegadores, es-ES empieza en lunes, pero es-MX y es-CO empiezan en domingo. Mismo idioma, otra cuadrícula.

Si dejas fija una sola convención, el calendario te funciona a ti y se rompe en cuanto lo abre alguien con otro locale. Los días quedan bajo el encabezado equivocado o la primera fila tiene un número incorrecto de celdas vacías.

La cuenta que depende de eso

Una cuadrícula mensual tiene 7 columnas. Antes del día 1 pintas algunas celdas vacías para que el 1 caiga bajo el día de la semana correcto. Ese número es el offset inicial.

Date#getDay() te da el día de la semana del 1, de 0 (domingo) a 6 (sábado). Si la semana empieza en domingo, el offset es ese mismo número. Si empieza en lunes, todo se recorre uno.

La versión general:

function getLeadingOffset(year: number, month: number, firstDayOfWeek: number) {
  // firstDayOfWeek usa la misma escala que getDay(): 0 = domingo ... 6 = sábado
  const firstOfMonth = new Date(year, month, 1).getDay();
  return (firstOfMonth - firstDayOfWeek + 7) % 7;
}

El 1 de octubre de 2026 cae en jueves (getDay() da 4). Para es-MX el offset es 4. Para es-ES es 3. Mismo mes, otra cuadrícula.

Entonces la única pregunta de verdad es: ¿de dónde sale firstDayOfWeek?

Intl.Locale ya lo sabe

La API nativa que no tocaba desde hacía rato era Intl.Locale.prototype.getWeekInfo(). Te dice las convenciones de la semana para un locale:

new Intl.Locale("es-MX").getWeekInfo();
// { firstDay: 7, weekend: [6, 7], minimalDays: 1 }
 
new Intl.Locale("es-ES").getWeekInfo();
// { firstDay: 1, weekend: [6, 7], minimalDays: 4 }

Ojo con los números. firstDay va de 1 (lunes) a 7 (domingo), al estilo ISO. getDay() va de 0 (domingo) a 6 (sábado). No coinciden, pero firstDay % 7 convierte uno en el otro: el 7 se vuelve 0 y lo demás se queda igual.

Hay otro detalle. Esta API salió primero como getter, locale.weekInfo, y después se cambió a método, locale.getWeekInfo(). Según los datos de compatibilidad de MDN, el método está en Chrome 130+, Safari 17+, Firefox 153+ y Node 24+, y llegó a Baseline en 2026. Las versiones anteriores de Chromium, Safari y Node (de Node 18 a 22) solo tienen el getter. Firefox no tenía ninguno de los dos antes de la 153.

Así que en la práctica revisas los dos y dejas un fallback para cuando no exista ninguno.

Una versión que funciona en todos lados

type WeekInfo = { firstDay: number; weekend: number[]; minimalDays: number };
 
type LocaleWithWeekInfo = Intl.Locale & {
  getWeekInfo?: () => WeekInfo;
  weekInfo?: WeekInfo;
};
 
// Snapshot de las regiones de CLDR donde la semana no empieza en lunes.
const SUNDAY_FIRST = new Set(
  "AG AS BD BR BS BT BW BZ CA CO DM DO ET GT GU HK HN ID IL IN JM JP KE KH KR LA MH MM MO MT MX MZ NI NP PA PE PH PK PR PT PY SA SG SV TH TT TW UM US VE VI WS YE ZA ZW".split(" ")
);
const SATURDAY_FIRST = new Set(
  "AF BH DJ DZ EG IQ IR JO KW LY OM QA SD SY".split(" ")
);
 
export function getFirstDayOfWeek(tag: string): number {
  const locale = new Intl.Locale(tag) as LocaleWithWeekInfo;
 
  const info =
    typeof locale.getWeekInfo === "function"
      ? locale.getWeekInfo()
      : locale.weekInfo;
 
  if (info) return info.firstDay % 7; // 1–7 (lun–dom) → 0–6 (dom–sáb)
 
  // Fallback: deducirlo de la región ("es" → "es-Latn-ES" → "ES")
  const region = locale.region ?? locale.maximize().region;
  if (region && SUNDAY_FIRST.has(region)) return 0;
  if (region && SATURDAY_FIRST.has(region)) return 6;
  return 1; // el default mundial de CLDR es lunes
}
Demo en vivoInicio de semana por localeUsa el teclado

Cargando demo…

Elige un locale y mira cómo se mueve el primer día de la semana.

La lista del fallback es un snapshot de los datos de CLDR, leídos con el mismo Intl.Locale. No necesita ser perfecta. Solo corre en navegadores que no tienen la API, y acierta en los casos importantes.

Luego le pasas el locale del usuario. En el navegador, navigator.language es un buen punto de partida. Si tu app ya tiene el locale en la URL, como /es o /en, usa ese para que la cuadrícula coincida con el resto de la página. Y toma en cuenta que un es a secas se resuelve a España, así que si tu público está en México, es-MX te da otra respuesta.

Los encabezados también se mueven

Si la cuadrícula empieza en lunes, la fila de encabezados también. Intl.DateTimeFormat te da los nombres localizados sin archivo de traducciones:

export function getWeekdayLabels(tag: string, firstDayOfWeek: number) {
  const format = new Intl.DateTimeFormat(tag, { weekday: "short" });
  // El 4 de enero de 2026 es domingo, así que el día 4 + n es el día de la semana n
  return Array.from({ length: 7 }, (_, i) =>
    format.format(new Date(2026, 0, 4 + ((firstDayOfWeek + i) % 7)))
  );
}
 
getWeekdayLabels("es-MX", 0); // ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"]

El offset y los encabezados salen del mismo firstDayOfWeek, así que no se pueden desincronizar.

Lo que me llevo

  • Nunca asumas domingo o lunes. Pregúntale al locale.
  • firstDay va de 1 a 7 y getDay() de 0 a 6. Convierte una sola vez, en un solo lugar.
  • Detecta tanto getWeekInfo() como weekInfo. Los dos existen allá afuera.
  • getWeekInfo() también regresa weekend, útil si quieres resaltar las columnas del fin de semana, que también cambian según la región.

Las APIs nativas de fechas tienen mala fama, y en parte se la ganaron. Pero Intl resuelve mucho de esto gratis, y vale la pena revisarlo antes de instalar una librería o dejar fija una convención.