-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplusfive.html
More file actions
243 lines (242 loc) · 33.5 KB
/
Copy pathplusfive.html
File metadata and controls
243 lines (242 loc) · 33.5 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
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>PlusFive - Joseph B. Smith</title><meta name="description" content="A private, offline logbook for the Starting Strength novice progression. Everything is stored on your device; nothing is uploaded."><link rel="canonical" href="https://josephbsmith.com/plusfive"><link rel="stylesheet" href="/tool.css"><link rel="manifest" href="/plusfive.webmanifest"><link rel="apple-touch-icon" href="/plusfive-icon.png"><meta name="theme-color" media="(prefers-color-scheme: light)" content="#0796c2"><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1b3d55"><style>
/* Light blue-book palette with system typography and restrained app chrome. */
:root{color-scheme:light;--bg:#b9e4f1;--fg:#142e3e;--muted:#567889;--rule:#8ccddd;--panel:#cfeef6;--client:#eaf7fb;--accent:#0796c2;--accent-dark:#066f91;--ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
body{font:14px/1.45 var(--ui);padding:4vh 16px 8vh}
main{max-width:700px}
.window{background:var(--client);border:1px solid rgba(7,111,145,.18);border-radius:14px;box-shadow:0 16px 48px rgba(20,67,87,.22);overflow:hidden}
.titlebar{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding:20px 18px 14px}
.apptitle{grid-column:2;font:800 26px/1 var(--ui);letter-spacing:-.055em;color:var(--fg)}
.apptitle .five{color:var(--accent)}
#install{grid-column:3;justify-self:end}
.tabs{display:flex;gap:2px;padding:0 10px;border-bottom:1px solid var(--rule)}
.tabs button{background:none;border:none;border-radius:0;color:var(--fg);font:650 14px var(--ui);padding:10px 14px 12px;border-bottom:3px solid var(--accent);margin-bottom:-1px;cursor:pointer}
.tabs button.secondary{color:var(--muted);border-bottom-color:transparent;font-weight:600}
.tabs button.secondary:hover{color:var(--fg)}
.clientarea{padding:18px 18px 20px}
section h2{display:none}
.toolbar{display:flex;align-items:center;gap:.7rem;margin-bottom:14px;flex-wrap:wrap}
.toolbar.timerbar{display:grid;grid-template-columns:108px 72px 108px;justify-content:center;gap:10px;margin-bottom:16px}
.timerbar button{width:108px}
.timerbar .timer{margin:0;text-align:center;font-variant-numeric:tabular-nums}
#resetTimer:disabled{visibility:hidden}
input,select,textarea{background:#fff;color:var(--fg);border:1px solid var(--rule);border-radius:8px;font:15px var(--ui);padding:.6rem .7rem;margin:.2rem 0 .8rem}
input:focus,select:focus{outline:2px solid var(--accent);outline-offset:1px}
label{font:13px var(--ui);color:var(--muted);letter-spacing:0;text-transform:none}
::placeholder{color:#7794a2}
button{background:var(--accent);border:none;border-radius:8px;color:#fff;font:650 14px var(--ui);padding:10px 16px;cursor:pointer}
button:hover{background:var(--accent-dark)}
button:disabled{cursor:default;opacity:.45}
button#complete,#gateYes{box-shadow:inset 0 0 0 1px var(--accent-dark)}
button.danger{background:transparent;color:#8a3f42;box-shadow:inset 0 0 0 1px #c98f91}
button.danger:hover{background:rgba(138,63,66,.08)}
.panel,.groupbox{background:var(--panel);border:none;border-radius:12px;margin:14px 0;padding:16px 16px 10px;position:relative}
.groupbox>.label{position:static;display:block;background:none;padding:0;font:700 11px var(--ui);letter-spacing:.1em;text-transform:uppercase;color:var(--accent);margin:0 0 .15rem}
.exercise h3{margin:.05rem 0 .5rem;font:750 19px var(--ui);color:var(--fg)}
.exercise-head{display:flex;align-items:baseline;justify-content:space-between;gap:1rem}
.prescription{font:700 12px var(--ui);color:var(--accent)}
.plate{color:var(--muted);font-size:13px;margin:.15rem 0 .7rem}
.stepper{display:grid;grid-template-columns:52px minmax(90px,1fr) 52px;gap:8px;align-items:center;margin:.55rem 0 .7rem}
.stepper button{font-size:21px;padding:7px;min-height:42px}
.stepper output{text-align:center;font:700 19px ui-monospace,Menlo,monospace;color:var(--fg)}
.choice{display:flex;gap:8px;margin:.45rem 0 1rem}
.choice button{flex:1}
.choice button.secondary{background:transparent;color:var(--fg);box-shadow:inset 0 0 0 1px var(--rule)}
.scheme{width:100%;font-size:13px;margin:.2rem 0 .6rem;border-collapse:collapse;background:rgba(255,255,255,.48);border-radius:8px;overflow:hidden}
.scheme th{background:none;color:var(--muted);font:650 10.5px var(--ui);letter-spacing:.07em;text-transform:uppercase;padding:.55rem .65rem;border-bottom:1px solid var(--rule);text-align:left}
.scheme td{padding:.5rem .65rem;border-bottom:1px solid rgba(140,205,221,.7);text-align:left}
.scheme tr:last-child td{border-bottom:none}
.scheme .work td{color:var(--accent-dark);font-weight:750;background:rgba(7,150,194,.09)}
.donerow{display:flex;gap:.65rem;align-items:center;font:600 14px var(--ui);color:var(--fg);margin:.55rem 0 .6rem;cursor:pointer}
.donerow input{width:22px;height:22px;margin:0;accent-color:var(--accent);flex:none;border-radius:6px}
.actionrow{display:flex;align-items:center;gap:1rem;margin-top:14px}
.actionrow .status{flex:1}
.status{font:600 12.5px var(--ui);color:var(--accent)}
.timer{font:750 17px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--accent-dark)}
table{font-size:13px;width:100%;border-collapse:collapse}
th{background:none;color:var(--muted);font:650 11px var(--ui);letter-spacing:.06em;text-transform:uppercase;border-bottom:1px solid var(--rule);padding:.5rem .6rem;text-align:left}
td{border-bottom:1px solid rgba(140,205,221,.7);padding:.5rem .6rem;text-align:left;vertical-align:top}
details.run{border:1px solid var(--rule);border-radius:10px;margin:.8rem 0;overflow:hidden}
details.run summary{cursor:pointer;padding:.6rem .8rem;background:rgba(255,255,255,.45);font:650 13px var(--ui);color:var(--fg);list-style-position:inside}
details.run .scroll{padding:0 .5rem}
.settings-grid{display:grid;grid-template-columns:1.4fr 2fr;gap:8px;align-items:center}
.settings-grid .stepper{margin:.2rem 0}
.settings-grid .label{padding-bottom:.6rem;font:700 11px var(--ui);letter-spacing:.08em;text-transform:uppercase;color:var(--accent)}
.statusbar{display:flex;gap:1.1rem;padding:10px 18px;background:rgba(7,150,194,.08);font:12px var(--ui);color:var(--muted);flex-wrap:wrap}
.statusbar span{padding:0;border:none;box-shadow:none}
.statusbar #session{color:var(--accent);font-weight:600}
.fine{font-size:11.5px;color:#527285;line-height:1.55}
.boundary{margin:14px 4px 0;max-width:none}
.grid{grid-template-columns:repeat(auto-fit,minmax(160px,1fr))}
#gate{background:var(--client);color:var(--fg);border:1px solid var(--rule);border-radius:14px;box-shadow:0 20px 70px rgba(20,67,87,.35);padding:0;max-width:min(92vw,360px)}
#gate::backdrop{background:rgba(20,57,74,.55)}
.gatetitle{padding:20px 20px 0;background:none;border:none;font:750 20px var(--ui);color:var(--fg)}
.gatebody{padding:10px 20px 20px}
.gatebody p{margin:0 0 16px;font-size:14.5px;line-height:1.55}
.gatebtns{display:flex;gap:10px}
.gatebtns button{flex:1;min-height:46px;font-size:15px}
#gateMsg{font:700 16px var(--ui);color:var(--accent-dark);margin:0 0 16px}
.video{aspect-ratio:16/9;width:100%;border:none;border-radius:10px;overflow:hidden;margin:.2rem 0 1rem;background:#91d3e6}
.video iframe{width:100%;height:100%;border:0;display:block}
.vlabel{font:700 11px var(--ui);letter-spacing:.09em;text-transform:uppercase;color:var(--muted);margin:.9rem 0 .3rem}
#guide h3{font:700 12px var(--ui);letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin:1.5rem 0 .3rem}
#guide h3:first-of-type{margin-top:.2rem}
#guide p{margin:.3rem 0 .6rem;font-size:14px;line-height:1.65;color:var(--fg)}
#guide ol{margin:.4rem 0 .8rem;padding-left:1.4rem;font-size:14px;line-height:1.9}
#guide ol b{color:var(--accent)}
#printout{display:none}
[hidden]{display:none!important}
@media(max-width:600px){
body{padding:0}
main{max-width:none}
.window{border:none;border-radius:0;box-shadow:none;min-height:100dvh;display:flex;flex-direction:column}
.titlebar{padding:calc(12px + env(safe-area-inset-top)) 16px 8px}
.apptitle{font-size:24px}
.tabs{position:sticky;top:0;z-index:3;background:var(--client);padding:0 6px}
.tabs button{flex:1;padding:12px 0 13px;font-size:14.5px;text-align:center}
.clientarea{flex:1;padding:16px 14px 8px}
input,select,textarea{font-size:16px;padding:.7rem .75rem}
button{min-height:44px;font-size:15px}
.exercise h3{font-size:20px}
.exercise{padding:15px 14px 8px}
.plate{font-size:14px}
.timer{font-size:19px}
.status{font-size:13px}
.actionrow{position:sticky;bottom:0;z-index:2;flex-wrap:wrap;margin:14px -14px 0;padding:12px 14px calc(12px + env(safe-area-inset-bottom)/2);background:linear-gradient(rgba(234,247,251,0),var(--client) 35%)}
.actionrow button{width:100%;min-height:50px;font-size:16px}
#settings .actionrow,#history .toolbar{position:static;margin:14px 0 0;padding:0;background:none}
#settings .actionrow button,#history .toolbar button{width:auto;min-height:44px}
.settings-grid{grid-template-columns:1.1fr 2fr}
.statusbar{padding-bottom:calc(10px + env(safe-area-inset-bottom))}
.boundary{margin:14px 16px 20px}
}
@media(prefers-color-scheme:dark){
:root{color-scheme:dark;--bg:#132c3d;--fg:#eff7fa;--muted:#aec4ce;--rule:#466d83;--panel:#234d67;--client:#1b3d55;--accent:#6fcbe9;--accent-dark:#b2e4f3}
.window{border-color:rgba(111,203,233,.18);box-shadow:0 16px 48px rgba(2,13,21,.45)}
input,select,textarea{background:#112b3c;color:var(--fg);border-color:var(--rule)}
::placeholder{color:#88a7b5}
button{background:#147fa8;color:#fff}
button:hover{background:#0f6d90}
button#complete,#gateYes{box-shadow:inset 0 0 0 1px #0b5d7d}
button.danger{color:#efb2b4;box-shadow:inset 0 0 0 1px #9c6669}
button.danger:hover{background:rgba(239,178,180,.08)}
.scheme{background:rgba(5,25,37,.24)}
.scheme td,td{border-bottom-color:rgba(70,109,131,.7)}
.scheme .work td{background:rgba(111,203,233,.08)}
details.run summary{background:rgba(5,25,37,.22)}
.statusbar{background:rgba(5,25,37,.18)}
.fine{color:#a7bfca}
#gate{box-shadow:0 20px 70px rgba(2,13,21,.65)}
#gate::backdrop{background:rgba(3,14,22,.72)}
.video{background:#163b52}
.actionrow{background:linear-gradient(rgba(27,61,85,0),var(--client) 35%)}
}
@media print{body>main>*{display:none!important}#printout{display:block!important}body{background:#fff;color:#000;padding:0;font:12px Georgia,serif}#printout h1{font:700 20px Georgia,serif;margin:0 0 2px}#printout .sub{color:#444;margin:0 0 14px;font-size:11px}#printout h2{font:700 14px Georgia,serif;margin:16px 0 6px;display:block}#printout table{width:100%;border-collapse:collapse;font-size:11px}#printout th,#printout td{border:1px solid #999;padding:4px 6px;text-align:left;color:#000;background:#fff}}
</style></head><body><main>
<div class="window">
<div class="titlebar"><span class="apptitle"><span>Plus</span><span class="five">Five</span></span><button id="install" hidden>Install</button></div>
<nav class="tabs" aria-label="PlusFive sections"><button data-tab="workout">Workout</button><button class="secondary" data-tab="history">History</button><button class="secondary" data-tab="settings">Settings</button><button class="secondary" data-tab="guide">Guide</button></nav>
<div class="clientarea">
<section id="workout"><div class="toolbar timerbar" aria-label="Rest timer"><button id="startTimer">Start rest</button><span id="timer" class="timer" aria-live="polite">3:00</span><button id="resetTimer" disabled>Reset rest</button></div><div id="exercises"></div><div class="actionrow"><p id="feedback" class="status" aria-live="polite"></p><button id="complete">Complete workout</button></div></section>
<section id="history" hidden><h2>Workout history</h2><div class="toolbar"><label style="margin:0">Range <select id="range" style="margin:0;width:auto"><option value="all">All time</option><option value="month">This month</option><option value="year">This year</option></select></label><button id="export">Export CSV</button><button id="exportPdf">Export PDF</button></div><div class="scroll"><table><thead><tr><th>Date</th><th>Phase</th><th>Session</th><th>Work</th></tr></thead><tbody id="historyRows"></tbody></table></div><div id="archiveList"></div></section>
<section id="settings" hidden><h2>Program settings</h2>
<div class="groupbox"><span class="label">Program</span><label>Phase<select id="phase"><option value="1">Phase 1</option><option value="2">Phase 2</option><option value="3">Phase 3</option></select></label><span class="label">Weight change</span><div class="choice"><button data-step="5">5 lb</button><button data-step="2.5">2.5 lb</button></div><div class="settings-grid"><span>Bar</span><div id="barSetting"></div><div id="liftSettings" style="display:contents"></div></div><p id="settingsStatus" class="status"></p><p class="fine">The selected weight change applies to every +/− control and to the next workout after completed reps.</p></div>
<div class="groupbox"><span class="label">Data</span>
<p class="fine" style="margin-top:.3rem">Every completed workout and settings change is saved on this device automatically, with the two previous saves kept as fallback copies. Download a backup file periodically; it is the only copy that survives clearing this browser's site data.</p>
<div class="actionrow" style="margin-top:6px;flex-wrap:wrap"><button id="backup">Download backup</button><button id="restore">Restore backup</button><input id="restoreFile" type="file" accept="application/json" hidden><button id="resetRun" class="danger">Start a new run</button></div>
<p class="fine">Start a new run after missed weeks: the current run is archived under Previous runs in History, the session counter returns to workout 1, and next loads stay as they are so you can lower them in the table above before saving.</p></div>
</section>
<section id="guide" hidden><h2>Guide</h2>
<h3>Add to your iPhone home screen</h3>
<p>PlusFive works best installed. Installed, it opens full screen like any app and works in the gym with no connection.</p>
<ol>
<li>Open <b>josephbsmith.com/plusfive</b> in Safari.</li>
<li>Tap the <b>Share</b> button, the square with the arrow pointing up, at the bottom of the screen.</li>
<li>Scroll down the share sheet and tap <b>Add to Home Screen</b>.</li>
<li>Tap <b>Add</b> in the top right corner.</li>
</ol>
<p>The PlusFive barbell icon appears on your home screen; open it from there from now on. On Android and desktop browsers, use the Install button in the title bar when it appears.</p>
<h3>The program</h3>
<p>PlusFive records the Starting Strength novice linear progression. Train three non-consecutive days a week. Every session starts with the squat. The press and bench press alternate by session. The pull alternates by phase: phase 1 is deadlift every session, phase 2 alternates deadlift and power clean, phase 3 rotates deadlift, chin-ups, power clean, chin-ups. You choose the phase in Settings; the app does not decide when you move.</p>
<h3>Official Starting Strength resources</h3>
<p>Learn the method from its makers in <a href="https://aasgaardco.com/store/books-posters-dvd/books/starting-strength-basic-barbell-training/" rel="noopener">Starting Strength: Basic Barbell Training, 3rd edition</a>, the “Blue Book,” and listen to <a href="https://startingstrength.com/radio" rel="noopener">Starting Strength Radio</a>. These links go directly to The Aasgaard Company and Starting Strength. They are not affiliate links, and I receive no payment or commission from visits or purchases.</p>
<h3>Learning the lifts</h3>
<p>The book teaches the lifts; these are the official Starting Strength instruction videos for each one. They stream from YouTube and need a connection.</p>
<p class="vlabel">Squat</p><div class="video"><iframe src="https://www.youtube-nocookie.com/embed/nhoikoUEI8U" title="Learning to Squat, The Starting Strength Method" loading="lazy" allowfullscreen></iframe></div>
<p class="vlabel">Press</p><div class="video"><iframe src="https://www.youtube-nocookie.com/embed/8dacy5hjaE8" title="Learning to Press, The Starting Strength Method" loading="lazy" allowfullscreen></iframe></div>
<p class="vlabel">Bench press</p><div class="video"><iframe src="https://www.youtube-nocookie.com/embed/rxD321l2svE" title="Learning to Bench Press, The Starting Strength Method" loading="lazy" allowfullscreen></iframe></div>
<p class="vlabel">Deadlift</p><div class="video"><iframe src="https://www.youtube-nocookie.com/embed/p2OPUi4xGrM" title="Learning to Deadlift, The Starting Strength Method" loading="lazy" allowfullscreen></iframe></div>
<p class="vlabel">Power clean</p><div class="video"><iframe src="https://www.youtube-nocookie.com/embed/37-wjE_c4NU" title="Power Clean Series Part 1, Learning the Three Positions" loading="lazy" allowfullscreen></iframe></div>
<h3>Logging a workout</h3>
<p>The Workout tab shows today's three lifts. Adjust a work weight with its − and + buttons; the step is 5 lb or 2.5 lb, chosen in Settings. Under it the app lays out the whole session, warm-up sets included, with the plates to put on each side of the bar for every set. Do the sets, tick the box for each lift when you have completed every prescribed rep; leave it unticked if you missed, and press Complete workout. The rest timer runs three minutes and vibrates where the device allows it.</p>
<h3>Warm-ups</h3>
<p>Warm-ups follow the book and are not editable. Squat, press, bench press, and power clean start with two sets of five with the empty bar, then even jumps up to the work weight with reps tapering five, three, two. The deadlift never starts from the empty bar: it warms up loaded, starting at 65 lb under a 135 pull, 95 lb under 225, and 135 lb above that, with the same taper. Jumps are rounded to five-pound plates. Change the work weight and the warm-ups recalculate.</p>
<h3>How loads progress</h3>
<p>Complete every prescribed rep and the lift goes up by the selected 5 lb or 2.5 lb step next time it appears. Miss reps and the load repeats. Chin-ups carry no load and no increment.</p>
<h3>How your data is saved</h3>
<p>Everything saves to this device the moment you press Complete workout or change a setting; the status bar shows the time of the last save. The two saves before it are kept automatically as fallback copies, and if the current save is ever unreadable the app restores the newest good copy on its own. PlusFive does not upload workout records anywhere.</p>
<h3>Backups</h3>
<p>Download backup, in Settings, writes a single file containing every run, setting, and workout. Restore backup reads that file back and replaces what is on the device. Site data on a phone or browser can be cleared by the system; a downloaded backup is the copy you control.</p>
<h3>Missed a week or more</h3>
<p>Use Start a new run in Settings. Your finished run is preserved under Previous runs in the History tab, the session counter starts over, and your next loads remain visible in Settings so you can set lower restart weights before the first workout back. What to restart with is your call, not the app's.</p>
<h3>Exporting</h3>
<p>In History, pick a range (all time, this month, this year) and export. CSV opens in any spreadsheet and includes previous runs, marked by run. PDF prepares a printable log and opens the device's print dialog; choose Save as PDF there. Month and year ranges make a clean monthly or annual training record.</p>
</section>
</div>
<div class="statusbar"><span id="session"></span><span id="saved"></span><span>Workout records stay on this device</span></div>
</div>
<dialog id="gate"><div class="gatetitle">Before you lift</div><div class="gatebody"><p id="gateQ">In the last 48 hours, have you eaten enough and have you gotten enough sleep?</p><p id="gateMsg" hidden>Go home, eat some eggs and take a nap.</p><div class="gatebtns"><button id="gateYes">Yes</button><button id="gateNo">No</button><button id="gateDone" hidden>Done</button></div></div></dialog>
<p class="fine boundary"><strong>Independent, noncommercial notice.</strong> PlusFive is an unofficial, unaffiliated logbook. I receive no payment, profit, commission, affiliate revenue, personal data, workout data, or analytics from its use. The app has no accounts, advertising, analytics, or server-side workout storage; workout records remain in this browser unless you export them. Third-party links and videos are provided for attribution and instruction and are governed by their own services. PlusFive does not teach lifting technique, decide phase transitions, diagnose missed reps, or provide medical or nutrition advice. <a href="https://github.com/josephbsmith/josephbsmith.com/blob/main/plusfive.html" rel="noopener">Source on GitHub</a>.</p>
<div id="printout"></div>
</main><script>
const KEY='plusfive-v1',LIFTS={squat:['Squat',[5,5,5],45],press:['Press',[5,5,5],45],bench:['Bench Press',[5,5,5],45],deadlift:['Deadlift',[5],95],power_clean:['Power Clean',[3,3,3,3,3],45],chinups:['Chin-ups',[],0]};
const defaults=()=>({phase:1,session:0,step:5,bar:45,plates:'45,25,10,5,2.5,1.25',lifts:Object.fromEntries(Object.entries(LIFTS).map(([k,v])=>[k,{weight:v[2]}])),history:[],archives:[],savedAt:null});
function loadState(){for(const k of [KEY,KEY+'-backup1',KEY+'-backup2']){try{const s=JSON.parse(localStorage.getItem(k));if(s&&s.lifts){s.archives=s.archives||[];s.step=s.step||5;return s}}catch{}}return defaults()}
let state=loadState();
const $=q=>document.querySelector(q),esc=v=>String(v).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function drawSaved(){$('#saved').textContent=state.savedAt?'Saved '+new Date(state.savedAt).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}):'Not saved yet'}
function saveState(){try{const prev=localStorage.getItem(KEY);if(prev){const b1=localStorage.getItem(KEY+'-backup1');if(b1)localStorage.setItem(KEY+'-backup2',b1);localStorage.setItem(KEY+'-backup1',prev)}state.savedAt=new Date().toISOString();localStorage.setItem(KEY,JSON.stringify(state));drawSaved()}catch(e){$('#feedback').textContent='Saving failed: '+e.message}}
function nextLifts(){const press=state.session%2?'bench':'press';let pull='deadlift';if(state.phase===2)pull=state.session%2?'power_clean':'deadlift';if(state.phase===3)pull=['deadlift','chinups','power_clean','chinups'][state.session%4];return['squat',press,pull]}
function platesFor(total){let remaining=Math.max(0,(total-state.bar)/2),out=[];for(const p of state.plates.split(',').map(Number).filter(x=>x>0).sort((a,b)=>b-a)){while(remaining+.0001>=p){out.push(p);remaining-=p}}return{out,remainder:remaining}}
function sideText(w){const loaded=platesFor(w);if(!loaded.out.length)return 'empty bar';const counts=new Map();for(const p of loaded.out)counts.set(p,(counts.get(p)||0)+1);const words=['','one','two','three','four','five','six','seven','eight','nine'];const parts=[...counts.entries()].map(([p,n])=>`${words[n]||n} ${p} lb plate${n>1?'s':''}`);let t=parts.join(' + ');if(loaded.remainder>.001)t+=` (${loaded.remainder.toFixed(2)} lb short)`;return t}
function evenPts(lo,hi,n){const step=(hi-lo)/(n+1),out=[];for(let k=1;k<=n;k++){const w=Math.round((lo+step*k)/5)*5;if(w>lo+2.4&&w<hi-2.4&&!out.includes(w))out.push(w)}return out}
function schemeFor(lift,work){
// The book's warm-up: 2x5 with the empty bar, then even jumps to the work set with reps tapering 5, 3, 2.
// Deadlifts start loaded, never from the empty bar: 65 under a 135 pull, 95 under 225, 135 above that.
work=+work;const rows=[],reps=[5,3,2];
if(lift==='chinups'||!(work>0))return rows;
if(lift==='deadlift'){
let start=work>=225?135:work>=135?95:65;
if(start>=work-4)return rows;
const list=[start,...evenPts(start,work,2)];
list.forEach((w,i)=>rows.push({w,r:reps[Math.min(i,2)],sets:1}));
}else{
rows.push({w:state.bar,r:5,sets:2});
evenPts(state.bar,work,3).forEach((w,i)=>rows.push({w,r:reps[Math.min(i,2)],sets:1}));
}
return rows}
function warmupText(lift,work){return schemeFor(lift,work).map(r=>`${r.w}×${r.r}${r.sets>1?'×'+r.sets:''}`).join(', ')}
function schemeTable(lift,work){const [name,reps]=LIFTS[lift];if(lift==='chinups')return '';const rows=schemeFor(lift,work);const body=rows.map(r=>`<tr><td>Warm-up · ${r.r} reps${r.sets>1?' × '+r.sets+' sets':''}</td><td>${r.w} lb</td><td>${esc(sideText(r.w))}</td></tr>`).join('');const work_r=`<tr class=work><td>Work · ${reps.length} set${reps.length>1?'s':''} of ${reps[0]}</td><td>${work} lb</td><td>${esc(sideText(+work))}</td></tr>`;return `<table class=scheme><thead><tr><th>Sets</th><th>Weight</th><th>Plates each side</th></tr></thead><tbody>${body}${work_r}</tbody></table>`}
function stepper(value,kind,lift=''){return`<div class=stepper data-kind=${kind}${lift?` data-lift=${lift}`:''} data-value=${value}><button data-delta=-1 aria-label="Decrease by ${state.step} pounds">−</button><output>${value} lb</output><button data-delta=1 aria-label="Increase by ${state.step} pounds">+</button></div>`}
function renderWorkout(){$('#session').textContent=`Phase ${state.phase} · session ${state.session+1}`;drawSaved();const root=$('#exercises');root.innerHTML='';for(const lift of nextLifts()){const [name,reps]=LIFTS[lift],s=state.lifts[lift],d=document.createElement('fieldset');d.className='panel exercise';d.dataset.lift=lift;const prescription=reps.length?`${reps.length} × ${reps[0]}`:'3 sets to fatigue';d.innerHTML=`<div class=exercise-head><h3>${esc(name)}</h3><span class=prescription>${prescription}</span></div>${lift==='chinups'?'<p class=plate>Bodyweight · no bar or warm-up sets.</p>':`${stepper(s.weight,'workout',lift)}<div class=schemebox>${schemeTable(lift,s.weight)}</div>`}<label class=donerow><input type=checkbox class=done>All prescribed reps completed</label>`;root.append(d)}$('#feedback').textContent=''}
function runRows(history){return history.slice().reverse().map(w=>`<tr><td>${esc(new Date(w.date).toLocaleDateString())}</td><td>${w.phase}</td><td>${w.session+1}</td><td>${w.entries.map(e=>`${esc(LIFTS[e.lift][0])} ${e.weight?e.weight+' lb ':''}${e.reps.length?'×'+esc(e.reps.join('/')):e.done?'done':'missed'}`).join('<br>')}</td></tr>`).join('')}
function renderHistory(){$('#historyRows').innerHTML=runRows(state.history)||'<tr><td colspan=4>No workouts recorded in this run yet.</td></tr>';$('#archiveList').innerHTML=state.archives.slice().reverse().map((a,i)=>{const n=state.archives.length-i;return `<details class=run><summary>Previous run ${n} · ended ${esc(new Date(a.archived_at).toLocaleDateString())} · ${a.history.length} workout${a.history.length===1?'':'s'}</summary><div class=scroll><table><thead><tr><th>Date</th><th>Phase</th><th>Session</th><th>Work</th></tr></thead><tbody>${runRows(a.history)||'<tr><td colspan=4>No workouts.</td></tr>'}</tbody></table></div></details>`}).join('')}
function renderSettings(){$('#phase').value=state.phase;$('#settingsStatus').textContent='';document.querySelectorAll('[data-step]').forEach(x=>{const selected=+x.dataset.step===state.step;x.classList.toggle('secondary',!selected);x.setAttribute('aria-pressed',selected)});$('#barSetting').innerHTML=stepper(state.bar,'bar');$('#liftSettings').innerHTML=Object.entries(LIFTS).filter(([k])=>k!=='chinups').map(([k,v])=>`<span>${esc(v[0])}</span>${stepper(state.lifts[k].weight,'setting',k)}`).join('')}
document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{document.querySelectorAll('[data-tab]').forEach(x=>x.classList.toggle('secondary',x!==b));for(const id of ['workout','history','settings','guide'])$('#'+id).hidden=id!==b.dataset.tab;if(b.dataset.tab==='workout')renderWorkout();if(b.dataset.tab==='history')renderHistory();if(b.dataset.tab==='settings')renderSettings()});
$('#complete').onclick=()=>{const entries=[];for(const card of document.querySelectorAll('.exercise')){const lift=card.dataset.lift,expected=LIFTS[lift][1],done=card.querySelector('.done').checked,ticker=card.querySelector('.stepper'),weight=ticker?+ticker.dataset.value:0;entries.push({lift,reps:done?expected.slice():[],weight,warmups:ticker?warmupText(lift,weight):'',done});state.lifts[lift].weight=done&&lift!=='chinups'?weight+state.step:(ticker?weight:state.lifts[lift].weight)}state.history.push({date:new Date().toISOString(),phase:state.phase,session:state.session,entries});state.session++;saveState();renderWorkout();$('#feedback').textContent='Saved on this device at '+new Date(state.savedAt).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'})+'. Next loads are ready.';scrollTo({top:0,behavior:'smooth'})};
$('#phase').onchange=e=>{state.phase=+e.target.value;saveState()};document.querySelectorAll('[data-step]').forEach(x=>x.onclick=()=>{state.step=+x.dataset.step;saveState();renderSettings()});
document.addEventListener('click',e=>{const button=e.target.closest('[data-delta]');if(!button)return;const ticker=button.closest('.stepper'),lift=ticker.dataset.lift,min=ticker.dataset.kind==='bar'?state.step:lift==='chinups'?0:state.bar,value=Math.max(min,+ticker.dataset.value+(+button.dataset.delta*state.step));ticker.dataset.value=value;ticker.querySelector('output').textContent=`${value} lb`;if(ticker.dataset.kind==='workout')ticker.closest('.exercise').querySelector('.schemebox').innerHTML=schemeTable(lift,value);if(ticker.dataset.kind==='setting')state.lifts[lift].weight=value;if(ticker.dataset.kind==='bar')state.bar=value;if(ticker.dataset.kind!=='workout')saveState()});
$('#resetRun').onclick=()=>{if(!confirm('Archive this run and start a new one? Your workouts are kept under Previous runs in History.'))return;state.archives.push({archived_at:new Date().toISOString(),phase:state.phase,session:state.session,lifts:JSON.parse(JSON.stringify(state.lifts)),history:state.history});state.history=[];state.session=0;saveState();renderSettings();renderWorkout();$('#settingsStatus').textContent='Run archived. Set your restart weights above before the next workout.'};
$('#backup').onclick=()=>{const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(state,null,1)],{type:'application/json'}));a.download='plusfive-backup-'+new Date().toISOString().slice(0,10)+'.json';a.click();URL.revokeObjectURL(a.href);$('#settingsStatus').textContent='Backup downloaded. Keep it somewhere safe.'};
$('#restore').onclick=()=>$('#restoreFile').click();
$('#restoreFile').onchange=async e=>{const f=e.target.files[0];if(!f)return;try{const s=JSON.parse(await f.text());if(!s||!s.lifts)throw Error('not a PlusFive backup');if(!confirm('Replace the data on this device with this backup?'))return;s.archives=s.archives||[];s.step=s.step||5;state=s;saveState();renderWorkout();renderSettings();$('#settingsStatus').textContent='Backup restored.'}catch(err){alert('Could not restore: '+err.message)}e.target.value=''};
function inRange(dateStr,range){if(range==='all')return true;const d=new Date(dateStr),now=new Date();if(range==='year')return d.getFullYear()===now.getFullYear();return d.getFullYear()===now.getFullYear()&&d.getMonth()===now.getMonth()}
function exportRows(range){const rows=[];const take=(hist,run)=>{for(const w of hist)if(inRange(w.date,range))for(const e of w.entries)rows.push([w.date,w.phase,w.session+1,e.lift,e.weight,e.reps.join('/'),e.warmups,e.done,run])};state.archives.forEach((a,i)=>take(a.history,'run '+(i+1)));take(state.history,'current');return rows}
$('#export').onclick=()=>{const rows=[['date','phase','session','lift','weight','reps','warmups','completed','run'],...exportRows($('#range').value)];const csv=rows.map(r=>r.map(v=>'"'+String(v).replaceAll('"','""')+'"').join(',')).join('\n'),a=document.createElement('a');a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'}));a.download='plusfive-'+$('#range').value+'-'+new Date().toISOString().slice(0,10)+'.csv';a.click();URL.revokeObjectURL(a.href)};
$('#exportPdf').onclick=()=>{const range=$('#range').value,label={all:'All time',month:new Date().toLocaleDateString([],{month:'long',year:'numeric'}),year:String(new Date().getFullYear())}[range];const block=(title,hist)=>{const rows=hist.filter(w=>inRange(w.date,range));if(!rows.length)return '';return `<h2>${esc(title)}</h2><table><thead><tr><th>Date</th><th>Phase</th><th>Session</th><th>Lift</th><th>Weight</th><th>Reps</th><th>Completed</th></tr></thead><tbody>${rows.map(w=>w.entries.map(e=>`<tr><td>${esc(new Date(w.date).toLocaleDateString())}</td><td>${w.phase}</td><td>${w.session+1}</td><td>${esc(LIFTS[e.lift][0])}</td><td>${e.weight}</td><td>${esc(e.reps.join('/'))}</td><td>${e.done?'yes':'no'}</td></tr>`).join('')).join('')}</tbody></table>`};$('#printout').innerHTML=`<h1>PlusFive training log</h1><p class=sub>${esc(label)} · exported ${esc(new Date().toLocaleDateString())} · novice linear progression</p>`+state.archives.map((a,i)=>block('Run '+(i+1)+' (ended '+new Date(a.archived_at).toLocaleDateString()+')',a.history)).join('')+block('Current run',state.history);print()};
let clock=null,tSecs=180;const tDraw=()=>$('#timer').textContent=`${Math.floor(tSecs/60)}:${String(tSecs%60).padStart(2,'0')}`;
function tStop(){clearInterval(clock);clock=null}
$('#startTimer').onclick=()=>{const b=$('#startTimer');if(clock){tStop();b.textContent='Resume rest';return}if(tSecs<=0)tSecs=180;b.textContent='Pause rest';$('#resetTimer').disabled=false;tDraw();clock=setInterval(()=>{tSecs--;tDraw();if(tSecs<=0){tStop();b.textContent='Start rest';navigator.vibrate?.([200,100,200])}},1000)};
$('#resetTimer').onclick=()=>{tStop();tSecs=180;tDraw();$('#startTimer').textContent='Start rest';$('#resetTimer').disabled=true};
let installEvent;window.addEventListener('beforeinstallprompt',e=>{e.preventDefault();installEvent=e;$('#install').hidden=false});$('#install').onclick=async()=>{await installEvent?.prompt();$('#install').hidden=true};if('serviceWorker'in navigator)navigator.serviceWorker.register('/plusfive-sw.js');renderWorkout();
const gate=$('#gate');if(gate&&gate.showModal){gate.addEventListener('cancel',e=>e.preventDefault());gate.showModal();$('#gateYes').onclick=()=>gate.close();$('#gateNo').onclick=()=>{$('#gateQ').hidden=true;$('#gateYes').hidden=true;$('#gateNo').hidden=true;$('#gateMsg').hidden=false;$('#gateDone').hidden=false};$('#gateDone').onclick=()=>{$('#gateMsg').hidden=true;$('#gateDone').hidden=true;$('#gateQ').hidden=false;$('#gateYes').hidden=false;$('#gateNo').hidden=false}}
</script></body></html>