Repository navigation
Expand file tree
/
Copy pathrenderer-shared.js
More file actions
1042 lines (968 loc) · 60.7 KB
/
Copy pathrenderer-shared.js
File metadata and controls
1042 lines (968 loc) · 60.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// renderer-shared.js — Shared PURE renderer logic for the Timer Widget app.
//
// This module de-duplicates logic that is copy-pasted across the renderer
// windows (electron-widget.html, electron-clock-widget.html, display.html /
// display-script.js). It contains ONLY pure functions.
//
// ES module: named exports (windows import it, Node tests take it via
// require(esm)).
//
// IMPORTANT: every function here mirrors the EXACT behavior already present in
// the renderers — they adopt these as drop-in replacements, no new behavior is
// invented.
//
// NOTE: a status→color helper was prototyped here too, but the color-band logic
// (percent- vs status-based thresholds) diverged enough between renderers that
// unifying it would change behavior; it was dropped to avoid a dead export.
// The window drag DID turn out to be unifiable — for the widget and the clock,
// whose blocks were verbatim clones differing in one line — and lives in
// window-geometry.js. The display keeps its own: no preventDefault, a fullscreen
// heuristic, no geometry. "Diverges enough" is a claim that has to be re-measured,
// not inherited.
import * as CONFIG from './constants.js';
// ---------------------------------------------------------------------------
// breakdown(totalAbsSeconds) → { hours, minutes, seconds, hasHours }
// ---------------------------------------------------------------------------
/**
* Decomposes a duration (in seconds) into hours/minutes/seconds using the SAME
* integer math as every renderer:
* hours = floor(abs / 3600)
* minutes = floor((abs % 3600) / 60)
* seconds = abs % 60
*
* The value is treated as an ABSOLUTE magnitude — the sign (overrun / negative)
* is the caller's concern, exactly like `const absSecs = Math.abs(secs)` in the
* renderers. Passing a negative number yields the same breakdown as its
* absolute value.
*
* `hasHours` reflects the digital/analog "show hours" rule, which is purely
* time-based: hours are shown when `hours > 0` (i.e. the duration is >= 3600s).
* NOTE: the FLIP style additionally shows hours when the timer's PRESET total
* is >= 3600 (`hours > 0 || totalSeconds >= 3600`). That extra condition
* depends on renderer state (`totalSeconds`) that is NOT derivable from the
* seconds alone, so the renderer must OR it in itself — see flipCells().
*
* Non-finite input (NaN / Infinity) is defensively coerced to 0.
*
* @param {number} totalAbsSeconds - duration in seconds (sign ignored)
* @returns {{hours:number, minutes:number, seconds:number, hasHours:boolean}}
*/
function breakdown(totalAbsSeconds) {
let abs = Math.abs(Math.floor(Number(totalAbsSeconds)));
if (!Number.isFinite(abs)) { abs = 0; }
const hours = Math.floor(abs / 3600);
const minutes = Math.floor((abs % 3600) / 60);
const seconds = abs % 60;
return { hours, minutes, seconds, hasHours: hours > 0 };
}
// ---------------------------------------------------------------------------
// flipCells(absSeconds[, presetTotalSeconds]) → individual digit characters
// ---------------------------------------------------------------------------
/**
* Splits a duration into the individual digit characters used by the split-flap
* (flip) style, mirroring the renderers exactly:
* hr1 = floor(hours / 10) % 10 hr2 = hours % 10
* m1 = floor(mins / 10) % 10 m2 = mins % 10
* s1 = floor(secs / 10) s2 = secs % 10
* All values are returned as single-character strings (e.g. '0'..'9').
*
* The flip style's "show hours" rule is `hours > 0 || totalSeconds >= 3600`
* (see electron-widget.html / display-script.js). Because `totalSeconds` is the
* timer's preset and cannot be derived from the elapsed seconds, the renderer
* passes it as the optional second argument. When omitted, `hasHours` falls
* back to the time-only rule (`hours > 0`), matching breakdown().
*
* Non-finite input is defensively coerced to 0.
*
* @param {number} absSeconds - duration in seconds (sign ignored)
* @param {number} [presetTotalSeconds] - timer preset; if >= 3600 forces hours
* @returns {{h1:string,h2:string,m1:string,m2:string,s1:string,s2:string,hasHours:boolean}}
*/
function flipCells(absSeconds, presetTotalSeconds) {
const { hours, minutes, seconds } = breakdown(absSeconds);
let preset = Number(presetTotalSeconds);
if (!Number.isFinite(preset)) { preset = 0; }
const hasHours = hours > 0 || preset >= 3600;
return {
h1: String(Math.floor(hours / 10) % 10),
h2: String(hours % 10),
m1: String(Math.floor(minutes / 10) % 10),
m2: String(minutes % 10),
s1: String(Math.floor(seconds / 10)),
s2: String(seconds % 10),
hasHours
};
}
// ---------------------------------------------------------------------------
// clampScale(value, min, max) → numeric clamp
// ---------------------------------------------------------------------------
/**
* Clamps a numeric scale value into [min, max], matching the renderers'
* Math.max(MIN, Math.min(MAX, value))
* used by every Ctrl/Shift+wheel scaling handler.
*
* Per-renderer bounds (the renderer supplies them; this fn just clamps):
* - Widget Ctrl+wheel: 30..600
* - Clock Ctrl+wheel: 30..600
* - Display timer: 30..300
* - Display blocks: 50..600
*
* Defensive: a NaN `value` returns `min` (the safe lower bound); ±Infinity is
* left to clamp naturally to the corresponding bound, exactly as the renderers'
* `Math.max/Math.min` would. If `min > max` the bounds are swapped so the
* result is always within the intended range.
*
* @param {number} value
* @param {number} min
* @param {number} max
* @returns {number}
*/
function clampScale(value, min, max) {
let lo = Number(min);
let hi = Number(max);
if (Number.isNaN(lo)) { lo = -Infinity; }
if (Number.isNaN(hi)) { hi = Infinity; }
if (lo > hi) { const t = lo; lo = hi; hi = t; }
const v = Number(value);
// Only NaN is unrecoverable through Math.max/min — guard it to the lower
// bound. ±Infinity clamps naturally below.
if (Number.isNaN(v)) { return lo; }
return Math.max(lo, Math.min(hi, v));
}
// ---------------------------------------------------------------------------
// fitBlockScale({width, height, centerX, centerY, free, requested}) → проценты
// ---------------------------------------------------------------------------
/**
* Потолок масштаба блока, растущего `transform: scale` вокруг своего центра.
*
* Зачем. Трансформация НЕ участвует в раскладке: соседи о новом размере не
* знают и с места не двигаются. Поэтому увеличенный блок сначала наезжает на
* них, а потом уходит за край окна — замер на дисплее 3440×1320: при 150 %
* круг перекрывал подпись «Осталось» на 148px и плашку статуса на 22px, при
* 300 % выходил за окно на 429px сверху и снизу. Раскладку это не чинит (у
* подписи сверху и плашки снизу своих запасов нет), поэтому ограничивается
* масштаб — и ограничивается по САМОЙ ТЕСНОЙ стороне: наезжает ведь она.
*
* Блок растёт от ЦЕНТРА, поэтому по каждой стороне помещается вдвое больше,
* чем расстояние от центра до границы: половина блока приходится на эту
* сторону.
*
* Уменьшение не ограничивается никогда: оно ни на что не наезжает.
*
* Мусор на входе возвращает ЗАПРОШЕННОЕ значение, а не 0: замер мог не
* состояться (окно ещё не разложено), и схлопнуть таймер в точку — худший из
* возможных ответов.
*
* `floorPct` (по умолчанию 100) — ниже какого масштаба потолок не опускается.
* Сто — когда место под неувеличенный блок отвела раскладка (подпись сверху,
* плашка снизу). Меньше — когда тесноту создают карточки СБОКУ: их ставит
* пользователь или раскладка «47-й этаж», а смена стиля делает таймер шире
* (см. sideCardBand). Тогда ограничивается и масштаб ≤ 100 %.
*
* @param {{width: number, height: number, centerX: number, centerY: number,
* free: {left: number, right: number, top: number, bottom: number},
* requested: number, floorPct?: number}} p
* @returns {number} проценты
*/
function fitBlockScale(p) {
const opts = /** @type {Partial<NonNullable<typeof p>>} */ (p || {});
const requested = Number(opts.requested);
if (!Number.isFinite(requested)) { return 100; }
const floorRaw = Number(opts.floorPct);
const floorPct = Number.isFinite(floorRaw) ? Math.min(100, Math.max(0, floorRaw)) : 100;
if (requested <= floorPct) { return requested; }
const width = Number(opts.width);
const height = Number(opts.height);
const free = opts.free;
if (!(width > 0) || !(height > 0) || !free) { return requested; }
const sides = [
(Number(opts.centerX) - Number(free.left)) / (width / 2),
(Number(free.right) - Number(opts.centerX)) / (width / 2),
(Number(opts.centerY) - Number(free.top)) / (height / 2),
(Number(free.bottom) - Number(opts.centerY)) / (height / 2)
];
if (!sides.every(Number.isFinite)) { return requested; }
const ceiling = Math.min(...sides) * 100;
if (!Number.isFinite(ceiling)) { return requested; }
// Ниже пола потолок не опускаем: при полу 100 место под НЕувеличенный
// блок отводит раскладка, и спорить с ней здесь не о чем.
return Math.max(floorPct, Math.min(requested, Math.floor(ceiling)));
}
// ---------------------------------------------------------------------------
// sideCardBand({centerX, top, bottom, boxes, gap, free}) → {left, right}
// ---------------------------------------------------------------------------
/**
* Горизонтальная полоса, которую таймеру оставляют карточки СБОКУ от него.
*
* Зачем. Карточки дисплея стоят `fixed` и о таймере не знают. Раскладка
* «47-й этаж» кладёт деньги по бокам от круга, а «Флип» и «Цифры» шире круга:
* замер 30.09.2026 на 1280×720 — таймер 242..1038 при «Перелимите» 97..338.
* Уступать здесь обязан таймер: карточку поставили туда, где она нужна.
*
* Боковая — карточка, чья вертикаль пересекает вертикаль таймера (`top`..
* `bottom`) и которая целиком по одну сторону от его центра. Карточка выше
* или ниже таймера — дело полосы сверху (topBandReserve), поперёк центра —
* сбоку её нет, и сужение по горизонтали ей не поможет.
*
* @param {{centerX: number, top: number, bottom: number,
* boxes: Array<{left: number, right: number, top: number, bottom: number}>,
* gap?: number, free: {left: number, right: number}}} p
* @returns {{left: number, right: number}}
*/
function sideCardBand(p) {
const opts = /** @type {Partial<NonNullable<typeof p>>} */ (p || {});
const free = /** @type {Partial<NonNullable<typeof opts.free>>} */ (opts.free || {});
let left = Number(free.left) || 0;
let right = Number(free.right) || 0;
const centerX = Number(opts.centerX);
const top = Number(opts.top);
const bottom = Number(opts.bottom);
const gap = Number(opts.gap) || 0;
if (![centerX, top, bottom].every(Number.isFinite)) { return { left, right }; }
for (const box of (Array.isArray(opts.boxes) ? opts.boxes : [])) {
if (!box) { continue; }
const b = { left: Number(box.left), right: Number(box.right), top: Number(box.top), bottom: Number(box.bottom) };
if (![b.left, b.right, b.top, b.bottom].every(Number.isFinite)) { continue; }
if (b.bottom <= top || b.top >= bottom) { continue; }
if (b.right <= centerX) {
left = Math.max(left, b.right + gap);
} else if (b.left >= centerX) {
right = Math.min(right, b.left - gap);
}
}
return { left, right };
}
// ---------------------------------------------------------------------------
// topBandReserve({column, boxes, gap, floor}) → px
// ---------------------------------------------------------------------------
/**
* Насколько колонка героя (подпись «Осталось» + таймер) обязана уступить
* прижатым к верху карточкам.
*
* Зачем. Карточки дисплея стоят `position: fixed` от верхнего края, а подпись с
* таймером центрируются по ОКНУ. Это два независимых способа сказать, где
* элемент, и на высоком окне они не спорят, а на низком сходятся в одной точке.
* Замер 18.08.2026 на позиции по умолчанию («Текущее время» — сверху по центру):
* 3440×1440 — зазор 108px, 1600×900 — уже перекрытие 21px в «Аналоге»,
* 1280×720 — 14/58/15px в «Круге», «Аналоге» и «Цифрах», 1100×620 — 28/2/81/29,
* то есть во ВСЕХ четырёх стилях. Готовые раскладки такого не допускают, потому
* что считают координаты от свободной полосы; вид по умолчанию не считал ничего.
*
* Ответ — не двигать карточку (выше 20px от края ей некуда) и не уменьшать её
* (на 1100×620 понадобилось бы вдвое), а сдвинуть колонку ровно настолько,
* чтобы её верхняя грань встала под нижней гранью карточки.
*
* Считаются только те карточки, что реально СТОЯТ НА ПУТИ: полоса берётся по
* пересечению с горизонтальным габаритом колонки. Карточка в углу низкого
* широкого окна колонке не мешает, и уступать ей высоту — значит без причины
* сдвинуть таймер вниз.
*
* Сдвиг ограничен снизу: плашка состояния прижата к нижнему краю и не уступает,
* а починить верх ценой низа — это перенос дефекта, а не починка. Если места не
* хватает даже так, отдаётся ВЕСЬ доступный сдвиг: часть перекрытия лучше, чем
* всё оно.
*
* Возвращается УДВОЕННЫЙ сдвиг — величина верхнего отступа колонки. Колонку
* центрирует флексбокс, поэтому отступ сверху в N пикселей сдвигает её на N/2
* (см. `.display-container` в display.css). Коэффициент живёт здесь, рядом с
* разбором, а не в CSS-выражении, где его никто не проверит.
*
* Обе вертикальные величины на входе — при НУЛЕВОЙ полосе. Поэтому функция не
* зависит от результата собственного применения: пересчёт идемпотентен и не
* может разогнать сам себя.
*
* @param {{column: {left: number, right: number, top: number, bottom?: number},
* boxes: Array<{left: number, right: number, bottom: number}>,
* gap: number, floor?: number}} p
* @returns {number} верхний отступ колонки в пикселях (0 — уступать нечего)
*/
/**
* Нижняя граница САМОЙ НИЖНЕЙ карточки, стоящей на пути колонки, плюс зазор.
*
* Общая часть двух ответов на одно и то же затруднение: сдвинуть колонку
* (topBandReserve) и ужать раму героя (heroFrameShrink). Два вычисления «кто
* мешает» разошлись бы при первой правке — например, при уточнении, что такое
* «на пути».
*
* @returns {number|null} null — мешать некому
*/
function blockingBottom(column, boxes, gap) {
const left = Number(column.left);
const right = Number(column.right);
if (!Number.isFinite(left) || !Number.isFinite(right) || !(right > left)) { return null; }
const list = Array.isArray(boxes) ? boxes : [];
let bottom = -Infinity;
for (const box of list) {
if (!box) { continue; }
const bl = Number(box.left);
const br = Number(box.right);
const bb = Number(box.bottom);
if (!Number.isFinite(bl) || !Number.isFinite(br) || !Number.isFinite(bb)) { continue; }
// Строгое неравенство: касание краями — не перекрытие.
if (br <= left || bl >= right) { continue; }
bottom = Math.max(bottom, bb + gap);
}
return Number.isFinite(bottom) ? bottom : null;
}
// ---------------------------------------------------------------------------
// heroFrameShrink({column, boxes, gap, floor, limit}) → px
// ---------------------------------------------------------------------------
/**
* На сколько ужать раму героя, чтобы колонка ВООБЩЕ поместилась.
*
* Сдвиг колонки (topBandReserve) решает задачу, пока места хватает. Когда не
* хватает — а это не редкость: «Аналог» на 1280×720 просит 479px при полосе в
* 463px, на 1100×620 — 427 при 363, — двигать больше некуда, и без второго
* шага остаётся перекрытие, то есть дефект «наполовину починен».
*
* Уступает ГЕРОЙ, а не карточка, и это выбор с обоснованием: карточка набрана
* фиксированным мини-циферблатом 100px и подписью, ниже ~80 % она выглядит
* сломанной, а круг и циферблат ужимаются непрерывно. Кроме того, ужимать
* пришлось бы только МЕШАЮЩИЕ карточки, и в кадре встали бы плашки двух разных
* размеров — ровно тот артефакт, за который уже ругали расчёт раскладок.
*
* ВСЕ величины на входе — при НУЛЕВОЙ уступке (вызывающая сторона обнуляет её и
* меряет заново). Иначе это была бы подача собственного выхода себе на вход:
* высота колонки зависит от рамы, рама — от этого ответа.
*
* `limit` — предохранитель на случай стиля, чья высота от рамы не зависит:
* ответ тогда бесполезен, но обязан быть ОГРАНИЧЕННЫМ, а не расти от вызова к
* вызову.
*
* @param {{column: {left:number, right:number, top:number, bottom:number},
* boxes: Array<{left:number, right:number, bottom:number}>,
* gap: number, floor: number, limit?: number}} p
* @returns {number} пиксели (0 — ужимать не за чем)
*/
function heroFrameShrink(p) {
const opts = /** @type {Partial<NonNullable<typeof p>>} */ (p || {});
const column = opts.column;
if (!column) { return 0; }
const top = Number(column.top);
const bottom = Number(column.bottom);
const floor = Number(opts.floor);
if (!Number.isFinite(top) || !Number.isFinite(bottom) || !Number.isFinite(floor)) { return 0; }
const gapRaw = Number(opts.gap);
const gap = Number.isFinite(gapRaw) ? gapRaw : 0;
const blocking = blockingBottom(column, opts.boxes, gap);
if (blocking === null) { return 0; }
const room = floor - blocking;
const shortfall = (bottom - top) - room;
if (!(shortfall > 0)) { return 0; }
const limit = Number(opts.limit);
return Number.isFinite(limit) ? Math.min(shortfall, Math.max(0, limit)) : shortfall;
}
function topBandReserve(p) {
const opts = p || {};
const column = opts.column;
if (!column) { return 0; }
const top = Number(column.top);
if (!Number.isFinite(top)) { return 0; }
const gapRaw = Number(opts.gap);
const gap = Number.isFinite(gapRaw) ? gapRaw : 0;
const obstacle = blockingBottom(column, opts.boxes, gap);
if (obstacle === null) { return 0; }
let shift = Math.max(0, obstacle - top);
const bottom = Number(column.bottom);
const floor = Number(opts.floor);
if (Number.isFinite(bottom) && Number.isFinite(floor)) {
shift = Math.min(shift, Math.max(0, floor - bottom));
}
return shift * 2;
}
// ---------------------------------------------------------------------------
// timerLifecycleStatus(state) → 'paused' | 'overtime' | 'finished' | 'running' | 'idle'
// ---------------------------------------------------------------------------
/**
* Решает, в каком состоянии ЖИЗНЕННОГО ЦИКЛА находится таймер, чтобы окно могло
* подписать статус-плашку. Три окна (панель управления, виджет, полноэкранный
* режим) раньше содержали три копии этого условия, и копии разошлись:
*
* - виджет красил «Перерасход» классом `running`, то есть ЗЕЛЁНЫМ, при красных цифрах;
* - полноэкранный режим проверял `finished` ПЕРВЫМ, а панель и виджет — перерасход,
* из-за чего одно состояние подписывалось в разных окнах по-разному;
* - ветка `isPaused` во всех трёх была недостижима при remainingSeconds <= 0,
* поэтому пауза в перерасходе показывалась как «Завершено» / «Время вышло!».
*
* Порядок приоритетов здесь единственный на всё приложение:
*
* 1. paused — пауза важнее всего: если пользователь остановил таймер, это пауза,
* даже в перерасходе (сам перерасход уже виден по красным цифрам);
* 2. overtime — ушли ниже нуля;
* 3. finished — досчитали до нуля (или флаг залатчен движком);
* 4. running — идёт отсчёт;
* 5. idle — всё остальное.
*
* Возвращается только КЛЮЧ. Подписи и CSS-классы остаются за каждым окном:
* тексты у них разные («Завершён» / «Завершено» / «Время вышло!»), и сводить их
* к одному — уже продуктовое решение, а не починка рассинхрона.
*
* @param {{remainingSeconds:number, totalSeconds:number, isRunning:boolean,
* isPaused:boolean, finished:boolean}} state
* @returns {'paused'|'overtime'|'finished'|'running'|'idle'}
*/
function timerLifecycleStatus(state) {
const raw = Math.floor(Number((state && state.remainingSeconds) || 0));
const secs = Number.isFinite(raw) ? raw : 0;
const totalRaw = Number((state && state.totalSeconds) || 0);
const total = Number.isFinite(totalRaw) ? totalRaw : 0;
const isRunning = !!(state && state.isRunning);
const isPaused = !!(state && state.isPaused);
const finished = !!(state && state.finished);
if (isPaused) { return 'paused'; }
if (secs < 0) { return 'overtime'; }
if (finished || (secs <= 0 && total > 0 && !isRunning)) { return 'finished'; }
if (isRunning) { return 'running'; }
return 'idle';
}
// ---------------------------------------------------------------------------
// timerColorBand(remainingSeconds, totalSeconds[, thresholds]) → цветовая полоса
// ---------------------------------------------------------------------------
/**
* Решает, каким ЦВЕТОМ показывать время: 'overtime' | 'danger' | 'warning' | 'normal'.
*
* Это НЕ то же самое, что timerLifecycleStatus(): та отвечает на вопрос «что с
* таймером» (для подписи статуса), а эта — «насколько всё срочно» (для окраски
* цифр, стрелок и кольца прогресса).
*
* Раньше эта лесенка была скопирована девять раз по трём окнам, и копии
* содержали два дефекта:
*
* 1. Условие danger было записано как `percentLeft <= 10 && percentLeft > 0`.
* Ровно на 00:00 процент равен нулю, `> 0` не проходит, и значение
* проваливалось в `<= 25` → время на нуле показывалось ЖЁЛТЫМ (warning),
* хотя это самая срочная точка отсчёта. При этом utils.getTimerStatus()
* всегда считал ноль за 'danger' — то есть общая утилита и рендереры
* противоречили друг другу.
* 2. Пороги в CONFIG (DANGER_PERCENTAGE / WARNING_PERCENTAGE) читала только
* панель управления, остальные окна держали 10 и 25 захардкоженными.
* Правка конфига разъехала бы окна между собой.
*
* Пороги берутся из аргумента, иначе из импортированного CONFIG, иначе 10 / 25.
*
* @param {number} remainingSeconds
* @param {number} totalSeconds
* @param {{danger?:number, warning?:number}} [thresholds]
* @returns {'overtime'|'danger'|'warning'|'normal'}
*/
function timerColorBand(remainingSeconds, totalSeconds, thresholds) {
const rawSecs = Number(remainingSeconds);
const secs = Number.isFinite(rawSecs) ? rawSecs : 0;
if (secs < 0) { return 'overtime'; }
const rawTotal = Number(totalSeconds);
const total = Number.isFinite(rawTotal) ? rawTotal : 0;
if (total <= 0) { return 'normal'; }
const t = thresholds || {};
const dangerPct = Number.isFinite(Number(t.danger))
? Number(t.danger)
: Number(CONFIG.DANGER_PERCENTAGE);
const warningPct = Number.isFinite(Number(t.warning))
? Number(t.warning)
: Number(CONFIG.WARNING_PERCENTAGE);
const percentLeft = (secs / total) * 100;
// Ноль ВХОДИТ в danger — это конец отсчёта, а не «предупреждение».
if (percentLeft <= dangerPct) { return 'danger'; }
if (percentLeft <= warningPct) { return 'warning'; }
return 'normal';
}
// ---------------------------------------------------------------------------
// pickOwnSetting(settings, ownKey, sharedKey) → значение или undefined
// ---------------------------------------------------------------------------
/**
* Выбирает значение настройки, у которой ДВА имени: собственное (принадлежит
* конкретному окну) и общее (осталось от версий, где окно было одно).
*
* Зачем это отдельная функция, а не `a || b` на месте вызова: общее имя в этом
* проекте ДВУСМЫСЛЕННО. В `displayExtSettings` поле `timerStyle` — стиль
* ВИДЖЕТА (settings-schema.js пишет его туда через `alsoWrite`, чтобы откат на
* предыдущую версию приложения не потерял настройку), а в IPC-пакете
* `display-settings-update` под тем же именем панель шлёт стиль ДИСПЛЕЯ. Оба
* набора приходят в одну и ту же функцию окна, поэтому окно обязано спрашивать
* СВОЁ имя первым, а общее держать запасным.
*
* Сравнение именно с undefined: `0` и пустая строка — это присланные значения,
* а не молчание, и проваливаться в запасное имя они не должны.
*
* @param {unknown} settings
* @param {string} ownKey — имя, принадлежащее этому окну
* @param {string} sharedKey — общее имя прежних версий
* @returns {unknown|undefined}
*/
function pickOwnSetting(settings, ownKey, sharedKey) {
if (!settings || typeof settings !== 'object') { return undefined; }
return settings[ownKey] !== undefined ? settings[ownKey] : settings[sharedKey];
}
/**
* Ключ цветов прежних версий. Панель ПИШЕТ его (цвета виджета) только ради
* отката: у версии до 29.10.2026 виджет и дисплей читают именно его. Читает
* его единственный запасной путь — ownColors ниже (виджет и панель), для
* профиля старше поканальных цветов. Условие удаления — в реестре constants.js.
*/
const LEGACY_COLORS_KEY = 'timerColors';
/**
* Цвета одного ключа хранилища — БЕЗ запасного пути.
*
* Объект (включая {} — «всё по CSS») либо null: ключа нет, JSON битый,
* значение не объект (массив, примитив) — как parseColors панели.
*
* Этим читает дисплей: панель досылает ему `displayColors || {}` без
* запасного пути (panel-colors.loadWindowColors), main помнит и шлёт при
* открытии, значит осевшее состояние дисплея без своего ключа — CSS-умолчание.
* Запасной путь на timerColors здесь давал первый кадр в цветах ВИДЖЕТА и
* затем скачок к умолчанию на самом частом профиле.
*
* @param {{getItem(key: string): string|null}|null|undefined} storage
* @param {string} key
* @returns {object|null}
*/
function storedColors(storage, key) {
if (!storage || !key) { return null; }
const raw = storage.getItem(key);
if (!raw || typeof raw !== 'string') { return null; }
try {
const value = JSON.parse(raw);
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
/**
* Цвета ВИДЖЕТА на старте: свой ключ, а без него — запасной путь на
* `timerColors` (профиль старше поканальных цветов).
*
* Запасной путь здесь законен, потому что панель досылает виджету ту же
* формулу (`widget || legacy`, panel-colors.loadWindowColors зовёт эту же
* функцию): первый кадр совпадает с осевшим. У дисплея досылка другая —
* он читает storedColors.
*
* Своё значение — объект, включая {} («всё по CSS»): пустой объект — это не
* отсутствие, иначе сброшенные цвета воскресали бы старыми.
*
* Хранилище — параметром: функция чистая, тест подсовывает поддельное.
*
* @param {{getItem(key: string): string|null}|null|undefined} storage
* @param {string} ownKey — ключ цветов этого окна
* @returns {object|null} объект цветов либо null (владелец умолчания — CSS)
*/
function ownColors(storage, ownKey) {
if (!storage || !ownKey) { return null; }
return storedColors(storage, ownKey) || storedColors(storage, LEGACY_COLORS_KEY);
}
/**
* Час и минута, когда таймер дойдёт до нуля: «закончится в 14:50».
*
* Считается ОТ переданного момента, а не от new Date() внутри, — иначе
* функцию нельзя проверить, а её единственная интересная точка это переход
* через полночь.
*
* Отрицательный остаток — это перерасход: время окончания уже в прошлом, и
* подпись над ним меняется на «должно было закончиться в». Арифметика та же,
* поэтому знак здесь не особый случай.
*
* @param {number} remainingSeconds остаток, может быть отрицательным
* @param {Date} now момент отсчёта
* @returns {string|null} 'ЧЧ:ММ' либо null, если аргументы бессмысленны
*/
function endsAt(remainingSeconds, now) {
if (!Number.isFinite(remainingSeconds)) { return null; }
if (!(now instanceof Date) || Number.isNaN(now.getTime())) { return null; }
const end = new Date(now.getTime() + Math.round(remainingSeconds) * 1000);
if (Number.isNaN(end.getTime())) { return null; }
const hh = String(end.getHours()).padStart(2, '0');
const mm = String(end.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
/**
* Относительная яркость цвета по WCAG. Принимает #rgb и #rrggbb.
* @param {string} color
* @returns {number|null} 0..1, либо null если цвет не разобран
*/
function relativeLuminance(color) {
if (typeof color !== 'string') { return null; }
let hex = color.trim().replace(/^#/, '');
if (hex.length === 3) { hex = hex.split('').map((c) => c + c).join(''); }
if (!/^[0-9a-f]{6}$/i.test(hex)) { return null; }
const channel = (v) => {
const c = parseInt(v, 16) / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const r = channel(hex.slice(0, 2));
const g = channel(hex.slice(2, 4));
const b = channel(hex.slice(4, 6));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/**
* Страж яркости дисплея: светлый под ним фон или тёмный.
*
* Дисплей следует теме, но фон под цифрами задаёт ПОЛЬЗОВАТЕЛЬ — заливкой,
* градиентом или картинкой. Решать цвет текста по теме означало бы вернуть
* задокументированный провал: тёмная заливка при светлой теме даёт чёрные
* цифры на чёрном, и на проекторе время не видно вообще.
*
* Поэтому решает не тема, а ЯРКОСТЬ фактического фона. Тема лишь выбирает
* фон по умолчанию, когда пользователь своего не задал.
*
* Порог 0.179 — точка, где белый и чёрный текст дают равный контраст по WCAG.
*
* Картинка не разбирается принципиально: у фотографии нет одной яркости, и
* гадать по ней хуже, чем держать заведомо читаемый светлый текст с затемняющим
* оверлеем — так это и работало до редизайна.
*
* @param {{mode?: string, solid?: string, grad1?: string, grad2?: string, theme?: string}} bg
* @returns {'light'|'dark'} какой ФОН получился, а не какой текст нужен
*/
function backgroundTone(bg) {
const LIGHT_THRESHOLD = 0.179;
const settings = bg || {};
const mode = settings.mode;
if (mode === 'local') { return 'dark'; }
let lum = null;
if (mode === 'solid') {
lum = relativeLuminance(settings.solid);
} else if (mode === 'gradient') {
const a = relativeLuminance(settings.grad1);
const b = relativeLuminance(settings.grad2);
const stops = [a, b].filter((v) => v !== null);
lum = stops.length ? stops.reduce((x, y) => x + y, 0) / stops.length : null;
}
// Своего фона нет — его выбирает тема. Светлая даёт белый холст.
if (lum === null) { return settings.theme === 'light' ? 'light' : 'dark'; }
return lum > LIGHT_THRESHOLD ? 'light' : 'dark';
}
// ---------------------------------------------------------------------------
// surfaceTone({ color, alpha, theme }) → 'light' | 'dark'
// ---------------------------------------------------------------------------
/**
* Тот же страж, что backgroundTone, но для окна, у которого ФОНА НЕТ ВОВСЕ.
*
* Виджет и часы лежат поверх чужого рабочего стола. Единственное, что там
* красится, — подложка стиля: пара «цвет + прозрачность» из панели. Значит и
* вход другой, а развилка та же — решает ЯРКОСТЬ того, что реально под
* цифрами, а тема выбирает фон по умолчанию.
*
* Прозрачность здесь не украшение, а условие применимости замера: сквозь
* подложку с alpha 0.2 видно обои пользователя, и «померить подложку» означало
* бы измерить не тот слой. У фотографии обоев одной яркости нет — гадать по
* ней хуже, чем вернуться к теме, ровно как backgroundTone поступает с
* картинкой на фоне дисплея. Порог 0.5: при нём подложка и стол вносят
* поровну, выше — подложка перевешивает.
*
* @param {{color?: string, alpha?: number, theme?: string}} surface
* @returns {'light'|'dark'} какой ФОН получился под цифрами
*/
function surfaceTone(surface) {
const s = surface || {};
const byTheme = s.theme === 'light' ? 'light' : 'dark';
const alpha = surfaceAlpha(s.alpha);
if (alpha !== null && alpha < 0.5) { return byTheme; }
const lum = relativeLuminance(typeof s.color === 'string' ? s.color : null);
if (lum === null) { return byTheme; }
return lum > 0.179 ? 'light' : 'dark';
}
// ---------------------------------------------------------------------------
// surfacePaint({ color, alpha }) → строка для CSS или null
// ---------------------------------------------------------------------------
/**
* Цвет ПОДЛОЖКИ виджета и часов. Пара «цвет + прозрачность» одна на окно и
* красит подложку того стиля, который сейчас на экране: у круга и «Цифр» своей
* подложки нет вовсе, у LED, флипа и аналога она своя и непрозрачная.
*
* Возвращается строка, которую окно кладёт в переменную `--surface-paint` на
* documentElement, а КАЖДАЯ подложка в CSS записана как
* `var(--surface-paint, <своё прежнее значение>)`. Отсюда два следствия:
*
* - `null` означает «удали переменную», а не «покрась прозрачным»: владельцем
* значения по умолчанию остаётся CSS, и вернуть подложку стиля можно только
* удалением. Красить прозрачным — не то же самое: у флипа под подложкой
* лежит ещё и разделительная линия, у LED — блик ::before;
* - прозрачность 0 — законное значение и единственный способ добиться полной
* прозрачности у стилей, чья подложка описана в CSS непрозрачной.
*
* Цвет проверяется СТРОГОЙ шестнадцатеричной записью, а не общим isSafeColor:
* значение уходит в CSS-объявление, и единственный источник этого значения —
* пипетка панели, которая всегда отдаёт `#rrggbb`. Всё остальное — либо мусор
* из чужого профиля, либо попытка дописать объявление.
*
* @param {{color?: string, alpha?: number}} surface
* @returns {string|null}
*/
function surfacePaint(surface) {
const s = surface || {};
const color = typeof s.color === 'string' ? s.color.trim().toLowerCase() : '';
if (!/^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(color)) { return null; }
const alpha = surfaceAlpha(s.alpha);
// Один знак после запятой: 1/3 иначе уехала бы в CSS хвостом double.
const percent = Math.round((alpha === null ? 1 : alpha) * 1000) / 10;
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
// ---------------------------------------------------------------------------
// surfaceSolid(surface) → '#rrggbb' или null
// ---------------------------------------------------------------------------
/**
* Тот же выбранный цвет, но БЕЗ прозрачности — для стилей, которым прозрачная
* подложка запрещена.
*
* Такой стиль ровно один: перекидыш. Его карточка — это ПЛАСТИНА, поверх
* которой ходят створки, и створки берут её фон себе (см. цепочку `inherit` в
* flip-card.css). Стоит подложке стать полупрозрачной, и сквозь падающую
* створку видно и цифру под ней, и рабочий стол — стиль читается как поломка.
* Решение пользователя 14.08.2026: у флипа прозрачности фона нет вовсе.
*
* Отдельная переменная, а не «убрать альфу из --surface-paint» в CSS: из
* готового `color-mix` прозрачность обратно не вынимается. Проверка цвета — та
* же строгая шестнадцатеричная, по той же причине, что в surfacePaint.
*
* @param {{color?: string}} surface
* @returns {string|null}
*/
function surfaceSolid(surface) {
const s = surface || {};
const color = typeof s.color === 'string' ? s.color.trim().toLowerCase() : '';
return /^#([0-9a-f]{3}|[0-9a-f]{6})$/.test(color) ? color : null;
}
// ---------------------------------------------------------------------------
// surfaceAlpha(value) → 0…1 или null
// ---------------------------------------------------------------------------
/**
* Прозрачность подложки как ОТДЕЛЬНОЕ значение — она работает и без выбранного
* цвета. Окно кладёт её в `--surface-alpha`, а подложка каждого стиля вплетает
* переменную в СВОЮ альфу (`rgba(…, calc(0.85 * var(--surface-alpha, 1)))` или
* `color-mix` для токена). Поэтому ползунок гасит и родную подложку стиля, а не
* только заливку, выбранную пользователем.
*
* `null` означает «не задана» и снимает переменную: множитель по умолчанию
* живёт в CSS. Отличать это от нуля обязательно — `Number(null) === 0`, и без
* явной проверки ненастроенный фон читался бы как погашенный.
*
* @param {number|string|null|undefined} value
* @returns {number|null}
*/
function surfaceAlpha(value) {
if (value === null || value === undefined || value === '') { return null; }
const alpha = Number(value);
if (!Number.isFinite(alpha)) { return null; }
return Math.min(1, Math.max(0, alpha));
}
// ---------------------------------------------------------------------------
// migrateTimerStyle(style) → актуальное имя стиля
// ---------------------------------------------------------------------------
/**
* Стиль LED (`digital`) слит с «Цифрами» (`digits`) 13.08.2026.
*
* Оба показывали одно и то же — время крупными цифрами; отличались рамкой,
* которую теперь даёт настраиваемый фон, и выбором шрифта, который у «Цифр»
* был всегда. Держать два стиля ради одной рамки значило держать вдвое больше
* разметки, CSS и веток в трёх окнах.
*
* Функция нужна ПОТОМУ, что имя стиля лежит в профиле пользователя: в
* `displayExtSettings`, в настройках часов и в снимке последнего стиля, который
* главный процесс досылает окнам. Без перевода такой профиль открылся бы окном
* без единого активного стиля — то есть пустым.
*
* Неизвестное значение возвращается КАК ЕСТЬ: подстановка 'circle' прятала бы
* чужую ошибку и меняла поведение окна на непонятном входе.
*
* @param {string} style
* @returns {string}
*/
function migrateTimerStyle(style) {
return style === 'digital' ? 'digits' : style;
}
// ---------------------------------------------------------------------------
// clockToSeconds(clock) → секунды с начала суток, или null
// ---------------------------------------------------------------------------
/**
* Парсит 'HH:MM' и возвращает количество секунд с начала суток (от 0 до 86399),
* или null, если вход невалиден.
*
* `null` используется вместо `0`, потому что ноль — это полуночь, легитимное время.
* Возвращение 0 для невалидного входа замаскировало бы разницу между «часы
* установлены на полночь» и «в качестве часов передан мусор».
*
* @param {string} clock — 'HH:MM'
* @returns {number|null} секунды (0–86399) или null для невалидного входа
*/
function clockToSeconds(clock) {
if (typeof clock !== 'string') { return null; }
const match = /^(\d{1,2}):(\d{2})$/.exec(clock.trim());
if (!match) { return null; }
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) { return null; }
return hours * 3600 + minutes * 60;
}
// signedSecondsUntilClock(nowSeconds, 'HH:MM') → знаковое расстояние до отметки сегодня
// ---------------------------------------------------------------------------
/**
* ЗНАКОВОЕ расстояние от системных часов до отметки: минус означает, что
* отметка прошла.
*
* Разбор входа живёт в `clockToSeconds()`, в одном месте: отсюда и из
* `eventClockDistances()` ниже. Две копии регулярки и диапазонов разошлись бы
* на первом же исправлении.
*
* Точная формулировка: через `clockToSeconds()` идёт ВСЁ НОВОЕ, что разбирает
* `'HH:MM'`, — но «единственный в проекте» сказать нельзя. `updateStaticMiniClock()`
* в display-script.js разбирает ту же строку своими `split(':')` + `parseInt`;
* он предсуществующий, у него другой потребитель (стрелки мини-циферблата на
* плашках) и своя ветка. Сведение их — отдельная работа; писать здесь, что
* копий нет, было бы неправдой.
*
* Невалидный вход даёт 0, и для знаковой версии это значит «отметка сейчас»,
* а не «отметка прошла»: выдумывать минус из мусора нельзя.
*
* @param {number} nowSeconds — секунды с начала суток
* @param {string} clock — 'HH:MM'
* @returns {number} секунды; отрицательные, если отметка позади
*/
function signedSecondsUntilClock(nowSeconds, clock) {
const now = Number(nowSeconds);
if (!Number.isFinite(now)) { return 0; }
const mark = clockToSeconds(clock);
if (mark === null) { return 0; }
return mark - now;
}
// ---------------------------------------------------------------------------
// bgOverlayPercent(value) → затемнение фона дисплея, % (BUG-16)
// ---------------------------------------------------------------------------
/** Затемнение картинки фона по умолчанию — то же, что `value` ползунка. */
const DEFAULT_BG_OVERLAY = 30;
/**
* Затемнение локального фона в процентах, [0, 100].
*
* `overlay || 30` в трёх местах превращал законный 0 в 30: ползунок стоял
* на 0 %, а дисплей темнил картинку на 30 %. Ноль — значение, умолчание — для
* ОТСУТСТВИЯ и мусора; и знает его одно место.
*/
function bgOverlayPercent(value) {
let n = NaN;
if (typeof value === 'number') { n = value; }
else if (typeof value === 'string' && value.trim() !== '') { n = Number(value); }
if (!Number.isFinite(n)) { return DEFAULT_BG_OVERLAY; }
return Math.max(0, Math.min(100, n));
}
// ---------------------------------------------------------------------------
// eventClockDistances(now, 'HH:MM', 'HH:MM') → { toStart, toEnd } (BUG-08)
// ---------------------------------------------------------------------------
const EVENT_DAY = 86400;
/** Сколько минус после конца мероприятия ещё показывается как перерасход. */
const EVENT_OVERRUN_GRACE = 6 * 3600;
/**
* Знаковые расстояния от «сейчас» до начала и до конца мероприятия — с
* пониманием полуночи.
*
* `signedSecondsUntilClock` вычитает секунды-с-полуночи и потому считает
* мероприятие СЕГОДНЯШНИМ всегда. Через полночь это давало сутки ошибки:
* в 23:00 при конце 01:00 «До конца» — −22:00:00 красным, в 23:50 при
* начале 00:10 «До начала» — −23:40:00. Даты у отметок нет, есть только
* время суток, поэтому «какие это сутки» решается здесь, одним правилом:
*
* 1. Конец раньше начала — мероприятие через полночь: конец — завтра.
* 2. «Сейчас» внутри мероприятия (сегодняшнего, вчерашнего через полночь
* или завтрашнего) — считаем от него.
* 3. Снаружи — между концом одного и началом следующего. Первые
* `EVENT_OVERRUN_GRACE` после конца (но не дальше середины промежутка)
* экран показывает перерасход прошедшего; дальше — отсчёт до следующего.
* Иначе утро перед вечерним мероприятием читалось бы как «кончилось
* вчера», а вечер перед ранним — как «началось сутки назад».
*
* Мусор в отметке даёт ей 0 («отметка сейчас», как у signedSecondsUntilClock:
* минус из мусора не выдумывается), а сутки выбираются по второй.
*
* @param {number} nowSeconds — секунды с начала суток
* @param {string} startClock — 'HH:MM'
* @param {string} endClock — 'HH:MM'
* @returns {{toStart: number, toEnd: number}}
*/
function eventClockDistances(nowSeconds, startClock, endClock) {
const now = Number(nowSeconds);
const startMark = clockToSeconds(startClock);
const endMark = clockToSeconds(endClock);
if (!Number.isFinite(now) || (startMark === null && endMark === null)) {
return { toStart: 0, toEnd: 0 };
}
const start = startMark === null ? endMark : startMark;
let end = endMark === null ? startMark : endMark;
if (end < start) { end += EVENT_DAY; }
const candidates = [now - EVENT_DAY, now, now + EVENT_DAY];
let anchor = candidates.find((c) => c >= start && c <= end);
if (anchor === undefined) {
const gap = EVENT_DAY - (end - start);
const grace = Math.min(EVENT_OVERRUN_GRACE, gap / 2);
const after = candidates.filter((c) => c > end).reduce((a, b) => Math.min(a, b), Infinity);
const before = candidates.filter((c) => c < start).reduce((a, b) => Math.max(a, b), -Infinity);
anchor = (after - end) < grace ? after : before;
}
return {
toStart: startMark === null ? 0 : start - anchor,
toEnd: endMark === null ? 0 : end - anchor
};
}
/**
* Сколько осталось до конца мероприятия — для блока «До завершения» на
* дисплее.
*
* Величина НЕ связана с таймером доклада: тот отсчитывает заданную
* длительность, а эта — расстояние от системных часов до момента «Конец».
*
* Формула — `eventClockDistances().toEnd`, та же, что у героя «До конца»:
* какие это сутки, решает мероприятие целиком (начало И конец). До 26.09.2026
* блок считал по одной отметке с клампом (`secondsUntilClock`) и в 23:30 при
* мероприятии 22:00–00:10 показывал 00:00:00 — конец «сегодня в 00:10» уже
* позади. Своё у блока только одно: он не показывает минус, прошедший конец —
* это ноль («закончилось»); минус перерасхода — дело героя.
*
* @param {number} nowSeconds — секунды с начала суток
* @param {string} startClock — 'HH:MM', начало мероприятия
* @param {string} endClock — 'HH:MM', конец мероприятия
* @returns {number} секунды, не меньше нуля
*/
function secondsUntilEventEnd(nowSeconds, startClock, endClock) {
return Math.max(0, eventClockDistances(nowSeconds, startClock, endClock).toEnd);
}
// ---------------------------------------------------------------------------
// migrateDisplayBlocks(settings) → настройки с личными тумблерами блоков
// ---------------------------------------------------------------------------
/**
* Общий тумблер блоков дисплея (`showTimeBlocks`) заменён личным у каждого
* блока (17.08.2026).
*
* Почему заменён: с личными тумблерами общий даёт ДВА уровня состояния, где