RhythmCal MVP: Energy-Aware AI Scheduler Chrome extension
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.verdict
|
||||
*.local
|
||||
@@ -0,0 +1,26 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Minimal valid 1x1 PNG (gray pixel) for placeholders
|
||||
// Real icons would be proper assets, but these satisfy manifest requirements for MVP
|
||||
const minimalPng = Buffer.from([
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, // 8-bit RGB, compression
|
||||
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
|
||||
0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, // compressed data (gray pixel)
|
||||
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE, //
|
||||
0xD7, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, // IEND chunk
|
||||
0x44, 0xAE, 0x42, 0x60, 0x82
|
||||
]);
|
||||
|
||||
const iconDir = path.join(__dirname, 'src', 'icons');
|
||||
fs.mkdirSync(iconDir, { recursive: true });
|
||||
|
||||
const sizes = [16, 48, 128];
|
||||
for (const size of sizes) {
|
||||
fs.writeFileSync(path.join(iconDir, `icon${size}.png`), minimalPng);
|
||||
}
|
||||
|
||||
console.log('Icons generated:', sizes.map(s => `icon${s}.png`).join(', '));
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "rhythmcal",
|
||||
"version": "1.0.0",
|
||||
"description": "Energy-Aware AI Scheduler for Chrome",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "node --test dist/test/**/*.test.js",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@crxjs/vite-plugin": "^2.0.0-beta.28",
|
||||
"@types/chrome": "^0.0.268",
|
||||
"@types/node": "^20.14.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 69 B |
|
After Width: | Height: | Size: 69 B |
|
After Width: | Height: | Size: 69 B |
|
After Width: | Height: | Size: 69 B |
|
After Width: | Height: | Size: 69 B |
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,86 @@
|
||||
import { getStorage, setStorage } from './lib/storage.js';
|
||||
import { DEFAULT_PROFILE, type EnergyProfile } from './lib/energy.js';
|
||||
import { findFreeSlots, suggestSlot, type ScheduleSuggestion } from './lib/scheduler.js';
|
||||
import { listPrimaryEvents, createPrimaryEvent, type CalendarEvent } from './lib/calendar.js';
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async (details) => {
|
||||
if (details.reason === 'install') {
|
||||
const existing = await getStorage<EnergyProfile>('energy_profile');
|
||||
if (!existing) {
|
||||
await setStorage('energy_profile', DEFAULT_PROFILE);
|
||||
}
|
||||
chrome.alarms.create('energy-check', { periodInMinutes: 60 });
|
||||
}
|
||||
});
|
||||
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === 'energy-check') {
|
||||
const now = new Date();
|
||||
const hour = now.getHours();
|
||||
const profile = await getStorage<EnergyProfile>('energy_profile');
|
||||
if (!profile) return;
|
||||
|
||||
const window = profile.windows.find(w => {
|
||||
if (w.startHour <= w.endHour) {
|
||||
return hour >= w.startHour && hour < w.endHour;
|
||||
}
|
||||
return hour >= w.startHour || hour < w.endHour;
|
||||
});
|
||||
|
||||
if (window && window.isRecovery && profile.protectRecovery) {
|
||||
chrome.notifications.create('recovery-' + Date.now(), {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'RhythmCal — Recovery Time',
|
||||
message: `It's ${window.label}. Protect this window — avoid scheduling meetings now.`,
|
||||
priority: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
(async () => {
|
||||
try {
|
||||
if (request.action === 'getProfile') {
|
||||
const profile = await getStorage<EnergyProfile>('energy_profile') || DEFAULT_PROFILE;
|
||||
sendResponse({ success: true, profile });
|
||||
} else if (request.action === 'saveProfile') {
|
||||
await setStorage('energy_profile', request.profile);
|
||||
sendResponse({ success: true });
|
||||
} else if (request.action === 'getEvents') {
|
||||
const { timeMin, timeMax } = request;
|
||||
const events = await listPrimaryEvents(timeMin, timeMax);
|
||||
sendResponse({ success: true, events });
|
||||
} else if (request.action === 'suggestSlot') {
|
||||
const { title, duration, date, timeMin, timeMax } = request;
|
||||
const events = await listPrimaryEvents(timeMin, timeMax);
|
||||
const profile = await getStorage<EnergyProfile>('energy_profile') || DEFAULT_PROFILE;
|
||||
const suggestion = suggestSlot(title, duration, events, profile, new Date(date));
|
||||
sendResponse({ success: true, suggestion });
|
||||
} else if (request.action === 'scheduleEvent') {
|
||||
const { title, start, end, description } = request;
|
||||
const event = await createPrimaryEvent({
|
||||
summary: title,
|
||||
start: { dateTime: start },
|
||||
end: { dateTime: end },
|
||||
description,
|
||||
});
|
||||
sendResponse({ success: true, event });
|
||||
} else if (request.action === 'getFreeSlots') {
|
||||
const { date, timeMin, timeMax, minDuration } = request;
|
||||
const events = await listPrimaryEvents(timeMin, timeMax);
|
||||
const profile = await getStorage<EnergyProfile>('energy_profile') || DEFAULT_PROFILE;
|
||||
const slots = findFreeSlots(events, profile, new Date(date), minDuration);
|
||||
sendResponse({ success: true, slots });
|
||||
} else {
|
||||
sendResponse({ success: false, error: 'Unknown action' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
sendResponse({ success: false, error: err.message || String(err) });
|
||||
}
|
||||
})();
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log('RhythmCal background service worker started');
|
||||
|
After Width: | Height: | Size: 69 B |
|
After Width: | Height: | Size: 69 B |
|
After Width: | Height: | Size: 69 B |
@@ -0,0 +1,103 @@
|
||||
import { getStorage, setStorage, removeStorage } from './storage.js';
|
||||
|
||||
const CALENDAR_SCOPES = 'https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events';
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
summary: string;
|
||||
start: { dateTime?: string; date?: string };
|
||||
end: { dateTime?: string; date?: string };
|
||||
description?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export async function getAuthToken(): Promise<string | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.identity.getAuthToken({ interactive: true }, (token) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(token ?? null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeToken(): Promise<void> {
|
||||
const token = await getStorage<string>('gapi_token');
|
||||
if (token) {
|
||||
await new Promise<void>((resolve) => {
|
||||
chrome.identity.removeCachedAuthToken({ token }, () => {
|
||||
chrome.identity.launchWebAuthFlow({
|
||||
url: `https://accounts.google.com/o/oauth2/revoke?token=${token}`,
|
||||
interactive: false,
|
||||
}, () => resolve());
|
||||
});
|
||||
});
|
||||
await removeStorage('gapi_token');
|
||||
}
|
||||
}
|
||||
|
||||
async function gapiFetch(path: string, token: string, options: RequestInit = {}): Promise<any> {
|
||||
const url = `https://www.googleapis.com/calendar/v3${path}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
await revokeToken();
|
||||
}
|
||||
throw new Error(`Calendar API ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function listCalendars(): Promise<{ id: string; summary: string }[]> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
const data = await gapiFetch('/users/me/calendarList', token);
|
||||
return (data.items || []).map((c: any) => ({ id: c.id, summary: c.summary }));
|
||||
}
|
||||
|
||||
export async function listEvents(calendarId: string, timeMin: string, timeMax: string): Promise<CalendarEvent[]> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
const params = new URLSearchParams({ timeMin, timeMax, singleEvents: 'true', orderBy: 'startTime' });
|
||||
const data = await gapiFetch(`/calendars/${encodeURIComponent(calendarId)}/events?${params}`, token);
|
||||
return data.items || [];
|
||||
}
|
||||
|
||||
export async function createEvent(calendarId: string, event: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
const data = await gapiFetch(`/calendars/${encodeURIComponent(calendarId)}/events`, token, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(event),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteEvent(calendarId: string, eventId: string): Promise<void> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
await gapiFetch(`/calendars/${encodeURIComponent(calendarId)}/events/${eventId}`, token, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listPrimaryEvents(timeMin: string, timeMax: string): Promise<CalendarEvent[]> {
|
||||
const calendars = await listCalendars();
|
||||
const primary = calendars.find(c => c.id === 'primary') || calendars[0];
|
||||
if (!primary) return [];
|
||||
return listEvents(primary.id, timeMin, timeMax);
|
||||
}
|
||||
|
||||
export async function createPrimaryEvent(event: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
||||
return createEvent('primary', event);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface EnergyWindow {
|
||||
startHour: number; // 0-23
|
||||
endHour: number; // 0-23
|
||||
energyLevel: number; // 1-5, 5 = peak energy
|
||||
label: string;
|
||||
isRecovery: boolean;
|
||||
}
|
||||
|
||||
export interface EnergyProfile {
|
||||
windows: EnergyWindow[];
|
||||
workDays: number[]; // 0-6, 0 = Sunday
|
||||
defaultEventDuration: number; // minutes
|
||||
preferredMeetingDuration: number; // minutes
|
||||
minRecoveryBlock: number; // minutes
|
||||
protectRecovery: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PROFILE: EnergyProfile = {
|
||||
windows: [
|
||||
{ startHour: 6, endHour: 9, energyLevel: 4, label: 'Morning Ramp', isRecovery: false },
|
||||
{ startHour: 9, endHour: 12, energyLevel: 5, label: 'Deep Work Peak', isRecovery: false },
|
||||
{ startHour: 12, endHour: 13, energyLevel: 2, label: 'Lunch / Recovery', isRecovery: true },
|
||||
{ startHour: 13, endHour: 15, energyLevel: 3, label: 'Light Work', isRecovery: false },
|
||||
{ startHour: 15, endHour: 16, energyLevel: 2, label: 'Afternoon Dip', isRecovery: true },
|
||||
{ startHour: 16, endHour: 18, energyLevel: 3, label: 'Second Wind', isRecovery: false },
|
||||
{ startHour: 18, endHour: 22, energyLevel: 2, label: 'Evening Wind-down', isRecovery: true },
|
||||
{ startHour: 22, endHour: 6, energyLevel: 1, label: 'Sleep', isRecovery: true },
|
||||
],
|
||||
workDays: [1, 2, 3, 4, 5], // Mon-Fri
|
||||
defaultEventDuration: 60,
|
||||
preferredMeetingDuration: 30,
|
||||
minRecoveryBlock: 30,
|
||||
protectRecovery: true,
|
||||
};
|
||||
|
||||
export function getEnergyLevelAtHour(profile: EnergyProfile, hour: number): number {
|
||||
const window = profile.windows.find(w => {
|
||||
if (w.startHour <= w.endHour) {
|
||||
return hour >= w.startHour && hour < w.endHour;
|
||||
}
|
||||
// Overnight window (e.g. 22-6)
|
||||
return hour >= w.startHour || hour < w.endHour;
|
||||
});
|
||||
return window ? window.energyLevel : 1;
|
||||
}
|
||||
|
||||
export function isRecoveryWindow(profile: EnergyProfile, hour: number): boolean {
|
||||
const window = profile.windows.find(w => {
|
||||
if (w.startHour <= w.endHour) {
|
||||
return hour >= w.startHour && hour < w.endHour;
|
||||
}
|
||||
return hour >= w.startHour || hour < w.endHour;
|
||||
});
|
||||
return window ? window.isRecovery : false;
|
||||
}
|
||||
|
||||
export function getWindowLabel(profile: EnergyProfile, hour: number): string {
|
||||
const window = profile.windows.find(w => {
|
||||
if (w.startHour <= w.endHour) {
|
||||
return hour >= w.startHour && hour < w.endHour;
|
||||
}
|
||||
return hour >= w.startHour || hour < w.endHour;
|
||||
});
|
||||
return window ? window.label : 'Unknown';
|
||||
}
|
||||
|
||||
export function energyScore(profile: EnergyProfile, startHour: number, durationHours: number): number {
|
||||
let total = 0;
|
||||
let count = 0;
|
||||
for (let h = 0; h < durationHours; h++) {
|
||||
const hour = (startHour + h) % 24;
|
||||
total += getEnergyLevelAtHour(profile, hour);
|
||||
count++;
|
||||
}
|
||||
return count > 0 ? total / count : 0;
|
||||
}
|
||||
|
||||
export function blocksRecovery(profile: EnergyProfile, startHour: number, durationMinutes: number): boolean {
|
||||
if (!profile.protectRecovery) return false;
|
||||
const durationHours = Math.ceil(durationMinutes / 60);
|
||||
for (let h = 0; h <= durationHours; h++) {
|
||||
const hour = (startHour + h) % 24;
|
||||
if (isRecoveryWindow(profile, hour)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { EnergyProfile, energyScore, blocksRecovery, getEnergyLevelAtHour, isRecoveryWindow, getWindowLabel } from './energy.js';
|
||||
import { CalendarEvent, listPrimaryEvents, createPrimaryEvent } from './calendar.js';
|
||||
import { getStorage, setStorage } from './storage.js';
|
||||
|
||||
export interface Slot {
|
||||
start: Date;
|
||||
end: Date;
|
||||
energyScore: number;
|
||||
blocksRecovery: boolean;
|
||||
conflicts: CalendarEvent[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ScheduleSuggestion {
|
||||
title: string;
|
||||
suggestedStart: Date;
|
||||
suggestedEnd: Date;
|
||||
energyScore: number;
|
||||
reason: string;
|
||||
recoveryWarning: string | null;
|
||||
}
|
||||
|
||||
export function findFreeSlots(
|
||||
events: CalendarEvent[],
|
||||
profile: EnergyProfile,
|
||||
date: Date,
|
||||
minDurationMinutes: number = 30
|
||||
): Slot[] {
|
||||
const slots: Slot[] = [];
|
||||
const dayStart = new Date(date);
|
||||
dayStart.setHours(6, 0, 0, 0);
|
||||
const dayEnd = new Date(date);
|
||||
dayEnd.setHours(22, 0, 0, 0);
|
||||
|
||||
const occupied = events
|
||||
.filter(e => e.start?.dateTime)
|
||||
.map(e => ({
|
||||
start: new Date(e.start.dateTime!),
|
||||
end: new Date(e.end?.dateTime || e.start.dateTime!),
|
||||
}))
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
let cursor = dayStart.getTime();
|
||||
|
||||
for (const block of occupied) {
|
||||
if (block.start.getTime() > cursor + minDurationMinutes * 60 * 1000) {
|
||||
const slotStart = new Date(cursor);
|
||||
const slotEnd = new Date(block.start.getTime());
|
||||
const hour = slotStart.getHours();
|
||||
const durationHours = (slotEnd.getTime() - slotStart.getTime()) / 3600000;
|
||||
const score = energyScore(profile, hour, durationHours);
|
||||
slots.push({
|
||||
start: slotStart,
|
||||
end: slotEnd,
|
||||
energyScore: score,
|
||||
blocksRecovery: blocksRecovery(profile, hour, (slotEnd.getTime() - slotStart.getTime()) / 60000),
|
||||
conflicts: [],
|
||||
label: getWindowLabel(profile, hour),
|
||||
});
|
||||
}
|
||||
cursor = Math.max(cursor, block.end.getTime());
|
||||
}
|
||||
|
||||
if (dayEnd.getTime() > cursor + minDurationMinutes * 60 * 1000) {
|
||||
const hour = new Date(cursor).getHours();
|
||||
slots.push({
|
||||
start: new Date(cursor),
|
||||
end: new Date(dayEnd.getTime()),
|
||||
energyScore: energyScore(profile, hour, (dayEnd.getTime() - cursor) / 3600000),
|
||||
blocksRecovery: blocksRecovery(profile, hour, (dayEnd.getTime() - cursor) / 60000),
|
||||
conflicts: [],
|
||||
label: getWindowLabel(profile, hour),
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function suggestSlot(
|
||||
title: string,
|
||||
durationMinutes: number,
|
||||
events: CalendarEvent[],
|
||||
profile: EnergyProfile,
|
||||
date: Date
|
||||
): ScheduleSuggestion | null {
|
||||
const slots = findFreeSlots(events, profile, date, durationMinutes);
|
||||
if (slots.length === 0) return null;
|
||||
|
||||
const valid = slots.filter(s => {
|
||||
const ms = s.end.getTime() - s.start.getTime();
|
||||
return ms >= durationMinutes * 60 * 1000;
|
||||
});
|
||||
|
||||
if (valid.length === 0) return null;
|
||||
|
||||
const candidates = valid.filter(s => !s.blocksRecovery);
|
||||
const pool = candidates.length > 0 ? candidates : valid;
|
||||
|
||||
const best = pool.reduce((a, b) => (a.energyScore > b.energyScore ? a : b));
|
||||
const start = new Date(best.start);
|
||||
const end = new Date(start.getTime() + durationMinutes * 60 * 1000);
|
||||
|
||||
let reason: string;
|
||||
if (best.energyScore >= 4.5) {
|
||||
reason = `Peak energy window (${best.label}) — ideal for deep work.`;
|
||||
} else if (best.energyScore >= 3) {
|
||||
reason = `Moderate energy (${best.label}) — good for focused tasks.`;
|
||||
} else {
|
||||
reason = `Low energy (${best.label}) — consider a shorter session or lighter task.`;
|
||||
}
|
||||
|
||||
let recoveryWarning: string | null = null;
|
||||
if (best.blocksRecovery) {
|
||||
const recoveryLabel = profile.windows.find(w => w.isRecovery && start.getHours() >= w.startHour && start.getHours() < w.endHour)?.label;
|
||||
recoveryWarning = `This slot overlaps a recovery window (${recoveryLabel || 'rest period'}). Consider protecting your energy.`;
|
||||
}
|
||||
|
||||
return { title, suggestedStart: start, suggestedEnd: end, energyScore: best.energyScore, reason, recoveryWarning };
|
||||
}
|
||||
|
||||
export function energyOverlayForDay(
|
||||
profile: EnergyProfile,
|
||||
date: Date
|
||||
): { hour: number; level: number; label: string; isRecovery: boolean }[] {
|
||||
const result = [];
|
||||
for (let h = 0; h < 24; h++) {
|
||||
result.push({
|
||||
hour: h,
|
||||
level: getEnergyLevelAtHour(profile, h),
|
||||
label: getWindowLabel(profile, h),
|
||||
isRecovery: isRecoveryWindow(profile, h),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export async function getStorage<T>(key: string): Promise<T | null> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get([key], (result) => {
|
||||
resolve(result[key] ?? null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function setStorage<T>(key: string, value: T): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.set({ [key]: value }, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeStorage(key: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.remove(key, () => resolve());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "RhythmCal — Energy-Aware Scheduler",
|
||||
"version": "1.0.0",
|
||||
"description": "AI calendar assistant that respects your energy rhythms and protects recovery windows",
|
||||
"permissions": [
|
||||
"identity",
|
||||
"storage",
|
||||
"alarms",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"https://www.googleapis.com/*",
|
||||
"https://accounts.google.com/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"options_page": "src/options/options.html",
|
||||
"background": {
|
||||
"service_worker": "src/background.ts",
|
||||
"type": "module"
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["src/popup/*.css", "src/options/*.css"],
|
||||
"matches": ["<all_urls>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>RhythmCal — Energy Profile</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; max-width: 720px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 24px; margin-bottom: 8px; color: #c4b5fd; }
|
||||
p.sub { color: #94a3b8; margin-bottom: 24px; font-size: 14px; }
|
||||
.window-card { background: #1e293b; border-radius: 12px; padding: 16px; margin-bottom: 12px; }
|
||||
.window-card h3 { font-size: 14px; margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.window-card.recovery { border-left: 3px solid #dc2626; }
|
||||
.window-card.peak { border-left: 3px solid #6366f1; }
|
||||
.row { display: flex; gap: 12px; margin-bottom: 8px; align-items: center; }
|
||||
.row label { font-size: 13px; color: #94a3b8; width: 80px; flex-shrink: 0; }
|
||||
.row input, .row select { flex: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid #334155; background: #0f172a; color: #e2e8f0; font-size: 13px; }
|
||||
.row input[type="checkbox"] { flex: 0; width: 18px; height: 18px; }
|
||||
.btn-row { display: flex; gap: 8px; margin-top: 16px; }
|
||||
button { padding: 10px 16px; border-radius: 8px; border: none; background: #6366f1; color: white; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
button:hover { background: #4f46e5; }
|
||||
button.secondary { background: #334155; }
|
||||
button.secondary:hover { background: #475569; }
|
||||
button.danger { background: #7f1d1d; }
|
||||
button.danger:hover { background: #991b1b; }
|
||||
.add-btn { background: #064e3b; margin-bottom: 16px; }
|
||||
.add-btn:hover { background: #065f46; }
|
||||
.saved { color: #4ade80; font-size: 13px; margin-left: 12px; opacity: 0; transition: opacity 0.3s; }
|
||||
.saved.show { opacity: 1; }
|
||||
.legend { display: flex; gap: 12px; margin-bottom: 16px; font-size: 12px; color: #94a3b8; }
|
||||
.legend span { display: flex; align-items: center; gap: 4px; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; }
|
||||
.dot.recovery { background: #dc2626; }
|
||||
.dot.peak { background: #6366f1; }
|
||||
.dot.work { background: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RhythmCal Energy Profile</h1>
|
||||
<p class="sub">Define your daily energy windows so RhythmCal can protect your recovery and schedule deep work at your peak.</p>
|
||||
<div class="legend">
|
||||
<span><div class="dot peak"></div> Peak energy</span>
|
||||
<span><div class="dot work"></div> Work window</span>
|
||||
<span><div class="dot recovery"></div> Recovery</span>
|
||||
</div>
|
||||
<div id="windows-container"></div>
|
||||
<button class="add-btn" id="btn-add">+ Add Window</button>
|
||||
<div class="row">
|
||||
<label>Work days</label>
|
||||
<input type="text" id="work-days" placeholder="e.g. Mon,Tue,Wed,Thu,Fri">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Min recovery</label>
|
||||
<input type="number" id="min-recovery" value="30" min="0" max="180" step="15"> min
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Protect recovery</label>
|
||||
<input type="checkbox" id="protect-recovery" checked>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button id="btn-save">Save Profile</button>
|
||||
<button class="secondary" id="btn-reset">Reset to Default</button>
|
||||
<span class="saved" id="saved-msg">Saved!</span>
|
||||
</div>
|
||||
<script src="options.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,121 @@
|
||||
import { DEFAULT_PROFILE, type EnergyProfile, type EnergyWindow } from '../lib/energy.js';
|
||||
|
||||
const container = document.getElementById('windows-container')!;
|
||||
const btnAdd = document.getElementById('btn-add')!;
|
||||
const btnSave = document.getElementById('btn-save')!;
|
||||
const btnReset = document.getElementById('btn-reset')!;
|
||||
const savedMsg = document.getElementById('saved-msg')!;
|
||||
const workDaysInput = document.getElementById('work-days') as HTMLInputElement;
|
||||
const minRecoveryInput = document.getElementById('min-recovery') as HTMLInputElement;
|
||||
const protectRecoveryInput = document.getElementById('protect-recovery') as HTMLInputElement;
|
||||
|
||||
let windows: EnergyWindow[] = [];
|
||||
|
||||
async function loadProfile() {
|
||||
const res: any = await new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ action: 'getProfile' }, (r) => resolve(r));
|
||||
});
|
||||
const profile: EnergyProfile = res?.profile || DEFAULT_PROFILE;
|
||||
windows = JSON.parse(JSON.stringify(profile.windows));
|
||||
renderWindows();
|
||||
workDaysInput.value = profile.workDays.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(',');
|
||||
minRecoveryInput.value = String(profile.minRecoveryBlock);
|
||||
protectRecoveryInput.checked = profile.protectRecovery;
|
||||
}
|
||||
|
||||
function renderWindows() {
|
||||
container.innerHTML = '';
|
||||
windows.forEach((w, i) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'window-card ' + (w.isRecovery ? 'recovery' : w.energyLevel >= 4 ? 'peak' : 'work');
|
||||
card.innerHTML = `
|
||||
<h3>Window ${i + 1} <button class="danger" data-idx="${i}" style="padding:4px 8px;font-size:12px;"
|
||||
>Remove</button></h3>
|
||||
<div class="row">
|
||||
<label>Label</label>
|
||||
<input type="text" class="label" data-idx="${i}" value="${w.label}">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Start</label>
|
||||
<input type="number" class="start" data-idx="${i}" value="${w.startHour}" min="0" max="23">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>End</label>
|
||||
<input type="number" class="end" data-idx="${i}" value="${w.endHour}" min="0" max="23">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Energy</label>
|
||||
<select class="energy" data-idx="${i}">
|
||||
<option value="1" ${w.energyLevel === 1 ? 'selected' : ''}>1 — Very Low</option>
|
||||
<option value="2" ${w.energyLevel === 2 ? 'selected' : ''}>2 — Low</option>
|
||||
<option value="3" ${w.energyLevel === 3 ? 'selected' : ''}>3 — Moderate</option>
|
||||
<option value="4" ${w.energyLevel === 4 ? 'selected' : ''}>4 — High</option>
|
||||
<option value="5" ${w.energyLevel === 5 ? 'selected' : ''}>5 — Peak</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Recovery</label>
|
||||
<input type="checkbox" class="recovery" data-idx="${i}" ${w.isRecovery ? 'checked' : ''}>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>('.window-card input, .window-card select').forEach(el => {
|
||||
el.addEventListener('change', updateFromUI);
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>('.window-card .danger').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const idx = parseInt((e.target as HTMLElement).getAttribute('data-idx')!, 10);
|
||||
windows.splice(idx, 1);
|
||||
renderWindows();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateFromUI() {
|
||||
document.querySelectorAll<HTMLInputElement>('.window-card').forEach((card, i) => {
|
||||
const w = windows[i];
|
||||
if (!w) return;
|
||||
w.label = card.querySelector<HTMLInputElement>('.label')!.value;
|
||||
w.startHour = parseInt(card.querySelector<HTMLInputElement>('.start')!.value, 10);
|
||||
w.endHour = parseInt(card.querySelector<HTMLInputElement>('.end')!.value, 10);
|
||||
w.energyLevel = parseInt(card.querySelector<HTMLSelectElement>('.energy')!.value, 10);
|
||||
w.isRecovery = card.querySelector<HTMLInputElement>('.recovery')!.checked;
|
||||
});
|
||||
}
|
||||
|
||||
btnAdd.addEventListener('click', () => {
|
||||
updateFromUI();
|
||||
windows.push({ startHour: 9, endHour: 10, energyLevel: 3, label: 'New Window', isRecovery: false });
|
||||
renderWindows();
|
||||
});
|
||||
|
||||
btnSave.addEventListener('click', async () => {
|
||||
updateFromUI();
|
||||
const dayMap: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||||
const workDays = workDaysInput.value.split(',').map(s => s.trim()).filter(Boolean).map(s => dayMap[s] ?? 1).filter(n => n >= 0 && n <= 6);
|
||||
const profile: EnergyProfile = {
|
||||
windows: windows.sort((a, b) => a.startHour - b.startHour),
|
||||
workDays: workDays.length ? workDays : [1, 2, 3, 4, 5],
|
||||
defaultEventDuration: 60,
|
||||
preferredMeetingDuration: 30,
|
||||
minRecoveryBlock: parseInt(minRecoveryInput.value, 10) || 30,
|
||||
protectRecovery: protectRecoveryInput.checked,
|
||||
};
|
||||
await new Promise<void>((resolve) => {
|
||||
chrome.runtime.sendMessage({ action: 'saveProfile', profile }, () => resolve());
|
||||
});
|
||||
savedMsg.classList.add('show');
|
||||
setTimeout(() => savedMsg.classList.remove('show'), 2000);
|
||||
});
|
||||
|
||||
btnReset.addEventListener('click', () => {
|
||||
windows = JSON.parse(JSON.stringify(DEFAULT_PROFILE.windows));
|
||||
renderWindows();
|
||||
workDaysInput.value = 'Mon,Tue,Wed,Thu,Fri';
|
||||
minRecoveryInput.value = '30';
|
||||
protectRecoveryInput.checked = true;
|
||||
});
|
||||
|
||||
loadProfile();
|
||||
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>RhythmCal</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { width: 360px; font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; }
|
||||
.header { padding: 16px; background: linear-gradient(135deg, #6366f1, #8b5cf6); }
|
||||
.header h1 { font-size: 18px; font-weight: 700; }
|
||||
.header p { font-size: 12px; opacity: 0.9; margin-top: 4px; }
|
||||
.section { padding: 12px 16px; border-bottom: 1px solid #1e293b; }
|
||||
.section-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; color: #94a3b8; margin-bottom: 8px; }
|
||||
.input-group { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
input[type="text"], input[type="number"], select {
|
||||
flex: 1; padding: 8px 10px; border-radius: 8px; border: 1px solid #334155;
|
||||
background: #1e293b; color: #e2e8f0; font-size: 14px;
|
||||
}
|
||||
input:focus { outline: none; border-color: #6366f1; }
|
||||
button {
|
||||
padding: 8px 14px; border-radius: 8px; border: none; background: #6366f1;
|
||||
color: white; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
button:hover { background: #4f46e5; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-secondary { background: #334155; }
|
||||
.btn-secondary:hover { background: #475569; }
|
||||
.energy-strip { display: flex; gap: 1px; height: 24px; border-radius: 6px; overflow: hidden; margin-top: 8px; }
|
||||
.energy-bar { flex: 1; }
|
||||
.energy-bar.recovery { background: #dc2626; }
|
||||
.energy-bar.l1 { background: #475569; }
|
||||
.energy-bar.l2 { background: #64748b; }
|
||||
.energy-bar.l3 { background: #94a3b8; }
|
||||
.energy-bar.l4 { background: #a78bfa; }
|
||||
.energy-bar.l5 { background: #6366f1; }
|
||||
.suggestion { background: #1e293b; border-radius: 10px; padding: 12px; margin-top: 8px; }
|
||||
.suggestion h3 { font-size: 14px; color: #c4b5fd; margin-bottom: 4px; }
|
||||
.suggestion p { font-size: 12px; color: #94a3b8; margin-bottom: 4px; }
|
||||
.suggestion .time { font-size: 13px; font-weight: 600; color: #e2e8f0; }
|
||||
.warning { color: #fbbf24; font-size: 12px; margin-top: 6px; }
|
||||
.error { color: #f87171; font-size: 12px; margin-top: 6px; }
|
||||
.success { color: #4ade80; font-size: 12px; margin-top: 6px; }
|
||||
.slot-list { max-height: 180px; overflow-y: auto; }
|
||||
.slot-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid #1e293b; }
|
||||
.slot-item:last-child { border-bottom: none; }
|
||||
.slot-time { font-size: 12px; color: #e2e8f0; }
|
||||
.slot-score { font-size: 11px; padding: 2px 6px; border-radius: 4px; }
|
||||
.score-high { background: #064e3b; color: #4ade80; }
|
||||
.score-mid { background: #451a03; color: #fbbf24; }
|
||||
.score-low { background: #450a0a; color: #f87171; }
|
||||
.slot-action { font-size: 11px; padding: 4px 8px; border-radius: 4px; background: #334155; color: white; border: none; cursor: pointer; }
|
||||
.loading { text-align: center; padding: 20px; color: #94a3b8; font-size: 13px; }
|
||||
.auth-prompt { text-align: center; padding: 20px; }
|
||||
.auth-prompt p { font-size: 13px; color: #94a3b8; margin-bottom: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>RhythmCal</h1>
|
||||
<p>Energy-aware AI scheduling</p>
|
||||
</div>
|
||||
<div class="section" id="auth-section">
|
||||
<div class="auth-prompt">
|
||||
<p>Connect Google Calendar to start scheduling</p>
|
||||
<button id="btn-auth">Connect Calendar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="main-section" style="display:none">
|
||||
<div class="section-title">Today's Energy Rhythm</div>
|
||||
<div class="energy-strip" id="energy-strip"></div>
|
||||
<div class="section-title" style="margin-top:12px">Quick Schedule</div>
|
||||
<div class="input-group">
|
||||
<input type="text" id="task-title" placeholder="What do you need to schedule?">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input type="number" id="task-duration" value="60" min="15" max="480" step="15" style="flex:0.4">
|
||||
<select id="task-date" style="flex:0.6"></select>
|
||||
</div>
|
||||
<button id="btn-suggest">Find Best Slot</button>
|
||||
<div id="suggestion-result"></div>
|
||||
<div class="section-title" style="margin-top:12px">Free Slots</div>
|
||||
<div class="slot-list" id="slot-list"></div>
|
||||
<div class="input-group" style="margin-top:8px">
|
||||
<button id="btn-options" class="btn-secondary">Edit Energy Profile</button>
|
||||
<button id="btn-refresh" class="btn-secondary">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="popup.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,198 @@
|
||||
import { DEFAULT_PROFILE, type EnergyProfile } from '../lib/energy.js';
|
||||
import type { ScheduleSuggestion, Slot } from '../lib/scheduler.js';
|
||||
|
||||
const authSection = document.getElementById('auth-section')!;
|
||||
const mainSection = document.getElementById('main-section')!;
|
||||
const btnAuth = document.getElementById('btn-auth')!;
|
||||
const btnSuggest = document.getElementById('btn-suggest')!;
|
||||
const btnRefresh = document.getElementById('btn-refresh')!;
|
||||
const btnOptions = document.getElementById('btn-options')!;
|
||||
const taskTitle = document.getElementById('task-title') as HTMLInputElement;
|
||||
const taskDuration = document.getElementById('task-duration') as HTMLInputElement;
|
||||
const taskDate = document.getElementById('task-date') as HTMLSelectElement;
|
||||
const suggestionResult = document.getElementById('suggestion-result')!;
|
||||
const slotList = document.getElementById('slot-list')!;
|
||||
const energyStrip = document.getElementById('energy-strip')!;
|
||||
|
||||
function formatTime(d: Date): string {
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function toISOStringLocal(d: Date): string {
|
||||
const offset = d.getTimezoneOffset() * 60000;
|
||||
return new Date(d.getTime() - offset).toISOString().slice(0, -1);
|
||||
}
|
||||
|
||||
async function checkAuth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ action: 'getEvents', timeMin: new Date().toISOString(), timeMax: new Date().toISOString() }, (res) => {
|
||||
resolve(res?.success === true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const isAuth = await checkAuth();
|
||||
if (isAuth) {
|
||||
authSection.style.display = 'none';
|
||||
mainSection.style.display = 'block';
|
||||
await loadDatePicker();
|
||||
await renderEnergyStrip();
|
||||
await loadSlots();
|
||||
} else {
|
||||
authSection.style.display = 'block';
|
||||
mainSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
btnAuth.addEventListener('click', async () => {
|
||||
btnAuth.setAttribute('disabled', 'true');
|
||||
btnAuth.textContent = 'Connecting...';
|
||||
const ok = await checkAuth();
|
||||
if (ok) {
|
||||
await init();
|
||||
} else {
|
||||
btnAuth.removeAttribute('disabled');
|
||||
btnAuth.textContent = 'Connect Calendar';
|
||||
suggestionResult.innerHTML = '<div class="error">Could not connect. Try again.</div>';
|
||||
}
|
||||
});
|
||||
|
||||
async function loadDatePicker() {
|
||||
taskDate.innerHTML = '';
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() + i);
|
||||
const opt = document.createElement('option');
|
||||
opt.value = formatDate(d);
|
||||
opt.textContent = i === 0 ? 'Today' : d.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
taskDate.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function renderEnergyStrip() {
|
||||
const profile: EnergyProfile = await new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ action: 'getProfile' }, (res) => resolve(res?.profile || DEFAULT_PROFILE));
|
||||
});
|
||||
energyStrip.innerHTML = '';
|
||||
for (let h = 6; h <= 21; h++) {
|
||||
const window = profile.windows.find(w => {
|
||||
if (w.startHour <= w.endHour) return h >= w.startHour && h < w.endHour;
|
||||
return h >= w.startHour || h < w.endHour;
|
||||
});
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'energy-bar';
|
||||
if (window) {
|
||||
bar.classList.add(window.isRecovery ? 'recovery' : `l${window.energyLevel}`);
|
||||
bar.title = `${window.label} (${h}:00-${h+1}:00)`;
|
||||
}
|
||||
energyStrip.appendChild(bar);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSlots() {
|
||||
slotList.innerHTML = '<div class="loading">Loading slots...</div>';
|
||||
const date = new Date(taskDate.value || formatDate(new Date()));
|
||||
const timeMin = toISOStringLocal(new Date(date.setHours(0,0,0,0))) + 'Z';
|
||||
const timeMax = toISOStringLocal(new Date(date.setHours(23,59,59,0))) + 'Z';
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'getFreeSlots', date: date.toISOString(), timeMin, timeMax, minDuration: 30 }, (res) => {
|
||||
if (!res?.success) {
|
||||
slotList.innerHTML = '<div class="error">Could not load calendar.</div>';
|
||||
return;
|
||||
}
|
||||
const slots: Slot[] = res.slots;
|
||||
if (slots.length === 0) {
|
||||
slotList.innerHTML = '<div class="error">No free slots found today.</div>';
|
||||
return;
|
||||
}
|
||||
slotList.innerHTML = '';
|
||||
slots.forEach(slot => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'slot-item';
|
||||
const start = new Date(slot.start);
|
||||
const end = new Date(slot.end);
|
||||
const scoreClass = slot.energyScore >= 4 ? 'score-high' : slot.energyScore >= 2.5 ? 'score-mid' : 'score-low';
|
||||
const recoveryText = slot.blocksRecovery ? ' ⚠️ Recovery' : '';
|
||||
div.innerHTML = `
|
||||
<div>
|
||||
<div class="slot-time">${formatTime(start)} — ${formatTime(end)}</div>
|
||||
<div class="slot-score ${scoreClass}">Energy: ${slot.energyScore.toFixed(1)}/5 ${recoveryText}</div>
|
||||
</div>
|
||||
`;
|
||||
slotList.appendChild(div);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
btnSuggest.addEventListener('click', async () => {
|
||||
const title = taskTitle.value.trim();
|
||||
const duration = parseInt(taskDuration.value, 10) || 60;
|
||||
if (!title) {
|
||||
suggestionResult.innerHTML = '<div class="error">Enter a task title.</div>';
|
||||
return;
|
||||
}
|
||||
btnSuggest.setAttribute('disabled', 'true');
|
||||
btnSuggest.textContent = 'Finding...';
|
||||
suggestionResult.innerHTML = '';
|
||||
|
||||
const date = new Date(taskDate.value || formatDate(new Date()));
|
||||
const timeMin = toISOStringLocal(new Date(date.setHours(0,0,0,0))) + 'Z';
|
||||
const timeMax = toISOStringLocal(new Date(date.setHours(23,59,59,0))) + 'Z';
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'suggestSlot', title, duration, date: date.toISOString(), timeMin, timeMax }, (res) => {
|
||||
btnSuggest.removeAttribute('disabled');
|
||||
btnSuggest.textContent = 'Find Best Slot';
|
||||
if (!res?.success) {
|
||||
suggestionResult.innerHTML = `<div class="error">${res?.error || 'Failed to find slot'}</div>`;
|
||||
return;
|
||||
}
|
||||
const s: ScheduleSuggestion = res.suggestion;
|
||||
if (!s) {
|
||||
suggestionResult.innerHTML = '<div class="error">No suitable slots found today.</div>';
|
||||
return;
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.className = 'suggestion';
|
||||
div.innerHTML = `
|
||||
<h3>Best slot: ${formatTime(s.suggestedStart)} — ${formatTime(s.suggestedEnd)}</h3>
|
||||
<p class="time">${s.reason}</p>
|
||||
<p>Energy score: ${s.energyScore.toFixed(1)}/5</p>
|
||||
${s.recoveryWarning ? `<div class="warning">${s.recoveryWarning}</div>` : ''}
|
||||
`;
|
||||
const btnSchedule = document.createElement('button');
|
||||
btnSchedule.textContent = 'Schedule It';
|
||||
btnSchedule.style.marginTop = '8px';
|
||||
btnSchedule.addEventListener('click', () => {
|
||||
btnSchedule.setAttribute('disabled', 'true');
|
||||
btnSchedule.textContent = 'Scheduling...';
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'scheduleEvent',
|
||||
title: s.title,
|
||||
start: s.suggestedStart.toISOString(),
|
||||
end: s.suggestedEnd.toISOString(),
|
||||
description: `Scheduled by RhythmCal — ${s.reason}`,
|
||||
}, (res2) => {
|
||||
if (res2?.success) {
|
||||
div.innerHTML += '<div class="success">Scheduled successfully!</div>';
|
||||
loadSlots();
|
||||
} else {
|
||||
div.innerHTML += `<div class="error">${res2?.error || 'Failed to schedule'}</div>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
div.appendChild(btnSchedule);
|
||||
suggestionResult.appendChild(div);
|
||||
});
|
||||
});
|
||||
|
||||
taskDate.addEventListener('change', () => loadSlots());
|
||||
btnRefresh.addEventListener('click', () => loadSlots());
|
||||
btnOptions.addEventListener('click', () => chrome.runtime.openOptionsPage());
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { DEFAULT_PROFILE, getEnergyLevelAtHour, isRecoveryWindow, energyScore, blocksRecovery, getWindowLabel } from '../lib/energy.js';
|
||||
import { findFreeSlots, suggestSlot, energyOverlayForDay } from '../lib/scheduler.js';
|
||||
import type { CalendarEvent } from '../lib/calendar.js';
|
||||
|
||||
describe('energy', () => {
|
||||
it('returns correct energy level at hour 10 (deep work peak)', () => {
|
||||
assert.strictEqual(getEnergyLevelAtHour(DEFAULT_PROFILE, 10), 5);
|
||||
});
|
||||
|
||||
it('returns recovery at hour 12 (lunch)', () => {
|
||||
assert.strictEqual(isRecoveryWindow(DEFAULT_PROFILE, 12), true);
|
||||
});
|
||||
|
||||
it('returns recovery at hour 3 (overnight sleep)', () => {
|
||||
assert.strictEqual(isRecoveryWindow(DEFAULT_PROFILE, 3), true);
|
||||
});
|
||||
|
||||
it('energyScore for 9-12 window is 5.0', () => {
|
||||
assert.strictEqual(energyScore(DEFAULT_PROFILE, 9, 3), 5.0);
|
||||
});
|
||||
|
||||
it('blocksRecovery for 60 min starting at 11:30 overlaps lunch recovery', () => {
|
||||
assert.strictEqual(blocksRecovery(DEFAULT_PROFILE, 11, 60), true);
|
||||
});
|
||||
|
||||
it('does not block recovery at 9am for 60 min', () => {
|
||||
assert.strictEqual(blocksRecovery(DEFAULT_PROFILE, 9, 60), false);
|
||||
});
|
||||
|
||||
it('getWindowLabel returns Deep Work Peak at 10', () => {
|
||||
assert.strictEqual(getWindowLabel(DEFAULT_PROFILE, 10), 'Deep Work Peak');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduler', () => {
|
||||
const events: CalendarEvent[] = [
|
||||
{ id: '1', summary: 'Meeting', start: { dateTime: '2024-06-15T09:00:00' }, end: { dateTime: '2024-06-15T10:00:00' } },
|
||||
{ id: '2', summary: 'Lunch', start: { dateTime: '2024-06-15T12:00:00' }, end: { dateTime: '2024-06-15T13:00:00' } },
|
||||
];
|
||||
|
||||
it('findFreeSlots returns slots between 6am and 9am, and 10am-12pm', () => {
|
||||
const slots = findFreeSlots(events, DEFAULT_PROFILE, new Date('2024-06-15'), 30);
|
||||
assert.ok(slots.length >= 2, `Expected at least 2 slots, got ${slots.length}`);
|
||||
const firstSlot = slots[0];
|
||||
assert.strictEqual(firstSlot.start.getHours(), 6);
|
||||
assert.strictEqual(firstSlot.end.getHours(), 9);
|
||||
});
|
||||
|
||||
it('suggestSlot picks highest energy non-recovery slot for 60 min', () => {
|
||||
const suggestion = suggestSlot('Deep Work', 60, events, DEFAULT_PROFILE, new Date('2024-06-15'));
|
||||
assert.ok(suggestion, 'Expected a suggestion');
|
||||
if (!suggestion) return;
|
||||
assert.ok(suggestion.energyScore >= 3, `Expected decent energy score, got ${suggestion.energyScore}`);
|
||||
assert.ok(suggestion.suggestedStart.getHours() >= 6, 'Should start after 6am');
|
||||
});
|
||||
|
||||
it('suggestSlot returns null when no free time exists', () => {
|
||||
const allDay: CalendarEvent[] = [
|
||||
{ id: 'x', summary: 'All Day', start: { dateTime: '2024-06-15T06:00:00' }, end: { dateTime: '2024-06-15T22:00:00' } },
|
||||
];
|
||||
const suggestion = suggestSlot('Task', 60, allDay, DEFAULT_PROFILE, new Date('2024-06-15'));
|
||||
assert.strictEqual(suggestion, null);
|
||||
});
|
||||
|
||||
it('energyOverlayForDay returns 24 items', () => {
|
||||
const overlay = energyOverlayForDay(DEFAULT_PROFILE, new Date('2024-06-15'));
|
||||
assert.strictEqual(overlay.length, 24);
|
||||
assert.strictEqual(overlay[10].level, 5);
|
||||
assert.strictEqual(overlay[12].isRecovery, true);
|
||||
});
|
||||
|
||||
it('findFreeSlots respects minDuration', () => {
|
||||
const shortEvents: CalendarEvent[] = [
|
||||
{ id: '1', summary: 'A', start: { dateTime: '2024-06-15T06:00:00' }, end: { dateTime: '2024-06-15T06:45:00' } },
|
||||
{ id: '2', summary: 'B', start: { dateTime: '2024-06-15T06:50:00' }, end: { dateTime: '2024-06-15T22:00:00' } },
|
||||
];
|
||||
const slots = findFreeSlots(shortEvents, DEFAULT_PROFILE, new Date('2024-06-15'), 30);
|
||||
// 5 min gap between 6:45 and 6:50 is less than 30 min minDuration, so should be 0 slots
|
||||
assert.strictEqual(slots.length, 0, 'Should not return sub-minDuration gap');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["chrome", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { crx } from '@crxjs/vite-plugin'
|
||||
import manifest from './src/manifest.json' with { type: 'json' }
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
plugins: [crx({ manifest })],
|
||||
})
|
||||