@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 +25 -0
- package/CONTRIBUTING.md +45 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/bin/ifc.js +23 -0
- package/package.json +37 -0
- package/src/index.esm.js +88 -0
- package/src/index.js +89 -0
- package/tests/ifc.test.js +166 -0
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
|
+
|
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
+
}
|
package/src/index.esm.js
ADDED
|
@@ -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
|
+
});
|