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