Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ Open-source, local-first typing tutor tailored for **programmers** and **technic

## Features

- **Skill-based tracks** — Programmers (code syntax, symbols) and Technical Writers (requirements, documentation)
- **Skill-based tracks** — Programmers (code syntax, symbols) and Technical Writers (requirements, documentation, translation)
- **Bilingual** — Interface and lessons in `en-US` and `pt-BR`
- **Progress dashboard** — WPM charts, error heatmap, lesson suggestions
- **Translation mode** — Practice bilingual technical writing
- **100% local** — No backend, no cloud costs; progress saved in IndexedDB
- **PWA** — Installable and works offline after first load
- **Contributor-friendly** — Add lessons by creating JSON files in `content/`

## Quick Start
Expand Down
7 changes: 7 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@
"@coderkeys/schemas": "workspace:*",
"clsx": "^2.1.1",
"dexie": "^4.0.11",
"date-fns": "^4.1.0",
"i18next": "^24.2.3",
"i18next-browser-languagedetector": "^8.0.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.4.0",
"recharts": "^2.15.1",
"tailwind-merge": "^3.0.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.36.4",
"@tailwindcss/vite": "^4.0.14",
"@types/node": "^22.14.0",
"@types/react": "^19.2.17",
Expand All @@ -34,6 +40,7 @@
"tailwindcss": "^4.0.14",
"typescript": "^5.8.2",
"vite": "^8.1.0",
"vite-plugin-pwa": "^0.21.2",
"vitest": "^3.0.9"
}
}
29 changes: 29 additions & 0 deletions apps/web/src/app/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
import { useSettingsStore } from '@/shared/stores/settings.store';

function resolveTheme(theme: 'light' | 'dark' | 'system'): 'light' | 'dark' {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme;
}

export function ThemeProvider({ children }: { children: React.ReactNode }) {
const theme = useSettingsStore((s) => s.theme);

useEffect(() => {
const apply = () => {
document.documentElement.setAttribute('data-theme', resolveTheme(theme));
};

apply();

if (theme === 'system') {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', apply);
return () => mq.removeEventListener('change', apply);
}
}, [theme]);

return <>{children}</>;
}
3 changes: 2 additions & 1 deletion apps/web/src/app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSettingsStore } from '@/shared/stores/settings.store';
import { ThemeProvider } from './ThemeProvider';
import '@/i18n';

export function AppProviders({ children }: { children: React.ReactNode }) {
Expand All @@ -25,5 +26,5 @@ export function AppProviders({ children }: { children: React.ReactNode }) {
);
}

return <>{children}</>;
return <ThemeProvider>{children}</ThemeProvider>;
}
2 changes: 2 additions & 0 deletions apps/web/src/app/router.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BrowserRouter, Navigate, Route, Routes, useParams } from 'react-router-dom';
import { Layout } from '@/shared/components/Layout';
import { CatalogPage, TrackPage } from '@/features/catalog/pages/CatalogPage';
import { DashboardPage } from '@/features/dashboard/pages/DashboardPage';
import { LessonPage } from '@/features/lesson/pages/LessonPage';
import { SettingsPage } from '@/features/settings/pages/SettingsPage';

Expand All @@ -16,6 +17,7 @@ export function AppRouter() {
<Layout>
<Routes>
<Route path="/" element={<CatalogPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/tracks/:trackId" element={<TrackRoute />} />
<Route path="/lessons/:lessonId" element={<LessonPage />} />
<Route path="/settings" element={<SettingsPage />} />
Expand Down
74 changes: 73 additions & 1 deletion apps/web/src/db/repositories/progress.repo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { SessionResult as EngineResult } from '@coderkeys/engine';
import { db, type LessonProgress, type SessionRecord } from '../database';
import { db, type LessonProgress, type SessionRecord, type UserSettings } from '../database';

export interface ProgressExport {
version: 1;
exportedAt: string;
settings: UserSettings;
lessonProgress: LessonProgress[];
sessions: SessionRecord[];
}

export async function getLessonProgress(lessonId: string): Promise<LessonProgress | undefined> {
return db.lessonProgress.get(lessonId);
Expand All @@ -9,6 +17,16 @@ export async function getAllLessonProgress(): Promise<LessonProgress[]> {
return db.lessonProgress.toArray();
}

export async function getAllSessions(): Promise<SessionRecord[]> {
return db.sessions.orderBy('completedAt').reverse().toArray();
}

export async function getSessionsSince(days: number): Promise<SessionRecord[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return db.sessions.where('completedAt').above(cutoff.toISOString()).toArray();
}

export async function saveSessionResult(
lessonId: string,
track: string,
Expand Down Expand Up @@ -50,3 +68,57 @@ export async function saveSessionResult(
await db.lessonProgress.put(progress);
});
}

export async function exportProgress(): Promise<ProgressExport> {
const [settings, lessonProgress, sessions] = await Promise.all([
db.settings.get('default'),
db.lessonProgress.toArray(),
db.sessions.toArray(),
]);

if (!settings) {
throw new Error('Settings not found');
}

return {
version: 1,
exportedAt: new Date().toISOString(),
settings,
lessonProgress,
sessions,
};
}

export async function importProgress(data: ProgressExport): Promise<void> {
if (data.version !== 1) {
throw new Error('Unsupported export version');
}

await db.transaction('rw', db.settings, db.lessonProgress, db.sessions, async () => {
await db.settings.put(data.settings);
await db.lessonProgress.clear();
await db.sessions.clear();
await db.lessonProgress.bulkPut(data.lessonProgress);
await db.sessions.bulkPut(data.sessions);
});
}

export async function resetProgress(trackId?: string): Promise<void> {
if (!trackId) {
await db.transaction('rw', db.lessonProgress, db.sessions, async () => {
await db.lessonProgress.clear();
await db.sessions.clear();
});
return;
}

const progress = await db.lessonProgress.where('track').equals(trackId).toArray();
const lessonIds = new Set(progress.map((p) => p.lessonId));

await db.transaction('rw', db.lessonProgress, db.sessions, async () => {
await db.lessonProgress.where('track').equals(trackId).delete();
const sessions = await db.sessions.toArray();
const toDelete = sessions.filter((s) => lessonIds.has(s.lessonId)).map((s) => s.id);
await db.sessions.bulkDelete(toDelete);
});
}
14 changes: 14 additions & 0 deletions apps/web/src/features/catalog/lesson-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,17 @@ export function getLessonsForModule(trackId: string, moduleId: string, locale: L
export function buildLessonId(trackId: string, moduleId: string, locale: Locale, fileSlug: string): string {
return `${trackId}/${moduleId}/${locale}/${fileSlug}`;
}

export function getLessonCandidates(locale: Locale) {
return getAllLessons()
.filter((l) => l.locale === locale)
.map((l) => ({
lessonId: buildLessonId(l.track, l.module, l.locale, l.id),
title: l.title,
skills: l.skills,
difficulty: l.difficulty,
keyboardHints: l.hints?.keyboard,
track: l.track,
module: l.module,
}));
}
Loading
Loading