Compare commits

..

No commits in common. "1d21caaa623a612a14b1be5c445c49510f27ac6d" and "c5adc316ef7aea96ead2e190dafa103b4e069135" have entirely different histories.

19 changed files with 130 additions and 3807 deletions

View File

@ -1,26 +0,0 @@
"""Add preferred_name to settings
Revision ID: 003
Revises: 002
Create Date: 2026-02-20 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: Union[str, None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('settings', sa.Column('preferred_name', sa.String(100), nullable=True))
def downgrade() -> None:
op.drop_column('settings', 'preferred_name')

View File

@ -11,6 +11,5 @@ class Settings(Base):
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False) pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
accent_color: Mapped[str] = mapped_column(String(20), default="cyan") accent_color: Mapped[str] = mapped_column(String(20), default="cyan")
upcoming_days: Mapped[int] = mapped_column(Integer, default=7) upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
preferred_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)
created_at: Mapped[datetime] = mapped_column(default=func.now()) created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -25,14 +25,12 @@ class SettingsCreate(BaseModel):
class SettingsUpdate(BaseModel): class SettingsUpdate(BaseModel):
accent_color: Optional[AccentColor] = None accent_color: Optional[AccentColor] = None
upcoming_days: int | None = None upcoming_days: int | None = None
preferred_name: str | None = None
class SettingsResponse(BaseModel): class SettingsResponse(BaseModel):
id: int id: int
accent_color: str accent_color: str
upcoming_days: int upcoming_days: int
preferred_name: str | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

View File

@ -4,9 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UMBRA</title> <title>UMBRA</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -20,38 +20,37 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-purple-500/10"> <Calendar className="h-5 w-5" />
<Calendar className="h-4 w-4 text-purple-400" />
</div>
Today's Events Today's Events
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6"> <p className="text-sm text-muted-foreground text-center py-8">
No events today No events scheduled for today
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-3">
{events.map((event) => ( {events.map((event) => (
<div <div
key={event.id} key={event.id}
className="flex items-start gap-3 p-3 rounded-lg border border-transparent hover:border-border/50 hover:bg-card-elevated transition-all duration-200" className="flex items-start gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
> >
<div <div
className="w-1 h-full min-h-[2rem] rounded-full shrink-0" className="w-1 h-full min-h-[2rem] rounded-full"
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }} style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm">{event.title}</p> <p className="font-medium">{event.title}</p>
{!event.all_day ? ( {!event.all_day && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1"> <div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{format(new Date(event.start_datetime), 'h:mm a')} {format(new Date(event.start_datetime), 'h:mm a')}
{event.end_datetime && ` ${format(new Date(event.end_datetime), 'h:mm a')}`} {event.end_datetime && ` - ${format(new Date(event.end_datetime), 'h:mm a')}`}
</div> </div>
) : ( )}
<p className="text-xs text-muted-foreground mt-1">All day</p> {event.all_day && (
<p className="text-sm text-muted-foreground mt-1">All day</p>
)} )}
</div> </div>
</div> </div>

View File

@ -8,21 +8,9 @@ import StatsWidget from './StatsWidget';
import TodoWidget from './TodoWidget'; import TodoWidget from './TodoWidget';
import CalendarWidget from './CalendarWidget'; import CalendarWidget from './CalendarWidget';
import UpcomingWidget from './UpcomingWidget'; import UpcomingWidget from './UpcomingWidget';
import WeekTimeline from './WeekTimeline';
import DayBriefing from './DayBriefing';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DashboardSkeleton } from '@/components/ui/skeleton'; import { DashboardSkeleton } from '@/components/ui/skeleton';
function getGreeting(name?: string): string {
const hour = new Date().getHours();
const suffix = name ? `, ${name}.` : '.';
if (hour < 5) return `Good night${suffix}`;
if (hour < 12) return `Good morning${suffix}`;
if (hour < 17) return `Good afternoon${suffix}`;
if (hour < 21) return `Good evening${suffix}`;
return `Good night${suffix}`;
}
export default function DashboardPage() { export default function DashboardPage() {
const { settings } = useSettings(); const { settings } = useSettings();
@ -48,13 +36,11 @@ export default function DashboardPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="px-6 py-6"> <div className="border-b bg-card px-6 py-4">
<div className="animate-pulse space-y-2"> <h1 className="text-3xl font-bold">Dashboard</h1>
<div className="h-8 w-48 rounded bg-muted" /> <p className="text-muted-foreground mt-1">Welcome back. Here's your overview.</p>
<div className="h-4 w-32 rounded bg-muted" />
</div> </div>
</div> <div className="flex-1 overflow-y-auto p-6">
<div className="flex-1 overflow-y-auto px-6 pb-6">
<DashboardSkeleton /> <DashboardSkeleton />
</div> </div>
</div> </div>
@ -71,87 +57,46 @@ export default function DashboardPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header — greeting + date */} <div className="border-b bg-card px-6 py-4">
<div className="px-6 pt-6 pb-2"> <h1 className="text-3xl font-bold">Dashboard</h1>
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in"> <p className="text-muted-foreground mt-1">Welcome back. Here's your overview.</p>
{getGreeting(settings?.preferred_name || undefined)}
</h1>
<p className="text-muted-foreground text-sm mt-1">
{format(new Date(), 'EEEE, MMMM d, yyyy')}
</p>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 pb-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="space-y-5"> <div className="space-y-6">
{/* Week Timeline */}
{upcomingData && (
<div className="animate-slide-up">
<WeekTimeline items={upcomingData.items} />
</div>
)}
{/* Smart Briefing */}
{upcomingData && (
<DayBriefing upcomingItems={upcomingData.items} dashboardData={data} />
)}
{/* Stats Row */}
<div className="animate-slide-up" style={{ animationDelay: '50ms', animationFillMode: 'backwards' }}>
<StatsWidget <StatsWidget
projectStats={data.project_stats} projectStats={data.project_stats}
totalPeople={data.total_people} totalPeople={data.total_people}
totalLocations={data.total_locations} totalLocations={data.total_locations}
/> />
</div>
{/* Main Content — 2 columns */} {upcomingData && upcomingData.items.length > 0 && (
<div className="grid gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
{/* Left: Upcoming feed (wider) */}
<div className="lg:col-span-3">
{upcomingData && upcomingData.items.length > 0 ? (
<UpcomingWidget items={upcomingData.items} days={upcomingData.days} /> <UpcomingWidget items={upcomingData.items} days={upcomingData.days} />
) : (
<Card className="h-full">
<CardHeader>
<CardTitle>Upcoming</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground text-center py-8">
Nothing upcoming. Enjoy the quiet.
</p>
</CardContent>
</Card>
)} )}
</div>
{/* Right: Today's events + todos stacked */} <div className="grid gap-6 lg:grid-cols-2">
<div className="lg:col-span-2 space-y-5">
<CalendarWidget events={data.todays_events} />
<TodoWidget todos={data.upcoming_todos} /> <TodoWidget todos={data.upcoming_todos} />
</div> <CalendarWidget events={data.todays_events} />
</div> </div>
{/* Active Reminders */}
{data.active_reminders.length > 0 && ( {data.active_reminders.length > 0 && (
<Card className="animate-slide-up" style={{ animationDelay: '150ms', animationFillMode: 'backwards' }}> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-orange-500/10"> <Bell className="h-5 w-5" />
<Bell className="h-4 w-4 text-orange-400" />
</div>
Active Reminders Active Reminders
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-3">
{data.active_reminders.map((reminder) => ( {data.active_reminders.map((reminder) => (
<div <div
key={reminder.id} key={reminder.id}
className="flex items-center gap-3 p-3 rounded-lg border border-transparent hover:border-border/50 hover:bg-card-elevated transition-all duration-200" className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
> >
<div className="w-1 h-8 rounded-full bg-orange-400" /> <Bell className="h-4 w-4 text-accent" />
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-sm">{reminder.title}</p> <p className="font-medium">{reminder.title}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a')} {format(new Date(reminder.remind_at), 'MMM d, yyyy h:mm a')}
</p> </p>

View File

@ -1,131 +0,0 @@
import { useMemo } from 'react';
import { format, isSameDay, startOfDay, addDays, isAfter } from 'date-fns';
import type { UpcomingItem, DashboardData } from '@/types';
interface DayBriefingProps {
upcomingItems: UpcomingItem[];
dashboardData: DashboardData;
}
function getItemTime(item: UpcomingItem): string {
if (item.datetime) {
return format(new Date(item.datetime), 'h:mm a');
}
return '';
}
export default function DayBriefing({ upcomingItems, dashboardData }: DayBriefingProps) {
const briefing = useMemo(() => {
const now = new Date();
const hour = now.getHours();
const today = startOfDay(now);
const tomorrow = addDays(today, 1);
const todayItems = upcomingItems.filter((item) => {
const d = item.datetime ? new Date(item.datetime) : new Date(item.date);
return isSameDay(startOfDay(d), today);
});
const tomorrowItems = upcomingItems.filter((item) => {
const d = item.datetime ? new Date(item.datetime) : new Date(item.date);
return isSameDay(startOfDay(d), tomorrow);
});
const todayEvents = dashboardData.todays_events;
const activeReminders = dashboardData.active_reminders;
const todayTodos = dashboardData.upcoming_todos.filter((t) => {
if (!t.due_date) return false;
return isSameDay(startOfDay(new Date(t.due_date)), today);
});
const parts: string[] = [];
// Night (9PM5AM): Focus on tomorrow
if (hour >= 21 || hour < 5) {
if (tomorrowItems.length === 0) {
parts.push('Tomorrow is clear — nothing scheduled.');
} else {
const firstEvent = tomorrowItems.find((i) => i.type === 'event' && i.datetime);
if (firstEvent) {
parts.push(
`You have ${tomorrowItems.length} item${tomorrowItems.length > 1 ? 's' : ''} tomorrow, starting with ${firstEvent.title} at ${getItemTime(firstEvent)}.`
);
} else {
parts.push(
`You have ${tomorrowItems.length} item${tomorrowItems.length > 1 ? 's' : ''} lined up for tomorrow.`
);
}
}
}
// Morning (5AM12PM)
else if (hour < 12) {
if (todayItems.length === 0) {
parts.push('Your day is wide open — no events or tasks scheduled.');
} else {
const eventCount = todayEvents.length;
const todoCount = todayTodos.length;
const segments: string[] = [];
if (eventCount > 0) segments.push(`${eventCount} event${eventCount > 1 ? 's' : ''}`);
if (todoCount > 0) segments.push(`${todoCount} task${todoCount > 1 ? 's' : ''} due`);
if (segments.length > 0) {
parts.push(`Today: ${segments.join(' and ')}.`);
}
const firstEvent = todayEvents.find((e) => {
const d = new Date(e.start_datetime);
return isAfter(d, now);
});
if (firstEvent) {
parts.push(`Up next is ${firstEvent.title} at ${format(new Date(firstEvent.start_datetime), 'h:mm a')}.`);
}
}
}
// Afternoon (12PM5PM)
else if (hour < 17) {
const remainingEvents = todayEvents.filter((e) => isAfter(new Date(e.end_datetime), now));
const completedTodos = todayTodos.length === 0;
if (remainingEvents.length === 0 && completedTodos) {
parts.push('The rest of your afternoon is clear.');
} else {
if (remainingEvents.length > 0) {
parts.push(
`${remainingEvents.length} event${remainingEvents.length > 1 ? 's' : ''} remaining this afternoon.`
);
}
if (todayTodos.length > 0) {
parts.push(`${todayTodos.length} task${todayTodos.length > 1 ? 's' : ''} still due today.`);
}
}
}
// Evening (5PM9PM)
else {
const eveningEvents = todayEvents.filter((e) => isAfter(new Date(e.end_datetime), now));
if (eveningEvents.length === 0 && tomorrowItems.length === 0) {
parts.push('Nothing left tonight, and tomorrow is clear too.');
} else {
if (eveningEvents.length > 0) {
parts.push(`${eveningEvents.length} event${eveningEvents.length > 1 ? 's' : ''} left this evening.`);
}
if (tomorrowItems.length > 0) {
parts.push(`Tomorrow has ${tomorrowItems.length} item${tomorrowItems.length > 1 ? 's' : ''} ahead.`);
}
}
}
// Reminder callout
if (activeReminders.length > 0) {
const nextReminder = activeReminders[0];
const remindTime = format(new Date(nextReminder.remind_at), 'h:mm a');
parts.push(`Don't forget: ${nextReminder.title} at ${remindTime}.`);
}
return parts.join(' ');
}, [upcomingItems, dashboardData]);
if (!briefing) return null;
return (
<p className="text-sm text-muted-foreground italic px-1">
{briefing}
</p>
);
}

View File

@ -1,4 +1,4 @@
import { FolderKanban, Users, MapPin, TrendingUp } from 'lucide-react'; import { FolderKanban, Users, MapPin } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
interface StatsWidgetProps { interface StatsWidgetProps {
@ -13,52 +13,42 @@ interface StatsWidgetProps {
export default function StatsWidget({ projectStats, totalPeople, totalLocations }: StatsWidgetProps) { export default function StatsWidget({ projectStats, totalPeople, totalLocations }: StatsWidgetProps) {
const statCards = [ const statCards = [
{ {
label: 'PROJECTS', label: 'Total Projects',
value: projectStats.total, value: projectStats.total,
icon: FolderKanban, icon: FolderKanban,
color: 'text-blue-400', color: 'text-blue-500',
glowBg: 'bg-blue-500/10',
}, },
{ {
label: 'IN PROGRESS', label: 'In Progress',
value: projectStats.by_status['in_progress'] || 0, value: projectStats.by_status['in_progress'] || 0,
icon: TrendingUp, icon: FolderKanban,
color: 'text-purple-400', color: 'text-purple-500',
glowBg: 'bg-purple-500/10',
}, },
{ {
label: 'PEOPLE', label: 'People',
value: totalPeople, value: totalPeople,
icon: Users, icon: Users,
color: 'text-emerald-400', color: 'text-green-500',
glowBg: 'bg-emerald-500/10',
}, },
{ {
label: 'LOCATIONS', label: 'Locations',
value: totalLocations, value: totalLocations,
icon: MapPin, icon: MapPin,
color: 'text-orange-400', color: 'text-orange-500',
glowBg: 'bg-orange-500/10',
}, },
]; ];
return ( return (
<div className="grid gap-3 grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{statCards.map((stat) => ( {statCards.map((stat) => (
<Card key={stat.label} className="bg-gradient-to-br from-accent/[0.03] to-transparent"> <Card key={stat.label}>
<CardContent className="p-4"> <CardContent className="p-6">
<div className="flex items-start justify-between"> <div className="flex items-center justify-between">
<div className="space-y-2"> <div>
<p className="text-[11px] font-medium tracking-wider text-muted-foreground"> <p className="text-sm font-medium text-muted-foreground">{stat.label}</p>
{stat.label} <p className="text-3xl font-bold mt-2">{stat.value}</p>
</p>
<p className="font-heading text-3xl font-bold tabular-nums leading-none">
{stat.value}
</p>
</div>
<div className={`p-2 rounded-lg ${stat.glowBg}`}>
<stat.icon className={`h-5 w-5 ${stat.color}`} />
</div> </div>
<stat.icon className={`h-8 w-8 ${stat.color}`} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,4 +1,4 @@
import { format, isPast, endOfDay } from 'date-fns'; import { format, isPast } from 'date-fns';
import { Calendar, CheckCircle2 } from 'lucide-react'; import { Calendar, CheckCircle2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -17,9 +17,9 @@ interface TodoWidgetProps {
} }
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
low: 'bg-green-500/10 text-green-400 border-green-500/20', low: 'bg-green-500/10 text-green-500 border-green-500/20',
medium: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
high: 'bg-red-500/10 text-red-400 border-red-500/20', high: 'bg-red-500/10 text-red-500 border-red-500/20',
}; };
export default function TodoWidget({ todos }: TodoWidgetProps) { export default function TodoWidget({ todos }: TodoWidgetProps) {
@ -27,45 +27,41 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-blue-500/10"> <CheckCircle2 className="h-5 w-5" />
<CheckCircle2 className="h-4 w-4 text-blue-400" />
</div>
Upcoming Todos Upcoming Todos
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{todos.length === 0 ? ( {todos.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6"> <p className="text-sm text-muted-foreground text-center py-8">
All caught up. No upcoming todos. You're all caught up!
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-3">
{todos.slice(0, 5).map((todo) => { {todos.slice(0, 5).map((todo) => {
const isOverdue = isPast(endOfDay(new Date(todo.due_date))); const isOverdue = isPast(new Date(todo.due_date));
return ( return (
<div <div
key={todo.id} key={todo.id}
className="flex items-center gap-3 p-3 rounded-lg border border-transparent hover:border-border/50 hover:bg-card-elevated transition-all duration-200" className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
> >
<div className={cn(
'w-1 h-8 rounded-full shrink-0',
todo.priority === 'high' ? 'bg-red-400' :
todo.priority === 'medium' ? 'bg-yellow-400' : 'bg-green-400'
)} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{todo.title}</p> <p className="font-medium">{todo.title}</p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<div className={cn( <div className={cn(
"flex items-center gap-1 text-xs", "flex items-center gap-1 text-xs",
isOverdue ? "text-destructive" : "text-muted-foreground" isOverdue ? "text-destructive" : "text-muted-foreground"
)}> )}>
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
{format(new Date(todo.due_date), 'MMM d')} {format(new Date(todo.due_date), 'MMM d, yyyy')}
{isOverdue && <span className="font-medium">overdue</span>} {isOverdue && <span className="font-medium">(Overdue)</span>}
</div>
{todo.category && (
<Badge variant="outline" className="text-xs">{todo.category}</Badge>
)}
</div> </div>
</div> </div>
</div> <Badge className={priorityColors[todo.priority] || priorityColors.medium}>
<Badge className={cn('text-[10px] shrink-0', priorityColors[todo.priority] || priorityColors.medium)}>
{todo.priority} {todo.priority}
</Badge> </Badge>
</div> </div>

View File

@ -1,98 +1,72 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { CheckSquare, Calendar, Bell, ArrowRight } from 'lucide-react'; import { CheckSquare, Calendar, Bell } from 'lucide-react';
import type { UpcomingItem } from '@/types'; import type { UpcomingItem } from '@/types';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
interface UpcomingWidgetProps { interface UpcomingWidgetProps {
items: UpcomingItem[]; items: UpcomingItem[];
days?: number; days?: number;
} }
const typeConfig: Record<string, { icon: typeof CheckSquare; color: string; borderColor: string; label: string }> = { const priorityColors: Record<string, string> = {
todo: { low: 'bg-green-500/10 text-green-500 border-green-500/20',
icon: CheckSquare, medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
color: 'text-blue-400', high: 'bg-red-500/10 text-red-500 border-red-500/20',
borderColor: 'border-l-blue-400',
label: 'Todo',
},
event: {
icon: Calendar,
color: 'text-purple-400',
borderColor: 'border-l-purple-400',
label: 'Event',
},
reminder: {
icon: Bell,
color: 'text-orange-400',
borderColor: 'border-l-orange-400',
label: 'Reminder',
},
}; };
export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) { export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps) {
const getIcon = (type: string) => {
switch (type) {
case 'todo':
return <CheckSquare className="h-5 w-5 text-blue-500" />;
case 'event':
return <Calendar className="h-5 w-5 text-purple-500" />;
case 'reminder':
return <Bell className="h-5 w-5 text-orange-500" />;
default:
return null;
}
};
return ( return (
<Card className="h-full"> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <CardTitle>Upcoming ({days} days)</CardTitle>
<CardTitle className="flex items-center gap-2">
<div className="p-1.5 rounded-md bg-accent/10">
<ArrowRight className="h-4 w-4 text-accent" />
</div>
Upcoming
</CardTitle>
<span className="text-xs text-muted-foreground">{days} days</span>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
Nothing upcoming No upcoming items in the next few days
</p> </p>
) : ( ) : (
<ScrollArea className="max-h-[400px] -mr-2 pr-2"> <ScrollArea className="h-[300px]">
<div className="space-y-1.5"> <div className="space-y-3">
{items.map((item, index) => { {items.map((item, index) => (
const config = typeConfig[item.type] || typeConfig.todo;
const Icon = config.icon;
return (
<div <div
key={`${item.type}-${item.id}-${index}`} key={`${item.type}-${item.id}-${index}`}
className={cn( className="flex items-start gap-3 p-3 rounded-lg border bg-card hover:bg-accent/5 transition-colors"
'flex items-start gap-3 p-3 rounded-lg',
'border-l-2 border border-transparent',
config.borderColor,
'hover:bg-card-elevated transition-all duration-200'
)}
> >
<Icon className={cn('h-4 w-4 mt-0.5 shrink-0', config.color)} /> {getIcon(item.type)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-sm">{item.title}</p> <p className="font-medium">{item.title}</p>
<div className="flex items-center gap-2 mt-1"> <p className="text-sm text-muted-foreground">
<span className="text-xs text-muted-foreground">
{item.datetime {item.datetime
? format(new Date(item.datetime), 'MMM d · h:mm a') ? format(new Date(item.datetime), 'MMM d, yyyy h:mm a')
: format(new Date(item.date), 'MMM d')} : format(new Date(item.date), 'MMM d, yyyy')}
</span> </p>
<span className={cn('text-[10px] font-medium uppercase tracking-wider', config.color)}>
{config.label}
</span>
</div>
</div> </div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="capitalize">
{item.type}
</Badge>
{item.priority && ( {item.priority && (
<span className={cn( <Badge className={priorityColors[item.priority]}>{item.priority}</Badge>
'text-[10px] font-medium px-1.5 py-0.5 rounded',
item.priority === 'high' ? 'bg-red-500/10 text-red-400' :
item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' :
'bg-green-500/10 text-green-400'
)}>
{item.priority}
</span>
)} )}
</div> </div>
); </div>
})} ))}
</div> </div>
</ScrollArea> </ScrollArea>
)} )}

View File

@ -1,90 +0,0 @@
import { useMemo } from 'react';
import { format, startOfWeek, addDays, isSameDay, isBefore, startOfDay } from 'date-fns';
import type { UpcomingItem } from '@/types';
import { cn } from '@/lib/utils';
interface WeekTimelineProps {
items: UpcomingItem[];
}
const typeColors: Record<string, string> = {
todo: 'bg-blue-400',
event: 'bg-purple-400',
reminder: 'bg-orange-400',
};
export default function WeekTimeline({ items }: WeekTimelineProps) {
const today = useMemo(() => startOfDay(new Date()), []);
const weekStart = useMemo(() => startOfWeek(today, { weekStartsOn: 1 }), [today]);
const days = useMemo(() => {
return Array.from({ length: 7 }, (_, i) => {
const date = addDays(weekStart, i);
const dayItems = items.filter((item) => {
const itemDate = item.datetime ? new Date(item.datetime) : new Date(item.date);
return isSameDay(startOfDay(itemDate), date);
});
return {
date,
key: format(date, 'yyyy-MM-dd'),
dayName: format(date, 'EEE'),
dayNum: format(date, 'd'),
isToday: isSameDay(date, today),
isPast: isBefore(date, today),
items: dayItems,
};
});
}, [weekStart, today, items]);
return (
<div className="flex items-stretch gap-2">
{days.map((day) => (
<div
key={day.key}
className={cn(
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border',
day.isToday
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
: day.isPast
? 'border-transparent opacity-50'
: 'border-transparent hover:border-border/50'
)}
>
<span
className={cn(
'text-[11px] font-medium uppercase tracking-wider',
day.isToday ? 'text-accent' : 'text-muted-foreground'
)}
>
{day.dayName}
</span>
<span
className={cn(
'font-heading text-lg font-semibold leading-none',
day.isToday ? 'text-accent' : 'text-foreground'
)}
>
{day.dayNum}
</span>
<div className="flex items-center gap-1 mt-0.5 min-h-[8px]">
{day.items.slice(0, 4).map((item) => (
<div
key={`${item.type}-${item.id}`}
className={cn(
'w-1.5 h-1.5 rounded-full',
!item.color && (typeColors[item.type] || 'bg-muted-foreground')
)}
style={item.color ? { backgroundColor: item.color } : undefined}
/>
))}
{day.items.length > 4 && (
<span className="text-[9px] text-muted-foreground font-medium">
+{day.items.length - 4}
</span>
)}
</div>
</div>
))}
</div>
);
}

View File

@ -45,16 +45,16 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
const navLinkClass = ({ isActive }: { isActive: boolean }) => const navLinkClass = ({ isActive }: { isActive: boolean }) =>
cn( cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200', 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive isActive
? 'bg-accent/15 text-accent border-l-2 border-accent' ? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/10 hover:text-accent border-l-2 border-transparent' : 'text-muted-foreground hover:bg-accent/10 hover:text-accent'
); );
const sidebarContent = ( const sidebarContent = (
<> <>
<div className="flex h-16 items-center justify-between border-b px-4"> <div className="flex h-16 items-center justify-between border-b px-4">
{!collapsed && <h1 className="font-heading text-xl font-bold tracking-tight text-accent">UMBRA</h1>} {!collapsed && <h1 className="text-xl font-bold text-accent">UMBRA</h1>}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -120,7 +120,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
{/* Mobile overlay */} {/* Mobile overlay */}
{mobileOpen && ( {mobileOpen && (
<div className="fixed inset-0 z-40 md:hidden"> <div className="fixed inset-0 z-40 md:hidden">
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm" onClick={onMobileClose} /> <div className="absolute inset-0 bg-background/80" onClick={onMobileClose} />
<aside className="relative z-50 flex flex-col w-64 h-full bg-card border-r"> <aside className="relative z-50 flex flex-col w-64 h-full bg-card border-r">
{sidebarContent} {sidebarContent}
</aside> </aside>

View File

@ -20,24 +20,12 @@ export default function SettingsPage() {
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings(); const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings();
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan'); const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7); const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
const [preferredName, setPreferredName] = useState(settings?.preferred_name || '');
const [pinForm, setPinForm] = useState({ const [pinForm, setPinForm] = useState({
oldPin: '', oldPin: '',
newPin: '', newPin: '',
confirmPin: '', confirmPin: '',
}); });
const handleNameSave = async () => {
const trimmed = preferredName.trim();
if (trimmed === (settings?.preferred_name || '')) return;
try {
await updateSettings({ preferred_name: trimmed || null });
toast.success('Name updated');
} catch (error) {
toast.error('Failed to update name');
}
};
const handleColorChange = async (color: string) => { const handleColorChange = async (color: string) => {
setSelectedColor(color); setSelectedColor(color);
try { try {
@ -85,34 +73,6 @@ export default function SettingsPage() {
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl space-y-6">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Personalize how UMBRA greets you</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="preferred_name">Preferred Name</Label>
<div className="flex gap-3 items-center">
<Input
id="preferred_name"
type="text"
placeholder="Enter your name"
value={preferredName}
onChange={(e) => setPreferredName(e.target.value)}
onBlur={handleNameSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleNameSave(); }}
className="max-w-xs"
maxLength={100}
/>
</div>
<p className="text-sm text-muted-foreground">
Used in the dashboard greeting, e.g. "Good morning, {preferredName || 'Kyle'}."
</p>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Appearance</CardTitle> <CardTitle>Appearance</CardTitle>

View File

@ -5,12 +5,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn( className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
'rounded-lg border bg-card text-card-foreground shadow-sm',
'transition-all duration-200',
'hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20',
className
)}
{...props} {...props}
/> />
) )
@ -19,7 +14,7 @@ Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} /> <div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
) )
); );
CardHeader.displayName = 'CardHeader'; CardHeader.displayName = 'CardHeader';
@ -28,7 +23,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<h3 <h3
ref={ref} ref={ref}
className={cn('font-heading text-lg font-semibold leading-none tracking-tight', className)} className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props} {...props}
/> />
) )
@ -45,14 +40,14 @@ CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} /> <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
) )
); );
CardContent.displayName = 'CardContent'; CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} /> <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
) )
); );
CardFooter.displayName = 'CardFooter'; CardFooter.displayName = 'CardFooter';

View File

@ -14,7 +14,7 @@ export function useSettings() {
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: async (updates: Partial<Settings> & { preferred_name?: string | null }) => { mutationFn: async (updates: Partial<Settings>) => {
const { data } = await api.put<Settings>('/settings', updates); const { data } = await api.put<Settings>('/settings', updates);
return data; return data;
}, },

View File

@ -8,7 +8,6 @@
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 0 0% 5%; --card: 0 0% 5%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--card-elevated: 0 0% 7%;
--popover: 0 0% 5%; --popover: 0 0% 5%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: var(--accent-h) var(--accent-s) var(--accent-l); --primary: var(--accent-h) var(--accent-s) var(--accent-l);
@ -30,14 +29,6 @@
--accent-h: 187; --accent-h: 187;
--accent-s: 85.7%; --accent-s: 85.7%;
--accent-l: 53.3%; --accent-l: 53.3%;
/* Transitions */
--transition-fast: 150ms;
--transition-normal: 250ms;
/* Fonts */
--font-heading: 'Sora', sans-serif;
--font-body: 'DM Sans', sans-serif;
} }
} }
@ -47,38 +38,8 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-family: var(--font-body);
font-feature-settings: "rlig" 1, "calt" 1; font-feature-settings: "rlig" 1, "calt" 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
}
}
/* Custom Scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: hsl(0 0% 20%) transparent;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(0 0% 20%);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent-color) / 0.5);
} }
/* FullCalendar Dark Theme Overrides */ /* FullCalendar Dark Theme Overrides */

View File

@ -2,7 +2,6 @@ export interface Settings {
id: number; id: number;
accent_color: string; accent_color: string;
upcoming_days: number; upcoming_days: number;
preferred_name?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@ -8,10 +8,6 @@ export default {
], ],
theme: { theme: {
extend: { extend: {
fontFamily: {
heading: ['Sora', 'sans-serif'],
body: ['DM Sans', 'sans-serif'],
},
colors: { colors: {
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
@ -45,7 +41,6 @@ export default {
card: { card: {
DEFAULT: 'hsl(var(--card))', DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))', foreground: 'hsl(var(--card-foreground))',
elevated: 'hsl(var(--card-elevated))',
}, },
}, },
borderRadius: { borderRadius: {
@ -53,20 +48,6 @@ export default {
md: 'calc(var(--radius) - 2px)', md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)', sm: 'calc(var(--radius) - 4px)',
}, },
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-up': {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 0.3s ease-out',
'slide-up': 'slide-up 0.4s ease-out',
},
}, },
}, },
plugins: [], plugins: [],