commit 1f6519635f2691b99a007886d59adcea8cd27714 Author: Kyle Pope Date: Sun Feb 15 16:13:41 2026 +0800 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad94979 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Database +POSTGRES_USER=lifemanager +POSTGRES_PASSWORD=changeme_in_production +POSTGRES_DB=lifemanager + +# Backend +DATABASE_URL=postgresql+asyncpg://lifemanager:changeme_in_production@db:5432/lifemanager +SECRET_KEY=change-this-to-a-random-secret-key-in-production diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d896f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Environment +.env + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yaml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..91c93a7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md - LifeManager + +## Hard Rules + +- **Naive datetimes only.** The DB uses `TIMESTAMP WITHOUT TIME ZONE`. Never send timezone-aware strings (no `Z` suffix, no `.toISOString()`). Use local datetime formatting helpers instead. +- **Eager load relationships in async SQLAlchemy.** Any `response_model` that includes a relationship (e.g. `ProjectResponse.tasks`) must use `selectinload()` when querying. Lazy loading raises `MissingGreenlet` in async context. +- **Never shadow SQLAlchemy names.** Model columns must not be named `relationship`, `column`, `metadata`, or other SQLAlchemy reserved names. The `person.py` model aliases the import as `sa_relationship` for this reason. +- **Frontend date inputs require exact formats.** `` needs `YYYY-MM-DD`, `` needs `YYYY-MM-DDThh:mm`. Backend may return `2026-02-15T00:00:00` which must be sliced/converted before binding. +- **All API routes are prefixed with `/api`.** Frontend axios base URL is `/api`. Nginx proxies `/api/` to backend:8000. Never duplicate the prefix. + +## Tech Stack + +### Backend +- **Python 3.12** (slim Docker image - no curl, use `urllib` for healthchecks) +- **FastAPI** with async lifespan, Pydantic v2 (`model_dump()`, `ConfigDict(from_attributes=True)`) +- **SQLAlchemy 2.0** async with `Mapped[]` type hints, `mapped_column()`, `async_sessionmaker` +- **PostgreSQL 16** (Alpine) via `asyncpg` +- **Alembic** for migrations +- **Auth:** PIN-based with bcrypt + itsdangerous signed cookies (not JWT) + +### Frontend +- **React 18** + TypeScript + Vite 6 +- **TanStack Query v5** for server state (`useQuery`, `useMutation`, `invalidateQueries`) +- **FullCalendar 6** (dayGrid, timeGrid, interaction plugins) +- **Tailwind CSS 3** with custom dark theme + accent color CSS variables +- **Sonner** for toast notifications +- **Lucide React** for icons +- Custom shadcn/ui-style components in `frontend/src/components/ui/` + +### Infrastructure +- **Docker Compose** - 3 services: `db`, `backend`, `frontend` +- **Nginx** (Alpine) serves frontend SPA, proxies `/api/` to backend +- Frontend served on port **80**, backend on port **8000** + +## Authority Links + +- [progress.md](progress.md) - Project tracker, milestone status, fix history, outstanding items + +## Project Structure + +``` +backend/app/ + main.py # FastAPI app, router registration, health endpoint + config.py # Pydantic BaseSettings (DATABASE_URL, SECRET_KEY) + database.py # Async engine, session factory, get_db dependency + models/ # SQLAlchemy 2.0 models (Mapped[] style) + schemas/ # Pydantic v2 request/response schemas + routers/ # One router per feature (auth, todos, events, etc.) + +frontend/src/ + App.tsx # Routes + ProtectedRoute wrapper + lib/api.ts # Axios instance + getErrorMessage helper + hooks/ # useAuth, useSettings, useTheme + types/index.ts # TypeScript interfaces matching backend schemas + components/ + ui/ # 12 base components (Button, Dialog, Input, etc.) + layout/ # AppLayout + Sidebar + auth/ # LockScreen (PIN setup/login) + dashboard/ # DashboardPage + widgets + calendar/ # CalendarPage + EventForm + todos/ # TodosPage + TodoList + TodoItem + TodoForm + reminders/ # RemindersPage + ReminderList + ReminderForm + projects/ # ProjectsPage + ProjectCard + ProjectDetail + forms + people/ # PeoplePage + PersonCard + PersonForm + locations/ # LocationsPage + LocationCard + LocationForm + settings/ # SettingsPage (accent color, PIN change) +``` + +## Essential Commands + +```bash +# Build and run all services +docker-compose up --build + +# Rebuild single service after changes +docker-compose up --build backend +docker-compose up --build frontend + +# View logs +docker-compose logs -f +docker-compose logs -f backend + +# Reset database (destroys all data) +docker-compose down -v && docker-compose up --build + +# Stop everything +docker-compose down +``` + +## API Routes + +All routes require auth (signed cookie) except `/api/auth/*` and `/health`. + +| Prefix | Resource | +|--------------------|-----------------| +| `/api/auth` | PIN setup/login/logout/status | +| `/api/todos` | Todos CRUD + toggle | +| `/api/events` | Calendar events CRUD | +| `/api/reminders` | Reminders CRUD + dismiss | +| `/api/projects` | Projects + nested tasks CRUD | +| `/api/people` | People CRUD | +| `/api/locations` | Locations CRUD | +| `/api/settings` | Settings + PIN change | +| `/api/dashboard` | Dashboard aggregation | +| `/api/upcoming` | Unified upcoming items | + +## Stop Conditions + +- **Do not** add timezone info to datetime strings sent to the backend +- **Do not** use `datetime.utcnow()` - use `datetime.now(timezone.utc)` instead (deprecated in 3.12) +- **Do not** return relationships from async endpoints without `selectinload()` +- **Do not** use `curl` in backend Docker healthchecks (not available in python:slim) +- **Do not** use `git push --force` or destructive git operations without explicit approval diff --git a/README.md b/README.md new file mode 100644 index 0000000..1aa0acf --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# LifeManager + +A self-hosted personal life administration app with a dark-themed UI. Manage your todos, calendar events, projects, reminders, contacts, and locations from a single dashboard. + +## Features + +- **Dashboard** - At-a-glance overview with today's events, upcoming todos, active reminders, and project stats +- **Todos** - Task management with priorities, due dates, and completion tracking +- **Calendar** - Full interactive calendar (month/week/day views) with drag-and-drop event rescheduling +- **Projects** - Project boards with nested task lists, status tracking, and progress indicators +- **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 + +## Screenshots + +*Coming soon* + +## Tech Stack + +| Layer | Technology | +|--------------|------------| +| Frontend | React 18, TypeScript, Vite, Tailwind CSS | +| UI Components | Custom shadcn/ui-style components, FullCalendar, Lucide icons | +| State | TanStack Query v5, React Router v6 | +| Backend | FastAPI, Python 3.12, Pydantic v2 | +| Database | PostgreSQL 16, SQLAlchemy 2.0 (async), Alembic | +| Auth | PIN-based with bcrypt + signed cookies | +| Deployment | Docker Compose (3 services), Nginx reverse proxy | + +## Quick Start + +### Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) + +### Setup + +1. **Clone the repository** + ```bash + git clone https://your-gitea-instance/youruser/lifemanager.git + cd lifemanager + ``` + +2. **Configure environment variables** + ```bash + cp .env.example .env + ``` + Edit `.env` and set secure values: + ```env + POSTGRES_USER=lifemanager + POSTGRES_PASSWORD=your-secure-password + POSTGRES_DB=lifemanager + DATABASE_URL=postgresql+asyncpg://lifemanager:your-secure-password@db:5432/lifemanager + SECRET_KEY=your-random-secret-key + ``` + +3. **Build and run** + ```bash + docker-compose up --build + ``` + +4. **Open the app** + + Navigate to `http://localhost` in your browser. On first launch you'll be prompted to create a PIN. + +## Architecture + +``` + +-----------+ + | Browser | + +-----+-----+ + | + port 80 (HTTP) + | + +-------+-------+ + | Nginx | + | (frontend) | + +---+-------+---+ + | | + static | | /api/* + files | | + v v + +---+-------+---+ + | FastAPI | + | (backend) | + | port 8000 | + +-------+-------+ + | + +-------+-------+ + | PostgreSQL | + | (db) | + | port 5432 | + +---------------+ +``` + +- **Frontend** is built as static files and served by Nginx. Nginx also reverse-proxies API requests to the backend. +- **Backend** runs Alembic migrations on startup, then serves the FastAPI application. +- **Database** uses a named Docker volume (`postgres_data`) for persistence. + +## API Overview + +All endpoints require authentication (signed session cookie) except auth routes and the health check. + +| Endpoint | Description | +|--------------------|-------------| +| `GET /health` | Health check | +| `/api/auth/*` | PIN setup, login, logout, status | +| `/api/todos/*` | Todos CRUD + toggle completion | +| `/api/events/*` | Calendar events CRUD | +| `/api/reminders/*` | Reminders CRUD + dismiss | +| `/api/projects/*` | Projects + nested tasks CRUD | +| `/api/people/*` | People CRUD | +| `/api/locations/*` | Locations CRUD | +| `/api/settings/*` | App settings + PIN change | +| `/api/dashboard` | Dashboard aggregation | +| `/api/upcoming` | Unified upcoming items feed | + +Full API documentation is available at `http://localhost:8000/docs` (Swagger UI) when the backend is running. + +## Development + +### Rebuild a single service + +```bash +docker-compose up --build backend # Backend only +docker-compose up --build frontend # Frontend only +``` + +### View logs + +```bash +docker-compose logs -f # All services +docker-compose logs -f backend # Backend only +``` + +### Reset database + +```bash +docker-compose down -v && docker-compose up --build +``` + +### Stop all services + +```bash +docker-compose down +``` + +## Project Structure + +``` +lifemanager/ +├── docker-compose.yaml +├── .env / .env.example +├── backend/ +│ ├── Dockerfile +│ ├── requirements.txt +│ ├── start.sh +│ ├── alembic.ini +│ ├── alembic/ # Database migrations +│ └── app/ +│ ├── main.py # FastAPI app entry point +│ ├── config.py # Environment settings +│ ├── database.py # Async SQLAlchemy setup +│ ├── models/ # SQLAlchemy ORM models +│ ├── schemas/ # Pydantic request/response schemas +│ └── routers/ # API route handlers +└── frontend/ + ├── Dockerfile + ├── nginx.conf + ├── package.json + └── src/ + ├── App.tsx # Routes and auth guard + ├── lib/api.ts # Axios client + ├── hooks/ # Auth, settings, theme hooks + ├── types/ # TypeScript interfaces + └── components/ + ├── ui/ # Base UI components + ├── layout/ # App shell and sidebar + ├── auth/ # PIN login screen + ├── dashboard/ # Dashboard and widgets + ├── calendar/ # Calendar and event form + ├── todos/ # Todo management + ├── reminders/ # Reminder management + ├── projects/ # Project boards and tasks + ├── people/ # Contact directory + ├── locations/ # Location management + └── settings/ # App settings +``` + +## License + +This project is for personal use. Feel free to fork and adapt for your own needs. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..85881df --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager +SECRET_KEY=your-secret-key-change-in-production-use-a-long-random-string diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..6696ad0 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,47 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment variables +.env +.env.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..790ba30 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run migrations and start server +CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..814decf --- /dev/null +++ b/backend/README.md @@ -0,0 +1,200 @@ +# LifeManager Backend + +A complete FastAPI backend for the LifeManager application with async SQLAlchemy, PostgreSQL, authentication, and comprehensive CRUD operations. + +## Features + +- **FastAPI** with async/await support +- **SQLAlchemy 2.0** with async engine +- **PostgreSQL** with asyncpg driver +- **Alembic** for database migrations +- **bcrypt** for password hashing +- **itsdangerous** for session management +- **PIN-based authentication** with secure session cookies +- **Full CRUD operations** for all entities +- **Dashboard** with aggregated data +- **CORS enabled** for frontend integration + +## Project Structure + +``` +backend/ +├── alembic/ # Database migrations +│ ├── versions/ # Migration files +│ ├── env.py # Alembic environment +│ └── script.py.mako # Migration template +├── app/ +│ ├── models/ # SQLAlchemy models +│ ├── schemas/ # Pydantic schemas +│ ├── routers/ # API route handlers +│ ├── config.py # Configuration +│ ├── database.py # Database setup +│ └── main.py # FastAPI application +├── requirements.txt # Python dependencies +├── Dockerfile # Docker configuration +├── alembic.ini # Alembic configuration +└── start.sh # Startup script +``` + +## Setup + +### 1. Install Dependencies + +```bash +cd backend +pip install -r requirements.txt +``` + +### 2. Configure Environment + +Create a `.env` file: + +```bash +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager +SECRET_KEY=your-secret-key-change-in-production +``` + +### 3. Create Database + +```bash +createdb lifemanager +``` + +### 4. Run Migrations + +```bash +alembic upgrade head +``` + +### 5. Start Server + +```bash +# Using the start script +chmod +x start.sh +./start.sh + +# Or directly with uvicorn +uvicorn app.main:app --reload +``` + +The API will be available at `http://localhost:8000` + +## API Documentation + +Interactive API documentation is available at: +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +## API Endpoints + +### Authentication +- `POST /api/auth/setup` - Initial PIN setup +- `POST /api/auth/login` - Login with PIN +- `POST /api/auth/logout` - Logout +- `GET /api/auth/status` - Check auth status + +### Todos +- `GET /api/todos` - List todos (with filters) +- `POST /api/todos` - Create todo +- `GET /api/todos/{id}` - Get todo +- `PUT /api/todos/{id}` - Update todo +- `DELETE /api/todos/{id}` - Delete todo +- `PATCH /api/todos/{id}/toggle` - Toggle completion + +### Calendar Events +- `GET /api/events` - List events (with date range) +- `POST /api/events` - Create event +- `GET /api/events/{id}` - Get event +- `PUT /api/events/{id}` - Update event +- `DELETE /api/events/{id}` - Delete event + +### Reminders +- `GET /api/reminders` - List reminders (with filters) +- `POST /api/reminders` - Create reminder +- `GET /api/reminders/{id}` - Get reminder +- `PUT /api/reminders/{id}` - Update reminder +- `DELETE /api/reminders/{id}` - Delete reminder +- `PATCH /api/reminders/{id}/dismiss` - Dismiss reminder + +### Projects +- `GET /api/projects` - List projects +- `POST /api/projects` - Create project +- `GET /api/projects/{id}` - Get project +- `PUT /api/projects/{id}` - Update project +- `DELETE /api/projects/{id}` - Delete project +- `GET /api/projects/{id}/tasks` - List project tasks +- `POST /api/projects/{id}/tasks` - Create project task +- `PUT /api/projects/{id}/tasks/{task_id}` - Update task +- `DELETE /api/projects/{id}/tasks/{task_id}` - Delete task + +### People +- `GET /api/people` - List people (with search) +- `POST /api/people` - Create person +- `GET /api/people/{id}` - Get person +- `PUT /api/people/{id}` - Update person +- `DELETE /api/people/{id}` - Delete person + +### Locations +- `GET /api/locations` - List locations (with category filter) +- `POST /api/locations` - Create location +- `GET /api/locations/{id}` - Get location +- `PUT /api/locations/{id}` - Update location +- `DELETE /api/locations/{id}` - Delete location + +### Settings +- `GET /api/settings` - Get settings +- `PUT /api/settings` - Update settings +- `PUT /api/settings/pin` - Change PIN + +### Dashboard +- `GET /api/dashboard` - Get dashboard data +- `GET /api/upcoming?days=7` - Get upcoming items + +## Database Schema + +The application uses the following tables: +- `settings` - Application settings and PIN +- `todos` - Task items +- `calendar_events` - Calendar events +- `reminders` - Reminders +- `projects` - Projects +- `project_tasks` - Tasks within projects +- `people` - Contacts/people +- `locations` - Physical locations + +## Docker + +Build and run with Docker: + +```bash +docker build -t lifemanager-backend . +docker run -p 8000:8000 -e DATABASE_URL=... -e SECRET_KEY=... lifemanager-backend +``` + +## Development + +### Create New Migration + +```bash +alembic revision --autogenerate -m "Description of changes" +``` + +### Apply Migrations + +```bash +alembic upgrade head +``` + +### Rollback Migration + +```bash +alembic downgrade -1 +``` + +## Security Notes + +- Change `SECRET_KEY` in production +- Use strong PINs (minimum 4 digits recommended) +- Session cookies are httpOnly and last 30 days +- All API endpoints (except auth) require authentication +- PINs are hashed with bcrypt before storage diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..5d23107 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,114 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..6bc3b16 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,99 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Import app config and models +from app.config import settings +from app.database import Base +from app.models import ( + Settings, + Todo, + CalendarEvent, + Reminder, + Project, + ProjectTask, + Person, + Location, +) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# Set database URL from app config +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/001_initial_migration.py b/backend/alembic/versions/001_initial_migration.py new file mode 100644 index 0000000..84a3f9a --- /dev/null +++ b/backend/alembic/versions/001_initial_migration.py @@ -0,0 +1,173 @@ +"""Initial migration - create all tables + +Revision ID: 001 +Revises: +Create Date: 2024-01-01 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '001' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create settings table + op.create_table( + 'settings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pin_hash', sa.String(length=255), nullable=False), + sa.Column('accent_color', sa.String(length=20), nullable=False, server_default='cyan'), + sa.Column('upcoming_days', sa.Integer(), nullable=False, server_default='7'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_settings_id'), 'settings', ['id'], unique=False) + + # Create people table + op.create_table( + 'people', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('address', sa.Text(), nullable=True), + sa.Column('birthday', sa.Date(), nullable=True), + sa.Column('relationship', sa.String(length=100), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_people_id'), 'people', ['id'], unique=False) + + # Create locations table + op.create_table( + 'locations', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('address', sa.Text(), nullable=False), + sa.Column('category', sa.String(length=100), nullable=False, server_default='other'), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_locations_id'), 'locations', ['id'], unique=False) + + # Create projects table + op.create_table( + 'projects', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False, server_default='not_started'), + sa.Column('color', sa.String(length=20), nullable=True), + sa.Column('due_date', sa.Date(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_projects_id'), 'projects', ['id'], unique=False) + + # Create reminders table + op.create_table( + 'reminders', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('remind_at', sa.DateTime(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('is_dismissed', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('recurrence_rule', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_reminders_id'), 'reminders', ['id'], unique=False) + + # Create calendar_events table + op.create_table( + 'calendar_events', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('start_datetime', sa.DateTime(), nullable=False), + sa.Column('end_datetime', sa.DateTime(), nullable=False), + sa.Column('all_day', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('color', sa.String(length=20), nullable=True), + sa.Column('location_id', sa.Integer(), nullable=True), + sa.Column('recurrence_rule', sa.String(length=255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.ForeignKeyConstraint(['location_id'], ['locations.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_calendar_events_id'), 'calendar_events', ['id'], unique=False) + + # Create todos table + op.create_table( + 'todos', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('priority', sa.String(length=20), nullable=False, server_default='medium'), + sa.Column('due_date', sa.Date(), nullable=True), + sa.Column('completed', sa.Boolean(), nullable=False, server_default='false'), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('category', sa.String(length=100), nullable=True), + sa.Column('recurrence_rule', sa.String(length=255), nullable=True), + sa.Column('project_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_todos_id'), 'todos', ['id'], unique=False) + + # Create project_tasks table + op.create_table( + 'project_tasks', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'), + sa.Column('priority', sa.String(length=20), nullable=False, server_default='medium'), + sa.Column('due_date', sa.Date(), nullable=True), + sa.Column('person_id', sa.Integer(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('now()')), + sa.ForeignKeyConstraint(['person_id'], ['people.id'], ), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_project_tasks_id'), 'project_tasks', ['id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_project_tasks_id'), table_name='project_tasks') + op.drop_table('project_tasks') + op.drop_index(op.f('ix_todos_id'), table_name='todos') + op.drop_table('todos') + op.drop_index(op.f('ix_calendar_events_id'), table_name='calendar_events') + op.drop_table('calendar_events') + op.drop_index(op.f('ix_reminders_id'), table_name='reminders') + op.drop_table('reminders') + op.drop_index(op.f('ix_projects_id'), table_name='projects') + op.drop_table('projects') + op.drop_index(op.f('ix_locations_id'), table_name='locations') + op.drop_table('locations') + op.drop_index(op.f('ix_people_id'), table_name='people') + op.drop_table('people') + op.drop_index(op.f('ix_settings_id'), table_name='settings') + op.drop_table('settings') diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..245591d --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/lifemanager" + SECRET_KEY: str = "your-secret-key-change-in-production" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True + ) + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..0df7f66 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,33 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base +from app.config import settings + +# Create async engine +engine = create_async_engine( + settings.DATABASE_URL, + echo=True, + future=True +) + +# Create async session factory +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False +) + +# Base class for models +Base = declarative_base() + + +# Dependency to get database session +async def get_db() -> AsyncSession: + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0be808a --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,54 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager + +from app.database import engine, Base +from app.routers import auth, todos, events, reminders, projects, people, locations, settings as settings_router, dashboard + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: Create tables + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + # Shutdown: Clean up resources + await engine.dispose() + + +app = FastAPI( + title="LifeManager API", + description="Backend API for LifeManager application", + version="1.0.0", + lifespan=lifespan +) + +# CORS configuration for development +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers with /api prefix +app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) +app.include_router(todos.router, prefix="/api/todos", tags=["Todos"]) +app.include_router(events.router, prefix="/api/events", tags=["Calendar Events"]) +app.include_router(reminders.router, prefix="/api/reminders", tags=["Reminders"]) +app.include_router(projects.router, prefix="/api/projects", tags=["Projects"]) +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.get("/") +async def root(): + return {"message": "LifeManager API is running"} + + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..9ca212b --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,19 @@ +from app.models.settings import Settings +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.project_task import ProjectTask +from app.models.person import Person +from app.models.location import Location + +__all__ = [ + "Settings", + "Todo", + "CalendarEvent", + "Reminder", + "Project", + "ProjectTask", + "Person", + "Location", +] diff --git a/backend/app/models/calendar_event.py b/backend/app/models/calendar_event.py new file mode 100644 index 0000000..c1bdc68 --- /dev/null +++ b/backend/app/models/calendar_event.py @@ -0,0 +1,24 @@ +from sqlalchemy import String, Text, Boolean, Integer, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional +from app.database import Base + + +class CalendarEvent(Base): + __tablename__ = "calendar_events" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + start_datetime: Mapped[datetime] = mapped_column(nullable=False) + end_datetime: Mapped[datetime] = mapped_column(nullable=False) + all_day: Mapped[bool] = mapped_column(Boolean, default=False) + 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) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) + + # Relationships + location: Mapped[Optional["Location"]] = relationship(back_populates="events") diff --git a/backend/app/models/location.py b/backend/app/models/location.py new file mode 100644 index 0000000..f9a116a --- /dev/null +++ b/backend/app/models/location.py @@ -0,0 +1,20 @@ +from sqlalchemy import String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from typing import Optional, List +from app.database import Base + + +class Location(Base): + __tablename__ = "locations" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + address: Mapped[str] = mapped_column(Text, nullable=False) + category: Mapped[str] = mapped_column(String(100), default="other") + notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) + + # Relationships + events: Mapped[List["CalendarEvent"]] = relationship(back_populates="location") diff --git a/backend/app/models/person.py b/backend/app/models/person.py new file mode 100644 index 0000000..05118db --- /dev/null +++ b/backend/app/models/person.py @@ -0,0 +1,23 @@ +from sqlalchemy import String, Text, Date, func +from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship +from datetime import datetime, date +from typing import Optional, List +from app.database import Base + + +class Person(Base): + __tablename__ = "people" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + address: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + birthday: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + relationship: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) + + # Relationships + assigned_tasks: Mapped[List["ProjectTask"]] = sa_relationship(back_populates="person") diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..b79b8f9 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,22 @@ +from sqlalchemy import String, Text, Date, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime, date +from typing import Optional, List +from app.database import Base + + +class Project(Base): + __tablename__ = "projects" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="not_started") + color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) + due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) + + # Relationships + tasks: Mapped[List["ProjectTask"]] = relationship(back_populates="project", cascade="all, delete-orphan") + todos: Mapped[List["Todo"]] = relationship(back_populates="project") diff --git a/backend/app/models/project_task.py b/backend/app/models/project_task.py new file mode 100644 index 0000000..c6eed3c --- /dev/null +++ b/backend/app/models/project_task.py @@ -0,0 +1,25 @@ +from sqlalchemy import String, Text, Integer, Date, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime, date +from typing import Optional +from app.database import Base + + +class ProjectTask(Base): + __tablename__ = "project_tasks" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="pending") + priority: Mapped[str] = mapped_column(String(20), default="medium") + due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + person_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("people.id"), nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) + + # Relationships + project: Mapped["Project"] = relationship(back_populates="tasks") + person: Mapped[Optional["Person"]] = relationship(back_populates="assigned_tasks") diff --git a/backend/app/models/reminder.py b/backend/app/models/reminder.py new file mode 100644 index 0000000..24162ac --- /dev/null +++ b/backend/app/models/reminder.py @@ -0,0 +1,19 @@ +from sqlalchemy import String, Text, Boolean, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from typing import Optional +from app.database import Base + + +class Reminder(Base): + __tablename__ = "reminders" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + remind_at: Mapped[datetime] = mapped_column(nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_dismissed: Mapped[bool] = mapped_column(Boolean, default=False) + recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + 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 new file mode 100644 index 0000000..4a2c605 --- /dev/null +++ b/backend/app/models/settings.py @@ -0,0 +1,15 @@ +from sqlalchemy import String, Integer, func +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.database import Base + + +class Settings(Base): + __tablename__ = "settings" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + pin_hash: Mapped[str] = mapped_column(String(255), nullable=False) + accent_color: Mapped[str] = mapped_column(String(20), default="cyan") + upcoming_days: Mapped[int] = mapped_column(Integer, default=7) + 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/todo.py b/backend/app/models/todo.py new file mode 100644 index 0000000..1673151 --- /dev/null +++ b/backend/app/models/todo.py @@ -0,0 +1,25 @@ +from sqlalchemy import String, Text, Boolean, Date, Integer, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime, date +from typing import Optional +from app.database import Base + + +class Todo(Base): + __tablename__ = "todos" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + priority: Mapped[str] = mapped_column(String(20), default="medium") + due_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True) + completed: Mapped[bool] = mapped_column(Boolean, default=False) + completed_at: Mapped[Optional[datetime]] = mapped_column(nullable=True) + category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + recurrence_rule: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + project_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("projects.id"), nullable=True) + created_at: Mapped[datetime] = mapped_column(default=func.now()) + updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) + + # Relationships + project: Mapped[Optional["Project"]] = relationship(back_populates="todos") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..cecc043 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1,13 @@ +from app.routers import auth, todos, events, reminders, projects, people, locations, settings, dashboard + +__all__ = [ + "auth", + "todos", + "events", + "reminders", + "projects", + "people", + "locations", + "settings", + "dashboard", +] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..12afe1d --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,151 @@ +from fastapi import APIRouter, Depends, HTTPException, Response, Cookie +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional +import bcrypt +from itsdangerous import TimestampSigner, BadSignature + +from app.database import get_db +from app.models.settings import Settings +from app.schemas.settings import SettingsCreate +from app.config import settings as app_settings + +router = APIRouter() + +# Initialize signer for session management +signer = TimestampSigner(app_settings.SECRET_KEY) + + +def hash_pin(pin: str) -> str: + """Hash a PIN using bcrypt.""" + return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() + + +def verify_pin(pin: str, hashed: str) -> bool: + """Verify a PIN against its hash.""" + return bcrypt.checkpw(pin.encode(), hashed.encode()) + + +def create_session_token(user_id: int) -> str: + """Create a signed session token.""" + return signer.sign(str(user_id)).decode() + + +def verify_session_token(token: str) -> Optional[int]: + """Verify and extract user ID from session token.""" + try: + unsigned = signer.unsign(token, max_age=86400 * 30) # 30 days + return int(unsigned) + except (BadSignature, ValueError): + return None + + +async def get_current_session( + session_cookie: Optional[str] = Cookie(None, alias="session"), + db: AsyncSession = Depends(get_db) +) -> Settings: + """Dependency to verify session and return current settings.""" + if not session_cookie: + raise HTTPException(status_code=401, detail="Not authenticated") + + user_id = verify_session_token(session_cookie) + if user_id is None: + raise HTTPException(status_code=401, detail="Invalid or expired session") + + result = await db.execute(select(Settings).where(Settings.id == user_id)) + settings_obj = result.scalar_one_or_none() + + if not settings_obj: + raise HTTPException(status_code=401, detail="Session invalid") + + return settings_obj + + +@router.post("/setup") +async def setup_pin( + data: SettingsCreate, + response: Response, + db: AsyncSession = Depends(get_db) +): + """Create initial PIN. Only works if no settings exist.""" + result = await db.execute(select(Settings)) + existing = result.scalar_one_or_none() + + if existing: + raise HTTPException(status_code=400, detail="Setup already completed") + + pin_hash = hash_pin(data.pin) + new_settings = Settings(pin_hash=pin_hash) + db.add(new_settings) + await db.commit() + await db.refresh(new_settings) + + # Create session + token = create_session_token(new_settings.id) + response.set_cookie( + key="session", + value=token, + httponly=True, + max_age=86400 * 30, # 30 days + samesite="lax" + ) + + return {"message": "Setup completed successfully", "authenticated": True} + + +@router.post("/login") +async def login( + data: SettingsCreate, + response: Response, + db: AsyncSession = Depends(get_db) +): + """Verify PIN and create session.""" + result = await db.execute(select(Settings)) + settings_obj = result.scalar_one_or_none() + + if not settings_obj: + raise HTTPException(status_code=400, detail="Setup required") + + if not verify_pin(data.pin, settings_obj.pin_hash): + raise HTTPException(status_code=401, detail="Invalid PIN") + + # Create session + token = create_session_token(settings_obj.id) + response.set_cookie( + key="session", + value=token, + httponly=True, + max_age=86400 * 30, # 30 days + samesite="lax" + ) + + return {"message": "Login successful", "authenticated": True} + + +@router.post("/logout") +async def logout(response: Response): + """Clear session cookie.""" + response.delete_cookie(key="session") + return {"message": "Logout successful"} + + +@router.get("/status") +async def auth_status( + session_cookie: Optional[str] = Cookie(None, alias="session"), + db: AsyncSession = Depends(get_db) +): + """Check authentication status.""" + result = await db.execute(select(Settings)) + settings_obj = result.scalar_one_or_none() + + setup_required = settings_obj is None + authenticated = False + + if not setup_required and session_cookie: + user_id = verify_session_token(session_cookie) + authenticated = user_id is not None + + return { + "authenticated": authenticated, + "setup_required": setup_required + } diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py new file mode 100644 index 0000000..7a8c7dd --- /dev/null +++ b/backend/app/routers/dashboard.py @@ -0,0 +1,192 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from datetime import datetime, date, timedelta +from typing import Optional, List, Dict, Any + +from app.database import get_db +from app.models.settings import Settings +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() + + +@router.get("/dashboard") +async def get_dashboard( + client_date: Optional[date] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get aggregated dashboard data.""" + today = client_date or date.today() + upcoming_cutoff = today + timedelta(days=current_user.upcoming_days) + + # Today's events + today_start = datetime.combine(today, datetime.min.time()) + today_end = datetime.combine(today, datetime.max.time()) + events_query = select(CalendarEvent).where( + CalendarEvent.start_datetime >= today_start, + CalendarEvent.start_datetime <= today_end + ) + events_result = await db.execute(events_query) + todays_events = events_result.scalars().all() + + # Upcoming todos (not completed, with due date within upcoming_days) + todos_query = select(Todo).where( + Todo.completed == False, + Todo.due_date.isnot(None), + Todo.due_date <= upcoming_cutoff + ).order_by(Todo.due_date.asc()) + todos_result = await db.execute(todos_query) + upcoming_todos = todos_result.scalars().all() + + # Active reminders (not dismissed, is_active = true) + reminders_query = select(Reminder).where( + Reminder.is_active == True, + Reminder.is_dismissed == False + ).order_by(Reminder.remind_at.asc()) + reminders_result = await db.execute(reminders_query) + active_reminders = reminders_result.scalars().all() + + # Project stats + total_projects_result = await db.execute(select(func.count(Project.id))) + total_projects = total_projects_result.scalar() + + projects_by_status_query = select( + Project.status, + func.count(Project.id).label("count") + ).group_by(Project.status) + 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 locations + total_locations_result = await db.execute(select(func.count(Location.id))) + total_locations = total_locations_result.scalar() + + return { + "todays_events": [ + { + "id": event.id, + "title": event.title, + "start_datetime": event.start_datetime, + "end_datetime": event.end_datetime, + "all_day": event.all_day, + "color": event.color + } + for event in todays_events + ], + "upcoming_todos": [ + { + "id": todo.id, + "title": todo.title, + "due_date": todo.due_date, + "priority": todo.priority, + "category": todo.category + } + for todo in upcoming_todos + ], + "active_reminders": [ + { + "id": reminder.id, + "title": reminder.title, + "remind_at": reminder.remind_at + } + for reminder in active_reminders + ], + "project_stats": { + "total": total_projects, + "by_status": projects_by_status + }, + "total_people": total_people, + "total_locations": total_locations + } + + +@router.get("/upcoming") +async def get_upcoming( + days: int = Query(default=7, ge=1, le=90), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get unified list of upcoming items (todos, events, reminders) sorted by date.""" + today = date.today() + cutoff_date = today + timedelta(days=days) + cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time()) + + # Get upcoming todos with due dates + todos_query = select(Todo).where( + Todo.completed == False, + Todo.due_date.isnot(None), + Todo.due_date <= cutoff_date + ) + todos_result = await db.execute(todos_query) + todos = todos_result.scalars().all() + + # Get upcoming events + events_query = select(CalendarEvent).where( + CalendarEvent.start_datetime <= cutoff_datetime + ) + events_result = await db.execute(events_query) + events = events_result.scalars().all() + + # Get upcoming reminders + reminders_query = select(Reminder).where( + Reminder.is_active == True, + Reminder.is_dismissed == False, + Reminder.remind_at <= cutoff_datetime + ) + reminders_result = await db.execute(reminders_query) + reminders = reminders_result.scalars().all() + + # Combine into unified list + upcoming_items: List[Dict[str, Any]] = [] + + for todo in todos: + upcoming_items.append({ + "type": "todo", + "id": todo.id, + "title": todo.title, + "date": todo.due_date.isoformat() if todo.due_date else None, + "datetime": None, + "priority": todo.priority, + "category": todo.category + }) + + for event in events: + upcoming_items.append({ + "type": "event", + "id": event.id, + "title": event.title, + "date": event.start_datetime.date().isoformat(), + "datetime": event.start_datetime.isoformat(), + "all_day": event.all_day, + "color": event.color + }) + + for reminder in reminders: + upcoming_items.append({ + "type": "reminder", + "id": reminder.id, + "title": reminder.title, + "date": reminder.remind_at.date().isoformat(), + "datetime": reminder.remind_at.isoformat() + }) + + # Sort by date/datetime + upcoming_items.sort(key=lambda x: x["datetime"] if x["datetime"] else x["date"]) + + return { + "items": upcoming_items, + "days": days, + "cutoff_date": cutoff_date.isoformat() + } diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py new file mode 100644 index 0000000..5b579e9 --- /dev/null +++ b/backend/app/routers/events.py @@ -0,0 +1,122 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional, List +from datetime import date + +from app.database import get_db +from app.models.calendar_event import CalendarEvent +from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse +from app.routers.auth import get_current_session +from app.models.settings import Settings + +router = APIRouter() + + +@router.get("/", response_model=List[CalendarEventResponse]) +async def get_events( + start: Optional[date] = Query(None), + end: Optional[date] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get all calendar events with optional date range filtering.""" + query = select(CalendarEvent) + + if start: + query = query.where(CalendarEvent.start_datetime >= start) + + if end: + query = query.where(CalendarEvent.end_datetime <= end) + + query = query.order_by(CalendarEvent.start_datetime.asc()) + + result = await db.execute(query) + events = result.scalars().all() + + return events + + +@router.post("/", response_model=CalendarEventResponse, status_code=201) +async def create_event( + event: CalendarEventCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Create a new calendar event.""" + if event.end_datetime < event.start_datetime: + raise HTTPException(status_code=400, detail="End datetime must be after start datetime") + + new_event = CalendarEvent(**event.model_dump()) + db.add(new_event) + await db.commit() + await db.refresh(new_event) + + return new_event + + +@router.get("/{event_id}", response_model=CalendarEventResponse) +async def get_event( + event_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get a specific calendar event by ID.""" + result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Calendar event not found") + + return event + + +@router.put("/{event_id}", response_model=CalendarEventResponse) +async def update_event( + event_id: int, + event_update: CalendarEventUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Update a calendar event.""" + result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Calendar event not found") + + update_data = event_update.model_dump(exclude_unset=True) + + # Validate datetime order if both are being updated + start = update_data.get("start_datetime", event.start_datetime) + end = update_data.get("end_datetime", event.end_datetime) + + if end < start: + raise HTTPException(status_code=400, detail="End datetime must be after start datetime") + + for key, value in update_data.items(): + setattr(event, key, value) + + await db.commit() + await db.refresh(event) + + return event + + +@router.delete("/{event_id}", status_code=204) +async def delete_event( + event_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Delete a calendar event.""" + result = await db.execute(select(CalendarEvent).where(CalendarEvent.id == event_id)) + event = result.scalar_one_or_none() + + if not event: + raise HTTPException(status_code=404, detail="Calendar event not found") + + await db.delete(event) + await db.commit() + + return None diff --git a/backend/app/routers/locations.py b/backend/app/routers/locations.py new file mode 100644 index 0000000..308d29d --- /dev/null +++ b/backend/app/routers/locations.py @@ -0,0 +1,107 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional, List + +from app.database import get_db +from app.models.location import Location +from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse +from app.routers.auth import get_current_session +from app.models.settings import Settings + +router = APIRouter() + + +@router.get("/", response_model=List[LocationResponse]) +async def get_locations( + category: Optional[str] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get all locations with optional category filter.""" + query = select(Location) + + if category: + query = query.where(Location.category == category) + + query = query.order_by(Location.name.asc()) + + result = await db.execute(query) + locations = result.scalars().all() + + return locations + + +@router.post("/", response_model=LocationResponse, status_code=201) +async def create_location( + location: LocationCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Create a new location.""" + new_location = Location(**location.model_dump()) + db.add(new_location) + await db.commit() + await db.refresh(new_location) + + return new_location + + +@router.get("/{location_id}", response_model=LocationResponse) +async def get_location( + location_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get a specific location by ID.""" + result = await db.execute(select(Location).where(Location.id == location_id)) + location = result.scalar_one_or_none() + + if not location: + raise HTTPException(status_code=404, detail="Location not found") + + return location + + +@router.put("/{location_id}", response_model=LocationResponse) +async def update_location( + location_id: int, + location_update: LocationUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Update a location.""" + result = await db.execute(select(Location).where(Location.id == location_id)) + location = result.scalar_one_or_none() + + if not location: + raise HTTPException(status_code=404, detail="Location not found") + + update_data = location_update.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + setattr(location, key, value) + + await db.commit() + await db.refresh(location) + + return location + + +@router.delete("/{location_id}", status_code=204) +async def delete_location( + location_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Delete a location.""" + result = await db.execute(select(Location).where(Location.id == location_id)) + location = result.scalar_one_or_none() + + if not location: + raise HTTPException(status_code=404, detail="Location not found") + + await db.delete(location) + await db.commit() + + return None diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py new file mode 100644 index 0000000..890dd55 --- /dev/null +++ b/backend/app/routers/people.py @@ -0,0 +1,107 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional, List + +from app.database import get_db +from app.models.person import Person +from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse +from app.routers.auth import get_current_session +from app.models.settings import Settings + +router = APIRouter() + + +@router.get("/", response_model=List[PersonResponse]) +async def get_people( + search: Optional[str] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get all people with optional search.""" + query = select(Person) + + if search: + query = query.where(Person.name.ilike(f"%{search}%")) + + query = query.order_by(Person.name.asc()) + + result = await db.execute(query) + people = result.scalars().all() + + return people + + +@router.post("/", response_model=PersonResponse, status_code=201) +async def create_person( + person: PersonCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Create a new person.""" + new_person = Person(**person.model_dump()) + db.add(new_person) + await db.commit() + await db.refresh(new_person) + + return new_person + + +@router.get("/{person_id}", response_model=PersonResponse) +async def get_person( + person_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get a specific person by ID.""" + result = await db.execute(select(Person).where(Person.id == person_id)) + person = result.scalar_one_or_none() + + if not person: + raise HTTPException(status_code=404, detail="Person not found") + + return person + + +@router.put("/{person_id}", response_model=PersonResponse) +async def update_person( + person_id: int, + person_update: PersonUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Update a person.""" + result = await db.execute(select(Person).where(Person.id == person_id)) + person = result.scalar_one_or_none() + + if not person: + raise HTTPException(status_code=404, detail="Person not found") + + update_data = person_update.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + setattr(person, key, value) + + await db.commit() + await db.refresh(person) + + return person + + +@router.delete("/{person_id}", status_code=204) +async def delete_person( + person_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Delete a person.""" + result = await db.execute(select(Person).where(Person.id == person_id)) + person = result.scalar_one_or_none() + + if not person: + raise HTTPException(status_code=404, detail="Person not found") + + await db.delete(person) + await db.commit() + + return None diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py new file mode 100644 index 0000000..3d48c47 --- /dev/null +++ b/backend/app/routers/projects.py @@ -0,0 +1,208 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm import selectinload +from typing import List + +from app.database import get_db +from app.models.project import Project +from app.models.project_task import ProjectTask +from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse +from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse +from app.routers.auth import get_current_session +from app.models.settings import Settings + +router = APIRouter() + + +@router.get("/", response_model=List[ProjectResponse]) +async def get_projects( + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get all projects with their tasks.""" + query = select(Project).options(selectinload(Project.tasks)).order_by(Project.created_at.desc()) + result = await db.execute(query) + projects = result.scalars().all() + + return projects + + +@router.post("/", response_model=ProjectResponse, status_code=201) +async def create_project( + project: ProjectCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Create a new project.""" + new_project = Project(**project.model_dump()) + db.add(new_project) + await db.commit() + + # Re-fetch with eagerly loaded tasks for response serialization + query = select(Project).options(selectinload(Project.tasks)).where(Project.id == new_project.id) + result = await db.execute(query) + return result.scalar_one() + + +@router.get("/{project_id}", response_model=ProjectResponse) +async def get_project( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get a specific project by ID with its tasks.""" + query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id) + result = await db.execute(query) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + return project + + +@router.put("/{project_id}", response_model=ProjectResponse) +async def update_project( + project_id: int, + project_update: ProjectUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Update a project.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + update_data = project_update.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + setattr(project, key, value) + + await db.commit() + + # Re-fetch with eagerly loaded tasks for response serialization + query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id) + result = await db.execute(query) + return result.scalar_one() + + +@router.delete("/{project_id}", status_code=204) +async def delete_project( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Delete a project and all its tasks.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + await db.delete(project) + await db.commit() + + return None + + +@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse]) +async def get_project_tasks( + project_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get all tasks for a specific project.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + query = select(ProjectTask).where(ProjectTask.project_id == project_id).order_by(ProjectTask.sort_order.asc()) + result = await db.execute(query) + tasks = result.scalars().all() + + return tasks + + +@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201) +async def create_project_task( + project_id: int, + task: ProjectTaskCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Create a new task for a project.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + task_data = task.model_dump() + task_data["project_id"] = project_id + new_task = ProjectTask(**task_data) + db.add(new_task) + await db.commit() + await db.refresh(new_task) + + return new_task + + +@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse) +async def update_project_task( + project_id: int, + task_id: int, + task_update: ProjectTaskUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Update a project task.""" + result = await db.execute( + select(ProjectTask).where( + ProjectTask.id == task_id, + ProjectTask.project_id == project_id + ) + ) + task = result.scalar_one_or_none() + + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + update_data = task_update.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + setattr(task, key, value) + + await db.commit() + await db.refresh(task) + + return task + + +@router.delete("/{project_id}/tasks/{task_id}", status_code=204) +async def delete_project_task( + project_id: int, + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Delete a project task.""" + result = await db.execute( + select(ProjectTask).where( + ProjectTask.id == task_id, + ProjectTask.project_id == project_id + ) + ) + task = result.scalar_one_or_none() + + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + await db.delete(task) + await db.commit() + + return None diff --git a/backend/app/routers/reminders.py b/backend/app/routers/reminders.py new file mode 100644 index 0000000..47950f7 --- /dev/null +++ b/backend/app/routers/reminders.py @@ -0,0 +1,132 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional, List + +from app.database import get_db +from app.models.reminder import Reminder +from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse +from app.routers.auth import get_current_session +from app.models.settings import Settings + +router = APIRouter() + + +@router.get("/", response_model=List[ReminderResponse]) +async def get_reminders( + active: Optional[bool] = Query(None), + dismissed: Optional[bool] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get all reminders with optional filters.""" + query = select(Reminder) + + if active is not None: + query = query.where(Reminder.is_active == active) + + if dismissed is not None: + query = query.where(Reminder.is_dismissed == dismissed) + + query = query.order_by(Reminder.remind_at.asc()) + + result = await db.execute(query) + reminders = result.scalars().all() + + return reminders + + +@router.post("/", response_model=ReminderResponse, status_code=201) +async def create_reminder( + reminder: ReminderCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Create a new reminder.""" + new_reminder = Reminder(**reminder.model_dump()) + db.add(new_reminder) + await db.commit() + await db.refresh(new_reminder) + + return new_reminder + + +@router.get("/{reminder_id}", response_model=ReminderResponse) +async def get_reminder( + reminder_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get a specific reminder by ID.""" + result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + reminder = result.scalar_one_or_none() + + if not reminder: + raise HTTPException(status_code=404, detail="Reminder not found") + + return reminder + + +@router.put("/{reminder_id}", response_model=ReminderResponse) +async def update_reminder( + reminder_id: int, + reminder_update: ReminderUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Update a reminder.""" + result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + reminder = result.scalar_one_or_none() + + if not reminder: + raise HTTPException(status_code=404, detail="Reminder not found") + + update_data = reminder_update.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + setattr(reminder, key, value) + + await db.commit() + await db.refresh(reminder) + + return reminder + + +@router.delete("/{reminder_id}", status_code=204) +async def delete_reminder( + reminder_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Delete a reminder.""" + result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + reminder = result.scalar_one_or_none() + + if not reminder: + raise HTTPException(status_code=404, detail="Reminder not found") + + await db.delete(reminder) + await db.commit() + + return None + + +@router.patch("/{reminder_id}/dismiss", response_model=ReminderResponse) +async def dismiss_reminder( + reminder_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Dismiss a reminder.""" + result = await db.execute(select(Reminder).where(Reminder.id == reminder_id)) + reminder = result.scalar_one_or_none() + + if not reminder: + raise HTTPException(status_code=404, detail="Reminder not found") + + reminder.is_dismissed = True + + await db.commit() + await db.refresh(reminder) + + return reminder diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py new file mode 100644 index 0000000..f2dd5e6 --- /dev/null +++ b/backend/app/routers/settings.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.database import get_db +from app.models.settings import Settings +from app.schemas.settings import SettingsUpdate, SettingsResponse, ChangePinRequest +from app.routers.auth import get_current_session, hash_pin, verify_pin + +router = APIRouter() + + +@router.get("/", response_model=SettingsResponse) +async def get_settings( + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get current settings (excluding PIN hash).""" + return current_user + + +@router.put("/", response_model=SettingsResponse) +async def update_settings( + settings_update: SettingsUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Update settings (accent color, upcoming days).""" + update_data = settings_update.model_dump(exclude_unset=True) + + for key, value in update_data.items(): + setattr(current_user, key, value) + + await db.commit() + await db.refresh(current_user) + + return current_user + + +@router.put("/pin") +async def change_pin( + pin_change: ChangePinRequest, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Change PIN. Requires old PIN verification.""" + if not verify_pin(pin_change.old_pin, current_user.pin_hash): + raise HTTPException(status_code=401, detail="Invalid old PIN") + + current_user.pin_hash = hash_pin(pin_change.new_pin) + + await db.commit() + + return {"message": "PIN changed successfully"} diff --git a/backend/app/routers/todos.py b/backend/app/routers/todos.py new file mode 100644 index 0000000..0d6d66c --- /dev/null +++ b/backend/app/routers/todos.py @@ -0,0 +1,149 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional, List +from datetime import datetime, timezone + +from app.database import get_db +from app.models.todo import Todo +from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse +from app.routers.auth import get_current_session +from app.models.settings import Settings + +router = APIRouter() + + +@router.get("/", response_model=List[TodoResponse]) +async def get_todos( + completed: Optional[bool] = Query(None), + priority: Optional[str] = Query(None), + category: Optional[str] = Query(None), + search: Optional[str] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get all todos with optional filters.""" + query = select(Todo) + + if completed is not None: + query = query.where(Todo.completed == completed) + + if priority: + query = query.where(Todo.priority == priority) + + if category: + query = query.where(Todo.category == category) + + if search: + query = query.where(Todo.title.ilike(f"%{search}%")) + + query = query.order_by(Todo.created_at.desc()) + + result = await db.execute(query) + todos = result.scalars().all() + + return todos + + +@router.post("/", response_model=TodoResponse, status_code=201) +async def create_todo( + todo: TodoCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Create a new todo.""" + new_todo = Todo(**todo.model_dump()) + db.add(new_todo) + await db.commit() + await db.refresh(new_todo) + + return new_todo + + +@router.get("/{todo_id}", response_model=TodoResponse) +async def get_todo( + todo_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Get a specific todo by ID.""" + result = await db.execute(select(Todo).where(Todo.id == todo_id)) + todo = result.scalar_one_or_none() + + if not todo: + raise HTTPException(status_code=404, detail="Todo not found") + + return todo + + +@router.put("/{todo_id}", response_model=TodoResponse) +async def update_todo( + todo_id: int, + todo_update: TodoUpdate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Update a todo.""" + result = await db.execute(select(Todo).where(Todo.id == todo_id)) + todo = result.scalar_one_or_none() + + if not todo: + raise HTTPException(status_code=404, detail="Todo not found") + + update_data = todo_update.model_dump(exclude_unset=True) + + # Handle completion timestamp + if "completed" in update_data: + if update_data["completed"] and not todo.completed: + update_data["completed_at"] = datetime.now(timezone.utc) + elif not update_data["completed"]: + update_data["completed_at"] = None + + for key, value in update_data.items(): + setattr(todo, key, value) + + await db.commit() + await db.refresh(todo) + + return todo + + +@router.delete("/{todo_id}", status_code=204) +async def delete_todo( + todo_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Delete a todo.""" + result = await db.execute(select(Todo).where(Todo.id == todo_id)) + todo = result.scalar_one_or_none() + + if not todo: + raise HTTPException(status_code=404, detail="Todo not found") + + await db.delete(todo) + await db.commit() + + return None + + +@router.patch("/{todo_id}/toggle", response_model=TodoResponse) +async def toggle_todo( + todo_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Toggle todo completion status.""" + result = await db.execute(select(Todo).where(Todo.id == todo_id)) + todo = result.scalar_one_or_none() + + if not todo: + raise HTTPException(status_code=404, detail="Todo not found") + + todo.completed = not todo.completed + todo.completed_at = datetime.now(timezone.utc) if todo.completed else None + + await db.commit() + await db.refresh(todo) + + return todo diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..9dfc0f2 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,36 @@ +from app.schemas.settings import SettingsCreate, SettingsUpdate, SettingsResponse, ChangePinRequest +from app.schemas.todo import TodoCreate, TodoUpdate, TodoResponse +from app.schemas.calendar_event import CalendarEventCreate, CalendarEventUpdate, CalendarEventResponse +from app.schemas.reminder import ReminderCreate, ReminderUpdate, ReminderResponse +from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse +from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse +from app.schemas.person import PersonCreate, PersonUpdate, PersonResponse +from app.schemas.location import LocationCreate, LocationUpdate, LocationResponse + +__all__ = [ + "SettingsCreate", + "SettingsUpdate", + "SettingsResponse", + "ChangePinRequest", + "TodoCreate", + "TodoUpdate", + "TodoResponse", + "CalendarEventCreate", + "CalendarEventUpdate", + "CalendarEventResponse", + "ReminderCreate", + "ReminderUpdate", + "ReminderResponse", + "ProjectCreate", + "ProjectUpdate", + "ProjectResponse", + "ProjectTaskCreate", + "ProjectTaskUpdate", + "ProjectTaskResponse", + "PersonCreate", + "PersonUpdate", + "PersonResponse", + "LocationCreate", + "LocationUpdate", + "LocationResponse", +] diff --git a/backend/app/schemas/calendar_event.py b/backend/app/schemas/calendar_event.py new file mode 100644 index 0000000..2592369 --- /dev/null +++ b/backend/app/schemas/calendar_event.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional + + +class CalendarEventCreate(BaseModel): + title: str + description: Optional[str] = None + start_datetime: datetime + end_datetime: datetime + all_day: bool = False + color: Optional[str] = None + location_id: Optional[int] = None + recurrence_rule: Optional[str] = None + + +class CalendarEventUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + start_datetime: Optional[datetime] = None + end_datetime: Optional[datetime] = None + all_day: Optional[bool] = None + color: Optional[str] = None + location_id: Optional[int] = None + recurrence_rule: Optional[str] = None + + +class CalendarEventResponse(BaseModel): + id: int + title: str + description: Optional[str] + start_datetime: datetime + end_datetime: datetime + all_day: bool + color: Optional[str] + location_id: Optional[int] + recurrence_rule: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/location.py b/backend/app/schemas/location.py new file mode 100644 index 0000000..d9e3e09 --- /dev/null +++ b/backend/app/schemas/location.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional + + +class LocationCreate(BaseModel): + name: str + address: str + category: str = "other" + notes: Optional[str] = None + + +class LocationUpdate(BaseModel): + name: Optional[str] = None + address: Optional[str] = None + category: Optional[str] = None + notes: Optional[str] = None + + +class LocationResponse(BaseModel): + id: int + name: str + address: str + category: str + notes: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/person.py b/backend/app/schemas/person.py new file mode 100644 index 0000000..379c833 --- /dev/null +++ b/backend/app/schemas/person.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime, date +from typing import Optional + + +class PersonCreate(BaseModel): + name: str + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + birthday: Optional[date] = None + relationship: Optional[str] = None + notes: Optional[str] = None + + +class PersonUpdate(BaseModel): + name: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + birthday: Optional[date] = None + relationship: Optional[str] = None + notes: Optional[str] = None + + +class PersonResponse(BaseModel): + id: int + name: str + email: Optional[str] + phone: Optional[str] + address: Optional[str] + birthday: Optional[date] + relationship: Optional[str] + notes: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..7f220c5 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime, date +from typing import Optional, List +from app.schemas.project_task import ProjectTaskResponse + + +class ProjectCreate(BaseModel): + name: str + description: Optional[str] = None + status: str = "not_started" + color: Optional[str] = None + due_date: Optional[date] = None + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + color: Optional[str] = None + due_date: Optional[date] = None + + +class ProjectResponse(BaseModel): + id: int + name: str + description: Optional[str] + status: str + color: Optional[str] + due_date: Optional[date] + created_at: datetime + updated_at: datetime + tasks: List[ProjectTaskResponse] = [] + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/project_task.py b/backend/app/schemas/project_task.py new file mode 100644 index 0000000..4a494db --- /dev/null +++ b/backend/app/schemas/project_task.py @@ -0,0 +1,39 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime, date +from typing import Optional + + +class ProjectTaskCreate(BaseModel): + title: str + description: Optional[str] = None + status: str = "pending" + priority: str = "medium" + due_date: Optional[date] = None + person_id: Optional[int] = None + sort_order: int = 0 + + +class ProjectTaskUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + priority: Optional[str] = None + due_date: Optional[date] = None + person_id: Optional[int] = None + sort_order: Optional[int] = None + + +class ProjectTaskResponse(BaseModel): + id: int + project_id: int + title: str + description: Optional[str] + status: str + priority: str + due_date: Optional[date] + person_id: Optional[int] + sort_order: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/reminder.py b/backend/app/schemas/reminder.py new file mode 100644 index 0000000..f34cfdd --- /dev/null +++ b/backend/app/schemas/reminder.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional + + +class ReminderCreate(BaseModel): + title: str + description: Optional[str] = None + remind_at: datetime + is_active: bool = True + recurrence_rule: Optional[str] = None + + +class ReminderUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + remind_at: Optional[datetime] = None + is_active: Optional[bool] = None + is_dismissed: Optional[bool] = None + recurrence_rule: Optional[str] = None + + +class ReminderResponse(BaseModel): + id: int + title: str + description: Optional[str] + remind_at: datetime + is_active: bool + is_dismissed: bool + recurrence_rule: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py new file mode 100644 index 0000000..5aed355 --- /dev/null +++ b/backend/app/schemas/settings.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime + + +class SettingsCreate(BaseModel): + pin: str + + +class SettingsUpdate(BaseModel): + accent_color: str | None = None + upcoming_days: int | None = None + + +class SettingsResponse(BaseModel): + id: int + accent_color: str + upcoming_days: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ChangePinRequest(BaseModel): + old_pin: str + new_pin: str diff --git a/backend/app/schemas/todo.py b/backend/app/schemas/todo.py new file mode 100644 index 0000000..4b0235f --- /dev/null +++ b/backend/app/schemas/todo.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime, date +from typing import Optional + + +class TodoCreate(BaseModel): + title: str + description: Optional[str] = None + priority: str = "medium" + due_date: Optional[date] = None + category: Optional[str] = None + recurrence_rule: Optional[str] = None + project_id: Optional[int] = None + + +class TodoUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + priority: Optional[str] = None + due_date: Optional[date] = None + completed: Optional[bool] = None + category: Optional[str] = None + recurrence_rule: Optional[str] = None + project_id: Optional[int] = None + + +class TodoResponse(BaseModel): + id: int + title: str + description: Optional[str] + priority: str + due_date: Optional[date] + completed: bool + completed_at: Optional[datetime] + category: Optional[str] + recurrence_rule: Optional[str] + project_id: Optional[int] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1451390 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +alembic==1.14.1 +pydantic==2.10.4 +pydantic-settings==2.7.1 +bcrypt==4.2.1 +python-multipart==0.0.20 +python-dateutil==2.9.0 +itsdangerous==2.2.0 diff --git a/backend/start.sh b/backend/start.sh new file mode 100644 index 0000000..770cff7 --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +# Run database migrations +echo "Running database migrations..." +alembic upgrade head + +# Start the FastAPI application +echo "Starting FastAPI application..." +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..23491da --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,39 @@ +services: + db: + image: postgres:16-alpine + restart: unless-stopped + env_file: .env + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: ./backend + restart: unless-stopped + ports: + - "8000:8000" + env_file: .env + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""] + interval: 10s + timeout: 5s + retries: 3 + + frontend: + build: ./frontend + restart: unless-stopped + ports: + - "80:80" + depends_on: + backend: + condition: service_healthy + +volumes: + postgres_data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..65ea0d6 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,32 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Environment variables +.env +.env.local +.env.production.local +.env.development.local +.env.test.local diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..f190830 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source files +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files from build stage +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port 80 +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7af3319 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,117 @@ +# LifeManager Frontend + +A modern, dark-themed React application for managing your life - todos, calendar events, reminders, projects, people, and locations. + +## Tech Stack + +- **React 18** with TypeScript +- **Vite** for fast builds +- **Tailwind CSS v3** for styling +- **shadcn/ui** components (manually implemented) +- **React Router v6** for routing +- **TanStack Query v5** for data fetching +- **Axios** for HTTP requests +- **FullCalendar** for calendar view +- **Lucide React** for icons +- **date-fns** for date formatting +- **sonner** for toast notifications + +## Features + +- PIN-based authentication with setup wizard +- Dark theme with customizable accent colors (cyan, blue, purple, orange, green) +- Dashboard with stats and widgets +- Todos with priority, category, and recurrence +- Calendar with event management +- Reminders with dismiss functionality +- Projects with tasks and progress tracking +- People management with relationships +- Locations categorization +- Responsive design with collapsible sidebar + +## Getting Started + +### Prerequisites + +- Node.js 20 or higher +- npm or yarn + +### Installation + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build + +# Preview production build +npm run preview +``` + +## Development + +The application expects a backend API at `/api`. During development, Vite proxies `/api` requests to `http://localhost:8000`. + +### Project Structure + +``` +frontend/ +├── src/ +│ ├── components/ +│ │ ├── auth/ # Authentication components +│ │ ├── calendar/ # Calendar page and forms +│ │ ├── dashboard/ # Dashboard widgets +│ │ ├── layout/ # Layout components (sidebar, etc) +│ │ ├── locations/ # Locations management +│ │ ├── people/ # People management +│ │ ├── projects/ # Projects and tasks +│ │ ├── reminders/ # Reminders management +│ │ ├── settings/ # Settings page +│ │ ├── todos/ # Todos management +│ │ └── ui/ # shadcn/ui components +│ ├── hooks/ # Custom React hooks +│ ├── lib/ # Utilities (API client, utils) +│ ├── types/ # TypeScript type definitions +│ ├── App.tsx # Main app with routing +│ ├── main.tsx # Entry point +│ └── index.css # Global styles +├── Dockerfile # Production Docker image +├── nginx.conf # Nginx configuration +└── package.json +``` + +## Docker Deployment + +Build and run with Docker: + +```bash +# Build image +docker build -t lifemanager-frontend . + +# Run container +docker run -p 80:80 lifemanager-frontend +``` + +## Environment Variables + +No environment variables required. API base URL is configured to `/api` and proxied by nginx in production. + +## Customization + +### Accent Colors + +Accent colors are defined in `src/hooks/useTheme.ts` and can be changed in Settings. The application uses CSS custom properties for theming. + +### Adding New Pages + +1. Create component in `src/components//` +2. Add route in `src/App.tsx` +3. Add navigation item in `src/components/layout/Sidebar.tsx` + +## License + +Private project - all rights reserved. diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..d4b428f --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/index.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0762d1e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + LifeManager + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..71df70b --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,41 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json; + + # API proxy + location /api { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # SPA fallback - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..13830e5 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "lifemanager", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "@tanstack/react-query": "^5.62.0", + "axios": "^1.7.9", + "@fullcalendar/react": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "lucide-react": "^0.468.0", + "date-fns": "^4.1.0", + "sonner": "^1.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^2.6.0", + "class-variance-authority": "^0.7.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..6e1694d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,60 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import LockScreen from '@/components/auth/LockScreen'; +import AppLayout from '@/components/layout/AppLayout'; +import DashboardPage from '@/components/dashboard/DashboardPage'; +import TodosPage from '@/components/todos/TodosPage'; +import CalendarPage from '@/components/calendar/CalendarPage'; +import RemindersPage from '@/components/reminders/RemindersPage'; +import ProjectsPage from '@/components/projects/ProjectsPage'; +import ProjectDetail from '@/components/projects/ProjectDetail'; +import PeoplePage from '@/components/people/PeoplePage'; +import LocationsPage from '@/components/locations/LocationsPage'; +import SettingsPage from '@/components/settings/SettingsPage'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { authStatus, isLoading } = useAuth(); + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!authStatus?.authenticated) { + return ; + } + + return <>{children}; +} + +function App() { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + +export default App; diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx new file mode 100644 index 0000000..8d740e8 --- /dev/null +++ b/frontend/src/components/auth/LockScreen.tsx @@ -0,0 +1,110 @@ +import { useState, FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'sonner'; +import { Lock } from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; + +export default function LockScreen() { + const navigate = useNavigate(); + const { authStatus, login, setup, isLoginPending, isSetupPending } = useAuth(); + const [pin, setPin] = useState(''); + const [confirmPin, setConfirmPin] = useState(''); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (authStatus?.setup_required) { + if (pin !== confirmPin) { + toast.error('PINs do not match'); + return; + } + if (pin.length < 4) { + toast.error('PIN must be at least 4 characters'); + return; + } + try { + await setup(pin); + toast.success('PIN created successfully'); + navigate('/dashboard'); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to create PIN'); + } + } else { + try { + await login(pin); + navigate('/dashboard'); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Invalid PIN'); + setPin(''); + } + } + }; + + const isSetup = authStatus?.setup_required; + + return ( +
+ + +
+ +
+ + {isSetup ? 'Welcome to LifeManager' : 'Enter PIN'} + + + {isSetup + ? 'Create a PIN to secure your account' + : 'Enter your PIN to access your dashboard'} + +
+ +
+
+ + setPin(e.target.value)} + placeholder="Enter PIN" + required + autoFocus + className="text-center text-lg tracking-widest" + /> +
+ {isSetup && ( +
+ + setConfirmPin(e.target.value)} + placeholder="Confirm PIN" + required + className="text-center text-lg tracking-widest" + /> +
+ )} + +
+
+
+
+ ); +} diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx new file mode 100644 index 0000000..5190351 --- /dev/null +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import FullCalendar from '@fullcalendar/react'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import type { EventClickArg, DateSelectArg, EventDropArg } from '@fullcalendar/core'; +import api, { getErrorMessage } from '@/lib/api'; +import type { CalendarEvent } from '@/types'; +import EventForm from './EventForm'; + +export default function CalendarPage() { + const queryClient = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [editingEvent, setEditingEvent] = useState(null); + const [selectedDate, setSelectedDate] = useState(null); + + const { data: events = [] } = useQuery({ + queryKey: ['calendar-events'], + queryFn: async () => { + const { data } = await api.get('/events'); + return data; + }, + }); + + const eventDropMutation = useMutation({ + mutationFn: async ({ id, start, end, allDay }: { id: number; start: string; end: string; allDay: boolean }) => { + const response = await api.put(`/events/${id}`, { + start_datetime: start, + end_datetime: end, + all_day: allDay, + }); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + toast.success('Event moved'); + }, + onError: (error) => { + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + toast.error(getErrorMessage(error, 'Failed to move event')); + }, + }); + + const calendarEvents = events.map((event) => ({ + id: event.id.toString(), + title: event.title, + start: event.start_datetime, + end: event.end_datetime || undefined, + allDay: event.all_day, + backgroundColor: event.color || 'hsl(var(--accent-color))', + borderColor: event.color || 'hsl(var(--accent-color))', + })); + + const handleEventClick = (info: EventClickArg) => { + const event = events.find((e) => e.id.toString() === info.event.id); + if (event) { + setEditingEvent(event); + setShowForm(true); + } + }; + + const toLocalDatetime = (d: Date): string => { + const pad = (n: number) => n.toString().padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + }; + + const handleEventDrop = (info: EventDropArg) => { + const id = parseInt(info.event.id); + const start = info.event.allDay + ? info.event.startStr + : info.event.start ? toLocalDatetime(info.event.start) : info.event.startStr; + const end = info.event.allDay + ? info.event.endStr || info.event.startStr + : info.event.end ? toLocalDatetime(info.event.end) : start; + eventDropMutation.mutate({ id, start, end, allDay: info.event.allDay }); + }; + + const handleDateSelect = (selectInfo: DateSelectArg) => { + setSelectedDate(selectInfo.startStr); + setShowForm(true); + }; + + const handleCloseForm = () => { + setShowForm(false); + setEditingEvent(null); + setSelectedDate(null); + }; + + return ( +
+
+

Calendar

+
+ +
+
+ +
+
+ + {showForm && ( + + )} +
+ ); +} diff --git a/frontend/src/components/calendar/EventForm.tsx b/frontend/src/components/calendar/EventForm.tsx new file mode 100644 index 0000000..da552f0 --- /dev/null +++ b/frontend/src/components/calendar/EventForm.tsx @@ -0,0 +1,260 @@ +import { useState, FormEvent } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import api, { getErrorMessage } from '@/lib/api'; +import type { CalendarEvent, Location } from '@/types'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogClose, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Select } from '@/components/ui/select'; +import { Label } from '@/components/ui/label'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; + +interface EventFormProps { + event: CalendarEvent | null; + initialDate?: string | null; + onClose: () => void; +} + +const colorPresets = [ + { name: 'Accent', value: '' }, + { name: 'Red', value: '#ef4444' }, + { name: 'Orange', value: '#f97316' }, + { name: 'Yellow', value: '#eab308' }, + { name: 'Green', value: '#22c55e' }, + { name: 'Blue', value: '#3b82f6' }, + { name: 'Purple', value: '#8b5cf6' }, + { name: 'Pink', value: '#ec4899' }, +]; + +// Extract just the date portion (YYYY-MM-DD) from any date/datetime string +function toDateOnly(dt: string): string { + if (!dt) return ''; + return dt.split('T')[0]; +} + +// Ensure a datetime string is in datetime-local format (YYYY-MM-DDThh:mm) +function toDatetimeLocal(dt: string, fallbackTime: string = '09:00'): string { + if (!dt) return ''; + if (dt.includes('T')) return dt.slice(0, 16); // trim seconds/timezone + return `${dt}T${fallbackTime}`; +} + +// Format a date/datetime string for the correct input type +function formatForInput(dt: string, allDay: boolean, fallbackTime: string = '09:00'): string { + if (!dt) return ''; + return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime); +} + +export default function EventForm({ event, initialDate, onClose }: EventFormProps) { + const queryClient = useQueryClient(); + const isAllDay = event?.all_day ?? false; + const rawStart = event?.start_datetime || initialDate || ''; + const rawEnd = event?.end_datetime || initialDate || ''; + const [formData, setFormData] = useState({ + title: event?.title || '', + description: event?.description || '', + start_datetime: formatForInput(rawStart, isAllDay, '09:00'), + end_datetime: formatForInput(rawEnd, isAllDay, '10:00'), + all_day: isAllDay, + location_id: event?.location_id?.toString() || '', + color: event?.color || '', + recurrence_rule: event?.recurrence_rule || '', + }); + + const { data: locations = [] } = useQuery({ + queryKey: ['locations'], + queryFn: async () => { + const { data } = await api.get('/locations'); + return data; + }, + }); + + const mutation = useMutation({ + mutationFn: async (data: typeof formData) => { + const payload = { + ...data, + location_id: data.location_id ? parseInt(data.location_id) : null, + }; + if (event) { + const response = await api.put(`/events/${event.id}`, payload); + return response.data; + } else { + const response = await api.post('/events', payload); + return response.data; + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + toast.success(event ? 'Event updated' : 'Event created'); + onClose(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, event ? 'Failed to update event' : 'Failed to create event')); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async () => { + await api.delete(`/events/${event?.id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendar-events'] }); + toast.success('Event deleted'); + onClose(); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to delete event')); + }, + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutation.mutate(formData); + }; + + return ( + + + + + {event ? 'Edit Event' : 'New Event'} + +
+
+ + setFormData({ ...formData, title: e.target.value })} + required + /> +
+ +
+ +