Dashboard Phase 2: weather widget, starred events, quick add, thinner events

- Add weather router with OpenWeatherMap integration and 1-hour cache
- Add is_starred column to calendar events with countdown widget
- Add weather_city setting with Settings page input
- Replace people/locations stats with open todos count + weather card
- Add quick-add dropdown (event/todo/reminder) to dashboard header
- Make CalendarWidget events single-line thin rows
- Add rain warnings to smart briefing when chance > 40%
- Invalidate dashboard/upcoming queries on form mutations
- Migration 004: is_starred + weather_city columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-20 13:15:43 +08:00
parent 10546de751
commit ca8b654471
20 changed files with 425 additions and 66 deletions

View File

@ -11,7 +11,8 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
- **Reminders** - Time-based reminders with dismiss functionality
- **People** - Contact directory with relationship tracking and task assignment
- **Locations** - Location management with categories
- **Settings** - Customizable accent color, upcoming days range, and PIN management
- **Weather** - Dashboard weather widget with temperature, conditions, and rain warnings
- **Settings** - Customizable accent color, upcoming days range, weather city, and PIN management
## Screenshots
@ -54,8 +55,11 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
POSTGRES_DB=umbra
DATABASE_URL=postgresql+asyncpg://umbra:your-secure-password@db:5432/umbra
SECRET_KEY=your-random-secret-key
OPENWEATHERMAP_API_KEY=your-openweathermap-api-key
```
> **Weather widget**: The dashboard weather widget requires a free [OpenWeatherMap](https://openweathermap.org/api) API key. Set `OPENWEATHERMAP_API_KEY` in `.env`, then configure your city in Settings.
3. **Build and run**
```bash
docker-compose up --build

View File

@ -0,0 +1,28 @@
"""Add is_starred to calendar_events and weather_city to settings
Revision ID: 004
Revises: 003
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 = '004'
down_revision: Union[str, None] = '003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('calendar_events', sa.Column('is_starred', sa.Boolean(), server_default=sa.text('false'), nullable=False))
op.add_column('settings', sa.Column('weather_city', sa.String(100), nullable=True))
def downgrade() -> None:
op.drop_column('calendar_events', 'is_starred')
op.drop_column('settings', 'weather_city')

View File

@ -7,6 +7,7 @@ class Settings(BaseSettings):
SECRET_KEY: str = "your-secret-key-change-in-production"
ENVIRONMENT: str = "development"
COOKIE_SECURE: bool = False
OPENWEATHERMAP_API_KEY: str = ""
model_config = SettingsConfigDict(
env_file=".env",

View File

@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from app.database import engine
from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard
from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard, weather
@asynccontextmanager
@ -38,6 +38,7 @@ app.include_router(people.router, prefix="/api/people", tags=["People"])
app.include_router(locations.router, prefix="/api/locations", tags=["Locations"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
@app.get("/")

View File

@ -17,6 +17,7 @@ class CalendarEvent(Base):
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), nullable=True)
recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
is_starred: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
created_at: Mapped[datetime] = mapped_column(default=func.now())
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())

View File

@ -12,5 +12,6 @@ class Settings(Base):
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)
weather_city: 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())

View File

@ -10,8 +10,6 @@ from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent
from app.models.reminder import Reminder
from app.models.project import Project
from app.models.person import Person
from app.models.location import Location
from app.routers.auth import get_current_session
router = APIRouter()
@ -65,13 +63,28 @@ async def get_dashboard(
projects_by_status_result = await db.execute(projects_by_status_query)
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
# Total people
total_people_result = await db.execute(select(func.count(Person.id)))
total_people = total_people_result.scalar()
# Total incomplete todos count
total_incomplete_result = await db.execute(
select(func.count(Todo.id)).where(Todo.completed == False)
)
total_incomplete_todos = total_incomplete_result.scalar()
# Total locations
total_locations_result = await db.execute(select(func.count(Location.id)))
total_locations = total_locations_result.scalar()
# Next starred event (soonest future starred event)
now = datetime.now()
starred_query = select(CalendarEvent).where(
CalendarEvent.is_starred == True,
CalendarEvent.start_datetime > now
).order_by(CalendarEvent.start_datetime.asc()).limit(1)
starred_result = await db.execute(starred_query)
next_starred = starred_result.scalar_one_or_none()
next_starred_event_data = None
if next_starred:
next_starred_event_data = {
"id": next_starred.id,
"title": next_starred.title,
"start_datetime": next_starred.start_datetime
}
return {
"todays_events": [
@ -81,7 +94,8 @@ async def get_dashboard(
"start_datetime": event.start_datetime,
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color
"color": event.color,
"is_starred": event.is_starred
}
for event in todays_events
],
@ -107,8 +121,8 @@ async def get_dashboard(
"total": total_projects,
"by_status": projects_by_status
},
"total_people": total_people,
"total_locations": total_locations
"total_incomplete_todos": total_incomplete_todos,
"next_starred_event": next_starred_event_data
}
@ -172,7 +186,8 @@ async def get_upcoming(
"date": event.start_datetime.date().isoformat(),
"datetime": event.start_datetime.isoformat(),
"all_day": event.all_day,
"color": event.color
"color": event.color,
"is_starred": event.is_starred
})
for reminder in reminders:

View File

@ -0,0 +1,78 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
import urllib.request
import urllib.parse
import urllib.error
import json
from app.database import get_db
from app.models.settings import Settings
from app.config import settings as app_settings
from app.routers.auth import get_current_session
router = APIRouter()
_cache: dict = {}
@router.get("/weather")
async def get_weather(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
# Check cache
now = datetime.now()
if _cache.get("expires_at") and now < _cache["expires_at"]:
return _cache["data"]
# Get city from settings
result = await db.execute(select(Settings))
settings_row = result.scalar_one_or_none()
city = settings_row.weather_city if settings_row else None
if not city:
raise HTTPException(status_code=400, detail="No weather city configured")
api_key = app_settings.OPENWEATHERMAP_API_KEY
if not api_key:
raise HTTPException(status_code=500, detail="Weather API key not configured")
try:
# Current weather
current_url = f"https://api.openweathermap.org/data/2.5/weather?q={urllib.parse.quote(city)}&units=metric&appid={api_key}"
with urllib.request.urlopen(current_url, timeout=10) as resp:
current_data = json.loads(resp.read().decode())
# Forecast for rain probability
forecast_url = f"https://api.openweathermap.org/data/2.5/forecast?q={urllib.parse.quote(city)}&units=metric&cnt=8&appid={api_key}"
with urllib.request.urlopen(forecast_url, timeout=10) as resp:
forecast_data = json.loads(resp.read().decode())
rain_chance = 0
for item in forecast_data.get("list", []):
pop = item.get("pop", 0)
if pop > rain_chance:
rain_chance = pop
weather_result = {
"temp": round(current_data["main"]["temp"]),
"temp_min": round(current_data["main"]["temp_min"]),
"temp_max": round(current_data["main"]["temp_max"]),
"description": current_data["weather"][0]["description"],
"rain_chance": round(rain_chance * 100),
"sunrise": current_data["sys"]["sunrise"],
"sunset": current_data["sys"]["sunset"],
"city": current_data["name"],
}
# Cache for 1 hour
_cache["data"] = weather_result
_cache["expires_at"] = now + timedelta(hours=1)
return weather_result
except urllib.error.URLError as e:
raise HTTPException(status_code=502, detail=f"Weather service unavailable: {str(e)}")
except (KeyError, json.JSONDecodeError) as e:
raise HTTPException(status_code=502, detail=f"Invalid weather data: {str(e)}")

View File

@ -12,6 +12,7 @@ class CalendarEventCreate(BaseModel):
color: Optional[str] = None
location_id: Optional[int] = None
recurrence_rule: Optional[str] = None
is_starred: bool = False
class CalendarEventUpdate(BaseModel):
@ -23,6 +24,7 @@ class CalendarEventUpdate(BaseModel):
color: Optional[str] = None
location_id: Optional[int] = None
recurrence_rule: Optional[str] = None
is_starred: Optional[bool] = None
class CalendarEventResponse(BaseModel):
@ -35,6 +37,7 @@ class CalendarEventResponse(BaseModel):
color: Optional[str]
location_id: Optional[int]
recurrence_rule: Optional[str]
is_starred: bool
created_at: datetime
updated_at: datetime

View File

@ -26,6 +26,7 @@ class SettingsUpdate(BaseModel):
accent_color: Optional[AccentColor] = None
upcoming_days: int | None = None
preferred_name: str | None = None
weather_city: str | None = None
class SettingsResponse(BaseModel):
@ -33,6 +34,7 @@ class SettingsResponse(BaseModel):
accent_color: str
upcoming_days: int
preferred_name: str | None = None
weather_city: str | None = None
created_at: datetime
updated_at: datetime

View File

@ -70,6 +70,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
location_id: event?.location_id?.toString() || '',
color: event?.color || '',
recurrence_rule: event?.recurrence_rule || '',
is_starred: event?.is_starred || false,
});
const { data: locations = [] } = useQuery({
@ -96,6 +97,8 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(event ? 'Event updated' : 'Event created');
onClose();
},
@ -110,6 +113,8 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success('Event deleted');
onClose();
},
@ -236,6 +241,15 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="is_starred"
checked={formData.is_starred}
onChange={(e) => setFormData({ ...formData, is_starred: (e.target as HTMLInputElement).checked })}
/>
<Label htmlFor="is_starred">Star this event</Label>
</div>
<DialogFooter>
{event && (
<Button

View File

@ -1,5 +1,5 @@
import { format } from 'date-fns';
import { Calendar, Clock } from 'lucide-react';
import { Calendar } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface DashboardEvent {
@ -9,6 +9,7 @@ interface DashboardEvent {
end_datetime: string;
all_day: boolean;
color?: string;
is_starred?: boolean;
}
interface CalendarWidgetProps {
@ -32,28 +33,22 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
No events today
</p>
) : (
<div className="space-y-2">
<div className="space-y-0.5">
{events.map((event) => (
<div
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-center gap-2.5 p-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
>
<div
className="w-1 h-full min-h-[2rem] rounded-full shrink-0"
className="w-[5px] h-[5px] rounded-full shrink-0"
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm">{event.title}</p>
{!event.all_day ? (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
<Clock className="h-3 w-3" />
{format(new Date(event.start_datetime), 'h:mm a')}
{event.end_datetime && ` ${format(new Date(event.end_datetime), 'h:mm a')}`}
</div>
) : (
<p className="text-xs text-muted-foreground mt-1">All day</p>
)}
</div>
<span className="text-xs text-muted-foreground w-[6.5rem] shrink-0 tabular-nums">
{event.all_day
? 'All day'
: `${format(new Date(event.start_datetime), 'h:mm a')} ${format(new Date(event.end_datetime), 'h:mm a')}`}
</span>
<span className="text-sm font-medium truncate">{event.title}</span>
</div>
))}
</div>

View File

@ -0,0 +1,32 @@
import { differenceInCalendarDays } from 'date-fns';
import { Star } from 'lucide-react';
interface CountdownWidgetProps {
event: {
id: number;
title: string;
start_datetime: string;
};
}
export default function CountdownWidget({ event }: CountdownWidgetProps) {
const daysUntil = differenceInCalendarDays(new Date(event.start_datetime), new Date());
if (daysUntil < 0) return null;
const label = daysUntil === 0
? 'Today'
: daysUntil === 1
? '1 day'
: `${daysUntil} days`;
return (
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-lg bg-amber-500/[0.07] border border-amber-500/10">
<Star className="h-3.5 w-3.5 text-amber-400 fill-amber-400 shrink-0" />
<span className="text-sm text-amber-200/90 truncate">
<span className="font-semibold tabular-nums">{label}</span>
{daysUntil > 0 ? ' until ' : ' — '}
<span className="font-medium text-foreground">{event.title}</span>
</span>
</div>
);
}

View File

@ -1,8 +1,9 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Bell } from 'lucide-react';
import { Bell, Plus, Calendar as CalIcon, ListTodo, Bell as BellIcon } from 'lucide-react';
import api from '@/lib/api';
import type { DashboardData, UpcomingResponse } from '@/types';
import type { DashboardData, UpcomingResponse, WeatherData } from '@/types';
import { useSettings } from '@/hooks/useSettings';
import StatsWidget from './StatsWidget';
import TodoWidget from './TodoWidget';
@ -10,7 +11,12 @@ import CalendarWidget from './CalendarWidget';
import UpcomingWidget from './UpcomingWidget';
import WeekTimeline from './WeekTimeline';
import DayBriefing from './DayBriefing';
import CountdownWidget from './CountdownWidget';
import EventForm from '../calendar/EventForm';
import TodoForm from '../todos/TodoForm';
import ReminderForm from '../reminders/ReminderForm';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { DashboardSkeleton } from '@/components/ui/skeleton';
function getGreeting(name?: string): string {
@ -25,6 +31,19 @@ function getGreeting(name?: string): string {
export default function DashboardPage() {
const { settings } = useSettings();
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
if (dropdownOpen) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [dropdownOpen]);
const { data, isLoading } = useQuery({
queryKey: ['dashboard'],
@ -45,6 +64,16 @@ export default function DashboardPage() {
},
});
const { data: weatherData } = useQuery<WeatherData>({
queryKey: ['weather'],
queryFn: async () => {
const { data } = await api.get<WeatherData>('/weather');
return data;
},
staleTime: 30 * 60 * 1000,
retry: false,
});
if (isLoading) {
return (
<div className="flex flex-col h-full">
@ -71,8 +100,9 @@ export default function DashboardPage() {
return (
<div className="flex flex-col h-full">
{/* Header — greeting + date */}
<div className="px-6 pt-6 pb-2">
{/* Header — greeting + date + quick add */}
<div className="px-6 pt-6 pb-2 flex items-center justify-between">
<div>
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting(settings?.preferred_name || undefined)}
</h1>
@ -80,6 +110,42 @@ export default function DashboardPage() {
{format(new Date(), 'EEEE, MMMM d, yyyy')}
</p>
</div>
<div className="relative" ref={dropdownRef}>
<Button
variant="outline"
size="icon"
onClick={() => setDropdownOpen(!dropdownOpen)}
className="h-9 w-9"
>
<Plus className="h-4 w-4" />
</Button>
{dropdownOpen && (
<div className="absolute right-0 top-full mt-1.5 w-44 rounded-lg border bg-popover shadow-xl z-50 py-1 animate-fade-in">
<button
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setQuickAddType('event'); setDropdownOpen(false); }}
>
<CalIcon className="h-4 w-4 text-purple-400" />
Event
</button>
<button
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setQuickAddType('todo'); setDropdownOpen(false); }}
>
<ListTodo className="h-4 w-4 text-blue-400" />
Todo
</button>
<button
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm hover:bg-card-elevated transition-colors"
onClick={() => { setQuickAddType('reminder'); setDropdownOpen(false); }}
>
<BellIcon className="h-4 w-4 text-orange-400" />
Reminder
</button>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<div className="space-y-5">
@ -92,22 +158,26 @@ export default function DashboardPage() {
{/* Smart Briefing */}
{upcomingData && (
<DayBriefing upcomingItems={upcomingData.items} dashboardData={data} />
<DayBriefing
upcomingItems={upcomingData.items}
dashboardData={data}
weatherData={weatherData || null}
/>
)}
{/* Stats Row */}
<div className="animate-slide-up" style={{ animationDelay: '50ms', animationFillMode: 'backwards' }}>
<StatsWidget
projectStats={data.project_stats}
totalPeople={data.total_people}
totalLocations={data.total_locations}
totalIncompleteTodos={data.total_incomplete_todos}
weatherData={weatherData || null}
/>
</div>
{/* Main Content — 2 columns */}
<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">
<div className="lg:col-span-3 flex flex-col">
{upcomingData && upcomingData.items.length > 0 ? (
<UpcomingWidget items={upcomingData.items} days={upcomingData.days} />
) : (
@ -124,8 +194,11 @@ export default function DashboardPage() {
)}
</div>
{/* Right: Today's events + todos stacked */}
<div className="lg:col-span-2 space-y-5">
{/* Right: Countdown + Today's events + todos stacked */}
<div className="lg:col-span-2 flex flex-col gap-5">
{data.next_starred_event && (
<CountdownWidget event={data.next_starred_event} />
)}
<CalendarWidget events={data.todays_events} />
<TodoWidget todos={data.upcoming_todos} />
</div>
@ -164,6 +237,11 @@ export default function DashboardPage() {
)}
</div>
</div>
{/* Quick Add Forms */}
{quickAddType === 'event' && <EventForm event={null} onClose={() => setQuickAddType(null)} />}
{quickAddType === 'todo' && <TodoForm todo={null} onClose={() => setQuickAddType(null)} />}
{quickAddType === 'reminder' && <ReminderForm reminder={null} onClose={() => setQuickAddType(null)} />}
</div>
);
}

View File

@ -5,6 +5,7 @@ import type { UpcomingItem, DashboardData } from '@/types';
interface DayBriefingProps {
upcomingItems: UpcomingItem[];
dashboardData: DashboardData;
weatherData?: { rain_chance: number; description: string } | null;
}
function getItemTime(item: UpcomingItem): string {
@ -14,7 +15,7 @@ function getItemTime(item: UpcomingItem): string {
return '';
}
export default function DayBriefing({ upcomingItems, dashboardData }: DayBriefingProps) {
export default function DayBriefing({ upcomingItems, dashboardData, weatherData }: DayBriefingProps) {
const briefing = useMemo(() => {
const now = new Date();
const hour = now.getHours();
@ -118,8 +119,19 @@ export default function DayBriefing({ upcomingItems, dashboardData }: DayBriefin
parts.push(`Don't forget: ${nextReminder.title} at ${remindTime}.`);
}
// Weather rain warning
if (weatherData && weatherData.rain_chance > 40) {
if (hour >= 5 && hour < 12) {
parts.push(`There's a ${weatherData.rain_chance}% chance of rain today — might want to grab an umbrella.`);
} else if (hour >= 12 && hour < 17) {
parts.push("Heads up, rain's looking likely this afternoon.");
} else {
parts.push(`Forecasts show ${weatherData.rain_chance}% chance of rain tomorrow, you might want to plan ahead.`);
}
}
return parts.join(' ');
}, [upcomingItems, dashboardData]);
}, [upcomingItems, dashboardData, weatherData]);
if (!briefing) return null;

View File

@ -1,4 +1,4 @@
import { FolderKanban, Users, MapPin, TrendingUp } from 'lucide-react';
import { FolderKanban, TrendingUp, CheckSquare, CloudSun } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
interface StatsWidgetProps {
@ -6,11 +6,11 @@ interface StatsWidgetProps {
total: number;
by_status: Record<string, number>;
};
totalPeople: number;
totalLocations: number;
totalIncompleteTodos: number;
weatherData?: { temp: number; description: string } | null;
}
export default function StatsWidget({ projectStats, totalPeople, totalLocations }: StatsWidgetProps) {
export default function StatsWidget({ projectStats, totalIncompleteTodos, weatherData }: StatsWidgetProps) {
const statCards = [
{
label: 'PROJECTS',
@ -27,18 +27,11 @@ export default function StatsWidget({ projectStats, totalPeople, totalLocations
glowBg: 'bg-purple-500/10',
},
{
label: 'PEOPLE',
value: totalPeople,
icon: Users,
color: 'text-emerald-400',
glowBg: 'bg-emerald-500/10',
},
{
label: 'LOCATIONS',
value: totalLocations,
icon: MapPin,
color: 'text-orange-400',
glowBg: 'bg-orange-500/10',
label: 'OPEN TODOS',
value: totalIncompleteTodos,
icon: CheckSquare,
color: 'text-teal-400',
glowBg: 'bg-teal-500/10',
},
];
@ -63,6 +56,41 @@ export default function StatsWidget({ projectStats, totalPeople, totalLocations
</CardContent>
</Card>
))}
{/* Weather card */}
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-[11px] font-medium tracking-wider text-muted-foreground">
WEATHER
</p>
{weatherData ? (
<>
<p className="font-heading text-3xl font-bold tabular-nums leading-none">
{weatherData.temp}°
</p>
<p className="text-[11px] text-muted-foreground capitalize leading-tight">
{weatherData.description}
</p>
</>
) : (
<>
<p className="font-heading text-3xl font-bold tabular-nums leading-none text-muted-foreground/50">
</p>
<p className="text-[11px] text-muted-foreground/50 leading-tight">
No city set
</p>
</>
)}
</div>
<div className="p-2 rounded-lg bg-amber-500/10">
<CloudSun className="h-5 w-5 text-amber-400" />
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -43,6 +43,8 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reminders'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(reminder ? 'Reminder updated' : 'Reminder created');
onClose();
},

View File

@ -1,5 +1,6 @@
import { useState, FormEvent, CSSProperties } from 'react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@ -17,10 +18,12 @@ const accentColors = [
];
export default function SettingsPage() {
const queryClient = useQueryClient();
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 [weatherCity, setWeatherCity] = useState(settings?.weather_city ?? '');
const [pinForm, setPinForm] = useState({
oldPin: '',
newPin: '',
@ -38,6 +41,18 @@ export default function SettingsPage() {
}
};
const handleWeatherCitySave = async () => {
const trimmed = weatherCity.trim();
if (trimmed === (settings?.weather_city || '')) return;
try {
await updateSettings({ weather_city: trimmed || null });
queryClient.invalidateQueries({ queryKey: ['weather'] });
toast.success('Weather city updated');
} catch (error) {
toast.error('Failed to update weather city');
}
};
const handleColorChange = async (color: string) => {
setSelectedColor(color);
try {
@ -179,6 +194,34 @@ export default function SettingsPage() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Weather</CardTitle>
<CardDescription>Configure the weather widget on your dashboard</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="weather_city">City</Label>
<div className="flex gap-3 items-center">
<Input
id="weather_city"
type="text"
placeholder="e.g. Sydney, AU"
value={weatherCity}
onChange={(e) => setWeatherCity(e.target.value)}
onBlur={handleWeatherCitySave}
onKeyDown={(e) => { if (e.key === 'Enter') handleWeatherCitySave(); }}
className="max-w-xs"
maxLength={100}
/>
</div>
<p className="text-sm text-muted-foreground">
City name for the weather widget. Requires an OpenWeatherMap API key in the server environment.
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Security</CardTitle>

View File

@ -45,6 +45,8 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
toast.success(todo ? 'Todo updated' : 'Todo created');
onClose();
},

View File

@ -3,6 +3,7 @@ export interface Settings {
accent_color: string;
upcoming_days: number;
preferred_name?: string | null;
weather_city?: string | null;
created_at: string;
updated_at: string;
}
@ -32,6 +33,7 @@ export interface CalendarEvent {
location_id?: number;
color?: string;
recurrence_rule?: string;
is_starred?: boolean;
created_at: string;
updated_at: string;
}
@ -112,6 +114,7 @@ export interface DashboardData {
end_datetime: string;
all_day: boolean;
color?: string;
is_starred?: boolean;
}>;
upcoming_todos: Array<{
id: number;
@ -129,8 +132,23 @@ export interface DashboardData {
total: number;
by_status: Record<string, number>;
};
total_people: number;
total_locations: number;
total_incomplete_todos: number;
next_starred_event: {
id: number;
title: string;
start_datetime: string;
} | null;
}
export interface WeatherData {
temp: number;
temp_min: number;
temp_max: number;
description: string;
rain_chance: number;
sunrise: number;
sunset: number;
city: string;
}
export interface UpcomingItem {
@ -143,6 +161,7 @@ export interface UpcomingItem {
category?: string;
all_day?: boolean;
color?: string;
is_starred?: boolean;
}
export interface UpcomingResponse {