Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"permissions": {
"allow": []
}
}
62 changes: 42 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ on:
permissions:
contents: read

env:
# Java 21 is required by AGP 8.x (minimum supported version for compileSdk 36)
JAVA_VERSION: '21'
# Pinned simulator name; update when Xcode drops this device from the runtime list
IOS_SIMULATOR: 'iPhone 16'

jobs:
android:
name: Android Build & Test
Expand All @@ -17,10 +23,10 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up JDK 21
- name: Set up JDK ${{ env.JAVA_VERSION }}
uses: actions/setup-java@v4
with:
java-version: '21'
java-version: ${{ env.JAVA_VERSION }}
distribution: 'temurin'

- name: Setup Gradle
Expand All @@ -30,10 +36,18 @@ jobs:
run: chmod +x gradlew

- name: Build debug APK
run: bash scripts/dev/android-build.sh assembleDebug
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 2
command: bash scripts/dev/android-build.sh assembleDebug

- name: Run unit tests and lint checks
run: bash scripts/dev/android-test.sh test lint
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 2
command: bash scripts/dev/android-test.sh test lint

ios:
name: iOS Build & Test
Expand All @@ -43,21 +57,29 @@ jobs:
uses: actions/checkout@v4

- name: Build iOS simulator app
run: |
xcodebuild \
-project ios/THDRoomFinder.xcodeproj \
-scheme THDRoomFinder \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO \
build
uses: nick-fields/retry@v3
with:
timeout_minutes: 15
max_attempts: 2
command: |
xcodebuild \
-project ios/THDRoomFinder.xcodeproj \
-scheme THDRoomFinder \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
CODE_SIGNING_ALLOWED=NO \
build

- name: Run THDRoomFinderTests
run: |
xcodebuild \
-project ios/THDRoomFinder.xcodeproj \
-scheme THDRoomFinder \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
-only-testing:THDRoomFinderTests \
CODE_SIGNING_ALLOWED=NO \
test
uses: nick-fields/retry@v3
with:
timeout_minutes: 15
max_attempts: 2
command: |
xcodebuild \
-project ios/THDRoomFinder.xcodeproj \
-scheme THDRoomFinder \
-destination 'platform=iOS Simulator,name=${{ env.IOS_SIMULATOR }},OS=latest' \
-only-testing:THDRoomFinderTests \
CODE_SIGNING_ALLOWED=NO \
test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

# Java class files
*.class
*.pyc
__pycache__/

# Generated files
bin/
Expand Down Expand Up @@ -62,3 +64,4 @@ Desktop.ini
# Crash logs
crash-logs/
nul
.env
173 changes: 173 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# CLAUDE.md — THD Room Finder

> Multi-platform app (Android + iOS) that helps THD students find free study rooms in real-time.

## Project Overview

**THD Room Finder** queries THD's public scheduling system **THabella** (`thabella.th-deg.de`) and cross-references occupied rooms against all known rooms to show which classrooms are currently available.

**Key principle:** No custom backend. Both apps talk directly to THabella's public `/opn/` endpoints and do all logic on-device.

## Repository Structure

```
app/ # Android app (Kotlin + Jetpack Compose)
ios/ # iOS app (SwiftUI)
THDRoomFinder/ # App source (Features/, Domain/, Data/, Intents/)
THDRoomFinder.xcodeproj
shared/
thd-room-taxonomy.json # Canonical room/building metadata (shared by both platforms)
scripts/
ci/ # CI helpers: upload-appetize.sh, export-ios-*.sh, android-*.sh
dev/ # Dev helpers: thd_room_normalization.py, export-thabella-snapshot.py
website/ # Static landing page (HTML/CSS)
docs/ # GitHub Wiki submodule
.github/workflows/ # CI: ci.yml, release.yml, appetize.yml, pages.yml
```

## Architecture

### Android

```
UI (Jetpack Compose + Material 3)
└── ViewModel (StateFlow, Kotlin Coroutines)
└── Use Cases (domain layer)
└── Repository (interface in domain, impl in data)
└── Remote: Retrofit + Kotlin Serialization → THabella
└── Local: Room DB (cache, 24h TTL rooms / 5min events)
```

Pattern: MVVM + Clean Architecture. Unidirectional data flow (UDF).
Package: `de.thd.roomfinder`

### iOS

```
Features/ (Home, RoomList, RoomDetail) — SwiftUI views + ViewModels
└── Domain/ (RoomModels, RoomPresentation, RoomPriorityPolicy, RoomRepository)
└── Data/ (ThabellaAPIClient → THabella)
```

Pattern: MVVM with async/await. No third-party dependencies — Foundation + SwiftUI only.

## Tech Stack

| | Android | iOS |
|---|---|---|
| Language | Kotlin | Swift |
| UI | Jetpack Compose + Material 3 | SwiftUI |
| Networking | Retrofit 2 + OkHttp | URLSession (async/await) |
| Serialization | kotlinx.serialization | Codable |
| Local DB | Room (cache) | — |
| DI | Hilt | — |
| Async | Coroutines + Flow | async/await |
| Min OS | Android 8.0 (SDK 26) | iOS 16+ |
| Build | Gradle Kotlin DSL | Xcode |

## THabella API

Base URL: `https://thabella.th-deg.de/thabella/opn/` — **no auth required**.

| Endpoint | Method | Body | Purpose |
|---|---|---|---|
| `/room/findRooms` | POST | `{}` | All rooms (289+) as `RoomDto[]` |
| `/period/findByDate/{dateTime}` | POST | `{"sqlDate":"YYYY-MM-DD HH:mm"}` | Events for a date/time as `PeriodDto[]` |

Key gotchas:
- `room_ident` in `PeriodDto` is `Map<String, String>`, not an array.
- Public fields only: `startDateTime`, `duration` (minutes), `eventTypeDescription`. Event titles/organiser are always null.
- **No official docs.** Endpoints discovered via THabella's RequireJS source.
- **API may change without notice** — use `ignoreUnknownKeys = true` and nullable fields everywhere.
- Unknown rate limits — cache aggressively.

## Shared Room Taxonomy (`shared/thd-room-taxonomy.json`)

Canonical metadata used by both platforms. Contains:
- `buildings` — 28 entries with codes, campus, display names
- `campuses`, `sites` — campus/site hierarchy
- `roomCodePatterns` — regex patterns for room code normalization

The normalization script (`scripts/dev/thd_room_normalization.py`) derives `RoomVisibilityClass` for each room:
- `teaching_room` — regular classrooms shown by default
- `secondary_venue` — labs, seminar rooms shown in expanded view
- `exclude_default` — admin/server/storage rooms hidden by default
- `unknown` — unrecognized pattern

**RoomPriorityPolicy** (iOS: `Domain/RoomPriorityPolicy.swift`, Android: use cases) sorts free rooms: main-campus teaching rooms first, then secondary venues, then remote buildings.

## Build & Run

### Android

```bash
# Debug APK
./gradlew assembleDebug

# Release APK
./gradlew assembleRelease

# Unit tests
./gradlew test

# Lint
./gradlew lint

# Full CI check (same as CI pipeline)
bash scripts/dev/android-build.sh assembleDebug
bash scripts/dev/android-test.sh
```

### iOS

Open `ios/THDRoomFinder.xcodeproj` in Xcode. Select a simulator and run.

```bash
# Package simulator bundle for Appetize
bash scripts/ci/package-ios-simulator.sh

# Export IPA for TestFlight
bash scripts/ci/export-ios-testflight.sh

# Upload to Appetize
bash scripts/ci/upload-appetize.sh
```

## CI / Delivery

| Workflow | Trigger | What it does |
|---|---|---|
| `ci.yml` | push / PR | Build + test both Android and iOS |
| `release.yml` | tag `v*` | Build release APK + iOS IPA, create GitHub release |
| `appetize.yml` | push to `main` | Build debug APK + iOS sim bundle, upload to Appetize for live preview |
| `pages.yml` | push to `main` | Deploy `website/` to GitHub Pages |

Appetize previews require the `APPETIZE_API_TOKEN` secret. If absent, CI still builds and attaches the artifact — it just skips the upload.

## Core Features

- [x] Free Room Finder — rooms not occupied right now per THabella schedule
- [x] Building filter — filter by building code (FilterChip row)
- [x] Time-based filtering — check availability at a future date/time
- [x] Room details — capacity, facilities, contact info, day schedule
- [x] Room priority sorting — main-campus teaching rooms ranked first
- [x] Student-friendly visibility filters — exclude admin/server rooms by default
- [x] Offline support — network-first with Room DB fallback (Android); local-first (iOS)
- [x] Auto-refresh — silent 5-minute background refresh
- [x] iOS App Intents — Siri / Shortcuts integration
- [ ] Favorites — save frequently used rooms

## Code Conventions

- **Architecture boundaries:** UI → Domain ← Data. Domain layer has no platform imports.
- **No wildcard imports** (Kotlin); explicit imports (Swift).
- **Defensive API parsing:** `ignoreUnknownKeys = true`, nullable fields for all DTO properties.
- **German context:** room names and building codes stay in their original German form; app UI is in English.
- **Naming:** `<Feature>Screen` / `<Feature>View`, `<Feature>ViewModel`, `<Entity>Repository`, `<Action><Entity>UseCase`, `<Entity>Dto`, `<Entity>Entity`.
- **Tests:** use fakes over mocks; name tests as `` fun `descriptive behavior`() ``.

## Before Committing

1. `./gradlew assembleDebug test lint` passes (Android)
2. Xcode builds without warnings (iOS)
3. One logical change per commit; imperative mood, ≤72 chars.
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,28 @@ bash scripts/dev/android-test.sh test lint
> [!NOTE]
> Codespaces is supported as a backup environment, not the default workflow. iOS builds still require either a Mac with Xcode or GitHub Actions on `macos-latest`.

### Audit THabella Content Locally

If you want to inspect THabella's raw content and the exact normalized data shape the apps use, run:

```bash
python scripts/dev/export-thabella-snapshot.py
```

You can also choose a specific query time:

```bash
python scripts/dev/export-thabella-snapshot.py --date-time "2026-04-14 09:00"
```

By default the script writes into `build/thabella-snapshot/<timestamp>/` and creates:

- `raw/` - untouched THabella room and period payloads
- `normalized/` - cleaned rooms, events, and building summaries
- `app/` - home, room-list, and per-room detail exports shaped like the apps
- `audit/data-quality.json` - content-gap metrics and fallback-title samples
- `summary.md` - a quick human-readable audit report

### iOS Build, Preview, and Install

The repository includes a native SwiftUI iPhone project at `ios/THDRoomFinder.xcodeproj`.
Expand Down
11 changes: 11 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ android {
version = release(36)
}

sourceSets {
getByName("main") {
assets.srcDirs("../shared")
}
}

defaultConfig {
applicationId = "de.thd.roomfinder"
minSdk = 26
Expand All @@ -35,6 +41,10 @@ android {
}

buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = false
proguardFiles(
Expand All @@ -53,6 +63,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

Expand Down
3 changes: 3 additions & 0 deletions app/src/debug/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">THD Room Finder Debug</string>
</resources>
8 changes: 8 additions & 0 deletions app/src/main/java/de/thd/roomfinder/data/AppDateFormats.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package de.thd.roomfinder.data

import java.time.format.DateTimeFormatter

internal object AppDateFormats {
val EVENT_DATE_TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val DATE_KEY: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import de.thd.roomfinder.data.local.entity.ScheduledEventEntity
ScheduledEventEntity::class,
CacheMetadataEntity::class,
],
version = 1,
version = 2,
exportSchema = false,
)
abstract class AppDatabase : RoomDatabase() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ data class RoomEntity(
val bookable: Boolean,
val inChargeName: String?,
val inChargeEmail: String?,
val untisLongname: String?,
)
Loading
Loading