islamic-date-adjustment 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Archive.v1.zip ADDED
Binary file
package/Archive.v2.zip ADDED
Binary file
package/Archive.v3.zip ADDED
Binary file
package/Archive.zip ADDED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "islamic-date-adjustment",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Islamic (Hijri) calendar date adjustment library with Firebase persistence and React Native hook. Handles moon-sighting offsets, per-event adjustments, and month boundary edge cases.",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -50,9 +50,9 @@
50
50
  "author": "",
51
51
  "license": "ISC",
52
52
  "peerDependencies": {
53
+ "@react-native-firebase/firestore": ">=14.0.0",
53
54
  "react": ">=16.8.0",
54
- "react-native": ">=0.60.0",
55
- "@react-native-firebase/firestore": ">=14.0.0"
55
+ "react-native": ">=0.60.0"
56
56
  },
57
57
  "peerDependenciesMeta": {
58
58
  "react": {
@@ -70,6 +70,9 @@
70
70
  "jest": "^30.2.0"
71
71
  },
72
72
  "dependencies": {
73
+ "moment": "^2.30.1",
74
+ "moment-hijri": "^3.0.0",
73
75
  "readline-sync": "^1.4.10"
74
- }
76
+ },
77
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
75
78
  }
@@ -258,6 +258,84 @@ export class HijriYear {
258
258
  return this;
259
259
  }
260
260
 
261
+ /**
262
+ * Get a list of selectable global-offset options the user can pick from.
263
+ *
264
+ * Each option shows:
265
+ * - the offset value
266
+ * - a human-readable label (e.g. "+1 day", "-2 days", "(current)")
267
+ * - the Muharram 1 (or first month) Gregorian date that would result
268
+ * - a preview of how every month's start date shifts
269
+ * - whether the option is valid (won't conflict with existing event adjustments)
270
+ *
271
+ * Use this to render a picker / radio-group so the user can visually
272
+ * compare options and then call `setGlobalOffset(chosen)`.
273
+ *
274
+ * @param {number} [range=3] – generate options from -range to +range
275
+ * @returns {GlobalOffsetOption[]}
276
+ */
277
+ getValidGlobalOffsets(range = 3) {
278
+ if (!Number.isInteger(range) || range < 0)
279
+ throw new Error("range must be a non-negative integer");
280
+
281
+ const currentGlobalOffset = this.globalOffset;
282
+ const currentAdjustments = [...this.adjustments];
283
+
284
+ const options = [];
285
+ for (let d = -range; d <= range; d++) {
286
+ const offset = d === 0 ? 0 : d; // avoid -0
287
+ const firstMonth = this._months[0];
288
+ const muharramDate = firstMonth.standardStart
289
+ ? addDays(firstMonth.standardStart, offset)
290
+ : null;
291
+
292
+ // Build a preview of all 12 month starts at this offset
293
+ const monthPreviews = this._months.map((m) => ({
294
+ index: m.index,
295
+ name: m.name,
296
+ adjustedStart: m.standardStart
297
+ ? formatDate(addDays(m.standardStart, offset))
298
+ : null,
299
+ }));
300
+
301
+ // Validate: try applying this offset and see if it causes errors
302
+ let valid = true;
303
+ let invalidReason = null;
304
+
305
+ if (offset !== currentGlobalOffset) {
306
+ try {
307
+ // Temporarily apply the offset
308
+ this.globalOffset = offset;
309
+ this._recalculate();
310
+ } catch (err) {
311
+ valid = false;
312
+ invalidReason = err.message;
313
+ } finally {
314
+ // Restore original state
315
+ this.globalOffset = currentGlobalOffset;
316
+ this.adjustments = currentAdjustments;
317
+ this._recalculate();
318
+ }
319
+ }
320
+
321
+ options.push({
322
+ offset,
323
+ label:
324
+ offset === 0
325
+ ? "(current)"
326
+ : offset > 0
327
+ ? `(+${offset} day${offset > 1 ? "s" : ""})`
328
+ : `(${offset} day${offset < -1 ? "s" : ""})`,
329
+ isCurrent: offset === currentGlobalOffset,
330
+ muharramStart: muharramDate ? formatDate(muharramDate) : null,
331
+ monthPreviews,
332
+ valid,
333
+ invalidReason,
334
+ });
335
+ }
336
+ return options;
337
+ }
338
+
261
339
  /**
262
340
  * Apply an event-based adjustment.
263
341
  *
@@ -568,6 +646,200 @@ export class HijriYear {
568
646
  return impact;
569
647
  }
570
648
 
649
+ // ══════════════════════════════════════════════
650
+ // MONTH-START ADJUSTMENTS
651
+ // ══════════════════════════════════════════════
652
+
653
+ /**
654
+ * Adjust a month's start date to a specific Gregorian date.
655
+ *
656
+ * This works for ANY month (1-12), unlike `adjustEvent` which requires
657
+ * a special event. Internally it creates a synthetic day-1 adjustment
658
+ * so the existing recalculation logic handles everything correctly.
659
+ *
660
+ * @param {number} monthIndex – Hijri month index (1-12)
661
+ * @param {string} gregorianDate – "YYYY-MM-DD" target start date
662
+ * @returns {{ adjustment, message }}
663
+ */
664
+ adjustMonthStart(monthIndex, gregorianDate) {
665
+ const month = this._months.find((m) => m.index === monthIndex);
666
+ if (!month) throw new Error(`Unknown month index: ${monthIndex}`);
667
+ if (!month.adjustedStart)
668
+ throw new Error(`Month ${monthIndex} has no computed start date`);
669
+
670
+ const targetDate = parseDate(gregorianDate);
671
+ const currentStart = month.adjustedStart;
672
+ const offsetDelta = diffDays(targetDate, currentStart);
673
+
674
+ if (offsetDelta === 0) {
675
+ return {
676
+ adjustment: null,
677
+ message: `${month.name} already starts on ${formatDate(currentStart)}. No adjustment needed.`,
678
+ };
679
+ }
680
+
681
+ // Synthetic event id so it integrates with the adjustment pipeline
682
+ const syntheticEventId = `__month_${monthIndex}_start`;
683
+
684
+ const adj = {
685
+ eventId: syntheticEventId,
686
+ eventHijriMonth: monthIndex,
687
+ eventHijriDay: 1,
688
+ targetDate,
689
+ offsetDelta,
690
+ };
691
+
692
+ // Save state for rollback on validation failure
693
+ const prevAdjustments = [...this.adjustments];
694
+
695
+ // Remove any previous adjustment for this month start
696
+ this.adjustments = this.adjustments.filter(
697
+ (a) => a.eventId !== syntheticEventId,
698
+ );
699
+ this.adjustments.push(adj);
700
+
701
+ try {
702
+ this._recalculate();
703
+ } catch (err) {
704
+ this.adjustments = prevAdjustments;
705
+ this._recalculate();
706
+ throw err;
707
+ }
708
+
709
+ const direction = offsetDelta > 0 ? "later" : "earlier";
710
+ const absDelta = Math.abs(offsetDelta);
711
+ const lines = [
712
+ `✅ ${month.name} start adjusted: ${formatDate(currentStart)} → ${gregorianDate} (${absDelta} day${absDelta !== 1 ? "s" : ""} ${direction})`,
713
+ ];
714
+
715
+ const monthIdx = this._months.indexOf(month);
716
+ if (monthIdx > 0) {
717
+ const prev = this._months[monthIdx - 1];
718
+ lines.push(
719
+ ` 📅 ${prev.name} is now ${prev.adjustedDays} days to accommodate the shift.`,
720
+ );
721
+ }
722
+ lines.push(` 📆 All months after ${month.name} are shifted accordingly.`);
723
+
724
+ return { adjustment: adj, message: lines.join("\n") };
725
+ }
726
+
727
+ /**
728
+ * Get valid date options for a month's start date.
729
+ * Returns only dates that keep the preceding month within 29-30 days.
730
+ *
731
+ * @param {number} monthIndex – Hijri month index (1-12)
732
+ * @param {number} [range=3] – check ± this many days from current start
733
+ * @returns {ValidOffset[]}
734
+ */
735
+ getValidMonthStartDates(monthIndex, range = 3) {
736
+ const month = this._months.find((m) => m.index === monthIndex);
737
+ if (!month || !month.adjustedStart) return [];
738
+
739
+ const monthIdx = this._months.indexOf(month);
740
+ const prevMonth = monthIdx > 0 ? this._months[monthIdx - 1] : null;
741
+
742
+ const offsets = [];
743
+ for (let d = -range; d <= range; d++) {
744
+ const date = addDays(month.adjustedStart, d);
745
+ let valid = true;
746
+
747
+ if (prevMonth && d !== 0) {
748
+ const newDays = prevMonth.adjustedDays + d;
749
+ if (newDays < MIN_MONTH_DAYS || newDays > MAX_MONTH_DAYS) {
750
+ valid = false;
751
+ }
752
+ }
753
+
754
+ offsets.push({
755
+ offset: d,
756
+ date: formatDate(date),
757
+ valid,
758
+ label:
759
+ d === 0
760
+ ? "(current)"
761
+ : d > 0
762
+ ? `(+${d} day${d > 1 ? "s" : ""})`
763
+ : `(${d} day${d < -1 ? "s" : ""})`,
764
+ });
765
+ }
766
+ return offsets;
767
+ }
768
+
769
+ /**
770
+ * Preview the impact of adjusting a month's start date WITHOUT changing state.
771
+ *
772
+ * @param {number} monthIndex – Hijri month index (1-12)
773
+ * @param {string} gregorianDate – "YYYY-MM-DD" proposed start date
774
+ * @returns {MonthStartImpact}
775
+ */
776
+ simulateMonthStartAdjustment(monthIndex, gregorianDate) {
777
+ const month = this._months.find((m) => m.index === monthIndex);
778
+ if (!month) throw new Error(`Unknown month index: ${monthIndex}`);
779
+ if (!month.adjustedStart)
780
+ throw new Error(`Month ${monthIndex} has no computed start date`);
781
+
782
+ const targetDate = parseDate(gregorianDate);
783
+ const currentStart = month.adjustedStart;
784
+ const offsetDelta = diffDays(targetDate, currentStart);
785
+
786
+ const impact = {
787
+ month: month.name,
788
+ monthIndex,
789
+ currentStart: formatDate(currentStart),
790
+ proposedStart: gregorianDate,
791
+ offsetDelta,
792
+ affectedMonths: [],
793
+ preservedMonths: [],
794
+ monthLengthChanges: [],
795
+ };
796
+
797
+ if (offsetDelta === 0) {
798
+ impact.noChange = true;
799
+ impact.valid = true;
800
+ return impact;
801
+ }
802
+
803
+ const monthIdx = this._months.indexOf(month);
804
+
805
+ // Months before the adjusted month are preserved
806
+ for (let i = 0; i < monthIdx; i++) {
807
+ impact.preservedMonths.push(this._months[i].name);
808
+ }
809
+
810
+ // Previous month absorbs the length change
811
+ if (monthIdx > 0) {
812
+ const prevMonth = this._months[monthIdx - 1];
813
+ const newDays = prevMonth.adjustedDays + offsetDelta;
814
+ // Remove prevMonth from preserved list (it gets modified)
815
+ impact.preservedMonths = impact.preservedMonths.filter(
816
+ (n) => n !== prevMonth.name,
817
+ );
818
+
819
+ const isValid = newDays >= MIN_MONTH_DAYS && newDays <= MAX_MONTH_DAYS;
820
+ impact.monthLengthChanges.push({
821
+ month: prevMonth.name,
822
+ originalDays: prevMonth.adjustedDays,
823
+ newDays,
824
+ valid: isValid,
825
+ reason: isValid
826
+ ? `${month.name} start moved by ${offsetDelta} day(s)`
827
+ : `${month.name} start moved by ${offsetDelta} day(s) — INVALID: ${newDays} days is outside ${MIN_MONTH_DAYS}–${MAX_MONTH_DAYS} range`,
828
+ });
829
+ impact.valid = isValid;
830
+ } else {
831
+ // Month 1 has no previous month — always valid
832
+ impact.valid = true;
833
+ }
834
+
835
+ // Months after are shifted
836
+ for (let i = monthIdx + 1; i < this._months.length; i++) {
837
+ impact.affectedMonths.push(this._months[i].name);
838
+ }
839
+
840
+ return impact;
841
+ }
842
+
571
843
  // ──── internal helpers ────
572
844
 
573
845
  _buildAdjustmentSummary(event, oldDate, newDate, delta) {
package/src/cli.js CHANGED
@@ -98,23 +98,7 @@ function main() {
98
98
  const hijriYear = new HijriYear(hijriYearNum, monthStarts);
99
99
 
100
100
  // ── Step 3: Global offset ──
101
- console.log("\n📌 Global Offset");
102
- console.log(
103
- "If your local moon sighting differs from the standard calendar,",
104
- );
105
- console.log(
106
- "enter an offset (e.g. -2 means everything starts 2 days earlier).\n",
107
- );
108
-
109
- const offsetInput = readlineSync.question("Global offset in days [0]: ");
110
- const globalOffset = offsetInput ? parseInt(offsetInput, 10) : 0;
111
-
112
- if (globalOffset !== 0) {
113
- hijriYear.setGlobalOffset(globalOffset);
114
- console.log(
115
- `\n✅ Global offset set to ${globalOffset}. All dates shifted.`,
116
- );
117
- }
101
+ promptGlobalOffset(hijriYear);
118
102
 
119
103
  // Show current calendar
120
104
  printCalendar(hijriYear);
@@ -126,11 +110,13 @@ function main() {
126
110
  console.log("\nWhat would you like to do?");
127
111
  console.log(" 1. Check upcoming events");
128
112
  console.log(" 2. Adjust a specific event");
129
- console.log(" 3. View full calendar");
130
- console.log(" 4. Check Hijri date for a Gregorian date");
131
- console.log(" 5. Exit\n");
113
+ console.log(" 3. Change global offset");
114
+ console.log(" 4. Adjust a month start date");
115
+ console.log(" 5. View full calendar");
116
+ console.log(" 6. Check Hijri date for a Gregorian date");
117
+ console.log(" 7. Exit\n");
132
118
 
133
- const choice = readlineSync.question("Choice [1-5]: ");
119
+ const choice = readlineSync.question("Choice [1-7]: ");
134
120
 
135
121
  switch (choice) {
136
122
  case "1":
@@ -140,16 +126,23 @@ function main() {
140
126
  handleEventAdjustment(hijriYear);
141
127
  break;
142
128
  case "3":
129
+ promptGlobalOffset(hijriYear);
143
130
  printCalendar(hijriYear);
144
131
  break;
145
132
  case "4":
146
- handleDateLookup(hijriYear);
133
+ handleMonthStartAdjustment(hijriYear);
147
134
  break;
148
135
  case "5":
136
+ printCalendar(hijriYear);
137
+ break;
138
+ case "6":
139
+ handleDateLookup(hijriYear);
140
+ break;
141
+ case "7":
149
142
  continueAdjusting = false;
150
143
  break;
151
144
  default:
152
- console.log("Invalid choice. Please enter 1-5.");
145
+ console.log("Invalid choice. Please enter 1-7.");
153
146
  }
154
147
  }
155
148
 
@@ -180,6 +173,170 @@ function promptMonthStarts() {
180
173
  return starts;
181
174
  }
182
175
 
176
+ function promptGlobalOffset(hijriYear) {
177
+ console.log("\n📌 Global Offset");
178
+ console.log(
179
+ "If your local moon sighting differs from the standard calendar,",
180
+ );
181
+ console.log(
182
+ "pick an offset below. Each option shows how Muharram 1 shifts.\n",
183
+ );
184
+
185
+ const allOptions = hijriYear.getValidGlobalOffsets(3);
186
+ const options = allOptions.filter((opt) => opt.valid);
187
+
188
+ // Show warning if some options were filtered out
189
+ const invalidCount = allOptions.length - options.length;
190
+ if (invalidCount > 0) {
191
+ console.log(
192
+ `ℹ️ Note: ${invalidCount} offset option(s) hidden due to conflicts with existing event adjustments.\n`,
193
+ );
194
+ }
195
+
196
+ if (options.length === 0) {
197
+ console.log(
198
+ "⚠️ No valid global offset options available due to existing event adjustments.",
199
+ );
200
+ console.log(
201
+ " Consider removing event adjustments first if you need to change the global offset.\n",
202
+ );
203
+ return;
204
+ }
205
+
206
+ console.log(" # Offset Muharram 1 starts Ramadan starts");
207
+ printDivider();
208
+ options.forEach((opt, i) => {
209
+ const muharram = opt.muharramStart || "?";
210
+ const ramadan =
211
+ opt.monthPreviews.find((m) => m.index === 9)?.adjustedStart || "?";
212
+ const marker = opt.isCurrent ? " ◄ current" : "";
213
+ const mDate = opt.muharramStart ? parseDate(opt.muharramStart) : null;
214
+ const dayName = mDate ? ` (${weekday(mDate)})` : "";
215
+ console.log(
216
+ ` ${String(i + 1).padStart(2)}. ${String(opt.offset >= 0 ? "+" + opt.offset : opt.offset).padStart(3)} days ${muharram}${dayName.padEnd(8)} ${ramadan} ${opt.label}${marker}`,
217
+ );
218
+ });
219
+ printDivider();
220
+
221
+ const offsetChoice = readlineSync.question(
222
+ "\nSelect option number, or press Enter to keep current: ",
223
+ );
224
+
225
+ if (offsetChoice.trim() === "") {
226
+ console.log(`Global offset unchanged (${hijriYear.globalOffset}).`);
227
+ return;
228
+ }
229
+
230
+ const idx = parseInt(offsetChoice, 10) - 1;
231
+ if (idx >= 0 && idx < options.length) {
232
+ const chosen = options[idx].offset;
233
+ if (chosen !== hijriYear.globalOffset) {
234
+ hijriYear.setGlobalOffset(chosen);
235
+ console.log(
236
+ `\n✅ Global offset set to ${chosen >= 0 ? "+" : ""}${chosen}. All dates shifted.`,
237
+ );
238
+ } else {
239
+ console.log("No change — that's already the current offset.");
240
+ }
241
+ } else {
242
+ console.log("Invalid selection. Offset unchanged.");
243
+ }
244
+ }
245
+
246
+ function handleMonthStartAdjustment(hijriYear) {
247
+ console.log("\nAdjust a month's start date:");
248
+ const cal = hijriYear.getCalendar();
249
+ cal.forEach((m) => {
250
+ console.log(
251
+ ` ${String(m.index).padStart(2)}. ${m.name.padEnd(20)} starts: ${m.adjustedStart || "?"}`,
252
+ );
253
+ });
254
+
255
+ const monthChoice = readlineSync.question("\nSelect month number (1-12): ");
256
+ const monthIdx = parseInt(monthChoice, 10);
257
+ if (monthIdx < 1 || monthIdx > 12) {
258
+ console.log("Invalid selection.");
259
+ return;
260
+ }
261
+
262
+ const month = cal.find((m) => m.index === monthIdx);
263
+ console.log(`\n📅 ${month.name} currently starts on: ${month.adjustedStart}`);
264
+
265
+ // Show valid date options
266
+ const allDates = hijriYear.getValidMonthStartDates(monthIdx, 3);
267
+ const validDates = allDates.filter((o) => o.valid);
268
+ console.log("\nValid start date options:");
269
+ validDates.forEach((o, i) => {
270
+ const d = parseDate(o.date);
271
+ const marker = o.offset === 0 ? " ◄ current" : "";
272
+ console.log(
273
+ ` ${i + 1}. ${o.date} (${weekday(d)}) ${o.label}${marker}`,
274
+ );
275
+ });
276
+
277
+ const dateChoice = readlineSync.question(
278
+ "\nSelect option number, or enter a date (YYYY-MM-DD): ",
279
+ );
280
+
281
+ let targetDate;
282
+ const choiceNum = parseInt(dateChoice, 10);
283
+ if (!isNaN(choiceNum) && choiceNum >= 1 && choiceNum <= validDates.length) {
284
+ targetDate = validDates[choiceNum - 1].date;
285
+ } else {
286
+ targetDate = dateChoice.trim();
287
+ }
288
+
289
+ // Simulate first
290
+ try {
291
+ const impact = hijriYear.simulateMonthStartAdjustment(monthIdx, targetDate);
292
+ console.log("\n📊 Impact Analysis:");
293
+ printDivider();
294
+
295
+ if (impact.noChange) {
296
+ console.log("No change needed — the month already starts on this date.");
297
+ return;
298
+ }
299
+
300
+ console.log(` Month: ${impact.month}`);
301
+ console.log(` Current start: ${impact.currentStart}`);
302
+ console.log(` Proposed start: ${impact.proposedStart}`);
303
+ console.log(
304
+ ` Offset: ${impact.offsetDelta > 0 ? "+" : ""}${impact.offsetDelta} day(s)`,
305
+ );
306
+
307
+ if (impact.preservedMonths.length > 0) {
308
+ console.log(
309
+ ` ✅ Preserved (unchanged): ${impact.preservedMonths.join(", ")}`,
310
+ );
311
+ }
312
+ if (impact.monthLengthChanges.length > 0) {
313
+ for (const ch of impact.monthLengthChanges) {
314
+ console.log(
315
+ ` 📏 ${ch.month}: ${ch.originalDays} → ${ch.newDays} days (${ch.reason})`,
316
+ );
317
+ }
318
+ }
319
+ if (impact.affectedMonths.length > 0) {
320
+ console.log(
321
+ ` 🔄 Shifted forward: ${impact.affectedMonths.join(", ")}`,
322
+ );
323
+ }
324
+
325
+ printDivider();
326
+
327
+ const confirm = readlineSync.keyInYNStrict("\nApply this adjustment? ");
328
+ if (confirm) {
329
+ const result = hijriYear.adjustMonthStart(monthIdx, targetDate);
330
+ console.log("\n" + result.message);
331
+ printCalendar(hijriYear);
332
+ } else {
333
+ console.log("Adjustment cancelled.");
334
+ }
335
+ } catch (err) {
336
+ console.log(`\n❌ Error: ${err.message}`);
337
+ }
338
+ }
339
+
183
340
  function handleUpcomingEvents(hijriYear) {
184
341
  const refInput = readlineSync.question(
185
342
  "\nReference date (YYYY-MM-DD) [today]: ",
package/src/defaults.js CHANGED
@@ -1,3 +1,4 @@
1
+ import momentHijri from "moment-hijri";
1
2
  /**
2
3
  * defaults.js
3
4
  *
@@ -5,17 +6,19 @@
5
6
  * These can be used directly or overridden by the user.
6
7
  */
7
8
 
9
+ const startOfHijriYear = momentHijri().locale("en").startOf("iYear");
10
+
8
11
  export const DEFAULT_1447_STARTS = {
9
- 1: "2025-06-26", // Muharram
10
- 2: "2025-07-26", // Safar
11
- 3: "2025-08-24", // Rabi al-Awwal
12
- 4: "2025-09-23", // Rabi al-Thani
13
- 5: "2025-10-22", // Jumada al-Ula
14
- 6: "2025-11-21", // Jumada al-Thani
15
- 7: "2025-12-20", // Rajab
16
- 8: "2026-01-19", // Shaban
17
- 9: "2026-02-17", // Ramadan
18
- 10: "2026-03-19", // Shawwal (Eid al-Fitr = 1 Shawwal)
19
- 11: "2026-04-17", // Dhul Qadah
20
- 12: "2026-05-17", // Dhul Hijjah
12
+ 1: startOfHijriYear.format("YYYY-MM-DD"),
13
+ 2: startOfHijriYear.clone().add(1, "iMonth").format("YYYY-MM-DD"),
14
+ 3: startOfHijriYear.clone().add(2, "iMonth").format("YYYY-MM-DD"),
15
+ 4: startOfHijriYear.clone().add(3, "iMonth").format("YYYY-MM-DD"),
16
+ 5: startOfHijriYear.clone().add(4, "iMonth").format("YYYY-MM-DD"),
17
+ 6: startOfHijriYear.clone().add(5, "iMonth").format("YYYY-MM-DD"),
18
+ 7: startOfHijriYear.clone().add(6, "iMonth").format("YYYY-MM-DD"),
19
+ 8: startOfHijriYear.clone().add(7, "iMonth").format("YYYY-MM-DD"),
20
+ 9: startOfHijriYear.clone().add(8, "iMonth").format("YYYY-MM-DD"),
21
+ 10: startOfHijriYear.clone().add(9, "iMonth").format("YYYY-MM-DD"),
22
+ 11: startOfHijriYear.clone().add(10, "iMonth").format("YYYY-MM-DD"),
23
+ 12: startOfHijriYear.clone().add(11, "iMonth").format("YYYY-MM-DD"),
21
24
  };
package/src/index.d.ts CHANGED
@@ -100,6 +100,33 @@ export interface SimulationImpact {
100
100
  noChange?: boolean;
101
101
  }
102
102
 
103
+ export interface MonthStartImpact {
104
+ month: string;
105
+ monthIndex: number;
106
+ currentStart: string;
107
+ proposedStart: string;
108
+ offsetDelta: number;
109
+ affectedMonths: string[];
110
+ preservedMonths: string[];
111
+ monthLengthChanges: MonthLengthChange[];
112
+ valid?: boolean;
113
+ noChange?: boolean;
114
+ }
115
+
116
+ export interface GlobalOffsetMonthPreview {
117
+ index: number;
118
+ name: string;
119
+ adjustedStart: string | null;
120
+ }
121
+
122
+ export interface GlobalOffsetOption {
123
+ offset: number;
124
+ label: string;
125
+ isCurrent: boolean;
126
+ muharramStart: string | null;
127
+ monthPreviews: GlobalOffsetMonthPreview[];
128
+ }
129
+
103
130
  export declare class HijriYear {
104
131
  year: number;
105
132
  globalOffset: number;
@@ -108,6 +135,7 @@ export declare class HijriYear {
108
135
  constructor(year: number, monthStarts: Record<number, string>);
109
136
 
110
137
  setGlobalOffset(offset: number): this;
138
+ getValidGlobalOffsets(range?: number): GlobalOffsetOption[];
111
139
  adjustEvent(eventId: string, actualGregorianDate: string): AdjustEventResult;
112
140
  getValidOffsets(eventId: string, range?: number): ValidOffset[];
113
141
  getEventDate(eventId: string): Date | null;
@@ -125,6 +153,15 @@ export declare class HijriYear {
125
153
  eventId: string,
126
154
  actualGregorianDate: string,
127
155
  ): SimulationImpact;
156
+ adjustMonthStart(
157
+ monthIndex: number,
158
+ gregorianDate: string,
159
+ ): AdjustEventResult;
160
+ getValidMonthStartDates(monthIndex: number, range?: number): ValidOffset[];
161
+ simulateMonthStartAdjustment(
162
+ monthIndex: number,
163
+ gregorianDate: string,
164
+ ): MonthStartImpact;
128
165
  }
129
166
 
130
167
  // ──────────────────────────────────────────────
@@ -197,8 +234,17 @@ export interface UseHijriYearReturn {
197
234
  adjustEvent(eventId: string, date: string): AdjustEventResult;
198
235
  simulateAdjustment(eventId: string, date: string): SimulationImpact;
199
236
  getValidOffsets(eventId: string, range?: number): ValidOffset[];
237
+ getValidGlobalOffsets(range?: number): GlobalOffsetOption[];
200
238
  reset(): void;
201
239
 
240
+ // Month-start adjustments
241
+ adjustMonthStart(monthIndex: number, date: string): AdjustEventResult;
242
+ getValidMonthStartDates(monthIndex: number, range?: number): ValidOffset[];
243
+ simulateMonthStartAdjustment(
244
+ monthIndex: number,
245
+ date: string,
246
+ ): MonthStartImpact;
247
+
202
248
  // Queries
203
249
  getEventDate(eventId: string): string | null;
204
250
  getMonthStart(monthIndex: number): string | null;
@@ -159,11 +159,45 @@ export function useHijriYear({
159
159
  [instance],
160
160
  );
161
161
 
162
+ const getValidGlobalOffsets = useCallback(
163
+ (range = 3) => {
164
+ return instance.getValidGlobalOffsets(range);
165
+ },
166
+ [instance],
167
+ );
168
+
162
169
  const reset = useCallback(() => {
163
170
  const hy = buildInitial();
164
171
  updateInstance(hy);
165
172
  }, [buildInitial, updateInstance]);
166
173
 
174
+ // ── Month-start adjustments ──
175
+
176
+ const adjustMonthStart = useCallback(
177
+ (monthIndex, date) => {
178
+ const result = instance.adjustMonthStart(monthIndex, date);
179
+ updateInstance(instance);
180
+ return result;
181
+ },
182
+ [instance, updateInstance],
183
+ );
184
+
185
+ const getValidMonthStartDates = useCallback(
186
+ (monthIndex, range = 3) => {
187
+ return instance
188
+ .getValidMonthStartDates(monthIndex, range)
189
+ .filter((o) => o.valid);
190
+ },
191
+ [instance],
192
+ );
193
+
194
+ const simulateMonthStartAdjustment = useCallback(
195
+ (monthIndex, date) => {
196
+ return instance.simulateMonthStartAdjustment(monthIndex, date);
197
+ },
198
+ [instance],
199
+ );
200
+
167
201
  // ══════════════════════════════════════════════
168
202
  // Derived / computed data
169
203
  // ══════════════════════════════════════════════
@@ -233,8 +267,14 @@ export function useHijriYear({
233
267
  adjustEvent,
234
268
  simulateAdjustment,
235
269
  getValidOffsets,
270
+ getValidGlobalOffsets,
236
271
  reset,
237
272
 
273
+ // Month-start adjustments
274
+ adjustMonthStart,
275
+ getValidMonthStartDates,
276
+ simulateMonthStartAdjustment,
277
+
238
278
  // Queries
239
279
  getEventDate,
240
280
  getMonthStart,
@@ -160,6 +160,92 @@ describe("Global offset", () => {
160
160
  });
161
161
  });
162
162
 
163
+ // ═══════════════════════════════════════════════
164
+ // 2b. getValidGlobalOffsets – selectable offset options
165
+ // ═══════════════════════════════════════════════
166
+
167
+ describe("getValidGlobalOffsets", () => {
168
+ test("returns 2*range+1 options by default (range=3 → 7 options)", () => {
169
+ const hy = freshYear();
170
+ const options = hy.getValidGlobalOffsets();
171
+ expect(options).toHaveLength(7); // -3, -2, -1, 0, +1, +2, +3
172
+ });
173
+
174
+ test("respects custom range", () => {
175
+ const hy = freshYear();
176
+ expect(hy.getValidGlobalOffsets(1)).toHaveLength(3); // -1, 0, +1
177
+ expect(hy.getValidGlobalOffsets(5)).toHaveLength(11);
178
+ });
179
+
180
+ test("offset=0 is marked isCurrent when globalOffset is 0", () => {
181
+ const hy = freshYear();
182
+ const options = hy.getValidGlobalOffsets();
183
+ const current = options.find((o) => o.isCurrent);
184
+ expect(current).toBeDefined();
185
+ expect(current.offset).toBe(0);
186
+ });
187
+
188
+ test("after setGlobalOffset(-2), offset=-2 is marked isCurrent", () => {
189
+ const hy = freshYear();
190
+ hy.setGlobalOffset(-2);
191
+ const options = hy.getValidGlobalOffsets();
192
+ const current = options.find((o) => o.isCurrent);
193
+ expect(current).toBeDefined();
194
+ expect(current.offset).toBe(-2);
195
+ });
196
+
197
+ test("each option shows the correct Muharram start date", () => {
198
+ const hy = freshYear();
199
+ const options = hy.getValidGlobalOffsets(2);
200
+ // Standard Muharram = 2025-06-26
201
+ expect(options.find((o) => o.offset === 0).muharramStart).toBe("2025-06-26");
202
+ expect(options.find((o) => o.offset === -1).muharramStart).toBe("2025-06-25");
203
+ expect(options.find((o) => o.offset === 1).muharramStart).toBe("2025-06-27");
204
+ expect(options.find((o) => o.offset === -2).muharramStart).toBe("2025-06-24");
205
+ expect(options.find((o) => o.offset === 2).muharramStart).toBe("2025-06-28");
206
+ });
207
+
208
+ test("each option includes monthPreviews for all 12 months", () => {
209
+ const hy = freshYear();
210
+ const options = hy.getValidGlobalOffsets(1);
211
+ for (const opt of options) {
212
+ expect(opt.monthPreviews).toHaveLength(12);
213
+ expect(opt.monthPreviews[0].name).toBe("Muharram");
214
+ expect(opt.monthPreviews[8].name).toBe("Ramadan");
215
+ }
216
+ });
217
+
218
+ test("monthPreviews reflect the offset correctly", () => {
219
+ const hy = freshYear();
220
+ const options = hy.getValidGlobalOffsets(1);
221
+ // Standard Ramadan = 2026-02-17. At offset -1 → 2026-02-16
222
+ const minusOne = options.find((o) => o.offset === -1);
223
+ expect(minusOne.monthPreviews[8].adjustedStart).toBe("2026-02-16");
224
+ });
225
+
226
+ test("labels are human-readable", () => {
227
+ const hy = freshYear();
228
+ const options = hy.getValidGlobalOffsets(2);
229
+ expect(options.find((o) => o.offset === 0).label).toBe("(current)");
230
+ expect(options.find((o) => o.offset === 1).label).toBe("(+1 day)");
231
+ expect(options.find((o) => o.offset === 2).label).toBe("(+2 days)");
232
+ expect(options.find((o) => o.offset === -1).label).toBe("(-1 day)");
233
+ expect(options.find((o) => o.offset === -2).label).toBe("(-2 days)");
234
+ });
235
+
236
+ test("range=0 returns only one option", () => {
237
+ const hy = freshYear();
238
+ const options = hy.getValidGlobalOffsets(0);
239
+ expect(options).toHaveLength(1);
240
+ expect(options[0].offset).toBe(0);
241
+ });
242
+
243
+ test("throws on negative range", () => {
244
+ const hy = freshYear();
245
+ expect(() => hy.getValidGlobalOffsets(-1)).toThrow("non-negative");
246
+ });
247
+ });
248
+
163
249
  // ═══════════════════════════════════════════════
164
250
  // 3. THE CRITICAL SCENARIO: USER'S EXACT EXAMPLE
165
251
  // Ramadan starts at Feb 18 standard, user offset -2 → starts fasting Feb 16.
@@ -980,3 +1066,207 @@ describe("Month-length validation (29–30 days)", () => {
980
1066
  );
981
1067
  });
982
1068
  });
1069
+
1070
+ // ══════════════════════════════════════════════
1071
+ // adjustMonthStart / getValidMonthStartDates / simulateMonthStartAdjustment
1072
+ // ══════════════════════════════════════════════
1073
+ describe("adjustMonthStart", () => {
1074
+ // Note: Shaban (month 8) has 29 standard days.
1075
+ // Moving Ramadan later → Shaban 29→30 ✓
1076
+ // Moving Ramadan earlier → Shaban 29→28 ✗
1077
+
1078
+ test("moves Ramadan start 1 day later (Shaban 29→30)", () => {
1079
+ const hy = freshYear();
1080
+ const originalStart = formatDate(hy.getMonthStart(9));
1081
+ const target = formatDate(addDays(parseDate(originalStart), 1));
1082
+
1083
+ const result = hy.adjustMonthStart(9, target);
1084
+ expect(result.adjustment).not.toBeNull();
1085
+ expect(formatDate(hy.getMonthStart(9))).toBe(target);
1086
+ });
1087
+
1088
+ test("moves Safar start 1 day earlier (Muharram 30→29)", () => {
1089
+ const hy = freshYear();
1090
+ const originalStart = formatDate(hy.getMonthStart(2));
1091
+ const target = formatDate(addDays(parseDate(originalStart), -1));
1092
+
1093
+ const result = hy.adjustMonthStart(2, target);
1094
+ expect(result.adjustment).not.toBeNull();
1095
+ expect(formatDate(hy.getMonthStart(2))).toBe(target);
1096
+ });
1097
+
1098
+ test("previous month absorbs the delta (Shaban grows by 1)", () => {
1099
+ const hy = freshYear();
1100
+ const shabanDaysBefore = hy.getMonthDays(8); // Shaban = 29
1101
+ const originalStart = formatDate(hy.getMonthStart(9));
1102
+ const target = formatDate(addDays(parseDate(originalStart), 1));
1103
+
1104
+ hy.adjustMonthStart(9, target);
1105
+ expect(hy.getMonthDays(8)).toBe(shabanDaysBefore + 1); // 29 → 30
1106
+ });
1107
+
1108
+ test("no change when target equals current start", () => {
1109
+ const hy = freshYear();
1110
+ const currentStart = formatDate(hy.getMonthStart(9));
1111
+
1112
+ const result = hy.adjustMonthStart(9, currentStart);
1113
+ expect(result.adjustment).toBeNull();
1114
+ expect(result.message).toMatch(/No adjustment needed/);
1115
+ });
1116
+
1117
+ test("throws for invalid month index", () => {
1118
+ const hy = freshYear();
1119
+ expect(() => hy.adjustMonthStart(99, "2026-01-01")).toThrow(
1120
+ /Unknown month/,
1121
+ );
1122
+ });
1123
+
1124
+ test("rolls back on validation failure (would exceed 30 days)", () => {
1125
+ const hy = freshYear();
1126
+ const originalStart = formatDate(hy.getMonthStart(9));
1127
+ const shabanDaysBefore = hy.getMonthDays(8);
1128
+
1129
+ // Try to push Ramadan 2 days later → Shaban would be 31 days
1130
+ const target = formatDate(addDays(parseDate(originalStart), 2));
1131
+ expect(() => hy.adjustMonthStart(9, target)).toThrow(/29–30/);
1132
+
1133
+ // State should be rolled back
1134
+ expect(formatDate(hy.getMonthStart(9))).toBe(originalStart);
1135
+ expect(hy.getMonthDays(8)).toBe(shabanDaysBefore);
1136
+ });
1137
+
1138
+ test("months after the adjusted month rechain forward", () => {
1139
+ const hy = freshYear();
1140
+ const originalShawwalStart = formatDate(hy.getMonthStart(10));
1141
+ const ramadanStart = formatDate(hy.getMonthStart(9));
1142
+ const target = formatDate(addDays(parseDate(ramadanStart), 1));
1143
+
1144
+ hy.adjustMonthStart(9, target);
1145
+
1146
+ // Shawwal should also shift 1 day later
1147
+ const newShawwalStart = formatDate(hy.getMonthStart(10));
1148
+ expect(newShawwalStart).toBe(
1149
+ formatDate(addDays(parseDate(originalShawwalStart), 1)),
1150
+ );
1151
+ });
1152
+
1153
+ test("works for month 1 (Muharram — no previous month)", () => {
1154
+ const hy = freshYear();
1155
+ const originalStart = formatDate(hy.getMonthStart(1));
1156
+ const target = formatDate(addDays(parseDate(originalStart), -1));
1157
+
1158
+ const result = hy.adjustMonthStart(1, target);
1159
+ expect(result.adjustment).not.toBeNull();
1160
+ expect(formatDate(hy.getMonthStart(1))).toBe(target);
1161
+ });
1162
+
1163
+ test("works with global offset already applied", () => {
1164
+ const hy = freshYear();
1165
+ hy.setGlobalOffset(-1);
1166
+ // After global offset -1, Shaban is still 29 days, so only +1 is valid
1167
+ const ramadanStart = formatDate(hy.getMonthStart(9));
1168
+ const target = formatDate(addDays(parseDate(ramadanStart), 1));
1169
+
1170
+ hy.adjustMonthStart(9, target);
1171
+ expect(formatDate(hy.getMonthStart(9))).toBe(target);
1172
+ });
1173
+
1174
+ test("works for months without special events (e.g. Safar, Muharram 30→29)", () => {
1175
+ const hy = freshYear();
1176
+ const originalStart = formatDate(hy.getMonthStart(2)); // Safar
1177
+ // Muharram has 30 days, so Safar can move 1 day earlier (Muharram→29)
1178
+ const target = formatDate(addDays(parseDate(originalStart), -1));
1179
+
1180
+ const result = hy.adjustMonthStart(2, target);
1181
+ expect(result.adjustment).not.toBeNull();
1182
+ expect(formatDate(hy.getMonthStart(2))).toBe(target);
1183
+ });
1184
+ });
1185
+
1186
+ describe("getValidMonthStartDates", () => {
1187
+ test("returns array of valid date options for a month", () => {
1188
+ const hy = freshYear();
1189
+ const offsets = hy.getValidMonthStartDates(9);
1190
+ expect(offsets.length).toBe(7); // -3 to +3
1191
+ expect(offsets[3].offset).toBe(0);
1192
+ expect(offsets[3].label).toBe("(current)");
1193
+ });
1194
+
1195
+ test("marks invalid dates where previous month would exceed 29-30", () => {
1196
+ const hy = freshYear();
1197
+ const offsets = hy.getValidMonthStartDates(9);
1198
+ const invalidOnes = offsets.filter((o) => !o.valid);
1199
+ expect(invalidOnes.length).toBeGreaterThan(0);
1200
+ });
1201
+
1202
+ test("all offsets have date, offset, valid, label properties", () => {
1203
+ const hy = freshYear();
1204
+ const offsets = hy.getValidMonthStartDates(5);
1205
+ for (const o of offsets) {
1206
+ expect(o).toHaveProperty("offset");
1207
+ expect(o).toHaveProperty("date");
1208
+ expect(o).toHaveProperty("valid");
1209
+ expect(o).toHaveProperty("label");
1210
+ }
1211
+ });
1212
+
1213
+ test("returns empty for unknown month", () => {
1214
+ const hy = freshYear();
1215
+ expect(hy.getValidMonthStartDates(99)).toEqual([]);
1216
+ });
1217
+
1218
+ test("custom range", () => {
1219
+ const hy = freshYear();
1220
+ const offsets = hy.getValidMonthStartDates(9, 1);
1221
+ expect(offsets.length).toBe(3); // -1, 0, +1
1222
+ });
1223
+ });
1224
+
1225
+ describe("simulateMonthStartAdjustment", () => {
1226
+ test("returns impact without changing state", () => {
1227
+ const hy = freshYear();
1228
+ const originalStart = formatDate(hy.getMonthStart(9));
1229
+ const target = formatDate(addDays(parseDate(originalStart), 1));
1230
+
1231
+ const impact = hy.simulateMonthStartAdjustment(9, target);
1232
+ expect(impact.month).toBe("Ramadan");
1233
+ expect(impact.offsetDelta).toBe(1);
1234
+ expect(impact.valid).toBe(true);
1235
+
1236
+ // State should NOT have changed
1237
+ expect(formatDate(hy.getMonthStart(9))).toBe(originalStart);
1238
+ });
1239
+
1240
+ test("noChange when target equals current start", () => {
1241
+ const hy = freshYear();
1242
+ const current = formatDate(hy.getMonthStart(9));
1243
+ const impact = hy.simulateMonthStartAdjustment(9, current);
1244
+ expect(impact.noChange).toBe(true);
1245
+ expect(impact.valid).toBe(true);
1246
+ });
1247
+
1248
+ test("marks invalid when previous month would exceed bounds", () => {
1249
+ const hy = freshYear();
1250
+ const target = formatDate(addDays(hy.getMonthStart(9), 2));
1251
+ const impact = hy.simulateMonthStartAdjustment(9, target);
1252
+ expect(impact.valid).toBe(false);
1253
+ expect(impact.monthLengthChanges[0].valid).toBe(false);
1254
+ });
1255
+
1256
+ test("lists preserved and affected months", () => {
1257
+ const hy = freshYear();
1258
+ const target = formatDate(addDays(hy.getMonthStart(9), 1));
1259
+ const impact = hy.simulateMonthStartAdjustment(9, target);
1260
+
1261
+ expect(impact.preservedMonths.length).toBeGreaterThan(0);
1262
+ expect(impact.affectedMonths).toContain("Shawwal");
1263
+ expect(impact.affectedMonths).toContain("Dhul Hijjah");
1264
+ });
1265
+
1266
+ test("throws for unknown month index", () => {
1267
+ const hy = freshYear();
1268
+ expect(() => hy.simulateMonthStartAdjustment(99, "2026-01-01")).toThrow(
1269
+ /Unknown month/,
1270
+ );
1271
+ });
1272
+ });