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:
Kyle 2026-02-20 07:37:43 +08:00
parent e9b3c90b0d
commit 1d21caaa62
9 changed files with 218 additions and 9 deletions

View 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')

View File

@ -11,5 +11,6 @@ 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,12 +25,14 @@ 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

@ -9,16 +9,18 @@ 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 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(): string { function getGreeting(name?: string): string {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour < 5) return 'Good night.'; const suffix = name ? `, ${name}.` : '.';
if (hour < 12) return 'Good morning.'; if (hour < 5) return `Good night${suffix}`;
if (hour < 17) return 'Good afternoon.'; if (hour < 12) return `Good morning${suffix}`;
if (hour < 21) return 'Good evening.'; if (hour < 17) return `Good afternoon${suffix}`;
return 'Good night.'; if (hour < 21) return `Good evening${suffix}`;
return `Good night${suffix}`;
} }
export default function DashboardPage() { export default function DashboardPage() {
@ -72,7 +74,7 @@ export default function DashboardPage() {
{/* Header — greeting + date */} {/* Header — greeting + date */}
<div className="px-6 pt-6 pb-2"> <div className="px-6 pt-6 pb-2">
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in"> <h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting()} {getGreeting(settings?.preferred_name || undefined)}
</h1> </h1>
<p className="text-muted-foreground text-sm mt-1"> <p className="text-muted-foreground text-sm mt-1">
{format(new Date(), 'EEEE, MMMM d, yyyy')} {format(new Date(), 'EEEE, MMMM d, yyyy')}
@ -88,6 +90,11 @@ export default function DashboardPage() {
</div> </div>
)} )}
{/* Smart Briefing */}
{upcomingData && (
<DayBriefing upcomingItems={upcomingData.items} dashboardData={data} />
)}
{/* Stats Row */} {/* Stats Row */}
<div className="animate-slide-up" style={{ animationDelay: '50ms', animationFillMode: 'backwards' }}> <div className="animate-slide-up" style={{ animationDelay: '50ms', animationFillMode: 'backwards' }}>
<StatsWidget <StatsWidget

View 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 (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

@ -72,8 +72,9 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
key={`${item.type}-${item.id}`} key={`${item.type}-${item.id}`}
className={cn( className={cn(
'w-1.5 h-1.5 rounded-full', '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 && ( {day.items.length > 4 && (

View File

@ -20,12 +20,24 @@ 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 {
@ -73,6 +85,34 @@ 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

@ -14,7 +14,7 @@ export function useSettings() {
}); });
const updateMutation = useMutation({ 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); const { data } = await api.put<Settings>('/settings', updates);
return data; return data;
}, },

View File

@ -2,6 +2,7 @@ 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;
} }