Repository navigation
Expand file tree
/
Copy pathdietChallenge.js
More file actions
7578 lines (6784 loc) · 404 KB
/
Copy pathdietChallenge.js
File metadata and controls
7578 lines (6784 loc) · 404 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';
const getClosestElement = (target, selector) => {
const isTextNode = typeof Node !== 'undefined' && target?.nodeType === Node.TEXT_NODE;
const element = isTextNode ? target.parentElement : target;
return element && typeof element.closest === 'function' ? element.closest(selector) : null;
};
// --- 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일 이상 기록해주세요."
}
},
// 의학 근거 기반 서술형 분석 리포트 참고문헌 라이브러리
MEDICAL_EVIDENCE: [
{
key: 'KSSO_DIAG_2022',
label: 'KSSO 2022 진단',
year: '2023',
title: 'Diagnosis of Obesity: 2022 Update of Clinical Practice Guidelines for Obesity by the Korean Society for the Study of Obesity',
source: 'Journal of Obesity & Metabolic Syndrome',
url: 'https://www.jomes.org/journal/view.html?doi=10.7570/jomes23031',
note: '한국 성인 비만 전 단계와 비만의 BMI 기준 및 허리둘레 보조 평가 근거.'
},
{
key: 'KSSO_MGMT_2020',
label: 'KSSO 2020 관리',
year: '2021',
title: '2020 Korean Society for the Study of Obesity Guidelines for the Management of Obesity in Korea',
source: 'Journal of Obesity & Metabolic Syndrome',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC8277596/',
note: '6개월 5~10% 감량 목표, 영양치료, 신체활동 등 한국 비만 관리 지침.'
},
{
key: 'USPSTF_BEHAVIOR_2018',
label: 'USPSTF 2018',
year: '2018',
title: 'Behavioral Weight Loss Interventions to Prevent Obesity-Related Morbidity and Mortality in Adults',
source: 'JAMA / USPSTF',
url: 'https://jamanetwork.com/journals/jama/fullarticle/2702878',
note: '성인 비만에서 집중적·다요소 행동중재 권고 근거.'
},
{
key: 'CANADA_CPG_2020',
label: 'Canada CPG 2020',
year: '2020',
title: 'Obesity in adults: a clinical practice guideline',
source: 'Canadian Medical Association Journal',
url: 'https://www.cmaj.ca/content/192/31/e875',
note: 'BMI와 허리둘레, 만성질환 관점의 비만 평가 및 장기 관리 프레임.'
},
{
key: 'WHO_PA_2020',
label: 'WHO 2020 운동',
year: '2020',
title: 'WHO guidelines on physical activity and sedentary behaviour',
source: 'World Health Organization / BJSM',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC7719906/',
note: '성인 주 150~300분 중강도 유산소 또는 75~150분 고강도 운동과 주 2회 이상 근력운동 권고.'
},
{
key: 'SELF_MONITOR_2024',
label: '체중 자기모니터링',
year: '2024',
title: 'Self-Monitoring of Weight as a Weight Loss Strategy',
source: 'Current Obesity Reports',
url: 'https://link.springer.com/article/10.1007/s12170-024-00746-5',
note: '정기적 체중 자기모니터링 빈도와 감량 성공의 관련성.'
},
{
key: 'VUORINEN_2021',
label: 'JMIR 2021 기록 빈도',
year: '2021',
title: 'Frequency of Self-Weighing and Weight Change',
source: 'Journal of Medical Internet Research',
url: 'https://www.jmir.org/2021/6/e25529/',
note: '자유생활 환경에서도 잦은 체중 측정이 더 유리한 감량 결과와 관련.'
},
{
key: 'LONGLAND_2016',
label: '단백질·운동 RCT',
year: '2016',
title: 'Higher compared with lower dietary protein during an energy deficit combined with intense exercise promotes greater lean mass gain and fat mass loss',
source: 'American Journal of Clinical Nutrition',
url: 'https://pubmed.ncbi.nlm.nih.gov/26817506/',
note: '에너지 제한 중 높은 단백질과 운동이 제지방량 보존·체지방 감량에 유리했던 무작위시험.'
},
{
key: 'VERREIJEN_2017',
label: '고단백·저항운동',
year: '2017',
title: 'Effect of a high protein diet and/or resistance exercise on the preservation of fat free mass during weight loss',
source: 'Nutrition Journal',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC5294725/',
note: '과체중·비만 성인의 감량 중 제지방량 보존과 저항운동·단백질 전략 근거.'
},
{
key: 'SLEEP_EXTENSION_2022',
label: '수면 연장 RCT',
year: '2022',
title: 'Effect of Sleep Extension on Objectively Assessed Energy Intake Among Adults With Overweight in Real-life Settings',
source: 'JAMA Internal Medicine',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC8822469/',
note: '짧게 자는 과체중 성인에서 수면 연장이 에너지 섭취 감소와 음의 에너지 균형에 기여한 임상시험.'
},
{
key: 'WEIGHT_VARIABILITY_2023',
label: '체중 변동성 메타분석',
year: '2023',
title: 'Weight variability and cardiovascular outcomes: a systematic review and meta-analysis',
source: 'Cardiovascular Diabetology',
url: 'https://link.springer.com/article/10.1186/s12933-022-01735-x',
note: '장기 체중 변동성과 심혈관 위험의 관련성을 다룬 체계적 문헌고찰·메타분석.'
},
{
key: 'WAIST_2020',
label: '허리둘레 vital sign',
year: '2020',
title: 'Waist circumference as a vital sign in clinical practice',
source: 'Nature Reviews Endocrinology',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC7027970/',
note: 'BMI만으로 부족한 복부 비만·대사 위험 평가에서 허리둘레 측정의 중요성.'
},
{
key: 'HALL_UPF_2019',
label: '초가공식품 RCT',
year: '2019',
title: 'Ultra-processed diets cause excess calorie intake and weight gain: an inpatient randomized controlled trial of ad libitum food intake',
source: 'Cell Metabolism',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC7946062/',
note: '식품 가공도와 자유 섭취 에너지, 체중 변화의 인과 가능성을 평가한 입원 교차 무작위시험.'
},
{
key: 'LOWE_TRE_2020',
label: 'TRE 2020 RCT',
year: '2020',
title: 'Effects of Time-Restricted Eating on Weight Loss and Other Metabolic Parameters in Women and Men With Overweight and Obesity',
source: 'JAMA Internal Medicine',
url: 'https://jamanetwork.com/journals/jamainternalmedicine/fullarticle/2771095',
note: '16:8 시간제한식이 단독으로 체중·대사지표 이득을 확실히 보이지 않았던 무작위시험.'
},
{
key: 'TRE_NEJM_2022',
label: 'TRE NEJM 2022',
year: '2022',
title: 'Calorie Restriction with or without Time-Restricted Eating in Weight Loss',
source: 'New England Journal of Medicine',
url: 'https://www.nejm.org/doi/full/10.1056/NEJMoa2114833',
note: '비만 성인에서 열량 제한에 시간제한식을 더해도 체중·체지방·대사 위험 감소가 추가로 커지지 않았던 임상시험.'
},
{
key: 'EARLY_TRE_2022',
label: 'eTRE 2022 RCT',
year: '2022',
title: 'Effectiveness of Early Time-Restricted Eating for Weight Loss, Fat Loss, and Cardiometabolic Health in Adults With Obesity',
source: 'JAMA Internal Medicine',
url: 'https://pubmed.ncbi.nlm.nih.gov/35939311/',
note: '초기 시간대 식사 제한이 일부 체중·혈압·기분 지표에서 유리할 수 있음을 보인 14주 무작위시험.'
},
{
key: 'MORTON_PROTEIN_2018',
label: '단백질·저항운동 메타분석',
year: '2018',
title: 'A systematic review, meta-analysis and meta-regression of the effect of protein supplementation on resistance training-induced gains in muscle mass and strength',
source: 'British Journal of Sports Medicine',
url: 'https://pubmed.ncbi.nlm.nih.gov/28698222/',
note: '저항운동 중 단백질 보충과 근육량·근력 적응의 관련성을 정량화한 대규모 메타분석.'
},
{
key: 'PROTEIN_META_2024',
label: '단백질 메타분석',
year: '2024',
title: 'Enhanced protein intake on maintaining muscle mass, strength, and physical function in adults with overweight/obesity',
source: 'Clinical Nutrition ESPEN',
url: 'https://pubmed.ncbi.nlm.nih.gov/39002131/',
note: '과체중·비만 성인의 감량 중 높은 단백질 섭취가 근육량 감소 억제와 관련된 체계적 문헌고찰·메타분석.'
},
{
key: 'RESISTANCE_META_2022',
label: '저항운동 메타분석',
year: '2022',
title: 'Resistance training effectiveness on body composition and body weight outcomes in individuals with overweight and obesity across the lifespan',
source: 'Obesity Reviews',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC9285060/',
note: '열량 제한 중 저항운동이 제지방량 보존과 체성분 관리에 중요함을 다룬 체계적 문헌고찰·메타분석.'
},
{
key: 'BMJ_PRIMARYCARE_2022',
label: '일차진료 행동중재',
year: '2022',
title: 'Effectiveness of behavioural weight management interventions for adults with obesity delivered in primary care',
source: 'BMJ',
url: 'https://www.bmj.com/content/377/bmj-2021-069719',
note: '일차진료 기반 행동 체중관리 중재 효과를 평가한 체계적 문헌고찰·메타분석.'
},
{
key: 'MAINTENANCE_2022',
label: '감량 유지 메타분석',
year: '2022',
title: 'Weight Maintenance after Dietary Weight Loss: Systematic Review and Meta-Analysis on the Effectiveness of Behavioural Intensive Intervention',
source: 'Nutrients',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC8953094/',
note: '감량 후 유지 단계에서 행동중재가 체중 재증가를 줄이는지 평가한 체계적 문헌고찰·메타분석.'
},
{
key: 'HALL_MAINTENANCE_2018',
label: '장기 유지 리뷰',
year: '2018',
title: 'Maintenance of lost weight and long-term management of obesity',
source: 'Medical Clinics of North America',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC5764193/',
note: '감량 후 체중 재증가가 흔하며 장기 관리가 필요하다는 생리·행동학적 근거 정리.'
},
{
key: 'EATING_DISORDER_2023',
label: '섭식장애 위험',
year: '2023',
title: 'Eating disorder risk during behavioral weight management in adults with overweight or obesity',
source: 'Obesity Reviews',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC10909435/',
note: '체중관리 중재와 섭식장애 위험을 함께 고려해야 함을 다룬 체계적 문헌고찰.'
},
{
key: 'GALLSTONE_2021',
label: '담석 위험 리뷰',
year: '2021',
title: 'Excess Body Weight and Gallstone Disease',
source: 'Visceral Medicine',
url: 'https://pmc.ncbi.nlm.nih.gov/articles/PMC8406364/',
note: '비만과 빠른 체중감량이 담석 형성과 관련될 수 있음을 정리한 임상 리뷰.'
},
{
key: 'NICE_OBESITY_2025',
label: 'NICE NG246',
year: '2025',
title: 'Overweight and obesity management',
source: 'National Institute for Health and Care Excellence',
url: 'https://www.nice.org.uk/guidance/ng246',
note: '과체중·비만·중심성 비만의 예방과 관리, 평가와 의뢰 원칙을 통합한 최신 지침.'
}
],
// 뱃지 정의
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) => {
const startDate = d1 instanceof Date ? d1 : new Date(d1);
const endDate = d2 instanceof Date ? d2 : new Date(d2);
const startDay = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const endDay = Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return (endDay - startDay) / 86400000;
},
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();
},
addMonthsClamped: (date, months) => {
const shiftedDate = date instanceof Date ? new Date(date.getTime()) : new Date(date);
const monthOffset = Number(months);
if (Number.isNaN(shiftedDate.getTime()) || !Number.isInteger(monthOffset)) return null;
// Date#setMonth()는 3월 31일에서 한 달을 빼면 2월 말이 아니라
// 다시 3월로 넘칠 수 있습니다. 먼저 1일로 이동한 뒤 대상 월의
// 말일에 맞춰 원래 일자를 제한해 월간 필터 경계를 보존합니다.
const originalDay = shiftedDate.getDate();
shiftedDate.setDate(1);
shiftedDate.setMonth(shiftedDate.getMonth() + monthOffset);
shiftedDate.setDate(Math.min(
originalDay,
DateUtil.getDaysInMonth(shiftedDate.getFullYear(), shiftedDate.getMonth())
));
return shiftedDate;
},
addYearsClamped: (date, years) => {
const yearOffset = Number(years);
if (!Number.isInteger(yearOffset)) return null;
return DateUtil.addMonthsClamped(date, yearOffset * 12);
},
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;
},
isValidDateString: (str) => {
if (typeof str !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(str)) return false;
const parsed = DateUtil.parse(str);
return parsed instanceof Date && !isNaN(parsed.getTime()) && DateUtil.format(parsed) === str;
}
};
const isNextCalendarDay = (previousRecord, currentRecord) => {
if (!previousRecord || !currentRecord) return false;
const previousDate = DateUtil.parse(previousRecord.date);
const currentDate = DateUtil.parse(currentRecord.date);
return previousDate instanceof Date
&& currentDate instanceof Date
&& !Number.isNaN(previousDate.getTime())
&& !Number.isNaN(currentDate.getTime())
&& DateUtil.daysBetween(previousDate, currentDate) === 1;
};
const isConsecutiveDailyRecordRange = (records) => (
Array.isArray(records)
&& records.length > 0
&& records.every((record, index) => index === 0 || isNextCalendarDay(records[index - 1], record))
);
const getLongestConsecutiveRecordRun = (records) => {
if (!Array.isArray(records) || records.length === 0) return 0;
let longestRun = 1;
let currentRun = 1;
for (let index = 1; index < records.length; index++) {
currentRun = isNextCalendarDay(records[index - 1], records[index])
? currentRun + 1
: 1;
longestRun = Math.max(longestRun, currentRun);
}
return longestRun;
};
const hasConsecutivePlateauBreak = (records, minimumStableDays, minimumDrop = Number.EPSILON) => {
if (!Array.isArray(records) || records.length < minimumStableDays + 1) return false;
let stableRecordCount = 1;
for (let index = 1; index < records.length; index++) {
if (!isNextCalendarDay(records[index - 1], records[index])) {
stableRecordCount = 1;
continue;
}
const change = records[index].weight - records[index - 1].weight;
if (Math.abs(change) < 0.2) {
stableRecordCount += 1;
continue;
}
if (stableRecordCount >= minimumStableDays && -change >= minimumDrop) {
return true;
}
stableRecordCount = 1;
}
return false;
};
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),
// Math.min(...values)/Math.max(...values)는 기록 수가 엔진의 인수 한도를
// 넘으면 RangeError를 발생시킵니다. 저장 가능한 대용량 백업도 안전하게
// 처리할 수 있도록 인수 전개 없이 한 번씩 순회합니다.
min: (values) => {
let result = Infinity;
for (const value of values) result = Math.min(result, value);
return result;
},
max: (values) => {
let result = -Infinity;
for (const value of values) result = Math.max(result, value);
return result;
},
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
};
let warnedAboutMissingRichHtmlSanitizer = false;
const DomUtil = {
escapeHtml: (text) => {
if (window.MothNoteSanitizer?.escapeHtml) {
return window.MothNoteSanitizer.escapeHtml(text);
}
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
},
setSafeHtml: (element, html) => {
if (!element) return;
const value = String(html ?? '');
if (!value) {
element.textContent = '';
return;
}
if (window.MothNoteSanitizer?.setSanitizedRichHtml) {
window.MothNoteSanitizer.setSanitizedRichHtml(element, value);
return;
}
// Sanitizer를 사용할 수 없는 환경에서는 HTML을 해석하지 않고 텍스트로 표시합니다.
// 리치 마크업은 사라지지만, 예외 상황에서도 스크립트 실행 가능성을 열어두지 않습니다.
if (!warnedAboutMissingRichHtmlSanitizer) {
console.warn('MothNoteSanitizer를 찾을 수 없어 리치 HTML을 텍스트로 표시합니다.');
warnedAboutMissingRichHtmlSanitizer = true;
}
element.textContent = value;
},
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 = null;
let pendingArgs = null;
let pendingThis = null;
const debounced = function(...args) {
pendingArgs = args;
pendingThis = this;
if (timer !== null) clearTimeout(timer);
timer = setTimeout(() => {
const callArgs = pendingArgs || [];
const callThis = pendingThis;
timer = null;
pendingArgs = null;
pendingThis = null;
func.apply(callThis, callArgs);
}, delay);
};
// 페이지가 숨겨지거나 닫힐 때 아직 대기 중인 마지막 저장을 즉시 실행할 수 있게 합니다.
debounced.flush = () => {
if (timer === null) return false;
clearTimeout(timer);
const callArgs = pendingArgs || [];
const callThis = pendingThis;
timer = null;
pendingArgs = null;
pendingThis = null;
func.apply(callThis, callArgs);
return true;
};
debounced.cancel = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
pendingArgs = null;
pendingThis = null;
};
return debounced;
};
const getFileReaderText = (event) => {
const result = event?.target?.result;
return typeof result === 'string' ? result.trim().replace(/^\uFEFF/, '') : '';
};
// --- 1. 상태 및 DOM 관리 ---
const AppState = {
STORAGE_KEY: 'diet_pro_records',
SETTINGS_KEY: 'diet_pro_settings',
INLINE_EDIT_DRAFT_KEY: 'diet_pro_inline_edit_draft',
FILTER_KEY: 'diet_pro_filter_mode',
CUSTOM_FILTER_START_KEY: 'diet_pro_filter_custom_start',
CUSTOM_FILTER_END_KEY: 'diet_pro_filter_custom_end',
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,
formEditSource: null,
inlineEditDraft: null,
statsCache: null,
isDirty: true,
calendarViewDate: new Date(),
medicalNarrativePlainText: '',
hasRenderedNonEmptyRecords: false,
recordsLoadFailed: false
}
};
// JSON/CSV 복원은 같은 기록 저장소를 갱신합니다. 두 FileReader가 겹치면
// 먼저 성공한 복원 결과를 늦게 끝난 작업이 다시 덮어쓸 수 있으므로,
// 파일 읽기 시작부터 저장·화면 갱신 완료까지 하나의 작업만 허용합니다.
let dietImportOperationInProgress = false;
const setDietImportControlsDisabled = (disabled) => {
['btn-import-json', 'btn-import-csv', 'jsonFileInput', 'csvImportInput'].forEach(id => {
const element = AppState.getEl(id);
if (element) element.disabled = disabled;
});
};
const beginDietImport = () => {
if (dietImportOperationInProgress) {
showToast('이미 다른 데이터 복원이 진행 중입니다. 현재 작업이 끝난 뒤 다시 시도해주세요.');
return false;
}
dietImportOperationInProgress = true;
setDietImportControlsDisabled(true);
return true;
};
const finishDietImport = () => {
dietImportOperationInProgress = false;
setDietImportControlsDisabled(false);
};
// JSON boolean/배열/객체가 Number()에서 숫자로 바뀌는 타입 혼동을 막습니다.
// 기존 숫자 문자열과 실제 숫자만 허용해 레거시 호환성은 유지합니다.
const parseFiniteNumericScalar = (value) => {
if (typeof value !== 'number' && typeof value !== 'string') return null;
if (typeof value === 'string' && value.trim() === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
const sanitizeDietRecord = (raw) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const date = String(raw.date ?? '').trim();
if (!DateUtil.isValidDateString(date) || DateUtil.isFuture(date)) return null;
const weight = parseFiniteNumericScalar(raw.weight);
if (weight === null || weight < CONFIG.LIMITS.MIN_WEIGHT || weight > CONFIG.LIMITS.MAX_WEIGHT) return null;
const record = { date, weight: MathUtil.round(weight) };
const hasFat = raw.fat !== undefined && raw.fat !== null && String(raw.fat).trim() !== '';
if (hasFat) {
const fat = parseFiniteNumericScalar(raw.fat);
if (fat !== null && fat >= CONFIG.LIMITS.MIN_FAT && fat <= CONFIG.LIMITS.MAX_FAT) {
record.fat = MathUtil.round(fat);
}
}
return record;
};
const sanitizeDietRecords = (records) => {
const byDate = new Map();
if (!Array.isArray(records)) return [];
records.forEach(raw => {
const record = sanitizeDietRecord(raw);
if (record) byDate.set(record.date, record);
});
return Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date));
};
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
const normalizeImportedDietRecords = (records) => {
if (!Array.isArray(records)) {
return { ok: false, records: [], error: 'records 항목은 배열이어야 합니다.' };
}
const normalizedRecords = [];
const seenDates = new Set();
for (let index = 0; index < records.length; index++) {
const rawRecord = records[index];
if (!isPlainObject(rawRecord)) {
return { ok: false, records: [], error: `${index + 1}번째 기록의 형식이 올바르지 않습니다.` };
}
const normalizedRecord = sanitizeDietRecord(rawRecord);
if (!normalizedRecord) {
return { ok: false, records: [], error: `${index + 1}번째 기록의 날짜 또는 체중이 유효하지 않습니다.` };
}
const hasFatValue = rawRecord.fat !== undefined
&& rawRecord.fat !== null
&& String(rawRecord.fat).trim() !== '';
if (hasFatValue && !Object.prototype.hasOwnProperty.call(normalizedRecord, 'fat')) {
return { ok: false, records: [], error: `${index + 1}번째 기록의 체지방률이 유효하지 않습니다.` };
}
if (seenDates.has(normalizedRecord.date)) {
return { ok: false, records: [], error: `${normalizedRecord.date} 날짜가 중복되어 있습니다.` };
}
seenDates.add(normalizedRecord.date);
normalizedRecords.push(normalizedRecord);
}
normalizedRecords.sort((a, b) => a.date.localeCompare(b.date));
return { ok: true, records: normalizedRecords, error: '' };
};
const sanitizeDietSettings = (settings) => {
const defaults = { height: 179, startWeight: 78.5, goal1: 70, intake: 1862 };
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return { ...defaults };
const height = parseFiniteNumericScalar(settings.height);
const startWeight = parseFiniteNumericScalar(settings.startWeight);
const goal1 = parseFiniteNumericScalar(settings.goal1);
const intake = parseFiniteNumericScalar(settings.intake);
// 저장 정밀도는 소수 첫째 자리입니다. 양수라도 반올림 후 0이면
// 키·시작 체중을 분모로 사용하는 통계에 유효하지 않은 값이 전달됩니다.
const positiveRoundedSetting = (number, max, fallback) => {
if (number === null || number <= 0 || number > max) return fallback;
const rounded = MathUtil.round(number);
return rounded > 0 ? rounded : fallback;
};
return {
height: positiveRoundedSetting(height, 300, defaults.height),
startWeight: positiveRoundedSetting(startWeight, 500, defaults.startWeight),
goal1: positiveRoundedSetting(goal1, 500, defaults.goal1),
intake: intake !== null && intake >= 1 && intake <= 10000 ? Math.round(intake) : defaults.intake
};
};
const normalizeImportedDietSettings = (settings, currentSettings) => {
if (!isPlainObject(settings)) {
return { ok: false, settings: null, error: 'settings 항목은 객체 형식이어야 합니다.' };
}
const current = sanitizeDietSettings(currentSettings);
const rules = {
height: { min: Number.EPSILON, max: 300, label: '키' },
startWeight: { min: Number.EPSILON, max: 500, label: '시작 체중' },
goal1: { min: Number.EPSILON, max: 500, label: '목표 체중' },
intake: { min: 1, max: 10000, label: '하루 섭취 칼로리', integer: true }
};
const candidate = { ...current };
for (const [key, rule] of Object.entries(rules)) {
if (!Object.prototype.hasOwnProperty.call(settings, key)) continue;
const rawValue = settings[key];
const numberValue = parseFiniteNumericScalar(rawValue);
if (numberValue === null
|| numberValue < rule.min
|| numberValue > rule.max) {
return {
ok: false,
settings: null,
error: `settings의 ${rule.label} 값이 올바르지 않습니다.`
};
}
const roundedValue = rule.integer ? Math.round(numberValue) : MathUtil.round(numberValue);
if (roundedValue < rule.min || roundedValue > rule.max) {
return {
ok: false,
settings: null,
error: `settings의 ${rule.label} 값이 반올림 후 유효 범위를 벗어납니다.`
};
}
candidate[key] = roundedValue;
}
return { ok: true, settings: candidate, error: '' };
};
const syncDietSettingsInputs = (settings) => {
const fields = [
['userHeight', 'height'],
['startWeight', 'startWeight'],
['goal1Weight', 'goal1'],
['dailyIntake', 'intake']
];
fields.forEach(([elementId, settingKey]) => {
const input = AppState.getEl(elementId);
if (input) input.value = settings[settingKey];
});
};
const VALID_CHART_FILTER_MODES = new Set(['1M', '3M', '6M', '1Y', 'ALL', 'CUSTOM']);
const sanitizeChartFilterMode = (mode) => VALID_CHART_FILTER_MODES.has(mode) ? mode : 'ALL';
const cloneDietRecords = (records) => sanitizeDietRecords(records).map(record => ({ ...record }));
const escapeCssAttributeValue = (value) => {
const stringValue = String(value ?? '');
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(stringValue);
}
return stringValue.split('').map((char) => {
if (char === '\\') return '\\\\';
if (char === '"') return '\\"';
if (char === '\n' || char === '\r' || char === '\f') return ' ';
return char;
}).join('');
};
const parseCsvLineSafely = (line) => {
const fields = [];
let field = '';
let inQuotes = false;
let quoteClosed = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (inQuotes) {
if (char === '"') {
if (line[i + 1] === '"') {
field += '"';
i += 1;
} else {
inQuotes = false;
quoteClosed = true;
}
} else {
field += char;
}
continue;
}
if (char === ',') {
fields.push(field);
field = '';
quoteClosed = false;
} else if (quoteClosed) {
// 닫힌 따옴표 뒤에는 공백이나 구분자만 허용합니다.
// "7"0 같은 손상 필드를 70으로 이어 붙여 기존 기록을 덮어쓰지 않습니다.
if (char !== ' ' && char !== '\t') return null;
} else if (char === '"' && field.trim().length === 0) {
field = '';
inQuotes = true;
} else if (char === '"') {
return null;
} else {
field += char;
}
}
// 마지막 필드뿐 아니라 생략해서 사용하지 않는 추가 열의 손상도 검증합니다.
if (inQuotes) return null;
fields.push(field);
return fields;
};
// RFC 4180 CSV에서는 큰따옴표로 감싼 필드에 줄바꿈이 들어갈 수 있습니다.
// 물리적 줄 단위로 먼저 나누면 정상 CSV 전체를 손상된 파일로 거부하거나
// 뒤따르는 기록을 독립적인 행으로 오인하므로, 따옴표 밖의 개행에서만 행을 구분합니다.
const splitCsvLogicalRecords = (text) => {
const records = [];
let start = 0;
let startLine = 1;
let lineNumber = 1;
let insideQuotes = false;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === '"') {
if (insideQuotes && text[i + 1] === '"') {
i++;
} else {
insideQuotes = !insideQuotes;
}
} else if (char === '\n' || char === '\r') {
const newlineLength = char === '\r' && text[i + 1] === '\n' ? 2 : 1;
if (!insideQuotes) {
records.push({ text: text.slice(start, i), lineNumber: startLine });
start = i + newlineLength;
startLine = lineNumber + 1;
}
lineNumber++;
i += newlineLength - 1;
}
}
if (insideQuotes) {
throw new Error(`${startLine}번째 행에서 CSV 큰따옴표가 닫히지 않았습니다. 기존 데이터를 유지합니다.`);
}
records.push({ text: text.slice(start), lineNumber: startLine });
return records;
};
const rollbackLocalStorageEntries = (previousEntries) => {
previousEntries.forEach(({ key, hadValue, value }) => {
try {
if (hadValue) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch (rollbackError) {
console.error('Diet localStorage rollback failed:', rollbackError);
}
});
};
const persistLocalStorageEntriesAtomically = (entries, errorMessage = '데이터를 저장하지 못했습니다. 저장 공간 또는 브라우저 권한을 확인해주세요.') => {
const previousEntries = [];
try {
entries.forEach(({ key }) => {
const previousValue = localStorage.getItem(key);
previousEntries.push({ key, hadValue: previousValue !== null, value: previousValue });
});
entries.forEach(({ key, value }) => localStorage.setItem(key, String(value)));
return true;
} catch (error) {
console.error('Diet localStorage persistence failed:', error);
rollbackLocalStorageEntries(previousEntries);
showToast(errorMessage);
return false;
}
};
const DIET_RECORD_RECOVERY_REQUIRED_MESSAGE = '저장된 체중 기록이 손상되어 원본 보호를 위해 변경사항을 저장하지 않았습니다. 정상 JSON 백업을 복원하거나 데이터 초기화를 진행해주세요.';
const persistDietRecordsImmediate = (records = AppState.records, { allowCorruptDataReplacement = false } = {}) => {
if (AppState.state.recordsLoadFailed && !allowCorruptDataReplacement) {
console.warn('Diet record save was blocked to preserve malformed stored data.');
showToast(DIET_RECORD_RECOVERY_REQUIRED_MESSAGE);
return false;
}
const safeRecords = sanitizeDietRecords(records);
const didPersist = persistLocalStorageEntriesAtomically([
{ key: AppState.STORAGE_KEY, value: JSON.stringify(safeRecords) }
], '체중 기록을 저장하지 못했습니다. 저장 공간 또는 브라우저 권한을 확인해주세요.');
if (didPersist && allowCorruptDataReplacement) AppState.state.recordsLoadFailed = false;
return didPersist;
};
const persistDietSettingsImmediate = (settings = AppState.settings) => {
const safeSettings = sanitizeDietSettings(settings);
return persistLocalStorageEntriesAtomically([
{ key: AppState.SETTINGS_KEY, value: JSON.stringify(safeSettings) }
], '다이어트 설정을 저장하지 못했습니다. 저장 공간 또는 브라우저 권한을 확인해주세요.');
};
const persistDietStateSnapshot = (records, settings, { allowCorruptDataReplacement = false, includeSettings = true } = {}) => {