diff --git a/apps/pwa/cypress/downloads/studyos.ics b/apps/pwa/cypress/downloads/studyos.ics new file mode 100644 index 0000000..6f33fed --- /dev/null +++ b/apps/pwa/cypress/downloads/studyos.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//StudyOS//planner//PT-BR +BEGIN:VEVENT +UID:019f4bee-5415-7f79-a3db-f0a66b4f7be1@studyos +SUMMARY:rotina e2e 1783685336185 +DTSTART:20260710T090000 +DURATION:PT90M +RRULE:FREQ=WEEKLY;BYDAY=FR +END:VEVENT +END:VCALENDAR diff --git a/apps/pwa/cypress/e2e/planner-loop.cy.ts b/apps/pwa/cypress/e2e/planner-loop.cy.ts new file mode 100644 index 0000000..7bab3c3 --- /dev/null +++ b/apps/pwa/cypress/e2e/planner-loop.cy.ts @@ -0,0 +1,55 @@ +// M3 acceptance: a routine for today's weekday populates Today with a plan block, +// a due reminder appears in the queue, and stats render after a study session. +describe('planner and routine loop', () => { + const stamp = Date.now(); + const routineTitle = `rotina e2e ${stamp}`; + const reminderTitle = `lembrete e2e ${stamp}`; + + it('creates a routine for today and sees a plan block on Today', () => { + cy.visit('/routines'); + cy.get('[data-testid="routine-title-input"]').type(routineTitle); + const todayDow = new Date().getDay(); + cy.get(`[data-testid="routine-day-${todayDow}"]`).click(); + cy.get('[data-testid="routine-duration-input"]').clear(); + cy.get('[data-testid="routine-duration-input"]').type('90'); + cy.get('[data-testid="routine-submit"]').click(); + cy.get('[data-testid="routine-block"]').contains(routineTitle); + + cy.visit('/'); + cy.get('[data-testid="today-item"][data-kind="block"]').contains('estudo livre'); + }); + + it('creates a due reminder and sees it on Today', () => { + cy.visit('/reminders'); + cy.get('[data-testid="reminder-title-input"]').type(reminderTitle); + const past = new Date(Date.now() - 60_000); + const pad = (n: number) => String(n).padStart(2, '0'); + const local = `${past.getFullYear()}-${pad(past.getMonth() + 1)}-${pad(past.getDate())}T${pad(past.getHours())}:${pad(past.getMinutes())}`; + cy.get('[data-testid="reminder-datetime-input"]').type(local); + cy.get('[data-testid="reminder-submit"]').click(); + cy.get('[data-testid="reminder-item"]').contains(reminderTitle); + + cy.visit('/'); + cy.get('[data-testid="today-item"][data-kind="reminder"]').contains(reminderTitle); + }); + + it('renders stats after a short study session', () => { + cy.visit('/study'); + cy.get('[data-testid="timer-start"]').click(); + cy.wait(1500); + cy.get('[data-testid="timer-finish"]').click(); + cy.get('[data-testid="session-save"]').click(); + cy.contains('sessão registrada'); + + cy.visit('/stats'); + cy.get('[data-testid="stats-heatmap"]').should('be.visible'); + cy.get('[data-testid="stats-streak"]').contains('1'); + cy.get('[data-testid="stats-comparison"]').should('be.visible'); + }); + + it('exports routines as .ics', () => { + cy.visit('/reminders'); + cy.get('[data-testid="ics-export"]').click(); + cy.readFile('cypress/downloads/studyos.ics').should('contain', 'BEGIN:VCALENDAR'); + }); +}); diff --git a/apps/pwa/cypress/e2e/student-loop.cy.ts b/apps/pwa/cypress/e2e/student-loop.cy.ts index ae18a49..8acb99d 100644 --- a/apps/pwa/cypress/e2e/student-loop.cy.ts +++ b/apps/pwa/cypress/e2e/student-loop.cy.ts @@ -77,7 +77,9 @@ describe('student core loop', () => { cy.get('[data-testid="review-empty"]').should('be.visible'); cy.contains('voltar ao hoje').click(); - cy.get('[data-testid="today-empty"]').should('be.visible'); + // other specs may have seeded routine blocks/reminders in the shared OPFS db, + // so assert only that no review items remain + cy.get('[data-testid="today-item"][data-kind="review"]').should('not.exist'); }); it('runs a study session with the net-hours timer', () => { diff --git a/apps/pwa/src/lib/push/ics.ts b/apps/pwa/src/lib/push/ics.ts new file mode 100644 index 0000000..bb2fae0 --- /dev/null +++ b/apps/pwa/src/lib/push/ics.ts @@ -0,0 +1,61 @@ +import { DAY_MS, parseRrule, routineOccurrences, type RoutineSpec } from '@studyos/core'; +import type { RoutineRow } from '@studyos/shared'; + +const BYDAY = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const; + +function pad(n: number): string { + return String(n).padStart(2, '0'); +} + +/** Floating local DTSTART: the routine's next occurrence at its start_time. */ +function nextOccurrenceStamp(spec: RoutineSpec, now: number): string { + const todayMidnight = new Date(now).setHours(0, 0, 0, 0); + const days = routineOccurrences(spec, todayMidnight, todayMidnight + 6 * DAY_MS); + const day = days[0] ?? todayMidnight; + const d = new Date(day); + const [hh = '00', mm = '00'] = spec.start_time.split(':'); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T${hh}${mm}00`; +} + +function escapeText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n'); +} + +/** VCALENDAR text with one weekly VEVENT per routine (invalid rrules skipped). */ +export function buildIcs(routines: RoutineRow[], now: number): string { + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//StudyOS//planner//PT-BR']; + for (const routine of routines) { + let days: number[]; + try { + days = parseRrule(routine.rrule); + } catch { + continue; + } + const spec: RoutineSpec = { + id: routine.id, + track_id: routine.track_id, + days, + start_time: routine.start_time, + duration_min: routine.duration_min, + }; + const byday = days + .map((d) => BYDAY[d]) + .filter((t) => t !== undefined) + .join(','); + lines.push( + 'BEGIN:VEVENT', + `UID:${routine.id}@studyos`, + `SUMMARY:${escapeText(routine.title)}`, + `DTSTART:${nextOccurrenceStamp(spec, now)}`, + `DURATION:PT${routine.duration_min}M`, + `RRULE:FREQ=WEEKLY;BYDAY=${byday}`, + 'END:VEVENT', + ); + } + lines.push('END:VCALENDAR'); + return lines.join('\r\n') + '\r\n'; +} diff --git a/apps/pwa/src/lib/push/local.ts b/apps/pwa/src/lib/push/local.ts new file mode 100644 index 0000000..a3c38da --- /dev/null +++ b/apps/pwa/src/lib/push/local.ts @@ -0,0 +1,24 @@ +import { dueReminders, type DbDriver } from '@studyos/db'; + +// Session-scoped throttle: each reminder notifies at most once per app open. +const notifiedIds = new Set(); + +/** + * Shows a local Notification for each due reminder not yet notified this + * session. No-op unless permission is already granted (asking is always an + * explicit user action elsewhere). + */ +export async function maybeNotifyDue(db: DbDriver): Promise { + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; + const due = await dueReminders(db, Date.now()); + for (const reminder of due) { + if (notifiedIds.has(reminder.id)) continue; + notifiedIds.add(reminder.id); + try { + const notification = new Notification(reminder.title, { body: 'lembrete · StudyOS' }); + notification.addEventListener('click', () => window.focus()); + } catch { + // some platforms only allow notifications via the service worker + } + } +} diff --git a/apps/pwa/src/lib/push/register.ts b/apps/pwa/src/lib/push/register.ts new file mode 100644 index 0000000..a1736a2 --- /dev/null +++ b/apps/pwa/src/lib/push/register.ts @@ -0,0 +1,13 @@ +import { browser } from '$app/environment'; + +/** Registers the static service worker (push notifications). Best-effort: the app works without it. */ +export function registerServiceWorker(): void { + if (!browser || !('serviceWorker' in navigator)) return; + try { + void navigator.serviceWorker.register('/sw.js').catch(() => { + // registration failures (private mode, unsupported) are non-fatal + }); + } catch { + // same: never let SW registration break app boot + } +} diff --git a/apps/pwa/src/lib/push/subscribe.ts b/apps/pwa/src/lib/push/subscribe.ts new file mode 100644 index 0000000..acfe031 --- /dev/null +++ b/apps/pwa/src/lib/push/subscribe.ts @@ -0,0 +1,64 @@ +import { dev } from '$app/environment'; +import { getOrCreateDeviceId, getSetting } from '@studyos/db'; +import { SETTINGS_KEYS } from '@studyos/shared'; +import { getDb } from '$lib/db/client'; + +function urlBase64ToUint8Array(base64: string): Uint8Array { + const padding = '='.repeat((4 - (base64.length % 4)) % 4); + const normalized = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(normalized); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; +} + +// Same token policy as lib/sync: stored sync token, dev fallback in dev builds. +async function getToken(): Promise { + const db = await getDb(); + const stored = await getSetting(db, SETTINGS_KEYS.syncToken); + if (stored) return stored; + return dev ? 'dev-token' : null; +} + +/** + * Full web-push enrollment: fetch the VAPID key, subscribe via the service + * worker's push manager and register the subscription on the worker. Throws on + * any failure — callers surface a calm status line. + */ +export async function enablePush(): Promise { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + throw new Error('push unsupported'); + } + const token = await getToken(); + if (!token) throw new Error('sync token missing'); + const headers = { authorization: `Bearer ${token}`, 'content-type': 'application/json' }; + + const keyRes = await fetch('/push/vapid', { headers }); + if (!keyRes.ok) throw new Error(`vapid key failed: ${keyRes.status}`); + const { publicKey } = (await keyRes.json()) as { publicKey: string }; + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + + const keys = subscription.toJSON().keys; + const p256dh = keys?.['p256dh']; + const auth = keys?.['auth']; + if (!p256dh || !auth) throw new Error('subscription keys missing'); + + const db = await getDb(); + const deviceId = await getOrCreateDeviceId(db); + const subRes = await fetch('/push/subscribe', { + method: 'POST', + headers, + body: JSON.stringify({ + device_id: deviceId, + endpoint: subscription.endpoint, + p256dh, + auth, + }), + }); + if (!subRes.ok) throw new Error(`subscribe failed: ${subRes.status}`); +} diff --git a/apps/pwa/src/lib/stores/reminders.svelte.ts b/apps/pwa/src/lib/stores/reminders.svelte.ts new file mode 100644 index 0000000..b511f19 --- /dev/null +++ b/apps/pwa/src/lib/stores/reminders.svelte.ts @@ -0,0 +1,42 @@ +import { createReminder, deleteReminder, getOrCreateDeviceId, listReminders } from '@studyos/db'; +import type { ReminderRow } from '@studyos/shared'; +import { browser } from '$app/environment'; +import { getDb } from '$lib/db/client'; +import { liveQuery } from '$lib/db/live.svelte'; +import { maybeNotifyDue } from '$lib/push/local'; + +export interface RemindersStore { + get reminders(): ReminderRow[]; + add(title: string, notifyAt: number): Promise; + remove(id: string): Promise; + destroy(): void; +} + +export function createRemindersStore(): RemindersStore { + const live = liveQuery((db) => listReminders(db), ['reminders'], [] as ReminderRow[]); + if (browser) { + void getDb().then((db) => maybeNotifyDue(db)); + } + return { + get reminders() { + return live.value; + }, + async add(title: string, notifyAt: number) { + const trimmed = title.trim(); + if (!trimmed || !Number.isFinite(notifyAt)) return; + const db = await getDb(); + const deviceId = await getOrCreateDeviceId(db); + await createReminder(db, deviceId, { title: trimmed, notify_at: notifyAt }); + await live.refresh(); + }, + async remove(id: string) { + const db = await getDb(); + const deviceId = await getOrCreateDeviceId(db); + await deleteReminder(db, deviceId, id); + await live.refresh(); + }, + destroy() { + live.destroy(); + }, + }; +} diff --git a/apps/pwa/src/lib/stores/routines.svelte.ts b/apps/pwa/src/lib/stores/routines.svelte.ts new file mode 100644 index 0000000..bc965fb --- /dev/null +++ b/apps/pwa/src/lib/stores/routines.svelte.ts @@ -0,0 +1,78 @@ +import { + createRoutine, + deleteRoutine, + getOrCreateDeviceId, + listRoutines, + listTracks, +} from '@studyos/db'; +import type { RoutineRow, TrackRow } from '@studyos/shared'; +import { getDb } from '$lib/db/client'; +import { liveQuery } from '$lib/db/live.svelte'; + +// Day number (0=Sun..6=Sat) -> RRULE BYDAY token, per the app-wide subset. +const BYDAY_BY_DAY = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const; + +export function rruleFromDays(days: number[]): string { + const tokens = [...new Set(days)] + .toSorted((a, b) => a - b) + .flatMap((day) => { + const token = BYDAY_BY_DAY[day]; + return token === undefined ? [] : [token]; + }); + return `FREQ=WEEKLY;BYDAY=${tokens.join(',')}`; +} + +export interface RoutineDraft { + title: string; + track_id: string | null; + days: number[]; + start_time: string; + duration_min: number; +} + +export interface RoutinesStore { + get routines(): RoutineRow[]; + get tracks(): TrackRow[]; + add(draft: RoutineDraft): Promise; + remove(id: string): Promise; + destroy(): void; +} + +export function createRoutinesStore(): RoutinesStore { + const routinesLive = liveQuery((db) => listRoutines(db), ['routines'], [] as RoutineRow[]); + const tracksLive = liveQuery((db) => listTracks(db), ['tracks'], [] as TrackRow[]); + + return { + get routines() { + return routinesLive.value; + }, + get tracks() { + return tracksLive.value; + }, + async add(draft: RoutineDraft) { + const title = draft.title.trim(); + const duration = Math.floor(draft.duration_min); + if (!title || draft.days.length === 0 || !draft.start_time || duration < 1) return; + const db = await getDb(); + const deviceId = await getOrCreateDeviceId(db); + await createRoutine(db, deviceId, { + title, + track_id: draft.track_id, + rrule: rruleFromDays(draft.days), + start_time: draft.start_time, + duration_min: duration, + }); + await routinesLive.refresh(); + }, + async remove(id: string) { + const db = await getDb(); + const deviceId = await getOrCreateDeviceId(db); + await deleteRoutine(db, deviceId, id); + await routinesLive.refresh(); + }, + destroy() { + routinesLive.destroy(); + tracksLive.destroy(); + }, + }; +} diff --git a/apps/pwa/src/lib/stores/stats.svelte.ts b/apps/pwa/src/lib/stores/stats.svelte.ts new file mode 100644 index 0000000..5974ff4 --- /dev/null +++ b/apps/pwa/src/lib/stores/stats.svelte.ts @@ -0,0 +1,149 @@ +import { + DAY_MS, + accuracyByTrack, + currentStreak, + netSecondsPerDay, + periodComparison, + weakTopics, +} from '@studyos/core'; +import { listTracks, plannerTopics, reviewSlices, sessionSlices, type DbDriver } from '@studyos/db'; +import { liveQuery } from '$lib/db/live.svelte'; + +export interface HeatCell { + day: number; + level: number; // 0 (empty) .. 4 + future: boolean; +} + +export interface StatsRow { + key: string; + label: string; +} + +export interface StatsData { + heatmap: HeatCell[]; // 84 days, column-major: week columns, dom..sáb rows + streak: number; + comparison: string; + accuracy: StatsRow[]; + weak: StatsRow[]; +} + +const EMPTY: StatsData = { heatmap: [], streak: 0, comparison: '', accuracy: [], weak: [] }; + +/** '130' minutes -> '2h10', '240' -> '4h', '45' -> '45min'. */ +function formatMinutes(totalMin: number): string { + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + if (h === 0) return `${m}min`; + if (m === 0) return `${h}h`; + return `${h}h${String(m).padStart(2, '0')}`; +} + +function formatSeconds(seconds: number): string { + return formatMinutes(Math.round(seconds / 60)); +} + +function quantile(sorted: number[], q: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1)); + return sorted[idx] ?? 0; +} + +function buildHeatmap( + perDay: { day: number; seconds: number }[], + todayMidnight: number, +): HeatCell[] { + const nonzero = perDay + .filter((d) => d.seconds > 0 && d.day <= todayMidnight) + .map((d) => d.seconds) + .toSorted((a, b) => a - b); + const q1 = quantile(nonzero, 0.25); + const q2 = quantile(nonzero, 0.5); + const q3 = quantile(nonzero, 0.75); + return perDay.map((d) => { + let level = 0; + if (d.seconds > 0) { + if (d.seconds <= q1) level = 1; + else if (d.seconds <= q2) level = 2; + else if (d.seconds <= q3) level = 3; + else level = 4; + } + return { day: d.day, level, future: d.day > todayMidnight }; + }); +} + +function comparisonLabel(cmp: { thisWeek: number; lastWeek: number; deltaPct: number | null }) { + const base = `esta semana ${formatSeconds(cmp.thisWeek)} · semana passada ${formatSeconds(cmp.lastWeek)}`; + if (cmp.deltaPct === null) return base; + const rounded = Math.round(cmp.deltaPct); + return `${base} · ${rounded >= 0 ? '+' : ''}${rounded}%`; +} + +async function loadStats(db: DbDriver): Promise { + const now = Date.now(); + const from = now - 84 * DAY_MS; + const [sessions, reviews, tracks, topics] = await Promise.all([ + sessionSlices(db, from), + reviewSlices(db, from), + listTracks(db), + plannerTopics(db), + ]); + + const todayMidnight = new Date(now).setHours(0, 0, 0, 0); + const weekStart = todayMidnight - new Date(todayMidnight).getDay() * DAY_MS; // sunday + const gridStart = weekStart - 11 * 7 * DAY_MS; + const perDay = netSecondsPerDay(sessions, gridStart, gridStart + 83 * DAY_MS); + + const trackTitles = new Map(tracks.map((t) => [t.id, t.title])); + const accuracy: StatsRow[] = []; + for (const row of accuracyByTrack(sessions)) { + if (row.track_id === null && row.total === 0) continue; + const title = (row.track_id !== null && trackTitles.get(row.track_id)) || 'sem trilha'; + if (row.pct === null) { + accuracy.push({ key: row.track_id ?? 'null', label: `${title} · sem questões` }); + } else { + accuracy.push({ + key: row.track_id ?? 'null', + label: `${title} · ${Math.round(row.pct)}% · ${row.total} ${row.total === 1 ? 'questão' : 'questões'}`, + }); + } + } + + const topicTitles = new Map(topics.map((t) => [t.id, t.title])); + const weak: StatsRow[] = []; + for (const w of weakTopics(reviews, sessions)) { + const title = topicTitles.get(w.topic_id); + if (title === undefined) continue; // deleted topic + weak.push({ key: w.topic_id, label: `${title} · atenção` }); + } + + return { + heatmap: buildHeatmap(perDay, todayMidnight), + streak: currentStreak(perDay, now), + comparison: comparisonLabel(periodComparison(sessions, now)), + accuracy, + weak, + }; +} + +export interface StatsStore { + get data(): StatsData; + destroy(): void; +} + +/** Live stats over the last 12 weeks of sessions and reviews. */ +export function createStatsStore(): StatsStore { + const live = liveQuery( + loadStats, + ['sessions', 'review_logs', 'fsrs_state', 'tracks', 'topics'], + EMPTY, + ); + return { + get data() { + return live.value; + }, + destroy() { + live.destroy(); + }, + }; +} diff --git a/apps/pwa/src/lib/stores/today.svelte.ts b/apps/pwa/src/lib/stores/today.svelte.ts new file mode 100644 index 0000000..f36c4ff --- /dev/null +++ b/apps/pwa/src/lib/stores/today.svelte.ts @@ -0,0 +1,169 @@ +import { + DAY_MS, + buildToday, + parseRrule, + replan, + routineOccurrences, + type PlannerTopic, + type RoutineSpec, + type TodayItem, +} from '@studyos/core'; +import { + dueReminders, + listDueReviews, + listRoutines, + listTargets, + plannerTopics, + sessionSlices, + targetProgress, + type DbDriver, +} from '@studyos/db'; +import type { RoutineRow, TargetRow } from '@studyos/shared'; +import { browser } from '$app/environment'; +import { getDb } from '$lib/db/client'; +import { liveQuery } from '$lib/db/live.svelte'; +import { maybeNotifyDue } from '$lib/push/local'; + +export interface TargetProgressRow { + id: string; + pct: number; // 0..100, for the bar width + label: string; +} + +interface TodayFeed { + items: TodayItem[]; + replanNote: boolean; +} + +function toSpecs(routines: RoutineRow[]): RoutineSpec[] { + const specs: RoutineSpec[] = []; + for (const routine of routines) { + try { + specs.push({ + id: routine.id, + track_id: routine.track_id, + days: parseRrule(routine.rrule), + start_time: routine.start_time, + duration_min: routine.duration_min, + }); + } catch { + // unsupported rrule: skip the routine rather than break Today + } + } + return specs; +} + +/** '130' minutes -> '2h10', '240' -> '4h', '45' -> '45min'. */ +function formatMinutes(totalMin: number): string { + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + if (h === 0) return `${m}min`; + if (m === 0) return `${h}h`; + return `${h}h${String(m).padStart(2, '0')}`; +} + +function targetLabel(target: TargetRow, ratio: number): string { + if (target.metric === 'net_hours') { + const doneMin = Math.round(ratio * target.value * 60); + const totalMin = Math.round(target.value * 60); + return `meta · ${formatMinutes(doneMin)} / ${formatMinutes(totalMin)}`; + } + return `meta · ${Math.round(ratio * target.value)} / ${target.value}`; +} + +/** + * Cheap replan heuristic (no persistence): yesterday had routine occurrences + * whose tracks still hold unfinished topics, and no session was logged + * yesterday — so today's plan absorbed the backlog. + */ +async function hadPendingYesterday( + db: DbDriver, + specs: RoutineSpec[], + topics: PlannerTopic[], + todayMidnight: number, +): Promise { + const yesterday = todayMidnight - DAY_MS; + const occurring = specs.filter((s) => routineOccurrences(s, yesterday, yesterday).length > 0); + if (occurring.length === 0) return false; + const trackIds = new Set( + occurring.map((s) => s.track_id).filter((id): id is string => id !== null), + ); + if (trackIds.size === 0) return false; + const unfinished = topics.some((t) => trackIds.has(t.track_id) && t.status !== 'done'); + if (!unfinished) return false; + const slices = await sessionSlices(db, yesterday); + return !slices.some((s) => s.started_at < todayMidnight); +} + +async function loadFeed(db: DbDriver): Promise { + const now = Date.now(); + const todayMidnight = new Date(now).setHours(0, 0, 0, 0); + const [due, routines, topics, reminders] = await Promise.all([ + listDueReviews(db, now), + listRoutines(db), + plannerTopics(db), + dueReminders(db, now), + ]); + const specs = toSpecs(routines); + const blocks = replan(specs, topics, todayMidnight, 7).filter((b) => b.day === todayMidnight); + const items = buildToday( + { + due: due.map((d) => ({ refKind: d.refKind, refId: d.refId, title: d.title, dueAt: d.dueAt })), + blocks, + reminders: reminders.map((r) => ({ id: r.id, title: r.title, notify_at: r.notify_at })), + }, + now, + ); + return { items, replanNote: await hadPendingYesterday(db, specs, topics, todayMidnight) }; +} + +async function loadTargets(db: DbDriver): Promise { + const now = Date.now(); + const targets = await listTargets(db); + const rows = await Promise.all( + targets.map(async (target): Promise => { + try { + const ratio = await targetProgress(db, target, now); + return { id: target.id, pct: Math.round(ratio * 100), label: targetLabel(target, ratio) }; + } catch { + return null; // unknown metric: hide instead of breaking Today + } + }), + ); + return rows.filter((r): r is TargetProgressRow => r !== null); +} + +export interface TodayStore { + get items(): TodayItem[]; + get replanNote(): boolean; + get targets(): TargetProgressRow[]; + destroy(): void; +} + +/** Live Today feed: reviews + plan blocks + reminders merged by core buildToday. */ +export function createTodayStore(): TodayStore { + const feed = liveQuery( + loadFeed, + ['fsrs_state', 'cards', 'topics', 'routines', 'reminders', 'sessions'], + { items: [], replanNote: false } as TodayFeed, + ); + const targets = liveQuery(loadTargets, ['targets', 'sessions', 'review_logs'], []); + if (browser) { + void getDb().then((db) => maybeNotifyDue(db)); + } + return { + get items() { + return feed.value.items; + }, + get replanNote() { + return feed.value.replanNote; + }, + get targets() { + return targets.value; + }, + destroy() { + feed.destroy(); + targets.destroy(); + }, + }; +} diff --git a/apps/pwa/src/routes/+layout.svelte b/apps/pwa/src/routes/+layout.svelte index 9c608c8..e6e5520 100644 --- a/apps/pwa/src/routes/+layout.svelte +++ b/apps/pwa/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { onMount } from 'svelte'; import { page } from '$app/state'; import { requestPersistence } from '$lib/db/client'; + import { registerServiceWorker } from '$lib/push/register'; import { startSyncLifecycle } from '$lib/sync/index.svelte'; import type { Snippet } from 'svelte'; @@ -11,7 +12,10 @@ const NAV = [ { href: '/', label: 'hoje' }, { href: '/tracks', label: 'trilhas' }, + { href: '/routines', label: 'rotina' }, { href: '/study', label: 'estudar' }, + { href: '/reminders', label: 'lembretes' }, + { href: '/stats', label: 'stats' }, ] as const; function isActive(href: string): boolean { @@ -29,6 +33,7 @@ window.addEventListener('online', update); window.addEventListener('offline', update); void requestPersistence(); + registerServiceWorker(); const stopSync = startSyncLifecycle(); return () => { window.removeEventListener('online', update); diff --git a/apps/pwa/src/routes/+page.svelte b/apps/pwa/src/routes/+page.svelte index 91347be..69a9fd2 100644 --- a/apps/pwa/src/routes/+page.svelte +++ b/apps/pwa/src/routes/+page.svelte @@ -1,20 +1,16 @@ + + + StudyOS — lembretes + + +
+

lembretes

+

lembretes

+ +
+ +
+ + + +
+
+ +
    + {#each store.reminders as reminder (reminder.id)} +
  • + {reminder.title} + {reminderDate(reminder.notify_at)} + +
  • + {/each} +
+ + {#if store.reminders.length === 0} +

nenhum lembrete ainda — crie o primeiro.

+ {/if} + +

notificações

+
+ + + + {#if pushLabel !== ''} + {pushLabel} + {/if} +
+
diff --git a/apps/pwa/src/routes/routines/+page.svelte b/apps/pwa/src/routes/routines/+page.svelte new file mode 100644 index 0000000..930b2b9 --- /dev/null +++ b/apps/pwa/src/routes/routines/+page.svelte @@ -0,0 +1,204 @@ + + + + StudyOS — rotinas + + +
+

estudo

+

rotinas

+ +
+ + + +

dias da semana

+
+ {#each DAY_LABELS as label, day (day)} + + {/each} +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

semana

+
+
+ {#each DAY_LABELS as label, day (day)} +
+

{label}

+
    + {#each columns[day] ?? [] as routine (routine.id)} +
  • +
    +

    + {routine.title} +

    + +
    +

    + {routine.start_time} · {formatDuration(routine.duration_min)} +

    + {#if routine.track_id !== null && trackTitleById.has(routine.track_id)} +

    + {trackTitleById.get(routine.track_id)} +

    + {/if} +
  • + {/each} +
+
+ {/each} +
+
+ + {#if store.routines.length === 0} +

nenhuma rotina ainda — monte sua semana de estudo.

+ {/if} +
diff --git a/apps/pwa/src/routes/stats/+page.svelte b/apps/pwa/src/routes/stats/+page.svelte new file mode 100644 index 0000000..1304e8f --- /dev/null +++ b/apps/pwa/src/routes/stats/+page.svelte @@ -0,0 +1,103 @@ + + + + StudyOS — estatísticas + + +
+

estatísticas

+

o que os números dizem

+ +

últimas 12 semanas

+ + +

constância

+

+ {streak} + {streak === 1 ? 'dia' : 'dias'} de constância +

+ +

comparativo

+

+ {store.data.comparison} +

+ +

acerto por trilha

+
    + {#each store.data.accuracy as row (row.key)} +
  • + {row.label} +
  • + {/each} +
+ {#if store.data.accuracy.length === 0} +

sem questões registradas ainda.

+ {/if} + +

pontos fracos

+
    + {#each store.data.weak as row (row.key)} +
  • + {row.label} +
  • + {/each} +
+ {#if store.data.weak.length === 0} +

ainda sem dados suficientes.

+ {/if} +
+ + diff --git a/apps/pwa/src/routes/tracks/[id]/+page.svelte b/apps/pwa/src/routes/tracks/[id]/+page.svelte index f90b209..1fa1796 100644 --- a/apps/pwa/src/routes/tracks/[id]/+page.svelte +++ b/apps/pwa/src/routes/tracks/[id]/+page.svelte @@ -2,12 +2,15 @@ import { untrack } from 'svelte'; import { SvelteSet } from 'svelte/reactivity'; import { page } from '$app/state'; + import { getOrCreateDeviceId, updateTrack } from '@studyos/db'; + import { getDb } from '$lib/db/client'; import { createTrackDetailStore, type TrackDetailStore } from '$lib/stores/tracksDetail.svelte'; import { buildTopicTree, type TreeActions } from './tree'; import TopicNode from './TopicNode.svelte'; import TopicForm from './TopicForm.svelte'; import OutlineImport from './OutlineImport.svelte'; import CardsPanel from './CardsPanel.svelte'; + import CycleEditor from './CycleEditor.svelte'; const trackId = $derived(page.params.id ?? ''); @@ -49,6 +52,16 @@ openFormId = 'root'; }, }; + + function setMode(mode: 'schedule' | 'cycle') { + if (track === null || track.mode === mode) return; + void (async () => { + const db = await getDb(); + const deviceId = await getOrCreateDeviceId(db); + // The db worker broadcasts tables-changed ['tracks'], refreshing trackLive. + await updateTrack(db, deviceId, trackId, { mode }); + })(); + } @@ -77,6 +90,36 @@

trilha

{track.title}

+
+ + +
+

tópicos{topics.length > 0 ? ` · ${topics.length}` : ''}

@@ -107,6 +150,10 @@

{/if} + {#if track.mode === 'cycle'} + + {/if} +
{#if selectedTopic} + import { onDestroy } from 'svelte'; + import { getOrCreateDeviceId, listCycleSlots, setCycleSlots } from '@studyos/db'; + import type { TopicRow } from '@studyos/shared'; + import { getDb } from '$lib/db/client'; + + let { trackId, topics }: { trackId: string; topics: TopicRow[] } = $props(); + + interface LocalSlot { + topic_id: string; + weight: number; + } + + let slots = $state([]); + let loaded = $state(false); + let addTopicId = $state(''); + + const titleById = $derived(new Map(topics.map((t) => [t.id, t.title]))); + const available = $derived(topics.filter((t) => !slots.some((s) => s.topic_id === t.id))); + + $effect(() => { + const id = trackId; + loaded = false; + let cancelled = false; + void (async () => { + const db = await getDb(); + const rows = await listCycleSlots(db, id); + if (cancelled) return; + slots = rows.map((r) => ({ topic_id: r.topic_id, weight: r.weight })); + loaded = true; + })(); + return () => { + cancelled = true; + flushNow(); // persist pending edits of the previous track before reloading + }; + }); + + async function persist(id: string, next: LocalSlot[]): Promise { + const db = await getDb(); + const deviceId = await getOrCreateDeviceId(db); + await setCycleSlots(db, deviceId, id, next); + } + + // setCycleSlots is replace-all, so weight typing would cause a write storm — + // debounce and flush on unmount / track change. + let timer: ReturnType | null = null; + let pending: (() => void) | null = null; + + function schedulePersist() { + const id = trackId; + const snapshot = slots.map((s) => ({ ...s })); + const run = () => { + timer = null; + pending = null; + void persist(id, snapshot); + }; + if (timer !== null) clearTimeout(timer); + pending = run; + timer = setTimeout(run, 400); + } + + function flushNow() { + if (timer === null) return; + clearTimeout(timer); + timer = null; + const run = pending; + pending = null; + run?.(); + } + + onDestroy(flushNow); + + function setWeight(topicId: string, value: number) { + if (Number.isNaN(value)) return; // mid-edit empty field — keep last valid weight + const weight = Math.min(5, Math.max(1, Math.round(value))); + slots = slots.map((s) => (s.topic_id === topicId ? { ...s, weight } : s)); + schedulePersist(); + } + + function removeSlot(topicId: string) { + slots = slots.filter((s) => s.topic_id !== topicId); + schedulePersist(); + } + + function onadd(event: SubmitEvent) { + event.preventDefault(); + if (addTopicId === '') return; + slots = [...slots, { topic_id: addTopicId, weight: 1 }]; + addTopicId = ''; + schedulePersist(); + } + + +
+

ciclo

+

peso maior · aparece mais vezes no ciclo

+ + {#if !loaded} +

carregando…

+ {:else} +
    + {#each slots as slot (slot.topic_id)} +
  • + + {titleById.get(slot.topic_id) ?? 'tópico removido'} + + + setWeight(slot.topic_id, e.currentTarget.valueAsNumber)} + class="type-item h-(--h-button-md) w-16 shrink-0 rounded-base border border-border bg-surface px-2 text-text-body" + /> + +
  • + {/each} +
+ + {#if slots.length === 0} +

+ nenhum tópico no ciclo ainda — adicione o primeiro. +

+ {/if} + +
+ + + +
+ {/if} +
diff --git a/apps/pwa/static/sw.js b/apps/pwa/static/sw.js new file mode 100644 index 0000000..9ba51e0 --- /dev/null +++ b/apps/pwa/static/sw.js @@ -0,0 +1,20 @@ +/* StudyOS service worker: payload-less web push + notification click focus. */ + +self.addEventListener('push', (event) => { + event.waitUntil( + self.registration.showNotification('StudyOS', { + body: 'lembrete de estudo · abra o app', + }), + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + const client = clients.find((c) => 'focus' in c); + if (client) return client.focus(); + return self.clients.openWindow('/'); + }), + ); +}); diff --git a/apps/worker/.dev.vars.example b/apps/worker/.dev.vars.example index 9a8e7b8..353ee5d 100644 --- a/apps/worker/.dev.vars.example +++ b/apps/worker/.dev.vars.example @@ -1 +1,5 @@ SYNC_TOKEN=dev-token +# Generate the VAPID values with: bun run scripts/gen-vapid.ts +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:dev@example.com diff --git a/apps/worker/README.md b/apps/worker/README.md index 4333c5f..708f31c 100644 --- a/apps/worker/README.md +++ b/apps/worker/README.md @@ -1,7 +1,8 @@ # @studyos worker -Cloudflare Worker serving the sync API (`/sync/push`, `/sync/pull`, `/health`) and the PWA -static assets. Thin HTTP shell around `@studyos/db/sync/server-core`; storage is D1. +Cloudflare Worker serving the sync API (`/sync/push`, `/sync/pull`, `/health`), the web +push endpoints, the reminder cron, and the PWA static assets. Thin HTTP shell around +`@studyos/db/sync/server-core`; storage is D1. ## Endpoints @@ -10,6 +11,36 @@ See `docs/SYNC.md` for the frozen wire contract. - `GET /health` - no auth, `{ "ok": true }` - `POST /sync/push` - `Authorization: Bearer `, body `PushRequest` - `GET /sync/pull?since=&device=` - same auth, returns `PullResponse` +- `POST /push/subscribe` - same auth, body `{ device_id, endpoint, p256dh, auth }`, + upserts into `push_subscriptions` keyed by `device_id` +- `GET /push/vapid` - same auth, returns `{ publicKey }` for `pushManager.subscribe` + +## Web push + +A cron trigger (`*/5 * * * *`, see `wrangler.jsonc`) runs `src/cron.ts`: if any reminder +came due in the last 5 minutes, every subscription gets a **payload-less** web push +(RFC 8030) signed with a VAPID JWT (RFC 8292, ES256 via WebCrypto). There is no payload +encryption in M3 - the service worker shows a generic notification. Subscriptions that +answer 404/410 are deleted. + +Three secrets drive it (exact formats matter): + +- `VAPID_PUBLIC_KEY` - the raw **uncompressed P-256 point** (65 bytes, starts with + `0x04`), base64url-encoded without padding. Sent to browsers as + `applicationServerKey` and in the `k=` parameter of the `Authorization` header. +- `VAPID_PRIVATE_KEY` - the matching private key as a **JWK JSON string**, e.g. + `{"kty":"EC","crv":"P-256","d":"...","x":"...","y":"...","ext":true,"key_ops":["sign"]}` + (`d`/`x`/`y` base64url). Imported with `crypto.subtle.importKey('jwk', ...)`. +- `VAPID_SUBJECT` - contact URI for the push service, `mailto:` or `https:`. + +Generate a pair (prints all three lines ready to paste): + +```sh +bun run scripts/gen-vapid.ts +``` + +Locally they live in `.dev.vars`; in production set them with +`bun x wrangler secret put VAPID_PUBLIC_KEY` (and `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`). ## Local development diff --git a/apps/worker/scripts/gen-vapid.ts b/apps/worker/scripts/gen-vapid.ts new file mode 100644 index 0000000..30e8b0f --- /dev/null +++ b/apps/worker/scripts/gen-vapid.ts @@ -0,0 +1,25 @@ +/* oxlint-disable no-console */ +// Generates the VAPID key pair for web push. Run from apps/worker: +// +// bun run scripts/gen-vapid.ts +// +// Paste the output into `.dev.vars` for local dev, or feed it to +// `wrangler secret put VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT`. + +function base64url(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +const pair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', +])) as CryptoKeyPair; + +const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); +const privateJwk = await crypto.subtle.exportKey('jwk', pair.privateKey); + +console.log(`VAPID_PUBLIC_KEY=${base64url(publicRaw)}`); +console.log(`VAPID_PRIVATE_KEY=${JSON.stringify(privateJwk)}`); +console.log('VAPID_SUBJECT=mailto:you@example.com'); diff --git a/apps/worker/src/cron.ts b/apps/worker/src/cron.ts new file mode 100644 index 0000000..8c09b7f --- /dev/null +++ b/apps/worker/src/cron.ts @@ -0,0 +1,102 @@ +import { d1Driver } from '@studyos/db/adapters/d1'; +import type { Env } from './env'; + +// Matches the wrangler cron cadence (*/5): a reminder is picked up by exactly +// one tick as long as ticks are not skipped. +const REMINDER_WINDOW_MS = 5 * 60 * 1000; +const JWT_TTL_S = 12 * 60 * 60; + +function base64url(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function encodeSegment(value: unknown): string { + return base64url(new TextEncoder().encode(JSON.stringify(value))); +} + +// Structural subset of WebCrypto's JsonWebKey (the global name is not exposed +// by the bun type setup here). +interface EcPrivateJwk { + kty?: string; + crv?: string; + d?: string; + x?: string; + y?: string; + ext?: boolean; + key_ops?: string[]; +} + +async function importVapidPrivateKey(jwkJson: string): Promise { + const jwk = JSON.parse(jwkJson) as EcPrivateJwk; + return crypto.subtle.importKey('jwk', jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, [ + 'sign', + ]); +} + +/** + * RFC 8292 VAPID JWT (ES256): aud = push service origin, exp <= 24h. + * WebCrypto ECDSA signatures are already the raw r||s concatenation JWS wants. + */ +async function vapidJwt(key: CryptoKey, aud: string, sub: string, nowMs: number): Promise { + const header = encodeSegment({ typ: 'JWT', alg: 'ES256' }); + const claims = encodeSegment({ aud, exp: Math.floor(nowMs / 1000) + JWT_TTL_S, sub }); + const input = `${header}.${claims}`; + const signature = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + key, + new TextEncoder().encode(input), + ); + return `${input}.${base64url(new Uint8Array(signature))}`; +} + +/** + * Cron tick: if any reminder came due in the last window, send a payload-less + * web push (RFC 8030; no body, so no content encryption) to every subscription. + * The service worker shows a generic notification. 404/410 responses drop the + * subscription; other per-subscription failures are swallowed so one bad + * endpoint never blocks the rest. + */ +export async function handleCron(env: Env): Promise { + const db = d1Driver(env.DB); + const now = Date.now(); + + const due = await db.exec( + 'SELECT id FROM reminders WHERE notify_at >= ? AND notify_at <= ? AND deleted_at IS NULL', + [now - REMINDER_WINDOW_MS, now], + ); + if (due.length === 0) return; + + const subs = await db.exec('SELECT id, endpoint FROM push_subscriptions'); + if (subs.length === 0) return; + + const key = await importVapidPrivateKey(env.VAPID_PRIVATE_KEY); + const jwtByOrigin = new Map(); + + for (const sub of subs) { + const id = sub['id'] as string; + const endpoint = sub['endpoint'] as string; + try { + const origin = new URL(endpoint).origin; + let jwt = jwtByOrigin.get(origin); + if (jwt === undefined) { + jwt = await vapidJwt(key, origin, env.VAPID_SUBJECT, now); + jwtByOrigin.set(origin, jwt); + } + const res = await fetch(endpoint, { + method: 'POST', + headers: { + TTL: '300', + Authorization: `vapid t=${jwt}, k=${env.VAPID_PUBLIC_KEY}`, + }, + }); + if (res.status === 404 || res.status === 410) { + await db.exec('DELETE FROM push_subscriptions WHERE id = ?', [id]); + } + } catch (err) { + // oxlint-disable-next-line no-console + console.error(`web push to subscription ${id} failed`, err); + } + } +} diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index 52e2584..75d7599 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -20,4 +20,10 @@ export interface Env { DB: D1DatabaseLike; SYNC_TOKEN: string; ASSETS: FetcherLike; + /** Raw uncompressed P-256 point, base64url (see README "Web push"). */ + VAPID_PUBLIC_KEY: string; + /** EC P-256 private key as a JWK JSON string (see README "Web push"). */ + VAPID_PRIVATE_KEY: string; + /** mailto: or https: contact, RFC 8292 `sub` claim. */ + VAPID_SUBJECT: string; } diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 0a9ed0d..9ae7e6d 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,6 +1,8 @@ import { Hono } from 'hono'; import { bearerAuth } from './auth'; +import { handleCron } from './cron'; import type { Env } from './env'; +import { handleSubscribe, handleVapidKey } from './push'; import { handlePull, handlePush } from './sync'; export function createApp(): Hono<{ Bindings: Env }> { @@ -9,6 +11,9 @@ export function createApp(): Hono<{ Bindings: Env }> { app.use('/sync/*', bearerAuth); app.post('/sync/push', handlePush); app.get('/sync/pull', handlePull); + app.use('/push/*', bearerAuth); + app.post('/push/subscribe', handleSubscribe); + app.get('/push/vapid', handleVapidKey); return app; } @@ -16,4 +21,11 @@ const app = createApp(); export default { fetch: app.fetch, + scheduled( + _controller: unknown, + env: Env, + ctx: { waitUntil(promise: Promise): void }, + ): void { + ctx.waitUntil(handleCron(env)); + }, }; diff --git a/apps/worker/src/push.ts b/apps/worker/src/push.ts new file mode 100644 index 0000000..80e88d6 --- /dev/null +++ b/apps/worker/src/push.ts @@ -0,0 +1,51 @@ +import type { Handler } from 'hono'; +import { d1Driver } from '@studyos/db/adapters/d1'; +import type { Env } from './env'; + +interface SubscribeBody { + device_id: string; + endpoint: string; + p256dh: string; + auth: string; +} + +function parseSubscribeBody(value: unknown): SubscribeBody | null { + if (typeof value !== 'object' || value === null) return null; + const o = value as Record; + if ( + typeof o['device_id'] !== 'string' || + typeof o['endpoint'] !== 'string' || + typeof o['p256dh'] !== 'string' || + typeof o['auth'] !== 'string' + ) { + return null; + } + return { + device_id: o['device_id'], + endpoint: o['endpoint'], + p256dh: o['p256dh'], + auth: o['auth'], + }; +} + +/** Upsert keyed by device: one subscription per device (id = device_id). */ +export const handleSubscribe: Handler<{ Bindings: Env }> = async (c) => { + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json({ error: 'invalid JSON body' }, 400); + } + const sub = parseSubscribeBody(body); + if (!sub) return c.json({ error: 'malformed subscribe request' }, 400); + + await d1Driver(c.env.DB).exec( + 'INSERT OR REPLACE INTO push_subscriptions (id, device_id, endpoint, p256dh, auth, created_at) ' + + 'VALUES (?, ?, ?, ?, ?, ?)', + [sub.device_id, sub.device_id, sub.endpoint, sub.p256dh, sub.auth, Date.now()], + ); + return c.json({ ok: true }); +}; + +export const handleVapidKey: Handler<{ Bindings: Env }> = (c) => + c.json({ publicKey: c.env.VAPID_PUBLIC_KEY }); diff --git a/apps/worker/test/auth.test.ts b/apps/worker/test/auth.test.ts index f1c23c6..f1b9dea 100644 --- a/apps/worker/test/auth.test.ts +++ b/apps/worker/test/auth.test.ts @@ -13,6 +13,9 @@ beforeEach(async () => { DB: await createFakeD1(), SYNC_TOKEN: TOKEN, ASSETS: { fetch: async () => new Response(null, { status: 404 }) }, + VAPID_PUBLIC_KEY: 'test-public-key', + VAPID_PRIVATE_KEY: '{}', + VAPID_SUBJECT: 'mailto:test@example.com', }; }); diff --git a/apps/worker/test/cron.test.ts b/apps/worker/test/cron.test.ts new file mode 100644 index 0000000..fc62eba --- /dev/null +++ b/apps/worker/test/cron.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { handleCron } from '../src/cron'; +import type { Env } from '../src/env'; +import { createFakeD1, type FakeD1 } from './fake-d1'; + +function base64url(bytes: Uint8Array): string { + let bin = ''; + for (const b of bytes) bin += String.fromCharCode(b); + return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function base64urlDecode(s: string): Uint8Array { + const b64 = s + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(s.length / 4) * 4, '='); + return Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); +} + +interface Recorded { + url: string; + method: string; + headers: Record; +} + +const realFetch = globalThis.fetch; + +let db: FakeD1; +let env: Env; +let verifyKey: CryptoKey; +let requests: Recorded[]; +let responder: (url: string) => Response; + +beforeEach(async () => { + db = await createFakeD1(); + + // throwaway keypair per run — never hardcode VAPID keys + const pair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair; + verifyKey = pair.publicKey; + + env = { + DB: db, + SYNC_TOKEN: 'test', + ASSETS: { fetch: async () => new Response(null, { status: 404 }) }, + VAPID_PUBLIC_KEY: base64url( + new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)), + ), + VAPID_PRIVATE_KEY: JSON.stringify(await crypto.subtle.exportKey('jwk', pair.privateKey)), + VAPID_SUBJECT: 'mailto:test@example.com', + }; + + requests = []; + responder = () => new Response(null, { status: 201 }); + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const headers = new Headers(init?.headers); + const flat: Record = {}; + headers.forEach((value, key) => { + flat[key] = value; + }); + requests.push({ url, method: init?.method ?? 'GET', headers: flat }); + const boom = url.includes('boom'); + if (boom) throw new Error('network down'); + return responder(url); + }) as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +async function insertReminder( + id: string, + notifyAt: number, + deletedAt: number | null = null, +): Promise { + await db + .prepare( + 'INSERT INTO reminders (id, title, ref_kind, ref_id, notify_at, rrule, updated_at, deleted_at) ' + + 'VALUES (?, ?, NULL, NULL, ?, NULL, ?, ?)', + ) + .bind(id, 'lembrete', notifyAt, notifyAt, deletedAt) + .run(); +} + +async function insertSubscription(id: string, endpoint: string): Promise { + await db + .prepare( + 'INSERT INTO push_subscriptions (id, device_id, endpoint, p256dh, auth, created_at) ' + + 'VALUES (?, ?, ?, ?, ?, ?)', + ) + .bind(id, id, endpoint, 'p256dh-key', 'auth-secret', Date.now()) + .run(); +} + +async function subscriptionIds(): Promise { + const { results } = await db + .prepare('SELECT id FROM push_subscriptions ORDER BY id') + .bind() + .all(); + return results.map((r) => r['id'] as string); +} + +describe('handleCron', () => { + test('no reminders in the window means no pushes', async () => { + await insertSubscription('device-a', 'https://push.example.com/sub/a'); + await insertReminder('r-old', Date.now() - 10 * 60 * 1000); // outside window + await insertReminder('r-future', Date.now() + 60 * 1000); // not due yet + + await handleCron(env); + expect(requests).toHaveLength(0); + }); + + test('soft-deleted reminders are ignored', async () => { + await insertSubscription('device-a', 'https://push.example.com/sub/a'); + await insertReminder('r-deleted', Date.now() - 60 * 1000, Date.now()); + + await handleCron(env); + expect(requests).toHaveLength(0); + }); + + test('sends one payload-less POST per subscription with VAPID auth and TTL', async () => { + await insertReminder('r1', Date.now() - 60 * 1000); + await insertSubscription('device-a', 'https://push-a.example.com/sub/a'); + await insertSubscription('device-b', 'https://push-b.example.com/sub/b'); + + await handleCron(env); + + expect(requests).toHaveLength(2); + for (const req of requests) { + expect(req.method).toBe('POST'); + expect(req.headers['ttl']).toBe('300'); + expect(req.headers['authorization']).toMatch(/^vapid t=[\w-]+\.[\w-]+\.[\w-]+, k=[\w-]+$/); + } + + // JWT is verifiable with the public key and carries the RFC 8292 claims + const reqA = requests.find((r) => r.url.startsWith('https://push-a.example.com')); + const auth = reqA?.headers['authorization'] ?? ''; + const jwt = /t=([^,]+),/.exec(auth)?.[1] ?? ''; + const [header = '', claims = '', signature = ''] = jwt.split('.'); + const valid = await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + verifyKey, + base64urlDecode(signature), + new TextEncoder().encode(`${header}.${claims}`), + ); + expect(valid).toBe(true); + + const decodedHeader = JSON.parse(new TextDecoder().decode(base64urlDecode(header))); + expect(decodedHeader).toEqual({ typ: 'JWT', alg: 'ES256' }); + + const decodedClaims = JSON.parse(new TextDecoder().decode(base64urlDecode(claims))) as { + aud: string; + exp: number; + sub: string; + }; + expect(decodedClaims.aud).toBe('https://push-a.example.com'); + expect(decodedClaims.sub).toBe('mailto:test@example.com'); + expect(decodedClaims.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)); + expect(decodedClaims.exp).toBeLessThanOrEqual(Math.floor(Date.now() / 1000) + 24 * 3600); + + const k = / k=([\w-]+)$/.exec(auth)?.[1]; + expect(k).toBe(env.VAPID_PUBLIC_KEY); + }); + + test('410 (and 404) responses delete that subscription only', async () => { + await insertReminder('r1', Date.now() - 60 * 1000); + await insertSubscription('device-gone', 'https://push.example.com/sub/gone-410'); + await insertSubscription('device-lost', 'https://push.example.com/sub/lost-404'); + await insertSubscription('device-ok', 'https://push.example.com/sub/ok'); + + responder = (url) => { + if (url.includes('gone-410')) return new Response(null, { status: 410 }); + if (url.includes('lost-404')) return new Response(null, { status: 404 }); + return new Response(null, { status: 201 }); + }; + + await handleCron(env); + + expect(requests).toHaveLength(3); + expect(await subscriptionIds()).toEqual(['device-ok']); + }); + + test('a failing endpoint does not block the other subscriptions', async () => { + await insertReminder('r1', Date.now() - 60 * 1000); + await insertSubscription('device-bad', 'https://push.example.com/sub/boom'); + await insertSubscription('device-ok', 'https://push.example.com/sub/ok'); + + await handleCron(env); + + expect(requests).toHaveLength(2); + expect(await subscriptionIds()).toEqual(['device-bad', 'device-ok']); + }); +}); diff --git a/apps/worker/test/push-pull.test.ts b/apps/worker/test/push-pull.test.ts index aeea042..e24004b 100644 --- a/apps/worker/test/push-pull.test.ts +++ b/apps/worker/test/push-pull.test.ts @@ -16,6 +16,9 @@ beforeEach(async () => { DB: db, SYNC_TOKEN: TOKEN, ASSETS: { fetch: async () => new Response(null, { status: 404 }) }, + VAPID_PUBLIC_KEY: 'test-public-key', + VAPID_PRIVATE_KEY: '{}', + VAPID_SUBJECT: 'mailto:test@example.com', }; }); diff --git a/apps/worker/test/push-subscribe.test.ts b/apps/worker/test/push-subscribe.test.ts new file mode 100644 index 0000000..e8bffff --- /dev/null +++ b/apps/worker/test/push-subscribe.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, test } from 'bun:test'; +import { createApp } from '../src/index'; +import type { Env } from '../src/env'; +import { createFakeD1, type FakeD1 } from './fake-d1'; + +const TOKEN = 'test'; +const app = createApp(); + +let db: FakeD1; +let env: Env; + +beforeEach(async () => { + db = await createFakeD1(); + env = { + DB: db, + SYNC_TOKEN: TOKEN, + ASSETS: { fetch: async () => new Response(null, { status: 404 }) }, + VAPID_PUBLIC_KEY: 'test-public-key', + VAPID_PRIVATE_KEY: '{}', + VAPID_SUBJECT: 'mailto:test@example.com', + }; +}); + +function validBody(overrides: Record = {}): Record { + return { + device_id: 'device-a', + endpoint: 'https://push.example.com/sub/abc', + p256dh: 'p256dh-key', + auth: 'auth-secret', + ...overrides, + }; +} + +async function subscribe(body: unknown, token = TOKEN): Promise { + return app.request( + '/push/subscribe', + { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }, + env, + ); +} + +async function subscriptions(): Promise[]> { + const { results } = await db.prepare('SELECT * FROM push_subscriptions').bind().all(); + return results; +} + +describe('POST /push/subscribe', () => { + test('requires bearer auth', async () => { + const res = await app.request( + '/push/subscribe', + { method: 'POST', body: JSON.stringify(validBody()) }, + env, + ); + expect(res.status).toBe(401); + + const wrong = await subscribe(validBody(), 'wrong-token'); + expect(wrong.status).toBe(401); + expect(await subscriptions()).toHaveLength(0); + }); + + test('stores the subscription keyed by device_id', async () => { + const res = await subscribe(validBody()); + expect(res.status).toBe(200); + expect((await res.json()) as { ok: boolean }).toEqual({ ok: true }); + + const rows = await subscriptions(); + expect(rows).toHaveLength(1); + expect(rows[0]?.['id']).toBe('device-a'); + expect(rows[0]?.['device_id']).toBe('device-a'); + expect(rows[0]?.['endpoint']).toBe('https://push.example.com/sub/abc'); + expect(rows[0]?.['p256dh']).toBe('p256dh-key'); + expect(rows[0]?.['auth']).toBe('auth-secret'); + expect(rows[0]?.['created_at']).toBeGreaterThan(0); + }); + + test('subscribing again from the same device replaces the row (upsert)', async () => { + await subscribe(validBody()); + await subscribe(validBody({ endpoint: 'https://push.example.com/sub/new' })); + + const rows = await subscriptions(); + expect(rows).toHaveLength(1); + expect(rows[0]?.['endpoint']).toBe('https://push.example.com/sub/new'); + }); + + test('malformed bodies return 400', async () => { + const cases: unknown[] = [ + {}, + validBody({ device_id: undefined }), + validBody({ endpoint: undefined }), + validBody({ p256dh: undefined }), + validBody({ auth: undefined }), + validBody({ device_id: 42 }), + validBody({ endpoint: null }), + 'not json', + ]; + for (const body of cases) { + const res = await subscribe(body); + expect(res.status).toBe(400); + } + expect(await subscriptions()).toHaveLength(0); + }); +}); + +describe('GET /push/vapid', () => { + test('requires bearer auth', async () => { + const res = await app.request('/push/vapid', {}, env); + expect(res.status).toBe(401); + }); + + test('returns the configured public key', async () => { + const res = await app.request( + '/push/vapid', + { headers: { authorization: `Bearer ${TOKEN}` } }, + env, + ); + expect(res.status).toBe(200); + expect((await res.json()) as { publicKey: string }).toEqual({ + publicKey: 'test-public-key', + }); + }); +}); diff --git a/apps/worker/test/two-device-http.test.ts b/apps/worker/test/two-device-http.test.ts index c3e1820..c6bb7ee 100644 --- a/apps/worker/test/two-device-http.test.ts +++ b/apps/worker/test/two-device-http.test.ts @@ -56,6 +56,9 @@ describe('two devices syncing through the real HTTP app', () => { DB: await createFakeD1(), SYNC_TOKEN: TOKEN, ASSETS: { fetch: async () => new Response(null, { status: 404 }) }, + VAPID_PUBLIC_KEY: 'test-public-key', + VAPID_PRIVATE_KEY: '{}', + VAPID_SUBJECT: 'mailto:test@example.com', }; transport = httpTransport(env); dbA = await createLocalDb(); diff --git a/apps/worker/wrangler.jsonc b/apps/worker/wrangler.jsonc index 0306b2e..fb3ad91 100644 --- a/apps/worker/wrangler.jsonc +++ b/apps/worker/wrangler.jsonc @@ -17,5 +17,6 @@ "migrations_dir": "../../packages/db/migrations", }, ], + "triggers": { "crons": ["*/5 * * * *"] }, "observability": { "enabled": true }, } diff --git a/docs/M3-CONTRACTS.md b/docs/M3-CONTRACTS.md new file mode 100644 index 0000000..44043be --- /dev/null +++ b/docs/M3-CONTRACTS.md @@ -0,0 +1,250 @@ +# M3 contracts (frozen for parallel workstreams) + +Planner & routine: routines (weekly), planner (schedule + cycle + replan), targets with +progress on Today, stats screen, reminders with local notifications, web push (payload-less) + +- `.ics` fallback. Done when a week of routine auto-populates Today and falling behind + replans forward — never a visible backlog. + +## RRULE subset (whole app) + +Only weekly recurrence: `FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU` (any subset of days). +`routines.start_time` is `'HH:MM'`, `duration_min` minutes. Parsing/expansion lives in core. + +## packages/core (stream A) + +### planner (M2's `QueueItem`/`dailyQueue` and the `planDay`/stats stubs were removed — verified unconsumed outside core; planner is consumed only via these contracts after M3) + +```ts +export interface RoutineSpec { + id: string; + track_id: string | null; + days: number[]; // 0=Sun..6=Sat + start_time: string; + duration_min: number; +} +export function parseRrule(rrule: string): number[]; // BYDAY -> days, throws on unsupported +export function routineOccurrences(r: RoutineSpec, fromDay: number, toDay: number): number[]; +// fromDay/toDay = epoch ms at local midnight; returns occurrence-day midnights inclusive + +export interface PlannerTopic { + id: string; + track_id: string; + title: string; + status: 'pending' | 'studying' | 'done'; + position: number; + deps: string[]; +} +export interface PlanBlock { + day: number; + routine_id: string; + track_id: string | null; + topic_id: string | null; + title: string; + duration_min: number; +} +// schedule mode: fill each routine occurrence in [fromDay, toDay] with the next +// unfinished topic of that routine's track in topological order (deps first, then +// position); routines without track_id get topic_id null + title 'estudo livre'. +export function allocateSchedule( + routines: RoutineSpec[], + topics: PlannerTopic[], + fromDay: number, + toDay: number, +): PlanBlock[]; + +export interface CycleSlotSpec { + id: string; + topic_id: string; + title: string; + weight: number; + position: number; +} +// weighted round-robin; pointer = total picks already made (persisted by caller in settings) +export function cycleNext( + slots: CycleSlotSpec[], + pointer: number, +): { slot: CycleSlotSpec; nextPointer: number } | null; + +// replan(todayDay): overdue plan-days are redistributed strictly forward over the next +// occurrences (idempotent: same inputs -> same outputs; never emits past days). +// M3 keeps plan blocks derived (not persisted), so replan == allocateSchedule over +// [todayDay, todayDay + horizonDays - 1] — the function exists to make that policy +// explicit and testable. `todayDay` is epoch ms of TODAY'S LOCAL MIDNIGHT, normalized +// by the caller (core cannot derive a local midnight from a raw timestamp). +export function replan( + routines: RoutineSpec[], + topics: PlannerTopic[], + todayDay: number, + horizonDays: number, +): PlanBlock[]; + +export interface TodayInputs { + due: { refKind: 'card' | 'topic'; refId: string; title: string; dueAt: number | null }[]; + blocks: PlanBlock[]; + reminders: { id: string; title: string; notify_at: number }[]; +} +export interface TodayItem { + kind: 'review' | 'block' | 'reminder'; + title: string; + subtitle: string | null; + href: string; + sort: number; +} +export function buildToday(inputs: TodayInputs, now: number): TodayItem[]; +// order: overdue reviews (dueAt < now), due reminders (notify_at <= now; not-yet-due +// reminders are omitted), today's blocks (input array order — allocateSchedule emits +// them per-day in start_time order), fresh reviews (dueAt null or >= now, undated last) +``` + +### stats + +```ts +export interface SessionSlice { + started_at: number; + net_seconds: number; + track_id: string | null; + topic_id: string | null; + questions_total: number | null; + questions_correct: number | null; +} +export interface ReviewSlice { + reviewed_at: number; + rating: number; + ref_id: string; + ref_kind: string; +} +export function netSecondsPerDay( + s: SessionSlice[], + fromDay: number, + toDay: number, +): { day: number; seconds: number }[]; // every day in range, zero-filled +export function currentStreak(perDay: { day: number; seconds: number }[], now: number): number; +// consecutive days (ending today or yesterday) with seconds > 0 +export function accuracyByTrack( + s: SessionSlice[], +): { track_id: string | null; total: number; correct: number; pct: number | null }[]; +// pct is 0..100 over sessions with questions_total > 0; null when a track has none +export function periodComparison( + s: SessionSlice[], + now: number, +): { thisWeek: number; lastWeek: number; deltaPct: number | null }; // net seconds, ISO weeks starting Monday +export interface WeakTopic { + topic_id: string; + score: number; +} // higher = weaker +export function weakTopics( + reviews: ReviewSlice[], + sessions: SessionSlice[], + limit?: number, +): WeakTopic[]; +// low ratings (1-2 share, weight 0.7) x normalized inverse net time (weight 0.3); +// only topics with >= 3 reviews; limit default 5. M3 limitation: only reviews with +// ref_kind 'topic' are scored — card reviews carry no card->topic mapping in the slice. +``` + +## packages/db (stream B) + +Same house style (localWrite, bumpedTs). Signatures: + +```ts +// repo/routines.ts +createRoutine(db, deviceId, { title, track_id?, rrule, start_time, duration_min }): Promise +listRoutines(db): Promise // active, not deleted +updateRoutine(db, deviceId, id, patch): Promise +deleteRoutine(db, deviceId, id): Promise + +// repo/targets.ts +createTarget(db, deviceId, { track_id?, metric, period, value }): Promise +listTargets(db): Promise +deleteTarget(db, deviceId, id): Promise +targetProgress(db, target, now): Promise // 0..1; metric net_hours|questions|reviews|sessions over current period + +// repo/reminders.ts +createReminder(db, deviceId, { title, notify_at, rrule?, ref_kind?, ref_id? }): Promise +listReminders(db): Promise +dueReminders(db, now): Promise // notify_at <= now, not deleted +deleteReminder(db, deviceId, id): Promise + +// repo/cycle.ts +setCycleSlots(db, deviceId, trackId, slots: { topic_id, weight }[]): Promise // replace-all atomic +listCycleSlots(db, trackId): Promise +getCyclePointer(db, trackId): Promise // settings key `cycle_pointer:`, local-only +setCyclePointer(db, trackId, n): Promise + +// repo/stats-queries.ts (read-only feeds for core/stats) +sessionSlices(db, fromMs): Promise +reviewSlices(db, fromMs): Promise // review_logs joined to fsrs_state ref +plannerTopics(db, trackIds?): Promise // topics + deps aggregated +``` + +### apps/worker (stream B too) + +- `POST /push/subscribe` (bearer): body `{ device_id, endpoint, p256dh, auth }` → upsert + into `push_subscriptions` (id = device_id, replace). +- `cron.ts` + wrangler `triggers.crons = ["*/5 * * * *"]`: query D1 `reminders` due in the + last 5 min window (synced there via oplog), for each subscription send a **payload-less** + web push (VAPID JWT via WebCrypto ES256; secrets `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, + `VAPID_SUBJECT`). No payload encryption in M3 — the SW shows a generic notification. + Delete subscriptions on 404/410. +- `GET /push/vapid` (bearer): returns `{ publicKey }` for the subscribe flow. + +## UI (streams C and D) + +Stream C owns `/routines/**` and the cycle-mode editor inside `/tracks/[id]/**`. +Stream D owns `/stats/**`, `/reminders/**`, Today (`routes/+page.svelte`), push/notification +plumbing (`lib/push/*`, `static/sw.js`) and `.ics` export. + +### /routines (C) + +Weekly grid (7 columns seg..dom) showing routine blocks at their times. +Testids: `routine-form`, `routine-title-input`, `routine-days` (7 toggle buttons +`routine-day-0`..`routine-day-6`), `routine-start-input` (time), `routine-duration-input`, +`routine-track-select`, `routine-submit`, `routine-grid`, `routine-block`, +`routine-delete`. Copy: dias `dom seg ter qua qui sex sáb`. + +### Cycle editor on /tracks/[id] (C) + +Mode toggle on the track page (`track-mode-toggle`, schedule ↔ cycle). When cycle: +`cycle-editor`, rows `cycle-slot` (topic title + `cycle-weight-input` 1..5), add via +`cycle-add-select` + `cycle-add-submit`, remove `cycle-remove`. Persist via setCycleSlots. +Uses updateTrack — B adds `updateTrack(db, deviceId, id, patch)` to repo/tracks.ts +(additive, allowed). + +### / Today additions (D) + +- Plan blocks + reminders merged via core `buildToday` (testids stay: `today-queue`, + `today-item`; add `today-item-kind` attr `data-kind="review|block|reminder"`). +- Targets progress: `target-progress` bar(s) above the queue (4px bar per ds), label + `meta · 2h10 / 4h` style, from targetProgress. +- Replan note when overdue plan existed: calm line `ontem ficou pendente — redistribuído.` + (`replan-note`) — shown when any routine occurrence before today had unfinished topics + (derive cheaply; do not persist). + +### /stats (D) + +`stats-heatmap` (last 12 weeks, amber alpha steps --heat-0..4 by net hours/day), +`stats-streak` (`N dias de constância`, tabular), `stats-accuracy` list per track +(`stats-accuracy-row`: track title · pct), `stats-comparison` (esta semana vs anterior, +delta), `stats-weak` list (`stats-weak-row`). All client-side from repo feeds + core/stats. +No chart lib — hand-rolled divs/svg with tokens. + +### /reminders (D) + +`reminder-form`, `reminder-title-input`, `reminder-datetime-input`, `reminder-submit`, +`reminder-list`, `reminder-item`, `reminder-delete`. Local notifications: on app open, if +permission granted show Notification for dueReminders (throttle: only ones not yet +notified this session). Permission ask via explicit button `notifications-enable` +(never on load). Push subscribe flow behind `push-enable` button: fetch VAPID key, +`pushManager.subscribe`, POST /push/subscribe. `.ics` export: `ics-export` button downloads +`studyos.ics` generated from routines (VEVENT with RRULE weekly BYDAY). + +### static/sw.js + registration (D) + +Minimal service worker: `push` event → `showNotification('StudyOS', { body: 'lembrete de +estudo · abra o app' })`; `notificationclick` → focus/open `/`. Registered from the layout +ONLY via a small `lib/push/register.ts` called in existing onMount (D may add ONE import + +one call line to +layout.svelte's onMount — the only layout touch allowed, coordinate by +keeping the diff to those two lines). + +Copy rules unchanged: pt-BR sentence case, `·` separators, no icons/emoji, calm tone. diff --git a/packages/core/src/planner/index.ts b/packages/core/src/planner/index.ts index 7402cdf..c01ec1d 100644 --- a/packages/core/src/planner/index.ts +++ b/packages/core/src/planner/index.ts @@ -1,33 +1,354 @@ -import type { CycleSlotRow, RoutineRow } from '@studyos/shared'; +// M3 planner: rrule parsing, routine expansion, schedule allocation, weighted +// cycle rotation and the Today feed. Pure functions, no platform deps. +// +// Timezone policy (stated once for the whole module): all day-boundary math +// works on epoch ms of LOCAL midnights passed IN by the caller. A "day" is a +// fixed 86_400_000 ms step; around DST transitions the stepped value drifts +// from the true local midnight by the shifted hour — acceptable in M3. +// `new Date(ms)` is used only as a pure arithmetic helper (local day-of-week +// extraction); core never reads the current clock. -export interface PlannedBlock { +export const DAY_MS = 86_400_000; + +export interface RoutineSpec { + id: string; + track_id: string | null; + days: number[]; // 0=Sun..6=Sat + start_time: string; // 'HH:MM' + duration_min: number; +} + +const BYDAY_TOKENS: Record = { + SU: 0, + MO: 1, + TU: 2, + WE: 3, + TH: 4, + FR: 5, + SA: 6, +}; + +const unsupported: () => never = () => { + throw new Error('unsupported rrule'); +}; + +/** + * Parses the app-wide RRULE subset: `FREQ=WEEKLY;BYDAY=MO,WE,FR` (day tokens + * in any order, any non-empty subset; `INTERVAL=1` tolerated). Anything else + * throws `Error('unsupported rrule')`. Returns sorted unique day numbers + * (0=Sun..6=Sat). + */ +export function parseRrule(rrule: string): number[] { + const parts = rrule + .trim() + .split(';') + .filter((p) => p.length > 0); + if (parts.length === 0) unsupported(); + + let freq: string | null = null; + let byday: string | null = null; + for (const part of parts) { + const eq = part.indexOf('='); + if (eq < 0) unsupported(); + const key = part.slice(0, eq).toUpperCase(); + const value = part.slice(eq + 1); + if (key === 'FREQ') { + if (freq !== null || value.toUpperCase() !== 'WEEKLY') unsupported(); + freq = value; + } else if (key === 'BYDAY') { + if (byday !== null) unsupported(); + byday = value; + } else if (key === 'INTERVAL') { + if (value !== '1') unsupported(); + } else { + unsupported(); + } + } + if (freq === null || byday === null || byday.length === 0) unsupported(); + + const days = new Set(); + for (const token of byday.split(',')) { + const day = BYDAY_TOKENS[token.trim().toUpperCase()]; + if (day === undefined) unsupported(); + days.add(day); + } + return [...days].toSorted((a, b) => a - b); +} + +/** Local day-of-week (0=Sun..6=Sat) of an epoch-ms local midnight. */ +function dayOfWeek(dayMs: number): number { + return new Date(dayMs).getDay(); +} + +/** + * Days (epoch ms local midnights) in `[fromDay, toDay]` inclusive on which the + * routine occurs. + */ +export function routineOccurrences(r: RoutineSpec, fromDay: number, toDay: number): number[] { + const wanted = new Set(r.days); + const out: number[] = []; + for (let day = fromDay; day <= toDay; day += DAY_MS) { + if (wanted.has(dayOfWeek(day))) out.push(day); + } + return out; +} + +export interface PlannerTopic { + id: string; + track_id: string; + title: string; + status: 'pending' | 'studying' | 'done'; + position: number; + deps: string[]; +} + +export interface PlanBlock { + day: number; routine_id: string; + track_id: string | null; topic_id: string | null; - starts_at: number; + title: string; duration_min: number; } -export interface QueueItem { - kind: 'review'; - ref_kind: 'card' | 'topic'; - ref_id: string; - due_at: number; +const FREE_STUDY_TITLE = 'estudo livre'; + +const byPosition = (a: PlannerTopic, b: PlannerTopic): number => + a.position - b.position || a.id.localeCompare(b.id); + +/** + * Topological order of a track's unfinished topics: dependencies first, + * position as the tiebreak (Kahn's algorithm with a min-position frontier). + * Deps pointing at done or unknown topics count as satisfied. If a dependency + * cycle remains, the lowest-position topic in the cycle is emitted next so the + * order stays total and deterministic. + */ +function orderTopics(topics: PlannerTopic[]): PlannerTopic[] { + const unfinished = topics.filter((t) => t.status !== 'done'); + const ids = new Set(unfinished.map((t) => t.id)); + const remainingDeps = new Map>(); + for (const t of unfinished) { + remainingDeps.set(t.id, new Set(t.deps.filter((d) => ids.has(d) && d !== t.id))); + } + + const pending = unfinished.toSorted(byPosition); + const done = new Set(); + const out: PlannerTopic[] = []; + while (out.length < unfinished.length) { + let pick: PlannerTopic | undefined; + for (const t of pending) { + if (done.has(t.id)) continue; + const deps = remainingDeps.get(t.id); + if (!deps || [...deps].every((d) => done.has(d))) { + pick = t; + break; + } + } + // Cycle: fall back to the lowest-position remaining topic. + if (!pick) pick = pending.find((t) => !done.has(t.id)); + if (!pick) break; // unreachable, satisfies noUncheckedIndexedAccess-style narrowing + done.add(pick.id); + out.push(pick); + } + return out; +} + +/** + * Schedule mode: fills each routine occurrence in `[fromDay, toDay]` with the + * next unfinished topic of that routine's track in topological order (deps + * first, then position). All routines of the same track share one cursor per + * call, so a topic is not repeated until every unfinished topic of the track + * has been consumed — then allocation cycles from the start. Routines without + * a track (and tracks whose topics are all done) produce `topic_id: null` + * blocks titled 'estudo livre'. + * + * Blocks are emitted day by day; within a day, in routine start_time order + * (routine id as tiebreak) — consumers rely on array order as the daily order. + */ +export function allocateSchedule( + routines: RoutineSpec[], + topics: PlannerTopic[], + fromDay: number, + toDay: number, +): PlanBlock[] { + const orderedByTrack = new Map(); + for (const t of topics) { + const list = orderedByTrack.get(t.track_id); + if (list) list.push(t); + else orderedByTrack.set(t.track_id, [t]); + } + for (const [trackId, list] of orderedByTrack) { + orderedByTrack.set(trackId, orderTopics(list)); + } + + const cursors = new Map(); + const sortedRoutines = routines.toSorted( + (a, b) => a.start_time.localeCompare(b.start_time) || a.id.localeCompare(b.id), + ); + + const out: PlanBlock[] = []; + for (let day = fromDay; day <= toDay; day += DAY_MS) { + const dow = dayOfWeek(day); + for (const r of sortedRoutines) { + if (!r.days.includes(dow)) continue; + let topic: PlannerTopic | undefined; + if (r.track_id !== null) { + const ordered = orderedByTrack.get(r.track_id) ?? []; + if (ordered.length > 0) { + const cursor = cursors.get(r.track_id) ?? 0; + topic = ordered[cursor % ordered.length]; + cursors.set(r.track_id, cursor + 1); + } + } + out.push({ + day, + routine_id: r.id, + track_id: r.track_id, + topic_id: topic ? topic.id : null, + title: topic ? topic.title : FREE_STUDY_TITLE, + duration_min: r.duration_min, + }); + } + } + return out; +} + +export interface CycleSlotSpec { + id: string; + topic_id: string; title: string; + weight: number; + position: number; +} + +/** + * Weighted round-robin: each slot takes `weight` consecutive turns per cycle, + * slots expand in position order (id tiebreak) — weights A=2, B=1 yield + * A A B A A B… The pointer is the total number of picks already made + * (persisted by the caller) and wraps around the expanded cycle. Slots with + * weight < 1 are skipped; returns null when nothing is pickable. + */ +export function cycleNext( + slots: CycleSlotSpec[], + pointer: number, +): { slot: CycleSlotSpec; nextPointer: number } | null { + const usable = slots + .filter((s) => Math.floor(s.weight) >= 1) + .toSorted((a, b) => a.position - b.position || a.id.localeCompare(b.id)); + if (usable.length === 0) return null; + + const expanded: CycleSlotSpec[] = []; + for (const slot of usable) { + for (let i = 0; i < Math.floor(slot.weight); i++) expanded.push(slot); + } + const index = ((pointer % expanded.length) + expanded.length) % expanded.length; + const slot = expanded[index]; + if (!slot) return null; // unreachable; index is in range + return { slot, nextPointer: pointer + 1 }; +} + +/** + * Replan: overdue plan-days are redistributed strictly forward. M3 keeps plan + * blocks derived (not persisted), so replanning is exactly `allocateSchedule` + * starting from today — this function makes that policy explicit and testable. + * + * `todayDay` is epoch ms of TODAY'S LOCAL MIDNIGHT (normalized by the caller; + * core cannot derive a local midnight from a raw timestamp). The horizon + * covers `todayDay` through `todayDay + (horizonDays - 1)` days, so no past + * day is ever emitted; being pure, it is idempotent by construction. + */ +export function replan( + routines: RoutineSpec[], + topics: PlannerTopic[], + todayDay: number, + horizonDays: number, +): PlanBlock[] { + if (horizonDays < 1) return []; + return allocateSchedule(routines, topics, todayDay, todayDay + (horizonDays - 1) * DAY_MS); +} + +export interface TodayInputs { + due: { refKind: 'card' | 'topic'; refId: string; title: string; dueAt: number | null }[]; + blocks: PlanBlock[]; + reminders: { id: string; title: string; notify_at: number }[]; } -const byDueAt = (a: QueueItem, b: QueueItem): number => a.due_at - b.due_at; +export interface TodayItem { + kind: 'review' | 'block' | 'reminder'; + title: string; + subtitle: string | null; + href: string; + sort: number; +} -export function dailyQueue(items: QueueItem[], now: number): QueueItem[] { - const due = items.filter((i) => i.due_at <= now); - const future = items.filter((i) => i.due_at > now); - return [...due.toSorted(byDueAt), ...future.toSorted(byDueAt)]; +/** '90 min' -> '1h30 de estudo', '120' -> '2h de estudo', '45' -> '45min de estudo'. */ +function blockSubtitle(durationMin: number): string { + const h = Math.floor(durationMin / 60); + const m = durationMin % 60; + if (h === 0) return `${m}min de estudo`; + if (m === 0) return `${h}h de estudo`; + return `${h}h${String(m).padStart(2, '0')} de estudo`; } -// TODO(M2): expand rrules and rotate cycle slots into concrete blocks. -export function planDay( - _routines: readonly RoutineRow[], - _slots: readonly CycleSlotRow[], - _dayStart: number, -): PlannedBlock[] { - throw new Error('not implemented (M2)'); +/** + * Merges today's inputs into one ordered feed. Sort bands: + * - 0.. overdue reviews (`dueAt < now`), oldest due first + * - 100.. due reminders (`notify_at <= now`), earliest first; reminders not + * yet due are omitted (they are not part of Today) + * - 200.. plan blocks, in input array order (allocateSchedule emits them in + * per-day chronological order) + * - 300.. fresh reviews (`dueAt` null or `>= now`), earliest first, undated last + */ +export function buildToday(inputs: TodayInputs, now: number): TodayItem[] { + const items: TodayItem[] = []; + + const overdue = inputs.due + .filter((d) => d.dueAt !== null && d.dueAt < now) + .toSorted((a, b) => (a.dueAt as number) - (b.dueAt as number)); + overdue.forEach((d, i) => { + items.push({ kind: 'review', title: d.title, subtitle: 'revisão', href: '/review', sort: i }); + }); + + const dueReminders = inputs.reminders + .filter((r) => r.notify_at <= now) + .toSorted((a, b) => a.notify_at - b.notify_at); + dueReminders.forEach((r, i) => { + items.push({ + kind: 'reminder', + title: r.title, + subtitle: 'lembrete', + href: '/reminders', + sort: 100 + i, + }); + }); + + inputs.blocks.forEach((b, i) => { + items.push({ + kind: 'block', + title: b.title, + subtitle: blockSubtitle(b.duration_min), + href: '/study', + sort: 200 + i, + }); + }); + + const fresh = inputs.due + .filter((d) => d.dueAt === null || d.dueAt >= now) + .toSorted((a, b) => { + if (a.dueAt === null && b.dueAt === null) return 0; + if (a.dueAt === null) return 1; + if (b.dueAt === null) return -1; + return a.dueAt - b.dueAt; + }); + fresh.forEach((d, i) => { + items.push({ + kind: 'review', + title: d.title, + subtitle: 'revisão', + href: '/review', + sort: 300 + i, + }); + }); + + return items.toSorted((a, b) => a.sort - b.sort); } diff --git a/packages/core/src/stats/index.ts b/packages/core/src/stats/index.ts index f79684f..b6010fd 100644 --- a/packages/core/src/stats/index.ts +++ b/packages/core/src/stats/index.ts @@ -1,16 +1,185 @@ -import type { SessionRow } from '@studyos/shared'; +// M3 stats: pure aggregations over session/review slices. Same timezone +// policy as the planner (see src/planner/index.ts): day boundaries are epoch +// ms of LOCAL midnights passed in by the caller, a day is a fixed 86_400_000 +// ms step, DST drift is acceptable in M3. The single sanctioned Date usage is +// `periodComparison`, which needs the local day-of-week / local midnight of +// `now` to find the ISO week start — pure ECMAScript, no platform dep. -export interface DailyTotals { - date: string; +import { DAY_MS } from '../planner'; + +export interface SessionSlice { + started_at: number; net_seconds: number; - session_count: number; + track_id: string | null; + topic_id: string | null; + questions_total: number | null; + questions_correct: number | null; +} + +export interface ReviewSlice { + reviewed_at: number; + rating: number; + ref_id: string; + ref_kind: string; +} + +/** + * Net study seconds per day over `[fromDay, toDay]` inclusive (local-midnight + * epoch ms), zero-filled: every day in the range appears exactly once. + * Sessions are bucketed by 86_400_000-ms offset from `fromDay`. + */ +export function netSecondsPerDay( + s: SessionSlice[], + fromDay: number, + toDay: number, +): { day: number; seconds: number }[] { + const out: { day: number; seconds: number }[] = []; + for (let day = fromDay; day <= toDay; day += DAY_MS) { + out.push({ day, seconds: 0 }); + } + for (const session of s) { + if (session.started_at < fromDay || session.started_at >= toDay + DAY_MS) continue; + const bucket = Math.floor((session.started_at - fromDay) / DAY_MS); + const entry = out[bucket]; + if (entry) entry.seconds += session.net_seconds; + } + return out; +} + +/** + * Consecutive days with `seconds > 0`, counting back from today (the latest + * `day` in `perDay` that is `<= now`). If today has 0 seconds the streak is + * still alive and counting starts from yesterday. Days missing from `perDay` + * count as 0 and break the streak. + */ +export function currentStreak(perDay: { day: number; seconds: number }[], now: number): number { + const seconds = new Map(); + let today = Number.NEGATIVE_INFINITY; + for (const entry of perDay) { + seconds.set(entry.day, entry.seconds); + if (entry.day <= now && entry.day > today) today = entry.day; + } + if (!Number.isFinite(today)) return 0; + + let day = today; + if ((seconds.get(day) ?? 0) === 0) day -= DAY_MS; // today empty: fall back to yesterday + let streak = 0; + while ((seconds.get(day) ?? 0) > 0) { + streak += 1; + day -= DAY_MS; + } + return streak; } -// TODO(M2): aggregate sessions into per-day totals and streaks. -export function dailyTotals(_sessions: readonly SessionRow[]): DailyTotals[] { - throw new Error('not implemented (M2)'); +/** + * Question accuracy per track, summed over sessions with `questions_total > 0`. + * Tracks appear in first-seen order (including `null`); `pct` is 0..100, or + * null when the track has no question-bearing sessions. + */ +export function accuracyByTrack( + s: SessionSlice[], +): { track_id: string | null; total: number; correct: number; pct: number | null }[] { + const rows = new Map< + string | null, + { track_id: string | null; total: number; correct: number } + >(); + for (const session of s) { + let row = rows.get(session.track_id); + if (!row) { + row = { track_id: session.track_id, total: 0, correct: 0 }; + rows.set(session.track_id, row); + } + if (session.questions_total !== null && session.questions_total > 0) { + row.total += session.questions_total; + row.correct += session.questions_correct ?? 0; + } + } + return [...rows.values()].map((row) => ({ + track_id: row.track_id, + total: row.total, + correct: row.correct, + pct: row.total > 0 ? (row.correct / row.total) * 100 : null, + })); } -export function streakDays(_totals: readonly DailyTotals[]): number { - throw new Error('not implemented (M2)'); +/** + * Net seconds this ISO week (Monday 00:00 local) vs the previous one. + * `deltaPct` is the percentage change from last week, null when last week is 0. + */ +export function periodComparison( + s: SessionSlice[], + now: number, +): { thisWeek: number; lastWeek: number; deltaPct: number | null } { + const d = new Date(now); + d.setHours(0, 0, 0, 0); // local midnight of today + const daysSinceMonday = (d.getDay() + 6) % 7; + const thisWeekStart = d.getTime() - daysSinceMonday * DAY_MS; + const lastWeekStart = thisWeekStart - 7 * DAY_MS; + const nextWeekStart = thisWeekStart + 7 * DAY_MS; + + let thisWeek = 0; + let lastWeek = 0; + for (const session of s) { + if (session.started_at >= thisWeekStart && session.started_at < nextWeekStart) { + thisWeek += session.net_seconds; + } else if (session.started_at >= lastWeekStart && session.started_at < thisWeekStart) { + lastWeek += session.net_seconds; + } + } + const deltaPct = lastWeek > 0 ? ((thisWeek - lastWeek) / lastWeek) * 100 : null; + return { thisWeek, lastWeek, deltaPct }; +} + +export interface WeakTopic { + topic_id: string; + score: number; +} // higher = weaker + +/** + * Weakness score per topic: share of ratings 1-2 weighted 0.7, plus normalized + * inverse net study time weighted 0.3 (least-studied candidate = 1; when no + * candidate has any time, every time component is 1). Only topics with >= 3 + * reviews qualify; sorted by score desc (topic_id asc tiebreak), top `limit` + * (default 5). + * + * Limitation (M3): only reviews with `ref_kind === 'topic'` are scored — + * card reviews carry no card→topic mapping in `ReviewSlice`, so they are + * ignored. Sessions are matched by `topic_id`. + */ +export function weakTopics( + reviews: ReviewSlice[], + sessions: SessionSlice[], + limit = 5, +): WeakTopic[] { + const ratings = new Map(); + for (const review of reviews) { + if (review.ref_kind !== 'topic') continue; + let entry = ratings.get(review.ref_id); + if (!entry) { + entry = { total: 0, low: 0 }; + ratings.set(review.ref_id, entry); + } + entry.total += 1; + if (review.rating <= 2) entry.low += 1; + } + + const candidates = [...ratings.entries()].filter(([, r]) => r.total >= 3); + if (candidates.length === 0) return []; + + const netByTopic = new Map(); + for (const session of sessions) { + if (session.topic_id === null) continue; + netByTopic.set(session.topic_id, (netByTopic.get(session.topic_id) ?? 0) + session.net_seconds); + } + const maxNet = Math.max(...candidates.map(([id]) => netByTopic.get(id) ?? 0)); + + return candidates + .map(([topic_id, r]) => { + const lowShare = r.low / r.total; + const net = netByTopic.get(topic_id) ?? 0; + const inverseTime = maxNet > 0 ? 1 - net / maxNet : 1; + return { topic_id, score: 0.7 * lowShare + 0.3 * inverseTime }; + }) + .toSorted((a, b) => b.score - a.score || a.topic_id.localeCompare(b.topic_id)) + .slice(0, limit); } diff --git a/packages/core/test/planner-m3.test.ts b/packages/core/test/planner-m3.test.ts new file mode 100644 index 0000000..f049677 --- /dev/null +++ b/packages/core/test/planner-m3.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, test } from 'bun:test'; +import { + DAY_MS, + allocateSchedule, + buildToday, + cycleNext, + parseRrule, + replan, + routineOccurrences, + type CycleSlotSpec, + type PlannerTopic, + type RoutineSpec, + type TodayInputs, +} from '../src/planner'; + +// Local midnight of 2026-01- — TZ-portable because tests and core both use +// the environment's local zone. 2026-01-05 is a Monday. +const day = (d: number): number => new Date(2026, 0, d).getTime(); + +const routine = (partial: Partial & { id: string }): RoutineSpec => ({ + track_id: null, + days: [1, 2, 3, 4, 5], + start_time: '08:00', + duration_min: 60, + ...partial, +}); + +const topic = (partial: Partial & { id: string }): PlannerTopic => ({ + track_id: 't1', + title: partial.id, + status: 'pending', + position: 0, + deps: [], + ...partial, +}); + +describe('parseRrule', () => { + test('parses weekly BYDAY subsets in any order, sorted output', () => { + expect(parseRrule('FREQ=WEEKLY;BYDAY=MO,WE,FR')).toEqual([1, 3, 5]); + expect(parseRrule('FREQ=WEEKLY;BYDAY=SA,SU')).toEqual([0, 6]); + expect(parseRrule('BYDAY=TU;FREQ=WEEKLY')).toEqual([2]); + expect(parseRrule('FREQ=WEEKLY;INTERVAL=1;BYDAY=SU,MO,TU,WE,TH,FR,SA')).toEqual([ + 0, 1, 2, 3, 4, 5, 6, + ]); + }); + + test('throws on anything outside the subset', () => { + const bad = [ + 'FREQ=DAILY;BYDAY=MO', + 'FREQ=WEEKLY;BYDAY=MO;INTERVAL=2', + 'FREQ=WEEKLY', + 'FREQ=WEEKLY;BYDAY=MO,XX', + 'FREQ=WEEKLY;BYDAY=MO;COUNT=5', + 'BYDAY=MO', + '', + ]; + for (const rrule of bad) { + expect(() => parseRrule(rrule)).toThrow('unsupported rrule'); + } + }); +}); + +describe('routineOccurrences', () => { + test('expands over two weeks with a subset of days', () => { + const r = routine({ id: 'r1', days: [1, 3] }); // Mon, Wed + // Mon Jan 5 .. Sun Jan 18 + expect(routineOccurrences(r, day(5), day(18))).toEqual([day(5), day(7), day(12), day(14)]); + }); + + test('inclusive bounds and empty result', () => { + const r = routine({ id: 'r1', days: [1] }); + expect(routineOccurrences(r, day(5), day(5))).toEqual([day(5)]); + expect(routineOccurrences(r, day(6), day(11))).toEqual([]); + }); +}); + +describe('allocateSchedule', () => { + test('respects deps before position, skips done, cycles when exhausted', () => { + const topics = [ + topic({ id: 'B', position: 0, deps: ['A'] }), + topic({ id: 'A', position: 1 }), + topic({ id: 'C', position: 2, status: 'done' }), + topic({ id: 'D', position: 3 }), + ]; + const r = routine({ id: 'r1', track_id: 't1', days: [1, 2, 3, 4, 5] }); + // Mon..Fri = 5 occurrences over A, B, D then cycle from the start. + const blocks = allocateSchedule([r], topics, day(5), day(9)); + expect(blocks.map((b) => b.topic_id)).toEqual(['A', 'B', 'D', 'A', 'B']); + expect(blocks.map((b) => b.day)).toEqual([day(5), day(6), day(7), day(8), day(9)]); + expect(blocks.every((b) => b.routine_id === 'r1' && b.duration_min === 60)).toBe(true); + }); + + test('same-track routines share one cursor; different tracks are independent', () => { + const topics = [ + topic({ id: 'A', track_id: 't1', position: 0 }), + topic({ id: 'B', track_id: 't1', position: 1 }), + topic({ id: 'X', track_id: 't2', position: 0 }), + ]; + const routines = [ + routine({ id: 'r-am', track_id: 't1', days: [1], start_time: '08:00' }), + routine({ id: 'r-pm', track_id: 't1', days: [1], start_time: '14:00' }), + routine({ id: 'r-t2', track_id: 't2', days: [1], start_time: '10:00' }), + ]; + const blocks = allocateSchedule(routines, topics, day(5), day(5)); + // Within the day, blocks are ordered by start_time. + expect(blocks.map((b) => b.routine_id)).toEqual(['r-am', 'r-t2', 'r-pm']); + expect(blocks.map((b) => b.topic_id)).toEqual(['A', 'X', 'B']); + }); + + test('track-less routines and exhausted tracks produce estudo livre blocks', () => { + const routines = [ + routine({ id: 'free', track_id: null, days: [1] }), + routine({ id: 'done-track', track_id: 't9', days: [1], start_time: '09:00' }), + ]; + const topics = [topic({ id: 'Z', track_id: 't9', status: 'done' })]; + const blocks = allocateSchedule(routines, topics, day(5), day(5)); + expect(blocks.map((b) => b.title)).toEqual(['estudo livre', 'estudo livre']); + expect(blocks.map((b) => b.topic_id)).toEqual([null, null]); + }); +}); + +describe('cycleNext', () => { + test('weights expand as consecutive turns per cycle and the pointer wraps', () => { + const slots: CycleSlotSpec[] = [ + { id: 'b', topic_id: 'tb', title: 'B', weight: 1, position: 1 }, + { id: 'a', topic_id: 'ta', title: 'A', weight: 2, position: 0 }, + ]; + const picks: string[] = []; + let pointer = 0; + for (let i = 0; i < 6; i++) { + const next = cycleNext(slots, pointer); + if (next === null) throw new Error('unexpected null'); + picks.push(next.slot.id); + expect(next.nextPointer).toBe(pointer + 1); + pointer = next.nextPointer; + } + expect(picks).toEqual(['a', 'a', 'b', 'a', 'a', 'b']); + }); + + test('returns null with no usable slots; skips weight < 1', () => { + expect(cycleNext([], 0)).toBeNull(); + const slots: CycleSlotSpec[] = [ + { id: 'a', topic_id: 'ta', title: 'A', weight: 0, position: 0 }, + { id: 'b', topic_id: 'tb', title: 'B', weight: 1, position: 1 }, + ]; + const next = cycleNext(slots, 5); + expect(next?.slot.id).toBe('b'); + }); +}); + +describe('replan', () => { + test('never emits past days and is idempotent', () => { + const r = routine({ id: 'r1', track_id: 't1', days: [0, 1, 2, 3, 4, 5, 6] }); + const topics = [topic({ id: 'A' }), topic({ id: 'B', position: 1 })]; + const today = day(7); + const first = replan([r], topics, today, 5); + const second = replan([r], topics, today, 5); + expect(first).toEqual(second); + expect(first.length).toBe(5); + expect(first.every((b) => b.day >= today)).toBe(true); + expect(first[first.length - 1]?.day).toBe(today + 4 * DAY_MS); + }); + + test('empty horizon produces nothing', () => { + expect(replan([routine({ id: 'r1' })], [], day(5), 0)).toEqual([]); + }); +}); + +describe('buildToday', () => { + test('orders overdue reviews, due reminders, blocks, fresh reviews', () => { + const now = day(7) + 12 * 3_600_000; // Wed noon + const inputs: TodayInputs = { + due: [ + { refKind: 'card', refId: 'c-fresh', title: 'fresh card', dueAt: now + 3_600_000 }, + { refKind: 'topic', refId: 't-old', title: 'old topic', dueAt: now - 2 * DAY_MS }, + { refKind: 'card', refId: 'c-new', title: 'new card', dueAt: null }, + { refKind: 'card', refId: 'c-late', title: 'late card', dueAt: now - 3_600_000 }, + ], + blocks: [ + { + day: day(7), + routine_id: 'r1', + track_id: 't1', + topic_id: 'A', + title: 'A', + duration_min: 90, + }, + { + day: day(7), + routine_id: 'r2', + track_id: null, + topic_id: null, + title: 'estudo livre', + duration_min: 45, + }, + ], + reminders: [ + { id: 'rem-future', title: 'depois', notify_at: now + 60_000 }, + { id: 'rem-due', title: 'simulado', notify_at: now - 60_000 }, + ], + }; + const items = buildToday(inputs, now); + expect(items.map((i) => `${i.kind}:${i.title}`)).toEqual([ + 'review:old topic', + 'review:late card', + 'reminder:simulado', + 'block:A', + 'block:estudo livre', + 'review:fresh card', + 'review:new card', + ]); + expect(items.map((i) => i.sort)).toEqual([0, 1, 100, 200, 201, 300, 301]); + }); + + test('hrefs and subtitles follow the contract copy', () => { + const now = day(7); + const items = buildToday( + { + due: [{ refKind: 'topic', refId: 't1', title: 'x', dueAt: now - 1 }], + blocks: [ + { + day: now, + routine_id: 'r1', + track_id: null, + topic_id: null, + title: 'estudo livre', + duration_min: 120, + }, + ], + reminders: [{ id: 'rem', title: 'y', notify_at: now }], + }, + now, + ); + expect(items.map((i) => i.href)).toEqual(['/review', '/reminders', '/study']); + expect(items.map((i) => i.subtitle)).toEqual(['revisão', 'lembrete', '2h de estudo']); + }); + + test('block subtitle formats hours and minutes', () => { + const now = day(7); + const block = (duration_min: number) => ({ + day: now, + routine_id: 'r', + track_id: null, + topic_id: null, + title: 'estudo livre', + duration_min, + }); + const items = buildToday({ due: [], reminders: [], blocks: [block(90), block(45)] }, now); + expect(items.map((i) => i.subtitle)).toEqual(['1h30 de estudo', '45min de estudo']); + }); +}); diff --git a/packages/core/test/planner.test.ts b/packages/core/test/planner.test.ts deleted file mode 100644 index d5e6cef..0000000 --- a/packages/core/test/planner.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test } from 'bun:test'; -import { dailyQueue, type QueueItem } from '../src/planner'; - -const item = (ref_id: string, due_at: number): QueueItem => ({ - kind: 'review', - ref_kind: 'card', - ref_id, - due_at, - title: ref_id, -}); - -test('dailyQueue puts due items first (oldest first), then future by due_at asc', () => { - const now = 1_000_000; - const items = [ - item('future-late', now + 5_000), - item('due-old', now - 9_000), - item('future-soon', now + 1_000), - item('due-now', now), - item('due-recent', now - 1_000), - ]; - const queue = dailyQueue(items, now); - expect(queue.map((i) => i.ref_id)).toEqual([ - 'due-old', - 'due-recent', - 'due-now', - 'future-soon', - 'future-late', - ]); -}); - -test('dailyQueue does not mutate the input array', () => { - const now = 1_000; - const items = [item('b', now + 1), item('a', now - 1)]; - dailyQueue(items, now); - expect(items.map((i) => i.ref_id)).toEqual(['b', 'a']); -}); - -test('dailyQueue handles empty input', () => { - expect(dailyQueue([], 0)).toEqual([]); -}); diff --git a/packages/core/test/stats.test.ts b/packages/core/test/stats.test.ts new file mode 100644 index 0000000..949353a --- /dev/null +++ b/packages/core/test/stats.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from 'bun:test'; +import { + accuracyByTrack, + currentStreak, + netSecondsPerDay, + periodComparison, + weakTopics, + type ReviewSlice, + type SessionSlice, +} from '../src/stats'; + +// Local midnight of 2026-01-; 2026-01-05 is a Monday. +const day = (d: number): number => new Date(2026, 0, d).getTime(); +const HOUR_MS = 3_600_000; + +const session = (partial: Partial): SessionSlice => ({ + started_at: day(5), + net_seconds: 0, + track_id: null, + topic_id: null, + questions_total: null, + questions_correct: null, + ...partial, +}); + +const review = (partial: Partial): ReviewSlice => ({ + reviewed_at: day(5), + rating: 3, + ref_id: 'topic-1', + ref_kind: 'topic', + ...partial, +}); + +describe('netSecondsPerDay', () => { + test('zero-fills every day in the inclusive range and sums buckets', () => { + const sessions = [ + session({ started_at: day(5) + HOUR_MS, net_seconds: 600 }), + session({ started_at: day(5) + 5 * HOUR_MS, net_seconds: 300 }), + session({ started_at: day(7) + HOUR_MS, net_seconds: 120 }), + session({ started_at: day(4), net_seconds: 999 }), // before range + session({ started_at: day(8), net_seconds: 999 }), // after range + ]; + expect(netSecondsPerDay(sessions, day(5), day(7))).toEqual([ + { day: day(5), seconds: 900 }, + { day: day(6), seconds: 0 }, + { day: day(7), seconds: 120 }, + ]); + }); + + test('single-day range', () => { + expect(netSecondsPerDay([], day(5), day(5))).toEqual([{ day: day(5), seconds: 0 }]); + }); +}); + +describe('currentStreak', () => { + const perDay = (seconds: number[]): { day: number; seconds: number }[] => + seconds.map((s, i) => ({ day: day(1 + i), seconds: s })); + + test('counts today when it has study time', () => { + const now = day(4) + 20 * HOUR_MS; + expect(currentStreak(perDay([0, 60, 60, 60]), now)).toBe(3); + }); + + test('today at zero falls back to yesterday without breaking', () => { + const now = day(4) + 20 * HOUR_MS; + expect(currentStreak(perDay([60, 60, 60, 0]), now)).toBe(3); + }); + + test('a gap breaks the streak; empty input is zero', () => { + const now = day(4) + 20 * HOUR_MS; + expect(currentStreak(perDay([60, 0, 60, 60]), now)).toBe(2); + expect(currentStreak(perDay([0, 0, 0, 0]), now)).toBe(0); + expect(currentStreak([], now)).toBe(0); + }); +}); + +describe('accuracyByTrack', () => { + test('sums question-bearing sessions per track; pct null without questions', () => { + const sessions = [ + session({ track_id: 'a', questions_total: 10, questions_correct: 7 }), + session({ track_id: 'a', questions_total: 10, questions_correct: 8 }), + session({ track_id: 'a', net_seconds: 100 }), // no questions, still counts the track + session({ track_id: 'b', net_seconds: 100 }), + session({ track_id: null, questions_total: 4, questions_correct: 1 }), + ]; + expect(accuracyByTrack(sessions)).toEqual([ + { track_id: 'a', total: 20, correct: 15, pct: 75 }, + { track_id: 'b', total: 0, correct: 0, pct: null }, + { track_id: null, total: 4, correct: 1, pct: 25 }, + ]); + }); + + test('questions_total of 0 does not count', () => { + expect(accuracyByTrack([session({ track_id: 'a', questions_total: 0 })])).toEqual([ + { track_id: 'a', total: 0, correct: 0, pct: null }, + ]); + }); +}); + +describe('periodComparison', () => { + // now = Wednesday 2026-01-07 noon; ISO week starts Monday 2026-01-05 00:00. + const now = day(7) + 12 * HOUR_MS; + + test('splits at the Monday boundary', () => { + const sessions = [ + session({ started_at: day(5), net_seconds: 100 }), // exactly Monday 00:00 -> this week + session({ started_at: day(6) + HOUR_MS, net_seconds: 50 }), + session({ started_at: day(4) + 23 * HOUR_MS, net_seconds: 40 }), // Sunday -> last week + session({ started_at: day(2), net_seconds: 60 }), // Friday -> last week + session({ started_at: day(20), net_seconds: 999 }), // outside both weeks + ]; + expect(periodComparison(sessions, now)).toEqual({ + thisWeek: 150, + lastWeek: 100, + deltaPct: 50, + }); + }); + + test('deltaPct is null when last week is empty', () => { + const sessions = [session({ started_at: day(6), net_seconds: 30 })]; + expect(periodComparison(sessions, now)).toEqual({ thisWeek: 30, lastWeek: 0, deltaPct: null }); + }); +}); + +describe('weakTopics', () => { + test('scores low ratings and low net time; filters topics under 3 reviews', () => { + const reviews = [ + // X: 4 reviews, 3 low, no study time -> 0.7*0.75 + 0.3*1 = 0.825 + review({ ref_id: 'X', rating: 1 }), + review({ ref_id: 'X', rating: 2 }), + review({ ref_id: 'X', rating: 1 }), + review({ ref_id: 'X', rating: 4 }), + // Y: 3 reviews, 1 low, most-studied -> 0.7*(1/3) + 0.3*0 ~ 0.2333 + review({ ref_id: 'Y', rating: 2 }), + review({ ref_id: 'Y', rating: 3 }), + review({ ref_id: 'Y', rating: 4 }), + // Z: only 2 reviews -> filtered out + review({ ref_id: 'Z', rating: 1 }), + review({ ref_id: 'Z', rating: 1 }), + // card reviews are ignored (no card->topic mapping in the slice) + review({ ref_id: 'X', rating: 1, ref_kind: 'card' }), + ]; + const sessions = [session({ topic_id: 'Y', net_seconds: 3600 })]; + const result = weakTopics(reviews, sessions); + expect(result.map((w) => w.topic_id)).toEqual(['X', 'Y']); + expect(result[0]?.score).toBeCloseTo(0.825, 5); + expect(result[1]?.score).toBeCloseTo(0.7 / 3, 5); + }); + + test('respects limit and handles all-zero study time', () => { + const reviews = ['A', 'B'].flatMap((id) => [ + review({ ref_id: id, rating: 1 }), + review({ ref_id: id, rating: 1 }), + review({ ref_id: id, rating: id === 'A' ? 1 : 5 }), + ]); + // No sessions: every candidate gets the full time weight (0.3). + const all = weakTopics(reviews, []); + expect(all.map((w) => w.topic_id)).toEqual(['A', 'B']); + expect(all[0]?.score).toBeCloseTo(1, 5); + expect(all[1]?.score).toBeCloseTo(0.7 * (2 / 3) + 0.3, 5); + expect(weakTopics(reviews, [], 1).map((w) => w.topic_id)).toEqual(['A']); + }); + + test('empty input yields empty output', () => { + expect(weakTopics([], [])).toEqual([]); + }); +}); diff --git a/packages/core/test/stubs.test.ts b/packages/core/test/stubs.test.ts deleted file mode 100644 index f96d973..0000000 --- a/packages/core/test/stubs.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from 'bun:test'; -import { planDay } from '../src/planner'; -import { dailyTotals } from '../src/stats'; - -test('planDay is an M2 stub', () => { - expect(() => planDay([], [], 0)).toThrow('not implemented (M2)'); -}); - -test('stats is an M2 stub', () => { - expect(() => dailyTotals([])).toThrow('not implemented (M2)'); -}); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d276c5a..7e89ed4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,5 +11,10 @@ export * from './repo/cards'; export * from './repo/fsrs'; export * from './repo/sessions'; export * from './repo/checklists'; +export * from './repo/routines'; +export * from './repo/targets'; +export * from './repo/reminders'; +export * from './repo/cycle'; +export * from './repo/stats-queries'; export * from './sync/apply'; export * from './sync/engine'; diff --git a/packages/db/src/repo/cycle.ts b/packages/db/src/repo/cycle.ts new file mode 100644 index 0000000..58e8de3 --- /dev/null +++ b/packages/db/src/repo/cycle.ts @@ -0,0 +1,82 @@ +import { newId, now, type CycleSlotRow } from '@studyos/shared'; +import type { DbDriver, Row, Stmt } from '../driver'; +import { localWriteStmts } from './oplog'; +import { getSetting, setSetting } from './settings'; +import { bumpedTs } from './ts'; + +export interface CycleSlotInput { + topic_id: string; + weight: number; +} + +function rowToCycleSlot(r: Row): CycleSlotRow { + return { + id: r['id'] as string, + track_id: r['track_id'] as string, + topic_id: r['topic_id'] as string, + weight: r['weight'] as number, + position: r['position'] as number, + updated_at: r['updated_at'] as number, + deleted_at: (r['deleted_at'] ?? null) as number | null, + }; +} + +export async function listCycleSlots(db: DbDriver, trackId: string): Promise { + const rows = await db.exec( + 'SELECT * FROM cycle_slots WHERE track_id = ? AND deleted_at IS NULL ' + + 'ORDER BY position ASC, id ASC', + [trackId], + ); + return rows.map(rowToCycleSlot); +} + +/** + * Replace-all: soft-deletes the track's current slots and inserts the new list + * (position = index, weight clamped >= 1) in ONE atomic batch. + */ +export async function setCycleSlots( + db: DbDriver, + deviceId: string, + trackId: string, + slots: CycleSlotInput[], +): Promise { + const existing = await listCycleSlots(db, trackId); + const stmts: Stmt[] = []; + for (const slot of existing) { + const ts = bumpedTs(slot.updated_at); + stmts.push( + ...localWriteStmts('cycle_slots', { ...slot, deleted_at: ts, updated_at: ts }, deviceId), + ); + } + const ts = now(); + slots.forEach((slot, position) => { + const row = { + id: newId(), + track_id: trackId, + topic_id: slot.topic_id, + weight: Math.max(1, Math.floor(slot.weight)), + position, + updated_at: ts, + deleted_at: null, + } satisfies CycleSlotRow; + stmts.push(...localWriteStmts('cycle_slots', row, deviceId)); + }); + if (stmts.length > 0) await db.batch(stmts); +} + +// The cycle pointer (total picks already made, consumed by core's cycleNext) is +// a per-device cursor, so it lives in local-only settings — never in the oplog. +function pointerKey(trackId: string): string { + return `cycle_pointer:${trackId}`; +} + +export async function getCyclePointer(db: DbDriver, trackId: string): Promise { + const raw = await getSetting(db, pointerKey(trackId)); + if (raw === null) return 0; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n >= 0 ? n : 0; +} + +export async function setCyclePointer(db: DbDriver, trackId: string, n: number): Promise { + await setSetting(db, pointerKey(trackId), String(n)); +} diff --git a/packages/db/src/repo/reminders.ts b/packages/db/src/repo/reminders.ts new file mode 100644 index 0000000..a4601ba --- /dev/null +++ b/packages/db/src/repo/reminders.ts @@ -0,0 +1,73 @@ +import { newId, now, type ReminderRow } from '@studyos/shared'; +import type { DbDriver, Row } from '../driver'; +import { localWrite } from './oplog'; +import { bumpedTs } from './ts'; + +export interface CreateReminderInput { + title: string; + notify_at: number; + rrule?: string | null; + ref_kind?: string | null; + ref_id?: string | null; +} + +function rowToReminder(r: Row): ReminderRow { + return { + id: r['id'] as string, + title: r['title'] as string, + ref_kind: (r['ref_kind'] ?? null) as string | null, + ref_id: (r['ref_id'] ?? null) as string | null, + notify_at: r['notify_at'] as number, + rrule: (r['rrule'] ?? null) as string | null, + updated_at: r['updated_at'] as number, + deleted_at: (r['deleted_at'] ?? null) as number | null, + }; +} + +export async function createReminder( + db: DbDriver, + deviceId: string, + input: CreateReminderInput, +): Promise { + const reminder = { + id: newId(), + title: input.title, + ref_kind: input.ref_kind ?? null, + ref_id: input.ref_id ?? null, + notify_at: input.notify_at, + rrule: input.rrule ?? null, + updated_at: now(), + deleted_at: null, + } satisfies ReminderRow; + await localWrite(db, 'reminders', reminder, deviceId); + return reminder; +} + +export async function getReminder(db: DbDriver, id: string): Promise { + const rows = await db.exec('SELECT * FROM reminders WHERE id = ?', [id]); + const r = rows[0]; + return r ? rowToReminder(r) : null; +} + +export async function listReminders(db: DbDriver): Promise { + const rows = await db.exec( + 'SELECT * FROM reminders WHERE deleted_at IS NULL ORDER BY notify_at ASC, id ASC', + ); + return rows.map(rowToReminder); +} + +export async function dueReminders(db: DbDriver, nowMs: number): Promise { + const rows = await db.exec( + 'SELECT * FROM reminders WHERE notify_at <= ? AND deleted_at IS NULL ' + + 'ORDER BY notify_at ASC, id ASC', + [nowMs], + ); + return rows.map(rowToReminder); +} + +export async function deleteReminder(db: DbDriver, deviceId: string, id: string): Promise { + const existing = await getReminder(db, id); + if (!existing || existing.deleted_at !== null) return; + const ts = bumpedTs(existing.updated_at); + await localWrite(db, 'reminders', { ...existing, deleted_at: ts, updated_at: ts }, deviceId); +} diff --git a/packages/db/src/repo/routines.ts b/packages/db/src/repo/routines.ts new file mode 100644 index 0000000..0366f1f --- /dev/null +++ b/packages/db/src/repo/routines.ts @@ -0,0 +1,89 @@ +import { newId, now, type RoutineRow } from '@studyos/shared'; +import type { DbDriver, Row } from '../driver'; +import { localWrite } from './oplog'; +import { bumpedTs } from './ts'; + +export interface CreateRoutineInput { + title: string; + track_id?: string | null; + rrule: string; + start_time: string; + duration_min: number; +} + +export interface RoutinePatch { + title?: string; + track_id?: string | null; + rrule?: string; + start_time?: string; + duration_min?: number; + active?: number; +} + +function rowToRoutine(r: Row): RoutineRow { + return { + id: r['id'] as string, + title: r['title'] as string, + track_id: (r['track_id'] ?? null) as string | null, + rrule: r['rrule'] as string, + start_time: r['start_time'] as string, + duration_min: r['duration_min'] as number, + active: r['active'] as number, + updated_at: r['updated_at'] as number, + deleted_at: (r['deleted_at'] ?? null) as number | null, + }; +} + +export async function createRoutine( + db: DbDriver, + deviceId: string, + input: CreateRoutineInput, +): Promise { + const routine = { + id: newId(), + title: input.title, + track_id: input.track_id ?? null, + rrule: input.rrule, + start_time: input.start_time, + duration_min: input.duration_min, + active: 1, + updated_at: now(), + deleted_at: null, + } satisfies RoutineRow; + await localWrite(db, 'routines', routine, deviceId); + return routine; +} + +export async function getRoutine(db: DbDriver, id: string): Promise { + const rows = await db.exec('SELECT * FROM routines WHERE id = ?', [id]); + const r = rows[0]; + return r ? rowToRoutine(r) : null; +} + +export async function listRoutines(db: DbDriver): Promise { + const rows = await db.exec( + 'SELECT * FROM routines WHERE active = 1 AND deleted_at IS NULL ' + + 'ORDER BY start_time ASC, id ASC', + ); + return rows.map(rowToRoutine); +} + +export async function updateRoutine( + db: DbDriver, + deviceId: string, + id: string, + patch: RoutinePatch, +): Promise { + const existing = await getRoutine(db, id); + if (!existing || existing.deleted_at !== null) return null; + const updated = { ...existing, ...patch, updated_at: bumpedTs(existing.updated_at) }; + await localWrite(db, 'routines', updated, deviceId); + return updated; +} + +export async function deleteRoutine(db: DbDriver, deviceId: string, id: string): Promise { + const existing = await getRoutine(db, id); + if (!existing || existing.deleted_at !== null) return; + const ts = bumpedTs(existing.updated_at); + await localWrite(db, 'routines', { ...existing, deleted_at: ts, updated_at: ts }, deviceId); +} diff --git a/packages/db/src/repo/stats-queries.ts b/packages/db/src/repo/stats-queries.ts new file mode 100644 index 0000000..0da5525 --- /dev/null +++ b/packages/db/src/repo/stats-queries.ts @@ -0,0 +1,93 @@ +import type { DbDriver, Row } from '../driver'; +import type { TopicStatus } from './topics'; + +// Read-only feeds for core/stats and core/planner. Shapes mirror core's +// SessionSlice / ReviewSlice / PlannerTopic structurally; db must not depend +// on @studyos/core. + +export interface SessionSliceRow { + started_at: number; + net_seconds: number; + track_id: string | null; + topic_id: string | null; + questions_total: number | null; + questions_correct: number | null; +} + +export interface ReviewSliceRow { + reviewed_at: number; + rating: number; + ref_id: string; + ref_kind: string; +} + +export interface PlannerTopicRow { + id: string; + track_id: string; + title: string; + status: TopicStatus; + position: number; + deps: string[]; +} + +/** Ended sessions started at/after fromMs (running sessions have no net time yet). */ +export async function sessionSlices(db: DbDriver, fromMs: number): Promise { + const rows = await db.exec( + 'SELECT started_at, net_seconds, track_id, topic_id, questions_total, questions_correct ' + + 'FROM sessions WHERE ended_at IS NOT NULL AND started_at >= ? AND deleted_at IS NULL ' + + 'ORDER BY started_at ASC, id ASC', + [fromMs], + ); + return rows.map((r) => ({ + started_at: r['started_at'] as number, + net_seconds: r['net_seconds'] as number, + track_id: (r['track_id'] ?? null) as string | null, + topic_id: (r['topic_id'] ?? null) as string | null, + questions_total: (r['questions_total'] ?? null) as number | null, + questions_correct: (r['questions_correct'] ?? null) as number | null, + })); +} + +/** Review log entries at/after fromMs, joined to fsrs_state for the ref. */ +export async function reviewSlices(db: DbDriver, fromMs: number): Promise { + const rows = await db.exec( + 'SELECT rl.reviewed_at, rl.rating, f.ref_id, f.ref_kind FROM review_logs rl ' + + 'JOIN fsrs_state f ON f.id = rl.fsrs_id WHERE rl.reviewed_at >= ? ' + + 'ORDER BY rl.reviewed_at ASC, rl.id ASC', + [fromMs], + ); + return rows.map((r) => ({ + reviewed_at: r['reviewed_at'] as number, + rating: r['rating'] as number, + ref_id: r['ref_id'] as string, + ref_kind: r['ref_kind'] as string, + })); +} + +function rowToPlannerTopic(r: Row): PlannerTopicRow { + const deps = r['deps']; + return { + id: r['id'] as string, + track_id: r['track_id'] as string, + title: r['title'] as string, + status: r['status'] as TopicStatus, + position: r['position'] as number, + deps: typeof deps === 'string' && deps !== '' ? deps.split(',') : [], + }; +} + +/** Live topics (optionally per track) with their dependency ids aggregated. */ +export async function plannerTopics(db: DbDriver, trackIds?: string[]): Promise { + if (trackIds !== undefined && trackIds.length === 0) return []; + const trackFilter = + trackIds === undefined ? '' : `AND t.track_id IN (${trackIds.map(() => '?').join(',')}) `; + const rows = await db.exec( + 'SELECT t.id, t.track_id, t.title, t.status, t.position, ' + + 'GROUP_CONCAT(d.depends_on_id) AS deps FROM topics t ' + + 'LEFT JOIN topic_deps d ON d.topic_id = t.id ' + + `WHERE t.deleted_at IS NULL ${trackFilter}` + + 'GROUP BY t.id ORDER BY t.position ASC, t.id ASC', + trackIds ?? [], + ); + return rows.map(rowToPlannerTopic); +} diff --git a/packages/db/src/repo/targets.ts b/packages/db/src/repo/targets.ts new file mode 100644 index 0000000..01a0af0 --- /dev/null +++ b/packages/db/src/repo/targets.ts @@ -0,0 +1,171 @@ +import { newId, now, type TargetRow } from '@studyos/shared'; +import type { DbDriver, Row, SqlValue } from '../driver'; +import { localWrite } from './oplog'; +import { bumpedTs } from './ts'; + +export interface CreateTargetInput { + track_id?: string | null; + metric: string; + period: string; + value: number; +} + +function rowToTarget(r: Row): TargetRow { + return { + id: r['id'] as string, + track_id: (r['track_id'] ?? null) as string | null, + metric: r['metric'] as string, + period: r['period'] as string, + value: r['value'] as number, + updated_at: r['updated_at'] as number, + deleted_at: (r['deleted_at'] ?? null) as number | null, + }; +} + +export async function createTarget( + db: DbDriver, + deviceId: string, + input: CreateTargetInput, +): Promise { + const target = { + id: newId(), + track_id: input.track_id ?? null, + metric: input.metric, + period: input.period, + value: input.value, + updated_at: now(), + deleted_at: null, + } satisfies TargetRow; + await localWrite(db, 'targets', target, deviceId); + return target; +} + +export async function getTarget(db: DbDriver, id: string): Promise { + const rows = await db.exec('SELECT * FROM targets WHERE id = ?', [id]); + const r = rows[0]; + return r ? rowToTarget(r) : null; +} + +export async function listTargets(db: DbDriver): Promise { + const rows = await db.exec( + 'SELECT * FROM targets WHERE deleted_at IS NULL ORDER BY updated_at DESC, id DESC', + ); + return rows.map(rowToTarget); +} + +export async function deleteTarget(db: DbDriver, deviceId: string, id: string): Promise { + const existing = await getTarget(db, id); + if (!existing || existing.deleted_at !== null) return; + const ts = bumpedTs(existing.updated_at); + await localWrite(db, 'targets', { ...existing, deleted_at: ts, updated_at: ts }, deviceId); +} + +// Start of the current period in LOCAL time: midnight today for 'day', Monday's +// midnight for 'week', the 1st for 'month'. new Date is fine here — this repo +// runs on the client (and in tests), never in the worker. +function periodStart(period: string, nowMs: number): number { + const d = new Date(nowMs); + d.setHours(0, 0, 0, 0); + if (period === 'week') d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); + else if (period === 'month') d.setDate(1); + return d.getTime(); +} + +async function countScalar(db: DbDriver, sql: string, params: SqlValue[]): Promise { + const rows = await db.exec(sql, params); + return (rows[0]?.['n'] ?? 0) as number; +} + +async function sessionMetric( + db: DbDriver, + select: string, + target: TargetRow, + fromMs: number, + nowMs: number, +): Promise { + const trackFilter = target.track_id === null ? '' : 'AND track_id = ? '; + const params: SqlValue[] = [fromMs, nowMs]; + if (target.track_id !== null) params.push(target.track_id); + return countScalar( + db, + `SELECT ${select} AS n FROM sessions ` + + `WHERE started_at >= ? AND started_at <= ? AND deleted_at IS NULL ${trackFilter}`, + params, + ); +} + +// review_logs carries no track; resolve the ref through fsrs_state to either a +// topic in the track or a card whose topic is in the track. +async function reviewCount( + db: DbDriver, + target: TargetRow, + fromMs: number, + nowMs: number, +): Promise { + if (target.track_id === null) { + return countScalar( + db, + 'SELECT COUNT(*) AS n FROM review_logs WHERE reviewed_at >= ? AND reviewed_at <= ?', + [fromMs, nowMs], + ); + } + return countScalar( + db, + 'SELECT COUNT(*) AS n FROM review_logs rl ' + + 'JOIN fsrs_state f ON f.id = rl.fsrs_id ' + + "LEFT JOIN topics tt ON f.ref_kind = 'topic' AND tt.id = f.ref_id " + + "LEFT JOIN cards c ON f.ref_kind = 'card' AND c.id = f.ref_id " + + 'LEFT JOIN topics ct ON ct.id = c.topic_id ' + + 'WHERE rl.reviewed_at >= ? AND rl.reviewed_at <= ? ' + + 'AND (tt.track_id = ? OR ct.track_id = ?)', + [fromMs, nowMs, target.track_id, target.track_id], + ); +} + +/** Progress 0..1 of a target over its current period (window = period start → now). */ +export async function targetProgress( + db: DbDriver, + target: TargetRow, + nowMs: number, +): Promise { + if (target.value <= 0) return 0; + const fromMs = periodStart(target.period, nowMs); + let ratio: number; + switch (target.metric) { + case 'net_hours': { + const seconds = await sessionMetric( + db, + 'COALESCE(SUM(net_seconds), 0)', + target, + fromMs, + nowMs, + ); + ratio = seconds / 3600 / target.value; + break; + } + case 'questions': { + const questions = await sessionMetric( + db, + 'COALESCE(SUM(questions_total), 0)', + target, + fromMs, + nowMs, + ); + ratio = questions / target.value; + break; + } + case 'sessions': { + const count = await sessionMetric(db, 'COUNT(*)', target, fromMs, nowMs); + ratio = count / target.value; + break; + } + case 'reviews': { + const count = await reviewCount(db, target, fromMs, nowMs); + ratio = count / target.value; + break; + } + default: + throw new Error(`unknown target metric: ${target.metric}`); + } + return Math.min(1, Math.max(0, ratio)); +} diff --git a/packages/db/src/repo/tracks.ts b/packages/db/src/repo/tracks.ts index d52c37a..d87b6a8 100644 --- a/packages/db/src/repo/tracks.ts +++ b/packages/db/src/repo/tracks.ts @@ -10,6 +10,13 @@ export interface CreateTrackInput { mode?: string; } +export interface TrackPatch { + title?: string; + description?: string | null; + mode?: string; + goal_id?: string | null; +} + function rowToTrack(r: Row): TrackRow { return { id: r['id'] as string, @@ -60,6 +67,19 @@ export async function listTracks(db: DbDriver): Promise { return rows.map(rowToTrack); } +export async function updateTrack( + db: DbDriver, + deviceId: string, + id: string, + patch: TrackPatch, +): Promise { + const existing = await getTrack(db, id); + if (!existing || existing.deleted_at !== null) return null; + const updated = { ...existing, ...patch, updated_at: bumpedTs(existing.updated_at) }; + await localWrite(db, 'tracks', updated, deviceId); + return updated; +} + export async function deleteTrack(db: DbDriver, deviceId: string, id: string): Promise { const existing = await getTrack(db, id); if (!existing || existing.deleted_at !== null) return; diff --git a/packages/db/test/repo-cycle.test.ts b/packages/db/test/repo-cycle.test.ts new file mode 100644 index 0000000..ae12383 --- /dev/null +++ b/packages/db/test/repo-cycle.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from 'bun:test'; +import { getCyclePointer, listCycleSlots, setCycleSlots, setCyclePointer } from '../src/repo/cycle'; +import { setSetting } from '../src/repo/settings'; +import { createTopic } from '../src/repo/topics'; +import { createTrack } from '../src/repo/tracks'; +import type { DbDriver } from '../src/driver'; +import { freshDb } from './load-migrations'; + +const DEVICE = 'device-test'; + +async function seedTrack(db: DbDriver): Promise<{ trackId: string; topicIds: string[] }> { + const track = await createTrack(db, DEVICE, { title: 'ciclo', mode: 'cycle' }); + const topicIds: string[] = []; + for (const title of ['a', 'b', 'c']) { + const topic = await createTopic(db, DEVICE, { track_id: track.id, title }); + topicIds.push(topic.id); + } + return { trackId: track.id, topicIds }; +} + +test('setCycleSlots inserts slots with position = index and weight clamped >= 1', async () => { + const db = await freshDb(); + const { trackId, topicIds } = await seedTrack(db); + + await setCycleSlots(db, DEVICE, trackId, [ + { topic_id: topicIds[0] ?? '', weight: 0 }, + { topic_id: topicIds[1] ?? '', weight: 2.7 }, + { topic_id: topicIds[2] ?? '', weight: 3 }, + ]); + + const slots = await listCycleSlots(db, trackId); + expect(slots.map((s) => s.topic_id)).toEqual(topicIds); + expect(slots.map((s) => s.position)).toEqual([0, 1, 2]); + expect(slots.map((s) => s.weight)).toEqual([1, 2, 3]); + + const ops = await db.exec("SELECT * FROM oplog WHERE tbl = 'cycle_slots'"); + expect(ops.length).toBe(3); +}); + +test('setCycleSlots replaces the previous list atomically (old slots soft-deleted)', async () => { + const db = await freshDb(); + const { trackId, topicIds } = await seedTrack(db); + + await setCycleSlots(db, DEVICE, trackId, [ + { topic_id: topicIds[0] ?? '', weight: 1 }, + { topic_id: topicIds[1] ?? '', weight: 1 }, + ]); + await setCycleSlots(db, DEVICE, trackId, [{ topic_id: topicIds[2] ?? '', weight: 5 }]); + + const slots = await listCycleSlots(db, trackId); + expect(slots.map((s) => s.topic_id)).toEqual([topicIds[2] ?? '']); + expect(slots[0]?.position).toBe(0); + + const all = await db.exec('SELECT * FROM cycle_slots WHERE track_id = ?', [trackId]); + expect(all.length).toBe(3); + expect(all.filter((r) => r['deleted_at'] !== null).length).toBe(2); + + // first set: 2 ops; second set: 2 soft-deletes + 1 insert + const ops = await db.exec("SELECT * FROM oplog WHERE tbl = 'cycle_slots'"); + expect(ops.length).toBe(5); +}); + +test('setCycleSlots with an empty list clears the cycle', async () => { + const db = await freshDb(); + const { trackId, topicIds } = await seedTrack(db); + + await setCycleSlots(db, DEVICE, trackId, [{ topic_id: topicIds[0] ?? '', weight: 1 }]); + await setCycleSlots(db, DEVICE, trackId, []); + + expect(await listCycleSlots(db, trackId)).toEqual([]); +}); + +test('cycle pointer defaults to 0, round-trips, and ignores garbage', async () => { + const db = await freshDb(); + + expect(await getCyclePointer(db, 'track-1')).toBe(0); + + await setCyclePointer(db, 'track-1', 7); + expect(await getCyclePointer(db, 'track-1')).toBe(7); + expect(await getCyclePointer(db, 'track-2')).toBe(0); + + await setSetting(db, 'cycle_pointer:track-1', 'abc'); + expect(await getCyclePointer(db, 'track-1')).toBe(0); + + // local-only: pointer writes never hit the oplog + expect((await db.exec("SELECT * FROM oplog WHERE tbl = 'settings'")).length).toBe(0); +}); diff --git a/packages/db/test/repo-reminders.test.ts b/packages/db/test/repo-reminders.test.ts new file mode 100644 index 0000000..711f96a --- /dev/null +++ b/packages/db/test/repo-reminders.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from 'bun:test'; +import { createReminder, deleteReminder, dueReminders, listReminders } from '../src/repo/reminders'; +import { freshDb } from './load-migrations'; + +const DEVICE = 'device-test'; +const NOW = Date.now(); + +test('createReminder writes the reminder and exactly one oplog row', async () => { + const db = await freshDb(); + const reminder = await createReminder(db, DEVICE, { + title: 'revisar redação', + notify_at: NOW + 3_600_000, + }); + + expect(reminder.ref_kind).toBeNull(); + expect(reminder.rrule).toBeNull(); + + const ops = await db.exec('SELECT * FROM oplog'); + expect(ops.length).toBe(1); + expect(ops[0]?.['tbl']).toBe('reminders'); + expect(ops[0]?.['row_id']).toBe(reminder.id); +}); + +test('listReminders hides deleted and orders by notify_at', async () => { + const db = await freshDb(); + const later = await createReminder(db, DEVICE, { title: 'depois', notify_at: NOW + 2000 }); + const sooner = await createReminder(db, DEVICE, { title: 'antes', notify_at: NOW + 1000 }); + const gone = await createReminder(db, DEVICE, { title: 'apagado', notify_at: NOW + 3000 }); + + await deleteReminder(db, DEVICE, gone.id); + + const visible = await listReminders(db); + expect(visible.map((r) => r.id)).toEqual([sooner.id, later.id]); +}); + +test('dueReminders returns notify_at <= now, excluding deleted', async () => { + const db = await freshDb(); + const due = await createReminder(db, DEVICE, { title: 'vencido', notify_at: NOW - 1000 }); + const atBoundary = await createReminder(db, DEVICE, { title: 'agora', notify_at: NOW }); + await createReminder(db, DEVICE, { title: 'futuro', notify_at: NOW + 60_000 }); + const deleted = await createReminder(db, DEVICE, { title: 'apagado', notify_at: NOW - 2000 }); + await deleteReminder(db, DEVICE, deleted.id); + + const result = await dueReminders(db, NOW); + expect(result.map((r) => r.id)).toEqual([due.id, atBoundary.id]); +}); + +test('deleteReminder soft-deletes and appends an oplog row', async () => { + const db = await freshDb(); + const reminder = await createReminder(db, DEVICE, { title: 'remover', notify_at: NOW }); + + await deleteReminder(db, DEVICE, reminder.id); + + const row = await db.exec('SELECT deleted_at FROM reminders WHERE id = ?', [reminder.id]); + expect(row[0]?.['deleted_at']).not.toBeNull(); + expect((await db.exec('SELECT * FROM oplog')).length).toBe(2); +}); diff --git a/packages/db/test/repo-routines.test.ts b/packages/db/test/repo-routines.test.ts new file mode 100644 index 0000000..e7af642 --- /dev/null +++ b/packages/db/test/repo-routines.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from 'bun:test'; +import { + createRoutine, + deleteRoutine, + getRoutine, + listRoutines, + updateRoutine, +} from '../src/repo/routines'; +import { freshDb } from './load-migrations'; + +const DEVICE = 'device-test'; + +test('createRoutine writes the routine and exactly one oplog row', async () => { + const db = await freshDb(); + const routine = await createRoutine(db, DEVICE, { + title: 'matemática de manhã', + rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR', + start_time: '07:30', + duration_min: 60, + }); + + expect(routine.active).toBe(1); + expect(routine.track_id).toBeNull(); + expect(await getRoutine(db, routine.id)).toEqual(routine); + + const ops = await db.exec('SELECT * FROM oplog'); + expect(ops.length).toBe(1); + expect(ops[0]?.['tbl']).toBe('routines'); + expect(ops[0]?.['row_id']).toBe(routine.id); + expect(ops[0]?.['op']).toBe('upsert'); +}); + +test('listRoutines hides deleted and inactive routines, ordered by start_time', async () => { + const db = await freshDb(); + const late = await createRoutine(db, DEVICE, { + title: 'noite', + rrule: 'FREQ=WEEKLY;BYDAY=TU', + start_time: '21:00', + duration_min: 30, + }); + const early = await createRoutine(db, DEVICE, { + title: 'manhã', + rrule: 'FREQ=WEEKLY;BYDAY=MO', + start_time: '06:00', + duration_min: 30, + }); + const gone = await createRoutine(db, DEVICE, { + title: 'apagada', + rrule: 'FREQ=WEEKLY;BYDAY=SU', + start_time: '10:00', + duration_min: 30, + }); + const paused = await createRoutine(db, DEVICE, { + title: 'pausada', + rrule: 'FREQ=WEEKLY;BYDAY=SA', + start_time: '11:00', + duration_min: 30, + }); + + await deleteRoutine(db, DEVICE, gone.id); + await updateRoutine(db, DEVICE, paused.id, { active: 0 }); + + const visible = await listRoutines(db); + expect(visible.map((r) => r.id)).toEqual([early.id, late.id]); +}); + +test('updateRoutine patches fields and bumps updated_at', async () => { + const db = await freshDb(); + const routine = await createRoutine(db, DEVICE, { + title: 'antes', + rrule: 'FREQ=WEEKLY;BYDAY=MO', + start_time: '08:00', + duration_min: 45, + }); + + const updated = await updateRoutine(db, DEVICE, routine.id, { + title: 'depois', + duration_min: 90, + }); + expect(updated?.title).toBe('depois'); + expect(updated?.duration_min).toBe(90); + expect(updated?.updated_at).toBeGreaterThan(routine.updated_at); + expect((await db.exec('SELECT * FROM oplog')).length).toBe(2); +}); + +test('updateRoutine returns null for missing or deleted routines', async () => { + const db = await freshDb(); + expect(await updateRoutine(db, DEVICE, 'nope', { title: 'x' })).toBeNull(); + + const routine = await createRoutine(db, DEVICE, { + title: 'gone', + rrule: 'FREQ=WEEKLY;BYDAY=MO', + start_time: '08:00', + duration_min: 30, + }); + await deleteRoutine(db, DEVICE, routine.id); + expect(await updateRoutine(db, DEVICE, routine.id, { title: 'x' })).toBeNull(); +}); + +test('deleteRoutine soft-deletes and appends an oplog row', async () => { + const db = await freshDb(); + const routine = await createRoutine(db, DEVICE, { + title: 'remover', + rrule: 'FREQ=WEEKLY;BYDAY=MO', + start_time: '08:00', + duration_min: 30, + }); + + await deleteRoutine(db, DEVICE, routine.id); + + const row = await db.exec('SELECT deleted_at FROM routines WHERE id = ?', [routine.id]); + expect(row[0]?.['deleted_at']).not.toBeNull(); + expect((await db.exec('SELECT * FROM oplog')).length).toBe(2); +}); diff --git a/packages/db/test/repo-targets.test.ts b/packages/db/test/repo-targets.test.ts new file mode 100644 index 0000000..5b2148d --- /dev/null +++ b/packages/db/test/repo-targets.test.ts @@ -0,0 +1,176 @@ +import type { TargetRow } from '@studyos/shared'; +import { expect, test } from 'bun:test'; +import type { DbDriver } from '../src/driver'; +import { createCard } from '../src/repo/cards'; +import { recordReview } from '../src/repo/fsrs'; +import { createTarget, deleteTarget, listTargets, targetProgress } from '../src/repo/targets'; +import { createTopic } from '../src/repo/topics'; +import { createTrack } from '../src/repo/tracks'; +import { freshDb } from './load-migrations'; + +const DEVICE = 'device-test'; +const NOW = Date.now(); + +function localMidnight(nowMs: number): number { + const d = new Date(nowMs); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +interface SessionSeed { + started_at: number; + net_seconds?: number; + track_id?: string | null; + questions_total?: number | null; +} + +// Read-path seeding: crafted timestamps go straight into the table (no oplog). +async function seedSession(db: DbDriver, seed: SessionSeed): Promise { + await db.exec( + 'INSERT INTO sessions (id, track_id, topic_id, type, started_at, ended_at, net_seconds, ' + + 'focused, questions_total, questions_correct, updated_at) ' + + 'VALUES (?, ?, NULL, ?, ?, ?, ?, 0, ?, NULL, ?)', + [ + crypto.randomUUID(), + seed.track_id ?? null, + 'study', + seed.started_at, + seed.started_at + 1000, + seed.net_seconds ?? 0, + seed.questions_total ?? null, + seed.started_at, + ], + ); +} + +function target(overrides: Partial): TargetRow { + return { + id: 'tg1', + track_id: null, + metric: 'net_hours', + period: 'day', + value: 1, + updated_at: NOW, + deleted_at: null, + ...overrides, + }; +} + +test('createTarget writes the target and one oplog row; listTargets hides deleted', async () => { + const db = await freshDb(); + const keep = await createTarget(db, DEVICE, { metric: 'net_hours', period: 'day', value: 4 }); + const gone = await createTarget(db, DEVICE, { metric: 'sessions', period: 'week', value: 5 }); + + await deleteTarget(db, DEVICE, gone.id); + + const visible = await listTargets(db); + expect(visible.map((t) => t.id)).toEqual([keep.id]); + + const ops = await db.exec("SELECT * FROM oplog WHERE tbl = 'targets'"); + expect(ops.length).toBe(3); +}); + +test('net_hours: sums net_seconds inside the day window only', async () => { + const db = await freshDb(); + const midnight = localMidnight(NOW); + await seedSession(db, { started_at: midnight + 1000, net_seconds: 3600 }); + await seedSession(db, { started_at: midnight - 1000, net_seconds: 7200 }); // yesterday + + const progress = await targetProgress(db, target({ metric: 'net_hours', value: 2 }), NOW); + expect(progress).toBeCloseTo(0.5); +}); + +test('progress clamps to 1 when over target', async () => { + const db = await freshDb(); + await seedSession(db, { started_at: NOW, net_seconds: 3 * 3600 }); + + const progress = await targetProgress(db, target({ metric: 'net_hours', value: 1 }), NOW); + expect(progress).toBe(1); +}); + +test('questions: sums questions_total, filtered by track when set', async () => { + const db = await freshDb(); + await seedSession(db, { started_at: NOW, questions_total: 10, track_id: 't1' }); + await seedSession(db, { started_at: NOW, questions_total: 30, track_id: 't2' }); + + const all = await targetProgress(db, target({ metric: 'questions', value: 80 }), NOW); + expect(all).toBeCloseTo(0.5); + + const tracked = await targetProgress( + db, + target({ metric: 'questions', value: 20, track_id: 't1' }), + NOW, + ); + expect(tracked).toBeCloseTo(0.5); +}); + +test('sessions: counts sessions in the window', async () => { + const db = await freshDb(); + await seedSession(db, { started_at: NOW }); + await seedSession(db, { started_at: NOW }); + + const progress = await targetProgress(db, target({ metric: 'sessions', value: 4 }), NOW); + expect(progress).toBeCloseTo(0.5); +}); + +test('reviews: counts review_logs; track filter resolves topic and card refs', async () => { + const db = await freshDb(); + const trackA = await createTrack(db, DEVICE, { title: 'A' }); + const trackB = await createTrack(db, DEVICE, { title: 'B' }); + const topicA = await createTopic(db, DEVICE, { track_id: trackA.id, title: 'a1' }); + const topicB = await createTopic(db, DEVICE, { track_id: trackB.id, title: 'b1' }); + const cardA = await createCard(db, DEVICE, { topic_id: topicA.id, front_md: 'q' }); + + const next = { + state: 'review' as const, + stability: 1, + difficulty: 5, + due_at: NOW + 86_400_000, + last_review: NOW, + reps: 1, + lapses: 0, + }; + await recordReview(db, DEVICE, { + refKind: 'topic', + refId: topicA.id, + next, + rating: 3, + reviewedAt: NOW, + }); + await recordReview(db, DEVICE, { + refKind: 'card', + refId: cardA.id, + next, + rating: 3, + reviewedAt: NOW, + }); + await recordReview(db, DEVICE, { + refKind: 'topic', + refId: topicB.id, + next, + rating: 3, + reviewedAt: NOW, + }); + + const all = await targetProgress( + db, + target({ metric: 'reviews', value: 6, period: 'week' }), + NOW, + ); + expect(all).toBeCloseTo(0.5); + + const trackOnly = await targetProgress( + db, + target({ metric: 'reviews', value: 4, period: 'week', track_id: trackA.id }), + NOW, + ); + expect(trackOnly).toBeCloseTo(0.5); +}); + +test('non-positive target value yields 0; unknown metric throws', async () => { + const db = await freshDb(); + expect(await targetProgress(db, target({ value: 0 }), NOW)).toBe(0); + await expect(targetProgress(db, target({ metric: 'streaks' }), NOW)).rejects.toThrow( + 'unknown target metric', + ); +}); diff --git a/packages/db/test/repo-tracks-topics.test.ts b/packages/db/test/repo-tracks-topics.test.ts index b08e8ad..69da354 100644 --- a/packages/db/test/repo-tracks-topics.test.ts +++ b/packages/db/test/repo-tracks-topics.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { createTrack, deleteTrack, getTrack, listTracks } from '../src/repo/tracks'; +import { createTrack, deleteTrack, getTrack, listTracks, updateTrack } from '../src/repo/tracks'; import { createTopic, createTopicTree, @@ -40,6 +40,30 @@ test('listTracks hides soft-deleted tracks; deleteTrack appends an oplog row', a expect((await db.exec('SELECT * FROM oplog')).length).toBe(3); }); +test('updateTrack patches fields, bumps updated_at and appends an oplog row', async () => { + const db = await freshDb(); + const track = await createTrack(db, DEVICE, { title: 'antes' }); + + const updated = await updateTrack(db, DEVICE, track.id, { title: 'depois', mode: 'cycle' }); + expect(updated?.title).toBe('depois'); + expect(updated?.mode).toBe('cycle'); + expect(updated?.updated_at).toBeGreaterThan(track.updated_at); + + const rows = await db.exec('SELECT title, mode FROM tracks WHERE id = ?', [track.id]); + expect(rows[0]?.['title']).toBe('depois'); + expect(rows[0]?.['mode']).toBe('cycle'); + expect((await db.exec("SELECT * FROM oplog WHERE tbl = 'tracks'")).length).toBe(2); +}); + +test('updateTrack returns null for missing or deleted tracks', async () => { + const db = await freshDb(); + expect(await updateTrack(db, DEVICE, 'nope', { title: 'x' })).toBeNull(); + + const track = await createTrack(db, DEVICE, { title: 'gone' }); + await deleteTrack(db, DEVICE, track.id); + expect(await updateTrack(db, DEVICE, track.id, { title: 'x' })).toBeNull(); +}); + test('createTopic defaults position to the next sibling index', async () => { const db = await freshDb(); const track = await createTrack(db, DEVICE, { title: 't' }); diff --git a/packages/db/test/stats-queries.test.ts b/packages/db/test/stats-queries.test.ts new file mode 100644 index 0000000..e33a617 --- /dev/null +++ b/packages/db/test/stats-queries.test.ts @@ -0,0 +1,119 @@ +import { expect, test } from 'bun:test'; +import type { DbDriver } from '../src/driver'; +import { createCard } from '../src/repo/cards'; +import { recordReview } from '../src/repo/fsrs'; +import { plannerTopics, reviewSlices, sessionSlices } from '../src/repo/stats-queries'; +import { createTopic } from '../src/repo/topics'; +import { createTrack } from '../src/repo/tracks'; +import { freshDb } from './load-migrations'; + +const DEVICE = 'device-test'; +const NOW = Date.now(); + +interface SessionSeed { + started_at: number; + ended_at?: number | null; + net_seconds?: number; + deleted_at?: number | null; +} + +async function seedSession(db: DbDriver, seed: SessionSeed): Promise { + await db.exec( + 'INSERT INTO sessions (id, track_id, topic_id, type, started_at, ended_at, net_seconds, ' + + 'focused, updated_at, deleted_at) VALUES (?, NULL, NULL, ?, ?, ?, ?, 0, ?, ?)', + [ + crypto.randomUUID(), + 'study', + seed.started_at, + seed.ended_at === undefined ? seed.started_at + 1000 : seed.ended_at, + seed.net_seconds ?? 60, + seed.started_at, + seed.deleted_at ?? null, + ], + ); +} + +test('sessionSlices returns ended, non-deleted sessions from fromMs, ordered', async () => { + const db = await freshDb(); + await seedSession(db, { started_at: NOW - 1000, net_seconds: 120 }); + await seedSession(db, { started_at: NOW - 3000, net_seconds: 60 }); + await seedSession(db, { started_at: NOW - 2000, ended_at: null }); // still running + await seedSession(db, { started_at: NOW - 1500, deleted_at: NOW }); // deleted + await seedSession(db, { started_at: NOW - 10_000 }); // before window + + const slices = await sessionSlices(db, NOW - 5000); + expect(slices.map((s) => s.started_at)).toEqual([NOW - 3000, NOW - 1000]); + expect(slices[1]?.net_seconds).toBe(120); + expect(slices[0]?.track_id).toBeNull(); +}); + +test('reviewSlices joins fsrs_state for ref_kind/ref_id and filters by fromMs', async () => { + const db = await freshDb(); + const track = await createTrack(db, DEVICE, { title: 't' }); + const topic = await createTopic(db, DEVICE, { track_id: track.id, title: 'a' }); + const card = await createCard(db, DEVICE, { topic_id: topic.id, front_md: 'q' }); + + const next = { + state: 'review' as const, + stability: 1, + difficulty: 5, + due_at: NOW + 86_400_000, + last_review: NOW, + reps: 1, + lapses: 0, + }; + await recordReview(db, DEVICE, { + refKind: 'topic', + refId: topic.id, + next, + rating: 2, + reviewedAt: NOW - 1000, + }); + await recordReview(db, DEVICE, { + refKind: 'card', + refId: card.id, + next, + rating: 4, + reviewedAt: NOW - 500, + }); + await recordReview(db, DEVICE, { + refKind: 'card', + refId: card.id, + next, + rating: 1, + reviewedAt: NOW - 60_000, // before window + }); + + const slices = await reviewSlices(db, NOW - 2000); + expect(slices).toEqual([ + { reviewed_at: NOW - 1000, rating: 2, ref_id: topic.id, ref_kind: 'topic' }, + { reviewed_at: NOW - 500, rating: 4, ref_id: card.id, ref_kind: 'card' }, + ]); +}); + +test('plannerTopics aggregates deps, filters by trackIds, skips deleted topics', async () => { + const db = await freshDb(); + const trackA = await createTrack(db, DEVICE, { title: 'A' }); + const trackB = await createTrack(db, DEVICE, { title: 'B' }); + const a1 = await createTopic(db, DEVICE, { track_id: trackA.id, title: 'a1' }); + const a2 = await createTopic(db, DEVICE, { track_id: trackA.id, title: 'a2' }); + const a3 = await createTopic(db, DEVICE, { track_id: trackA.id, title: 'a3' }); + const b1 = await createTopic(db, DEVICE, { track_id: trackB.id, title: 'b1' }); + + await db.exec('INSERT INTO topic_deps (topic_id, depends_on_id) VALUES (?, ?)', [a3.id, a1.id]); + await db.exec('INSERT INTO topic_deps (topic_id, depends_on_id) VALUES (?, ?)', [a3.id, a2.id]); + await db.exec('UPDATE topics SET deleted_at = ? WHERE id = ?', [NOW, b1.id]); + + const all = await plannerTopics(db); + expect(all.map((t) => t.id)).toEqual([a1.id, a2.id, a3.id]); + + const onlyA = await plannerTopics(db, [trackA.id]); + expect(onlyA.map((t) => t.title)).toEqual(['a1', 'a2', 'a3']); + expect(onlyA[0]?.deps).toEqual([]); + expect(onlyA[2]?.deps?.toSorted()).toEqual([a1.id, a2.id].toSorted()); + expect(onlyA[0]?.status).toBe('pending'); + expect(onlyA[1]?.position).toBe(1); + + expect(await plannerTopics(db, [])).toEqual([]); + expect((await plannerTopics(db, [trackB.id])).length).toBe(0); +});