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 +0 -0
- package/Archive.v2.zip +0 -0
- package/Archive.v3.zip +0 -0
- package/Archive.zip +0 -0
- package/package.json +7 -4
- package/src/adjustmentEngine.js +272 -0
- package/src/cli.js +180 -23
- package/src/defaults.js +15 -12
- package/src/index.d.ts +46 -0
- package/src/useHijriYear.js +40 -0
- package/tests/adjustmentEngine.test.js +290 -0
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.
|
|
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
|
}
|
package/src/adjustmentEngine.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
130
|
-
console.log(" 4.
|
|
131
|
-
console.log(" 5.
|
|
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-
|
|
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
|
-
|
|
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-
|
|
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: "
|
|
10
|
-
2: "
|
|
11
|
-
3: "
|
|
12
|
-
4: "
|
|
13
|
-
5: "
|
|
14
|
-
6: "
|
|
15
|
-
7: "
|
|
16
|
-
8: "
|
|
17
|
-
9: "
|
|
18
|
-
10: "
|
|
19
|
-
11: "
|
|
20
|
-
12: "
|
|
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;
|
package/src/useHijriYear.js
CHANGED
|
@@ -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
|
+
});
|