Improve tests

This commit is contained in:
2026-04-03 16:15:37 +02:00
parent 517ad2f80f
commit 1a7958672e
14 changed files with 52285 additions and 516 deletions

48
.claudeignore Normal file
View File

@@ -0,0 +1,48 @@
# --- IDE & OS Specific ---
**/.idea/
**/.vscode/
**/.settings/
**/.classpath/
**/.project/
**/*.iml
.DS_Store
Thumbs.db
**/testdata/**
# --- Frontend Tooling & Reports ---
peakup-frontend/playwright-report/
peakup-frontend/test-results/
peakup-frontend/.cache/
**/dist/**
**/node_modules/**
# --- Temporary & Log Files ---
*.log
**/tmp/
**/.temp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
*.sql
*.json
*.jsonl
# --- Infrastructure & Secrets (Safety) ---
*.pem
*.key
.env*
! .env.example
# --- Binary Assets ---
# Logo bleibt im Repo, wird aber für Code-Analysen ignoriert
resources/Logo_peakUp.png
# --- Wrapper & Scripts (Optional, falls Claude sie nicht editieren soll) ---
# peakup-backend/mvnw
# peakup-backend/mvnw.cmd

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@
hs_err_pid*
replay_pid*
/peakup-backend/src/test/resources/

48
.idea/compiler.xml generated
View File

@@ -9,57 +9,11 @@
<outputRelativeToContentRoot value="true" />
<module name="peakup-tools" />
</profile>
<profile name="Annotation profile for peakup-backend" enabled="true">
<profile name="Annotation profile for peakup-parent" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<processorPath useClasspath="false">
<entry name="$USER_HOME$/.m2/peak-repo/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_core/2.37.0/error_prone_core-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_annotation/2.37.0/error_prone_annotation-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_check_api/2.37.0/error_prone_check_api-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/github/java-diff-utils/java-diff-utils/4.12/java-diff-utils-4.12.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/github/kevinstern/software-and-algorithms/1.0/software-and-algorithms-1.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/github/ben-manes/caffeine/caffeine/3.0.5/caffeine-3.0.5.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/googlejavaformat/google-java-format/1.24.0/google-java-format-1.24.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/pcollections/pcollections/4.0.1/pcollections-4.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/guava/32.1.1-jre/guava-32.1.1-jre.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/checkerframework/checker-qual/3.33.0/checker-qual-3.33.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/j2objc/j2objc-annotations/2.8/j2objc-annotations-2.8.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/auto-common/1.2.2/auto-common-1.2.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/github/eisop/dataflow-errorprone/3.41.0-eisop1/dataflow-errorprone-3.41.0-eisop1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/value/auto-value-annotations/1.9/auto-value-annotations-1.9.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_annotations/2.37.0/error_prone_annotations-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/protobuf/protobuf-java/3.25.5/protobuf-java-3.25.5.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/service/auto-service-annotations/1.0.1/auto-service-annotations-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/javax/inject/javax.inject/1/javax.inject-1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/uber/nullaway/nullaway/0.13.1/nullaway-0.13.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/checkerframework/dataflow-nullaway/3.53.0/dataflow-nullaway-3.53.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/orm/hibernate-processor/7.2.4.Final/hibernate-processor-7.2.4.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/orm/hibernate-core/7.2.4.Final/hibernate-core-7.2.4.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/persistence/jakarta.persistence-api/3.2.0/jakarta.persistence-api-3.2.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/fasterxml/classmate/1.7.1/classmate-1.7.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/inject/jakarta.inject-api/2.0.1/jakarta.inject-api-2.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/models/hibernate-models/1.0.1/hibernate-models-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/smallrye/jandex/3.3.2/jandex-3.3.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/xml/bind/jakarta.xml.bind-api/4.0.4/jakarta.xml.bind-api-4.0.4.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/activation/jakarta.activation-api/2.1.4/jakarta.activation-api-2.1.4.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/jaxb-runtime/4.0.6/jaxb-runtime-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/jaxb-core/4.0.6/jaxb-core-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/eclipse/angus/angus-activation/2.0.3/angus-activation-2.0.3.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/txw2/4.0.6/txw2-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/sun/istack/istack-commons-runtime/4.1.2/istack-commons-runtime-4.1.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/validation/jakarta.validation-api/3.1.1/jakarta.validation-api-3.1.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/annotation/jakarta.annotation-api/3.0.0/jakarta.annotation-api-3.0.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/net/bytebuddy/byte-buddy/1.17.8/byte-buddy-1.17.8.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/jboss/logging/jboss-logging/3.6.1.Final/jboss-logging-3.6.1.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_core/2.37.0/error_prone_core-2.37.0.jar" />

502
CLAUDE.md
View File

@@ -1,472 +1,90 @@
# CLAUDE.md — Project-Specific Guidelines for Claude Code (claude.ai/code)
# CLAUDE.md — Project Guidelines
**Last updated:** 2026-03-29
**Purpose:** This file is the single source of truth for how Claude must behave when working in this repository.
Claude **must** follow these rules 100 % of the time. No exceptions.
**Status:** Single source of truth. 100% compliance required.
## 1. Core Operating Rules
- Always use terminal tools (`ls`, `grep`, `find`, `cat`, `rg`, `tree`, `mvn`, `npm`, etc.) to explore and validate *
*before** and **after** every change.
- Never assume file contents, package structure, or conventions — read them first.
- Never start editing, moving, or creating files without following the correct workflow.
- **Always write clean code.** When a change makes a field, method, class, column, or migration step unreachable or
unwritten, remove it — do not leave dead code behind. **always ask the user before keeping or removing it.**
- If a task feels truly trivial (rename variable, fix typo, add comment, single-line fix), **always ask first**:
> “This looks trivial. May I edit directly, or should I still follow the full Proposal → Validate → Implement
process?”
- Use terminal tools (`ls`, `rg`, `mvn`, etc.) to validate state **before** and **after** changes.
- Never assume; read file contents and package structures first.
- **Clean Code:** Remove unreachable code immediately (ask user first).
- **Trivial Tasks:** For renames/typos, ask: "Trivial? Direct edit or 4-Phase Workflow?"
## 2. Mandatory Workflows
## 2. Mandatory Workflow (4-Phase)
### A. Code Changes, Refactorings & Features (4-Phase Workflow)
1. **PROPOSAL:** Clear description + before/after diff snippets + impact. Wait for `OK PROPOSAL`.
2. **VALIDATE:** Brutal self-critique (NullAway, layering, regressions). Wait for `OK VALIDATE`.
3. **IMPLEMENT:** One unit at a time. Run `PackageInfoGenerator` after new packages. Show exact diff.
4. **VERIFICATION:** Propose/run tests (`$MVN test`). End with suggested commit message.
Applies to all code edits, new features, bug fixes, logic changes.
## 3. Project Structure & Conventions
**Phase 1 – PROPOSAL**
### Modules
- Clear description + **before/after** snippets (as diff blocks)
- Why + affected files/classes/methods + impact
- Risks & regressions
End with:
→ `✅ PROPOSAL READY — Waiting for explicit "OK PROPOSAL" to proceed to Validate`
- `peakup-backend`: Quarkus (Java 21), Hibernate, PostgreSQL/TimescaleDB.
- `peakup-frontend`: Vite, React 18, TS, Tailwind, shadcn/ui.
- `peakup-tools`: Dev-time generators.
**Phase 2 – VALIDATE** (only after “OK PROPOSAL”)
### Backend Architecture (com.peakup)
- Brutally self-critique the plan
- NullAway / `@Nullable` / `@Transactional` / layering check
- Regression & side-effect analysis
- List potential build/runtime issues, dependency problems, rollback plan
- Alternatives if better
End with:
→ `✅ VALIDATED — Waiting for explicit "OK VALIDATE" to proceed to Implement`
- **Domain Slices** (`activity`, `athlete`): `model` (JPA) → `dao` (Criteria API) → `service`.
- **Connectors** (`connector/<name>`): `model` → `dao` → `service` → `client` (Rest) → `mapper`.
- **Mapping:** External DTO → Domain Entity **must** live in `connector/<name>/mapper/`.
- **Streams:** TimescaleDB hypertables (`ActivityStreamPoint`) use `AbstractTimescaleEntity`.
**Phase 3 – IMPLEMENT** (only after “OK VALIDATE”)
### Base Classes & Patterns
- One file / one logical unit at a time
- Show **exact diff** before applying
- Run after new package:
```bash
JAVA_HOME=/home/ingo/.jdks/temurin-21.0.7 mvn -f peakup-tools/pom.xml -s /home/ingo/.m2/settings_peak.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo compile -q
/home/ingo/.jdks/temurin-21.0.7/bin/java -cp peakup-tools/target/classes com.peakup.tools.generator.PackageInfoGenerator
```
- After change: suggest verification + wait for confirmation
- `AbstractEntity`: Global sequence ID.
- `AbstractTimestampedEntity`: Adds `createdAt`/`modifiedAt`.
- **Strong Dependent:** `@Embeddable`, stored via `@ElementCollection` (e.g., `ActivityZone`).
- **Dependent:** `@Entity` with own DAO, DB-level `ON DELETE CASCADE` (e.g., `ActivityStreamPoint`).
**Phase 4 – VERIFICATION** (only after implementation is complete and user confirms)
### Integration Details
- Ask if verification should be executed and tests should be written
- Propose test(s)
- Ask for further test ideas
- Implement test(s) with same diff discipline
- Run tests (`$MVN test` or `npm test`)
- If failure → loop back to Implement with fix
- End with suggested commit message
- **Strava:** OAuth2 with token refresh in `StravaConnectorService`.
- **Intervals.icu:** Basic Auth (API_KEY).
- **Scheduler:** `ActivityImportScheduler` (every 20m) orchestrates all importers.
**Approval shorthand users may use:**
`OK DESIGN` • `OK PROPOSAL` • `OK VALIDATE` • `PROCEED` • `NEXT` • `CONFIRM`
### Database & Code Quality
## 3. Project Structure & Conventions (must be respected)
- **DB:** Local `localhost:5432/peakup`. Flyway migrations in `db/migration/`.
- **NullAway:** Enforced null-safety. Use `@Nullable` and `@SuppressWarnings("NullAway.Init")`.
- **Lombok:** Use `@Data`, `@NoArgsConstructor`, `@AllArgsConstructor`, `@Slf4j`.
**Modules**
## 4. Frontend (Clean Geometric Performance)
- `peakup-backend` — Quarkus Java 21 REST API
- `peakup-frontend` — Vite + React 18 + TypeScript
- `peakup-tools` — Standalone Java dev-time generators (not part of backend build; can run with any JDK)
- **Theme:** Light mode only (`#FFFFFF`). No `dark:` variants.
- **Accent:** Red-to-orange gradient (`from-red-500 to-orange-500`) for CTAs/Charts only.
- **Components:** `shadcn/ui` primitives. Max `rounded-sm`.
- **State/Data:** TanStack Query v5, TanStack Table v8. No `useEffect` for fetching.
- **Charts:** Recharts (always responsive). Linear gradients for curves.
**Backend Top-Level Packages** (under `com.peakup`)
## 5. Design Violations (Fix when touching)
| Package | Responsibility |
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `com.peakup.activity` | Domain logic, model, and behavior of activities (model, DAO, service, importer, connector interface) |
| `com.peakup.athlete` | Domain logic, model, and behavior of athletes (model, DAO, service) |
| `com.peakup.connector` | Connector domain — one sub-package per external app (strava, intervals, garmin, zwift, …). Contains credentials model, DAO, service, REST client, context, headers factory, and **mapper** |
| `com.peakup.common` | Base classes, utilities, and exceptions only — no domain knowledge |
| `com.peakup.scheduler` | Cross-cutting scheduled jobs (intentionally separate; crosses multiple domains) |
- **V1:** Move `ActivityConnector` implementations to `connector/<name>/`.
- **V2/V3:** Extract mapping logic from importers into dedicated mappers.
- **V4:** Move `IntervalsEnrichmentStatusEnum` to connector package to decouple domains.
- **V5:** Move `AbstractEnumConverter` inner classes to their respective enums.
**Domain slice structure** (`activity`, `athlete`):
## 6. Technical Rules & Checkstyle
```
<domain>/
├── model/ → JPA entities (extends AbstractEntity or AbstractTimestampedEntity)
├── dao/ → extends AbstractDAO<T>
└── service/ → extends AbstractService<E, D> and implements getDAO()
```
- **Java 21** strictly enforced.
- **Checkstyle:** 4 spaces, 120 chars, K&R braces, no star imports.
- **Import Order:** Non-java alpha, then `java.*`, then `jakarta.*`.
- **Tests:** `@QuarkusTest` against real DB. Seed via `skripts.sql`.
**Connector slice structure** (`connector/<name>`):
```
connector/<name>/
├── model/ → connector entity (@Entity: credentials/API config per athlete)
├── dao/ → extends AbstractDAO<T>
├── service/ → extends AbstractService + token/auth logic (e.g. OAuth refresh, Basic auth)
├── client/ → @RegisterRestClient interface + nested DTO inner classes + Context (ThreadLocal) + HeadersFactory
└── mapper/ → maps client DTOs → peakup domain models (e.g. StravaActivityMapper, IntervalsActivityMapper)
```
**New domain / connector rule**
- Domain → follow `model → dao → service`
- Connector → follow the structure above (model → dao → service → client → mapper)
- Mapping from external API DTOs to peakup domain models **must** live in the connector's `mapper/` package — never
inside importers
**peakup-tools — PackageInfoGenerator**
Run after every new package:
## 7. Commands
```bash
JAVA_HOME=/home/ingo/.jdks/temurin-21.0.7 mvn -f peakup-tools/pom.xml -s /home/ingo/.m2/settings_peak.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo compile -q
/home/ingo/.jdks/temurin-21.0.7/bin/java -cp peakup-tools/target/classes com.peakup.tools.generator.PackageInfoGenerator
```
Must be executed from project root.
Maven Commands (always from peakup-backend/):
export JAVA_HOME=/home/ingo/.jdks/temurin-21.0.7
MVN="./mvnw -s /home/ingo/.m2/settings_peak.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo"
$MVN compile # fast
$MVN clean compile # after Error Prone / NullAway changes
# Backend
$MVN compile
$MVN quarkus:dev
$MVN test -Dtest=XXX
$MVN test -Dtest=ClassName
### Base Classes
# Package Generator (from root)
JAVA_HOME=... mvn -f peakup-tools/pom.xml ... compile
... com.peakup.tools.generator.PackageInfoGenerator
- `model/AbstractEntity` — Base JPA entity with global sequence-based ID (`global_id_seq`, starts at 100)
- `model/AbstractTimestampedEntity` — Adds `createdAt` / `modifiedAt`
- `model/AbstractTimescaleEntity` — Base entity for TimescaleDB hypertables. Uses `@Id Instant time` (partition column)
combined with `@IdClass` on the entity for composite natural keys. No global sequence ID. Used by
`ActivityStreamPoint` and `ActivityMapStreamPoint`.
- `dao/AbstractDAO<T>` — Generic JPA DAO with `getById`, `getAll`, `insert`, `update`, `delete` using Criteria API
- `service/AbstractService<E, D>` — Thin delegation layer over the DAO; concrete services must implement `getDAO()`
### Dependent Entity Pattern
Two categories of entities that belong to an owner entity (e.g. `IntervalsActivity`):
**Strong Dependent** — No own identity, no DAO. Defined as `@Embeddable` and stored via
`@ElementCollection` list on the owner entity. JPA handles cascade insert/delete automatically.
| Entity | Table | Owner |
|-----------------------|------------------------|---------------------|
| `ActivityAchievement` | `activity_achievement` | `IntervalsActivity` |
| `ActivityZone` | `activity_zone` | `IntervalsActivity` |
| `IgnorePart` | `activity_ignore_part` | `IntervalsActivity` |
| `ActivityAttachment` | `activity_attachment` | `IntervalsActivity` |
| tags (`String`) | `activity_tag` | `IntervalsActivity` |
**Dependent** — Own `@Entity` with a custom DAO (typically JDBC batch insert). Implements
`DependentEntity` interface (`getFK()` returns the owner's ID). Cascade delete is enforced at the
DB level via `FOREIGN KEY ... ON DELETE CASCADE`.
| Entity | Table | Owner | DAO |
|--------------------------|-----------------------------|---------------------|------------------------|
| `ActivityStreamPoint` | `activity_stream_point` | `IntervalsActivity` | `ActivityStreamDAO` |
| `ActivityMapStreamPoint` | `activity_map_stream_point` | `IntervalsActivity` | `ActivityMapStreamDAO` |
New dependent entities must follow one of these two patterns:
- If the entity has no need for direct queries or batch JDBC → make it a **strong dependent** (`@Embeddable`)
- If the entity requires its own DAO (e.g. bulk inserts, direct queries) → make it a **dependent** (
`@Entity implements DependentEntity`) with a DB-level `ON DELETE CASCADE` FK
### Strava Integration
- `StravaConnector` (entity) — Stores OAuth credentials (`accessToken`, `refreshToken`, `expiresAt`, `clientId`,
`clientSecret`) per athlete
- `StravaContext` — `@ApplicationScoped` bean using `ThreadLocal<Long>` to carry the current athlete ID within a
scheduled job invocation
- `StravaConnectorService.getToken()` — Checks token expiry; auto-refreshes via `StravaAuthClient` if expired
- `StravaHeadersFactory` — Injects the `Authorization: Bearer <token>` header into outgoing Strava API calls
- `StravaClient` — MicroProfile REST client for `https://www.strava.com/api/v3`; uses `strava-api` config key
- `ActivityImportScheduler` — single `@ApplicationScoped` Quarkus `@Scheduled(every = "20m")` job in
`com.peakup.scheduler` that handles both Strava and Intervals imports. Uses `AtomicBoolean running` to prevent
overlapping runs. Iterates all `StravaConnector` records → calls `StravaActivityImporter.importActivities()`, then all
`IntervalsConnector` records → sets `IntervalsContext`, calls `IntervalsActivityImporter.importActivities()`, clears
context in `finally`.
### Intervals.icu Integration
- `IntervalsConnector` (entity) — Stores `apiKey` and `intervalsAthleteId` per athlete
- `IntervalsContext` — `@ApplicationScoped` bean using `ThreadLocal<Long>` to carry the current
athlete ID within a scheduled job invocation
- `IntervalsContext.getBasicAuth()` — Builds `Basic base64(API_KEY:<apiKey>)` header from the
stored `api_key`
- `IntervalsHeadersFactory` — Injects the `Authorization: Basic <token>` header into outgoing
intervals.icu API calls via MicroProfile REST client `@RegisterClientHeaders`
- `IntervalsClient` — MicroProfile REST client for `https://intervals.icu`; uses `intervals-api`
config key
**Auth — HTTP Basic Auth:**
- Username: `API_KEY` (literal string)
- Password: `api_key` column from `intervals_connector` table
- curl example:
```bash
curl -u "API_KEY:<api_key>" "https://intervals.icu/api/v1/athlete/{athleteId}/activities"
```
**Key API endpoints used:**
- `GET /api/v1/athlete/{id}/activities` — activity list
- `GET /api/v1/activity/{id}` — single activity detail
- `GET /api/v1/activity/{id}/streams` — sensor streams (time, hr, power, cadence, …)
- `GET /api/v1/activity/{id}/map` — GPS track (`latlngs` field: `[[lat,lng],…]`)
**Full API specification:** `resources/intervals.icu.json` (OpenAPI 3.0)
### Activity Connector Pattern
`activity/connector/ActivityConnector<T>` — interface (stays in `activity` as domain contract):
- `fetchRawActivities(@Nullable Instant after, @Nullable Instant before): List<T>`
Implementations (**target location: connector sub-packages** — see Design Violations §V1):
- `StravaActivityConnector` — paginates Strava API (up to 10 pages × 200 activities); uses `StravaContext` +
`@RestClient StravaClient`
- `IntervalsActivityConnector` — fetches activities for a date range from Intervals.icu; uses `IntervalsContext` +
`@RestClient IntervalsClient`
New activity connector implementations must live in `connector/<name>/` and implement this interface.
### Activity Importer Pattern
`activity/importer/AbstractActivityImporter<R, A extends AbstractActivity>` — abstract base (pure orchestration):
- `getDAO(): AbstractDAO<A>` — abstract
- `findExisting(R raw): @Nullable A` — abstract, checks for duplicate by origin ID
- `map(R raw, Long athleteId): A` — abstract, delegates to connector mapper
- `persist(Long athleteId, List<R> raws): int` — template method; calls findExisting/map/insert
Implementations:
- `StravaActivityImporter` extends `AbstractActivityImporter<StravaClient.Activity, StravaActivity>`
- `IntervalsActivityImporter` extends `AbstractActivityImporter<IntervalsClient.IntervalsActivity, IntervalsActivity>`
- Overrides `persist()` with per-activity `@Transactional persistSingle()` via CDI self-injection (
`@Inject IntervalsActivityImporter self`)
- Also imports `ActivityStreamPoint` and `ActivityMapStreamPoint` after activity insert/update
**Target pattern after mapper extraction:** importers delegate to `connector/<name>/mapper/` for all DTO → entity
mapping. The `map()` method in the importer should call the mapper, not contain mapping logic itself.
New importers must extend `AbstractActivityImporter` and follow this pattern.
### Activity Streams
Stored in two TimescaleDB hypertables (extend `AbstractTimescaleEntity`):
`ActivityStreamPoint` (`activity_stream_point`) — per-second sensor data:
- Composite PK: `(activityId, time)` via `@IdClass`
- Fields: `elapsedS`, `distanceM`, `altitudeM`, `heartrateBpm`, `watts`, `torqueNm`, `cadenceRpm`, `velocityMps`, `lat`,
`lng`, `tempC`, `gradeSmooth`, `moving`, `lrBalance`, `hrvMs`, `respirationBrpm`, `fixedAltitudeM`,
`leftPedalSmoothnessPct`, `rightPedalSmoothnessPct`, `leftTorqueEffectivenessPct`, `rightTorqueEffectivenessPct`
`ActivityMapStreamPoint` (`activity_map_stream_point`) — GPS track:
- Composite PK: `(activityId, time)` via `@IdClass`
- Fields: `lat`, `lng`
DAOs: `ActivityStreamDAO`, `ActivityMapStreamDAO`
Source: `GET /api/v1/activity/{id}/streams` and `GET /api/v1/activity/{id}/map` on Intervals.icu API.
Streams are imported per-activity in `IntervalsActivityImporter.importStreams()` / `importMapStream()`.
### Database
**Connection details (local dev):**
- Host: `localhost:5432`
- Database: `peakup`
- Username: `peakup`
- Password: `peakup`
```bash
psql -h localhost -U peakup -d peakup
# or
psql "postgresql://peakup:peakup@localhost:5432/peakup"
```
- PostgreSQL via JDBC; Flyway runs migrations at startup
- Migrations live in `src/main/resources/db/migration/` (V1…V25; V17–V18 introduce TimescaleDB stream hypertables,
V22–V24 add stream columns, V23 adds FK cascade, V25 converts achievements to embeddable)
- Naming strategy: `CamelCaseToUnderscoresNamingStrategy` — field names are auto-mapped to snake_case columns
- Hibernate static metamodel is generated at compile time (`hibernate-jpamodelgen`) and used in Criteria API queries
### Code Quality
- **NullAway** (ErrorProne plugin) enforces null-safety across all `com.peakup` packages at compile time. Fields that
cannot be initialized at construction (e.g., `@Inject` fields) require `@SuppressWarnings("NullAway.Init")`. Return
values that may be null must be annotated `@Nullable` (Jakarta annotation).
- **Lombok** is used for `@Data`, `@NoArgsConstructor`, `@AllArgsConstructor`, `@Slf4j` (`@lombok.extern.slf4j.Slf4j`
style).
- `PersistableEnum` / `AbstractEnumConverter` pattern is used for storing enums as string codes in the DB.
### Tests
Tests use `@QuarkusTest` and run against a real database (connection configured in `application.properties`). The test
script `src/test/resources/skripts.sql` seeds test data.
## Frontend (peakup-frontend)
> ⚠️ **NOT YET IMPLEMENTED** — This module does not exist yet. The section below documents the intended stack and
> conventions for when it is built.
### Stack
| Concern | Library |
|-------------------|-------------------------------------|
| Build / Framework | Vite + React 18 (TypeScript strict) |
| Styling | Tailwind CSS (light mode only) |
| Components | shadcn/ui |
| Server state | TanStack Query v5 |
| Tables | TanStack Table v8 |
| Charts | Recharts |
| Icons | Lucide React |
### Commands
Run from `peakup-frontend/`:
```bash
npm run dev # Vite dev server with HMR
npm run build # production build
npm run lint # ESLint
```
API base URL is configured via `VITE_API_URL` (default: `http://localhost:8111`).
### Design Philosophy — "Clean Geometric Performance"
- **Theme:** Light mode only. Background is pure white (`#FFFFFF` / `white`).
Do not add `dark:` Tailwind variants.
- **Accent:** Red-to-orange gradient (`from-red-500 to-orange-500`) is the **sole** color accent.
Used only for: CTAs (buttons), active nav items, and performance curves (Pace/Power charts).
Never use it decoratively or as a background fill.
Wire the accent into shadcn CSS variables (`--primary`, `--ring`) in `globals.css` so all
shadcn components inherit it automatically. For Recharts curves, define an SVG `<linearGradient>`
— Tailwind gradient classes have no effect on SVG strokes.
- **Shadows:** No harsh shadows. Use `shadow-sm` or `border border-gray-200` to separate cards.
Keep surfaces flat and clean.
- **Typography:** Inter or Geist Sans. Headlines are geometric and slim — use `font-light` or
`font-normal` weights for headings to echo the linear logo aesthetic. Data values use
`font-semibold` or `font-bold` for scannability.
- **Shapes:** Crisp edges preferred. Max `rounded-sm` on cards and inputs — no `rounded-xl` or
pill shapes. Reflects the geometric rigor of the mountain logo.
- **Logo:** Logo can be found in resources/Logo_peakUp.png
### Components (shadcn/ui)
- Install via `npx shadcn@latest add <component>` — never hand-roll what shadcn provides.
- `src/components/ui/` — shadcn primitives (auto-generated, do not edit directly).
- `src/components/` — domain components (`ActivityCard`, `MetricBadge`, etc.) composed from shadcn primitives.
- Extend shadcn components with Tailwind classes; avoid overriding CSS variables unless necessary.
### Data Fetching (TanStack Query)
- All server state via `useQuery` / `useMutation`. No `useEffect` + `fetch` patterns.
- Query key conventions: `['activities']`, `['activity', id]`, `['athlete']`.
- `staleTime`: 5 min for lists; `Infinity` for historical activity detail (immutable data).
- API types are defined in `src/types/api.ts` and mirror backend field names exactly.
### Charts (Recharts)
- Always wrap in `<ResponsiveContainer>` — no fixed pixel widths.
- Primary chart: `ComposedChart` with `Line` for HR/Power, `Area` for training zones.
- X-axis unit: elapsed time in seconds (`elapsed_time_s`); format with a custom tick formatter.
- Provide a custom `<ChartTooltip>` showing metric value + unit (e.g. `207 W`, `142 bpm`).
### Tables (TanStack Table)
- Activity list columns: date, name, sport type, distance, duration, avg HR, avg power.
- Use `useReactTable` with `getCoreRowModel`, `getSortedRowModel`, `getFilteredRowModel`, `getPaginationRowModel`.
- Column definitions typed as `ColumnDef<Activity>` (imported from `src/types/api.ts`).
### TypeScript
- `"strict": true` in tsconfig — no exceptions.
- No `any`. Use `unknown` and narrow explicitly.
- API response shapes in `src/types/api.ts` must stay in sync with backend `Activity` / `Athlete` fields.
## 3b. Known Design Violations (to be fixed)
The following existing code violates the architecture defined in §3. Do not add new code that
follows these patterns. When touching these areas, fix the violation as part of the change.
### V1 — `activity.connector` implementations are misplaced
The `ActivityConnector<T>` **interface** correctly lives in `activity/connector/`. However, its
implementations belong in their respective connector sub-packages:
| File (current) | Should move to |
|------------------------------------------------------|------------------------|
| `activity/connector/StravaActivityConnector.java` | `connector/strava/` |
| `activity/connector/IntervalsActivityConnector.java` | `connector/intervals/` |
### V2 — Mapping logic is embedded in importers instead of dedicated mapper classes
All API-DTO → peakup-entity mapping must live in `connector/<name>/mapper/`. Currently it is
embedded as private methods inside the importers:
| File (current) | Offending methods | Target mapper class |
|----------------------------------------------------|----------------------------------------------------------------|------------------------------------------------------|
| `activity/importer/StravaActivityImporter.java` | `map()` | `connector/strava/mapper/StravaActivityMapper` |
| `activity/importer/IntervalsActivityImporter.java` | `map()`, `mapStreams()`, `mapMapStream()`, `mapAchievements()` | `connector/intervals/mapper/IntervalsActivityMapper` |
### V3 — Importers mix orchestration responsibility with mapping
`activity/importer/IntervalsActivityImporter.java` currently does: fetch-from-connector + map +
persist + import-streams + map-streams + map-map-stream. After V2 is resolved, importers must be
pure orchestration (connector → mapper → DAO). No field-level mapping logic inside importers.
### V4 — `IntervalsEnrichmentStatusEnum` in `activity.model` creates cross-connector coupling
`StravaActivity.intervalsEnrichmentStatus` (in `activity/model/StravaActivity.java`) uses
`IntervalsEnrichmentStatusEnum` defined in `activity/model/IntervalsEnrichmentStatusEnum.java`.
This means the Strava activity entity has direct knowledge of the Intervals connector.
The enum should move to `connector/strava/model/` or `connector/intervals/model/` (it tracks
the status of Intervals-enrichment on a Strava activity — it is a Strava connector concern).
### V5 — `AbstractEnumConverter` in `common.dao` couples common to domain enums
`common/dao/AbstractEnumConverter.java` contains inner static converter classes for all 4 domain
enums: `ActivityTypeEnum`, `SexEnum`, `IntervalsEnrichmentStatusEnum`, `StreamStatusEnum`.
The `common` package must not depend on domain-specific types. Each converter inner class should
live next to its enum definition (i.e. inside the domain or connector package that owns the enum).
## 3c. Checkstyle Rules (enforced via `checkstyle.xml`)
Run `$MVN checkstyle:check` after edits. Zero violations are expected. Config lives in
`peakup-backend/checkstyle.xml`; suppressions in `peakup-backend/suppressions.xml`.
**Key rules to follow when writing code:**
| Rule | Requirement |
|---|---|
| Indentation | 4 spaces, no tabs |
| Line length | Max 120 characters (imports/URLs exempt) |
| Braces | Opening `{` on same line (K&R); always use `{}` for `if`/`else`/`for`/`while`/`do` — no bare single-liners |
| Blank lines | Exactly 1 blank line between methods/constructors; no consecutive double blank lines |
| Star imports | Never use `.*` imports — always expand to explicit named imports |
| Import order | All non-`java.*` imports first (alphabetically), then `java.*` after a blank line, then `javax.*` after another blank line. Static imports at bottom. Within jakarta.*: `annotation` < `enterprise` < `inject` < `persistence` < `transaction` < `ws` |
| Trailing whitespace | None — not even on blank lines inside a class |
| Newline at EOF | Every file must end with a single newline |
| Naming | Types: `UpperCamelCase`; methods/fields/params: `lowerCamelCase`; constants: `UPPER_SNAKE_CASE`; packages: all lowercase |
| Modifier order | `public protected private abstract default static final transient volatile synchronized native strictfp` |
| One statement per line | No multiple declarations or statements on one line |
| String equality | Use `.equals()`, never `==` for String comparison |
**Suppressed intentionally** (do not add more suppressions without justification):
- `mapper/` packages: `WhitespaceAfter`, `LineLength`, `NeedBraces` — aligned `@Mapping` annotations and compact null-guards
- `client/` packages: `MemberName`, `AvoidStarImport`, `LineLength` — snake_case API DTO fields, JAX-RS wildcard imports
- `ActivityStreamDAO`, `ActivityMapStreamDAO`: `Indentation`, `MethodParamPad` — aligned try-with-resources and batch params
- `package-info.java`: `EmptyLineSeparator`, `NewlineAtEndOfFile`
# 4. Non-Negotiable Technical Rules
Java 21 exactly (enforcer will fail otherwise)
NullAway active on all com.peakup packages → @NonNull package-info mandatory
@Inject fields → @SuppressWarnings("NullAway.Init") when needed
Use existing Abstract* base classes
Never remove code without explicit justification + approval
Prefer small incremental changes
Tests must run against real DB (@QuarkusTest)
# Frontend
npm run dev
npm run build

View File

@@ -43,4 +43,4 @@ nb-configuration.xml
/.quarkus/cli/plugins/
# TLS Certificates
.certs/
/src/test/resources/skripts.sql
/src/test/resources/

View File

@@ -0,0 +1,30 @@
package com.peakup.common;
import javax.annotation.Nullable;
public class PerformanceMeasure {
private final String name;
private final Long startMs;
private @Nullable Long stopMs;
private PerformanceMeasure(String name, Long startMs) {
this.name = name;
this.startMs = startMs;
}
public static PerformanceMeasure start(String name) {
return new PerformanceMeasure(name, System.currentTimeMillis());
}
public Long stop() {
this.stopMs = System.currentTimeMillis();
return this.stopMs;
}
public String getPerformanceInfo() {
return name + " : " + ((stopMs == null ? stop() : stopMs.longValue()) - startMs.longValue()) + "ms";
}
}

View File

@@ -38,7 +38,9 @@ public abstract class AbstractPeakUpTest {
*/
EMPTY,
/**
* Startup-seeded: athletes 90/91 + connectors + full Q1 2026 activity data for athlete 90.
* Startup-seeded: athletes 90/91 + connectors + Jan–Feb 2026 activity and stream data
* for athlete 90, loaded from {@code testdata/seed_data.sql} by {@link TestDataInitializer}.
* Regenerate with {@code TestDataDumper} after schema changes.
*/
WITH_DATA
}

View File

@@ -1,34 +1,64 @@
package com.peakup;
import com.peakup.activity.importer.IntervalsActivityImporter;
import com.peakup.connector.intervals.client.IntervalsContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.peakup.common.PerformanceMeasure;
import io.agroal.api.AgroalDataSource;
import io.quarkus.runtime.StartupEvent;
import jakarta.annotation.Nullable;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.postgresql.copy.CopyManager;
import org.postgresql.core.BaseConnection;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
/**
* Seeds athlete 90's full Q1 2026 activity data (activities + streams) into the
* test database on application startup. Only active when {@code test.seed-data=true},
* which is set by {@link PostgresWithDataTestProfile}.
* Loads athlete 90's Jan–Feb 2026 activity and stream data from
* {@code testdata/seed_data.jsonl} into the test database on application startup.
* Only active when {@code test.seed-data=true}, which is set by
* {@link PostgresWithDataTestProfile}.
*
* <p>Called once per Quarkus application start — not per test method.
* Tests using {@code DbMode.WITH_DATA} must not call {@code flyway.clean()}.
* Tests using {@code DbMode.WITH_DATA} must not modify the shared dataset.
*
* <p>The JSONL format uses exact DB column names as keys (snake_case) plus a
* {@code _type} field holding the table name. Each line is inserted verbatim
* via a generated {@code INSERT INTO <table> (col, …) VALUES (?, …)} statement.
*
* <p>To regenerate {@code seed_data.jsonl}, run {@code TestDataDumper} from project root:
* <pre>
* JAVA_HOME=/home/ingo/.jdks/temurin-21.0.7 mvn -f peakup-tools/pom.xml \
* -s /home/ingo/.m2/settings_peak.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo \
* compile exec:java -Dexec.mainClass=com.peakup.tools.testdata.TestDataDumper \
* "-Dexec.args=90 2026-01-01 2026-02-28" -q
* </pre>
*/
@Slf4j
@ApplicationScoped
@SuppressWarnings("NullAway.Init")
public class TestDataInitializer {
private static final long ATHLETE_ID = 90L;
private static final String INTERVALS_ATHLETE_ID = "i157824";
private static final ObjectMapper MAPPER = JsonMapper.builder()
.addModule(new JavaTimeModule())
.build();
@ConfigProperty(name = "test.seed-data", defaultValue = "false")
boolean seedData;
@@ -36,34 +66,129 @@ public class TestDataInitializer {
@Inject
AgroalDataSource dataSource;
@Inject
IntervalsActivityImporter importer;
@Inject
IntervalsContext context;
void onStart(@Observes StartupEvent ev) throws Exception {
if (!seedData) return;
log.info("TestDataInitializer: resetting data and seeding athlete 90 Q1 2026 data");
log.info("TestDataInitializer: resetting schema and loading seed_data.jsonl");
truncateAllTables();
seedAthletes();
PerformanceMeasure pm = PerformanceMeasure.start("loadSeedData");
loadSeedData();
log.info("Performance measure: {}", pm.getPerformanceInfo());
log.info("TestDataInitializer: done");
}
context.setAthleteId(ATHLETE_ID);
try {
importer.importActivities(ATHLETE_ID, INTERVALS_ATHLETE_ID,
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31));
importer.importPendingStreams(ATHLETE_ID);
} finally {
context.clear();
// -----------------------------------------------------------------------
// Seed helpers
// -----------------------------------------------------------------------
private void loadSeedData() throws Exception {
var url = getClass().getClassLoader().getResource("testdata/seed_data.jsonl");
if (url == null) {
log.warn("TestDataInitializer: seed_data.jsonl not found on classpath, skipping");
return;
}
log.info("TestDataInitializer: done");
// Buffer rows per table (preserving insertion order) so we can compute
// the union of all column names before issuing COPY — different rows of
// the same table can have different fields in the JSONL dump.
Map<String, List<ObjectNode>> byTable = new LinkedHashMap<>();
try (var reader = new BufferedReader(new InputStreamReader(url.openStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) continue;
ObjectNode obj = (ObjectNode) MAPPER.readTree(line);
String type = obj.get("_type").asText();
if ("_meta".equals(type)) continue;
obj.remove("_type");
byTable.computeIfAbsent(type, t -> new ArrayList<>()).add(obj);
}
}
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
CopyManager copy = new CopyManager(conn.unwrap(BaseConnection.class));
for (Map.Entry<String, List<ObjectNode>> entry : byTable.entrySet()) {
String table = entry.getKey();
List<ObjectNode> rows = entry.getValue();
// Union of all columns across every row, preserving first-seen order.
LinkedHashSet<String> colSet = new LinkedHashSet<>();
for (ObjectNode row : rows) {
row.fieldNames().forEachRemaining(colSet::add);
}
List<String> cols = new ArrayList<>(colSet);
StringBuilder buf = new StringBuilder();
for (ObjectNode row : rows) {
for (int i = 0; i < cols.size(); i++) {
if (i > 0) buf.append('\t');
@Nullable JsonNode val = row.get(cols.get(i));
appendCopyValue(buf, val);
}
buf.append('\n');
}
flushCopy(copy, table, cols, buf);
}
conn.commit();
}
}
private static void flushCopy(CopyManager copy, String table, List<String> cols,
StringBuilder buf) throws Exception {
String sql = "COPY " + table + " (" + String.join(", ", cols) + ") FROM STDIN";
copy.copyIn(sql, new StringReader(buf.toString()));
}
private static void appendCopyValue(StringBuilder buf, @Nullable JsonNode val) {
if (val == null || val.isNull()) {
buf.append("\\N");
} else if (val.isBoolean()) {
buf.append(val.booleanValue() ? 't' : 'f');
} else if (val.isNumber()) {
buf.append(val.asText());
} else if (val.isArray()) {
ArrayNode arr = (ArrayNode) val;
buf.append('{');
for (int i = 0; i < arr.size(); i++) {
if (i > 0) buf.append(',');
JsonNode elem = arr.get(i);
if (elem.isNumber()) {
buf.append(elem.asText());
} else {
buf.append('"');
appendEscaped(buf, elem.asText());
buf.append('"');
}
}
buf.append('}');
} else {
appendEscaped(buf, val.asText());
}
}
private static void appendEscaped(StringBuilder buf, String s) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '\\' -> buf.append("\\\\");
case '\t' -> buf.append("\\t");
case '\n' -> buf.append("\\n");
case '\r' -> buf.append("\\r");
default -> buf.append(c);
}
}
}
private void truncateAllTables() throws Exception {
try (Connection conn = dataSource.getConnection(); Statement s = conn.createStatement()) {
// Truncate all user tables dynamically — flyway.clean() is incompatible with
// TimescaleDB (dropping the extension breaks subsequent connections).
// CASCADE handles FK ordering; flyway_schema_history is preserved.
s.execute("""
DO $$
DECLARE r record;
@@ -82,6 +207,8 @@ public class TestDataInitializer {
private void seedAthletes() throws Exception {
try (Connection conn = dataSource.getConnection(); Statement s = conn.createStatement()) {
// Athlete 90 — Ingo
s.execute("""
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
VALUES (90, 'Ingo', 'Schnabel', 'ingo', 93.0, now(), now())
@@ -102,6 +229,8 @@ public class TestDataInitializer {
(athlete_id, api_key, intervals_athlete_id, created_at, modified_at)
VALUES (90, '38he2acw5nakpy749jabprdtz', 'i157824', now(), now())
""");
// Athlete 91 — Mirja
s.execute("""
INSERT INTO athlete (id, first_name, last_name, login, weight, created_at, modified_at)
VALUES (91, 'Mirja', 'Schnabel', 'mirja', 50.0, now(), now())

View File

@@ -0,0 +1,55 @@
package com.peakup.activity;
import com.peakup.AbstractPeakUpTest;
import com.peakup.PostgresWithDataTestProfile;
import com.peakup.activity.dao.ActivityStreamDAO;
import com.peakup.activity.dao.IntervalsActivityDAO;
import com.peakup.activity.model.IntervalsActivity;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import java.util.List;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@QuarkusTest
@TestProfile(PostgresWithDataTestProfile.class)
class ActivityTest extends AbstractPeakUpTest {
private static final long ATHLETE_ID = 90L;
private static final int EXPECTED_ACTIVITY_COUNT = 4;
@Override
protected DbMode dbMode() {
return DbMode.WITH_DATA;
}
@Inject
@SuppressWarnings("NullAway.Init")
IntervalsActivityDAO intervalsActivityDAO;
@Inject
@SuppressWarnings("NullAway.Init")
ActivityStreamDAO activityStreamDAO;
@Test
@Transactional
void allActivitiesHaveStreams() throws SQLException {
List<IntervalsActivity> activities = intervalsActivityDAO.getByAthleteId(ATHLETE_ID, 0, 100);
assertEquals(EXPECTED_ACTIVITY_COUNT, activities.size(),
"seed data must contain exactly " + EXPECTED_ACTIVITY_COUNT + " activities for athlete " + ATHLETE_ID);
for (IntervalsActivity activity : activities) {
long activityId = Objects.requireNonNull(activity.getId());
assertTrue(activityStreamDAO.hasStreams(activityId),
"activity " + activityId + " (" + activity.getName() + ") must have stream points");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,4 +14,40 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.9</version>
</dependency>
<dependency>
<groupId>com.peakup</groupId>
<artifactId>peakup-backend</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.20.1</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.20.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<workingDirectory>${project.basedir}/..</workingDirectory>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,151 @@
package com.peakup.tools.testdata;
import com.peakup.common.dao.PersistableEnum;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Transient;
import java.lang.reflect.Field;
import java.sql.Array;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
/**
* Maps a {@link ResultSet} row to a domain model instance via reflection.
*
* <p>Column-to-field resolution (per field, walking the full class hierarchy):
* <ul>
* <li>If the field carries {@code @Column(name="…")}, that name is used (lowercased).
* <li>Otherwise the field name is converted from camelCase to snake_case.
* </ul>
*
* <p>Fields annotated with {@code @ElementCollection} or {@code @Transient} are skipped —
* they have no backing column in the table being queried.
*
* <p>Type conversions handled:
* <ul>
* <li>{@code Timestamp} → {@code Instant}
* <li>JDBC {@code Array} → {@code int[]} or {@code String[]}
* <li>{@code Integer} → {@code PersistableEnum} subtype via {@code getEnumByKey()}
* <li>{@code Number} → {@code Long}, {@code Integer}, {@code Double} (widening/narrowing)
* <li>{@code Boolean} passthrough; integer 0/1 fallback
* </ul>
*/
public final class ResultSetMapper {
private ResultSetMapper() {
}
public static <T> T map(ResultSet rs, Class<T> clazz) throws Exception {
Map<String, Object> columns = buildColumnMap(rs);
T instance = newInstance(clazz);
Class<?> cursor = clazz;
while (cursor != null && cursor != Object.class) {
for (Field field : cursor.getDeclaredFields()) {
if (shouldSkip(field)) {
continue;
}
Object raw = columns.get(resolveColumnName(field));
if (raw == null) {
continue;
}
field.setAccessible(true);
field.set(instance, convert(raw, field));
}
cursor = cursor.getSuperclass();
}
return instance;
}
// -----------------------------------------------------------------------
private static <T> T newInstance(Class<T> clazz) throws Exception {
var ctor = clazz.getDeclaredConstructor();
ctor.setAccessible(true);
return ctor.newInstance();
}
private static Map<String, Object> buildColumnMap(ResultSet rs) throws Exception {
ResultSetMetaData meta = rs.getMetaData();
int n = meta.getColumnCount();
Map<String, Object> map = new HashMap<>(n * 2);
for (int i = 1; i <= n; i++) {
map.put(meta.getColumnName(i).toLowerCase(), rs.getObject(i));
}
return map;
}
private static boolean shouldSkip(Field field) {
return field.isAnnotationPresent(ElementCollection.class)
|| field.isAnnotationPresent(Transient.class)
|| field.isSynthetic();
}
private static String resolveColumnName(Field field) {
Column col = field.getAnnotation(Column.class);
if (col != null && !col.name().isEmpty()) {
return col.name().toLowerCase();
}
return camelToSnake(field.getName());
}
@SuppressWarnings({"unchecked", "rawtypes"})
private static Object convert(Object raw, Field field) throws Exception {
Class<?> type = field.getType();
if (type == Instant.class) {
return ((Timestamp) raw).toInstant();
}
if (type.isEnum() && PersistableEnum.class.isAssignableFrom(type)) {
int key = ((Number) raw).intValue();
return PersistableEnum.getEnumByKey(key, (PersistableEnum[]) type.getEnumConstants());
}
if (type == int[].class && raw instanceof Array arr) {
Object javaArr = arr.getArray();
if (javaArr instanceof int[]) {
return javaArr;
}
Integer[] boxed = (Integer[]) javaArr;
int[] result = new int[boxed.length];
for (int i = 0; i < boxed.length; i++) {
result[i] = boxed[i];
}
return result;
}
if (type == String[].class && raw instanceof Array arr) {
return (String[]) arr.getArray();
}
if (type == Long.class || type == long.class) {
return ((Number) raw).longValue();
}
if (type == Integer.class || type == int.class) {
return ((Number) raw).intValue();
}
if (type == Double.class || type == double.class) {
return ((Number) raw).doubleValue();
}
if (type == Boolean.class || type == boolean.class) {
return raw instanceof Boolean ? raw : ((Number) raw).intValue() != 0;
}
if (type == String.class) {
return raw.toString();
}
return raw;
}
private static String camelToSnake(String name) {
StringBuilder sb = new StringBuilder(name.length() + 4);
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
if (Character.isUpperCase(c) && i > 0) {
sb.append('_');
}
sb.append(Character.toLowerCase(c));
}
return sb.toString();
}
}

View File

@@ -0,0 +1,245 @@
package com.peakup.tools.testdata;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.BufferedWriter;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Array;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.Timestamp;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.stream.Collectors;
/**
* Dumps intervals_activity, activity_stream_point and activity_map_stream_point
* for a given athlete and date range into a JSONL file suitable for loading
* in {@code DbMode.WITH_DATA} tests via {@code TestDataInitializer}.
*
* <p>Each line is a JSON object with a {@code _type} field (table name) plus all
* non-null columns serialized under their exact DB column names (snake_case).
* The first line is a {@code _meta} record with dump parameters.
*
* <p>Run from project root:
* <pre>
* JAVA_HOME=/home/ingo/.jdks/temurin-21.0.7 mvn -f peakup-tools/pom.xml \
* -s /home/ingo/.m2/settings_peak.xml -Dmaven.repo.local=/home/ingo/.m2/peak-repo \
* compile exec:java -Dexec.mainClass=com.peakup.tools.testdata.TestDataDumper \
* "-Dexec.args=90 2026-01-01 2026-02-28" -q
* </pre>
*
* <p>Args (all optional — defaults shown): {@code <athleteId=90> <from=2026-01-01> <to=2026-02-28>}
* The {@code to} date is inclusive (activities before midnight of the following day are included).
*/
public class TestDataDumper {
private static final String JDBC_URL = "jdbc:postgresql://localhost:5432/peakup";
private static final String DB_USER = "peakup";
private static final String DB_PASSWORD = "peakup";
private static final String OUTPUT_PATH =
"peakup-backend/src/test/resources/testdata/seed_data.jsonl";
private static final ObjectMapper MAPPER = JsonMapper.builder()
.addModule(new JavaTimeModule())
.defaultPropertyInclusion(JsonInclude.Value.construct(
JsonInclude.Include.NON_NULL, JsonInclude.Include.NON_NULL))
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
public static void main(String[] args) throws Exception {
long athleteId = 90L;
LocalDate from = LocalDate.of(2026, 1, 1);
LocalDate to = LocalDate.of(2026, 2, 28);
if (args.length >= 3) {
athleteId = Long.parseLong(args[0]);
from = LocalDate.parse(args[1]);
to = LocalDate.parse(args[2]);
}
Path output = Paths.get(System.getProperty("user.dir")).resolve(OUTPUT_PATH);
System.out.println("Dumping: athlete=" + athleteId + " from=" + from + " to=" + to);
System.out.println("Output: " + output.toAbsolutePath());
try (Connection conn = DriverManager.getConnection(JDBC_URL, DB_USER, DB_PASSWORD);
BufferedWriter writer = Files.newBufferedWriter(output, StandardCharsets.UTF_8)) {
writeMeta(writer, athleteId, from, to);
List<Long> activityIds = dumpActivities(writer, conn, athleteId, from, to);
System.out.println(" intervals_activity: " + activityIds.size() + " rows");
if (activityIds.isEmpty()) {
System.out.println("No activities found — output file contains no data rows.");
return;
}
int streamRows = dumpStreamTable(writer, conn, "activity_stream_point", activityIds);
System.out.println(" activity_stream_point: " + streamRows + " rows");
int mapRows = dumpStreamTable(writer, conn, "activity_map_stream_point", activityIds);
System.out.println(" activity_map_stream_point: " + mapRows + " rows");
}
System.out.println("Done.");
}
// -----------------------------------------------------------------------
// Table dumpers
// -----------------------------------------------------------------------
private static List<Long> dumpActivities(BufferedWriter writer, Connection conn,
long athleteId, LocalDate from, LocalDate to) throws Exception {
String query = "SELECT DISTINCT ON (sport_type) * FROM intervals_activity"
+ " WHERE athlete_id = ?"
+ " AND start_time >= ?"
+ " AND start_time < ?"
+ " ORDER BY sport_type, start_time DESC";
List<Long> ids = new ArrayList<>();
try (PreparedStatement ps = conn.prepareStatement(query)) {
ps.setLong(1, athleteId);
ps.setTimestamp(2, Timestamp.from(from.atStartOfDay(ZoneOffset.UTC).toInstant()));
ps.setTimestamp(3, Timestamp.from(to.plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
ObjectNode node = rowToNode(rs, "intervals_activity");
if (node.has("id")) {
ids.add(node.get("id").asLong());
}
writeLine(writer, MAPPER.writeValueAsString(node));
}
}
}
return ids;
}
private static int dumpStreamTable(BufferedWriter writer, Connection conn,
String table, List<Long> activityIds) throws Exception {
String inClause = activityIds.stream()
.map(String::valueOf)
.collect(Collectors.joining(","));
String query = "SELECT * FROM " + table
+ " WHERE activity_id IN (" + inClause + ")"
+ " ORDER BY activity_id, time";
int count = 0;
try (var stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(query)) {
while (rs.next()) {
writeLine(writer, MAPPER.writeValueAsString(rowToNode(rs, table)));
count++;
}
}
return count;
}
// -----------------------------------------------------------------------
// Raw ResultSet → JSON
// -----------------------------------------------------------------------
private static ObjectNode rowToNode(ResultSet rs, String tableName) throws Exception {
ObjectNode node = MAPPER.createObjectNode();
node.put("_type", tableName);
ResultSetMetaData meta = rs.getMetaData();
int n = meta.getColumnCount();
for (int i = 1; i <= n; i++) {
String col = meta.getColumnName(i).toLowerCase();
Object val = rs.getObject(i);
if (val != null) {
putValue(node, col, val);
}
}
return node;
}
private static void putValue(ObjectNode node, String col, Object val) throws Exception {
if (val instanceof Boolean b) {
node.put(col, b);
} else if (val instanceof Integer i) {
node.put(col, i);
} else if (val instanceof Long l) {
node.put(col, l);
} else if (val instanceof Float f) {
node.put(col, f);
} else if (val instanceof Double d) {
node.put(col, d);
} else if (val instanceof BigDecimal bd) {
node.put(col, bd.doubleValue());
} else if (val instanceof Number num) {
node.put(col, num.longValue());
} else if (val instanceof Timestamp ts) {
node.put(col, ts.toInstant().toString());
} else if (val instanceof byte[] bytes) {
node.put(col, Base64.getEncoder().encodeToString(bytes));
} else if (val instanceof Array arr) {
ArrayNode arrNode = node.putArray(col);
Object javaArr = arr.getArray();
if (javaArr instanceof Integer[] ints) {
for (int v : ints) {
arrNode.add(v);
}
} else if (javaArr instanceof Long[] longs) {
for (long v : longs) {
arrNode.add(v);
}
} else if (javaArr instanceof String[] strs) {
for (String s : strs) {
arrNode.add(s);
}
} else if (javaArr instanceof Object[] objs) {
for (Object o : objs) {
arrNode.add(o == null ? null : o.toString());
}
}
} else {
node.put(col, val.toString());
}
}
// -----------------------------------------------------------------------
// JSONL helpers
// -----------------------------------------------------------------------
private static void writeMeta(BufferedWriter writer, long athleteId,
LocalDate from, LocalDate to) throws Exception {
String dumpedAt = ZonedDateTime.now(ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
ObjectNode meta = MAPPER.createObjectNode();
meta.put("_type", "_meta");
meta.put("athleteId", athleteId);
meta.put("from", from.toString());
meta.put("to", to.toString());
meta.put("dumpedAt", dumpedAt);
writeLine(writer, MAPPER.writeValueAsString(meta));
}
private static void writeLine(BufferedWriter writer, String line) throws Exception {
writer.write(line);
writer.newLine();
}
}

17
pom.xml Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.peakup</groupId>
<artifactId>peakup-parent</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>peakup-backend</module>
<module>peakup-tools</module>
</modules>
</project>