// Loop MVP — Home + Meeting screens
// HomeScreen, MeetingsScreen, BriefScreen, LiveMeetingScreen, RecapScreen
const D = () => window.LOOP_DATA;
// ─────────────────────────────────────────────────────────────
// HomeScreen — orchestrated by Loop's AI coach layer.
// Each module is ranked by relevance to today; the user can pin,
// mute, or reorder anything from the orchestrator sheet.
// ─────────────────────────────────────────────────────────────
const HOME_MODULES = {
next: { label: 'Next meeting', agent: 'Chief of staff', icon: 'calendar-clock' },
insights: { label: "Today's signals", agent: 'Chief of staff', icon: 'sparkles' },
weather: { label: 'Weather & outfit', agent: 'Daily ritual', icon: 'cloud-sun' },
spots: { label: 'Places near your day', agent: 'Chief of staff', icon: 'map-pin' },
health: { label: 'Health & recovery', agent: 'Health coach', icon: 'activity' },
kpis: { label: 'Company pulse', agent: 'Board & investor relations', icon: 'trending-up' },
trip: { label: 'Next trip', agent: 'Chief of staff', icon: 'plane' },
people: { label: 'My people', agent: 'People & hiring', icon: 'users' },
agenda: { label: 'Your day', agent: 'Chief of staff', icon: 'calendar' },
todos: { label: 'Follow-ups', agent: 'Chief of staff', icon: 'check-square' },
agents: { label: 'Agents running', agent: 'Orchestrator', icon: 'sparkles' },
notifs: { label: 'Signal not noise', agent: 'Orchestrator', icon: 'bell' },
};
const DEFAULT_ORDER = {
principal: ['weather', 'next', 'insights', 'kpis', 'trip', 'spots', 'people', 'agenda', 'todos', 'agents', 'notifs'],
moderator: ['weather', 'next', 'insights', 'kpis', 'people', 'agenda', 'spots', 'trip', 'todos', 'agents', 'notifs'],
};
const DEFAULT_ENABLED = Object.keys(HOME_MODULES).reduce((a, k) => (a[k] = true, a), {});
function HomeScreen({ persona, go }) {
const data = D();
const u = data[persona];
const meetings = data.meetings[persona];
const next = meetings.find(m => m.status === 'upcoming');
const todos = data.todos[persona].filter(t => t.priority === 'high').slice(0, 3);
const notifs = data.notifications[persona].slice(0, 2);
const ringPeople = data.people.filter(p => p.status === 'needs-attention' || p.status === 'warm').slice(0, 4);
const trip = data.trip[persona];
const companies = data.companies[persona];
const [companyId, setCompanyId] = React.useState(companies[0].id);
const company = companies.find(c => c.id === companyId) || companies[0];
const [enabled, setEnabled] = React.useState(DEFAULT_ENABLED);
const [order, setOrder] = React.useState(DEFAULT_ORDER[persona]);
const [sheetOpen, setSheetOpen] = React.useState(false);
React.useEffect(() => {
setOrder(DEFAULT_ORDER[persona]);
setCompanyId(data.companies[persona][0].id);
}, [persona]);
const renderModule = (key) => {
if (!enabled[key]) return null;
switch (key) {
case 'next':
return next ? (
go('brief', { id: next.id })} style={{ padding: 24, position: 'relative', overflow: 'hidden' }}>
Next · {next.when}
{next.title}
{next.time} · {next.duration}
·
{next.location}
{next.participants && (
({
initials: p.initials, tint: '#CF4500',
photo: (findPerson(p.name) || findPerson(p.initials) || {}).photo,
}))} size={32} />
{ e.stopPropagation(); go('brief', { id: next.id }); }} />
)}
) : null;
case 'insights': return
;
case 'weather':
// When both weather + health are enabled, render them as a paired briefing.
// Otherwise this slot renders the full weather module.
if (enabled.health) {
return
;
}
return
;
case 'spots': return
;
case 'health':
// Already rendered alongside weather as a paired briefing
if (enabled.weather) return null;
return
;
case 'kpis':
return (
{company.name}
}
action={{company.role}
}>
{/* Company switcher — horizontal scroller of company chips */}
{companies.map(c => {
const active = c.id === companyId;
if (c.isAdd) {
return (
{c.name}
);
}
return (
setCompanyId(c.id)} style={{
flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: 8,
padding: '6px 14px 6px 6px', borderRadius: 999,
border: active ? '1.5px solid var(--ink)' : '1.5px solid rgba(20,20,19,0.12)',
background: active ? 'var(--ink)' : 'var(--canvas-lifted)',
color: active ? 'var(--canvas)' : 'var(--ink)',
cursor: 'pointer', fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 500,
}}>
{c.name}
);
})}
{company.meta}
{company.kpis.map(k => )}
Add KPI
);
case 'trip':
return (
);
case 'people':
return (
go('people')} style={{ background: 'none', border: 'none', cursor: 'pointer',
fontSize: 14, color: 'var(--ink)', textDecoration: 'underline' }}>All}>
{ringPeople.map((p, i) => (
go('person', { id: p.id })}
style={{ flexShrink: 0, width: 130, cursor: 'pointer',
transform: i % 2 === 0 ? 'translateY(8px)' : 'translateY(-12px)' }}>
{p.name.split(' ')[0]}
{p.last}
))}
);
case 'agenda':
return (
go('meetings')} style={{ background: 'none', border: 'none', cursor: 'pointer',
fontSize: 14, color: 'var(--ink)', textDecoration: 'underline' }}>All}>
{meetings.slice(0, 4).map((m, i) => (
{m.time}
{m.duration}
}
title={{m.title} }
subtitle={`${m.location} · ${m.status === 'done' ? 'done' : m.when}`}
trailing={ }
onClick={() => go(m.status === 'done' ? 'recap' : 'brief', { id: m.id })}
/>
))}
);
case 'todos':
return (
go('tasks')} style={{ background: 'none', border: 'none', cursor: 'pointer',
fontSize: 14, color: 'var(--ink)', textDecoration: 'underline' }}>All}>
{todos.map((t, i) => (
}
title={t.body}
subtitle={`${t.from} · ${t.due}`}
trailing={ }
onClick={() => go('tasks')}
/>
))}
);
case 'agents':
return (
go('agents')} style={{ background: 'none', border: 'none', cursor: 'pointer',
fontSize: 14, color: 'var(--ink)', textDecoration: 'underline' }}>All}>
{data.agents[persona].slice(0, 3).map(a => (
go('agent-chat', { id: a.id })}
style={{ minWidth: 260, padding: 18 }}>
{a.name}
{a.role}
{a.skills} skills · {a.runs}
))}
);
case 'notifs':
return (
{notifs.map((n, i) => (
}
title={n.body}
subtitle={n.when}
onClick={() => go('notifications')}
/>
))}
);
default: return null;
}
};
return (
{/* Greeting block */}
{data.today}
setSheetOpen(true)} title="Orchestrator" style={{
position: 'relative', width: 40, height: 40, borderRadius: '50%',
background: 'transparent', border: 'none', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
go('notifications')} style={{
position: 'relative', width: 40, height: 40, borderRadius: '50%',
background: 'transparent', border: 'none', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>
{u.greeting}.
{u.contextLine}.
{order.map(k => renderModule(k))}
setSheetOpen(false)}
enabled={enabled} setEnabled={setEnabled}
order={order} setOrder={setOrder}
modules={HOME_MODULES} />
);
}
// ─────────────────────────────────────────────────────────────
// KPI card
// ─────────────────────────────────────────────────────────────
function KPICard({ k }) {
const trendColor = {
up: 'var(--success)',
down: 'var(--danger)',
'down-good': 'var(--success)',
flat: 'var(--slate)',
}[k.trend] || 'var(--slate)';
const trendIcon = {
up: 'trending-up', down: 'trending-down',
'down-good': 'trending-down', flat: 'minus',
}[k.trend] || 'minus';
return (
{k.label}
{k.value}
{k.delta}
· {k.period}
);
}
// ─────────────────────────────────────────────────────────────
// FlightCard — private jet headline, Qatar-airways inspired
// Compact summary by default; expand to reveal full metadata.
// ─────────────────────────────────────────────────────────────
function FlightCard({ flight, persona }) {
const [open, setOpen] = React.useState(false);
return (
{/* Hero — fixed compact height, hero image + airport codes */}
setOpen(o => !o)} style={{
all: 'unset', cursor: 'pointer', display: 'block', width: '100%',
}}>
{flight.status}
{persona === 'principal' && (
Private jet
)}
{flight.from.code}
{flight.from.time} · {flight.from.date}
{flight.to.code}
{flight.to.time} · {flight.to.date}
{/* Expanded meta */}
{open && (
{flight.crew && flight.crew !== '—' && }
{flight.catering && }
)}
);
}
function KV({ label, value }) {
return (
);
}
// ─────────────────────────────────────────────────────────────
// AccommodationCard — compact summary + expandable meta
// ─────────────────────────────────────────────────────────────
function AccommodationCard({ accom }) {
const [open, setOpen] = React.useState(false);
return (
setOpen(o => !o)} style={{
all: 'unset', cursor: 'pointer', display: 'block', width: '100%',
}}>
{accom.kind}
{accom.name}
{accom.checkIn} → {accom.checkOut.split(' · ')[0]} · {accom.nights}n
{open && (
Concierge · {accom.concierge}
Message
)}
);
}
// ─────────────────────────────────────────────────────────────
// MeetingsScreen — full agenda list
// ─────────────────────────────────────────────────────────────
function MeetingsScreen({ persona, go }) {
const meetings = D().meetings[persona];
const [filter, setFilter] = React.useState('today');
const filters = [
{ key: 'today', label: 'Today' },
{ key: 'week', label: 'This week' },
{ key: 'past', label: 'Past' },
];
return (
} />
{filters.map(f => (
setFilter(f.key)} style={{
padding: '8px 16px', borderRadius: 999,
border: filter === f.key ? '1.5px solid var(--ink)' : '1.5px solid rgba(20,20,19,0.18)',
background: filter === f.key ? 'var(--ink)' : 'transparent',
color: filter === f.key ? 'var(--canvas)' : 'var(--ink)',
fontSize: 13, fontWeight: 500, cursor: 'pointer', whiteSpace: 'nowrap',
}}>{f.label}
))}
{meetings.map((m, i) => {
const past = m.status === 'done';
return (
go(past ? 'recap' : 'brief', { id: m.id })}
style={{ display: 'flex', gap: 16, padding: '20px 0',
borderBottom: i < meetings.length - 1 ? '1px solid rgba(20,20,19,0.08)' : 'none', cursor: 'pointer' }}>
{m.kind} · {m.location}
{m.title}
{m.participants && (
({
initials: p.initials, tint: 'var(--ink)',
photo: (findPerson(p.name) || findPerson(p.initials) || {}).photo,
}))} size={24} />
{past ? 'recap ready' : m.when}
)}
);
})}
);
}
function MeetingKindDot({ kind }) {
const map = {
investor: '#CF4500', board: '#9A3A0A', hiring: '#3860BE',
interview: '#3860BE', internal: '#1F7A4D', sales: '#F37338',
};
return ;
}
// ─────────────────────────────────────────────────────────────
// BriefScreen — pre-meeting one-pager
// ─────────────────────────────────────────────────────────────
function BriefScreen({ persona, go, params }) {
const data = D();
const m = data.meetings[persona].find(x => x.id === params?.id) || data.meetings[persona][0];
if (!m.agenda) {
// not enough data for this meeting
return (
go('meetings')} title="Brief" eyebrow={`${m.time} · ${m.location}`} />
Coming up
{m.title}
Loop is preparing the brief. It will be ready 30 minutes before the meeting.
Generate brief now
);
}
return (
go('meetings')} title="Brief" eyebrow={`${m.time} · ${m.duration}`}
trailing={
} />
{m.kind} · {m.location}
{m.title}
{m.when} · prepared by Loop · 3 min read
{/* Participants — circular portraits per design system */}
{m.participants.map((p, i) => {
const person = findPerson(p.name) || findPerson(p.initials);
return (
person && go('person', { id: person.id })}
style={{ flexShrink: 0, width: 170, cursor: 'pointer' }}>
{p.name}
{p.role}
{p.last}
);
})}
{/* Talking points — the principal's edge */}
{m.talkingPoints.map((tp, i) => (
{String(i + 1).padStart(2, '0')}
{tp}
))}
{/* Agenda */}
{m.agenda.map((a, i) => (
{i + 1}.}
title={a}
trailing={null} />
))}
{/* Open threads */}
{m.threads.map((t, i) => (
{t.label}
{t.body}
))}
{/* Files */}
{m.files.map((f, i) => (
}
title={f.name} subtitle={f.meta}
trailing={ } />
))}
{/* Floating bottom CTA */}
Snooze
go('live', { id: m.id })} style={{ flex: 2 }}
icon={ }>
Join · Loop will capture
);
}
// ─────────────────────────────────────────────────────────────
// LiveMeetingScreen — capture + live notes
// ─────────────────────────────────────────────────────────────
function LiveMeetingScreen({ persona, go, params }) {
const data = D();
const m = data.meetings[persona].find(x => x.id === params?.id) || data.meetings[persona][0];
const [elapsed, setElapsed] = React.useState(247); // 4:07
const [notes, setNotes] = React.useState([]);
const [tab, setTab] = React.useState('notes');
React.useEffect(() => {
const id = setInterval(() => setElapsed(e => e + 1), 1000);
return () => clearInterval(id);
}, []);
React.useEffect(() => {
// Stream in fake notes
const seed = persona === 'principal' ? [
{ t: 0, kind: 'decision', who: 'Roelof', body: 'Series C target post-money: $420M. Comfortable with $400M floor.' },
{ t: 4, kind: 'action', who: 'You', body: 'Send updated cap table by end of day.' },
{ t: 9, kind: 'topic', who: 'Jess', body: 'Two board candidates in the next ten days — operator profile, no investors.' },
{ t: 14, kind: 'decision', who: 'Room', body: 'Timeline target: signed term sheet by mid-August.' },
{ t: 19, kind: 'action', who: 'Roelof', body: 'Roelof to send a model term sheet on Friday.' },
] : [
{ t: 0, kind: 'topic', who: 'Mae', body: 'Distributed inventory — walked the room through Stripe Issuing primitives.' },
{ t: 5, kind: 'decision', who: 'Panel', body: 'Strong technical depth. 8/10 against rubric.' },
{ t: 10, kind: 'risk', who: 'Devon', body: 'Some hesitation on cross-functional conflict resolution.' },
{ t: 15, kind: 'action', who: 'You', body: 'Share EU GM context doc and schedule final with Aurélien.' },
{ t: 20, kind: 'topic', who: 'Mae', body: 'Comp expectation: $340 base, 1.0% equity — open to negotiate.' },
];
let i = 0;
const id = setInterval(() => {
if (i >= seed.length) return clearInterval(id);
setNotes(n => [...n, seed[i]]); i++;
}, 1800);
return () => clearInterval(id);
}, [persona]);
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
const kindMeta = {
decision: { tone: 'ink', label: 'Decision', icon: 'check-circle' },
action: { tone: 'signal', label: 'Action', icon: 'arrow-right-circle' },
topic: { tone: 'default', label: 'Topic', icon: 'message-square' },
risk: { tone: 'warning', label: 'Risk', icon: 'alert-triangle' },
};
return (
{/* Live header */}
go('meetings')} style={{ background: 'transparent', border: 'none', cursor: 'pointer',
color: 'var(--canvas)', display: 'inline-flex', alignItems: 'center', gap: 6, padding: 8, marginLeft: -8 }}>
Recording
go('recap', { id: m.id })} style={{ background: 'transparent', border: 'none', cursor: 'pointer',
color: 'var(--canvas)', fontSize: 13, fontWeight: 500 }}>End ·
{m.kind} · {m.location}
{m.title}
({
initials: p.initials, tint: '#CF4500',
photo: (findPerson(p.name) || findPerson(p.initials) || {}).photo,
})) || []} size={28} />
{fmt(elapsed)}
{/* Tabs */}
{['notes', 'transcript', 'people'].map(t => (
setTab(t)} style={{
background: 'transparent', border: 'none', cursor: 'pointer',
padding: '12px 0', fontSize: 14, fontWeight: 500,
color: tab === t ? 'var(--ink)' : 'var(--slate)',
borderBottom: tab === t ? '2px solid var(--ink)' : '2px solid transparent',
textTransform: 'capitalize',
}}>{t}
))}
{tab === 'notes' && notes.map((n, i) => {
const meta = kindMeta[n.kind];
return (
{meta.label}
{n.who}
{n.body}
);
})}
{tab === 'notes' && notes.length === 0 && (
Listening…
)}
{tab === 'transcript' && (
{m.participants[0].name}: Thanks for making time. We've been tracking the numbers — Q1 was strong.
You: 41% QoQ, came in ahead of plan. Burn is steady. Runway sits at 22 months.
{m.participants[0].name}: Good. On the Series C — I want to push for a September close. Founders Fund is circling.
You: I'd like two more weeks on diligence before we commit a date.
· Loop captured this as a decision pending. ·
)}
{tab === 'people' && (
{m.participants.map((p, i) => {
const person = findPerson(p.name) || findPerson(p.initials);
return (
person && go('person', { id: person.id })}>
{p.name}
{p.role}
{p.last}
);
})}
)}
{/* Floating control deck */}
Tap to add note ·
go('recap', { id: m.id })}
style={{ width: 48, height: 48, borderRadius: '50%', background: 'var(--loop-orange)',
border: 'none', color: 'var(--white)', cursor: 'pointer' }}>
);
}
// ─────────────────────────────────────────────────────────────
// RecapScreen — post-meeting summary
// ─────────────────────────────────────────────────────────────
function RecapScreen({ persona, go, params }) {
const data = D();
const all = data.meetings[persona];
const m = all.find(x => x.id === params?.id) || all.find(x => x.status === 'done') || all[0];
const recap = persona === 'principal' ? {
summary: 'Sequoia is anchored on a $420M post and a 6-week close. Roelof will send a model term sheet Friday; Jess will surface two operator-profile board candidates inside 10 days. You did not commit to a September close — that\'s yours to decide by Tuesday.',
decisions: [
'Series C target post-money: $420M, floor $400M.',
'Sequoia to lead. Roelof to send draft term sheet Friday.',
'Timeline: signed by mid-August. Aurélien to confirm by Tuesday.',
],
actions: [
{ who: 'You', body: 'Send updated cap table to Roelof — by today 18:00.', status: 'open' },
{ who: 'You', body: 'Reply with September close decision — by Tuesday.', status: 'open' },
{ who: 'Roelof', body: 'Send model term sheet — by Friday.', status: 'tracked' },
{ who: 'Jess', body: 'Surface 2 board candidates — within 10 days.', status: 'tracked' },
],
sentiment: 'Warm. Roelof is leaning in. Jess is curious. Neither pushed on dilution.',
crmWrite: 'Salesforce account "Sequoia · Series C" updated with stage, next step, deal size, and your sentiment read.',
} : {
summary: 'Mae cleared the technical bar (8/10 systems design). Soft on cross-functional conflict — Devon flagged hesitation. She is at final stage at Anthropic. You need to move within 48 hours or lose her. Comp ask sits inside our band.',
decisions: [
'Mae advances to final round with Aurélien.',
'Comp framework: $340 base + 1.0% equity is acceptable starting point.',
'Decision-by date: Monday EOD.',
],
actions: [
{ who: 'You', body: 'Send EU GM context doc to Mae — by 09:30.', status: 'open' },
{ who: 'You', body: 'Schedule final with Aurélien — by EOD today.', status: 'open' },
{ who: 'Devon', body: 'Add round 2 notes to Greenhouse — by EOD.', status: 'tracked' },
],
sentiment: 'Warm — Mae is bought into the mission. Comp negotiation will be friendly.',
crmWrite: 'Greenhouse candidate "Mae Chen" moved to Final Round. Scorecard v3 attached.',
};
return (
);
}
Object.assign(window, { HomeScreen, MeetingsScreen, BriefScreen, LiveMeetingScreen, RecapScreen });