diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b83f5b2..4ab7f47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,12 +12,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python 3.12 uses: actions/setup-python@v5 with: python-version: "3.12" + cache: 'pip' - name: Install dependencies working-directory: backend @@ -32,12 +33,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "22" + cache: 'npm' + cache-dependency-path: 'frontend/package-lock.json' - name: Install dependencies working-directory: frontend @@ -49,4 +52,25 @@ jobs: - name: Build working-directory: frontend - run: npm run build \ No newline at end of file + run: npm run build + + backend-lint: + name: Backend Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install dependencies + working-directory: backend + run: pip install -e ".[dev]" + + - name: Run ruff + working-directory: backend + run: ruff check app/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index c0938af..7cd02ea 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ build/ node_modules/ dist/ .vite/ +*.tsbuildinfo # Environment .env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e9d2e2d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to HomeOS + +Thanks for your interest in contributing! Here's how to get started. + +## Setup + +Follow the [Development Guide](docs/DEVELOPMENT.md) to set up the project locally. + +## Branch Workflow + +1. **Fork** the repository on GitHub +2. **Clone** your fork locally +3. **Create a branch** from `main` with a descriptive name: + - Features: `add-meal-planner`, `add-ai-assistant` + - Fixes: `fix-prayer-time-offset`, `fix-calendar-swipe` + - Improvements: `improve-weather-card`, `refactor-task-modal` +4. **Work** on your branch, committing as you go +5. **Push** your branch to your fork +6. **Open a Pull Request** targeting the `develop` branch + +## Code Conventions + +### Backend (Python) +- Python 3.12+, FastAPI, SQLAlchemy (async), SQLite +- Use the `homeos` conda environment for all Python work +- Lint with `ruff check app/` — must pass with zero errors +- Follow the module pattern: `models.py` → `schemas.py` → `service.py` → `router.py` +- Business logic goes in the service layer, not the router + +### Frontend (TypeScript/React) +- React 19, TypeScript, Vite, Tailwind CSS 4 +- Use CSS theme variables (`bg-surface`, `text-text`, `text-primary`, etc.) — no hardcoded colours +- Shared components go in `src/components/`, feature-specific components in `src/features//` +- Shared constants (`INPUT_STYLE`, `TAP_SPRING`, `PRESET_COLOURS`) go in `src/constants.ts` +- Use TanStack Query hooks for data fetching — no direct API calls in components + +## Testing + +- All backend tests must pass before submitting a PR: `pytest tests/ -v` +- TypeScript must compile cleanly: `npx tsc --noEmit` +- Frontend must build: `npm run build` +- The pre-push hook runs all three checks automatically +- Add tests for new backend endpoints + +## Pull Request Guidelines + +- Keep PRs focused — one feature or fix per PR +- Write a clear title and description explaining what changed and why +- Include test coverage for new backend functionality +- Never commit secrets, API keys, or personal data (`.env`, coordinates, family photos) +- Screenshots are helpful for UI changes + +## Project Structure + +See the [Development Guide](docs/DEVELOPMENT.md#project-structure) for a full overview of the codebase layout. + +## Questions? + +Open an issue if you're unsure about anything or want to discuss an approach before starting work. diff --git a/README.md b/README.md index 036a7e3..3d73865 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,22 @@ # HomeOS -A self-hosted family operating system for a kitchen touchscreen. Manages household tasks, shopping lists, and family notes from a single shared display. +A self-hosted family operating system for a kitchen touchscreen. Manages household tasks, shopping lists, notes, prayer times, and birthdays from a single shared display. + +## Screenshots + +

+ Dashboard + Calendar +

+ +

+ Tasks + Shopping +

+ +

+ Settings +

## Tech Stack @@ -9,257 +25,22 @@ A self-hosted family operating system for a kitchen touchscreen. Manages househo - **Infrastructure:** Docker, Docker Compose - **Target Device:** Linux Mini PC + touchscreen display -## Setup - -### Backend - -```bash -conda activate homeos -cd backend -pip install -e ".[dev]" -uvicorn app.main:app --reload -``` - -Backend runs at `http://localhost:8000` - -### Frontend - -```bash -cd frontend -npm install -npm run dev -``` - -Frontend runs at `http://localhost:5173` - -### Docker (recommended) - -Run the entire app with a single command: - -```bash -docker compose up --build -``` - -App runs at `http://localhost`. Backend on port 8000, frontend on port 80 via nginx. - -To stop: `docker compose down` - -#### Docker without admin access (macOS) - -If you don't have admin access to install Docker Desktop, use Colima: - -```bash -brew install docker colima docker-compose -mkdir -p ~/.docker/cli-plugins -ln -sf $(brew --prefix)/bin/docker-compose ~/.docker/cli-plugins/docker-compose -colima start -``` - -Then `docker compose up --build` works as normal. To stop Colima when done: `colima stop`. - -## Production Deployment - -Deploy HomeOS on a dedicated Linux machine (Mini PC, old laptop, etc.). - -### Prerequisites - -- Linux with Docker and Docker Compose installed -- Git installed - -### First-time setup +## Quick Start ```bash git clone https://github.com/YOUR_USERNAME/HomeOS.git cd HomeOS cp .env.example .env +docker compose up --build ``` -Edit `.env` and set your location coordinates. These are used for both weather and prayer times: - -``` -WEATHER_LATITUDE=your_latitude -WEATHER_LONGITUDE=your_longitude -``` - -To find your coordinates, search your city name on Google Maps and copy the latitude/longitude from the URL. - -#### Prayer Times - -Prayer times are fetched from the [Aladhan API](https://aladhan.com/prayer-times-api) using: - -- **Method 15** (Moonsighting Committee Worldwide) — closest to UK mosque timetables for Fajr -- **Hanafi school** — for later Asr times matching most UK mosques - -Times refresh automatically at 1am daily. The next upcoming prayer is highlighted on the dashboard. These are calculated astronomical times, so they may differ by a few minutes from your local mosque's posted times. - -Start the app in the background: - -```bash -docker compose up --build -d -``` - -Open `http://localhost` in a browser. To access from other devices on the same network, use the machine's IP address. - -### Updating to latest version - -```bash -cd HomeOS -git pull -docker compose down -docker compose up --build -d -``` - -Your data (members, tasks, shopping, notes) is stored on a Docker volume and is preserved across updates. - -### Auto-start after reboot - -To ensure HomeOS starts automatically when the machine restarts: - -```bash -sudo systemctl enable docker -``` - -This makes Docker start on boot. The containers auto-start with Docker because they're configured with `restart: unless-stopped`. No need to run `docker compose up` again after a reboot. - -To verify Docker is running after a restart: - -```bash -sudo systemctl status docker -``` - -### Remote Access with Tailscale - -Access HomeOS from your phone outside your home WiFi using Tailscale (free VPN). - -**On the Linux laptop (HomeOS server):** - -```bash -curl -fsSL https://tailscale.com/install.sh | sh -sudo tailscale up -``` - -Note the Tailscale IP shown (e.g. `100.x.x.x`). - -**On your phone:** - -1. Install Tailscale from App Store (iPhone) or Play Store (Android) -2. Sign in with the same account used on the Linux laptop -3. Open `http://100.x.x.x` in your phone browser (use the Tailscale IP from above) - -Tailscale runs in the background — once set up, your phone can reach HomeOS from anywhere without opening ports or exposing your home network. - -### Install on Phone (Add to Home Screen) - -HomeOS is a PWA — it can be installed on your phone's home screen like a native app. - -**iPhone (Safari):** - -1. Open HomeOS in Safari (use your home WiFi IP or Tailscale IP) -2. Tap the Share button (square with arrow) -3. Scroll down and tap "Add to Home Screen" -4. Tap "Add" - -**Android (Chrome):** - -1. Open HomeOS in Chrome -2. Tap the three-dot menu -3. Tap "Add to Home screen" or "Install app" -4. Tap "Add" - -The app opens fullscreen without a browser address bar, with the HomeOS icon on your home screen. - -### Useful commands - -```bash -docker compose up --build -d # start in background -docker compose down # stop -docker compose logs -f # view live logs -docker compose ps # check container status -``` - -### Database Backups - -Run the backup script manually: - -```bash -cd HomeOS -./scripts/backup.sh -``` - -Backups are saved to `~/homeos-backups/` with timestamps (e.g. `homeos-2026-08-09.db`). Backups older than 7 days are automatically deleted. - -To schedule daily backups at 3am on the Linux machine: - -```bash -crontab -e -# Add this line (adjust the path to your HomeOS directory): -0 3 * * * cd /path/to/HomeOS && ./scripts/backup.sh >> ~/homeos-backups/backup.log 2>&1 -``` - -### Pre-Push Checks - -Run all checks (backend tests, frontend type check, frontend build) manually before pushing: - -```bash -cd HomeOS -.git/hooks/pre-push -``` - -These checks also run automatically on every `git push`. If any check fails, the push is blocked. - -### Releasing a New Version - -When you're ready to release a new version: - -1. Update the version number in two files: - - `backend/app/core/config.py` — change `VERSION` - - `frontend/package.json` — change `version` - -2. Commit, push, and merge to main - -3. Tag the release on main: -```bash -git checkout main -git pull -git tag v1.2 -git push origin v1.2 -``` - -4. Update CHANGELOG.md with what changed in this version +## Documentation -Version format is two numbers (e.g. v1.0, v1.1, v1.2). Bump the second number for new features, use a third number for bug fixes if needed (e.g. v1.2.1). +- [Deployment Guide](docs/DEPLOYMENT.md) +- [Development Guide](docs/DEVELOPMENT.md) +- [Contributing](CONTRIBUTING.md) +- [Changelog](CHANGELOG.md) -### Run Tests +## License -```bash -conda activate homeos -cd backend -pytest tests/ -v # run all tests -pytest tests/test_health.py # run a specific test file -pytest tests/ -v --cov=app # run with coverage report -``` - -Tests use an in-memory SQLite database — each test gets a fresh database so tests don't interfere with each other. - -## Project Structure - -``` -HomeOS/ -├── backend/ # FastAPI application -│ ├── app/ -│ │ ├── api/v1/ # Versioned API routes -│ │ ├── core/ # Config, database, logging, exceptions -│ │ └── modules/ # Feature modules (members, tasks, shopping, notes) -│ ├── migrations/ # Alembic database migrations -│ └── tests/ -├── frontend/ # React application -│ └── src/ -│ ├── api/ # Axios client -│ ├── components/# Shared UI components -│ ├── features/ # Feature pages (dashboard, tasks, shopping, notes, settings) -│ ├── layouts/ # App shell and navigation -│ ├── stores/ # Zustand state management -│ └── types/ # Shared TypeScript types -├── scripts/ # Backup and utility scripts -└── CHANGELOG.md # Release history -``` +MIT diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index d91e99d..3a82b3b 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,16 +1,16 @@ from fastapi import APIRouter from app.api.v1.health import router as health_router +from app.modules.calendar.birthday_router import router as birthdays_router +from app.modules.calendar.calendar_router import router as calendar_router from app.modules.members.router import router as members_router -from app.modules.tasks.router import router as tasks_router -from app.modules.shopping.router import router as shopping_router from app.modules.notes.router import router as notes_router -from app.modules.weather.router import router as weather_router from app.modules.prayer.router import router as prayer_router -from app.modules.shopping.store_router import router as store_router from app.modules.shopping.quick_add_router import router as quick_add_router -from app.modules.calendar.birthday_router import router as birthdays_router -from app.modules.calendar.calendar_router import router as calendar_router +from app.modules.shopping.router import router as shopping_router +from app.modules.shopping.store_router import router as store_router +from app.modules.tasks.router import router as tasks_router +from app.modules.weather.router import router as weather_router v1_router = APIRouter() v1_router.include_router(health_router) diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py index 248e91c..f704093 100644 --- a/backend/app/core/scheduler.py +++ b/backend/app/core/scheduler.py @@ -1,5 +1,8 @@ +from collections.abc import Awaitable, Callable + from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger +from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import async_session_factory from app.core.logging import get_logger @@ -9,6 +12,20 @@ scheduler = AsyncIOScheduler() +async def _run_job( + name: str, work_fn: Callable[[AsyncSession], Awaitable[None]] +) -> None: + """Run *work_fn* inside a session with automatic commit / rollback.""" + logger.info("Scheduled %s triggered", name) + async with async_session_factory() as session: + try: + await work_fn(session) + await session.commit() + except Exception: + await session.rollback() + logger.exception("Scheduled %s failed", name) + + async def run_prayer_refresh() -> None: from app.modules.prayer.service import fetch_prayer_times @@ -22,27 +39,19 @@ async def run_prayer_refresh() -> None: async def run_recurrence_reset() -> None: from app.modules.tasks.recurrence import reset_recurring_tasks - logger.info("Scheduled recurring task reset triggered") - async with async_session_factory() as session: - try: - await reset_recurring_tasks(session) - await session.commit() - except Exception: - await session.rollback() - logger.exception("Scheduled recurring task reset failed") + await _run_job("recurring task reset", reset_recurring_tasks) async def run_rotation() -> None: from app.modules.tasks.rotation import rotate_tasks - logger.info("Scheduled rotation triggered") - async with async_session_factory() as session: - try: - await rotate_tasks(session) - await session.commit() - except Exception: - await session.rollback() - logger.exception("Scheduled rotation failed") + await _run_job("rotation", rotate_tasks) + + +async def run_cleanup() -> None: + from app.modules.cleanup.service import cleanup_old_records + + await _run_job("cleanup", cleanup_old_records) def setup_scheduler() -> None: @@ -64,8 +73,14 @@ def setup_scheduler() -> None: id="daily_prayer_refresh", replace_existing=True, ) + scheduler.add_job( + run_cleanup, + trigger=CronTrigger(hour=2, minute=0), + id="daily_cleanup", + replace_existing=True, + ) scheduler.start() - logger.info("Scheduler started: recurrence reset daily at 00:01, task rotation every Monday at midnight, prayer times refresh daily at 1am") + logger.info("Scheduler started: recurrence reset daily at 00:01, task rotation every Monday at midnight, prayer times refresh daily at 1am, cleanup daily at 2am") def shutdown_scheduler() -> None: diff --git a/backend/app/core/schemas.py b/backend/app/core/schemas.py new file mode 100644 index 0000000..e8a8202 --- /dev/null +++ b/backend/app/core/schemas.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class ReorderRequest(BaseModel): + ids: list[int] = Field(min_length=1) diff --git a/backend/app/core/utils.py b/backend/app/core/utils.py new file mode 100644 index 0000000..1319035 --- /dev/null +++ b/backend/app/core/utils.py @@ -0,0 +1,20 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import NotFoundError +from app.modules.members.models import Member + + +async def verify_member_exists(db: AsyncSession, member_id: int) -> None: + result = await db.execute(select(Member).where(Member.id == member_id)) + if not result.scalar_one_or_none(): + raise NotFoundError("Member", member_id) + + +async def reorder_items(db: AsyncSession, model_class: type, item_ids: list[int]) -> None: + for index, item_id in enumerate(item_ids): + result = await db.execute(select(model_class).where(model_class.id == item_id)) + item = result.scalar_one_or_none() + if item: + item.sort_order = index + await db.flush() diff --git a/backend/app/main.py b/backend/app/main.py index 081664f..0a5ccfc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,5 @@ -from contextlib import asynccontextmanager from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware diff --git a/backend/app/modules/calendar/birthday_models.py b/backend/app/modules/calendar/birthday_models.py index 19f2e9f..d3f7a23 100644 --- a/backend/app/modules/calendar/birthday_models.py +++ b/backend/app/modules/calendar/birthday_models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import String, Integer, DateTime, func +from sqlalchemy import DateTime, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base diff --git a/backend/app/modules/calendar/birthday_router.py b/backend/app/modules/calendar/birthday_router.py index cad1967..4457ddc 100644 --- a/backend/app/modules/calendar/birthday_router.py +++ b/backend/app/modules/calendar/birthday_router.py @@ -2,8 +2,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.modules.calendar.birthday_schemas import BirthdayCreate, BirthdayResponse, UpcomingBirthdayResponse from app.modules.calendar import birthday_service as service +from app.modules.calendar.birthday_schemas import ( + BirthdayCreate, + BirthdayResponse, + UpcomingBirthdayResponse, +) router = APIRouter(prefix="/birthdays", tags=["birthdays"]) diff --git a/backend/app/modules/calendar/birthday_service.py b/backend/app/modules/calendar/birthday_service.py index 1bbd1e8..41ebb76 100644 --- a/backend/app/modules/calendar/birthday_service.py +++ b/backend/app/modules/calendar/birthday_service.py @@ -33,7 +33,10 @@ async def get_upcoming_birthdays(db: AsyncSession, days: int = 7) -> list[Upcomi continue if this_year < today: - this_year = date(today.year + 1, bday.month, bday.day) + try: + this_year = date(today.year + 1, bday.month, bday.day) + except ValueError: + continue days_until = (this_year - today).days if 0 <= days_until <= days: diff --git a/backend/app/modules/calendar/calendar_schemas.py b/backend/app/modules/calendar/calendar_schemas.py index 0143ab2..39e95ef 100644 --- a/backend/app/modules/calendar/calendar_schemas.py +++ b/backend/app/modules/calendar/calendar_schemas.py @@ -1,7 +1,7 @@ from pydantic import BaseModel -from app.modules.tasks.schemas import TaskResponse from app.modules.calendar.birthday_schemas import BirthdayResponse +from app.modules.tasks.schemas import TaskResponse class CalendarDateResponse(BaseModel): diff --git a/backend/app/modules/calendar/calendar_service.py b/backend/app/modules/calendar/calendar_service.py index 02a1dd8..9a0f23b 100644 --- a/backend/app/modules/calendar/calendar_service.py +++ b/backend/app/modules/calendar/calendar_service.py @@ -2,9 +2,9 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.tasks.service import get_tasks_by_date from app.modules.calendar.birthday_service import get_birthdays_by_date from app.modules.calendar.calendar_schemas import CalendarDateResponse +from app.modules.tasks.service import get_tasks_by_date async def get_calendar_by_date(db: AsyncSession, target_date: date) -> CalendarDateResponse: diff --git a/backend/app/modules/cleanup/__init__.py b/backend/app/modules/cleanup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/cleanup/service.py b/backend/app/modules/cleanup/service.py new file mode 100644 index 0000000..02eaada --- /dev/null +++ b/backend/app/modules/cleanup/service.py @@ -0,0 +1,39 @@ +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logging import get_logger +from app.modules.notes.models import Note +from app.modules.shopping.models import ShoppingItem +from app.modules.tasks.models import Task + +logger = get_logger(__name__) + +RETENTION_DAYS = 365 + + +async def cleanup_old_records(db: AsyncSession) -> None: + cutoff = datetime.now(UTC).replace(tzinfo=None) - timedelta(days=RETENTION_DAYS) + + tasks_result = await db.execute( + delete(Task).where(Task.is_completed == True, Task.completed_at < cutoff) + ) + tasks_deleted = tasks_result.rowcount + + shopping_result = await db.execute( + delete(ShoppingItem).where(ShoppingItem.is_purchased == True, ShoppingItem.purchased_at < cutoff) + ) + shopping_deleted = shopping_result.rowcount + + notes_result = await db.execute( + delete(Note).where(Note.created_at < cutoff) + ) + notes_deleted = notes_result.rowcount + + await db.flush() + + logger.info( + "Cleanup complete: %d tasks, %d shopping items, %d notes deleted (older than %d days)", + tasks_deleted, shopping_deleted, notes_deleted, RETENTION_DAYS, + ) \ No newline at end of file diff --git a/backend/app/modules/members/models.py b/backend/app/modules/members/models.py index 6eb96e0..c502a5e 100644 --- a/backend/app/modules/members/models.py +++ b/backend/app/modules/members/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import String, DateTime, func +from sqlalchemy import DateTime, String, func from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base diff --git a/backend/app/modules/members/router.py b/backend/app/modules/members/router.py index 91adaf3..0d0cbda 100644 --- a/backend/app/modules/members/router.py +++ b/backend/app/modules/members/router.py @@ -1,29 +1,74 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, UploadFile +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.modules.members.schemas import MemberCreate, MemberUpdate, MemberResponse +from app.core.exceptions import NotFoundError, ValidationError from app.modules.members import service +from app.modules.members.schemas import MemberCreate, MemberResponse, MemberUpdate router = APIRouter(prefix="/members", tags=["members"]) +ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"} +MAX_FILE_SIZE = 5 * 1024 * 1024 -@router.get("", response_model=list[MemberResponse]) + +def _to_response(member) -> dict: + data = MemberResponse.model_validate(member).model_dump() + data["avatar_url"] = service.get_avatar_url(member.id) + return data + + +@router.get("") async def list_members(db: AsyncSession = Depends(get_db)): - return await service.get_all_members(db) + members = await service.get_all_members(db) + return [_to_response(m) for m in members] -@router.post("", response_model=MemberResponse, status_code=201) +@router.post("", status_code=201) async def create_member(data: MemberCreate, db: AsyncSession = Depends(get_db)): - return await service.create_member(db, data) + member = await service.create_member(db, data) + return _to_response(member) -@router.put("/{member_id}", response_model=MemberResponse) +@router.put("/{member_id}") async def update_member(member_id: int, data: MemberUpdate, db: AsyncSession = Depends(get_db)): - return await service.update_member(db, member_id, data) + member = await service.update_member(db, member_id, data) + return _to_response(member) @router.delete("/{member_id}") async def delete_member(member_id: int, db: AsyncSession = Depends(get_db)): await service.delete_member(db, member_id) return {"message": "Member deleted"} + + +@router.post("/{member_id}/avatar") +async def upload_avatar(member_id: int, file: UploadFile, db: AsyncSession = Depends(get_db)): + await service.get_member(db, member_id) + + if file.content_type not in ALLOWED_TYPES: + raise ValidationError("File must be JPEG, PNG, or WebP") + + file_data = await file.read() + if len(file_data) > MAX_FILE_SIZE: + raise ValidationError("File must be under 5MB") + + avatar_url = await service.save_avatar(member_id, file_data) + return {"avatar_url": avatar_url} + + +@router.get("/{member_id}/avatar") +async def get_avatar(member_id: int): + path = service.get_avatar_path(member_id) + if not path.is_file(): + raise NotFoundError("Avatar", member_id) + return FileResponse(path, media_type="image/jpeg") + + +@router.delete("/{member_id}/avatar") +async def delete_avatar(member_id: int, db: AsyncSession = Depends(get_db)): + await service.get_member(db, member_id) + + service.delete_avatar(member_id) + return {"message": "Avatar deleted"} \ No newline at end of file diff --git a/backend/app/modules/members/schemas.py b/backend/app/modules/members/schemas.py index 0e0e1f1..59c1ade 100644 --- a/backend/app/modules/members/schemas.py +++ b/backend/app/modules/members/schemas.py @@ -8,15 +8,14 @@ class MemberCreate(BaseModel): colour: str = Field(min_length=7, max_length=7, pattern=r"^#[0-9a-fA-F]{6}$") -class MemberUpdate(BaseModel): - name: str = Field(min_length=1, max_length=100) - colour: str = Field(min_length=7, max_length=7, pattern=r"^#[0-9a-fA-F]{6}$") +MemberUpdate = MemberCreate class MemberResponse(BaseModel): id: int name: str colour: str + avatar_url: str | None = None created_at: datetime model_config = {"from_attributes": True} diff --git a/backend/app/modules/members/service.py b/backend/app/modules/members/service.py index 9c2d63e..ceefd7a 100644 --- a/backend/app/modules/members/service.py +++ b/backend/app/modules/members/service.py @@ -1,11 +1,26 @@ +import io +from pathlib import Path + +from PIL import Image from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError, ValidationError from app.modules.members.models import Member from app.modules.members.schemas import MemberCreate, MemberUpdate -from app.modules.tasks.models import Task from app.modules.notes.models import Note +from app.modules.tasks.models import Task + +AVATAR_DIR = Path("data/avatars") +AVATAR_MAX_SIZE = 256 + + +async def get_member(db: AsyncSession, member_id: int) -> Member: + result = await db.execute(select(Member).where(Member.id == member_id)) + member = result.scalar_one_or_none() + if not member: + raise NotFoundError("Member", member_id) + return member async def get_all_members(db: AsyncSession) -> list[Member]: @@ -26,10 +41,7 @@ async def create_member(db: AsyncSession, data: MemberCreate) -> Member: async def update_member(db: AsyncSession, member_id: int, data: MemberUpdate) -> Member: - result = await db.execute(select(Member).where(Member.id == member_id)) - member = result.scalar_one_or_none() - if not member: - raise NotFoundError("Member", member_id) + member = await get_member(db, member_id) if data.name != member.name: existing = await db.execute(select(Member).where(Member.name == data.name)) @@ -44,12 +56,50 @@ async def update_member(db: AsyncSession, member_id: int, data: MemberUpdate) -> async def delete_member(db: AsyncSession, member_id: int) -> None: - result = await db.execute(select(Member).where(Member.id == member_id)) - member = result.scalar_one_or_none() - if not member: - raise NotFoundError("Member", member_id) + member = await get_member(db, member_id) await db.execute(update(Task).where(Task.assigned_to == member_id).values(assigned_to=None)) await db.execute(update(Note).where(Note.author_id == member_id).values(author_id=None)) + delete_avatar(member_id) await db.delete(member) + + +def get_avatar_path(member_id: int) -> Path: + return AVATAR_DIR / f"{member_id}.jpg" + + +def avatar_exists(member_id: int) -> bool: + return get_avatar_path(member_id).is_file() + + +def get_avatar_url(member_id: int) -> str | None: + if avatar_exists(member_id): + return f"/api/v1/members/{member_id}/avatar" + return None + + +async def save_avatar(member_id: int, file_data: bytes) -> str: + AVATAR_DIR.mkdir(parents=True, exist_ok=True) + path = get_avatar_path(member_id) + + img = Image.open(io.BytesIO(file_data)) + img = img.convert("RGB") + + width, height = img.size + side = min(width, height) + left = (width - side) // 2 + top = (height - side) // 2 + img = img.crop((left, top, left + side, top + side)) + + if side > AVATAR_MAX_SIZE: + img = img.resize((AVATAR_MAX_SIZE, AVATAR_MAX_SIZE), Image.LANCZOS) + + img.save(path, "JPEG", quality=85) + return f"/api/v1/members/{member_id}/avatar" + + +def delete_avatar(member_id: int) -> None: + path = get_avatar_path(member_id) + if path.is_file(): + path.unlink() diff --git a/backend/app/modules/notes/models.py b/backend/app/modules/notes/models.py index 3750aa7..0d34a40 100644 --- a/backend/app/modules/notes/models.py +++ b/backend/app/modules/notes/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import String, DateTime, Integer, ForeignKey, func +from sqlalchemy import DateTime, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base diff --git a/backend/app/modules/notes/router.py b/backend/app/modules/notes/router.py index 1ac312f..712677c 100644 --- a/backend/app/modules/notes/router.py +++ b/backend/app/modules/notes/router.py @@ -2,8 +2,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.modules.notes.schemas import NoteCreate, NoteUpdate, NoteResponse from app.modules.notes import service +from app.modules.notes.schemas import NoteCreate, NoteResponse, NoteUpdate router = APIRouter(prefix="/notes", tags=["notes"]) diff --git a/backend/app/modules/notes/service.py b/backend/app/modules/notes/service.py index b8a091d..254f97d 100644 --- a/backend/app/modules/notes/service.py +++ b/backend/app/modules/notes/service.py @@ -2,17 +2,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError -from app.modules.members.models import Member +from app.core.utils import verify_member_exists from app.modules.notes.models import Note from app.modules.notes.schemas import NoteCreate, NoteUpdate -async def _verify_member_exists(db: AsyncSession, member_id: int) -> None: - result = await db.execute(select(Member).where(Member.id == member_id)) - if not result.scalar_one_or_none(): - raise NotFoundError("Member", member_id) - - async def get_all_notes(db: AsyncSession) -> list[Note]: result = await db.execute(select(Note).order_by(Note.id.desc())) return list(result.scalars().all()) @@ -20,7 +14,7 @@ async def get_all_notes(db: AsyncSession) -> list[Note]: async def create_note(db: AsyncSession, data: NoteCreate) -> Note: if data.author_id is not None: - await _verify_member_exists(db, data.author_id) + await verify_member_exists(db, data.author_id) note = Note(content=data.content, author_id=data.author_id) db.add(note) diff --git a/backend/app/modules/prayer/service.py b/backend/app/modules/prayer/service.py index d3c8f46..561fe09 100644 --- a/backend/app/modules/prayer/service.py +++ b/backend/app/modules/prayer/service.py @@ -52,7 +52,7 @@ async def fetch_prayer_times() -> PrayerTimesResponse: "longitude": settings.WEATHER_LONGITUDE, "method": 15, "school": 1, - "tune": "0,0,0,5,0,5,0,9,0", + "tune": "0,0,0,5,0,5,0,24,0", } async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: diff --git a/backend/app/modules/shopping/__init__.py b/backend/app/modules/shopping/__init__.py index 1f26c17..19e14b5 100644 --- a/backend/app/modules/shopping/__init__.py +++ b/backend/app/modules/shopping/__init__.py @@ -1,5 +1,5 @@ from app.modules.shopping.models import ShoppingItem -from app.modules.shopping.store_models import Store from app.modules.shopping.quick_add_models import QuickAddItem +from app.modules.shopping.store_models import Store -__all__ = ["ShoppingItem", "Store", "QuickAddItem"] +__all__ = ["QuickAddItem", "ShoppingItem", "Store"] diff --git a/backend/app/modules/shopping/models.py b/backend/app/modules/shopping/models.py index 8260ddb..be3874e 100644 --- a/backend/app/modules/shopping/models.py +++ b/backend/app/modules/shopping/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base @@ -12,6 +12,7 @@ class ShoppingItem(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(200), nullable=False) is_purchased: Mapped[bool] = mapped_column(Boolean, default=False) + purchased_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) store_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("stores.id"), nullable=True) sort_order: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/app/modules/shopping/quick_add_models.py b/backend/app/modules/shopping/quick_add_models.py index b927041..b272592 100644 --- a/backend/app/modules/shopping/quick_add_models.py +++ b/backend/app/modules/shopping/quick_add_models.py @@ -1,4 +1,4 @@ -from sqlalchemy import String, Integer +from sqlalchemy import Integer, String from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base diff --git a/backend/app/modules/shopping/quick_add_router.py b/backend/app/modules/shopping/quick_add_router.py index 6c83878..ccbe5a4 100644 --- a/backend/app/modules/shopping/quick_add_router.py +++ b/backend/app/modules/shopping/quick_add_router.py @@ -2,8 +2,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.modules.shopping.quick_add_schemas import QuickAddItemCreate, QuickAddItemResponse from app.modules.shopping import quick_add_service as service +from app.modules.shopping.quick_add_schemas import QuickAddItemCreate, QuickAddItemResponse router = APIRouter(prefix="/quick-add", tags=["quick-add"]) diff --git a/backend/app/modules/shopping/quick_add_service.py b/backend/app/modules/shopping/quick_add_service.py index 2fef30b..b2a4c98 100644 --- a/backend/app/modules/shopping/quick_add_service.py +++ b/backend/app/modules/shopping/quick_add_service.py @@ -1,4 +1,4 @@ -from sqlalchemy import select, func +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError diff --git a/backend/app/modules/shopping/router.py b/backend/app/modules/shopping/router.py index 020cb18..bf8f815 100644 --- a/backend/app/modules/shopping/router.py +++ b/backend/app/modules/shopping/router.py @@ -2,9 +2,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.modules.shopping.schemas import ShoppingItemCreate, ShoppingItemUpdate, ShoppingItemResponse -from app.modules.tasks.schemas import ReorderRequest +from app.core.schemas import ReorderRequest from app.modules.shopping import service +from app.modules.shopping.schemas import ( + ShoppingItemCreate, + ShoppingItemResponse, + ShoppingItemUpdate, +) router = APIRouter(prefix="/shopping", tags=["shopping"]) diff --git a/backend/app/modules/shopping/schemas.py b/backend/app/modules/shopping/schemas.py index ad3f3d8..5702b57 100644 --- a/backend/app/modules/shopping/schemas.py +++ b/backend/app/modules/shopping/schemas.py @@ -16,9 +16,7 @@ class StoreCreate(BaseModel): colour: str = Field(min_length=7, max_length=7, pattern=r"^#[0-9a-fA-F]{6}$") -class StoreUpdate(BaseModel): - name: str = Field(min_length=1, max_length=100) - colour: str = Field(min_length=7, max_length=7, pattern=r"^#[0-9a-fA-F]{6}$") +StoreUpdate = StoreCreate class StoreResponse(BaseModel): @@ -35,15 +33,14 @@ class ShoppingItemCreate(BaseModel): store_id: int | None = None -class ShoppingItemUpdate(BaseModel): - name: str = Field(min_length=1, max_length=200) - store_id: int | None = None +ShoppingItemUpdate = ShoppingItemCreate class ShoppingItemResponse(BaseModel): id: int name: str is_purchased: bool + purchased_at: datetime | None store_id: int | None created_at: datetime store: StoreSummary | None = None diff --git a/backend/app/modules/shopping/service.py b/backend/app/modules/shopping/service.py index 543058c..42af423 100644 --- a/backend/app/modules/shopping/service.py +++ b/backend/app/modules/shopping/service.py @@ -1,10 +1,13 @@ -from sqlalchemy import select, case +from datetime import UTC, datetime + +from sqlalchemy import case, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError +from app.core.utils import reorder_items as reorder_items_generic from app.modules.shopping.models import ShoppingItem -from app.modules.shopping.store_models import Store from app.modules.shopping.schemas import ShoppingItemCreate, ShoppingItemUpdate +from app.modules.shopping.store_models import Store async def _verify_store_exists(db: AsyncSession, store_id: int) -> None: @@ -16,7 +19,7 @@ async def _verify_store_exists(db: AsyncSession, store_id: int) -> None: async def get_all_items(db: AsyncSession) -> list[ShoppingItem]: result = await db.execute( select(ShoppingItem).order_by( - case((ShoppingItem.is_purchased == False, 0), else_=1), # noqa: E712 + case((ShoppingItem.is_purchased == False, 0), else_=1), ShoppingItem.sort_order, ShoppingItem.created_at, ) @@ -58,18 +61,14 @@ async def toggle_item(db: AsyncSession, item_id: int) -> ShoppingItem: raise NotFoundError("Shopping item", item_id) item.is_purchased = not item.is_purchased + item.purchased_at = datetime.now(UTC) if item.is_purchased else None await db.flush() await db.refresh(item) return item async def reorder_items(db: AsyncSession, ids: list[int]) -> None: - for index, item_id in enumerate(ids): - result = await db.execute(select(ShoppingItem).where(ShoppingItem.id == item_id)) - item = result.scalar_one_or_none() - if item: - item.sort_order = index - await db.flush() + await reorder_items_generic(db, ShoppingItem, ids) async def delete_item(db: AsyncSession, item_id: int) -> None: diff --git a/backend/app/modules/shopping/store_models.py b/backend/app/modules/shopping/store_models.py index 090527e..f37890b 100644 --- a/backend/app/modules/shopping/store_models.py +++ b/backend/app/modules/shopping/store_models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import String, DateTime, func +from sqlalchemy import DateTime, String, func from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base diff --git a/backend/app/modules/shopping/store_router.py b/backend/app/modules/shopping/store_router.py index c8839c1..e0e620e 100644 --- a/backend/app/modules/shopping/store_router.py +++ b/backend/app/modules/shopping/store_router.py @@ -2,8 +2,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.modules.shopping.schemas import StoreCreate, StoreUpdate, StoreResponse from app.modules.shopping import store_service as service +from app.modules.shopping.schemas import StoreCreate, StoreResponse, StoreUpdate router = APIRouter(prefix="/stores", tags=["stores"]) diff --git a/backend/app/modules/shopping/store_service.py b/backend/app/modules/shopping/store_service.py index eff9b95..8ca44bc 100644 --- a/backend/app/modules/shopping/store_service.py +++ b/backend/app/modules/shopping/store_service.py @@ -2,9 +2,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError, ValidationError -from app.modules.shopping.store_models import Store from app.modules.shopping.models import ShoppingItem from app.modules.shopping.schemas import StoreCreate, StoreUpdate +from app.modules.shopping.store_models import Store async def get_all_stores(db: AsyncSession) -> list[Store]: diff --git a/backend/app/modules/tasks/models.py b/backend/app/modules/tasks/models.py index 23ed6bf..236441c 100644 --- a/backend/app/modules/tasks/models.py +++ b/backend/app/modules/tasks/models.py @@ -1,6 +1,6 @@ from datetime import datetime -from sqlalchemy import String, DateTime, Boolean, Integer, ForeignKey, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base diff --git a/backend/app/modules/tasks/recurrence.py b/backend/app/modules/tasks/recurrence.py index 917f770..8809cc6 100644 --- a/backend/app/modules/tasks/recurrence.py +++ b/backend/app/modules/tasks/recurrence.py @@ -1,6 +1,6 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime -from sqlalchemy import select, and_ +from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.logging import get_logger @@ -10,13 +10,13 @@ async def reset_recurring_tasks(db: AsyncSession) -> None: - now = datetime.now(timezone.utc) + now = datetime.now(UTC) weekday = now.weekday() day_of_month = now.day daily_result = await db.execute( select(Task).where( - and_(Task.recurrence == "daily", Task.is_completed == True) # noqa: E712 + and_(Task.recurrence == "daily", Task.is_completed == True) ) ) daily_tasks = list(daily_result.scalars().all()) @@ -25,7 +25,7 @@ async def reset_recurring_tasks(db: AsyncSession) -> None: if weekday == 0: weekly_result = await db.execute( select(Task).where( - and_(Task.recurrence == "weekly", Task.is_completed == True) # noqa: E712 + and_(Task.recurrence == "weekly", Task.is_completed == True) ) ) weekly_tasks = list(weekly_result.scalars().all()) @@ -34,7 +34,7 @@ async def reset_recurring_tasks(db: AsyncSession) -> None: if day_of_month == 1: monthly_result = await db.execute( select(Task).where( - and_(Task.recurrence == "monthly", Task.is_completed == True) # noqa: E712 + and_(Task.recurrence == "monthly", Task.is_completed == True) ) ) monthly_tasks = list(monthly_result.scalars().all()) diff --git a/backend/app/modules/tasks/rotation.py b/backend/app/modules/tasks/rotation.py index 39ef582..0774bc8 100644 --- a/backend/app/modules/tasks/rotation.py +++ b/backend/app/modules/tasks/rotation.py @@ -16,7 +16,9 @@ async def rotate_tasks(db: AsyncSession) -> None: logger.info("Rotation skipped: no members") return - tasks_result = await db.execute(select(Task).order_by(Task.id)) + tasks_result = await db.execute( + select(Task).where(Task.assigned_to.isnot(None)).order_by(Task.id) + ) tasks = list(tasks_result.scalars().all()) if not tasks: diff --git a/backend/app/modules/tasks/router.py b/backend/app/modules/tasks/router.py index 1ac3759..c4dce39 100644 --- a/backend/app/modules/tasks/router.py +++ b/backend/app/modules/tasks/router.py @@ -4,9 +4,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.modules.tasks.schemas import TaskCreate, TaskUpdate, TaskResponse, ReorderRequest +from app.core.schemas import ReorderRequest from app.modules.tasks import service from app.modules.tasks.rotation import rotate_tasks +from app.modules.tasks.schemas import TaskCreate, TaskResponse, TaskUpdate router = APIRouter(prefix="/tasks", tags=["tasks"]) diff --git a/backend/app/modules/tasks/schemas.py b/backend/app/modules/tasks/schemas.py index 00fcb71..c76374a 100644 --- a/backend/app/modules/tasks/schemas.py +++ b/backend/app/modules/tasks/schemas.py @@ -15,26 +15,24 @@ class MemberSummary(BaseModel): id: int name: str colour: str + avatar_url: str | None = None model_config = {"from_attributes": True} - -class TaskCreate(BaseModel): - title: str = Field(min_length=1, max_length=200) - assigned_to: int | None = None - reminder_at: datetime | None = None - recurrence: Recurrence = Recurrence.none + def model_post_init(self, __context, /): + from app.modules.members.service import get_avatar_url + if self.avatar_url is None: + self.avatar_url = get_avatar_url(self.id) -class TaskUpdate(BaseModel): +class TaskCreate(BaseModel): title: str = Field(min_length=1, max_length=200) assigned_to: int | None = None reminder_at: datetime | None = None recurrence: Recurrence = Recurrence.none -class ReorderRequest(BaseModel): - ids: list[int] = Field(min_length=1) +TaskUpdate = TaskCreate class TaskResponse(BaseModel): diff --git a/backend/app/modules/tasks/service.py b/backend/app/modules/tasks/service.py index 4f246c4..692a8ef 100644 --- a/backend/app/modules/tasks/service.py +++ b/backend/app/modules/tasks/service.py @@ -1,20 +1,14 @@ -from datetime import date, datetime, timezone +from datetime import UTC, date, datetime -from sqlalchemy import select, or_, and_, func +from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError -from app.modules.members.models import Member +from app.core.utils import reorder_items, verify_member_exists from app.modules.tasks.models import Task from app.modules.tasks.schemas import TaskCreate, TaskUpdate -async def _verify_member_exists(db: AsyncSession, member_id: int) -> None: - result = await db.execute(select(Member).where(Member.id == member_id)) - if not result.scalar_one_or_none(): - raise NotFoundError("Member", member_id) - - async def get_all_tasks(db: AsyncSession, assigned_to: int | None = None) -> list[Task]: query = select(Task).order_by(Task.sort_order, Task.created_at) if assigned_to is not None: @@ -25,7 +19,6 @@ async def get_all_tasks(db: AsyncSession, assigned_to: int | None = None) -> lis async def get_tasks_by_date(db: AsyncSession, target_date: date) -> list[Task]: today = date.today() - weekday = target_date.weekday() day_of_month = target_date.day start_of_day = datetime(target_date.year, target_date.month, target_date.day) @@ -36,10 +29,9 @@ async def get_tasks_by_date(db: AsyncSession, target_date: date) -> list[Task]: Task.recurrence == "daily", ] - if weekday == target_date.weekday(): - conditions.append( - and_(Task.recurrence == "weekly", func.strftime("%w", Task.created_at) == str(target_date.isoweekday() % 7)) - ) + conditions.append( + and_(Task.recurrence == "weekly", func.strftime("%w", Task.created_at) == str(target_date.isoweekday() % 7)) + ) conditions.append( and_(Task.recurrence == "monthly", func.strftime("%d", Task.created_at) == f"{day_of_month:02d}") @@ -58,7 +50,7 @@ async def get_tasks_by_date(db: AsyncSession, target_date: date) -> list[Task]: async def create_task(db: AsyncSession, data: TaskCreate) -> Task: if data.assigned_to is not None: - await _verify_member_exists(db, data.assigned_to) + await verify_member_exists(db, data.assigned_to) task = Task(title=data.title, assigned_to=data.assigned_to, reminder_at=data.reminder_at, recurrence=data.recurrence.value) db.add(task) @@ -74,7 +66,7 @@ async def update_task(db: AsyncSession, task_id: int, data: TaskUpdate) -> Task: raise NotFoundError("Task", task_id) if data.assigned_to is not None: - await _verify_member_exists(db, data.assigned_to) + await verify_member_exists(db, data.assigned_to) task.title = data.title task.assigned_to = data.assigned_to @@ -92,19 +84,14 @@ async def toggle_task(db: AsyncSession, task_id: int) -> Task: raise NotFoundError("Task", task_id) task.is_completed = not task.is_completed - task.completed_at = datetime.now(timezone.utc) if task.is_completed else None + task.completed_at = datetime.now(UTC) if task.is_completed else None await db.flush() await db.refresh(task) return task async def reorder_tasks(db: AsyncSession, ids: list[int]) -> None: - for index, task_id in enumerate(ids): - result = await db.execute(select(Task).where(Task.id == task_id)) - task = result.scalar_one_or_none() - if task: - task.sort_order = index - await db.flush() + await reorder_items(db, Task, ids) async def delete_task(db: AsyncSession, task_id: int) -> None: diff --git a/backend/app/modules/weather/schemas.py b/backend/app/modules/weather/schemas.py index d90e37d..c148da8 100644 --- a/backend/app/modules/weather/schemas.py +++ b/backend/app/modules/weather/schemas.py @@ -9,4 +9,5 @@ class WeatherResponse(BaseModel): feels_like: float = 0 rain_chance: int = 0 temp_high: float = 0 - temp_low: float = 0 \ No newline at end of file + temp_low: float = 0 + is_day: bool = True \ No newline at end of file diff --git a/backend/app/modules/weather/service.py b/backend/app/modules/weather/service.py index d611101..aba8a33 100644 --- a/backend/app/modules/weather/service.py +++ b/backend/app/modules/weather/service.py @@ -57,7 +57,7 @@ async def get_weather() -> WeatherResponse: params = { "latitude": settings.WEATHER_LATITUDE, "longitude": settings.WEATHER_LONGITUDE, - "current": "temperature_2m,apparent_temperature,weather_code,wind_speed_10m", + "current": "temperature_2m,apparent_temperature,weather_code,wind_speed_10m,is_day", "daily": "temperature_2m_max,temperature_2m_min,precipitation_probability_max", "timezone": "auto", "forecast_days": 1, @@ -84,6 +84,7 @@ async def get_weather() -> WeatherResponse: rain_chance=daily.get("precipitation_probability_max", [0])[0], temp_high=round(daily.get("temperature_2m_max", [0])[0], 1), temp_low=round(daily.get("temperature_2m_min", [0])[0], 1), + is_day=bool(current.get("is_day", 1)), ) _cache["data"] = result diff --git a/backend/migrations/versions/83eed160c486_add_purchased_at_to_shopping_items.py b/backend/migrations/versions/83eed160c486_add_purchased_at_to_shopping_items.py new file mode 100644 index 0000000..627ede0 --- /dev/null +++ b/backend/migrations/versions/83eed160c486_add_purchased_at_to_shopping_items.py @@ -0,0 +1,25 @@ +"""add purchased_at to shopping_items + +Revision ID: 83eed160c486 +Revises: 3a17512a60fb +Create Date: 2026-08-17 18:00:20.000000 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '83eed160c486' +down_revision: Union[str, None] = '3a17512a60fb' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('shopping_items', sa.Column('purchased_at', sa.DateTime(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('shopping_items', 'purchased_at') \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 315382d..f714cba 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "homeos-backend" -version = "0.1.0" +version = "1.3.0" description = "HomeOS — self-hosted family operating system backend" requires-python = ">=3.12,<4" dependencies = [ @@ -13,6 +13,8 @@ dependencies = [ "pydantic-settings>=2.7,<3", "apscheduler>=3.10,<4", "httpx>=0.28,<1", + "python-multipart>=0.0.18,<1", + "pillow>=11.0,<12", ] [project.optional-dependencies] @@ -34,6 +36,9 @@ include = ["app*"] target-version = "py312" line-length = 100 +[tool.ruff.lint] +ignore = ["B008", "DTZ001", "DTZ005", "DTZ011"] + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/backend/tests/test_cleanup.py b/backend/tests/test_cleanup.py new file mode 100644 index 0000000..c6aac21 --- /dev/null +++ b/backend/tests/test_cleanup.py @@ -0,0 +1,141 @@ +import pytest +from datetime import datetime, timedelta, timezone +from httpx import ASGITransport, AsyncClient + +from app.main import app +from app.core.database import Base, engine, async_session_factory +from app.modules.cleanup.service import cleanup_old_records +from app.modules.tasks.models import Task +from app.modules.shopping.models import ShoppingItem +from app.modules.notes.models import Note + + +@pytest.fixture(autouse=True) +async def setup_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +@pytest.mark.asyncio +async def test_cleanup_deletes_old_completed_tasks(client): + task = (await client.post("/api/v1/tasks", json={"title": "Old task"})).json() + await client.patch(f"/api/v1/tasks/{task['id']}/toggle") + + async with async_session_factory() as session: + from sqlalchemy import select, update + result = await session.execute(select(Task).where(Task.id == task["id"])) + t = result.scalar_one() + t.completed_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=366) + await session.commit() + + async with async_session_factory() as session: + await cleanup_old_records(session) + await session.commit() + + tasks = (await client.get("/api/v1/tasks")).json() + assert len(tasks) == 0 + + +@pytest.mark.asyncio +async def test_cleanup_keeps_recent_completed_tasks(client): + task = (await client.post("/api/v1/tasks", json={"title": "Recent task"})).json() + await client.patch(f"/api/v1/tasks/{task['id']}/toggle") + + async with async_session_factory() as session: + await cleanup_old_records(session) + await session.commit() + + tasks = (await client.get("/api/v1/tasks")).json() + assert len(tasks) == 1 + + +@pytest.mark.asyncio +async def test_cleanup_keeps_incomplete_old_tasks(client): + await client.post("/api/v1/tasks", json={"title": "Old but incomplete"}) + + async with async_session_factory() as session: + await cleanup_old_records(session) + await session.commit() + + tasks = (await client.get("/api/v1/tasks")).json() + assert len(tasks) == 1 + + +@pytest.mark.asyncio +async def test_cleanup_deletes_old_purchased_shopping(client): + item = (await client.post("/api/v1/shopping", json={"name": "Old milk"})).json() + await client.patch(f"/api/v1/shopping/{item['id']}/toggle") + + async with async_session_factory() as session: + from sqlalchemy import select + result = await session.execute(select(ShoppingItem).where(ShoppingItem.id == item["id"])) + si = result.scalar_one() + si.purchased_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=366) + await session.commit() + + async with async_session_factory() as session: + await cleanup_old_records(session) + await session.commit() + + items = (await client.get("/api/v1/shopping")).json() + assert len(items) == 0 + + +@pytest.mark.asyncio +async def test_cleanup_keeps_unpurchased_old_shopping(client): + await client.post("/api/v1/shopping", json={"name": "Old but needed"}) + + async with async_session_factory() as session: + from sqlalchemy import select + result = await session.execute(select(ShoppingItem)) + si = result.scalar_one() + si.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=366) + await session.commit() + + async with async_session_factory() as session: + await cleanup_old_records(session) + await session.commit() + + items = (await client.get("/api/v1/shopping")).json() + assert len(items) == 1 + + +@pytest.mark.asyncio +async def test_cleanup_deletes_old_notes(client): + await client.post("/api/v1/notes", json={"content": "Old note"}) + + async with async_session_factory() as session: + from sqlalchemy import select + result = await session.execute(select(Note)) + note = result.scalar_one() + note.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(days=366) + await session.commit() + + async with async_session_factory() as session: + await cleanup_old_records(session) + await session.commit() + + notes = (await client.get("/api/v1/notes")).json() + assert len(notes) == 0 + + +@pytest.mark.asyncio +async def test_cleanup_keeps_recent_notes(client): + await client.post("/api/v1/notes", json={"content": "Recent note"}) + + async with async_session_factory() as session: + await cleanup_old_records(session) + await session.commit() + + notes = (await client.get("/api/v1/notes")).json() + assert len(notes) == 1 \ No newline at end of file diff --git a/backend/tests/test_members.py b/backend/tests/test_members.py index 4165f17..4dc8b3b 100644 --- a/backend/tests/test_members.py +++ b/backend/tests/test_members.py @@ -1,8 +1,12 @@ +import io + import pytest from httpx import ASGITransport, AsyncClient +from PIL import Image from app.main import app from app.core.database import Base, engine +from app.modules.members.service import avatar_exists @pytest.fixture(autouse=True) @@ -13,6 +17,11 @@ async def setup_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) + from app.modules.members.service import AVATAR_DIR + import shutil + if AVATAR_DIR.is_dir(): + shutil.rmtree(AVATAR_DIR) + @pytest.fixture async def client(): @@ -117,3 +126,107 @@ async def test_delete_nonexistent_member(client): response = await client.delete("/api/v1/members/999") assert response.status_code == 404 assert "not found" in response.json()["error"] + + +def make_test_image(fmt="JPEG", size=(100, 100)): + img = Image.new("RGB", size, color="red") + buf = io.BytesIO() + img.save(buf, format=fmt) + buf.seek(0) + return buf + + +@pytest.mark.asyncio +async def test_upload_avatar(client): + member = (await client.post("/api/v1/members", json={"name": "Danial", "colour": "#2563eb"})).json() + img = make_test_image() + + response = await client.post( + f"/api/v1/members/{member['id']}/avatar", + files={"file": ("photo.jpg", img, "image/jpeg")}, + ) + assert response.status_code == 200 + assert response.json()["avatar_url"] == f"/api/v1/members/{member['id']}/avatar" + assert avatar_exists(member["id"]) + + +@pytest.mark.asyncio +async def test_serve_avatar(client): + member = (await client.post("/api/v1/members", json={"name": "Danial", "colour": "#2563eb"})).json() + img = make_test_image() + await client.post(f"/api/v1/members/{member['id']}/avatar", files={"file": ("photo.jpg", img, "image/jpeg")}) + + response = await client.get(f"/api/v1/members/{member['id']}/avatar") + assert response.status_code == 200 + assert response.headers["content-type"] == "image/jpeg" + + +@pytest.mark.asyncio +async def test_serve_avatar_not_found(client): + member = (await client.post("/api/v1/members", json={"name": "Danial", "colour": "#2563eb"})).json() + response = await client.get(f"/api/v1/members/{member['id']}/avatar") + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_avatar(client): + member = (await client.post("/api/v1/members", json={"name": "Danial", "colour": "#2563eb"})).json() + img = make_test_image() + await client.post(f"/api/v1/members/{member['id']}/avatar", files={"file": ("photo.jpg", img, "image/jpeg")}) + + response = await client.delete(f"/api/v1/members/{member['id']}/avatar") + assert response.status_code == 200 + assert not avatar_exists(member["id"]) + + +@pytest.mark.asyncio +async def test_upload_avatar_invalid_type(client): + member = (await client.post("/api/v1/members", json={"name": "Danial", "colour": "#2563eb"})).json() + + response = await client.post( + f"/api/v1/members/{member['id']}/avatar", + files={"file": ("doc.txt", io.BytesIO(b"not an image"), "text/plain")}, + ) + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_upload_avatar_nonexistent_member(client): + img = make_test_image() + response = await client.post("/api/v1/members/999/avatar", files={"file": ("photo.jpg", img, "image/jpeg")}) + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_delete_member_cascades_avatar(client): + member = (await client.post("/api/v1/members", json={"name": "Danial", "colour": "#2563eb"})).json() + img = make_test_image() + await client.post(f"/api/v1/members/{member['id']}/avatar", files={"file": ("photo.jpg", img, "image/jpeg")}) + assert avatar_exists(member["id"]) + + await client.delete(f"/api/v1/members/{member['id']}") + assert not avatar_exists(member["id"]) + + +@pytest.mark.asyncio +async def test_avatar_url_in_member_response(client): + member = (await client.post("/api/v1/members", json={"name": "Danial", "colour": "#2563eb"})).json() + assert member["avatar_url"] is None + + img = make_test_image() + await client.post(f"/api/v1/members/{member['id']}/avatar", files={"file": ("photo.jpg", img, "image/jpeg")}) + + members = (await client.get("/api/v1/members")).json() + assert members[0]["avatar_url"] == f"/api/v1/members/{member['id']}/avatar" + + +@pytest.mark.asyncio +async def test_upload_avatar_resizes_large_image(client): + member = (await client.post("/api/v1/members", json={"name": "Danial", "colour": "#2563eb"})).json() + img = make_test_image(size=(1000, 800)) + await client.post(f"/api/v1/members/{member['id']}/avatar", files={"file": ("photo.jpg", img, "image/jpeg")}) + + response = await client.get(f"/api/v1/members/{member['id']}/avatar") + saved = Image.open(io.BytesIO(response.content)) + assert saved.size[0] <= 256 + assert saved.size[1] <= 256 diff --git a/backend/tests/test_rotation.py b/backend/tests/test_rotation.py index b6990c1..2bc33c2 100644 --- a/backend/tests/test_rotation.py +++ b/backend/tests/test_rotation.py @@ -66,14 +66,14 @@ async def test_rotation_resets_completion(client): @pytest.mark.asyncio -async def test_rotation_assigns_unassigned_tasks(client): - danial = await create_member(client, "Danial", "#2563eb") +async def test_rotation_skips_unassigned_tasks(client): + await create_member(client, "Danial", "#2563eb") await create_task(client, "Dishes") await client.post("/api/v1/tasks/rotate") tasks = (await client.get("/api/v1/tasks")).json() - assert tasks[0]["assigned_to"] == danial["id"] + assert tasks[0]["assigned_to"] is None @pytest.mark.asyncio @@ -135,7 +135,7 @@ async def test_rotation_after_member_deletion(client): assert response.status_code == 200 tasks = (await client.get("/api/v1/tasks")).json() - assert tasks[0]["assigned_to"] == ali["id"] + assert tasks[0]["assigned_to"] is None @pytest.mark.asyncio diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..8b98d7c --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,138 @@ +# Production Deployment + +Deploy HomeOS on a dedicated Linux machine (Mini PC, old laptop, etc.). + +## Prerequisites + +- Linux with Docker and Docker Compose installed +- Git installed + +## First-time setup + +```bash +git clone https://github.com/YOUR_USERNAME/HomeOS.git +cd HomeOS +cp .env.example .env +``` + +Edit `.env` and set your location coordinates. These are used for both weather and prayer times: + +``` +WEATHER_LATITUDE=your_latitude +WEATHER_LONGITUDE=your_longitude +``` + +To find your coordinates, search your city name on Google Maps and copy the latitude/longitude from the URL. + +### Prayer Times + +Prayer times are fetched from the [Aladhan API](https://aladhan.com/prayer-times-api) using: + +- **Method 15** (Moonsighting Committee Worldwide) — closest to UK mosque timetables for Fajr +- **Hanafi school** — for later Asr times matching most UK mosques + +Times refresh automatically at 1am daily. The next upcoming prayer is highlighted on the dashboard. These are calculated astronomical times, so they may differ by a few minutes from your local mosque's posted times. + +Start the app in the background: + +```bash +docker compose up --build -d +``` + +Open `http://localhost` in a browser. To access from other devices on the same network, use the machine's IP address. + +## Updating to latest version + +```bash +cd HomeOS +git pull +docker compose down +docker compose up --build -d +``` + +Your data (members, tasks, shopping, notes) is stored on a Docker volume and is preserved across updates. + +## Auto-start after reboot + +To ensure HomeOS starts automatically when the machine restarts: + +```bash +sudo systemctl enable docker +``` + +This makes Docker start on boot. The containers auto-start with Docker because they're configured with `restart: unless-stopped`. No need to run `docker compose up` again after a reboot. + +To verify Docker is running after a restart: + +```bash +sudo systemctl status docker +``` + +## Remote Access with Tailscale + +Access HomeOS from your phone outside your home WiFi using Tailscale (free VPN). + +**On the Linux laptop (HomeOS server):** + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up +``` + +Note the Tailscale IP shown (e.g. `100.x.x.x`). + +**On your phone:** + +1. Install Tailscale from App Store (iPhone) or Play Store (Android) +2. Sign in with the same account used on the Linux laptop +3. Open `http://100.x.x.x` in your phone browser (use the Tailscale IP from above) + +Tailscale runs in the background — once set up, your phone can reach HomeOS from anywhere without opening ports or exposing your home network. + +## Install on Phone (Add to Home Screen) + +HomeOS is a PWA — it can be installed on your phone's home screen like a native app. + +**iPhone (Safari):** + +1. Open HomeOS in Safari (use your home WiFi IP or Tailscale IP) +2. Tap the Share button (square with arrow) +3. Scroll down and tap "Add to Home Screen" +4. Tap "Add" + +**Android (Chrome):** + +1. Open HomeOS in Chrome +2. Tap the three-dot menu +3. Tap "Add to Home screen" or "Install app" +4. Tap "Add" + +The app opens fullscreen without a browser address bar, with the HomeOS icon on your home screen. + +## Useful commands + +```bash +docker compose up --build -d # start in background +docker compose down # stop +docker compose logs -f # view live logs +docker compose ps # check container status +``` + +## Database Backups + +Run the backup script manually: + +```bash +cd HomeOS +./scripts/backup.sh +``` + +Backups are saved to `~/homeos-backups/` with timestamps (e.g. `homeos-2026-08-09.db`). Backups older than 7 days are automatically deleted. + +To schedule daily backups at 3am on the Linux machine: + +```bash +crontab -e +# Add this line (adjust the path to your HomeOS directory): +0 3 * * * cd /path/to/HomeOS && ./scripts/backup.sh >> ~/homeos-backups/backup.log 2>&1 +``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..ccde10a --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,117 @@ +# Development Guide + +## Setup + +### Backend + +```bash +conda activate homeos +cd backend +pip install -e ".[dev]" +uvicorn app.main:app --reload +``` + +Backend runs at `http://localhost:8000` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Frontend runs at `http://localhost:5173` + +### Docker (recommended) + +Run the entire app with a single command: + +```bash +docker compose up --build +``` + +App runs at `http://localhost`. Backend on port 8000, frontend on port 80 via nginx. + +To stop: `docker compose down` + +#### Docker without admin access (macOS) + +If you don't have admin access to install Docker Desktop, use Colima: + +```bash +brew install docker colima docker-compose +mkdir -p ~/.docker/cli-plugins +ln -sf $(brew --prefix)/bin/docker-compose ~/.docker/cli-plugins/docker-compose +colima start +``` + +Then `docker compose up --build` works as normal. To stop Colima when done: `colima stop`. + +## Run Tests + +```bash +conda activate homeos +cd backend +pytest tests/ -v # run all tests +pytest tests/test_health.py # run a specific test file +pytest tests/ -v --cov=app # run with coverage report +``` + +Tests use an in-memory SQLite database — each test gets a fresh database so tests don't interfere with each other. + +## Pre-Push Checks + +Run all checks (backend tests, frontend type check, frontend build) manually before pushing: + +```bash +cd HomeOS +.git/hooks/pre-push +``` + +These checks also run automatically on every `git push`. If any check fails, the push is blocked. + +## Releasing a New Version + +When you're ready to release a new version: + +1. Update the version number in two files: + - `backend/app/core/config.py` — change `VERSION` + - `frontend/package.json` — change `version` + +2. Commit, push, and merge to main + +3. Tag the release on main: +```bash +git checkout main +git pull +git tag v1.2 +git push origin v1.2 +``` + +4. Update CHANGELOG.md with what changed in this version + +Version format is two numbers (e.g. v1.0, v1.1, v1.2). Bump the second number for new features, use a third number for bug fixes if needed (e.g. v1.2.1). + +## Project Structure + +``` +HomeOS/ +├── backend/ # FastAPI application +│ ├── app/ +│ │ ├── api/v1/ # Versioned API routes +│ │ ├── core/ # Config, database, scheduler, logging, exceptions +│ │ └── modules/ # Feature modules (members, tasks, shopping, notes, calendar, prayer, weather, cleanup) +│ ├── migrations/ # Alembic database migrations +│ └── tests/ +├── frontend/ # React application +│ └── src/ +│ ├── api/ # API client functions +│ ├── components/# Shared UI components (Button, Card, Modal, icons) +│ ├── features/ # Feature pages (dashboard, tasks, shopping, notes, calendar, settings) +│ ├── hooks/ # TanStack Query hooks +│ ├── layouts/ # App shell and navigation +│ └── types/ # Shared TypeScript types +├── scripts/ # Backup and utility scripts +└── CHANGELOG.md # Release history +``` diff --git a/docs/screenshots/calendar.jpeg b/docs/screenshots/calendar.jpeg new file mode 100644 index 0000000..3a363fc Binary files /dev/null and b/docs/screenshots/calendar.jpeg differ diff --git a/docs/screenshots/dashboard-scrolled.jpeg b/docs/screenshots/dashboard-scrolled.jpeg new file mode 100644 index 0000000..e334338 Binary files /dev/null and b/docs/screenshots/dashboard-scrolled.jpeg differ diff --git a/docs/screenshots/dashboard.jpeg b/docs/screenshots/dashboard.jpeg new file mode 100644 index 0000000..8d2e6f3 Binary files /dev/null and b/docs/screenshots/dashboard.jpeg differ diff --git a/docs/screenshots/settings.jpeg b/docs/screenshots/settings.jpeg new file mode 100644 index 0000000..14939bb Binary files /dev/null and b/docs/screenshots/settings.jpeg differ diff --git a/docs/screenshots/shopping.jpeg b/docs/screenshots/shopping.jpeg new file mode 100644 index 0000000..78597af Binary files /dev/null and b/docs/screenshots/shopping.jpeg differ diff --git a/docs/screenshots/tasks.jpeg b/docs/screenshots/tasks.jpeg new file mode 100644 index 0000000..4ad896b Binary files /dev/null and b/docs/screenshots/tasks.jpeg differ diff --git a/frontend/src/api/members.ts b/frontend/src/api/members.ts index 9c51e15..ee20daf 100644 --- a/frontend/src/api/members.ts +++ b/frontend/src/api/members.ts @@ -29,3 +29,16 @@ export async function updateMember(id: number, data: UpdateMemberPayload): Promi export async function deleteMember(id: number): Promise { await apiClient.delete(`/members/${id}`); } + +export async function uploadAvatar(id: number, file: File): Promise<{ avatar_url: string }> { + const formData = new FormData(); + formData.append("file", file); + const response = await apiClient.post<{ avatar_url: string }>(`/members/${id}/avatar`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + return response.data; +} + +export async function deleteAvatar(id: number): Promise { + await apiClient.delete(`/members/${id}/avatar`); +} diff --git a/frontend/src/app.css b/frontend/src/app.css index 82b988a..e1197ca 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,16 +1,22 @@ @import "tailwindcss"; @theme { - --color-primary: #2563eb; - --color-primary-light: #3b82f6; - --color-primary-dark: #1d4ed8; - --color-surface: #ffffff; - --color-surface-dim: #f1f5f9; - --color-text: #0f172a; - --color-text-muted: #64748b; - --color-border: #e2e8f0; - --color-success: #16a34a; - --color-danger: #dc2626; + --color-background: #F4E8D5; + --color-surface: #FFF8ED; + --color-surface-dim: #FFFCF6; + --color-primary: #B9783E; + --color-primary-light: #E7C79F; + --color-primary-dark: #9A6330; + --color-accent-pale: #F3DFC2; + --color-text: #3D2E22; + --color-text-muted: #75695D; + --color-text-faint: #9A8C7D; + --color-nav: #3D2E22; + --color-border: #E3D2BA; + --color-divider: #EADCC8; + --color-success: #55A85C; + --color-warning: #D99035; + --color-danger: #C94C42; } * { @@ -38,4 +44,5 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -webkit-font-smoothing: antialiased; + background-color: var(--color-background); } diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index e0e5079..3812c4f 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -1,4 +1,5 @@ import { motion } from "framer-motion"; +import { TAP_SPRING } from "../constants"; type ButtonVariant = "primary" | "secondary" | "danger"; @@ -14,8 +15,8 @@ interface ButtonProps { const variantStyles: Record = { primary: "bg-primary text-white active:bg-primary-dark", secondary: - "bg-transparent border border-border text-text active:bg-surface-dim", - danger: "bg-danger text-white active:bg-red-700", + "bg-accent-pale border border-border text-primary active:bg-primary-light", + danger: "bg-danger text-white active:brightness-90", }; export default function Button({ @@ -32,7 +33,7 @@ export default function Button({ onClick={onClick} disabled={disabled} whileTap={{ scale: 0.8 }} - transition={{ type: "spring", stiffness: 400, damping: 20 }} + transition={TAP_SPRING} className={`min-h-[48px] px-6 rounded-xl font-semibold text-base transition-colors ${variantStyles[variant]} ${ fullWidth ? "w-full" : "" } ${disabled ? "opacity-40 pointer-events-none" : ""}`} diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx index 96523b8..ff66943 100644 --- a/frontend/src/components/Card.tsx +++ b/frontend/src/components/Card.tsx @@ -1,4 +1,5 @@ import { motion } from "framer-motion"; +import { TAP_SPRING } from "../constants"; interface CardProps { children: React.ReactNode; @@ -8,7 +9,7 @@ interface CardProps { export default function Card({ children, title, onClick }: CardProps) { const card = ( -
+
{title && (

{title} @@ -22,7 +23,7 @@ export default function Card({ children, title, onClick }: CardProps) { return ( diff --git a/frontend/src/components/ColourPicker.tsx b/frontend/src/components/ColourPicker.tsx new file mode 100644 index 0000000..084ed61 --- /dev/null +++ b/frontend/src/components/ColourPicker.tsx @@ -0,0 +1,23 @@ +interface ColourPickerProps { + colours: string[]; + selected: string; + onSelect: (colour: string) => void; +} + +export default function ColourPicker({ colours, selected, onSelect }: ColourPickerProps) { + return ( +
+ {colours.map((c) => ( +
+ ); +} diff --git a/frontend/src/components/IconButton.tsx b/frontend/src/components/IconButton.tsx index 3b83c6a..88950be 100644 --- a/frontend/src/components/IconButton.tsx +++ b/frontend/src/components/IconButton.tsx @@ -1,4 +1,5 @@ import { motion } from "framer-motion"; +import { TAP_SPRING } from "../constants"; type IconButtonVariant = "default" | "danger"; @@ -12,7 +13,7 @@ interface IconButtonProps { const variantStyles: Record = { default: "text-text-muted active:bg-surface-dim", - danger: "text-danger active:bg-red-100", + danger: "text-danger active:bg-danger/10", }; export default function IconButton({ @@ -25,7 +26,7 @@ export default function IconButton({ diff --git a/frontend/src/components/MemberDot.tsx b/frontend/src/components/MemberDot.tsx new file mode 100644 index 0000000..f5c152d --- /dev/null +++ b/frontend/src/components/MemberDot.tsx @@ -0,0 +1,26 @@ +interface MemberDotProps { + avatarUrl: string | null; + colour: string; + name: string; + size?: number; +} + +export default function MemberDot({ avatarUrl, colour, name, size = 12 }: MemberDotProps) { + if (avatarUrl) { + return ( + {name} + ); + } + + return ( +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index c7d1148..80b3100 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -46,7 +46,7 @@ export default function Modal({ open, onClose, title, children }: ModalProps) { animate={{ y: 0 }} exit={{ y: "100%" }} transition={{ type: "spring", stiffness: 300, damping: 30 }} - className="fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl bg-white max-h-[85dvh] flex flex-col" + className="fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl bg-surface max-h-[85dvh] flex flex-col" >
diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index efbd7a3..3d8ebcb 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -51,3 +51,28 @@ export function SettingsIcon({ size = 24, className }: IconProps) { ); } + +export function GripIcon({ size = 20, className }: IconProps) { + return ( + + + + + + + + + ); +} + +export function TrashIcon({ size = 20, className }: IconProps) { + return ( + + + + + + + + ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index ad2283f..14e112e 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -6,10 +6,14 @@ export { default as IconButton } from "./IconButton"; export { default as Modal } from "./Modal"; export { default as LoadingSpinner } from "./LoadingSpinner"; export { default as PullToRefresh } from "./PullToRefresh"; +export { default as MemberDot } from "./MemberDot"; +export { default as ColourPicker } from "./ColourPicker"; export { HomeIcon, CheckCircleIcon, ShoppingCartIcon, NotepadIcon, SettingsIcon, + GripIcon, + TrashIcon, } from "./icons"; diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts new file mode 100644 index 0000000..d73777c --- /dev/null +++ b/frontend/src/constants.ts @@ -0,0 +1,15 @@ +export const INPUT_STYLE = + "w-full min-h-[48px] px-4 rounded-xl border border-border bg-surface text-text text-lg focus:outline-none focus:ring-2 focus:ring-primary"; + +export const TAP_SPRING = { type: "spring" as const, stiffness: 400, damping: 20 }; + +export const PRESET_COLOURS = [ + "#2563eb", + "#16a34a", + "#dc2626", + "#f97316", + "#8b5cf6", + "#0891b2", + "#db2777", + "#ca8a04", +]; diff --git a/frontend/src/features/calendar/AddBirthdayModal.tsx b/frontend/src/features/calendar/AddBirthdayModal.tsx index a56689f..0d19e86 100644 --- a/frontend/src/features/calendar/AddBirthdayModal.tsx +++ b/frontend/src/features/calendar/AddBirthdayModal.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Modal, Button } from "../../components"; +import { INPUT_STYLE } from "../../constants"; interface AddBirthdayModalProps { open: boolean; @@ -40,7 +41,7 @@ export default function AddBirthdayModal({ open, onClose, onSave, dateLabel }: A onChange={(e) => setName(e.target.value)} placeholder="Enter name" autoFocus - className="w-full min-h-[48px] px-4 rounded-xl border border-border bg-surface text-text text-lg focus:outline-none focus:ring-2 focus:ring-primary" + className={INPUT_STYLE} />
diff --git a/frontend/src/features/calendar/CalendarModal.tsx b/frontend/src/features/calendar/CalendarModal.tsx index c9054b2..c002b72 100644 --- a/frontend/src/features/calendar/CalendarModal.tsx +++ b/frontend/src/features/calendar/CalendarModal.tsx @@ -1,36 +1,10 @@ import { useState, useMemo, useRef, useCallback, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Button, LoadingSpinner, IconButton } from "../../components"; +import { Button, LoadingSpinner, IconButton, TrashIcon } from "../../components"; import { useCalendarDate } from "../../hooks/useCalendar"; import { useCreateBirthday, useDeleteBirthday } from "../../hooks/useBirthdays"; import AddBirthdayModal from "./AddBirthdayModal"; - -const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; -const MONTHS = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", -]; - -function getDaysInMonth(year: number, month: number) { - return new Date(year, month + 1, 0).getDate(); -} - -function getFirstDayOfMonth(year: number, month: number) { - const day = new Date(year, month, 1).getDay(); - return day === 0 ? 6 : day - 1; -} - -function TrashIcon() { - return ( - - - - - - - - ); -} +import { DAYS, MONTHS, getDaysInMonth, getFirstDayOfMonth } from "./calendarUtils"; interface CalendarModalProps { open: boolean; @@ -91,22 +65,24 @@ export default function CalendarModal({ open, onClose }: CalendarModalProps) { function prevMonth() { setDirection(-1); - if (viewMonth === 0) { - setViewMonth(11); - setViewYear(viewYear - 1); - } else { - setViewMonth(viewMonth - 1); - } + setViewMonth(prev => { + if (prev === 0) { + setViewYear(y => y - 1); + return 11; + } + return prev - 1; + }); } function nextMonth() { setDirection(1); - if (viewMonth === 11) { - setViewMonth(0); - setViewYear(viewYear + 1); - } else { - setViewMonth(viewMonth + 1); - } + setViewMonth(prev => { + if (prev === 11) { + setViewYear(y => y + 1); + return 0; + } + return prev + 1; + }); } function goToday() { @@ -160,7 +136,7 @@ export default function CalendarModal({ open, onClose }: CalendarModalProps) { } else if (diff < -50) { nextMonth(); } - }, [viewMonth, viewYear]); + }, []); const slideVariants = { enter: (dir: number) => ({ @@ -181,7 +157,7 @@ export default function CalendarModal({ open, onClose }: CalendarModalProps) { function formatSelectedDate() { if (selectedDay === null) return ""; - return `${selectedDay} ${MONTHS[viewMonth]?.slice(0, 3)}`; + return `${selectedDay} ${MONTHS[selectedMonth]?.slice(0, 3)}`; } return ( @@ -205,7 +181,7 @@ export default function CalendarModal({ open, onClose }: CalendarModalProps) { className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 pointer-events-none" >
@@ -353,7 +329,7 @@ export default function CalendarModal({ open, onClose }: CalendarModalProps) {
{bday.name} } + icon={} variant="danger" label={`Delete ${bday.name}`} onClick={() => deleteBirthday.mutate(bday.id)} diff --git a/frontend/src/features/calendar/CalendarWidget.tsx b/frontend/src/features/calendar/CalendarWidget.tsx index 1dfccfb..9660393 100644 --- a/frontend/src/features/calendar/CalendarWidget.tsx +++ b/frontend/src/features/calendar/CalendarWidget.tsx @@ -1,20 +1,6 @@ import { useState, useMemo } from "react"; import { Card } from "../../components"; - -const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; -const MONTHS = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", -]; - -function getDaysInMonth(year: number, month: number) { - return new Date(year, month + 1, 0).getDate(); -} - -function getFirstDayOfMonth(year: number, month: number) { - const day = new Date(year, month, 1).getDay(); - return day === 0 ? 6 : day - 1; -} +import { DAYS, MONTHS, getDaysInMonth, getFirstDayOfMonth } from "./calendarUtils"; export default function CalendarWidget({ onExpand }: { onExpand?: () => void }) { const today = new Date(); diff --git a/frontend/src/features/calendar/calendarUtils.ts b/frontend/src/features/calendar/calendarUtils.ts new file mode 100644 index 0000000..bb1f4ba --- /dev/null +++ b/frontend/src/features/calendar/calendarUtils.ts @@ -0,0 +1,14 @@ +export const DAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; +export const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +export function getDaysInMonth(year: number, month: number): number { + return new Date(year, month + 1, 0).getDate(); +} + +export function getFirstDayOfMonth(year: number, month: number): number { + const day = new Date(year, month, 1).getDay(); + return day === 0 ? 6 : day - 1; +} diff --git a/frontend/src/features/dashboard/NotesCard.tsx b/frontend/src/features/dashboard/NotesCard.tsx index 33ea6c2..90eddf6 100644 --- a/frontend/src/features/dashboard/NotesCard.tsx +++ b/frontend/src/features/dashboard/NotesCard.tsx @@ -1,4 +1,4 @@ -import { Card, EmptyState, LoadingSpinner } from "../../components"; +import { Card, EmptyState, LoadingSpinner, MemberDot } from "../../components"; import { useNotes } from "../../hooks/useNotes"; export default function NotesCard() { @@ -6,7 +6,7 @@ export default function NotesCard() { const recent = notes.slice(0, 3); return ( - + {isLoading ? ( ) : recent.length === 0 ? ( @@ -17,10 +17,7 @@ export default function NotesCard() {
{note.author && (
-
+ {note.author.name} diff --git a/frontend/src/features/dashboard/PrayerTimesBar.tsx b/frontend/src/features/dashboard/PrayerTimesBar.tsx index 0d4b8c9..e53dbc7 100644 --- a/frontend/src/features/dashboard/PrayerTimesBar.tsx +++ b/frontend/src/features/dashboard/PrayerTimesBar.tsx @@ -1,6 +1,74 @@ import { Card, LoadingSpinner } from "../../components"; import { usePrayerTimes } from "../../hooks/usePrayerTimes"; +const S = 22; + +const SUN = "#D99035"; +const MOON = "#3D2E22"; +const MOON_STROKE = "#3D2E22"; + +function FajrIcon() { + return ( + + + + ); +} + +function DhuhrIcon() { + return ( + + + {[0, 45, 90, 135, 180, 225, 270, 315].map((angle) => { + const rad = (angle * Math.PI) / 180; + const x1 = 12 + 7.5 * Math.cos(rad); + const y1 = 12 + 7.5 * Math.sin(rad); + const x2 = 12 + 9.5 * Math.cos(rad); + const y2 = 12 + 9.5 * Math.sin(rad); + return ; + })} + + ); +} + +function AsrIcon() { + return ( + + + {[0, 45, 90, 135, 180, 225, 270, 315].map((angle) => { + const rad = (angle * Math.PI) / 180; + const x1 = 12 + 7 * Math.cos(rad); + const y1 = 10 + 7 * Math.sin(rad); + const x2 = 12 + 8.5 * Math.cos(rad); + const y2 = 10 + 8.5 * Math.sin(rad); + return ; + })} + + + ); +} + +function MaghribIcon() { + return ( + + + + + + + + + ); +} + +const PRAYER_ICONS: Record = { + Fajr: FajrIcon, + Dhuhr: DhuhrIcon, + Asr: AsrIcon, + Maghrib: MaghribIcon, + Isha: FajrIcon, +}; + export default function PrayerTimesBar() { const { data, isLoading } = usePrayerTimes(); @@ -18,13 +86,13 @@ export default function PrayerTimesBar() { return ( -
+
{data.prayers.map((prayer) => { const isCurrent = data.current_prayer === prayer.name; return (
{prayer.name} + {PRAYER_ICONS[prayer.name] && (() => { + const Icon = PRAYER_ICONS[prayer.name]!; + return ; + })()} - - - ); -} - export default function ShoppingCard() { const { data: items = [], isLoading } = useShoppingItems(); const toggleItem = useToggleShoppingItem(); @@ -56,6 +48,8 @@ export default function ShoppingCard() { ) : items.length === 0 ? ( + ) : unpurchased.length === 0 ? ( +

All items purchased!

) : hasStores ? (
{storeCounts.map((group) => ( @@ -76,34 +70,18 @@ export default function ShoppingCard() {
) : (
- {items.map((item) => ( + {unpurchased.map((item) => (
toggleItem.mutate(item.id)} className="flex items-center gap-3 py-1.5 cursor-pointer" > -
- {item.is_purchased && } -
- - {item.name} - +
+ {item.name}
))}
)} ); -} +} \ No newline at end of file diff --git a/frontend/src/features/dashboard/TasksCard.tsx b/frontend/src/features/dashboard/TasksCard.tsx index 1e16695..17f7617 100644 --- a/frontend/src/features/dashboard/TasksCard.tsx +++ b/frontend/src/features/dashboard/TasksCard.tsx @@ -1,18 +1,11 @@ -import { Card, EmptyState, LoadingSpinner } from "../../components"; +import { Card, EmptyState, LoadingSpinner, MemberDot } from "../../components"; import { useTasks, useToggleTask } from "../../hooks/useTasks"; -function CheckIcon() { - return ( - - - - ); -} - export default function TasksCard() { const { data: tasks = [], isLoading } = useTasks(); const toggleTask = useToggleTask(); const now = new Date(); + const activeTasks = tasks.filter((t) => !t.is_completed); return ( @@ -20,10 +13,12 @@ export default function TasksCard() { ) : tasks.length === 0 ? ( + ) : activeTasks.length === 0 ? ( +

All tasks done!

) : (
- {tasks.map((task) => { - const isOverdue = task.reminder_at && new Date(task.reminder_at) < now && !task.is_completed; + {activeTasks.map((task) => { + const isOverdue = task.reminder_at && new Date(task.reminder_at) < now; return (
-
- {task.is_completed && } -
+
{task.member && ( @@ -78,10 +63,7 @@ export default function TasksCard() {
{task.member && ( -
+ )}
); @@ -90,4 +72,4 @@ export default function TasksCard() { )} ); -} +} \ No newline at end of file diff --git a/frontend/src/features/dashboard/WeatherCard.tsx b/frontend/src/features/dashboard/WeatherCard.tsx index cb54e3d..e24cdca 100644 --- a/frontend/src/features/dashboard/WeatherCard.tsx +++ b/frontend/src/features/dashboard/WeatherCard.tsx @@ -9,6 +9,8 @@ const WEATHER_ICONS: Record = { snowy: "❄️", stormy: "⛈️", windy: "💨", + "night-clear": "🌙", + "night-cloudy": "☁️", }; const WEATHER_CLASSES: Record = { @@ -19,14 +21,20 @@ const WEATHER_CLASSES: Record = { snowy: "weather-snowy", stormy: "weather-stormy", windy: "weather-windy", + "night-clear": "weather-night-clear", + "night-cloudy": "weather-night-cloudy", }; const LIGHT_BACKGROUNDS = new Set(["snowy"]); -function getEffectiveWeather(icon: string, windSpeed: number): string { +function getEffectiveWeather(icon: string, windSpeed: number, isDay: boolean): string { if (windSpeed >= 40 && !["stormy", "rainy", "snowy"].includes(icon)) { return "windy"; } + if (!isDay) { + if (icon === "sunny") return "night-clear"; + if (icon === "partly-cloudy") return "night-cloudy"; + } return icon; } @@ -35,7 +43,7 @@ export default function WeatherCard() { if (isLoading) { return ( -
+

Loading...

); @@ -43,13 +51,13 @@ export default function WeatherCard() { if (!weather) { return ( -
+

Weather unavailable

); } - const effectiveWeather = getEffectiveWeather(weather.icon, weather.wind_speed); + const effectiveWeather = getEffectiveWeather(weather.icon, weather.wind_speed, weather.is_day); const bgClass = WEATHER_CLASSES[effectiveWeather] ?? "weather-cloudy"; const icon = WEATHER_ICONS[effectiveWeather] ?? "🌤️"; const isLight = LIGHT_BACKGROUNDS.has(effectiveWeather); @@ -70,7 +78,7 @@ export default function WeatherCard() {
Feels {weather.feels_like}° - 🌧 {weather.rain_chance}% + {weather.rain_chance > 0 && 🌧 {weather.rain_chance}%}
diff --git a/frontend/src/features/dashboard/weather-animations.css b/frontend/src/features/dashboard/weather-animations.css index 17acf08..9df53da 100644 --- a/frontend/src/features/dashboard/weather-animations.css +++ b/frontend/src/features/dashboard/weather-animations.css @@ -9,14 +9,17 @@ .weather-snowy::after, .weather-stormy::before, .weather-windy::before, -.weather-windy::after { +.weather-windy::after, +.weather-night-clear::before, +.weather-night-clear::after, +.weather-night-cloudy::before { will-change: transform, opacity; transform: translateZ(0); } /* Sunny — warm gradient + sun glow pulse */ .weather-sunny { - background: linear-gradient(135deg, #f59e0b, #3b82f6); + background: linear-gradient(135deg, #D4944C, #B9783E); position: relative; overflow: hidden; } @@ -40,7 +43,7 @@ /* Partly Cloudy — blue to grey */ .weather-partly-cloudy { - background: linear-gradient(135deg, #60a5fa, #9ca3af); + background: linear-gradient(135deg, #C4A882, #9A8C7D); position: relative; overflow: hidden; } @@ -59,7 +62,7 @@ /* Cloudy — grey gradient + drifting clouds */ .weather-cloudy { - background: linear-gradient(135deg, #9ca3af, #6b7280); + background: linear-gradient(135deg, #9A8C7D, #75695D); position: relative; overflow: hidden; } @@ -96,7 +99,7 @@ /* Rainy — dark blue gradient + falling rain */ .weather-rainy { - background: linear-gradient(135deg, #1e3a5f, #374151); + background: linear-gradient(135deg, #5C4A3A, #3D2E22); position: relative; overflow: hidden; } @@ -130,7 +133,7 @@ /* Snowy — ice blue gradient + floating snowflakes */ .weather-snowy { - background: linear-gradient(135deg, #e0f2fe, #93c5fd); + background: linear-gradient(135deg, #F3DFC2, #E3D2BA); position: relative; overflow: hidden; } @@ -168,7 +171,7 @@ /* Stormy — dark purple gradient + lightning flash */ .weather-stormy { - background: linear-gradient(135deg, #4c1d95, #1f2937); + background: linear-gradient(135deg, #4A3228, #2B2118); position: relative; overflow: hidden; } @@ -189,7 +192,7 @@ /* Windy — teal gradient + horizontal streaks */ .weather-windy { - background: linear-gradient(135deg, #0d9488, #6b7280); + background: linear-gradient(135deg, #8B7355, #6B5D4D); position: relative; overflow: hidden; } @@ -225,6 +228,65 @@ 100% { transform: translateX(250px); opacity: 0; } } +/* Night Clear — dark navy with stars and moon glow */ +.weather-night-clear { + background: + radial-gradient(1px 1px at 10% 20%, #fff 50%, transparent 50%), + radial-gradient(1px 1px at 30% 65%, #fff 50%, transparent 50%), + radial-gradient(1.5px 1.5px at 55% 15%, #fff 50%, transparent 50%), + radial-gradient(1px 1px at 70% 45%, #fff 50%, transparent 50%), + radial-gradient(1.5px 1.5px at 85% 75%, #fff 50%, transparent 50%), + radial-gradient(1px 1px at 20% 80%, #fff 50%, transparent 50%), + radial-gradient(1.5px 1.5px at 45% 40%, #fff 50%, transparent 50%), + radial-gradient(1px 1px at 90% 20%, #fff 50%, transparent 50%), + radial-gradient(2px 2px at 65% 85%, #fff 50%, transparent 50%), + radial-gradient(1px 1px at 5% 50%, #fff 50%, transparent 50%), + radial-gradient(2px 2px at 38% 10%, #fff 50%, transparent 50%), + radial-gradient(1.5px 1.5px at 78% 60%, #fff 50%, transparent 50%), + linear-gradient(135deg, #1a1a2e, #16213e); + position: relative; + overflow: hidden; +} + +.weather-night-clear::before { + content: ""; + position: absolute; + inset: 0; + background: transparent; + animation: twinkle 3s ease-in-out infinite; +} + +@keyframes twinkle { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* Night Cloudy — dark grey-blue with stars and drifting cloud */ +.weather-night-cloudy { + background: + radial-gradient(1px 1px at 15% 25%, #fff 50%, transparent 50%), + radial-gradient(1px 1px at 40% 15%, #fff 50%, transparent 50%), + radial-gradient(1px 1px at 60% 70%, #fff 50%, transparent 50%), + radial-gradient(1.5px 1.5px at 80% 35%, #fff 50%, transparent 50%), + radial-gradient(1px 1px at 25% 60%, #fff 50%, transparent 50%), + radial-gradient(1.5px 1.5px at 90% 80%, #fff 50%, transparent 50%), + linear-gradient(135deg, #1a1a2e, #2d3436); + position: relative; + overflow: hidden; +} + +.weather-night-cloudy::before { + content: ""; + position: absolute; + top: 10px; + right: 20px; + width: 60px; + height: 30px; + border-radius: 30px; + background: rgba(255, 255, 255, 0.1); + animation: cloud-drift 8s ease-in-out infinite; +} + /* Respect user preference for reduced motion */ @media (prefers-reduced-motion: reduce) { .weather-sunny::before, @@ -237,7 +299,10 @@ .weather-snowy::after, .weather-stormy::before, .weather-windy::before, - .weather-windy::after { + .weather-windy::after, + .weather-night-clear::before, + .weather-night-clear::after, + .weather-night-cloudy::before { animation: none; } } \ No newline at end of file diff --git a/frontend/src/features/notes/EditNoteModal.tsx b/frontend/src/features/notes/EditNoteModal.tsx deleted file mode 100644 index 05c0aed..0000000 --- a/frontend/src/features/notes/EditNoteModal.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useState, useEffect } from "react"; -import { Modal, Button } from "../../components"; - -interface EditNoteModalProps { - open: boolean; - onClose: () => void; - onSave: (content: string) => void; - currentContent: string; -} - -export default function EditNoteModal({ open, onClose, onSave, currentContent }: EditNoteModalProps) { - const [content, setContent] = useState(currentContent); - - useEffect(() => { - setContent(currentContent); - }, [currentContent]); - - function handleSave() { - const trimmed = content.trim(); - if (!trimmed) return; - onSave(trimmed); - } - - return ( - -
-
- -