feat(web): Phase B.4 Day Planner web UI + planner API client
This commit is contained in:
parent
23c584f4a8
commit
9973c548fc
287
web/src/app/planner/page.tsx
Normal file
287
web/src/app/planner/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
web/src/lib/planner-client.ts
Normal file
80
web/src/lib/planner-client.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user