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)
|
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())
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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}`}
|
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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user