Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 60 additions & 13 deletions src/commands/ramadan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
shouldApplyRecommendedSchool,
} from '../ramadan-config.js';
import {
getCountryRamadanStartDate,
getRecommendedMethod,
getRecommendedSchool,
} from '../recommendations.js';
Expand Down Expand Up @@ -120,12 +121,16 @@ export const to12HourTime = (value: string): string => {
return `${twelveHour}:${String(minute).padStart(2, '0')} ${period}`;
};

const toRamadanRow = (day: PrayerData, roza: number): RamadanRow => ({
const toRamadanRow = (
day: PrayerData,
roza: number,
hijriDay?: number
): RamadanRow => ({
roza,
sehar: to12HourTime(day.timings.Fajr),
iftar: to12HourTime(day.timings.Maghrib),
date: day.date.readable,
hijri: `${day.date.hijri.day} ${day.date.hijri.month.en} ${day.date.hijri.year}`,
hijri: `${hijriDay ?? day.date.hijri.day} ${day.date.hijri.month.en} ${day.date.hijri.year}`,
});

const getRozaNumberFromHijriDay = (day: PrayerData): number => {
Expand Down Expand Up @@ -422,6 +427,38 @@ const getConfiguredFirstRozaDate = (
return null;
};

export const resolveCountryFirstRozaDate = (
country: string | undefined,
today: PrayerData,
todayGregorianDate: Date
): Date | null => {
if (!country) {
return null;
}

const hijriYear = Number.parseInt(today.date.hijri.year, 10);
if (Number.isNaN(hijriYear)) {
return null;
}

const startDateStr = getCountryRamadanStartDate(country, hijriYear);
if (!startDateStr) {
return null;
}

const startDate = parseIsoDate(startDateStr);
if (!startDate) {
return null;
}

const testRoza = getRozaNumberFromStartDate(startDate, todayGregorianDate);
if (testRoza > 30) {
return null;
}

return startDate;
};

export const getTargetRamadanYear = (today: PrayerData): number => {
const hijriYear = Number.parseInt(today.date.hijri.year, 10);
const hijriMonth = today.date.hijri.month.number;
Expand Down Expand Up @@ -901,13 +938,14 @@ const fetchCustomRamadanDays = async (

const getRowByRozaNumber = (
days: ReadonlyArray<PrayerData>,
rozaNumber: number
rozaNumber: number,
hijriDay?: number
): RamadanRow => {
const day = days[rozaNumber - 1];
if (!day) {
throw new Error(`Could not find roza ${rozaNumber} timings.`);
}
return toRamadanRow(day, rozaNumber);
return toRamadanRow(day, rozaNumber, hijriDay);
};

const getDayByRozaNumber = (
Expand Down Expand Up @@ -1070,7 +1108,10 @@ export const ramadanCommand = async (
throw new Error('Could not parse Gregorian date from prayer response.');
}
const targetYear = getTargetRamadanYear(today);
const hasCustomFirstRozaDate = configuredFirstRozaDate !== null;
const effectiveFirstRozaDate =
configuredFirstRozaDate ??
resolveCountryFirstRozaDate(query.country, today, todayGregorianDate);
const hasCustomFirstRozaDate = effectiveFirstRozaDate !== null;

if (opts.all && opts.rozaNumber !== undefined) {
throw new Error('Use either --all or --number, not both.');
Expand All @@ -1082,12 +1123,16 @@ export const ramadanCommand = async (
let selectedDay: PrayerData;

if (hasCustomFirstRozaDate) {
const firstRozaDate = configuredFirstRozaDate;
const firstRozaDate = effectiveFirstRozaDate;
if (!firstRozaDate) {
throw new Error('Could not determine first roza date.');
}
const customDays = await fetchCustomRamadanDays(query, firstRozaDate);
row = getRowByRozaNumber(customDays, opts.rozaNumber);
row = getRowByRozaNumber(
customDays,
opts.rozaNumber,
opts.rozaNumber
);
selectedDay = getDayByRozaNumber(customDays, opts.rozaNumber);
hijriYear = getHijriYearFromRozaNumber(
customDays,
Expand Down Expand Up @@ -1130,7 +1175,7 @@ export const ramadanCommand = async (
let highlightDay: PrayerData | null = null;

if (hasCustomFirstRozaDate) {
const firstRozaDate = configuredFirstRozaDate;
const firstRozaDate = effectiveFirstRozaDate;
if (!firstRozaDate) {
throw new Error('Could not determine first roza date.');
}
Expand All @@ -1141,13 +1186,13 @@ export const ramadanCommand = async (

if (rozaNumber < 1) {
const firstRozaDay = await fetchRamadanDay(query, firstRozaDate);
row = toRamadanRow(firstRozaDay, 1);
row = toRamadanRow(firstRozaDay, 1, 1);
highlightDay = firstRozaDay;
outputHijriYear = Number.parseInt(firstRozaDay.date.hijri.year, 10);
}

if (rozaNumber >= 1) {
row = toRamadanRow(today, rozaNumber);
row = toRamadanRow(today, rozaNumber, rozaNumber);
highlightDay = today;
outputHijriYear = Number.parseInt(today.date.hijri.year, 10);
}
Expand Down Expand Up @@ -1202,12 +1247,14 @@ export const ramadanCommand = async (
let hijriYear = targetYear;

if (hasCustomFirstRozaDate) {
const firstRozaDate = configuredFirstRozaDate;
const firstRozaDate = effectiveFirstRozaDate;
if (!firstRozaDate) {
throw new Error('Could not determine first roza date.');
}
const customDays = await fetchCustomRamadanDays(query, firstRozaDate);
rows = customDays.map((day, index) => toRamadanRow(day, index + 1));
rows = customDays.map((day, index) =>
toRamadanRow(day, index + 1, index + 1)
);
const firstCustomDay = customDays[0];
if (firstCustomDay) {
hijriYear = Number.parseInt(firstCustomDay.date.hijri.year, 10);
Expand All @@ -1229,7 +1276,7 @@ export const ramadanCommand = async (
today,
todayGregorianDate,
targetYear,
configuredFirstRozaDate,
configuredFirstRozaDate: effectiveFirstRozaDate,
});

spinner?.stop();
Expand Down
69 changes: 69 additions & 0 deletions src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,75 @@ export const getRecommendedMethod = (country: string): number | null => {
return null;
};

// Official Ramadan start dates by Hijri year, based on moon sighting announcements.
// Only explicitly verified countries are listed. Unknown countries fall back to the
// API's computational Hijri date. Update this map each Ramadan season — if no data
// exists for a year, the CLI behaves exactly as before.
// Sources for 1447:
// - https://khaleejtimes.com/ramadan/countries-that-will-begin-holy-month-feb-19-2026
// - https://gulfnews.com/uae/ramadan/ramadan-2026-in-uae-crescent-moon-sighting-and-announcements-worldwide-1.500445904
const ramadanStartDates: Readonly<
Record<number, Readonly<Record<string, string>>>
> = {
1447: {
// Feb 18 — crescent sighted on Feb 17
'Saudi Arabia': '2026-02-18',
UAE: '2026-02-18',
'United Arab Emirates': '2026-02-18',
Qatar: '2026-02-18',
Kuwait: '2026-02-18',
Bahrain: '2026-02-18',
Palestine: '2026-02-18',
Lebanon: '2026-02-18',
Yemen: '2026-02-18',
Iran: '2026-02-18',
Iraq: '2026-02-18',
// Feb 19 — crescent not sighted on Feb 17
Egypt: '2026-02-19',
Turkey: '2026-02-19',
Türkiye: '2026-02-19',
Oman: '2026-02-19',
Jordan: '2026-02-19',
Syria: '2026-02-19',
Morocco: '2026-02-19',
Sudan: '2026-02-19',
Pakistan: '2026-02-19',
India: '2026-02-19',
Bangladesh: '2026-02-19',
Indonesia: '2026-02-19',
Malaysia: '2026-02-19',
Singapore: '2026-02-19',
Australia: '2026-02-19',
'United Kingdom': '2026-02-19',
UK: '2026-02-19',
France: '2026-02-19',
},
};

export const getCountryRamadanStartDate = (
country: string,
hijriYear: number
): string | null => {
const yearData = ramadanStartDates[hijriYear];
if (!yearData) {
return null;
}

const direct = yearData[country];
if (direct !== undefined) {
return direct;
}

const lowerCountry = country.toLowerCase();
for (const [key, value] of Object.entries(yearData)) {
if (key.toLowerCase() === lowerCountry) {
return value;
}
}

return null;
};

export const getRecommendedSchool = (country: string): number => {
if (hanafiCountries.has(country)) {
return 1;
Expand Down