DateCivile.swift 86 lignes · 3317 octets
import Foundation

/// Date sans heure ni fuseau — équivalent Swift de `java.time.LocalDate`, que
/// Foundation n'offre pas.
///
/// Le calendrier de planification raisonne en jours, jamais en instants : un
/// `Date` (qui est un instant UTC) donnerait des résultats dépendants du fuseau
/// de l'appareil. Toute l'arithmétique passe donc par un calendrier grégorien
/// figé sur UTC.
struct DateCivile: Equatable, Comparable, CustomStringConvertible {
    let annee: Int
    let mois: Int
    let jour: Int

    /// Calendrier grégorien en UTC : même résultat quel que soit le réglage de l'appareil.
    private static let calendrier: Calendar = {
        var c = Calendar(identifier: .gregorian)
        c.timeZone = TimeZone(secondsFromGMT: 0)!
        return c
    }()

    /// `nil` si la chaîne n'est pas un `AAAA-MM-JJ` valide (donnée abîmée par une
    /// synchronisation partielle).
    init?(iso: String) {
        let parts = iso.split(separator: "-", omittingEmptySubsequences: false)
        guard parts.count == 3,
              parts[0].count == 4, parts[1].count == 2, parts[2].count == 2,
              let a = Int(parts[0]), let m = Int(parts[1]), let j = Int(parts[2])
        else { return nil }
        // Rejette les dates inexistantes (31/02) : le calendrier les normaliserait en silence.
        let composantes = DateComponents(year: a, month: m, day: j)
        guard composantes.isValidDate(in: Self.calendrier) else { return nil }
        annee = a
        mois = m
        jour = j
    }

    private init(annee: Int, mois: Int, jour: Int) {
        self.annee = annee
        self.mois = mois
        self.jour = jour
    }

    private var instantUtc: Date {
        Self.calendrier.date(from: DateComponents(year: annee, month: mois, day: jour))!
    }

    private init(instantUtc: Date) {
        let c = Self.calendrier.dateComponents([.year, .month, .day], from: instantUtc)
        self.init(annee: c.year!, mois: c.month!, jour: c.day!)
    }

    /// 1 = lundi … 7 = dimanche (numérotation `java.time.DayOfWeek`).
    var jourDeSemaine: Int {
        // `Calendar.weekday` est 1 = dimanche … 7 = samedi.
        let weekday = Self.calendrier.component(.weekday, from: instantUtc)
        return weekday == 1 ? 7 : weekday - 1
    }

    func plusJours(_ n: Int) -> DateCivile {
        DateCivile(instantUtc: Self.calendrier.date(byAdding: .day, value: n, to: instantUtc)!)
    }

    /// « 09/01 » — format court des cartes kanban, identique au web.
    var jourMois: String {
        "\(Self.pad(jour, 2))/\(Self.pad(mois, 2))"
    }

    var iso: String {
        "\(Self.pad(annee, 4))-\(Self.pad(mois, 2))-\(Self.pad(jour, 2))"
    }

    /// Zéros de tête à la main : `String(format: "%02d", …)` mélange `Int`
    /// 64 bits et `%d` 32 bits dans les varargs, et décale les arguments suivants.
    private static func pad(_ valeur: Int, _ largeur: Int) -> String {
        let chiffres = String(valeur)
        guard chiffres.count < largeur else { return chiffres }
        return String(repeating: "0", count: largeur - chiffres.count) + chiffres
    }

    var description: String { iso }

    static func < (lhs: DateCivile, rhs: DateCivile) -> Bool {
        (lhs.annee, lhs.mois, lhs.jour) < (rhs.annee, rhs.mois, rhs.jour)
    }
}