diff --git a/js/utils/xp_client.js b/js/utils/xp_client.js new file mode 100644 index 0000000..8270840 --- /dev/null +++ b/js/utils/xp_client.js @@ -0,0 +1,461 @@ +/** + * xp_client.js + * + * Browser-side XP client. Drop this next to your existing JS files and import + * it in main.js (or index.js): + * + * import { XPClient, XPWidget } from './xp_client.js'; + * + * The XPWidget auto-mounts a floating XP badge into the DOM and listens to + * store events so it stays in sync without any extra plumbing. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// XPClient — thin wrapper around /api/xp endpoints +// ───────────────────────────────────────────────────────────────────────────── + +export class XPClient { + /** + * @param {object} opts + * @param {string} [opts.baseUrl='/api/xp'] + * @param {string} [opts.userId='default'] + * @param {function} [opts.onXpChange] — called with (newTotal, delta, reason, levelInfo) + */ + constructor(opts = {}) { + this.baseUrl = opts.baseUrl || '/api/xp'; + this.userId = opts.userId || 'default'; + this.onXpChange = opts.onXpChange || null; + + // Local cache so we can derive deltas + this._xp = 0; + this._level = 1; + this._ready = false; + + // Study-time session state + this._studySessionToken = null; + this._studySessionStart = null; + this._studySessionTaskId = null; + this._studyFlushTimer = null; + } + + // ── Lifecycle ─────────────────────────────────────────────────────────────── + + /** Fetch current XP and warm the cache. */ + async init() { + try { + const data = await this._get(''); + this._apply(data); + this._ready = true; + return data; + } catch (e) { + console.warn('[XPClient] init failed:', e.message); + return null; + } + } + + // ── Public API ────────────────────────────────────────────────────────────── + + /** Fetch fresh XP total from server. */ + async fetchXp() { + const data = await this._get(''); + this._apply(data); + return data; + } + + /** + * Award XP for a completed task. + * @param {string|number} taskId + * @param {number} [xpOverride] override default 20 XP + */ + async taskDone(taskId, xpOverride) { + const body = { user_id: this.userId, task_id: String(taskId) }; + if (xpOverride) body.xp_override = xpOverride; + const data = await this._post('/task-done', body); + if (data.xp_earned > 0) this._notify(data, 'task_done'); + return data; + } + + /** + * Award 50 XP for completing a topic/subject. + * @param {string|number} topicId + * @param {string} [topicName] + */ + async topicDone(topicId, topicName) { + const data = await this._post('/topic-done', { + user_id: this.userId, + topic_id: String(topicId), + topic_name: topicName || '', + }); + if (data.xp_earned > 0) this._notify(data, 'topic_done'); + return data; + } + + /** + * Award XP for completing notes. + * @param {string|number} noteId + * @param {number} [xpOverride] + */ + async notesDone(noteId, xpOverride) { + const body = { user_id: this.userId, note_id: String(noteId) }; + if (xpOverride) body.xp_override = xpOverride; + const data = await this._post('/notes-done', body); + if (data.xp_earned > 0) this._notify(data, 'notes_done'); + return data; + } + + // ── Study-time session helpers ────────────────────────────────────────────── + + /** + * Call when user starts studying. + * @param {string|number} [taskId] — optional task being studied + */ + startStudySession(taskId = null) { + if (this._studySessionToken) return; // already running + this._studySessionToken = `sess_${Date.now()}_${Math.random().toString(36).slice(2)}`; + this._studySessionStart = Date.now(); + this._studySessionTaskId = taskId; + + // Flush every 5 minutes so XP isn't lost on tab close + this._studyFlushTimer = setInterval(() => this._flushStudySession(false), 5 * 60 * 1000); + + // Flush on page hide/close + this._pageHideHandler = () => this._flushStudySession(true); + window.addEventListener('pagehide', this._pageHideHandler); + window.addEventListener('beforeunload', this._pageHideHandler); + + console.debug('[XPClient] study session started', this._studySessionToken); + } + + /** Call when user stops/pauses studying. Returns XP earned. */ + async stopStudySession() { + if (!this._studySessionToken) return 0; + clearInterval(this._studyFlushTimer); + window.removeEventListener('pagehide', this._pageHideHandler); + window.removeEventListener('beforeunload', this._pageHideHandler); + return this._flushStudySession(false, true); + } + + /** Internal: commit elapsed study time to server. */ + async _flushStudySession(useBeacon = false, clearSession = false) { + if (!this._studySessionToken || !this._studySessionStart) return 0; + + const elapsedMs = Date.now() - this._studySessionStart; + const minutes = Math.floor(elapsedMs / 60000); + if (minutes < 1) return 0; + + const payload = { + user_id: this.userId, + minutes, + session_token: this._studySessionToken, + task_id: this._studySessionTaskId, + }; + + if (clearSession) { + this._studySessionToken = null; + this._studySessionStart = null; + this._studySessionTaskId = null; + } else { + // Slide start forward so we don't double-count next flush + this._studySessionStart = Date.now(); + } + + if (useBeacon && navigator.sendBeacon) { + navigator.sendBeacon( + `${this.baseUrl}/study-time`, + new Blob([JSON.stringify(payload)], { type: 'application/json' }) + ); + return minutes * 2; + } + + try { + const data = await this._post('/study-time', payload); + if (data.xp_earned > 0) this._notify(data, 'study_time'); + return data.xp_earned || 0; + } catch (e) { + console.warn('[XPClient] study flush failed:', e.message); + return 0; + } + } + + // ── Internals ─────────────────────────────────────────────────────────────── + + _apply(data) { + this._xp = data.xp ?? this._xp; + this._level = data.level ?? this._level; + } + + _notify(data, reason) { + const prev = this._xp; + this._apply(data); + const delta = data.xp_earned || (data.xp - prev); + if (this.onXpChange) { + this.onXpChange(data.xp, delta, reason, data); + } + // Dispatch custom DOM event so any widget can listen + window.dispatchEvent(new CustomEvent('xp:change', { + detail: { xp: data.xp, delta, reason, level: data.level, ...data } + })); + } + + async _get(path) { + const r = await fetch(`${this.baseUrl}${path}?user_id=${encodeURIComponent(this.userId)}`); + if (!r.ok) throw new Error(`XP API ${r.status}`); + return r.json(); + } + + async _post(path, body) { + const r = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) throw new Error(`XP API ${r.status}`); + return r.json(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// XPWidget — floating badge that auto-updates +// ───────────────────────────────────────────────────────────────────────────── + +export class XPWidget { + /** + * @param {XPClient} client + * @param {object} [opts] + * @param {string} [opts.position] — 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' + */ + constructor(client, opts = {}) { + this.client = client; + this.position = opts.position || 'top-right'; + this._el = null; + this._animating = false; + } + + /** Mount the widget into the DOM. */ + mount() { + this._injectStyles(); + this._el = document.createElement('div'); + this._el.className = `xp-widget xp-widget--${this.position}`; + this._el.innerHTML = this._template(0, 1, 0, 100, 0); + document.body.appendChild(this._el); + + // Listen for XP changes + window.addEventListener('xp:change', (e) => { + this._update(e.detail); + }); + + // Initial data + this.client.fetchXp().then(data => { + if (data) this._update({ ...data, delta: 0 }); + }); + + return this; + } + + _template(xp, level, xpInto, xpForNext, pct) { + return ` +
+ + `; + } + + _update(data) { + if (!this._el) return; + const { xp = 0, level = 1, xp_into_level = 0, xp_for_next_level = 100, progress_pct = 0, delta = 0 } = data; + + this._el.querySelector('.xp-widget__level').textContent = `Lv ${level}`; + this._el.querySelector('.xp-widget__total').textContent = `${xp.toLocaleString()} XP`; + this._el.querySelector('.xp-widget__bar-fill').style.width = `${progress_pct}%`; + this._el.querySelector('.xp-widget__sub').textContent = `${xp_into_level} / ${xp_for_next_level} XP`; + + if (delta > 0) { + this._showDelta(delta, data.reason); + } + } + + _showDelta(delta, reason) { + if (this._animating) return; + this._animating = true; + const deltaEl = this._el.querySelector('.xp-widget__delta'); + const label = REASON_LABELS[reason] || ''; + deltaEl.textContent = `+${delta} XP ${label}`; + deltaEl.classList.add('xp-widget__delta--show'); + setTimeout(() => { + deltaEl.classList.remove('xp-widget__delta--show'); + this._animating = false; + }, 2200); + } + + _injectStyles() { + if (document.getElementById('xp-widget-styles')) return; + const posMap = { + 'top-right': 'top:80px; right:24px;', + 'top-left': 'top:80px; left:24px;', + 'bottom-right': 'bottom:24px; right:24px;', + 'bottom-left': 'bottom:24px; left:24px;', + }; + const style = document.createElement('style'); + style.id = 'xp-widget-styles'; + style.textContent = ` + .xp-widget { + position: fixed; + ${posMap[this.position] || posMap['top-right']} + z-index: 9000; + min-width: 170px; + background: var(--color-background-primary, #fff); + border: 1px solid var(--color-border-tertiary, rgba(0,0,0,.08)); + border-radius: 14px; + padding: 10px 14px 8px; + box-shadow: 0 8px 32px rgba(0,0,0,.12); + font-family: 'Inter', system-ui, sans-serif; + user-select: none; + backdrop-filter: blur(10px); + transition: transform .2s ease, box-shadow .2s ease; + } + .xp-widget:hover { + transform: translateY(-2px); + box-shadow: 0 12px 40px rgba(0,0,0,.16); + } + .xp-widget__inner {} + .xp-widget__top { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 6px; + } + .xp-widget__level { + font-size: 11px; + font-weight: 700; + color: var(--color-text-tertiary, #9c9a92); + text-transform: uppercase; + letter-spacing: .06em; + } + .xp-widget__total { + font-size: 14px; + font-weight: 700; + color: var(--color-text-primary, #1a1a18); + } + .xp-widget__bar-wrap { + height: 5px; + border-radius: 99px; + background: var(--color-border-tertiary, rgba(0,0,0,.08)); + overflow: hidden; + margin-bottom: 4px; + } + .xp-widget__bar-fill { + height: 100%; + border-radius: 99px; + background: linear-gradient(90deg, var(--color-text-success, #166534), #4ade80); + transition: width 1s cubic-bezier(.16,1,.3,1); + } + .xp-widget__sub { + font-size: 10px; + color: var(--color-text-tertiary, #9c9a92); + font-weight: 500; + } + .xp-widget__delta { + position: absolute; + top: -8px; + right: 8px; + font-size: 12px; + font-weight: 700; + color: var(--color-text-success, #166534); + background: var(--color-background-success, #eaf3de); + border: 1px solid var(--color-border-success, rgba(22,101,52,.35)); + border-radius: 99px; + padding: 2px 8px; + pointer-events: none; + opacity: 0; + transform: translateY(4px); + transition: opacity .25s ease, transform .25s ease; + white-space: nowrap; + } + .xp-widget__delta--show { + opacity: 1; + transform: translateY(-4px); + animation: xpDeltaLife 2.2s ease forwards; + } + @keyframes xpDeltaLife { + 0% { opacity: 0; transform: translateY(4px); } + 15% { opacity: 1; transform: translateY(-6px); } + 75% { opacity: 1; transform: translateY(-6px); } + 100% { opacity: 0; transform: translateY(-14px); } + } + `; + document.head.appendChild(style); + } +} + +const REASON_LABELS = { + task_done: '📝 Task done', + topic_done: '📚 Topic done', + notes_done: '🗒️ Notes done', + study_time: '⏱ Study time', +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Store integration helper +// Call `integrateXpWithStore(store, xpClient)` after your store is set up. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Wires the XP client into the existing store so that task completions + * automatically trigger XP awards. + * + * @param {object} store — your existing store object + * @param {XPClient} xpClient + */ +export function integrateXpWithStore(store, xpClient) { + // Patch toggleTaskStatus + const _origToggle = store.toggleTaskStatus.bind(store); + store.toggleTaskStatus = async function (taskId) { + const task = store.tasks.find(t => String(t.id) === String(taskId)); + const wasDone = task && task.status === 'Done'; + _origToggle(taskId); + + // Re-find after toggle + const updated = store.tasks.find(t => String(t.id) === String(taskId)); + if (updated && updated.status === 'Done' && !wasDone) { + try { + await xpClient.taskDone(taskId); + } catch (e) { + console.warn('[XP] taskDone award failed', e); + } + } + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Convenience: attach XP to Focus Mode timer +// Call after timer elements exist in DOM. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * @param {XPClient} xpClient + * @param {string} [taskId] — current focused task id + */ +export function attachXpToTimer(xpClient, getActiveTaskId) { + const startBtn = document.getElementById('timer-start-btn'); + const pauseBtn = document.getElementById('timer-pause-btn'); + const resetBtn = document.getElementById('timer-reset-btn'); + + if (!startBtn) return; + + startBtn.addEventListener('click', () => { + const tid = getActiveTaskId ? getActiveTaskId() : null; + xpClient.startStudySession(tid); + }); + + const stopSession = () => xpClient.stopStudySession(); + if (pauseBtn) pauseBtn.addEventListener('click', stopSession); + if (resetBtn) resetBtn.addEventListener('click', stopSession); +} \ No newline at end of file diff --git a/js/utils/xp_integration.js b/js/utils/xp_integration.js new file mode 100644 index 0000000..4e2ca68 --- /dev/null +++ b/js/utils/xp_integration.js @@ -0,0 +1,131 @@ +/** + * xp_integration.js + * + * Drop-in integration patch for your existing main.js / index.js. + * + * USAGE — add these lines at the top of your main.js: + * + * import { XPClient, XPWidget, integrateXpWithStore, attachXpToTimer } from './xp_client.js'; + * import { initXP } from './xp_integration.js'; + * + * Then call once after `store.fetchInitialData()`: + * + * const xp = initXP(store); + * + * That's it. The widget mounts, task completions earn XP, and + * the Focus Mode timer auto-tracks study time. + */ + +import { XPClient, XPWidget, integrateXpWithStore, attachXpToTimer } from './xp_client.js'; +import { Toast } from './utils/toast.js'; + +/** + * Initialise the XP subsystem and return the client for advanced use. + * + * @param {object} store — your existing store object + * @param {object} [opts] + * @param {string} [opts.userId] — user identifier (default 'default') + * @returns {XPClient} + */ +export function initXP(store, opts = {}) { + const xpClient = new XPClient({ + userId: opts.userId || 'default', + onXpChange: (newTotal, delta, reason, data) => { + if (delta <= 0) return; + + // Level-up toast + const prevLevel = xpClient._level; + if (data.level > prevLevel) { + Toast.show(`🎉 Level up! You reached Level ${data.level}!`, 'success'); + } else { + const label = { + task_done: '📝 Task completed', + topic_done: '📚 Topic completed', + notes_done: '🗒️ Notes completed', + study_time: '⏱ Study session', + }[reason] || 'Activity'; + Toast.show(`+${delta} XP — ${label}`, 'success'); + } + }, + }); + + // Mount the floating badge + new XPWidget(xpClient, { position: 'top-right' }).mount(); + + // Wire store task toggles → XP awards + integrateXpWithStore(store, xpClient); + + // Wire Focus Mode timer → study-time XP + // (runs after DOM is ready; activeFocusTaskId is the module-level variable in main.js) + document.addEventListener('DOMContentLoaded', () => { + attachXpToTimer(xpClient, () => { + // Return the currently focused task id — adjust if your variable is named differently + return window._activeFocusTaskId || null; + }); + }); + + // Warm cache + xpClient.init(); + + return xpClient; +} + +/* +───────────────────────────────────────────────────────────────────────────── + SERVER SETUP (server.js / app.js) +───────────────────────────────────────────────────────────────────────────── + + const xpRouter = require('./xp_system'); // CommonJS + xpRouter.setDb(db); // pass your existing SQLite db handle + app.use('/api/xp', xpRouter); + + That's all the server-side wiring needed. + +───────────────────────────────────────────────────────────────────────────── + API ENDPOINTS SUMMARY +───────────────────────────────────────────────────────────────────────────── + + GET /api/xp → { user_id, xp, level, xp_into_level, xp_for_next_level, progress_pct } + GET /api/xp/ledger?limit=50 → { xp, level, transactions: [...] } + + POST /api/xp/award → generic award { amount, reason, [metadata] } + POST /api/xp/study-time → { minutes, [session_token], [task_id] } → 2 XP/min + POST /api/xp/task-done → { task_id, [xp_override] } → 20 XP default + POST /api/xp/topic-done → { topic_id, [topic_name] } → 50 XP fixed + POST /api/xp/notes-done → { note_id, [xp_override] } → 30 XP default + + All POST responses include updated { xp, level, progress_pct, … }. + +───────────────────────────────────────────────────────────────────────────── + XP RATES (configurable in xp_system.js › XP_RATES) +───────────────────────────────────────────────────────────────────────────── + + study_time → 2 XP per minute (capped at 120 min/call) + task_done → 20 XP (overridable per call via xp_override) + topic_done → 50 XP (fixed, once per topic per day) + notes_done → 30 XP (overridable per call via xp_override) + +───────────────────────────────────────────────────────────────────────────── + ANTI-ABUSE GUARDS +───────────────────────────────────────────────────────────────────────────── + + • task_done — each task_id can only earn XP once (lifetime). + • topic_done — each topic_id can only earn XP once per calendar day. + • notes_done — each note_id can only earn XP once (lifetime). + • study_time — session_token dedup prevents double-submission. + Hard cap: max 120 minutes per API call. + +───────────────────────────────────────────────────────────────────────────── + LEVEL FORMULA +───────────────────────────────────────────────────────────────────────────── + + Level N requires N × 100 XP to advance. + Cumulative thresholds: Lv1→2: 100 XP | Lv2→3: 200 XP | Lv3→4: 300 XP … + + Example: + 0 XP → Level 1 + 100 XP → Level 2 + 300 XP → Level 3 + 600 XP → Level 4 + 1000XP → Level 5 +*/ \ No newline at end of file diff --git a/js/utils/xp_system.js b/js/utils/xp_system.js new file mode 100644 index 0000000..1c6aada --- /dev/null +++ b/js/utils/xp_system.js @@ -0,0 +1,382 @@ +/** + * xp_system.js + * + * XP System — backend module (Express router + SQLite helpers). + * Mount in your main server file: + * const xpRouter = require('./xp_system'); + * app.use('/api/xp', xpRouter); + * + * The module creates its own `xp_ledger` and `xp_sessions` tables on first run. + * + * XP Rules: + * study_time → 2 XP per minute (floored, minimum 1 min to count) + * task_done → configurable (default 20 XP) + * topic_done → 50 XP (fixed) + * notes_done → configurable (default 30 XP) + */ + +'use strict'; + +const express = require('express'); +const router = express.Router(); + +// ── DB bootstrap ────────────────────────────────────────────────────────────── +// We reuse whatever db handle the parent app exposes, or fall back to a local +// SQLite instance via `better-sqlite3` / `sqlite3`. +// To integrate: pass your existing `db` via `xpRouter.setDb(db)` after require. + +let db = null; // will be set by parent or auto-initialised + +const XP_RATES = { + study_time: 2, // per minute + task_done: 20, // default, overridable per call + topic_done: 50, // fixed + notes_done: 30, // default, overridable per call +}; + +// Anti-abuse: cap time-based XP to 120 min per session call +const MAX_STUDY_MINUTES_PER_CALL = 120; + +// ── Middleware: require db ───────────────────────────────────────────────────── +function requireDb(req, res, next) { + if (!db) { + return res.status(503).json({ error: 'Database not initialised. Call xpRouter.setDb(db) first.' }); + } + next(); +} + +// ── Schema helpers ───────────────────────────────────────────────────────────── +function ensureTables() { + db.run(` + CREATE TABLE IF NOT EXISTS xp_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + amount INTEGER NOT NULL, + reason TEXT NOT NULL, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + + db.run(` + CREATE TABLE IF NOT EXISTS xp_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL DEFAULT 'default', + session_token TEXT NOT NULL UNIQUE, + task_id TEXT, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + ended_at TEXT, + minutes INTEGER, + xp_awarded INTEGER DEFAULT 0, + committed INTEGER DEFAULT 0 + ) + `); +} + +// ── XP helpers ───────────────────────────────────────────────────────────────── +function getTotalXp(userId) { + return new Promise((resolve, reject) => { + db.get( + `SELECT COALESCE(SUM(amount), 0) AS total FROM xp_ledger WHERE user_id = ?`, + [userId], + (err, row) => (err ? reject(err) : resolve(row.total)) + ); + }); +} + +function getLedger(userId, limit = 50) { + return new Promise((resolve, reject) => { + db.all( + `SELECT id, amount, reason, metadata, created_at + FROM xp_ledger + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT ?`, + [userId, limit], + (err, rows) => (err ? reject(err) : resolve(rows)) + ); + }); +} + +function awardXp(userId, amount, reason, metadata = null) { + return new Promise((resolve, reject) => { + db.run( + `INSERT INTO xp_ledger (user_id, amount, reason, metadata) VALUES (?, ?, ?, ?)`, + [userId, amount, reason, metadata ? JSON.stringify(metadata) : null], + function (err) { + if (err) return reject(err); + resolve({ ledger_id: this.lastID, amount, reason }); + } + ); + }); +} + +// ── Routes ───────────────────────────────────────────────────────────────────── + +/** + * GET /api/xp + * Returns current XP total + level info for a user. + * Query param: user_id (default: 'default') + */ +router.get('/', requireDb, async (req, res) => { + try { + const userId = (req.query.user_id || 'default').toString().trim(); + const total = await getTotalXp(userId); + res.json({ user_id: userId, xp: total, ...computeLevel(total) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/** + * GET /api/xp/ledger + * Returns last N XP transactions. + */ +router.get('/ledger', requireDb, async (req, res) => { + try { + const userId = (req.query.user_id || 'default').toString().trim(); + const limit = Math.min(parseInt(req.query.limit) || 50, 200); + const rows = await getLedger(userId, limit); + const total = await getTotalXp(userId); + res.json({ user_id: userId, xp: total, transactions: rows, ...computeLevel(total) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/** + * POST /api/xp/award + * Generic XP award endpoint. + * Body: { user_id?, amount, reason, metadata? } + * amount must be a positive integer ≤ 9999. + */ +router.post('/award', requireDb, async (req, res) => { + try { + const userId = (req.body.user_id || 'default').toString().trim(); + const amount = parseInt(req.body.amount); + const reason = (req.body.reason || 'manual').toString().trim().substring(0, 120); + const meta = req.body.metadata || null; + + if (!Number.isInteger(amount) || amount < 1 || amount > 9999) { + return res.status(400).json({ error: 'amount must be an integer 1–9999' }); + } + + const entry = await awardXp(userId, amount, reason, meta); + const newTotal = await getTotalXp(userId); + res.json({ ok: true, ...entry, xp: newTotal, ...computeLevel(newTotal) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/** + * POST /api/xp/study-time + * Awards XP for study time. + * Body: { user_id?, minutes, task_id? } + * 1 minute = 2 XP. Capped at MAX_STUDY_MINUTES_PER_CALL. + * + * Anti-abuse: each session_token can only be committed once. + */ +router.post('/study-time', requireDb, async (req, res) => { + try { + const userId = (req.body.user_id || 'default').toString().trim(); + const rawMins = parseFloat(req.body.minutes) || 0; + const taskId = req.body.task_id || null; + const token = req.body.session_token || null; + + const minutes = Math.min(Math.floor(rawMins), MAX_STUDY_MINUTES_PER_CALL); + + if (minutes < 1) { + return res.status(400).json({ error: 'Minimum 1 full minute required to earn XP.' }); + } + + // Dedup by session_token if provided + if (token) { + const existing = await new Promise((resolve, reject) => + db.get('SELECT committed FROM xp_sessions WHERE session_token = ?', [token], + (err, row) => (err ? reject(err) : resolve(row))) + ); + if (existing && existing.committed) { + return res.status(409).json({ error: 'Session already committed.' }); + } + } + + const xpEarned = minutes * XP_RATES.study_time; + const meta = { minutes, task_id: taskId, session_token: token }; + + // Persist session record + if (token) { + db.run(`INSERT OR IGNORE INTO xp_sessions + (user_id, session_token, task_id, minutes, xp_awarded, committed) + VALUES (?, ?, ?, ?, ?, 1)`, + [userId, token, taskId, minutes, xpEarned]); + db.run(`UPDATE xp_sessions SET committed = 1, ended_at = datetime('now'), + minutes = ?, xp_awarded = ? + WHERE session_token = ?`, + [minutes, xpEarned, token]); + } + + const entry = await awardXp(userId, xpEarned, 'study_time', meta); + const newTotal = await getTotalXp(userId); + + res.json({ + ok: true, + xp_earned: xpEarned, + minutes, + xp: newTotal, + ...computeLevel(newTotal), + }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/** + * POST /api/xp/task-done + * Awards XP when a task is completed. + * Body: { user_id?, task_id, xp_override? } + * + * Anti-abuse: each task_id can only award task_done XP once. + */ +router.post('/task-done', requireDb, async (req, res) => { + try { + const userId = (req.body.user_id || 'default').toString().trim(); + const taskId = req.body.task_id ? String(req.body.task_id) : null; + const xpAmt = Math.min(parseInt(req.body.xp_override) || XP_RATES.task_done, 200); + + if (!taskId) return res.status(400).json({ error: 'task_id required' }); + + // Check if already awarded for this task + const alreadyAwarded = await new Promise((resolve, reject) => + db.get( + `SELECT id FROM xp_ledger + WHERE user_id = ? AND reason = 'task_done' + AND json_extract(metadata, '$.task_id') = ?`, + [userId, taskId], + (err, row) => (err ? reject(err) : resolve(!!row)) + ) + ); + + if (alreadyAwarded) { + const total = await getTotalXp(userId); + return res.json({ ok: true, xp_earned: 0, already_awarded: true, xp: total, ...computeLevel(total) }); + } + + const entry = await awardXp(userId, xpAmt, 'task_done', { task_id: taskId }); + const newTotal = await getTotalXp(userId); + + res.json({ ok: true, xp_earned: xpAmt, xp: newTotal, ...computeLevel(newTotal) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/** + * POST /api/xp/topic-done + * Awards 50 XP for completing a topic (subject). + * Body: { user_id?, topic_id, topic_name? } + * + * Anti-abuse: each topic_id can only award topic_done XP once per day. + */ +router.post('/topic-done', requireDb, async (req, res) => { + try { + const userId = (req.body.user_id || 'default').toString().trim(); + const topicId = req.body.topic_id ? String(req.body.topic_id) : null; + const topicName = req.body.topic_name ? String(req.body.topic_name) : 'Unknown topic'; + + if (!topicId) return res.status(400).json({ error: 'topic_id required' }); + + // One award per topic per calendar day + const alreadyToday = await new Promise((resolve, reject) => + db.get( + `SELECT id FROM xp_ledger + WHERE user_id = ? AND reason = 'topic_done' + AND json_extract(metadata, '$.topic_id') = ? + AND date(created_at) = date('now')`, + [userId, topicId], + (err, row) => (err ? reject(err) : resolve(!!row)) + ) + ); + + if (alreadyToday) { + const total = await getTotalXp(userId); + return res.json({ ok: true, xp_earned: 0, already_awarded: true, xp: total, ...computeLevel(total) }); + } + + const entry = await awardXp(userId, XP_RATES.topic_done, 'topic_done', { topic_id: topicId, topic_name: topicName }); + const newTotal = await getTotalXp(userId); + + res.json({ ok: true, xp_earned: XP_RATES.topic_done, xp: newTotal, ...computeLevel(newTotal) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +/** + * POST /api/xp/notes-done + * Awards XP for completing notes. + * Body: { user_id?, note_id, xp_override? } + */ +router.post('/notes-done', requireDb, async (req, res) => { + try { + const userId = (req.body.user_id || 'default').toString().trim(); + const noteId = req.body.note_id ? String(req.body.note_id) : null; + const xpAmt = Math.min(parseInt(req.body.xp_override) || XP_RATES.notes_done, 200); + + if (!noteId) return res.status(400).json({ error: 'note_id required' }); + + const alreadyAwarded = await new Promise((resolve, reject) => + db.get( + `SELECT id FROM xp_ledger + WHERE user_id = ? AND reason = 'notes_done' + AND json_extract(metadata, '$.note_id') = ?`, + [userId, noteId], + (err, row) => (err ? reject(err) : resolve(!!row)) + ) + ); + + if (alreadyAwarded) { + const total = await getTotalXp(userId); + return res.json({ ok: true, xp_earned: 0, already_awarded: true, xp: total, ...computeLevel(total) }); + } + + const entry = await awardXp(userId, xpAmt, 'notes_done', { note_id: noteId }); + const newTotal = await getTotalXp(userId); + + res.json({ ok: true, xp_earned: xpAmt, xp: newTotal, ...computeLevel(newTotal) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// ── Level computation ────────────────────────────────────────────────────────── +/** + * Computes level from total XP. + * Threshold formula: each level requires level * 100 XP (cumulative). + * Level 1: 0–99, Level 2: 100–299, Level 3: 300–599, … + */ +function computeLevel(xp) { + let level = 1; + let threshold = 0; + while (xp >= threshold + level * 100) { + threshold += level * 100; + level++; + } + const xpIntoLevel = xp - threshold; + const xpForNext = level * 100; + const progress = Math.min(Math.floor((xpIntoLevel / xpForNext) * 100), 100); + return { level, xp_into_level: xpIntoLevel, xp_for_next_level: xpForNext, progress_pct: progress }; +} + +// ── Public interface ─────────────────────────────────────────────────────────── +router.setDb = function (database) { + db = database; + ensureTables(); +}; + +router.computeLevel = computeLevel; +router.awardXp = awardXp; +router.getTotalXp = getTotalXp; +router.XP_RATES = XP_RATES; + +module.exports = router; \ No newline at end of file