Open the week-data .json exported from Takt (Week & PT → “Week data (JSON)”).
| Meal · item (protected) | Status | kcal | P g | C g | F g |
|---|
'+(stCounts.act+stCounts.upd)+' of '+nTot+' meals counted'+ (stCounts.plan?' · '+stCounts.plan+' planned ('+r0(planKcal)+' kcal) not counted yet':'')+'
':"")+ (T.kcal?"":'No targets yet: set them in More → Standard day.
')); m.appendChild(card); // medication const mc=document.createElement("div"); mc.className="card jumpto"; mc.dataset.sec="med"; mc.id="medCard"; // v66g: each row of My medication at each of its times: a tick box (taken) and a "Not taken" button. // Plain status only — no colour change, no counts, no alerts. "Not taken" is stored as before // (status "skipped"), so the record and older entries read the same. mc.innerHTML="No medication yet. Add your own in More → Settings → My medication, and each dose will appear here with a tick box.
"); plannedDoses().forEach(({med,t})=>{ const key=medKey(med.id,t), name=medDisplayName(med); const rec=day.meds[key]||{}, state=rec.status||"planned"; const row=document.createElement("div"); row.className="row medrow"; row.dataset.dose=key; const st=state==="taken"?"Taken at "+(rec.t||t):state==="skipped"?"Not taken":(rec.t&&rec.t!==t?"Planned for "+rec.t:""); row.innerHTML=''+esc(t)+''+esc(name)+ ' '+esc(med.dose||"")+""+(st?' '+esc(st)+"":"")+""; const tl=document.createElement("label"); tl.className="medtick"; const cb=document.createElement("input"); cb.type="checkbox"; cb.checked=state==="taken"; cb.setAttribute("aria-label",name+" at "+t+": taken"); cb.className="tickin"; const tk=document.createElement("span"); tk.className="tickmark"; tk.setAttribute("aria-hidden","true"); tl.append(cb,tk,document.createTextNode("Taken")); cb.onchange=()=>{ if(cb.checked) day.meds[key]={status:"taken",t:(state==="planned"&&rec.t)||t}; else delete day.meds[key]; save(); render(); }; const nt=document.createElement("button"); nt.type="button"; nt.className="btn ghost small notaken"+(state==="skipped"?" on":""); nt.textContent="Not taken"; nt.setAttribute("aria-pressed",state==="skipped"?"true":"false"); nt.setAttribute("aria-label",name+" at "+t+": not taken"); nt.onclick=()=>{ if(state==="skipped") delete day.meds[key]; else day.meds[key]={status:"skipped"}; save(); render(); }; row.append(tl,nt); if(state!=="skipped"){ const ti=document.createElement("input"); ti.type="time"; ti.value=rec.t||t; ti.style.width="130px"; ti.setAttribute("aria-label",name+" at "+t+": "+(state==="taken"?"time taken":"time for today")); ti.onchange=()=>{ if(state==="taken") day.meds[key]={status:"taken",t:ti.value}; else if(ti.value===t) delete day.meds[key]; else day.meds[key]={status:"planned",t:ti.value}; save(); render(); }; row.appendChild(ti); } mc.appendChild(row); }); const allDoses=plannedDoses(); if(allDoses.length && allDoses.some(({med,t})=>{ const r=day.meds[medKey(med.id,t)]; return !r||r.status!=="taken"; })){ const allMedB=document.createElement("button"); allMedB.className="btn small"; allMedB.style.marginTop="10px"; allMedB.textContent="Mark all taken as planned"; allMedB.title="Mark every not-yet-taken dose today as taken, at its own scheduled time"; allMedB.onclick=()=>{ allDoses.forEach(({med,t})=>{ const key=medKey(med.id,t); const r=day.meds[key]; if(!r||r.status!=="taken") day.meds[key]={status:"taken",t}; }); save(); render(); }; mc.appendChild(allMedB); } // Reset every dose's time back to its scheduled (default) time. Only offered when at least one // dose actually has a changed time. Planned doses simply drop their override; doses already marked // Taken keep their status but have their recorded time replaced, so that case asks first. const custom=plannedDoses().filter(({med,t})=>{ const r=day.meds[medKey(med.id,t)]; return r&&r.status!=="skipped"&&r.t&&r.t!==t; }); if(custom.length){ const rb=document.createElement("button"); rb.className="btn ghost small"; rb.style.margin="8px 0"; rb.textContent="↺ Set times to default ("+custom.length+" changed)"; rb.title="Put every medication time back to its scheduled time"; rb.onclick=()=>{ const nTaken=custom.filter(({med,t})=>day.meds[medKey(med.id,t)].status==="taken").length; if(nTaken && !confirm(nTaken+" dose(s) marked Taken have a different actual time recorded. Replace with the scheduled time?")) return; custom.forEach(({med,t})=>{ const k=medKey(med.id,t), r=day.meds[k]; if(r.status==="taken") day.meds[k]={status:"taken",t}; else delete day.meds[k]; }); save(); render(); }; mc.appendChild(rb); } mc.appendChild(noteInput("Medication note (side effects, changes)…",day.medNote,v=>day.medNote=v)); mc.appendChild(medTimingNote()); collapsible(mc,"med","Medication"); if(!medOff()) m.appendChild(mc); // v66h: not shown while the user has chosen not to record medication // meals // v66f: the section heading carries the day-wide actions (fill / mark every meal as standard). const mealsHead=document.createElement("div"); mealsHead.className="mealshead jumpto"; mealsHead.id="mealsHead"; mealsHead.dataset.sec="nut"; mealsHead.innerHTML='| Time | What | Why |
|---|---|---|
| "+esc(p.time)+" | "+esc(p.what)+" | "+esc(p.why)+" |
No targets set yet — enter them in More → Standard day.
'); } else if(!st.nDays){ tg.insertAdjacentHTML("beforeend",'No food logged this week yet — averages will appear here.
'); } else { const cg=document.createElement("div"); cg.className="totgrid"; [["kcal","Calories","",r0],["p","Protein","g",r1],["c","Carbohydrates","g",r1],["f","Fat","g",r1]].forEach(([k,lbl,unit,rf])=>{ const avg=st.avg[k], t3=T2[k]; const box=document.createElement("div"); box.className="totbox"; const pct=t3?Math.round(avg/t3*100):null; const delta=t3?rf(avg-t3):null; box.innerHTML=''+lbl+(unit?" ("+unit+")":"")+""+ 'Daily averages over '+st.nDays+' logged day'+(st.nDays===1?"":"s")+' vs the agreed targets.
'); } m.appendChild(tg); // comments const cc=document.createElement("div"); cc.className="card grid2"; const mkTa=(label,val,cb)=>{ const d=document.createElement("div"); d.innerHTML="1. Send your PT the review file (or the week-data JSON plus the separate takt-pt-review.html). 2. They adjust macros, set targets, comment, save a PDF, and download a return file. 3. Import that file below — it stores their verified figures, targets, comments and review stamp.
"; // v63b (M5): what the review file carries — the Takt link's sharing choices, else these two boxes. const shr=ptFileShare(), shBox=document.createElement("div"); shBox.id="ptFileShare"; shBox.className="hint"; shBox.style.margin="0 0 8px"; if(shr.src==="link") shBox.textContent="The review file follows what you share with "+(shr.coach||"your coach")+" on Takt: food, weight and training"+(shr.med?", medication":"")+(shr.bio?", other biometrics":"")+(shr.from?", from "+shr.from:"")+". Change it on the Data tab."; else if(shr.src==="unknown") shBox.textContent="Your sharing choices haven't loaded, so the review file holds only food, weight and training until they do."; else { const mk=(id,key,label)=>{ const l=document.createElement("label"); l.style.display="block"; const c=document.createElement("input"); c.type="checkbox"; c.id=id; c.checked=shr[key]; c.onchange=()=>{ S.ptFileShare=Object.assign({med:false,bio:false},S.ptFileShare,{[key]:c.checked}); save(); }; l.append(c," "+label); return l; }; const t=document.createElement("div"); t.textContent="The review file always holds food, weight and training. Also include:"; shBox.append(t, medOff()?"":mk("ptShareMed","med","Medication (with supplements and routine)"), mk("ptShareBio","bio","Other biometrics (blood pressure, heart rate, SpO₂, body fat, sleep, mood, water, steps)")); } { // v65b: the progress comparison (Before vs the latest snapshot), within the same sharing — off until ticked const l=document.createElement("label"); l.style.display="block"; l.className="hint"; l.style.margin="0 0 8px"; const c=document.createElement("input"); c.type="checkbox"; c.id="ptShareProg"; c.checked=!!(S.ptFileShare&&S.ptFileShare.progress===true); c.addEventListener("change",()=>{ S.ptFileShare=Object.assign({med:false,bio:false},S.ptFileShare,{progress:c.checked}); if(!c.checked) delete S.ptFileShare.progress; save(); }); l.append(c," Include the progress comparison (Before and your latest snapshot)"); shBox.appendChild(l); } rt.appendChild(shBox); // A signed-in client's links may not have loaded yet: load them first, so the file never falls // back to the boxes while a Takt link is in force. const ensureLinks=async()=>{ if(typeof sbSignedIn==="function"&&sbSignedIn()&&typeof acctLogSyncOk==="function"&&acctLogSyncOk()&&coachState.links===null) await coachLoadLinks(); }; const rtRow=document.createElement("div"); rtRow.className="row"; const exH=document.createElement("button"); exH.className="btn primary"; exH.textContent="PT review file (HTML)"; // v66h: the screen's one primary button // Stamp the week as "sent to PT" BEFORE building the file, so the file the PT receives already // shows this week as sent; undone if the download didn't actually happen. const stampSent=()=>{ const w=W(curWeek), prev={a:w.sentToPtAt||null,b:!!w.sentBackfilled}; w.sentToPtAt=todayKey(); w.sentBackfilled=false; return ()=>{ w.sentToPtAt=prev.a; w.sentBackfilled=prev.b; }; }; exH.onclick=async()=>{ await ensureLinks(); const undo=stampSent(); const data=reviewerHTML(buildWeekExport(curWeek)); const ptName="Takt PT review ("+weekLabelForFilename(curWeek)+") ("+fileDateStr(todayKey())+") (v"+nextFileVersion("ptReview")+").html"; let sentOk=false; try{ sentOk=!!(await fileSave(ptName,data)); } catch(e){ try{ sentOk=!!(await fileSave("takt-week-"+curWeek+".json",JSON.stringify(buildWeekExport(curWeek),null,1))); alert("HTML downloads aren't enabled here, so I saved the week DATA file instead. Send it with the takt-pt-review.html file (from the chat) — your PT opens that file and loads this JSON into it."); } catch(e2){} } if(sentOk){ save(); render(); } else undo(); }; const exJ=document.createElement("button"); exJ.className="btn ghost"; exJ.textContent="Week data (JSON)"; exJ.onclick=async()=>{ await ensureLinks(); const undo=stampSent(); let ok=false; try{ ok=!!(await fileSave("takt-week-"+curWeek+".json",JSON.stringify(buildWeekExport(curWeek),null,1))); }catch(e){ alert("Download failed — try again, or use the PT review file (HTML) button instead."); } if(ok){ save(); render(); } else undo(); }; rtRow.append(exH,exJ); rt.appendChild(rtRow); const impD=document.createElement("details"); impD.innerHTML='"+esc(fmtDay(from))+" – "+esc(fmtDay(today))+" · tap a chart for values
"); const pick=(fn,ks)=>(ks||keys).map(k=>{const d=S.days[k];const v=d?fn(d):null;return {x:k,y:(v==null||isNaN(v))?null:v};}); // v66f: each group of charts takes its section's accent (chartCard reads chartSec) const SEC_OF_GROUP={"Nutrition vs target":"nut","Biometrics":"bio","Training volume per week":"train"}; const sec=t=>{ chartSec=SEC_OF_GROUP[t]||""; m.insertAdjacentHTML("beforeend","No macro targets set yet — enter them in More → Standard day.
'); // v60: water, from meals + extra, against the water target. const water=pick(d=>{ const t=dayWater(d).total; return t==null?null:r1(t); }), wTg=waterTarget(), tWater=keys.map(k=>({x:k,y:wTg})); m.appendChild(chartCard("Water (L/day, "+WATER_LABEL+")","bar",withT("Water",water,tWater),"L",{key:"water",summary:trendVsTarget(water,tWater,"L",keys.length,1)})); // ---- Biometrics ---- sec("Biometrics"); const bpMean=(d,key)=>{ const rs=bpReadings(d); return rs.length?rs.reduce((a,r)=>a+r[key],0)/rs.length:null; }; // the 7-day average needs the 6 days before the range too, or its first points average too little const wtLead=pick(d=>d.bio.wt,keysFrom(addDays(from,-6))), wt=wtLead.slice(6), wtAvg=smooth(wtLead,7).slice(6); const sys=pick(d=>bpMean(d,"sys")), dia=pick(d=>bpMean(d,"dia")); const bpSum=(()=>{ const ix=sys.map((p,i)=>i).filter(i=>sys[i].y!=null&&dia[i].y!=null); if(!ix.length) return ""; const z=ix[ix.length-1], av=a=>Math.round(ix.reduce((t,i)=>t+a[i].y,0)/ix.length); return "Latest "+Math.round(sys[z].y)+"/"+Math.round(dia[z].y)+" ("+fmtDay(keys[z])+") · avg "+av(sys)+"/"+av(dia)+" over "+ix.length+" day"+(ix.length===1?"":"s"); })(); const hr=pick(d=>d.bio.hr), steps=pick(d=>d.train.steps), sleep=pick(d=>d.bio.sleepH), bf=pick(d=>d.bio.bf), spo2=pick(d=>d.bio.spo2); const bio=[ ["Weight (kg)","line",[{name:"Weight",cvar:"--s1",pts:wt},{name:"7-day avg",cvar:"--s2",pts:wtAvg,dash:true}],"kg",{key:"wt",summary:trendLatest(wt,"kg",1)}], ["Blood pressure (mmHg)","line",[{name:"Systolic",cvar:"--s1",pts:sys},{name:"Diastolic",cvar:"--s2",pts:dia}],"mmHg",{key:"bp",summary:bpSum}], ["Resting heart rate (bpm)","line",[{name:"Resting HR",cvar:"--s1",pts:hr}],"bpm",{key:"hr",summary:trendLatest(hr,"bpm",0)}], ["Steps per day","bar",[{name:"Steps",cvar:"--s1",pts:steps}],"steps",{key:"steps",summary:trendAvg(steps,"steps/day",0)}], ["Sleep (hours)","bar",[{name:"Sleep",cvar:"--s1",pts:sleep}],"h",{key:"sleep",summary:trendAvg(sleep,"h/night",1)}], ["Body fat (%)","line",[{name:"Body fat",cvar:"--s1",pts:bf}],"%",{key:"bf",summary:trendLatest(bf,"%",1)}], ["SpO₂ (%)","line",[{name:"SpO₂",cvar:"--s1",pts:spo2}],"%",{key:"spo2",summary:trendLatest(spo2,"%",0)}] ]; const missing=[]; bio.forEach(([t,kind,ser,unit,o])=>{ if(ser[0].pts.some(p=>p.y!=null)) m.appendChild(chartCard(t,kind,ser,unit,o)); else missing.push(t.replace(/ \(.*\)$/,"")); }); if(missing.length) m.insertAdjacentHTML("beforeend","Not logged in this range: "+esc(missing.join(", "))+".
"); // ---- Training volume per week ---- sec("Training volume per week"); const lastWk=isoWeekKey(today); let firstWk=isoWeekKey(from); if(weekDates(firstWk)[0]>weekDates(weekShift(lastWk,-3))[0]) firstWk=weekShift(lastWk,-3); const tw=trainingByWeek(firstWk,lastWk); const wkPts=f=>tw.map(w=>({x:w.week,y:f(w)})); const done=tw.slice(0,-1).filter(w=>w.mins!=null), cur=tw[tw.length-1]; let tSum=""; if(done.length){ tSum="Avg "+fmtNum(done.reduce((t,w)=>t+w.mins,0)/done.length,0)+" min/week · "+ fmtNum(done.reduce((t,w)=>t+w.sessions,0)/done.length,1)+(cur.planned?" of "+cur.planned+" planned":"")+" sessions"; } if(cur.mins!=null) tSum+=(tSum?" · ":"")+"this week so far "+cur.mins+" min, "+cur.sessions+" session"+(cur.sessions===1?"":"s"); const wkTip=wk=>{ const ds=weekDates(wk); return "Wk "+wk.slice(-2)+" ("+fmtDay(ds[0])+" – "+fmtDay(ds[6])+")"; }; m.appendChild(chartCard("Training minutes per week","bar",[ {name:"Minutes",cvar:"--s1",pts:wkPts(w=>w.mins)}, {name:"Sessions",unit:"",pts:wkPts(w=>w.sessions),hide:true}, {name:"Planned",unit:"",pts:wkPts(w=>w.mins==null?null:w.planned),hide:true} ],"min",{key:"train",summary:tSum,xLabel:wk=>"Wk "+wk.slice(-2),tipLabel:wkTip})); } function smooth(pts,n){ // a value for every day with a reading somewhere in its window, so weigh-ins every other day // still draw a continuous average line return pts.map((p,i)=>{ const win=pts.slice(Math.max(0,i-n+1),i+1).map(q=>q.y).filter(v=>v!=null); return {x:p.x,y:win.length?win.reduce((a,b)=>a+b,0)/win.length:null}; }); } // series: [{name,cvar,pts:[{x,y}],dash?,step?,hide?,unit?}] — series[0] defines the x axis; on a bar // chart only series[0] is bars and the rest are overlay lines. `hide` series appear only in the // tooltip and table view. opts: {key, summary, xLabel(x), tipLabel(x)}. let chartSec=""; function chartCard(title,kind,series,unit,opts){ opts=opts||{}; const card=document.createElement("div"); card.className="card chart"; if(chartSec) card.dataset.sec=chartSec; if(opts.key) card.dataset.k=opts.key; const drawn=series.filter(s=>!s.hide); const has=drawn.some(s=>s.pts.some(p=>p.y!=null)); card.innerHTML='| "+(opts.tipLabel?"Week":"Date")+" | "+series.map(s=>""+esc(s.name)+" | ").join("")+"
|---|---|
| "+esc(lab(r.x))+" | "+r.vals.map(v=>""+(v==null?"–":fmtNum(r1(v)))+" | ").join("")+"
You signed in with a temporary password. Choose your own before you continue — at least "+PW_MIN+" characters. Nothing else opens until you have.
"; const row=document.createElement("div"); row.className="row fields"; const a=document.createElement("input"); a.type="password"; a.id="pwNew"; a.placeholder="New password"; a.autocomplete="new-password"; a.className="grow"; const b=document.createElement("input"); b.type="password"; b.id="pwAgain"; b.placeholder="New password again"; b.autocomplete="new-password"; b.className="grow"; const go=document.createElement("button"); go.className="btn small primary"; go.id="pwSave"; go.textContent="Save password"; const msg=document.createElement("p"); msg.className="hint"; msg.id="pwMsg"; msg.setAttribute("role","alert"); msg.style.color="var(--crit)"; go.onclick=async()=>{ msg.textContent=""; if(a.value.lengthSigned in as "+esc((a&&a.name)||(sbSession()||{}).email||"")+" — a coach account. Your clients' logs open here.
"; who.appendChild(acctSignOutRow()); const hb=document.createElement("button"); hb.className="btn ghost small"; hb.id="coachHelp"; hb.textContent="Help"; // v66d (help-text.js) hb.addEventListener("click",()=>{ helpPage=true; render(); try{ window.scrollTo(0,0); }catch(e){} }); who.appendChild(hb); m.appendChild(who); m.appendChild(coachLogoCard()); // v66 (logo.js) if(themeHasLight()) m.appendChild(modeCard()); // v66h: light or dark (settings.js); a coach's choice stays on this device renderCoachClients(m); // v55 if(coachLinksWanted()){ coachLoadLinks().then(render); } const inv=document.createElement("div"); inv.className="card"; inv.id="coachInvites"; inv.innerHTML="No clients or invites yet.
"); links.forEach(l=>{ const row=document.createElement("div"); row.className="row"; row.dataset.link=l.link_id; row.dataset.status=l.status; const t=document.createElement("span"); t.className="grow"; if(l.status==="active") t.innerHTML=""+esc(l.other_name)+" · since "+esc(coachDate(l.started_at))+" · shares "+esc(coachShares(l))+""; else if(l.status==="invited") t.innerHTML="Invite "+esc(l.invite_code||"")+" · unused · valid until "+esc(coachDate(l.invite_expires_at))+""; else if(l.status==="expired") t.innerHTML="Invite expired "+esc(coachDate(l.invite_expires_at))+""; else t.innerHTML=""+(l.started_at?esc(l.other_name)+" · ended "+esc(coachDate(l.ended_at))+(l.ended_by_me?" by you":" by the client"):"Invite cancelled "+esc(coachDate(l.ended_at)))+""; row.appendChild(t); if(l.status==="active"||l.status==="invited"){ const b=document.createElement("button"); b.className="btn ghost small"; b.textContent=l.status==="active"?"End link":"Cancel invite"; b.onclick=()=>coachEnd(l,l.status==="active"?"End your link with "+l.other_name+"? You will no longer see their log. They can link again with a new code.":"Cancel this invite code?"); row.appendChild(b); } list.appendChild(row); }); m.appendChild(list); } /* ---- The client's side: the Coach card on the Data tab ---- */ // v63b (security review L6): change what the active link shares — narrower or wider, and from when — // without ending it. The coach's next read follows (update_link_sharing). Closed until asked for. function coachShareEditor(cur){ const d=document.createElement("details"); d.id="coachShareEdit"; d.style.marginTop="8px"; d.innerHTML="Not linked to a coach.
"); // v64a: linking needs the client's consent to share with a coach (accept_invite refuses without it). const cons=(acctCached()||{}).consent; if(cons&&typeof cons==="object"&&!cons.coach){ const p=document.createElement("p"); p.className="hint"; p.id="coachNeedsConsent"; p.style.marginTop="10px"; p.textContent=LEGAL.consent.joinNeedsConsent; card.appendChild(p); return card; } // v66d: the form for a code sits in its own section — closed while a coach is linked ("Switch to a // different coach"), so its unticked boxes aren't read as the current sharing; open when none is. const sw=document.createElement("details"); sw.id="coachSwitch"; sw.style.marginTop="10px"; sw.open=!cur; const swS=document.createElement("summary"); swS.className="hint"; swS.style.cursor="pointer"; swS.textContent=cur?"Switch to a different coach":"Link to a coach with an invite code"; sw.appendChild(swS); const f=document.createElement("div"); f.id="coachJoin"; f.style.marginTop="10px"; f.innerHTML=""+(cur?"Linking to a different coach ends the link with "+esc(cur.other_name)+".":"Your coach gives you a code.")+" They always see your food, weight and daily training totals; tick what else to share.
"+ ""+ ""+esc(String(t.by||"Your coach"))+" changed your daily targets: "+esc(bits.join(" · "))+"."+(t.reason?" “"+esc(String(t.reason))+"”":"")+"
"; const b=document.createElement("button"); b.className="btn small"; b.id="tgSeen"; b.textContent="Got it"; b.onclick=async()=>{ b.disabled=true; try{ await sbRpc("mark_targets_seen"); }catch(e){ b.disabled=false; alert("Couldn't reach the cloud — try again when you're online."); return; } S.targetsMeta.seen=true; save(); render(); }; c.appendChild(b); return c; } // The Standard day's target editor while a coach owns the targets: the coach's name, read-only. function tgLockedBy(){ const l=coachLinkedNow(); return l&&sbSignedIn()&&acctLogSyncOk()?l.other_name:null; } /* ================= v55: the coach screen — clients, their week, targets and food ================= */ const coachWeekStart=k=>weekDates(isoWeekKey(k))[0]; async function coachLoadClients(){ coachView.loading=true; try{ coachView.clients=(await sbRpc("coach_clients_overview"))||[]; coachView.err=""; } catch(e){ coachView.err=e.code==="offline"?"Offline — clients can't be loaded.":"Couldn't load clients: "+(e.message||e.code); } coachView.loading=false; } async function coachLoadClient(){ const c=coachView.sel; if(!c) return; coachView.loading=true; coachView.msg=""; try{ coachView.summary=((await sbRpc("weekly_summary",{p_client:c.client_id, p_week_start:coachView.week}))||[])[0]||null; coachView.target=((await sbRpc("macro_target_on",{p_client:c.client_id, p_day:todayKey()}))||[])[0]||null; coachView.log=(await sbRpc("coach_day_log",{p_client:c.client_id, p_from:coachView.day, p_to:coachView.day}))||[]; }catch(e){ coachView.msg="Couldn't load: "+(e.message||e.code); } // v65a: the client's starting record, within the link's sharing choices; false = not available (SQL not run). try{ coachView.start=await sbRpc("coach_onboarding",{p_client:c.client_id}); }catch(e){ coachView.start=false; } try{ coachView.snaps=await sbRpc("coach_snapshots",{p_client:c.client_id}); }catch(e){ coachView.snaps=false; } // v65b try{ coachView.base=await sbRpc("coach_baseline",{p_client:c.client_id}); }catch(e){ coachView.base=false; } // v66a: Before = the first finished version coachView.loading=false; } function renderCoachClients(m){ if(coachView.clients==null&&!coachView.loading){ coachLoadClients().then(render); } const card=document.createElement("div"); card.className="card"; card.id="coachClients"; card.innerHTML="No clients yet — create an invite code below.
"); list.forEach(c=>{ const row=document.createElement("div"); row.className="row coachClient"; row.dataset.client=c.client_id; const checks=[c.no_log_3_days?"no log for 3 days":null, c.under_target_days>=3?"under target "+c.under_target_days+" of the last 7 days":null, c.unseen_target?"target change not yet seen":null].filter(Boolean); const b=document.createElement("button"); b.className="btn ghost small"+(coachView.sel&&coachView.sel.client_id===c.client_id?" on":""); b.textContent=c.client_name; b.onclick=()=>{ coachView.sel=c; coachView.week=coachWeekStart(todayKey()); coachView.day=todayKey(); coachView.summary=null; coachView.log=null; coachView.target=null; coachView.start=undefined; coachView.snaps=undefined; coachView.base=undefined; render(); coachLoadClient().then(render); }; const f=document.createElement("span"); f.className="grow hint coachChecks"; f.innerHTML=checks.length?checks.map(x=>''+esc(x)+"").join(" "):"nothing to check"+(c.last_logged?" · last logged "+esc(fmtNice(String(c.last_logged).slice(0,10))):""); row.append(b,f); card.appendChild(row); }); m.appendChild(card); if(coachView.sel) renderCoachClient(m); } // A form box with its name above it (v66i: the coach's targets and food corrections). function cField(name,input,cls){ const l=document.createElement("label"); l.className="cfield"+(cls?" "+cls:""); const s=document.createElement("span"); s.textContent=name; l.append(s,input); return l; } function renderCoachClient(m){ const c=coachView.sel; const head=document.createElement("div"); head.className="card"; head.id="coachClientHead"; head.innerHTML=""+esc(coachView.msg)+"
":"")+(coachView.loading?"Loading…
":""); m.appendChild(head); m.appendChild(obCoachCard(coachView.start)); // v65a: read-only (onboard.js) m.appendChild(snapCoachCard(coachView.snaps,coachView.base)); // v65b: read-only (progress.js); v66a: Before from coach_baseline // Weekly summary, Sunday to Saturday const ws=document.createElement("div"); ws.className="card"; ws.id="coachWeek"; const wd=weekDates(isoWeekKey(coachView.week)); ws.innerHTML="| Days logged | "+nz(S2.days_logged)+" of "+nz(S2.days_counted)+" |
| Average kcal | "+nz(S2.avg_kcal)+(S2.avg_target_kcal!=null?" target "+nz(S2.avg_target_kcal)+"":"")+" |
| Average protein | "+nz(S2.avg_protein_g," g")+(S2.avg_target_protein_g!=null?" target "+nz(S2.avg_target_protein_g," g")+"":"")+" |
| Average carbs · fat | "+nz(S2.avg_carbs_g," g")+" · "+nz(S2.avg_fat_g," g")+" |
| Training | "+nz(S2.training_sessions)+" sessions · "+nz(S2.training_minutes," min")+" · volume "+nz(S2.training_volume)+" |
| Average water from meals + extra | "+nz(S2.avg_water_l," L")+" |
| Weight | "+nz(S2.weight_last," kg")+(S2.weight_change!=null?" ("+(+S2.weight_change>0?"+":"")+esc(String(+S2.weight_change))+" kg vs last week)":"")+" |
"+ (T?"Now: "+nz(T.kcal," kcal")+" · protein "+nz(T.protein_g," g")+" · carbs "+nz(T.carbs_g," g")+" · fat "+nz(T.fat_g," g")+" — set by "+esc(T.set_by)+(T.by_coach&&T.seen_at==null?" · not yet seen":""):"No target set yet.")+"
"; const fr=document.createElement("div"); fr.className="row"; const mk=(id,ph,v)=>{ const i=document.createElement("input"); i.type="number"; i.id=id; i.placeholder=ph; i.title=ph; i.style.width="90px"; if(v!=null) i.value=+v; return i; }; const tk=mk("ctKcal","kcal",T&&T.kcal), tp=mk("ctP","protein g",T&&T.protein_g), tcb=mk("ctC","carbs g",T&&T.carbs_g), tf=mk("ctF","fat g",T&&T.fat_g); const from=document.createElement("input"); from.type="date"; from.id="ctFrom"; from.value=todayKey(); from.min=todayKey(); from.title="takes effect from"; const why=document.createElement("input"); why.id="ctWhy"; why.className="grow"; why.placeholder="Reason (the client sees it)"; why.maxLength=500; const go=document.createElement("button"); go.className="btn small"; go.id="ctSave"; go.textContent="Set targets"; const msg=document.createElement("p"); msg.className="hint"; msg.id="ctMsg"; msg.setAttribute("role","alert"); msg.style.color="var(--crit)"; go.onclick=async()=>{ const v=k=>k.value===""?null:+k.value, t={kcal:v(tk),p:v(tp),c:v(tcb),f:v(tf)}; if(!why.value.trim()){ msg.textContent="Give a reason — the client sees it."; return; } if(!tgValid(t)){ msg.textContent="Use kcal 800–6000, protein 0–400 g, carbs 0–1000 g, fat 0–400 g."; return; } go.disabled=true; const r=await sbFetch("/rest/v1/macro_targets",{method:"POST",headers:{"Prefer":"return=minimal"},body:JSON.stringify({client_id:c.client_id, link_id:c.link_id, kcal:t.kcal, protein_g:t.p, carbs_g:t.c, fat_g:t.f, effective_from:from.value||todayKey(), reason:why.value.trim()})}).catch(e=>({ok:false, json:async()=>({message:e.message})})); if(!r.ok){ const j=await r.json().catch(()=>({})); go.disabled=false; msg.textContent=j.message||"Couldn't set the targets."; return; } await coachLoadClient(); await coachLoadClients(); render(); }; fr.className="row cfields"; // v66i: each box keeps a visible name once it holds a figure fr.append(cField("kcal",tk),cField("Protein g",tp),cField("Carbs g",tcb),cField("Fat g",tf),cField("From",from),cField("Reason",why,"wide"),go); tc.append(fr,msg); m.appendChild(tc); // The daily log: read-only except the four macro figures of each food item const lc=document.createElement("div"); lc.className="card"; lc.id="coachLog"; lc.innerHTML="Nothing logged (or before the date the client shares from).
"); L.filter(r=>r.kind==="food").forEach(r=>{ const row=document.createElement("div"); row.className="row coachFood"; row.dataset.entry=r.entry_id; row.innerHTML=""+esc(r.meal)+" "+esc(r.food)+(r.amount!=null?" "+esc(String(+r.amount))+" g":"")+ (r.corrected_by_coach?" corrected":"")+""; if(r.correctable){ const ins=[["kcal","kcal"],["protein_g","P g"],["carbs_g","C g"],["fat_g","F g"]].map(([k,ph])=>{ const i=document.createElement("input"); i.type="number"; i.step="0.1"; i.dataset.k=k; i.placeholder=ph; i.title=ph; i.style.width="72px"; i.value=+r[k]; return i; }); const sv=document.createElement("button"); sv.className="btn small"; sv.textContent="Save"; sv.onclick=async()=>{ const v=Object.fromEntries(ins.map(i=>[i.dataset.k, i.value===""?NaN:+i.value])); if(Object.values(v).some(x=>!Number.isFinite(x)||x<0)||v.kcal>20000||v.protein_g>2000||v.carbs_g>2000||v.fat_g>2000){ alert("kcal 0–20000, protein, carbs and fat 0–2000 g."); return; } sv.disabled=true; try{ await sbRpc("coach_correct_food",{p_entry:r.entry_id, p_kcal:v.kcal, p_protein_g:v.protein_g, p_carbs_g:v.carbs_g, p_fat_g:v.fat_g}); } catch(e){ sv.disabled=false; alert("Not saved: "+(e.message||e.code)); return; } await coachLoadClient(); await coachLoadClients(); render(); }; row.append(...ins.map(i=>cField(({kcal:"kcal",protein_g:"Protein g",carbs_g:"Carbs g",fat_g:"Fat g"})[i.dataset.k],i)),sv); } else row.insertAdjacentHTML("beforeend",""+r0(+r.kcal)+" kcal · P "+r1(+r.protein_g)+" · C "+r1(+r.carbs_g)+" · F "+r1(+r.fat_g)+""); lc.appendChild(row); }); const rd=L.filter(r=>r.kind==="reading"), ds=L.filter(r=>r.kind==="dose"); if(rd.length) lc.insertAdjacentHTML("beforeend",""+rd.map(r=>esc(({water:"extra water", water_total:"water (from meals + extra)"})[r.metric]||r.metric.replace(/_/g," "))+" "+esc(String(+r.value))+" "+esc(r.unit)).join(" · ")+"
"); if(ds.length) lc.insertAdjacentHTML("beforeend",""+ds.map(r=>esc(r.medication)+(r.dose?" "+esc(r.dose):"")+" — "+esc(r.status)).join(" · ")+"
"); m.appendChild(lc); } /* ---- onboard.js ---- */ /* ================= v65a: Get started — the client's starting record ================= A new client's Today starts with a "Get started" card: seven short steps, each saved as soon as it is done, each skippable, resumable. The answers are the client's starting record, S.onboarding: {v:1, startDate, show?, finishedAt?, steps:{about, habits, meals, meds, sups, training, targets}, status:{A snapshot saves today's latest weight and body fat, your 7-day averages of sleep, water (meals + extra), calories, protein and steps, and your training sessions in the last 7 days. Snapshots are kept as they are: they can't be changed later.
"; const row=document.createElement("div"); row.className="row"; const tb=document.createElement("button"); tb.className="btn small"; tb.id="snapTake"; tb.textContent="Take a snapshot"; tb.addEventListener("click",snapTake); row.appendChild(tb); const n=(Array.isArray(S.snapshots)?S.snapshots:[]).length; const cnt=document.createElement("span"); cnt.className="hint"; cnt.id="snapCount"; cnt.textContent=n?n+" snapshot"+(n===1?"":"s")+" taken":"No snapshots yet"; row.appendChild(cnt); card.appendChild(row); if(snapUI.msg){ const p=document.createElement("p"); p.className="hint"; p.id="snapMsg"; p.textContent=snapUI.msg; card.appendChild(p); } const pts=snapPoints(); if(pts.length<2){ const p=document.createElement("p"); p.className="hint"; p.id="snapNoCompare"; p.textContent=pts.length?"Take another snapshot later to compare.":"Finish Get started (your Before) or take two snapshots to compare."; card.appendChild(p); return card; } const {a:A,b:B}=snapPair(pts); const sel=(id,cur,key)=>{ const s=document.createElement("select"); s.id=id; s.className="grow"; pts.forEach(p=>{ const o=document.createElement("option"); o.value=p.id; o.textContent=p.label; s.appendChild(o); }); s.value=cur?cur.id:""; s.addEventListener("change",()=>{ snapUI[key]=s.value; snapUI.msg=""; render(); }); return s; }; const cmp=document.createElement("div"); cmp.className="row"; cmp.style.marginTop="8px"; const h=document.createElement("b"); h.textContent="Compare"; cmp.append(h, sel("snapA",A,"a"), sel("snapB",B,"b")); card.appendChild(cmp); if(A&&B) card.appendChild(snapTable(A,B,SNAP_METRICS)); if(A&&A.before) card.appendChild(Object.assign(document.createElement("p"),{className:"hint",textContent:"Before uses your starting record: usual sleep and water, and sessions a week."})); return card; } // The coach screen's read-only copy: Before (v66a: coach_baseline, the FIRST finished starting record) and the // snapshots (coach_snapshots), both already within the link's sharing choices. Columns: Before, the // snapshots; then the change from the first to the last. Without coach_baseline (false), no Before column. function snapCoachCard(rows,base){ const card=document.createElement("div"); card.className="card"; card.id="coachSnaps"; card.innerHTML="Loading…
"); return card; } if(rows===false){ card.insertAdjacentHTML("beforeend","Not available yet.
"); return card; } const pts=[]; if(base&&typeof base==="object"){ pts.push({id:"before", label:"Before", v:{weight:snapNum(base.weight), bodyFat:snapNum(base.bodyFat), sleep:snapNum(base.sleep), water:snapNum(base.water), kcal:null, protein:null, sessions:snapNum(base.sessions), steps:null}}); } (Array.isArray(rows)?rows:[]).forEach(r=>{ if(!r||typeof r!=="object") return; const v={}; SNAP_METRICS.forEach(m=>v[m.k]=snapNum(r[m.k])); pts.push({id:String(r.taken_at||r.taken_on), label:isValidDateKey(String(r.taken_on))?fmtNice(String(r.taken_on)):"Snapshot", v}); }); if(!(Array.isArray(rows)&&rows.length)){ card.insertAdjacentHTML("beforeend","No snapshots yet.
"); return card; } const bio=!!(base&&base.shares_biometrics)||(Array.isArray(rows)&&rows.some(r=>r&&("sleep" in r||"steps" in r))); const metrics=SNAP_METRICS.filter(m=>bio||!m.bio); const tw=document.createElement("div"); tw.className="tablewrap"; const t=document.createElement("table"); t.id="coachSnapTable"; t.className="snaptable"; const hr=document.createElement("tr"); [""].concat(pts.map(p=>p.label),pts.length>1?["Change"]:[]).forEach((x,i)=>{ const th=document.createElement("th"); th.textContent=x; if(i) th.className="num"; hr.appendChild(th); }); const hd=document.createElement("thead"); hd.appendChild(hr); t.appendChild(hd); const bd=document.createElement("tbody"); metrics.forEach(m=>{ if(pts.every(p=>p.v[m.k]==null)) return; const tr=document.createElement("tr"); const cells=[m.label].concat(pts.map(p=>snapFmt(m,p.v[m.k]))); if(pts.length>1) cells.push(snapChange(m,pts[0].v[m.k],pts[pts.length-1].v[m.k])); cells.forEach((x,i)=>{ const td=document.createElement("td"); td.textContent=x; if(i) td.className="num"; tr.appendChild(td); }); bd.appendChild(tr); }); t.appendChild(bd); tw.appendChild(t); card.appendChild(tw); if(!bio) card.insertAdjacentHTML("beforeend","Not shared with you: body fat, sleep, water and steps.
"); return card; } // The PT review file's optional comparison (Week & PT → "Include progress comparison"), within the file's // sharing: the other biometrics only with sh.bio; with a "share from" date, only points whose figures // all fall after it. Plain strings: the file only shows them. function snapForPT(sh){ const from=sh&&sh.from||null; const pts=snapPoints().filter(p=>!from||(p.before?p.date>=from:addDays(p.date,-6)>=from)); if(pts.length<2) return null; const A=pts[0], B=pts[pts.length-1]; const val=(p,m)=>{ if(from&&((m.k==="weight"&&p.wtOn&&p.wtOnPNG or JPG, up to 200 KB (larger pictures are made smaller here first). It appears in the header for you and your linked clients, on every device they sign in on.
"; const b=brandCurrent(), own=b&&b.own?b:null; if(own){ const pv=document.createElement("img"); pv.id="coachLogoPreview"; pv.className="logopreview"; pv.alt="Your logo"; pv.src="data:"+own.mime+";base64,"+own.b64; card.appendChild(pv); } else card.insertAdjacentHTML("beforeend","No logo yet: your clients see the standard Takt look.
"); const row=document.createElement("div"); row.className="row"; const fi=document.createElement("input"); fi.type="file"; fi.id="coachLogoFile"; fi.accept="image/png,image/jpeg"; fi.className="sr"; // v66i: the file box is shown as an ordinary button with the chosen file's name beside it, like Restore const fl=document.createElement("label"); fl.className="btn ghost small filebtn"; fl.append(fi,document.createTextNode("Choose image…")); const fn=document.createElement("span"); fn.className="hint"; fn.id="coachLogoFileName"; fn.textContent="No file chosen"; fi.addEventListener("change",()=>{ fn.textContent=fi.files&&fi.files[0]?fi.files[0].name:"No file chosen"; }); const up=document.createElement("button"); up.className="btn small"; up.id="coachLogoUpload"; up.textContent=own?"Replace logo":"Upload logo"; row.append(fl,fn,up); if(own){ const rm=document.createElement("button"); rm.className="btn ghost small"; rm.id="coachLogoRemove"; rm.textContent="Remove logo"; rm.addEventListener("click",async()=>{ if(!confirm("Remove your logo? Your clients will see the standard Takt look.")) return; logoUI={busy:true,msg:""}; try{ await sbRpc("remove_coach_logo"); }catch(e){ logoUI={busy:false,msg:e.message||"Couldn't remove it."}; render(); return; } logoUI={busy:false,msg:"Logo removed."}; await brandLoad(); render(); }); row.appendChild(rm); } card.appendChild(row); const msg=document.createElement("p"); msg.className="hint"; msg.id="coachLogoMsg"; msg.textContent=logoUI.busy?"Working…":logoUI.msg; card.appendChild(msg); up.addEventListener("click",async()=>{ if(logoUI.busy) return; logoUI={busy:true,msg:""}; msg.textContent="Working…"; up.disabled=true; let p; try{ p=await logoPrepare(fi.files&&fi.files[0]); }catch(e){ logoUI={busy:false,msg:e.message}; render(); return; } try{ await sbRpc("set_coach_logo",{p_mime:p.mime, p_data:p.b64, p_width:p.w, p_height:p.h}); } catch(e){ logoUI={busy:false,msg:/404|not found|Could not find/i.test(e.message||"")?"Logos aren't available yet.":(e.code==="offline"?"Offline — try again when you're connected.":(e.message||"Couldn't save the logo."))}; render(); return; } logoUI={busy:false,msg:"Logo saved ("+Math.round(p.size/1024)+" KB, "+p.w+" × "+p.h+")."}; await brandLoad(); render(); }); return card; } /* ---- main2.js ---- */ /* ================= GUIDE ================= */ // Personal guide sections come from the user's own data (S.profile.guide — imported from their // personal-settings file, or a restored backup), so they are untrusted HTML here: rebuilt element by // element from an allowlist — no scripts, styles, event handlers or non-https links survive. const GUIDE_TAGS=new Set(["P","B","I","EM","STRONG","BR","UL","OL","LI","TABLE","THEAD","TBODY","TR","TH","TD","DIV","SPAN","A"]); const GUIDE_CLASSES=new Set(["cite","tablewrap","num","hint","warnbox"]); function safeGuideHTML(html){ const tpl=document.createElement("template"); tpl.innerHTML=String(html||""); const out=document.createDocumentFragment(); const copy=(src,dst)=>{ src.childNodes.forEach(n=>{ if(n.nodeType===3){ dst.appendChild(document.createTextNode(n.textContent)); return; } if(n.nodeType!==1) return; const tag=n.tagName.toUpperCase(); if(tag==="SCRIPT"||tag==="STYLE"||tag==="TEMPLATE") return; if(!GUIDE_TAGS.has(tag)){ copy(n,dst); return; } // unknown element: keep its text only const el=document.createElement(tag.toLowerCase()); const cls=(n.getAttribute("class")||"").split(/\s+/).filter(c=>GUIDE_CLASSES.has(c)); if(cls.length) el.className=cls.join(" "); if(tag==="A"){ const href=n.getAttribute("href")||""; if(/^https:\/\//i.test(href)){ el.href=href; el.target="_blank"; el.rel="noopener noreferrer"; } } copy(n,el); dst.appendChild(el); }); }; copy(tpl.content,out); return out; } // v50: the Timing guide holds only what the user wrote (S.profile.guide, from their personal-settings // file). Up to v49 it also carried the app's own section on protein timing, with general-guidance // figures — timing advice the app must not give. The food-database note moved to Data → My foods. /* ================= Help (v66d): help-text.js, by role ================= */ let helpPage=false; // the coach screen's Help page (clients have the Help tab) // Which parts of HELP this account sees: everyone the client part; a coach also the coach part; an // account the owner listed in takt_admins (my_admin_help) everything. Only the Help changes. function helpRoles(){ const a=typeof sbSignedIn==="function"&&sbEnabled()&&sbSignedIn()&&typeof acctCached==="function"?acctCached():null; if(a&&a.admin===true) return ["client","coach","admin"]; return a&&a.role==="coach"?["client","coach"]:["client"]; } function renderHelp(m,withBack){ const card=document.createElement("div"); card.className="card"; card.id="helpPage"; if(withBack){ const back=document.createElement("button"); back.className="btn ghost small"; back.id="helpBack"; back.textContent="← Back"; back.addEventListener("click",()=>{ helpPage=false; render(); try{ window.scrollTo(0,0); }catch(e){} }); card.appendChild(back); } const h=document.createElement("h2"); h.textContent=HELP.title; h.style.margin="6px 0 4px"; const intro=document.createElement("p"); intro.className="hint"; intro.textContent=HELP.intro; // v66i: under More the title sits above the card, like every other More screen if(withBack) card.append(h,intro); else { h.style.margin=""; const sh=document.createElement("div"); sh.className="screenhead"; sh.appendChild(h); m.appendChild(sh); intro.style.marginTop="0"; card.append(intro); } helpRoles().forEach(role=>{ const sec=document.createElement("section"); sec.className="helpRole"; sec.dataset.role=role; const rh=document.createElement("h3"); rh.textContent=HELP.roleTitles[role]; rh.style.marginTop="16px"; sec.appendChild(rh); (HELP[role]||[]).forEach(x=>{ const d=document.createElement("details"); d.className="helpSec"; d.id=x.id; d.style.margin="6px 0"; const sm=document.createElement("summary"); sm.textContent=x.h; sm.style.cursor="pointer"; sm.style.fontWeight="600"; d.appendChild(sm); (x.p||[]).forEach(t=>{ const p=document.createElement("p"); p.textContent=t; d.appendChild(p); }); if(x.steps){ const ol=document.createElement("ol"); x.steps.forEach(t=>{ const li=document.createElement("li"); li.textContent=t; ol.appendChild(li); }); d.appendChild(ol); } (x.code||[]).forEach(t=>{ const pre=document.createElement("pre"); pre.className="num"; pre.style.cssText="white-space:pre-wrap;word-break:break-word;font-size:.85em;background:var(--surface2);padding:6px;border-radius:6px"; pre.textContent=t; d.appendChild(pre); }); sec.appendChild(d); }); card.appendChild(sec); }); m.appendChild(card); } function renderGuide(m){ const h=document.createElement("div"); h.innerHTML="Removes your log and everything else Takt keeps in this browser on this device. It cannot be undone. Download a backup first if you want to keep your log.
"; const row=document.createElement("div"); row.className="row"; if(!eraseAsk){ const go=document.createElement("button"); go.className="btn ghost"; go.id="eraseStart"; go.textContent="Erase all my data on this device…"; go.onclick=()=>{ eraseAsk=true; render(); const b=document.getElementById("eraseBackup"); if(b) b.focus(); }; row.append(go); } else { const p=document.createElement("p"); p.className="hint"; p.id="eraseAskText"; p.setAttribute("role","alert"); p.textContent="Erase everything now? Your log will be gone from this device."; const bk=document.createElement("button"); bk.className="btn"; bk.id="eraseBackup"; bk.textContent="Download a backup first"; bk.onclick=async()=>{ try{ await downloadBackupFile(); }catch(e){} }; const yes=document.createElement("button"); yes.className="btn danger"; yes.id="eraseYes"; yes.textContent="Erase everything"; yes.onclick=async()=>{ yes.disabled=true; await eraseDeviceData(); location.reload(); }; const no=document.createElement("button"); no.className="btn ghost"; no.id="eraseNo"; no.textContent="Cancel"; no.onclick=()=>{ eraseAsk=false; render(); }; c.appendChild(p); row.append(bk,yes,no); } c.appendChild(row); return c; } // Everything with Takt's name in this browser: local and tab storage, the backup-folder database, the // app's offline cache and its service worker. Nothing is written afterwards (the page reloads empty). async function eraseDeviceData(){ if(saveTimer){ clearTimeout(saveTimer); saveTimer=null; } const drop=st=>{ try{ Object.keys(st).filter(k=>k.startsWith("takt-")).forEach(k=>st.removeItem(k)); }catch(e){} }; drop(localStorage); drop(sessionStorage); try{ indexedDB.deleteDatabase(FS_DB); }catch(e){} try{ if(window.caches) for(const k of await caches.keys()) if(k.startsWith("takt-")) await caches.delete(k); }catch(e){} try{ if(navigator.serviceWorker) for(const r of await navigator.serviceWorker.getRegistrations()) await r.unregister(); }catch(e){} S=normalizeState(BLANK()); ensureTemplate(); ensureMedPlan(); migrateMedKeys(); ensurePtTracking(); eraseAsk=false; } // v66i (data review 2): a local-only log on an iPhone/iPad opened in Safari (not from the Home Screen), // where Safari may clear a website's storage after about a week without a visit. function homeScreenTipDue(){ if(!IS_LOCAL||artifactNS||cloudSaving()) return false; let installed=false; try{ installed=navigator.standalone===true||(window.matchMedia&&window.matchMedia("(display-mode: standalone)").matches); }catch(e){} return !installed&&touchApple(navigator.userAgent,(navigator.maxTouchPoints||0)>0); } // v66i: the separate claude.ai copy is only mentioned in the neutral Takt theme. const DATA_CLAUDE_NOTE=THEME.id==="takt"?" Note: this copy and the claude.ai app do not sync with each other — pick one as your main log, or move data between them with backup files.":""; function renderData(m){ m.insertAdjacentHTML("beforeend",""+(artifactNS? "Data is saved into this page itself on claude.ai — it follows the link across your devices, and your PT sees the same data on the shared link (they need edit access and a claude.ai sign-in to enter macros).": (sbEnabled()? "With cloud sync. Once you sign in below, your log is kept in your own cloud account and is the same on every device you sign in on. A copy is also kept on this device so the app opens and saves offline — changes made offline sync when you're back online. Anyone who can unlock this device can open that copy: use your device lock, avoid shared devices, and download a backup after each PT review."+DATA_CLAUDE_NOTE: IS_LOCAL? "Your log is stored only on this device. Anyone who can unlock this device can open it. Use your device lock, avoid shared devices, and download a backup after each PT review.
It stays in this browser when you close it or restart the device, but clearing this browser's site data erases it, and it does not follow you to another browser or device. A backup restores everything."+DATA_CLAUDE_NOTE: "This view cannot save to the page — data is kept in this browser only. Make regular backups below."))+ "
"+(homeScreenTipDue()?"On an iPhone or iPad, add Takt to your Home Screen (Share, then Add to Home Screen). Safari can remove a website's data after about a week without a visit; from the Home Screen it keeps it.
":"")+ ""+nDays+" day(s) stored · entries older than ~1 year are pruned automatically."+(IS_LOCAL?"":" A copy is also cached in this browser.")+"
"; // v66f: sign-in and the coach link first (docs/ui-review.md), then storage and the rest. if(sbEnabled()) m.appendChild(sbCard()); if(sbSignedIn()&&acctLogSyncOk()) m.appendChild(coachLinkCard()); // v53 m.appendChild(info); m.appendChild(privacyCard()); // v64a m.appendChild(obDataCard()); // v65a: the starting record (onboard.js) m.appendChild(snapCard()); // v65b: progress snapshots (progress.js) if(obUI.preview) m.appendChild(obPreviewCard()); if(sbSignedIn()) m.appendChild(hsCard()); if(sbSignedIn()) m.appendChild(sbSnapshotsCard()); m.appendChild(personalCard()); const ex=document.createElement("div"); ex.className="card"; ex.innerHTML="Pick a backup file, or paste a backup below. Restoring replaces all current data.
'; // v66i: the file box is shown as an ordinary button (the real input stays in the page, focusable) const fl=document.createElement("label"); fl.className="btn ghost filebtn"; const fi=document.createElement("input"); fi.type="file"; fi.accept=".json,application/json"; fi.id="restoreFile"; fi.className="sr"; const fn=document.createElement("span"); fn.className="hint"; fn.id="restoreFileName"; fn.textContent="No file chosen"; fl.append(fi,document.createTextNode("Choose backup file…")); const fr=document.createElement("div"); fr.className="row"; fr.append(fl,fn); const ta=document.createElement("textarea"); ta.placeholder='{"version":1,…}'; ta.setAttribute("aria-label","Backup to restore"); const rb=document.createElement("button"); rb.className="btn danger small"; rb.textContent="Restore (replace all data)"; fi.onchange=()=>{ const f=fi.files[0]; fn.textContent=f?f.name:"No file chosen"; if(!f) return; if(importTooBig(f.size)){ alert(IMPORT_TOO_BIG_MSG); return; } f.text().then(t=>ta.value=t); }; rb.onclick=()=>{ if(importTooBig(ta.value.length)){ alert(IMPORT_TOO_BIG_MSG); return; } let obj; try{ obj=JSON.parse(ta.value); }catch(e){ alert("That isn't valid JSON."); return; } // typeof null==="object" in JS, so the old check here (typeof obj.days!=="object") let // {"version":1,"days":null} through — ensureTemplate() then crashed uncaught reading a // property off null, bricking the live session from the one UI path meant for disaster // recovery. Require days to actually be a (non-array) object. if(!obj||obj.version!==1||!obj.days||typeof obj.days!=="object"||Array.isArray(obj.days)){ alert("Not a Takt backup (version 1 expected)."); return; } if(!confirm("Replace ALL current data with this backup?")) return; const prev=S; try{ S=normalizeState(obj); ensureTemplate(); ensureMedPlan(); migrateMedKeys(); ensurePtTracking(); }catch(e){ // Something in the file was still malformed enough to crash setup — roll back rather than // leave S half-replaced with a broken store and no way to recover without a reload. S=prev; alert("That file doesn't look like a valid Takt backup — nothing was changed."); return; } save(); render(); }; ib.append(fr,ta,rb); imp.appendChild(ib); ex.appendChild(imp); const backupsOff=phoneBackupsOff(); // v65d: iPhone/iPad with cloud sync — no backup section (link at the bottom) if(!backupsOff) m.appendChild(ex); if(eraseCardDue()) m.appendChild(eraseCard()); // v66i (data review 1) // Automatic local backup folder (File System Access API) — the user's request: point backups at a // real folder on disk their Google Drive desktop app already syncs, so they reach Drive with zero // manual step, instead of relying on them remembering to move each download over themselves. const fsc=document.createElement("div"); fsc.className="card"; fsc.innerHTML="Not available in this browser — needs Chrome or Edge on a desktop computer. Backups will still download as regular files here.
"); } if(!backupsOff&&(canFSAccess||!((navigator.maxTouchPoints||0)>0||isPhone()))) m.appendChild(fsc); // v66i: not on phones // My Foods (v66g: every row editable; "Also save to My Foods" on Add food adds to it too) const cf=document.createElement("div"); cf.className="card"; cf.id="myFoodsCard"; cf.dataset.sec="nut"; cf.innerHTML="The built-in food list holds ~130 common foods (UK staples included) with approximate per-100 g values compiled from standard nutrition references, so treat every figure as an estimate. Your PT verifies weekly figures in Week & PT. Public databases for reference: the UK Composition of Foods Integrated Dataset (CoFID), USDA FoodData Central and Open Food Facts.
'; const MF=[["kcal","kcal /100 g"],["p","Protein g"],["c","Carbs g"],["f","Fat g"]]; const list=document.createElement("div"); list.className="myfoods"; if(!(S.customFoods||[]).length){ const e=document.createElement("p"); e.className="hint"; e.id="myFoodsEmpty"; e.textContent="No foods yet."; list.appendChild(e); } (S.customFoods||[]).forEach((f,ix)=>{ const r2=document.createElement("div"); r2.className="myfood"; r2.dataset.ix=ix; const nI=document.createElement("input"); nI.value=f.n; nI.maxLength=120; nI.className="mfname"; nI.setAttribute("aria-label","Name of my food "+(ix+1)); nI.onchange=()=>{ const v=nI.value.trim(); if(v){ f.n=v.slice(0,120); save(); } render(); }; const nums=document.createElement("div"); nums.className="mfnums"; MF.forEach(([k,lbl])=>{ const l=document.createElement("label"); l.className="mfnum"; const sp=document.createElement("span"); sp.className="l"; sp.textContent=lbl; const i=document.createElement("input"); i.type="number"; i.min="0"; i.step="0.1"; i.inputMode="decimal"; i.value=f[k]; i.dataset.f=k; i.onchange=()=>{ const v=+i.value; if(i.value!==""&&Number.isFinite(v)&&v>=0){ f[k]=r1(v); save(); } render(); }; l.append(sp,i); nums.appendChild(l); }); const x=document.createElement("button"); x.className="xbtn"; x.textContent="✕"; x.setAttribute("aria-label","Remove "+f.n+" from My Foods"); x.onclick=()=>{ S.customFoods.splice(ix,1); save(); render(); }; r2.append(nI,nums,x); list.appendChild(r2); }); cf.appendChild(list); const ar=document.createElement("div"); ar.className="row myfoodadd"; ar.style.marginTop="6px"; ar.innerHTML=''+ ''+ ''+ ''+ ''; const ab=document.createElement("button"); ab.className="btn small"; ab.textContent="Add food"; ab.onclick=()=>{ const v=q=>ar.querySelector('[data-f="'+q+'"]').value; if(!v("n")) return; if(!myFoodsAdd({n:v("n"),kcal:v("kcal"),p:v("p"),c:v("c"),f:v("f")})){ alert("“"+v("n").trim()+"” is already in My Foods — change it in the list above."); return; } save(); render(); }; ar.appendChild(ab); cf.appendChild(ar); m.appendChild(cf); const sh=document.createElement("div"); sh.className="card"; sh.id="ptWorkCard"; // v66d: with cloud sync the coach link is the way to work with a PT; the review file is an optional extra. sh.innerHTML="Your coach on Takt. Link to your coach on the Coach card above, with the invite code they give you. They then see your log in Takt, within what you choose to share, can set your daily targets and can correct the figures of a food you logged. There is nothing to send.
"+ "Optional: a PT review file. On Week & PT you can still download a weekly PT review file (HTML) and email it, for example to a PT who isn't on Takt. It holds only what you tick on that card, and they can send back a small return file for you to import there.
"+ "A file you email stays with the person you sent it to. If you withdraw your consent, we ask your coach to delete any PT review files you sent them; ask anyone else you sent one to as well.
": IS_LOCAL? "1. Each week, open Week & PT and download the PT review file (HTML) — email it to your PT.
"+
"2. They open it in any browser, verify/adjust the macros, set targets, comment, save a PDF, and send you back the small return file.
"+
"3. Import the return file in Week & PT — their verified figures, targets, comments and review stamp are stored.
1. Sharing this page's link with your PT (edit access) is optional — your data syncs here either way; it just lets them see your live log directly.
"+
"2. Each week, open Week & PT and download the PT review file (HTML) — send it to your PT. They open it in any browser, verify or correct each meal's macros, set next week's targets, comment, save a PDF, and send back a small return file.
"+
"3. Import that return file in Week & PT — their verified meal corrections, targets, comments and review stamp are stored automatically. The auto-generated weekly summary can also be copied into email or WhatsApp if they prefer reading it there.