feat: unified context panel, remove tabs, context-aware layout

- Merged AI Assistant + Lead 360 tabs into single context-aware panel
- Context section shows: lead profile, AI insight (live from event bus), appointments, activity
- "On call with" banner only shows during active calls (isInCall prop)
- AI Chat always available at bottom of panel
- Phase 1 only — AI Chat panel needs full redesign with Vercel AI SDK tool calling (Phase 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 09:51:49 +05:30
parent e6b2208077
commit 48ed300094
7 changed files with 2424 additions and 195 deletions

680
docs/generate-pptx.cjs Normal file
View File

@@ -0,0 +1,680 @@
/**
* Helix Engage — Weekly Update (Mar 1825, 2026)
* Light Mode PowerPoint Generator via PptxGenJS
*/
const PptxGenJS = require("pptxgenjs");
// ── Design Tokens (Light Mode) ─────────────────────────────────────────
const C = {
bg: "FFFFFF",
bgSubtle: "F8FAFC",
bgCard: "F1F5F9",
bgCardAlt: "E2E8F0",
text: "1E293B",
textSec: "475569",
textMuted: "94A3B8",
accent1: "0EA5E9", // Sky blue (telephony)
accent2: "8B5CF6", // Violet (server/backend)
accent3: "10B981", // Emerald (UX)
accent4: "F59E0B", // Amber (features)
accent5: "EF4444", // Rose (ops)
accent6: "6366F1", // Indigo (timeline)
white: "FFFFFF",
border: "CBD5E1",
};
const FONT = {
heading: "Arial",
body: "Arial",
};
// ── Helpers ──────────────────────────────────────────────────────────────
function addSlideNumber(slide, num, total) {
slide.addText(`${num} / ${total}`, {
x: 8.8, y: 5.2, w: 1.2, h: 0.3,
fontSize: 8, color: C.textMuted,
fontFace: FONT.body,
align: "right",
});
}
function addAccentBar(slide, color) {
slide.addShape("rect", {
x: 0, y: 0, w: 10, h: 0.06,
fill: { color },
});
}
function addLabel(slide, text, color, x, y) {
slide.addShape("roundRect", {
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
fill: { color, transparency: 88 },
rectRadius: 0.15,
});
slide.addText(text.toUpperCase(), {
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
fontSize: 7, fontFace: FONT.heading, bold: true,
color, align: "center", valign: "middle",
letterSpacing: 2,
});
}
function addCard(slide, opts) {
const { x, y, w, h, title, titleColor, items, badge } = opts;
// Card background
slide.addShape("roundRect", {
x, y, w, h,
fill: { color: C.bgCard },
line: { color: C.border, width: 0.5 },
rectRadius: 0.1,
});
// Title
const titleText = badge
? [{ text: title + " ", options: { bold: true, color: titleColor, fontSize: 11 } },
{ text: badge, options: { bold: true, color: C.white, fontSize: 7, highlight: titleColor } }]
: title;
slide.addText(titleText, {
x: x + 0.2, y: y + 0.08, w: w - 0.4, h: 0.35,
fontSize: 11, fontFace: FONT.heading, bold: true,
color: titleColor,
});
// Items as bullet list
if (items && items.length > 0) {
slide.addText(
items.map(item => ({
text: item,
options: {
fontSize: 8.5, fontFace: FONT.body, color: C.textSec,
bullet: { type: "bullet", style: "arabicPeriod" },
paraSpaceAfter: 2,
breakLine: true,
},
})),
{
x: x + 0.2, y: y + 0.4, w: w - 0.4, h: h - 0.5,
valign: "top",
bullet: { type: "bullet" },
lineSpacingMultiple: 1.1,
}
);
}
}
// ── Build Presentation ──────────────────────────────────────────────────
async function build() {
const pptx = new PptxGenJS();
pptx.layout = "LAYOUT_16x9";
pptx.author = "Satya Suman Sari";
pptx.company = "FortyTwo Platform";
pptx.title = "Helix Engage — Weekly Update (Mar 1825, 2026)";
pptx.subject = "Engineering Progress Report";
const TOTAL = 9;
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 1 — Title
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
// Accent bar top
addAccentBar(slide, C.accent1);
// Decorative side stripe
slide.addShape("rect", {
x: 0, y: 0, w: 0.12, h: 5.63,
fill: { color: C.accent1 },
});
// Label
addLabel(slide, "Weekly Engineering Update", C.accent1, 3.0, 1.2);
// Title
slide.addText("Helix Engage", {
x: 1.0, y: 1.8, w: 8, h: 1.2,
fontSize: 44, fontFace: FONT.heading, bold: true,
color: C.accent1, align: "center",
});
// Subtitle
slide.addText("Contact Center CRM · Real-time Telephony · AI Copilot", {
x: 1.5, y: 2.9, w: 7, h: 0.5,
fontSize: 14, fontFace: FONT.body,
color: C.textSec, align: "center",
});
// Date
slide.addText("March 18 25, 2026", {
x: 3, y: 3.6, w: 4, h: 0.4,
fontSize: 12, fontFace: FONT.heading, bold: true,
color: C.textMuted, align: "center",
letterSpacing: 3,
});
// Bottom decoration
slide.addShape("rect", {
x: 3.5, y: 4.2, w: 3, h: 0.04,
fill: { color: C.accent2 },
});
// Author
slide.addText("Satya Suman Sari · FortyTwo Platform", {
x: 2, y: 4.5, w: 6, h: 0.35,
fontSize: 9, fontFace: FONT.body,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 1, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 2 — At a Glance
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent2);
addLabel(slide, "At a Glance", C.accent2, 0.5, 0.3);
slide.addText("Week in Numbers", {
x: 0.5, y: 0.65, w: 5, h: 0.5,
fontSize: 24, fontFace: FONT.heading, bold: true,
color: C.text,
});
// Stat cards
const stats = [
{ value: "78", label: "Total Commits", color: C.accent1 },
{ value: "3", label: "Repositories", color: C.accent2 },
{ value: "8", label: "Days Active", color: C.accent3 },
{ value: "50", label: "Frontend Commits", color: C.accent4 },
];
stats.forEach((s, i) => {
const x = 0.5 + i * 2.35;
// Card bg
slide.addShape("roundRect", {
x, y: 1.3, w: 2.1, h: 1.7,
fill: { color: C.bgCard },
line: { color: C.border, width: 0.5 },
rectRadius: 0.12,
});
// Accent top line
slide.addShape("rect", {
x: x + 0.2, y: 1.35, w: 1.7, h: 0.035,
fill: { color: s.color },
});
// Number
slide.addText(s.value, {
x, y: 1.5, w: 2.1, h: 0.9,
fontSize: 36, fontFace: FONT.heading, bold: true,
color: s.color, align: "center", valign: "middle",
});
// Label
slide.addText(s.label, {
x, y: 2.4, w: 2.1, h: 0.4,
fontSize: 9, fontFace: FONT.body,
color: C.textSec, align: "center",
});
});
// Repo breakdown pills
const repos = [
{ name: "helix-engage", count: "50", clr: C.accent1 },
{ name: "helix-engage-server", count: "27", clr: C.accent2 },
{ name: "FortyTwoApps/SDK", count: "1", clr: C.accent3 },
];
repos.forEach((r, i) => {
const x = 1.5 + i * 2.8;
slide.addShape("roundRect", {
x, y: 3.4, w: 2.5, h: 0.4,
fill: { color: C.bgCard },
line: { color: r.clr, width: 1 },
rectRadius: 0.2,
});
slide.addText(`${r.name} ${r.count}`, {
x, y: 3.4, w: 2.5, h: 0.4,
fontSize: 9, fontFace: FONT.heading, bold: true,
color: r.clr, align: "center", valign: "middle",
});
});
// Summary text
slide.addText("3 repos · 7 working days · 78 commits shipped to production", {
x: 1, y: 4.2, w: 8, h: 0.35,
fontSize: 10, fontFace: FONT.body, italic: true,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 2, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 3 — Telephony & SIP
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent1);
addLabel(slide, "Core Infrastructure", C.accent1, 0.5, 0.3);
slide.addText([
{ text: "☎ ", options: { fontSize: 22 } },
{ text: "Telephony & SIP Overhaul", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Outbound Calling", titleColor: C.accent1, badge: "Frontend",
items: [
"Direct SIP call from browser — no Kookoo bridge",
"Immediate call card UI with auto-answer SIP bridge",
"End Call label fix, force active state after auto-answer",
"Reset outboundPending on call end",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Ozonetel Integration", titleColor: C.accent2, badge: "Server",
items: [
"Ozonetel V3 dial endpoint + webhook handler",
"Set Disposition API for ACW release",
"Force Ready endpoint for agent state mgmt",
"Token: 10-min cache, 401 invalidation, refresh on login",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "SIP & Agent State", titleColor: C.accent1, badge: "Frontend",
items: [
"SIP driven by Agent entity with token refresh",
"Centralised outbound dial into useSip().dialOutbound()",
"UCID tracking from SIP headers for disposition",
"Network indicator for connection health",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "Multi-Agent & Sessions", titleColor: C.accent2, badge: "Server",
items: [
"Multi-agent SIP with Redis session lockout",
"Strict duplicate login — one device per agent",
"Session lock stores IP + timestamp for debugging",
"SSE agent state broadcast for supervisor view",
],
});
addSlideNumber(slide, 3, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 4 — Call Desk & Agent UX
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent3);
addLabel(slide, "User Experience", C.accent3, 0.5, 0.3);
slide.addText([
{ text: "🖥 ", options: { fontSize: 22 } },
{ text: "Call Desk & Agent UX", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 3.05, h: 2.6,
title: "Call Desk Redesign", titleColor: C.accent3,
items: [
"2-panel layout with collapsible sidebar & inline AI",
"Collapsible context panel, worklist/calls tabs",
"Pinned header & chat input, numpad dialler",
"Ringtone support for incoming calls",
],
});
addCard(slide, {
x: 3.55, y: 1.35, w: 3.05, h: 2.6,
title: "Post-Call Workflow", titleColor: C.accent3,
items: [
"Disposition → appointment booking → follow-up",
"Disposition returns straight to worklist",
"Send disposition to sidecar with UCID for ACW",
"Enquiry in post-call, appointment skip button",
],
});
addCard(slide, {
x: 6.8, y: 1.35, w: 2.9, h: 2.6,
title: "UI Polish", titleColor: C.accent3,
items: [
"FontAwesome Pro Duotone icon migration",
"Tooltips, sticky headers, roles, search",
"Fix React error #520 in prod tables",
"AI scroll containment, brand tokens refresh",
],
});
addSlideNumber(slide, 4, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 5 — Features Shipped
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent4);
addLabel(slide, "Features Shipped", C.accent4, 0.5, 0.3);
slide.addText([
{ text: "🚀 ", options: { fontSize: 22 } },
{ text: "Major Features", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Supervisor Module", titleColor: C.accent4,
items: [
"Team performance analytics page",
"Live monitor with active calls visibility",
"Master data management pages",
"Server: team perf + active calls endpoints",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Missed Call Queue (Phase 2)", titleColor: C.accent4,
items: [
"Missed call queue ingestion & worklist",
"Auto-assignment engine for agents",
"Login redesign with role-based routing",
"Lead lookup for missed callers",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "Agent Features (Phase 1)", titleColor: C.accent4,
items: [
"Agent status toggle (Ready / Not Ready / Break)",
"Global search across patients, leads, calls",
"Enquiry form for new patient intake",
"My Performance page + logout modal",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "Recording Analysis", titleColor: C.accent4,
items: [
"Deepgram diarization + AI insights",
"Redis caching layer for analysis results",
"Full-stack: frontend player + server module",
],
});
addSlideNumber(slide, 5, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 6 — Backend & Data
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent2);
addLabel(slide, "Backend & Data", C.accent2, 0.5, 0.3);
slide.addText([
{ text: "⚙ ", options: { fontSize: 22 } },
{ text: "Backend & Data Layer", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Platform Data Wiring", titleColor: C.accent2,
items: [
"Migrated frontend to Jotai + Vercel AI SDK",
"Corrected all 7 GraphQL queries (fields, LINKS/PHONES)",
"Webhook handler for Ozonetel call records",
"Complete seeder: 5 doctors, appointments linked",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Server Endpoints", titleColor: C.accent2,
items: [
"Call control, recording, CDR, missed calls, live assist",
"Agent summary, AHT, performance aggregation",
"Token refresh endpoint for auto-renewal",
"Search module with full-text capabilities",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "Data Pages Built", titleColor: C.accent2,
items: [
"Worklist table, call history, patients, dashboard",
"Reports, team dashboard, campaigns, settings",
"Agent detail page, campaign edit slideout",
"Appointments page with data refresh on login",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "SDK App", titleColor: C.accent3, badge: "FortyTwoApps",
items: [
"Helix Engage SDK app entity definitions",
"Call center CRM object model for platform",
"Foundation for platform-native data integration",
],
});
addSlideNumber(slide, 6, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 7 — Deployment & Ops
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent5);
addLabel(slide, "Operations", C.accent5, 0.5, 0.3);
slide.addText([
{ text: "🛠 ", options: { fontSize: 22 } },
{ text: "Deployment & DevOps", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 3.05, h: 2.2,
title: "Deployment", titleColor: C.accent5,
items: [
"Deployed to Hostinger VPS with Docker",
"Switched to global_healthx Ozonetel account",
"Dockerfile for server-side containerization",
],
});
addCard(slide, {
x: 3.55, y: 1.35, w: 3.05, h: 2.2,
title: "AI & Testing", titleColor: C.accent5,
items: [
"Migrated AI to Vercel AI SDK + OpenAI provider",
"AI flow test script — validates full pipeline",
"Live call assist integration",
],
});
addCard(slide, {
x: 6.8, y: 1.35, w: 2.9, h: 2.2,
title: "Documentation", titleColor: C.accent5,
items: [
"Team onboarding README with arch guide",
"Supervisor module spec + plan",
"Multi-agent spec + plan",
"Next session plans in commits",
],
});
addSlideNumber(slide, 7, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 8 — Timeline
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent6);
addLabel(slide, "Day by Day", C.accent6, 0.5, 0.3);
slide.addText([
{ text: "📅 ", options: { fontSize: 22 } },
{ text: "Development Timeline", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
const timeline = [
{ date: "MAR 18 (Tue)", title: "Foundation Day", desc: "Call desk redesign, Jotai + AI SDK migration, seeder, AI flow test, VPS deploy" },
{ date: "MAR 19 (Wed)", title: "Data Layer Sprint", desc: "All data pages, post-call workflow, GraphQL fixes, Kookoo IVR, outbound UI" },
{ date: "MAR 20 (Thu)", title: "Telephony Breakthrough", desc: "Direct SIP replacing Kookoo, UCID tracking, Force Ready, Set Disposition" },
{ date: "MAR 21 (Fri)", title: "Agent Experience", desc: "Phase 1: status toggle, search, enquiry form, My Performance, FA icons, AHT" },
{ date: "MAR 23 (Sun)", title: "Scale & Reliability", desc: "Phase 2: missed call queue, auto-assign, Redis lockout, Patient 360, SDK defs" },
{ date: "MAR 24 (Mon)", title: "Supervisor Module", desc: "Team perf, live monitor, master data, SSE, UUID fix, maintenance, QA sweep" },
{ date: "MAR 25 (Tue)", title: "Intelligence Layer", desc: "Deepgram diarization, AI insights, SIP via Agent entity, token refresh, network" },
];
// Vertical line
slide.addShape("rect", {
x: 1.4, y: 1.3, w: 0.025, h: 4.0,
fill: { color: C.accent6, transparency: 60 },
});
timeline.forEach((entry, i) => {
const y = 1.3 + i * 0.56;
// Dot
slide.addShape("ellipse", {
x: 1.32, y: y + 0.08, w: 0.18, h: 0.18,
fill: { color: C.accent6 },
line: { color: C.bg, width: 2 },
});
// Date
slide.addText(entry.date, {
x: 1.7, y: y, w: 1.6, h: 0.22,
fontSize: 7, fontFace: FONT.heading, bold: true,
color: C.accent6,
});
// Title
slide.addText(entry.title, {
x: 3.3, y: y, w: 2.0, h: 0.22,
fontSize: 9, fontFace: FONT.heading, bold: true,
color: C.text,
});
// Description
slide.addText(entry.desc, {
x: 5.3, y: y, w: 4.2, h: 0.45,
fontSize: 8, fontFace: FONT.body,
color: C.textSec,
valign: "top",
});
});
addSlideNumber(slide, 8, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 9 — Closing
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent3);
// Big headline
slide.addText("78 commits. 8 days. Ship mode.", {
x: 0.5, y: 1.4, w: 9, h: 0.8,
fontSize: 32, fontFace: FONT.heading, bold: true,
color: C.accent3, align: "center",
});
// Ship emoji
slide.addText("🚢", {
x: 4.2, y: 2.3, w: 1.6, h: 0.6,
fontSize: 28, align: "center",
});
// Description
slide.addText(
"From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.",
{
x: 1.5, y: 3.0, w: 7, h: 0.6,
fontSize: 11, fontFace: FONT.body,
color: C.textSec, align: "center",
lineSpacingMultiple: 1.3,
}
);
// Achievement pills
const achievements = [
{ text: "SIP Calling ✓", color: C.accent1 },
{ text: "Multi-Agent ✓", color: C.accent2 },
{ text: "Supervisor ✓", color: C.accent3 },
{ text: "AI Copilot ✓", color: C.accent4 },
{ text: "Recording Analysis ✓", color: C.accent5 },
];
achievements.forEach((a, i) => {
const x = 0.8 + i * 1.8;
slide.addShape("roundRect", {
x, y: 3.9, w: 1.6, h: 0.35,
fill: { color: C.bgCard },
line: { color: a.color, width: 1 },
rectRadius: 0.17,
});
slide.addText(a.text, {
x, y: 3.9, w: 1.6, h: 0.35,
fontSize: 8, fontFace: FONT.heading, bold: true,
color: a.color, align: "center", valign: "middle",
});
});
// Author
slide.addText("Satya Suman Sari · FortyTwo Platform", {
x: 2, y: 4.7, w: 6, h: 0.3,
fontSize: 9, fontFace: FONT.body,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 9, TOTAL);
}
// ── Save ──────────────────────────────────────────────────────────────
const outPath = "weekly-update-mar18-25.pptx";
await pptx.writeFile({ fileName: outPath });
console.log(`✅ Presentation saved: ${outPath}`);
}
build().catch(err => {
console.error("❌ Failed:", err.message);
process.exit(1);
});

680
docs/generate-pptx.js Normal file
View File

@@ -0,0 +1,680 @@
/**
* Helix Engage — Weekly Update (Mar 1825, 2026)
* Light Mode PowerPoint Generator via PptxGenJS
*/
const PptxGenJS = require("pptxgenjs");
// ── Design Tokens (Light Mode) ─────────────────────────────────────────
const C = {
bg: "FFFFFF",
bgSubtle: "F8FAFC",
bgCard: "F1F5F9",
bgCardAlt: "E2E8F0",
text: "1E293B",
textSec: "475569",
textMuted: "94A3B8",
accent1: "0EA5E9", // Sky blue (telephony)
accent2: "8B5CF6", // Violet (server/backend)
accent3: "10B981", // Emerald (UX)
accent4: "F59E0B", // Amber (features)
accent5: "EF4444", // Rose (ops)
accent6: "6366F1", // Indigo (timeline)
white: "FFFFFF",
border: "CBD5E1",
};
const FONT = {
heading: "Arial",
body: "Arial",
};
// ── Helpers ──────────────────────────────────────────────────────────────
function addSlideNumber(slide, num, total) {
slide.addText(`${num} / ${total}`, {
x: 8.8, y: 5.2, w: 1.2, h: 0.3,
fontSize: 8, color: C.textMuted,
fontFace: FONT.body,
align: "right",
});
}
function addAccentBar(slide, color) {
slide.addShape("rect", {
x: 0, y: 0, w: 10, h: 0.06,
fill: { color },
});
}
function addLabel(slide, text, color, x, y) {
slide.addShape("roundRect", {
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
fill: { color, transparency: 88 },
rectRadius: 0.15,
});
slide.addText(text.toUpperCase(), {
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
fontSize: 7, fontFace: FONT.heading, bold: true,
color, align: "center", valign: "middle",
letterSpacing: 2,
});
}
function addCard(slide, opts) {
const { x, y, w, h, title, titleColor, items, badge } = opts;
// Card background
slide.addShape("roundRect", {
x, y, w, h,
fill: { color: C.bgCard },
line: { color: C.border, width: 0.5 },
rectRadius: 0.1,
});
// Title
const titleText = badge
? [{ text: title + " ", options: { bold: true, color: titleColor, fontSize: 11 } },
{ text: badge, options: { bold: true, color: C.white, fontSize: 7, highlight: titleColor } }]
: title;
slide.addText(titleText, {
x: x + 0.2, y: y + 0.08, w: w - 0.4, h: 0.35,
fontSize: 11, fontFace: FONT.heading, bold: true,
color: titleColor,
});
// Items as bullet list
if (items && items.length > 0) {
slide.addText(
items.map(item => ({
text: item,
options: {
fontSize: 8.5, fontFace: FONT.body, color: C.textSec,
bullet: { type: "bullet", style: "arabicPeriod" },
paraSpaceAfter: 2,
breakLine: true,
},
})),
{
x: x + 0.2, y: y + 0.4, w: w - 0.4, h: h - 0.5,
valign: "top",
bullet: { type: "bullet" },
lineSpacingMultiple: 1.1,
}
);
}
}
// ── Build Presentation ──────────────────────────────────────────────────
async function build() {
const pptx = new PptxGenJS();
pptx.layout = "LAYOUT_16x9";
pptx.author = "Satya Suman Sari";
pptx.company = "FortyTwo Platform";
pptx.title = "Helix Engage — Weekly Update (Mar 1825, 2026)";
pptx.subject = "Engineering Progress Report";
const TOTAL = 9;
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 1 — Title
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
// Accent bar top
addAccentBar(slide, C.accent1);
// Decorative side stripe
slide.addShape("rect", {
x: 0, y: 0, w: 0.12, h: 5.63,
fill: { color: C.accent1 },
});
// Label
addLabel(slide, "Weekly Engineering Update", C.accent1, 3.0, 1.2);
// Title
slide.addText("Helix Engage", {
x: 1.0, y: 1.8, w: 8, h: 1.2,
fontSize: 44, fontFace: FONT.heading, bold: true,
color: C.accent1, align: "center",
});
// Subtitle
slide.addText("Contact Center CRM · Real-time Telephony · AI Copilot", {
x: 1.5, y: 2.9, w: 7, h: 0.5,
fontSize: 14, fontFace: FONT.body,
color: C.textSec, align: "center",
});
// Date
slide.addText("March 18 25, 2026", {
x: 3, y: 3.6, w: 4, h: 0.4,
fontSize: 12, fontFace: FONT.heading, bold: true,
color: C.textMuted, align: "center",
letterSpacing: 3,
});
// Bottom decoration
slide.addShape("rect", {
x: 3.5, y: 4.2, w: 3, h: 0.04,
fill: { color: C.accent2 },
});
// Author
slide.addText("Satya Suman Sari · FortyTwo Platform", {
x: 2, y: 4.5, w: 6, h: 0.35,
fontSize: 9, fontFace: FONT.body,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 1, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 2 — At a Glance
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent2);
addLabel(slide, "At a Glance", C.accent2, 0.5, 0.3);
slide.addText("Week in Numbers", {
x: 0.5, y: 0.65, w: 5, h: 0.5,
fontSize: 24, fontFace: FONT.heading, bold: true,
color: C.text,
});
// Stat cards
const stats = [
{ value: "78", label: "Total Commits", color: C.accent1 },
{ value: "3", label: "Repositories", color: C.accent2 },
{ value: "8", label: "Days Active", color: C.accent3 },
{ value: "50", label: "Frontend Commits", color: C.accent4 },
];
stats.forEach((s, i) => {
const x = 0.5 + i * 2.35;
// Card bg
slide.addShape("roundRect", {
x, y: 1.3, w: 2.1, h: 1.7,
fill: { color: C.bgCard },
line: { color: C.border, width: 0.5 },
rectRadius: 0.12,
});
// Accent top line
slide.addShape("rect", {
x: x + 0.2, y: 1.35, w: 1.7, h: 0.035,
fill: { color: s.color },
});
// Number
slide.addText(s.value, {
x, y: 1.5, w: 2.1, h: 0.9,
fontSize: 36, fontFace: FONT.heading, bold: true,
color: s.color, align: "center", valign: "middle",
});
// Label
slide.addText(s.label, {
x, y: 2.4, w: 2.1, h: 0.4,
fontSize: 9, fontFace: FONT.body,
color: C.textSec, align: "center",
});
});
// Repo breakdown pills
const repos = [
{ name: "helix-engage", count: "50", clr: C.accent1 },
{ name: "helix-engage-server", count: "27", clr: C.accent2 },
{ name: "FortyTwoApps/SDK", count: "1", clr: C.accent3 },
];
repos.forEach((r, i) => {
const x = 1.5 + i * 2.8;
slide.addShape("roundRect", {
x, y: 3.4, w: 2.5, h: 0.4,
fill: { color: C.bgCard },
line: { color: r.clr, width: 1 },
rectRadius: 0.2,
});
slide.addText(`${r.name} ${r.count}`, {
x, y: 3.4, w: 2.5, h: 0.4,
fontSize: 9, fontFace: FONT.heading, bold: true,
color: r.clr, align: "center", valign: "middle",
});
});
// Summary text
slide.addText("3 repos · 7 working days · 78 commits shipped to production", {
x: 1, y: 4.2, w: 8, h: 0.35,
fontSize: 10, fontFace: FONT.body, italic: true,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 2, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 3 — Telephony & SIP
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent1);
addLabel(slide, "Core Infrastructure", C.accent1, 0.5, 0.3);
slide.addText([
{ text: "☎ ", options: { fontSize: 22 } },
{ text: "Telephony & SIP Overhaul", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Outbound Calling", titleColor: C.accent1, badge: "Frontend",
items: [
"Direct SIP call from browser — no Kookoo bridge",
"Immediate call card UI with auto-answer SIP bridge",
"End Call label fix, force active state after auto-answer",
"Reset outboundPending on call end",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Ozonetel Integration", titleColor: C.accent2, badge: "Server",
items: [
"Ozonetel V3 dial endpoint + webhook handler",
"Set Disposition API for ACW release",
"Force Ready endpoint for agent state mgmt",
"Token: 10-min cache, 401 invalidation, refresh on login",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "SIP & Agent State", titleColor: C.accent1, badge: "Frontend",
items: [
"SIP driven by Agent entity with token refresh",
"Centralised outbound dial into useSip().dialOutbound()",
"UCID tracking from SIP headers for disposition",
"Network indicator for connection health",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "Multi-Agent & Sessions", titleColor: C.accent2, badge: "Server",
items: [
"Multi-agent SIP with Redis session lockout",
"Strict duplicate login — one device per agent",
"Session lock stores IP + timestamp for debugging",
"SSE agent state broadcast for supervisor view",
],
});
addSlideNumber(slide, 3, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 4 — Call Desk & Agent UX
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent3);
addLabel(slide, "User Experience", C.accent3, 0.5, 0.3);
slide.addText([
{ text: "🖥 ", options: { fontSize: 22 } },
{ text: "Call Desk & Agent UX", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 3.05, h: 2.6,
title: "Call Desk Redesign", titleColor: C.accent3,
items: [
"2-panel layout with collapsible sidebar & inline AI",
"Collapsible context panel, worklist/calls tabs",
"Pinned header & chat input, numpad dialler",
"Ringtone support for incoming calls",
],
});
addCard(slide, {
x: 3.55, y: 1.35, w: 3.05, h: 2.6,
title: "Post-Call Workflow", titleColor: C.accent3,
items: [
"Disposition → appointment booking → follow-up",
"Disposition returns straight to worklist",
"Send disposition to sidecar with UCID for ACW",
"Enquiry in post-call, appointment skip button",
],
});
addCard(slide, {
x: 6.8, y: 1.35, w: 2.9, h: 2.6,
title: "UI Polish", titleColor: C.accent3,
items: [
"FontAwesome Pro Duotone icon migration",
"Tooltips, sticky headers, roles, search",
"Fix React error #520 in prod tables",
"AI scroll containment, brand tokens refresh",
],
});
addSlideNumber(slide, 4, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 5 — Features Shipped
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent4);
addLabel(slide, "Features Shipped", C.accent4, 0.5, 0.3);
slide.addText([
{ text: "🚀 ", options: { fontSize: 22 } },
{ text: "Major Features", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Supervisor Module", titleColor: C.accent4,
items: [
"Team performance analytics page",
"Live monitor with active calls visibility",
"Master data management pages",
"Server: team perf + active calls endpoints",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Missed Call Queue (Phase 2)", titleColor: C.accent4,
items: [
"Missed call queue ingestion & worklist",
"Auto-assignment engine for agents",
"Login redesign with role-based routing",
"Lead lookup for missed callers",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "Agent Features (Phase 1)", titleColor: C.accent4,
items: [
"Agent status toggle (Ready / Not Ready / Break)",
"Global search across patients, leads, calls",
"Enquiry form for new patient intake",
"My Performance page + logout modal",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "Recording Analysis", titleColor: C.accent4,
items: [
"Deepgram diarization + AI insights",
"Redis caching layer for analysis results",
"Full-stack: frontend player + server module",
],
});
addSlideNumber(slide, 5, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 6 — Backend & Data
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent2);
addLabel(slide, "Backend & Data", C.accent2, 0.5, 0.3);
slide.addText([
{ text: "⚙ ", options: { fontSize: 22 } },
{ text: "Backend & Data Layer", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Platform Data Wiring", titleColor: C.accent2,
items: [
"Migrated frontend to Jotai + Vercel AI SDK",
"Corrected all 7 GraphQL queries (fields, LINKS/PHONES)",
"Webhook handler for Ozonetel call records",
"Complete seeder: 5 doctors, appointments linked",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Server Endpoints", titleColor: C.accent2,
items: [
"Call control, recording, CDR, missed calls, live assist",
"Agent summary, AHT, performance aggregation",
"Token refresh endpoint for auto-renewal",
"Search module with full-text capabilities",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "Data Pages Built", titleColor: C.accent2,
items: [
"Worklist table, call history, patients, dashboard",
"Reports, team dashboard, campaigns, settings",
"Agent detail page, campaign edit slideout",
"Appointments page with data refresh on login",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "SDK App", titleColor: C.accent3, badge: "FortyTwoApps",
items: [
"Helix Engage SDK app entity definitions",
"Call center CRM object model for platform",
"Foundation for platform-native data integration",
],
});
addSlideNumber(slide, 6, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 7 — Deployment & Ops
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent5);
addLabel(slide, "Operations", C.accent5, 0.5, 0.3);
slide.addText([
{ text: "🛠 ", options: { fontSize: 22 } },
{ text: "Deployment & DevOps", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 3.05, h: 2.2,
title: "Deployment", titleColor: C.accent5,
items: [
"Deployed to Hostinger VPS with Docker",
"Switched to global_healthx Ozonetel account",
"Dockerfile for server-side containerization",
],
});
addCard(slide, {
x: 3.55, y: 1.35, w: 3.05, h: 2.2,
title: "AI & Testing", titleColor: C.accent5,
items: [
"Migrated AI to Vercel AI SDK + OpenAI provider",
"AI flow test script — validates full pipeline",
"Live call assist integration",
],
});
addCard(slide, {
x: 6.8, y: 1.35, w: 2.9, h: 2.2,
title: "Documentation", titleColor: C.accent5,
items: [
"Team onboarding README with arch guide",
"Supervisor module spec + plan",
"Multi-agent spec + plan",
"Next session plans in commits",
],
});
addSlideNumber(slide, 7, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 8 — Timeline
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent6);
addLabel(slide, "Day by Day", C.accent6, 0.5, 0.3);
slide.addText([
{ text: "📅 ", options: { fontSize: 22 } },
{ text: "Development Timeline", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
const timeline = [
{ date: "MAR 18 (Tue)", title: "Foundation Day", desc: "Call desk redesign, Jotai + AI SDK migration, seeder, AI flow test, VPS deploy" },
{ date: "MAR 19 (Wed)", title: "Data Layer Sprint", desc: "All data pages, post-call workflow, GraphQL fixes, Kookoo IVR, outbound UI" },
{ date: "MAR 20 (Thu)", title: "Telephony Breakthrough", desc: "Direct SIP replacing Kookoo, UCID tracking, Force Ready, Set Disposition" },
{ date: "MAR 21 (Fri)", title: "Agent Experience", desc: "Phase 1: status toggle, search, enquiry form, My Performance, FA icons, AHT" },
{ date: "MAR 23 (Sun)", title: "Scale & Reliability", desc: "Phase 2: missed call queue, auto-assign, Redis lockout, Patient 360, SDK defs" },
{ date: "MAR 24 (Mon)", title: "Supervisor Module", desc: "Team perf, live monitor, master data, SSE, UUID fix, maintenance, QA sweep" },
{ date: "MAR 25 (Tue)", title: "Intelligence Layer", desc: "Deepgram diarization, AI insights, SIP via Agent entity, token refresh, network" },
];
// Vertical line
slide.addShape("rect", {
x: 1.4, y: 1.3, w: 0.025, h: 4.0,
fill: { color: C.accent6, transparency: 60 },
});
timeline.forEach((entry, i) => {
const y = 1.3 + i * 0.56;
// Dot
slide.addShape("ellipse", {
x: 1.32, y: y + 0.08, w: 0.18, h: 0.18,
fill: { color: C.accent6 },
line: { color: C.bg, width: 2 },
});
// Date
slide.addText(entry.date, {
x: 1.7, y: y, w: 1.6, h: 0.22,
fontSize: 7, fontFace: FONT.heading, bold: true,
color: C.accent6,
});
// Title
slide.addText(entry.title, {
x: 3.3, y: y, w: 2.0, h: 0.22,
fontSize: 9, fontFace: FONT.heading, bold: true,
color: C.text,
});
// Description
slide.addText(entry.desc, {
x: 5.3, y: y, w: 4.2, h: 0.45,
fontSize: 8, fontFace: FONT.body,
color: C.textSec,
valign: "top",
});
});
addSlideNumber(slide, 8, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 9 — Closing
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent3);
// Big headline
slide.addText("78 commits. 8 days. Ship mode.", {
x: 0.5, y: 1.4, w: 9, h: 0.8,
fontSize: 32, fontFace: FONT.heading, bold: true,
color: C.accent3, align: "center",
});
// Ship emoji
slide.addText("🚢", {
x: 4.2, y: 2.3, w: 1.6, h: 0.6,
fontSize: 28, align: "center",
});
// Description
slide.addText(
"From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.",
{
x: 1.5, y: 3.0, w: 7, h: 0.6,
fontSize: 11, fontFace: FONT.body,
color: C.textSec, align: "center",
lineSpacingMultiple: 1.3,
}
);
// Achievement pills
const achievements = [
{ text: "SIP Calling ✓", color: C.accent1 },
{ text: "Multi-Agent ✓", color: C.accent2 },
{ text: "Supervisor ✓", color: C.accent3 },
{ text: "AI Copilot ✓", color: C.accent4 },
{ text: "Recording Analysis ✓", color: C.accent5 },
];
achievements.forEach((a, i) => {
const x = 0.8 + i * 1.8;
slide.addShape("roundRect", {
x, y: 3.9, w: 1.6, h: 0.35,
fill: { color: C.bgCard },
line: { color: a.color, width: 1 },
rectRadius: 0.17,
});
slide.addText(a.text, {
x, y: 3.9, w: 1.6, h: 0.35,
fontSize: 8, fontFace: FONT.heading, bold: true,
color: a.color, align: "center", valign: "middle",
});
});
// Author
slide.addText("Satya Suman Sari · FortyTwo Platform", {
x: 2, y: 4.7, w: 6, h: 0.3,
fontSize: 9, fontFace: FONT.body,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 9, TOTAL);
}
// ── Save ──────────────────────────────────────────────────────────────
const outPath = "weekly-update-mar18-25.pptx";
await pptx.writeFile({ fileName: outPath });
console.log(`✅ Presentation saved: ${outPath}`);
}
build().catch(err => {
console.error("❌ Failed:", err.message);
process.exit(1);
});

View File

@@ -0,0 +1,886 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Helix Engage — Weekly Update (Mar 1825, 2026)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ===========================================
CSS CUSTOM PROPERTIES (DARK EXECUTIVE THEME)
=========================================== */
:root {
--bg-primary: #0b0e17;
--bg-secondary: #111827;
--bg-card: rgba(255,255,255,0.04);
--bg-card-hover: rgba(255,255,255,0.07);
--text-primary: #f0f2f5;
--text-secondary: #8892a4;
--text-muted: #4b5563;
--accent-cyan: #22d3ee;
--accent-violet: #a78bfa;
--accent-emerald: #34d399;
--accent-amber: #fbbf24;
--accent-rose: #fb7185;
--accent-blue: #60a5fa;
--glow-cyan: rgba(34,211,238,0.15);
--glow-violet: rgba(167,139,250,0.15);
--glow-emerald: rgba(52,211,153,0.15);
--font-display: 'Space Grotesk', sans-serif;
--font-body: 'DM Sans', sans-serif;
--slide-padding: clamp(2rem, 6vw, 5rem);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--duration: 0.7s;
}
/* ===========================================
BASE RESET
=========================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-snap-type: y mandatory; }
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
/* ===========================================
SLIDE CONTAINER
=========================================== */
.slide {
min-height: 100vh;
padding: var(--slide-padding);
scroll-snap-align: start;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
overflow: hidden;
}
/* ===========================================
PROGRESS BAR
=========================================== */
.progress-bar {
position: fixed; top: 0; left: 0;
height: 3px; width: 0%;
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-violet));
z-index: 100;
transition: width 0.3s ease;
}
/* ===========================================
NAVIGATION DOTS
=========================================== */
.nav-dots {
position: fixed; right: 1.5rem; top: 50%;
transform: translateY(-50%);
display: flex; flex-direction: column; gap: 10px;
z-index: 100;
}
.nav-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--text-muted);
border: none; cursor: pointer;
transition: all 0.3s ease;
}
.nav-dot.active {
background: var(--accent-cyan);
box-shadow: 0 0 12px var(--glow-cyan);
transform: scale(1.3);
}
/* ===========================================
SLIDE COUNTER
=========================================== */
.slide-counter {
position: fixed; bottom: 1.5rem; right: 2rem;
font-family: var(--font-display);
font-size: 0.85rem;
color: var(--text-muted);
z-index: 100;
letter-spacing: 0.1em;
}
/* ===========================================
REVEAL ANIMATIONS
=========================================== */
.reveal {
opacity: 0;
transform: translateY(35px);
transition: opacity var(--duration) var(--ease-out-expo),
transform var(--duration) var(--ease-out-expo);
}
.slide.visible .reveal { opacity: 1; transform: translateY(0); }
.reveal:nth-child(1) { transition-delay: 0.08s; }
.reveal:nth-child(2) { transition-delay: 0.16s; }
.reveal:nth-child(3) { transition-delay: 0.24s; }
.reveal:nth-child(4) { transition-delay: 0.32s; }
.reveal:nth-child(5) { transition-delay: 0.40s; }
.reveal:nth-child(6) { transition-delay: 0.48s; }
.reveal:nth-child(7) { transition-delay: 0.56s; }
.reveal:nth-child(8) { transition-delay: 0.64s; }
@media (prefers-reduced-motion: reduce) {
.reveal { transition: opacity 0.3s ease; transform: none; }
}
/* ===========================================
TYPOGRAPHY
=========================================== */
h1 { font-family: var(--font-display); font-weight: 700; }
h2 { font-family: var(--font-display); font-weight: 600; font-size: clamp(1.6rem, 4vw, 2.5rem); margin-bottom: 0.5em; }
h3 { font-family: var(--font-display); font-weight: 500; font-size: 1.1rem; }
p, li { line-height: 1.65; }
.label {
display: inline-block;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.15em;
font-size: 0.7rem;
font-weight: 600;
padding: 0.3em 0.9em;
border-radius: 100px;
margin-bottom: 1rem;
}
/* ===========================================
TITLE SLIDE
=========================================== */
.title-slide {
text-align: center;
background:
radial-gradient(ellipse at 30% 70%, rgba(34,211,238,0.08) 0%, transparent 50%),
radial-gradient(ellipse at 70% 30%, rgba(167,139,250,0.08) 0%, transparent 50%),
var(--bg-primary);
}
.title-slide h1 {
font-size: clamp(2.5rem, 6vw, 4.5rem);
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-violet) 50%, var(--accent-emerald) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.15;
margin-bottom: 0.3em;
}
.title-slide .subtitle {
font-size: clamp(1rem, 2vw, 1.4rem);
color: var(--text-secondary);
margin-bottom: 0.4em;
}
.title-slide .date-range {
font-family: var(--font-display);
color: var(--text-muted);
font-size: 0.9rem;
letter-spacing: 0.08em;
}
/* ===========================================
STAT CARDS
=========================================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.2rem;
margin-top: 1.5rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
padding: 1.8rem 1.5rem;
text-align: center;
transition: all 0.4s var(--ease-out-expo);
position: relative;
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute; top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0;
transition: opacity 0.4s ease;
}
.stat-card:hover { background: var(--bg-card-hover); transform: translateY(-4px); }
.stat-card:hover::after { opacity: 1; }
.stat-number {
font-family: var(--font-display);
font-size: 3rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.3em;
}
.stat-number.cyan { color: var(--accent-cyan); }
.stat-number.violet { color: var(--accent-violet); }
.stat-number.emerald { color: var(--accent-emerald); }
.stat-number.amber { color: var(--accent-amber); }
.stat-label { color: var(--text-secondary); font-size: 0.85rem; }
/* ===========================================
CONTENT CARDS
=========================================== */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.2rem;
margin-top: 1.2rem;
}
.content-card {
background: var(--bg-card);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px;
padding: 1.5rem;
transition: all 0.3s var(--ease-out-expo);
}
.content-card:hover { background: var(--bg-card-hover); border-color: rgba(255,255,255,0.1); }
.content-card h3 { margin-bottom: 0.6rem; }
.content-card ul {
list-style: none; padding: 0;
}
.content-card li {
position: relative;
padding-left: 1.2em;
margin-bottom: 0.45em;
color: var(--text-secondary);
font-size: 0.9rem;
}
.content-card li::before {
content: '';
position: absolute;
left: 0;
color: var(--accent-cyan);
font-weight: 700;
}
/* ===========================================
TIMELINE
=========================================== */
.timeline {
position: relative;
padding-left: 2rem;
margin-top: 1.5rem;
}
.timeline::before {
content: '';
position: absolute; left: 0; top: 0; bottom: 0;
width: 2px;
background: linear-gradient(to bottom, var(--accent-cyan), var(--accent-violet), var(--accent-emerald));
opacity: 0.4;
}
.tl-item {
position: relative;
margin-bottom: 1.5rem;
padding-left: 1rem;
}
.tl-item::before {
content: '';
position: absolute; left: -2.35rem; top: 0.3em;
width: 10px; height: 10px;
border-radius: 50%;
background: var(--accent-cyan);
border: 2px solid var(--bg-primary);
}
.tl-date {
font-family: var(--font-display);
font-size: 0.75rem;
color: var(--accent-cyan);
letter-spacing: 0.08em;
margin-bottom: 0.15em;
}
.tl-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.15em;
}
.tl-desc {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* ===========================================
REPO BADGE
=========================================== */
.repo-badge {
display: inline-block;
font-family: var(--font-display);
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
letter-spacing: 0.05em;
margin-left: 0.5em;
vertical-align: middle;
}
.badge-frontend { background: rgba(34,211,238,0.15); color: var(--accent-cyan); }
.badge-server { background: rgba(167,139,250,0.15); color: var(--accent-violet); }
.badge-sdk { background: rgba(52,211,153,0.15); color: var(--accent-emerald); }
/* ===========================================
PILL LIST
=========================================== */
.pill-list {
display: flex; flex-wrap: wrap; gap: 0.5rem;
margin-top: 0.8rem;
}
.pill {
display: inline-block;
font-size: 0.78rem;
padding: 0.3em 0.9em;
border-radius: 100px;
background: var(--bg-card);
border: 1px solid rgba(255,255,255,0.08);
color: var(--text-secondary);
}
/* ===========================================
SECTION HEADER
=========================================== */
.section-header {
display: flex;
align-items: center;
gap: 0.7rem;
margin-bottom: 0.3rem;
}
.section-icon {
width: 36px; height: 36px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem;
}
/* ===========================================
KEYBOARD HINT
=========================================== */
.keyboard-hint {
position: fixed; bottom: 1.5rem; left: 2rem;
font-size: 0.75rem; color: var(--text-muted);
z-index: 100;
display: flex; align-items: center; gap: 0.5rem;
opacity: 0;
animation: hintFade 0.6s 2s forwards;
}
.key {
display: inline-block;
padding: 2px 8px;
border: 1px solid var(--text-muted);
border-radius: 4px;
font-family: var(--font-display);
font-size: 0.7rem;
}
@keyframes hintFade { to { opacity: 1; } }
/* ===========================================
CLOSING SLIDE
=========================================== */
.closing-slide {
text-align: center;
background:
radial-gradient(ellipse at 50% 50%, rgba(34,211,238,0.06) 0%, transparent 60%),
var(--bg-primary);
}
.closing-slide h2 {
font-size: clamp(1.8rem, 4vw, 3rem);
background: linear-gradient(135deg, var(--accent-emerald), var(--accent-cyan));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
/* ===========================================
RESPONSIVE
=========================================== */
@media (max-width: 768px) {
.nav-dots, .keyboard-hint { display: none; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.card-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Progress bar -->
<div class="progress-bar" id="progressBar"></div>
<!-- Navigation dots -->
<nav class="nav-dots" id="navDots"></nav>
<!-- Slide counter -->
<div class="slide-counter" id="slideCounter"></div>
<!-- Keyboard hint -->
<div class="keyboard-hint">
<span class="key"></span><span class="key"></span> or <span class="key">Space</span> to navigate
</div>
<!-- ======================================
SLIDE 1: TITLE
====================================== -->
<section class="slide title-slide">
<div class="reveal">
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Weekly Engineering Update</span>
</div>
<h1 class="reveal">Helix Engage</h1>
<p class="subtitle reveal">Contact Center CRM · Real-time Telephony · AI Copilot</p>
<p class="date-range reveal">March 18 25, 2026</p>
</section>
<!-- ======================================
SLIDE 2: AT A GLANCE
====================================== -->
<section class="slide">
<div class="reveal">
<span class="label" style="background: var(--glow-violet); color: var(--accent-violet);">At a Glance</span>
</div>
<h2 class="reveal">Week in Numbers</h2>
<div class="stats-grid">
<div class="stat-card reveal">
<div class="stat-number cyan" data-count="78">0</div>
<div class="stat-label">Total Commits</div>
</div>
<div class="stat-card reveal">
<div class="stat-number violet" data-count="3">0</div>
<div class="stat-label">Repositories</div>
</div>
<div class="stat-card reveal">
<div class="stat-number emerald" data-count="8">0</div>
<div class="stat-label">Days Active</div>
</div>
<div class="stat-card reveal">
<div class="stat-number amber" data-count="50">0</div>
<div class="stat-label">Frontend Commits</div>
</div>
</div>
<div class="pill-list reveal" style="margin-top:1.5rem; justify-content: center;">
<span class="pill" style="border-color: rgba(34,211,238,0.3); color: var(--accent-cyan);">helix-engage <b>50</b></span>
<span class="pill" style="border-color: rgba(167,139,250,0.3); color: var(--accent-violet);">helix-engage-server <b>27</b></span>
<span class="pill" style="border-color: rgba(52,211,153,0.3); color: var(--accent-emerald);">FortyTwoApps/SDK <b>1</b></span>
</div>
</section>
<!-- ======================================
SLIDE 3: TELEPHONY & SIP
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: var(--glow-cyan);">📞</div>
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Core Infrastructure</span>
</div>
</div>
<h2 class="reveal">Telephony & SIP Overhaul</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-cyan);">Outbound Calling <span class="repo-badge badge-frontend">Frontend</span></h3>
<ul>
<li>Direct SIP call from browser — no Kookoo bridge needed</li>
<li>Immediate call card UI with auto-answer SIP bridge</li>
<li>End Call label fix, force active state after auto-answer</li>
<li>Reset outboundPending on call end to prevent inbound poisoning</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Ozonetel Integration <span class="repo-badge badge-server">Server</span></h3>
<ul>
<li>Ozonetel V3 dial endpoint + webhook handler for call events</li>
<li>Kookoo IVR outbound bridging (deprecated → direct SIP)</li>
<li>Set Disposition API for ACW release</li>
<li>Force Ready endpoint for agent state management</li>
<li>Token: 10-min cache, 401 invalidation, refresh on login</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-cyan);">SIP & Agent State <span class="repo-badge badge-frontend">Frontend</span></h3>
<ul>
<li>SIP driven by Agent entity with token refresh</li>
<li>Dynamic SIP from agentConfig, logout cleanup, heartbeat</li>
<li>Centralised outbound dial into <code>useSip().dialOutbound()</code></li>
<li>UCID tracking from SIP headers for Ozonetel disposition</li>
<li>Network indicator for connection health</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Multi-Agent & Sessions <span class="repo-badge badge-server">Server</span></h3>
<ul>
<li>Multi-agent SIP with Redis session lockout</li>
<li>Strict duplicate login lockout — one device per agent</li>
<li>Session lock stores IP + timestamp for debugging</li>
<li>SSE agent state broadcast for real-time supervisor view</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 4: CALL DESK & UX
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: var(--glow-emerald);">🖥️</div>
<span class="label" style="background: var(--glow-emerald); color: var(--accent-emerald);">User Experience</span>
</div>
</div>
<h2 class="reveal">Call Desk & Agent UX</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-emerald);">Call Desk Redesign</h3>
<ul>
<li>2-panel layout with collapsible sidebar & inline AI</li>
<li>Collapsible context panel, worklist/calls tabs, phone numbers</li>
<li>Pinned header & chat input, numpad dialler</li>
<li>Ringtone support for incoming calls</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-emerald);">Post-Call Workflow</h3>
<ul>
<li>Disposition → appointment booking → follow-up creation</li>
<li>Disposition returns straight to worklist — no intermediate screens</li>
<li>Send disposition to sidecar with UCID for Ozonetel ACW</li>
<li>Enquiry in post-call, appointment skip button</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-emerald);">UI Polish</h3>
<ul>
<li>FontAwesome Pro Duotone icon migration (all icons)</li>
<li>Tooltips, sticky headers, roles, search, AI prompts</li>
<li>Fix React error #520 (isRowHeader) in production tables</li>
<li>AI scroll containment, brand tokens refresh</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 5: FEATURES SHIPPED
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: rgba(251,191,36,0.15);">🚀</div>
<span class="label" style="background: rgba(251,191,36,0.15); color: var(--accent-amber);">Features Shipped</span>
</div>
</div>
<h2 class="reveal">Major Features</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-amber);">Supervisor Module</h3>
<ul>
<li>Team performance analytics page</li>
<li>Live monitor with active calls visibility</li>
<li>Master data management pages</li>
<li>Server: team perf + active calls endpoints</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-amber);">Missed Call Queue (Phase 2)</h3>
<ul>
<li>Missed call queue ingestion & worklist</li>
<li>Auto-assignment engine for agents</li>
<li>Login redesign with role-based routing</li>
<li>Lead lookup for missed callers</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-amber);">Agent Features (Phase 1)</h3>
<ul>
<li>Agent status toggle (Ready / Not Ready / Break)</li>
<li>Global search across patients, leads, calls</li>
<li>Enquiry form for new patient intake</li>
<li>My Performance page + logout modal</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-amber);">Recording Analysis</h3>
<ul>
<li>Deepgram diarization + AI insights</li>
<li>Redis caching layer for analysis results</li>
<li>Full-stack: frontend player + server module</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 6: DATA & BACKEND
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: var(--glow-violet);">⚙️</div>
<span class="label" style="background: var(--glow-violet); color: var(--accent-violet);">Backend & Data</span>
</div>
</div>
<h2 class="reveal">Backend & Data Layer</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Platform Data Wiring</h3>
<ul>
<li>Migrated frontend to Jotai + Vercel AI SDK</li>
<li>Corrected all 7 GraphQL queries (field names, LINKS/PHONES)</li>
<li>Webhook handler for Ozonetel call records</li>
<li>Complete seeder: 5 doctors, appointments linked, agent names match</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Server Endpoints</h3>
<ul>
<li>Call control, recording, CDR, missed calls, live call assist</li>
<li>Agent summary, AHT, performance aggregation</li>
<li>Token refresh endpoint for auto-renewal</li>
<li>Search module with full-text capabilities</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Data Pages Built</h3>
<ul>
<li>Worklist table, call history, patients, dashboard</li>
<li>Reports, team dashboard, campaigns, settings</li>
<li>Agent detail page, campaign edit slideout</li>
<li>Appointments page with data refresh on login</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-emerald);">SDK App <span class="repo-badge badge-sdk">FortyTwoApps</span></h3>
<ul>
<li>Helix Engage SDK app entity definitions</li>
<li>Call center CRM object model for Fortytwo platform</li>
<li>Foundation for platform-native data integration</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 7: DEPLOYMENT & OPS
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: rgba(251,113,133,0.15);">🛠️</div>
<span class="label" style="background: rgba(251,113,133,0.15); color: var(--accent-rose);">Operations</span>
</div>
</div>
<h2 class="reveal">Deployment & DevOps</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-rose);">Deployment</h3>
<ul>
<li>Deployed to Hostinger VPS with Docker</li>
<li>Switched to global_healthx Ozonetel account</li>
<li>Dockerfile for server-side containerization</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-rose);">AI & Testing</h3>
<ul>
<li>Migrated AI to Vercel AI SDK + OpenAI provider</li>
<li>AI flow test script — validates auth, lead, patient, doctor, appointments</li>
<li>Live call assist integration</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-rose);">Documentation</h3>
<ul>
<li>Team onboarding README with architecture guide</li>
<li>Supervisor module spec + implementation plan</li>
<li>Multi-agent spec + plan</li>
<li>Next session plans documented in commits</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 8: TIMELINE
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: var(--glow-cyan);">📅</div>
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Day by Day</span>
</div>
</div>
<h2 class="reveal">Development Timeline</h2>
<div class="timeline reveal" style="max-height: 60vh; overflow-y: auto; padding-right: 1rem;">
<div class="tl-item">
<div class="tl-date">MAR 18 (Tue)</div>
<div class="tl-title">Foundation Day</div>
<div class="tl-desc">Call desk redesign, Jotai + Vercel AI SDK migration, seeder with 5 doctors + linked appointments, AI flow test script, deployed to VPS</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 19 (Wed)</div>
<div class="tl-title">Data Layer Sprint</div>
<div class="tl-desc">All data pages built (worklist, call history, patients, dashboard, reports), post-call workflow (disposition → booking), GraphQL fixes, Kookoo IVR outbound, outbound call UI</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 20 (Thu)</div>
<div class="tl-title">Telephony Breakthrough</div>
<div class="tl-desc">Direct SIP call from browser replacing Kookoo bridge, UCID tracking, Force Ready, Ozonetel Set Disposition, telephony overhaul</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 21 (Fri)</div>
<div class="tl-title">Agent Experience</div>
<div class="tl-desc">Phase 1 shipped — agent status toggle, global search, enquiry form, My Performance page, full FontAwesome icon migration, agent summary/AHT endpoints</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 23 (Sun)</div>
<div class="tl-title">Scale & Reliability</div>
<div class="tl-desc">Phase 2 — missed call queue + auto-assignment, multi-agent SIP with Redis lockout, duplicate login prevention, Patient 360 rewrite, onboarding docs, SDK entity defs</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 24 (Mon)</div>
<div class="tl-title">Supervisor Module</div>
<div class="tl-desc">Supervisor module with team performance + live monitor + master data, SSE agent state, UUID fix, maintenance module, QA bug sweep, supervisor endpoints</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 25 (Tue)</div>
<div class="tl-title">Intelligence Layer</div>
<div class="tl-desc">Call recording analysis with Deepgram diarization + AI insights, SIP driven by Agent entity, token refresh, network indicator</div>
</div>
</div>
</section>
<!-- ======================================
SLIDE 9: CLOSING
====================================== -->
<section class="slide closing-slide">
<h2 class="reveal">78 commits. 8 days. Ship mode.&nbsp;🚢</h2>
<p class="reveal" style="color: var(--text-secondary); margin-top: 0.6em; font-size: 1.1rem; max-width: 600px; margin-inline: auto;">
From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.
</p>
<div class="pill-list reveal" style="justify-content: center; margin-top: 1.5rem;">
<span class="pill" style="border-color: rgba(34,211,238,0.3); color: var(--accent-cyan);">SIP Calling ✓</span>
<span class="pill" style="border-color: rgba(167,139,250,0.3); color: var(--accent-violet);">Multi-Agent ✓</span>
<span class="pill" style="border-color: rgba(52,211,153,0.3); color: var(--accent-emerald);">Supervisor Module ✓</span>
<span class="pill" style="border-color: rgba(251,191,36,0.3); color: var(--accent-amber);">AI Copilot ✓</span>
<span class="pill" style="border-color: rgba(251,113,133,0.3); color: var(--accent-rose);">Recording Analysis ✓</span>
</div>
<p class="reveal" style="color: var(--text-muted); margin-top: 2rem; font-size: 0.8rem;">Satya Suman Sari · FortyTwo Platform</p>
</section>
<!-- ===========================================
SLIDE PRESENTATION CONTROLLER
=========================================== -->
<script>
class SlidePresentation {
constructor() {
this.slides = document.querySelectorAll('.slide');
this.progressBar = document.getElementById('progressBar');
this.navDots = document.getElementById('navDots');
this.slideCounter = document.getElementById('slideCounter');
this.currentSlide = 0;
this.createNavDots();
this.setupObserver();
this.setupKeyboard();
this.setupTouch();
this.animateCounters();
this.updateCounter();
}
/* --- Navigation dots --- */
createNavDots() {
this.slides.forEach((_, i) => {
const dot = document.createElement('button');
dot.classList.add('nav-dot');
dot.setAttribute('aria-label', `Go to slide ${i + 1}`);
dot.addEventListener('click', () => this.goToSlide(i));
this.navDots.appendChild(dot);
});
}
/* --- Intersection Observer for reveal animations --- */
setupObserver() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
const idx = Array.from(this.slides).indexOf(entry.target);
if (idx !== -1) {
this.currentSlide = idx;
this.updateProgress();
this.updateDots();
this.updateCounter();
if (idx === 1) this.animateCounters();
}
}
});
}, { threshold: 0.45 });
this.slides.forEach(slide => observer.observe(slide));
}
/* --- Keyboard navigation --- */
setupKeyboard() {
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'ArrowRight') {
e.preventDefault();
this.goToSlide(Math.min(this.currentSlide + 1, this.slides.length - 1));
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault();
this.goToSlide(Math.max(this.currentSlide - 1, 0));
}
});
}
/* --- Touch swipe support --- */
setupTouch() {
let startY = 0;
document.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY; });
document.addEventListener('touchend', (e) => {
const dy = startY - e.changedTouches[0].clientY;
if (Math.abs(dy) > 50) {
if (dy > 0) this.goToSlide(Math.min(this.currentSlide + 1, this.slides.length - 1));
else this.goToSlide(Math.max(this.currentSlide - 1, 0));
}
});
}
goToSlide(idx) {
this.slides[idx].scrollIntoView({ behavior: 'smooth' });
}
updateProgress() {
const pct = ((this.currentSlide) / (this.slides.length - 1)) * 100;
this.progressBar.style.width = pct + '%';
}
updateDots() {
this.navDots.querySelectorAll('.nav-dot').forEach((dot, i) => {
dot.classList.toggle('active', i === this.currentSlide);
});
}
updateCounter() {
this.slideCounter.textContent = `${this.currentSlide + 1} / ${this.slides.length}`;
}
/* --- Animate counter numbers --- */
animateCounters() {
document.querySelectorAll('[data-count]').forEach(el => {
const target = parseInt(el.dataset.count);
const duration = 1200;
const start = performance.now();
const animate = (now) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(eased * target);
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
});
}
}
// Initialize
new SlidePresentation();
</script>
</body>
</html>

Binary file not shown.

View File

@@ -0,0 +1,14 @@
import { cx } from "@/utils/cx";
interface AvatarCountProps {
count: number;
className?: string;
}
export const AvatarCount = ({ count, className }: AvatarCountProps) => (
<div className={cx("absolute right-0 bottom-0 p-px", className)}>
<div className="flex size-3.5 items-center justify-center rounded-full bg-fg-error-primary text-center text-[10px] leading-[13px] font-bold text-white">
{count}
</div>
</div>
);

View File

@@ -0,0 +1,49 @@
import type { FC, ReactNode } from "react";
import { createContext } from "react";
export type SelectItemType = {
/** Unique identifier for the item. */
id: string | number;
/** The primary display text. */
label?: string;
/** Avatar image URL. */
avatarUrl?: string;
/** Whether the item is disabled. */
isDisabled?: boolean;
/** Secondary text displayed alongside the label. */
supportingText?: string;
/** Leading icon component or element. */
icon?: FC | ReactNode;
};
export interface CommonProps {
/** Helper text displayed below the input. */
hint?: string;
/** Field label displayed above the input. */
label?: string;
/** Tooltip text for the help icon next to the label. */
tooltip?: string;
/**
* The size of the component.
* @default "md"
*/
size?: "sm" | "md" | "lg";
/** Placeholder text when no value is selected. */
placeholder?: string;
/** Whether to hide the required indicator from the label. */
hideRequiredIndicator?: boolean;
}
export const sizes = {
sm: {
root: "py-2 pl-3 pr-2.5 gap-2 *:data-icon:size-4 *:data-icon:stroke-[2.25px]",
withIcon: "",
text: "text-sm",
textContainer: "gap-x-1.5",
shortcut: "pr-2.5",
},
md: { root: "py-2 px-3 gap-2 *:data-icon:size-5", withIcon: "", text: "text-md", textContainer: "gap-x-1.5", shortcut: "pr-2.5" },
lg: { root: "py-2.5 px-3.5 gap-2 *:data-icon:size-5", withIcon: "", text: "text-md", textContainer: "gap-x-1.5", shortcut: "pr-3" },
};
export const SelectContext = createContext<{ size: "sm" | "md" | "lg" }>({ size: "md" });

View File

@@ -1,18 +1,15 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faUser, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons';
import { faSparkles, faCalendarCheck, faPhone, faUser } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { AiChatPanel } from './ai-chat-panel';
import { Badge } from '@/components/base/badges/badges';
import { apiClient } from '@/lib/api-client';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead, LeadActivity } from '@/types/entities';
const CalendarCheck = faIcon(faCalendarCheck);
type ContextTab = 'ai' | 'lead360';
interface ContextPanelProps {
selectedLead: Lead | null;
activities: LeadActivity[];
@@ -21,74 +18,13 @@ interface ContextPanelProps {
callUcid?: string | null;
}
export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => {
const [activeTab, setActiveTab] = useState<ContextTab>('ai');
// Auto-switch to lead 360 when a lead is selected
useEffect(() => {
if (selectedLead) {
setActiveTab('lead360');
}
}, [selectedLead?.id]);
const callerContext = selectedLead ? {
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
leadId: selectedLead.id,
leadName: `${selectedLead.contactName?.firstName ?? ''} ${selectedLead.contactName?.lastName ?? ''}`.trim(),
} : callerPhone ? { callerPhone } : undefined;
return (
<div className="flex h-full flex-col">
{/* Tab bar */}
<div className="flex shrink-0 border-b border-secondary">
<button
onClick={() => setActiveTab('ai')}
className={cx(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
activeTab === 'ai'
? "border-b-2 border-brand text-brand-secondary"
: "text-tertiary hover:text-secondary",
)}
>
<FontAwesomeIcon icon={faSparkles} className="size-3.5" />
AI Assistant
</button>
<button
onClick={() => setActiveTab('lead360')}
className={cx(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
activeTab === 'lead360'
? "border-b-2 border-brand text-brand-secondary"
: "text-tertiary hover:text-secondary",
)}
>
<FontAwesomeIcon icon={faUser} className="size-3.5" />
{(selectedLead as any)?.patientId ? 'Patient 360' : 'Lead 360'}
</button>
</div>
{/* Tab content */}
{activeTab === 'ai' && (
<div className="flex flex-1 flex-col overflow-hidden p-4">
<AiChatPanel callerContext={callerContext} />
</div>
)}
{activeTab === 'lead360' && (
<div className="flex-1 overflow-y-auto">
<Lead360Tab lead={selectedLead} activities={activities} />
</div>
)}
</div>
);
};
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall }: ContextPanelProps) => {
const [patientData, setPatientData] = useState<any>(null);
const [loadingPatient, setLoadingPatient] = useState(false);
// Fetch patient data when lead has a patientId (returning patient)
// Fetch patient data when lead has a patientId
useEffect(() => {
const patientId = (lead as any)?.patientId;
const patientId = (selectedLead as any)?.patientId;
if (!patientId) {
setPatientData(null);
return;
@@ -97,10 +33,10 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
setLoadingPatient(true);
apiClient.graphql<{ patients: { edges: Array<{ node: any }> } }>(
`query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node {
id fullName { firstName lastName } dateOfBirth gender patientType
id fullName { firstName lastName } dateOfBirth gender
phones { primaryPhoneNumber } emails { primaryEmail }
appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt status doctorName department reasonForVisit appointmentType
id scheduledAt status doctorName department reasonForVisit
} } }
calls(first: 5, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callStatus disposition direction startedAt durationSec agentName
@@ -112,121 +48,64 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
setPatientData(data.patients.edges[0]?.node ?? null);
}).catch(() => setPatientData(null))
.finally(() => setLoadingPatient(false));
}, [(lead as any)?.patientId]);
}, [(selectedLead as any)?.patientId]);
if (!lead) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<FontAwesomeIcon icon={faUser} className="mb-3 size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">Select a lead from the worklist to see their full profile.</p>
</div>
);
}
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const phone = lead.contactPhone?.[0];
const email = lead.contactEmail?.[0]?.address;
const lead = selectedLead;
const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim();
const phone = lead?.contactPhone?.[0];
const email = lead?.contactEmail?.[0]?.address;
const leadActivities = activities
.filter((a) => a.leadId === lead.id)
.filter((a) => lead && a.leadId === lead.id)
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
.slice(0, 10);
const isReturning = !!patientData;
const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? [];
const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? [];
const patientAge = patientData?.dateOfBirth
? Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null;
const patientGender = patientData?.gender === 'MALE' ? 'M' : patientData?.gender === 'FEMALE' ? 'F' : null;
const callerContext = lead ? {
callerPhone: phone?.number ?? callerPhone,
leadId: lead.id,
leadName: fullName,
} : callerPhone ? { callerPhone } : undefined;
return (
<div className="p-4 space-y-4">
{/* Profile */}
<div className="flex h-full flex-col">
{/* Context header — shows caller/lead info when available */}
{lead && (
<div className="shrink-0 border-b border-secondary p-4 space-y-3">
{/* Call status banner */}
{isInCall && (
<div className="flex items-center gap-2 rounded-lg bg-success-primary px-3 py-2">
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary" />
<span className="text-xs font-semibold text-success-primary">
On call with {fullName || callerPhone || 'Unknown'}
</span>
</div>
)}
{/* Lead profile */}
<div>
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
<h3 className="text-lg font-bold text-primary">{fullName || 'Unknown'}</h3>
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
{email && <p className="text-xs text-tertiary">{email}</p>}
<div className="mt-2 flex flex-wrap gap-1.5">
{isReturning && (
</div>
{/* Status badges */}
<div className="flex flex-wrap gap-1.5">
{!!patientData && (
<Badge size="sm" color="brand" type="pill-color">Returning Patient</Badge>
)}
{patientAge !== null && patientGender && (
<Badge size="sm" color="gray" type="pill-color">{patientAge}y · {patientGender}</Badge>
)}
{lead.leadStatus && <Badge size="sm" color="brand">{lead.leadStatus}</Badge>}
{lead.leadSource && <Badge size="sm" color="gray">{lead.leadSource}</Badge>}
{lead.priority && lead.priority !== 'NORMAL' && (
<Badge size="sm" color={lead.priority === 'URGENT' ? 'error' : 'warning'}>{lead.priority}</Badge>
)}
{lead.leadStatus && <Badge size="sm" color="brand">{lead.leadStatus.replace(/_/g, ' ')}</Badge>}
{lead.leadSource && <Badge size="sm" color="gray">{lead.leadSource.replace(/_/g, ' ')}</Badge>}
</div>
{lead.interestedService && (
<p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>
)}
</div>
{/* Returning patient: Appointments */}
{loadingPatient && (
<p className="text-xs text-tertiary">Loading patient details...</p>
)}
{isReturning && appointments.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Appointments</h4>
<div className="space-y-2">
{appointments.map((appt: any) => {
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning',
};
return (
<div key={appt.id} className="flex items-start gap-2 rounded-lg bg-secondary p-2">
<CalendarCheck className="mt-0.5 size-3.5 text-fg-brand-primary shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-primary">
{appt.doctorName ?? 'Doctor'} · {appt.department ?? ''}
</span>
{appt.status && (
<Badge size="sm" color={statusColors[appt.status] ?? 'gray'}>
{appt.status.toLowerCase()}
</Badge>
)}
</div>
<p className="text-[10px] text-quaternary">
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''}
{appt.reasonForVisit ? `${appt.reasonForVisit}` : ''}
</p>
</div>
</div>
);
})}
</div>
</div>
<p className="text-xs text-secondary">Interested in: {lead.interestedService}</p>
)}
{/* Returning patient: Recent calls */}
{isReturning && patientCalls.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Recent Calls</h4>
<div className="space-y-1">
{patientCalls.map((call: any) => (
<div key={call.id} className="flex items-center gap-2 text-xs">
<div className="mt-0.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
<span className="text-primary">
{call.direction === 'INBOUND' ? 'Inbound' : 'Outbound'}
{call.disposition ? `${call.disposition.replace(/_/g, ' ').toLowerCase()}` : ''}
</span>
<span className="text-quaternary ml-auto">{call.startedAt ? formatShortDate(call.startedAt) : ''}</span>
</div>
))}
</div>
</div>
)}
{/* AI Insight */}
{/* AI Insight — live from platform */}
{(lead.aiSummary || lead.aiSuggestedAction) && (
<div className="rounded-lg bg-brand-primary p-3">
<div className="mb-1 flex items-center gap-1.5">
@@ -240,18 +119,42 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
</div>
)}
{/* Activity timeline */}
{/* Upcoming appointments */}
{appointments.length > 0 && (
<div>
<span className="text-[10px] font-bold text-tertiary uppercase">Appointments</span>
<div className="mt-1 space-y-1">
{appointments.slice(0, 3).map((appt: any) => (
<div key={appt.id} className="flex items-center gap-2 rounded-md bg-secondary px-2 py-1.5">
<CalendarCheck className="size-3 text-fg-brand-primary shrink-0" />
<span className="text-xs text-primary truncate">
{appt.doctorName ?? 'Doctor'} · {appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''}
</span>
{appt.status && (
<Badge size="sm" color={appt.status === 'COMPLETED' ? 'success' : appt.status === 'CANCELLED' ? 'error' : 'brand'}>
{appt.status.toLowerCase()}
</Badge>
)}
</div>
))}
</div>
</div>
)}
{loadingPatient && <p className="text-[10px] text-quaternary">Loading patient details...</p>}
{/* Recent activity */}
{leadActivities.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Activity</h4>
<div className="space-y-2">
{leadActivities.map((a) => (
<span className="text-[10px] font-bold text-tertiary uppercase">Recent Activity</span>
<div className="mt-1 space-y-1">
{leadActivities.slice(0, 5).map((a) => (
<div key={a.id} className="flex items-start gap-2">
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
<div className="min-w-0 flex-1">
<p className="text-xs text-primary">{a.summary}</p>
<p className="text-xs text-primary truncate">{a.summary}</p>
<p className="text-[10px] text-quaternary">
{a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
{a.activityType?.replace(/_/g, ' ')}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
</p>
</div>
</div>
@@ -260,5 +163,22 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
</div>
)}
</div>
)}
{/* No lead selected — empty state */}
{!lead && (
<div className="shrink-0 flex items-center justify-center border-b border-secondary px-4 py-6">
<div className="text-center">
<FontAwesomeIcon icon={faUser} className="mb-2 size-6 text-fg-quaternary" />
<p className="text-xs text-tertiary">Select a lead from the worklist to see context</p>
</div>
</div>
)}
{/* AI Chat — always available at the bottom */}
<div className="flex flex-1 flex-col overflow-hidden">
<AiChatPanel callerContext={callerContext} />
</div>
</div>
);
};