RhythmCal MVP: Energy-Aware AI Scheduler Chrome extension

This commit is contained in:
Bun Bun
2026-06-15 18:31:50 +00:00
commit aecb5face5
26 changed files with 2635 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.verdict
*.local
+26
View File
@@ -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(', '));
+1524
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

+86
View File
@@ -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');
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

+103
View File
@@ -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);
}
+86
View File
@@ -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;
}
+135
View File
@@ -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;
}
+19
View File
@@ -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());
});
}
+40
View File
@@ -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>"]
}
]
}
+67
View File
@@ -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>
+121
View File
@@ -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();
+90
View File
@@ -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>
+198
View File
@@ -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();
+83
View File
@@ -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');
});
});
+20
View File
@@ -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"]
}
+10
View File
@@ -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 })],
})