]> git.laktatnebel.de Git - service.triathlon-coaching.com.git/commitdiff
erster Entwurf by Claude-KI master
authorOle B. Rosentreter <ole@laktatnebel.de>
Mon, 20 Apr 2026 09:21:11 +0000 (11:21 +0200)
committerOle B. Rosentreter <ole@laktatnebel.de>
Mon, 20 Apr 2026 09:21:11 +0000 (11:21 +0200)
17 files changed:
base-auth/pom.xml [new file with mode: 0644]
base-auth/src/main/java/com/triathlon_coaching/product/auth/service/AuthService.java [new file with mode: 0644]
base-calc/pom.xml [new file with mode: 0644]
base-calc/src/main/java/com/triathlon_coaching/product/calc/swim/CssCalculator.java [new file with mode: 0644]
base-core/pom.xml [new file with mode: 0644]
base-core/src/main/resources/db/migration/V1__initial_schema.sql [new file with mode: 0644]
base-user/pom.xml [new file with mode: 0644]
pom.xml
swim-domain/pom.xml [new file with mode: 0644]
swim-domain/src/main/java/com/triathlon_coaching/product/swim/domain/Block.java [new file with mode: 0644]
swim-domain/src/main/java/com/triathlon_coaching/product/swim/domain/Trainingsplan.java [new file with mode: 0644]
swim-fit/pom.xml [new file with mode: 0644]
swim-pdf/pom.xml [new file with mode: 0644]
swim-web/pom.xml [new file with mode: 0644]
swim-web/src/main/java/com/triathlon_coaching/product/swim/web/SwimApplication.java [new file with mode: 0644]
swim-web/src/main/java/com/triathlon_coaching/product/swim/web/controller/SwimController.java [new file with mode: 0644]
swim-web/src/main/resources/application.yml [new file with mode: 0644]

diff --git a/base-auth/pom.xml b/base-auth/pom.xml
new file mode 100644 (file)
index 0000000..959835e
--- /dev/null
@@ -0,0 +1,47 @@
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.triathlon-coaching.product</groupId>
+        <artifactId>service-parent</artifactId>
+        <version>1.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>base-auth</artifactId>
+    <n>Base — Auth (Benutzerverwaltung, JWT, 2FA)</n>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-security</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-mail</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-impl</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.jsonwebtoken</groupId>
+            <artifactId>jjwt-jackson</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>dev.samstevens.totp</groupId>
+            <artifactId>totp-spring-boot-starter</artifactId>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/base-auth/src/main/java/com/triathlon_coaching/product/auth/service/AuthService.java b/base-auth/src/main/java/com/triathlon_coaching/product/auth/service/AuthService.java
new file mode 100644 (file)
index 0000000..8e7f51c
--- /dev/null
@@ -0,0 +1,51 @@
+package com.triathlon_coaching.product.auth.service;
+
+/**
+ * Benutzerverwaltung — plattformübergreifend nutzbar für alle Apps.
+ *
+ * Flows:
+ *   Registrierung  → E-Mail-Bestätigung → aktiver Account
+ *   Login          → JWT (30min) + Session-Token (30 Tage, DB-persistiert)
+ *   2FA            → TOTP-Setup → QR-Code → Verify → aktiviert
+ *   Passwort       → Token per Mail → Reset
+ *   Entitlement    → Produkt-Freischaltung (z.B. 'swim_fit')
+ */
+public interface AuthService {
+
+    /** Registrierung: sendet Bestätigungsmail. */
+    void register(String email, String password, String displayName);
+
+    /** E-Mail-Token einlösen → Account aktiv. */
+    void verifyEmail(String token);
+
+    /**
+     * Login.
+     * @param totpCode  null wenn 2FA nicht aktiv
+     * @return          Session-Token (opakes, DB-persistiertes Token)
+     */
+    String login(String email, String password, String totpCode);
+
+    /** Session invalidieren. */
+    void logout(String sessionToken);
+
+    /** Passwort-Reset-Mail anfordern. */
+    void requestPasswordReset(String email);
+
+    /** Passwort-Reset-Token einlösen. */
+    void resetPassword(String token, String newPassword);
+
+    /**
+     * TOTP-Setup starten.
+     * @return  otpauth://-URI für QR-Code-Generierung
+     */
+    String setupTotp(String userId);
+
+    /** TOTP aktivieren (nach QR-Code-Scan und erstem Code). */
+    void enableTotp(String userId, String totpCode);
+
+    /** TOTP deaktivieren (Passwortbestätigung erforderlich). */
+    void disableTotp(String userId, String currentPassword);
+
+    /** Prüft ob User ein Produkt-Entitlement hat (z.B. 'swim_fit'). */
+    boolean hasEntitlement(String userId, String productId);
+}
diff --git a/base-calc/pom.xml b/base-calc/pom.xml
new file mode 100644 (file)
index 0000000..55ac927
--- /dev/null
@@ -0,0 +1,29 @@
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.triathlon-coaching.product</groupId>
+        <artifactId>service-parent</artifactId>
+        <version>1.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>base-calc</artifactId>
+    <n>Base — Calc (CSS, Zonen, Jack Daniels, GPX)</n>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-core</artifactId>
+        </dependency>
+        <!-- GPX-Parsing -->
+        <dependency>
+            <groupId>io.jenetics</groupId>
+            <artifactId>jpx</artifactId>
+            <version>3.2.0</version>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/base-calc/src/main/java/com/triathlon_coaching/product/calc/swim/CssCalculator.java b/base-calc/src/main/java/com/triathlon_coaching/product/calc/swim/CssCalculator.java
new file mode 100644 (file)
index 0000000..cf092f2
--- /dev/null
@@ -0,0 +1,93 @@
+package com.triathlon_coaching.product.calc.swim;
+
+import org.springframework.stereotype.Service;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+/**
+ * Berechnet CSS-basierte Schwimmtempi.
+ * Direkter Port von swim_lib.inc.php: getZone(), getTempo(), speed2time(), toMin2()
+ *
+ * CSS = Critical Swim Speed, angegeben als Sekunden pro 100m.
+ */
+@Service
+public class CssCalculator {
+
+    /**
+     * CSS-Näherung aus 400m-Test.
+     * Entspricht: $testspeed = $strecke / $testzeit (mit strecke=400)
+     */
+    public double cssAusTest400(double test400Sek) {
+        return runden2(test400Sek / 4.0);
+    }
+
+    /**
+     * Präzise CSS nach Wakayoshi (1992): aus 200m + 400m Test.
+     * CSS = (400 - 200) / (t400 - t200) ergibt m/s → umrechnen auf sek/100m
+     */
+    public double cssAusTest200Und400(double t200Sek, double t400Sek) {
+        double speedMps = (400.0 - 200.0) / (t400Sek - t200Sek);
+        return runden2(100.0 / speedMps);  // Sekunden pro 100m
+    }
+
+    /**
+     * Zielzeit für eine Strecke bei gegebenem CSS-Prozentsatz.
+     * Entspricht: speed2time($testspeed, $level, $distance)
+     *
+     * @param cssSek      CSS in Sek/100m
+     * @param cssProzent  Zonenintensität, z.B. 85 für 85% CSS
+     * @param streckeM    Streckenlänge in Metern
+     * @return            Zielzeit in Sekunden
+     */
+    public double zielzeit(double cssSek, int cssProzent, int streckeM) {
+        double speedMps = (100.0 / cssSek) * (cssProzent / 100.0);
+        return runden2(streckeM / speedMps);
+    }
+
+    /**
+     * Zielzeit für Offset-Tempo (+5s, -5s etc. pro 100m).
+     * Entspricht: getTempo($delta, ...) mit: $testzeit = $testzeit*100/$strecke + $delta
+     *
+     * @param cssSek    CSS in Sek/100m
+     * @param deltaSek  Offset in Sekunden pro 100m (positiv = langsamer)
+     * @param streckeM  Streckenlänge in Metern
+     * @return          Zielzeit in Sekunden
+     */
+    public double zeitzielMitOffset(double cssSek, int deltaSek, int streckeM) {
+        double tempoPro100m = cssSek + deltaSek;
+        return runden2(tempoPro100m * streckeM / 100.0);
+    }
+
+    /**
+     * Formatiert Sekunden als "M:SS"-String.
+     * Entspricht: toMin2($sekunden) in swim_lib.inc.php
+     */
+    public String formatMinSek(double sek) {
+        int minuten = (int) Math.floor(sek / 60);
+        double sekRest = Math.round((sek - minuten * 60.0) * 100.0) / 100.0;
+        if (sekRest >= 60) {
+            minuten++;
+            sekRest = 0;
+        }
+        return String.format("%d:%s", minuten,
+            sekRest < 10
+                ? String.format("0%.0f", sekRest)
+                : String.format("%.0f", sekRest));
+    }
+
+    /**
+     * Extrahiert Sekundenanteil als String (für Pausenangabe im PDF).
+     * Entspricht: getSecondsFromTime($time) — gibt "SS''" zurück.
+     */
+    public String formatPause(String timeStr) {
+        if (timeStr == null || timeStr.isBlank()) return "";
+        if (timeStr.length() >= 8) {
+            return timeStr.substring(6, 8) + "''";
+        }
+        return timeStr;
+    }
+
+    private double runden2(double val) {
+        return BigDecimal.valueOf(val).setScale(2, RoundingMode.HALF_UP).doubleValue();
+    }
+}
diff --git a/base-core/pom.xml b/base-core/pom.xml
new file mode 100644 (file)
index 0000000..698a123
--- /dev/null
@@ -0,0 +1,40 @@
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.triathlon-coaching.product</groupId>
+        <artifactId>service-parent</artifactId>
+        <version>1.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>base-core</artifactId>
+    <n>Base — Core (DB-Konfiguration, Basisklassen)</n>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.postgresql</groupId>
+            <artifactId>postgresql</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.flywaydb</groupId>
+            <artifactId>flyway-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.flywaydb</groupId>
+            <artifactId>flyway-database-postgresql</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/base-core/src/main/resources/db/migration/V1__initial_schema.sql b/base-core/src/main/resources/db/migration/V1__initial_schema.sql
new file mode 100644 (file)
index 0000000..0c14478
--- /dev/null
@@ -0,0 +1,8 @@
+-- V1__initial_schema.sql
+-- Inhalt: vollständiges Schema aus schema_swim_triathlon_coaching.sql
+--
+-- Dieses File muss vor dem ersten Start mit dem Inhalt von
+-- schema_swim_triathlon_coaching.sql befüllt werden.
+--
+-- Flyway führt alle V*__*.sql Files in der Reihenfolge ihrer Versionsnummer aus.
+-- Weitere Migrationen: V2__..., V3__... etc.
diff --git a/base-user/pom.xml b/base-user/pom.xml
new file mode 100644 (file)
index 0000000..37b06bb
--- /dev/null
@@ -0,0 +1,27 @@
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.triathlon-coaching.product</groupId>
+        <artifactId>service-parent</artifactId>
+        <version>1.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>base-user</artifactId>
+    <n>Base — User (Profil, Entitlements, CSS-Historie)</n>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-auth</artifactId>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/pom.xml b/pom.xml
index a31f1c809e325605bac1102af83bc95214e42d40..af845de750d9c97ab42914b5f4cd4b3cecdf0002 100644 (file)
--- a/pom.xml
+++ b/pom.xml
-<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/maven-v4_0_0.xsd">
-       <modelVersion>4.0.0</modelVersion>
-       <groupId>de.laktatnebel.product</groupId>
-       <artifactId>service.triathlon-coaching.com</artifactId>
-       <version>0.0.1-SNAPSHOT</version>
-       <packaging>pom</packaging>
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
 
-       <name>service.triathlon-coaching.com</name>
+    <groupId>com.triathlon-coaching.product</groupId>
+    <artifactId>service-parent</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+    <packaging>pom</packaging>
+    <name>Triathlon Coaching — Service Parent</name>
 
-       <parent>
-               <groupId>de.laktatnebel.maven</groupId>
-               <artifactId>spring-boot-starter-parent</artifactId>
-               <version>3.3.1</version>
-       </parent>
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>3.3.1</version>
+        <relativePath/>
+    </parent>
 
+    <modules>
+        <!-- Shared Platform Modules -->
+        <module>base-core</module>
+        <module>base-auth</module>
+        <module>base-calc</module>
+        <module>base-user</module>
+        <!-- Swim App Modules -->
+        <module>swim-domain</module>
+        <module>swim-pdf</module>
+        <module>swim-fit</module>
+        <module>swim-web</module>
+    </modules>
+
+    <properties>
+        <java.version>21</java.version>
+        <jjwt.version>0.12.5</jjwt.version>
+        <pdfbox.version>3.0.2</pdfbox.version>
+        <totp.version>1.7.1</totp.version>
+        <mapstruct.version>1.5.5.Final</mapstruct.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <!-- Shared Platform -->
+            <dependency>
+                <groupId>com.triathlon-coaching.product</groupId>
+                <artifactId>base-core</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.triathlon-coaching.product</groupId>
+                <artifactId>base-auth</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.triathlon-coaching.product</groupId>
+                <artifactId>base-calc</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.triathlon-coaching.product</groupId>
+                <artifactId>base-user</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <!-- Swim App -->
+            <dependency>
+                <groupId>com.triathlon-coaching.product</groupId>
+                <artifactId>swim-domain</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.triathlon-coaching.product</groupId>
+                <artifactId>swim-pdf</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.triathlon-coaching.product</groupId>
+                <artifactId>swim-fit</artifactId>
+                <version>${project.version}</version>
+            </dependency>
+            <!-- JWT -->
+            <dependency>
+                <groupId>io.jsonwebtoken</groupId>
+                <artifactId>jjwt-api</artifactId>
+                <version>${jjwt.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.jsonwebtoken</groupId>
+                <artifactId>jjwt-impl</artifactId>
+                <version>${jjwt.version}</version>
+                <scope>runtime</scope>
+            </dependency>
+            <dependency>
+                <groupId>io.jsonwebtoken</groupId>
+                <artifactId>jjwt-jackson</artifactId>
+                <version>${jjwt.version}</version>
+                <scope>runtime</scope>
+            </dependency>
+            <!-- PDF -->
+            <dependency>
+                <groupId>org.apache.pdfbox</groupId>
+                <artifactId>pdfbox</artifactId>
+                <version>${pdfbox.version}</version>
+            </dependency>
+            <!-- TOTP / 2FA -->
+            <dependency>
+                <groupId>dev.samstevens.totp</groupId>
+                <artifactId>totp-spring-boot-starter</artifactId>
+                <version>${totp.version}</version>
+            </dependency>
+            <!-- MapStruct -->
+            <dependency>
+                <groupId>org.mapstruct</groupId>
+                <artifactId>mapstruct</artifactId>
+                <version>${mapstruct.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <!-- Gemeinsame Abhängigkeiten für ALLE Module -->
+    <dependencies>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <pluginManagement>
+            <plugins>
+                <plugin>
+                    <groupId>org.apache.maven.plugins</groupId>
+                    <artifactId>maven-compiler-plugin</artifactId>
+                    <configuration>
+                        <annotationProcessorPaths>
+                            <path>
+                                <groupId>org.projectlombok</groupId>
+                                <artifactId>lombok</artifactId>
+                            </path>
+                            <path>
+                                <groupId>org.mapstruct</groupId>
+                                <artifactId>mapstruct-processor</artifactId>
+                                <version>${mapstruct.version}</version>
+                            </path>
+                        </annotationProcessorPaths>
+                    </configuration>
+                </plugin>
+            </plugins>
+        </pluginManagement>
+    </build>
 </project>
diff --git a/swim-domain/pom.xml b/swim-domain/pom.xml
new file mode 100644 (file)
index 0000000..23134c9
--- /dev/null
@@ -0,0 +1,31 @@
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.triathlon-coaching.product</groupId>
+        <artifactId>service-parent</artifactId>
+        <version>1.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>swim-domain</artifactId>
+    <n>Swim — Domain (Entities, Repositories, Generator)</n>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-core</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-calc</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-user</artifactId>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/swim-domain/src/main/java/com/triathlon_coaching/product/swim/domain/Block.java b/swim-domain/src/main/java/com/triathlon_coaching/product/swim/domain/Block.java
new file mode 100644 (file)
index 0000000..08365df
--- /dev/null
@@ -0,0 +1,61 @@
+package com.triathlon_coaching.product.swim.domain;
+
+import jakarta.persistence.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(onlyExplicitlyIncluded = true)
+@Entity
+@Table(name = "block", schema = "swim")
+public class Block {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @EqualsAndHashCode.Include
+    private Integer id;
+
+    @Column(nullable = false, unique = true, length = 128)
+    private String name;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "fk_blockfunktion", nullable = false)
+    private Blockfunktion blockfunktion;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "fk_trainingsziel")
+    private Trainingsziel trainingsziel;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "fk_technikschwerpunkt")
+    private Technikschwerpunkt technikschwerpunkt;
+
+    private String detail;
+
+    /**
+     * 'serie' | 'pyramide' | 'intervall' | 'technik_drill'
+     * Pyramiden und Intervallmuster: strecken_muster enthält die Abfolge.
+     */
+    private String strukturtyp;
+
+    /**
+     * JSON-Array der Streckenlängen für Pyramiden/Intervalle.
+     * Beispiel Pyramide:  [100, 150, 200, 150, 100]
+     * Beispiel Intervall: [100, 200, 100, 200, 100]
+     */
+    @JdbcTypeCode(SqlTypes.JSON)
+    @Column(name = "strecken_muster", columnDefinition = "jsonb")
+    private List<Integer> streckenMuster;
+
+    @OneToMany(mappedBy = "block",
+               cascade = CascadeType.ALL,
+               orphanRemoval = true,
+               fetch = FetchType.LAZY)
+    @OrderBy("position ASC")
+    private List<BlockUebung> uebungen = new ArrayList<>();
+}
diff --git a/swim-domain/src/main/java/com/triathlon_coaching/product/swim/domain/Trainingsplan.java b/swim-domain/src/main/java/com/triathlon_coaching/product/swim/domain/Trainingsplan.java
new file mode 100644 (file)
index 0000000..29011b5
--- /dev/null
@@ -0,0 +1,46 @@
+package com.triathlon_coaching.product.swim.domain;
+
+import jakarta.persistence.*;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@EqualsAndHashCode(onlyExplicitlyIncluded = true)
+@Entity
+@Table(name = "trainingsplan", schema = "swim")
+public class Trainingsplan {
+
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    @EqualsAndHashCode.Include
+    private Integer id;
+
+    @Column(nullable = false, unique = true, length = 255)
+    private String name;
+
+    @ManyToOne(fetch = FetchType.LAZY)
+    @JoinColumn(name = "fk_trainingsziel")
+    private Trainingsziel trainingsziel;
+
+    @Column(name = "umfang_meter")
+    private Integer umfangMeter;
+
+    @Column(name = "ist_generiert", nullable = false)
+    private boolean istGeneriert = false;
+
+    private String detail;
+
+    @Column(name = "created_at", nullable = false, updatable = false)
+    private Instant createdAt = Instant.now();
+
+    @OneToMany(mappedBy = "trainingsplan",
+               cascade = CascadeType.ALL,
+               orphanRemoval = true,
+               fetch = FetchType.LAZY)
+    @OrderBy("position ASC")
+    private List<TrainingsplanBlock> bloecke = new ArrayList<>();
+}
diff --git a/swim-fit/pom.xml b/swim-fit/pom.xml
new file mode 100644 (file)
index 0000000..af88f40
--- /dev/null
@@ -0,0 +1,43 @@
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.triathlon-coaching.product</groupId>
+        <artifactId>service-parent</artifactId>
+        <version>1.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>swim-fit</artifactId>
+    <n>Swim — FIT (Garmin FIT-File-Erzeugung)</n>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>swim-domain</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-calc</artifactId>
+        </dependency>
+        <!--
+            Garmin FIT SDK: manuell ins lokale Maven-Repo installieren.
+            Download: https://developer.garmin.com/fit/download/
+
+            mvn install:install-file \
+              -Dfile=FitSDKRelease_21.141.00.jar \
+              -DgroupId=com.garmin \
+              -DartifactId=fit-sdk \
+              -Dversion=21.141.00 \
+              -Dpackaging=jar
+        -->
+        <dependency>
+            <groupId>com.garmin</groupId>
+            <artifactId>fit-sdk</artifactId>
+            <version>21.141.00</version>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/swim-pdf/pom.xml b/swim-pdf/pom.xml
new file mode 100644 (file)
index 0000000..c782ed2
--- /dev/null
@@ -0,0 +1,31 @@
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.triathlon-coaching.product</groupId>
+        <artifactId>service-parent</artifactId>
+        <version>1.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>swim-pdf</artifactId>
+    <n>Swim — PDF (Trainingsplan-PDF-Erzeugung)</n>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>swim-domain</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-calc</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.pdfbox</groupId>
+            <artifactId>pdfbox</artifactId>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/swim-web/pom.xml b/swim-web/pom.xml
new file mode 100644 (file)
index 0000000..27cfcc3
--- /dev/null
@@ -0,0 +1,84 @@
+<?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
+         https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.triathlon-coaching.product</groupId>
+        <artifactId>service-parent</artifactId>
+        <version>1.0.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>swim-web</artifactId>
+    <packaging>jar</packaging>
+    <n>Swim — Web (Spring Boot App, deployable)</n>
+
+    <dependencies>
+        <!-- Swim App -->
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>swim-domain</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>swim-pdf</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>swim-fit</artifactId>
+        </dependency>
+        <!-- Shared Platform -->
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-auth</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-user</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.triathlon-coaching.product</groupId>
+            <artifactId>base-calc</artifactId>
+        </dependency>
+        <!-- Web -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-thymeleaf</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.thymeleaf.extras</groupId>
+            <artifactId>thymeleaf-extras-springsecurity6</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.mapstruct</groupId>
+            <artifactId>mapstruct</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <configuration>
+                    <excludes>
+                        <exclude>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                        </exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>
diff --git a/swim-web/src/main/java/com/triathlon_coaching/product/swim/web/SwimApplication.java b/swim-web/src/main/java/com/triathlon_coaching/product/swim/web/SwimApplication.java
new file mode 100644 (file)
index 0000000..a1b36b7
--- /dev/null
@@ -0,0 +1,17 @@
+package com.triathlon_coaching.product.swim.web;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+@SpringBootApplication(scanBasePackages = "com.triathlon_coaching.product")
+@EntityScan(basePackages = "com.triathlon_coaching.product")
+@EnableJpaRepositories(basePackages = "com.triathlon_coaching.product")
+@EnableAsync
+public class SwimApplication {
+    public static void main(String[] args) {
+        SpringApplication.run(SwimApplication.class, args);
+    }
+}
diff --git a/swim-web/src/main/java/com/triathlon_coaching/product/swim/web/controller/SwimController.java b/swim-web/src/main/java/com/triathlon_coaching/product/swim/web/controller/SwimController.java
new file mode 100644 (file)
index 0000000..d38bda3
--- /dev/null
@@ -0,0 +1,86 @@
+package com.triathlon_coaching.product.swim.web.controller;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * REST API für swim.triathlon-coaching.com
+ *
+ * Öffentlich (kein Login):
+ *   GET  /api/swim/plaene                        → Liste (filterbar, sortierbar)
+ *   GET  /api/swim/plaene/{name}/pdf             → PDF mit %-Angaben
+ *   GET  /api/swim/plaene/{name}/pdf?css=83.5    → PDF mit persönlichen Zeiten
+ *   POST /api/swim/css/berechnen                 → CSS aus Testzeit
+ *   GET  /api/swim/zonen?css=83.5               → alle Zonen mit Zeiten
+ *
+ * Auth + Entitlement 'swim_fit' erforderlich:
+ *   GET  /api/swim/plaene/{name}/fit             → FIT-Download (sichtbar, aber gesperrt ohne Login)
+ *
+ * Auth erforderlich:
+ *   GET  /api/swim/mein/css                      → eigene CSS-Historie
+ *   POST /api/swim/mein/css                      → CSS speichern
+ */
+@RestController
+@RequestMapping("/api/swim")
+public class SwimController {
+
+    @GetMapping("/plaene")
+    public ResponseEntity<?> getPlaene(
+            @RequestParam(required = false) String ziel,
+            @RequestParam(required = false) Integer minUmfang,
+            @RequestParam(required = false) Integer maxUmfang,
+            @RequestParam(defaultValue = "umfang") String sort,
+            @RequestParam(defaultValue = "asc") String direction) {
+        // TODO: TrainingsplanService.findAll(filter, sort)
+        return ResponseEntity.ok().build();
+    }
+
+    @GetMapping("/plaene/{name}/pdf")
+    public ResponseEntity<byte[]> getPdf(
+            @PathVariable String name,
+            @RequestParam(required = false) Double css,
+            @RequestParam(required = false) Double test400) {
+        // TODO: TrainingsplanPdfService.erzeugePdf(name, css)
+        return ResponseEntity.ok()
+            .header(HttpHeaders.CONTENT_DISPOSITION,
+                "attachment; filename=\"" + name.replace(" ", "_") + ".pdf\"")
+            .contentType(MediaType.APPLICATION_PDF)
+            .body(new byte[0]);
+    }
+
+    /**
+     * FIT-Download: Endpunkt ist immer erreichbar (für "Lockmittel"-Anzeige),
+     * aber ohne Entitlement kommt HTTP 402 Payment Required zurück.
+     */
+    @GetMapping("/plaene/{name}/fit")
+    public ResponseEntity<byte[]> getFit(
+            @PathVariable String name,
+            @RequestParam(required = false) Double css) {
+        // TODO: Security-Check @PreAuthorize("@entitlementService.hasEntitlement('swim_fit')")
+        // Ohne Login/Entitlement → 402 mit Hinweis auf Registration
+        return ResponseEntity.status(402).build();
+    }
+
+    @PostMapping("/css/berechnen")
+    public ResponseEntity<?> berechneCss(@RequestBody CssRequest request) {
+        // TODO: CssCalculator.cssAusTest400() oder cssAusTest200Und400()
+        return ResponseEntity.ok().build();
+    }
+
+    @GetMapping("/zonen")
+    public ResponseEntity<?> getZonen(@RequestParam double css) {
+        // TODO: ZonenRechner.berechneAlleZonen(zonenAusDb, css, standardStrecken)
+        return ResponseEntity.ok().build();
+    }
+
+    // ── Records für Request/Response ──────────────────────────────────
+
+    record CssRequest(
+        Double test400Sek,   // 400m-Test in Sekunden
+        Double t200Sek,      // 200m-Test (für präzise CSS-Berechnung)
+        Double t400Sek,      // 400m-Test (für präzise CSS-Berechnung)
+        Double cssDirectSek  // direkte CSS-Eingabe in Sek/100m
+    ) {}
+}
diff --git a/swim-web/src/main/resources/application.yml b/swim-web/src/main/resources/application.yml
new file mode 100644 (file)
index 0000000..e8d4cdf
--- /dev/null
@@ -0,0 +1,81 @@
+spring:
+  datasource:
+    url: jdbc:postgresql://localhost:5432/triathlon_coaching
+    username: ${DB_USER}
+    password: ${DB_PASSWORD}
+    hikari:
+      maximum-pool-size: 10
+      connection-timeout: 30000
+  jpa:
+    hibernate:
+      ddl-auto: validate        # Schema kommt von Flyway, nie von Hibernate
+    properties:
+      hibernate:
+        default_schema: swim
+        dialect: org.hibernate.dialect.PostgreSQLDialect
+        format_sql: true
+    show-sql: false
+  flyway:
+    schemas: swim,auth,calc
+    locations: classpath:db/migration
+    baseline-on-migrate: true
+  mail:
+    host: ${SMTP_HOST}
+    port: 587
+    username: ${SMTP_USER}
+    password: ${SMTP_PASSWORD}
+    properties:
+      mail.smtp.auth: true
+      mail.smtp.starttls.enable: true
+  thymeleaf:
+    cache: false                # in Produktion auf true setzen
+
+server:
+  port: 8080
+  compression:
+    enabled: true
+    mime-types: text/html,text/css,application/javascript,application/json
+
+stc:
+  auth:
+    jwt-secret: ${JWT_SECRET}
+    jwt-expiry-minutes: 30
+    session-expiry-days: 30
+    base-url: https://swim.triathlon-coaching.com
+    mail-from: noreply@triathlon-coaching.com
+  pdf:
+    watermark: "(c) by triathlon-coaching.com"
+    logo-path: classpath:static/img/logo-02.png
+  fit:
+    enabled: true               # false = FIT-Download deaktiviert (z.B. SDK nicht vorhanden)
+
+management:
+  endpoints:
+    web:
+      exposure:
+        include: health,info
+  endpoint:
+    health:
+      show-details: when-authorized
+
+logging:
+  level:
+    com.triathlon_coaching.product: INFO
+    org.springframework.security: WARN
+    org.flywaydb: INFO
+
+---
+# Profil: local (Entwicklung)
+spring:
+  config:
+    activate:
+      on-profile: local
+  datasource:
+    url: jdbc:postgresql://localhost:5432/triathlon_coaching_dev
+  jpa:
+    show-sql: true
+  thymeleaf:
+    cache: false
+logging:
+  level:
+    com.triathlon_coaching.product: DEBUG