studyctl 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- studyctl/__init__.py +3 -0
- studyctl/calendar.py +140 -0
- studyctl/cli/__init__.py +56 -0
- studyctl/cli/_config.py +128 -0
- studyctl/cli/_content.py +462 -0
- studyctl/cli/_lazy.py +35 -0
- studyctl/cli/_review.py +491 -0
- studyctl/cli/_schedule.py +125 -0
- studyctl/cli/_setup.py +164 -0
- studyctl/cli/_shared.py +83 -0
- studyctl/cli/_state.py +69 -0
- studyctl/cli/_sync.py +156 -0
- studyctl/cli/_web.py +228 -0
- studyctl/content/__init__.py +5 -0
- studyctl/content/markdown_converter.py +271 -0
- studyctl/content/models.py +31 -0
- studyctl/content/notebooklm_client.py +434 -0
- studyctl/content/splitter.py +159 -0
- studyctl/content/storage.py +105 -0
- studyctl/content/syllabus.py +416 -0
- studyctl/history.py +982 -0
- studyctl/maintenance.py +69 -0
- studyctl/mcp/__init__.py +1 -0
- studyctl/mcp/server.py +58 -0
- studyctl/mcp/tools.py +234 -0
- studyctl/pdf.py +89 -0
- studyctl/review_db.py +277 -0
- studyctl/review_loader.py +375 -0
- studyctl/scheduler.py +242 -0
- studyctl/services/__init__.py +6 -0
- studyctl/services/content.py +39 -0
- studyctl/services/review.py +127 -0
- studyctl/settings.py +367 -0
- studyctl/shared.py +425 -0
- studyctl/state.py +120 -0
- studyctl/sync.py +229 -0
- studyctl/tui/__main__.py +33 -0
- studyctl/tui/app.py +395 -0
- studyctl/tui/study_cards.py +396 -0
- studyctl/web/__init__.py +1 -0
- studyctl/web/app.py +68 -0
- studyctl/web/routes/__init__.py +1 -0
- studyctl/web/routes/artefacts.py +57 -0
- studyctl/web/routes/cards.py +86 -0
- studyctl/web/routes/courses.py +91 -0
- studyctl/web/routes/history.py +69 -0
- studyctl/web/server.py +260 -0
- studyctl/web/static/app.js +853 -0
- studyctl/web/static/icon-192.svg +4 -0
- studyctl/web/static/icon-512.svg +4 -0
- studyctl/web/static/index.html +50 -0
- studyctl/web/static/manifest.json +21 -0
- studyctl/web/static/style.css +657 -0
- studyctl/web/static/sw.js +14 -0
- studyctl-2.0.0.dist-info/METADATA +49 -0
- studyctl-2.0.0.dist-info/RECORD +58 -0
- studyctl-2.0.0.dist-info/WHEEL +4 -0
- studyctl-2.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/* Socratic Study Mentor — PWA flashcard & quiz review */
|
|
2
|
+
|
|
3
|
+
const $ = (sel) => document.querySelector(sel);
|
|
4
|
+
const app = $("#app");
|
|
5
|
+
const shortcuts = $("#shortcuts");
|
|
6
|
+
|
|
7
|
+
let state = {
|
|
8
|
+
view: "courses",
|
|
9
|
+
course: null,
|
|
10
|
+
mode: null,
|
|
11
|
+
cards: [],
|
|
12
|
+
index: 0,
|
|
13
|
+
correct: 0,
|
|
14
|
+
incorrect: 0,
|
|
15
|
+
skipped: 0,
|
|
16
|
+
wrongHashes: new Set(),
|
|
17
|
+
revealed: false,
|
|
18
|
+
startTime: 0,
|
|
19
|
+
cardStart: 0,
|
|
20
|
+
isRetry: false,
|
|
21
|
+
allCards: [],
|
|
22
|
+
voiceOn: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/* --- Service Worker --- */
|
|
26
|
+
if ("serviceWorker" in navigator) {
|
|
27
|
+
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* --- Dyslexic toggle --- */
|
|
31
|
+
const dyslexicBtn = $("#dyslexic-toggle");
|
|
32
|
+
if (localStorage.getItem("dyslexic") === "true") {
|
|
33
|
+
document.body.classList.add("dyslexic");
|
|
34
|
+
dyslexicBtn.classList.add("active");
|
|
35
|
+
}
|
|
36
|
+
dyslexicBtn.addEventListener("click", () => {
|
|
37
|
+
document.body.classList.toggle("dyslexic");
|
|
38
|
+
const on = document.body.classList.contains("dyslexic");
|
|
39
|
+
dyslexicBtn.classList.toggle("active", on);
|
|
40
|
+
localStorage.setItem("dyslexic", on);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/* --- Theme toggle --- */
|
|
44
|
+
const themeBtn = $("#theme-toggle");
|
|
45
|
+
if (localStorage.getItem("theme") === "light") {
|
|
46
|
+
document.body.classList.add("light");
|
|
47
|
+
themeBtn.classList.add("active");
|
|
48
|
+
}
|
|
49
|
+
themeBtn.addEventListener("click", () => {
|
|
50
|
+
document.body.classList.toggle("light");
|
|
51
|
+
const light = document.body.classList.contains("light");
|
|
52
|
+
themeBtn.classList.toggle("active", light);
|
|
53
|
+
localStorage.setItem("theme", light ? "light" : "dark");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/* --- Voice toggle --- */
|
|
57
|
+
const voiceBtn = $("#voice-toggle");
|
|
58
|
+
const voiceSelect = $("#voice-select");
|
|
59
|
+
let voicesLoaded = false;
|
|
60
|
+
let preferredVoice = null;
|
|
61
|
+
|
|
62
|
+
function loadVoices() {
|
|
63
|
+
if (!window.speechSynthesis) return;
|
|
64
|
+
const voices = window.speechSynthesis.getVoices();
|
|
65
|
+
if (!voices.length) return;
|
|
66
|
+
voicesLoaded = true;
|
|
67
|
+
|
|
68
|
+
// Populate dropdown with English voices
|
|
69
|
+
const englishVoices = voices.filter((v) => v.lang.startsWith("en"));
|
|
70
|
+
voiceSelect.innerHTML = "";
|
|
71
|
+
englishVoices.forEach((v) => {
|
|
72
|
+
const opt = document.createElement("option");
|
|
73
|
+
opt.value = v.name;
|
|
74
|
+
const label = v.name.replace(/Microsoft |Google |Apple /i, "");
|
|
75
|
+
opt.textContent = v.localService ? label : `${label} (online)`;
|
|
76
|
+
voiceSelect.appendChild(opt);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Restore saved selection, or auto-pick best voice
|
|
80
|
+
const saved = localStorage.getItem("voiceName");
|
|
81
|
+
const savedVoice = saved && englishVoices.find((v) => v.name === saved);
|
|
82
|
+
if (savedVoice) {
|
|
83
|
+
preferredVoice = savedVoice;
|
|
84
|
+
voiceSelect.value = savedVoice.name;
|
|
85
|
+
} else {
|
|
86
|
+
preferredVoice =
|
|
87
|
+
englishVoices.find((v) => /premium|enhanced|natural/i.test(v.name)) ||
|
|
88
|
+
englishVoices.find((v) => /samantha|daniel|karen|moira|tessa|fiona/i.test(v.name)) ||
|
|
89
|
+
englishVoices.find((v) => v.lang.startsWith("en-") && !v.name.includes("Google")) ||
|
|
90
|
+
englishVoices[0] || null;
|
|
91
|
+
if (preferredVoice) voiceSelect.value = preferredVoice.name;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (window.speechSynthesis) {
|
|
96
|
+
loadVoices();
|
|
97
|
+
window.speechSynthesis.onvoiceschanged = loadVoices;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
voiceSelect.addEventListener("change", () => {
|
|
101
|
+
const voices = window.speechSynthesis.getVoices();
|
|
102
|
+
preferredVoice = voices.find((v) => v.name === voiceSelect.value) || null;
|
|
103
|
+
localStorage.setItem("voiceName", voiceSelect.value);
|
|
104
|
+
if (state.voiceOn) speakNow("Voice changed");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (localStorage.getItem("voice") === "true") {
|
|
108
|
+
state.voiceOn = true;
|
|
109
|
+
voiceBtn.classList.add("active");
|
|
110
|
+
voiceSelect.classList.remove("hidden");
|
|
111
|
+
}
|
|
112
|
+
voiceBtn.addEventListener("click", () => {
|
|
113
|
+
state.voiceOn = !state.voiceOn;
|
|
114
|
+
voiceBtn.classList.toggle("active", state.voiceOn);
|
|
115
|
+
voiceSelect.classList.toggle("hidden", !state.voiceOn);
|
|
116
|
+
localStorage.setItem("voice", state.voiceOn);
|
|
117
|
+
if (state.voiceOn) {
|
|
118
|
+
// Speak current card if in study view
|
|
119
|
+
if (state.view === "study" && state.index < state.cards.length) {
|
|
120
|
+
const card = state.cards[state.index];
|
|
121
|
+
if (state.revealed) {
|
|
122
|
+
speak(card.type === "flashcard" ? card.back : "");
|
|
123
|
+
} else {
|
|
124
|
+
speak(card.type === "flashcard" ? card.front : card.question);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
speak("Voice enabled");
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
stopSpeaking();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/* --- Voice (Web Speech API) --- */
|
|
135
|
+
function speak(text) {
|
|
136
|
+
if (!state.voiceOn || !window.speechSynthesis || !text) return;
|
|
137
|
+
speakNow(text);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function speakNow(text) {
|
|
141
|
+
if (!window.speechSynthesis || !text) return;
|
|
142
|
+
window.speechSynthesis.cancel();
|
|
143
|
+
const u = new SpeechSynthesisUtterance(text);
|
|
144
|
+
u.rate = 0.95;
|
|
145
|
+
u.pitch = 1.0;
|
|
146
|
+
if (preferredVoice) u.voice = preferredVoice;
|
|
147
|
+
window.speechSynthesis.speak(u);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function speakCurrentCard() {
|
|
151
|
+
if (state.view !== "study" || state.index >= state.cards.length) return;
|
|
152
|
+
const card = state.cards[state.index];
|
|
153
|
+
if (card.type === "flashcard") {
|
|
154
|
+
speakNow(state.revealed ? card.back : card.front);
|
|
155
|
+
} else {
|
|
156
|
+
speakNow(state.revealed ? "" : card.question);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function stopSpeaking() {
|
|
161
|
+
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* --- API --- */
|
|
165
|
+
async function api(path, opts) {
|
|
166
|
+
const r = await fetch(path, opts);
|
|
167
|
+
return r.json();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* --- Views --- */
|
|
171
|
+
async function showCourses() {
|
|
172
|
+
state.view = "courses";
|
|
173
|
+
state.isRetry = false;
|
|
174
|
+
stopSpeaking();
|
|
175
|
+
|
|
176
|
+
const [courses, history] = await Promise.all([
|
|
177
|
+
api("/api/courses"),
|
|
178
|
+
api("/api/history"),
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
if (courses.length === 0) {
|
|
182
|
+
app.innerHTML = `
|
|
183
|
+
<div style="text-align:center;color:var(--text-muted)">
|
|
184
|
+
<h2 style="margin-bottom:12px">No courses found</h2>
|
|
185
|
+
<p>Configure directories in ~/.config/studyctl/config.yaml:</p>
|
|
186
|
+
<pre style="text-align:left;margin:16px auto;max-width:400px;background:var(--bg-card);padding:16px;border-radius:8px">review:
|
|
187
|
+
directories:
|
|
188
|
+
- ~/Desktop/ZTM-DE/downloads
|
|
189
|
+
- ~/Desktop/Python/downloads</pre>
|
|
190
|
+
</div>`;
|
|
191
|
+
shortcuts.innerHTML = "";
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const courseCards = courses.map((c) => {
|
|
196
|
+
const dueBadge = c.due_count > 0
|
|
197
|
+
? `<span class="due-badge">${c.due_count} due</span>`
|
|
198
|
+
: "";
|
|
199
|
+
return `
|
|
200
|
+
<div class="course-card" data-course="${escAttr(c.name)}">
|
|
201
|
+
<h2>${escHtml(c.name)}${dueBadge}</h2>
|
|
202
|
+
<div class="counts">
|
|
203
|
+
<span>${c.flashcard_count} flashcards</span>
|
|
204
|
+
<span>${c.quiz_count} quiz questions</span>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="stats-row">
|
|
207
|
+
<span>${c.total_reviews} reviews</span>
|
|
208
|
+
<span>${c.mastered} mastered</span>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="mode-buttons">
|
|
211
|
+
${c.flashcard_count ? `<button class="mode-btn flashcard" data-course="${escAttr(c.name)}" data-mode="flashcards">Flashcards</button>` : ""}
|
|
212
|
+
${c.quiz_count ? `<button class="mode-btn quiz" data-course="${escAttr(c.name)}" data-mode="quiz">Quiz</button>` : ""}
|
|
213
|
+
</div>
|
|
214
|
+
</div>`;
|
|
215
|
+
}).join("");
|
|
216
|
+
|
|
217
|
+
// Heatmap — last 90 days
|
|
218
|
+
const heatmapDays = buildHeatmap(history);
|
|
219
|
+
|
|
220
|
+
// Recent history
|
|
221
|
+
const historyHtml = history.length ? `
|
|
222
|
+
<div class="history-section">
|
|
223
|
+
<h3>Recent Sessions</h3>
|
|
224
|
+
<div class="history-list">
|
|
225
|
+
${history.slice(0, 8).map((h) => {
|
|
226
|
+
const pct = h.total > 0 ? Math.round((h.correct / h.total) * 100) : 0;
|
|
227
|
+
return `<div class="history-item">
|
|
228
|
+
<span class="hi-course">${escHtml(h.course)}</span>
|
|
229
|
+
<span>${h.mode}</span>
|
|
230
|
+
<span class="hi-score">${pct}% (${h.correct}/${h.total})</span>
|
|
231
|
+
<span class="hi-date">${h.date || ""}</span>
|
|
232
|
+
</div>`;
|
|
233
|
+
}).join("")}
|
|
234
|
+
</div>
|
|
235
|
+
</div>` : "";
|
|
236
|
+
|
|
237
|
+
app.innerHTML = `
|
|
238
|
+
<div style="width:100%;max-width:800px">
|
|
239
|
+
<div class="courses">${courseCards}</div>
|
|
240
|
+
${heatmapDays ? `
|
|
241
|
+
<div class="heatmap-section">
|
|
242
|
+
<h3 style="font-size:1rem;color:var(--text-muted);margin-bottom:8px">Study Activity</h3>
|
|
243
|
+
<div class="heatmap">${heatmapDays}</div>
|
|
244
|
+
</div>` : ""}
|
|
245
|
+
${historyHtml}
|
|
246
|
+
</div>`;
|
|
247
|
+
|
|
248
|
+
app.querySelectorAll(".mode-btn").forEach((btn) => {
|
|
249
|
+
btn.addEventListener("click", (e) => {
|
|
250
|
+
e.stopPropagation();
|
|
251
|
+
showSessionConfig(btn.dataset.course, btn.dataset.mode);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
shortcuts.innerHTML = "";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* --- Session config (source filter + card limit) --- */
|
|
259
|
+
async function showSessionConfig(course, mode) {
|
|
260
|
+
const sources = await api(`/api/sources/${encodeURIComponent(course)}?mode=${mode}`);
|
|
261
|
+
|
|
262
|
+
if (sources.length <= 1) {
|
|
263
|
+
// No chapters to filter — go straight to session
|
|
264
|
+
startSession(course, mode, "all", 0);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Show config modal
|
|
269
|
+
app.innerHTML = `
|
|
270
|
+
<div class="study-view">
|
|
271
|
+
<div class="nav-bar">
|
|
272
|
+
<button class="nav-btn" onclick="showCourses()" title="Back">
|
|
273
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
|
|
274
|
+
</button>
|
|
275
|
+
<span class="nav-course">${escHtml(course)} — ${mode}</span>
|
|
276
|
+
<span></span>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="card" style="cursor:default">
|
|
279
|
+
<div class="card-label">Session Setup</div>
|
|
280
|
+
<div class="config-bar">
|
|
281
|
+
<label>Chapter:
|
|
282
|
+
<select id="source-filter">
|
|
283
|
+
<option value="all">All chapters (${sources.length})</option>
|
|
284
|
+
${sources.map((s) => `<option value="${escAttr(s)}">${escHtml(s)}</option>`).join("")}
|
|
285
|
+
</select>
|
|
286
|
+
</label>
|
|
287
|
+
<label>Cards:
|
|
288
|
+
<select id="card-limit">
|
|
289
|
+
<option value="0">All</option>
|
|
290
|
+
<option value="10">10</option>
|
|
291
|
+
<option value="20" selected>20</option>
|
|
292
|
+
<option value="50">50</option>
|
|
293
|
+
<option value="100">100</option>
|
|
294
|
+
</select>
|
|
295
|
+
</label>
|
|
296
|
+
</div>
|
|
297
|
+
<div style="margin-top:16px">
|
|
298
|
+
<button class="action-btn btn-flip" id="start-btn" style="width:100%">Start Session</button>
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
</div>`;
|
|
302
|
+
|
|
303
|
+
$("#start-btn").addEventListener("click", () => {
|
|
304
|
+
const source = $("#source-filter").value;
|
|
305
|
+
const limit = parseInt($("#card-limit").value);
|
|
306
|
+
startSession(course, mode, source, limit);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function startSession(course, mode, sourceFilter, limit) {
|
|
311
|
+
let cards = await api(`/api/cards/${encodeURIComponent(course)}?mode=${mode}`);
|
|
312
|
+
if (!cards.length) return;
|
|
313
|
+
|
|
314
|
+
// Filter by source
|
|
315
|
+
if (sourceFilter && sourceFilter !== "all") {
|
|
316
|
+
cards = cards.filter((c) => c.source === sourceFilter);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Shuffle
|
|
320
|
+
for (let i = cards.length - 1; i > 0; i--) {
|
|
321
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
322
|
+
[cards[i], cards[j]] = [cards[j], cards[i]];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Limit
|
|
326
|
+
if (limit > 0 && cards.length > limit) {
|
|
327
|
+
cards = cards.slice(0, limit);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!cards.length) return;
|
|
331
|
+
|
|
332
|
+
Object.assign(state, {
|
|
333
|
+
view: "study",
|
|
334
|
+
course,
|
|
335
|
+
mode,
|
|
336
|
+
cards,
|
|
337
|
+
allCards: [...cards],
|
|
338
|
+
index: 0,
|
|
339
|
+
correct: 0,
|
|
340
|
+
incorrect: 0,
|
|
341
|
+
skipped: 0,
|
|
342
|
+
wrongHashes: new Set(),
|
|
343
|
+
revealed: false,
|
|
344
|
+
startTime: Date.now(),
|
|
345
|
+
cardStart: Date.now(),
|
|
346
|
+
isRetry: false,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
showCard();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function restartSession() {
|
|
353
|
+
stopSpeaking();
|
|
354
|
+
showSessionConfig(state.course, state.mode);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function showCard() {
|
|
358
|
+
if (state.index >= state.cards.length) {
|
|
359
|
+
showSummary();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const card = state.cards[state.index];
|
|
364
|
+
const total = state.cards.length;
|
|
365
|
+
const pct = ((state.index / total) * 100).toFixed(0);
|
|
366
|
+
const retryTag = state.isRetry ? " (Retry)" : "";
|
|
367
|
+
state.revealed = false;
|
|
368
|
+
state.cardStart = Date.now();
|
|
369
|
+
|
|
370
|
+
const navBar = `
|
|
371
|
+
<div class="nav-bar">
|
|
372
|
+
<button class="nav-btn" onclick="showCourses()" title="Home">
|
|
373
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
|
374
|
+
</button>
|
|
375
|
+
<span class="nav-course">${escHtml(state.course)} — ${state.mode}${retryTag}</span>
|
|
376
|
+
<button class="nav-btn" onclick="restartSession()" title="Restart">
|
|
377
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 105.64-12.36L1 10"/></svg>
|
|
378
|
+
</button>
|
|
379
|
+
</div>`;
|
|
380
|
+
|
|
381
|
+
if (card.type === "flashcard") {
|
|
382
|
+
app.innerHTML = `
|
|
383
|
+
<div class="study-view">
|
|
384
|
+
${navBar}
|
|
385
|
+
<div class="progress-bar">
|
|
386
|
+
<span>${state.index + 1}/${total}</span>
|
|
387
|
+
<div class="progress-track"><div class="progress-fill" style="width:${pct}%"></div></div>
|
|
388
|
+
<span>${scoreText()}</span>
|
|
389
|
+
</div>
|
|
390
|
+
<div class="card" id="card">
|
|
391
|
+
<div class="card-header">
|
|
392
|
+
<div class="card-label">Question</div>
|
|
393
|
+
<button class="speak-btn" onclick="event.stopPropagation();speakCurrentCard()" title="Read aloud (T)">
|
|
394
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 010 7.07"/></svg>
|
|
395
|
+
</button>
|
|
396
|
+
</div>
|
|
397
|
+
<div class="card-content">${escHtml(card.front)}</div>
|
|
398
|
+
<div class="card-hint">Tap or press Space to reveal</div>
|
|
399
|
+
</div>
|
|
400
|
+
<div class="actions" id="actions" style="display:none">
|
|
401
|
+
<button class="action-btn btn-correct" onclick="answer(true)">I knew it</button>
|
|
402
|
+
<button class="action-btn btn-incorrect" onclick="answer(false)">Didn't know</button>
|
|
403
|
+
<button class="action-btn btn-skip" onclick="skip()">Skip</button>
|
|
404
|
+
</div>
|
|
405
|
+
</div>`;
|
|
406
|
+
|
|
407
|
+
$("#card").addEventListener("click", flipCard);
|
|
408
|
+
speak(card.front);
|
|
409
|
+
} else {
|
|
410
|
+
app.innerHTML = `
|
|
411
|
+
<div class="study-view">
|
|
412
|
+
${navBar}
|
|
413
|
+
<div class="progress-bar">
|
|
414
|
+
<span>${state.index + 1}/${total}</span>
|
|
415
|
+
<div class="progress-track"><div class="progress-fill" style="width:${pct}%"></div></div>
|
|
416
|
+
<span>${scoreText()}</span>
|
|
417
|
+
</div>
|
|
418
|
+
<div class="card" id="card">
|
|
419
|
+
<div class="card-header">
|
|
420
|
+
<div class="card-label">Question</div>
|
|
421
|
+
<button class="speak-btn" onclick="event.stopPropagation();speakCurrentCard()" title="Read aloud (T)">
|
|
422
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 010 7.07"/></svg>
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
<div class="card-content">${escHtml(card.question)}</div>
|
|
426
|
+
${card.hint ? `<div class="card-hint">Hint: ${escHtml(card.hint)}</div>` : ""}
|
|
427
|
+
<div class="quiz-options" id="quiz-options">
|
|
428
|
+
${card.options.map((o, i) => `
|
|
429
|
+
<button class="quiz-option" data-idx="${i}">
|
|
430
|
+
<span class="option-letter">${"ABCDEFGHIJ"[i]}</span>
|
|
431
|
+
<span>${escHtml(o.text)}</span>
|
|
432
|
+
</button>`).join("")}
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
</div>`;
|
|
436
|
+
|
|
437
|
+
app.querySelectorAll(".quiz-option").forEach((btn) => {
|
|
438
|
+
btn.addEventListener("click", () => answerQuiz(parseInt(btn.dataset.idx)));
|
|
439
|
+
});
|
|
440
|
+
speak(card.question);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
updateShortcuts("study");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function flipCard() {
|
|
447
|
+
if (state.revealed) return;
|
|
448
|
+
state.revealed = true;
|
|
449
|
+
|
|
450
|
+
const card = state.cards[state.index];
|
|
451
|
+
const cardEl = $("#card");
|
|
452
|
+
cardEl.classList.add("revealed");
|
|
453
|
+
cardEl.querySelector(".card-label").textContent = "Answer";
|
|
454
|
+
cardEl.querySelector(".card-content").innerHTML = escHtml(card.back);
|
|
455
|
+
cardEl.querySelector(".card-hint").style.display = "none";
|
|
456
|
+
$("#actions").style.display = "flex";
|
|
457
|
+
speak(card.back);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function answerQuiz(idx) {
|
|
461
|
+
const card = state.cards[state.index];
|
|
462
|
+
const buttons = app.querySelectorAll(".quiz-option");
|
|
463
|
+
const correctIdx = card.options.findIndex((o) => o.is_correct);
|
|
464
|
+
const isCorrect = idx === correctIdx;
|
|
465
|
+
|
|
466
|
+
buttons.forEach((btn, i) => {
|
|
467
|
+
btn.style.pointerEvents = "none";
|
|
468
|
+
if (i === correctIdx) btn.classList.add("correct");
|
|
469
|
+
if (i === idx && !isCorrect) btn.classList.add("incorrect");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const correctOpt = card.options[correctIdx];
|
|
473
|
+
if (correctOpt.rationale) {
|
|
474
|
+
const r = document.createElement("div");
|
|
475
|
+
r.className = "rationale";
|
|
476
|
+
r.textContent = correctOpt.rationale;
|
|
477
|
+
$("#quiz-options").after(r);
|
|
478
|
+
speak(isCorrect ? "Correct! " + correctOpt.rationale : "Incorrect. The answer is: " + correctOpt.text + ". " + correctOpt.rationale);
|
|
479
|
+
} else {
|
|
480
|
+
speak(isCorrect ? "Correct!" : "Incorrect. The answer is: " + correctOpt.text);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
recordAnswer(isCorrect);
|
|
484
|
+
|
|
485
|
+
setTimeout(() => {
|
|
486
|
+
state.index++;
|
|
487
|
+
showCard();
|
|
488
|
+
}, isCorrect ? 1500 : 3000);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function answer(correct) {
|
|
492
|
+
recordAnswer(correct);
|
|
493
|
+
state.index++;
|
|
494
|
+
showCard();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function skip() {
|
|
498
|
+
state.skipped++;
|
|
499
|
+
state.index++;
|
|
500
|
+
stopSpeaking();
|
|
501
|
+
showCard();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function recordAnswer(correct) {
|
|
505
|
+
const card = state.cards[state.index];
|
|
506
|
+
const elapsed = Date.now() - state.cardStart;
|
|
507
|
+
|
|
508
|
+
if (correct) {
|
|
509
|
+
state.correct++;
|
|
510
|
+
} else {
|
|
511
|
+
state.incorrect++;
|
|
512
|
+
state.wrongHashes.add(card.hash);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (!state.isRetry) {
|
|
516
|
+
api("/api/review", {
|
|
517
|
+
method: "POST",
|
|
518
|
+
headers: { "Content-Type": "application/json" },
|
|
519
|
+
body: JSON.stringify({
|
|
520
|
+
course: state.course,
|
|
521
|
+
card_type: card.type,
|
|
522
|
+
card_hash: card.hash,
|
|
523
|
+
correct,
|
|
524
|
+
response_time_ms: elapsed,
|
|
525
|
+
}),
|
|
526
|
+
}).catch(() => {});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function showSummary() {
|
|
531
|
+
state.view = "summary";
|
|
532
|
+
stopSpeaking();
|
|
533
|
+
const attempted = state.correct + state.incorrect;
|
|
534
|
+
const pct = attempted > 0 ? Math.round((state.correct / attempted) * 100) : 0;
|
|
535
|
+
const duration = Math.round((Date.now() - state.startTime) / 1000);
|
|
536
|
+
const mins = Math.floor(duration / 60);
|
|
537
|
+
const secs = duration % 60;
|
|
538
|
+
const wrongCount = state.wrongHashes.size;
|
|
539
|
+
|
|
540
|
+
let grade, gradeClass;
|
|
541
|
+
if (pct >= 80) { grade = "Excellent!"; gradeClass = "excellent"; }
|
|
542
|
+
else if (pct >= 60) { grade = "Good progress"; gradeClass = "good"; }
|
|
543
|
+
else { grade = "Keep reviewing"; gradeClass = "review"; }
|
|
544
|
+
|
|
545
|
+
const circumference = 2 * Math.PI * 58;
|
|
546
|
+
const offset = circumference - (pct / 100) * circumference;
|
|
547
|
+
|
|
548
|
+
if (!state.isRetry) {
|
|
549
|
+
api("/api/session", {
|
|
550
|
+
method: "POST",
|
|
551
|
+
headers: { "Content-Type": "application/json" },
|
|
552
|
+
body: JSON.stringify({
|
|
553
|
+
course: state.course,
|
|
554
|
+
mode: state.mode,
|
|
555
|
+
total: state.cards.length,
|
|
556
|
+
correct: state.correct,
|
|
557
|
+
duration_seconds: duration,
|
|
558
|
+
}),
|
|
559
|
+
}).catch(() => {});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
app.innerHTML = `
|
|
563
|
+
<div class="summary">
|
|
564
|
+
<div class="score-ring">
|
|
565
|
+
<svg width="140" height="140" viewBox="0 0 140 140">
|
|
566
|
+
<circle class="track" cx="70" cy="70" r="58"/>
|
|
567
|
+
<circle class="fill ${gradeClass}" cx="70" cy="70" r="58"
|
|
568
|
+
stroke-dasharray="${circumference}"
|
|
569
|
+
stroke-dashoffset="${offset}"/>
|
|
570
|
+
</svg>
|
|
571
|
+
<div class="score-text">${pct}%</div>
|
|
572
|
+
</div>
|
|
573
|
+
<h2>Session Complete</h2>
|
|
574
|
+
<div class="grade ${gradeClass}">${grade}</div>
|
|
575
|
+
<div class="summary-stats">
|
|
576
|
+
<span>${state.correct} correct</span>
|
|
577
|
+
<span>${state.incorrect} wrong</span>
|
|
578
|
+
<span>${state.skipped} skipped</span>
|
|
579
|
+
<span>${mins}m ${secs}s</span>
|
|
580
|
+
</div>
|
|
581
|
+
<div class="summary-actions">
|
|
582
|
+
${wrongCount && !state.isRetry ? `<button class="summary-btn btn-retry" onclick="retryWrong()">Retry ${wrongCount} wrong</button>` : ""}
|
|
583
|
+
<button class="summary-btn btn-restart" onclick="restartSession()">Restart</button>
|
|
584
|
+
<button class="summary-btn btn-back" onclick="showCourses()">Home</button>
|
|
585
|
+
</div>
|
|
586
|
+
</div>`;
|
|
587
|
+
|
|
588
|
+
speak(`Session complete. You scored ${pct} percent. ${state.correct} correct, ${state.incorrect} wrong.`);
|
|
589
|
+
updateShortcuts("summary");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function retryWrong() {
|
|
593
|
+
const wrong = state.wrongHashes;
|
|
594
|
+
const retryCards = state.allCards.filter((c) => wrong.has(c.hash));
|
|
595
|
+
if (!retryCards.length) return;
|
|
596
|
+
|
|
597
|
+
Object.assign(state, {
|
|
598
|
+
cards: retryCards,
|
|
599
|
+
index: 0,
|
|
600
|
+
correct: 0,
|
|
601
|
+
incorrect: 0,
|
|
602
|
+
skipped: 0,
|
|
603
|
+
wrongHashes: new Set(),
|
|
604
|
+
revealed: false,
|
|
605
|
+
startTime: Date.now(),
|
|
606
|
+
isRetry: true,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
showCard();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/* --- Heatmap builder --- */
|
|
613
|
+
function buildHeatmap(history) {
|
|
614
|
+
if (!history.length) return "";
|
|
615
|
+
const counts = {};
|
|
616
|
+
history.forEach((h) => { if (h.date) counts[h.date] = (counts[h.date] || 0) + 1; });
|
|
617
|
+
|
|
618
|
+
const days = [];
|
|
619
|
+
const today = new Date();
|
|
620
|
+
for (let i = 89; i >= 0; i--) {
|
|
621
|
+
const d = new Date(today);
|
|
622
|
+
d.setDate(d.getDate() - i);
|
|
623
|
+
const key = d.toISOString().slice(0, 10);
|
|
624
|
+
const n = counts[key] || 0;
|
|
625
|
+
const level = n === 0 ? "" : n === 1 ? "l1" : n <= 3 ? "l2" : n <= 5 ? "l3" : "l4";
|
|
626
|
+
days.push(`<div class="heatmap-day ${level}" title="${key}: ${n} session${n !== 1 ? "s" : ""}"></div>`);
|
|
627
|
+
}
|
|
628
|
+
return days.join("");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/* --- Helpers --- */
|
|
632
|
+
function scoreText() {
|
|
633
|
+
const attempted = state.correct + state.incorrect;
|
|
634
|
+
if (!attempted) return "";
|
|
635
|
+
return `${Math.round((state.correct / attempted) * 100)}%`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function escHtml(s) {
|
|
639
|
+
const d = document.createElement("div");
|
|
640
|
+
d.textContent = s;
|
|
641
|
+
return d.innerHTML;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function escAttr(s) {
|
|
645
|
+
return s.replace(/&/g, "&").replace(/"/g, """);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function updateShortcuts(view) {
|
|
649
|
+
if (view === "study") {
|
|
650
|
+
shortcuts.innerHTML = `
|
|
651
|
+
<span><kbd>Space</kbd> Flip</span>
|
|
652
|
+
<span><kbd>Y</kbd> Correct</span>
|
|
653
|
+
<span><kbd>N</kbd> Incorrect</span>
|
|
654
|
+
<span><kbd>S</kbd> Skip</span>
|
|
655
|
+
<span><kbd>T</kbd> Read</span>
|
|
656
|
+
<span><kbd>V</kbd> Auto-voice</span>
|
|
657
|
+
<span><kbd>Esc</kbd> Home</span>`;
|
|
658
|
+
} else if (view === "summary") {
|
|
659
|
+
shortcuts.innerHTML = `
|
|
660
|
+
<span><kbd>R</kbd> Retry</span>
|
|
661
|
+
<span><kbd>Esc</kbd> Home</span>`;
|
|
662
|
+
} else {
|
|
663
|
+
shortcuts.innerHTML = "";
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/* --- Keyboard shortcuts --- */
|
|
668
|
+
document.addEventListener("keydown", (e) => {
|
|
669
|
+
if ((e.key === "v" || e.key === "V") && state.view !== "courses") {
|
|
670
|
+
voiceBtn.click();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (state.view === "study") {
|
|
675
|
+
const card = state.cards[state.index];
|
|
676
|
+
if (e.key === " " || e.key === "Enter") {
|
|
677
|
+
e.preventDefault();
|
|
678
|
+
if (card.type === "flashcard" && !state.revealed) flipCard();
|
|
679
|
+
}
|
|
680
|
+
if (state.revealed && card.type === "flashcard") {
|
|
681
|
+
if (e.key === "y" || e.key === "Y") answer(true);
|
|
682
|
+
if (e.key === "n" || e.key === "N") answer(false);
|
|
683
|
+
}
|
|
684
|
+
if (e.key === "s" || e.key === "S") skip();
|
|
685
|
+
if (e.key === "t" || e.key === "T") speakCurrentCard();
|
|
686
|
+
if (e.key === "Escape") showCourses();
|
|
687
|
+
if (card.type === "quiz") {
|
|
688
|
+
const num = parseInt(e.key);
|
|
689
|
+
if (num >= 1 && num <= card.options.length) answerQuiz(num - 1);
|
|
690
|
+
if (e.key >= "a" && e.key <= "d") {
|
|
691
|
+
const idx = e.key.charCodeAt(0) - 97;
|
|
692
|
+
if (idx < card.options.length) answerQuiz(idx);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (state.view === "summary") {
|
|
698
|
+
if (e.key === "r" || e.key === "R") retryWrong();
|
|
699
|
+
if (e.key === "Escape") showCourses();
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
/* --- Pomodoro Timer --- */
|
|
704
|
+
const pomo = {
|
|
705
|
+
STUDY: 25 * 60,
|
|
706
|
+
BREAK: 5 * 60,
|
|
707
|
+
LONG_BREAK: 15 * 60,
|
|
708
|
+
running: false,
|
|
709
|
+
paused: false,
|
|
710
|
+
isBreak: false,
|
|
711
|
+
remaining: 25 * 60,
|
|
712
|
+
total: 25 * 60,
|
|
713
|
+
interval: null,
|
|
714
|
+
sessions: 0,
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
const pomoEl = $("#pomodoro");
|
|
718
|
+
const pomoToggle = $("#pomo-toggle");
|
|
719
|
+
const pomoTime = $("#pomo-time");
|
|
720
|
+
const pomoLabel = $("#pomo-label");
|
|
721
|
+
const pomoArc = $("#pomo-arc");
|
|
722
|
+
const pomoPause = $("#pomo-pause");
|
|
723
|
+
const pomoStop = $("#pomo-stop");
|
|
724
|
+
const CIRCUMFERENCE = 2 * Math.PI * 18;
|
|
725
|
+
|
|
726
|
+
pomoToggle.addEventListener("click", () => {
|
|
727
|
+
if (pomo.running) {
|
|
728
|
+
// Show/hide the timer widget
|
|
729
|
+
pomoEl.classList.toggle("hidden");
|
|
730
|
+
} else {
|
|
731
|
+
pomoStart();
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
pomoPause.addEventListener("click", () => {
|
|
736
|
+
if (pomo.paused) {
|
|
737
|
+
pomoResume();
|
|
738
|
+
} else {
|
|
739
|
+
pomoPauseTimer();
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
pomoStop.addEventListener("click", pomoStopTimer);
|
|
744
|
+
|
|
745
|
+
function pomoStart() {
|
|
746
|
+
pomo.isBreak = false;
|
|
747
|
+
pomo.remaining = pomo.STUDY;
|
|
748
|
+
pomo.total = pomo.STUDY;
|
|
749
|
+
pomo.running = true;
|
|
750
|
+
pomo.paused = false;
|
|
751
|
+
pomoEl.classList.remove("hidden", "break");
|
|
752
|
+
pomoToggle.classList.add("active");
|
|
753
|
+
pomoLabel.textContent = "Study";
|
|
754
|
+
pomoPause.innerHTML = "❚❚";
|
|
755
|
+
pomoTick();
|
|
756
|
+
pomo.interval = setInterval(pomoTick, 1000);
|
|
757
|
+
speak("Pomodoro started. 25 minutes of focused study.");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function pomoPauseTimer() {
|
|
761
|
+
pomo.paused = true;
|
|
762
|
+
clearInterval(pomo.interval);
|
|
763
|
+
pomoPause.innerHTML = "▶";
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function pomoResume() {
|
|
767
|
+
pomo.paused = false;
|
|
768
|
+
pomoPause.innerHTML = "❚❚";
|
|
769
|
+
pomo.interval = setInterval(pomoTick, 1000);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function pomoStopTimer() {
|
|
773
|
+
pomo.running = false;
|
|
774
|
+
pomo.paused = false;
|
|
775
|
+
clearInterval(pomo.interval);
|
|
776
|
+
pomoEl.classList.add("hidden");
|
|
777
|
+
pomoToggle.classList.remove("active");
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function pomoTick() {
|
|
781
|
+
pomo.remaining--;
|
|
782
|
+
if (pomo.remaining <= 0) {
|
|
783
|
+
clearInterval(pomo.interval);
|
|
784
|
+
if (pomo.isBreak) {
|
|
785
|
+
// Break over — start new study session
|
|
786
|
+
speak("Break over! Time to study.");
|
|
787
|
+
pomoNotify("Break over!", "Time for another study session.");
|
|
788
|
+
pomo.isBreak = false;
|
|
789
|
+
pomo.remaining = pomo.STUDY;
|
|
790
|
+
pomo.total = pomo.STUDY;
|
|
791
|
+
pomoEl.classList.remove("break");
|
|
792
|
+
pomoLabel.textContent = "Study";
|
|
793
|
+
} else {
|
|
794
|
+
// Study over — start break
|
|
795
|
+
pomo.sessions++;
|
|
796
|
+
const isLong = pomo.sessions % 4 === 0;
|
|
797
|
+
const breakTime = isLong ? pomo.LONG_BREAK : pomo.BREAK;
|
|
798
|
+
speak(isLong ? "Great work! Take a 15 minute break." : "Good session! Take a 5 minute break.");
|
|
799
|
+
pomoNotify("Study session complete!", isLong ? "Take a 15 minute break." : "Take a 5 minute break.");
|
|
800
|
+
pomo.isBreak = true;
|
|
801
|
+
pomo.remaining = breakTime;
|
|
802
|
+
pomo.total = breakTime;
|
|
803
|
+
pomoEl.classList.add("break");
|
|
804
|
+
pomoLabel.textContent = isLong ? "Long Break" : "Break";
|
|
805
|
+
}
|
|
806
|
+
pomo.interval = setInterval(pomoTick, 1000);
|
|
807
|
+
}
|
|
808
|
+
pomoRender();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function pomoRender() {
|
|
812
|
+
const mins = Math.floor(pomo.remaining / 60);
|
|
813
|
+
const secs = pomo.remaining % 60;
|
|
814
|
+
pomoTime.textContent = `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
815
|
+
const progress = 1 - pomo.remaining / pomo.total;
|
|
816
|
+
pomoArc.setAttribute(
|
|
817
|
+
"stroke-dashoffset",
|
|
818
|
+
(CIRCUMFERENCE * (1 - progress)).toString()
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function pomoNotify(title, body) {
|
|
823
|
+
// Browser notification
|
|
824
|
+
if ("Notification" in window && Notification.permission === "granted") {
|
|
825
|
+
new Notification(title, { body, icon: "/icon-192.svg" });
|
|
826
|
+
}
|
|
827
|
+
// Audio chime — short beep sequence
|
|
828
|
+
try {
|
|
829
|
+
const ctx = new AudioContext();
|
|
830
|
+
[0, 200, 400].forEach((delay) => {
|
|
831
|
+
const osc = ctx.createOscillator();
|
|
832
|
+
const gain = ctx.createGain();
|
|
833
|
+
osc.connect(gain);
|
|
834
|
+
gain.connect(ctx.destination);
|
|
835
|
+
osc.frequency.value = 880;
|
|
836
|
+
gain.gain.value = 0.15;
|
|
837
|
+
osc.start(ctx.currentTime + delay / 1000);
|
|
838
|
+
osc.stop(ctx.currentTime + delay / 1000 + 0.12);
|
|
839
|
+
});
|
|
840
|
+
} catch (_) {
|
|
841
|
+
/* audio not available */
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Request notification permission on first pomodoro use
|
|
846
|
+
pomoToggle.addEventListener("click", () => {
|
|
847
|
+
if ("Notification" in window && Notification.permission === "default") {
|
|
848
|
+
Notification.requestPermission();
|
|
849
|
+
}
|
|
850
|
+
}, { once: true });
|
|
851
|
+
|
|
852
|
+
/* --- Init --- */
|
|
853
|
+
showCourses();
|