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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/pwa/cypress/downloads/studyos.ics
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions apps/pwa/cypress/e2e/planner-loop.cy.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
4 changes: 3 additions & 1 deletion apps/pwa/cypress/e2e/student-loop.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
61 changes: 61 additions & 0 deletions apps/pwa/src/lib/push/ics.ts
Original file line number Diff line number Diff line change
@@ -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';
}
24 changes: 24 additions & 0 deletions apps/pwa/src/lib/push/local.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

/**
* 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<void> {
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
}
}
}
13 changes: 13 additions & 0 deletions apps/pwa/src/lib/push/register.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
64 changes: 64 additions & 0 deletions apps/pwa/src/lib/push/subscribe.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
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<string | null> {
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<void> {
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}`);
}
42 changes: 42 additions & 0 deletions apps/pwa/src/lib/stores/reminders.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
remove(id: string): Promise<void>;
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();
},
};
}
78 changes: 78 additions & 0 deletions apps/pwa/src/lib/stores/routines.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
remove(id: string): Promise<void>;
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();
},
};
}
Loading