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:
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* 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 */,
|
||||
|
||||
167
ios/App/App/CoachSleep.swift
Normal file
167
ios/App/App/CoachSleep.swift
Normal 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 (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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user