# 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.

- Pool Ortega, 22 de septiembre de 2026
- URL: https://www.poolortega.com/es/blog/calendar-week-start-intl-locale
- Tags: javascript, typescript, react, i18n

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:

```ts
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:

```ts
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

```ts
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
}
```

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:

```ts
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.
