Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1e46cc1
Add database backup script with 7-day retention and update roadmap
danialkhilji Aug 9, 2026
721d377
Add rotation duty tracker and auto-update deployment to future features
danialkhilji Aug 9, 2026
4765053
Add prayer times with Aladhan API, daily 1am refresh, and current pra…
danialkhilji Aug 9, 2026
2c59cf7
Add pull-to-refresh to dashboard for weather, prayer times, and all c…
danialkhilji Aug 9, 2026
00788f6
Update roadmap: add post-launch features section with prayer times
danialkhilji Aug 9, 2026
78172c0
Add auto-start after reboot instructions to README
danialkhilji Aug 10, 2026
25de184
Add prayer times API client and TanStack Query hook for frontend
danialkhilji Aug 10, 2026
04d885e
Add prayer times bar to dashboard with current prayer highlight
danialkhilji Aug 10, 2026
7a8a53d
Update roadmap: mark prayer times feature as completed
danialkhilji Aug 10, 2026
10f371f
Tune prayer times to match Masjid-e-Salaam timetable
danialkhilji Aug 10, 2026
73368d5
Add Islamic calendar date to header from Aladhan API
danialkhilji Aug 10, 2026
97192db
Add reusable long-press hook for touch edit interactions
danialkhilji Aug 10, 2026
924cb52
Add long-press edit for tasks with EditTaskModal
danialkhilji Aug 10, 2026
e3ce2ef
Add long-press edit for shopping items
danialkhilji Aug 10, 2026
6216f61
Add long-press edit for notes with EditNoteModal
danialkhilji Aug 10, 2026
9edf5ef
Add long-press edit for members with backend update endpoint and Edit…
danialkhilji Aug 10, 2026
b7fb093
Remove pencil edit icon from shopping list, long-press replaces it
danialkhilji Aug 10, 2026
c11caf7
Add press animation to notes and member list rows
danialkhilji Aug 10, 2026
8ef598f
Make button press animation more noticeable
danialkhilji Aug 10, 2026
431ced4
Enable task and shopping item toggle directly from dashboard cards
danialkhilji Aug 10, 2026
1ce130e
Fill test gaps: add 15 new tests for member update, validation, weath…
danialkhilji Aug 10, 2026
ecdd80d
Add GitHub Actions CI pipeline for backend tests and frontend type check
danialkhilji Aug 10, 2026
458df26
Add CI/CD pipeline, pre-push checks, fill test gaps, and update roadmap
danialkhilji Aug 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ CORS_ORIGINS=["http://localhost:5173"]
# API
API_V1_PREFIX=/api/v1

# Weather (set your location coordinates)
WEATHER_LATITUDE=51.5074
WEATHER_LONGITUDE=-0.1278
# Weather and Prayer Times (set your location coordinates)
# Find your coordinates on Google Maps
WEATHER_LATITUDE=your_latitude
WEATHER_LONGITUDE=your_longitude
WEATHER_CACHE_MINUTES=30
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

on:
push:
branches: [main, develop]
pull_request:
branches: [main]

jobs:
backend-tests:
name: Backend Tests
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install dependencies
working-directory: backend
run: pip install -e ".[dev]"

- name: Run tests
working-directory: backend
run: pytest tests/ -v

frontend-check:
name: Frontend Type Check & Build
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Install dependencies
working-directory: frontend
run: npm ci

- name: Type check
working-directory: frontend
run: npx tsc -b

- name: Build
working-directory: frontend
run: npm run build
63 changes: 60 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,24 @@ cd HomeOS
cp .env.example .env
```

Edit `.env` and set your location for weather:
Edit `.env` and set your location coordinates. These are used for both weather and prayer times:

```
WEATHER_LATITUDE=51.5074
WEATHER_LONGITUDE=-0.1278
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
Expand All @@ -100,6 +111,22 @@ 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
```

### Useful commands

```bash
Expand All @@ -109,6 +136,36 @@ 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.

### Run Tests

```bash
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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

v1_router = APIRouter()
v1_router.include_router(health_router)
Expand All @@ -14,3 +15,4 @@
v1_router.include_router(shopping_router)
v1_router.include_router(notes_router)
v1_router.include_router(weather_router)
v1_router.include_router(prayer_router)
18 changes: 17 additions & 1 deletion backend/app/core/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
scheduler = AsyncIOScheduler()


async def run_prayer_refresh() -> None:
from app.modules.prayer.service import fetch_prayer_times

logger.info("Scheduled prayer times refresh triggered")
try:
await fetch_prayer_times()
except Exception:
logger.exception("Scheduled prayer times refresh failed")


async def run_rotation() -> None:
from app.modules.tasks.rotation import rotate_tasks

Expand All @@ -29,8 +39,14 @@ def setup_scheduler() -> None:
id="weekly_task_rotation",
replace_existing=True,
)
scheduler.add_job(
run_prayer_refresh,
trigger=CronTrigger(hour=1, minute=0),
id="daily_prayer_refresh",
replace_existing=True,
)
scheduler.start()
logger.info("Scheduler started: task rotation runs every Monday at midnight")
logger.info("Scheduler started: task rotation every Monday at midnight, prayer times refresh daily at 1am")


def shutdown_scheduler() -> None:
Expand Down
7 changes: 6 additions & 1 deletion backend/app/modules/members/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.modules.members.schemas import MemberCreate, MemberResponse
from app.modules.members.schemas import MemberCreate, MemberUpdate, MemberResponse
from app.modules.members import service

router = APIRouter(prefix="/members", tags=["members"])
Expand All @@ -18,6 +18,11 @@ async def create_member(data: MemberCreate, db: AsyncSession = Depends(get_db)):
return await service.create_member(db, data)


@router.put("/{member_id}", response_model=MemberResponse)
async def update_member(member_id: int, data: MemberUpdate, db: AsyncSession = Depends(get_db)):
return await service.update_member(db, member_id, data)


@router.delete("/{member_id}")
async def delete_member(member_id: int, db: AsyncSession = Depends(get_db)):
await service.delete_member(db, member_id)
Expand Down
5 changes: 5 additions & 0 deletions backend/app/modules/members/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ 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}$")


class MemberResponse(BaseModel):
id: int
name: str
Expand Down
20 changes: 19 additions & 1 deletion backend/app/modules/members/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from app.core.exceptions import NotFoundError, ValidationError
from app.modules.members.models import Member
from app.modules.members.schemas import MemberCreate
from app.modules.members.schemas import MemberCreate, MemberUpdate
from app.modules.tasks.models import Task
from app.modules.notes.models import Note

Expand All @@ -25,6 +25,24 @@ async def create_member(db: AsyncSession, data: MemberCreate) -> Member:
return 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)

if data.name != member.name:
existing = await db.execute(select(Member).where(Member.name == data.name))
if existing.scalar_one_or_none():
raise ValidationError("Member with this name already exists")

member.name = data.name
member.colour = data.colour
await db.flush()
await db.refresh(member)
return member


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()
Expand Down
Empty file.
11 changes: 11 additions & 0 deletions backend/app/modules/prayer/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import APIRouter

from app.modules.prayer.schemas import PrayerTimesResponse
from app.modules.prayer.service import get_prayer_times

router = APIRouter(prefix="/prayer-times", tags=["prayer"])


@router.get("", response_model=PrayerTimesResponse)
async def prayer_times():
return await get_prayer_times()
12 changes: 12 additions & 0 deletions backend/app/modules/prayer/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from pydantic import BaseModel


class PrayerTime(BaseModel):
name: str
time: str


class PrayerTimesResponse(BaseModel):
prayers: list[PrayerTime]
current_prayer: str | None = None
hijri_date: str | None = None
92 changes: 92 additions & 0 deletions backend/app/modules/prayer/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from datetime import datetime

import httpx

from app.core.config import settings
from app.core.logging import get_logger
from app.modules.prayer.schemas import PrayerTime, PrayerTimesResponse

logger = get_logger(__name__)

ALADHAN_URL = "https://api.aladhan.com/v1/timings"
PRAYER_NAMES = ["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"]

_cache: PrayerTimesResponse | None = None


def _to_12h(time_24h: str) -> str:
hour, minute = map(int, time_24h.split(":"))
period = "AM" if hour < 12 else "PM"
display_hour = hour % 12 or 12
return f"{display_hour}:{minute:02d} {period}"


def _to_minutes(prayer: PrayerTime) -> int:
time_str = prayer.time.replace(" AM", "").replace(" PM", "")
hour, minute = map(int, time_str.split(":"))
if "PM" in prayer.time and hour != 12:
hour += 12
if "AM" in prayer.time and hour == 12:
hour = 0
return hour * 60 + minute


def _find_current_prayer(prayers: list[PrayerTime]) -> str | None:
now = datetime.now()
current_minutes = now.hour * 60 + now.minute

current = None
for prayer in prayers:
if _to_minutes(prayer) <= current_minutes:
current = prayer.name

return current


async def fetch_prayer_times() -> PrayerTimesResponse:
global _cache

try:
params = {
"latitude": settings.WEATHER_LATITUDE,
"longitude": settings.WEATHER_LONGITUDE,
"method": 15,
"school": 1,
"tune": "0,0,0,5,0,5,0,9,0",
}

async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(ALADHAN_URL, params=params)
response.raise_for_status()
data = response.json()

timings = data["data"]["timings"]
prayers = []
for name in PRAYER_NAMES:
raw_time = timings.get(name, "")
if raw_time:
clean_time = raw_time.split(" ")[0]
prayers.append(PrayerTime(name=name, time=_to_12h(clean_time)))

hijri = data["data"]["date"]["hijri"]
hijri_date = f"{hijri['day']} {hijri['month']['en']} {hijri['year']}"

current_prayer = _find_current_prayer(prayers)
_cache = PrayerTimesResponse(prayers=prayers, current_prayer=current_prayer, hijri_date=hijri_date)
logger.info("Prayer times updated: %d prayers fetched", len(prayers))

except Exception:
logger.exception("Failed to fetch prayer times")
if _cache is None:
_cache = PrayerTimesResponse(prayers=[], current_prayer=None)

return _cache


async def get_prayer_times() -> PrayerTimesResponse:
if _cache is not None:
return PrayerTimesResponse(
prayers=_cache.prayers,
current_prayer=_find_current_prayer(_cache.prayers),
)
return await fetch_prayer_times()
Loading
Loading