-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdietChallenge.js
More file actions
5204 lines (4550 loc) · 237 KB
/
dietChallenge.js
File metadata and controls
5204 lines (4550 loc) · 237 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
(function() {
'use strict';
// --- 0. 설정 및 상수 (CONFIG) ---
const CONFIG = {
// 한국인 기준 (대한비만학회 2020)
BMI: {
UNDER: 18.5,
NORMAL_END: 23,
PRE_OBESE_END: 25,
OBESE_1_END: 30,
OBESE_2_END: 35
},
LIMITS: { MIN_WEIGHT: 30, MAX_WEIGHT: 300, MIN_FAT: 1, MAX_FAT: 70 },
// CSS 변수명과 매핑되는 차트 색상값
COLORS: {
GAIN: 'var(--heatmap-gain)', // #ffcdd2
LOSS: 'var(--secondary)', // #bbdefb
WEEKEND: '#F44336',
WEEKDAY: '#4CAF50'
},
// 복싱 체급 기준
WEIGHT_CLASSES: [
{ name: "헤비급", min: 90.7 },
{ name: "크루저급", min: 79.4 },
{ name: "라이트헤비급", min: 76.2 },
{ name: "슈퍼미들급", min: 72.6 },
{ name: "미들급", min: 69.9 },
{ name: "슈퍼웰터급", min: 66.7 },
{ name: "웰터급", min: 63.5 },
{ name: "슈퍼라이트급", min: 61.2 },
{ name: "라이트급", min: 59.0 },
{ name: "슈퍼페더급", min: 57.2 },
{ name: "페더급", min: 55.3 },
{ name: "슈퍼밴텀급", min: 53.5 },
{ name: "밴텀급", min: 52.2 },
{ name: "슈퍼플라이급", min: 50.8 },
{ name: "플라이급", min: 49.0 },
{ name: "라이트플라이급", min: 47.6 },
{ name: "미니멈급", min: 0 }
],
// 문자열 상수
MESSAGES: {
ANALYSIS: {
LOSS: "어제보다 {diff}kg 빠졌습니다! 이대로 쭉 가봅시다! 🔥",
GAIN: "약간 증량({diff}kg)했지만 괜찮습니다. 장기적인 추세가 중요합니다.",
MAINTAIN: "체중 유지 중입니다. 꾸준함이 답입니다.",
DATA_Need: "데이터가 2개 이상 쌓이면 분석을 시작합니다. 화이팅!"
},
PERSONA: {
ROLLER: "🎢 롤러코스터형 (변동이 큽니다)",
TURTLE: "🐢 꾸준한 거북이형 (안정적입니다)",
BALANCE: "🏃 밸런스형 (적당한 변동)",
WEEKEND: "🍻 주말 폭식형 (월요일 급증 주의)",
RABBIT: "🐰 토끼형 (급빠급찐)"
},
TIPS: [
"단백질 섭취량을 체중 1kg당 1.5g 이상으로 늘려보세요.",
"하루 물 섭취량을 500ml 더 늘려보세요.",
"운동 강도를 높이거나 루틴을 완전히 바꿔보세요.",
"치팅밀이나 간식을 완전히 끊어보세요.",
"수면 시간을 1시간 늘려보세요.",
"간헐적 단식 시간을 2시간 더 늘려보세요."
],
PLATEAU: {
DETECTED: "🛑 <strong>정체기 감지!</strong> 최근 2주간 변화가 {diff}kg 입니다.<br>💡 팁: {tip}",
GOOD: "📉 현재 감량 흐름이 좋습니다! 이대로 유지하세요!",
WARN: "📈 약간의 증량이 있지만, 일시적인 현상일 수 있습니다.",
NEED_DATA: "데이터가 충분하지 않습니다. 7일 이상 기록해주세요."
}
},
// 뱃지 정의
BADGES: [
{ id: 'start', name: '시작이 반', icon: '🐣', desc: '첫 기록을 남겼습니다.' },
{ id: 'holiday', name: '홀리데이 서바이버', icon: '🎅', desc: '명절/연말 전후 증량을 0.5kg 미만으로 막아냈습니다.' },
{ id: 'zombie', name: '돌아온 탕아', icon: '🧟', desc: '15일 이상의 공백을 깨고 다시 기록을 시작했습니다.' },
{ id: 'sniper', name: '스나이퍼', icon: '🎯', desc: '목표 체중을 소수점까지 정확하게 명중시켰습니다.' },
{ id: 'coaster', name: '롤러코스터', icon: '🎢', desc: '하루 만에 1.5kg 이상의 급격한 변화를 경험했습니다.' },
{ id: 'zen', name: '평정심', icon: '🧘', desc: '7일 연속으로 체중 변동 폭이 0.1kg 이내로 유지되었습니다.' },
{ id: 'loss3', name: '3kg 감량', icon: '🥉', desc: '총 3kg 이상 감량했습니다.' },
{ id: 'loss5', name: '5kg 감량', icon: '🥈', desc: '총 5kg 이상 감량했습니다.' },
{ id: 'loss10', name: '10kg 감량', icon: '🥇', desc: '총 10kg 이상 감량했습니다.' },
{ id: 'streak3', name: '작심삼일 탈출', icon: '🔥', desc: '3일 연속으로 감량 또는 유지했습니다.' },
{ id: 'streak7', name: '일주일 연속', icon: '⚡', desc: '7일 연속으로 감량 또는 유지했습니다.' },
{ id: 'digit', name: '앞자리 체인지', icon: '✨', desc: '체중의 십의 자리 숫자가 바뀌었습니다.' },
{ id: 'goal', name: '목표 달성', icon: '👑', desc: '최종 목표 체중에 도달했습니다.' },
{ id: 'weekend', name: '주말 방어전', icon: '🛡️', desc: '주말(토~월) 동안 체중이 늘지 않았습니다.' },
{ id: 'plateau', name: '정체기 탈출', icon: '🧗♀️', desc: '7일 이상의 정체기를 뚫고 감량했습니다.' },
{ id: 'bmi', name: 'BMI 돌파', icon: '🩸', desc: 'BMI 단계(비만->과체중->정상)가 개선되었습니다.' },
{ id: 'yoyo', name: '요요 방지턱', icon: '🧘', desc: '목표 달성 후 10일간 체중을 유지했습니다.' },
{ id: 'ottogi', name: '오뚜기', icon: '💪', desc: '급격한 증량 후 3일 내에 다시 복구했습니다.' },
{ id: 'recordGod', name: '기록의 신', icon: '📝', desc: '총 누적 기록 365개를 달성했습니다.' },
{ id: 'goldenCross', name: '골든 크로스', icon: '📉', desc: '급격한 하락 추세(30일 평균 대비 7일 평균 급감)에 진입했습니다.' },
{ id: 'fatDestroyer', name: '체지방 파괴자', icon: '🥓', desc: '체지방률 25% 미만에 진입했습니다.' },
{ id: 'plateauMaster', name: '정체기 끝판왕', icon: '🧱', desc: '7일 이상 변동 없다가 0.5kg 이상 감량했습니다.' },
{ id: 'recordMaster', name: '기록의 달인', icon: '📅', desc: '90일 연속으로 기록했습니다.' },
{ id: 'reborn', name: '다시 태어난', icon: '🦋', desc: '최고 체중에서 10kg 이상 감량했습니다.' },
{ id: 'slowSteady', name: '슬로우 앤 스테디', icon: '🐢', desc: '3개월간 월평균 2kg 이하로 꾸준히 감량했습니다.' },
{ id: 'weightExpert', name: '체중 변화 전문가', icon: '🎓', desc: '1개월간 4kg 이상 감량했습니다.' },
{ id: 'plateauDestroyer', name: '정체기 파괴자', icon: '🔨', desc: '2주 이상의 정체기를 극복했습니다.' },
{ id: 'iconOfConstancy', name: '꾸준함의 아이콘', icon: '🗿', desc: '6개월 이상 연속 기록을 유지했습니다.' },
{ id: 'bigStep', name: '빅 스텝', icon: '👣', desc: '하루 만에 1.0kg 이상 감량했습니다.' },
{ id: 'phoenix', name: '불사조', icon: '🐦🔥', desc: '요요(증량) 후 다시 심기일전하여 최저 체중을 경신했습니다.' },
{ id: 'weekendRuler', name: '주말의 지배자', icon: '🧛', desc: '금요일 아침보다 월요일 아침 체중이 같거나 낮았습니다.' },
{ id: 'curiosity', name: '궁금증 해결사', icon: '🕵️', desc: '체지방률을 안 재다가 10일 연속으로 꼼꼼히 기록했습니다.' },
{ id: 'timeTraveler', name: '시공간 초월', icon: '🚀', desc: '예상 완료일을 10일 이상 앞당겼습니다.' },
{ id: 'parking', name: '주차의 달인', icon: '🅿️', desc: '14일 동안 체중 변동 폭이 ±0.3kg 이내로 유지되었습니다.' },
{ id: 'whoosh', name: '후루룩', icon: '📉', desc: '정체기 직후 하루 만에 0.8kg 이상 감량되었습니다.' },
{ id: 'fullMoon', name: '보름달', icon: '🌕', desc: '한 달(30일) 동안 하루도 빠짐없이 기록했습니다.' },
{ id: 'lucky7', name: '럭키 세븐', icon: '🎰', desc: '체중의 소수점 자리가 .7 또는 .77로 끝납니다.' },
{ id: 'ironWall', name: '철벽 방어', icon: '🧱', desc: '최고 체중 직전에서 다시 감량했습니다.' },
{ id: 'seasonality', name: '시즌 플레이어', icon: '🗓️', desc: '4계절(3, 6, 9, 12월)에 모두 기록이 존재합니다.' },
// v3.0.57 추가
{ id: 'decalcomania', name: '데칼코마니', icon: '🪞', desc: '이틀 연속 체중이 소수점까지 완전히 똑같습니다.' },
{ id: 'cleaning', name: '대청소', icon: '🧹', desc: '체지방 감량량이 총 체중 감량량보다 큽니다. (이상적 감량)' },
{ id: 'gyroDrop', name: '자이로드롭', icon: '📉', desc: '하루 만에 1.0kg 이상 빠졌습니다.' },
{ id: 'weekendSniper', name: '주말의 명사수', icon: '🗓️', desc: '금요일 체중보다 월요일 체중이 더 낮습니다.' },
{ id: 'piMiracle', name: '파이(π)의 기적', icon: '🔢', desc: '3.14kg 감량했거나 체중이 .14로 끝납니다.' },
// v3.0.67 추가
{ id: 'palindrome', name: '회문 마스터', icon: '🪞', desc: '체중이 78.87, 65.56 처럼 앞뒤가 똑같은 숫자입니다.' },
{ id: 'anniversary', name: '기념일 챙기기', icon: '🎉', desc: '기록 시작 100일, 1주년 또는 1000일을 달성했습니다.' },
// v3.0.71 추가
{ id: 'breakMaster', name: '브레이크 마스터', icon: '🛑', desc: '폭식(급증) 후 다음날 즉시 50% 이상을 복구했습니다.' },
{ id: 'weekendVictory', name: '주말 방어전 승리', icon: '🗓️', desc: '금요일 체중보다 월요일 체중이 더 낮거나 같습니다.' },
{ id: 'maintainerQual', name: '유지어터의 자질', icon: '🧘', desc: '감량 없이 ±0.2kg 범위 내에서 10일 이상 머물렀습니다.' },
{ id: 'wallBreaker', name: '마의 구간 돌파', icon: '📉', desc: '가장 오래 머물렀던 체중 구간을 뚫고 내려갔습니다.' }
]
};
// --- 0.1 유틸리티 (DateUtil, MathUtil, DomUtil) ---
const DateUtil = {
parse: (str) => {
if (!str) return null;
const parts = str.split('-');
return new Date(parts[0], parts[1] - 1, parts[2]);
},
format: (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}`;
},
daysBetween: (d1, d2) => (d2 - d1) / (1000 * 3600 * 24),
addDays: (dateStr, days) => {
const d = DateUtil.parse(dateStr);
d.setDate(d.getDate() + days);
return DateUtil.format(d);
},
isFuture: (dateStr) => {
const inputDate = DateUtil.parse(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
return inputDate > today;
},
getDaysInMonth: (year, month) => {
return new Date(year, month + 1, 0).getDate();
},
getWeekNumber: (d) => {
d = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay()||7));
var yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1));
var weekNo = Math.ceil(( ( (d - yearStart) / 86400000) + 1)/7);
return weekNo;
}
};
const MathUtil = {
round: (num, decimals = 1) => {
if (num === null || num === undefined) return 0;
const factor = Math.pow(10, decimals);
return Math.round((num + Number.EPSILON) * factor) / factor;
},
diff: (a, b) => MathUtil.round(a - b),
add: (a, b) => MathUtil.round(a + b),
clamp: (num, min, max) => Math.min(Math.max(num, min), max),
stdDev: (arr) => {
if (arr.length === 0) return 0;
const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
const variance = arr.reduce((a, b) => a + Math.pow(b - mean, 2), 0) / arr.length;
return Math.sqrt(variance);
},
mean: (arr) => arr.length ? arr.reduce((a,b)=>a+b, 0) / arr.length : 0
};
const DomUtil = {
escapeHtml: (text) => {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
},
getChartColors: () => {
const styles = getComputedStyle(document.body);
return {
grid: styles.getPropertyValue('--chart-grid').trim(),
text: styles.getPropertyValue('--chart-text').trim(),
primary: styles.getPropertyValue('--primary').trim(),
secondary: styles.getPropertyValue('--secondary').trim(),
danger: styles.getPropertyValue('--danger').trim(),
accent: styles.getPropertyValue('--accent').trim()
};
},
setTextColor: (el, colorType) => {
if (!el) return;
el.className = el.className.replace(/\btext-\S+/g, '');
if (colorType === 'danger') el.classList.add('text-danger');
else if (colorType === 'primary') el.classList.add('text-primary');
else if (colorType === 'secondary') el.classList.add('text-secondary');
else if (colorType === 'accent') el.classList.add('text-accent');
else if (colorType === 'default') el.classList.add('text-default');
},
getTemplate: (id) => document.getElementById(id),
clearAndAppend: (element, fragment) => {
if (!element) return;
element.innerHTML = '';
element.appendChild(fragment);
}
};
const debounce = (func, delay) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => func(...args), delay);
};
};
// --- 1. 상태 및 DOM 관리 ---
const AppState = {
STORAGE_KEY: 'diet_pro_records',
SETTINGS_KEY: 'diet_pro_settings',
FILTER_KEY: 'diet_pro_filter_mode',
records: [],
settings: { height: 179, startWeight: 78.5, goal1: 70, intake: 1862 },
chartFilterMode: 'ALL',
customStart: null,
customEnd: null,
// charts 객체는 차트 인스턴스 추적용
charts: {},
_elCache: {},
getEl: function(id) {
if (!this._elCache[id]) {
this._elCache[id] = document.getElementById(id);
}
return this._elCache[id];
},
state: {
editingDate: null,
statsCache: null,
isDirty: true,
calendarViewDate: new Date()
}
};
// --- 2. 초기화 ---
function init() {
const ids = [
'dateInput', 'weightInput', 'fatInput', 'userHeight', 'startWeight', 'goal1Weight', 'dailyIntake',
'settingsPanel', 'badgeGrid', 'jsonFileInput', 'csvImportInput', 'resetConfirmInput', 'recordInputGroup',
'chartStartDate', 'chartEndDate', 'showTrend',
'currentWeightDisplay', 'totalLostDisplay', 'percentLostDisplay', 'progressPercent',
'remainingWeightDisplay', 'remainingPercentDisplay', 'bmiDisplay', 'predictedDate',
'predictionRange', 'dashboardRate7Days', 'dashboardRate30Days', 'streakDisplay', 'successRateDisplay', 'minMaxWeightDisplay',
'dailyVolatilityDisplay', 'weeklyAvgDisplay', 'monthCompareDisplay', 'analysisText',
'lbmDisplay', 'lbmiDisplay', 'dDayDisplay', 'estTdeeDisplay', 'estTdeeSubDisplay', 'weeklyEffDisplay', 'shortTrendDisplay',
'waterIndexDisplay', 'netChangeDisplay', 'netChangeSubDisplay', 'consistencyDisplay', 'deficitDisplay', 'ffmiDisplay',
'maDisparityDisplay', 'weightClassDisplay', 'recoveryScoreDisplay',
'plateauHelperText', 'yoyoRiskDisplay', 'recent3DayAvgDisplay', 'weeklySpeedDisplay', 'idealWeeklyRateDisplay',
'bodyCompBalanceDisplay', 'lossConsistencyDisplay', 'calEfficiencyDisplay', 'volatilityIndexDisplay', 'bodyCompTrendDisplay',
'metabolicAgeDisplay', 'dietCostDisplay', 'weekendImpactDisplay', 'muscleLossCard', 'muscleLossDisplay',
'paperTowelDisplay', 'bmiPrimeDisplay', 'surplusCalDisplay', 'metabolicAdaptDisplay',
'cvDisplay', 'resistanceTableBody', 'weekdayProbTableBody', 'controlChart', 'violinChart', 'githubCalendarChart',
'dailyWinRateTable', 'zoneDurationTable', 'streakDetailTable', 'bestWorstMonthTable', 'zoneReportTableBody', 'sprintTableBody', 'gradesTableBody',
'top5TableBody', 'monthlyRateTableBody',
'advancedAnalysisList', 'calendarContainer', 'periodCompareTable', 'detailedStatsTable',
'progressBarFill', 'progressEmoji', 'progressText', 'labelStart', 'labelGoal',
'bmiProgressBarFill', 'bmiProgressEmoji', 'bmiProgressText', 'bmiLabelLeft', 'bmiLabelRight', 'bmiStageScale',
'rate7Days', 'rate30Days', 'weeklyCompareDisplay', 'heatmapGrid', 'chartBackdrop',
'monthlyTableBody', 'weeklyTableBody', 'milestoneTableBody', 'historyList',
'tab-monthly', 'tab-weekly', 'tab-milestone', 'tab-history', 'tab-zone', 'tab-sprint', 'tab-grades', 'tab-btn-top5', 'tab-btn-monthly-rate',
'btn-1m', 'btn-3m', 'btn-6m', 'btn-1y', 'btn-all',
'tab-btn-monthly', 'tab-btn-weekly', 'tab-btn-milestone', 'tab-btn-history', 'tab-btn-zone', 'tab-btn-sprint', 'tab-btn-grades', 'tab-btn-top5', 'tab-btn-monthly-rate',
'recordBtn',
'radarChart', 'candleStickChart', 'macdChart', 'seasonalSpiralChart',
// --- [NEW] v3.0.71 추가 ID ---
'trendDeviationDisplay', 'lbmRetentionDisplay', 'sodiumWarningDisplay', 'cvStatusDisplay',
'goalTunnelChart', 'drawdownChart', 'lbmFatAreaChart', 'speedometerChart',
'wallTableBody', 'monthlyFatLossTableBody',
// [추가] 이벤트 리스너용 ID들
'btn-theme-toggle', 'btn-settings-toggle', 'btn-save-settings', 'btn-import-json', 'btn-export-json', 'btn-export-csv', 'btn-import-csv', 'btn-reset-data', 'badge-toggle-header'
];
ids.forEach(id => AppState.getEl(id));
// --- [CSP 수정] 이벤트 리스너 연결 ---
const bindClick = (id, handler) => {
const el = document.getElementById(id);
if(el) el.addEventListener('click', handler);
};
const bindChange = (id, handler) => {
const el = document.getElementById(id);
if(el) el.addEventListener('change', handler);
};
bindClick('btn-theme-toggle', toggleDarkMode);
bindClick('btn-settings-toggle', toggleSettings);
bindClick('btn-save-settings', saveSettings);
bindClick('btn-import-json', importJSON);
bindClick('btn-export-json', exportJSON);
bindClick('btn-export-csv', exportCSV);
bindClick('btn-import-csv', importCSV);
bindClick('btn-reset-data', safeResetData);
bindClick('recordBtn', addRecord);
bindClick('badge-toggle-header', toggleBadges);
bindClick('chartBackdrop', closeAllExpands);
bindChange('showTrend', updateMainChart);
bindChange('chartStartDate', applyCustomDateRange);
bindChange('chartEndDate', applyCustomDateRange);
// 필터 버튼들
bindClick('btn-1m', () => setChartFilter('1M'));
bindClick('btn-3m', () => setChartFilter('3M'));
bindClick('btn-6m', () => setChartFilter('6M'));
bindClick('btn-1y', () => setChartFilter('1Y'));
bindClick('btn-all', () => setChartFilter('ALL'));
// 탭 버튼들
bindClick('tab-btn-history', () => switchTab('tab-history'));
bindClick('tab-btn-monthly', () => switchTab('tab-monthly'));
bindClick('tab-btn-weekly', () => switchTab('tab-weekly'));
bindClick('tab-btn-milestone', () => switchTab('tab-milestone'));
bindClick('tab-btn-zone', () => switchTab('tab-zone'));
bindClick('tab-btn-sprint', () => switchTab('tab-sprint'));
bindClick('tab-btn-grades', () => switchTab('tab-grades'));
bindClick('tab-btn-top5', () => switchTab('tab-top5'));
bindClick('tab-btn-monthly-rate', () => switchTab('tab-monthly-rate'));
// 확대 버튼들 (클래스 기반)
document.querySelectorAll('.expand-chart-btn').forEach(btn => {
btn.addEventListener('click', function() { toggleChartExpand(this); });
});
// -------------------------------------
// [수정됨] 캘린더 뷰 이벤트 위임 추가 (버튼 및 셀렉트 박스)
const calContainer = AppState.getEl('calendarContainer');
if (calContainer) {
calContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('cal-btn-prev')) {
changeCalendarMonth(-1);
} else if (e.target.classList.contains('cal-btn-next')) {
changeCalendarMonth(1);
}
});
calContainer.addEventListener('change', (e) => {
if (e.target.id === 'calYearSelect' || e.target.id === 'calMonthSelect') {
jumpToCalendarDate();
}
});
}
// -------------------------------------
const dateInput = AppState.getEl('dateInput');
if (dateInput) dateInput.value = DateUtil.format(new Date());
try {
AppState.records = JSON.parse(localStorage.getItem(AppState.STORAGE_KEY)) || [];
const savedSettings = JSON.parse(localStorage.getItem(AppState.SETTINGS_KEY));
if (savedSettings) AppState.settings = savedSettings;
} catch (e) {
console.error('Data Load Error', e);
AppState.records = [];
}
AppState.chartFilterMode = localStorage.getItem(AppState.FILTER_KEY) || 'ALL';
// [기능 추가] 부모 창(MothNote)의 테마 설정 확인 (URL 파라미터)
const urlParams = new URLSearchParams(window.location.search);
const theme = urlParams.get('theme');
if (theme === 'dark') {
document.body.classList.add('dark-mode');
} else if (localStorage.getItem('diet_pro_dark_mode') === 'true') {
document.body.classList.add('dark-mode');
}
const hEl = AppState.getEl('userHeight');
const sEl = AppState.getEl('startWeight');
const gEl = AppState.getEl('goal1Weight');
const iEl = AppState.getEl('dailyIntake');
if(hEl) hEl.value = AppState.settings.height;
if(sEl) sEl.value = AppState.settings.startWeight;
if(gEl) gEl.value = AppState.settings.goal1;
if(iEl) iEl.value = AppState.settings.intake || 1862;
if(AppState.records.length > 0) {
AppState.state.calendarViewDate = DateUtil.parse(AppState.records[AppState.records.length-1].date);
}
// 이벤트 위임
const hmGrid = AppState.getEl('heatmapGrid');
if (hmGrid) {
hmGrid.addEventListener('click', (e) => {
const cell = e.target.closest('.heatmap-cell');
if(cell && cell.title) showToast(cell.title);
});
}
const badgeGrid = AppState.getEl('badgeGrid');
if (badgeGrid) {
badgeGrid.addEventListener('click', (e) => {
const item = e.target.closest('.badge-item');
});
}
const handleEnter = (e) => { if(e.key === 'Enter') addRecord(); };
const wInput = AppState.getEl('weightInput');
const fInput = AppState.getEl('fatInput');
if (wInput) wInput.addEventListener('keyup', handleEnter);
if (fInput) fInput.addEventListener('keyup', handleEnter);
const histList = AppState.getEl('historyList');
if (histList) {
histList.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
const action = btn.dataset.action;
const date = btn.dataset.date;
if (action === 'edit') App.enableInlineEdit(date);
else if (action === 'delete') deleteRecord(date);
else if (action === 'save-inline') App.saveInlineEdit(date);
else if (action === 'cancel-inline') App.cancelInlineEdit();
});
}
// [기능 추가] 부모 창(MothNote)로부터 테마 변경 메시지 수신
window.addEventListener('message', (event) => {
if (event.data.type === 'setTheme') {
if (event.data.theme === 'dark') {
document.body.classList.add('dark-mode');
localStorage.setItem('diet_pro_dark_mode', 'true');
} else {
document.body.classList.remove('dark-mode');
localStorage.setItem('diet_pro_dark_mode', 'false');
}
updateUI(); // 테마 변경 후 UI(차트 등) 업데이트
}
});
updateFilterButtons();
updateUI();
}
// --- 3. 기본 기능 ---
const debouncedSaveRecords = debounce(() => {
localStorage.setItem(AppState.STORAGE_KEY, JSON.stringify(AppState.records));
}, 500);
const debouncedSaveSettings = debounce(() => {
localStorage.setItem(AppState.SETTINGS_KEY, JSON.stringify(AppState.settings));
}, 500);
function showToast(message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerText = message;
container.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
function toggleSettings() {
const panel = AppState.getEl('settingsPanel');
panel.style.display = panel.style.display === 'block' ? 'none' : 'block';
}
function toggleBadges() {
const grid = AppState.getEl('badgeGrid');
grid.style.display = grid.style.display === 'grid' ? 'none' : 'grid';
}
function toggleDarkMode() {
document.body.classList.toggle('dark-mode');
localStorage.setItem('diet_pro_dark_mode', document.body.classList.contains('dark-mode'));
updateUI();
}
function saveSettings() {
const height = parseFloat(AppState.getEl('userHeight').value);
const startWeight = parseFloat(AppState.getEl('startWeight').value);
const goal1 = parseFloat(AppState.getEl('goal1Weight').value);
const intake = parseFloat(AppState.getEl('dailyIntake').value);
if (isNaN(height) || height <= 0 || height > 300) return showToast('유효한 키(cm)를 입력해주세요.');
if (isNaN(startWeight) || startWeight <= 0 || startWeight > 500) return showToast('유효한 시작 체중을 입력해주세요.');
if (isNaN(goal1) || goal1 <= 0 || goal1 > 500) return showToast('유효한 목표 체중을 입력해주세요.');
AppState.settings.height = height;
AppState.settings.startWeight = startWeight;
AppState.settings.goal1 = goal1;
AppState.settings.intake = intake || 2000;
AppState.state.isDirty = true;
debouncedSaveSettings();
toggleSettings();
updateUI();
showToast('설정이 저장되었습니다.');
}
function addRecord() {
// [수정 핵심] 버튼 엘리먼트를 가장 먼저 가져옵니다.
const btn = AppState.getEl('recordBtn');
// [수정 핵심] 중복 실행 방지 (Debounce)
// 버튼이 비활성화(처리 중) 상태라면, 유효성 검사도 하지 않고 즉시 함수를 종료합니다.
// 이 코드가 맨 위에 있어야 두 번째 호출 시 "체중을 입력해주세요" 메시지가 뜨지 않습니다.
if (btn.disabled) return;
const dateInput = AppState.getEl('dateInput');
const weightInput = AppState.getEl('weightInput');
const fatInput = AppState.getEl('fatInput');
// 값 가져오기
const date = dateInput.value;
const weightStr = weightInput.value;
const fatStr = fatInput.value;
if (!date) return showToast('날짜를 입력해주세요.');
// 값이 비어있는지 확인
if (!weightStr || weightStr.trim() === '') {
return showToast('체중을 입력해주세요.');
}
const weight = parseFloat(weightStr);
const fat = parseFloat(fatStr);
// 유효성 검사
if (isNaN(weight) || weight < CONFIG.LIMITS.MIN_WEIGHT || weight > CONFIG.LIMITS.MAX_WEIGHT) {
return showToast(`유효한 체중을 입력해주세요 (${CONFIG.LIMITS.MIN_WEIGHT}~${CONFIG.LIMITS.MAX_WEIGHT}kg).`);
}
if (fatStr && (isNaN(fat) || fat < CONFIG.LIMITS.MIN_FAT || fat > CONFIG.LIMITS.MAX_FAT)) {
return showToast(`유효한 체지방률을 입력해주세요 (${CONFIG.LIMITS.MIN_FAT}~${CONFIG.LIMITS.MAX_FAT}%).`);
}
// [수정 핵심] 유효성 검사를 통과했다면 즉시 버튼을 잠급니다.
btn.disabled = true;
try {
const record = { date, weight: MathUtil.round(weight) };
if (!isNaN(fat) && fatStr !== '') record.fat = MathUtil.round(fat);
const existingIndex = AppState.records.findIndex(r => r.date === date);
if (AppState.state.editingDate) {
// 수정 모드일 때
if (AppState.state.editingDate !== date) {
// 날짜를 변경해서 수정하는 경우
if (existingIndex >= 0) {
if (!confirm(`${date}에 이미 기록이 있습니다. 덮어쓰시겠습니까?`)) {
// 사용자가 취소하면 버튼 잠금 해제 후 종료
btn.disabled = false; return;
}
AppState.records = AppState.records.filter(r => r.date !== AppState.state.editingDate && r.date !== date);
AppState.records.push(record);
} else {
AppState.records = AppState.records.filter(r => r.date !== AppState.state.editingDate);
AppState.records.push(record);
}
} else {
// 날짜는 그대로두고 값만 수정하는 경우
AppState.records[existingIndex] = record;
}
} else {
// 신규 기록일 때
if (existingIndex >= 0) {
if(!confirm(`${date}에 이미 기록이 있습니다. 덮어쓰시겠습니까?`)) {
// 사용자가 취소하면 버튼 잠금 해제 후 종료
btn.disabled = false; return;
}
AppState.records[existingIndex] = record;
} else {
AppState.records.push(record);
}
}
// 데이터 정렬 및 저장
AppState.records.sort((a, b) => new Date(a.date) - new Date(b.date));
AppState.state.isDirty = true;
debouncedSaveRecords();
// 입력창 초기화 및 UI 업데이트
resetForm(date);
updateUI();
showToast('기록이 저장되었습니다.');
} catch (e) {
console.error(e);
showToast('저장 중 오류가 발생했습니다.');
} finally {
// [수정 핵심] 처리가 끝나면(성공이든 실패든) 잠시 후 버튼 잠금을 해제합니다.
// 500ms 딜레이는 엔터키 연타로 인한 중복 실행을 확실하게 막아줍니다.
setTimeout(() => { btn.disabled = false; }, 500);
}
}
function resetForm(lastDateStr = null) {
if (lastDateStr) {
AppState.getEl('dateInput').value = DateUtil.addDays(lastDateStr, 1);
} else {
AppState.getEl('dateInput').value = DateUtil.format(new Date());
}
AppState.getEl('weightInput').value = '';
AppState.getEl('fatInput').value = '';
AppState.state.editingDate = null;
const rBtn = AppState.getEl('recordBtn');
rBtn.innerText = '기록하기 📝';
rBtn.classList.remove('editing-mode');
AppState.getEl('weightInput').focus();
}
function deleteRecord(date) {
if(confirm('이 날짜의 기록을 삭제하시겠습니까?')) {
AppState.records = AppState.records.filter(r => r.date !== date);
AppState.state.isDirty = true;
debouncedSaveRecords();
updateUI();
showToast('삭제되었습니다.');
}
}
function editRecord(date) {
const record = AppState.records.find(r => r.date === date);
if (record) {
AppState.getEl('dateInput').value = record.date;
AppState.getEl('weightInput').value = record.weight;
if (record.fat) AppState.getEl('fatInput').value = record.fat;
else AppState.getEl('fatInput').value = '';
AppState.state.editingDate = date;
const rBtn = AppState.getEl('recordBtn');
rBtn.innerText = '수정 완료 ✔️';
rBtn.classList.add('editing-mode');
window.scrollTo({ top: 0, behavior: 'smooth' });
showToast(`${date} 기록을 수정합니다.`);
const inputGroup = AppState.getEl('recordInputGroup');
inputGroup.classList.add('highlight');
setTimeout(() => inputGroup.classList.remove('highlight'), 1000);
}
}
function safeResetData() {
const input = AppState.getEl('resetConfirmInput');
if (input.value === "초기화") {
localStorage.removeItem(AppState.STORAGE_KEY);
AppState.records = [];
AppState.state.isDirty = true;
input.value = '';
updateUI();
showToast('초기화되었습니다.');
} else {
showToast('"초기화"라고 정확히 입력해주세요.');
}
}
function importJSON() {
const file = AppState.getEl('jsonFileInput').files[0];
if (!file) return showToast('JSON 파일을 선택해주세요.');
const reader = new FileReader();
reader.onload = function(e) {
const content = e.target.result.trim().replace(/^\uFEFF/, '');
try {
const data = JSON.parse(content);
if(data.records && Array.isArray(data.records)) {
AppState.records = data.records.filter(r => r.date && !isNaN(r.weight));
if(data.settings) AppState.settings = data.settings;
AppState.records.sort((a, b) => new Date(a.date) - new Date(b.date));
AppState.state.isDirty = true;
localStorage.setItem(AppState.STORAGE_KEY, JSON.stringify(AppState.records));
localStorage.setItem(AppState.SETTINGS_KEY, JSON.stringify(AppState.settings));
updateUI();
showToast('데이터(JSON) 복원 완료');
} else {
throw new Error('올바르지 않은 JSON 형식');
}
} catch(err) {
showToast('JSON 파일 오류: ' + err.message);
}
};
reader.readAsText(file);
}
function importCSV() {
const file = AppState.getEl('csvImportInput').files[0];
if (!file) return showToast('CSV 파일을 선택해주세요.');
const reader = new FileReader();
reader.onload = function(e) {
const content = e.target.result.trim().replace(/^\uFEFF/, '');
const lines = content.split(/\r?\n/);
let count = 0;
const csvRegex = /(?:^|,)(?:"([^"]*)"|([^",]*))/g;
for(let i=0; i<lines.length; i++) {
const line = lines[i].trim();
if(!line || line.toLowerCase().startsWith('date')) continue;
const matches = [];
let match;
while ((match = csvRegex.exec(line)) !== null) {
matches.push(match[1] ? match[1] : match[2]);
}
if(matches.length >= 2) {
const d = matches[0].trim().replace(/['"]/g, '');
const w = parseFloat(matches[1]);
if(d.match(/^\d{4}-\d{2}-\d{2}$/) && !isNaN(w)) {
const rec = { date: d, weight: w };
if(matches[2] && !isNaN(parseFloat(matches[2]))) {
rec.fat = parseFloat(matches[2]);
}
const idx = AppState.records.findIndex(r => r.date === d);
if(idx >= 0) AppState.records[idx] = rec;
else AppState.records.push(rec);
count++;
}
}
csvRegex.lastIndex = 0;
}
AppState.records.sort((a, b) => new Date(a.date) - new Date(b.date));
AppState.state.isDirty = true;
localStorage.setItem(AppState.STORAGE_KEY, JSON.stringify(AppState.records));
updateUI();
showToast(`${count}건의 데이터(CSV)를 불러왔습니다.`);
};
reader.readAsText(file);
}
function exportCSV() {
if (AppState.records.length === 0) return showToast('내보낼 데이터가 없습니다.');
let csvContent = "\uFEFFDate,Weight,BodyFat\n";
AppState.records.forEach(row => {
csvContent += `${row.date},${row.weight},${row.fat || ''}\n`;
});
const now = new Date();
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const fileName = `${yy}${mm}${dd}_Diet_Challenge_Backup.csv`;
downloadFile(csvContent, fileName, "text/csv;charset=utf-8");
}
function exportJSON() {
const data = {
settings: AppState.settings,
records: AppState.records,
exportDate: new Date().toISOString()
};
const now = new Date();
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
const fileName = `${yy}${mm}${dd}_Diet_Challenge_Backup.json`;
downloadFile(JSON.stringify(data, null, 2), fileName, "application/json");
}
function downloadFile(content, fileName, mimeType) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// --- 4. 메인 렌더링 함수 ---
function updateUI() {
if(AppState.state.isDirty) {
AppState.state.statsCache = analyzeRecords(AppState.records);
AppState.state.isDirty = false;
}
const s = AppState.state.statsCache;
renderStats(s);
renderNewStats(s);
renderAnalysisText(s);
renderAdvancedText(s);
renderPlateauHelper(s);
renderPeriodComparison();
renderDetailedStats(s);
renderExtendedStats();
renderNewTables();
renderResistanceTable();
renderWeekdayProbTable();
const colors = DomUtil.getChartColors();
updateMainChart(colors);
updateDayOfWeekChart(colors);
updateHistogram(colors);
updateCumulativeChart(colors);
updateMonthlyChangeChart(colors);
updateBodyFatChart(colors);
updateScatterChart(colors);
updateWeekendChart(colors);
updateBodyCompStackedChart(colors);
updateMonthlyBoxPlotChart(colors);
updateRocChart(colors);
updateGhostRunnerChart(colors);
updateGaugeCharts(colors);
updateWeeklyBodyCompChart(colors);
updateWeightSpeedScatterChart(colors);
updateWaterfallChart(colors);
updateSeasonalityChart(colors);
updateBellCurveChart(colors);
updateRadarChart(colors);
updateCandleStickChart(colors);
updateMacdChart(colors);
updateSeasonalSpiralChart(colors);
updateControlChart(colors);
updateViolinChart(colors);
updateGithubStyleCalendar();
// --- [NEW] v3.0.71 추가 차트 업데이트 호출 ---
updateGoalTunnelChart(colors);
updateDrawdownChart(colors);
updateLbmFatAreaChart(colors);
updateSpeedometerChart(colors);
renderHeatmap();
renderCalendarView();
renderAllTables();
renderBadges(s);
}
// --- 5. 분석 계산 로직 ---
function analyzeRecords(records) {
if (!records || records.length === 0) return {};
const weights = records.map(r => r.weight);
const current = weights[weights.length - 1];
const min = Math.min(...weights);
const max = Math.max(...weights);
const lastRec = records[records.length - 1];
let maxStreak = 0, curStreak = 0;
let successCount = 0;
let maxDrop = 0, maxGain = 0;
let diffs = [];
if (records.length > 1) {
for (let i = 1; i < records.length; i++) {
const diff = MathUtil.diff(records[i].weight, records[i-1].weight);
diffs.push(diff);
if (diff <= 0) curStreak++;
else curStreak = 0;
if (curStreak > maxStreak) maxStreak = curStreak;
if (diff < 0) successCount++;
const dayDiff = DateUtil.daysBetween(new Date(records[i-1].date), new Date(records[i].date));
if (dayDiff === 1) {
if (diff < 0 && Math.abs(diff) > maxDrop) maxDrop = Math.abs(diff);
if (diff > 0 && diff > maxGain) maxGain = diff;
}
}
}
const maxRec = records.find(r => r.weight === max) || {};
const minRec = records.find(r => r.weight === min) || {};
const stdDev = MathUtil.stdDev(weights);
const mean = MathUtil.mean(weights);
const cv = mean !== 0 ? (stdDev / mean) * 100 : 0;
let fatChange = 0, lbmChange = 0;
const firstFatRec = records.find(r => r.fat);
const lastFatRec = [...records].reverse().find(r => r.fat);
if(firstFatRec && lastFatRec) {
const startFatKg = firstFatRec.weight * (firstFatRec.fat / 100);
const endFatKg = lastFatRec.weight * (lastFatRec.fat / 100);
fatChange = MathUtil.diff(endFatKg, startFatKg);
const startLbmKg = firstFatRec.weight * (1 - firstFatRec.fat / 100);
const endLbmKg = lastFatRec.weight * (1 - lastFatRec.fat / 100);
lbmChange = MathUtil.diff(endLbmKg, startLbmKg);
}
let maxPlateau = 0, curPlateau = 0;
for(let i=1; i<records.length; i++) {
if(Math.abs(MathUtil.diff(records[i].weight, records[i-1].weight)) < 0.2) curPlateau++;
else curPlateau = 0;
if(curPlateau > maxPlateau) maxPlateau = curPlateau;
}
const totalLost = MathUtil.diff(AppState.settings.startWeight, current);
const hMeter = AppState.settings.height / 100;
const bmi = Math.round((current / (hMeter * hMeter)) * 100) / 100;
const getRateVal = (days) => {
const now = new Date(); now.setHours(0,0,0,0);
const startTimestamp = now.getTime() - (days * 24 * 60 * 60 * 1000);
const rel = records.filter(r => DateUtil.parse(r.date).getTime() >= startTimestamp);
if(rel.length < 2) return "-";
const diff = MathUtil.diff(rel[rel.length-1].weight, rel[0].weight);
const d = DateUtil.daysBetween(DateUtil.parse(rel[0].date), DateUtil.parse(rel[rel.length-1].date));
if(d===0) return "-";
const g = ((diff/d)*1000).toFixed(0);
return `${g > 0 ? '+' : ''}${g}g / 일`;
};
const rate7 = getRateVal(7);
const rate30 = getRateVal(30);
const now = new Date(); now.setHours(0,0,0,0);
const t7 = now.getTime() - (7 * 24 * 60 * 60 * 1000);
const t14 = now.getTime() - (14 * 24 * 60 * 60 * 1000);
const thisW = records.filter(r => DateUtil.parse(r.date).getTime() >= t7);
const lastW = records.filter(r => { const t = DateUtil.parse(r.date).getTime(); return t >= t14 && t < t7; });
let weeklyComp = "데이터 부족";
if(thisW.length > 0 && lastW.length > 0) {
const avgT = thisW.reduce((a,b)=>a+b.weight,0)/thisW.length;
const avgL = lastW.reduce((a,b)=>a+b.weight,0)/lastW.length;
const diff = MathUtil.diff(avgT, avgL);
const icon = diff < 0 ? '🔻' : (diff > 0 ? '🔺' : '➖');
weeklyComp = `${icon} ${Math.abs(diff)}kg`;
}
const thisMonthKey = DateUtil.format(now).slice(0, 7);
const lastMonthDate = new Date(); lastMonthDate.setMonth(now.getMonth()-1);
const lastMonthKey = DateUtil.format(lastMonthDate).slice(0, 7);
const thisMonthRecs = records.filter(r => r.date.startsWith(thisMonthKey));
const lastMonthRecs = records.filter(r => r.date.startsWith(lastMonthKey));
let monthlyComp = '-';
if(thisMonthRecs.length > 0 && lastMonthRecs.length > 0) {
const avgThis = thisMonthRecs.reduce((a,b)=>a+b.weight,0)/thisMonthRecs.length;
const avgLast = lastMonthRecs.reduce((a,b)=>a+b.weight,0)/lastMonthRecs.length;
const diff = MathUtil.diff(avgThis, avgLast);
monthlyComp = `${diff > 0 ? '▲' : '▼'} ${Math.abs(diff).toFixed(1)}kg`;
}
let weeklyAvgLoss = '-';
if(records.length >= 2) {
const weeks = {};
[...records].forEach(r => {
const d = DateUtil.parse(r.date);
const day = d.getDay();
const monday = new Date(d.setDate(d.getDate() - day + (day == 0 ? -6 : 1)));
monday.setHours(0,0,0,0);
const key = monday.getTime();
if(!weeks[key]) weeks[key] = [];
weeks[key].push(r.weight);
});
const weekKeys = Object.keys(weeks).sort();
if(weekKeys.length >= 2) {
let totalL = 0, count = 0;
for(let i=1; i<weekKeys.length; i++) {
const prevAvg = weeks[weekKeys[i-1]].reduce((a,b)=>a+b,0)/weeks[weekKeys[i-1]].length;
const currAvg = weeks[weekKeys[i]].reduce((a,b)=>a+b,0)/weeks[weekKeys[i]].length;
totalL += (prevAvg - currAvg);
count++;
}
if(count > 0) weeklyAvgLoss = (totalL / count).toFixed(2);
}
}
return {
current, min, max, maxStreak, lastRec, diffs,
successRate: records.length > 1 ? Math.round((successCount / (records.length - 1)) * 100) : 0,
maxDrop: MathUtil.round(maxDrop),
maxGain: MathUtil.round(maxGain),
maxDate: maxRec.date, minDate: minRec.date,
stdDev: stdDev,
cv: cv,
fatChange, lbmChange,
maxPlateau,
totalLost, bmi, rate7, rate30, weeklyComp, monthlyComp, weeklyAvgLoss
};
}