feat(web): Phase B.4 Day Planner web UI + planner API client

This commit is contained in:
saravanakumardb1 2026-04-18 18:07:12 -07:00
parent 23c584f4a8
commit 9973c548fc
2 changed files with 367 additions and 0 deletions

View File

@ -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<ActivityInput[]>([
{ label: '', durationMinutes: 30, priority: 'medium', category: '' },
]);
const [plan, setPlan] = useState<PlanDayResponse | null>(null);
const [loading, setLoading] = useState(false);
const [applied, setApplied] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
<div className="text-center">
<CalendarDays size={48} style={{ color: 'var(--cm-text-tertiary)' }} className="mx-auto mb-4" />
<p className="text-sm" style={{ color: 'var(--cm-text-secondary)' }}>Day Planner is not enabled. Enable the <code>day_planner.enabled</code> flag.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
<div className="max-w-2xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Link href="/" className="p-2 rounded-lg" style={{ color: 'var(--cm-text-secondary)' }} aria-label="Back to dashboard">
<ArrowLeft size={20} />
</Link>
<div>
<h1 className="text-xl font-bold" style={{ color: 'var(--cm-text-primary)' }}>Day Planner</h1>
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>AI-powered schedule generation</p>
</div>
</div>
{/* Date picker */}
<div className="mb-4">
<label className="text-xs font-medium block mb-1" style={{ color: 'var(--cm-text-secondary)' }}>Date</label>
<input
type="date"
value={date}
onChange={e => 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)' }}
/>
</div>
{/* Activity list */}
<div className="space-y-3 mb-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold" style={{ color: 'var(--cm-text-primary)' }}>Activities</h2>
<button
onClick={addActivity}
aria-label="Add activity"
className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs cursor-pointer"
style={{ color: 'var(--cm-accent)' }}
>
<Plus size={14} /> Add
</button>
</div>
{activities.map((act, idx) => (
<div
key={idx}
className="rounded-xl border p-3"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<div className="flex gap-2 mb-2">
<input
type="text"
value={act.label}
onChange={e => 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 && (
<button
onClick={() => removeActivity(idx)}
aria-label="Remove activity"
className="p-1.5 rounded cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-[10px] block mb-0.5" style={{ color: 'var(--cm-text-tertiary)' }}>Duration</label>
<select
value={act.durationMinutes}
onChange={e => updateActivity(idx, 'durationMinutes', parseInt(e.target.value))}
className="w-full px-2 py-1 rounded text-xs"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }}
>
{[15, 30, 45, 60, 90, 120, 180].map(m => (
<option key={m} value={m}>{m >= 60 ? `${m / 60}h` : `${m}m`}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="text-[10px] block mb-0.5" style={{ color: 'var(--cm-text-tertiary)' }}>Priority</label>
<select
value={act.priority}
onChange={e => updateActivity(idx, 'priority', e.target.value)}
className="w-full px-2 py-1 rounded text-xs"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)', border: '1px solid var(--cm-border)' }}
>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
</div>
))}
</div>
{/* Generate button */}
<button
onClick={handlePlan}
disabled={loading}
className="w-full py-3 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 cursor-pointer disabled:opacity-50"
style={{ backgroundColor: 'var(--cm-accent)', color: 'var(--cm-white)' }}
>
<Sparkles size={16} /> {loading ? 'Planning...' : 'Generate Day Plan'}
</button>
{/* Error */}
{error && (
<div className="rounded-lg p-3 mt-4 text-sm" style={{ backgroundColor: 'var(--cm-critical-10)', color: 'var(--cm-danger)' }} role="alert">
{error}
</div>
)}
{/* Plan result */}
{plan && (
<div className="mt-6">
<h2 className="text-sm font-semibold mb-3 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
<CalendarDays size={16} /> Proposed Schedule
</h2>
<div className="space-y-2 mb-4">
{plan.proposed.map((p, idx) => (
<div
key={idx}
className="flex items-center gap-3 rounded-xl border p-3"
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
>
<Clock size={14} style={{ color: 'var(--cm-accent)' }} />
<div className="flex-1">
<span className="text-sm font-medium" style={{ color: 'var(--cm-text-primary)' }}>{p.label}</span>
<p className="text-[10px]" style={{ color: 'var(--cm-text-tertiary)' }}>
{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>
</div>
<span
className="px-2 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: p.priority === 'high' ? 'var(--cm-important-15)' : p.priority === 'low' ? 'var(--cm-passive-15)' : 'var(--cm-standard-15)',
color: p.priority === 'high' ? 'var(--cm-important)' : p.priority === 'low' ? 'var(--cm-passive)' : 'var(--cm-standard)',
}}
>
{p.priority}
</span>
</div>
))}
</div>
{/* Overflow */}
{plan.overflow.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-medium mb-2 flex items-center gap-1" style={{ color: 'var(--cm-important)' }}>
<AlertTriangle size={12} /> Overflow ({plan.totalMinutesOverflow}m)
</h3>
{plan.overflow.map((o, idx) => (
<div key={idx} className="text-xs py-1" style={{ color: 'var(--cm-text-secondary)' }}>
{o.label} {o.durationMinutes}m (could not fit)
</div>
))}
</div>
)}
{/* Summary + Apply */}
<div className="flex items-center justify-between mt-4">
<span className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
{plan.totalMinutesPlanned}m planned · {plan.existingTimerCount} existing timers
</span>
{applied ? (
<span className="flex items-center gap-1 text-xs font-medium" style={{ color: 'var(--cm-gentle)' }}>
<Check size={14} /> Applied
</span>
) : (
<button
onClick={handleApply}
disabled={loading}
className="flex items-center gap-1 px-4 py-2 rounded-lg text-xs font-medium cursor-pointer disabled:opacity-50"
style={{ backgroundColor: 'var(--cm-gentle-15)', color: 'var(--cm-gentle)' }}
>
<Check size={14} /> Apply Plan
</button>
)}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -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<T>(path: string, opts?: RequestInit): Promise<T> {
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<T>;
}
// ── 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<PlanDayResponse> {
return apiFetch<PlanDayResponse>('/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 }),
});
}