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 })],
|
||||||
|
})
|
||||||