Plugin natif CoachSleep : le sommeil avec son fuseau

L'interface HealthSample de @capgo/capacitor-health n'expose aucun champ
metadata — seul Workout en a un. Le fuseau d'une nuit
(HKMetadataKeyTimeZone), qu'Apple recommande pourtant de stocker avec le
sommeil, n'atteignait donc jamais le JavaScript.

Sans lui, douze nuits d'avril passées au Pérou s'affichaient 06:17 ->
14:25 au lieu de 23:17 -> 07:25, et leurs couchers étaient écartés du
calcul de régularité faute de pouvoir les situer.

Le plugin lit les échantillons de sommeil avec leur fuseau, leur source
et leur stade, sur le modèle de CoachHealthRoute. Le stade passe par
l'énumération HKCategoryValueSleepAnalysis et non par les entiers bruts,
qu'Apple ne publie pas. Le champ timeZone peut rester nul : Apple ne
garantit pas que la Watch renseigne la métadonnée, et c'est un cas
normal côté web.

Ajouté au projet Xcode manuellement : les sources de la cible App sont
référencées une par une dans project.pbxproj (seul CoachWatchWidgets est
un groupe synchronisé), donc un fichier posé sur le disque ne serait pas
compilé.

CLAUDE.md : le Mac mini est à jour depuis un moment, les builds iOS ne
sont plus bloqués. La mention contraire a fait déconseiller à tort des
chantiers natifs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-08-19 09:37:49 +00:00
parent 1d2d7a0e18
commit a965c1ad2b
3 changed files with 173 additions and 2 deletions

View File

@@ -10,7 +10,7 @@ Instructions pour Claude Code sur ce projet. Lis ce fichier en début de session
- **Repo Gitea** : encore nommé `coach-ios` (renommage `coach-mobile` à faire) ; fichier `coach-ios.local.json` conservé pour ne pas casser les refs Swift - **Repo Gitea** : encore nommé `coach-ios` (renommage `coach-mobile` à faire) ; fichier `coach-ios.local.json` conservé pour ne pas casser les refs Swift
- **Plateformes** : iOS 13+ et Android 8.0+ (API 26+, requis par Health Connect) - **Plateformes** : iOS 13+ et Android 8.0+ (API 26+, requis par Health Connect)
- **Build iOS** : nécessite le Mac mini (macOS 13+, Xcode 15+). Pas de Mac cloud nécessaire. - **Build iOS** : nécessite le Mac mini (macOS 13+, Xcode 15+). Pas de Mac cloud nécessaire.
- **État** : iOS prêt (HealthKit + cookie inject AppDelegate). Android prêt. Bloqué externe : upgrade macOS 26 pour build iOS + Play Console $25 ~3-5j. - **État** : iOS prêt (HealthKit + cookie inject AppDelegate). Android prêt. Mac mini à jour, builds iOS possibles (confirmé 2026-08-19). Reste bloqué : Play Console $25 ~3-5j pour Android.
## 🔒 Règles non négociables ## 🔒 Règles non négociables
@@ -98,7 +98,7 @@ l'emplacement de la toolchain.
## 🚧 Backlog notable ## 🚧 Backlog notable
- Renommer le repo Gitea `coach-ios``coach-mobile` - Renommer le repo Gitea `coach-ios``coach-mobile`
- Upgrade macOS 26 sur Mac mini pour reprendre les builds iOS - ~~Upgrade macOS 26 sur Mac mini~~ — fait, builds iOS opérationnels (2026-08-19)
- Play Console validation (~3-5j, $25 one-shot) pour upload Android - Play Console validation (~3-5j, $25 one-shot) pour upload Android
- **watchOS live workout compagnon** — Phases 1-3 RÉALISÉES (2026-05-25, sur - **watchOS live workout compagnon** — Phases 1-3 RÉALISÉES (2026-05-25, sur
`main`) : target `CoachWatch` + 4 fichiers Swift watchOS + plugin Capacitor `main`) : target `CoachWatch` + 4 fichiers Swift watchOS + plugin Capacitor

View File

@@ -64,6 +64,7 @@
E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; }; E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; };
E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; }; E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; };
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; }; F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; };
F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0004 /* CoachSleep.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -180,6 +181,7 @@
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; }; E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = "<group>"; }; EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = "<group>"; };
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = "<group>"; }; F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = "<group>"; };
F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.swift; sourceTree = "<group>"; };
F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; }; F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = "<group>"; };
FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -296,6 +298,7 @@
C0AC4A07811751FB78AA0002 /* CoachAuth.swift */, C0AC4A07811751FB78AA0002 /* CoachAuth.swift */,
D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */, D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */,
F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */, F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */,
F1ABCDEF0123456789AB0004 /* CoachSleep.swift */,
A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */, A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */,
60061E60061E60061E060002 /* CoachGoogleAuth.swift */, 60061E60061E60061E060002 /* CoachGoogleAuth.swift */,
E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */, E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */,
@@ -554,6 +557,7 @@
88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */, 88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */,
88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */, 88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */,
F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */, F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */,
F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */,
A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */, A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */,
60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */, 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */,
88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */, 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */,

View File

@@ -0,0 +1,167 @@
// CoachSleep.swift
// Plugin Capacitor natif iOS : lit les échantillons de sommeil HealthKit AVEC
// leur fuseau horaire et leur source. Comble un manque de
// @capgo/capacitor-health, dont l'interface `HealthSample` n'expose aucun champ
// `metadata` (seul `Workout` en a un) l'information n'arrive donc jamais
// jusqu'au JavaScript.
//
// Pourquoi ce plugin existe. Douze nuits d'avril 2026 s'affichaient à
// 06:17 14:25 au lieu de 23:17 07:25 : des nuits passées au Pérou (UTC5)
// rendues en heure de Zurich (UTC+2). Sans le fuseau d'origine, l'app ne peut
// ni les afficher juste, ni juger la régularité du coucher elle les écarte
// (cf. `web/sleep_score.py::bedtime_regularity`).
//
// Apple recommande explicitement de stocker ce fuseau pour le sommeil :
// « For best results when analyzing sleep samples, it's recommended that you
// store time zone metadata with your sleep sample data » (HKMetadataKeyTimeZone).
// La clé « takes a string value compatible with the NSTimeZone class's
// timeZoneWithName: method » donc un identifiant du type "America/Lima".
//
// Rien ne garantit que l'Apple Watch la renseigne : Apple la recommande aux
// apps qui écrivent, sans documenter ce que ses propres sources posent. Le
// champ `timeZone` peut donc rester nul, et c'est un cas normal, pas une
// panne d'où `tzSource`, qui dit d'où vient (ou ne vient pas) l'information.
//
// Usage côté JS :
// const r = await window.Capacitor.Plugins.CoachSleep.readSamples({
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
// });
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
// // r.available = false si HealthKit est indisponible ou non autorisé
import Foundation
import UIKit
import Capacitor
import HealthKit
@objc(CoachSleepPlugin)
public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "CoachSleepPlugin"
public let jsName = "CoachSleep"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "readSamples", returnType: CAPPluginReturnPromise),
]
private lazy var store = HKHealthStore()
// Plafond de sécurité. La lecture web tourne par tranches de 14 jours
// (~560 échantillons) ; six mois d'un coup en feraient ~7 000. On garde de
// la marge sans jamais tronquer en silence : `truncated` le dit.
private static let sampleLimit = 20000
public override func load() {
NSLog("[CoachSleep] plugin loaded (iOS \(UIDevice.current.systemVersion))")
super.load()
}
@objc func isAvailable(_ call: CAPPluginCall) {
call.resolve(["available": HKHealthStore.isHealthDataAvailable()])
}
@objc func readSamples(_ call: CAPPluginCall) {
guard HKHealthStore.isHealthDataAvailable() else {
call.resolve(["available": false, "samples": [], "reason": "HealthKit unavailable"])
return
}
guard let sleepType = HKObjectType.categoryType(forIdentifier: .sleepAnalysis) else {
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
return
}
guard let start = Self.parseISO(call.getString("startDate")),
let end = Self.parseISO(call.getString("endDate")) else {
call.reject("startDate et endDate requis (ISO 8601)")
return
}
// `.strictStartDate` n'est PAS utilisé : une nuit commence la veille et
// doit être trouvée par une fenêtre qui la chevauche, sinon la première
// nuit de chaque tranche disparaîtrait.
let pred = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
let sort = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: true)
let query = HKSampleQuery(sampleType: sleepType,
predicate: pred,
limit: Self.sampleLimit,
sortDescriptors: [sort]) { _, results, err in
if let err = err {
call.reject("Sleep fetch failed: \(err.localizedDescription)")
return
}
let samples = (results as? [HKCategorySample]) ?? []
let out = samples.map { Self.describe($0) }
call.resolve([
"available": true,
"samples": out,
"truncated": samples.count >= Self.sampleLimit,
])
}
store.execute(query)
}
// MARK: - Conversion
private static func describe(_ sample: HKCategorySample) -> [String: Any] {
var out: [String: Any] = [
"startDate": iso(sample.startDate),
"endDate": iso(sample.endDate),
"sourceName": sample.sourceRevision.source.name,
"platformId": sample.uuid.uuidString,
]
if let state = sleepState(sample.value) {
out["sleepState"] = state
}
// Fuseau de la nuit. Deux origines possibles, distinguées pour ne pas
// faire passer une valeur d'appareil pour une valeur de l'échantillon.
if let name = sample.metadata?[HKMetadataKeyTimeZone] as? String,
TimeZone(identifier: name) != nil {
out["timeZone"] = name
out["tzSource"] = "sample"
}
return out
}
/// Nomme le stade avec le vocabulaire déjà utilisé côté JS (celui de Capgo),
/// pour que l'agrégation par nuit n'ait rien à traduire.
///
/// Le passage par l'énumération plutôt que par les entiers bruts est
/// délibéré : Apple publie les cas, jamais leurs `rawValue`.
private static func sleepState(_ value: Int) -> String? {
guard let v = HKCategoryValueSleepAnalysis(rawValue: value) else { return nil }
switch v {
case .inBed: return "inBed"
case .awake: return "awake"
case .asleepCore: return "light" // Capgo nomme "light" le core d'Apple
case .asleepDeep: return "deep"
case .asleepREM: return "rem"
case .asleepUnspecified: return "asleep"
@unknown default: return "asleep" // un stade inédit reste du sommeil
}
}
// MARK: - Dates
private static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static let isoFormatterNoFraction: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private static func iso(_ date: Date) -> String {
isoFormatter.string(from: date)
}
/// Accepte les deux formes ISO 8601 : avec et sans fraction de seconde.
/// `ISO8601DateFormatter` rend `nil` sur celle qu'il n'attend pas, et le JS
/// envoie les deux selon le chemin (`toISOString()` en produit toujours,
/// une date recomposée à la main souvent pas).
private static func parseISO(_ value: String?) -> Date? {
guard let value = value, !value.isEmpty else { return nil }
return isoFormatter.date(from: value) ?? isoFormatterNoFraction.date(from: value)
}
}