Skip to content

Commit ef8dc7e

Browse files
Progress tracking (#23)
* add progress tracking * add your progress page to nagivation bar * generate list of paths after build, not before * make you progress page responsive on mobile
1 parent 85b01e7 commit ef8dc7e

File tree

11 files changed

+963
-6
lines changed

11 files changed

+963
-6
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,5 @@
1818
npm-debug.log*
1919
yarn-debug.log*
2020
yarn-error.log*
21+
22+
/static/docs-manifest.json

docs/start-here.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1-
# Start Here
1+
import StartHereProgress from '@site/src/components/StartHereProgress/StartHereProgress';
2+
3+
# Your Progress
24

35
The Borr Project aims to empower learners to master college curricula through free resources. Choose a major and start today!
46

5-
We offer the following fore curricula right now:
7+
We offer the following four curricula right now:
8+
9+
<StartHereProgress />
10+
11+
Happy learning!
612

7-
- [Computer Science](../computer-science/)
8-
- [Pre-College Math](../precollege-math/)
9-
- [Data Science](../data-science/)
10-
- [Math](../math/)
13+
:::tip
14+
The progress trackers above reflect your progress through each curriculum and are stored locally in your browser. This data is private and only accessible to you. To back up your progress more permanently, use the export button.
15+
:::

docusaurus.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ const config = {
191191
},
192192
{to: '/getting-help', label: 'Getting Help'},
193193
{to: '/blog', label: 'Blog'},
194+
{to: '/start-here', label: 'Your Progress', position: 'right'},
194195
{
195196
href: 'https://github.com/BorrProject/',
196197
position: 'right',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"scripts": {
66
"docusaurus": "docusaurus",
77
"start": "docusaurus start",
8+
"postbuild": "node ./scripts/generate-docs-manifest.mjs",
89
"build": "docusaurus build",
910
"swizzle": "docusaurus swizzle",
1011
"deploy": "docusaurus deploy",

scripts/generate-docs-manifest.mjs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/usr/bin/env node
2+
3+
import fs from 'fs/promises';
4+
import path from 'path';
5+
6+
const BUILD_DIR = path.resolve(process.cwd(), 'build');
7+
const OUT_FILE = path.join(BUILD_DIR, 'docs-manifest.json');
8+
9+
async function walk(dir) {
10+
const entries = await fs.readdir(dir, { withFileTypes: true });
11+
const files = [];
12+
13+
for (const ent of entries) {
14+
const full = path.join(dir, ent.name);
15+
if (ent.isDirectory()) {
16+
files.push(...await walk(full));
17+
} else if (ent.isFile() && ent.name === 'index.html') {
18+
files.push(full);
19+
}
20+
}
21+
22+
return files;
23+
}
24+
25+
function toRoute(filePath) {
26+
const rel = path.relative(BUILD_DIR, path.dirname(filePath)).split(path.sep).join('/');
27+
return rel === '' ? '/' : `/${rel}/`;
28+
}
29+
30+
async function main() {
31+
try {
32+
await fs.access(BUILD_DIR);
33+
} catch (e) {
34+
console.error('build/ directory not found, skipping manifest generation.');
35+
process.exit(0);
36+
}
37+
38+
const files = await walk(BUILD_DIR);
39+
const routes = files.map(toRoute);
40+
const unique = Array.from(new Set(routes)).sort();
41+
42+
try {
43+
await fs.writeFile(OUT_FILE, JSON.stringify(unique, null, 2), 'utf8');
44+
console.log(`Wrote docs manifest with ${unique.length} entries to ${path.relative(process.cwd(), OUT_FILE)}`);
45+
} catch (e) {
46+
console.error('Failed to write manifest:', e);
47+
process.exit(1);
48+
}
49+
}
50+
51+
main();
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import React, {useEffect, useState, useRef} from 'react';
2+
import styles from './styles.module.css';
3+
4+
const STORAGE_PREFIX = 'borr:read:';
5+
6+
function normalizePath(path) {
7+
if (!path) return '/';
8+
try {
9+
const url = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
10+
path = url.pathname;
11+
} catch (e) {
12+
}
13+
if (path !== '/' && path.endsWith('/')) path = path.slice(0, -1);
14+
return path || '/';
15+
}
16+
17+
function getSidebarDocLinks() {
18+
const anchors = Array.from(document.querySelectorAll('.menu__list a'));
19+
const internal = anchors
20+
.map((a) => a.getAttribute('href'))
21+
.filter(Boolean)
22+
.filter((h) => h.startsWith('/'))
23+
.map((h) => normalizePath(h));
24+
return Array.from(new Set(internal));
25+
}
26+
27+
const TRACK_SECTIONS = {
28+
'/computer-science': 'Computer Science',
29+
'/precollege-math': 'Pre-College Math',
30+
'/data-science': 'Data Science',
31+
'/math': 'Math',
32+
};
33+
34+
export default function ReadingProgress() {
35+
const [totalPages, setTotalPages] = useState(0);
36+
const [readCount, setReadCount] = useState(0);
37+
const [isRead, setIsRead] = useState(false);
38+
const [pagesList, setPagesList] = useState(null);
39+
const [currentSection, setCurrentSection] = useState(null);
40+
const fileInputRef = useRef(null);
41+
42+
useEffect(() => {
43+
if (typeof window === 'undefined') return;
44+
45+
const currentPath = normalizePath(window.location.pathname + window.location.search);
46+
47+
const normalizedSections = Object.keys(TRACK_SECTIONS).map((p) => normalizePath(p));
48+
const found = normalizedSections.find((s) => currentPath === s || currentPath.startsWith(s + '/')) || null;
49+
setCurrentSection(found);
50+
51+
if (!found) {
52+
setTotalPages(0);
53+
setReadCount(0);
54+
setIsRead(false);
55+
setPagesList([]);
56+
return;
57+
}
58+
async function loadManifest() {
59+
try {
60+
const res = await fetch('/docs-manifest.json', {cache: 'no-store'});
61+
if (res.ok) {
62+
const arr = await res.json();
63+
const pages = arr.map((p) => normalizePath(p));
64+
const sectionPages = pages.filter((p) => p === found || p.startsWith(found + '/'));
65+
setPagesList(sectionPages);
66+
setTotalPages(sectionPages.length);
67+
68+
const counts = sectionPages.reduce((acc, p) => acc + (localStorage.getItem(STORAGE_PREFIX + p) === '1' ? 1 : 0), 0);
69+
setReadCount(counts);
70+
71+
const currentKey = STORAGE_PREFIX + currentPath;
72+
setIsRead(localStorage.getItem(currentKey) === '1');
73+
return;
74+
}
75+
} catch (e) {
76+
}
77+
78+
const links = getSidebarDocLinks();
79+
const sectionLinks = (links.length > 0 ? links : [currentPath]).filter((p) => p === found || p.startsWith(found + '/'));
80+
81+
setPagesList(sectionLinks);
82+
setTotalPages(sectionLinks.length);
83+
84+
const counts = sectionLinks.reduce((acc, p) => {
85+
const key = STORAGE_PREFIX + p;
86+
if (localStorage.getItem(key) === '1') acc++;
87+
return acc;
88+
}, 0);
89+
setReadCount(counts);
90+
91+
const currentKey = STORAGE_PREFIX + currentPath;
92+
setIsRead(localStorage.getItem(currentKey) === '1');
93+
}
94+
95+
loadManifest();
96+
97+
function onStorage(ev) {
98+
if (!ev.key) return;
99+
if (!ev.key.startsWith(STORAGE_PREFIX)) return;
100+
let newCounts = 0;
101+
for (let i = 0; i < localStorage.length; i++) {
102+
const k = localStorage.key(i);
103+
if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
104+
const p = k.slice(STORAGE_PREFIX.length);
105+
if (p === found || p.startsWith(found + '/')) newCounts++;
106+
}
107+
setReadCount(newCounts);
108+
setIsRead(localStorage.getItem(STORAGE_PREFIX + currentPath) === '1');
109+
}
110+
111+
window.addEventListener('storage', onStorage);
112+
return () => window.removeEventListener('storage', onStorage);
113+
}, []);
114+
115+
if (typeof window === 'undefined') return null;
116+
117+
if (!currentSection) return null;
118+
119+
const percent = totalPages > 0 ? Math.round((readCount / totalPages) * 100) : 0;
120+
function toggleRead() {
121+
const currentPath = normalizePath(window.location.pathname + window.location.search);
122+
const key = STORAGE_PREFIX + currentPath;
123+
if (localStorage.getItem(key) === '1') {
124+
localStorage.removeItem(key);
125+
setIsRead(false);
126+
setReadCount((c) => Math.max(0, c - 1));
127+
} else {
128+
localStorage.setItem(key, '1');
129+
setIsRead(true);
130+
setReadCount((c) => c + 1);
131+
}
132+
try {
133+
window.dispatchEvent(new Event('storage'));
134+
} catch (e) {}
135+
}
136+
137+
function exportProgress() {
138+
if (!currentSection) return;
139+
const entries = [];
140+
for (let i = 0; i < localStorage.length; i++) {
141+
const k = localStorage.key(i);
142+
if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
143+
const p = k.slice(STORAGE_PREFIX.length);
144+
if (p === currentSection || p.startsWith(currentSection + '/')) entries.push(p);
145+
}
146+
if (entries.length === 0) {
147+
alert('No progress saved for this section.');
148+
return;
149+
}
150+
const data = {version: 1, section: currentSection, entries};
151+
const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
152+
const url = URL.createObjectURL(blob);
153+
const a = document.createElement('a');
154+
const safeSection = currentSection.replace(/[^a-z0-9]/gi, '_').replace(/^_+|_+$/g, '');
155+
a.download = `borr-progress-${safeSection}.json`;
156+
a.href = url;
157+
document.body.appendChild(a);
158+
a.click();
159+
a.remove();
160+
URL.revokeObjectURL(url);
161+
}
162+
163+
function openImportDialog() {
164+
if (fileInputRef.current) fileInputRef.current.click();
165+
}
166+
167+
async function handleImportFile(ev) {
168+
const f = ev.target.files && ev.target.files[0];
169+
if (!f) return;
170+
try {
171+
const text = await f.text();
172+
const json = JSON.parse(text);
173+
let entries = [];
174+
if (Array.isArray(json)) entries = json;
175+
else if (json && Array.isArray(json.entries)) entries = json.entries;
176+
else if (json && typeof json === 'object') {
177+
if (json.entries && typeof json.entries === 'object') entries = Object.keys(json.entries);
178+
}
179+
if (entries.length === 0) {
180+
alert('No entries found in import file. Expected an array of doc paths or {entries: [...]}.');
181+
return;
182+
}
183+
184+
if (json.section && json.section !== currentSection) {
185+
const ok = window.confirm(`Import file is for "${json.section}" but you are on "${currentSection}". Import anyway (will merge)?`);
186+
if (!ok) return;
187+
}
188+
189+
let added = 0;
190+
for (const p of entries) {
191+
const norm = normalizePath(p);
192+
if (norm === currentSection || norm.startsWith(currentSection + '/')) {
193+
const key = STORAGE_PREFIX + norm;
194+
if (localStorage.getItem(key) !== '1') {
195+
localStorage.setItem(key, '1');
196+
added++;
197+
}
198+
}
199+
}
200+
const newCounts = pagesList ? pagesList.reduce((acc, p) => acc + (localStorage.getItem(STORAGE_PREFIX + p) === '1' ? 1 : 0), 0) : 0;
201+
setReadCount(newCounts);
202+
setIsRead(localStorage.getItem(STORAGE_PREFIX + normalizePath(window.location.pathname)) === '1');
203+
alert(`Imported ${added} entries for this section.`);
204+
} catch (e) {
205+
alert('Failed to import file: ' + (e && e.message ? e.message : String(e)));
206+
} finally {
207+
if (fileInputRef.current) fileInputRef.current.value = '';
208+
}
209+
}
210+
211+
function resetProgress() {
212+
if (!currentSection) return;
213+
const ok = window.confirm('Reset progress for this section? This will remove all saved "done" marks for this section.');
214+
if (!ok) return;
215+
let removed = 0;
216+
const toRemove = [];
217+
for (let i = 0; i < localStorage.length; i++) {
218+
const k = localStorage.key(i);
219+
if (!k || !k.startsWith(STORAGE_PREFIX)) continue;
220+
const p = k.slice(STORAGE_PREFIX.length);
221+
if (p === currentSection || p.startsWith(currentSection + '/')) toRemove.push(k);
222+
}
223+
for (const k of toRemove) {
224+
localStorage.removeItem(k);
225+
removed++;
226+
}
227+
setReadCount(0);
228+
setIsRead(false);
229+
alert(`Removed ${removed} entries for this section.`);
230+
}
231+
232+
return (
233+
<div className={styles.container}>
234+
<div className={styles.header}>
235+
<div className={styles.summary}>
236+
<span className={styles.sectionName}>{TRACK_SECTIONS[currentSection] ?? ''}</span>
237+
<strong>{readCount}</strong>
238+
<span className={styles.sep}>/</span>
239+
<span>{totalPages}</span>
240+
241+
</div>
242+
<div style={{display: 'flex', gap: '0.5rem', alignItems: 'center'}}>
243+
<button
244+
aria-pressed={isRead}
245+
className={`button button--primary borr-tick ${styles.tick} ${isRead ? styles.ticked : ''}`}
246+
onClick={toggleRead}
247+
title={isRead ? 'Mark page as not done' : 'Mark page as done'}
248+
>
249+
{isRead ? '✓ Done' : 'Mark done'}
250+
</button>
251+
</div>
252+
</div>
253+
254+
<div className={styles.progressBar} aria-hidden>
255+
<div className={styles.filler} style={{width: `${percent}%`}} />
256+
</div>
257+
<div className={styles.percent}>{percent}%</div>
258+
259+
<div className={styles.controls}>
260+
<input ref={fileInputRef} className={styles.fileInput} type="file" accept="application/json" onChange={handleImportFile} />
261+
<div style={{display: 'flex', gap: '0.5rem'}}>
262+
<button className="button button--secondary borr-export" onClick={exportProgress} title="Export progress for this section">Export</button>
263+
<button className="button button--secondary borr-import" onClick={openImportDialog} title="Import progress JSON">Import</button>
264+
<button className="button button--outline borr-reset" onClick={resetProgress} title="Reset progress for this section">Reset</button>
265+
</div>
266+
</div>
267+
</div>
268+
);
269+
}

0 commit comments

Comments
 (0)