-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
2720 lines (2476 loc) · 117 KB
/
Copy pathapp.js
File metadata and controls
2720 lines (2476 loc) · 117 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* =========================================================================
ONTRACK — app.js
A personal system: understand what matters, see what's getting in the
way, build a system, execute today, learn from friction, review weekly.
Vanilla JS, no build step. Local-first; Supabase becomes the source of
truth for an account once configured (see supabase-config.js).
========================================================================= */
const STORAGE_KEY = 'ontrack_data';
const SESSION_KEY = 'ontrack_session';
const LEGACY_DATA_KEY = 'four_keys_data';
const LEGACY_SESSION_KEY = 'four_keys_session';
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const STATUS = { DONE: 'done', ADJUSTED: 'adjusted', MISSED: 'missed' };
const STATUS_HELP = {
done: 'Completed as planned.',
adjusted: 'Completed a smaller or modified version of the planned habit \u2014 for example a 10-minute workout instead of the planned 30 minutes. It still counts toward your consistency.',
missed: "Didn't happen. Worth a quick note on why, so patterns can show up in Friction."
};
/* -------------------------------------------------------------------------
ONBOARDING CATALOG — categories stay broad on purpose; the flow around
them is what changed (see the wizard further down).
------------------------------------------------------------------------- */
const ONBOARDING_CATEGORIES = [
{
id: 'mind_focus', title: 'Mind & Focus',
items: ["Procrastination", "Brain fog", "Doomscrolling", "Overthinking", "Short attention span",
"Feeling mentally scattered", "Lack of discipline", "Inconsistent routines", "Burnout", "Feeling stuck",
"Decision paralysis", "Perfectionism", "Fear of failure", "Fear of wasting time",
"Difficulty starting things", "Difficulty finishing things", "Constantly seeking stimulation",
"Feeling behind in life"]
},
{
id: 'identity_self', title: 'Identity & Self',
items: ["Low self-confidence", "Comparing myself to everyone", "Not knowing who I am", "People-pleasing",
"Caring too much about what people think", "Struggling with self-expression",
"Feeling like I'm living for other people", "Difficulty setting boundaries", "Becoming whoever I'm around",
"Feeling disconnected from myself", "Wanting to reinvent myself", "Finding my personal style",
"Building my identity", "Becoming more independent"]
},
{
id: 'dating_relationships', title: 'Dating & Relationships',
items: ["Getting over an ex", "Missing someone I shouldn't", "Dating anxiety", "Fear of rejection",
"Fear of commitment", "Fear of intimacy", "Getting attached too quickly", "Avoiding emotional intimacy",
"Situationships", "Toxic relationship patterns", "Setting relationship boundaries",
"Communication problems", "Jealousy", "Trust issues", "Feeling lonely while dating",
"Not knowing what I want from relationships", "Choosing unavailable people",
"People-pleasing in relationships", "Struggling to let go", "Learning to be alone"]
},
{
id: 'sexuality_identity', title: 'Sexuality & Identity', private: true,
note: "You don't need to choose a label. You can change or remove this later.",
items: ["Exploring my sexuality", "Questioning my sexuality", "Understanding my attraction",
"Understanding romantic vs sexual attraction", "Figuring out what I'm comfortable with",
"Setting sexual boundaries", "Communicating boundaries", "Navigating dating while figuring myself out",
"Feeling confused about labels", "Feeling pressure to choose a label",
"Feeling different from people around me", "Dealing with shame or stigma", "Understanding consent",
"Learning about healthy sexual relationships", "Balancing sexuality with my personal values",
"Feeling confident expressing my boundaries"]
},
{
id: 'social_life', title: 'Social Life',
items: ["Social anxiety", "Making new friends", "Maintaining friendships", "Feeling like the third wheel",
"Feeling left out", "Fear of being judged", "Fear of embarrassment", "Struggling to start conversations",
"Struggling to keep conversations going", "Finding my people", "Feeling lonely in a crowd",
"Feeling like nobody really knows me", "People-pleasing", "Setting boundaries with friends",
"Losing friends", "Outgrowing friendships", "Becoming more socially confident"]
},
{
id: 'digital_life', title: 'Digital Life',
items: ["Doomscrolling", "Phone addiction", "Social media comparison", "Constantly checking notifications",
"FOMO", "Internet rabbit holes", "Porn consumption", "Excessive gaming", "Staying up scrolling",
"Seeking validation online", "Obsessing over likes/views", "Comparing my life to people online",
"Difficulty being offline", "Digital distractions while studying/working"]
},
{
id: 'money_independence', title: 'Money & Independence',
items: ["Bad spending habits", "Impulse buying", "Saving money", "Budgeting", "Financial anxiety",
"Not knowing where my money goes", "Depending too much on others", "Wanting financial independence",
"Finding ways to earn", "Fear of financial instability", "Balancing enjoyment and saving"]
},
{
id: 'school_career', title: 'School & Career',
items: ["Academic procrastination", "Exam anxiety", "Falling behind", "Poor time management",
"Can't focus while studying", "Study consistency", "Feeling academically average",
"Pressure from family", "Choosing a career", "Feeling lost about my future",
"Fear of graduating without a plan", "Building useful skills", "Finding internships",
"Building a portfolio", "Feeling behind my peers", "Not knowing what I'm good at"]
},
{
id: 'lifestyle', title: 'Lifestyle',
items: ["Poor sleep", "Irregular sleep schedule", "Lack of exercise", "Inconsistent eating", "Low energy",
"Spending too much time indoors", "Poor daily routine", "Hygiene consistency",
"Taking better care of myself", "Building healthier routines", "Wanting more energy",
"Difficulty maintaining routines"]
},
{
id: 'emotional_regulation', title: 'Emotional Regulation',
items: ["Anger", "Irritability", "Emotional outbursts", "Holding things in", "Getting triggered easily",
"Difficulty communicating when upset", "Taking things personally", "Emotional impulsivity",
"Difficulty calming down", "Avoiding difficult conversations"]
},
{
id: 'purpose_life', title: 'Purpose & Life',
items: ["Feeling directionless", "Feeling behind", "Not knowing what I want", "Lack of motivation",
"Feeling like I'm wasting my potential", "Fear of wasting my 20s", "Wanting to become more disciplined",
"Building a meaningful life", "Finding things I actually care about", "Becoming independent",
"Figuring out what success means to me", "Creating a vision for my life"]
}
];
function findCategory(catId) { return ONBOARDING_CATEGORIES.find(c => c.id === catId); }
/* Contextual "what's getting in your way" options, tailored per category so
the question feels like it understands the person rather than collecting
a generic label. */
const CATEGORY_BLOCKERS = {
mind_focus: ["Procrastination", "Difficulty concentrating", "Overthinking", "Lack of discipline",
"Don't know where to start", "Getting distracted easily"],
identity_self: ["Overthinking", "Caring too much what others think", "People-pleasing",
"Don't know where to start", "Inconsistent routines"],
dating_relationships: ["Overthinking", "Fear of rejection", "Fear of commitment",
"Don't know where to start", "Choosing unavailable people"],
sexuality_identity: ["Overthinking", "Pressure to have an answer right away", "Fear of judgment",
"Don't know where to start"],
social_life: ["Overthinking", "Fear of rejection", "Don't know where to start",
"Don't meet enough people", "Prefer staying alone", "Difficulty maintaining friendships"],
digital_life: ["Too much scrolling", "Constant notifications", "Can't stay focused",
"Gaming", "Social media", "Phone dependence"],
money_independence: ["Inconsistent income", "Overspending", "Difficulty saving",
"Don't know where to start", "Lack of opportunities"],
school_career: ["Procrastination", "Difficulty concentrating", "Too much workload",
"Poor time management", "Lack of motivation", "Don't know where to start"],
lifestyle: ["Inconsistent routines", "Low energy", "Poor time management",
"Don't know where to start", "Difficulty maintaining routines"],
emotional_regulation: ["Overthinking", "Difficulty expressing emotions", "Avoiding difficult situations",
"Inconsistent routines", "Difficulty calming down"],
purpose_life: ["Lack of motivation", "Don't know where to start", "Overthinking",
"Fear of failure", "Difficulty maintaining routines"]
};
/* -------------------------------------------------------------------------
CATEGORY PRESET LIBRARY
Each category can seed: habits, skills, watchFor ("things to watch for" —
NOT real friction history), and frictionPatterns (used only to inform
intervention copy, never written as real friction records).
------------------------------------------------------------------------- */
const CATEGORY_PRESETS = {
mind_focus: {
habits: ["Start the most avoided task for 10 minutes", "Work in one 25-minute focus sprint",
"Nightly plan for tomorrow's top 3 tasks", "Zero phone for the first 20 minutes awake"],
skills: ["Task initiation", "Sustained focus"],
watchFor: ["Waiting until you \"feel ready\" to start", "Reorganizing/planning instead of starting",
"Reaching for your phone the moment a task gets hard"],
frictionPatterns: ["Poor planning", "Distraction", "Avoidance"]
},
identity_self: {
habits: ["Write one honest journal entry about what you want", "Say no to one thing that isn't you",
"Spend 15 minutes on something purely for yourself"],
skills: ["Self-expression", "Boundary setting"],
watchFor: ["Agreeing with the last person who spoke", "Deciding based on what others will think"],
frictionPatterns: ["Overthinking", "Avoidance"]
},
dating_relationships: {
habits: ["Journal your feelings after any date or interaction", "Practice stating one boundary clearly",
"Limit dating-app usage to a set window", "Do one thing today solely for your own fulfillment"],
skills: ["Boundary setting", "Discerning intentions"],
watchFor: ["Ignoring a clear red flag for quick validation", "Checking an ex's activity or old messages",
"Fixating on response times"],
frictionPatterns: ["Overthinking", "No motivation"]
},
sexuality_identity: {
habits: ["Write privately about what felt true today", "Name one boundary you want to hold this week"],
skills: ["Self-understanding", "Communicating boundaries"],
watchFor: ["Pressuring yourself to have an answer right away", "Comparing your timeline to someone else's"],
frictionPatterns: ["Overthinking", "Avoidance"]
},
social_life: {
habits: ["Initiate one short conversation today", "Hold eye contact for a few seconds in one interaction",
"Message one friend you haven't spoken to in a while"],
skills: ["Conversation initiation", "Active listening"],
watchFor: ["Using your phone as a social shield", "Replaying an interaction on a loop afterward"],
frictionPatterns: ["Distraction", "Don't know where to start"]
},
digital_life: {
habits: ["No phone in the first 20 minutes of the day", "Use grayscale mode during work/study hours",
"One screen-free hour before bed"],
skills: ["Attention control", "Digital boundaries"],
watchFor: ["Checking notifications the instant you wake up", "Opening an app out of boredom, not intent"],
frictionPatterns: ["Distraction", "Phone dependence"]
},
money_independence: {
habits: ["Log every purchase for the day", "Wait 24 hours before a non-essential purchase",
"Review spending once a week"],
skills: ["Budgeting", "Delayed gratification"],
watchFor: ["Buying something to match what others have", "Avoiding looking at your balance"],
frictionPatterns: ["Avoidance", "Overspending"]
},
school_career: {
habits: ["Study in one uninterrupted 25-minute block", "Spend 15 minutes on your portfolio or resume",
"Write tomorrow's top study priority tonight"],
skills: ["Time management", "Deep work"],
watchFor: ["Opening social apps mid-study session", "Waiting for motivation instead of starting"],
frictionPatterns: ["Distraction", "Poor time management"]
},
lifestyle: {
habits: ["Lights out by a consistent time", "20-minute walk outside", "Drink water before your first coffee"],
skills: ["Routine building", "Energy management"],
watchFor: ["Staying up scrolling past your bedtime target", "Skipping meals when busy"],
frictionPatterns: ["Fatigue", "Inconsistent routines"]
},
emotional_regulation: {
habits: ["10-minute decompression after a stressful event", "Note today's emotional trigger in one line",
"Pause 10 seconds before responding when upset"],
skills: ["Emotional regulation", "De-escalation"],
watchFor: ["Responding immediately while still activated", "Holding a grudge instead of naming it"],
frictionPatterns: ["Overthinking", "Difficulty calming down"]
},
purpose_life: {
habits: ["Spend 20 minutes on a long-term goal", "Weekly review of what mattered this week",
"Read 10 pages toward something you care about"],
skills: ["Self-reflection", "Long-term thinking"],
watchFor: ["Chasing short-term distraction over long-term goals", "Letting fear of failure stall a start"],
frictionPatterns: ["Lack of motivation", "Don't know where to start"]
}
};
const FRICTION_REASONS = ["Fatigue", "Forgot", "Busy", "No motivation", "Distraction", "Too difficult",
"Poor planning", "Unexpected event", "Environment", "Overthinking", "Avoidance", "Other"];
const INTERVENTIONS = {
"Fatigue": "Consider moving this habit to a higher-energy part of your day.",
"Forgot": "Try attaching it to an existing routine, or setting a reminder.",
"Busy": "Create a smaller version that fits into a 10\u201315 minute window.",
"No motivation": "Lower the bar for today \u2014 a token version still counts as adjusted.",
"Distraction": "Remove the most immediate source of distraction before starting.",
"Too difficult": "Break it into a smaller first step you can't say no to.",
"Poor planning": "Decide the exact time and place the night before.",
"Unexpected event": "Build a little slack into your week for the unplannable.",
"Environment": "Change your surroundings so the habit is the easy option.",
"Overthinking": "Set a 2-minute timer and start before you're ready.",
"Avoidance": "Name what you're avoiding, then do the smallest piece of it.",
"Other": "Log what happened \u2014 patterns often show up after a few entries."
};
/* -------------------------------------------------------------------------
STATE SHAPE
users[userId] = {
id, name, avatar, accessCode, createdAt, lastSeen, onboarded,
habits: [{id,name,description,category,frequency:{type,days},createdAt,active,reminder}],
completions: { "habitId__YYYY-MM-DD": "done"|"adjusted"|"missed" },
skills: [{id,name,description,progress,hours,relatedProblem,evidence:[{id,date,text}]}],
friction: [{id,habitId,habitNameSnapshot,date,reason,note}],
watchFor: [string],
priorities: [{key,catId,label,isTop}], -- feeds Today's Challenge
onboardingSelections: { problems, blockers, completedAt },
weeklyReviews: [{weekKey,reflection,answers,createdAt,snapshot}],
challenges: { "YYYY-MM-DD": {habitId, text, completed} },
settings: { appearance, notifications:{...}, privacy:{...} }
}
No `groups` — OnTrack is a personal product, not a social one.
------------------------------------------------------------------------- */
function defaultState() {
return {
version: 3,
session: { activeUserId: null },
users: {}
};
}
let state = defaultState();
let pendingMissCell = null;
let onboard = { step: 1, problems: [], priorities: [], blockers: [] };
let currentTab = 'today';
let habitsSubTab = 'grid'; // 'grid' | 'skills'
const today = new Date();
let viewYear = today.getFullYear();
let viewMonth = today.getMonth() + 1;
/* ---------- ID generation ---------- */
function genId(prefix) {
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
function genUuid() {
if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/* ---------- Date helpers ---------- */
function pad2(n) { return String(n).padStart(2, '0'); }
function dateKey(year, month, day) { return `${year}-${pad2(month)}-${pad2(day)}`; }
function daysInMonth(year, month) { return new Date(year, month, 0).getDate(); }
function todayKey() { const d = new Date(); return dateKey(d.getFullYear(), d.getMonth() + 1, d.getDate()); }
function parseDateKey(s) { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); }
function addDays(d, n) { const nd = new Date(d); nd.setDate(nd.getDate() + n); return nd; }
function isoWeekKey(d) {
const dt = new Date(d);
dt.setHours(0, 0, 0, 0);
dt.setDate(dt.getDate() + 3 - ((dt.getDay() + 6) % 7));
const week1 = new Date(dt.getFullYear(), 0, 4);
const weekNo = 1 + Math.round(((dt - week1) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
return `${dt.getFullYear()}-W${pad2(weekNo)}`;
}
function escapeHtml(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/* =========================================================================
DATA LAYER — StorageAdapter + SupabaseAdapter + DataStore
UI → OnTrack logic → DataStore → Adapter → localStorage / Supabase
DataStore picks StorageAdapter (localStorage) by default. If
supabase-config.js provides a URL + anon key, DataStore instead uses
SupabaseAdapter, and localStorage becomes a local cache the app reads
instantly on load while the network round-trip completes. This file
never hardcodes credentials — see supabase-config.js and SETUP.md.
========================================================================= */
const StorageAdapter = {
read(key) {
try {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch (err) {
console.error(`Could not read "${key}" from local storage.`, err);
return null;
}
},
write(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (err) {
console.error(`Could not write "${key}" to local storage.`, err);
return false;
}
},
readRaw(key) {
try { return localStorage.getItem(key); } catch (err) { return null; }
},
writeRaw(key, value) {
try { localStorage.setItem(key, value); return true; } catch (err) { return false; }
},
remove(key) {
try { localStorage.removeItem(key); return true; } catch (err) { return false; }
}
};
/* ---------- Supabase adapter (only active once configured) ----------
supabase-config.js defines window.ONTRACK_SUPABASE_CONFIG = { url, anonKey, edgeFunctionUrl }.
Until real values are filled in there, isSupabaseConfigured() is false
and the app runs entirely on StorageAdapter (localStorage), exactly as
before. Nothing here fakes a network connection or invents data. */
function isSupabaseConfigured() {
const cfg = window.ONTRACK_SUPABASE_CONFIG;
return !!(cfg && cfg.url && cfg.anonKey && !cfg.url.includes('YOUR_') && !cfg.anonKey.includes('YOUR_'));
}
let supabaseClient = null;
function getSupabaseClient() {
if (!isSupabaseConfigured()) return null;
if (supabaseClient) return supabaseClient;
if (typeof window.supabase === 'undefined' || !window.supabase.createClient) {
console.error('Supabase config is set but the supabase-js library did not load.');
return null;
}
const cfg = window.ONTRACK_SUPABASE_CONFIG;
supabaseClient = window.supabase.createClient(cfg.url, cfg.anonKey);
return supabaseClient;
}
/* Maps between this app's in-memory user shape (unchanged from the
localStorage era, so every render/calc function keeps working as-is)
and the normalized Supabase tables described in supabase-schema.sql. */
const SupabaseAdapter = {
// ---- Cloud account lifecycle ----
// Creates a brand-new cloud account (anonymous Supabase Auth user, so
// there's no email/password — the Access Code is the recovery
// mechanism) and an initial profiles row.
async createCloudProfile(name) {
const sb = getSupabaseClient();
if (!sb) return null;
const { data: authData, error: authErr } = await sb.auth.signInAnonymously();
if (authErr || !authData.user) { console.error('Supabase sign-up failed', authErr); return null; }
const userId = authData.user.id;
const accessCode = genAccessCode();
const { error: insertErr } = await sb.from('profiles').insert({
id: userId, name, access_code: accessCode, onboarded: false,
settings: newUserShell(name).settings
});
if (insertErr) { console.error('Could not create cloud profile', insertErr); return null; }
const shell = newUserShell(name);
shell.id = userId;
shell.accessCode = accessCode;
return shell;
},
// Redeems a short access code for a session on THIS device, via a
// server-side Edge Function that holds the service-role key (never
// shipped to the client). The function validates the code and mints a
// real Supabase session, which is the secure way to let a code (rather
// than a password) recover an account across devices.
async redeemAccessCode(code) {
const cfg = window.ONTRACK_SUPABASE_CONFIG;
if (!cfg || !cfg.edgeFunctionUrl) {
console.error('No edgeFunctionUrl configured for access-code redemption.');
return { ok: false, reason: 'not_configured' };
}
try {
const res = await fetch(cfg.edgeFunctionUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'redeem',
code: String(code || '').trim().toUpperCase()
})
});
if (!res.ok) return { ok: false, reason: res.status === 429 ? 'rate_limited' : 'invalid_code' };
const payload = await res.json();
const sb = getSupabaseClient();
if (!sb || !payload.access_token || !payload.refresh_token) return { ok: false, reason: 'invalid_response' };
const { error } = await sb.auth.setSession({
access_token: payload.access_token,
refresh_token: payload.refresh_token
});
if (error) return { ok: false, reason: 'session_failed' };
return { ok: true, userId: payload.user_id };
} catch (err) {
console.error('Access code redemption failed', err);
return { ok: false, reason: 'network_error' };
}
},
async getSessionUserId() {
const sb = getSupabaseClient();
if (!sb) return null;
const { data } = await sb.auth.getSession();
return data && data.session ? data.session.user.id : null;
},
async signOut() {
const sb = getSupabaseClient();
if (sb) await sb.auth.signOut();
},
// ---- Full profile fetch: reassembles the normalized tables back into
// this app's existing nested user object shape. ----
async fetchProfileState(userId) {
const sb = getSupabaseClient();
if (!sb) return null;
const [profileRes, habitsRes, complRes, frictionRes, prioritiesRes, skillsRes, reviewsRes, challengesRes] = await Promise.all([
sb.from('profiles').select('*').eq('id', userId).single(),
sb.from('habits').select('*').eq('user_id', userId),
sb.from('habit_completions').select('*').eq('user_id', userId),
sb.from('friction_items').select('*').eq('user_id', userId),
sb.from('priorities').select('*').eq('user_id', userId),
sb.from('skills').select('*').eq('user_id', userId),
sb.from('weekly_reviews').select('*').eq('user_id', userId),
sb.from('challenges').select('*').eq('user_id', userId)
]);
if (profileRes.error || !profileRes.data) { console.error('Could not fetch cloud profile', profileRes.error); return null; }
const p = profileRes.data;
const user = newUserShell(p.name);
user.id = p.id;
user.accessCode = p.access_code;
user.avatar = p.avatar_url || null;
user.createdAt = new Date(p.created_at).getTime();
user.lastSeen = p.last_seen ? new Date(p.last_seen).getTime() : null;
user.onboarded = !!p.onboarded;
user.settings = p.settings || user.settings;
user.habits = (habitsRes.data || []).map(h => ({
id: h.id, name: h.name, description: h.description || '', category: h.category || '',
frequency: h.frequency || { type: 'daily' }, createdAt: new Date(h.created_at).getTime(),
active: h.active !== false, reminder: h.reminder || null
}));
user.completions = {};
(complRes.data || []).forEach(c => { user.completions[`${c.habit_id}__${c.date}`] = c.status; });
user.friction = (frictionRes.data || []).map(f => ({
id: f.id, habitId: f.habit_id, habitNameSnapshot: f.habit_name_snapshot || '',
date: f.date, reason: f.reason, note: f.note || ''
}));
user.priorities = (prioritiesRes.data || []).map(pr => ({
key: `${pr.category}::${pr.label}`, catId: pr.category, label: pr.label, isTop: !!pr.is_top
}));
user.skills = (skillsRes.data || []).map(s => ({
id: s.id, name: s.name, description: s.description || '', progress: s.progress || 0,
hours: s.hours || 0, relatedProblem: s.related_problem || '', evidence: s.evidence || []
}));
user.weeklyReviews = (reviewsRes.data || []).map(r => ({
weekKey: r.week_key, reflection: r.reflection || '', answers: r.answers || {},
createdAt: new Date(r.created_at).getTime(), snapshot: r.snapshot || {}
}));
user.challenges = {};
(challengesRes.data || []).forEach(c => {
user.challenges[c.date] = { habitId: c.habit_id, text: c.text, completed: !!c.completed };
});
return user;
},
// ---- Best-effort push: upserts every collection. Called after
// saveData() when a cloud session exists; never blocks the UI. Delivers
// "online: Supabase <-> local cache, offline: local cache only" without
// a full conflict-resolution engine, which isn't needed yet at this
// product's scale (single device editing a record at a time). ----
async pushFullState(user) {
const sb = getSupabaseClient();
if (!sb) return false;
try {
await sb.from('profiles').upsert({
id: user.id, name: user.name, avatar_url: user.avatar, access_code: user.accessCode,
onboarded: user.onboarded, settings: user.settings, last_seen: new Date().toISOString()
});
if (user.habits.length) {
await sb.from('habits').upsert(user.habits.map(h => ({
id: h.id, user_id: user.id, name: h.name, description: h.description, category: h.category,
frequency: h.frequency, active: h.active, reminder: h.reminder,
created_at: new Date(h.createdAt).toISOString()
})));
}
const complRows = Object.keys(user.completions).map(key => {
const [habitId, date] = key.split('__');
return { user_id: user.id, habit_id: habitId, date, status: user.completions[key] };
});
if (complRows.length) await sb.from('habit_completions').upsert(complRows, { onConflict: 'habit_id,date' });
if (user.friction.length) {
await sb.from('friction_items').upsert(user.friction.map(f => ({
id: f.id, user_id: user.id, habit_id: f.habitId, habit_name_snapshot: f.habitNameSnapshot,
date: f.date, reason: f.reason, note: f.note
})));
}
if (user.priorities && user.priorities.length) {
await sb.from('priorities').upsert(user.priorities.map(pr => ({
user_id: user.id, category: pr.catId, label: pr.label, is_top: !!pr.isTop
})), { onConflict: 'user_id,category,label' });
}
if (user.skills.length) {
await sb.from('skills').upsert(user.skills.map(s => ({
id: s.id, user_id: user.id, name: s.name, description: s.description, progress: s.progress,
hours: s.hours, related_problem: s.relatedProblem, evidence: s.evidence
})));
}
if (user.weeklyReviews.length) {
await sb.from('weekly_reviews').upsert(user.weeklyReviews.map(r => ({
user_id: user.id, week_key: r.weekKey, reflection: r.reflection, answers: r.answers || {},
snapshot: r.snapshot
})), { onConflict: 'user_id,week_key' });
}
const challengeRows = Object.keys(user.challenges || {}).map(date => ({
user_id: user.id, date, habit_id: user.challenges[date].habitId,
text: user.challenges[date].text, completed: !!user.challenges[date].completed
}));
if (challengeRows.length) await sb.from('challenges').upsert(challengeRows, { onConflict: 'user_id,date' });
return true;
} catch (err) {
console.error('Cloud sync failed (data is still safe in the local cache).', err);
return false;
}
}
};
/* ---------- DataStore ----------
The single seam everything else talks to. `local*` methods always hit
StorageAdapter so the app is instantly usable offline. `cloud*` methods
are only meaningful once isSupabaseConfigured() is true, and every call
site treats their failure as non-fatal (the local cache is always the
fallback of record). */
const DataStore = {
adapter: StorageAdapter,
loadState() {
const parsed = this.adapter.read(STORAGE_KEY);
if (parsed && parsed.users) return parsed;
return null;
},
saveState(nextState) {
const ok = this.adapter.write(STORAGE_KEY, nextState);
this.maybeSyncToCloud();
return ok;
},
getSessionUserId() { return this.adapter.readRaw(SESSION_KEY); },
setSessionUserId(userId) { return this.adapter.writeRaw(SESSION_KEY, userId); },
clearSession() { return this.adapter.remove(SESSION_KEY); },
readLegacyState() { return this.adapter.read(LEGACY_DATA_KEY); },
readLegacySessionKey() { return this.adapter.readRaw(LEGACY_SESSION_KEY); },
clearLegacy() { this.adapter.remove(LEGACY_DATA_KEY); this.adapter.remove(LEGACY_SESSION_KEY); },
// Fire-and-forget push of the active user to Supabase, debounced so a
// burst of local saves (e.g. ticking off several habits) doesn't spam
// the network. Never awaited by callers — the UI never blocks on it.
_syncTimer: null,
maybeSyncToCloud() {
if (!isSupabaseConfigured()) return;
clearTimeout(this._syncTimer);
this._syncTimer = setTimeout(async () => {
const u = getUser();
if (!u) return;
await SupabaseAdapter.pushFullState(u);
}, 1200);
}
};
/* =========================================================================
PERSISTENCE + MIGRATION
========================================================================= */
function saveData() {
return DataStore.saveState(state);
}
function loadData() {
const parsed = DataStore.loadState();
if (parsed) {
state = parsed;
backfillAccessCodes();
return true;
}
return false;
}
/* ---------- Access codes ----------
A short, human-typeable code that identifies an account. Format
OT-XXXX-XXXX (letters/digits, ambiguous characters like 0/O/1/I/L
excluded) so it reads clearly out loud or off a screen. */
const ACCESS_CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
function generateAccessCodeCandidate() {
const block = () => {
let s = '';
for (let i = 0; i < 4; i++) s += ACCESS_CODE_ALPHABET[Math.floor(Math.random() * ACCESS_CODE_ALPHABET.length)];
return s;
};
return `OT-${block()}-${block()}`;
}
function genAccessCode() {
let code = generateAccessCodeCandidate();
let guard = 0;
while (Object.values(state.users).some(u => u.accessCode === code) && guard < 50) {
code = generateAccessCodeCandidate();
guard++;
}
return code;
}
function backfillAccessCodes() {
let changed = false;
Object.values(state.users).forEach(u => {
if (!u.accessCode) { u.accessCode = genAccessCode(); changed = true; }
});
if (changed) saveData();
}
function findUserByAccessCode(code) {
const norm = String(code || '').trim().toUpperCase();
if (!norm) return null;
return Object.values(state.users).find(u => u.accessCode === norm) || null;
}
function newUserShell(name) {
return {
id: genId('user'),
name: name || 'You',
avatar: null,
accessCode: null,
createdAt: Date.now(),
lastSeen: null,
onboarded: false,
habits: [],
completions: {},
skills: [],
friction: [],
watchFor: [],
priorities: [],
onboardingSelections: null,
weeklyReviews: [],
challenges: {},
settings: {
appearance: 'system',
notifications: { habitReminders: true, challengeReminders: true, weeklyReview: true, dailyCheckin: false },
privacy: { dataVisibility: true }
}
};
}
/* Legacy schema (v1, pre-rebuild): a single 'four_keys_data' key with
hardcoded friend1..friend8 profiles, index-based habit logs, and a
stake system. We migrate what we reasonably can and discard the rest
(stake data, unused demo slots) rather than silently destroying real
history. */
const LEGACY_KEYS = {
"AX7K2M": "user", "BQ4L9P": "friend1", "CR8N3T": "friend2", "DZ5V6H": "friend3",
"EY1J8R": "friend4", "FW3S2L": "friend5", "GH2M7X": "friend6", "JK9P4L": "friend7",
"MN6R1Q": "friend8"
};
const LEGACY_KEYS_REVERSE = Object.fromEntries(Object.entries(LEGACY_KEYS).map(([code, pKey]) => [pKey, code]));
function migrateLegacyIfPresent() {
let legacyRaw;
try {
legacyRaw = localStorage.getItem(LEGACY_DATA_KEY);
} catch (err) {
return false;
}
if (!legacyRaw) return false;
let legacy;
try {
legacy = JSON.parse(legacyRaw);
} catch (err) {
console.error('Legacy OnTrack data was unreadable; skipping migration.', err);
return false;
}
if (!legacy || !legacy.profiles) return false;
let lastSessionCode = null;
try { lastSessionCode = localStorage.getItem(LEGACY_SESSION_KEY); } catch (err) { /* ignore */ }
const lastSessionProfileKey = lastSessionCode ? LEGACY_KEYS[lastSessionCode] : null;
let migratedAny = false;
let firstMigratedUserId = null;
Object.keys(legacy.profiles).forEach(pKey => {
const oldProfile = legacy.profiles[pKey];
if (!oldProfile) return;
if (/^friend\d+$/.test(pKey) && !oldProfile.onboarded) return;
if (!oldProfile.onboarded && !(oldProfile.habits && oldProfile.habits.length)) return;
const user = newUserShell(oldProfile.name || 'Migrated profile');
user.avatar = oldProfile.avatar || null;
user.onboarded = !!oldProfile.onboarded;
user.lastSeen = oldProfile.lastSeen || null;
user.accessCode = LEGACY_KEYS_REVERSE[pKey] || genAccessCode();
const idxToId = {};
(oldProfile.habits || []).forEach((habitName, idx) => {
const h = {
id: genId('habit'), name: habitName, description: '', category: '',
frequency: { type: 'daily' }, createdAt: Date.now(), active: true, reminder: null
};
user.habits.push(h);
idxToId[idx] = h.id;
});
const statusMap = { '\u2713': STATUS.DONE, '~': STATUS.ADJUSTED, '\u2715': STATUS.MISSED };
Object.keys(oldProfile.logs || {}).forEach(key => {
const parts = key.split('-');
if (parts.length !== 4) return;
const [hIdxStr, y, m, d] = parts;
const habitId = idxToId[Number(hIdxStr)];
if (!habitId) return;
const status = statusMap[oldProfile.logs[key]];
if (!status) return;
user.completions[`${habitId}__${dateKey(Number(y), Number(m), Number(d))}`] = status;
});
(oldProfile.skills || []).forEach(s => {
user.skills.push({
id: genId('skill'), name: s.name || 'Skill', description: '',
progress: s.progress || 0, hours: s.hours || 0, relatedProblem: '', evidence: []
});
});
(oldProfile.friction || []).forEach(f => {
if (f && f.seed) {
if (f.reason) user.watchFor.push(f.reason);
return;
}
if (!f || !f.date) return;
const habitId = f.habit ? (user.habits.find(h => h.name === f.habit) || {}).id : null;
user.friction.push({
id: genId('fric'), habitId: habitId || null, habitNameSnapshot: f.habit || '',
date: f.date, reason: f.reason || 'Other', note: ''
});
});
// Discard: stake settings/history, group membership. No equivalent —
// and none needed, per the current product direction.
state.users[user.id] = user;
migratedAny = true;
if (pKey === lastSessionProfileKey || (!firstMigratedUserId && user.onboarded)) {
firstMigratedUserId = user.id;
}
});
if (migratedAny) {
state.session.activeUserId = firstMigratedUserId || Object.values(state.users)[0].id;
saveData();
DataStore.clearLegacy();
}
return migratedAny;
}
// v2 -> v3 in-place migration: drops the removed `groups` collection and
// each user's group-only fields (groupId, activityLog, old onboarding
// impact ratings, the old 5-item priority cap) without touching anything
// else. Existing habits/completions/skills/friction/IDs are untouched.
function migrateV2ToV3IfNeeded() {
if (!state || state.version >= 3) return false;
delete state.groups;
Object.values(state.users || {}).forEach(u => {
delete u.groupId;
delete u.activityLog;
if (!Array.isArray(u.priorities)) {
const sel = u.onboardingSelections;
u.priorities = (sel && Array.isArray(sel.priorities))
? sel.priorities.map(key => {
const [catId, ...rest] = String(key).split('::');
return { key, catId, label: rest.join('::'), isTop: true };
})
: [];
}
if (u.onboardingSelections) delete u.onboardingSelections.impact;
if (!u.challenges) u.challenges = {};
if (u.settings && u.settings.notifications) {
delete u.settings.notifications.groupActivity;
if (u.settings.notifications.challengeReminders === undefined) u.settings.notifications.challengeReminders = true;
}
if (u.settings) {
u.settings.privacy = { dataVisibility: true };
}
});
state.version = 3;
saveData();
return true;
}
function getUser() {
return state.users[state.session.activeUserId] || null;
}
function touchLastSeen() {
const u = getUser();
if (!u) return;
u.lastSeen = Date.now();
saveData();
}
/* =========================================================================
HABIT DUE / CONSISTENCY / STREAK — single source of truth.
Today, Habits, Progress, and habit detail all call these same functions
so the numbers never disagree with each other.
========================================================================= */
function isHabitDue(habit, date) {
if (!habit.frequency || habit.frequency.type === 'daily') return true;
if (habit.frequency.type === 'weekly') {
const days = habit.frequency.days || [];
return days.length === 0 ? true : days.includes(date.getDay());
}
return true;
}
function getCompletion(user, habitId, dStr) {
return user.completions[`${habitId}__${dStr}`] || null;
}
function setCompletion(user, habitId, dStr, status) {
const key = `${habitId}__${dStr}`;
if (status) user.completions[key] = status;
else delete user.completions[key];
}
function habitDueDatesInRange(habit, to) {
const dates = [];
const start = new Date(habit.createdAt);
start.setHours(0, 0, 0, 0);
const end = to ? new Date(to) : new Date();
end.setHours(0, 0, 0, 0);
if (end < start) return dates;
let cursor = new Date(start);
let guard = 0;
while (cursor <= end && guard < 3660) {
if (isHabitDue(habit, cursor)) dates.push(new Date(cursor));
cursor = addDays(cursor, 1);
guard++;
}
return dates;
}
function calcConsistency(user, opts) {
opts = opts || {};
const habits = opts.habitId
? [user.habits.find(h => h.id === opts.habitId)].filter(Boolean)
: user.habits.filter(h => h.active !== false);
let expected = 0, done = 0, adjusted = 0, missed = 0;
habits.forEach(habit => {
let dueDates = habitDueDatesInRange(habit, opts.to);
if (opts.from) {
const fromTime = new Date(opts.from).setHours(0, 0, 0, 0);
dueDates = dueDates.filter(d => d.getTime() >= fromTime);
}
dueDates.forEach(d => {
const dStr = dateKey(d.getFullYear(), d.getMonth() + 1, d.getDate());
const status = getCompletion(user, habit.id, dStr);
expected++;
if (status === STATUS.DONE) done++;
else if (status === STATUS.ADJUSTED) adjusted++;
else if (status === STATUS.MISSED) missed++;
});
});
if (expected === 0) return null;
const pct = Math.round(((done + adjusted) / expected) * 100);
return { pct, expected, done, adjusted, missed };
}
function calcCurrentStreak(user, habitId) {
const habit = user.habits.find(h => h.id === habitId);
if (!habit) return 0;
const createdDate = new Date(habit.createdAt);
createdDate.setHours(0, 0, 0, 0);
let cursor = new Date();
cursor.setHours(0, 0, 0, 0);
const isToday = (d) => d.getTime() === new Date().setHours(0, 0, 0, 0);
let streak = 0;
let guard = 0;
while (cursor >= createdDate && guard < 3660) {
guard++;
if (isHabitDue(habit, cursor)) {
const dStr = dateKey(cursor.getFullYear(), cursor.getMonth() + 1, cursor.getDate());
const status = getCompletion(user, habit.id, dStr);
if (status === STATUS.DONE || status === STATUS.ADJUSTED) {
streak++;
} else if (isToday(cursor) && !status) {
// give today the benefit of the doubt, keep walking backward
} else {
break;
}
}
cursor = addDays(cursor, -1);
}
return streak;
}
function calcLongestStreak(user, habitId) {
const habit = user.habits.find(h => h.id === habitId);
if (!habit) return 0;
const dueDates = habitDueDatesInRange(habit, new Date());
let longest = 0, run = 0;
dueDates.forEach(d => {
const dStr = dateKey(d.getFullYear(), d.getMonth() + 1, d.getDate());
const status = getCompletion(user, habit.id, dStr);
if (status === STATUS.DONE || status === STATUS.ADJUSTED) {
run++;
longest = Math.max(longest, run);
} else {
run = 0;
}
});
return longest;
}
function overallCleanDaySet(user) {
if (user.habits.length === 0) return new Set();
const earliest = user.habits.reduce((min, h) => Math.min(min, h.createdAt), Date.now());
const start = new Date(earliest);
start.setHours(0, 0, 0, 0);
const end = new Date();
end.setHours(0, 0, 0, 0);
const clean = new Set();
let cursor = new Date(start);
let guard = 0;
while (cursor <= end && guard < 3660) {
guard++;
const dStr = dateKey(cursor.getFullYear(), cursor.getMonth() + 1, cursor.getDate());
const dueHabits = user.habits.filter(h => new Date(h.createdAt).setHours(0, 0, 0, 0) <= cursor.getTime() && isHabitDue(h, cursor));