@yuneta/gobj-ui 2.6.0 → 4.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.
Files changed (56) hide show
  1. package/README.md +331 -11
  2. package/dist/gobj-ui.cjs.js +21386 -17919
  3. package/dist/gobj-ui.es.js +26059 -22613
  4. package/index.js +45 -9
  5. package/package.json +4 -4
  6. package/src/c_g6_nodes_tree.js +128 -40
  7. package/src/c_yui_form.css +9 -0
  8. package/src/c_yui_form.js +113 -53
  9. package/src/c_yui_gobj_tree_js.js +58 -36
  10. package/src/c_yui_json.css +139 -0
  11. package/src/c_yui_json.js +928 -0
  12. package/src/c_yui_json_graph.js +110 -20
  13. package/src/c_yui_map.js +18 -36
  14. package/src/c_yui_nav.js +6 -9
  15. package/src/c_yui_period.css +184 -0
  16. package/src/c_yui_period.js +1441 -0
  17. package/src/c_yui_shell.css +120 -0
  18. package/src/c_yui_shell.js +672 -111
  19. package/src/c_yui_treedb_graph.js +639 -37
  20. package/src/c_yui_treedb_schema.js +478 -0
  21. package/src/c_yui_treedb_topic_with_form.css +18 -0
  22. package/src/c_yui_treedb_topic_with_form.js +153 -103
  23. package/src/c_yui_treedb_topics.css +73 -0
  24. package/src/c_yui_treedb_topics.js +1070 -29
  25. package/src/c_yui_window.css +22 -0
  26. package/src/c_yui_window.js +182 -97
  27. package/src/c_yui_window_manager.js +38 -4
  28. package/src/json_view_helpers.js +214 -0
  29. package/src/json_view_helpers.test.js +135 -0
  30. package/src/route_map_model.js +317 -0
  31. package/src/route_map_model.test.js +209 -0
  32. package/src/route_resolver.js +24 -1
  33. package/src/route_resolver.test.js +40 -1
  34. package/src/shell_modals.js +162 -52
  35. package/src/shell_route_map.css +225 -0
  36. package/src/shell_route_map.js +363 -0
  37. package/src/tabulator.css +245 -0
  38. package/src/yui_dev.js +130 -8
  39. package/src/yui_frontend_view.js +106 -0
  40. package/src/yui_icons.css +62 -0
  41. package/src/yui_inputs.css +8 -3
  42. package/src/yui_inputs.js +51 -8
  43. package/src/yui_tabulator_i18n.js +128 -0
  44. package/src/yui_theme.js +147 -0
  45. package/src/yui_time.js +666 -0
  46. package/src/yui_time.test.js +330 -0
  47. package/src/yui_toolbar.css +28 -0
  48. package/src/yui_toolbar.js +86 -43
  49. package/src/c_yui_main.css +0 -501
  50. package/src/c_yui_main.js +0 -1623
  51. package/src/c_yui_routing.css +0 -18
  52. package/src/c_yui_routing.js +0 -837
  53. package/src/c_yui_tabs.js +0 -487
  54. package/src/themes.js +0 -215
  55. package/src/ytable.css +0 -63
  56. package/src/ytable.js +0 -505
@@ -0,0 +1,666 @@
1
+ /***********************************************************************
2
+ * yui_time.js
3
+ *
4
+ * Time, without a DOM: epochs, and the algebra of PERIODS.
5
+ *
6
+ * Two things live here, and they are the two halves of every date
7
+ * UI the projects keep rewriting:
8
+ *
9
+ * 1) EPOCH <-> the local wall clock. A timestamp travels in the
10
+ * producer's own unit — seconds, or milliseconds when a topic
11
+ * says so — and every conversion crosses that `ms` flag. Getting
12
+ * it wrong puts the same instant on two different clocks in one
13
+ * screen (it did: a picker asking "from 18:55" local returned
14
+ * rows a table labelled 16:55).
15
+ *
16
+ * 2) The PERIOD. A period is not an enum of five names: it is a
17
+ * BUCKET, and a bucket is `count` of a `unit`:
18
+ *
19
+ * {id: "day", unit: "day", count: 1}
20
+ * {id: "week", unit: "week", count: 1} ISO, Mon-first
21
+ * {id: "bimester", unit: "month", count: 2}
22
+ * {id: "quarter", unit: "month", count: 3}
23
+ * {id: "semester", unit: "month", count: 6}
24
+ * {id: "decade", unit: "year", count: 10}
25
+ * {id: "15min", unit: "minute", count: 15}
26
+ *
27
+ * Given that pair, ONE implementation answers every question a
28
+ * date navigator asks — where does the bucket holding this
29
+ * instant start and end, what is the previous/next one, what do
30
+ * I call it — so an app that wants quarters declares a quarter
31
+ * instead of asking for a new component.
32
+ *
33
+ * Buckets are ALIGNED, never "count back from now": months align to
34
+ * the year (which is why 2, 3, 4, 6 and 12 fall on clean calendar
35
+ * boundaries), weeks to Monday, hours to local midnight. "Last 7
36
+ * days" is a different animal — a rolling window — and it is not a
37
+ * period: see `rolling_bounds()`.
38
+ *
39
+ * Everything here is LOCAL time (the user reads a local clock) and
40
+ * DST-safe: it never adds 86400000 ms to cross a day, it builds the
41
+ * next date from calendar fields and lets the platform do it.
42
+ *
43
+ * Copyright (c) 2026, ArtGins.
44
+ * All Rights Reserved.
45
+ ***********************************************************************/
46
+
47
+ /***************************************************************
48
+ * Constants
49
+ ***************************************************************/
50
+ const MS_DAY = 86400000;
51
+
52
+ /* The bucket units, smallest first. A spec is one of these plus a
53
+ * `count`; nothing else is a period. */
54
+ const PERIOD_UNITS = ["minute", "hour", "day", "week", "month", "year"];
55
+
56
+ /* The catalog every app starts from. An app is free to declare its own
57
+ * — the algebra below knows nothing about these ids, only about
58
+ * (unit, count) — but these are the ones with a NAME, and a named
59
+ * bucket labels itself better than a range ("Q3 2026", not
60
+ * "jul – sep 2026"). */
61
+ const YUI_PERIODS = {
62
+ minute: {id: "minute", unit: "minute", count: 1},
63
+ "5min": {id: "5min", unit: "minute", count: 5},
64
+ "15min": {id: "15min", unit: "minute", count: 15},
65
+ hour: {id: "hour", unit: "hour", count: 1},
66
+ day: {id: "day", unit: "day", count: 1},
67
+ week: {id: "week", unit: "week", count: 1},
68
+ fortnight:{id: "fortnight",unit: "week", count: 2},
69
+ month: {id: "month", unit: "month", count: 1},
70
+ bimester: {id: "bimester", unit: "month", count: 2},
71
+ quarter: {id: "quarter", unit: "month", count: 3},
72
+ semester: {id: "semester", unit: "month", count: 6},
73
+ year: {id: "year", unit: "year", count: 1},
74
+ decade: {id: "decade", unit: "year", count: 10}
75
+ };
76
+
77
+ /* The default set of a navigator: the five of the phone apps everybody
78
+ * knows, plus the hour (a log is read by the hour far more often than
79
+ * by the year). The rest of the catalog is one config line away. */
80
+ const YUI_PERIODS_DEFAULT = ["hour", "day", "week", "month", "year"];
81
+
82
+ /* Rolling windows — NOT periods. They have no bucket and no previous:
83
+ * they end at `now` and reach back. They earn their place next to the
84
+ * periods because they are what a live log is actually read with. */
85
+ const YUI_ROLLING = {
86
+ "1h": {id: "1h", secs: 3600, label: "last hour"},
87
+ "6h": {id: "6h", secs: 6 * 3600, label: "last 6h"},
88
+ "24h": {id: "24h", secs: 24 * 3600, label: "last 24h"},
89
+ "7d": {id: "7d", secs: 7 * 24 * 3600, label: "last 7 days"},
90
+ "30d": {id: "30d", secs: 30 * 24 * 3600, label: "last 30 days"}
91
+ };
92
+
93
+
94
+ /***************************
95
+ * Locale / i18n
96
+ ***************************/
97
+
98
+
99
+ /***************************************************************
100
+ * The locale to format with.
101
+ *
102
+ * NEVER at module top level, and never `navigator.language` raw:
103
+ * Firefox can report the literal STRING "undefined", and every Intl
104
+ * constructor throws RangeError on it — a module that built its
105
+ * formatter on import took the whole bundle down with it (that is why
106
+ * gui_agent refuses to import the gobj-ui barrel).
107
+ ***************************************************************/
108
+ function safe_locale(locale)
109
+ {
110
+ if(typeof locale === "string" && locale && locale !== "undefined") {
111
+ return locale;
112
+ }
113
+ let nav = (typeof navigator !== "undefined") ? navigator.language : null;
114
+ if(typeof nav === "string" && nav && nav !== "undefined") {
115
+ return nav;
116
+ }
117
+ return "en";
118
+ }
119
+
120
+ /***************************************************************
121
+ * Translate through the APP's translator when it gave us one, and
122
+ * degrade to the key itself otherwise — interpolating `{{x}}` by hand,
123
+ * so a label never renders raw mustaches at a caller that has no i18n.
124
+ ***************************************************************/
125
+ function tr(t, key, params)
126
+ {
127
+ if(typeof t === "function") {
128
+ return t(key, params);
129
+ }
130
+ let s = key;
131
+ for(let k in (params || {})) {
132
+ s = s.replaceAll(`{{${k}}}`, String(params[k]));
133
+ }
134
+ return s;
135
+ }
136
+
137
+ /***************************************************************
138
+ * Intl formatters, built PER CALL (see safe_locale). They are cheap
139
+ * next to a repaint, and caching them would need a locale-change hook
140
+ * the library has no business owning.
141
+ ***************************************************************/
142
+ function fmt_intl(date, locale, opts)
143
+ {
144
+ try {
145
+ return new Intl.DateTimeFormat(safe_locale(locale), opts).format(date);
146
+ } catch(e) {
147
+ /* A locale Intl refuses is not worth a broken screen. */
148
+ return date.toDateString();
149
+ }
150
+ }
151
+
152
+
153
+ /***************************
154
+ * Epoch <-> clock
155
+ ***************************/
156
+
157
+
158
+ /***************************************************************
159
+ * Epoch in the producer's unit -> milliseconds, and back.
160
+ * `ms` true means the value already IS milliseconds.
161
+ ***************************************************************/
162
+ function epoch_to_ms(value, ms)
163
+ {
164
+ if(!value) {
165
+ return 0;
166
+ }
167
+ return ms ? value : value * 1000;
168
+ }
169
+
170
+ function ms_to_epoch(value_ms, ms)
171
+ {
172
+ if(!value_ms) {
173
+ return 0;
174
+ }
175
+ return ms ? value_ms : Math.floor(value_ms / 1000);
176
+ }
177
+
178
+ /***************************************************************
179
+ * The LOCAL wall-clock string an `<input type="datetime-local">` takes
180
+ * ("YYYY-MM-DDTHH:MM:SS", which needs step=1 to keep the seconds), and
181
+ * the parse back. Empty / unparseable -> 0, which is exactly how a
182
+ * match condition reads an absent bound.
183
+ ***************************************************************/
184
+ function epoch_to_local_input(value, ms)
185
+ {
186
+ if(!value) {
187
+ return "";
188
+ }
189
+ let d = new Date(epoch_to_ms(value, ms));
190
+ let pad = (n) => String(n).padStart(2, "0");
191
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
192
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
193
+ }
194
+
195
+ function local_input_to_epoch(v, ms)
196
+ {
197
+ if(!v) {
198
+ return 0;
199
+ }
200
+ let parsed = Date.parse(v);
201
+ if(Number.isNaN(parsed)) {
202
+ return 0;
203
+ }
204
+ return ms_to_epoch(parsed, ms);
205
+ }
206
+
207
+ /***************************************************************
208
+ * A timestamp for a table cell: the local wall clock, space-separated,
209
+ * keeping the milliseconds when the producer bothered to send them (a
210
+ * topic that sets sf_t_ms usually appends several records inside the
211
+ * same second, and a column that hides that shows a column of ties).
212
+ ***************************************************************/
213
+ function fmt_epoch(value, ms)
214
+ {
215
+ if(!value) {
216
+ return "";
217
+ }
218
+ try {
219
+ let s = epoch_to_local_input(value, ms).replace("T", " ");
220
+ if(ms) {
221
+ s += `.${String(value % 1000).padStart(3, "0")}`;
222
+ }
223
+ return s;
224
+ } catch(e) {
225
+ return String(value);
226
+ }
227
+ }
228
+
229
+
230
+ /***************************
231
+ * Calendar atoms
232
+ ***************************/
233
+
234
+
235
+ /***************************************************************
236
+ * The day number of a date, counting local calendar days from
237
+ * 1970-01-01. Built from the FIELDS (Date.UTC of y/m/d), never from
238
+ * the timestamp: a DST day is 23 or 25 hours long, so dividing a local
239
+ * epoch by 86400000 drifts a day twice a year.
240
+ ***************************************************************/
241
+ function day_number(d)
242
+ {
243
+ return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / MS_DAY);
244
+ }
245
+
246
+ function date_from_day_number(n)
247
+ {
248
+ let d = new Date(n * MS_DAY);
249
+ /* Back to LOCAL midnight of that calendar day. */
250
+ return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
251
+ }
252
+
253
+ /***************************************************************
254
+ * The Monday of the week a date falls in (ISO: the week starts on
255
+ * Monday, and Sunday closes it).
256
+ ***************************************************************/
257
+ function start_of_iso_week(d)
258
+ {
259
+ let day = d.getDay(); /* 0 = Sunday */
260
+ let back = (day === 0) ? 6 : (day - 1);
261
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate() - back);
262
+ }
263
+
264
+ /***************************************************************
265
+ * The ISO week a date belongs to: {week, year}. The YEAR is the ISO
266
+ * week-year, not the calendar year — 2026-12-31 lives in week 1 of
267
+ * 2027, and a label that said "week 1 2026" would name a week six
268
+ * months away.
269
+ ***************************************************************/
270
+ function iso_week(d)
271
+ {
272
+ /* The Thursday of this week decides the year (ISO-8601). */
273
+ let monday = start_of_iso_week(d);
274
+ let thursday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 3);
275
+ let jan1 = new Date(thursday.getFullYear(), 0, 1);
276
+ let week = Math.floor((day_number(thursday) - day_number(jan1)) / 7) + 1;
277
+ return {week: week, year: thursday.getFullYear()};
278
+ }
279
+
280
+ /***************************************************************
281
+ * The index of the ISO week a date falls in, counting weeks from the
282
+ * Monday of the epoch week. It is what aligns a MULTI-week bucket (a
283
+ * fortnight) to a Monday instead of to whatever day 1970-01-01 was.
284
+ ***************************************************************/
285
+ function week_index(d)
286
+ {
287
+ /* 1970-01-01 was a Thursday, so its Monday sits 3 days earlier. */
288
+ return Math.floor((day_number(start_of_iso_week(d)) + 3) / 7);
289
+ }
290
+
291
+
292
+ /***************************
293
+ * Period algebra
294
+ ***************************/
295
+
296
+
297
+ /***************************************************************
298
+ * Normalize whatever a caller hands us into a spec: an id of the
299
+ * catalog, or a spec of its own. Returns null for anything that is not
300
+ * a bucket (a "span" / "custom" mode of a navigator has no algebra).
301
+ ***************************************************************/
302
+ function period_spec(period)
303
+ {
304
+ if(!period) {
305
+ return null;
306
+ }
307
+ let spec = (typeof period === "string") ? YUI_PERIODS[period] : period;
308
+ if(!spec || !spec.unit) {
309
+ return null;
310
+ }
311
+ if(PERIOD_UNITS.indexOf(spec.unit) < 0) {
312
+ return null;
313
+ }
314
+ let count = parseInt(spec.count, 10);
315
+ if(!(count > 0)) {
316
+ count = 1;
317
+ }
318
+ return {id: spec.id || spec.unit, unit: spec.unit, count: count};
319
+ }
320
+
321
+ /***************************************************************
322
+ * The START of the bucket that holds `anchor_ms`, as a local Date.
323
+ *
324
+ * Alignment is what makes a bucket a bucket. Each unit floors against
325
+ * the natural origin of the unit above it, so the boundaries are the
326
+ * ones a human already has in their head:
327
+ *
328
+ * minute -> the hour (a 15min bucket starts at :00/:15/:30/:45)
329
+ * hour -> local midnight (a 6h bucket starts at 00/06/12/18)
330
+ * day -> 1970-01-01 (count=1 is the plain day; a 10-day
331
+ * bucket has no calendar origin to
332
+ * align to, so it aligns to the epoch)
333
+ * week -> the epoch week, Monday-first (ISO)
334
+ * month -> January (which is why 2/3/4/6/12 give calendar
335
+ * bimesters, quarters and semesters)
336
+ * year -> year 0 (a decade starts at 2020, not 2021)
337
+ ***************************************************************/
338
+ function period_start(period, anchor_ms)
339
+ {
340
+ let spec = period_spec(period);
341
+ if(!spec) {
342
+ return null;
343
+ }
344
+ let d = new Date(anchor_ms);
345
+ let n = spec.count;
346
+
347
+ switch(spec.unit) {
348
+ case "minute": {
349
+ let m = Math.floor(d.getMinutes() / n) * n;
350
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), m);
351
+ }
352
+ case "hour": {
353
+ let h = Math.floor(d.getHours() / n) * n;
354
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate(), h);
355
+ }
356
+ case "day": {
357
+ let idx = Math.floor(day_number(d) / n) * n;
358
+ return date_from_day_number(idx);
359
+ }
360
+ case "week": {
361
+ let idx = Math.floor(week_index(d) / n) * n;
362
+ /* Week 0 is the epoch's Monday, 1969-12-29. */
363
+ return date_from_day_number(idx * 7 - 3);
364
+ }
365
+ case "month": {
366
+ let mi = d.getFullYear() * 12 + d.getMonth();
367
+ let s = Math.floor(mi / n) * n;
368
+ return new Date(Math.floor(s / 12), s % 12, 1);
369
+ }
370
+ case "year": {
371
+ let y = Math.floor(d.getFullYear() / n) * n;
372
+ return new Date(y, 0, 1);
373
+ }
374
+ default:
375
+ return null;
376
+ }
377
+ }
378
+
379
+ /***************************************************************
380
+ * The start of the bucket `delta` buckets away (delta may be negative).
381
+ * Calendar arithmetic, never millisecond arithmetic: `new Date(y, m+3, 1)`
382
+ * overflows the year by itself, and stepping a day across a DST change
383
+ * keeps landing on midnight.
384
+ ***************************************************************/
385
+ function period_shift(period, anchor_ms, delta)
386
+ {
387
+ let spec = period_spec(period);
388
+ if(!spec) {
389
+ return anchor_ms;
390
+ }
391
+ let s = period_start(spec, anchor_ms);
392
+ let step = spec.count * (delta || 0);
393
+
394
+ switch(spec.unit) {
395
+ case "minute":
396
+ return new Date(s.getFullYear(), s.getMonth(), s.getDate(),
397
+ s.getHours(), s.getMinutes() + step).getTime();
398
+ case "hour":
399
+ return new Date(s.getFullYear(), s.getMonth(), s.getDate(),
400
+ s.getHours() + step).getTime();
401
+ case "day":
402
+ return new Date(s.getFullYear(), s.getMonth(), s.getDate() + step).getTime();
403
+ case "week":
404
+ return new Date(s.getFullYear(), s.getMonth(), s.getDate() + step * 7).getTime();
405
+ case "month":
406
+ return new Date(s.getFullYear(), s.getMonth() + step, 1).getTime();
407
+ case "year":
408
+ return new Date(s.getFullYear() + step, 0, 1).getTime();
409
+ default:
410
+ return anchor_ms;
411
+ }
412
+ }
413
+
414
+ /***************************************************************
415
+ * The bucket holding `anchor_ms`, as {from, to} in MILLISECONDS.
416
+ *
417
+ * `to` is INCLUSIVE — the last millisecond of the bucket, not the first
418
+ * of the next one. Both ends of a match condition are inclusive, and an
419
+ * exclusive end handed to one silently swallows the record that landed
420
+ * exactly on the boundary.
421
+ ***************************************************************/
422
+ function period_bounds(period, anchor_ms)
423
+ {
424
+ let spec = period_spec(period);
425
+ if(!spec) {
426
+ return {from: 0, to: 0};
427
+ }
428
+ let from = period_start(spec, anchor_ms).getTime();
429
+ let next = period_shift(spec, anchor_ms, 1);
430
+ return {from: from, to: next - 1};
431
+ }
432
+
433
+ /***************************************************************
434
+ * The same bucket, in the unit the CONSUMER speaks (a tranger topic
435
+ * keeps its timestamps in seconds, unless its system_flag says
436
+ * milliseconds). This is the function a query builder calls.
437
+ ***************************************************************/
438
+ function period_bounds_epoch(period, anchor_ms, ms)
439
+ {
440
+ let b = period_bounds(period, anchor_ms);
441
+ if(!b.from) {
442
+ return {from: 0, to: 0};
443
+ }
444
+ return {from: ms_to_epoch(b.from, ms), to: ms_to_epoch(b.to, ms)};
445
+ }
446
+
447
+ /***************************************************************
448
+ * A ROLLING window: `secs` back from now. Not a bucket — it has no
449
+ * previous and no next, it just ends at `now`.
450
+ *
451
+ * The `to` end is left OPEN (0). An iterator with no upper bound keeps
452
+ * matching the records that land while the card is on screen; pinning
453
+ * it to "now" would freeze the window at the instant the user clicked.
454
+ ***************************************************************/
455
+ function rolling_bounds(rolling, ms, now_ms)
456
+ {
457
+ let r = (typeof rolling === "string") ? YUI_ROLLING[rolling] : rolling;
458
+ if(!r || !r.secs) {
459
+ return {from: 0, to: 0};
460
+ }
461
+ let now = (now_ms === undefined) ? Date.now() : now_ms;
462
+ return {from: ms_to_epoch(now - r.secs * 1000, ms), to: 0};
463
+ }
464
+
465
+ /***************************************************************
466
+ * Is this the LAST bucket — the one `now` falls in? It is what greys
467
+ * out the "next" arrow, and what tells the navigator it is already home.
468
+ ***************************************************************/
469
+ function is_current_period(period, anchor_ms, now_ms)
470
+ {
471
+ let spec = period_spec(period);
472
+ if(!spec) {
473
+ return false;
474
+ }
475
+ let now = (now_ms === undefined) ? Date.now() : now_ms;
476
+ let a = period_start(spec, anchor_ms);
477
+ let b = period_start(spec, now);
478
+ return !!a && !!b && a.getTime() === b.getTime();
479
+ }
480
+
481
+ /***************************************************************
482
+ * Recognize the bucket a {from, to} pair came from, so a range that
483
+ * travelled through a URL, a saved view or a backend answer comes back
484
+ * as the period the user picked instead of as a hand-typed range.
485
+ *
486
+ * `from`/`to` are in the CONSUMER's unit (`ms`), and so is the
487
+ * comparison — that is the whole trap: a bucket ends on the last
488
+ * MILLISECOND (…23:59:59.999), and a consumer that keeps seconds stored
489
+ * it truncated (…23:59:59). Comparing in milliseconds, a week saved by a
490
+ * seconds-based topic came back as "no bucket at all", every time.
491
+ *
492
+ * Only an EXACT match counts: both ends must land on the bucket's own
493
+ * boundaries, as that consumer would have written them. `candidates` are
494
+ * ids/specs, tried in order. Returns {period, anchor} (anchor in ms) or
495
+ * null.
496
+ ***************************************************************/
497
+ function infer_period(from, to, candidates, ms)
498
+ {
499
+ if(!from || !to || to <= from) {
500
+ return null;
501
+ }
502
+ let from_ms = epoch_to_ms(from, ms);
503
+
504
+ for(let candidate of (candidates || Object.keys(YUI_PERIODS))) {
505
+ let spec = period_spec(candidate);
506
+ if(!spec) {
507
+ continue;
508
+ }
509
+ let b = period_bounds_epoch(spec, from_ms, ms);
510
+ if(b.from === from && b.to === to) {
511
+ return {period: spec, anchor: from_ms};
512
+ }
513
+ }
514
+ return null;
515
+ }
516
+
517
+
518
+ /***************************
519
+ * Period labels
520
+ ***************************/
521
+
522
+
523
+ /***************************************************************
524
+ * The name of a GRANULARITY (what the segmented control shows):
525
+ * the spec's own id as an i18n key, so an app that declares
526
+ * {id: "quarter"} gets "Trimestre" the moment it adds the key.
527
+ ***************************************************************/
528
+ function period_name(period, t)
529
+ {
530
+ let spec = period_spec(period);
531
+ if(!spec) {
532
+ return tr(t, String((period && period.id) || period || ""));
533
+ }
534
+ return tr(t, spec.id);
535
+ }
536
+
537
+ /***************************************************************
538
+ * The name of the bucket a navigator is PARKED on: what goes between
539
+ * the two arrows.
540
+ *
541
+ * day -> "Today" / "Yesterday" / "13 jul 2026"
542
+ * week -> "Week 27" (+ year when it is not this one)
543
+ * month -> "July" (+ year when it is not this one)
544
+ * quarter -> "Q3 2026" (unit month, count 3)
545
+ * semester -> "H2 2026" (unit month, count 6)
546
+ * year -> "2026"
547
+ * decade -> "2020 – 2029"
548
+ * any other -> the bucket's own edges: "1 jul – 31 aug 2026"
549
+ *
550
+ * The named ones are named because a range reads worse than a name;
551
+ * everything an app invents falls back to the range, which is always
552
+ * true and never wrong.
553
+ ***************************************************************/
554
+ function period_label(period, anchor_ms, t, locale)
555
+ {
556
+ let spec = period_spec(period);
557
+ if(!spec) {
558
+ return "";
559
+ }
560
+ let loc = safe_locale(locale);
561
+ let start = period_start(spec, anchor_ms);
562
+ let end = new Date(period_bounds(spec, anchor_ms).to);
563
+ let now = new Date();
564
+ let this_year = start.getFullYear() === now.getFullYear();
565
+
566
+ if(spec.unit === "day" && spec.count === 1) {
567
+ let diff = day_number(start) - day_number(now);
568
+ if(diff === 0) {
569
+ return tr(t, "today");
570
+ }
571
+ if(diff === -1) {
572
+ return tr(t, "yesterday");
573
+ }
574
+ if(diff === 1) {
575
+ return tr(t, "tomorrow");
576
+ }
577
+ return fmt_intl(start, loc, this_year
578
+ ? {day: "numeric", month: "short"}
579
+ : {day: "numeric", month: "short", year: "numeric"});
580
+ }
581
+
582
+ if(spec.unit === "hour" && spec.count === 1) {
583
+ let hh = fmt_intl(start, loc, {hour: "2-digit", minute: "2-digit"});
584
+ if(day_number(start) === day_number(now)) {
585
+ return hh;
586
+ }
587
+ return `${hh} · ${fmt_intl(start, loc, {day: "numeric", month: "short"})}`;
588
+ }
589
+
590
+ if(spec.unit === "week" && spec.count === 1) {
591
+ /* The two weeks a human never calls by their number. */
592
+ let away = week_index(start) - week_index(now);
593
+ if(away === 0) {
594
+ return tr(t, "this week");
595
+ }
596
+ if(away === -1) {
597
+ return tr(t, "last week");
598
+ }
599
+ let w = iso_week(start);
600
+ return (w.year === now.getFullYear())
601
+ ? tr(t, "week {{n}}", {n: w.week})
602
+ : tr(t, "week {{n}} {{y}}", {n: w.week, y: w.year});
603
+ }
604
+
605
+ if(spec.unit === "month" && spec.count === 1) {
606
+ return fmt_intl(start, loc, this_year
607
+ ? {month: "long"}
608
+ : {month: "long", year: "numeric"});
609
+ }
610
+
611
+ if(spec.unit === "month" && (spec.count === 3 || spec.count === 6)) {
612
+ let n = Math.floor(start.getMonth() / spec.count) + 1;
613
+ let key = (spec.count === 3) ? "quarter {{n}} {{y}}" : "semester {{n}} {{y}}";
614
+ return tr(t, key, {n: n, y: start.getFullYear()});
615
+ }
616
+
617
+ if(spec.unit === "year") {
618
+ if(spec.count === 1) {
619
+ return String(start.getFullYear());
620
+ }
621
+ return `${start.getFullYear()} – ${end.getFullYear()}`;
622
+ }
623
+
624
+ /* Anything else — a bimester, a fortnight, a 10-day bucket, whatever
625
+ * an app declares — says exactly what it spans. */
626
+ let opts = (spec.unit === "minute" || spec.unit === "hour")
627
+ ? {day: "numeric", month: "short", hour: "2-digit", minute: "2-digit"}
628
+ : {day: "numeric", month: "short"};
629
+
630
+ if(start.getFullYear() !== end.getFullYear()) {
631
+ let y_opts = Object.assign({year: "numeric"}, opts);
632
+ return `${fmt_intl(start, loc, y_opts)} – ${fmt_intl(end, loc, y_opts)}`;
633
+ }
634
+ let span = `${fmt_intl(start, loc, opts)} – ${fmt_intl(end, loc, opts)}`;
635
+ return this_year ? span : `${span} ${start.getFullYear()}`;
636
+ }
637
+
638
+ export {
639
+ MS_DAY,
640
+ PERIOD_UNITS,
641
+ YUI_PERIODS,
642
+ YUI_PERIODS_DEFAULT,
643
+ YUI_ROLLING,
644
+
645
+ safe_locale,
646
+ epoch_to_ms,
647
+ ms_to_epoch,
648
+ epoch_to_local_input,
649
+ local_input_to_epoch,
650
+ fmt_epoch,
651
+
652
+ day_number,
653
+ start_of_iso_week,
654
+ iso_week,
655
+
656
+ period_spec,
657
+ period_start,
658
+ period_shift,
659
+ period_bounds,
660
+ period_bounds_epoch,
661
+ rolling_bounds,
662
+ is_current_period,
663
+ infer_period,
664
+ period_name,
665
+ period_label
666
+ };