feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
+100 -21
View File
@@ -1,16 +1,33 @@
"use client";
import { useMemo } from "react";
import { clsx } from "clsx";
interface AssistantInsightMetric {
label: string;
value: string;
tone?: "neutral" | "good" | "warn" | "danger" | "info";
}
interface AssistantInsightSection {
title: string;
metrics: AssistantInsightMetric[];
}
interface AssistantInsight {
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
title: string;
subtitle?: string;
metrics: AssistantInsightMetric[];
sections?: AssistantInsightSection[];
}
interface ChatMessageProps {
role: "user" | "assistant";
content: string;
insights?: AssistantInsight[];
}
/**
* Lightweight inline markdown renderer — handles bold, italic, code,
* bullet lists, and numbered lists without a full markdown library.
*/
function renderMarkdown(text: string) {
const lines = text.split("\n");
const elements: React.ReactNode[] = [];
@@ -21,7 +38,7 @@ function renderMarkdown(text: string) {
if (listItems.length > 0 && listType) {
const Tag = listType;
elements.push(
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "list-disc pl-4 my-1 space-y-0.5" : "list-decimal pl-4 my-1 space-y-0.5"}>
<Tag key={`list-${elements.length}`} className={listType === "ul" ? "my-1 list-disc space-y-0.5 pl-4" : "my-1 list-decimal space-y-0.5 pl-4"}>
{listItems}
</Tag>,
);
@@ -31,7 +48,6 @@ function renderMarkdown(text: string) {
};
for (const [i, line] of lines.entries()) {
// Bullet list: "- item" or "* item"
const bulletMatch = line.match(/^[\s]*[-*]\s+(.*)/);
if (bulletMatch?.[1]) {
if (listType !== "ul") flushList();
@@ -40,7 +56,6 @@ function renderMarkdown(text: string) {
continue;
}
// Numbered list: "1. item"
const numMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
if (numMatch?.[1]) {
if (listType !== "ol") flushList();
@@ -49,54 +64,46 @@ function renderMarkdown(text: string) {
continue;
}
// Not a list item — flush any pending list
flushList();
// Empty line → spacing
if (line.trim() === "") {
elements.push(<div key={`br-${i}`} className="h-2" />);
continue;
}
// Regular paragraph
elements.push(<p key={`p-${i}`} className="my-0">{inlineFormat(line)}</p>);
}
flushList();
return elements;
}
/** Parse inline formatting: **bold**, *italic*, `code` */
function inlineFormat(text: string): React.ReactNode {
// Split by inline patterns, preserving delimiters
const parts: React.ReactNode[] = [];
// Regex: **bold**, *italic*, `code`
const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
// Text before this match
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
if (match[2]) {
// **bold**
parts.push(<strong key={`b-${match.index}`} className="font-semibold">{match[2]}</strong>);
} else if (match[3]) {
// *italic*
parts.push(<em key={`i-${match.index}`}>{match[3]}</em>);
} else if (match[4]) {
// `code`
parts.push(
<code key={`c-${match.index}`} className="rounded bg-black/10 px-1 py-0.5 text-xs font-mono dark:bg-white/10">
{match[4]}
</code>,
);
}
lastIndex = match.index + match[0].length;
}
// Remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
@@ -104,7 +111,72 @@ function inlineFormat(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function ChatMessage({ role, content }: ChatMessageProps) {
function metricToneClasses(tone: AssistantInsightMetric["tone"] | undefined): string {
switch (tone) {
case "good":
return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-300";
case "warn":
return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-300";
case "danger":
return "border-red-200 bg-red-50 text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300";
case "info":
return "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/30 dark:text-sky-300";
default:
return "border-gray-200 bg-white text-gray-700 dark:border-slate-700 dark:bg-slate-900/60 dark:text-gray-200";
}
}
function InsightMetric({ metric }: { metric: AssistantInsightMetric }) {
return (
<div className={clsx("rounded-xl border px-2.5 py-2", metricToneClasses(metric.tone))}>
<div className="text-[10px] font-medium uppercase tracking-[0.08em] opacity-70">{metric.label}</div>
<div className="mt-1 text-sm font-semibold leading-tight">{metric.value}</div>
</div>
);
}
function InsightCard({ insight }: { insight: AssistantInsight }) {
return (
<div className="rounded-2xl border border-slate-200 bg-white/90 p-3 shadow-sm dark:border-slate-700 dark:bg-slate-900/85">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{insight.title}</div>
{insight.subtitle && (
<div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{insight.subtitle}</div>
)}
</div>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.08em] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
{insight.kind.replace("_", " ")}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{insight.metrics.map((metric, index) => (
<InsightMetric key={`${insight.kind}-${metric.label}-${index}`} metric={metric} />
))}
</div>
{insight.sections && insight.sections.length > 0 && (
<div className="mt-3 space-y-2">
{insight.sections.map((section, sectionIndex) => (
<div key={`${insight.kind}-${section.title}-${sectionIndex}`} className="rounded-xl border border-dashed border-slate-200 bg-slate-50/70 p-2.5 dark:border-slate-700 dark:bg-slate-800/60">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500 dark:text-slate-400">
{section.title}
</div>
<div className="grid grid-cols-2 gap-2">
{section.metrics.map((metric, metricIndex) => (
<InsightMetric key={`${section.title}-${metric.label}-${metricIndex}`} metric={metric} />
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
export function ChatMessage({ role, content, insights }: ChatMessageProps) {
const isUser = role === "user";
const rendered = useMemo(() => (isUser ? null : renderMarkdown(content)), [isUser, content]);
@@ -121,12 +193,19 @@ export function ChatMessage({ role, content }: ChatMessageProps) {
<span className="whitespace-pre-wrap break-words">{content}</span>
) : (
<>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300 mb-1.5">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span className="mb-1.5 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-medium text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
AI Generated
</span>
{insights && insights.length > 0 && (
<div className="mb-2 space-y-2">
{insights.map((insight, index) => (
<InsightCard key={`${insight.kind}-${insight.title}-${index}`} insight={insight} />
))}
</div>
)}
<div className="space-y-0.5 break-words">{rendered}</div>
</>
)}
@@ -38,6 +38,26 @@ function resolvePageContext(pathname: string): string {
interface Message {
role: "user" | "assistant";
content: string;
insights?: AssistantInsight[];
}
interface AssistantInsightMetric {
label: string;
value: string;
tone?: "neutral" | "good" | "warn" | "danger" | "info";
}
interface AssistantInsightSection {
title: string;
metrics: AssistantInsightMetric[];
}
interface AssistantInsight {
kind: "chargeability" | "resource_match" | "holiday_region" | "resource_holidays";
title: string;
subtitle?: string;
metrics: AssistantInsightMetric[];
sections?: AssistantInsightSection[];
}
const STORAGE_KEY = "capakraken-chat-messages";
@@ -47,7 +67,23 @@ function loadPersistedMessages(): Message[] {
if (typeof window === "undefined") return [];
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw) as Message[];
if (raw) {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return parsed
.filter((item): item is Partial<Message> & { role: Message["role"]; content: string } => (
typeof item === "object"
&& item !== null
&& (item.role === "user" || item.role === "assistant")
&& typeof item.content === "string"
))
.map((item) => ({
role: item.role,
content: item.content,
...(Array.isArray(item.insights) ? { insights: item.insights as AssistantInsight[] } : {}),
}));
}
}
} catch { /* ignore corrupt data */ }
return [];
}
@@ -101,10 +137,23 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
messages: updated.slice(-40).map((m) => ({ role: m.role, content: m.content })),
...(pathname ? { pageContext: resolvePageContext(pathname) } : {}),
});
setMessages((prev) => [...prev, { role: "assistant", content: reply.content }]);
const typedReply = reply as {
content: string;
role: "assistant";
actions?: Array<{ type: string; url?: string; scope?: string[] }>;
insights?: AssistantInsight[];
};
setMessages((prev) => [
...prev,
{
role: "assistant",
content: typedReply.content,
...(Array.isArray(typedReply.insights) && typedReply.insights.length > 0 ? { insights: typedReply.insights } : {}),
},
]);
// Handle actions from the AI (navigation, data invalidation)
const actions = (reply as { actions?: Array<{ type: string; url?: string; scope?: string[] }> }).actions;
const actions = typedReply.actions;
if (actions) {
for (const action of actions) {
if (action.type === "navigate" && action.url) {
@@ -230,7 +279,12 @@ export function ChatPanel({ onClose }: { onClose: () => void }) {
</div>
)}
{messages.map((msg, i) => (
<ChatMessage key={i} role={msg.role} content={msg.content} />
<ChatMessage
key={i}
role={msg.role}
content={msg.content}
{...(msg.insights ? { insights: msg.insights } : {})}
/>
))}
{isLoading && <TypingIndicator />}
{error && (