// desktop.jsx — Chrono for Mac, full-window calendar workspace
function AIEmptyState({ theme }) {
return (
Ask Chrono anything
Plan your week, block focus time, or move things around. Start by typing below.
);
}
function ChronoDesktop({ theme, personality, initialView, fullBleed }) {
useEvents();
const [active, setActive] = React.useState(initialView || 'week');
// Allow the guided tutorial to drive the active view.
useTutorialView(React.useCallback((v) => { if (v) setActive(v); }, []));
// Assistant popout is hidden by default — the user opens it from the
// title-bar icon. The guided tour forces it open (and closed on exit) via
// the tutorial-AI channel so new users see it during onboarding.
const [aiOpen, setAiOpen] = React.useState(false);
useTutorialAI(React.useCallback((open) => setAiOpen(open), []));
// When the Trips planner finishes, it jumps the Week view to the trip's
// first week so the user lands right on the new itinerary.
const [weekJump, setWeekJump] = React.useState(null);
const goToWeek = React.useCallback((wkOff) => { setWeekJump(wkOff); setActive('week'); }, []);
// Top-bar search: jump the Week view to a found event and flash it.
const [searchTarget, setSearchTarget] = React.useState(null);
const goToEvent = React.useCallback((off, ev) => {
const weekStartsOn = (window.settingsStore && window.settingsStore.get().weekStartsOn) || 0;
const wkStart = off - ((dateAtOffset(off).getDay() - weekStartsOn + 7) % 7);
setWeekJump(wkStart);
setActive('week');
setSearchTarget({ ev, off, ts: Date.now() });
}, []);
const [selected, setSelected] = React.useState(null);
const [composerOpen, setComposerOpen] = React.useState(false);
const [composerSeed, setComposerSeed] = React.useState(null);
const [paywallOpen, setPaywallOpen] = React.useState(false);
React.useEffect(() => { if (initialView) setActive(initialView); }, [initialView]);
const openComposer = (seed = null) => { setComposerSeed(seed); setComposerOpen(true); };
const closeComposer = () => setComposerOpen(false);
return (
{/* Title bar */}
setAiOpen(o=>!o)} onSearchSelect={goToEvent}/>
{/* Sidebar */}
{/* Main */}
{aiOpen && active !== 'ai' && (
setAiOpen(false)}/>
)}
{selected && setSelected(null)} onEdit={()=>{ openComposer(selected); setSelected(null); }}/>}
{composerOpen && }
{paywallOpen && setPaywallOpen(false)}/>}
{/* One-time beta welcome, shown once the tutorial is complete */}
{/* Global futurism overlay for desktop */}
);
}
// One-time "welcome to the beta" notice. Appears once the user has finished
// onboarding (so it lands right after the tutorial), then never again.
function BetaNotice({ theme }) {
const onboarding = useOnboarding();
if (!onboarding.done || onboarding.betaNoticeSeen) return null;
const dismiss = () => setOnboarding({ betaNoticeSeen: true });
return (
You're testing an early build, so expect a few rough edges. Every account has Pro unlocked while we test.
Hit a bug or have an idea? Use Send feedback on your Profile — it goes straight to the team.
Let's go
);
}
// Top-bar search — finds events by title/note/location/category. Selecting a
// result jumps the Week view to that event and flashes it (see goToEvent).
function DesktopSearch({ theme, onSelect }) {
const [q, setQ] = React.useState('');
const [open, setOpen] = React.useState(false);
const [hi, setHi] = React.useState(0);
useEvents(); // refresh results when the calendar changes
const wrapRef = React.useRef(null);
React.useEffect(() => {
const onDoc = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
const term = q.trim().toLowerCase();
const results = [];
if (term) {
for (const k of Object.keys(EVENTS_BY_OFFSET)) {
const off = Number(k);
for (const e of EVENTS_BY_OFFSET[k]) {
const hay = `${e.title || ''} ${e.note || ''} ${e.location || ''} ${e.category || ''}`.toLowerCase();
if (hay.includes(term)) results.push({ off, ev: e });
}
}
// Nearest to today first, then chronological.
results.sort((a, b) => Math.abs(a.off) - Math.abs(b.off) || a.off - b.off);
}
const shown = results.slice(0, 8);
const dot = (c) => (ChronoTokens.events[c] || ChronoTokens.events.grey).glow;
const choose = (r) => { if (!r) return; onSelect && onSelect(r.off, r.ev); setQ(''); setOpen(false); };
const onKey = (e) => {
if (e.key === 'Escape') { setQ(''); setOpen(false); e.currentTarget.blur(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); setHi(h => Math.min(h + 1, shown.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHi(h => Math.max(h - 1, 0)); }
else if (e.key === 'Enter') { choose(shown[hi] || shown[0]); }
};
return (
{/* Search — left of the AI button; finds events and flashes them on the Week view */}
{/* Assistant toggle — a labeled "AI" pill in a signature violet so it
reads as the AI assistant, distinct from the accent-colored "+"
manual event button beside it. */}
{onToggleAI && (() => {
const v = ChronoTokens.events.purple; // AI signature color
return (
);
})()}
);
}
function DesktopAmbient({ theme }) {
if (theme.bg === 'true-black') return null;
return (
<>
>
);
}
// ── Sidebar ───────────────────────────────────────────────────────────────
function DesktopSidebar({ theme, active, setActive }) {
const items = [
{ id: 'week', label: 'Week', icon: },
{ id: 'month', label: 'Month', icon: },
{ id: 'focus', label: 'Focus', icon: },
{ id: 'trips', label: 'Trips', icon: },
{ id: 'ai', label: 'Assistant', icon: },
{ id: 'categories', label: 'Categories', icon: },
{ id: 'canvas', label: 'Canvas LMS', icon: },
{ id: 'profile', label: 'Profile', icon: },
{ id: 'settings', label: 'Settings', icon: },
];
const [catsOpen, setCatsOpen] = React.useState(true);
// Live per-category counts — walks the event store and re-renders on any
// event mutation (manual add/edit/delete or AI-created), so the sidebar
// tallies stay in sync with what's actually on the calendar.
const catCounts = useCategories();
return (
beginDrag(ev, e, off, 'resize-start')} style={{
position: 'absolute', top: 0, left: 0, right: 0, height: handleH,
cursor: 'ns-resize', zIndex: 2,
}}/>
{compact ? (
// Single-line pill — title + time inline, like GCal's
// "break, 6pm" so a 5-min event is fully legible.
<>
{e.title}
{/* Inline time only when the pill is full-width — in
narrow side-by-side columns the title gets all the
room (time is still one click away). */}
{cols === 1 && {fmtTimeShort(e.start)}}
>
) : (
<>
);
}
// Settings group: a section title, optional one-line description, and a rounded
// card holding the rows. Spotify-style — everything visible at once, no tabs.
function DeskGroup({ theme, title, desc, children }) {
return (
{title}
{desc && (
{desc}
)}
{children}
);
}
// A single settings row: label + description on the left, an inline control on
// the right (a switch, a dropdown, or a tappable badge for actions / status).
function DeskRow({ theme, k, desc, help, editor, onClick, muted, badge, badgeTone, last }) {
const isToggle = editor && editor.kind === 'toggle';
// A toggle is operated by its own switch; the row itself isn't clickable.
const rowClickable = !muted && !!onClick && !isToggle;
// Hover-to-learn: after the pointer rests on a setting for 5 seconds, reveal
// a fuller description of what it does. Settings-only (DeskRow is used only
// here). Clears immediately on mouse-out so it never lingers.
const tipText = help || desc;
const rowRef = React.useRef(null);
const [showTip, setShowTip] = React.useState(false);
const [placeBelow, setPlaceBelow] = React.useState(false);
const tipTimer = React.useRef(null);
const HOVER_DELAY_MS = 450; // snappy: brief intentional pause, then reveal
const startTip = () => {
if (!tipText) return;
clearTimeout(tipTimer.current);
tipTimer.current = setTimeout(() => {
// Flip the tooltip below the row when there isn't enough room above it
// within the scrolling settings panel, so it never gets cut off.
const el = rowRef.current;
if (el) {
const scroller = el.closest('.chrono-scrollbar');
const top = el.getBoundingClientRect().top;
const bound = scroller ? scroller.getBoundingClientRect().top : 0;
setPlaceBelow((top - bound) < 140);
}
setShowTip(true);
}, HOVER_DELAY_MS);
};
const endTip = () => { clearTimeout(tipTimer.current); setShowTip(false); };
React.useEffect(() => () => clearTimeout(tipTimer.current), []);
return (
{/* Hover tooltip — opaque card with a gap from the row, flipped above or
below depending on room, so it never bleeds into or gets clipped by
the neighbouring settings or the panel edge. */}
{showTip && tipText && (
{k}
{tipText}
)}
);
}
// Inline status / action chip used on the right of a row.
function DeskBadge({ theme, label, tone, muted }) {
if (!label) return null;
const palette = {
on: { fg: '#52d094', bd: '#52d09455', bg: 'rgba(82,208,148,0.12)' },
action: { fg: theme.accentColor, bd: `${theme.accentColor}55`, bg: `${theme.accentColor}14` },
danger: { fg: ChronoTokens.events.red.base, bd: `${ChronoTokens.events.red.base}55`, bg: `${ChronoTokens.events.red.base}14` },
muted: { fg: theme.textFaint, bd: theme.hairline, bg: 'transparent' },
};
const p = muted ? palette.muted : (palette[tone] || palette.action);
return (
{label}
);
}
// Inline control — a switch for toggles, a native dropdown for selects. Both
// commit immediately (no expand-then-confirm step), the way Spotify does it.
function DeskControl({ theme, editor }) {
if (editor.kind === 'toggle') {
const on = !!editor.value;
return (
);
}
if (editor.kind === 'select') {
return (
);
}
// ── Trips — AI vacation itinerary builder ────────────────────────────────
// Enter a destination + dates; the assistant builds a timed, buffered
// day-by-day plan and drops every item onto the main calendar under a
// "Travel" category. Transit buffers between activities keep the user on time.
const TRIP_MAX_DAYS = 10;
// All events on the calendar belonging to a given trip (tagged with `trip`),
// grouped by day-offset and sorted by start. Read live from the store.
function tripDaysFor(destination) {
const byOff = new Map();
Object.keys(EVENTS_BY_OFFSET).forEach(k => {
const off = parseInt(k, 10);
EVENTS_BY_OFFSET[k].forEach(e => {
if (e.trip && e.trip === destination) {
if (!byOff.has(off)) byOff.set(off, []);
byOff.get(off).push(e);
}
});
});
return [...byOff.keys()].sort((a, b) => a - b).map(off => ({
off,
date: dateAtOffset(off),
events: byOff.get(off).slice().sort((a, b) => tm(a.start) - tm(b.start)),
}));
}
// Group a flat list of draft events (each carrying an `off`) into the same
// day-by-day shape tripDaysFor() returns, so the draft and the committed trip
// render through one itinerary view.
function groupEventsByOff(events) {
const byOff = new Map();
(events || []).forEach(e => {
if (!byOff.has(e.off)) byOff.set(e.off, []);
byOff.get(e.off).push(e);
});
return [...byOff.keys()].sort((a, b) => a - b).map(off => ({
off,
date: dateAtOffset(off),
events: byOff.get(off).slice().sort((a, b) => tm(a.start) - tm(b.start)),
}));
}
// Map the model's returned events into draft rows, clamped to the trip window.
function mapTripEvents(final, startOff, endOff) {
const out = [];
if (final && Array.isArray(final.events)) {
final.events.forEach(ev => {
if (!ev || typeof ev !== 'object') return;
const off = offsetFromDayLabel(ev.day || ev.date);
if (off < startOff - 1 || off > endOff + 1) return; // keep within the trip window
const color = (ev.color && ChronoTokens.events[ev.color]) ? ev.color : 'grey';
out.push({
title: String(ev.title || 'Activity').slice(0, 120),
start: ev.start || '09:00',
end: ev.end || '10:00',
color,
note: String(ev.note || '').slice(0, 280),
off,
});
});
}
return out;
}
const TRIP_PACE_TEXT = {
relaxed: 'relaxed — 2–3 main activities per day, leisurely meals, built-in downtime',
packed: 'packed — early starts and as much as can comfortably fit without rushing',
balanced: 'balanced — 3–4 activities per day with breathing room between them',
};
// A date picker styled to match Chrono (instead of the OS-native one). Opens a
// themed month grid that highlights TODAY (accent ring) and the selected day
// (filled accent), respects the user's "Week starts on" setting, and can
// enforce a minimum date (used so departure can't precede arrival).
function ThemedDatePicker({ theme, value, onChange, min, disabled }) {
const settings = useSettings();
const weekStartsOn = settings.weekStartsOn || 0;
const [open, setOpen] = React.useState(false);
const wrapRef = React.useRef(null);
const selected = value ? ymdToDate(value) : null;
const [viewMonth, setViewMonth] = React.useState(() => {
const base = selected || new Date(CHRONO_TODAY);
return new Date(base.getFullYear(), base.getMonth(), 1);
});
// Re-center the grid on the selected month if the value changes externally.
React.useEffect(() => {
if (value) { const d = ymdToDate(value); setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1)); }
}, [value]);
// Close on outside-click or Escape.
React.useEffect(() => {
if (!open) return;
const onDown = e => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); };
const onKey = e => { if (e.key === 'Escape') setOpen(false); };
window.addEventListener('mousedown', onDown);
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('mousedown', onDown); window.removeEventListener('keydown', onKey); };
}, [open]);
const today = new Date(CHRONO_TODAY); today.setHours(0,0,0,0);
const minDate = min ? ymdToDate(min) : null; if (minDate) minDate.setHours(0,0,0,0);
const sameDay = (a, b) => a && b && a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate();
const first = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
const lead = (first.getDay() - weekStartsOn + 7) % 7;
const gridStart = new Date(first); gridStart.setDate(1 - lead);
const cells = Array.from({ length: 42 }, (_, i) => { const d = new Date(gridStart); d.setDate(gridStart.getDate()+i); return d; });
const dowLabels = Array.from({ length: 7 }, (_, i) => DAY_NAMES[(i + weekStartsOn) % 7]);
const label = selected
? `${MONTH_NAMES[selected.getMonth()].slice(0,3)} ${selected.getDate()}, ${selected.getFullYear()}`
: 'Select date';
const pick = (d) => {
const dd = new Date(d); dd.setHours(0,0,0,0);
if (minDate && dd < minDate) return;
onChange(dateToYMD(d));
setOpen(false);
};
return (
);
const REFINE_EXAMPLES = [
'Add a free morning on day 2',
'Swap one dinner for a food market',
'Make it less packed',
'Add more museums',
];
return (
{/* Planner form */}
Plan a trip
The assistant drafts a timed itinerary from your goals — review and tweak it before it touches your calendar.
{busy ? <> {busyLabel}…> : <> {draft || activeTrip ? 'Re-draft trip' : 'Draft my trip'}>}
{busy && (
)}
gpt-4o · up to {TRIP_MAX_DAYS} days
{/* Draft itinerary — review, refine, then accept or discard */}
{draft && draftDays.length > 0 && (
First draft
{draft.destination}
{draftDays.length} day{draftDays.length > 1 ? 's' : ''} · {draftTotal} activities · not yet on your calendar
Looks good? Add it to your calendar. Want changes? Tell the assistant what to add, remove, or rework.
{/* Refine box */}
setRefineText(e.target.value)}
placeholder="e.g. add a cooking class on day 2 and drop the late dinners"
maxLength={300} disabled={busy}
onKeyDown={e => { if (e.key === 'Enter') refine(refineText); }}
style={{ ...inputStyle, flex: 1 }}/>
refine(refineText)}>
{busy ? <> {busyLabel}…> : <> Apply>}
{REFINE_EXAMPLES.map(ex => (
))}
{renderDays(draftDays)}
{/* Accept / discard */}
Add to calendar
)}
{/* Committed itinerary (after the user accepts a draft) */}
{!draft && activeTrip && committedDays.length > 0 && (
{/* Account & sync — sign in to persist across devices */}
{auth && (
{auth.isAuthed()
? (auth.getStatus() === 'syncing' ? 'Syncing…' : auth.getStatus() === 'error' ? 'Sync error — will retry' : 'Synced to your account')
: 'Not signed in — changes stay on this device'}
{auth.isAuthed() ? (auth.getUser() && auth.getUser().email) : 'Sign in to sync events, categories & settings'}