Improve tests
This commit is contained in:
48
.claudeignore
Normal file
48
.claudeignore
Normal 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
1
.gitignore
vendored
@@ -24,3 +24,4 @@
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
/peakup-backend/src/test/resources/
|
||||
|
||||
48
.idea/compiler.xml
generated
48
.idea/compiler.xml
generated
@@ -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
502
CLAUDE.md
@@ -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
|
||||
2
peakup-backend/.gitignore
vendored
2
peakup-backend/.gitignore
vendored
@@ -43,4 +43,4 @@ nb-configuration.xml
|
||||
/.quarkus/cli/plugins/
|
||||
# TLS Certificates
|
||||
.certs/
|
||||
/src/test/resources/skripts.sql
|
||||
/src/test/resources/
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
51483
peakup-backend/src/test/resources/testdata/seed_data.jsonl
vendored
Normal file
51483
peakup-backend/src/test/resources/testdata/seed_data.jsonl
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
151
peakup-tools/src/main/java/com/peakup/tools/testdata/ResultSetMapper.java
vendored
Normal file
151
peakup-tools/src/main/java/com/peakup/tools/testdata/ResultSetMapper.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
245
peakup-tools/src/main/java/com/peakup/tools/testdata/TestDataDumper.java
vendored
Normal file
245
peakup-tools/src/main/java/com/peakup/tools/testdata/TestDataDumper.java
vendored
Normal 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
17
pom.xml
Normal 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>
|
||||
Reference in New Issue
Block a user