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
35 changes: 35 additions & 0 deletions __tests__/hijri.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { getUpcomingHolyDays, HOLY_DAYS_NAME } from "../src/utils/hijri";

describe("getUpcomingHolyDays", () => {
it("returns the three sequential holy nights for Ramadan 1445", () => {
const reference = new Date(Date.UTC(2024, 2, 20));
const result = getUpcomingHolyDays(reference);

expect(result).toHaveLength(3);
expect(result.map((item) => item.hijriDay)).toEqual([17, 19, 21]);

expect(result[0].gregorian.toISOString().slice(0, 10)).toBe("2024-03-27");
expect(result[1].gregorian.toISOString().slice(0, 10)).toBe("2024-03-29");
expect(result[2].gregorian.toISOString().slice(0, 10)).toBe("2024-03-31");

result.forEach((item) => {
const diff =
(item.gregorian.getTime() - item.reminder.getTime()) / (1000 * 60 * 60 * 24);
expect(diff).toBe(3);
});
});

it("starts from the next cycle when the reference is before the first holy night", () => {
const reference = new Date(Date.UTC(2024, 2, 10));
const result = getUpcomingHolyDays(reference);

expect(result[0].hijriDay).toBe(17);
expect(result[0].gregorian.getTime()).toBeGreaterThan(reference.getTime());
});
});

describe("HOLY_DAYS_NAME", () => {
it("uses the new naming convention", () => {
expect(HOLY_DAYS_NAME).toBe("ليالي القدر");
});
});
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/**/*.test.ts"],
};
26 changes: 26 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "web",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/node": "^20.11.17",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"typescript": "^5.3.3"
}
}
44 changes: 44 additions & 0 deletions src/components/HolyDaysTimeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from "react";
import { getHolyDayTimeline, HOLY_DAYS_NAME } from "../utils/hijri";

export interface HolyDaysTimelineProps {
referenceDate?: Date;
}

export const HolyDaysTimeline: React.FC<HolyDaysTimelineProps> = ({ referenceDate }) => {
const [entries, setEntries] = React.useState(() => getHolyDayTimeline(referenceDate));

React.useEffect(() => {
setEntries(getHolyDayTimeline(referenceDate));
}, [referenceDate?.getTime()]);

return (
<section aria-labelledby="holy-days-heading">
<header>
<h2 id="holy-days-heading">{HOLY_DAYS_NAME}</h2>
<p>
نتابع {HOLY_DAYS_NAME} في الليالي ١٧ و١٩ و٢١ من شهر رمضان بناءً على تقويم أم القرى،
مع إرسال تذكير قبل ثلاثة أيام لكل ليلة.
</p>
</header>
<ol>
{entries.map((entry) => (
<li key={`${entry.hijriYear}-${entry.hijriMonth}-${entry.hijriDay}`}>
<h3>ليلة {entry.hijriDay}</h3>
<p>
<strong>التاريخ الهجري:</strong> {entry.hijriLabel}
</p>
<p>
<strong>التاريخ الميلادي:</strong> {entry.gregorianLabel}
</p>
<p>
<strong>موعد التذكير:</strong> {entry.reminderLabel}
</p>
</li>
))}
</ol>
</section>
);
};

export default HolyDaysTimeline;
19 changes: 19 additions & 0 deletions src/pages/dashboard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from "react";
import HolyDaysTimeline from "../../components/HolyDaysTimeline";
import { HOLY_DAYS_NAME } from "../../utils/hijri";

const DashboardPage: React.FC = () => {
return (
<main>
<header>
<h1>لوحة التحكم</h1>
<p>
يعرض هذا القسم حالة {HOLY_DAYS_NAME} القادمة، بما في ذلك مواعيد التذكير قبل ثلاثة أيام لمساعدة الفرق على الاستعداد اللوجستي.
</p>
</header>
<HolyDaysTimeline />
</main>
);
};

export default DashboardPage;
20 changes: 20 additions & 0 deletions src/pages/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from "react";
import HolyDaysTimeline from "../components/HolyDaysTimeline";
import { HOLY_DAYS_NAME } from "../utils/hijri";

const HomePage: React.FC = () => {
return (
<main>
<section>
<h1>الصفحة العامة</h1>
<p>
نقدم في هذه الصفحة لمحة سريعة عن أهم الأنشطة الموسمية، وعلى رأسها {HOLY_DAYS_NAME}.
يساعدك الجدول الزمني التالي في التحضير لهذه الليالي المباركة، مع تذكير قبل ثلاثة أيام لكل موعد.
</p>
</section>
<HolyDaysTimeline />
</main>
);
};

export default HomePage;
18 changes: 18 additions & 0 deletions src/pages/services/holy-days.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from "react";
import HolyDaysTimeline from "../../components/HolyDaysTimeline";
import { HOLY_DAYS_NAME } from "../../utils/hijri";

const HolyDaysPage: React.FC = () => {
return (
<main>
<h1>{HOLY_DAYS_NAME}</h1>
<p>
توفر هذه الخدمة نظرة شاملة على {HOLY_DAYS_NAME} مع إبراز الليالي المباركة والأوقات المثالية للاستعداد.
نعتمد على تقويم أم القرى لضمان دقة التواريخ الهجرية ورصد التذكيرات قبل ثلاثة أيام لكل ليلة.
</p>
<HolyDaysTimeline />
</main>
);
};

export default HolyDaysPage;
108 changes: 108 additions & 0 deletions src/utils/hijri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
export const HOLY_DAYS_NAME = "ليالي القدر";

export interface HijriDateParts {
day: number;
month: number;
year: number;
}

export interface HolyDayOccurrence extends HijriDateParts {
gregorian: Date;
reminder: Date;
}

const HOLY_DAYS: ReadonlyArray<number> = [17, 19, 21];
const CALENDAR = "islamic-umalqura";

const hijriFormatter = new Intl.DateTimeFormat("en-u-ca-" + CALENDAR, {
day: "numeric",
month: "numeric",
year: "numeric",
});

const hijriLongFormatter = new Intl.DateTimeFormat("ar-u-ca-" + CALENDAR, {
day: "numeric",
month: "long",
year: "numeric",
});

const gregorianFormatter = new Intl.DateTimeFormat("ar", {
dateStyle: "full",
});

function cloneToUtcMidnight(date: Date): Date {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}

function addDays(date: Date, days: number): Date {
const result = new Date(date.getTime());
result.setUTCDate(result.getUTCDate() + days);
return result;
}

export function formatHijri(date: Date): string {
return hijriLongFormatter.format(date);
}

export function formatGregorian(date: Date): string {
return gregorianFormatter.format(date);
}

export function getHijriParts(date: Date): HijriDateParts {
const parts = hijriFormatter.formatToParts(date);
const day = Number(parts.find((part) => part.type === "day")?.value ?? "0");
const month = Number(parts.find((part) => part.type === "month")?.value ?? "0");
const year = Number(parts.find((part) => part.type === "year")?.value ?? "0");

return { day, month, year };
}

export function getUpcomingHolyDays(referenceDate: Date = new Date()): HolyDayOccurrence[] {
const occurrences: HolyDayOccurrence[] = [];
const start = cloneToUtcMidnight(referenceDate);
let cursor = start;
let targetMonth: HijriDateParts | null = null;
let safety = 0;

while (occurrences.length < HOLY_DAYS.length && safety < 800) {
const hijri = getHijriParts(cursor);

if (!targetMonth) {
if (hijri.day === HOLY_DAYS[0]) {
targetMonth = { ...hijri };
}
Comment on lines +70 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip remaining holy nights when reference date is mid-Ramadan

The search for upcoming nights only initializes targetMonth once the loop encounters a day equal to HOLY_DAYS[0] (17 Ramadan). If the reference date is already after the 17th but before the 21st of the current Ramadan, targetMonth remains null until the loop reaches the next year’s Ramadan, so the function returns the following year’s 17/19/21 and never lists the still-upcoming 21 of the current cycle. Consider detecting that the reference date is already inside Ramadan and collecting the remaining nights without waiting for another 17.

Useful? React with 👍 / 👎.

}

if (targetMonth) {
const expectedDay = HOLY_DAYS[occurrences.length];
if (hijri.year === targetMonth.year && hijri.month === targetMonth.month && hijri.day === expectedDay) {
occurrences.push({
...hijri,
gregorian: new Date(cursor.getTime()),
reminder: addDays(cursor, -3),
});
Comment on lines +79 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Populate Hijri fields expected by timeline consumers

The occurrences pushed in getUpcomingHolyDays expose the Hijri day, month, and year under generic keys day, month, and year, but downstream code and tests read hijriDay, hijriMonth, and hijriYear. This leaves the timeline rendering undefined values and causes the unit tests that assert result.map(item => item.hijriDay) to fail. The objects returned here should either rename the fields or provide aliases so the expected properties exist.

Useful? React with 👍 / 👎.

}

if (
hijri.year > targetMonth.year ||
(hijri.year === targetMonth.year && hijri.month > targetMonth.month)
) {
break;
}
}

cursor = addDays(cursor, 1);
safety += 1;
}

return occurrences;
}

export function getHolyDayTimeline(referenceDate: Date = new Date()) {
return getUpcomingHolyDays(referenceDate).map((occurrence) => ({
...occurrence,
hijriLabel: formatHijri(occurrence.gregorian),
gregorianLabel: formatGregorian(occurrence.gregorian),
reminderLabel: formatGregorian(occurrence.reminder),
}));
}
16 changes: 16 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "Node",
"resolveJsonModule": true,
"types": ["node", "jest"],
"baseUrl": "."
},
"include": ["src", "__tests__"]
}