-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
2016 lines (1857 loc) · 81.5 KB
/
Copy pathapp.js
File metadata and controls
2016 lines (1857 loc) · 81.5 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
/* ============================================================
UC systemwide holiday table
-----------------------------------------------------------
Baseline: UCOP systemwide holiday calendar (authoritative).
Plus: Winter Curtailment days commonly observed across UC
campuses. Individual campuses may add or remove days — this
list takes an inclusive "union" approach so the calculator
works for employees at any UC location (UCOP, UCB, UCD, UCI,
UCLA, UCM, UCR, UCSD, UCSF, UCSB, UCSC). Verify against your
local HR if a specific date matters.
Source: ucop.edu/local-human-resources/resources/holiday-calendar.html
============================================================ */
const HOLIDAYS = new Set([
/* 2023 */
"2023-01-02", // New Year Holiday (observed; Jan 1 was Sunday)
"2023-01-16", // Martin Luther King Jr. Day
"2023-02-20", // Presidents' Day
"2023-03-31", // Cesar Chavez Day
"2023-05-29", // Memorial Day
"2023-06-19", // Juneteenth
"2023-07-04", // Independence Day
"2023-09-04", // Labor Day
"2023-11-10", // Veterans Day (observed)
"2023-11-23", // Thanksgiving
"2023-11-24", // Day after Thanksgiving
"2023-12-25", // Christmas Day
"2023-12-26", // Winter Holiday
"2023-12-27", // Winter Curtailment
"2023-12-28", // Winter Curtailment
"2023-12-29", // Winter Curtailment
/* 2024 */
"2024-01-01", // New Year Holiday
"2024-01-02", // Winter Curtailment (some campuses)
"2024-01-15", // Martin Luther King Jr. Day
"2024-02-19", // Presidents' Day
"2024-03-29", // Cesar Chavez (observed; Mar 31 was Sunday)
"2024-05-27", // Memorial Day
"2024-06-19", // Juneteenth
"2024-07-04", // Independence Day
"2024-09-02", // Labor Day
"2024-11-11", // Veterans Day
"2024-11-28", // Thanksgiving
"2024-11-29", // Day after Thanksgiving
"2024-12-24", // Winter Holiday
"2024-12-25", // Christmas Day
"2024-12-26", // Winter Curtailment
"2024-12-27", // Winter Curtailment
"2024-12-30", // Winter Curtailment
"2024-12-31", // New Year Holiday (eve)
/* 2025 */
"2025-01-01", // New Year Holiday
"2025-01-02", // Winter Curtailment (some campuses)
"2025-01-20", // Martin Luther King Jr. Day
"2025-02-17", // Presidents' Day
"2025-03-28", // Cesar Chavez (observed)
"2025-05-26", // Memorial Day
"2025-06-19", // Juneteenth
"2025-07-04", // Independence Day
"2025-09-01", // Labor Day
"2025-11-11", // Veterans Day
"2025-11-27", // Thanksgiving
"2025-11-28", // Day after Thanksgiving
"2025-12-22", // Winter Curtailment (UCSC, UCB, others)
"2025-12-23", // Winter Curtailment (UCSC, UCB, others)
"2025-12-24", // Winter Holiday
"2025-12-25", // Christmas Day
"2025-12-26", // Winter Curtailment
"2025-12-29", // Winter Curtailment
"2025-12-30", // Winter Curtailment
"2025-12-31", // New Year Eve Holiday
/* 2026 — UCOP authoritative */
"2026-01-01", // New Year Holiday
"2026-01-02", // Winter Curtailment extension (UCSC, others)
"2026-01-19", // Martin Luther King Jr. Day
"2026-02-16", // Presidents' Day
"2026-03-27", // Cesar Chavez Holiday
"2026-05-25", // Memorial Day
"2026-06-19", // Juneteenth
"2026-07-03", // Independence Day (observed; Jul 4 is Saturday)
"2026-09-07", // Labor Day
"2026-11-11", // Veterans Day
"2026-11-26", // Thanksgiving
"2026-11-27", // Day after Thanksgiving
"2026-12-24", // Winter Holiday
"2026-12-25", // Christmas Day
"2026-12-28", // Winter Curtailment
"2026-12-29", // Winter Curtailment
"2026-12-30", // Winter Curtailment
"2026-12-31", // New Year Holiday
/* 2027 — projected using standard UC observation rules */
"2027-01-01", // New Year Holiday
"2027-01-18", // Martin Luther King Jr. Day
"2027-02-15", // Presidents' Day
"2027-03-31", // Cesar Chavez Day
"2027-05-31", // Memorial Day
"2027-06-18", // Juneteenth (observed; Jun 19 is Saturday)
"2027-07-05", // Independence Day (observed; Jul 4 is Sunday)
"2027-09-06", // Labor Day
"2027-11-11", // Veterans Day
"2027-11-25", // Thanksgiving
"2027-11-26", // Day after Thanksgiving
"2027-12-24" // Christmas Day (observed; Dec 25 is Saturday)
]);
/* ============================================================
Date utilities — ISO strings throughout to avoid TZ bugs
============================================================ */
const ISO = d => {
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const day = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
};
const parseISO = s => {
if (!s) return null;
const [y,m,d] = s.split('-').map(Number);
return new Date(y, m-1, d);
};
const addDays = (d, n) => {
const r = new Date(d.getFullYear(), d.getMonth(), d.getDate());
r.setDate(r.getDate() + n);
return r;
};
const isWeekend = d => { const w = d.getDay(); return w === 0 || w === 6; };
const isHoliday = d => HOLIDAYS.has(ISO(d));
const isBusinessDay = d => !isWeekend(d) && !isHoliday(d);
/* Excel WORKDAY.INTL(start, n, 1, holidays): returns the date that is n
working days after start, skipping weekends and holidays. n may be 0. */
const addWorkdays = (start, n) => {
if (n <= 0) return new Date(start.getFullYear(), start.getMonth(), start.getDate());
let d = new Date(start.getFullYear(), start.getMonth(), start.getDate());
let count = 0;
while (count < n) {
d = addDays(d, 1);
if (isBusinessDay(d)) count++;
}
return d;
};
/* PFCB end date: counts `n` calendar days forward from start (inclusive) but
does NOT count holidays toward the total. Each holiday inside the window
pushes the end out by one calendar day, so the employee gets the full 8
weeks of PFCB without holidays being deducted. Weekends still count. */
const addPfcbDays = (start, n) => {
let d = new Date(start.getFullYear(), start.getMonth(), start.getDate());
let counted = 0;
while (true) {
if (!isHoliday(d)) counted++;
if (counted >= n) return d;
d = addDays(d, 1);
}
};
/* Excel NETWORKDAYS.INTL(start, end, 1, holidays): inclusive count of
business days between two dates. */
const networkdays = (start, end) => {
if (!start || !end || end < start) return 0;
let count = 0;
let d = new Date(start.getFullYear(), start.getMonth(), start.getDate());
const stop = new Date(end.getFullYear(), end.getMonth(), end.getDate());
while (d <= stop) {
if (isBusinessDay(d)) count++;
d = addDays(d, 1);
}
return count;
};
const fmtLong = d => d ? d.toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric', year:'numeric' }) : '';
const fmtShort = d => d ? d.toLocaleDateString(undefined, { month:'short', day:'numeric', year:'numeric' }) : '';
const fmtMonthYear = d => d.toLocaleDateString(undefined, { month:'long', year:'numeric' });
/* ============================================================
Core calculator — ports the Excel formulas into plain JS
============================================================ */
function calculate(input) {
const {
lastDay, dueDate, actualBirth, returnDate,
deliveryType, sickHours, vacHours,
waitingPeriodDays, appliesLincoln,
pdlEligible, fmlEligible, cfraEligible,
pfcbWeeks, pfcbStart,
cclWeeks, cclAnchor,
scheduleType, hoursPerDay, daysPerWeek, fallbackStrategy,
employeeType
} = input;
/* Postdocs follow different plan rules: a 7-day disability waiting period,
short-term disability (STD) through The Standard instead of Lincoln
Financial, PPFL instead of PFCB (8 weeks, no FMLA/CFRA eligibility
required, per birth), and no Child Caring Leave. The PDL span is
unchanged — 42 days natural / 56 days C-section already matches the
postdoc 6–8 week guideline. */
const isPostdoc = employeeType === 'postdoc';
/* `lastDay` is the field id, but the field semantically represents the
Leave Start Date — the first day of leave. We keep the variable name
for diff stability while the meaning is documented here. All downstream
date math assumes this is day 1 of leave, NOT the last day at work. */
const leaveStart = lastDay;
/* --- Schedule-derived constants ---
Regular employees (5×8): 8 hr/day, 5 days/week, sick cap 22 workdays.
Variable employees (e.g. 3×12): user-specified hr/day and days/week,
sick cap 30 workdays.
waitingWorkdays = working days that fall within the calendar waiting
period for this schedule — e.g. 10 for regular staff (14-day wait),
5 for regular postdocs (7-day wait), ~6 for 3×12 staff. */
const isVariable = scheduleType === 'variable';
const effHrsPerDay = isVariable && hoursPerDay > 0 ? hoursPerDay : 8;
const effDaysPerWeek = isVariable && daysPerWeek > 0 ? daysPerWeek : 5;
const maxSickCap = isVariable ? 30 : 22;
const effWaitingDays = waitingPeriodDays || (isPostdoc ? 7 : 14);
const waitingWorkdays = Math.ceil(effWaitingDays * effDaysPerWeek / 7);
const sickDaysRaw = Math.floor((sickHours || 0) / effHrsPerDay);
const vacDays = Math.floor((vacHours || 0) / effHrsPerDay);
const sickDays = Math.min(sickDaysRaw, maxSickCap);
const sickCapped = sickDaysRaw > maxSickCap;
/* --- SICK leave span ---
Starts on the leave-start date (assumed to be a workday) and runs for
`sickDays` working days inclusive. addWorkdays(d, 0) returns d unchanged
if d is itself a workday. */
let sickBegin = null, sickEnd = null;
if (sickDays >= 1) {
sickBegin = addWorkdays(leaveStart, 0);
sickEnd = addWorkdays(sickBegin, sickDays - 1);
}
/* --- VACATION span ---
Vacation is used only when all three apply:
1. The user has vacation hours
2. Sick leave doesn't cover the waiting period (sick < waitingWorkdays)
3. The user elected "Use vacation" as the fallback strategy. */
let vacBegin = null, vacEnd = null, vacNote = '';
const sickCoversWaiting = sickDays >= waitingWorkdays;
const useVacation = vacDays >= 1 && !sickCoversWaiting && fallbackStrategy === 'vacation';
if (useVacation) {
vacBegin = sickDays === 0
? addWorkdays(leaveStart, 0)
: addWorkdays(sickEnd, 1);
vacEnd = addWorkdays(vacBegin, vacDays - 1);
} else if (vacDays >= 1 && sickCoversWaiting) {
/* Postdocs hold both vacation and PTO, so their balance is labelled
VAC/PTO throughout. */
vacNote = isPostdoc
? 'Using vacation/PTO is not necessary — sick leave covers the waiting period'
: 'Using vacation is not necessary — sick leave covers the waiting period';
} else if (vacDays >= 1 && fallbackStrategy === 'lns') {
vacNote = isPostdoc
? 'VAC/PTO available but not used (elected to go without pay)'
: 'Vacation available but not used (elected to go without pay)';
}
/* --- WAITING PERIOD ---
Only applies when the employee is applying for disability income through
their carrier (Lincoln Financial for staff — 14 days; The Standard for
postdocs — 7 days). Calendar window starting on the leave start date.
If sick covers or exceeds the waiting-period workdays, the effective
"still-waiting-for-disability-pay" window extends until sick runs out
(capped at maxSickCap) — this is also what lets sick leave be used
during disability leave while no disability benefits are being paid.
When the employee is not applying for disability, there is no waiting
period. `appliesLincoln` keeps its historical name but means "applying
for carrier disability" in both modes. */
let waitBegin = null, waitEnd = null;
if (appliesLincoln) {
waitBegin = leaveStart;
if (sickDays <= waitingWorkdays) {
waitEnd = addDays(waitBegin, effWaitingDays - 1);
} else {
waitEnd = sickEnd;
}
}
/* --- Lincoln Financial CLAIM FILE date: 28 days before leave start.
UCD recommends 1–2 weeks before; plan ceiling is 30 days.
Only relevant when applying for Lincoln disability. --- */
const fileClaim = appliesLincoln ? addDays(leaveStart, -28) : null;
/* --- PDL (row 62): 42 natural / 56 C-section.
Starts on leave-start date. Ends `pdlDurationDays` calendar days
after birth (inclusive of the birth day), so we subtract 1 from
the addDays offset. Only displayed when the employee marks PDL
eligibility. --- */
let pdlBegin = null, pdlEnd = null;
const pdlDurationDays = deliveryType === 'C-section' ? 56 : 42;
const pdlAnchor = actualBirth || dueDate;
if (pdlEligible && pdlAnchor) {
pdlBegin = leaveStart;
pdlEnd = addDays(pdlAnchor, pdlDurationDays - 1);
}
/* --- Lincoln Financial income (row 59):
Begins day after waiting period ends, ends when PDL ends.
If PDL ends before waiting period completes, income never pays. --- */
let lincBegin = null, lincEnd = null, lincNote = '';
if (!appliesLincoln || !pdlEnd) {
/* Not applying for Lincoln disability, or no PDL window to anchor to. */
lincNote = '';
} else if (pdlEnd < waitEnd) {
lincNote = 'Disability ends before benefit pays';
} else {
lincBegin = addDays(waitEnd, 1);
lincEnd = pdlEnd;
}
/* --- FMLA (row 65): 84 days from FMLA begin (= leave start).
Capped at Dec 31 of start year if it would cross year boundary. --- */
let fmlBegin = null, fmlEnd = null, fmlCapped = false;
let fmlNewYearBegin = null, fmlNewYearEnd = null;
if (fmlEligible) {
fmlBegin = leaveStart;
const naive = addDays(fmlBegin, 83); // 84 days inclusive
if (naive.getFullYear() > fmlBegin.getFullYear()) {
fmlCapped = true;
fmlEnd = new Date(fmlBegin.getFullYear(), 11, 31);
fmlNewYearBegin = new Date(fmlBegin.getFullYear()+1, 0, 1);
fmlNewYearEnd = addDays(fmlNewYearBegin, 83);
} else {
fmlEnd = naive;
}
}
/* --- CFRA (row 71): 84 days starting day after PDL ends (if PDL is in
play), else day after leave starts (CFRA-only without PDL). --- */
let cfraBegin = null, cfraEnd = null;
if (cfraEligible) {
cfraBegin = pdlEnd ? addDays(pdlEnd, 1) : leaveStart;
cfraEnd = addDays(cfraBegin, 83);
}
/* --- PFCB (row 74) ---
If the user entered a number of weeks but no start date, default the
start to the day after PDL ends (the natural bonding window beginning). */
let pfcbStartResolved = pfcbStart || null;
let pfcbEnd = null;
let pfcbStartInferred = false;
if (pfcbWeeks > 0) {
if (!pfcbStartResolved && pdlEnd) {
pfcbStartResolved = addDays(pdlEnd, 1);
pfcbStartInferred = true;
}
if (pfcbStartResolved) {
/* Holidays inside the PFCB window are not deducted from the 8 weeks —
each one extends the end date by a day. */
pfcbEnd = addPfcbDays(pfcbStartResolved, pfcbWeeks * 7);
}
}
/* --- CCL (Child Caring Leave) ---
Up to 12 weeks of unpaid leave that begins after the chosen anchor stage
ends. The anchor varies by campus — some start CCL after PDL ends, some
after FML, some after CFRA. If the selected anchor isn't available in
this scenario (e.g. user picked "after FML" but isn't FML-eligible), we
fall back to the next available anchor in the list and note the change. */
let cclBegin = null, cclEnd = null, cclAnchorUsed = null;
const cclAnchorRequested = cclAnchor;
/* CCL is a staff benefit — postdocs never get a CCL block even if a
value survived in the hidden field. */
if (cclWeeks > 0 && !isPostdoc) {
const available = {
cfra: cfraEnd,
fml: fmlNewYearEnd || fmlEnd,
pdl: pdlEnd
};
const fallbackOrder = {
cfra: ['cfra', 'fml', 'pdl'],
fml: ['fml', 'pdl'],
pdl: ['pdl']
};
const tryList = fallbackOrder[cclAnchor] || ['pdl'];
for (const key of tryList) {
if (available[key]) { cclAnchorUsed = key; break; }
}
const anchorDate = cclAnchorUsed ? available[cclAnchorUsed] : null;
if (anchorDate) {
cclBegin = addDays(anchorDate, 1);
cclEnd = addDays(cclBegin, cclWeeks * 7 - 1);
}
}
/* --- End of PIE (L30): 31 days after actual birth --- */
const endPIE = actualBirth ? addDays(actualBirth, 31) : null;
/* --- Baby's first birthday: same calendar date, one year later.
Using setFullYear(+1) rather than addDays(365/366) because the
literal number of days between two yearly anniversaries is 365 in
non-leap spans and 366 across a Feb 29, and setFullYear returns
the true calendar anniversary in both cases. --- */
let firstBday = null;
if (actualBirth) {
firstBday = new Date(
actualBirth.getFullYear() + 1,
actualBirth.getMonth(),
actualBirth.getDate()
);
}
return {
isPostdoc, fmlEligible,
sickDays, sickDaysRaw, sickCapped, maxSickCap, vacDays, vacNote, lincNote, fmlCapped,
scheduleType, effHrsPerDay, effDaysPerWeek, waitingWorkdays, fallbackStrategy,
sickBegin, sickEnd, vacBegin, vacEnd,
waitBegin, waitEnd,
fileClaim,
pdlBegin, pdlEnd,
lincBegin, lincEnd,
fmlBegin, fmlEnd, fmlNewYearBegin, fmlNewYearEnd,
cfraBegin, cfraEnd,
pfcbStart: pfcbStartResolved, pfcbEnd, pfcbWeeks, pfcbStartInferred,
cclBegin, cclEnd, cclWeeks: isPostdoc ? 0 : cclWeeks,
cclAnchorRequested, cclAnchorUsed,
endPIE, firstBday,
lastDay, dueDate, actualBirth, returnDate
};
}
/* ============================================================
Render helpers
============================================================ */
function durationDays(start, end) {
if (!start || !end) return '';
const ms = end - start;
const days = Math.round(ms / (1000*60*60*24)) + 1;
return days + ' calendar day' + (days === 1 ? '' : 's');
}
/* Tiny DOM helpers used by renderTimeline and renderSummary. Keeping
these generic means we never build HTML strings from dynamic values —
which eliminates the class of bugs where a stray angle bracket in
user data becomes an injection sink. textContent is inherently safe:
whatever string you give it is rendered as literal text. */
function createEl(tag, className, text) {
const el = document.createElement(tag);
if (className) el.className = className;
if (text != null && text !== '') el.textContent = String(text);
return el;
}
function clearChildren(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
function renderTimeline(r) {
const list = document.getElementById('timelineList');
clearChildren(list);
/* Push a row with begin/optional-end dates. */
const push = (cls, label, meta, begin, end) => {
if (!begin && !end) return;
const datesText = begin && end && +begin !== +end
? fmtShort(begin) + ' → ' + fmtShort(end)
: fmtShort(begin || end);
const durText = (begin && end) ? durationDays(begin, end) : '';
const li = createEl('li', 'cat-' + cls);
li.appendChild(createEl('span', 'label', label));
const datesSpan = createEl('span', 'dates', datesText);
if (durText) datesSpan.appendChild(createEl('span', 'dur', durText));
li.appendChild(datesSpan);
if (meta) li.appendChild(createEl('span', 'meta', meta));
list.appendChild(li);
};
/* Dateless note card — used for "vacation not necessary", "LNS gap", etc. */
const pushNote = (cls, label, meta) => {
const li = createEl('li', 'cat-' + cls);
li.appendChild(createEl('span', 'label', label));
li.appendChild(createEl('span', 'meta', meta));
list.appendChild(li);
};
/* Order requested by the service-channel management team: the primary
leave blocks come first, in this sequence — Leave start date, Disability
waiting period, Pregnancy Disability Leave, Family & Medical Leave, then
CFRA. Supporting milestones and pay items follow. */
push('milestone', 'Leave start date', '', r.lastDay, null);
push('wait', 'Disability waiting period', '', r.waitBegin, r.waitEnd);
if (r.pdlBegin) {
push('pdl', 'Pregnancy Disability Leave (PDL)',
r.actualBirth ? 'Anchored to actual birth date' : 'Anchored to estimated due date',
r.pdlBegin, r.pdlEnd);
}
if (r.fmlBegin) {
push('fml', 'Family & Medical Leave Act (FMLA)',
r.fmlCapped ? 'Capped at calendar year end — balance carries over' : '',
r.fmlBegin, r.fmlEnd);
if (r.fmlNewYearBegin) push('fml', 'FMLA — new calendar year', '', r.fmlNewYearBegin, r.fmlNewYearEnd);
}
if (r.cfraBegin) push('cfra', 'California Family Rights Act (CFRA)', '', r.cfraBegin, r.cfraEnd);
/* Carrier naming differs by employee type: staff use Lincoln Financial,
postdocs apply for short-term disability (STD) through The Standard. */
const carrierIncomeLabel = r.isPostdoc
? 'The Standard short-term disability (STD) income'
: 'Lincoln Financial disability income';
if (r.lincBegin) push('linc', carrierIncomeLabel, '', r.lincBegin, r.lincEnd);
else if (r.lincNote) pushNote('linc', carrierIncomeLabel, r.lincNote);
if (r.pfcbStart) {
const bondingLabel = r.isPostdoc
? 'Postdoc Paid Family Leave (PPFL)'
: 'Pay for Family Care and Bonding (PFCB)';
let pfcbMeta = r.pfcbWeeks + ' week' + (r.pfcbWeeks === 1 ? '' : 's')
+ (r.pfcbStartInferred ? ' · starts day after PDL ends (default)' : '');
if (r.isPostdoc) {
pfcbMeta += ' · per birth — may be taken up until the child\'s first birthday';
pfcbMeta += r.fmlEligible
? ' · sick leave may not be used for pay during family leave (PPFL or VAC/PTO only)'
: ' · personal leave paid via PPFL — no departmental approval needed';
}
push('pfcb', bondingLabel, pfcbMeta, r.pfcbStart, r.pfcbEnd);
}
if (r.fileClaim) push('linc',
r.isPostdoc ? 'File STD claim with The Standard' : 'File Lincoln Financial claim',
'May file up to 30 days before leave begins. UCD recommends 1–2 weeks before. Requires medical certification; the LOA team processes the leave.',
r.fileClaim, null);
if (r.sickBegin) {
let sickMeta = r.sickDays + ' calendar day' + (r.sickDays===1?'':'s') + ' used';
if (r.sickCapped) {
sickMeta += ' (capped at ' + r.maxSickCap + ' — you have ' + r.sickDaysRaw + ' total)';
}
push('sick', 'Sick leave', sickMeta, r.sickBegin, r.sickEnd);
}
const vacLabel = r.isPostdoc ? 'VAC/PTO' : 'Vacation leave';
if (r.vacBegin) push('vac', vacLabel, r.vacDays + ' day' + (r.vacDays===1?'':'s') + ' used', r.vacBegin, r.vacEnd);
else if (r.vacNote) pushNote('vac', vacLabel, r.vacNote);
if (r.waitBegin && r.sickDays < r.waitingWorkdays && r.fallbackStrategy === 'lns') {
const gapDays = r.waitingWorkdays - r.sickDays;
pushNote('wait', 'Leave without pay (waiting-period gap)',
'Sick covers ' + r.sickDays + ' of ' + r.waitingWorkdays + ' waiting-period working days. ' +
gapDays + ' working day' + (gapDays===1?'':'s') + ' will be unpaid.');
}
push('milestone', 'Estimated due date', '', r.dueDate, null);
if (r.actualBirth) push('milestone', 'Actual birth date', '', r.actualBirth, null);
if (r.cclBegin) {
const anchorLabel = { pdl: 'PDL', fml: 'FMLA', cfra: 'CFRA' }[r.cclAnchorUsed];
const fellBack = r.cclAnchorUsed !== r.cclAnchorRequested;
const requestedLabel = { pdl: 'PDL', fml: 'FMLA', cfra: 'CFRA' }[r.cclAnchorRequested];
let cclMeta = r.cclWeeks + ' week' + (r.cclWeeks === 1 ? '' : 's')
+ ' · starts day after ' + anchorLabel + ' ends';
if (fellBack) {
cclMeta += ' (fallback — ' + requestedLabel + ' not available in this scenario)';
}
push('ccl', 'Child Caring Leave (CCL)', cclMeta, r.cclBegin, r.cclEnd);
} else if (r.cclWeeks > 0) {
pushNote('ccl', 'Child Caring Leave (CCL)',
r.cclWeeks + ' week' + (r.cclWeeks === 1 ? '' : 's')
+ ' requested — no anchor date available. Enter at least a last day worked and due date.');
}
if (r.returnDate) push('milestone', 'Estimated return to work', '', r.returnDate, null);
if (r.endPIE) push('milestone', 'End of PIE (enroll baby by)', '31 days after birth', r.endPIE, null);
if (r.firstBday) push('milestone', "Baby's first birthday", 'End of parental bonding window', r.firstBday, null);
}
function renderSummary(r) {
const container = document.getElementById('summaryCallout');
clearChildren(container);
let isFirst = true;
const addLine = (label, body) => {
if (!isFirst) container.appendChild(document.createTextNode(' '));
isFirst = false;
const strong = createEl('strong', null, label + ':');
container.appendChild(strong);
container.appendChild(document.createTextNode(' ' + body));
};
if (r.pdlBegin) {
addLine('Pregnancy Disability Leave', fmtShort(r.pdlBegin) + ' → ' + fmtShort(r.pdlEnd) + '.');
}
if (r.fmlBegin) {
addLine('FMLA', fmtShort(r.fmlBegin) + ' → ' + fmtShort(r.fmlEnd)
+ (r.fmlCapped ? ' (calendar year cap)' : '') + '.');
}
if (r.cfraBegin) {
addLine('CFRA', fmtShort(r.cfraBegin) + ' → ' + fmtShort(r.cfraEnd) + '.');
}
if (r.lincBegin) {
addLine(r.isPostdoc ? 'The Standard STD income' : 'Lincoln Financial income',
fmtShort(r.lincBegin) + ' → ' + fmtShort(r.lincEnd) + '.');
}
if (r.pfcbStart) {
addLine(r.isPostdoc ? 'PPFL' : 'PFCB', fmtShort(r.pfcbStart) + ' → ' + fmtShort(r.pfcbEnd)
+ ' (' + r.pfcbWeeks + ' week' + (r.pfcbWeeks === 1 ? '' : 's') + ').');
}
if (r.cclBegin) {
const anchorLabel = { pdl: 'PDL', fml: 'FMLA', cfra: 'CFRA' }[r.cclAnchorUsed];
addLine('CCL', fmtShort(r.cclBegin) + ' → ' + fmtShort(r.cclEnd)
+ ' (' + r.cclWeeks + ' week' + (r.cclWeeks === 1 ? '' : 's')
+ ', after ' + anchorLabel + ').');
}
}
/* ============================================================
Calendar rendering
============================================================ */
function buildEventIndex(r) {
/* Map ISO date → array of {type, label}.
Precedence (visual): category background (sick/vac/lns), then chips. */
const idx = new Map();
const add = (d, type, label) => {
if (!d) return;
const key = ISO(d);
if (!idx.has(key)) idx.set(key, { chips: [], cats: new Set(), labels: [] });
const bucket = idx.get(key);
if (type === 'cat') bucket.cats.add(label);
else bucket.chips.push({ type, label });
bucket.labels.push(label);
};
/* categories — fill in the ranges */
const fillRange = (begin, end, cat) => {
if (!begin || !end) return;
let d = new Date(begin);
while (d <= end) {
if (isBusinessDay(d)) add(new Date(d), 'cat', cat);
d = addDays(d, 1);
}
};
if (r.sickBegin) fillRange(r.sickBegin, r.sickEnd, 'sick');
if (r.vacBegin) fillRange(r.vacBegin, r.vacEnd, 'vac');
/* leave-no-salary = within waiting period but outside sick/vac */
if (r.waitBegin) {
let d = new Date(r.waitBegin);
while (d <= r.waitEnd) {
if (isBusinessDay(d)) {
const key = ISO(d);
const ex = idx.get(key);
if (!ex || (!ex.cats.has('sick') && !ex.cats.has('vac'))) {
add(new Date(d), 'cat', 'lns');
}
}
d = addDays(d, 1);
}
}
/* event chips — only at begin/end dates to keep calendar readable */
const mark = (d, type, label) => add(d, type, label);
mark(r.lastDay, 'milestone', 'Leave start date');
mark(r.dueDate, 'milestone', 'Est due date');
mark(r.actualBirth, 'milestone', 'Actual birth');
mark(r.returnDate, 'milestone', 'Return to work');
mark(r.endPIE, 'milestone', 'End of PIE');
mark(r.firstBday, 'milestone', 'Baby 1st bday');
mark(r.fileClaim, 'linc', 'File claim');
mark(r.waitBegin, 'wait', 'Waiting begins');
mark(r.waitEnd, 'wait', 'Waiting ends');
const carrierChip = r.isPostdoc ? 'STD' : 'Lincoln';
mark(r.lincBegin, 'linc', carrierChip + ' begins');
mark(r.lincEnd, 'linc', carrierChip + ' ends');
mark(r.pdlBegin, 'pdl', 'PDL begins');
mark(r.pdlEnd, 'pdl', 'PDL ends');
mark(r.fmlBegin, 'fml', 'FML begins');
mark(r.fmlEnd, 'fml', r.fmlCapped ? 'FML ends (CY)' : 'FML ends');
mark(r.fmlNewYearBegin, 'fml', 'FML resumes');
mark(r.fmlNewYearEnd, 'fml', 'FML ends');
mark(r.cfraBegin, 'cfra', 'CFRA begins');
mark(r.cfraEnd, 'cfra', 'CFRA ends');
const bondingChip = r.isPostdoc ? 'PPFL' : 'PFCB';
mark(r.pfcbStart, 'pfcb', bondingChip + ' begins');
mark(r.pfcbEnd, 'pfcb', bondingChip + ' ends');
mark(r.cclBegin, 'ccl', 'CCL begins');
mark(r.cclEnd, 'ccl', 'CCL ends');
return idx;
}
function renderCalendars(r) {
const wrap = document.getElementById('calendars');
clearChildren(wrap);
/* Determine range: one month before lastDay → through latest event */
const anchor = r.lastDay;
const start = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1);
const candidates = [
r.pdlEnd, r.fmlEnd, r.fmlNewYearEnd, r.cfraEnd, r.pfcbEnd, r.cclEnd,
r.endPIE, r.firstBday, r.lincEnd, r.returnDate
].filter(Boolean);
let maxDate = candidates.reduce((a,b) => (a > b ? a : b), anchor);
const end = new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);
const events = buildEventIndex(r);
let cursor = new Date(start.getFullYear(), start.getMonth(), 1);
const endMonth = new Date(end.getFullYear(), end.getMonth(), 1);
while (cursor <= endMonth) {
wrap.appendChild(buildMonth(cursor, events));
cursor = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1);
}
}
function buildMonth(monthDate, events) {
const container = document.createElement('section');
container.className = 'cal';
const table = document.createElement('table');
table.setAttribute('role', 'table');
const caption = document.createElement('caption');
caption.textContent = fmtMonthYear(monthDate);
table.appendChild(caption);
const thead = document.createElement('thead');
const trh = document.createElement('tr');
['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d => {
const th = document.createElement('th');
th.scope = 'col'; th.textContent = d;
trh.appendChild(th);
});
thead.appendChild(trh);
table.appendChild(thead);
const tbody = document.createElement('tbody');
const year = monthDate.getFullYear();
const month = monthDate.getMonth();
const firstWeekday = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month+1, 0).getDate();
let tr = document.createElement('tr');
/* leading empties */
for (let i = 0; i < firstWeekday; i++) {
const td = document.createElement('td');
td.className = 'empty';
tr.appendChild(td);
}
for (let d = 1; d <= daysInMonth; d++) {
const date = new Date(year, month, d);
const key = ISO(date);
const td = document.createElement('td');
td.className = 'day';
if (isWeekend(date)) td.classList.add('weekend');
if (isHoliday(date)) td.classList.add('holiday');
const ev = events.get(key);
if (ev) {
if (ev.cats.has('sick')) td.classList.add('c-sick');
else if (ev.cats.has('vac')) td.classList.add('c-vac');
else if (ev.cats.has('lns')) td.classList.add('c-lns');
}
const dnum = document.createElement('span');
dnum.className = 'd';
dnum.textContent = d;
td.appendChild(dnum);
if (ev) {
/* category label (sick / vac) as small text */
if (ev.cats.has('sick')) {
const cat = document.createElement('span');
cat.className = 'cat'; cat.textContent = 'SICK';
td.appendChild(cat);
} else if (ev.cats.has('vac')) {
const cat = document.createElement('span');
cat.className = 'cat'; cat.textContent = 'VAC';
td.appendChild(cat);
} else if (ev.cats.has('lns')) {
const cat = document.createElement('span');
cat.className = 'cat'; cat.textContent = 'LNS';
cat.title = 'Leave no salary';
td.appendChild(cat);
}
/* chips for discrete events */
ev.chips.forEach(c => {
const chip = document.createElement('span');
chip.className = 'chip ' + c.type;
chip.textContent = c.label;
td.appendChild(chip);
});
/* accessible summary on the cell */
td.setAttribute('aria-label',
date.toLocaleDateString(undefined,{weekday:'long',month:'long',day:'numeric',year:'numeric'})
+ '. ' + ev.labels.join('. ') + '.');
} else if (isHoliday(date)) {
td.setAttribute('aria-label',
date.toLocaleDateString(undefined,{weekday:'long',month:'long',day:'numeric',year:'numeric'})
+ '. Campus holiday.');
}
tr.appendChild(td);
if ((firstWeekday + d) % 7 === 0) {
tbody.appendChild(tr);
tr = document.createElement('tr');
}
}
/* trailing empties */
if (tr.children.length > 0) {
while (tr.children.length < 7) {
const td = document.createElement('td');
td.className = 'empty';
tr.appendChild(td);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
container.appendChild(table);
return container;
}
/* ============================================================
Validation + wiring
============================================================ */
const showErr = (id, msg) => {
const el = document.getElementById(id + '-err');
const field = document.getElementById(id);
if (msg) {
el.textContent = msg; el.hidden = false;
field.setAttribute('aria-invalid','true');
} else {
el.textContent = ''; el.hidden = true;
field.removeAttribute('aria-invalid');
}
};
function collect() {
const v = id => document.getElementById(id).value;
const n = id => { const x = parseFloat(v(id)); return Number.isFinite(x) ? x : 0; };
const radioValue = name => {
const el = document.querySelector('input[name="' + name + '"]:checked');
return el ? el.value : null;
};
/* Eligibility:
PDL is automatic for California pregnancy, so it is always shown. The
single "FMLA/CFRA" checkbox is the common UC case where the employee
qualifies for both federal FMLA and state CFRA, which run concurrently —
checking it turns on both the FMLA and CFRA leave blocks. */
const cb = id => {
const el = document.getElementById(id);
return el ? el.checked : false;
};
const eligFMLCFRA = cb('eligFMLCFRA');
/* Carrier disability: when "no", the disability waiting period and carrier
income are omitted from the calculation entirely. The radio keeps its
historical `lincolnDisability` name; for postdocs it means STD through
The Standard. */
const appliesLincoln = (radioValue('lincolnDisability') || 'yes') === 'yes';
const employeeType = radioValue('employeeType') || 'staff';
const isPostdoc = employeeType === 'postdoc';
return {
employeeType,
lastDay: parseISO(v('lastDay')),
dueDate: parseISO(v('dueDate')),
actualBirth: parseISO(v('actualBirth')),
returnDate: parseISO(v('returnDate')),
deliveryType: v('deliveryType'),
sickHours: n('sickHours'),
vacHours: n('vacHours'),
waitingPeriodDays: parseInt(v('waitingPeriod'),10) || (isPostdoc ? 7 : 14),
appliesLincoln,
pdlEligible: true,
fmlEligible: eligFMLCFRA,
cfraEligible: eligFMLCFRA,
pfcbWeeks: n('pfcbWeeks'),
pfcbStart: parseISO(v('pfcbStart')),
/* CCL is a staff benefit; the field is hidden in postdoc mode, so any
leftover value is ignored. */
cclWeeks: isPostdoc ? 0 : n('cclWeeks'),
cclAnchor: v('cclAnchor') || 'pdl',
scheduleType: radioValue('scheduleType') || 'regular',
hoursPerDay: n('hoursPerDay'),
daysPerWeek: n('daysPerWeek'),
fallbackStrategy: radioValue('fallbackStrategy') || 'vacation'
};
}
/* Human-readable labels for the form fields we validate. Keeping these
in one place means the error summary, inline errors, and any future
export share the same wording. */
const FIELD_LABELS = {
lastDay: 'Leave start date',
dueDate: 'Estimated due date',
deliveryType: 'Delivery type',
sickHours: 'Total sick hours',
hoursPerDay: 'Hours per day',
daysPerWeek: 'Days per week',
pfcbWeeks: 'PFCB weeks',
cclWeeks: 'CCL weeks'
};
/* The pfcbWeeks field is labeled "PPFL weeks" in postdoc mode. Resolving the
label at error-render time keeps FIELD_LABELS itself immutable. */
const fieldLabel = (fieldId) => {
if (fieldId === 'pfcbWeeks' && document.body.classList.contains('mode-postdoc')) {
return 'PPFL weeks';
}
return FIELD_LABELS[fieldId] || fieldId;
};
const VALIDATED_FIELDS = Object.keys(FIELD_LABELS);
function validate(input) {
/* Clear all previous inline errors first so resolved issues disappear. */
VALIDATED_FIELDS.forEach(id => showErr(id, ''));
/* Accumulator: each entry is { fieldId, message }. The order here
determines the display order in the summary. */
const errors = [];
const addError = (fieldId, message) => {
errors.push({ fieldId, message });
showErr(fieldId, message);
/* A collapsed section must not hide an error from the user. */
revealSectionFor(document.getElementById(fieldId));
};
if (!input.lastDay) {
addError('lastDay', 'Leave start date is required.');
}
if (!input.dueDate) {
addError('dueDate', 'Estimated due date is required.');
}
if (!input.deliveryType) {
addError('deliveryType', 'Please select a delivery type.');
}
const sickField = document.getElementById('sickHours');
if (sickField.value === '' || isNaN(parseFloat(sickField.value))) {
addError('sickHours', 'Total sick hours is required (enter 0 if none).');
} else if (input.sickHours < 0) {
addError('sickHours', 'Sick hours cannot be negative.');
}
if (input.lastDay && input.dueDate && input.lastDay > input.dueDate) {
addError('dueDate', 'Due date should be on or after the leave start date.');
}
if (input.scheduleType === 'variable') {
if (!(input.hoursPerDay > 0)) {
addError('hoursPerDay', 'Enter hours per day (greater than 0).');
}
if (!(input.daysPerWeek > 0)) {
addError('daysPerWeek', 'Enter days per week (1–7).');
}
}
/* PFCB / CCL week counts: blank or 0 skips the block; otherwise it must be
a whole number within the allowed range. CCL's upper bound depends on
FMLA/CFRA eligibility (14 weeks if eligible, 26 weeks if not). */
const validateWeeks = (id, max, message) => {
const field = document.getElementById(id);
if (!field) return;
const raw = field.value.trim();
if (raw === '') return; // blank = skip
const num = parseFloat(raw);
if (isNaN(num) || !Number.isInteger(num) || num < 0 || num > max) {
addError(id, message);
}
};
const isPostdoc = input.employeeType === 'postdoc';
const bondingTerm = isPostdoc ? 'PPFL' : 'PFCB';
validateWeeks('pfcbWeeks', 8,
bondingTerm + ' weeks must be 0 or left blank to skip, or a whole number from 1 to 8.');
/* CCL does not apply to postdocs — its fields are hidden, so skip
validating a value the user can no longer see or fix. */
if (!isPostdoc) {
const cclMax = input.fmlEligible ? 14 : 26;
const cclReason = input.fmlEligible
? 'Because you are eligible for FMLA/CFRA, CCL is up to 14 weeks.'
: 'Because you are not eligible for FMLA/CFRA, CCL is up to 26 weeks.';
validateWeeks('cclWeeks', cclMax,
'CCL weeks must be 0 or left blank to skip, or a whole number from 1 to ' +
cclMax + '. ' + cclReason);
}
renderErrorSummary(errors);
return errors.length === 0;
}
/* Render (or clear) the consolidated error summary at the top of the
form. On show, the summary element gets programmatic focus so screen
readers read its content — the ONE announcement channel for
validation errors. Deliberately no role="alert" on the summary, no
alerts on the inline messages, and no extra live-region count: a
single failed submit produces a single announcement, and each
field's own message is read in context through aria-describedby