-
Notifications
You must be signed in to change notification settings - Fork 0
Add Hijri holy days timeline and reminders #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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("ليالي القدر"); | ||
| }); | ||
| }); |
| 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"], | ||
| }; |
| 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" | ||
| } | ||
| } |
| 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; |
| 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; |
| 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; |
| 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; |
| 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 }; | ||
| } | ||
| } | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The occurrences pushed in 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), | ||
| })); | ||
| } | ||
| 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__"] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The search for upcoming nights only initializes
targetMonthonce the loop encounters a day equal toHOLY_DAYS[0](17 Ramadan). If the reference date is already after the 17th but before the 21st of the current Ramadan,targetMonthremains 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 👍 / 👎.