Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
977 lines (935 loc) · 102 KB
/
Copy pathindex.html
File metadata and controls
977 lines (935 loc) · 102 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
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0b1a2e">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Packing">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon-192.png">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<style>:root{padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}body{margin:0}[hidden]{display:none!important}</style>
<title>Packing</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=Barlow:wght@400;500;600&display=swap">
<style>
/* Layout: one-column phone app — brand header, sticky day bar, big tap tiles per client, icon tab bar.
Palette: night sky navy + canopy light-blue (from the icon); orange only for money owed. */
:root{
--bg:#0b1a2e; --panel:#122640; --panel-2:#19324f; --line:#24405f;
--fg:#eaf2fa; --muted:#8ea7c0; --accent:#2cc0e8; --accent-ink:#03202c; --accent-soft:rgba(44,192,232,.16);
--warn:#ff8a4c; --ok:#4fd49a; --danger:#ff6b6b; --bar:#0e2036;
--display:"Barlow Condensed","Arial Narrow",system-ui,sans-serif;
--body:"Barlow",system-ui,-apple-system,"Segoe UI",sans-serif;
color-scheme:dark;
}
@media (prefers-color-scheme: light){
:root:not([data-skin="dark"]){
--bg:#edf3f8; --panel:#ffffff; --panel-2:#e2ecf5; --line:#cddbe8;
--fg:#0c2136; --muted:#567089; --accent:#0a8ec0; --accent-ink:#ffffff; --accent-soft:rgba(10,142,192,.12);
--warn:#d4601c; --ok:#178a55; --danger:#c93636; --bar:#ffffff; color-scheme:light;
}
}
:root[data-skin="light"]{
--bg:#edf3f8; --panel:#ffffff; --panel-2:#e2ecf5; --line:#cddbe8;
--fg:#0c2136; --muted:#567089; --accent:#0a8ec0; --accent-ink:#ffffff; --accent-soft:rgba(10,142,192,.12);
--warn:#d4601c; --ok:#178a55; --danger:#c93636; --bar:#ffffff; color-scheme:light;
}
/* brand header */
.top{max-width:560px;margin:0 auto;padding-inline:16px;padding-block:14px 0;display:flex;align-items:center;gap:10px}
.top .logo{width:34px;height:34px;flex:none;border-radius:9px}
.top .brand{flex:1;font-family:var(--display);font-weight:700;font-size:26px;letter-spacing:.02em}
.themebtn{width:44px;height:44px;border-radius:12px;border:1px solid var(--line);background:var(--panel);display:grid;place-items:center;color:var(--fg)}
.themebtn svg{width:22px;height:22px}
*{box-sizing:border-box}
html,body{height:100%}
body{background:var(--bg);color:var(--fg);font-family:var(--body);font-size:16px;-webkit-tap-highlight-color:transparent}
button,input{font:inherit;color:inherit}
button{cursor:pointer}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.app{max-width:560px;margin:0 auto;padding-inline:16px;padding-block:12px 96px;display:flex;flex-direction:column;gap:16px}
/* day bar */
.daybar{display:flex;align-items:center;gap:8px;position:sticky;top:env(safe-area-inset-top,0px);background:var(--bg);padding-block:8px;z-index:2}
.navbtn{width:44px;height:44px;border-radius:10px;border:1px solid var(--line);background:var(--panel);font-size:20px;flex:none}
.daylabel{flex:1;min-width:0;text-align:center}
.daylabel .eyebrow{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
.daylabel .date{font-family:var(--display);font-weight:600;font-size:22px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.daylabel input{position:absolute;opacity:0;pointer-events:none;width:1px;height:1px}
.totals{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1px;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden}
.totals>div{background:var(--panel);padding:12px 16px;min-width:0}
.totals .k{font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted)}
.totals .v{font-family:var(--display);font-weight:700;font-size:40px;line-height:1.05;font-variant-numeric:tabular-nums;white-space:nowrap}
.totals .v.eur{color:var(--accent)}
.banner{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;border:1px dashed var(--accent);border-radius:12px;padding:10px 14px;color:var(--muted);font-size:14px}
.banner button{background:none;border:0;color:var(--accent);font-weight:600;padding:4px 0}
/* client tiles */
/* client bands on the Conta page: name/notes left, count, small actions, big + */
.tiles{display:grid;grid-template-columns:1fr;gap:10px}
.tile-wrap{position:relative;display:flex}
.tile{position:relative;overflow:hidden;width:100%;display:flex;align-items:center;gap:12px;min-height:104px;padding:12px 140px 12px 16px;border-radius:18px;border:1px solid var(--line);background:var(--panel);text-align:left;transition:transform .08s,background .25s,border-color .25s}
.tile:active{transform:scale(.97)}
.tile.bump{background:var(--accent-soft);border-color:var(--accent)}
.tile .tl{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}
.tile .name{font-weight:600;font-size:19px;line-height:1.2;overflow-wrap:anywhere}
.tile .cnote{font-size:15px;line-height:1.3;color:var(--fg);opacity:.82;white-space:pre-line;overflow-wrap:anywhere}
.tile .tc{display:flex;flex-direction:column;align-items:flex-end;flex:none}
.tile .count{font-family:var(--display);font-weight:700;font-size:52px;line-height:.9;font-variant-numeric:tabular-nums;color:var(--accent)}
.tile.zero .count{color:var(--muted)}
.tile .sub{font-size:14px;color:var(--muted);font-variant-numeric:tabular-nums}
.tile .plus{position:absolute;right:14px;top:50%;transform:translateY(-50%);width:58px;height:58px;border-radius:50%;background:var(--accent);color:var(--accent-ink);display:grid;place-items:center;font-weight:700;font-size:32px;line-height:1}
.tile .fly{position:absolute;right:150px;top:8px;font-family:var(--display);font-weight:700;font-size:28px;color:var(--accent);pointer-events:none;animation:fly .7s ease-out forwards}
.tile .count.pop{animation:pop .3s ease-out}
@keyframes fly{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-46px)}}
@keyframes pop{0%{transform:scale(1)}40%{transform:scale(1.18)}100%{transform:scale(1)}}
.minus{width:36px;height:36px;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--muted);font-size:20px;line-height:1;display:grid;place-items:center;flex:none}
.tile-wrap>.minus{position:absolute;right:84px;top:14px}
.tile-wrap>.minus.donebtn{top:auto;bottom:14px;color:var(--ok)}
.clip1,.clip3{display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden}
.clip1{-webkit-line-clamp:1}.clip3{-webkit-line-clamp:3}
.note-in{min-height:0;font-family:var(--body);font-size:14px}
.crow{border-top:1px solid var(--line)}.crow:first-child{border-top:0}
.swatches{display:flex;gap:12px;flex-wrap:wrap}
.swatch{width:42px;height:42px;border-radius:50%;border:2px solid var(--line)}
.swatch[aria-pressed="true"]{outline:3px solid var(--fg);outline-offset:3px}
.themebtn.on{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.btn.small{padding:6px 10px;font-size:13px}
#methodForm{gap:10px}#methodForm input{flex:1;min-width:0}
.card>.switch:last-child{border-bottom:0}
/* ranking medals (sober metals) and tips (green, like money gained) */
:root{--gold:#c9a85a;--silver:#a3afbb;--bronze:#b5805d;--tip:var(--ok)}
.rk{width:28px;height:28px;border-radius:50%;display:grid;place-items:center;font-weight:700;font-size:13px;flex:none;background:var(--panel-2);color:var(--muted)}
.rk.r1{background:var(--gold)}.rk.r2{background:var(--silver)}.rk.r3{background:var(--bronze)}.rk.r1,.rk.r2,.rk.r3{color:#1d2530}
.pod{display:flex;align-items:flex-end;justify-content:center;gap:8px;padding:16px 10px 0}
.pc{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:4px;background:none;border:0;padding:0;text-align:center;color:var(--fg)}
.pc .nm{font-weight:600;font-size:14px;line-height:1.15;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pc .eur{font-family:var(--display);font-weight:700;font-size:19px;font-variant-numeric:tabular-nums}
.pc .av{width:44px;height:44px;border-radius:50%;display:grid;place-items:center;font-weight:700;background:var(--panel-2);border:2px solid}
.pc .blk{width:100%;border-radius:10px 10px 0 0;display:grid;place-items:center;font-family:var(--display);font-size:28px;font-weight:700;color:var(--fg);background:var(--panel-2);border-top:5px solid var(--line)}
.pc .blk.m-gold{border-top-color:var(--gold)}.pc .blk.m-silver{border-top-color:var(--silver)}.pc .blk.m-bronze{border-top-color:var(--bronze)}
.pc.sel .av{box-shadow:0 0 0 3px var(--accent)}
.rrow{width:100%;background:none;border:0;border-top:1px solid var(--line);color:var(--fg);font:inherit}
.card>.rrow:first-child{border-top:0}
.rrow.sel{background:var(--accent-soft)}
.rrow .eur,.rdet .eur{font-family:var(--display);font-weight:700;font-size:20px;font-variant-numeric:tabular-nums}
.rrow.sel+.rdet{background:var(--accent-soft);border-top:0;padding-top:0}
.rdet{padding:12px;display:flex;flex-direction:column;gap:10px;border-top:1px solid var(--line);background:var(--panel)}
.card>.rdet:first-child{border-top:0}
.rdet .dh{display:flex;align-items:center;gap:10px}.rdet .g{flex:1;min-width:0}.rdet .nm{font-weight:600;font-size:16px}
.rdet .st{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}
.rdet .st div{background:var(--panel-2);border-radius:8px;padding:7px 6px;min-width:0}
.rdet .st b{display:block;font-family:var(--display);font-size:18px;font-variant-numeric:tabular-nums}
.rdet .st span{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.up{color:var(--ok);font-size:12px;font-weight:600}.dn{color:var(--warn);font-size:12px;font-weight:600}
.fold{display:flex;align-items:center;gap:12px;width:100%;text-align:left;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:12px 14px;color:var(--fg);font:inherit}
.fold .fold-t{font-family:var(--display);font-weight:600;font-size:15px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
.fold .chev{flex:none;color:var(--accent);transition:transform .2s}
.fold[aria-expanded="true"] .chev{transform:rotate(180deg)}
.fold[aria-expanded="true"]{border-color:var(--accent)}
.tipc{color:var(--tip)}
.tips .tipgrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;background:var(--line)}
.tips .tipgrid div{background:var(--panel);padding:12px 14px;display:flex;flex-direction:column;gap:2px;min-width:0}
.tips .tipgrid .k{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
.tips .tipgrid b{display:block;white-space:nowrap;overflow:hidden;font-family:var(--display);font-size:24px;color:var(--tip);font-variant-numeric:tabular-nums}
.tips .tiptop{display:flex;flex-wrap:wrap;gap:8px 16px;padding:12px 14px;font-size:14px;border-top:1px solid var(--line)}
.tips .tiptop span{display:inline-flex;align-items:center;gap:6px}
.tips .tiptop b{color:var(--tip)}
.tips>.actions{padding-top:12px!important;border-top:1px solid var(--line)}
select{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px 12px;color:var(--fg);font:inherit}
.pickbtn{display:flex;align-items:center;justify-content:center;gap:10px;width:100%;padding:15px 16px;border-radius:14px;border:1.5px dashed var(--accent);background:var(--accent-soft);color:var(--accent);font:inherit;font-size:17px;font-weight:600}
.picker{border-color:var(--accent)}
.res .grow{flex:1;min-width:0}
.pick-h{display:flex;gap:8px;padding:10px;border-bottom:1px solid var(--line);align-items:center}
.pick-h input{flex:1;min-width:0;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:11px 12px;font-size:16px;color:var(--fg)}
.pick-h input:focus{border-color:var(--accent);outline:none}
.picker .results{max-height:52vh;overflow-y:auto;overscroll-behavior:contain}
.searchwrap{position:relative;display:flex;flex-direction:column;gap:6px}
.searchwrap input{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;font-size:17px;color:var(--fg)}
.searchwrap input:focus{border-color:var(--accent);outline:none}
.results{display:flex;flex-direction:column}
.res{display:flex;align-items:center;gap:12px;width:100%;text-align:left;background:none;border:0;border-top:1px solid var(--line);padding:14px;font-size:16px}
.res:first-child{border-top:0}
.res:active{background:var(--panel-2)}
.res.new{color:var(--accent);font-weight:600}
.empty{color:var(--muted);text-align:center;padding:24px 8px;border:1px dashed var(--line);border-radius:14px}
/* sections */
h2{font-family:var(--display);font-weight:600;font-size:15px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin:8px 0 0}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;overflow:hidden}
.row{display:flex;align-items:center;gap:12px;padding:12px 14px;border-top:1px solid var(--line)}
.row:first-child{border-top:0}
.row .grow{flex:1;min-width:0;overflow-wrap:anywhere}
.row .num{font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
.row.total{background:var(--panel-2);font-weight:600}
.row.total .num{font-family:var(--display);font-size:22px;color:var(--accent)}
.muted{color:var(--muted)}
.small{font-size:13px}
.seg{display:flex;gap:6px;flex-wrap:wrap}
.seg button{border:1px solid var(--line);background:var(--panel);border-radius:999px;padding:8px 14px;font-size:14px}
.seg button[aria-pressed="true"]{background:var(--accent);color:var(--accent-ink);border-color:var(--accent);font-weight:600}
.btn{border:1px solid var(--line);background:var(--panel-2);border-radius:10px;padding:10px 14px;font-weight:600}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink)}
.btn.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--line))}
.btn.ghost{background:none}
.actions{display:flex;gap:8px;flex-wrap:wrap}
input[type=text],input[type=number],textarea{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:10px 12px;color:var(--fg);font:inherit}
textarea{min-height:110px;font-size:12px;font-family:ui-monospace,monospace}
label.f{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--muted)}
.form{display:flex;flex-direction:column;gap:12px;padding:14px}
.form .two{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:10px}
.price-in{width:88px!important;text-align:right}
/* bottom tabs */
.tabs{position:fixed;left:0;right:0;bottom:0;background:var(--bar);border-top:1px solid var(--line);display:flex;justify-content:center;padding-bottom:env(safe-area-inset-bottom,0px);z-index:3}
.tabs .in{display:flex;width:100%;max-width:560px;padding-inline:4px}
.tabs button{flex:1;min-width:0;display:flex;flex-direction:column;align-items:center;gap:3px;background:none;border:0;padding:8px 2px 10px;font-size:11px;font-weight:600;letter-spacing:.02em;color:var(--muted)}
.tabs button svg{width:26px;height:26px;padding:3px 12px;box-sizing:content-box;border-radius:999px;transition:background .2s}
.tabs button[aria-selected="true"]{color:var(--fg)}
.tabs button[aria-selected="true"] svg{background:var(--accent-soft);color:var(--accent)}
/* calendar */
.calhead{display:flex;align-items:center;gap:8px}
.calhead .m{flex:1;text-align:center;font-family:var(--display);font-weight:600;font-size:22px;text-transform:capitalize}
.cal{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:4px}
.cal .wd{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);text-align:center;padding-block:4px}
.cal .d{position:relative;aspect-ratio:1/1.05;max-width:100%;border-radius:10px;border:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;align-items:center;justify-content:flex-start;padding-top:6px;font-variant-numeric:tabular-nums;font-size:15px}
.cal .d.out{visibility:hidden}
.cal .d.plan{background:color-mix(in srgb,var(--accent) 22%,var(--panel));border-color:var(--accent)}
.cal .d.today{box-shadow:inset 0 0 0 2px var(--fg)}
.cal .d.sel{outline:2px solid var(--accent);outline-offset:1px}
.cal .d .n{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);font-size:11px;font-weight:600;color:var(--ok)}
.legend{display:flex;gap:14px;flex-wrap:wrap;font-size:13px;color:var(--muted)}
.legend span{display:inline-flex;align-items:center;gap:6px}
.sw{width:12px;height:12px;border-radius:3px;border:1px solid var(--accent);background:color-mix(in srgb,var(--accent) 22%,var(--panel))}
.switch{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px;border-bottom:1px solid var(--line)}
.switch input{width:22px;height:22px;accent-color:var(--accent)}
a.btn{text-decoration:none;display:inline-block;color:var(--accent)}
.sw-n{display:inline-grid;place-items:center;width:18px;height:18px;border-radius:4px;border:1px solid var(--line);background:var(--panel);font-style:normal;font-size:11px;font-weight:700;color:var(--ok)}
.pill{display:inline-block;border-radius:999px;padding:3px 10px;font-size:13px;font-weight:600;white-space:nowrap;font-variant-numeric:tabular-nums}
.pill.due{background:color-mix(in srgb,var(--warn) 18%,var(--panel));color:var(--warn)}
.pill.ok{background:color-mix(in srgb,var(--ok) 16%,var(--panel));color:var(--ok)}
.pill.cred{background:var(--panel-2);color:var(--muted)}
.paybox{display:flex;flex-direction:column;gap:10px;padding:0 14px 14px;border-top:1px dashed var(--line);padding-top:12px}
.toast{position:fixed;left:50%;bottom:84px;transform:translateX(-50%);background:var(--fg);color:var(--bg);padding:10px 16px;border-radius:999px;font-weight:600;font-size:14px;z-index:5;max-width:calc(100% - 32px)}
.updbar{position:fixed;left:50%;transform:translateX(-50%);bottom:calc(84px + env(safe-area-inset-bottom,0px));z-index:6;display:flex;align-items:center;gap:12px;background:var(--panel-2);border:1px solid var(--accent);border-radius:999px;padding:8px 8px 8px 16px;font-weight:600;font-size:14px;box-shadow:0 6px 24px rgba(0,0,0,.3);max-width:calc(100% - 32px)}
.fixnote{background:var(--panel-2);border-radius:12px;padding:10px 12px;font-size:16px;line-height:1.35;white-space:pre-wrap}
.fixnote .k{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:4px}
.finrow{gap:10px}.finrow b{font-weight:600}
@media (prefers-reduced-motion:reduce){.tile{transition:none}.tile:active{transform:none}.tile .fly,.tile .count.pop{animation:none}.tile .fly{display:none}}
</style>
</head>
<body>
<header class="top">
<img class="logo" src="data:image/png;base64,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" alt="" width="34" height="34">
<span class="brand">Packing</span>
<button class="themebtn" id="setBtn" aria-label="Impostazioni"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h0a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v0a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg></button>
<button class="themebtn" id="themeBtn" aria-label="Cambia aspetto chiaro o scuro"></button>
</header>
<div class="app" id="app"></div>
<nav class="tabs" role="tablist"><div class="in">
<button role="tab" id="t-conta" data-tab="conta"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v8M8 12h8"/></svg>Conta</button>
<button role="tab" id="t-riep" data-tab="riep"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 20h16M7 16v-4M12 16V7M17 16v-7"/></svg>Riepilogo</button>
<button role="tab" id="t-pag" data-tab="pag"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M17 6.5A7 7 0 1 0 17 17.5M5 10h9M5 14h9"/></svg>Pagamenti</button>
<button role="tab" id="t-cli" data-tab="cli"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c.6-3.6 3.2-5.5 6.5-5.5s5.9 1.9 6.5 5.5"/><path d="M16 4.6a3.5 3.5 0 0 1 0 6.8M18 14.8c2 .6 3.2 2.4 3.5 5.2"/></svg>Clienti</button>
<button role="tab" id="t-cal" data-tab="cal"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2.5"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>Calendario</button>
</div></nav>
<div class="toast" id="toast" hidden></div>
<div class="updbar" id="updBar" hidden><span>Nuova versione pronta</span><button class="btn primary small" id="updBtn">Aggiorna</button></div>
<script>
(function(){
const KEY='ripiegamenti.v1';
const DEFAULT_PRICE=6;
const eur=n=>n.toLocaleString(LOC(),{style:'currency',currency:'EUR'});
const uid=()=>Date.now().toString(36)+Math.random().toString(36).slice(2,7);
const pad=n=>String(n).padStart(2,'0');
const dayKey=d=>d.getFullYear()+'-'+pad(d.getMonth()+1)+'-'+pad(d.getDate());
const parseDay=k=>{const[a,b,c]=k.split('-').map(Number);return new Date(a,b-1,c)};
const today=()=>dayKey(new Date());
const fmtDay=k=>{const s=parseDay(k).toLocaleDateString(LOC(),{weekday:'long',day:'numeric',month:'long'});return s.charAt(0).toUpperCase()+s.slice(1)};
const isEN=()=>{try{return S.set.lang==='en'}catch(e){return false}};
const LOC=()=>isEN()?'en-GB':'it-IT';
const EN={
'+ Calendario':'+ Calendar','+ Mancia senza pagamento':'+ Tip without payment','+ Nuovo cliente':'+ New client','+ Pagamento':'+ Payment',
'Aggiungi':'Add','Aggiungi a Google Calendar':'Add to Google Calendar','Aggiungi cliente alla giornata':'Add client to the day','Al giorno':'Per day','Altro':'Other','Anno':'Year','Annulla':'Cancel',
'Applica a tutti i clienti':'Apply to all clients','Apri il contatore di questo giorno':'Open this day’s counter','Aspetto':'Appearance','Automatico':'Automatic',
'Backup, prezzo per i nuovi clienti e metodi di pagamento sono nelle Impostazioni (ingranaggio in alto).':'Backup, price for new clients and payment methods are in Settings (gear icon at the top).',
'Bonifico':'Bank transfer','Calendario':'Calendar','Cancella e inizia':'Clear and start','Cancella tutti i dati':'Delete all data','Cancella tutto':'Delete everything','Cancella nota':'Delete note',
'Cancellare tutti i clienti, ripiegamenti, pagamenti e note?':'Delete all clients, packs, payments and notes?','Chiaro':'Light','Classifica clienti':'Client ranking','Cliente':'Client','Clienti':'Clients',
'Clienti e prezzo standard':'Clients and standard price','Clienti in debito':'Clients owing','Colore principale':'Accent color','Compare nel riepilogo che copi e mandi ai clienti.':'Shown in the summary you copy and send to clients.',
'Conferma':'Confirm','Conta':'Count','Contanti':'Cash','Contatore':'Counter','Copia come testo':'Copy as text','Copia di sicurezza':'Backup','Copia riepilogo':'Copy summary',
'Da incassare':'To collect','Guadagno':'Earnings','Da pagare':'To pay','Dati':'Data','Dati cancellati':'Data deleted','Dettaglio giornata':'Day details','Elimina':'Delete','Eliminare':'Delete',
'Esporta tutti (.ics)':'Export all (.ics)','Fatto':'Done','Giornata':'Day','Giornata di oggi':'Today','Giorni':'Days','Giorno di lavoro segnato':'Working day','Giorno di lavoro':'Working day',
'Guadagni per mese':'Earnings by month',
'I dati restano solo su questo telefono. Manda il backup a te stesso via email o salvalo su Drive: se cambi o perdi il telefono, lo ripristini da qui (il ripristino sostituisce i dati attuali).':'Your data stays only on this phone. Email the backup to yourself or save it to Drive: if you change or lose your phone, restore it from here (restoring replaces the current data).',
'Ignora un secondo tocco sullo stesso cliente entro 1,5 secondi.':'Ignores a second tap on the same client within 1.5 seconds.',
'Il file .ics si apre con il calendario del telefono (Google, Samsung o iPhone) e aggiunge tutti i giorni in un colpo. Funziona nella versione installata sul telefono.':'The .ics file opens in your phone’s calendar (Google, Samsung or iPhone) and adds all days at once. Works in the version installed on your phone.',
'Il prezzo vale per i nuovi ripiegamenti. Quelli già segnati non cambiano.':'The price applies to new packs. Packs already recorded don’t change.',
'Il ripristino sostituisce tutti i dati attuali.':'Restoring replaces all current data.','Il saldo considera tutti i ripiegamenti segnati meno i pagamenti ricevuti.':'The balance is all recorded packs minus payments received.',
'Il tuo nome':'Your name','Il tuo profilo':'Your profile','Importi in euro del lavoro fatto. Tocca un mese per vederne i totali per cliente.':'Euro amounts of work done. Tap a month to see its totals per client.',
'Importo ricevuto €':'Amount received €','Impostazioni':'Settings','In totale':'All time','Incassato questo mese':'Received this month','Incolla qui il backup':'Paste the backup here','Incolla testo':'Paste text',
'Invia backup':'Send backup','Lista nera':'Black list','Lun':'Mon','Mar':'Tue','Mer':'Wed','Gio':'Thu','Ven':'Fri','Sab':'Sat','Dom':'Sun','Mai':'Never','Mance':'Tips','Mancia €':'Tip €','Mese':'Month',
'Metodi di pagamento':'Payment methods','Nessun backup fatto finora.':'No backup made yet.','Nessun cliente in questa giornata.':'No clients on this day.','Nessun cliente.':'No clients.',
'Nessun pagamento registrato.':'No payments recorded.','Nessun ripiegamento in questo periodo.':'No packs in this period.','Nessun ripiegamento segnato':'No packs recorded','Nessun ripiegamento segnato.':'No packs recorded.',
'Nessuno ti deve soldi. Tutti i clienti sono in pari.':'Nobody owes you money. All clients are settled.','Nome cliente':'Client name',
'Non si può annullare. Le impostazioni restano. Fai prima un backup se vuoi conservarli.':'This can’t be undone. Settings are kept. Make a backup first if you want to keep them.',
'Nota (posto, cliente, orario)':'Note (place, client, time)','Nota della giornata':'Day note','Note della giornata':'Day notes','Pagamenti':'Payments',
'Per un caso particolare cambia il prezzo del singolo ripiegamento.':'For a special case, change the price of a single pack.','Predefinito':'Default','predefinito':'default','Prezzi':'Prices',
'Prezzo standard per i nuovi clienti (€)':'Standard price for new clients (€)','Prezzo €':'Price €','Promemoria backup':'Backup reminder','Prossimi giorni di lavoro':'Upcoming working days',
'Protezione doppio tocco':'Double-tap protection','Quest\'anno':'This year','Questi sono dati di esempio.':'This is sample data.','Questo mese':'This month','Registra mancia':'Record tip',
'Registra pagamento':'Record payment','Riepilogo':'Summary','Ripieg.':'Packs','Ripiegamenti':'Packs','Ripristina':'Restore','Ripristina da file':'Restore from file','Salva':'Save','Salva file':'Save file',
'Scegli…':'Choose…','Scuro':'Dark','Settimana':'Week','Stai guardando un giorno passato.':'You are viewing a past day.','Tema':'Theme','Tocca un cliente per vedere i dettagli.':'Tap a client to see details.',
'Tocca un giorno sul calendario e attiva "Giorno di lavoro".':'Tap a day on the calendar and turn on "Working day".',
'Tocca «Aggiungi cliente alla giornata» e scegli a chi stai piegando.':'Tap “Add client to the day” and choose who you’re packing for.',
'Torna a oggi':'Back to today','Totali per cliente':'Totals per client','Tutti i clienti sono già nella giornata.':'All clients are already on this day.','Tutto':'All','Ultimi pagamenti':'Recent payments',
'Una breve vibrazione a ogni +1.':'A short vibration on every +1.','Vibrazione al tocco':'Vibrate on tap','di cui mancia €':'of which tip €','di cui mance':'of which tips','mancia':'tip',
'Mance ricevute da questo cliente:':'Tips from this client:','Nota fissa':'Permanent note','Nuova versione pronta':'New version ready','Aggiorna':'Update','Ripristinare questo backup?':'Restore this backup?','I dati attuali verranno sostituiti. Ne tengo una copia, così puoi annullare il ripristino.':'Current data will be replaced. A copy is kept so you can undo the restore.','Dati precedenti ripristinati':'Previous data restored','Attenzione: non riesco a salvare i dati su questo telefono. Fai subito un backup.':'Warning: data can\u2019t be saved on this phone. Make a backup now.','Riapri':'Reopen','Pagato':'Paid','Tutti i clienti di oggi hanno finito.':'All of today\u2019s clients are done.','Saldato: spostato tra i finiti di oggi':'Settled: moved to today\u2019s finished','Lingua':'Language','Scrivi un nome per creare il primo cliente.':'Type a name to create the first client.',
'Non hai ancora fatto un backup dei dati.':'You haven’t backed up your data yet.','Nessun cliente ancora.':'No clients yet.',
// aria / placeholders
'Arancione':'Orange','Azzurro':'Light blue','Giallo':'Yellow','Rosa':'Pink','Verde':'Green','Viola':'Purple','Cambia aspetto chiaro o scuro':'Switch light or dark look','Chiudi dettaglio':'Close details',
'Elimina cliente':'Delete client','Elimina pagamento':'Delete payment','Es. Aviosuperficie, dalle 9':'E.g. Dropzone, from 9am','Es. Filippo':'E.g. Filippo','Es. consegnata alle 15, manca il pull-up cord…':'E.g. delivered at 3pm, pull-up cord missing…',
'Filtra clienti':'Filter clients','Filtra per nome o nota…':'Filter by name or note…','Giorno precedente':'Previous day','Giorno successivo':'Next day','Guadagni in euro per mese':'Earnings in euro by month',
'Mese precedente':'Previous month','Mese successivo':'Next month','Nome':'Name','Note fisse: vela, preferenze, accordi…':'Permanent notes: canopy, preferences, agreements…','Nuovo metodo, es. Revolut':'New method, e.g. Revolut',
'Prezzo':'Price','Prezzo standard':'Standard price','Cerca cliente':'Search client','Prezzo standard per i nuovi clienti':'Standard price for new clients',
// toasts
'Tolto 1 ripiegamento':'Removed 1 pack','Nota salvata':'Note saved','Nome salvato':'Name saved','Prezzo aggiornato':'Price updated','Prezzo aggiornato per tutti':'Price updated for everyone',
'Riepilogo copiato':'Summary copied','Backup copiato':'Backup copied','Backup inviato':'Backup sent','Backup salvato nei Download':'Backup saved to Downloads','Backup ripristinato':'Backup restored',
'Pagamento registrato':'Payment recorded','Inserisci un importo':'Enter an amount','Scegli il cliente':'Choose the client','Doppio tocco ignorato':'Double tap ignored','File calendario creato':'Calendar file created',
'Esportazione non disponibile qui':'Export not available here','Salvataggio non disponibile qui':'Saving not available here','Copia non riuscita':'Copy failed',
'Testo non valido: incolla il backup completo':'Invalid text: paste the full backup','File non valido: scegli un backup di Packing':'Invalid file: choose a Packing backup',
'Aspetto automatico, come il telefono':'Automatic look, like your phone','Aspetto chiaro, per il sole':'Light look, for sunlight','Aspetto scuro':'Dark look','Lingua: italiano':'Lingua: italiano','Language: English':'Language: English'
};
const ENP=[
[/^(\d+) pagamento · (.+) incassati in totale$/,'$1 payment · $2 received in total'],[/^(\d+) pagamenti · (.+) incassati in totale$/,'$1 payments · $2 received in total'],
[/^(\d+) ripiegamenti · orari e prezzi singoli$/,'$1 packs · times and single prices'],[/^(\d+) ripiegamenti segnati$/,'$1 packs recorded'],[/^(\d+) ripiegamenti · (.+)$/,'$1 packs · $2'],[/^(\d+) ripiegamenti$/,'$1 packs'],
[/^(\d+) giorni$/,'$1 days'],[/^in (\d+) giorni$/,'in $1 days'],[/^Deve (.+)$/,'Owes $1'],[/^Anticipo (.+)$/,'Advance $1'],[/^In pari$/,'Settled'],[/^Lavoro (.+) · Pagato (.+)$/,'Work $1 · Paid $2'],
[/^Packing · versione (.+)$/,'Packing · version $1'],[/^Mettere (.+) a tutti i (\d+) clienti\? I ripiegamenti già segnati non cambiano\.$/,'Set $1 for all $2 clients? Packs already recorded won’t change.'],
[/^Nota fissa: ([\s\S]*)$/,'Permanent note: $1'],[/^Questo mese · (\d+) ripiegamenti$/,'This month · $1 packs'],[/^Mese scorso · (\d+) ripiegamenti$/,'Last month · $1 packs'],
[/^Se ricevi più di (.+), la differenza va nelle mance\. Se era un anticipo, metti 0\.$/,'If you receive more than $1, the difference goes to tips. If it was an advance, set 0.'],
[/^Totale · (\d+) ripiegamenti$/,'Total · $1 packs'],[/^Totale (.+) · (\d+) ripiegamenti$/,'Total $1 · $2 packs'],[/^e i suoi (\d+) ripiegamenti\?$/,'and their $1 packs?'],
[/^In (.+) non hai ancora segnato ripiegamenti\.$/,'No packs recorded in $1 yet.'],[/^▲ nuovo rispetto a (.+)$/,'▲ new compared to $1'],[/^▲ \+(\d+)% rispetto a (.+)$/,'▲ +$1% vs $2'],
[/^▼ (-?\d+)% rispetto a (.+)$/,'▼ $1% vs $2'],[/^= come (.+)$/,'= same as $1'],[/^Mostrati gli ultimi (\d+)\.$/,'Showing the last $1.'],[/^Nessun ripiegamento in (.+)\.$/,'No packs in $1.'],
[/^Nessun ripiegamento segnato$/,'No packs recorded'],[/^Ultimo backup: (.+)$/,'Last backup: $1'],[/^Ultimo backup (\d+) giorni fa\.$/,'Last backup $1 days ago.'],
[/^\+ Nuovo cliente «(.+)» a (.+)$/,'+ New client “$1” at $2'],[/^di cui mancia (.+)$/,'of which tip $1'],[/^(\d+) (?:ripiegamenti|ripiegamento)$/,'$1 packs'],
[/^(.+) · (Contanti|Bonifico|Altro)$/,(m,a,b)=>a+' · '+EN[b]],
[/^Aggiungi un ripiegamento per (.+)$/,'Add a pack for $1'],[/^Togli l'ultimo ripiegamento di (.+)$/,'Remove the last pack of $1'],[/^Togli (.+) dalla giornata$/,'Remove $1 from the day'],
[/^Dettagli di (.+)$/,'Details of $1'],[/^Nota della giornata per (.+)$/,'Day note for $1'],[/^Togli (.+)$/,'Remove $1'],[/^Elimina (.+)$/,'Delete $1'],
// toasts
[/^Backup del (.+) · (\d+) clienti · (\d+) ripiegamenti · (\d+) pagamenti$/,'Backup of $1 · $2 clients · $3 packs · $4 payments'],[/^(\d+) clienti · (\d+) ripiegamenti · (\d+) pagamenti$/,'$1 clients · $2 packs · $3 payments'],[/^Annulla l'ultimo ripristino \(dati del (.+)\)$/,'Undo last restore (data from $1)'],[/^Finiti oggi · (\d+)$/,'Finished today · $1'],[/^(.+) spostato tra i finiti$/,'$1 moved to finished'],[/^(.+) riaperto$/,'$1 reopened'],[/^Segna (.+) come finito$/,'Mark $1 as finished'],[/^(.+) aggiunto$/,'$1 added'],[/^(.+) tolto$/,'$1 removed'],[/^(.+) c'è già$/,'$1 already exists'],[/^Prezzo standard: (.+)$/,'Standard price: $1'],[/^Mancia registrata: (.+)$/,'Tip recorded: $1'],
[/^Pagamento registrato, mancia (.+)$/,'Payment recorded, tip $1']
];
function tr(s){if(!isEN()||!s)return s;const k=s.replace(/\s+/g,' ').trim();const put=x=>s.includes(k)?s.replace(k,x):x;
if(Object.prototype.hasOwnProperty.call(EN,k))return put(EN[k]);
for(const [re,rep] of ENP){if(re.test(k))return put(k.replace(re,rep))}return s}
const tm=m=>isEN()&&EN[m]?EN[m]:m;
let trBusy=false;
function translateDom(root){
if(!isEN()||trBusy)return;trBusy=true;
try{const w=document.createTreeWalker(root||document.body,NodeFilter.SHOW_TEXT);let n;
while(n=w.nextNode()){const p=n.parentNode;if(!p||p.nodeName==='SCRIPT'||p.nodeName==='STYLE'||p.nodeName==='TEXTAREA'||p.closest('[data-u]'))continue;
const v=n.nodeValue;if(!v.trim())continue;const t=tr(v);if(t!==v)n.nodeValue=t}
(root||document.body).querySelectorAll('[placeholder],[aria-label]').forEach(e=>{['placeholder','aria-label'].forEach(a=>{const v=e.getAttribute(a);if(v){const t=tr(v);if(t!==v)e.setAttribute(a,t)}})});
}finally{trBusy=false}
}
let trQueued=false;
try{new MutationObserver(()=>{if(!isEN()||trBusy||trQueued)return;trQueued=true;requestAnimationFrame(()=>{trQueued=false;translateDom()})}).observe(document.body,{childList:true,subtree:true,characterData:true})}catch(e){}
const num=v=>parseFloat(String(v==null?'':v).replace(',','.').replace(/[^0-9.\-]/g,''));
const esc=s=>String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
function load(){try{const r=localStorage.getItem(KEY);if(r)return JSON.parse(r)}catch(e){}return null}
let saveWarned=0;
function save(){try{localStorage.setItem(KEY,JSON.stringify(S))}catch(e){if(Date.now()-saveWarned>60000){saveWarned=Date.now();setTimeout(()=>toast('Attenzione: non riesco a salvare i dati su questo telefono. Fai subito un backup.'),50)}}}
function sample(){
const ex=(navigator.language||'it').toLowerCase().startsWith('it')?'esempio':'example';
const c=[{id:uid(),name:'Marco ('+ex+')',price:6,note:ex==='esempio'?'Sabre 2 170 · vuole il pro pack':'Sabre 2 170 · wants a pro pack'},{id:uid(),name:(ex==='esempio'?'Scuola Skydive':'Skydive School')+' ('+ex+')',price:6},{id:uid(),name:'Giulia ('+ex+')',price:8}];
const d=today(),e=[];const add=(cl,n)=>{for(let i=0;i<n;i++)e.push({id:uid(),clientId:cl.id,day:d,ts:Date.now()-i*6e5,price:cl.price})};
add(c[0],4);add(c[1],7);add(c[2],1);
return {clients:c,entries:e,dayNotes:{[d]:{[c[1].id]:ex==='esempio'?'2 tandem consegnati alle 15':'2 tandems delivered at 3pm'}},payments:[{id:uid(),clientId:c[0].id,day:d,ts:Date.now(),amount:12,method:'Contanti'}],sample:true};
}
let S=load()||sample();
S.planned=S.planned||{};
S.payments=S.payments||[];
S.dayNotes=S.dayNotes||{};
S.dayDone=S.dayDone||{};
if(!S.dayClients){S.dayClients={};S.entries.forEach(e=>{const l=S.dayClients[e.day]||(S.dayClients[e.day]=[]);if(!l.includes(e.clientId))l.push(e.clientId)})}
const METHODS=['Contanti','Bonifico','PayPal','Satispay','Altro'];
const SET_DEFAULTS={lang:((navigator.language||'it').toLowerCase().startsWith('it')?'it':'en'),name:'',price:DEFAULT_PRICE,methods:METHODS,defMethod:'Contanti',accent:'azzurro',vibrate:true,guard:true,backupDays:7};
S.set=Object.assign({},SET_DEFAULTS,S.set||{});
const cfg=()=>S.set;
const ACCENTS={
azzurro:{label:'Azzurro',d:'#2cc0e8',l:'#0a8ec0',inkD:'#03202c',inkL:'#ffffff'},
verde:{label:'Verde',d:'#4fd49a',l:'#16865a',inkD:'#04241a',inkL:'#ffffff'},
arancione:{label:'Arancione',d:'#ff8a4c',l:'#c9581a',inkD:'#2a1003',inkL:'#ffffff'},
viola:{label:'Viola',d:'#ac8dff',l:'#6a45d6',inkD:'#1c0f3d',inkL:'#ffffff'},
rosa:{label:'Rosa',d:'#ff7ab6',l:'#c2347a',inkD:'#3a0a20',inkL:'#ffffff'},
giallo:{label:'Giallo',d:'#ffcf3f',l:'#a87700',inkD:'#2b2000',inkL:'#ffffff'}};
const APP_VERSION='1.9.3';
let view={tab:'conta',day:today(),range:'day',editing:null,adding:false,confirm:null,importOpen:false,calMonth:today().slice(0,7),calSel:today()};
const clientById=id=>S.clients.find(c=>c.id===id);
const entriesOn=day=>S.entries.filter(e=>e.day===day);
function inRange(e){
if(view.range==='day')return e.day===view.day;
if(view.range==='all')return true;
const d=parseDay(view.day),x=parseDay(e.day);
if(view.range==='month')return x.getFullYear()===d.getFullYear()&&x.getMonth()===d.getMonth();
if(view.range==='week'){const m=new Date(d);m.setDate(d.getDate()-((d.getDay()+6)%7));const s=new Date(m);s.setDate(m.getDate()+7);return x>=m&&x<s}
}
function summarize(list){
const by={};list.forEach(e=>{const b=by[e.clientId]||(by[e.clientId]={n:0,tot:0});b.n++;b.tot+=e.price});
const rows=Object.entries(by).map(([id,v])=>({name:(clientById(id)||{name:'Cliente eliminato'}).name,...v})).sort((a,b)=>b.n-a.n);
return {rows,n:list.length,tot:list.reduce((s,e)=>s+e.price,0)};
}
let toastT;function toast(m){const t=document.getElementById('toast');t.textContent=tr(m);t.hidden=false;clearTimeout(toastT);toastT=setTimeout(()=>t.hidden=true,1800)}
function dayBar(){
const sum=summarize(entriesOn(view.day));
const isToday=view.day===today();
return `<div class="daybar">
<button class="navbtn" data-act="prev" aria-label="Giorno precedente">‹</button>
<div class="daylabel"><div class="eyebrow">${isToday?'Giornata di oggi':'Giornata'}</div><div class="date">${fmtDay(view.day)}</div></div>
<button class="navbtn" data-act="next" aria-label="Giorno successivo" ${isToday?'disabled style="opacity:.35"':''}>›</button>
</div>
<div class="totals"><div><div class="k">Ripiegamenti</div><div class="v">${sum.n}</div></div><div><div class="k">Guadagno</div><div class="v eur">${eur(sum.tot)}</div></div></div>
${!isToday?`<div class="banner">Stai guardando un giorno passato.<button data-act="today">Torna a oggi</button></div>`:''}`;
}
const SKIN_ICONS={
auto:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5v17a8.5 8.5 0 0 0 0-17z" fill="currentColor"/></svg>',
light:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.3 5.3l1.4 1.4M17.3 17.3l1.4 1.4M5.3 18.7l1.4-1.4M17.3 6.7l1.4-1.4"/></svg>',
dark:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/></svg>'};
function applySkin(){
const m=S.theme||'auto',root=document.documentElement;
if(m==='auto')delete root.dataset.skin;else root.dataset.skin=m;
const b=document.getElementById('themeBtn');if(b)b.innerHTML=SKIN_ICONS[m];
let light=m==='light';if(m==='auto'){try{light=matchMedia('(prefers-color-scheme: light)').matches}catch(e){}}
const a=ACCENTS[cfg().accent]||ACCENTS.azzurro,st=root.style;
if(cfg().accent==='azzurro'){['--accent','--accent-ink','--accent-soft'].forEach(k=>st.removeProperty(k))}
else{const c=light?a.l:a.d,n=parseInt(c.slice(1),16);
st.setProperty('--accent',c);st.setProperty('--accent-ink',light?a.inkL:a.inkD);
st.setProperty('--accent-soft',`rgba(${n>>16},${(n>>8)&255},${n&255},${light?.12:.16})`)}
const sb=document.getElementById('setBtn');if(sb)sb.classList.toggle('on',view.tab==='set');
translateDom();
const meta=document.querySelector('meta[name="theme-color"]');
if(meta){const bg=getComputedStyle(root).getPropertyValue('--bg').trim();if(bg)meta.setAttribute('content',bg)}
}
try{matchMedia('(prefers-color-scheme: light)').addEventListener('change',applySkin)}catch(e){}
function backupDue(){const d=cfg().backupDays;return d>0&&!S.sample&&S.entries.length>0&&(!S.lastBackup||Date.now()-S.lastBackup>d*864e5)}
function backupJSON(){return JSON.stringify({app:'Packing',saved:new Date().toISOString(),clients:S.clients,entries:S.entries,planned:S.planned,payments:S.payments,dayClients:S.dayClients,dayNotes:S.dayNotes,dayDone:S.dayDone,set:S.set,theme:S.theme},null,1)}
function backupName(){return 'packing-backup-'+today()+'.json'}
function downloadBackup(){
try{const url=URL.createObjectURL(new Blob([backupJSON()],{type:'application/json'}));const l=document.createElement('a');l.href=url;l.download=backupName();document.body.appendChild(l);l.click();l.remove();setTimeout(()=>URL.revokeObjectURL(url),4000);
S.lastBackup=Date.now();save();render();toast('Backup salvato nei Download')}catch(e){toast('Salvataggio non disponibile qui')}
}
async function shareBackup(){
let f;try{f=new File([backupJSON()],backupName(),{type:'application/json'})}catch(e){}
if(f&&navigator.canShare&&navigator.canShare({files:[f]})){
try{await navigator.share({files:[f],title:'Backup Packing',text:(isEN()?'Packing data backup – ':'Backup dei dati di Packing del ')+fmtDay(today())});S.lastBackup=Date.now();save();render();toast('Backup inviato')}
catch(e){if(e&&e.name!=='AbortError')downloadBackup()}
}else downloadBackup();
}
const PREV_KEY=KEY+'.prima-del-ripristino';
function cleanImport(d){
if(!d||!Array.isArray(d.clients)||!Array.isArray(d.entries))throw new Error('bad');
const ok=o=>o&&typeof o==='object';
const clients=d.clients.filter(c=>ok(c)&&c.id&&typeof c.name==='string').map(c=>({...c,price:isFinite(c.price)?Number(c.price):cfg().price}));
const ids=new Set(clients.map(c=>c.id));
const entries=d.entries.filter(e=>ok(e)&&ids.has(e.clientId)&&/^\d{4}-\d{2}-\d{2}$/.test(e.day)&&isFinite(e.price)).map(e=>({...e,price:Number(e.price)}));
const payments=(Array.isArray(d.payments)?d.payments:[]).filter(p=>ok(p)&&ids.has(p.clientId)&&isFinite(p.amount)&&/^\d{4}-\d{2}-\d{2}$/.test(p.day));
const obj=x=>ok(x)&&!Array.isArray(x)?x:{};
return {clients,entries,payments,planned:obj(d.planned),dayClients:obj(d.dayClients),dayNotes:obj(d.dayNotes),dayDone:obj(d.dayDone),set:Object.assign({},SET_DEFAULTS,ok(d.set)?d.set:S.set),theme:d.theme||S.theme,saved:d.saved||''};
}
function askImport(d){view.pendingImport=cleanImport(d);view.importOpen=false;view.tab='set';render();const el=document.getElementById('importConfirm');el&&el.scrollIntoView({behavior:'smooth',block:'center'})}
function applyImport(){
const n=view.pendingImport;if(!n)return;
try{localStorage.setItem(PREV_KEY,JSON.stringify({at:Date.now(),data:S}))}catch(e){}
const {saved,...data}=n;S={...data,lastBackup:Date.now()};view.pendingImport=null;
closePast();save();applySkin();render();toast('Backup ripristinato');
}
function prevCopy(){try{const r=localStorage.getItem(PREV_KEY);return r?JSON.parse(r):null}catch(e){return null}}
function undoImport(){const p=prevCopy();if(!p||!p.data)return;S=p.data;try{localStorage.removeItem(PREV_KEY)}catch(e){}closePast();save();applySkin();render();toast('Dati precedenti ripristinati')}
function dayNote(d,cid){return ((S.dayNotes||{})[d]||{})[cid]||''}
function setDayNote(d,cid,txt){const m=S.dayNotes[d]||(S.dayNotes[d]={});if(txt)m[cid]=txt;else{delete m[cid];if(!Object.keys(m).length)delete S.dayNotes[d]}}
function dayIds(d){
const ids=[...(S.dayClients[d]||[])];
entriesOn(d).forEach(e=>{if(!ids.includes(e.clientId))ids.push(e.clientId)});
return ids.filter(id=>clientById(id));
}
function closePast(){const t=today(),from=S.closedTo||'';if(from===t)return;
const days=new Set([...Object.keys(S.dayClients||{}),...S.entries.map(e=>e.day)]);
days.forEach(d=>{if(d<t&&d>=from){const ids=dayIds(d);if(ids.length)S.dayDone[d]=[...new Set([...(S.dayDone[d]||[]),...ids])]}});
S.closedTo=t;save()}
function searchResults(raw){
const q=raw.trim().toLowerCase(),active=new Set(dayIds(view.day));
const list=S.clients.filter(c=>!active.has(c.id)&&(!q||(c.name+' '+(c.note||'')).toLowerCase().includes(q))).sort((a,b)=>a.name.localeCompare(b.name,isEN()?'en':'it'));
let html=list.map(c=>`<button class="res" data-act="dayAdd" data-id="${c.id}"><span class="grow">${esc(c.name)}${c.note?`<br><span class="muted small clip1">${esc(c.note.split('\n')[0])}</span>`:''}</span><span class="muted small">${eur(c.price)}</span></button>`).join('');
if(q&&!S.clients.some(c=>c.name.toLowerCase()===q))html+=`<button class="res new" data-act="createFromSearch">+ Nuovo cliente «${esc(raw.trim())}» a ${eur(cfg().price)}</button>`;
if(!html)html=`<div class="muted small" style="padding:12px 14px">${S.clients.length?'Tutti i clienti sono già nella giornata.':'Scrivi un nome per creare il primo cliente.'}</div>`;
return html;
}
function renderConta(){
const day=entriesOn(view.day);
const doneSet=new Set(S.dayDone[view.day]||[]),allIds=dayIds(view.day);
const tiles=allIds.filter(id=>!doneSet.has(id)).map(clientById).map(c=>{
const mine=day.filter(e=>e.clientId===c.id),n=mine.length;
return `<div class="tile-wrap"><button class="tile ${n?'':'zero'}" data-act="plus" data-id="${c.id}" id="tile-${c.id}" aria-label="Aggiungi un ripiegamento per ${esc(c.name)}">
<span class="tl"><span class="name" data-u>${esc(c.name)}</span>
${c.note?`<span class="cnote clip3" data-u>${esc(c.note)}</span>`:''}
</span>
<span class="tc"><span class="count">${n}</span><span class="sub">${eur(mine.reduce((s,e)=>s+e.price,0))}</span></span><span class="plus" aria-hidden="true">+</span>
</button>${n?`<button class="minus" data-act="minus" data-id="${c.id}" aria-label="Togli l'ultimo ripiegamento di ${esc(c.name)}">−</button>`
:`<button class="minus" data-act="dayRemove" data-id="${c.id}" aria-label="Togli ${esc(c.name)} dalla giornata">×</button>`}
<button class="minus donebtn" data-act="doneOn" data-id="${c.id}" aria-label="Segna ${esc(c.name)} come finito"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg></button></div>`}).join('');
const fin=allIds.filter(id=>doneSet.has(id)).map(clientById).filter(Boolean);
const finHtml=fin.length?`<h2>Finiti oggi · ${fin.length}</h2><div class="card">${fin.map(c=>{const mine=day.filter(e=>e.clientId===c.id),due=balance(c.id).due;
return `<div class="row finrow"><span class="grow"><b data-u>${esc(c.name)}</b><br><span class="muted small">${mine.length} ripiegamenti · ${eur(mine.reduce((a,e)=>a+e.price,0))}</span></span>
${due>0?`<span class="pill due">Deve ${eur(due)}</span>`:`<span class="pill ok">Pagato</span>`}
<button class="btn ghost small" data-act="doneOff" data-id="${c.id}">Riapri</button></div>`}).join('')}</div>`:'';
return `${dayBar()}
${S.sample?`<div class="banner">Questi sono dati di esempio.<button data-act="clearSample">Cancella e inizia</button></div>`:''}
${backupDue()?`<div class="banner">${S.lastBackup?'Ultimo backup '+Math.floor((Date.now()-S.lastBackup)/864e5)+' giorni fa.':'Non hai ancora fatto un backup dei dati.'}<button data-act="shareBackup">Invia backup</button></div>`:''}
${view.pickOpen?`<div class="card picker">
<div class="pick-h"><input type="search" id="search" value="${esc(view.pickQ||'')}" placeholder="Filtra per nome o nota…" autocomplete="off" aria-label="Filtra clienti" enterkeyhint="done">
<button class="btn ghost" data-act="pickClose">Fatto</button></div>
<div class="results" id="results">${searchResults(view.pickQ||'')}</div>
</div>`:`<button class="pickbtn" data-act="pickOpen"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Aggiungi cliente alla giornata</button>`}
${tiles?`<div class="tiles">${tiles}</div>`
:fin.length?`<div class="empty">Tutti i clienti di oggi hanno finito.</div>`:`<div class="empty">Nessun cliente in questa giornata.<br>Tocca «Aggiungi cliente alla giornata» e scegli a chi stai piegando.</div>`}${finHtml}`;
}
function openResults(){const r=document.getElementById('results'),s=document.getElementById('search');if(r&&s){view.pickQ=s.value;r.innerHTML=searchResults(s.value)}}
function addForm(){
return `<div class="card"><form class="form" id="addForm">
<div class="two"><label class="f">Nome cliente<input type="text" id="newName" required autocomplete="off"></label>
<label class="f">Prezzo €<input type="text" inputmode="decimal" id="newPrice" step="0.5" min="0" value="${cfg().price}"></label></div>
<div class="actions"><button class="btn primary" type="submit">Aggiungi</button><button class="btn ghost" type="button" data-act="addClose">Annulla</button></div>
</form></div>`;
}
function rankPeriod(kind,shift){
const d=parseDay(view.day);
if(kind==='month'){const x=new Date(d.getFullYear(),d.getMonth()+shift,1);return {key:x.getFullYear()+'-'+pad(x.getMonth()+1),label:x.toLocaleDateString(LOC(),{month:'long',year:'numeric'}),short:x.toLocaleDateString(LOC(),{month:'long'})}}
if(kind==='year'){const y=d.getFullYear()+shift;return {key:String(y),label:String(y),short:String(y)}}
return {key:'',label:'sempre',short:''};
}
function rankStats(key){
const by={};
S.entries.forEach(e=>{if(key&&!e.day.startsWith(key))return;const c=clientById(e.clientId);if(!c)return;
const b=by[e.clientId]||(by[e.clientId]={id:e.clientId,name:c.name,n:0,tot:0,days:new Set()});b.n++;b.tot+=e.price;b.days.add(e.day)});
return Object.values(by).sort((a,b)=>b.tot-a.tot||b.n-a.n);
}
function rankDetail(r,i,kind,inline){
const prevKey=kind==='all'?null:rankPeriod(kind,-1).key;
let trend='';
if(prevKey!==null){const p=rankStats(prevKey).find(x=>x.id===r.id);const pl=rankPeriod(kind,-1).short;
if(!p)trend=`<span class="up">▲ nuovo rispetto a ${pl}</span>`;
else{const pct=Math.round((r.tot-p.tot)/p.tot*100);trend=pct>0?`<span class="up">▲ +${pct}% rispetto a ${pl}</span>`:pct<0?`<span class="dn">▼ ${pct}% rispetto a ${pl}</span>`:`<span class="muted small">= come ${pl}</span>`}}
const due=Math.max(0,balance(r.id).due),days=r.days.size,c=clientById(r.id);
return `<div class="rdet" id="rdet-${r.id}">
${inline?(trend?`<div>${trend}</div>`:''):`<div class="dh"><span class="rk r${i<3?i+1:''}">${i+1}</span><div class="g"><div class="nm">${esc(r.name)}</div>${trend}</div><div class="eur">${eur(r.tot)}</div>
<button class="minus" style="position:static;flex:none" data-act="rankSel" data-id="${r.id}" aria-label="Chiudi dettaglio">×</button></div>`}
<div class="st">
<div><b>${r.n}</b><span>Ripieg.</span></div>
<div><b>${days}</b><span>Giorni</span></div>
<div><b>${(r.n/days).toLocaleString(LOC(),{maximumFractionDigits:1})}</b><span>Al giorno</span></div>
<div><b style="${due?'color:var(--warn)':''}">${eur(due)}</b><span>Da pagare</span></div>
</div>
${(()=>{const tp=tipsSum(p=>p.clientId===r.id);return tp?`<div class="small">Mance ricevute da questo cliente: <b class="tipc">${eur(tp)}</b></div>`:''})()}
${c&&c.note?`<div class="muted small" style="white-space:pre-wrap">${esc(c.note)}</div>`:''}
</div>`;
}
function rankSection(){
const kind=view.rankKind||'month',per=rankPeriod(kind,0),list=rankStats(per.key);
const segs=[['month','Mese'],['year','Anno'],['all','Tutto']].map(([k,l])=>`<button data-act="rankKind" data-v="${k}" aria-pressed="${kind===k}">${l}</button>`).join('');
const head=`<h2>Classifica clienti</h2><div class="seg">${segs}</div>`;
if(!list.length)return head+`<div class="empty">Nessun ripiegamento ${kind==='all'?'segnato':'in '+per.label}.</div>`;
const total=list.reduce((a,r)=>a+r.tot,0),sel=view.rankSel;
const med=['gold','silver','bronze'],h=[96,68,48];
const pc=i=>{const r=list[i];if(!r)return `<div class="pc"></div>`;
return `<button class="pc ${sel===r.id?'sel':''}" data-act="rankSel" data-id="${r.id}" aria-label="Dettagli di ${esc(r.name)}">
<span class="av" style="border-color:var(--${med[i]})">${esc(r.name.trim().charAt(0).toUpperCase())}</span>
<span class="nm">${esc(r.name)}</span><span class="eur">${eur(r.tot)}</span>
<span class="blk m-${med[i]}" style="height:${h[i]}px">${i+1}</span></button>`};
const podium=`<div class="card"><div class="pod">${pc(1)}${pc(0)}${pc(2)}</div></div>`;
const topDet=list.slice(0,3).findIndex(r=>r.id===sel);
const rest=list.slice(3).map((r,j)=>{const i=j+3;
return `<button class="row rrow ${sel===r.id?'sel':''}" data-act="rankSel" data-id="${r.id}"><span class="rk">${i+1}</span><span class="grow" style="text-align:left"><b>${esc(r.name)}</b><br><span class="muted small">${r.n} ripiegamenti</span></span><span class="eur">${eur(r.tot)}</span></button>${sel===r.id?rankDetail(r,i,kind,true):''}`}).join('');
return `${head}${podium}${topDet>=0?`<div class="card">${rankDetail(list[topDet],topDet,kind)}</div>`:''}
<div class="card">${rest}<div class="row total"><span class="grow">Totale ${kind==='all'?'':per.label} · ${list.reduce((a,r)=>a+r.n,0)} ripiegamenti</span><span class="num">${eur(total)}</span></div></div>
<p class="muted small" style="margin:0">Tocca un cliente per vedere i dettagli.</p>`;
}
function monthChart(){
if(!S.entries.length)return '';
const now=new Date(),months=[];
const first=S.entries.reduce((m,e)=>e.day<m?e.day:m,today()).slice(0,7);
for(let i=11;i>=0;i--){const d=new Date(now.getFullYear(),now.getMonth()-i,1);const k=d.getFullYear()+'-'+pad(d.getMonth()+1);if(k>=first)months.push({k,d})}
months.forEach(m=>{const es=S.entries.filter(e=>e.day.startsWith(m.k));m.n=es.length;m.tot=es.reduce((s,e)=>s+e.price,0);
m.paid=S.payments.filter(p=>p.day.startsWith(m.k)).reduce((s,p)=>s+p.amount,0)});
const max=Math.max(...months.map(m=>m.tot),1);
const W=360,H=190,top=22,bottom=24,ch=H-top-bottom,slot=W/Math.max(months.length,6),bw=Math.min(36,slot*0.62);
const bars=months.map((m,i)=>{const x=i*slot+(slot-bw)/2,h=Math.max(m.tot/max*ch,m.tot?3:1),y=top+ch-h;
const lab=m.d.toLocaleDateString(LOC(),{month:'short'}).replace('.','');
return `<g data-act="monthPick" data-k="${m.k}" style="cursor:pointer"><rect x="${i*slot}" y="0" width="${slot}" height="${H}" fill="transparent"></rect>
<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="4" fill="${m.tot?'var(--accent)':'var(--line)'}" ${i===months.length-1?'':'fill-opacity="0.8"'}></rect>
${m.tot?`<text x="${(x+bw/2).toFixed(1)}" y="${(y-6).toFixed(1)}" text-anchor="middle" font-size="11" fill="var(--fg)" style="font-variant-numeric:tabular-nums">${Math.round(m.tot)}</text>`:''}
<text x="${(x+bw/2).toFixed(1)}" y="${H-6}" text-anchor="middle" font-size="11" fill="var(--muted)">${lab}</text></g>`}).join('');
const cur=months[months.length-1],prev=months[months.length-2];
return `<h2>Guadagni per mese</h2>
<div class="card" style="padding:14px 12px 6px">
<svg viewBox="0 0 ${W} ${H}" width="100%" role="img" aria-label="Guadagni in euro per mese" style="display:block;font-family:var(--body)">
<line x1="0" x2="${W}" y1="${top+ch}" y2="${top+ch}" stroke="var(--line)"></line>${bars}</svg>
</div>
<div class="card">
<div class="row"><span class="grow">Questo mese · ${cur.n} ripiegamenti</span><span class="num">${eur(cur.tot)}</span></div>
<div class="row"><span class="grow muted">Incassato questo mese</span><span class="num muted">${eur(cur.paid)}</span></div>
${tipsSum(p=>p.day.startsWith(cur.k))?`<div class="row"><span class="grow muted">di cui mance</span><span class="num tipc">${eur(tipsSum(p=>p.day.startsWith(cur.k)))}</span></div>`:''}
${prev?`<div class="row"><span class="grow muted">Mese scorso · ${prev.n} ripiegamenti</span><span class="num muted">${eur(prev.tot)}</span></div>`:''}
</div>
<p class="muted small" style="margin:0">Importi in euro del lavoro fatto. Tocca un mese per vederne i totali per cliente.</p>`;
}
function renderRiep(){
const list=S.entries.filter(inRange);
const sum=summarize(list);
const R=[['day','Giornata'],['week','Settimana'],['month','Mese'],['all','Tutto']];
const seg=`<div class="seg">${R.map(([k,l])=>`<button data-act="range" data-r="${k}" aria-pressed="${view.range===k}">${l}</button>`).join('')}</div>`;
const table=sum.n?`<div class="card">${sum.rows.map(r=>`<div class="row"><span class="grow">${esc(r.name)}</span><span class="num muted">${r.n} ×</span><span class="num" style="min-width:84px">${eur(r.tot)}</span></div>`).join('')}
<div class="row total"><span class="grow">Totale · ${sum.n} ripiegamenti</span><span class="num">${eur(sum.tot)}</span></div></div>
<div class="actions"><button class="btn" data-act="copySum">Copia riepilogo</button></div>`
:`<div class="empty">Nessun ripiegamento in questo periodo.</div>`;
let detail='';
if(view.range==='day'&&list.length){
const items=[...list].sort((a,b)=>b.ts-a.ts).map(e=>{
const c=clientById(e.clientId);const t=new Date(e.ts);
return `<div class="row"><span class="num muted small">${pad(t.getHours())}:${pad(t.getMinutes())}</span><span class="grow">${esc(c?c.name:'Cliente eliminato')}</span>
<input type="text" inputmode="decimal" class="price-in" id="p-${e.id}" data-price="${e.id}" step="0.5" min="0" value="${e.price}" aria-label="Prezzo">
<button class="minus" style="position:static" data-act="delEntry" data-id="${e.id}" aria-label="Elimina">×</button></div>`}).join('');
const open=!!view.detailOpen;
detail=`<button class="fold" data-act="toggleDetail" aria-expanded="${open}"><span class="grow"><span class="fold-t">Dettaglio giornata</span><br><span class="muted small">${list.length} ripiegamenti · orari e prezzi singoli</span></span><svg class="chev" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg></button>
${open?`<p class="muted small" style="margin:0">Per un caso particolare cambia il prezzo del singolo ripiegamento.</p><div class="card">${items}</div>`:''}`;
}
const dn=view.range==='day'?Object.entries(S.dayNotes[view.day]||{}).filter(([id])=>clientById(id)):[];
const notes=dn.length?`<h2>Note della giornata</h2><div class="card">${dn.map(([id,t])=>`<div class="row"><span class="grow"><b>${esc(clientById(id).name)}</b><br><span class="small" style="white-space:pre-wrap">${esc(t)}</span></span></div>`).join('')}</div>`:'';
return `${dayBar()}<h2>Totali per cliente</h2>${seg}${table}${notes}${detail}${rankSection()}${monthChart()}`;
}
function renderCli(){
const rows=S.clients.map(c=>{
if(view.confirm===c.id){
const n=S.entries.filter(e=>e.clientId===c.id).length;
return `<div class="row" style="flex-wrap:wrap"><span class="grow">Eliminare <b>${esc(c.name)}</b>${n?` e i suoi ${n} ripiegamenti`:''}?</span>
<div class="actions"><button class="btn danger" data-act="delCli" data-id="${c.id}">Elimina</button><button class="btn ghost" data-act="cancelDel">Annulla</button></div></div>`}
return `<div class="crow"><div class="row" style="border-top:0"><input type="text" id="n-${c.id}" data-name="${c.id}" value="${esc(c.name)}" aria-label="Nome">
<input type="text" inputmode="decimal" class="price-in" id="cp-${c.id}" data-cprice="${c.id}" step="0.5" min="0" value="${c.price}" aria-label="Prezzo standard">
<button class="minus" style="position:static;flex:none" data-act="askDel" data-id="${c.id}" aria-label="Elimina cliente">×</button></div>
<div style="padding:0 14px 12px"><textarea class="note-in" id="cn-${c.id}" data-cnote="${c.id}" rows="2" placeholder="Note fisse: vela, preferenze, accordi…">${esc(c.note||'')}</textarea></div></div>`}).join('');
return `<h2>Clienti e prezzo standard</h2>
<p class="muted small" style="margin:0">Il prezzo vale per i nuovi ripiegamenti. Quelli già segnati non cambiano.</p>
${view.adding?addForm():''}
${S.clients.length?`<div class="card">${rows}</div>`:`<div class="empty">Nessun cliente.</div>`}
<div class="actions"><button class="btn primary" data-act="addOpen">+ Nuovo cliente</button></div>
<p class="muted small" style="margin:0">Backup, prezzo per i nuovi clienti e metodi di pagamento sono nelle Impostazioni (ingranaggio in alto).</p>`;
}
const compact=k=>k.replace(/-/g,'');
function nextDay(k){const d=parseDay(k);d.setDate(d.getDate()+1);return dayKey(d)}
function gcalLink(k){
const note=S.planned[k]||'';
const p=new URLSearchParams({action:'TEMPLATE',text:(isEN()?'Parachute packing':'Ripiegamenti')+(note?' – '+note:''),dates:compact(k)+'/'+compact(nextDay(k)),details:isEN()?'Working day marked in Packing':'Giornata di lavoro segnata in Packing'});
return 'https://calendar.google.com/calendar/render?'+p.toString();
}
function icsText(keys){
const stamp=new Date().toISOString().replace(/[-:]/g,'').slice(0,15)+'Z';
const ev=keys.map(k=>['BEGIN:VEVENT','UID:packing-'+k+'@packing','DTSTAMP:'+stamp,'DTSTART;VALUE=DATE:'+compact(k),'DTEND;VALUE=DATE:'+compact(nextDay(k)),'SUMMARY:'+(isEN()?'Parachute packing':'Ripiegamenti')+(S.planned[k]?' - '+S.planned[k].replace(/[,;\n]/g,' '):''),'END:VEVENT'].join('\r\n'));
return ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Packing//IT',...ev,'END:VCALENDAR'].join('\r\n');
}
function seg(act,opts,cur){return `<div class="seg">${opts.map(([v,l])=>`<button data-act="${act}" data-v="${v}" aria-pressed="${String(cur)===String(v)}">${l}</button>`).join('')}</div>`}
function toggleRow(k,title,sub){return `<label class="switch"><span><b>${title}</b><br><span class="muted small">${sub}</span></span><input type="checkbox" id="set-${k}" data-toggle="${k}" ${cfg()[k]?'checked':''}></label>`}
function renderSet(){
const c=cfg();
const methods=c.methods.map((m,i)=>`<div class="row"><span class="grow">${esc(tm(m))}${m===c.defMethod?' <span class="pill ok" style="margin-left:6px">predefinito</span>':''}</span>
${m!==c.defMethod?`<button class="btn ghost small" data-act="defMethod" data-i="${i}">Predefinito</button>`:''}
<button class="minus" style="position:static;flex:none" data-act="delMethod" data-i="${i}" aria-label="Togli ${esc(m)}" ${c.methods.length<2?'disabled style="position:static;flex:none;opacity:.35"':''}>×</button></div>`).join('');
const sw=Object.entries(ACCENTS).map(([k,a])=>`<button class="swatch" data-act="setAccent" data-v="${k}" aria-pressed="${c.accent===k}" aria-label="${a.label}" style="background:${a.d}"></button>`).join('');
return `<h2 style="margin-top:0">Impostazioni</h2>
<h2>Lingua / Language</h2>
${seg('setLang',[['it','Italiano'],['en','English']],c.lang||'it')}
<h2>Il tuo profilo</h2>
<div class="card"><div class="form">
<label class="f">Il tuo nome<input type="text" id="set-name" data-set="name" value="${esc(c.name)}" placeholder="Es. Filippo" autocomplete="name"></label>
<span class="muted small">Compare nel riepilogo che copi e mandi ai clienti.</span>
</div></div>
<h2>Prezzi</h2>
<div class="card"><div class="form">
<label class="f">Prezzo standard per i nuovi clienti (€)<input type="text" inputmode="decimal" id="set-price" data-set="price" step="0.5" min="0" value="${c.price}" style="max-width:140px"></label>
${view.confirmPrice?`<div class="banner" style="border-style:solid">Mettere ${eur(c.price)} a tutti i ${S.clients.length} clienti? I ripiegamenti già segnati non cambiano.
<span class="actions"><button class="btn primary" data-act="priceAllDo">Conferma</button><button class="btn ghost" data-act="priceAllNo">Annulla</button></span></div>`
:`<div class="actions"><button class="btn" data-act="priceAllAsk" ${S.clients.length?'':'disabled'}>Applica a tutti i clienti</button></div>`}
</div></div>
<h2>Metodi di pagamento</h2>
<div class="card">${methods}
<form class="row" id="methodForm"><input type="text" id="newMethod" placeholder="Nuovo metodo, es. Revolut" autocomplete="off"><button class="btn" type="submit">Aggiungi</button></form>
</div>
<h2>Aspetto</h2>
<div class="card"><div class="form">
<span class="f">Tema</span>${seg('setTheme',[['auto','Automatico'],['light','Chiaro'],['dark','Scuro']],S.theme||'auto')}
<span class="f">Colore principale</span><div class="swatches">${sw}</div>
</div></div>
<h2>Contatore</h2>
<div class="card">
${toggleRow('vibrate','Vibrazione al tocco','Una breve vibrazione a ogni +1.')}
${toggleRow('guard','Protezione doppio tocco','Ignora un secondo tocco sullo stesso cliente entro 1,5 secondi.')}
</div>
<h2>Promemoria backup</h2>
${seg('setBackupDays',[[0,'Mai'],[3,'3 giorni'],[7,'7 giorni'],[14,'14 giorni'],[30,'30 giorni']],c.backupDays)}
<h2>Copia di sicurezza</h2>
<p class="muted small" style="margin:0">I dati restano solo su questo telefono. Manda il backup a te stesso via email o salvalo su Drive: se cambi o perdi il telefono, lo ripristini da qui (il ripristino sostituisce i dati attuali).<br>${S.lastBackup?'Ultimo backup: '+fmtDay(dayKey(new Date(S.lastBackup))):'Nessun backup fatto finora.'}</p>
<div class="actions"><button class="btn primary" data-act="shareBackup">Invia backup</button><button class="btn" data-act="saveBackup">Salva file</button></div>
<div class="actions"><label class="btn" for="importFile" style="cursor:pointer">Ripristina da file</label><input type="file" id="importFile" accept=".json,application/json,text/plain" hidden>
<button class="btn ghost" data-act="exportData">Copia come testo</button><button class="btn ghost" data-act="importOpen">Incolla testo</button></div>
${view.importOpen?`<div class="card"><div class="form"><label class="f">Incolla qui il backup<textarea id="importBox"></textarea></label>
<div class="actions"><button class="btn primary" data-act="importDo">Ripristina</button><button class="btn ghost" data-act="importClose">Annulla</button></div>
<span class="muted small">Il ripristino sostituisce tutti i dati attuali.</span></div></div>`:''}
${view.pendingImport?(()=>{const n=view.pendingImport,dt=n.saved?fmtDay(dayKey(new Date(n.saved))):'';return `<div class="card" id="importConfirm" style="border-color:var(--warn)"><div class="form">
<b>Ripristinare questo backup?</b>
<span class="small">${dt?`Backup del ${dt} · `:''}${n.clients.length} clienti · ${n.entries.length} ripiegamenti · ${n.payments.length} pagamenti</span>
<span class="muted small">I dati attuali verranno sostituiti. Ne tengo una copia, così puoi annullare il ripristino.</span>
<div class="actions"><button class="btn primary" data-act="importYes">Ripristina</button><button class="btn ghost" data-act="importNo">Annulla</button></div></div></div>`})():''}
${(()=>{const p=prevCopy();return p&&p.data?`<div class="actions"><button class="btn ghost" data-act="undoImport">Annulla l'ultimo ripristino (dati del ${fmtDay(dayKey(new Date(p.at)))})</button></div>`:''})()}
<h2>Dati</h2>
${view.confirmWipe?`<div class="card"><div class="form"><b style="color:var(--danger)">Cancellare tutti i clienti, ripiegamenti, pagamenti e note?</b>
<span class="muted small">Non si può annullare. Le impostazioni restano. Fai prima un backup se vuoi conservarli.</span>
<div class="actions"><button class="btn danger" data-act="wipeDo">Cancella tutto</button><button class="btn ghost" data-act="wipeNo">Annulla</button></div></div></div>`
:`<div class="actions"><button class="btn danger" data-act="wipeAsk">Cancella tutti i dati</button></div>`}
<p class="muted small" style="margin:0;text-align:center">Packing · versione ${APP_VERSION}</p>`;
}
function renderCal(){
const [y,m]=view.calMonth.split('-').map(Number);
const first=new Date(y,m-1,1),days=new Date(y,m,0).getDate(),lead=(first.getDay()+6)%7;
const counts={};S.entries.forEach(e=>{if(e.day.startsWith(view.calMonth))counts[e.day]=(counts[e.day]||0)+1});
const t=today();
let cells=['Lun','Mar','Mer','Gio','Ven','Sab','Dom'].map(w=>`<div class="wd">${w}</div>`).join('');
for(let i=0;i<lead;i++)cells+=`<div class="d out"></div>`;
for(let d=1;d<=days;d++){const k=view.calMonth+'-'+pad(d);
cells+=`<button class="d ${k in S.planned?'plan':''} ${k===t?'today':''} ${k===view.calSel?'sel':''}" data-act="calPick" data-k="${k}" aria-label="${fmtDay(k)}">${d}${counts[k]?`<span class="n">${counts[k]}</span>`:''}</button>`}
const k=view.calSel,planned=k in S.planned,n=S.entries.filter(e=>e.day===k).length;
const upcoming=Object.keys(S.planned).filter(x=>x>=t).sort();
const monthName=first.toLocaleDateString(LOC(),{month:'long',year:'numeric'});
return `<div class="calhead"><button class="navbtn" data-act="calPrev" aria-label="Mese precedente">‹</button><div class="m">${monthName}</div><button class="navbtn" data-act="calNext" aria-label="Mese successivo">›</button></div>
<div class="cal">${cells}</div>
<div class="legend"><span><i class="sw"></i>Giorno di lavoro segnato</span></div>
<div class="muted small">${(()=>{const n=Object.values(counts).reduce((a,b)=>a+b,0);return n?`In ${monthName}: <b style="color:var(--fg)">${n} ripiegamenti</b> in ${Object.keys(counts).length} giorni`:`In ${monthName} non hai ancora segnato ripiegamenti.`})()}</div>
<div class="card">
<label class="switch"><span><b>${fmtDay(k)}</b><br><span class="muted small">${n?n+' ripiegamenti segnati':'Nessun ripiegamento segnato'}</span></span>
<input type="checkbox" id="planToggle" ${planned?'checked':''} aria-label="Giorno di lavoro"></label>
${planned?`<div class="form"><label class="f">Nota (posto, cliente, orario)<input type="text" id="planNote" value="${esc(S.planned[k])}" placeholder="Es. Aviosuperficie, dalle 9"></label>
<div class="actions"><a class="btn" href="${gcalLink(k)}" target="_blank" rel="noopener">Aggiungi a Google Calendar</a></div></div>`:''}
${k<=t?`<div class="form" style="padding-top:${planned?0:14}px"><div class="actions"><button class="btn primary" data-act="calGo">Apri il contatore di questo giorno</button></div></div>`:''}
</div>
<h2>Prossimi giorni di lavoro</h2>
${upcoming.length?`<div class="card">${upcoming.map(x=>`<div class="row"><span class="grow">${fmtDay(x)}${S.planned[x]?`<br><span class="muted small">${esc(S.planned[x])}</span>`:''}</span><a class="btn ghost small" href="${gcalLink(x)}" target="_blank" rel="noopener">+ Calendario</a></div>`).join('')}</div>
<div class="actions"><button class="btn" data-act="icsAll">Esporta tutti (.ics)</button></div>
<p class="muted small" style="margin:0">Il file .ics si apre con il calendario del telefono (Google, Samsung o iPhone) e aggiunge tutti i giorni in un colpo. Funziona nella versione installata sul telefono.</p>`
:`<div class="empty">Tocca un giorno sul calendario e attiva "Giorno di lavoro".</div>`}`;
}
function balance(cid){
const owed=S.entries.filter(e=>e.clientId===cid).reduce((s,e)=>s+e.price,0);
const paid=S.payments.filter(p=>p.clientId===cid).reduce((s,p)=>s+p.amount-(p.tip||0),0);
return {owed,paid,due:Math.round((owed-paid)*100)/100};
}
function tipsSum(pred){return S.payments.filter(p=>p.tip&&pred(p)).reduce((a,p)=>a+p.tip,0)}
function tipsSection(){
const t=today(),m=t.slice(0,7),y=t.slice(0,4);
const mm=tipsSum(p=>p.day.startsWith(m)),yy=tipsSum(p=>p.day.startsWith(y)),all=tipsSum(()=>true);
const by={};S.payments.forEach(p=>{if(p.tip&&clientById(p.clientId))by[p.clientId]=(by[p.clientId]||0)+p.tip});
const top=Object.entries(by).sort((a,b)=>b[1]-a[1]).slice(0,3);
const opts=[...S.clients].sort((a,b)=>a.name.localeCompare(b.name,isEN()?'en':'it')).map(c=>`<option value="${c.id}">${esc(c.name)}</option>`).join('');
const form=view.tipOpen?`<div class="form" style="border-top:1px solid var(--line)">
<div class="two"><label class="f">Cliente<select id="tipClient"><option value="">Scegli…</option>${opts}</select></label>
<label class="f">Mancia €<input type="text" inputmode="decimal" id="tipAmount" step="0.5" min="0"></label></div>
<div class="seg">${cfg().methods.map(x=>`<button data-act="payMethod" data-m="${esc(x)}" aria-pressed="${view.payMethod===x}">${esc(tm(x))}</button>`).join('')}</div>
<div class="actions"><button class="btn primary" data-act="tipDo">Registra mancia</button><button class="btn ghost" data-act="tipClose">Annulla</button></div></div>`:'';
return `<h2>Mance</h2>
<div class="card tips">
<div class="tipgrid"><div><span class="k">Questo mese</span><b>${eur(mm)}</b></div><div><span class="k">Quest'anno</span><b>${eur(yy)}</b></div><div><span class="k">In totale</span><b>${eur(all)}</b></div></div>
${top.length?`<div class="tiptop">${top.map(([id,v],i)=>`<span><i class="rk r${i+1}" style="width:20px;height:20px;font-size:11px;display:inline-grid">${i+1}</i>${esc(clientById(id).name)} <b>${eur(v)}</b></span>`).join('')}</div>`:''}
${view.tipOpen?form:`<div style="padding:0 14px 14px" class="actions"><button class="btn" data-act="tipOpen" ${S.clients.length?'':'disabled'}>+ Mancia senza pagamento</button></div>`}
</div>`;
}
function renderPag(){
const list=S.clients.map(c=>({c,...balance(c.id)})).filter(x=>x.due>0).sort((a,b)=>b.due-a.due);
const totDue=list.reduce((s,x)=>s+Math.max(0,x.due),0);
const debtors=list.filter(x=>x.due>0).length;
const rows=list.map(({c,owed,paid,due})=>{
const pill=due>0?`<span class="pill due">Deve ${eur(due)}</span>`:due<0?`<span class="pill cred">Anticipo ${eur(-due)}</span>`:`<span class="pill ok">In pari</span>`;
const open=view.payFor===c.id;
return `<div><div class="row"><span class="grow">${esc(c.name)}<br><span class="muted small">Lavoro ${eur(owed)} · Pagato ${eur(paid)}</span></span>${pill}</div>
${open?`<div class="paybox">
<div class="two"><label class="f">Importo ricevuto €<input type="text" inputmode="decimal" id="payAmount" data-due="${due}" step="0.5" min="0" value="${due>0?due:''}"></label>
<label class="f">di cui mancia €<input type="text" inputmode="decimal" id="payTip" step="0.5" min="0" value="0"></label></div>
<span class="muted small" id="payHint">Se ricevi più di ${eur(due)}, la differenza va nelle mance. Se era un anticipo, metti 0.</span>
<div class="seg">${cfg().methods.map(m=>`<button data-act="payMethod" data-m="${m}" aria-pressed="${view.payMethod===m}">${esc(tm(m))}</button>`).join('')}</div>
<div class="actions"><button class="btn primary" data-act="payDo" data-id="${c.id}">Registra pagamento</button><button class="btn ghost" data-act="payClose">Annulla</button></div>
</div>`:`<div style="padding:0 14px 12px" class="actions"><button class="btn" data-act="payOpen" data-id="${c.id}">+ Pagamento</button></div>`}</div>`}).join('<div style="border-top:1px solid var(--line)"></div>');
const hist=[...S.payments].sort((a,b)=>b.ts-a.ts).slice(0,30).map(p=>{const c=clientById(p.clientId);
return `<div class="row"><span class="grow">${esc(c?c.name:'Cliente eliminato')}<br><span class="muted small">${fmtDay(p.day)} · ${esc(tm(p.method))}${p.tip?` · <span class="tipc">${p.tip===p.amount?'mancia':'di cui mancia '+eur(p.tip)}</span>`:''}</span></span><span class="num">${eur(p.amount)}</span>
<button class="minus" style="position:static;flex:none" data-act="delPay" data-id="${p.id}" aria-label="Elimina pagamento">×</button></div>`}).join('');
return `<div class="totals"><div><div class="k">Da incassare</div><div class="v eur">${eur(totDue)}</div></div><div><div class="k">Clienti in debito</div><div class="v">${debtors}</div></div></div>
<h2>Lista nera</h2>
<p class="muted small" style="margin:0">Il saldo considera tutti i ripiegamenti segnati meno i pagamenti ricevuti.</p>
${list.length?`<div class="card">${rows}</div>`:`<div class="empty">Nessuno ti deve soldi. Tutti i clienti sono in pari.</div>`}
${tipsSection()}
${hist?(()=>{const n=Math.min(S.payments.length,30),open=!!view.payHistOpen,tot=S.payments.reduce((a,p)=>a+p.amount,0);
return `<button class="fold" data-act="togglePayHist" aria-expanded="${open}"><span class="grow"><span class="fold-t">Ultimi pagamenti</span><br><span class="muted small">${S.payments.length} ${S.payments.length===1?'pagamento':'pagamenti'} · ${eur(tot)} incassati in totale</span></span><svg class="chev" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg></button>
${open?`<div class="card">${hist}</div>${S.payments.length>n?`<p class="muted small" style="margin:0">Mostrati gli ultimi ${n}.</p>`:''}`:''}`})()
:`<h2>Ultimi pagamenti</h2><div class="empty">Nessun pagamento registrato.</div>`}`;
}
function render(){
const app=document.getElementById('app');
app.innerHTML=view.tab==='conta'?renderConta():view.tab==='riep'?renderRiep():view.tab==='cal'?renderCal():view.tab==='pag'?renderPag():view.tab==='set'?renderSet():renderCli();
document.querySelectorAll('.tabs button').forEach(b=>b.setAttribute('aria-selected',b.dataset.tab===view.tab));
const sb=document.getElementById('setBtn');if(sb)sb.classList.toggle('on',view.tab==='set');
if(view.adding){const i=document.getElementById('newName');i&&i.focus()}
fitNumbers();
}
function fitNumbers(){document.querySelectorAll('.totals .v,.tips .tipgrid b').forEach(el=>{el.style.fontSize='';let f=parseFloat(getComputedStyle(el).fontSize);
while(el.scrollWidth>el.clientWidth+1&&f>16){f-=2;el.style.fontSize=f+'px'}})}
window.addEventListener('resize',()=>fitNumbers());
function shiftDay(n){const d=parseDay(view.day);d.setDate(d.getDate()+n);const k=dayKey(d);if(k>today())return;view.day=k;render()}
function copy(text,msg){
const done=()=>toast(msg);
try{navigator.clipboard.writeText(text).then(done,()=>fallback())}catch(e){fallback()}
function fallback(){const t=document.createElement('textarea');t.value=text;document.body.appendChild(t);t.select();try{document.execCommand('copy');done()}catch(e){toast('Copia non riuscita')}t.remove()}
}
function summaryText(){
const list=S.entries.filter(inRange),s=summarize(list);
const E=isEN(),label={day:fmtDay(view.day),week:(E?'Week of ':'Settimana del ')+fmtDay(view.day),month:parseDay(view.day).toLocaleDateString(LOC(),{month:'long',year:'numeric'}),all:E?'All time':'Totale complessivo'}[view.range];
return `${E?'Packs':'Ripiegamenti'}${cfg().name?(E?' by ':' di ')+cfg().name:''} – ${label}\n`+s.rows.map(r=>`${r.name}: ${r.n} × = ${eur(r.tot)}`).join('\n')+`\n${E?'TOTAL':'TOTALE'}: ${s.n} ${E?'packs':'ripiegamenti'} = ${eur(s.tot)}`;
}
document.addEventListener('click',ev=>{
if(ev.target.closest('#themeBtn')){const order=['auto','light','dark'];S.theme=order[(order.indexOf(S.theme||'auto')+1)%3];save();applySkin();
toast({auto:'Aspetto automatico, come il telefono',light:'Aspetto chiaro, per il sole',dark:'Aspetto scuro'}[S.theme]);if(view.tab==='set')render();return}
if(ev.target.closest('#setBtn')){view.tab='set';view.adding=false;view.confirm=null;view.confirmWipe=false;view.confirmPrice=false;render();window.scrollTo(0,0);return}
const tab=ev.target.closest('.tabs button');if(tab){view.pickOpen=false;view.tab=tab.dataset.tab;view.adding=false;view.confirm=null;render();return}
const b=ev.target.closest('[data-act]');if(!b)return;
const a=b.dataset.act,id=b.dataset.id;
switch(a){
case 'prev':shiftDay(-1);break;
case 'next':shiftDay(1);break;
case 'today':view.day=today();render();break;
case 'plus':{const c=clientById(id);const now=new Date();
if(cfg().guard){const last=S.entries.filter(e=>e.clientId===id&&e.day===view.day).reduce((m,e)=>Math.max(m,e.created||0),0);if(Date.now()-last<1500){toast('Doppio tocco ignorato');break}}const ts=view.day===today()?now.getTime():parseDay(view.day).setHours(12,0,0,0);
S.entries.push({id:uid(),clientId:id,day:view.day,ts,price:c.price,created:Date.now()});save();render();
const t=document.getElementById('tile-'+id);if(t){t.classList.add('bump');setTimeout(()=>t.classList.remove('bump'),260);
const cn=t.querySelector('.count');cn&&cn.classList.add('pop');
const f=document.createElement('span');f.className='fly';f.textContent='+1';t.appendChild(f);setTimeout(()=>f.remove(),750)}
if(cfg().vibrate){try{navigator.vibrate&&navigator.vibrate(15)}catch(e){}}break}
case 'minus':{const l=S.entries.filter(e=>e.clientId===id&&e.day===view.day).sort((a,b)=>b.ts-a.ts)[0];
if(l){S.entries=S.entries.filter(e=>e!==l);save();render();toast('Tolto 1 ripiegamento')}break}
case 'clearSample':S={clients:[],entries:[],planned:{},payments:[],dayClients:{},dayNotes:{},dayDone:{},set:S.set,theme:S.theme};save();view.adding=true;render();break;
case 'addOpen':view.adding=true;render();break;
case 'addClose':view.adding=false;render();break;
case 'range':view.range=b.dataset.r;render();break;
case 'copySum':copy(summaryText(),'Riepilogo copiato');break;
case 'delEntry':S.entries=S.entries.filter(e=>e.id!==id);save();render();break;
case 'askDel':view.confirm=id;render();break;
case 'cancelDel':view.confirm=null;render();break;
case 'delCli':S.clients=S.clients.filter(c=>c.id!==id);S.entries=S.entries.filter(e=>e.clientId!==id);S.payments=S.payments.filter(p=>p.clientId!==id);
for(const m of [S.dayClients,S.dayDone])for(const d in m){m[d]=m[d].filter(x=>x!==id);if(!m[d].length)delete m[d]}
for(const d in S.dayNotes){delete S.dayNotes[d][id];if(!Object.keys(S.dayNotes[d]).length)delete S.dayNotes[d]}
view.confirm=null;save();render();break;
case 'calPrev':case 'calNext':{const[y,m]=view.calMonth.split('-').map(Number);const d=new Date(y,m-1+(a==='calNext'?1:-1),1);view.calMonth=d.getFullYear()+'-'+pad(d.getMonth()+1);render();break}
case 'monthPick':{const[y,m]=b.dataset.k.split('-').map(Number);const last=dayKey(new Date(y,m,0));view.day=last>today()?today():last;view.range='month';render();window.scrollTo({top:0,behavior:'smooth'});break}
case 'setLang':S.set.lang=b.dataset.v;document.documentElement.lang=S.set.lang;save();render();toast(S.set.lang==='en'?'Language: English':'Lingua: italiano');break;
case 'setTheme':S.theme=b.dataset.v;save();applySkin();render();break;
case 'setAccent':S.set.accent=b.dataset.v;save();applySkin();render();break;
case 'setBackupDays':S.set.backupDays=Number(b.dataset.v);save();render();break;
case 'defMethod':S.set.defMethod=S.set.methods[Number(b.dataset.i)];save();render();break;
case 'delMethod':{if(S.set.methods.length<2)break;const m=S.set.methods[Number(b.dataset.i)];S.set.methods=S.set.methods.filter((x,i)=>i!==Number(b.dataset.i));
if(S.set.defMethod===m)S.set.defMethod=S.set.methods[0];save();render();toast(m+' tolto');break}
case 'priceAllAsk':view.confirmPrice=true;render();break;
case 'priceAllNo':view.confirmPrice=false;render();break;
case 'priceAllDo':S.clients.forEach(c=>c.price=S.set.price);view.confirmPrice=false;save();render();toast('Prezzo aggiornato per tutti');break;
case 'wipeAsk':view.confirmWipe=true;render();break;
case 'wipeNo':view.confirmWipe=false;render();break;
case 'wipeDo':S={clients:[],entries:[],planned:{},payments:[],dayClients:{},dayNotes:{},dayDone:{},set:S.set,theme:S.theme};view.confirmWipe=false;save();render();toast('Dati cancellati');break;
case 'togglePayHist':view.payHistOpen=!view.payHistOpen;render();break;
case 'toggleDetail':view.detailOpen=!view.detailOpen;render();break;
case 'rankKind':view.rankKind=b.dataset.v;view.rankSel=null;render();break;
case 'rankSel':{view.rankSel=view.rankSel===id?null:id;render();if(view.rankSel){const el=document.getElementById('rdet-'+id);el&&el.scrollIntoView({behavior:'smooth',block:'nearest'})}break}
case 'shareBackup':shareBackup();break;
case 'saveBackup':downloadBackup();break;
case 'doneOn':{const l=S.dayDone[view.day]||(S.dayDone[view.day]=[]);if(!l.includes(id))l.push(id);save();const c=clientById(id);render();toast((c?c.name:'')+' spostato tra i finiti');break}
case 'doneOff':{S.dayDone[view.day]=(S.dayDone[view.day]||[]).filter(x=>x!==id);save();const c=clientById(id);render();toast((c?c.name:'')+' riaperto');break}
case 'pickOpen':view.pickOpen=true;view.pickQ='';render();break;
case 'pickClose':view.pickOpen=false;view.pickQ='';render();break;
case 'dayAdd':{const l=S.dayClients[view.day]||(S.dayClients[view.day]=[]);if(!l.includes(id))l.push(id);save();const c=clientById(id);render();toast((c?c.name:'Cliente')+' aggiunto');break}
case 'dayRemove':{S.dayClients[view.day]=(S.dayClients[view.day]||[]).filter(x=>x!==id);S.dayDone[view.day]=(S.dayDone[view.day]||[]).filter(x=>x!==id);save();render();break}
case 'createFromSearch':{const name=(document.getElementById('search').value||'').trim();if(!name)break;view.pickQ='';
if(S.sample)S={clients:[],entries:[],planned:S.planned,payments:[],dayClients:{},dayNotes:{},dayDone:{},set:S.set,theme:S.theme};
const c={id:uid(),name,price:cfg().price};S.clients.push(c);(S.dayClients[view.day]||(S.dayClients[view.day]=[])).push(c.id);save();render();toast(name+' aggiunto');break}
case 'payOpen':view.payFor=id;view.payMethod=cfg().methods.includes(view.payMethod)?view.payMethod:(cfg().methods.includes(cfg().defMethod)?cfg().defMethod:cfg().methods[0]);render();const pa=document.getElementById('payAmount');pa&&pa.focus();break;
case 'payClose':view.payFor=null;render();break;
case 'payMethod':view.payMethod=b.dataset.m;document.querySelectorAll('[data-act="payMethod"]').forEach(x=>x.setAttribute('aria-pressed',x===b));break;
case 'payDo':{const v=num(document.getElementById('payAmount').value);if(isNaN(v)||v<=0){toast('Inserisci un importo');break}
let tip=num(document.getElementById('payTip').value);tip=isNaN(tip)||tip<0?0:Math.min(tip,v);
S.payments.push({id:uid(),clientId:id,day:today(),ts:Date.now(),amount:v,tip,method:view.payMethod||cfg().defMethod});
let moved=false;if(balance(id).due<=0&&dayIds(today()).includes(id)){const l=S.dayDone[today()]||(S.dayDone[today()]=[]);if(!l.includes(id)){l.push(id);moved=true}}
save();view.payFor=null;render();
toast(moved?'Saldato: spostato tra i finiti di oggi':(tip?`Pagamento registrato, mancia ${eur(tip)}`:'Pagamento registrato'));break}
case 'tipOpen':view.tipOpen=true;view.payMethod=cfg().methods.includes(cfg().defMethod)?cfg().defMethod:cfg().methods[0];render();break;
case 'tipClose':view.tipOpen=false;render();break;
case 'tipDo':{const cid=document.getElementById('tipClient').value,v=num(document.getElementById('tipAmount').value);
if(!cid){toast('Scegli il cliente');break}if(isNaN(v)||v<=0){toast('Inserisci un importo');break}
S.payments.push({id:uid(),clientId:cid,day:today(),ts:Date.now(),amount:v,tip:v,method:view.payMethod||cfg().defMethod});view.tipOpen=false;save();render();toast('Mancia registrata: '+eur(v));break}
case 'delPay':S.payments=S.payments.filter(p=>p.id!==id);save();render();break;
case 'calPick':view.calSel=b.dataset.k;render();break;
case 'calGo':view.day=view.calSel;view.tab='conta';render();break;
case 'icsAll':{const keys=Object.keys(S.planned).filter(x=>x>=today()).sort();
try{const url=URL.createObjectURL(new Blob([icsText(keys)],{type:'text/calendar'}));const l=document.createElement('a');l.href=url;l.download='packing-giorni.ics';document.body.appendChild(l);l.click();l.remove();setTimeout(()=>URL.revokeObjectURL(url),4000);toast('File calendario creato')}catch(e){toast('Esportazione non disponibile qui')}break}
case 'exportData':copy(JSON.stringify({clients:S.clients,entries:S.entries,planned:S.planned,payments:S.payments,dayClients:S.dayClients,dayNotes:S.dayNotes,dayDone:S.dayDone,set:S.set,theme:S.theme}),'Backup copiato');break;
case 'importOpen':view.importOpen=true;render();break;
case 'importClose':view.importOpen=false;render();break;
case 'importDo':{try{askImport(JSON.parse(document.getElementById('importBox').value))}catch(e){toast('Testo non valido: incolla il backup completo')}break}
case 'importYes':applyImport();break;
case 'importNo':view.pendingImport=null;render();break;
case 'undoImport':undoImport();break;
}
});
document.addEventListener('input',ev=>{if(ev.target.id==='search')openResults();
if(ev.target.id==='payAmount'){const v=num(ev.target.value),due=num(ev.target.dataset.due)||0,t=document.getElementById('payTip');
if(t&&!isNaN(v)){const x=Math.max(0,Math.round((v-Math.max(due,0))*100)/100);t.value=isEN()?String(x):String(x).replace('.',',')}}});
document.addEventListener('keydown',ev=>{if(ev.target.id==='search'&&ev.key==='Enter'){ev.target.blur()}});
document.addEventListener('submit',ev=>{
if(ev.target.id==='methodForm'){ev.preventDefault();const v=document.getElementById('newMethod').value.trim();
if(!v)return;if(S.set.methods.some(m=>m.toLowerCase()===v.toLowerCase())){toast(v+' c\'è già');return}
S.set.methods=[...S.set.methods,v];save();render();toast(v+' aggiunto');return}
if(ev.target.id!=='addForm')return;ev.preventDefault();
const name=document.getElementById('newName').value.trim();if(!name)return;
const p=num(document.getElementById('newPrice').value);
if(S.sample){S={clients:[],entries:[],planned:{},payments:[],dayClients:{},dayNotes:{},dayDone:{},set:S.set,theme:S.theme}}
S.clients.push({id:uid(),name,price:isNaN(p)?cfg().price:p});save();view.adding=false;render();toast(name+' aggiunto');
});
document.addEventListener('change',ev=>{
const t=ev.target,v=num(t.value);
if(t.dataset.price){const e=S.entries.find(x=>x.id===t.dataset.price);if(e&&!isNaN(v)&&v>=0){e.price=v;save();render()}}
if(t.dataset.cprice){const c=clientById(t.dataset.cprice);if(c&&!isNaN(v)&&v>=0){c.price=v;save();toast('Prezzo aggiornato')}}
if(t.id==='importFile'&&t.files&&t.files[0]){const r=new FileReader();r.onload=()=>{try{askImport(JSON.parse(r.result))}catch(e){toast('File non valido: scegli un backup di Packing')}t.value=''};r.readAsText(t.files[0]);return}
if(t.id==='planToggle'){if(t.checked)S.planned[view.calSel]=S.planned[view.calSel]||'';else delete S.planned[view.calSel];save();render();return}
if(t.id==='planNote'){S.planned[view.calSel]=t.value.trim();save();render();return}
if(t.dataset.set==='name'){S.set.name=t.value.trim();save();toast('Nome salvato');return}
if(t.dataset.set==='price'){const v=num(t.value);if(!isNaN(v)&&v>=0){S.set.price=v;save();render();toast('Prezzo standard: '+eur(v))}return}
if(t.dataset.toggle){S.set[t.dataset.toggle]=t.checked;save();return}
if(t.dataset.cnote){const c=clientById(t.dataset.cnote);if(c){c.note=t.value.trim();save();toast('Nota salvata')}return}
if(t.dataset.name){const c=clientById(t.dataset.name);const n=t.value.trim();if(c&&n){c.name=n;save()}}
});
let lastToday=today();
function checkDay(){const t=today();if(t===lastToday)return;
if(view.day===lastToday)view.day=t;if(view.calSel===lastToday)view.calSel=t;if(view.calMonth===lastToday.slice(0,7))view.calMonth=t.slice(0,7);
lastToday=t;closePast();render()}
document.addEventListener('visibilitychange',()=>{if(!document.hidden)checkDay()});
window.addEventListener('focus',checkDay);setInterval(checkDay,60000);
// new version ready (service worker found an update): offer a reload
try{if(navigator.serviceWorker){
navigator.serviceWorker.addEventListener('message',e=>{const d=e.data||{};if(d.t==='packing-updated'&&d.v&&d.v!==APP_VERSION){const b=document.getElementById('updBar');if(b)b.hidden=false}});
navigator.serviceWorker.ready.then(r=>{const w=navigator.serviceWorker.controller||r.active;w&&w.postMessage('packing-check')}).catch(()=>{});
}}catch(e){}
document.addEventListener('click',e=>{if(e.target.closest('#updBtn'))location.reload()});
document.documentElement.lang=cfg().lang||'it';
closePast();
applySkin();
render();
})();
</script>
<script>if('serviceWorker' in navigator){addEventListener('load',()=>navigator.serviceWorker.register('sw.js').catch(()=>{}))}</script>
</body>
</html>