Schema export

This commit is contained in:
2026-04-03 23:38:04 +02:00
parent 5fac1eee29
commit 5175f47853
22 changed files with 312 additions and 51986 deletions

View File

@@ -1,17 +0,0 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "FILE=$(jq -r '.tool_input.file_path'); echo \"$FILE\" | grep -q 'peakup-backend/src/main/java/com/peakup/.*\\.java$' || exit 0; cd /home/ingo/deve/peakUp && 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 2>/dev/null || true",
"timeout": 60,
"statusMessage": "Regenerating package-info files..."
}
]
}
]
}
}

View File

@@ -1,48 +1,77 @@
# --- IDE & OS Specific ---
**/.idea/
**/.vscode/
**/.settings/
**/.classpath/
**/.project/
**/*.iml
.DS_Store
Thumbs.db
**/testdata/**
# =============================================
# .claudeignore – Optimierte Version für peakUp (Pro Plan)
# Ziel: Minimale automatische Datei-Reads + niedrige Burn-Rate
# =============================================
# --- Frontend Tooling & Reports ---
# === Build & Target Ordner (sehr wichtig!) ===
**/target/
**/dist/
**/build/
**.next/
**.nuxt/
# === Node.js / Frontend Junk ===
peakup-frontend/node_modules/
peakup-frontend/dist/
peakup-frontend/playwright-report/
peakup-frontend/test-results/
peakup-frontend/.cache/
**/dist/**
**/node_modules/**
peakup-frontend/e2e/
peakup-frontend/*.log
peakup-frontend/package-lock.json
peakup-frontend/pnpm-lock.yaml
peakup-frontend/yarn.lock
# --- Temporary & Log Files ---
*.log
**/tmp/
**/.temp/
# === Maven & Java generierte Dateien ===
**/mvnw
**/mvnw.cmd
**/*.iml # IntelliJ Projekt-Dateien
**/.idea/
**/*.class
**/*.jar
**/*.war
**/*.ear
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
*.sql
*.json
*.jsonl
# === Logs, Cache, Temp ===
**/*.log
logs/
log/
tmp/
temp/
.cache/
**/.cache/
# --- Infrastructure & Secrets (Safety) ---
*.pem
*.key
.env*
! .env.example
# === Test & Coverage ===
**/coverage/
**/playwright-report/
**/test-results/
**/__tests__/
**/e2e/
# --- Binary Assets ---
# Logo bleibt im Repo, wird aber für Code-Analysen ignoriert
# === Resources & große/nicht relevante Dateien ===
resources/Logo_peakUp.png
resources/*.json # strava-api.json & intervals.icu.json nur bei Bedarf lesen
resources/*.sql # SQL-Skripte nur explizit mit @ laden
# === Lockfiles ===
**/pom.xml.asc
**/*.lock
package-lock.json
yarn.lock
pnpm-lock.yaml
bun.lockb
# === Dokumentation & Deploy (nicht ständig lesen) ===
doc/
deploy.sh
deploy-frontend.sh
README.md
**/README.md
# === Sonstiges unnötiges ===
**/.DS_Store
**/*.tmp
**/*.temp
**/*.bak
**/*.swp
**/*.swo
# --- Wrapper & Scripts (Optional, falls Claude sie nicht editieren soll) ---
# peakup-backend/mvnw
# peakup-backend/mvnw.cmd

15
.idea/checkstyle-idea.xml generated Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CheckStyle-IDEA" serialisationVersion="2">
<checkstyleVersion>13.4.0</checkstyleVersion>
<scanScope>JavaOnly</scanScope>
<option name="thirdPartyClasspath" />
<option name="activeLocationIds" />
<option name="locations">
<list>
<ConfigurationLocation id="bundled-sun-checks" type="BUNDLED" scope="All" description="Sun Checks">(bundled)</ConfigurationLocation>
<ConfigurationLocation id="bundled-google-checks" type="BUNDLED" scope="All" description="Google Checks">(bundled)</ConfigurationLocation>
</list>
</option>
</component>
</project>

2
.idea/compiler.xml generated
View File

@@ -70,7 +70,7 @@
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="peakup-backend" options="-XDcompilePolicy=simple -XDshould-stop.ifError=FLOW &quot;-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup -XepOpt:NullAway:TreatGeneratedAsUnannotated=true -XepOpt:NullAway:CheckOptionalEmptiness=true -Xep:MissingSummary:OFF -Xep:MissingOverride:ERROR -Xep:DefaultCharset:ERROR -Xep:StringCaseLocaleUsage:ERROR -Xep:MathAbsoluteRandom:ERROR -Xep:UnnecessaryDefaultInEnumSwitch:WARN -Xep:OperatorPrecedence:WARN&quot; -Xpkginfo:always -J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED" />
<module name="peakup-backend" options="-XDcompilePolicy=simple -XDshould-stop.ifError=FLOW &quot;-Xplugin:ErrorProne&#10; -Xep:NullAway:ERROR&#10; -XepOpt:NullAway:AnnotatedPackages=com.peakup&#10; -Xep:MissingSummary:OFF&#10; -Xep:MissingOverride:ERROR&#10; -Xep:DefaultCharset:ERROR&#10; -Xep:StringCaseLocaleUsage:ERROR&#10; -Xep:MathAbsoluteRandom:ERROR&#10; -Xep:UnnecessaryDefaultInEnumSwitch:ERROR&#10; -Xep:OperatorPrecedence:ERROR&#10; -XepOpt:NullAway:TreatGeneratedAsUnannotated=true&#10; -XepOpt:NullAway:CheckOptionalEmptiness=true&quot; -Xpkginfo:always -J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED -J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED -Xmaxerrs 1000" />
</option>
</component>
</project>

View File

@@ -25,6 +25,7 @@
<option name="m_ignoreImmediatelyReturnedVariables" value="false" />
<option name="m_ignoreAnnotatedVariables" value="false" />
</inspection_tool>
<inspection_tool class="UnnecessaryUnboxing" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="UnusedProperty" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="VulnerableLibrariesLocal" enabled="false" level="WARNING" enabled_by_default="false">
<option name="isIgnoringEnabled" value="true" />

116
CLAUDE.md
View File

@@ -1,90 +1,76 @@
# CLAUDE.md — Project Guidelines
# CLAUDE.md – peakUp Project Guidelines
**Last updated:** 2026-03-29
**Status:** Single source of truth. 100% compliance required.
**Last updated:** 2026-04-03 | Single source of truth. Strict compliance required.
## 1. Core Operating Rules
## 1. Core Rules
- 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?"
- Never assume. Always read files and structure first using tools.
- Use terminal commands (`ls`, `rg`, `mvn`, `npm`) to validate before and after changes.
- Remove unreachable/dead code immediately (ask user first if unsure).
- For trivial tasks (renames, typos, small fixes): Ask "Trivial? Direct edit or full workflow?"
## 2. Mandatory Workflow (4-Phase)
## 2. Mandatory 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.
1. **PROPOSAL** – Clear plan + before/after diff + impact. Wait for `OK PROPOSAL`.
2. **VALIDATE** – Brutal self-critique (NullAway, layering, regressions). Wait for `OK VALIDATE`.
3. **IMPLEMENT** – One unit at a time. Show exact diff.
4. **VERIFICATION** – Run relevant tests. End with suggested commit message.
## 3. Project Structure & Conventions
## 3. Project Structure
### Modules
- **peakup-backend**: Quarkus (Java 21), Hibernate, PostgreSQL/TimescaleDB
- **peakup-frontend**: Vite + React 18 + TypeScript + Tailwind + shadcn/ui
- **peakup-tools**: Development generators
- **peakup-model**: Shared domain models
- `peakup-backend`: Quarkus (Java 21), Hibernate, PostgreSQL/TimescaleDB.
- `peakup-frontend`: Vite, React 18, TS, Tailwind, shadcn/ui.
- `peakup-tools`: Dev-time generators.
**Backend Architecture (com.peakup)**:
### Backend Architecture (com.peakup)
- Domain slices (`activity`, `athlete`): `model` → `dao` → `service`
- Connectors: `connector/<name>` with `model` → `dao` → `service` → `client` → `mapper`
- All external mapping logic **must** live in `connector/<name>/mapper/`
- **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`.
## 4. Important Technical Rules
### Base Classes & Patterns
**Java / Backend:**
- `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`).
- Java 21 + Quarkus
- Strict NullAway compliance (`@Nullable`, `@SuppressWarnings("NullAway.Init")` only when necessary)
- Lombok: `@Data`, `@NoArgsConstructor`, `@AllArgsConstructor`, `@Slf4j`
- Checkstyle: 4 spaces, 120 char line limit, K&R braces, no star imports
- Use `AbstractEntity` and `AbstractTimestampedEntity`
- TimescaleDB hypertables via `AbstractTimescaleEntity`
### Integration Details
**Frontend:**
- **Strava:** OAuth2 with token refresh in `StravaConnectorService`.
- **Intervals.icu:** Basic Auth (API_KEY).
- **Scheduler:** `ActivityImportScheduler` (every 20m) orchestrates all importers.
- Light mode only (`#FFFFFF`)
- Accent: red-to-orange gradient (`from-red-500 to-orange-500`) only for CTAs and charts
- Use TanStack Query v5 + TanStack Table v8
- Prefer Server Components / minimal `useEffect` for data fetching
- Charts: Recharts (always responsive)
### Database & Code Quality
## 5. Design Violations to Fix When Touching
- **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`.
- Move mapping logic from importers into dedicated mappers
- Move `ActivityConnector` implementations into `connector/<name>/`
- Extract `IntervalsEnrichmentStatusEnum` and `AbstractEnumConverter` properly
## 4. Frontend (Clean Geometric Performance)
## 6. Useful Commands
- **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:**
## 5. Design Violations (Fix when touching)
- `$MVN compile`
- `$MVN quarkus:dev`
- `$MVN test -Dtest=ClassName`
- **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.
**Frontend:**
## 6. Technical Rules & Checkstyle
- `npm run dev`
- `npm run build`
- **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`.
**Tools:**
## 7. Commands
- PackageInfoGenerator (in `peakup-tools`)
```bash
# Backend
$MVN compile
$MVN quarkus:dev
$MVN test -Dtest=ClassName
---
# Package Generator (from root)
JAVA_HOME=... mvn -f peakup-tools/pom.xml ... compile
... com.peakup.tools.generator.PackageInfoGenerator
# Frontend
npm run dev
npm run build
**Style:** Be concise. Show only changed code + short explanation. Ask before large refactors.

View File

@@ -0,0 +1,9 @@
# =============================================
# .claudeignore – Optimierte Version für peakUp (Pro Plan)
# Ziel: Minimale automatische Datei-Reads + niedrige Burn-Rate
# =============================================
target/
*.class
*.iml
mvnw
mvnw.cmd

View File

@@ -1,5 +1,5 @@
<?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"
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.peakup</groupId>
@@ -118,7 +118,24 @@
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-generator</artifactId>
<version>5.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jakarta-validation</artifactId>
<version>5.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.github.victools</groupId>
<artifactId>jsonschema-module-jackson</artifactId>
<version>5.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
@@ -166,11 +183,27 @@
<configuration>
<release>${maven.compiler.release}</release>
<showWarnings>true</showWarnings>
<fork>true</fork>
<showDeprecation>true</showDeprecation>
<!--fork>true</fork-->
<forceLegacyJavacApi>true</forceLegacyJavacApi>
<compilerArgs>
<arg>-XDcompilePolicy=simple</arg>
<arg>-XDshould-stop.ifError=FLOW</arg>
<arg>-Xplugin:ErrorProne -Xep:NullAway:ERROR -XepOpt:NullAway:AnnotatedPackages=com.peakup -XepOpt:NullAway:TreatGeneratedAsUnannotated=true -XepOpt:NullAway:CheckOptionalEmptiness=true -Xep:MissingSummary:OFF -Xep:MissingOverride:ERROR -Xep:DefaultCharset:ERROR -Xep:StringCaseLocaleUsage:ERROR -Xep:MathAbsoluteRandom:ERROR -Xep:UnnecessaryDefaultInEnumSwitch:WARN -Xep:OperatorPrecedence:WARN</arg>
<arg>
-Xplugin:ErrorProne
-Xep:NullAway:ERROR
-XepOpt:NullAway:AnnotatedPackages=com.peakup
-Xep:MissingSummary:OFF
-Xep:MissingOverride:ERROR
-Xep:DefaultCharset:ERROR
-Xep:StringCaseLocaleUsage:ERROR
-Xep:MathAbsoluteRandom:ERROR
-Xep:UnnecessaryDefaultInEnumSwitch:ERROR
-Xep:OperatorPrecedence:ERROR
-XepOpt:NullAway:TreatGeneratedAsUnannotated=true
-XepOpt:NullAway:CheckOptionalEmptiness=true
</arg>
<arg>-Xpkginfo:always</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
@@ -182,6 +215,8 @@
<arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
<arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
<arg>-Xmaxerrs</arg>
<arg>1000</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>

View File

@@ -1,5 +1,6 @@
package com.peakup.common.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.GeneratedValue;
@@ -21,6 +22,7 @@ public abstract class AbstractEntity {
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "global_seq")
@SequenceGenerator(name = "global_seq", sequenceName = "global_id_seq", allocationSize = 1)
@Column(name = "ID")
@JsonIgnore
private @Nullable Long id;
}

View File

@@ -1,5 +1,6 @@
package com.peakup.common.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist;
@@ -19,9 +20,11 @@ import java.time.Instant;
public abstract class AbstractTimestampedEntity extends AbstractEntity {
@Column(nullable = false)
@JsonIgnore
private Instant modifiedAt = Instant.now();
@Column(nullable = false)
@JsonIgnore
private Instant createdAt = Instant.now();
@PrePersist

View File

@@ -1,7 +1,6 @@
package com.peakup.workout;
import com.peakup.workout.model.WeekPlan;
import com.peakup.workout.model.WeekPlanImportRequest;
import com.peakup.workout.model.Workout;
import com.peakup.workout.service.WeekPlanImportService;
import com.peakup.workout.service.WeekPlanService;
@@ -127,7 +126,7 @@ public class WeekPlanResource {
@Path("/import/schema")
@Produces(MediaType.APPLICATION_JSON)
public Response getImportSchema() {
return serveClasspathResource("weekplan-import.schema.json", MediaType.APPLICATION_JSON);
return Response.ok(weekPlanService.generateImportSchema()).build();
}
@GET
@@ -150,24 +149,36 @@ public class WeekPlanResource {
}
}
@GET
@Path("/{id}/export")
public Response exportPlan(@PathParam("id") long id) {
try {
WeekPlan weekPlan = weekPlanService.getByIdWithWorkouts(id);
return Response.ok(weekPlan).build();
} catch (NoResultException e) {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
@POST
@Path("/import")
public Response importPlan(@QueryParam("athleteId") @Nullable Long athleteId,
WeekPlanImportRequest req) {
WeekPlan weekPlan) {
if (athleteId == null) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("athleteId query parameter is required").build();
}
List<String> errors = weekPlanImportService.validate(req);
List<String> errors = weekPlanImportService.validate(weekPlan);
if (!errors.isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST).entity(errors).build();
}
WeekPlan existing = weekPlanService.findByAthleteAndWeek(athleteId, req.getYear(), req.getCalendarWeek());
WeekPlan existing = weekPlanService.findByAthleteAndWeek(
athleteId, weekPlan.getYear(), weekPlan.getCalendarWeek());
if (existing != null) {
return Response.status(Response.Status.CONFLICT)
.entity("A plan already exists for this week").build();
}
WeekPlan created = weekPlanImportService.mapAndCreate(athleteId, req);
WeekPlan created = weekPlanImportService.create(athleteId, weekPlan);
return Response.status(Response.Status.CREATED).entity(created).build();
}
}

View File

@@ -11,6 +11,7 @@ public enum IntensityReferenceEnum implements PersistableEnum<IntensityReference
;
private final int key;
@SuppressWarnings({"FieldCanBeLocal", "unused"})
private final IntensityMetric intensityMetric;
IntensityReferenceEnum(int key, IntensityMetric intensityMetric) {

View File

@@ -1,5 +1,6 @@
package com.peakup.workout.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.peakup.common.model.AbstractTimestampedEntity;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
@@ -18,6 +19,7 @@ import java.util.List;
uniqueConstraints = @UniqueConstraint(columnNames = {"athlete_id", "year", "calendar_week"}))
@Data
@EqualsAndHashCode(callSuper = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class WeekPlan extends AbstractTimestampedEntity {
@Column(nullable = false)

View File

@@ -1,42 +0,0 @@
package com.peakup.workout.model;
import jakarta.annotation.Nullable;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data
public class WeekPlanImportRequest {
public int year;
public int calendarWeek;
public @Nullable String weekLabel;
public @Nullable String weekDescription;
public List<WorkoutImportItem> workouts = new ArrayList<>();
@Data
public static class WorkoutImportItem {
public @Nullable String title;
public @Nullable String day;
public @Nullable String time;
public @Nullable String type;
public @Nullable Integer estimatedTimeSeconds;
public @Nullable Integer estimatedTss;
public @Nullable String description;
public @Nullable String statusNote;
public List<StepImportItem> steps = new ArrayList<>();
}
@Data
public static class StepImportItem {
public @Nullable String label;
public @Nullable String stepType;
public int durationSeconds;
public @Nullable String description;
public @Nullable String intensityReference;
public @Nullable Integer targetValuePct;
public @Nullable Integer targetValuePctStart;
public @Nullable Integer targetValuePctEnd;
}
}

View File

@@ -1,5 +1,6 @@
package com.peakup.workout.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.peakup.common.model.AbstractEntity;
import com.peakup.common.model.DependentEntity;
import jakarta.annotation.Nullable;
@@ -18,47 +19,38 @@ import java.util.List;
@Table(name = "workout")
@Data
@EqualsAndHashCode(callSuper = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Workout extends AbstractEntity implements DependentEntity {
@Column(name = "week_plan_id", nullable = false)
public long weekPlanId;
@Override
public long getFK() {
return weekPlanId;
}
@Column(nullable = false)
public String title;
@Column(columnDefinition = "TEXT")
public @Nullable String description;
@Column
public @Nullable Instant workoutAt;
@Column(columnDefinition = "TEXT", nullable = false)
public String description = "";
@Column(nullable = false)
public Instant workoutAt;
@Column(nullable = false)
public WorkoutTypeEnum workoutType;
@Column(nullable = false)
public int sortOrder;
@Column(nullable = false)
public WorkoutStatusEnum status = WorkoutStatusEnum.PLANNED;
@Column(columnDefinition = "TEXT")
public @Nullable String statusNote;
@Column
public @Nullable Integer estimatedTimeSeconds;
@Column(nullable = false)
public int estimatedTimeSeconds;
@Column
public @Nullable Integer estimatedTss;
@Transient
public List<WorkoutStep> steps = new ArrayList<>();
@SuppressWarnings("NullAway.Init")
public Workout() {
}
@Override
public long getFK() {
return weekPlanId;
}
}

View File

@@ -1,10 +1,10 @@
package com.peakup.workout.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.peakup.common.model.AbstractEntity;
import com.peakup.common.model.DependentEntity;
import jakarta.annotation.Nullable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Inheritance;
@@ -27,35 +27,30 @@ import org.hibernate.annotations.DiscriminatorFormula;
})
@Data
@EqualsAndHashCode(callSuper = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public abstract class WorkoutStep extends AbstractEntity implements DependentEntity {
@Column(name = "workout_id", nullable = false)
public long workoutId;
@Column(nullable = false)
public int sortIndex;
@Column(nullable = false)
public WorkoutStepTypeEnum stepType;
@Column(nullable = false)
public String label;
@Column(columnDefinition = "TEXT", nullable = false)
public String description = "";
@Column(nullable = false)
public int durationSeconds;
@Column(nullable = false)
public IntensityReferenceEnum intensityReference;
@SuppressWarnings("NullAway.Init")
protected WorkoutStep() {
}
@Override
public long getFK() {
return workoutId;
}
@Column(nullable = false)
public int sortIndex;
@Column(nullable = false)
public WorkoutStepTypeEnum stepType;
@Column(nullable = false)
public String label;
@Column(columnDefinition = "TEXT")
public @Nullable String description;
@Column(nullable = false)
public int durationSeconds;
@Column
public @Nullable IntensityReferenceEnum intensityReference;
@SuppressWarnings("NullAway.Init")
protected WorkoutStep() {
}
}

View File

@@ -1,27 +1,18 @@
package com.peakup.workout.service;
import com.peakup.workout.model.FixedWorkoutStep;
import com.peakup.workout.model.IntensityReferenceEnum;
import com.peakup.workout.model.RampWorkoutStep;
import com.peakup.workout.model.WeekPlan;
import com.peakup.workout.model.WeekPlanImportRequest;
import com.peakup.workout.model.Workout;
import com.peakup.workout.model.WorkoutStatusEnum;
import com.peakup.workout.model.WorkoutStep;
import com.peakup.workout.model.WorkoutStepTypeEnum;
import com.peakup.workout.model.WorkoutTypeEnum;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZoneOffset;
import java.time.temporal.IsoFields;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@ApplicationScoped
public class WeekPlanImportService {
@@ -37,183 +28,87 @@ public class WeekPlanImportService {
return year < currentYear || (year == currentYear && calendarWeek < currentWeek);
}
public List<String> validate(WeekPlanImportRequest req) {
public List<String> validate(WeekPlan plan) {
List<String> errors = new ArrayList<>();
if (req.getYear() < 2000 || req.getYear() > 2100) {
if (plan.getYear() < 2000 || plan.getYear() > 2100) {
errors.add("year: must be between 2000 and 2100");
}
if (req.getCalendarWeek() < 1 || req.getCalendarWeek() > 53) {
if (plan.getCalendarWeek() < 1 || plan.getCalendarWeek() > 53) {
errors.add("calendarWeek: must be between 1 and 53");
}
if (errors.isEmpty() && isPastWeek(req.getYear(), req.getCalendarWeek())) {
if (errors.isEmpty() && isPastWeek(plan.getYear(), plan.getCalendarWeek())) {
errors.add("Cannot import a plan for a past week");
}
if (req.getWorkouts().isEmpty()) {
if (plan.getWorkouts().isEmpty()) {
errors.add("workouts: must contain at least one workout");
}
for (int i = 0; i < req.getWorkouts().size(); i++) {
validateWorkout(req.getWorkouts().get(i), i, errors);
for (int i = 0; i < plan.getWorkouts().size(); i++) {
validateWorkout(plan.getWorkouts().get(i), i, errors);
}
return errors;
}
private void validateWorkout(WeekPlanImportRequest.WorkoutImportItem w, int idx, List<String> errors) {
@SuppressWarnings("NullAway")
private void validateWorkout(Workout w, int idx, List<String> errors) {
String prefix = "workouts[" + idx + "]";
//noinspection ConstantValue
if (w.getTitle() == null || w.getTitle().isBlank()) {
errors.add(prefix + ".title: required");
}
if (w.getDay() == null) {
errors.add(prefix + ".day: required");
} else {
try {
var ignored = DayOfWeek.valueOf(w.getDay().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
errors.add(prefix + ".day: must be one of monday, tuesday, wednesday,"
+ " thursday, friday, saturday, sunday");
}
//noinspection ConstantValue
if (w.getWorkoutAt() == null) {
errors.add(prefix + ".workoutAt: required (ISO-8601 instant, e.g. 2026-04-01T06:30:00Z)");
}
if (w.getType() == null) {
errors.add(prefix + ".type: required");
} else {
try {
var ignored = WorkoutTypeEnum.valueOf(w.getType().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
errors.add(prefix + ".type: must be one of BIKE, RUN, SWIM, STRENGTH, OTHER");
}
}
if (w.getTime() != null) {
try {
LocalTime.parse(w.getTime());
} catch (Exception e) {
errors.add(prefix + ".time: must be in HH:mm format");
}
//noinspection ConstantValue
if (w.getWorkoutType() == null) {
errors.add(prefix + ".workoutType: required (BIKE, RUN, SWIM, STRENGTH, OTHER)");
}
for (int j = 0; j < w.getSteps().size(); j++) {
validateStep(w.getSteps().get(j), prefix + ".steps[" + j + "]", errors);
}
}
private void validateStep(WeekPlanImportRequest.StepImportItem s, String prefix, List<String> errors) {
if (s.getLabel() == null || s.getLabel().isBlank()) {
@SuppressWarnings("NullAway")
private void validateStep(WorkoutStep step, String prefix, List<String> errors) {
//noinspection ConstantValue
if (step.getLabel() == null || step.getLabel().isBlank()) {
errors.add(prefix + ".label: required");
}
if (s.getDurationSeconds() <= 0) {
if (step.getDurationSeconds() <= 0) {
errors.add(prefix + ".durationSeconds: must be a positive integer");
}
if (s.getDescription() == null || s.getDescription().isBlank()) {
//noinspection ConstantValue
if (step.getDescription() == null || step.getDescription().isBlank()) {
errors.add(prefix + ".description: required");
}
if (s.getIntensityReference() == null) {
//noinspection ConstantValue
if (step.getIntensityReference() == null) {
errors.add(prefix + ".intensityReference: required");
} else {
try {
var ignored = IntensityReferenceEnum.valueOf(s.getIntensityReference().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
errors.add(prefix + ".intensityReference: must be one of FTP, LTHR, THRESHOLD_PACE, MAX_HR");
}
}
WorkoutStepTypeEnum stepType = null;
if (s.getStepType() == null) {
//noinspection ConstantValue
if (step.getStepType() == null) {
errors.add(prefix + ".stepType: required");
} else {
try {
stepType = WorkoutStepTypeEnum.valueOf(s.getStepType().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
errors.add(prefix + ".stepType: must be one of WARM_UP, WORK, RAMP, RECOVERY, REST, COOL_DOWN");
}
}
if (stepType == WorkoutStepTypeEnum.RAMP) {
if (s.getTargetValuePctStart() == null) {
} else if (step instanceof RampWorkoutStep ramp) {
if (ramp.getTargetValuePctStart() == null) {
errors.add(prefix + ".targetValuePctStart: required for RAMP steps");
}
if (s.getTargetValuePctEnd() == null) {
if (ramp.getTargetValuePctEnd() == null) {
errors.add(prefix + ".targetValuePctEnd: required for RAMP steps");
}
} else if (stepType != null) {
if (s.getTargetValuePct() == null) {
} else if (step instanceof FixedWorkoutStep fixed) {
if (fixed.getTargetValuePct() == null) {
errors.add(prefix + ".targetValuePct: required");
}
}
}
public WeekPlan mapAndCreate(long athleteId, WeekPlanImportRequest req) {
WeekPlan plan = new WeekPlan();
public WeekPlan create(long athleteId, WeekPlan plan) {
plan.setAthleteId(athleteId);
plan.setYear(req.getYear());
plan.setCalendarWeek(req.getCalendarWeek());
plan.setWeekLabel(req.getWeekLabel());
plan.setWeekDescription(req.getWeekDescription());
LocalDate monday = weekMonday(req.getYear(), req.getCalendarWeek());
List<Workout> workouts = new ArrayList<>();
for (int i = 0; i < req.getWorkouts().size(); i++) {
workouts.add(mapWorkout(req.getWorkouts().get(i), monday, i));
List<Workout> workouts = plan.getWorkouts();
for (int i = 0; i < workouts.size(); i++) {
workouts.get(i).setSortOrder(i);
}
plan.setWorkouts(workouts);
weekPlanService.createWeekPlan(plan);
return plan;
}
private Workout mapWorkout(WeekPlanImportRequest.WorkoutImportItem dto, LocalDate monday, int sortOrder) {
Workout w = new Workout();
w.setTitle(Objects.requireNonNull(dto.getTitle()));
w.setDescription(dto.getDescription());
w.setWorkoutType(WorkoutTypeEnum.valueOf(Objects.requireNonNull(dto.getType()).toUpperCase(Locale.ROOT)));
w.setSortOrder(sortOrder);
w.setStatus(WorkoutStatusEnum.PLANNED);
w.setStatusNote(dto.getStatusNote());
w.setEstimatedTimeSeconds(dto.getEstimatedTimeSeconds());
w.setEstimatedTss(dto.getEstimatedTss());
String day = dto.getDay();
if (day != null) {
DayOfWeek dow = DayOfWeek.valueOf(day.toUpperCase(Locale.ROOT));
LocalDate workoutDate = monday.plusDays(dow.getValue() - 1);
LocalTime time = dto.getTime() != null ? LocalTime.parse(dto.getTime()) : LocalTime.of(0, 0);
w.setWorkoutAt(workoutDate.atTime(time).toInstant(ZoneOffset.UTC));
}
List<WorkoutStep> steps = new ArrayList<>();
for (WeekPlanImportRequest.StepImportItem stepDto : dto.getSteps()) {
steps.add(mapStep(stepDto));
}
w.setSteps(steps);
return w;
}
private WorkoutStep mapStep(WeekPlanImportRequest.StepImportItem dto) {
WorkoutStepTypeEnum stepType = WorkoutStepTypeEnum.valueOf(
Objects.requireNonNull(dto.getStepType()).toUpperCase(Locale.ROOT));
WorkoutStep step;
if (stepType == WorkoutStepTypeEnum.RAMP) {
RampWorkoutStep ramp = new RampWorkoutStep();
ramp.setTargetValuePctStart(dto.getTargetValuePctStart());
ramp.setTargetValuePctEnd(dto.getTargetValuePctEnd());
step = ramp;
} else {
FixedWorkoutStep fixed = new FixedWorkoutStep();
fixed.setTargetValuePct(dto.getTargetValuePct());
step = fixed;
}
step.setLabel(Objects.requireNonNull(dto.getLabel()));
step.setDescription(dto.getDescription());
step.setStepType(stepType);
step.setDurationSeconds(dto.getDurationSeconds());
String intensityRef = dto.getIntensityReference();
if (intensityRef != null) {
step.setIntensityReference(IntensityReferenceEnum.valueOf(intensityRef.toUpperCase(Locale.ROOT)));
}
return step;
}
private static LocalDate weekMonday(int year, int week) {
return LocalDate.of(year, 1, 4)
.with(IsoFields.WEEK_OF_WEEK_BASED_YEAR, week)
.with(DayOfWeek.MONDAY);
}
}

View File

@@ -1,5 +1,12 @@
package com.peakup.workout.service;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationModule;
import com.github.victools.jsonschema.module.jakarta.validation.JakartaValidationOption;
import com.peakup.common.service.AbstractService;
import com.peakup.workout.dao.WeekPlanDAO;
import com.peakup.workout.dao.WorkoutDAO;
@@ -111,4 +118,19 @@ public class WeekPlanService extends AbstractService<WeekPlan, WeekPlanDAO> {
public void delete(WeekPlan entity) {
super.delete(entity);
}
public String generateImportSchema() {
JakartaValidationModule validationModule = new JakartaValidationModule(
JakartaValidationOption.NOT_NULLABLE_FIELD_IS_REQUIRED,
JakartaValidationOption.INCLUDE_PATTERN_EXPRESSIONS
);
@SuppressWarnings("removal") SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(
SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON)
.with(validationModule)
.with(new JacksonModule());
SchemaGenerator generator = new SchemaGenerator(configBuilder.build());
return generator.generateSchema(WeekPlan.class).toString();
}
}

View File

@@ -1,146 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "weekplan-import",
"title": "WeekPlanImportRequest",
"description": "AI-generated week plan import format for peakUp",
"type": "object",
"required": ["year", "calendarWeek", "workouts"],
"additionalProperties": false,
"properties": {
"year": {
"type": "integer",
"minimum": 2000,
"maximum": 2100,
"description": "ISO week-based year"
},
"calendarWeek": {
"type": "integer",
"minimum": 1,
"maximum": 53,
"description": "ISO calendar week number (1–53)"
},
"weekLabel": {
"type": "string",
"description": "Short label for the week, e.g. 'Race Prep Week 3'"
},
"weekDescription": {
"type": "string",
"description": "Longer description of the week's focus and goals"
},
"workouts": {
"type": "array",
"minItems": 1,
"description": "Planned workouts. Omit rest days — do not add empty entries.",
"items": { "$ref": "#/$defs/WorkoutImportItem" }
}
},
"$defs": {
"WorkoutImportItem": {
"type": "object",
"required": ["title", "day", "type"],
"additionalProperties": false,
"properties": {
"title": {
"type": "string",
"description": "Short name of the workout, e.g. 'Zone 2 Ride'"
},
"day": {
"type": "string",
"enum": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
"description": "Day of the week (lowercase)"
},
"time": {
"type": "string",
"pattern": "^\\d{2}:\\d{2}$",
"description": "Start time in HH:mm format (24h), e.g. '07:30'. Optional — omit if no fixed start time."
},
"type": {
"type": "string",
"enum": ["BIKE", "RUN", "SWIM", "STRENGTH", "OTHER"],
"description": "Sport type"
},
"estimatedTimeSeconds": {
"type": "integer",
"minimum": 1,
"description": "Total workout duration in seconds. Must equal the sum of all step durations when steps are provided."
},
"estimatedTss": {
"type": "integer",
"minimum": 1,
"description": "Estimated Training Stress Score. Formula: IF² × (estimatedTimeSeconds / 3600) × 100, where IF = avg intensity / threshold."
},
"description": {
"type": "string",
"description": "Optional longer description / coaching notes for the workout"
},
"statusNote": {
"type": "string",
"description": "Optional pre-populated status note"
},
"steps": {
"type": "array",
"description": "Ordered workout steps. If omitted the workout has no structured intervals.",
"items": { "$ref": "#/$defs/StepImportItem" }
}
}
},
"StepImportItem": {
"type": "object",
"required": ["label", "stepType", "durationSeconds", "description", "intensityReference"],
"additionalProperties": false,
"if": {
"properties": { "stepType": { "const": "RAMP" } },
"required": ["stepType"]
},
"then": {
"required": ["targetValuePctStart", "targetValuePctEnd"]
},
"else": {
"required": ["targetValuePct"]
},
"properties": {
"label": {
"type": "string",
"description": "Short name, e.g. 'Warm Up', '4×8 min threshold', 'Recovery'"
},
"stepType": {
"type": "string",
"enum": ["WARM_UP", "WORK", "RAMP", "RECOVERY", "REST", "COOL_DOWN"],
"description": "Step category. RAMP = progressive intensity change (requires targetValuePctStart + targetValuePctEnd)."
},
"durationSeconds": {
"type": "integer",
"minimum": 1,
"description": "Duration of this step in seconds"
},
"description": {
"type": "string",
"description": "Optional coaching cue for this step, e.g. 'Keep cadence above 90 rpm'"
},
"intensityReference": {
"type": "string",
"enum": ["FTP", "LTHR", "THRESHOLD_PACE", "MAX_HR"],
"description": "Reference metric for target intensity. FTP = cycling power, LTHR = lactate-threshold HR, THRESHOLD_PACE = run threshold pace, MAX_HR = maximum HR."
},
"targetValuePct": {
"type": "integer",
"minimum": 0,
"maximum": 200,
"description": "Target intensity as % of intensityReference. Use for all stepTypes except RAMP."
},
"targetValuePctStart": {
"type": "integer",
"minimum": 0,
"maximum": 200,
"description": "Ramp start intensity (% of intensityReference). RAMP steps only."
},
"targetValuePctEnd": {
"type": "integer",
"minimum": 0,
"maximum": 200,
"description": "Ramp end intensity (% of intensityReference). RAMP steps only."
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
package com.peakup;
import io.quarkus.test.common.QuarkusTestResourceLifecycleManager;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.Map;
@@ -12,8 +12,8 @@ public class TimescaleDBResource implements QuarkusTestResourceLifecycleManager
.parse("timescale/timescaledb:latest-pg16")
.asCompatibleSubstituteFor("postgres");
private static final PostgreSQLContainer<?> DB =
new PostgreSQLContainer<>(IMAGE)
private static final PostgreSQLContainer DB =
new PostgreSQLContainer(IMAGE)
.withDatabaseName("peakup")
.withUsername("peakup")
.withPassword("peakup");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
# =============================================
# .claudeignore – Optimierte Version für peakUp (Pro Plan)
# Ziel: Minimale automatische Datei-Reads + niedrige Burn-Rate
# =============================================
node_modules/
dist/
playwright-report/
test-results/
e2e/
*.log
package-lock.json