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
|
- **Reminders** - Time-based reminders with dismiss functionality
|
||||||
- **People** - Contact directory with relationship tracking and task assignment
|
- **People** - Contact directory with relationship tracking and task assignment
|
||||||
- **Locations** - Location management with categories
|
- **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
|
## Screenshots
|
||||||
|
|
||||||
@ -54,8 +55,11 @@ A self-hosted personal life administration app with a dark-themed UI. Manage you
|
|||||||
POSTGRES_DB=umbra
|
POSTGRES_DB=umbra
|
||||||
DATABASE_URL=postgresql+asyncpg://umbra:your-secure-password@db:5432/umbra
|
DATABASE_URL=postgresql+asyncpg://umbra:your-secure-password@db:5432/umbra
|
||||||
SECRET_KEY=your-random-secret-key
|
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**
|
3. **Build and run**
|
||||||
```bash
|
```bash
|
||||||
docker-compose up --build
|
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"
|
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||||
ENVIRONMENT: str = "development"
|
ENVIRONMENT: str = "development"
|
||||||
COOKIE_SECURE: bool = False
|
COOKIE_SECURE: bool = False
|
||||||
|
OPENWEATHERMAP_API_KEY: str = ""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from app.database import engine
|
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
|
@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(locations.router, prefix="/api/locations", tags=["Locations"])
|
||||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
|
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
|
||||||
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
|
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
|
||||||
|
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -17,6 +17,7 @@ class CalendarEvent(Base):
|
|||||||
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||||
location_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("locations.id"), 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)
|
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())
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|||||||
@ -12,5 +12,6 @@ class Settings(Base):
|
|||||||
accent_color: Mapped[str] = mapped_column(String(20), default="cyan")
|
accent_color: Mapped[str] = mapped_column(String(20), default="cyan")
|
||||||
upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
|
upcoming_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||||
preferred_name: Mapped[str | None] = mapped_column(String(100), nullable=True, default=None)
|
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())
|
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||||
|
|||||||
@ -10,8 +10,6 @@ from app.models.todo import Todo
|
|||||||
from app.models.calendar_event import CalendarEvent
|
from app.models.calendar_event import CalendarEvent
|
||||||
from app.models.reminder import Reminder
|
from app.models.reminder import Reminder
|
||||||
from app.models.project import Project
|
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
|
from app.routers.auth import get_current_session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -65,13 +63,28 @@ async def get_dashboard(
|
|||||||
projects_by_status_result = await db.execute(projects_by_status_query)
|
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}
|
projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
|
||||||
|
|
||||||
# Total people
|
# Total incomplete todos count
|
||||||
total_people_result = await db.execute(select(func.count(Person.id)))
|
total_incomplete_result = await db.execute(
|
||||||
total_people = total_people_result.scalar()
|
select(func.count(Todo.id)).where(Todo.completed == False)
|
||||||
|
)
|
||||||
|
total_incomplete_todos = total_incomplete_result.scalar()
|
||||||
|
|
||||||
# Total locations
|
# Next starred event (soonest future starred event)
|
||||||
total_locations_result = await db.execute(select(func.count(Location.id)))
|
now = datetime.now()
|
||||||
total_locations = total_locations_result.scalar()
|
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 {
|
return {
|
||||||
"todays_events": [
|
"todays_events": [
|
||||||
@ -81,7 +94,8 @@ async def get_dashboard(
|
|||||||
"start_datetime": event.start_datetime,
|
"start_datetime": event.start_datetime,
|
||||||
"end_datetime": event.end_datetime,
|
"end_datetime": event.end_datetime,
|
||||||
"all_day": event.all_day,
|
"all_day": event.all_day,
|
||||||
"color": event.color
|
"color": event.color,
|
||||||
|
"is_starred": event.is_starred
|
||||||
}
|
}
|
||||||
for event in todays_events
|
for event in todays_events
|
||||||
],
|
],
|
||||||
@ -107,8 +121,8 @@ async def get_dashboard(
|
|||||||
"total": total_projects,
|
"total": total_projects,
|
||||||
"by_status": projects_by_status
|
"by_status": projects_by_status
|
||||||
},
|
},
|
||||||
"total_people": total_people,
|
"total_incomplete_todos": total_incomplete_todos,
|
||||||
"total_locations": total_locations
|
"next_starred_event": next_starred_event_data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -172,7 +186,8 @@ async def get_upcoming(
|
|||||||
"date": event.start_datetime.date().isoformat(),
|
"date": event.start_datetime.date().isoformat(),
|
||||||
"datetime": event.start_datetime.isoformat(),
|
"datetime": event.start_datetime.isoformat(),
|
||||||
"all_day": event.all_day,
|
"all_day": event.all_day,
|
||||||
"color": event.color
|
"color": event.color,
|
||||||
|
"is_starred": event.is_starred
|
||||||
})
|
})
|
||||||
|
|
||||||
for reminder in reminders:
|
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
|
color: Optional[str] = None
|
||||||
location_id: Optional[int] = None
|
location_id: Optional[int] = None
|
||||||
recurrence_rule: Optional[str] = None
|
recurrence_rule: Optional[str] = None
|
||||||
|
is_starred: bool = False
|
||||||
|
|
||||||
|
|
||||||
class CalendarEventUpdate(BaseModel):
|
class CalendarEventUpdate(BaseModel):
|
||||||
@ -23,6 +24,7 @@ class CalendarEventUpdate(BaseModel):
|
|||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
location_id: Optional[int] = None
|
location_id: Optional[int] = None
|
||||||
recurrence_rule: Optional[str] = None
|
recurrence_rule: Optional[str] = None
|
||||||
|
is_starred: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class CalendarEventResponse(BaseModel):
|
class CalendarEventResponse(BaseModel):
|
||||||
@ -35,6 +37,7 @@ class CalendarEventResponse(BaseModel):
|
|||||||
color: Optional[str]
|
color: Optional[str]
|
||||||
location_id: Optional[int]
|
location_id: Optional[int]
|
||||||
recurrence_rule: Optional[str]
|
recurrence_rule: Optional[str]
|
||||||
|
is_starred: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ class SettingsUpdate(BaseModel):
|
|||||||
accent_color: Optional[AccentColor] = None
|
accent_color: Optional[AccentColor] = None
|
||||||
upcoming_days: int | None = None
|
upcoming_days: int | None = None
|
||||||
preferred_name: str | None = None
|
preferred_name: str | None = None
|
||||||
|
weather_city: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SettingsResponse(BaseModel):
|
class SettingsResponse(BaseModel):
|
||||||
@ -33,6 +34,7 @@ class SettingsResponse(BaseModel):
|
|||||||
accent_color: str
|
accent_color: str
|
||||||
upcoming_days: int
|
upcoming_days: int
|
||||||
preferred_name: str | None = None
|
preferred_name: str | None = None
|
||||||
|
weather_city: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
location_id: event?.location_id?.toString() || '',
|
location_id: event?.location_id?.toString() || '',
|
||||||
color: event?.color || '',
|
color: event?.color || '',
|
||||||
recurrence_rule: event?.recurrence_rule || '',
|
recurrence_rule: event?.recurrence_rule || '',
|
||||||
|
is_starred: event?.is_starred || false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: locations = [] } = useQuery({
|
const { data: locations = [] } = useQuery({
|
||||||
@ -96,6 +97,8 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||||
toast.success(event ? 'Event updated' : 'Event created');
|
toast.success(event ? 'Event updated' : 'Event created');
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
@ -110,6 +113,8 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||||
toast.success('Event deleted');
|
toast.success('Event deleted');
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
@ -236,6 +241,15 @@ export default function EventForm({ event, initialStart, initialEnd, initialAllD
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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>
|
<DialogFooter>
|
||||||
{event && (
|
{event && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { format } from 'date-fns';
|
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';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
interface DashboardEvent {
|
interface DashboardEvent {
|
||||||
@ -9,6 +9,7 @@ interface DashboardEvent {
|
|||||||
end_datetime: string;
|
end_datetime: string;
|
||||||
all_day: boolean;
|
all_day: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
is_starred?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalendarWidgetProps {
|
interface CalendarWidgetProps {
|
||||||
@ -32,28 +33,22 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
|||||||
No events today
|
No events today
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-0.5">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
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
|
<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))' }}
|
style={{ backgroundColor: event.color || 'hsl(var(--primary))' }}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<span className="text-xs text-muted-foreground w-[6.5rem] shrink-0 tabular-nums">
|
||||||
<p className="font-medium text-sm">{event.title}</p>
|
{event.all_day
|
||||||
{!event.all_day ? (
|
? 'All day'
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
|
: `${format(new Date(event.start_datetime), 'h:mm a')} – ${format(new Date(event.end_datetime), 'h:mm a')}`}
|
||||||
<Clock className="h-3 w-3" />
|
</span>
|
||||||
{format(new Date(event.start_datetime), 'h:mm a')}
|
<span className="text-sm font-medium truncate">{event.title}</span>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 { useQuery } from '@tanstack/react-query';
|
||||||
import { format } from 'date-fns';
|
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 api from '@/lib/api';
|
||||||
import type { DashboardData, UpcomingResponse } from '@/types';
|
import type { DashboardData, UpcomingResponse, WeatherData } from '@/types';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import StatsWidget from './StatsWidget';
|
import StatsWidget from './StatsWidget';
|
||||||
import TodoWidget from './TodoWidget';
|
import TodoWidget from './TodoWidget';
|
||||||
@ -10,7 +11,12 @@ import CalendarWidget from './CalendarWidget';
|
|||||||
import UpcomingWidget from './UpcomingWidget';
|
import UpcomingWidget from './UpcomingWidget';
|
||||||
import WeekTimeline from './WeekTimeline';
|
import WeekTimeline from './WeekTimeline';
|
||||||
import DayBriefing from './DayBriefing';
|
import 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { DashboardSkeleton } from '@/components/ui/skeleton';
|
import { DashboardSkeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
function getGreeting(name?: string): string {
|
function getGreeting(name?: string): string {
|
||||||
@ -25,6 +31,19 @@ function getGreeting(name?: string): string {
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { settings } = useSettings();
|
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({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['dashboard'],
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@ -71,14 +100,51 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header — greeting + date */}
|
{/* Header — greeting + date + quick add */}
|
||||||
<div className="px-6 pt-6 pb-2">
|
<div className="px-6 pt-6 pb-2 flex items-center justify-between">
|
||||||
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
<div>
|
||||||
{getGreeting(settings?.preferred_name || undefined)}
|
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
|
||||||
</h1>
|
{getGreeting(settings?.preferred_name || undefined)}
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
</h1>
|
||||||
{format(new Date(), 'EEEE, MMMM d, yyyy')}
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
</p>
|
{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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||||
@ -92,22 +158,26 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Smart Briefing */}
|
{/* Smart Briefing */}
|
||||||
{upcomingData && (
|
{upcomingData && (
|
||||||
<DayBriefing upcomingItems={upcomingData.items} dashboardData={data} />
|
<DayBriefing
|
||||||
|
upcomingItems={upcomingData.items}
|
||||||
|
dashboardData={data}
|
||||||
|
weatherData={weatherData || null}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Row */}
|
{/* Stats Row */}
|
||||||
<div className="animate-slide-up" style={{ animationDelay: '50ms', animationFillMode: 'backwards' }}>
|
<div className="animate-slide-up" style={{ animationDelay: '50ms', animationFillMode: 'backwards' }}>
|
||||||
<StatsWidget
|
<StatsWidget
|
||||||
projectStats={data.project_stats}
|
projectStats={data.project_stats}
|
||||||
totalPeople={data.total_people}
|
totalIncompleteTodos={data.total_incomplete_todos}
|
||||||
totalLocations={data.total_locations}
|
weatherData={weatherData || null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content — 2 columns */}
|
{/* Main Content — 2 columns */}
|
||||||
<div className="grid gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
|
<div className="grid gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
|
||||||
{/* Left: Upcoming feed (wider) */}
|
{/* Left: Upcoming feed (wider) */}
|
||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3 flex flex-col">
|
||||||
{upcomingData && upcomingData.items.length > 0 ? (
|
{upcomingData && upcomingData.items.length > 0 ? (
|
||||||
<UpcomingWidget items={upcomingData.items} days={upcomingData.days} />
|
<UpcomingWidget items={upcomingData.items} days={upcomingData.days} />
|
||||||
) : (
|
) : (
|
||||||
@ -124,8 +194,11 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Today's events + todos stacked */}
|
{/* Right: Countdown + Today's events + todos stacked */}
|
||||||
<div className="lg:col-span-2 space-y-5">
|
<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} />
|
<CalendarWidget events={data.todays_events} />
|
||||||
<TodoWidget todos={data.upcoming_todos} />
|
<TodoWidget todos={data.upcoming_todos} />
|
||||||
</div>
|
</div>
|
||||||
@ -164,6 +237,11 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type { UpcomingItem, DashboardData } from '@/types';
|
|||||||
interface DayBriefingProps {
|
interface DayBriefingProps {
|
||||||
upcomingItems: UpcomingItem[];
|
upcomingItems: UpcomingItem[];
|
||||||
dashboardData: DashboardData;
|
dashboardData: DashboardData;
|
||||||
|
weatherData?: { rain_chance: number; description: string } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemTime(item: UpcomingItem): string {
|
function getItemTime(item: UpcomingItem): string {
|
||||||
@ -14,7 +15,7 @@ function getItemTime(item: UpcomingItem): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DayBriefing({ upcomingItems, dashboardData }: DayBriefingProps) {
|
export default function DayBriefing({ upcomingItems, dashboardData, weatherData }: DayBriefingProps) {
|
||||||
const briefing = useMemo(() => {
|
const briefing = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const hour = now.getHours();
|
const hour = now.getHours();
|
||||||
@ -118,8 +119,19 @@ export default function DayBriefing({ upcomingItems, dashboardData }: DayBriefin
|
|||||||
parts.push(`Don't forget: ${nextReminder.title} at ${remindTime}.`);
|
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(' ');
|
return parts.join(' ');
|
||||||
}, [upcomingItems, dashboardData]);
|
}, [upcomingItems, dashboardData, weatherData]);
|
||||||
|
|
||||||
if (!briefing) return null;
|
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';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
interface StatsWidgetProps {
|
interface StatsWidgetProps {
|
||||||
@ -6,11 +6,11 @@ interface StatsWidgetProps {
|
|||||||
total: number;
|
total: number;
|
||||||
by_status: Record<string, number>;
|
by_status: Record<string, number>;
|
||||||
};
|
};
|
||||||
totalPeople: number;
|
totalIncompleteTodos: number;
|
||||||
totalLocations: 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 = [
|
const statCards = [
|
||||||
{
|
{
|
||||||
label: 'PROJECTS',
|
label: 'PROJECTS',
|
||||||
@ -27,18 +27,11 @@ export default function StatsWidget({ projectStats, totalPeople, totalLocations
|
|||||||
glowBg: 'bg-purple-500/10',
|
glowBg: 'bg-purple-500/10',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'PEOPLE',
|
label: 'OPEN TODOS',
|
||||||
value: totalPeople,
|
value: totalIncompleteTodos,
|
||||||
icon: Users,
|
icon: CheckSquare,
|
||||||
color: 'text-emerald-400',
|
color: 'text-teal-400',
|
||||||
glowBg: 'bg-emerald-500/10',
|
glowBg: 'bg-teal-500/10',
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'LOCATIONS',
|
|
||||||
value: totalLocations,
|
|
||||||
icon: MapPin,
|
|
||||||
color: 'text-orange-400',
|
|
||||||
glowBg: 'bg-orange-500/10',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -63,6 +56,41 @@ export default function StatsWidget({ projectStats, totalPeople, totalLocations
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,8 @@ export default function ReminderForm({ reminder, onClose }: ReminderFormProps) {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
queryClient.invalidateQueries({ queryKey: ['reminders'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||||
toast.success(reminder ? 'Reminder updated' : 'Reminder created');
|
toast.success(reminder ? 'Reminder updated' : 'Reminder created');
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useState, FormEvent, CSSProperties } from 'react';
|
import { useState, FormEvent, CSSProperties } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -17,10 +18,12 @@ const accentColors = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings();
|
const { settings, updateSettings, changePin, isUpdating, isChangingPin } = useSettings();
|
||||||
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
const [selectedColor, setSelectedColor] = useState(settings?.accent_color || 'cyan');
|
||||||
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
const [upcomingDays, setUpcomingDays] = useState(settings?.upcoming_days || 7);
|
||||||
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
|
const [preferredName, setPreferredName] = useState(settings?.preferred_name ?? '');
|
||||||
|
const [weatherCity, setWeatherCity] = useState(settings?.weather_city ?? '');
|
||||||
const [pinForm, setPinForm] = useState({
|
const [pinForm, setPinForm] = useState({
|
||||||
oldPin: '',
|
oldPin: '',
|
||||||
newPin: '',
|
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) => {
|
const handleColorChange = async (color: string) => {
|
||||||
setSelectedColor(color);
|
setSelectedColor(color);
|
||||||
try {
|
try {
|
||||||
@ -179,6 +194,34 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Security</CardTitle>
|
<CardTitle>Security</CardTitle>
|
||||||
|
|||||||
@ -45,6 +45,8 @@ export default function TodoForm({ todo, onClose }: TodoFormProps) {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
queryClient.invalidateQueries({ queryKey: ['todos'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
|
||||||
toast.success(todo ? 'Todo updated' : 'Todo created');
|
toast.success(todo ? 'Todo updated' : 'Todo created');
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,7 @@ export interface Settings {
|
|||||||
accent_color: string;
|
accent_color: string;
|
||||||
upcoming_days: number;
|
upcoming_days: number;
|
||||||
preferred_name?: string | null;
|
preferred_name?: string | null;
|
||||||
|
weather_city?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -32,6 +33,7 @@ export interface CalendarEvent {
|
|||||||
location_id?: number;
|
location_id?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
recurrence_rule?: string;
|
recurrence_rule?: string;
|
||||||
|
is_starred?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -112,6 +114,7 @@ export interface DashboardData {
|
|||||||
end_datetime: string;
|
end_datetime: string;
|
||||||
all_day: boolean;
|
all_day: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
is_starred?: boolean;
|
||||||
}>;
|
}>;
|
||||||
upcoming_todos: Array<{
|
upcoming_todos: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@ -129,8 +132,23 @@ export interface DashboardData {
|
|||||||
total: number;
|
total: number;
|
||||||
by_status: Record<string, number>;
|
by_status: Record<string, number>;
|
||||||
};
|
};
|
||||||
total_people: number;
|
total_incomplete_todos: number;
|
||||||
total_locations: 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 {
|
export interface UpcomingItem {
|
||||||
@ -143,6 +161,7 @@ export interface UpcomingItem {
|
|||||||
category?: string;
|
category?: string;
|
||||||
all_day?: boolean;
|
all_day?: boolean;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
is_starred?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpcomingResponse {
|
export interface UpcomingResponse {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user