Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
2064 lines (1868 loc) · 82.1 KB
/
Copy pathapp.js
File metadata and controls
2064 lines (1868 loc) · 82.1 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
const WEEKDAY_SYMBOLS = ["MO", "DI", "MI", "DO", "FR", "SA", "SO"];
const LOCALE = "de-DE";
// Mirrors --outside-opacity from styles.css, which the DOM grid applies
// directly via CSS. The canvas export can't use CSS, so it reads the same
// value here rather than hardcoding a second copy of the number.
const OUTSIDE_MONTH_OPACITY =
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--outside-opacity")) || 0.7;
const DEFAULT_SETTINGS = {
p1Name: "Papa",
p2Name: "Mama",
p1Color: "#a3cf8f",
p2Color: "#f7dd86",
};
// The two owner fills come from a free-choice <input type="color"> (any of
// 16M hex values, see Einstellungen), so the day number/note-dot/today-ring
// drawn on top of a fill can't stay legible with one hardcoded dark ink —
// it has to be picked per fill. DARK_INK/LIGHT_INK are the only two inks we
// ever use; inkFor() below picks whichever gives higher WCAG contrast
// against a given fill.
const DARK_INK = "#1c1c1e";
const LIGHT_INK = "#ffffff";
function hexToRgb(hex) {
let h = hex.slice(1);
if (h.length === 3 || h.length === 4) {
h = h.split("").map((c) => c + c).join("");
}
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
};
}
function relativeLuminance({ r, g, b }) {
const srgb = [r, g, b].map((c) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
}
function contrastRatio(lumA, lumB) {
const [lighter, darker] = lumA >= lumB ? [lumA, lumB] : [lumB, lumA];
return (lighter + 0.05) / (darker + 0.05);
}
/** Whichever of DARK_INK/LIGHT_INK reads better (WCAG contrast) on top of `fillHex`. */
function inkFor(fillHex) {
const fillLum = relativeLuminance(hexToRgb(fillHex));
const darkContrast = contrastRatio(fillLum, relativeLuminance(hexToRgb(DARK_INK)));
const lightContrast = contrastRatio(fillLum, relativeLuminance(hexToRgb(LIGHT_INK)));
return darkContrast >= lightContrast ? DARK_INK : LIGHT_INK;
}
/** The ink to put on top of an inkFor(...)-colored surface itself (e.g. the digit inside the today-ring) — inkFor() only ever returns one of two fixed constants, so its "on" color is simply the other one. */
function inkOn(ink) {
return ink === DARK_INK ? LIGHT_INK : DARK_INK;
}
/** Recomputes --p1-ink/--p2-ink (+ their "-on" counterparts for the today-ring digit) from the currently committed owner colors and pushes them onto the document, so painted-cell text stays legible no matter which hex the color wells picked. */
function applyInkVars() {
const root = document.documentElement.style;
const p1Ink = inkFor(colorOf("p1"));
const p2Ink = inkFor(colorOf("p2"));
root.setProperty("--p1-ink", p1Ink);
root.setProperty("--p1-ink-on", inkOn(p1Ink));
root.setProperty("--p2-ink", p2Ink);
root.setProperty("--p2-ink-on", inkOn(p2Ink));
}
const state = {
displayedMonth: startOfMonth(new Date()),
entries: {},
notes: {},
// For "both" days: which brush ("p1" or "p2") was already on the day
// before the tap that combined it, so that color stays on the left/first
// side of the diagonal split.
splitOrder: {},
settings: { ...DEFAULT_SETTINGS },
activeBrush: "p1",
};
// Set while a long-press note-dialog is open, so the click event that
// follows the release on touch devices doesn't also cycle the brush color.
let noteEditingDate = null;
let longPressTimer = null;
let longPressFired = false;
function cancelPendingLongPress() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Whether the "Mehrfachauswahl" toggle is on; while it is, day-cell drags
// paint instead of the viewport treating horizontal motion as a month swipe.
let paintModeActive = false;
// Set for the duration of one pointer press-and-maybe-drag in paint mode.
// `committed` flips true once the movement threshold is crossed, at which
// point `owner` (decided once, from the first cell) is locked in; a press
// that never crosses the threshold stays uncommitted and is left for the
// normal click handler to treat as a plain tap.
let paintDrag = null;
// In-memory only — an undo history doesn't need to survive a reload, and
// persisting it would mean reconciling it with edits made elsewhere (backup
// restore, another tab) between sessions.
// Each entry is a *batch*: the list of per-date records for one user
// gesture, so undoLastAction() reverts a whole tap or a whole paint drag in
// one step instead of one cell at a time.
let undoStack = [];
const UNDO_STACK_LIMIT = 20;
// Set for the duration of one paint drag so every cell it touches lands in
// the same undo batch; null outside a drag, when pushUndoRecord() commits
// each record as its own single-record batch (a plain tap).
let currentUndoBatch = null;
function startOfMonth(date) {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
function addMonths(date, count) {
return new Date(date.getFullYear(), date.getMonth() + count, 1);
}
function dateKey(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function parseDateKey(key) {
const [y, m, d] = key.split("-").map(Number);
return new Date(y, m - 1, d);
}
function mondayIndex(date) {
return (date.getDay() + 6) % 7; // Monday = 0 ... Sunday = 6
}
function addDays(date, count) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + count);
}
function ownerAt(date) {
return state.entries[dateKey(date)] || "none";
}
function colorOf(brush) {
return brush === "p1" ? state.settings.p1Color : state.settings.p2Color;
}
function nameOf(brush) {
return brush === "p1" ? state.settings.p1Name : state.settings.p2Name;
}
/**
* For a "both" day, decides which color starts the diagonal split (the base
* fill) and which finishes it (the overlay triangle). This follows tap
* order: whichever brush was on the day first (before the tap that combined
* it into "both") stays on the left/first side; see `state.splitOrder`,
* which `applyBrush` records at the moment of combining.
*/
function splitColorsFor(date) {
const recorded = state.splitOrder[dateKey(date)];
const firstBrush = recorded === "p1" || recorded === "p2" ? recorded : "p2";
const secondBrush = firstBrush === "p1" ? "p2" : "p1";
return { first: colorOf(firstBrush), second: colorOf(secondBrush) };
}
/** Everything both the DOM grid and the canvas export need to know about one day. */
function describeCell(date) {
const key = dateKey(date);
const owner = ownerAt(date);
return {
key,
owner,
hasNote: Boolean(state.notes[key]),
split: owner === "both" ? splitColorsFor(date) : null,
};
}
/**
* Returns one entry per grid cell, including the tail end of the previous
* month and the start of the next one so the 7-column grid is always full.
* `current` marks whether the date actually belongs to `displayedMonth`.
*/
function buildMonthCells(displayedMonth) {
const year = displayedMonth.getFullYear();
const month = displayedMonth.getMonth();
const firstOfMonth = new Date(year, month, 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const leading = mondayIndex(firstOfMonth);
const cells = [];
for (let i = leading; i > 0; i--) {
cells.push({ date: addDays(firstOfMonth, -i), current: false });
}
for (let day = 1; day <= daysInMonth; day++) {
cells.push({ date: new Date(year, month, day), current: true });
}
const lastOfMonth = new Date(year, month, daysInMonth);
for (let i = 1; cells.length % 7 !== 0; i++) {
cells.push({ date: addDays(lastOfMonth, i), current: false });
}
return cells;
}
function monthTitle(date) {
const monthName = date.toLocaleString(LOCALE, { month: "long" }).toUpperCase();
const yy = String(date.getFullYear()).slice(-2);
return `${monthName} ${yy}`;
}
const COMBINE_TABLE = {
"none:p1": "p1",
"none:p2": "p2",
"p1:p1": "none",
"p2:p2": "none",
"p1:p2": "both",
"p2:p1": "both",
"both:p1": "p2",
"both:p2": "p1",
};
function nextOwner(current, brush) {
return COMBINE_TABLE[`${current}:${brush}`];
}
/** Counts nights per parent in a month; a "both" day counts as half a night each. */
function monthStats(displayedMonth) {
const year = displayedMonth.getFullYear();
const month = displayedMonth.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
let p1 = 0;
let p2 = 0;
for (let day = 1; day <= daysInMonth; day++) {
const owner = ownerAt(new Date(year, month, day));
if (owner === "p1") p1 += 1;
else if (owner === "p2") p2 += 1;
else if (owner === "both") {
p1 += 0.5;
p2 += 0.5;
}
}
return { p1, p2 };
}
function formatNights(n) {
return n.toLocaleString(LOCALE, { maximumFractionDigits: 1 });
}
const HANDOVER_HORIZON_DAYS = 60;
/**
* Only a switch into "p1" or "p2" counts as an announceable handover: a run
* of unfilled days ahead just means nobody's scheduled it yet (not a real
* switch), and a move into "both" is custody becoming shared rather than one
* parent taking sole charge. A move *out of* "both" (or out of "none") into
* a single person does count, since that's the moment someone takes over.
*/
function findNextHandover(fromDate) {
// Track the previous day's owner (not just today's), so a switch back to
// today's parent after a shared/unfilled stretch still counts as a handover.
let prevOwner = ownerAt(fromDate);
for (let i = 1; i <= HANDOVER_HORIZON_DAYS; i++) {
const date = addDays(fromDate, i);
const owner = ownerAt(date);
if ((owner === "p1" || owner === "p2") && owner !== prevOwner) {
return { date, fromOwner: prevOwner, toOwner: owner };
}
// Skip "none" days: a gap shouldn't reset who the last real owner was.
if (owner !== "none") prevOwner = owner;
}
return null;
}
// A separate walk rather than a generalization of findNextHandover: that
// function's exact "first match or null" contract is pinned by dedicated
// regression tests, and this feeds an unrelated feature (push-notification
// sync, see "Push-Benachrichtigungen" below) that has no reason to risk
// that contract just to share a loop.
function listUpcomingHandovers(fromDate) {
const handovers = [];
let prevOwner = ownerAt(fromDate);
for (let i = 1; i <= HANDOVER_HORIZON_DAYS; i++) {
const date = addDays(fromDate, i);
const owner = ownerAt(date);
if ((owner === "p1" || owner === "p2") && owner !== prevOwner) {
handovers.push({ date, fromOwner: prevOwner, toOwner: owner });
}
if (owner !== "none") prevOwner = owner;
}
return handovers;
}
// Plain JSON-object fields, each independently loaded/saved under its own
// localStorage key so a corrupt value in one can't wipe the others.
const JSON_FIELDS = {
entries: "kk.entries",
notes: "kk.notes",
splitOrder: "kk.splitOrder",
};
const SETTINGS_STORAGE_KEY = "kk.settings";
const ACTIVE_BRUSH_STORAGE_KEY = "kk.activeBrush";
const NOTES_HINT_DISMISSED_STORAGE_KEY = "kk.notesHintDismissed";
// A literal "null"/number/array/etc is valid JSON but not a usable map.
function asPlainObjectOrFallback(value, fallback) {
return value && typeof value === "object" && !Array.isArray(value) ? value : fallback;
}
function loadJSON(storageKey, fallback) {
try {
const raw = localStorage.getItem(storageKey);
// Fall back rather than handing e.g. null or [] on to the owner migration.
return asPlainObjectOrFallback(raw ? JSON.parse(raw) : fallback, fallback);
} catch {
return fallback;
}
}
function saveJSON(stateKey) {
localStorage.setItem(JSON_FIELDS[stateKey], JSON.stringify(state[stateKey]));
}
// "both" days keep a splitOrder entry alongside their entries entry (see
// setOwner), so the two always need persisting together.
function saveEntryState() {
saveJSON("entries");
saveJSON("splitOrder");
syncPushHandoversDebounced();
}
// Only these hold owner values ("mine"/"ex"/"p1"/"p2"); notes are free-form
// user text and must not be run through the owner migration below.
const OWNER_MAP_FIELDS = new Set(["entries", "splitOrder"]);
// One-time migration from the original "mine"/"ex" naming to the neutral
// "p1"/"p2" scheme, so a calendar already filled in before this rename
// keeps working instead of losing its data.
function migrateLegacyOwner(value) {
if (value === "mine") return "p1";
if (value === "ex") return "p2";
return value;
}
const VALID_OWNERS = new Set(["p1", "p2", "both"]);
function migrateLegacyOwnerMap(map) {
const migrated = {};
for (const [key, value] of Object.entries(map)) {
const owner = migrateLegacyOwner(value);
// Drop anything else (corrupt/hand-edited backup) instead of letting it
// reach classList.add later, where a stray space would throw and brick render().
if (VALID_OWNERS.has(owner)) {
// defineProperty (not migrated[key] = owner) so a "__proto__" key from
// an imported backup lands as a plain own property instead of invoking
// the accessor and re-parenting `migrated` into an undeletable entry.
Object.defineProperty(migrated, key, { value: owner, writable: true, enumerable: true, configurable: true });
}
}
return migrated;
}
const LEGACY_SETTINGS_KEYS = {
mineName: "p1Name",
exName: "p2Name",
mineColor: "p1Color",
exColor: "p2Color",
};
const HEX_COLOR_RE = /^#[0-9a-f]{3,8}$/i;
const OTHER_OWNER_COLOR_KEY = { p1Color: "p2Color", p2Color: "p1Color" };
// Below this Euclidean RGB distance (max ~441.7), two owner colors read as
// "the same" at a glance -- and colour is the *only* thing distinguishing
// Person 1 from Person 2 anywhere in the UI (grid cells, stat dots, brush
// dots, the diagonal "both" split), so anything this close makes those
// undecodable.
const MIN_OWNER_COLOR_DISTANCE = 60;
function colorDistance(hexA, hexB) {
const a = hexToRgb(hexA);
const b = hexToRgb(hexB);
return Math.sqrt((a.r - b.r) ** 2 + (a.g - b.g) ** 2 + (a.b - b.b) ** 2);
}
function rgbToHex({ r, g, b }) {
return (
"#" +
[r, g, b]
.map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0"))
.join("")
);
}
// If `hex` is too close to `otherHex` to stay visually distinguishable,
// nudge it to its RGB inverse (guaranteed far away in color space), falling
// back to whichever of black/white is furthest from `otherHex` for the rare
// case where a color sits close to its own inverse (grays).
function resolveOwnerColorCollision(hex, otherHex) {
if (colorDistance(hex, otherHex) >= MIN_OWNER_COLOR_DISTANCE) return hex;
const rgb = hexToRgb(hex);
const inverted = rgbToHex({ r: 255 - rgb.r, g: 255 - rgb.g, b: 255 - rgb.b });
if (colorDistance(inverted, otherHex) >= MIN_OWNER_COLOR_DISTANCE) return inverted;
return colorDistance("#000000", otherHex) >= colorDistance("#ffffff", otherHex) ? "#000000" : "#ffffff";
}
// Settings can come from localStorage or an imported backup file, both of
// which a hand-edited/malicious file fully controls. Colors flow straight
// into CSS custom properties (styles.css), so anything other than a plain
// hex value (e.g. "url(https://evil.example/x)") would let a poisoned file
// turn the offline app into a network beacon on every render. Reset
// anything that isn't a valid hex color back to the default instead of
// trusting the file.
function sanitizeSettings(settings) {
for (const key of ["p1Color", "p2Color"]) {
if (typeof settings[key] !== "string" || !HEX_COLOR_RE.test(settings[key])) {
settings[key] = DEFAULT_SETTINGS[key];
}
}
// A hand-edited file (or one written before this check existed) can carry
// two identical/near-identical owner colors, which is just as undecodable
// as picking them live in the settings dialog -- resolve it the same way.
settings.p2Color = resolveOwnerColorCollision(settings.p2Color, settings.p1Color);
for (const key of ["p1Name", "p2Name"]) {
settings[key] = String(settings[key] ?? "").trim() || DEFAULT_SETTINGS[key];
}
return settings;
}
// Migrates old mine*/ex* keys into `state.settings` only as a fallback for
// the case where the new key was never set, and deletes them afterwards. A
// saved blob can carry both (a pre-rename save merged with newer p1*/p2*
// edits since); unconditionally applying the legacy key here would keep
// clobbering every fresh edit with the years-old value on every single load,
// since nothing ever removed it from the persisted JSON before now.
function mergeLegacySettings(savedSettings) {
state.settings = { ...state.settings, ...savedSettings };
for (const [legacyKey, newKey] of Object.entries(LEGACY_SETTINGS_KEYS)) {
if (savedSettings[legacyKey] !== undefined && savedSettings[newKey] === undefined) {
state.settings[newKey] = savedSettings[legacyKey];
}
delete state.settings[legacyKey];
}
sanitizeSettings(state.settings);
}
function loadState() {
for (const [stateKey, storageKey] of Object.entries(JSON_FIELDS)) {
const loaded = loadJSON(storageKey, {});
state[stateKey] = OWNER_MAP_FIELDS.has(stateKey) ? migrateLegacyOwnerMap(loaded) : loaded;
}
try {
const savedSettings = JSON.parse(localStorage.getItem(SETTINGS_STORAGE_KEY));
if (savedSettings) mergeLegacySettings(savedSettings);
} catch {
// ignore malformed settings, defaults stay in place
}
const savedBrush = migrateLegacyOwner(localStorage.getItem(ACTIVE_BRUSH_STORAGE_KEY));
if (savedBrush === "p1" || savedBrush === "p2") {
state.activeBrush = savedBrush;
}
}
function saveSettings() {
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(state.settings));
}
function saveBrush() {
localStorage.setItem(ACTIVE_BRUSH_STORAGE_KEY, state.activeBrush);
}
function pushUndoBatch(batch) {
undoStack.push(batch);
if (undoStack.length > UNDO_STACK_LIMIT) {
undoStack.shift();
}
}
// `null` stands in for "key was absent" — entries/splitOrder values are
// always non-empty strings, so it can't collide with a real prior value.
function pushUndoRecord(key) {
const record = {
key,
prevEntry: Object.prototype.hasOwnProperty.call(state.entries, key) ? state.entries[key] : null,
prevSplitOrder: Object.prototype.hasOwnProperty.call(state.splitOrder, key) ? state.splitOrder[key] : null,
};
if (currentUndoBatch) {
currentUndoBatch.push(record);
return;
}
pushUndoBatch([record]);
}
// Opens a batch so a run of setOwner() calls (one paint drag) lands in the
// undo stack as a single entry; must be paired with commitUndoBatch() once
// the gesture ends, however it ends (pointerup, or a forced mid-drag render).
function beginUndoBatch() {
currentUndoBatch = [];
}
function commitUndoBatch() {
if (currentUndoBatch === null) return;
const batch = currentUndoBatch;
currentUndoBatch = null;
if (batch.length === 0) return;
pushUndoBatch(batch);
}
// Every place that nulls or replaces paintDrag outside of endPaintDrag's own
// pointerup/pointercancel handling (a forced render, a long-press stealing
// the gesture, a second pointerdown before release) must go through here —
// otherwise a committed drag's undo batch never closes and currentUndoBatch
// wedges open, silently swallowing all undo history from then on.
function flushPaintDrag() {
if (paintDrag && paintDrag.committed) {
commitUndoBatch();
saveEntryState();
}
paintDrag = null;
}
// Restores entries + splitOrder for every date in the popped batch together,
// since a single tap (or drag-painted cell) can change both at once (e.g.
// "both" -> "p2" also clears splitOrder) and reverting only one half would
// leave the diagonal-split bookkeeping wrong. A batch holds one record per
// date (paintCell dedupes touches within a drag), so order doesn't matter.
function undoLastAction() {
if (undoStack.length === 0) return;
const batch = undoStack.pop();
// A batch's dates can be out of view by now (month navigation doesn't
// touch undoStack) — jump back to the batch's month first, or the revert
// would land invisibly on whatever month happens to be on screen.
state.displayedMonth = startOfMonth(parseDateKey(batch[0].key));
for (const { key, prevEntry, prevSplitOrder } of batch) {
if (prevEntry === null) {
delete state.entries[key];
} else {
state.entries[key] = prevEntry;
}
if (prevSplitOrder === null) {
delete state.splitOrder[key];
} else {
state.splitOrder[key] = prevSplitOrder;
}
}
saveEntryState();
render();
}
/** Writes `owner` for one date, including the "both" splitOrder bookkeeping and undo tracking — no save/render, so drag-painting can call this per cell and only flush once. */
function setOwner(date, owner) {
const key = dateKey(date);
pushUndoRecord(key);
if (owner === "none") {
delete state.entries[key];
} else {
state.entries[key] = owner;
}
if (owner === "both") {
// Combining to "both" always layers the active brush on top of the
// other one — true for a single tap (COMBINE_TABLE only reaches "both"
// from the non-active owner) and for drag-painting, where a cell can
// jump straight from "none" to "both" with no prior owner of its own.
// Deriving it from activeBrush instead of the cell's own prior state
// keeps every cell touched by one drag on the same diagonal split.
state.splitOrder[key] = state.activeBrush === "p1" ? "p2" : "p1";
} else {
delete state.splitOrder[key];
}
}
/** A tap only ever changes the one tapped cell, so — unlike a month change or restore — it doesn't need render()'s full grid teardown; repainting that cell plus the chrome that depends on the entries (handover/stats/undo) is enough. */
function applyBrush(date, cell) {
setOwner(date, nextOwner(ownerAt(date), state.activeBrush));
saveEntryState();
applyOwnerVisual(cell, date);
refreshChrome();
}
function setActiveBrush(brush) {
state.activeBrush = brush;
saveBrush();
updateBrushActiveStyles();
}
function updateBrushActiveStyles() {
for (const brush of ["p1", "p2"]) {
const btn = document.getElementById(brush === "p1" ? "p1Brush" : "p2Brush");
const selected = state.activeBrush === brush;
btn.setAttribute("aria-checked", selected ? "true" : "false");
btn.tabIndex = selected ? 0 : -1;
btn.style.background = selected ? `${colorOf(brush)}33` : "";
}
}
/** Textual equivalent of an owner's color, for aria-label — the color/split is otherwise the only cue for who has the child that day. */
function ownerLabelText(owner) {
if (owner === "both") return `${nameOf("p1")} und ${nameOf("p2")}`;
if (owner === "p1" || owner === "p2") return nameOf(owner);
return "nicht zugeteilt";
}
/** First letter of a person's name, upper-cased, for the in-cell owner glyph (falls back to "?" for an empty name). */
function ownerInitial(name) {
return (String(name || "").trim().charAt(0) || "?").toUpperCase();
}
/** Visible, non-color cue for who owns a day — a same-color viewer, grayscale display or B&W print still needs a way to tell owners apart (WCAG 1.4.1), so this renders the owner's initial(s) in the cell alongside the fill color. */
function ownerGlyph(owner) {
if (owner === "both") return `${ownerInitial(nameOf("p1"))}${ownerInitial(nameOf("p2"))}`;
if (owner === "p1" || owner === "p2") return ownerInitial(nameOf(owner));
return "";
}
/** Applies owner-dependent classes/colors to an already-built cell — reused to repaint a cell live during a paint drag without rebuilding the whole grid. Also (re)builds the aria-label, since ownership and note presence are otherwise conveyed only by color/opacity and a bare dot. `info` lets a caller that already has describeCell(date)'s result (buildDayCell) pass it in instead of it being recomputed here. */
function applyOwnerVisual(cell, date, info = describeCell(date)) {
cell.classList.remove("p1", "p2", "both");
const { owner, split, hasNote, key } = info;
if (owner !== "none") {
cell.classList.add(owner);
}
if (split) {
cell.style.setProperty("--cell-first", split.first);
cell.style.setProperty("--cell-second", split.second);
// The diagonal split's ink can't come from the global --p1-ink/--p2-ink:
// .note-dot sits over the base fill (--cell-first) and .num sits over
// the overlay triangle (--cell-second, see styles.css), and either one
// can be either owner's color depending on splitOrder — so each side's
// ink has to be computed straight from that side's own actual fill.
cell.style.setProperty("--cell-first-ink", inkFor(split.first));
const secondInk = inkFor(split.second);
cell.style.setProperty("--cell-second-ink", secondInk);
cell.style.setProperty("--cell-second-ink-on", inkOn(secondInk));
} else {
cell.style.removeProperty("--cell-first");
cell.style.removeProperty("--cell-second");
cell.style.removeProperty("--cell-first-ink");
cell.style.removeProperty("--cell-second-ink");
cell.style.removeProperty("--cell-second-ink-on");
}
// See ownerGlyph() for why this exists; aria-hidden since the cell's own
// aria-label (built below) already announces the owner in full.
let mark = cell.querySelector(".owner-mark");
if (owner === "none") {
if (mark) mark.remove();
} else {
if (!mark) {
mark = document.createElement("span");
mark.className = "owner-mark";
mark.setAttribute("aria-hidden", "true");
cell.appendChild(mark);
}
mark.textContent = ownerGlyph(owner);
}
let label = `${formatFullDate(date)}, ${ownerLabelText(owner)}`;
if (hasNote) label += `, Notiz: ${state.notes[key]}`;
if (cell.classList.contains("outside")) label += ", anderer Monat";
if (cell.classList.contains("today")) label += ", heute";
cell.setAttribute("aria-label", label);
}
/** Builds a single day cell, styled but without the interactive listeners (added separately for the live grid). `todayKey` is computed once by the caller building a whole grid (render(), buildCardContent()) instead of per cell. */
function buildDayCell(date, current, todayKey) {
const cell = document.createElement("button");
cell.className = "day-cell";
if (!current) {
cell.classList.add("outside");
}
const info = describeCell(date);
if (info.key === todayKey) {
cell.classList.add("today");
cell.setAttribute("aria-current", "date");
}
const { key, hasNote } = info;
cell.dataset.date = key; // read back by paint-drag tracking, which finds cells via elementFromPoint rather than their own events
// Documents the Space-to-open-note keyboard shortcut for assistive tech,
// since it has no other discoverable affordance (see the keydown handler
// wired up in render()).
cell.setAttribute("aria-keyshortcuts", "Space");
applyOwnerVisual(cell, date, info);
const num = document.createElement("span");
num.className = "num";
num.textContent = String(date.getDate());
cell.appendChild(num);
if (hasNote) {
const dot = document.createElement("span");
dot.className = "note-dot";
cell.appendChild(dot);
}
return cell;
}
function fillWeekdayRow(container) {
for (const symbol of WEEKDAY_SYMBOLS) {
const span = document.createElement("span");
span.textContent = symbol;
container.appendChild(span);
}
}
/** Builds a static, non-interactive calendar card for `month` — used as the sliding preview during a swipe. */
function buildCardContent(month) {
const card = document.createElement("div");
card.className = "calendar-card";
const title = document.createElement("h1");
title.className = "month-title";
title.textContent = monthTitle(month);
card.appendChild(title);
const weekdayRow = document.createElement("div");
weekdayRow.className = "weekday-row";
fillWeekdayRow(weekdayRow);
card.appendChild(weekdayRow);
const grid = document.createElement("div");
grid.className = "day-grid";
const todayKey = dateKey(new Date());
for (const { date, current } of buildMonthCells(month)) {
grid.appendChild(buildDayCell(date, current, todayKey));
}
card.appendChild(grid);
return card;
}
/** Pushes the currently committed owner colors (and their derived inks) onto the document — shared by render() and the settings dialog's live color-input commit. */
function applyOwnerColorVars() {
document.documentElement.style.setProperty("--p1-color", colorOf("p1"));
document.documentElement.style.setProperty("--p2-color", colorOf("p2"));
applyInkVars();
}
function isDisplayingCurrentMonth() {
return dateKey(state.displayedMonth) === dateKey(startOfMonth(new Date()));
}
function render() {
applyOwnerColorVars();
document.getElementById("monthTitle").textContent = monthTitle(state.displayedMonth);
document.getElementById("todayBtn").disabled = isDisplayingCurrentMonth();
// WEEKDAY_SYMBOLS never changes at runtime, so the row only needs
// building once rather than torn down and rebuilt on every render().
const weekdayRow = document.getElementById("weekdayRow");
if (!weekdayRow.childElementCount) fillWeekdayRow(weekdayRow);
// A render() can land mid-long-press (e.g. switching months while a
// press is pending); the grid below gets rebuilt either way, so any
// scheduled note-dialog-open for the old cell must be cancelled with it.
cancelPendingLongPress();
// Likewise, a paint drag holds a reference to its start cell — if
// something forces a render mid-drag (normally render() only happens
// *after* the drag's own pointerup), that reference would go stale.
// Flush whatever was already painted so it isn't silently lost, then drop it.
flushPaintDrag();
const grid = document.getElementById("dayGrid");
// grid.innerHTML = "" below destroys whichever day-cell button currently
// has keyboard focus (e.g. after Escape-closing the note dialog opened
// via Space), silently stranding focus at <body> (WCAG 2.4.3). Remember
// which date was focused so it can be re-focused once the new cell for
// that date exists.
const focusedDate = document.activeElement?.closest?.(".day-cell")?.dataset.date;
grid.innerHTML = "";
grid.classList.toggle("paint-mode", paintModeActive);
const todayKey = dateKey(new Date());
for (const { date, current } of buildMonthCells(state.displayedMonth)) {
const cell = buildDayCell(date, current, todayKey);
cell.addEventListener("pointerdown", (event) => {
// longPressTimer/longPressFired are shared across all cells; a second
// finger touching down elsewhere must not steal that slot from (or
// reset the swallow flag for) whichever press already owns it.
if (!event.isPrimary) return;
longPressFired = false;
longPressTimer = setTimeout(() => {
longPressFired = true;
// The dialog is taking over this press; drop the pending drag so a
// move afterwards doesn't flip this cell's color behind the modal.
flushPaintDrag();
openNoteDialog(date);
}, 500);
if (paintModeActive) {
// A second pointerdown before the previous drag's pointerup (e.g. a
// right-click while still holding the left button) would otherwise
// clobber an already-committed paintDrag without ever closing it.
flushPaintDrag();
paintDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
lastX: event.clientX,
lastY: event.clientY,
startCell: cell,
committed: false,
touched: new Set(),
};
}
});
const cancelLongPress = (event) => {
// Likewise, a second finger's release must not cancel the primary
// press's still-pending timer.
if (!event.isPrimary) return;
cancelPendingLongPress();
};
cell.addEventListener("pointerup", cancelLongPress);
cell.addEventListener("pointerleave", cancelLongPress);
cell.addEventListener("pointercancel", cancelLongPress);
// Keyboard-only equivalent of the pointer long-press: a day cell is a
// <button>, so Enter/Space would otherwise only ever reach applyBrush
// via the synthetic click, leaving openNoteDialog completely
// unreachable without a pointer (WCAG 2.1.1). Space is free to
// repurpose here since the native click it would trigger is prevented;
// Enter keeps behaving like a tap (applyBrush), Space opens the note.
cell.addEventListener("keydown", (event) => {
if (event.key === " " || event.key === "Spacebar") {
event.preventDefault();
openNoteDialog(date);
}
});
cell.addEventListener("click", () => {
if (longPressFired) {
longPressFired = false;
return;
}
applyBrush(date, cell);
});
grid.appendChild(cell);
}
if (focusedDate) {
grid.querySelector(`[data-date="${focusedDate}"]`)?.focus();
}
refreshChrome();
}
// A "both" cell's diagonal-split colors are baked into its own
// --cell-first/--cell-second at the point it's built (applyOwnerVisual),
// not read live from --p1-color/--p2-color like plain owner cells' fill —
// so a settings-only color change has to explicitly repaint already-rendered
// split cells, or they keep showing the old color until the next full
// render() (month change, reload, ...).
function refreshSplitCellColors() {
for (const cell of document.querySelectorAll("#dayGrid .both")) {
applyOwnerVisual(cell, parseDateKey(cell.dataset.date));
}
}
// The Badging API works independently of push permission/subscription, so
// this keeps the home-screen icon's countdown in sync the moment the app is
// open (initial render or right after an edit) instead of only moving once
// a night when the server-side push fires.
function updateAppBadge(handover) {
if (!("setAppBadge" in navigator)) return;
if (!handover) {
navigator.clearAppBadge();
return;
}
const today = new Date();
const todayMidnight = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const daysLeft = Math.round((handover.date - todayMidnight) / 86400000);
if (daysLeft > 0) {
navigator.setAppBadge(daysLeft);
} else {
navigator.clearAppBadge();
}
}
/** The parts of render() that depend on entries/settings but not on the grid's own DOM — split out so a single-cell repaint (applyBrush) can refresh them without rebuilding all 42 cells. */
function refreshChrome() {
// The handover chip always refers to "today" (the next real-world switch),
// while the stats row below refers to whichever month is displayed — these
// can be very different periods (e.g. browsing to next March). Word each
// row so its time scope is explicit, and reserve the handover row's height
// via a same-shaped placeholder (rather than collapsing it with
// display:none) so the stats row doesn't shift depending on whether a
// handover happens to exist right now.
const handover = findNextHandover(new Date());
updateAppBadge(handover);
const handoverRow = document.getElementById("handoverRow");
const handoverPlaceholder = document.getElementById("handoverPlaceholder");
if (handover) {
const name = nameOf(handover.toOwner);
const color = colorOf(handover.toOwner);
handoverRow.style.display = "";
handoverPlaceholder.style.display = "none";
document.getElementById("handoverDot").style.background = color;
const handoverDate = handover.date.toLocaleDateString(LOCALE, { weekday: "short", day: "numeric", month: "short" });
document.getElementById("handoverLabel").textContent = `Wechsel zu ${name} am ${handoverDate}`;
} else {
handoverRow.style.display = "none";
handoverPlaceholder.style.display = "";
}
// The displayed month is already spelled out in #monthTitle right above
// this row, so repeating it verbatim in every chip just adds redundant,
// wrap-prone text — each chip only needs to state its own name/count.
const stats = monthStats(state.displayedMonth);
for (const brush of ["p1", "p2"]) {
document.getElementById(`${brush}StatDot`).style.background = colorOf(brush);
document.getElementById(`${brush}StatLabel`).textContent = `${nameOf(brush)} ${formatNights(stats[brush])} Nächte`;
document.getElementById(`${brush}Label`).textContent = nameOf(brush);
document.getElementById(`${brush}Dot`).style.background = colorOf(brush);
}
updateBrushActiveStyles();
document.getElementById("undoBtn").disabled = undoStack.length === 0;
}
function drawCellBackground(ctx, rect, info) {
const { x, y, w, h } = rect;
const solid = { none: LIGHT_INK, p1: colorOf("p1"), p2: colorOf("p2") }[info.owner];
if (solid) {
ctx.fillStyle = solid;
ctx.fillRect(x, y, w, h);
return;
}
// both: see splitColorsFor() for how first/second are decided
ctx.fillStyle = info.split.first;
ctx.fillRect(x, y, w, h);
ctx.fillStyle = info.split.second;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + w, y);
ctx.lineTo(x + w, y + h);
ctx.closePath();
ctx.fill();
}
const CANVAS_FONT_FAMILY = 'ui-rounded, "SF Pro Rounded", -apple-system, BlinkMacSystemFont, sans-serif';
function canvasFont(weight, size) {
return `${weight} ${size}px ${CANVAS_FONT_FAMILY}`;
}
function drawCalendarToCanvas(canvas) {
const cells = buildMonthCells(state.displayedMonth);
const cols = 7;
const rows = cells.length / 7;
const cellW = 108;
const cellH = 132;
const headerH = 110;
const weekdayH = 56;
const width = cellW * cols;
const height = headerH + weekdayH + cellH * rows;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.fillStyle = LIGHT_INK;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = DARK_INK;
ctx.font = canvasFont(800, 46);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(monthTitle(state.displayedMonth), width / 2, headerH / 2);
ctx.fillStyle = "#3a3a3f";
ctx.fillRect(0, headerH, width, weekdayH);
ctx.fillStyle = LIGHT_INK;
ctx.font = canvasFont(700, 20);
WEEKDAY_SYMBOLS.forEach((symbol, i) => {
ctx.fillText(symbol, i * cellW + cellW / 2, headerH + weekdayH / 2);
});
cells.forEach(({ date, current }, index) => {
const col = index % 7;
const row = Math.floor(index / 7);
const x = col * cellW;
const y = headerH + weekdayH + row * cellH;
ctx.globalAlpha = current ? 1 : OUTSIDE_MONTH_OPACITY;
const cellInfo = describeCell(date);
drawCellBackground(ctx, { x, y, w: cellW, h: cellH }, cellInfo);
ctx.fillStyle = DARK_INK;
ctx.font = canvasFont(600, 24);
ctx.textAlign = "left";
ctx.textBaseline = "top";
ctx.fillText(String(date.getDate()), x + 10, y + 8);
if (cellInfo.hasNote) {
ctx.beginPath();
ctx.fillStyle = DARK_INK;
ctx.arc(x + cellW - 14, y + cellH - 14, 5, 0, Math.PI * 2);
ctx.fill();