diff --git a/web/src/app/planner/page.tsx b/web/src/app/planner/page.tsx new file mode 100644 index 0000000..79070eb --- /dev/null +++ b/web/src/app/planner/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { ArrowLeft, CalendarDays, Plus, Trash2, Sparkles, Check, Clock, AlertTriangle } from 'lucide-react'; +import { planDay, applyPlan, type PlanDayResponse, type ProposedTimer } from '@/lib/planner-client'; +import { isEnabled } from '@/lib/feature-flags'; +import { trackEvent } from '@/lib/telemetry'; + +interface ActivityInput { + label: string; + durationMinutes: number; + priority: 'high' | 'medium' | 'low'; + category: string; +} + +function todayStr(): string { + return new Date().toISOString().split('T')[0]; +} + +export default function PlannerPage() { + const [date, setDate] = useState(todayStr()); + const [activities, setActivities] = useState([ + { label: '', durationMinutes: 30, priority: 'medium', category: '' }, + ]); + const [plan, setPlan] = useState(null); + const [loading, setLoading] = useState(false); + const [applied, setApplied] = useState(false); + const [error, setError] = useState(null); + + const enabled = isEnabled('day_planner.enabled'); + + const addActivity = () => { + setActivities([...activities, { label: '', durationMinutes: 30, priority: 'medium', category: '' }]); + }; + + const removeActivity = (idx: number) => { + setActivities(activities.filter((_, i) => i !== idx)); + }; + + const updateActivity = (idx: number, field: keyof ActivityInput, value: string | number) => { + const updated = [...activities]; + updated[idx] = { ...updated[idx], [field]: value }; + setActivities(updated); + }; + + const handlePlan = async () => { + const valid = activities.filter(a => a.label.trim()); + if (valid.length === 0) { setError('Add at least one activity'); return; } + setLoading(true); + setError(null); + setPlan(null); + setApplied(false); + try { + const result = await planDay({ + date, + activities: valid.map(a => ({ + label: a.label.trim(), + durationMinutes: a.durationMinutes, + priority: a.priority, + category: a.category || undefined, + })), + }); + setPlan(result); + trackEvent('info', 'planner', 'plan_generated'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to generate plan'); + } + setLoading(false); + }; + + const handleApply = async () => { + if (!plan) return; + setLoading(true); + try { + await applyPlan(plan.planId, plan.proposed); + setApplied(true); + trackEvent('info', 'planner', 'plan_applied'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to apply plan'); + } + setLoading(false); + }; + + if (!enabled) { + return ( +
+
+ +

Day Planner is not enabled. Enable the day_planner.enabled flag.

+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + + +
+

Day Planner

+

AI-powered schedule generation

+
+
+ + {/* Date picker */} +
+ + setDate(e.target.value)} + className="px-3 py-2 rounded-lg text-sm w-full" + style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }} + /> +
+ + {/* Activity list */} +
+
+

Activities

+ +
+ + {activities.map((act, idx) => ( +
+
+ updateActivity(idx, 'label', e.target.value)} + placeholder="Activity name" + className="flex-1 px-2 py-1.5 rounded text-sm" + style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }} + /> + {activities.length > 1 && ( + + )} +
+
+
+ + +
+
+ + +
+
+
+ ))} +
+ + {/* Generate button */} + + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Plan result */} + {plan && ( +
+

+ Proposed Schedule +

+ +
+ {plan.proposed.map((p, idx) => ( +
+ +
+ {p.label} +

+ {new Date(p.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {' — '} + {new Date(p.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {' · '}{p.durationMinutes}m · {p.urgency} +

+
+ + {p.priority} + +
+ ))} +
+ + {/* Overflow */} + {plan.overflow.length > 0 && ( +
+

+ Overflow ({plan.totalMinutesOverflow}m) +

+ {plan.overflow.map((o, idx) => ( +
+ {o.label} — {o.durationMinutes}m (could not fit) +
+ ))} +
+ )} + + {/* Summary + Apply */} +
+ + {plan.totalMinutesPlanned}m planned · {plan.existingTimerCount} existing timers + + {applied ? ( + + Applied + + ) : ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/web/src/lib/planner-client.ts b/web/src/lib/planner-client.ts new file mode 100644 index 0000000..3255341 --- /dev/null +++ b/web/src/lib/planner-client.ts @@ -0,0 +1,80 @@ +/** + * Day Planner API client — talks to ChronoMind backend (port 4011). + */ + +import { getBackendBaseURL } from './product-config'; + +function getToken(): string | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem('chronomind_access_token'); +} + +async function apiFetch(path: string, opts?: RequestInit): Promise { + const token = getToken(); + const res = await fetch(`${getBackendBaseURL()}${path}`, { + ...opts, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(opts?.headers ?? {}), + }, + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`${res.status}: ${body}`); + } + return res.json() as Promise; +} + +// ── Types ── + +export interface ProposedTimer { + label: string; + startTime: string; + endTime: string; + durationMinutes: number; + priority: 'high' | 'medium' | 'low'; + urgency: string; + category?: string; + cascade: string; +} + +export interface PlanDayResponse { + planId: string; + date: string; + proposed: ProposedTimer[]; + overflow: ProposedTimer[]; + totalMinutesPlanned: number; + totalMinutesOverflow: number; + existingTimerCount: number; +} + +export interface PlanDayRequest { + date: string; + dayStartHour?: number; + dayEndHour?: number; + prepTimeMinutes?: number; + activities: Array<{ + label: string; + durationMinutes: number; + priority?: 'high' | 'medium' | 'low'; + category?: string; + urgency?: string; + }>; +} + +// ── API calls ── + +export async function planDay(request: PlanDayRequest): Promise { + return apiFetch('/api/planner/plan-day', { + method: 'POST', + body: JSON.stringify(request), + }); +} + +export async function applyPlan(planId: string, proposed: ProposedTimer[]): Promise<{ planId: string; created: string[] }> { + return apiFetch<{ planId: string; created: string[] }>('/api/planner/apply-plan', { + method: 'POST', + body: JSON.stringify({ planId, proposed }), + }); +}