{
  "openapi": "3.1.0",
  "info": {
    "title": "Calcul Allure API",
    "version": "1.0.0",
    "summary": "API JSON gratuite de calcul d'allure et de génération de plans d'entraînement en course à pied.",
    "description": "API publique de calcul-allure.com. Deux ressources : `/api/pace` calcule l'allure (min/km), la vitesse (km/h) et les temps de passage pour une distance et un temps donnés ; `/api/plan` génère un plan d'entraînement personnalisé (5 km, 10 km, semi-marathon, marathon). Aucune clé d'API, aucune authentification, CORS ouvert (`Access-Control-Allow-Origin: *`). Les réponses de calcul sont mises en cache 60 secondes (`Cache-Control: public, max-age=60`) ; les erreurs et `/api/status` portent `Cache-Control: no-store`. Les libellés de séances du plan sont en français. Documentation lisible : https://calcul-allure.com/api\n\n## Lecture seule\n\nToutes les opérations sont des `GET` sans effet de bord : rien n'est créé, modifié ni stocké. Elles sont donc idempotentes (`x-idempotent: true`) et sûres à réessayer ou à paralléliser, et aucun bac à sable n'est nécessaire pour les appeler, il n'y a aucun état à corrompre. Les seules méthodes acceptées sont `GET`, `HEAD` et `OPTIONS`.\n\n## Erreurs\n\nToutes les erreurs renvoient le schéma `Error` en JSON : `error` (texte historique en anglais, conservé pour la compatibilité), `code` (identifiant stable en snake_case, à tester plutôt que le texte : `missing_parameter`, `invalid_distance`, `invalid_time`, `not_found`, `method_not_allowed`, `unsupported_api_version`), `message` (même information en français), `hint` (comment corriger l'appel, avec une valeur d'exemple) et `docs`. Un paramètre manquant ou invalide renvoie `400` ; un chemin `/api/*` inconnu renvoie `404` avec la liste des endpoints dans `endpoints` ; une méthode non autorisée renvoie `405` avec l'en-tête `Allow` et le champ `allowed_methods`.\n\n## Stabilité et versions\n\nL'API n'est pas versionnée : `/api/*` est la surface stable (v1) et le reste. Les évolutions sont additives (nouveaux endpoints, nouveaux champs), les champs existants ne changent ni de nom, ni de type, ni de sens. Une rupture, si elle devait survenir, serait annoncée au moins 6 mois à l'avance sur https://calcul-allure.com/api et dans cette spécification, et les endpoints concernés renverraient d'ici là les en-têtes `Deprecation` (RFC 9745) et `Sunset` (RFC 8594). Chaque réponse JSON porte l'en-tête `API-Version: 1`, qui ne changerait qu'à l'issue de ce préavis ; un client peut aussi envoyer l'en-tête de requête facultatif `API-Version: 1` pour épingler la version attendue (toute autre valeur renvoie 400 `unsupported_api_version`). Voir `x-versioning-policy`.",
    "termsOfService": "https://calcul-allure.com/api",
    "contact": {
      "name": "Calcul Allure",
      "url": "https://calcul-allure.com"
    },
    "license": {
      "name": "MIT",
      "identifier": "MIT"
    },
    "x-no-auth": true,
    "x-cors": "*",
    "x-rate-limit": "Aucune limite formelle. Usage raisonnable attendu : mettez les réponses en cache, évitez les boucles de plusieurs requêtes par seconde.",
    "x-versioning-policy": {
      "versioned": false,
      "stable-surface": "https://calcul-allure.com/api/*",
      "current-version": "1.0.0",
      "change-policy": "additive-only",
      "breaking-change-notice-period": "P6M",
      "deprecation-headers": [
        "Deprecation (RFC 9745)",
        "Sunset (RFC 8594)"
      ],
      "announcement-urls": [
        "https://calcul-allure.com/api",
        "https://calcul-allure.com/openapi.json"
      ],
      "description": "API non versionnée : les chemins /api/* forment la surface stable (v1), sans préfixe de version et sans en-tête de version à envoyer. Chaque réponse JSON (succès comme erreur) porte l'en-tête de réponse `API-Version: 1` ; cette valeur ne changerait qu'en cas de rupture, après le préavis. Seules des évolutions additives sont apportées (nouveaux endpoints, nouveaux champs de réponse) ; un client existant continue de fonctionner. Toute rupture serait annoncée au moins 6 mois à l'avance sur https://calcul-allure.com/api et dans cette spécification, et l'endpoint concerné renverrait pendant toute la période de préavis les en-têtes Deprecation (RFC 9745) et Sunset (RFC 8594) indiquant sa date de retrait.",
      "version-header": "API-Version",
      "version-header-value": "1",
      "version-request-header": "API-Version (optional, enum: 1)"
    },
    "x-idempotent": true,
    "x-read-only": true,
    "x-safe-to-retry": true
  },
  "externalDocs": {
    "description": "Documentation en français (page HTML)",
    "url": "https://calcul-allure.com/api"
  },
  "servers": [
    {
      "url": "https://calcul-allure.com",
      "description": "Production (Cloudflare Pages Functions)"
    }
  ],
  "tags": [
    {
      "name": "allure",
      "description": "Calcul d'allure, de vitesse et de temps de passage"
    },
    {
      "name": "plan",
      "description": "Plan d'entraînement personnalisé"
    },
    {
      "name": "service",
      "description": "État du service"
    }
  ],
  "paths": {
    "/api/pace": {
      "get": {
        "tags": [
          "allure"
        ],
        "operationId": "getPace",
        "summary": "Calcule l'allure, la vitesse et les temps de passage",
        "description": "Renvoie l'allure moyenne (min/km et secondes/km), la vitesse (km/h), le temps formaté et les temps de passage aux points clés (1 km, 5 km, 10 km, semi, arrivée) pour une distance et un temps total. Sans authentification, CORS ouvert, cache 60 s.",
        "x-no-auth": true,
        "x-cors": "*",
        "parameters": [
          {
            "name": "distance",
            "in": "query",
            "required": true,
            "description": "Distance à parcourir : soit un nombre en kilomètres (ex. `21.0975`, `15`, `0.4`), soit un alias. Insensible à la casse et aux espaces.",
            "schema": {
              "oneOf": [
                {
                  "type": "string",
                  "enum": [
                    "5km",
                    "10km",
                    "semi",
                    "semi-marathon",
                    "marathon",
                    "100m",
                    "200m",
                    "400m",
                    "800m",
                    "1500m",
                    "3000m",
                    "5000m",
                    "10000m"
                  ],
                  "description": "Alias de distance"
                },
                {
                  "type": "number",
                  "exclusiveMinimum": 0,
                  "description": "Distance en kilomètres"
                }
              ]
            },
            "examples": {
              "alias": {
                "summary": "Alias semi-marathon",
                "value": "semi"
              },
              "kilometres": {
                "summary": "Nombre en km (15 km)",
                "value": 15
              },
              "piste": {
                "summary": "Alias piste 400 m",
                "value": "400m"
              }
            }
          },
          {
            "name": "time",
            "in": "query",
            "required": true,
            "description": "Temps total. Formats acceptés : secondes entières (6300), `1h45m00s`, `1h45m`, `1h45`, `45m30s`, `1:45:00` ou `45:30`. Alias historique accepté : `temps`.",
            "schema": {
              "type": "string",
              "pattern": "^(\\d+|\\d+h\\d+m\\d+s|\\d+h\\d+m?|\\d+m\\d+s|\\d+:\\d+(:\\d+)?)$"
            },
            "examples": {
              "seconds": {
                "summary": "Secondes",
                "value": "6300"
              },
              "hms": {
                "summary": "Heures, minutes, secondes",
                "value": "1h45m00s"
              },
              "colon": {
                "summary": "Notation hh:mm:ss",
                "value": "1:45:00"
              }
            }
          },
          {
            "name": "temps",
            "in": "query",
            "required": false,
            "deprecated": true,
            "description": "Alias historique de `time` (même format). Ignoré si `time` est présent.",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/ApiVersion"
          }
        ],
        "responses": {
          "200": {
            "description": "Allure calculée",
            "headers": {
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                },
                "description": "CORS ouvert"
              },
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "const": "public, max-age=60"
                },
                "description": "Cache 60 secondes"
              },
              "API-Version": {
                "$ref": "#/components/headers/API-Version"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaceResult"
                },
                "examples": {
                  "semi": {
                    "summary": "Semi-marathon en 1h45",
                    "x-query": {
                      "distance": "semi",
                      "time": "1h45m00s"
                    },
                    "value": {
                      "distance_km": 21.0975,
                      "distance_label": "Semi-marathon",
                      "time": "1h45'00\"",
                      "time_seconds": 6300,
                      "pace": "4'59\"",
                      "pace_seconds_per_km": 298.61358,
                      "speed": "12.06 km/h",
                      "speed_kmh": 12.06,
                      "splits": [
                        {
                          "km": 1,
                          "label": "1 km",
                          "time": "4'59\""
                        },
                        {
                          "km": 5,
                          "label": "5 km",
                          "time": "24'53\""
                        },
                        {
                          "km": 10,
                          "label": "10 km",
                          "time": "49'46\""
                        },
                        {
                          "km": 21.0975,
                          "label": "Arrivée (semi-marathon)",
                          "time": "1h45'00\""
                        }
                      ]
                    }
                  },
                  "dixKm": {
                    "summary": "10 km en 50 min",
                    "x-query": {
                      "distance": "10km",
                      "time": "50:00"
                    },
                    "value": {
                      "distance_km": 10,
                      "distance_label": "10 km",
                      "time": "50'00\"",
                      "time_seconds": 3000,
                      "pace": "5'00\"",
                      "pace_seconds_per_km": 300,
                      "speed": "12.00 km/h",
                      "speed_kmh": 12,
                      "splits": [
                        {
                          "km": 1,
                          "label": "1 km",
                          "time": "5'00\""
                        },
                        {
                          "km": 5,
                          "label": "5 km",
                          "time": "25'00\""
                        },
                        {
                          "km": 10,
                          "label": "10 km",
                          "time": "50'00\""
                        }
                      ]
                    }
                  },
                  "piste400": {
                    "summary": "400 m en 58 s",
                    "x-query": {
                      "distance": "400m",
                      "time": "58"
                    },
                    "value": {
                      "distance_km": 0.4,
                      "distance_label": "400 m",
                      "time": "0'58\"",
                      "time_seconds": 58,
                      "pace": "2'25\"",
                      "pace_seconds_per_km": 145,
                      "speed": "24.83 km/h",
                      "speed_kmh": 24.83,
                      "splits": [
                        {
                          "km": 0.4,
                          "label": "0.4 km",
                          "time": "0'58\""
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Paramètre manquant ou invalide",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "missing": {
                    "summary": "Paramètres manquants",
                    "x-query": {},
                    "value": {
                      "error": "Required parameters: distance (e.g. 21.0975 or 'semi') and time (e.g. 6300 or '1h45m00s')",
                      "code": "missing_parameter",
                      "message": "Paramètres requis : distance (par exemple 21.0975 ou \"semi\") et time (par exemple 6300 ou \"1h45m00s\").",
                      "hint": "Ajoutez les deux paramètres dans l'URL, par exemple /api/pace?distance=semi&time=1h45m00s.",
                      "docs": "https://calcul-allure.com/api"
                    }
                  },
                  "badDistance": {
                    "summary": "Distance invalide",
                    "x-query": {
                      "distance": "abc",
                      "time": "58"
                    },
                    "value": {
                      "error": "Invalid distance: \"abc\". Use a number in km (e.g. 21.0975) or an alias (5km, 10km, semi, marathon).",
                      "code": "invalid_distance",
                      "message": "Distance invalide : \"abc\". Utilisez un nombre de kilomètres (par exemple 21.0975) ou un alias (5km, 10km, semi, marathon, 400m, 800m, 1500m, 3000m).",
                      "hint": "Exemple valide : distance=semi (ou distance=21.0975 pour la même distance en kilomètres).",
                      "docs": "https://calcul-allure.com/api"
                    }
                  }
                }
              }
            },
            "headers": {
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                },
                "description": "CORS ouvert"
              },
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "const": "no-store"
                },
                "description": "Les erreurs ne sont pas mises en cache"
              },
              "API-Version": {
                "$ref": "#/components/headers/API-Version"
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          }
        },
        "x-idempotent": true,
        "x-side-effects": "none"
      }
    },
    "/api/plan": {
      "get": {
        "tags": [
          "plan"
        ],
        "operationId": "getTrainingPlan",
        "summary": "Génère un plan d'entraînement personnalisé",
        "description": "Génère un plan semaine par semaine (phases Base, Development, Intensity, Taper, Race Prep, Race) avec les allures d'entraînement dérivées du temps visé. Le niveau (beginner, intermediate, advanced) est déduit automatiquement du temps cible. Les titres et descriptions de séances sont en français. Sans authentification, CORS ouvert, cache 60 s.",
        "x-no-auth": true,
        "x-cors": "*",
        "parameters": [
          {
            "name": "distance",
            "in": "query",
            "required": true,
            "description": "Distance de la course. Alias acceptés (insensibles à la casse) ou valeur numérique en km.",
            "schema": {
              "type": "string",
              "enum": [
                "5km",
                "5000m",
                "5",
                "10km",
                "10000m",
                "10",
                "semi",
                "semi-marathon",
                "21.0975",
                "marathon",
                "42.195"
              ]
            },
            "example": "semi"
          },
          {
            "name": "time",
            "in": "query",
            "required": true,
            "description": "Temps visé sur la course. Temps total. Formats acceptés : secondes entières (6300), `1h45m00s`, `1h45m`, `1h45`, `45m30s`, `1:45:00` ou `45:30`. Alias historique accepté : `temps`.",
            "schema": {
              "type": "string",
              "pattern": "^(\\d+|\\d+h\\d+m\\d+s|\\d+h\\d+m?|\\d+m\\d+s|\\d+:\\d+(:\\d+)?)$"
            },
            "example": "1h45m00s"
          },
          {
            "name": "temps",
            "in": "query",
            "required": false,
            "deprecated": true,
            "description": "Alias historique de `time`. Ignoré si `time` est présent.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "age",
            "in": "query",
            "required": false,
            "description": "Âge du coureur. À partir de 40 ans les récupérations sont allongées de 10 % et le volume facile réduit de 5 % ; à partir de 50 ans, +20 % et -15 %. Valeur hors de ]0, 120[ ignorée.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 119
            },
            "example": 45
          },
          {
            "name": "sessions",
            "in": "query",
            "required": false,
            "description": "Nombre de séances par semaine, de 2 à 6 (défaut : 3). Les 5e et 6e séances ne sont ajoutées que pour le semi-marathon et le marathon. Valeur hors plage : défaut appliqué.",
            "schema": {
              "type": "integer",
              "minimum": 2,
              "maximum": 6,
              "default": 3
            },
            "example": 4
          },
          {
            "name": "weeks",
            "in": "query",
            "required": false,
            "description": "Durée totale du plan en semaines, bornée à la plage de la distance : 5 km et 10 km de 6 à 12 (défaut 8), semi-marathon de 8 à 20 (défaut 12), marathon de 12 à 24 (défaut 16). Une valeur hors plage est ramenée à la borne la plus proche.",
            "schema": {
              "type": "integer",
              "minimum": 6,
              "maximum": 24
            },
            "example": 16
          },
          {
            "name": "vo2max",
            "in": "query",
            "required": false,
            "description": "VO2max estimée (ml/kg/min), strictement entre 10 et 100. Si présente, les allures d'entraînement sont calculées à partir de la VMA (VO2max / 3,5) : facile 65 %, sortie longue 72 %, tempo 82 %, fractionné 95 % de VMA.",
            "schema": {
              "type": "number",
              "exclusiveMinimum": 10,
              "exclusiveMaximum": 100
            },
            "example": 52
          },
          {
            "name": "previous_time",
            "in": "query",
            "required": false,
            "description": "Temps réalisé précédemment sur la même distance (mêmes formats que `time`). Sert à calculer l'écart avec l'objectif et à pondérer les phases : écart > 15 % allonge la phase de base, écart < 5 % allonge la phase d'intensité.",
            "schema": {
              "type": "string"
            },
            "example": "1h52m00s"
          },
          {
            "$ref": "#/components/parameters/ApiVersion"
          }
        ],
        "responses": {
          "200": {
            "description": "Plan d'entraînement généré",
            "headers": {
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                },
                "description": "CORS ouvert"
              },
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "const": "public, max-age=60"
                },
                "description": "Cache 60 secondes"
              },
              "API-Version": {
                "$ref": "#/components/headers/API-Version"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlanResult"
                },
                "examples": {
                  "cinqKm": {
                    "summary": "5 km en 25 min, plan de 6 semaines",
                    "x-query": {
                      "distance": "5km",
                      "time": "25m00s",
                      "weeks": "6"
                    },
                    "value": {
                      "distance_km": 5,
                      "distance_label": "5 km",
                      "target_time": "25'00\"",
                      "target_time_seconds": 1500,
                      "level": "intermediate",
                      "paces": {
                        "race": {
                          "display": "5'00\"/km",
                          "seconds_per_km": 300
                        },
                        "easy": {
                          "display": "6'30\"/km",
                          "seconds_per_km": 390
                        },
                        "tempo": {
                          "display": "5'15\"/km",
                          "seconds_per_km": 315
                        },
                        "interval": {
                          "display": "4'45\"/km",
                          "seconds_per_km": 285
                        },
                        "long_run": {
                          "display": "6'00\"/km",
                          "seconds_per_km": 360
                        }
                      },
                      "total_weeks": 6,
                      "sessions_per_week": 3,
                      "weeks": [
                        {
                          "week": 1,
                          "phase": "Base",
                          "sessions": [
                            {
                              "type": "easy",
                              "title": "Endurance fondamentale",
                              "description": "30 min à allure facile — 6'30\"/km"
                            },
                            {
                              "type": "interval",
                              "title": "Fractionné court",
                              "description": "6×400 m à allure 4'45\"/km, récupération 1'30\" trot entre chaque"
                            },
                            {
                              "type": "long_run",
                              "title": "Sortie longue",
                              "description": "35 min à allure facile — 6'30\"/km"
                            }
                          ]
                        },
                        {
                          "week": 2,
                          "phase": "Development",
                          "sessions": [
                            {
                              "type": "easy",
                              "title": "Endurance fondamentale",
                              "description": "35 min à allure facile — 6'30\"/km"
                            },
                            {
                              "type": "tempo",
                              "title": "Tempo",
                              "description": "20 min en continu à allure tempo — 5'15\"/km"
                            },
                            {
                              "type": "long_run",
                              "title": "Sortie longue",
                              "description": "45 min à allure facile — 6'30\"/km"
                            }
                          ]
                        },
                        {
                          "week": 3,
                          "phase": "Intensity",
                          "sessions": [
                            {
                              "type": "easy",
                              "title": "Endurance fondamentale",
                              "description": "35 min à allure facile — 6'30\"/km"
                            },
                            {
                              "type": "interval",
                              "title": "Fractionné",
                              "description": "8×400 m à allure 4'45\"/km, récupération 1'15\" trot entre chaque"
                            },
                            {
                              "type": "tempo",
                              "title": "Tempo",
                              "description": "25 min en continu à allure tempo — 5'15\"/km"
                            }
                          ]
                        },
                        {
                          "week": 4,
                          "phase": "Taper",
                          "sessions": [
                            {
                              "type": "easy",
                              "title": "Endurance fondamentale",
                              "description": "25 min à allure facile — 6'30\"/km"
                            },
                            {
                              "type": "interval",
                              "title": "Fractionné léger",
                              "description": "4×400 m à allure 4'45\"/km, récupération 2'00\" trot entre chaque"
                            },
                            {
                              "type": "easy",
                              "title": "Footing de récupération",
                              "description": "20 min très facile — 7'00\"/km"
                            }
                          ]
                        },
                        {
                          "week": 5,
                          "phase": "Taper",
                          "sessions": [
                            {
                              "type": "easy",
                              "title": "Endurance fondamentale",
                              "description": "25 min à allure facile — 6'30\"/km"
                            },
                            {
                              "type": "interval",
                              "title": "Fractionné léger",
                              "description": "4×400 m à allure 4'45\"/km, récupération 2'00\" trot entre chaque"
                            },
                            {
                              "type": "easy",
                              "title": "Footing de récupération",
                              "description": "20 min très facile — 7'00\"/km"
                            }
                          ]
                        },
                        {
                          "week": 6,
                          "phase": "Race",
                          "sessions": [
                            {
                              "type": "easy",
                              "title": "Activation",
                              "description": "15 min facile + 3×100 m progressifs pour activer les jambes"
                            },
                            {
                              "type": "race",
                              "title": "🏁 Course 5 km",
                              "description": "Objectif : 5'00\"/km — Donnez tout !"
                            },
                            {
                              "type": "easy",
                              "title": "Récupération",
                              "description": "10 min très facile, étirements doux — repos bien mérité"
                            }
                          ]
                        }
                      ],
                      "personalization": {
                        "age": null,
                        "sessions_per_week": 3,
                        "total_weeks": 6,
                        "uses_vo2max": false,
                        "vo2max": null,
                        "previous_time_seconds": null,
                        "gap_to_target_percent": null
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Paramètre manquant ou invalide",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "examples": {
                  "badDistance": {
                    "summary": "Distance non prise en charge",
                    "x-query": {
                      "distance": "15km",
                      "time": "1h"
                    },
                    "value": {
                      "error": "Invalid distance: \"15km\". Supported values: 5km, 10km, semi, marathon.",
                      "code": "invalid_distance",
                      "message": "Distance invalide : \"15km\". Le plan n'existe que pour 5km, 10km, semi (ou semi-marathon) et marathon.",
                      "hint": "Exemple valide : distance=marathon (les valeurs 5, 10, 21.0975 et 42.195 sont aussi acceptées).",
                      "docs": "https://calcul-allure.com/api"
                    }
                  }
                }
              }
            },
            "headers": {
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                },
                "description": "CORS ouvert"
              },
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "const": "no-store"
                },
                "description": "Les erreurs ne sont pas mises en cache"
              },
              "API-Version": {
                "$ref": "#/components/headers/API-Version"
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          }
        },
        "x-idempotent": true,
        "x-side-effects": "none"
      }
    },
    "/api/status": {
      "get": {
        "tags": [
          "service"
        ],
        "operationId": "getStatus",
        "summary": "État et version de l'API",
        "description": "Renvoie l'état du service, la version de l'API et l'horodatage courant (ISO 8601).",
        "responses": {
          "200": {
            "description": "Service en fonctionnement",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status"
                },
                "example": {
                  "status": "running",
                  "version": "1.0.0",
                  "timestamp": "2026-08-22T10:00:00.000Z"
                }
              }
            },
            "headers": {
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                },
                "description": "CORS ouvert"
              },
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "const": "no-store"
                },
                "description": "Réponse non mise en cache"
              },
              "API-Version": {
                "$ref": "#/components/headers/API-Version"
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          }
        },
        "x-idempotent": true,
        "x-side-effects": "none",
        "x-no-auth": true,
        "x-cors": "*",
        "parameters": [
          {
            "$ref": "#/components/parameters/ApiVersion"
          }
        ]
      }
    },
    "/health": {
      "get": {
        "tags": [
          "service"
        ],
        "operationId": "getHealth",
        "summary": "Healthcheck",
        "description": "Sonde de disponibilité minimale.",
        "responses": {
          "200": {
            "description": "Service disponible",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                },
                "example": {
                  "status": "ok"
                }
              }
            },
            "headers": {
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                },
                "description": "CORS ouvert"
              },
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "const": "no-store"
                },
                "description": "Réponse non mise en cache"
              },
              "API-Version": {
                "$ref": "#/components/headers/API-Version"
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          }
        },
        "x-idempotent": true,
        "x-side-effects": "none",
        "x-no-auth": true,
        "x-cors": "*",
        "parameters": [
          {
            "$ref": "#/components/parameters/ApiVersion"
          }
        ]
      }
    },
    "/api/calculer": {
      "get": {
        "tags": [
          "allure"
        ],
        "operationId": "getCalculerLegacy",
        "deprecated": true,
        "summary": "Ancienne URL : redirige vers /api/pace",
        "description": "Redirection permanente (301) vers `/api/pace` en conservant la query string. Conservée pour les liens existants ; utilisez `/api/pace`.",
        "parameters": [
          {
            "name": "distance",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Transmis tel quel à /api/pace"
          },
          {
            "name": "time",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Transmis tel quel à /api/pace"
          },
          {
            "$ref": "#/components/parameters/ApiVersion"
          }
        ],
        "responses": {
          "301": {
            "description": "Redirection vers /api/pace",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string",
                  "example": "/api/pace?distance=semi&time=6300"
                },
                "description": "URL de /api/pace avec la même query string"
              },
              "Access-Control-Allow-Origin": {
                "schema": {
                  "type": "string",
                  "const": "*"
                },
                "description": "CORS ouvert"
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "405": {
            "$ref": "#/components/responses/MethodNotAllowed"
          }
        },
        "x-idempotent": true,
        "x-side-effects": "none",
        "x-no-auth": true,
        "x-cors": "*"
      }
    }
  },
  "components": {
    "schemas": {
      "PaceResult": {
        "type": "object",
        "required": [
          "distance_km",
          "distance_label",
          "time",
          "time_seconds",
          "pace",
          "pace_seconds_per_km",
          "speed",
          "speed_kmh",
          "splits"
        ],
        "properties": {
          "distance_km": {
            "type": "number",
            "description": "Distance en kilomètres",
            "examples": [
              21.0975
            ]
          },
          "distance_label": {
            "type": "string",
            "description": "Libellé de la distance en français (ou `<km> km` pour une distance libre)",
            "examples": [
              "Semi-marathon"
            ]
          },
          "time": {
            "type": "string",
            "description": "Temps total formaté : `1h45'00\"` ou `50'00\"`",
            "examples": [
              "1h45'00\""
            ]
          },
          "time_seconds": {
            "type": "integer",
            "description": "Temps total en secondes",
            "examples": [
              6300
            ]
          },
          "pace": {
            "type": "string",
            "description": "Allure en min/km formatée `m'ss\"`",
            "examples": [
              "4'59\""
            ]
          },
          "pace_seconds_per_km": {
            "type": "number",
            "description": "Allure en secondes par kilomètre (6 décimales max)",
            "examples": [
              298.61358
            ]
          },
          "speed": {
            "type": "string",
            "description": "Vitesse formatée en km/h",
            "examples": [
              "12.06 km/h"
            ]
          },
          "speed_kmh": {
            "type": "number",
            "description": "Vitesse en km/h (2 décimales)",
            "examples": [
              12.06
            ]
          },
          "splits": {
            "type": "array",
            "description": "Temps de passage aux points clés (1, 5, 10, 21.0975, 42.195 km) inférieurs à la distance, puis arrivée",
            "items": {
              "$ref": "#/components/schemas/Split"
            }
          }
        },
        "description": "Résultat d'un calcul d'allure : allure moyenne, vitesse et temps de passage pour une distance et un temps donnés."
      },
      "Split": {
        "type": "object",
        "required": [
          "km",
          "label",
          "time"
        ],
        "properties": {
          "km": {
            "type": "number",
            "description": "Point de passage en km"
          },
          "label": {
            "type": "string",
            "description": "Libellé français du point de passage",
            "examples": [
              "5 km",
              "Arrivée (semi-marathon)"
            ]
          },
          "time": {
            "type": "string",
            "description": "Temps de passage formaté",
            "examples": [
              "24'53\""
            ]
          }
        },
        "description": "Temps de passage à un point clé du parcours."
      },
      "PaceZone": {
        "type": "object",
        "required": [
          "display",
          "seconds_per_km"
        ],
        "properties": {
          "display": {
            "type": "string",
            "description": "Allure formatée avec unité",
            "examples": [
              "5'00\"/km"
            ]
          },
          "seconds_per_km": {
            "type": "number",
            "description": "Allure en secondes par km"
          }
        },
        "description": "Allures d'entraînement dérivées du temps visé, en secondes par kilomètre et en texte formaté."
      },
      "PlanSession": {
        "type": "object",
        "required": [
          "type",
          "title",
          "description"
        ],
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "easy",
              "tempo",
              "interval",
              "long_run",
              "race"
            ],
            "description": "Type de séance."
          },
          "title": {
            "type": "string",
            "description": "Nom de la séance (français)"
          },
          "description": {
            "type": "string",
            "description": "Détail de la séance avec les allures (français)"
          }
        },
        "description": "Séance d'entraînement d'une semaine du plan. Titre et description en français."
      },
      "PlanWeek": {
        "type": "object",
        "required": [
          "week",
          "phase",
          "sessions"
        ],
        "properties": {
          "week": {
            "type": "integer",
            "minimum": 1,
            "description": "Numéro de la semaine, à partir de 1."
          },
          "phase": {
            "type": "string",
            "enum": [
              "Base",
              "Development",
              "Intensity",
              "Taper",
              "Race Prep",
              "Race"
            ],
            "description": "Phase d'entraînement en anglais : Base, Development, Intensity, Taper, Race Prep ou Race."
          },
          "sessions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlanSession"
            },
            "description": "Séances de la semaine, dans l'ordre."
          }
        },
        "description": "Semaine du plan : numéro, phase et séances."
      },
      "PlanResult": {
        "type": "object",
        "required": [
          "distance_km",
          "distance_label",
          "target_time",
          "target_time_seconds",
          "level",
          "paces",
          "total_weeks",
          "sessions_per_week",
          "weeks",
          "personalization"
        ],
        "properties": {
          "distance_km": {
            "type": "number",
            "enum": [
              5,
              10,
              21.0975,
              42.195
            ],
            "description": "Distance de la course en kilomètres."
          },
          "distance_label": {
            "type": "string",
            "enum": [
              "5 km",
              "10 km",
              "Semi-marathon",
              "Marathon"
            ],
            "description": "Libellé de la distance en français."
          },
          "target_time": {
            "type": "string",
            "description": "Temps visé formaté"
          },
          "target_time_seconds": {
            "type": "integer",
            "description": "Temps visé sur la course, en secondes."
          },
          "level": {
            "type": "string",
            "enum": [
              "beginner",
              "intermediate",
              "advanced"
            ],
            "description": "Niveau déduit du temps visé"
          },
          "paces": {
            "type": "object",
            "required": [
              "race",
              "easy",
              "tempo",
              "interval",
              "long_run"
            ],
            "properties": {
              "race": {
                "$ref": "#/components/schemas/PaceZone"
              },
              "easy": {
                "$ref": "#/components/schemas/PaceZone"
              },
              "tempo": {
                "$ref": "#/components/schemas/PaceZone"
              },
              "interval": {
                "$ref": "#/components/schemas/PaceZone"
              },
              "long_run": {
                "$ref": "#/components/schemas/PaceZone"
              }
            },
            "description": "Allures d'entraînement dérivées du temps visé."
          },
          "total_weeks": {
            "type": "integer",
            "minimum": 6,
            "maximum": 24,
            "description": "Durée du plan en semaines, après application des bornes propres à la distance."
          },
          "sessions_per_week": {
            "type": "integer",
            "minimum": 2,
            "maximum": 6,
            "description": "Nombre de séances par semaine réellement programmées."
          },
          "weeks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlanWeek"
            },
            "description": "Semaines du plan, dans l'ordre."
          },
          "personalization": {
            "type": "object",
            "required": [
              "age",
              "sessions_per_week",
              "total_weeks",
              "uses_vo2max",
              "vo2max",
              "previous_time_seconds",
              "gap_to_target_percent"
            ],
            "properties": {
              "age": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "sessions_per_week": {
                "type": "integer"
              },
              "total_weeks": {
                "type": "integer"
              },
              "uses_vo2max": {
                "type": "boolean"
              },
              "vo2max": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "previous_time_seconds": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "gap_to_target_percent": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Écart entre le temps précédent et l'objectif, en pourcentage arrondi"
              }
            },
            "description": "Options réellement appliquées, après remplacement des valeurs hors plage par les défauts."
          }
        },
        "description": "Plan d'entraînement complet généré pour une distance et un temps visé."
      },
      "Status": {
        "type": "object",
        "required": [
          "status",
          "version",
          "timestamp"
        ],
        "properties": {
          "status": {
            "type": "string",
            "const": "running",
            "description": "Toujours \"running\" quand le service répond."
          },
          "version": {
            "type": "string",
            "examples": [
              "1.0.0"
            ],
            "description": "Version de l'API, alignée sur info.version de cette spécification."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "Horodatage ISO 8601 de la réponse."
          }
        },
        "description": "État du service et version de l'API."
      },
      "Health": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "const": "ok",
            "description": "Toujours \"ok\" quand le service répond."
          }
        },
        "description": "Réponse de la sonde de disponibilité."
      },
      "Error": {
        "type": "object",
        "title": "Erreur",
        "description": "Erreur JSON, identique pour tous les endpoints et tous les statuts (400, 404, 405). Testez `code`, pas le texte : `error` et `message` peuvent être reformulés, `code` est stable. Renvoyée avec Content-Type: application/json; charset=utf-8, Access-Control-Allow-Origin: * et Cache-Control: no-store.",
        "required": [
          "error",
          "code",
          "message",
          "hint",
          "docs"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "Message d'erreur en anglais, champ historique conservé pour la compatibilité des intégrations existantes. Même information que `message`."
          },
          "code": {
            "type": "string",
            "description": "Identifiant stable de l'erreur, à tester plutôt que le texte du message.",
            "enum": [
              "missing_parameter",
              "invalid_distance",
              "invalid_time",
              "not_found",
              "method_not_allowed",
              "unsupported_api_version"
            ]
          },
          "message": {
            "type": "string",
            "description": "Message d'erreur en français, affichable tel quel à un utilisateur."
          },
          "hint": {
            "type": "string",
            "description": "Comment corriger l'appel, avec une valeur d'exemple valide."
          },
          "docs": {
            "type": "string",
            "format": "uri",
            "description": "URL de la documentation lisible de l'API.",
            "examples": [
              "https://calcul-allure.com/api"
            ]
          },
          "endpoints": {
            "type": "array",
            "description": "Chemins disponibles. Présent uniquement sur les 404 (`code: not_found`).",
            "items": {
              "type": "string"
            }
          },
          "allowed_methods": {
            "type": "array",
            "description": "Méthodes HTTP acceptées sur ce chemin, mêmes valeurs que l'en-tête `Allow`. Présent uniquement sur les 405 (`code: method_not_allowed`).",
            "items": {
              "type": "string"
            }
          }
        }
      }
    },
    "responses": {
      "NotFound": {
        "description": "Chemin /api/* inconnu. Le corps liste les endpoints disponibles dans `endpoints`.",
        "headers": {
          "Cache-Control": {
            "schema": {
              "type": "string",
              "const": "no-store"
            },
            "description": "Les erreurs ne sont pas mises en cache"
          },
          "Access-Control-Allow-Origin": {
            "schema": {
              "type": "string",
              "const": "*"
            },
            "description": "CORS ouvert"
          },
          "API-Version": {
            "$ref": "#/components/headers/API-Version"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "unknownPath": {
                "summary": "Chemin inexistant",
                "x-error-call": [
                  "notFoundBody",
                  "/api/inconnu"
                ],
                "value": {
                  "error": "Unknown endpoint: \"/api/inconnu\". Available endpoints: /api/pace, /api/plan, /api/status, /api/calculer, /health.",
                  "code": "not_found",
                  "message": "Chemin inconnu : \"/api/inconnu\". Cette API n'expose que /api/pace, /api/plan, /api/status, /api/calculer, /health.",
                  "hint": "Utilisez un des chemins listés dans \"endpoints\", par exemple /api/pace?distance=semi&time=1h45m00s. Documentation : https://calcul-allure.com/api, spécification : https://calcul-allure.com/openapi.json.",
                  "docs": "https://calcul-allure.com/api",
                  "endpoints": [
                    "/api/pace",
                    "/api/plan",
                    "/api/status",
                    "/api/calculer",
                    "/health"
                  ]
                }
              }
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "Méthode HTTP non autorisée : l'API est en lecture seule (GET, HEAD, OPTIONS).",
        "headers": {
          "Allow": {
            "schema": {
              "type": "string",
              "examples": [
                "GET, HEAD, OPTIONS"
              ]
            },
            "description": "Méthodes acceptées sur ce chemin"
          },
          "Cache-Control": {
            "schema": {
              "type": "string",
              "const": "no-store"
            },
            "description": "Les erreurs ne sont pas mises en cache"
          },
          "Access-Control-Allow-Origin": {
            "schema": {
              "type": "string",
              "const": "*"
            },
            "description": "CORS ouvert"
          },
          "API-Version": {
            "$ref": "#/components/headers/API-Version"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "examples": {
              "postOnPace": {
                "summary": "POST sur /api/pace",
                "x-error-call": [
                  "methodNotAllowedBody",
                  "POST",
                  "/api/pace",
                  [
                    "GET",
                    "HEAD",
                    "OPTIONS"
                  ]
                ],
                "value": {
                  "error": "Method POST is not allowed on /api/pace. Allowed methods: GET, HEAD, OPTIONS.",
                  "code": "method_not_allowed",
                  "message": "Méthode POST non autorisée sur /api/pace. Méthodes acceptées : GET, HEAD, OPTIONS.",
                  "hint": "L'API est en lecture seule : passez les paramètres dans l'URL, par exemple GET /api/pace?distance=semi&time=1h45m00s.",
                  "docs": "https://calcul-allure.com/api",
                  "allowed_methods": [
                    "GET",
                    "HEAD",
                    "OPTIONS"
                  ]
                }
              }
            }
          }
        }
      }
    },
    "headers": {
      "API-Version": {
        "description": "Version de la surface /api/*, présente sur chaque réponse JSON (succès comme erreur). Vaut `1` ; ne changerait qu'en cas de rupture annoncée 6 mois à l'avance (voir info.x-versioning-policy).",
        "schema": {
          "type": "string",
          "enum": [
            "1"
          ],
          "example": "1"
        }
      }
    },
    "parameters": {
      "ApiVersion": {
        "name": "API-Version",
        "in": "header",
        "required": false,
        "description": "Version de la surface /api/* attendue par le client (facultatif). Seule la valeur `1` est servie ; une autre valeur renvoie 400 `unsupported_api_version` plutôt qu'une réponse de forme inattendue. L'en-tête de réponse du même nom confirme la version servie.",
        "schema": {
          "type": "string",
          "enum": [
            "1"
          ],
          "default": "1"
        }
      }
    }
  }
}
