@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.
- package/README.md +331 -11
- package/dist/gobj-ui.cjs.js +21386 -17919
- package/dist/gobj-ui.es.js +26059 -22613
- package/index.js +45 -9
- package/package.json +4 -4
- package/src/c_g6_nodes_tree.js +128 -40
- package/src/c_yui_form.css +9 -0
- package/src/c_yui_form.js +113 -53
- package/src/c_yui_gobj_tree_js.js +58 -36
- package/src/c_yui_json.css +139 -0
- package/src/c_yui_json.js +928 -0
- package/src/c_yui_json_graph.js +110 -20
- package/src/c_yui_map.js +18 -36
- package/src/c_yui_nav.js +6 -9
- package/src/c_yui_period.css +184 -0
- package/src/c_yui_period.js +1441 -0
- package/src/c_yui_shell.css +120 -0
- package/src/c_yui_shell.js +672 -111
- package/src/c_yui_treedb_graph.js +639 -37
- package/src/c_yui_treedb_schema.js +478 -0
- package/src/c_yui_treedb_topic_with_form.css +18 -0
- package/src/c_yui_treedb_topic_with_form.js +153 -103
- package/src/c_yui_treedb_topics.css +73 -0
- package/src/c_yui_treedb_topics.js +1070 -29
- package/src/c_yui_window.css +22 -0
- package/src/c_yui_window.js +182 -97
- package/src/c_yui_window_manager.js +38 -4
- package/src/json_view_helpers.js +214 -0
- package/src/json_view_helpers.test.js +135 -0
- package/src/route_map_model.js +317 -0
- package/src/route_map_model.test.js +209 -0
- package/src/route_resolver.js +24 -1
- package/src/route_resolver.test.js +40 -1
- package/src/shell_modals.js +162 -52
- package/src/shell_route_map.css +225 -0
- package/src/shell_route_map.js +363 -0
- package/src/tabulator.css +245 -0
- package/src/yui_dev.js +130 -8
- package/src/yui_frontend_view.js +106 -0
- package/src/yui_icons.css +62 -0
- package/src/yui_inputs.css +8 -3
- package/src/yui_inputs.js +51 -8
- package/src/yui_tabulator_i18n.js +128 -0
- package/src/yui_theme.js +147 -0
- package/src/yui_time.js +666 -0
- package/src/yui_time.test.js +330 -0
- package/src/yui_toolbar.css +28 -0
- package/src/yui_toolbar.js +86 -43
- package/src/c_yui_main.css +0 -501
- package/src/c_yui_main.js +0 -1623
- package/src/c_yui_routing.css +0 -18
- package/src/c_yui_routing.js +0 -837
- package/src/c_yui_tabs.js +0 -487
- package/src/themes.js +0 -215
- package/src/ytable.css +0 -63
- package/src/ytable.js +0 -505
package/src/yui_time.js
ADDED
|
@@ -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
|
+
};
|