Dashboard personalization: preferred name, colored dots, smart briefing
- Add preferred_name column to settings model/schema with migration - Settings page gets Profile card with name input (saves on blur/enter) - Dashboard greeting now shows "Good evening, Kyle." when name is set - WeekTimeline dots use event's actual color when available - New DayBriefing component shows time-of-day-aware contextual summary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e9b3c90b0d
commit
1d21caaa62
26
backend/alembic/versions/003_add_preferred_name.py
Normal file
26
backend/alembic/versions/003_add_preferred_name.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""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')
|
||||
@ -11,5 +11,6 @@ class Settings(Base):
|
||||
pin_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
accent_color: Mapped[str] = mapped_column(String(20), default="cyan")
|
||||
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())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
@ -25,12 +25,14 @@ class SettingsCreate(BaseModel):
|
||||
class SettingsUpdate(BaseModel):
|
||||
accent_color: Optional[AccentColor] = None
|
||||
upcoming_days: int | None = None
|
||||
preferred_name: str | None = None
|
||||
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
id: int
|
||||
accent_color: str
|
||||
upcoming_days: int
|
||||
preferred_name: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@ -9,16 +9,18 @@ import TodoWidget from './TodoWidget';
|
||||
import CalendarWidget from './CalendarWidget';
|
||||
import UpcomingWidget from './UpcomingWidget';
|
||||
import WeekTimeline from './WeekTimeline';
|
||||
import DayBriefing from './DayBriefing';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DashboardSkeleton } from '@/components/ui/skeleton';
|
||||
|
||||
function getGreeting(): string {
|
||||
function getGreeting(name?: string): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 5) return 'Good night.';
|
||||
if (hour < 12) return 'Good morning.';
|
||||
if (hour < 17) return 'Good afternoon.';
|
||||
if (hour < 21) return 'Good evening.';
|
||||
return 'Good night.';
|
||||
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() {
|
||||
@ -72,7 +74,7 @@ export default function DashboardPage() {
|
||||
{/* Header — greeting + date */}
|
||||
<div className="px-6 pt-6 pb-2">
|
||||
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
||||
{getGreeting()}
|
||||
{getGreeting(settings?.preferred_name || undefined)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{format(new Date(), 'EEEE, MMMM d, yyyy')}
|
||||
@ -88,6 +90,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Smart Briefing */}
|
||||
{upcomingData && (
|
||||
<DayBriefing upcomingItems={upcomingData.items} dashboardData={data} />
|
||||
)}
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="animate-slide-up" style={{ animationDelay: '50ms', animationFillMode: 'backwards' }}>
|
||||
<StatsWidget
|
||||
|
||||
131
frontend/src/components/dashboard/DayBriefing.tsx
Normal file
131
frontend/src/components/dashboard/DayBriefing.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
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 (9PM–5AM): 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 (5AM–12PM)
|
||||
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 (12PM–5PM)
|
||||
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 (5PM–9PM)
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -72,8 +72,9 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
|
||||
key={`${item.type}-${item.id}`}
|
||||
className={cn(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
typeColors[item.type] || 'bg-muted-foreground'
|
||||
!item.color && (typeColors[item.type] || 'bg-muted-foreground')
|
||||
)}
|
||||
style={item.color ? { backgroundColor: item.color } : undefined}
|
||||
/>
|
||||
))}
|
||||
{day.items.length > 4 && (
|
||||
|
||||
@ -20,12 +20,24 @@ export default function SettingsPage() {
|
||||
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings();
|
||||
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
||||
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
||||
const [preferredName, setPreferredName] = useState(settings?.preferred_name || '');
|
||||
const [pinForm, setPinForm] = useState({
|
||||
oldPin: '',
|
||||
newPin: '',
|
||||
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) => {
|
||||
setSelectedColor(color);
|
||||
try {
|
||||
@ -73,6 +85,34 @@ export default function SettingsPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-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>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
|
||||
@ -14,7 +14,7 @@ export function useSettings() {
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (updates: Partial<Settings>) => {
|
||||
mutationFn: async (updates: Partial<Settings> & { preferred_name?: string | null }) => {
|
||||
const { data } = await api.put<Settings>('/settings', updates);
|
||||
return data;
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ export interface Settings {
|
||||
id: number;
|
||||
accent_color: string;
|
||||
upcoming_days: number;
|
||||
preferred_name?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user