Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
195 lines (182 loc) · 11.9 KB
/
Copy pathapp.js
File metadata and controls
195 lines (182 loc) · 11.9 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
// Hash router. Every page is a function (root, ctx) that may return a cleanup.
import { h, mount } from './src/ui/dom.js';
import { makeStore } from './src/core/store.js';
import { SECTIONS } from './config/sections.js';
import { activatePreset, activePreset, presetSections } from './config/presets.js';
import { pathPage } from './src/ui/pages/path.js';
import { progressPage } from './src/ui/pages/progress.js';
import { settingsPage } from './src/ui/pages/settings.js';
import { aboutPage } from './src/ui/pages/about.js';
import { brandLockup, wordmark } from './src/ui/logo.js';
import { icon } from './src/ui/icons.js';
import { setRail, toast } from './src/ui/sheet.js';
import { creditTick } from './src/core/activity.js';
import { initTheme } from './src/ui/theme.js';
import { createSync } from './src/ui/sync.js';
import { initPwa } from './src/ui/pwa.js';
import { gistConfig, syncNow } from './src/ui/gist.js';
// Pages that carry question generators, games or lessons load when first opened.
const sectionPg = () => import('./src/ui/pages/section.js');
const learnPg = () => import('./src/ui/pages/learn.js');
const zapnPg = () => import('./src/ui/pages/zapn.js');
const mockPg = () => import('./src/ui/pages/mock.js');
const mkPg = () => import('./src/ui/pages/mk.js');
const setsPg = () => import('./src/ui/pages/sets.js');
const runnerPg = () => import('./src/ui/runner.js');
// Study pages carry about 3 MB of lessons, so they load the first time Study opens.
let studyPages;
const study = () => (studyPages ??= import('./src/study/pages.js'));
import { dueLessons, openBeliefs } from './src/study/progress.js';
const sync = createSync();
const store = makeStore(undefined, sync.hooks);
const view = document.getElementById('view');
const nav = document.getElementById('nav');
const tabbar = document.getElementById('tabbar');
let cleanup = null;
activatePreset(store.settings().preset);
initTheme();
mount(document.querySelector('.sidebar .brand'),
h('a', { href: '#/', class: 'brand-link', 'aria-label': 'OptPrep, today' }, brandLockup()));
mount(document.getElementById('sidebar-foot'), h('div', {}, 'Not affiliated with or endorsed by Optiver. ', h('a', { href: '#/about' }, 'About and legal')));
const ROUTES = [
[/^#?\/?$/, () => pathPage(view, { store, sync })],
[/^#\/progress(?:\/(\w+))?$/, (m) => progressPage(view, { store, tab: m[1] })],
[/^#\/settings$/, () => settingsPage(view, { store })],
[/^#\/about$/, () => aboutPage(view)],
[/^#\/s\/(\w+)$/, (m, pg) => pg.sectionPage(view, { store, id: m[1] }), sectionPg],
[/^#\/s\/(\w+)\/sets$/, (m, pg) => pg.setsPage(view, { store, id: m[1] }), setsPg],
[/^#\/s\/(\w+)\/sets\/(\d+)\/(practice|timed)$/, (m, pg) => pg.setRunPage(view, { store, id: m[1], n: +m[2], mode: m[3] }), setsPg],
[/^#\/s\/(\w+)\/learn\/([\w-]+)$/, (m, pg) => pg.learnPage(view, { store, id: m[1], family: m[2] }), learnPg],
[/^#\/run\/(\w+)\/exam(?:\/v(\d+))?$/, (m, pg) => pg.runExam(view, { sectionId: m[1], store, variant: m[2] != null ? SECTIONS[m[1]].variants[+m[2]] : null }), runnerPg],
[/^#\/run\/(\w+)\/(practice|drill|mistakes)(?:\/([\w-]+))?$/, (m, pg) => pg.runFeedbackSession(view, { sectionId: m[1], mode: m[2], family: m[3], store }), runnerPg],
[/^#\/zapn$/, (m, pg) => pg.zapnHub(view, { store }), zapnPg],
[/^#\/zapn\/(\w+)(?:\/(practice|exam))?$/, (m, pg) => pg.zapnGame(view, { store, id: m[1], mode: m[2] || 'practice' }), zapnPg],
[/^#\/study$/, (m, pg) => pg.studyHome(view, { store }), study],
[/^#\/study\/book\/(\w+)$/, (m, pg) => pg.bookPage(view, { store, id: m[1] }), study],
[/^#\/study\/lesson\/(\w+\/[\w-]+)$/, (m, pg) => pg.lessonPage(view, { store, id: m[1] }), study],
[/^#\/study\/lesson\/(\w+\/[\w-]+)\/restore$/, (m, pg) => pg.lessonPage(view, { store, id: m[1], restore: true }), study],
[/^#\/study\/review\/(\w+\/[\w-]+)$/, (m, pg) => pg.reviewPage(view, { store, id: m[1] }), study],
[/^#\/study\/mistakes(?:\/(.+))?$/, (m, pg) => pg.mistakesPage(view, { store, key: decode(m[1]) }), study],
[/^#\/study\/week$/, (m, pg) => pg.weekPage(view, { store }), study],
[/^#\/study\/cheat\/(\w+)$/, (m, pg) => pg.cheatPage(view, { id: m[1] }), study],
[/^#\/study\/drill\/(\w+)$/, (m, pg) => pg.drillPage(view, { id: m[1] }), study],
[/^#\/study\/mixed\/(\w+)\/(\d+)$/, (m, pg) => pg.mixedPage(view, { store, id: m[1], chapter: +m[2] }), study],
[/^#\/mock$/, (m, pg) => pg.mockPage(view, { store }), mockPg],
[/^#\/mk$/, (m, pg) => pg.mkPage(view, { store }), mkPg],
// Backup moved into Settings; old #/data bookmarks land there.
[/^#\/data$/, () => location.replace('#/settings')],
];
const decode = (x) => { try { return x ? decodeURIComponent(x) : null; } catch { return null; } };
// Today's minutes and streak, shown in the sidebar and the top bar.
function todayBits() {
const st = store.streak();
const minutes = Math.floor(store.todayMs() / 60e3);
return [
h('span', { class: 'stat', title: 'Streak' }, icon('fire', { size: 16 }), h('span', { class: 'visually-hidden' }, 'Streak: '), h('span', { class: 'num' }, String(st.current)), h('span', { class: 'visually-hidden' }, ' days')),
h('span', { class: 'stat', title: 'Minutes today' }, icon('timer', { size: 16 }), h('span', { class: 'visually-hidden' }, 'Minutes today: '), h('span', { class: 'num' }, `${minutes}/${store.goalMin()}`)),
];
}
function renderNav(hash) {
const due = dueLessons(store).length;
const open = openBeliefs(store).length;
const here = (href) => hash === href || (href !== '#/' && hash.startsWith(`${href}/`));
const link = (href, label, extra = null, ico = null, on = here(href)) => h('a', { href, 'aria-current': on ? 'page' : null }, ico ? icon(ico, { size: 18 }) : null, h('span', {}, label), extra);
const studyOwn = /^#\/study\/(mistakes|week)/.test(hash);
const tasks = presetSections(activePreset());
mount(nav,
link('#/', 'Today', null, 'path'),
h('div', { class: 'group' }, 'Tasks'),
tasks.map((id) => (id === 'zapn' ? link('#/zapn', 'Zap-N games') : link(`#/s/${id}`, SECTIONS[id].title))),
h('div', { class: 'group' }, 'Learn'),
link('#/study', 'Study guide', due ? h('span', { class: 'nav-count' }, String(due), h('span', { class: 'visually-hidden' }, ' lessons due')) : null, 'study', (hash === '#/study' || hash.startsWith('#/study/')) && !studyOwn),
link('#/progress', 'Progress', open ? h('span', { class: 'nav-count' }, String(open), h('span', { class: 'visually-hidden' }, ' open mistakes')) : null, 'progress', hash.startsWith('#/progress') || studyOwn),
link('#/mock', 'Full mock', null, 'timer'),
link('#/mk', 'Market making', null, 'bolt'),
link('#/settings', 'Settings', null, 'settings', hash === '#/settings'));
mount(tabbar,
link('#/', 'Today', null, 'path', hash === '#/' || hash === '' || hash.startsWith('#/s/') || hash.startsWith('#/zapn') || hash.startsWith('#/run/')),
link('#/study', 'Study', null, 'study', hash.startsWith('#/study') && !studyOwn),
link('#/progress', 'Progress', null, 'progress', hash.startsWith('#/progress') || studyOwn),
link('#/mock', 'Mock', null, 'timer'),
link('#/settings', 'Settings', null, 'settings', hash === '#/settings'));
mount(document.getElementById('topbar'),
h('a', { href: '#/', class: 'brand-link', 'aria-label': 'OptPrep, today' }, wordmark({ height: 22, label: '' })),
h('div', { class: 'nav-today' }, todayBits()));
}
function route() {
const go = () => render();
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
// A short crossfade between pages; runs and games swap instantly (keyboard-heavy).
if (document.startViewTransition && !reduce && !/^#\/(run|zapn\/\w+)/.test(location.hash) && view.childElementCount) { const t = document.startViewTransition(go); t.ready.catch(() => {}); t.finished.catch(() => {}); } else go();
}
let renderSeq = 0;
async function render() {
const seq = ++renderSeq;
const hash = location.hash || '#/';
cleanup?.();
cleanup = null;
setRail(Math.min(1, store.todayMs() / 60e3 / store.goalMin()), `${Math.floor(store.todayMs() / 60e3)} of ${store.goalMin()} minutes today`);
renderNav(hash.startsWith('#/study') ? hash : hash.replace(/\/(practice|exam|drill|mistakes).*$/, ''));
for (const [re, fn, load] of ROUTES) {
const m = hash.match(re);
if (m) {
try {
const mod = load ? await load() : null;
if (seq !== renderSeq) return; // the reader moved on while this page was loading
const c = fn(m, mod); cleanup = typeof c === 'function' ? c : null;
} catch (e) {
if (seq !== renderSeq) return;
console.error(e);
mount(view, h('h1', {}, 'This page failed to load'), h('p', {}, 'Your progress is safe. Reload the page; if it happens again, report it with the details below.'), h('pre', {}, String(e.stack || e)));
}
// Name the page and put focus on its heading (WCAG 2.4.2, 2.4.3).
const h1 = view.querySelector('h1');
document.title = h1 ? `${h1.textContent} · OptPrep` : 'OptPrep · Optiver online assessment practice';
// A page that already placed focus (the runner focuses the question) keeps it.
if (!view.contains(document.activeElement) || document.activeElement === view) { if (h1) { h1.tabIndex = -1; h1.focus({ preventScroll: true }); } else view.focus({ preventScroll: true }); }
window.scrollTo(0, 0);
return;
}
}
mount(view, h('h1', {}, 'Page not found'), h('p', {}, 'This link does not match any page. ', h('a', { href: '#/' }, 'Go to today')));
document.title = 'Page not found · OptPrep';
}
window.addEventListener('hashchange', route);
// The skip link moves focus to the page without touching the router.
document.querySelector('.skip')?.addEventListener('click', (e) => { e.preventDefault(); (view.querySelector('h1') || view).focus(); });
// Printing a lesson opens every derivation step first, so the page prints as a complete document.
window.addEventListener('beforeprint', () => view.querySelectorAll('[data-print-expand]:not([hidden])').forEach((b) => b.click()));
sync.start(store).then((ok) => { if (ok && (location.hash || '#/') === '#/') route(); });
route();
initPwa();
// Active minutes: a 5 s ticker credits time only inside a session, with the tab visible and
// input in the last 90 s. Saved on the next store write and when the page is hidden.
const SESSION = /^#\/(run\/|s\/\w+\/(learn|sets\/\d+)|zapn\/\w+|study\/(lesson|review|drill|mixed|cheat)\/|mock|mk)/;
let lastTick = Date.now();
let lastInputAt = null;
for (const ev of ['pointerdown', 'keydown', 'input', 'wheel', 'scroll', 'touchstart']) window.addEventListener(ev, () => { lastInputAt = Date.now(); }, { passive: true, capture: true });
setInterval(() => {
const now = Date.now();
const before = store.todayMs();
store.creditMs(creditTick({ now, lastTick, lastInputAt, hidden: document.hidden, inSession: SESSION.test(location.hash) }));
lastTick = now;
const goal = store.goalMin() * 60e3;
if (before < goal && store.todayMs() >= goal) {
store.save();
toast(`Daily goal met. Streak: ${store.streak().current} days.`, { icon: icon('fire', { size: 18 }) });
}
}, 5000);
document.addEventListener('visibilitychange', () => { if (document.hidden) store.save(); else lastTick = Date.now(); });
window.addEventListener('pagehide', () => store.save());
// Ask the browser to keep this site's data (no prompt in most browsers once the user engages).
window.addEventListener('pointerdown', () => navigator.storage?.persist?.().catch(() => {}), { once: true });
// Optional Gist sync: on open and when the tab is hidden. A conflict waits for Settings.
if (gistConfig()?.token) {
syncNow(store).then((r) => { if (r.action === 'pull') route(); }).catch(() => {});
document.addEventListener('visibilitychange', () => { if (document.hidden) syncNow(store).catch(() => {}); });
}
// A new version is ready: offer one reload, never force it.
window.addEventListener('optprep:update', (e) => {
const t = toast('A new version of OptPrep is ready.', { ms: 0 });
t.append(h('button', { class: 'btn small', type: 'button', onclick: () => e.detail?.apply?.() }, 'Reload'));
});