From a965c1ad2ba732cf6966dc13e56d17e50fbc4eae Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Wed, 19 Aug 2026 09:37:49 +0000 Subject: [PATCH] Plugin natif CoachSleep : le sommeil avec son fuseau MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 4 +- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/CoachSleep.swift | 167 ++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 ios/App/App/CoachSleep.swift diff --git a/CLAUDE.md b/CLAUDE.md index cbc262f..c7c6676 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 - **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. -- **É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 @@ -98,7 +98,7 @@ l'emplacement de la toolchain. ## 🚧 Backlog notable - 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 - **watchOS live workout compagnon** — Phases 1-3 RÉALISÉES (2026-05-25, sur `main`) : target `CoachWatch` + 4 fichiers Swift watchOS + plugin Capacitor diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index a45fb37..74c5a83 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -64,6 +64,7 @@ E2CE6C2AA1DF4F3CAD6E0001 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */; }; E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.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 */ /* Begin PBXContainerItemProxy section */ @@ -180,6 +181,7 @@ E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; EBBF61A35F291A021BA75C60 /* CoachLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachLiveActivityWidget.swift; sourceTree = ""; }; F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachHealthRoute.swift; sourceTree = ""; }; + F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.swift; sourceTree = ""; }; F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = ""; }; FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ @@ -296,6 +298,7 @@ C0AC4A07811751FB78AA0002 /* CoachAuth.swift */, D1BD5B1990CF3F2B9C5D0002 /* CoachWorkoutKit.swift */, F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */, + F1ABCDEF0123456789AB0004 /* CoachSleep.swift */, A99E51A99E51A99E51A90002 /* CoachAppleAuth.swift */, 60061E60061E60061E060002 /* CoachGoogleAuth.swift */, E2CE6C2AA1DF4F3CAD6E0002 /* MainViewController.swift */, @@ -554,6 +557,7 @@ 88ABE0043024C45A002636FD /* CoachQuickLog.swift in Sources */, 88ABE0053024C45A002636FD /* CoachQuickSync.swift in Sources */, F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */, + F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */, A99E51A99E51A99E51A90001 /* CoachAppleAuth.swift in Sources */, 60061E60061E60061E060001 /* CoachGoogleAuth.swift in Sources */, 88F28EB12FBB330E00E8306E /* CoachWorkoutObserver.swift in Sources */, diff --git a/ios/App/App/CoachSleep.swift b/ios/App/App/CoachSleep.swift new file mode 100644 index 0000000..d542ba2 --- /dev/null +++ b/ios/App/App/CoachSleep.swift @@ -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 (UTC−5) +// 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) + } +}