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
+
+
+
+ semana
+
+
+ {#each DAY_LABELS as label, day (day)}
+
+ {/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
+
+ {#each store.data.heatmap as cell (cell.day)}
+
+ {/each}
+
+
+ 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}
+
+
+ {#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);
+});