// data.jsx — seed events, categories, sessions // "Today" anchor — real current date, normalized to midnight local time. const CHRONO_TODAY = (() => { const d = new Date(); d.setHours(0,0,0,0); return d; })(); // Categories are user-owned and persist in localStorage. A fresh account // starts with NONE — the user (or the AI, with the user's explicit yes) // creates them as real events come in. Events that don't match any category // stay uncategorized and render grey (see ChronoTokens.events.grey). const CATEGORY_STORE_KEY = 'chrono.categories.v1'; function loadCategories() { try { const raw = localStorage.getItem(CATEGORY_STORE_KEY); if (raw) { const arr = JSON.parse(raw); if (Array.isArray(arr)) { // Keep only well-formed rows; normalize the count field. return arr .filter(c => c && typeof c === 'object' && c.id && c.name) .map(c => ({ id: String(c.id), name: String(c.name), color: String(c.color || 'blue'), count: 0 })); } } } catch (e) { /* fall through to empty */ } return []; } // Mutated in place (push/splice) so every existing `CATEGORIES.map/find/some` // call site keeps working against a live array. const CATEGORIES = loadCategories(); const CATEGORY_LISTENERS = new Set(); function persistCategories() { try { localStorage.setItem(CATEGORY_STORE_KEY, JSON.stringify(CATEGORIES)); } catch (e) {} } function notifyCategoriesChanged() { persistCategories(); CATEGORY_LISTENERS.forEach(cb => { try { cb(); } catch (e) {} }); } // Build a stable, unique id from a display name. function slugifyCategory(name) { const base = String(name || '').toLowerCase().trim() .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'category'; let id = base, n = 2; while (CATEGORIES.some(c => c.id === id)) id = `${base}-${n++}`; return id; } // Add a category (idempotent on id/name). Returns the category row — the // existing one if a match is already present, so the AI flow never dupes. function addCategory({ id, name, color } = {}) { const finalName = String(name || 'Category').slice(0, 40).trim() || 'Category'; const validColor = typeof ChronoTokens !== 'undefined' && ChronoTokens.events && ChronoTokens.events[color]; const finalColor = validColor ? color : 'blue'; const existing = CATEGORIES.find(c => (id && c.id === id) || c.name.toLowerCase() === finalName.toLowerCase()); if (existing) return existing; const cat = { id: (id && !CATEGORIES.some(c => c.id === id)) ? String(id) : slugifyCategory(finalName), name: finalName, color: finalColor, count: 0, }; CATEGORIES.push(cat); notifyCategoriesChanged(); return cat; } function removeCategory(id) { const i = CATEGORIES.findIndex(c => c.id === id); if (i >= 0) { CATEGORIES.splice(i, 1); notifyCategoriesChanged(); return true; } return false; } // Re-render hook for any view that lists categories (sidebar, composer, // categories page) so adds/removes show up immediately. function useCategoryList() { const [, setTick] = React.useState(0); React.useEffect(() => { const cb = () => setTick(t => t + 1); CATEGORY_LISTENERS.add(cb); return () => CATEGORY_LISTENERS.delete(cb); }, []); return CATEGORIES; } // Day offsets from CHRONO_TODAY → events for that day // Format: { title, start: 'HH:MM', end: 'HH:MM', color, category, priority, location, note } function makeEvents() { // Blank slate — a fresh install has no events. return {}; /* eslint-disable no-unreachable */ const out = {}; const set = (offset, evs) => { out[offset] = evs; }; set(-2, [ { title: 'Algorithms — Lecture 18', start: '09:00', end: '10:30', color: 'purple', category: 'study', priority: 'med' }, { title: 'Gym', start: '07:00', end: '08:00', color: 'green', category: 'health' }, { title: 'Standup', start: '11:00', end: '11:15', color: 'red', category: 'meet' }, ]); set(-1, [ { title: 'Design review', start: '10:00', end: '11:00', color: 'red', category: 'meet', priority: 'high' }, { title: 'CS 374 problem set', start: '14:00', end: '16:30', color: 'yellow', category: 'canvas' }, { title: 'Dinner w/ Leila', start: '19:00', end: '20:30', color: 'pink', category: 'personal' }, ]); set(0, [ { title: 'Morning run', start: '06:30', end: '07:15', color: 'green', category: 'health' }, { title: 'Deep work — Project X', start: '09:00', end: '11:30', color: 'orange', category: 'creative', priority: 'high', note: 'Finish API spec draft' }, { title: 'Standup', start: '11:30', end: '11:45', color: 'red', category: 'meet' }, { title: 'Lunch (block)', start: '12:30', end: '13:30', color: 'blue', category: 'work' }, { title: 'AI seminar', start: '14:00', end: '15:30', color: 'purple', category: 'study', priority: 'med' }, { title: '1:1 with Naomi', start: '15:45', end: '16:15', color: 'red', category: 'meet', location: 'Zoom' }, { title: 'Review PR #482', start: '16:30', end: '17:30', color: 'blue', category: 'work' }, { title: 'Yoga', start: '19:00', end: '20:00', color: 'green', category: 'health' }, ]); set(1, [ { title: 'Coffee w/ Marco', start: '08:00', end: '09:00', color: 'pink', category: 'personal' }, { title: 'Linear Algebra final', start: '10:00', end: '12:00', color: 'yellow', category: 'canvas', priority: 'high' }, { title: 'Roadmap planning', start: '14:00', end: '15:30', color: 'red', category: 'meet' }, { title: 'Studio time', start: '20:00', end: '22:00', color: 'orange', category: 'creative' }, ]); set(2, [ { title: 'PT appointment', start: '09:00', end: '10:00', color: 'green', category: 'health' }, { title: 'Quarterly review', start: '11:00', end: '12:30', color: 'red', category: 'meet', priority: 'high' }, { title: 'Reading group', start: '17:00', end: '18:00', color: 'purple', category: 'study' }, ]); set(3, [ { title: 'Long run', start: '07:00', end: '08:30', color: 'green', category: 'health' }, { title: 'Family brunch', start: '11:00', end: '13:00', color: 'pink', category: 'personal' }, ]); set(4, [ { title: 'Hiking — Mt Tam', start: '08:00', end: '13:00', color: 'pink', category: 'personal' }, ]); set(5, [ { title: 'Sprint planning', start: '10:00', end: '11:30', color: 'red', category: 'meet' }, { title: 'OSes Lab 4', start: '14:00', end: '17:00', color: 'yellow', category: 'canvas' }, ]); set(6, [ { title: 'Deep work', start: '09:00', end: '12:00', color: 'orange', category: 'creative', priority: 'high' }, { title: 'Therapy', start: '14:00', end: '15:00', color: 'green', category: 'health' }, ]); set(7, [ { title: 'Conference talk', start: '10:00', end: '11:00', color: 'blue', category: 'work', priority: 'high' }, ]); set(8, [ { title: 'Book club', start: '19:00', end: '20:30', color: 'purple', category: 'study' }, ]); set(10, [ { title: 'Off-site', start: '09:00', end: '17:00', color: 'blue', category: 'work' }, ]); // Some random sprinkles backwards/forwards set(-5, [{ title: 'Dentist', start:'09:00', end:'10:00', color:'green', category:'health'}]); set(-3, [{ title: 'PR review', start:'15:00', end:'16:00', color:'blue', category:'work'}]); set(-7, [{ title: 'Birthday', start:'19:00', end:'22:00', color:'pink', category:'personal'}]); set(11, [{ title: 'Demo day', start:'14:00', end:'16:00', color:'red', category:'meet', priority:'high'}]); set(13, [{ title: 'Workshop', start:'10:00', end:'12:00', color:'purple', category:'study'}]); set(14, [{ title: 'Concert', start:'20:00', end:'23:00', color:'pink', category:'personal'}]); return out; } const EVENTS_BY_OFFSET = makeEvents(); function dateAtOffset(offset) { const d = new Date(CHRONO_TODAY); d.setDate(d.getDate() + offset); return d; } function offsetForDate(d) { const ms = d - CHRONO_TODAY; return Math.round(ms / (1000*60*60*24)); } function eventsForOffset(off) { return EVENTS_BY_OFFSET[off] || []; } // ── US federal holidays ──────────────────────────────────────────────── // Computed from rules so any year works. Cached per year. const _HOLIDAY_CACHE = new Map(); function usHolidaysForYear(year) { if (_HOLIDAY_CACHE.has(year)) return _HOLIDAY_CACHE.get(year); const out = new Map(); const key = (mo, day) => `${year}-${String(mo).padStart(2,'0')}-${String(day).padStart(2,'0')}`; // Fixed-date holidays out.set(key(1, 1), "New Year's Day"); out.set(key(6, 19), 'Juneteenth'); out.set(key(7, 4), 'Independence Day'); out.set(key(11, 11),'Veterans Day'); out.set(key(12, 25),'Christmas Day'); // Nth weekday of month (weekday: 0=Sun..6=Sat) const nthWeekday = (mo, wd, n) => { const first = new Date(year, mo-1, 1); const shift = (wd - first.getDay() + 7) % 7; return 1 + shift + (n-1)*7; }; out.set(key(1, nthWeekday(1, 1, 3)), 'Martin Luther King Jr. Day'); // 3rd Mon Jan out.set(key(2, nthWeekday(2, 1, 3)), "Presidents' Day"); // 3rd Mon Feb out.set(key(9, nthWeekday(9, 1, 1)), 'Labor Day'); // 1st Mon Sep out.set(key(10, nthWeekday(10, 1, 2)), 'Columbus Day'); // 2nd Mon Oct out.set(key(11, nthWeekday(11, 4, 4)), 'Thanksgiving Day'); // 4th Thu Nov // Memorial Day — last Monday of May. const lastMay = new Date(year, 5, 0); // day 0 of June = last day of May const memOffset = (lastMay.getDay() - 1 + 7) % 7; out.set(key(5, lastMay.getDate() - memOffset), 'Memorial Day'); _HOLIDAY_CACHE.set(year, out); return out; } function holidayForDate(d) { const k = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; return usHolidaysForYear(d.getFullYear()).get(k) || null; } function holidayForOffset(off) { return holidayForDate(dateAtOffset(off)); } // ── Persisted reactive stores (profile / settings / integrations) ──── function makePersistedStore(key, defaults) { let state; try { const raw = localStorage.getItem(key); state = raw ? { ...defaults, ...JSON.parse(raw) } : { ...defaults }; } catch (e) { state = { ...defaults }; } const listeners = new Set(); const notify = () => listeners.forEach(cb => { try { cb(); } catch (e) {} }); return { get: () => state, set: (patch) => { state = { ...state, ...patch }; try { localStorage.setItem(key, JSON.stringify(state)); } catch (e) {} notify(); }, reset: () => { state = { ...defaults }; try { localStorage.removeItem(key); } catch (e) {} notify(); }, use: () => { const [, setTick] = React.useState(0); React.useEffect(() => { const cb = () => setTick(t => t + 1); listeners.add(cb); return () => listeners.delete(cb); }, []); return state; }, // Subscribe outside React (used by the account-sync engine). Returns an // unsubscribe fn. subscribe: (cb) => { listeners.add(cb); return () => listeners.delete(cb); }, }; } const profileStore = makePersistedStore('chrono.profile.v1', { name: 'Your name', email: 'you@chrono.app', plan: 'Pro', // Beta: every tester is Pro. Tiers are hidden until after beta. }); const settingsStore = makePersistedStore('chrono.settings.v1', { weekStartsOn: 0, // 0=Sun, 1=Mon defaultDuration: 60, // minutes showHolidays: true, timeFormat: '12', // '12' | '24' sounds: true, notifLead: 10, // minutes aiBufferMin: 10, aiShowReasoning: true, aiFollowups: 'auto', // 'auto' = act on vague prompts with smart defaults; 'ask' = ask clarifying questions first aiPersonality: 'concierge', // concierge | copilot | silent focusSessionMin: 25, // default focus timer length workStart: '08:00', // derived from sleep schedule workEnd: '22:00', }); const onboardingStore = makePersistedStore('chrono.onboarding.v1', { done: false, tutorialDone: false, betaNoticeSeen: false, // one-time "you're in the beta" notice after the tutorial answers: {}, }); const integrationsStore = makePersistedStore('chrono.integrations.v1', { google: false, icloud: false, canvas: false, outlook: false, canvasSchool: '', canvasCourses: [], // [{ id, code, name, color, assignments: [{title, dueOff, due, weight}] }] }); function useProfile() { return profileStore.use(); } function setProfile(patch) { profileStore.set(patch); } function useSettings() { return settingsStore.use(); } function setSettings(patch) { settingsStore.set(patch); } function resetSettings() { settingsStore.reset(); } function useIntegrations() { return integrationsStore.use(); } function setIntegrations(patch) { integrationsStore.set(patch); } function useOnboarding() { return onboardingStore.use(); } function setOnboarding(patch) { onboardingStore.set(patch); } function resetOnboarding() { onboardingStore.reset(); } // Tutorial pub-sub: lets the GuidedTutorial drive the desktop's active view // without lifting state out of ChronoDesktop. const TUTORIAL_CHANNEL = { listeners: new Set() }; function setTutorialView(v) { TUTORIAL_CHANNEL.listeners.forEach(cb => { try { cb(v); } catch (e) {} }); } function useTutorialView(handler) { React.useEffect(() => { TUTORIAL_CHANNEL.listeners.add(handler); return () => TUTORIAL_CHANNEL.listeners.delete(handler); }, [handler]); } // Tutorial → AI panel: lets the guided tour force the assistant popout open // (and closed again on exit) without lifting aiOpen state out of ChronoDesktop. const TUTORIAL_AI_CHANNEL = { listeners: new Set() }; function setTutorialAI(open) { TUTORIAL_AI_CHANNEL.listeners.forEach(cb => { try { cb(!!open); } catch (e) {} }); } function useTutorialAI(handler) { React.useEffect(() => { TUTORIAL_AI_CHANNEL.listeners.add(handler); return () => TUTORIAL_AI_CHANNEL.listeners.delete(handler); }, [handler]); } // Per-category live stats. Walks the live store on every call (cheap at our // scale — single-user, hundreds of events max) so counts/hours always match // what's on the calendar. Falls back to color-based bucketing for events // that have no explicit category — keeps the AI's color-only outputs from // showing up as "uncategorized". function categoriesWithCounts() { const stats = new Map(CATEGORIES.map(c => [c.id, { count: 0, minutes: 0 }])); const colorToId = new Map(CATEGORIES.map(c => [c.color, c.id])); for (const k of Object.keys(EVENTS_BY_OFFSET)) { for (const e of EVENTS_BY_OFFSET[k]) { const id = e.category && stats.has(e.category) ? e.category : colorToId.get(e.color); if (!id) continue; const s = stats.get(id); s.count += 1; s.minutes += Math.max(0, tm(e.end) - tm(e.start)); } } return CATEGORIES.map(c => { const s = stats.get(c.id); return { ...c, count: s.count, hours: Math.round(s.minutes / 60 * 10) / 10 }; }); } function useCategories() { useEvents(); // re-render on event mutations useCategoryList(); // re-render on category add/remove return categoriesWithCounts(); } // Aggregate stats derived from the live event store. function eventStats() { let totalEvents = 0, totalMinutes = 0; for (const k of Object.keys(EVENTS_BY_OFFSET)) { const arr = EVENTS_BY_OFFSET[k]; totalEvents += arr.length; arr.forEach(e => { totalMinutes += tm(e.end) - tm(e.start); }); } return { totalEvents, totalHours: Math.round(totalMinutes / 60 * 10) / 10 }; } // Mutable event store — manual add/edit notifies subscribers so calendar views re-render. const EVENT_LISTENERS = new Set(); function notifyEventsChanged() { EVENT_LISTENERS.forEach(cb => { try { cb(); } catch (e) {} }); } function addEventAtOffset(off, ev) { if (!EVENTS_BY_OFFSET[off]) EVENTS_BY_OFFSET[off] = []; EVENTS_BY_OFFSET[off].push(ev); notifyEventsChanged(); return ev; // return the stored ref so callers can re-tag it later (AI category flow) } // Re-tag an existing event in place (used when the user accepts an AI-proposed // category for events that were just added uncategorized). function setEventCategory(ref, category, color) { if (!ref || typeof ref !== 'object') return false; ref.category = category; if (color) ref.color = color; notifyEventsChanged(); return true; } function updateEventByRef(originalEv, newOff, newEv) { for (const k of Object.keys(EVENTS_BY_OFFSET)) { const arr = EVENTS_BY_OFFSET[k]; const i = arr.indexOf(originalEv); if (i >= 0) { arr.splice(i, 1); break; } } if (!EVENTS_BY_OFFSET[newOff]) EVENTS_BY_OFFSET[newOff] = []; EVENTS_BY_OFFSET[newOff].push(newEv); notifyEventsChanged(); } function deleteEventByRef(ev) { for (const k of Object.keys(EVENTS_BY_OFFSET)) { const arr = EVENTS_BY_OFFSET[k]; const i = arr.indexOf(ev); if (i >= 0) { arr.splice(i, 1); notifyEventsChanged(); return true; } } return false; } // ── Canvas LMS → calendar ──────────────────────────────────────────────── // Materialize Canvas assignment due dates as real calendar events so they // show up in the Week/Month views — not just the Canvas tab. Events carry // source:'canvas' so we can wipe & re-sync idempotently (connect, reconnect, // disconnect) without touching the user's own events. const CANVAS_DUE_TIME = { start: '17:00', end: '18:00' }; // shown as an afternoon deadline block function setCanvasEvents(courses) { // 1. Remove any previously-synced Canvas events. for (const k of Object.keys(EVENTS_BY_OFFSET)) { const kept = EVENTS_BY_OFFSET[k].filter(e => e.source !== 'canvas'); if (kept.length) EVENTS_BY_OFFSET[k] = kept; else delete EVENTS_BY_OFFSET[k]; } // 2. Add one event per assignment at its due-date offset. (courses || []).forEach(c => { (c.assignments || []).forEach(a => { if (a.dueOff == null) return; const off = a.dueOff; if (!EVENTS_BY_OFFSET[off]) EVENTS_BY_OFFSET[off] = []; EVENTS_BY_OFFSET[off].push({ title: `${a.title} · ${c.code}`, start: CANVAS_DUE_TIME.start, end: CANVAS_DUE_TIME.end, color: c.color || 'yellow', category: 'canvas', source: 'canvas', priority: a.weight === 'Exam' ? 'high' : undefined, note: `Canvas due${a.weight ? ' · ' + a.weight : ''} · ${c.name}`, }); }); }); notifyEventsChanged(); } function clearCanvasEvents() { setCanvasEvents([]); } function useEvents() { const [, setTick] = React.useState(0); React.useEffect(() => { const cb = () => setTick(t => t + 1); EVENT_LISTENERS.add(cb); return () => EVENT_LISTENERS.delete(cb); }, []); } const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December']; const DAY_NAMES = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; const DAY_FULL = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; // Convert HH:MM to minutes function tm(s) { const [h,m] = s.split(':').map(Number); return h*60+m; } // Respects the user's 12/24-hour preference (settings.timeFormat), so every // place that prints an event time updates the instant the setting changes. function fmtTime(s) { const [h,m] = s.split(':').map(Number); const fmt = (settingsStore && settingsStore.get().timeFormat) || '12'; if (fmt === '24') return `${h.toString().padStart(2,'0')}:${m.toString().padStart(2,'0')}`; const period = h >= 12 ? 'PM' : 'AM'; const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; return `${h12}:${m.toString().padStart(2,'0')} ${period}`; } // Format an hour-of-day (0–23) for axis labels, honoring timeFormat. function fmtHour(h) { const fmt = (settingsStore && settingsStore.get().timeFormat) || '12'; if (fmt === '24') return `${String(h).padStart(2,'0')}:00`; return h === 0 ? '12 AM' : h === 12 ? '12 PM' : h > 12 ? `${h-12} PM` : `${h} AM`; } // ── Account-sync bridge ─────────────────────────────────────────────────── // Helpers the account-sync engine (auth.jsx) uses to (a) subscribe to data // mutations so it can debounce-push, and (b) bulk-replace the in-memory stores // from a server pull. Events are stored by day-offset from "today"; on the // wire they travel as absolute 'YYYY-MM-DD' dates so they survive across days // and devices. function subscribeEvents(cb) { EVENT_LISTENERS.add(cb); return () => EVENT_LISTENERS.delete(cb); } function subscribeCategories(cb) { CATEGORY_LISTENERS.add(cb); return () => CATEGORY_LISTENERS.delete(cb); } function ymdLocal(d) { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function parseYmdLocal(s){ const [y,m,dd] = String(s).split('-').map(Number); return new Date(y, (m||1)-1, dd||1); } // Serialize every in-memory event to a flat, date-stamped array for the server. function exportAllEvents() { const out = []; for (const k of Object.keys(EVENTS_BY_OFFSET)) { const date = ymdLocal(dateAtOffset(Number(k))); for (const e of EVENTS_BY_OFFSET[k]) { out.push({ date, start: e.start, end: e.end, title: e.title, color: e.color ?? null, category: e.category ?? null, priority: e.priority ?? null, location: e.location ?? null, note: e.note ?? null, source: e.source ?? null, }); } } return out; } // Replace the whole event store from a server pull, mapping absolute dates back // to the live offset model. Fires one re-render. function replaceAllEvents(list) { for (const k of Object.keys(EVENTS_BY_OFFSET)) delete EVENTS_BY_OFFSET[k]; (list || []).forEach(e => { if (!e || !e.date) return; const off = offsetForDate(parseYmdLocal(e.date)); if (!EVENTS_BY_OFFSET[off]) EVENTS_BY_OFFSET[off] = []; EVENTS_BY_OFFSET[off].push({ title: e.title, start: e.start, end: e.end, color: e.color || undefined, category: e.category || undefined, priority: e.priority || undefined, location: e.location || undefined, note: e.note || undefined, source: e.source || undefined, }); }); notifyEventsChanged(); } // Replace the whole category store from a server pull. Fires one re-render. function replaceAllCategories(list) { CATEGORIES.length = 0; (list || []).forEach(c => { if (!c || !c.id || !c.name) return; CATEGORIES.push({ id: String(c.id), name: String(c.name), color: String(c.color || 'blue'), count: 0 }); }); notifyCategoriesChanged(); } Object.assign(window, { CHRONO_TODAY, CATEGORIES, EVENTS_BY_OFFSET, dateAtOffset, offsetForDate, eventsForOffset, addEventAtOffset, updateEventByRef, deleteEventByRef, setEventCategory, useEvents, setCanvasEvents, clearCanvasEvents, categoriesWithCounts, useCategories, useCategoryList, addCategory, removeCategory, usHolidaysForYear, holidayForDate, holidayForOffset, useProfile, setProfile, useSettings, setSettings, resetSettings, useIntegrations, setIntegrations, eventStats, useOnboarding, setOnboarding, resetOnboarding, setTutorialView, useTutorialView, setTutorialAI, useTutorialAI, MONTH_NAMES, DAY_NAMES, DAY_FULL, tm, fmtTime, fmtHour, // Sync bridge settingsStore, profileStore, subscribeEvents, subscribeCategories, exportAllEvents, replaceAllEvents, replaceAllCategories, }); // Events live in memory (not persisted), but the Canvas connection is persisted // in localStorage. On load, re-materialize the synced assignment events so a // connected Canvas still shows up on the calendar after a refresh. try { const ig = integrationsStore.get(); if (ig.canvas && Array.isArray(ig.canvasCourses) && ig.canvasCourses.length) { setCanvasEvents(ig.canvasCourses); } } catch (e) { /* non-fatal */ }