diff --git a/README.md b/README.md index 1f7ccd5..91cf80e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/alembic/versions/004_add_starred_and_weather_city.py b/backend/alembic/versions/004_add_starred_and_weather_city.py new file mode 100644 index 0000000..5070afe --- /dev/null +++ b/backend/alembic/versions/004_add_starred_and_weather_city.py @@ -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') diff --git a/backend/app/config.py b/backend/app/config.py index f00a2ec..b40d86e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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", diff --git a/backend/app/main.py b/backend/app/main.py index d731b2b..823a07f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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("/") diff --git a/backend/app/models/calendar_event.py b/backend/app/models/calendar_event.py index c1bdc68..7cce5f4 100644 --- a/backend/app/models/calendar_event.py +++ b/backend/app/models/calendar_event.py @@ -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()) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 2fd432e..e48bab5 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -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()) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 21ad031..3e51f42 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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: diff --git a/backend/app/routers/weather.py b/backend/app/routers/weather.py new file mode 100644 index 0000000..4321b69 --- /dev/null +++ b/backend/app/routers/weather.py @@ -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)}") diff --git a/backend/app/schemas/calendar_event.py b/backend/app/schemas/calendar_event.py index 2592369..ffa6d66 100644 --- a/backend/app/schemas/calendar_event.py +++ b/backend/app/schemas/calendar_event.py @@ -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 diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index 5556c48..9951b74 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -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 diff --git a/frontend/src/components/calendar/EventForm.tsx b/frontend/src/components/calendar/EventForm.tsx index 0d37c96..6e3dd13 100644 --- a/frontend/src/components/calendar/EventForm.tsx +++ b/frontend/src/components/calendar/EventForm.tsx @@ -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 +
+ setFormData({ ...formData, is_starred: (e.target as HTMLInputElement).checked })} + /> + +
+ {event && ( + {dropdownOpen && ( +
+ + + +
+ )} +
@@ -92,22 +158,26 @@ export default function DashboardPage() { {/* Smart Briefing */} {upcomingData && ( - + )} {/* Stats Row */}
{/* Main Content — 2 columns */}
{/* Left: Upcoming feed (wider) */} -
+
{upcomingData && upcomingData.items.length > 0 ? ( ) : ( @@ -124,8 +194,11 @@ export default function DashboardPage() { )}
- {/* Right: Today's events + todos stacked */} -
+ {/* Right: Countdown + Today's events + todos stacked */} +
+ {data.next_starred_event && ( + + )}
@@ -164,6 +237,11 @@ export default function DashboardPage() { )}
+ + {/* Quick Add Forms */} + {quickAddType === 'event' && setQuickAddType(null)} />} + {quickAddType === 'todo' && setQuickAddType(null)} />} + {quickAddType === 'reminder' && setQuickAddType(null)} />}
); } diff --git a/frontend/src/components/dashboard/DayBriefing.tsx b/frontend/src/components/dashboard/DayBriefing.tsx index 7f8ed19..91c70b7 100644 --- a/frontend/src/components/dashboard/DayBriefing.tsx +++ b/frontend/src/components/dashboard/DayBriefing.tsx @@ -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; diff --git a/frontend/src/components/dashboard/StatsWidget.tsx b/frontend/src/components/dashboard/StatsWidget.tsx index f9aff94..37bc1d1 100644 --- a/frontend/src/components/dashboard/StatsWidget.tsx +++ b/frontend/src/components/dashboard/StatsWidget.tsx @@ -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; }; - 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 ))} + + {/* Weather card */} + + +
+
+

+ WEATHER +

+ {weatherData ? ( + <> +

+ {weatherData.temp}° +

+

+ {weatherData.description} +

+ + ) : ( + <> +

+ — +

+

+ No city set +

+ + )} +
+
+ +
+
+
+
); } diff --git a/frontend/src/components/reminders/ReminderForm.tsx b/frontend/src/components/reminders/ReminderForm.tsx index 4be0a09..6777add 100644 --- a/frontend/src/components/reminders/ReminderForm.tsx +++ b/frontend/src/components/reminders/ReminderForm.tsx @@ -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(); }, diff --git a/frontend/src/components/settings/SettingsPage.tsx b/frontend/src/components/settings/SettingsPage.tsx index 917ef01..d8e0d43 100644 --- a/frontend/src/components/settings/SettingsPage.tsx +++ b/frontend/src/components/settings/SettingsPage.tsx @@ -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() { + + + Weather + Configure the weather widget on your dashboard + + +
+ +
+ setWeatherCity(e.target.value)} + onBlur={handleWeatherCitySave} + onKeyDown={(e) => { if (e.key === 'Enter') handleWeatherCitySave(); }} + className="max-w-xs" + maxLength={100} + /> +
+

+ City name for the weather widget. Requires an OpenWeatherMap API key in the server environment. +

+
+
+
+ Security diff --git a/frontend/src/components/todos/TodoForm.tsx b/frontend/src/components/todos/TodoForm.tsx index 7a40665..90bff72 100644 --- a/frontend/src/components/todos/TodoForm.tsx +++ b/frontend/src/components/todos/TodoForm.tsx @@ -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(); }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4a6c6d8..69c315e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; }; - 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 {