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:
parent
10546de751
commit
ca8b654471
@ -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
|
||||
|
||||
28
backend/alembic/versions/004_add_starred_and_weather_city.py
Normal file
28
backend/alembic/versions/004_add_starred_and_weather_city.py
Normal 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')
|
||||
@ -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",
|
||||
|
||||
@ -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("/")
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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:
|
||||
|
||||
78
backend/app/routers/weather.py
Normal file
78
backend/app/routers/weather.py
Normal 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)}")
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
32
frontend/src/components/dashboard/CountdownWidget.tsx
Normal file
32
frontend/src/components/dashboard/CountdownWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,14 +100,51 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 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(settings?.preferred_name || undefined)}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{format(new Date(), 'EEEE, MMMM d, yyyy')}
|
||||
</p>
|
||||
{/* 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>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
{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">
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
},
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user