@gooddadmike/ifc-js 1.0.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to `ifc-js` will be documented here.
4
+
5
+ > This library's core calendar math originated in
6
+ > [pebble-ifc-complication](https://github.com/gooddadmike/pebble-ifc-complication),
7
+ > a Pebble watch widget package. It was extracted, corrected, and
8
+ > published here as a standalone universal library. For pre-extraction
9
+ > history see that repo's commit log.
10
+
11
+ ## [1.0.0] - 2026-03-22
12
+ ### Added
13
+ - `toIFC(date?)` — Gregorian ISO string or Date to IFC result object
14
+ - `toGregorian(ifcString)` — IFC: prefixed string to Gregorian ISO string
15
+ - `isLeap(year)` — leap year utility export
16
+ - CLI via `ifc` command — no args for today, accepts Gregorian or IFC: strings
17
+ - CommonJS and ES module exports
18
+ - Full Jest test suite covering edge cases, special days, and round trips
19
+
20
+ ### Fixed
21
+ - DST off-by-one: date strings parsed manually via split('-') rather than
22
+ new Date() to avoid 1-hour spring-forward causing wrong day-of-year
23
+ - Leap Day correctly at doy 169, not 181
24
+ - All months after Sol correctly offset by 1 in leap years
25
+
@@ -0,0 +1,45 @@
1
+ # Contributing
2
+
3
+ Pull requests are welcome. A few guidelines:
4
+
5
+ ## Core IFC rules — do not change lightly
6
+ The IFC math follows the standard Cotsworth/Eastman specification:
7
+ - 13 months x 28 days
8
+ - Leap Day inserted after June 28 (doy 169) in leap years only
9
+ - Year Day is the last day of the year (doy 365 or 366)
10
+ - Every month starts on Sunday, ends on Saturday
11
+ - Leap Day and Year Day have no weekday
12
+
13
+ Any PR that changes this behavior needs a very compelling reason and
14
+ must update the test suite accordingly.
15
+
16
+ ## Adding features
17
+ - New config options should be additive and not break existing behavior
18
+ - `toIFC()` must always return a complete result object, don't change the shape
19
+ - New output formats go in a `format` option, not as separate functions
20
+ - Locale and language support is welcome, add to a `locale` option
21
+
22
+ ## Code style
23
+ - Keep it simple, this is intentionally a zero-dependency library
24
+ - Pure functions only in `src/index.js`, no side effects
25
+ - Tests for every new behavior in `tests/ifc.test.js`
26
+ - Run `npm test` before submitting, all tests must pass
27
+
28
+ ## Adding tests
29
+ - Edge cases around Leap Day and Year Day are especially important
30
+ - Round trip tests (Gregorian to IFC to Gregorian) for any new date paths
31
+ - If you find a date that converts incorrectly, add a failing test first
32
+
33
+ ## What we would love to see
34
+ - Locale and language support for month and weekday names
35
+ - A `format` option for controlling output string style
36
+ - Additional CLI flags
37
+
38
+ ## What to avoid
39
+ - Changing the `IFC:` prefix format, it exists to prevent ambiguity
40
+ - Returning raw strings from `toIFC()`, the result object is intentional
41
+ - Adding dependencies to the main package
42
+
43
+ ## License
44
+
45
+ MIT
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mike searcy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # ifc-js
2
+
3
+ A lightweight JavaScript library for converting dates between the Gregorian
4
+ calendar and the International Fixed Calendar (IFC).
5
+
6
+ ## What is the International Fixed Calendar?
7
+
8
+ The Gregorian calendar has months of 28, 29, 30, and 31 days so your
9
+ birthday falls on a different weekday every year and nothing lines up
10
+ neatly. The International Fixed Calendar fixes this.
11
+
12
+ 13 months x 28 days = 364 days. Every date falls on the same weekday every
13
+ year. Your birthday is always on the same day of the week. The first of every
14
+ month is always a Sunday. Most civil holidays stay put.
15
+
16
+ Got something you do every other day? Water the plants, take a supplement,
17
+ whatever it is. With IFC you can just look at the date number and know. With
18
+ Gregorian, months can end on an odd day so you would have to skip a day just
19
+ to stay on schedule. IFC months always end on day 28. No skipping. Ever.
20
+
21
+ Months: Jan, Feb, Mar, Apr, May, Jun, **Sol**, Jul, Aug, Sep, Oct, Nov, Dec
22
+
23
+ ### Bonus Days
24
+
25
+ The remaining day (or two in a leap year) becomes something more interesting
26
+ than just another Tuesday. These intercalary days sit at the end of their
27
+ month like a 29th day but belong to no week and no weekday.
28
+
29
+ - 🎆 **Year Day** (Dec 29) — after the last day of December every year.
30
+ New Year's Eve elevated: a day outside the week, a pause between years.
31
+ - ☀️ **Leap Day** (Jun 29) — after June 28 in leap years only. In any
32
+ normal IFC month, day 28 is a Saturday. Instead of a lost Sunday, this
33
+ becomes a once every four years midsummer holiday outside the week entirely.
34
+
35
+ ---
36
+
37
+ ## Install
38
+ ```bash
39
+ npm install ifc-js
40
+ ```
41
+
42
+ For the CLI:
43
+ ```bash
44
+ npm install -g ifc-js
45
+ ```
46
+
47
+ ---
48
+
49
+ ## CLI
50
+ ```bash
51
+ # Today's date in IFC
52
+ ifc
53
+
54
+ # Gregorian to IFC
55
+ ifc 2024-06-17
56
+
57
+ # IFC to Gregorian
58
+ ifc IFC:2024-06-29
59
+ ```
60
+
61
+ Output:
62
+ ```
63
+ IFC:2026-03-25
64
+ IFC:2024-06-29
65
+ 2024-06-17
66
+ ```
67
+
68
+ ---
69
+
70
+ ## API
71
+
72
+ ### `toIFC(date?)`
73
+
74
+ Converts a Gregorian date to an IFC result object.
75
+ ```js
76
+ const { toIFC } = require('ifc-js');
77
+
78
+ toIFC('2026-03-22');
79
+ // {
80
+ // year: 2026,
81
+ // month: 3,
82
+ // day: 25,
83
+ // weekday: 4, // 0=Sun, 1=Mon ... 6=Sat
84
+ // isLeapDay: false,
85
+ // isYearDay: false
86
+ // }
87
+
88
+ toIFC('2024-06-17');
89
+ // { year: 2024, month: 6, day: 29, weekday: null, isLeapDay: true, isYearDay: false }
90
+
91
+ toIFC('2026-12-31');
92
+ // { year: 2026, month: 13, day: 29, weekday: null, isLeapDay: false, isYearDay: true }
93
+
94
+ // No argument uses today
95
+ toIFC();
96
+ ```
97
+
98
+ Months are 1-based: 1=January, 7=Sol, 13=December.
99
+ `weekday` is `null` for Leap Day and Year Day as they have no weekday.
100
+
101
+ ---
102
+
103
+ ### `toGregorian(ifcString)`
104
+
105
+ Converts an IFC date string to a Gregorian ISO date string.
106
+ ```js
107
+ const { toGregorian } = require('ifc-js');
108
+
109
+ toGregorian('IFC:2024-06-29'); // '2024-06-17' (Leap Day)
110
+ toGregorian('IFC:2026-07-01'); // '2026-06-18' (Sol 1)
111
+ toGregorian('IFC:2026-13-29'); // '2026-12-31' (Year Day)
112
+ ```
113
+
114
+ ---
115
+
116
+ ### `isLeap(year)`
117
+
118
+ Returns `true` if the given year is a leap year.
119
+ ```js
120
+ const { isLeap } = require('ifc-js');
121
+
122
+ isLeap(2024); // true
123
+ isLeap(2026); // false
124
+ isLeap(1900); // false
125
+ isLeap(2000); // true
126
+ ```
127
+
128
+ ---
129
+
130
+ ### ES Modules
131
+ ```js
132
+ import { toIFC, toGregorian, isLeap } from 'ifc-js';
133
+ ```
134
+
135
+ ---
136
+
137
+ ## IFC Date Format
138
+
139
+ IFC dates must use the `IFC:` prefix. Without it the parser assumes
140
+ Gregorian. This is not optional. The same numeric string means different
141
+ things in each calendar:
142
+ ```
143
+ 2024-07-15 -> Gregorian July 15
144
+ IFC:2024-07-15 -> IFC Sol 15 (Gregorian July 2nd)
145
+ ```
146
+
147
+ IFC month numbers are 1-based and go up to 13:
148
+
149
+ | Number | Month |
150
+ |--------|---------|
151
+ | 1 - 6 | Jan-Jun |
152
+ | 7 | Sol |
153
+ | 8 - 13 | Jul-Dec |
154
+
155
+ ---
156
+
157
+ ## Contributing
158
+
159
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
160
+
161
+ ---
162
+
163
+ ## License
164
+
165
+ MIT
package/bin/ifc.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { toIFC, toGregorian } = require('../src/index.js');
5
+
6
+ const pad = n => String(n).padStart(2, '0');
7
+ const fmt = r => `IFC:${r.year}-${pad(r.month)}-${pad(r.day)}`;
8
+
9
+ const arg = process.argv[2];
10
+
11
+ try {
12
+ if (!arg) {
13
+ console.log(fmt(toIFC()));
14
+ } else if (arg.startsWith('IFC:')) {
15
+ console.log(toGregorian(arg));
16
+ } else {
17
+ console.log(fmt(toIFC(arg)));
18
+ }
19
+ } catch (err) {
20
+ console.error(`Error: ${err.message}`);
21
+ process.exit(1);
22
+ }
23
+
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@gooddadmike/ifc-js",
3
+ "version": "1.0.0",
4
+ "description": "International Fixed Calendar conversion library — Gregorian to IFC and back",
5
+ "main": "src/index.js",
6
+ "module": "src/index.esm.js",
7
+ "exports": {
8
+ "require": "./src/index.js",
9
+ "import": "./src/index.esm.js"
10
+ },
11
+ "bin": {
12
+ "ifc": "./bin/ifc.js"
13
+ },
14
+ "scripts": {
15
+ "test": "jest"
16
+ },
17
+ "keywords": [
18
+ "ifc",
19
+ "international-fixed-calendar",
20
+ "calendar",
21
+ "date",
22
+ "cotsworth"
23
+ ],
24
+ "author": "mike@searcy.me",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/gooddadmike/ifc-js.git"
29
+ },
30
+ "bugs": {
31
+ "url": "https://github.com/gooddadmike/ifc-js/issues"
32
+ },
33
+ "homepage": "https://github.com/gooddadmike/ifc-js#readme",
34
+ "devDependencies": {
35
+ "jest": "^30.3.0"
36
+ }
37
+ }
@@ -0,0 +1,88 @@
1
+ // ─── Month names ──────────────────────────────────────────────────────────────
2
+ const IFC_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun',
3
+ 'Sol','Jul','Aug','Sep','Oct','Nov','Dec'];
4
+ const GREG_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun',
5
+ 'Jul','Aug','Sep','Oct','Nov','Dec'];
6
+ const WEEKDAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
7
+
8
+ // ─── Core utilities ───────────────────────────────────────────────────────────
9
+ function isLeap(year) {
10
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
11
+ }
12
+
13
+ function gregToDoy(year, month0, day) {
14
+ const dim = [31, isLeap(year)?29:28, 31,30,31,30,31,31,30,31,30,31];
15
+ let doy = day;
16
+ for (let i = 0; i < month0; i++) doy += dim[i];
17
+ return doy;
18
+ }
19
+
20
+ function doyToGreg(year, doy) {
21
+ const dim = [31, isLeap(year)?29:28, 31,30,31,30,31,31,30,31,30,31];
22
+ let rem = doy;
23
+ for (let m = 0; m < 12; m++) {
24
+ if (rem <= dim[m]) return { month: m, day: rem };
25
+ rem -= dim[m];
26
+ }
27
+ }
28
+
29
+ function ifcToDoy(year, mi, day) {
30
+ // mi is 1-based (1=Jan ... 7=Sol ... 13=Dec)
31
+ if (mi === 6 && day === 29 && isLeap(year)) return 169;
32
+ let doy = (mi - 1) * 28 + day;
33
+ if (mi > 6 && isLeap(year)) doy += 1;
34
+ return doy;
35
+ }
36
+
37
+ function doyToIfc(year, doy) {
38
+ const leap = isLeap(year);
39
+ const yearLen = leap ? 366 : 365;
40
+ if (leap && doy === 169) return { month: 6, day: 29, weekday: null, isLeapDay: true, isYearDay: false };
41
+ if (doy === yearLen) return { month: 13, day: 29, weekday: null, isLeapDay: false, isYearDay: true };
42
+ const adjDoy = leap && doy > 169 ? doy - 1 : doy;
43
+ const mi = Math.floor((adjDoy - 1) / 28) + 1; // 1-based
44
+ const day = (adjDoy - 1) % 28 + 1;
45
+ const weekday = (day - 1) % 7; // 0=Sun ... 6=Sat
46
+ return { month: mi, day, weekday, isLeapDay: false, isYearDay: false };
47
+ }
48
+
49
+ // ─── Public API ───────────────────────────────────────────────────────────────
50
+ function toIFC(input, options = {}) {
51
+ let year, month0, day;
52
+ if (input) {
53
+ const [y, m, d] = String(input).split('-').map(Number);
54
+ year = y;
55
+ month0 = m - 1;
56
+ day = d;
57
+ if (isNaN(year) || isNaN(month0) || isNaN(day)) throw new Error(`Invalid date: ${input}`);
58
+ } else {
59
+ const now = new Date();
60
+ year = now.getFullYear();
61
+ month0 = now.getMonth();
62
+ day = now.getDate();
63
+ }
64
+ const doy = gregToDoy(year, month0, day);
65
+ return { year, ...doyToIfc(year, doy) };
66
+ }
67
+
68
+ function toGregorian(ifcString) {
69
+ if (!ifcString.startsWith('IFC:')) {
70
+ throw new Error('IFC dates must be prefixed with "IFC:" e.g. IFC:2024-07-15');
71
+ }
72
+ const parts = ifcString.slice(4).split('-').map(Number);
73
+ const [year, month, day] = parts;
74
+ if (!year || !month || !day) throw new Error(`Invalid IFC date format: ${ifcString}`);
75
+ if (month < 1 || month > 13) throw new Error(`IFC month must be 1-13, got ${month}`);
76
+ if (day < 1 || day > 29) throw new Error(`IFC day must be 1-29, got ${day}`);
77
+ if (day === 29) {
78
+ if (month === 6 && !isLeap(year)) throw new Error(`Leap Day only exists in leap years`);
79
+ if (month !== 6 && month !== 13) throw new Error(`Day 29 only valid for June (leap years) or December`);
80
+ }
81
+ const doy = ifcToDoy(year, month, day);
82
+ const g = doyToGreg(year, doy);
83
+ const date = new Date(year, g.month, g.day);
84
+ return date.toISOString().split('T')[0];
85
+ }
86
+
87
+ export { toIFC, toGregorian, isLeap };
88
+
package/src/index.js ADDED
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ // ─── Month names ──────────────────────────────────────────────────────────────
4
+ const IFC_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun',
5
+ 'Sol','Jul','Aug','Sep','Oct','Nov','Dec'];
6
+ const GREG_MONTHS = ['Jan','Feb','Mar','Apr','May','Jun',
7
+ 'Jul','Aug','Sep','Oct','Nov','Dec'];
8
+ const WEEKDAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
9
+
10
+ // ─── Core utilities ───────────────────────────────────────────────────────────
11
+ function isLeap(year) {
12
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
13
+ }
14
+
15
+ function gregToDoy(year, month0, day) {
16
+ const dim = [31, isLeap(year)?29:28, 31,30,31,30,31,31,30,31,30,31];
17
+ let doy = day;
18
+ for (let i = 0; i < month0; i++) doy += dim[i];
19
+ return doy;
20
+ }
21
+
22
+ function doyToGreg(year, doy) {
23
+ const dim = [31, isLeap(year)?29:28, 31,30,31,30,31,31,30,31,30,31];
24
+ let rem = doy;
25
+ for (let m = 0; m < 12; m++) {
26
+ if (rem <= dim[m]) return { month: m, day: rem };
27
+ rem -= dim[m];
28
+ }
29
+ }
30
+
31
+ function ifcToDoy(year, mi, day) {
32
+ // mi is 1-based (1=Jan ... 7=Sol ... 13=Dec)
33
+ if (mi === 6 && day === 29 && isLeap(year)) return 169;
34
+ let doy = (mi - 1) * 28 + day;
35
+ if (mi > 6 && isLeap(year)) doy += 1;
36
+ return doy;
37
+ }
38
+
39
+ function doyToIfc(year, doy) {
40
+ const leap = isLeap(year);
41
+ const yearLen = leap ? 366 : 365;
42
+ if (leap && doy === 169) return { month: 6, day: 29, weekday: null, isLeapDay: true, isYearDay: false };
43
+ if (doy === yearLen) return { month: 13, day: 29, weekday: null, isLeapDay: false, isYearDay: true };
44
+ const adjDoy = leap && doy > 169 ? doy - 1 : doy;
45
+ const mi = Math.floor((adjDoy - 1) / 28) + 1; // 1-based
46
+ const day = (adjDoy - 1) % 28 + 1;
47
+ const weekday = (day - 1) % 7; // 0=Sun ... 6=Sat
48
+ return { month: mi, day, weekday, isLeapDay: false, isYearDay: false };
49
+ }
50
+
51
+ // ─── Public API ───────────────────────────────────────────────────────────────
52
+ function toIFC(input, options = {}) {
53
+ let year, month0, day;
54
+ if (input) {
55
+ const [y, m, d] = String(input).split('-').map(Number);
56
+ year = y;
57
+ month0 = m - 1;
58
+ day = d;
59
+ if (isNaN(year) || isNaN(month0) || isNaN(day)) throw new Error(`Invalid date: ${input}`);
60
+ } else {
61
+ const now = new Date();
62
+ year = now.getFullYear();
63
+ month0 = now.getMonth();
64
+ day = now.getDate();
65
+ }
66
+ const doy = gregToDoy(year, month0, day);
67
+ return { year, ...doyToIfc(year, doy) };
68
+ }
69
+
70
+ function toGregorian(ifcString) {
71
+ if (!ifcString.startsWith('IFC:')) {
72
+ throw new Error('IFC dates must be prefixed with "IFC:" e.g. IFC:2024-07-15');
73
+ }
74
+ const parts = ifcString.slice(4).split('-').map(Number);
75
+ const [year, month, day] = parts;
76
+ if (!year || !month || !day) throw new Error(`Invalid IFC date format: ${ifcString}`);
77
+ if (month < 1 || month > 13) throw new Error(`IFC month must be 1-13, got ${month}`);
78
+ if (day < 1 || day > 29) throw new Error(`IFC day must be 1-29, got ${day}`);
79
+ if (day === 29) {
80
+ if (month === 6 && !isLeap(year)) throw new Error(`Leap Day only exists in leap years`);
81
+ if (month !== 6 && month !== 13) throw new Error(`Day 29 only valid for June (leap years) or December`);
82
+ }
83
+ const doy = ifcToDoy(year, month, day);
84
+ const g = doyToGreg(year, doy);
85
+ const date = new Date(year, g.month, g.day);
86
+ return date.toISOString().split('T')[0];
87
+ }
88
+
89
+ module.exports = { toIFC, toGregorian, isLeap };
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+ const { toIFC, toGregorian, isLeap } = require('../src/index.js');
3
+
4
+ // ─── isLeap ───────────────────────────────────────────────────────────────────
5
+ describe('isLeap', () => {
6
+ test('2024 is a leap year', () => expect(isLeap(2024)).toBe(true));
7
+ test('2023 is not a leap year', () => expect(isLeap(2023)).toBe(false));
8
+ test('1900 is not a leap year (div by 100)', () => expect(isLeap(1900)).toBe(false));
9
+ test('2000 is a leap year (div by 400)', () => expect(isLeap(2000)).toBe(true));
10
+ });
11
+
12
+ // ─── toIFC ────────────────────────────────────────────────────────────────────
13
+ describe('toIFC', () => {
14
+ test('normal date — Mar 22 2026', () => {
15
+ expect(toIFC('2026-03-22')).toMatchObject({
16
+ year: 2026, month: 3, day: 25, isLeapDay: false, isYearDay: false
17
+ });
18
+ });
19
+
20
+ test('first day of Sol — 2026', () => {
21
+ expect(toIFC('2026-06-18')).toMatchObject({
22
+ year: 2026, month: 7, day: 1, isLeapDay: false, isYearDay: false
23
+ });
24
+ });
25
+
26
+ test('Leap Day — 2024', () => {
27
+ expect(toIFC('2024-06-17')).toMatchObject({
28
+ year: 2024, month: 6, day: 29, isLeapDay: true, isYearDay: false, weekday: null
29
+ });
30
+ });
31
+
32
+ test('day before Leap Day is Jun 28 — 2024', () => {
33
+ expect(toIFC('2024-06-16')).toMatchObject({
34
+ year: 2024, month: 6, day: 28, isLeapDay: false, isYearDay: false, weekday: 6
35
+ });
36
+ });
37
+
38
+ test('day after Leap Day is Sol 1 — 2024', () => {
39
+ expect(toIFC('2024-06-18')).toMatchObject({
40
+ year: 2024, month: 7, day: 1, isLeapDay: false, isYearDay: false
41
+ });
42
+ });
43
+
44
+ test('Year Day non-leap — 2026', () => {
45
+ expect(toIFC('2026-12-31')).toMatchObject({
46
+ year: 2026, month: 13, day: 29, isLeapDay: false, isYearDay: true, weekday: null
47
+ });
48
+ });
49
+
50
+ test('Year Day leap — 2024', () => {
51
+ expect(toIFC('2024-12-31')).toMatchObject({
52
+ year: 2024, month: 13, day: 29, isLeapDay: false, isYearDay: true, weekday: null
53
+ });
54
+ });
55
+
56
+ test('Dec 28 non-leap — last normal day before Year Day', () => {
57
+ expect(toIFC('2026-12-30')).toMatchObject({
58
+ year: 2026, month: 13, day: 28, isLeapDay: false, isYearDay: false, weekday: 6
59
+ });
60
+ });
61
+
62
+ test('Jan 1 — first day of IFC year', () => {
63
+ expect(toIFC('2026-01-01')).toMatchObject({
64
+ year: 2026, month: 1, day: 1, isLeapDay: false, isYearDay: false, weekday: 0
65
+ });
66
+ });
67
+
68
+ test('Gregorian Feb 29 (leap) maps to IFC Mar 4', () => {
69
+ expect(toIFC('2024-02-29')).toMatchObject({
70
+ year: 2024, month: 3, day: 4, isLeapDay: false, isYearDay: false
71
+ });
72
+ });
73
+
74
+ test('Sol 1 non-leap is not affected by leap offset', () => {
75
+ expect(toIFC('2026-06-18')).toMatchObject({
76
+ year: 2026, month: 7, day: 1
77
+ });
78
+ });
79
+
80
+ test('throws on invalid date', () => {
81
+ expect(() => toIFC('not-a-date')).toThrow('Invalid date: not-a-date');
82
+ });
83
+
84
+ test('weekday is always 0 (Sun) for day 1 of any month', () => {
85
+ const result = toIFC('2026-01-01');
86
+ expect(result.weekday).toBe(0);
87
+ });
88
+
89
+ test('weekday is always 6 (Sat) for day 28 of any month', () => {
90
+ const result = toIFC('2026-01-28');
91
+ expect(result.weekday).toBe(6);
92
+ });
93
+
94
+ test('weekday is consistent across months — Sol 1 is always Sunday', () => {
95
+ expect(toIFC('2026-06-18').weekday).toBe(0);
96
+ expect(toIFC('2024-06-18').weekday).toBe(0);
97
+ });
98
+ });
99
+
100
+ // ─── toGregorian ──────────────────────────────────────────────────────────────
101
+ describe('toGregorian', () => {
102
+ test('normal date — IFC Mar 22', () => {
103
+ expect(toGregorian('IFC:2026-03-22')).toBe('2026-03-19');
104
+ });
105
+
106
+ test('Sol 1 2026', () => {
107
+ expect(toGregorian('IFC:2026-07-01')).toBe('2026-06-18');
108
+ });
109
+
110
+ test('Leap Day 2024', () => {
111
+ expect(toGregorian('IFC:2024-06-29')).toBe('2024-06-17');
112
+ });
113
+
114
+ test('Year Day 2026', () => {
115
+ expect(toGregorian('IFC:2026-13-29')).toBe('2026-12-31');
116
+ });
117
+
118
+ test('Jan 1 — first day of IFC year', () => {
119
+ expect(toGregorian('IFC:2026-01-01')).toBe('2026-01-01');
120
+ });
121
+
122
+ test('Dec 28 — last normal IFC day before Year Day', () => {
123
+ expect(toGregorian('IFC:2026-13-28')).toBe('2026-12-30');
124
+ });
125
+
126
+ test('throws without IFC: prefix', () => {
127
+ expect(() => toGregorian('2024-06-17')).toThrow('IFC dates must be prefixed with "IFC:" e.g. IFC:2024-07-15');
128
+ });
129
+
130
+ test('throws on invalid month', () => {
131
+ expect(() => toGregorian('IFC:2024-14-01')).toThrow('IFC month must be 1-13, got 14');
132
+ });
133
+
134
+ test('throws on day 30', () => {
135
+ expect(() => toGregorian('IFC:2026-03-30')).toThrow('IFC day must be 1-29, got 30');
136
+ });
137
+
138
+ test('throws on day 29 in non-leap year June', () => {
139
+ expect(() => toGregorian('IFC:2026-06-29')).toThrow('Leap Day only exists in leap years');
140
+ });
141
+
142
+ test('throws on day 29 for non-special month', () => {
143
+ expect(() => toGregorian('IFC:2024-03-29')).toThrow('Day 29 only valid for June (leap years) or December');
144
+ });
145
+
146
+ test('round trip — Gregorian → IFC → Gregorian', () => {
147
+ const start = '2024-08-15';
148
+ const ifc = toIFC(start);
149
+ const back = toGregorian(`IFC:${ifc.year}-${ifc.month}-${ifc.day}`);
150
+ expect(back).toBe(start);
151
+ });
152
+
153
+ test('round trip — post leap day 2024', () => {
154
+ const start = '2024-09-01';
155
+ const ifc = toIFC(start);
156
+ const back = toGregorian(`IFC:${ifc.year}-${ifc.month}-${ifc.day}`);
157
+ expect(back).toBe(start);
158
+ });
159
+
160
+ test('round trip — Jan 1', () => {
161
+ const start = '2026-01-01';
162
+ const ifc = toIFC(start);
163
+ const back = toGregorian(`IFC:${ifc.year}-${ifc.month}-${ifc.day}`);
164
+ expect(back).toBe(start);
165
+ });
166
+ });