@nextera.one/tps-standard 0.6.0 → 0.8.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.
@@ -0,0 +1,346 @@
1
+ import { TPSComponents } from "../types";
2
+
3
+ export const TPS_DAY_MS = 24 * 60 * 60 * 1000;
4
+ export const TPS_DAY_START_OFFSET_MS = 7 * 60 * 60 * 1000;
5
+ export const TPS_DAYS_PER_WEEK = 7;
6
+ export const TPS_WEEKS_PER_MONTH = 4;
7
+ export const TPS_MONTHS_PER_YEAR = 12;
8
+ export const TPS_DAYS_PER_MONTH =
9
+ TPS_DAYS_PER_WEEK * TPS_WEEKS_PER_MONTH;
10
+ export const TPS_DAYS_PER_YEAR = TPS_DAYS_PER_MONTH * TPS_MONTHS_PER_YEAR;
11
+ export const TPS_EPOCH_START_MS = Date.UTC(1999, 7, 11, 7, 0, 0, 0);
12
+
13
+ export type TpsIndexedParts = {
14
+ dayIndex: number;
15
+ dayFraction: number;
16
+ subDayMilliseconds: number;
17
+ fractionPrecision?: number;
18
+ };
19
+
20
+ function floorDiv(value: number, divisor: number): number {
21
+ return Math.floor(value / divisor);
22
+ }
23
+
24
+ function mod(value: number, divisor: number): number {
25
+ return ((value % divisor) + divisor) % divisor;
26
+ }
27
+
28
+ function getFractionalMilliseconds(second: number | undefined): number {
29
+ if (second === undefined) return 0;
30
+ const fractional = second - Math.floor(second);
31
+ return Math.round(fractional * 1000);
32
+ }
33
+
34
+ function normalizeIndexedParts(
35
+ dayIndex: number,
36
+ dayFraction: number,
37
+ ): TpsIndexedParts {
38
+ const roundedSubDayMilliseconds = Math.round(dayFraction * TPS_DAY_MS);
39
+ const dayCarry = floorDiv(roundedSubDayMilliseconds, TPS_DAY_MS);
40
+ const subDayMilliseconds = mod(roundedSubDayMilliseconds, TPS_DAY_MS);
41
+ const normalizedDayIndex = dayIndex + dayCarry;
42
+
43
+ return {
44
+ dayIndex: normalizedDayIndex,
45
+ dayFraction: subDayMilliseconds / TPS_DAY_MS,
46
+ subDayMilliseconds,
47
+ };
48
+ }
49
+
50
+ export function splitTpsFullYear(fullYear: number): {
51
+ millennium: number;
52
+ century: number;
53
+ year: number;
54
+ } {
55
+ const millennium = floorDiv(fullYear, 1000) + 1;
56
+ const withinMillennium = mod(fullYear, 1000);
57
+ const century = floorDiv(withinMillennium, 100) + 1;
58
+ const year = mod(fullYear, 100);
59
+
60
+ return { millennium, century, year };
61
+ }
62
+
63
+ export function getTpsFullYear(components: Partial<TPSComponents>): number {
64
+ if (
65
+ components.millennium !== undefined ||
66
+ components.century !== undefined
67
+ ) {
68
+ return (
69
+ ((components.millennium ?? 1) - 1) * 1000 +
70
+ ((components.century ?? 1) - 1) * 100 +
71
+ (components.year ?? 0)
72
+ );
73
+ }
74
+
75
+ return components.year ?? 0;
76
+ }
77
+
78
+ export function getTpsDayOfMonth(
79
+ components: Partial<TPSComponents>,
80
+ ): number {
81
+ const day = components.day ?? 1;
82
+ const week = components.week;
83
+
84
+ if (week !== undefined && day >= 1 && day <= TPS_DAYS_PER_WEEK) {
85
+ return (week - 1) * TPS_DAYS_PER_WEEK + day;
86
+ }
87
+
88
+ return day;
89
+ }
90
+
91
+ export function getTpsSubDayMilliseconds(
92
+ input: Date | Partial<TPSComponents>,
93
+ ): number {
94
+ if (input instanceof Date) {
95
+ const indexed = getTpsIndexedFromDate(input);
96
+ return indexed.subDayMilliseconds;
97
+ }
98
+
99
+ if (input.subDayMilliseconds !== undefined) {
100
+ return input.subDayMilliseconds;
101
+ }
102
+
103
+ const hour = input.hour ?? 0;
104
+ const minute = input.minute ?? 0;
105
+ const second = Math.floor(input.second ?? 0);
106
+ const millisecond =
107
+ input.millisecond ?? getFractionalMilliseconds(input.second);
108
+
109
+ return (
110
+ hour * 60 * 60 * 1000 +
111
+ minute * 60 * 1000 +
112
+ second * 1000 +
113
+ millisecond
114
+ );
115
+ }
116
+
117
+ export function getTpsIndexedFromDate(date: Date): TpsIndexedParts {
118
+ const deltaMs = date.getTime() - TPS_EPOCH_START_MS;
119
+ const dayIndex = floorDiv(deltaMs, TPS_DAY_MS);
120
+ const subDayMilliseconds = mod(deltaMs, TPS_DAY_MS);
121
+
122
+ return {
123
+ dayIndex,
124
+ dayFraction: subDayMilliseconds / TPS_DAY_MS,
125
+ subDayMilliseconds,
126
+ };
127
+ }
128
+
129
+ export function buildTpsComponentsFromDayIndex(
130
+ dayIndex: number,
131
+ dayFraction: number = 0,
132
+ ): Partial<TPSComponents> {
133
+ const indexed = normalizeIndexedParts(dayIndex, dayFraction);
134
+ const fullYear = floorDiv(indexed.dayIndex, TPS_DAYS_PER_YEAR);
135
+ const dayOfYear = mod(indexed.dayIndex, TPS_DAYS_PER_YEAR);
136
+ const month = floorDiv(dayOfYear, TPS_DAYS_PER_MONTH) + 1;
137
+ const dayOfMonth = mod(dayOfYear, TPS_DAYS_PER_MONTH) + 1;
138
+ const week = floorDiv(dayOfMonth - 1, TPS_DAYS_PER_WEEK) + 1;
139
+
140
+ let remainder = indexed.subDayMilliseconds;
141
+ const hour = floorDiv(remainder, 60 * 60 * 1000);
142
+ remainder = mod(remainder, 60 * 60 * 1000);
143
+ const minute = floorDiv(remainder, 60 * 1000);
144
+ remainder = mod(remainder, 60 * 1000);
145
+ const second = floorDiv(remainder, 1000);
146
+ const millisecond = mod(remainder, 1000);
147
+
148
+ return {
149
+ calendar: "tps",
150
+ ...splitTpsFullYear(fullYear),
151
+ month,
152
+ week,
153
+ day: dayOfMonth,
154
+ hour,
155
+ minute,
156
+ second,
157
+ millisecond,
158
+ dayIndex: indexed.dayIndex,
159
+ dayFraction: indexed.dayFraction,
160
+ subDayMilliseconds: indexed.subDayMilliseconds,
161
+ };
162
+ }
163
+
164
+ export function normalizeTpsComponents(
165
+ components: Partial<TPSComponents>,
166
+ ): Partial<TPSComponents> {
167
+ if (components.dayIndex !== undefined) {
168
+ const dayFraction =
169
+ components.dayFraction ??
170
+ (getTpsSubDayMilliseconds(components) / TPS_DAY_MS);
171
+ const normalized = buildTpsComponentsFromDayIndex(
172
+ components.dayIndex,
173
+ dayFraction,
174
+ );
175
+
176
+ return {
177
+ ...components,
178
+ ...normalized,
179
+ calendar: "tps",
180
+ fractionPrecision: components.fractionPrecision,
181
+ };
182
+ }
183
+
184
+ const fullYear = getTpsFullYear(components);
185
+ const month = components.month ?? 1;
186
+ const dayOfMonth = getTpsDayOfMonth(components);
187
+ const subDayMilliseconds = getTpsSubDayMilliseconds(components);
188
+ const week = components.week ?? floorDiv(dayOfMonth - 1, TPS_DAYS_PER_WEEK) + 1;
189
+ const timeParts = buildTpsComponentsFromDayIndex(
190
+ fullYear * TPS_DAYS_PER_YEAR +
191
+ (month - 1) * TPS_DAYS_PER_MONTH +
192
+ (dayOfMonth - 1),
193
+ subDayMilliseconds / TPS_DAY_MS,
194
+ );
195
+
196
+ return {
197
+ ...components,
198
+ ...splitTpsFullYear(fullYear),
199
+ ...timeParts,
200
+ calendar: "tps",
201
+ month,
202
+ week,
203
+ day: dayOfMonth,
204
+ dayIndex:
205
+ fullYear * TPS_DAYS_PER_YEAR +
206
+ (month - 1) * TPS_DAYS_PER_MONTH +
207
+ (dayOfMonth - 1),
208
+ dayFraction: subDayMilliseconds / TPS_DAY_MS,
209
+ subDayMilliseconds,
210
+ };
211
+ }
212
+
213
+ export function getTpsDayIndex(
214
+ input: Date | Partial<TPSComponents>,
215
+ ): number {
216
+ if (input instanceof Date) {
217
+ return getTpsIndexedFromDate(input).dayIndex;
218
+ }
219
+
220
+ if (input.dayIndex !== undefined) {
221
+ return input.dayIndex;
222
+ }
223
+
224
+ const fullYear = getTpsFullYear(input);
225
+ const month = input.month ?? 1;
226
+ const dayOfMonth = getTpsDayOfMonth(input);
227
+
228
+ return (
229
+ fullYear * TPS_DAYS_PER_YEAR +
230
+ (month - 1) * TPS_DAYS_PER_MONTH +
231
+ (dayOfMonth - 1)
232
+ );
233
+ }
234
+
235
+ export function getTpsDayFraction(
236
+ input: Date | Partial<TPSComponents>,
237
+ ): number {
238
+ if (input instanceof Date) {
239
+ return getTpsIndexedFromDate(input).dayFraction;
240
+ }
241
+
242
+ if (input.dayFraction !== undefined) {
243
+ return input.dayFraction;
244
+ }
245
+
246
+ return getTpsSubDayMilliseconds(input) / TPS_DAY_MS;
247
+ }
248
+
249
+ export function parseTpsIndexedToken(token: string): TpsIndexedParts | null {
250
+ const match = token.trim().match(/^i(\d+)(?:\.(\d+))?$/i);
251
+ if (!match) return null;
252
+
253
+ const dayIndex = Number(match[1]);
254
+ if (!Number.isSafeInteger(dayIndex) || dayIndex < 0) {
255
+ return null;
256
+ }
257
+
258
+ const digits = match[2];
259
+ if (digits && digits.endsWith("0")) {
260
+ return null;
261
+ }
262
+
263
+ const dayFraction = digits ? Number(`0.${digits}`) : 0;
264
+ if (!Number.isFinite(dayFraction) || dayFraction < 0 || dayFraction >= 1) {
265
+ return null;
266
+ }
267
+
268
+ const normalized = normalizeIndexedParts(dayIndex, dayFraction);
269
+ return {
270
+ ...normalized,
271
+ fractionPrecision: digits?.length,
272
+ };
273
+ }
274
+
275
+ export function formatTpsIndexedToken(
276
+ components: Partial<TPSComponents>,
277
+ precision?: number,
278
+ ): string {
279
+ const normalized = normalizeTpsComponents(components);
280
+ const dayIndex = normalized.dayIndex ?? 0;
281
+ const dayFraction = normalized.dayFraction ?? 0;
282
+
283
+ const effectivePrecision =
284
+ precision ?? normalized.fractionPrecision ?? 9;
285
+ let fraction = "";
286
+
287
+ if (dayFraction > 0 && effectivePrecision > 0) {
288
+ fraction = dayFraction
289
+ .toFixed(effectivePrecision)
290
+ .slice(2)
291
+ .replace(/0+$/g, "");
292
+ }
293
+
294
+ return fraction ? `i${dayIndex}.${fraction}` : `i${dayIndex}`;
295
+ }
296
+
297
+ export function isTpsIndexedToken(token: string): boolean {
298
+ return /^i\d+(?:\.\d+)?$/i.test(token.trim());
299
+ }
300
+
301
+ export function validateTpsComponents(
302
+ components: Partial<TPSComponents>,
303
+ ): boolean {
304
+ const month = components.month ?? 1;
305
+ const day = components.day ?? 1;
306
+ const week = components.week;
307
+ const hour = components.hour ?? 0;
308
+ const minute = components.minute ?? 0;
309
+ const second = components.second ?? 0;
310
+ const millisecond =
311
+ components.millisecond ?? getFractionalMilliseconds(components.second);
312
+
313
+ if (
314
+ components.dayIndex !== undefined &&
315
+ (!Number.isSafeInteger(components.dayIndex) || components.dayIndex < 0)
316
+ ) {
317
+ return false;
318
+ }
319
+
320
+ if (
321
+ components.dayFraction !== undefined &&
322
+ (!Number.isFinite(components.dayFraction) ||
323
+ components.dayFraction < 0 ||
324
+ components.dayFraction >= 1)
325
+ ) {
326
+ return false;
327
+ }
328
+
329
+ if (month < 1 || month > TPS_MONTHS_PER_YEAR) return false;
330
+ if (day < 1 || day > TPS_DAYS_PER_MONTH) return false;
331
+ if (week !== undefined && (week < 1 || week > TPS_WEEKS_PER_MONTH)) {
332
+ return false;
333
+ }
334
+
335
+ if (week !== undefined && day > TPS_DAYS_PER_WEEK) {
336
+ const expectedWeek = floorDiv(day - 1, TPS_DAYS_PER_WEEK) + 1;
337
+ if (expectedWeek !== week) return false;
338
+ }
339
+
340
+ if (hour < 0 || hour > 23) return false;
341
+ if (minute < 0 || minute > 59) return false;
342
+ if (second < 0 || second >= 60) return false;
343
+ if (millisecond < 0 || millisecond >= 1000) return false;
344
+
345
+ return true;
346
+ }
@@ -1,9 +1,23 @@
1
- import { TPSComponents, TimeOrder, DefaultCalendars } from "../types";
1
+ import {
2
+ TPSComponents,
3
+ TimeOrder,
4
+ DefaultCalendars,
5
+ TPSTimeOptions,
6
+ } from "../types";
7
+ import {
8
+ formatTpsIndexedToken,
9
+ isTpsIndexedToken,
10
+ normalizeTpsComponents,
11
+ parseTpsIndexedToken,
12
+ } from "./tps-native";
2
13
 
3
14
  /**
4
15
  * Generate the canonical `T:` time string for a set of components.
5
16
  */
6
- export function buildTimePart(comp: TPSComponents): string {
17
+ export function buildTimePart(
18
+ comp: Partial<TPSComponents>,
19
+ options?: TPSTimeOptions,
20
+ ): string {
7
21
  const calendar = (comp.calendar || "").toLowerCase();
8
22
  if (!/^[a-z]{3,4}$/.test(calendar)) {
9
23
  throw new Error(
@@ -19,19 +33,35 @@ export function buildTimePart(comp: TPSComponents): string {
19
33
  return time;
20
34
  }
21
35
 
36
+ if (calendar === DefaultCalendars.TPS && options?.timeMode === "indexed-fraction") {
37
+ time += `.${formatTpsIndexedToken(comp, options.indexedPrecision)}`;
38
+
39
+ if (comp.signature) {
40
+ time += `!${comp.signature}`;
41
+ }
42
+
43
+ return time;
44
+ }
45
+
46
+ const source =
47
+ calendar === DefaultCalendars.TPS ? normalizeTpsComponents(comp) : comp;
48
+
22
49
  const tokens: Array<[string, number | undefined, number]> = [
23
- ["m", comp.millennium, 8],
24
- ["c", comp.century, 7],
25
- ["y", comp.year, 6],
26
- ["m", comp.month, 5],
27
- ["d", comp.day, 4],
28
- ["h", comp.hour, 3],
29
- ["m", comp.minute, 2],
30
- ["s", comp.second, 1],
31
- ["m", comp.millisecond, 0],
50
+ ["m", source.millennium, 8],
51
+ ["c", source.century, 7],
52
+ ["y", source.year, 6],
53
+ ["m", source.month, 5],
54
+ ...(calendar === DefaultCalendars.TPS && source.week !== undefined
55
+ ? [["w", source.week, 4.5] as [string, number | undefined, number]]
56
+ : []),
57
+ ["d", source.day, 4],
58
+ ["h", source.hour, 3],
59
+ ["m", source.minute, 2],
60
+ ["s", source.second, 1],
61
+ ["m", source.millisecond, 0],
32
62
  ];
33
63
 
34
- const order: TimeOrder = comp.order || TimeOrder.DESC;
64
+ const order: TimeOrder = options?.order || source.order || TimeOrder.DESC;
35
65
  const activeTokens = order === TimeOrder.ASC ? [...tokens].reverse() : tokens;
36
66
 
37
67
  for (const [pref, val] of activeTokens) {
@@ -40,8 +70,8 @@ export function buildTimePart(comp: TPSComponents): string {
40
70
  }
41
71
  }
42
72
 
43
- if (comp.signature) {
44
- time += `!${comp.signature}`;
73
+ if (source.signature) {
74
+ time += `!${source.signature}`;
45
75
  }
46
76
 
47
77
  return time;
@@ -56,14 +86,35 @@ export function parseTimeString(
56
86
  let s = input.trim();
57
87
  s = s.split(/[!;?#]/)[0];
58
88
  if (s.startsWith("T:")) s = s.slice(2);
89
+
90
+ const firstDot = s.indexOf(".");
91
+ const calendar = firstDot === -1 ? s : s.slice(0, firstDot);
92
+ const rawTokenString = firstDot === -1 ? "" : s.slice(firstDot + 1);
93
+
94
+ if (calendar === DefaultCalendars.TPS && isTpsIndexedToken(rawTokenString)) {
95
+ const indexed = parseTpsIndexedToken(rawTokenString);
96
+ if (!indexed) return null;
97
+ return {
98
+ components: normalizeTpsComponents({
99
+ calendar,
100
+ ...indexed,
101
+ }),
102
+ order: TimeOrder.DESC,
103
+ };
104
+ }
105
+
106
+ if (calendar === DefaultCalendars.TPS && /^i/i.test(rawTokenString)) {
107
+ return null;
108
+ }
109
+
59
110
  const parts = s.split(".");
60
111
  if (parts.length === 0) return null;
61
- const calendar = parts[0];
62
112
  const comp: Partial<TPSComponents> = { calendar };
63
113
 
64
114
  const fixedRankMap: Record<string, number> = {
65
115
  c: 7,
66
116
  y: 6,
117
+ w: 4.5,
67
118
  d: 4,
68
119
  h: 3,
69
120
  s: 1,
@@ -139,6 +190,9 @@ export function parseTimeString(
139
190
  case "y":
140
191
  comp.year = parseInt(value, 10);
141
192
  break;
193
+ case "w":
194
+ comp.week = parseInt(value, 10);
195
+ break;
142
196
  case "d":
143
197
  comp.day = parseInt(value, 10);
144
198
  break;
@@ -162,5 +216,18 @@ export function parseTimeString(
162
216
  if (isAsc && !isDesc) order = TimeOrder.ASC;
163
217
  }
164
218
 
219
+ if (
220
+ calendar === DefaultCalendars.TPS &&
221
+ comp.month !== undefined &&
222
+ comp.day !== undefined &&
223
+ comp.month >= 1 &&
224
+ comp.day >= 1
225
+ ) {
226
+ return {
227
+ components: normalizeTpsComponents(comp),
228
+ order,
229
+ };
230
+ }
231
+
165
232
  return { components: comp, order };
166
233
  }