draw log final implementation

fix scheduler + unavailability stuff
add team resting view
paca_championship
Raz 1 year ago
parent 9291d63d05
commit 77b3f27685
  1. 8
      PadelClub.xcodeproj/project.pbxproj
  2. 47
      PadelClub/Data/DrawLog.swift
  3. 11
      PadelClub/Data/Match.swift
  4. 19
      PadelClub/Data/MatchScheduler.swift
  5. 38
      PadelClub/Data/TeamRegistration.swift
  6. 107
      PadelClub/Data/Tournament.swift
  7. 2
      PadelClub/Data/TournamentStore.swift
  8. 12
      PadelClub/Views/Match/Components/PlayerBlockView.swift
  9. 30
      PadelClub/Views/Round/DrawLogsView.swift
  10. 107
      PadelClub/Views/Round/PreviewBracketPositionView.swift
  11. 76
      PadelClub/Views/Round/RoundSettingsView.swift
  12. 167
      PadelClub/Views/Round/RoundView.swift
  13. 103
      PadelClub/Views/Team/TeamRestingView.swift
  14. 140
      PadelClub/Views/Team/TeamRowView.swift
  15. 18
      PadelClubTests/ServerDataTests.swift

@ -430,6 +430,9 @@
FF6761572CC7803600CC9BF2 /* DrawLogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6761562CC7803600CC9BF2 /* DrawLogsView.swift */; };
FF6761582CC7803600CC9BF2 /* DrawLogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6761562CC7803600CC9BF2 /* DrawLogsView.swift */; };
FF6761592CC7803600CC9BF2 /* DrawLogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6761562CC7803600CC9BF2 /* DrawLogsView.swift */; };
FF67615B2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF67615A2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift */; };
FF67615C2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF67615A2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift */; };
FF67615D2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF67615A2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift */; };
FF6EC8F72B94773200EA7F5A /* RowButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8F62B94773100EA7F5A /* RowButtonView.swift */; };
FF6EC8FB2B94788600EA7F5A /* TournamentButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8FA2B94788600EA7F5A /* TournamentButtonView.swift */; };
FF6EC8FE2B94792300EA7F5A /* Screen.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF6EC8FD2B94792300EA7F5A /* Screen.swift */; };
@ -1074,6 +1077,7 @@
FF663FBD2BE019EC0031AE83 /* TournamentFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentFilterView.swift; sourceTree = "<group>"; };
FF6761522CC77D1900CC9BF2 /* DrawLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawLog.swift; sourceTree = "<group>"; };
FF6761562CC7803600CC9BF2 /* DrawLogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrawLogsView.swift; sourceTree = "<group>"; };
FF67615A2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewBracketPositionView.swift; sourceTree = "<group>"; };
FF6EC8F62B94773100EA7F5A /* RowButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowButtonView.swift; sourceTree = "<group>"; };
FF6EC8FA2B94788600EA7F5A /* TournamentButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TournamentButtonView.swift; sourceTree = "<group>"; };
FF6EC8FD2B94792300EA7F5A /* Screen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Screen.swift; sourceTree = "<group>"; };
@ -1881,6 +1885,7 @@
FFC2DCB32BBE9ECD0046DB9F /* LoserRoundsView.swift */,
FF5647122C0B6F380081F995 /* LoserRoundSettingsView.swift */,
FF6761562CC7803600CC9BF2 /* DrawLogsView.swift */,
FF67615A2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift */,
);
path = Round;
sourceTree = "<group>";
@ -2454,6 +2459,7 @@
FF967D092BAF3D4000A9A3BD /* TeamDetailView.swift in Sources */,
FF5DA18F2BB9268800A33061 /* GroupStagesSettingsView.swift in Sources */,
FF663FBE2BE019EC0031AE83 /* TournamentFilterView.swift in Sources */,
FF67615D2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift in Sources */,
FF1F4B752BFA00FC000B4573 /* HtmlGenerator.swift in Sources */,
FF17CA532CBE4788003C7323 /* BracketCallingView.swift in Sources */,
FF8F26382BAD523300650388 /* PadelRule.swift in Sources */,
@ -2730,6 +2736,7 @@
FF4CC0012C996C0600151637 /* TeamDetailView.swift in Sources */,
FF4CC0022C996C0600151637 /* GroupStagesSettingsView.swift in Sources */,
FF4CC0032C996C0600151637 /* TournamentFilterView.swift in Sources */,
FF67615C2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift in Sources */,
FF4CC0042C996C0600151637 /* HtmlGenerator.swift in Sources */,
FF17CA542CBE4788003C7323 /* BracketCallingView.swift in Sources */,
FF4CC0052C996C0600151637 /* PadelRule.swift in Sources */,
@ -2985,6 +2992,7 @@
FF70FB802C90584900129CC2 /* TeamDetailView.swift in Sources */,
FF70FB812C90584900129CC2 /* GroupStagesSettingsView.swift in Sources */,
FF70FB822C90584900129CC2 /* TournamentFilterView.swift in Sources */,
FF67615B2CC8ED6900CC9BF2 /* PreviewBracketPositionView.swift in Sources */,
FF70FB832C90584900129CC2 /* HtmlGenerator.swift in Sources */,
FF17CA552CBE4788003C7323 /* BracketCallingView.swift in Sources */,
FF70FB842C90584900129CC2 /* PadelRule.swift in Sources */,

@ -19,29 +19,40 @@ final class DrawLog: ModelObject, Storable {
var id: String = Store.randomId()
var tournament: String
var drawDate: Date = Date()
var drawSeed: Int?
var drawPosition: Int
var drawSeed: Int
var drawMatchIndex: Int
var drawTeamPosition: TeamPosition
internal init(id: String = Store.randomId(), tournament: String, drawDate: Date = Date(), drawSeed: Int?, drawPosition: Int, drawTeamPosition: TeamPosition) {
internal init(id: String = Store.randomId(), tournament: String, drawDate: Date = Date(), drawSeed: Int, drawMatchIndex: Int, drawTeamPosition: TeamPosition) {
self.id = id
self.tournament = tournament
self.drawDate = drawDate
self.drawSeed = drawSeed
self.drawPosition = drawPosition
self.drawMatchIndex = drawMatchIndex
self.drawTeamPosition = drawTeamPosition
}
func tournamentObject() -> Tournament? {
Store.main.findById(self.tournament)
}
func computedBracketPosition() -> Int {
drawMatchIndex * 2 + drawTeamPosition.rawValue
}
func updateTeamBracketPosition(_ team: TeamRegistration) {
guard let match = drawMatch() else { return }
let seedPosition: Int = match.lockAndGetSeedPosition(atTeamPosition: drawTeamPosition)
team.bracketPosition = seedPosition
tournamentObject()?.updateTeamScores(in: seedPosition)
}
func exportedDrawLog() -> String {
[drawDate.localizedDate(), localizedDrawLogLabel(), localizedDrawBranch()].joined(separator: " ")
}
func localizedDrawSeedLabel() -> String {
if let drawSeed {
return "Tête de série #\(drawSeed + 1)"
} else {
return "Tête de série non trouvé"
}
return "Tête de série #\(drawSeed + 1)"
}
func localizedDrawLogLabel() -> String {
@ -52,11 +63,23 @@ final class DrawLog: ModelObject, Storable {
drawTeamPosition.localizedBranchLabel()
}
func drawMatch() -> Match? {
let roundIndex = RoundRule.roundIndex(fromMatchIndex: drawMatchIndex)
return tournamentStore.rounds.first(where: { $0.parent == nil && $0.index == roundIndex })?._matches().first(where: { $0.index == drawMatchIndex })
}
func positionLabel() -> String {
let roundIndex = RoundRule.roundIndex(fromMatchIndex: drawPosition)
return tournamentStore.rounds.first(where: { $0.parent == nil && $0.index == roundIndex })?._matches().first(where: { $0.index == drawPosition })?.roundAndMatchTitle() ?? ""
return drawMatch()?.roundAndMatchTitle() ?? ""
}
func roundLabel() -> String {
return drawMatch()?.roundTitle() ?? ""
}
func matchLabel() -> String {
return drawMatch()?.matchTitle() ?? ""
}
var tournamentStore: TournamentStore {
return TournamentStore.instance(tournamentId: self.tournament)
}
@ -69,7 +92,7 @@ final class DrawLog: ModelObject, Storable {
case _tournament = "tournament"
case _drawDate = "drawDate"
case _drawSeed = "drawSeed"
case _drawPosition = "drawPosition"
case _drawMatchIndex = "drawMatchIndex"
case _drawTeamPosition = "drawTeamPosition"
}

@ -233,6 +233,7 @@ defer {
groupStageObject?.updateGroupStageState()
roundObject?.updateTournamentState()
currentTournament()?.updateTournamentState()
teams().forEach({ $0.resetRestingTime() })
}
func resetScores() {
@ -534,7 +535,7 @@ defer {
if endDate == nil {
endDate = Date()
}
teams().forEach({ $0.resetRestingTime() })
winningTeamId = teamScoreWinning.teamRegistration
losingTeamId = teamScoreWalkout.teamRegistration
groupStageObject?.updateGroupStageState()
@ -557,7 +558,9 @@ defer {
teamOne?.hasArrived()
teamTwo?.hasArrived()
teamOne?.resetRestingTime()
teamTwo?.resetRestingTime()
winningTeamId = teamOne?.id
losingTeamId = teamTwo?.id
@ -923,6 +926,10 @@ defer {
(teams().compactMap({ $0.restingTime() }).max() ?? .distantFuture).timeIntervalSinceNow
}
func isValidSpot() -> Bool {
previousMatches().allSatisfy({ $0.isSeeded() == false })
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _round = "round"

@ -493,9 +493,17 @@ final class MatchScheduler : ModelObject, Storable {
if rotationIndex > 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], !freeCourtPreviousRotation.isEmpty {
print("Handling break time conflicts or waiting for free courts")
let previousPreviousRotationSlots = slots.filter { $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) }
let previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime)
let previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false)
var previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime)
var previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false)
if let courtsUnavailability, previousEndDate != nil {
previousEndDate = getFirstFreeCourt(startDate: previousEndDate!, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability).earliestFreeDate
}
if let courtsUnavailability, previousEndDateNoBreak != nil {
previousEndDateNoBreak = getFirstFreeCourt(startDate: previousEndDateNoBreak!, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability).earliestFreeDate
}
let noBreakAlreadyTested = previousRotationSlots.anySatisfy { $0.startDate == previousEndDateNoBreak }
if let previousEndDate, let previousEndDateNoBreak {
@ -651,9 +659,14 @@ final class MatchScheduler : ModelObject, Storable {
}
if freeCourtPerRotation[rotationIndex]?.count == courtsAvailable.count {
print("All courts in rotation \(rotationIndex) are free")
print("All courts in rotation \(rotationIndex) are free, minimumTargetedEndDate : \(minimumTargetedEndDate)")
}
if let courtsUnavailability {
let computedStartDateAndCourts = getFirstFreeCourt(startDate: minimumTargetedEndDate, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability)
return computedStartDateAndCourts.earliestFreeDate
}
return minimumTargetedEndDate
}

@ -139,11 +139,13 @@ final class TeamRegistration: ModelObject, Storable {
qualified = true
}
if let tournament = tournamentObject() {
let drawLog = DrawLog(tournament: tournament.id, drawSeed: index(in: tournament.selectedSortedTeams()), drawPosition: match.index, drawTeamPosition: teamPosition)
do {
try tournamentStore.drawLogs.addOrUpdate(instance: drawLog)
} catch {
Logger.error(error)
if let index = index(in: tournament.selectedSortedTeams()) {
let drawLog = DrawLog(tournament: tournament.id, drawSeed: index, drawMatchIndex: match.index, drawTeamPosition: teamPosition)
do {
try tournamentStore.drawLogs.addOrUpdate(instance: drawLog)
} catch {
Logger.error(error)
}
}
tournament.updateTeamScores(in: bracketPosition)
}
@ -556,8 +558,21 @@ final class TeamRegistration: ModelObject, Storable {
}
}
var _cachedRestingTime: (Bool, Date?)?
func restingTime() -> Date? {
matches().sorted(by: \.computedEndDateForSorting).last?.endDate
if let _cachedRestingTime { return _cachedRestingTime.1 }
let restingTime = matches().filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).last?.endDate
_cachedRestingTime = (true, restingTime)
return restingTime
}
func resetRestingTime() {
_cachedRestingTime = nil
}
var restingTimeForSorting: Date {
restingTime()!
}
func teamNameLabel() -> String {
@ -568,6 +583,17 @@ final class TeamRegistration: ModelObject, Storable {
}
}
func isDifferentPosition(_ drawMatchIndex: Int?) -> Bool {
if let bracketPosition, let drawMatchIndex {
return drawMatchIndex != bracketPosition
} else if let bracketPosition {
return true
} else if let drawMatchIndex {
return true
}
return false
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _tournament = "tournament"

@ -1,5 +1,5 @@
//
// Tournament.swift
// swift
// PadelClub
//
// Created by Laurent Morvillier on 02/02/2024.
@ -558,7 +558,7 @@ defer {
return endDate != nil
}
func state() -> Tournament.State {
func state() -> State {
if self.isCanceled == true {
return .canceled
}
@ -2275,6 +2275,109 @@ defer {
self.tournamentStore.drawLogs.sorted(by: \.drawDate)
}
func seedSpotsLeft() -> Bool {
let alreadySeededRounds = rounds().filter({ $0.seeds().isEmpty == false })
if alreadySeededRounds.isEmpty { return true }
let spotsLeft = alreadySeededRounds.flatMap({ $0.playedMatches() }).filter { $0.isEmpty() || $0.isValidSpot() }
return spotsLeft.isEmpty == false
}
func isRoundValidForSeeding(roundIndex: Int) -> Bool {
if let lastRoundWithSeeds = rounds().last(where: { $0.seeds().isEmpty == false }) {
return roundIndex >= lastRoundWithSeeds.index
} else {
return true
}
}
func updateSeedsBracketPosition() async {
await removeAllSeeds()
let drawLogs = drawLogs().reversed()
let seeds = seeds()
for (index, seed) in seeds.enumerated() {
if let drawLog = drawLogs.first(where: { $0.drawSeed == index }) {
drawLog.updateTeamBracketPosition(seed)
}
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: seeds)
} catch {
Logger.error(error)
}
}
func removeAllSeeds() async {
unsortedTeams().forEach({ team in
team.bracketPosition = nil
})
let ts = allRoundMatches().flatMap { match in
match.teamScores
}
do {
try tournamentStore.teamScores.delete(contentOfs: ts)
} catch {
Logger.error(error)
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams())
} catch {
Logger.error(error)
}
allRounds().forEach({ round in
round.enableRound()
})
}
func addNewRound(_ roundIndex: Int) async {
let round = Round(tournament: id, index: roundIndex, matchFormat: matchFormat)
let matchCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex)
let matchStartIndex = RoundRule.matchIndex(fromRoundIndex: roundIndex)
let nextRound = round.nextRound()
var currentIndex = 0
let matches = (0..<matchCount).map { index in //0 is final match
let computedIndex = index + matchStartIndex
let match = Match(round: round.id, index: computedIndex, matchFormat: round.matchFormat)
if let nextRound, let followingMatch = self.tournamentStore.matches.first(where: { $0.round == nextRound.id && $0.index == (computedIndex - 1) / 2 }) {
if followingMatch.disabled {
match.disabled = true
} else if computedIndex%2 == 1 && followingMatch.team(.one) != nil {
//index du match courant impair = position haut du prochain match
match.disabled = true
} else if computedIndex%2 == 0 && followingMatch.team(.two) != nil {
//index du match courant pair = position basse du prochain match
match.disabled = true
} else {
match.setMatchName(Match.setServerTitle(upperRound: round, matchIndex: currentIndex))
currentIndex += 1
}
} else {
match.setMatchName(Match.setServerTitle(upperRound: round, matchIndex: currentIndex))
currentIndex += 1
}
return match
}
do {
try tournamentStore.rounds.addOrUpdate(instance: round)
} catch {
Logger.error(error)
}
do {
try tournamentStore.matches.addOrUpdate(contentOfs: matches)
} catch {
Logger.error(error)
}
round.buildLoserBracket()
matches.filter { $0.disabled }.forEach {
$0._toggleLoserMatchDisableState(true)
}
}
func exportedDrawLogs() -> String {
var logs : [String] = ["Journal des tirages\n\n"]
logs.append(drawLogs().map { $0.exportedDrawLog() }.joined(separator: "\n\n"))

@ -52,7 +52,7 @@ class TournamentStore: Store, ObservableObject {
self.matches = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.teamScores = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.matchSchedulers = self.registerCollection(synchronized: false, indexed: indexed)
self.drawLogs = self.registerCollection(synchronized: false, indexed: indexed)
self.drawLogs = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.loadCollectionsFromServerIfNoFile()

@ -78,16 +78,8 @@ struct PlayerBlockView: View {
}
}
if displayRestingTime, let restingTime = team?.restingTime()?.timeIntervalSinceNow, let value = Date.hourMinuteFormatter.string(from: restingTime * -1) {
if restingTime > -300 {
Text("vient de finir")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("en repos depuis " + value)
.font(.footnote)
.foregroundStyle(.secondary)
}
if displayRestingTime, let team {
TeamRowView.TeamRestingView(team: team)
}
}
.bold(hasWon)

@ -18,12 +18,20 @@ struct DrawLogsView: View {
var body: some View {
List {
ForEach(drawLogs) { drawLog in
LabeledContent {
Text(drawLog.localizedDrawBranch())
} label: {
Text(drawLog.localizedDrawSeedLabel())
Text(drawLog.positionLabel())
Text(drawLog.drawDate.localizedDate())
HStack {
VStack(alignment: .leading) {
Text(drawLog.localizedDrawSeedLabel())
Text(drawLog.drawDate.localizedDate())
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing) {
Text(drawLog.positionLabel()).lineLimit(1).truncationMode(.middle)
Text(drawLog.localizedDrawBranch())
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
@ -39,6 +47,16 @@ struct DrawLogsView: View {
Label("Partager les tirages", systemImage: "square.and.arrow.up")
.labelStyle(.titleAndIcon)
}
Divider()
Button("Tout effacer", role: .destructive) {
do {
try tournament.tournamentStore.drawLogs.deleteAll()
} catch {
Logger.error(error)
}
}
} label: {
LabelOptions()
}

@ -0,0 +1,107 @@
//
// PreviewBracketPositionView.swift
// PadelClub
//
// Created by razmig on 23/10/2024.
//
import SwiftUI
struct PreviewBracketPositionView: View {
let seeds: [TeamRegistration]
let drawLogs: [DrawLog]
@State private var filterOption: PreviewBracketPositionFilterOption = .difference
enum PreviewBracketPositionFilterOption: Int, Identifiable, CaseIterable {
var id: Int { self.rawValue }
case all
case difference
case summon
func isDisplayable(_ team: TeamRegistration, drawLog: DrawLog?) -> Bool {
switch self {
case .all:
true
case .difference:
team.isDifferentPosition(drawLog?.computedBracketPosition()) == true
case .summon:
team.callDate != drawLog?.drawMatch()?.startDate
}
}
}
var body: some View {
List {
Section {
ForEach(seeds.indices, id: \.self) { seedIndex in
let seed = seeds[seedIndex]
let drawLog = drawLogs.first(where: { $0.drawSeed == seedIndex })
if filterOption.isDisplayable(seed, drawLog: drawLog) {
HStack {
VStack(alignment: .leading) {
Text("Tête de série #\(seedIndex + 1)").font(.caption)
TeamRowView.TeamView(team: seed)
TeamRowView.TeamCallDateView(team: seed)
}
Spacer()
if let drawLog {
VStack(alignment: .trailing) {
Text(drawLog.roundLabel()).lineLimit(1).truncationMode(.middle).font(.caption)
Text(drawLog.matchLabel()).lineLimit(1).truncationMode(.middle)
Text(drawLog.localizedDrawBranch())
if let expectedDate = drawLog.drawMatch()?.startDate {
Text(expectedDate.localizedDate())
.font(.caption)
} else {
Text("Aucun horaire")
.font(.caption)
}
}
}
}
.listRowView(isActive: true, color: seed.isDifferentPosition(drawLog?.computedBracketPosition()) ? .logoRed : .green, hideColorVariation: true)
}
}
} header: {
Picker(selection: $filterOption) {
Text("Tous").tag(PreviewBracketPositionFilterOption.all)
Text("Changements").tag(PreviewBracketPositionFilterOption.difference)
Text("Convoc.").tag(PreviewBracketPositionFilterOption.summon)
} label: {
Text("Filter")
}
.labelsHidden()
.pickerStyle(.segmented)
.textCase(nil)
}
}
.overlay(content: {
if seeds.isEmpty {
ContentUnavailableView("Aucune équipe", systemImage: "person.2.slash", description: Text("Aucun tête de série dans le tournoi."))
} else if filterOption == .difference, noSeedHasDifferentPlace() {
ContentUnavailableView("Aucun changement", systemImage: "dice", description: Text("Aucun changement dans le tableau."))
} else if filterOption == .summon, noSeedHasDifferentSummon() {
ContentUnavailableView("Aucun changement", systemImage: "dice", description: Text("Aucun changement dans le tableau."))
}
})
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Aperçu du tableau tiré")
}
func noSeedHasDifferentPlace() -> Bool {
seeds.enumerated().allSatisfy({ seedIndex, seed in
let drawLog = drawLogs.first(where: { $0.drawSeed == seedIndex })
return seed.isDifferentPosition(drawLog?.computedBracketPosition()) == false
})
}
func noSeedHasDifferentSummon() -> Bool {
seeds.enumerated().allSatisfy({ seedIndex, seed in
let drawLog = drawLogs.first(where: { $0.drawSeed == seedIndex })
return seed.callDate == drawLog?.drawMatch()?.startDate
})
}
}

@ -82,19 +82,19 @@ struct RoundSettingsView: View {
Text("Gestionnaire des tirages au sort")
}
if tournament.rounds().flatMap({ $0.seeds() }).count < tournament.seedsCount(), tournament.lastDrawnDate() != nil {
if tournament.rounds().flatMap({ $0.seeds() }).count < tournament.seedsCount(), tournament.lastDrawnDate() != nil, tournament.seedSpotsLeft() {
NavigationLink {
PreviewBracketPositionView(seeds: tournament.seeds(), drawLogs: tournament.drawLogs())
} label: {
Text("Aperçu du décalage")
Text("Aperçu du repositionnement")
}
RowButtonView("Décaler les têtes de série", role: .destructive) {
RowButtonView("Replacer toutes les têtes de série", role: .destructive) {
await tournament.updateSeedsBracketPosition()
}
}
} footer: {
Text("Vous avez une place libre dans votre tableau. Pour respecter le tirage au sort effectué, vous pouvez décaler les têtes de séries.")
Text("Vous avez une ou plusieurs places libres dans votre tableau. Pour respecter le tirage au sort effectué, vous pouvez décaler les têtes de séries.")
}
// Section {
@ -150,72 +150,12 @@ struct RoundSettingsView: View {
}
private func _removeAllSeeds() async {
tournament.unsortedTeams().forEach({ team in
team.bracketPosition = nil
})
let ts = tournament.allRoundMatches().flatMap { match in
match.teamScores
}
do {
try tournamentStore.teamScores.delete(contentOfs: ts)
} catch {
Logger.error(error)
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: tournament.unsortedTeams())
} catch {
Logger.error(error)
}
tournament.allRounds().forEach({ round in
round.enableRound()
})
await tournament.removeAllSeeds()
self.isEditingTournamentSeed.wrappedValue = true
}
private func _addNewRound(_ roundIndex: Int) async {
let round = Round(tournament: tournament.id, index: roundIndex, matchFormat: tournament.matchFormat)
let matchCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex)
let matchStartIndex = RoundRule.matchIndex(fromRoundIndex: roundIndex)
let nextRound = round.nextRound()
var currentIndex = 0
let matches = (0..<matchCount).map { index in //0 is final match
let computedIndex = index + matchStartIndex
let match = Match(round: round.id, index: computedIndex, matchFormat: round.matchFormat)
if let nextRound, let followingMatch = self.tournament.tournamentStore.matches.first(where: { $0.round == nextRound.id && $0.index == (computedIndex - 1) / 2 }) {
if followingMatch.disabled {
match.disabled = true
} else if computedIndex%2 == 1 && followingMatch.team(.one) != nil {
//index du match courant impair = position haut du prochain match
match.disabled = true
} else if computedIndex%2 == 0 && followingMatch.team(.two) != nil {
//index du match courant pair = position basse du prochain match
match.disabled = true
} else {
match.setMatchName(Match.setServerTitle(upperRound: round, matchIndex: currentIndex))
currentIndex += 1
}
} else {
match.setMatchName(Match.setServerTitle(upperRound: round, matchIndex: currentIndex))
currentIndex += 1
}
return match
}
do {
try tournamentStore.rounds.addOrUpdate(instance: round)
} catch {
Logger.error(error)
}
do {
try tournamentStore.matches.addOrUpdate(contentOfs: matches)
} catch {
Logger.error(error)
}
round.buildLoserBracket()
matches.filter { $0.disabled }.forEach {
$0._toggleLoserMatchDisableState(true)
}
await tournament.addNewRound(roundIndex)
}
private func _removeRound(_ lastRound: Round) async {

@ -15,7 +15,7 @@ struct RoundView: View {
@Environment(Tournament.self) var tournament: Tournament
@EnvironmentObject var dataStore: DataStore
@Environment(NavigationViewModel.self) private var navigation: NavigationViewModel
@State private var selectedSeedGroup: SeedInterval?
@State private var showPrintScreen: Bool = false
@ -37,14 +37,14 @@ struct RoundView: View {
let displayableMatches: [Match] = self.upperRound.round.playedMatches()
return displayableMatches.filter { match in
match.teamScores.count == 1
}
}.filter({ $0.isValidSpot() })
}
private var seedSpaceLeft: [Match] {
let displayableMatches: [Match] = self.upperRound.round.playedMatches()
return displayableMatches.filter { match in
match.teamScores.count == 0
}
}.filter({ $0.isValidSpot() })
}
private var availableSeedGroup: SeedInterval? {
@ -59,7 +59,7 @@ struct RoundView: View {
}
}
)}
var body: some View {
List {
let displayableMatches = upperRound.round.playedMatches().sorted(by: \.index)
@ -73,9 +73,9 @@ struct RoundView: View {
if disabledMatchesCount > 0 {
let bracketTip = BracketEditTip(nextRoundName: upperRound.round.nextRound()?.roundTitle())
TipView(bracketTip).tipStyle(tint: .green, asSection: true)
let leftToPlay = (RoundRule.numberOfMatches(forRoundIndex: upperRound.round.index) - disabledMatchesCount)
if upperRound.round.hasStarted() == false, leftToPlay >= 0 {
Section {
LabeledContent {
@ -96,7 +96,7 @@ struct RoundView: View {
showPrintScreen = true
}
.tipStyle(tint: .master, asSection: true)
if upperRound.round.index > 0 {
let correspondingLoserRoundTitle = upperRound.round.correspondingLoserRoundTitle()
Section {
@ -121,9 +121,10 @@ struct RoundView: View {
}
}
} else {
let isRoundValidForSeeding = tournament.isRoundValidForSeeding(roundIndex: upperRound.round.index)
let availableSeeds = tournament.availableSeeds()
let availableQualifiedTeams = tournament.availableQualifiedTeams()
if availableSeeds.isEmpty == false, let availableSeedGroup {
Section {
RowButtonView("Placer \(availableSeedGroup.localizedInterval())" + ((availableSeedGroup.isFixed() == false) ? " au hasard" : "")) {
@ -148,94 +149,53 @@ struct RoundView: View {
}
}
}
if availableQualifiedTeams.isEmpty == false {
let qualifiedOnSeedSpot = (spaceLeft.isEmpty || tournament.seeds().isEmpty) ? true : false
let availableSeedSpot : [any SpinDrawable] = qualifiedOnSeedSpot ? (seedSpaceLeft + spaceLeft).flatMap({ $0.matchSpots() }).filter({ $0.match.team( $0.teamPosition) == nil }) : spaceLeft
if availableSeedSpot.isEmpty == false {
Section {
DisclosureGroup {
ForEach(availableQualifiedTeams) { team in
NavigationLink {
SpinDrawView(drawees: [team], segments: availableSeedSpot) { results in
Task {
results.forEach { drawResult in
if let matchSpot : MatchSpot = availableSeedSpot[drawResult.drawIndex] as? MatchSpot {
team.setSeedPosition(inSpot: matchSpot.match, slot: matchSpot.teamPosition, opposingSeeding: false)
} else if let matchSpot : Match = availableSeedSpot[drawResult.drawIndex] as? Match {
team.setSeedPosition(inSpot: matchSpot, slot: nil, opposingSeeding: true)
}
Section {
DisclosureGroup {
ForEach(availableQualifiedTeams) { team in
NavigationLink {
SpinDrawView(drawees: [team], segments: availableSeedSpot) { results in
Task {
results.forEach { drawResult in
if let matchSpot : MatchSpot = availableSeedSpot[drawResult.drawIndex] as? MatchSpot {
team.setSeedPosition(inSpot: matchSpot.match, slot: matchSpot.teamPosition, opposingSeeding: false)
} else if let matchSpot : Match = availableSeedSpot[drawResult.drawIndex] as? Match {
team.setSeedPosition(inSpot: matchSpot, slot: nil, opposingSeeding: true)
}
_save(seeds: [team])
}
_save(seeds: [team])
}
} label: {
TeamRowView(team: team, displayCallDate: false)
}
} label: {
TeamRowView(team: team, displayCallDate: false)
}
} label: {
Text("Qualifié\(availableQualifiedTeams.count.pluralSuffix) à placer").badge(availableQualifiedTeams.count)
.disabled(availableSeedSpot.isEmpty || isRoundValidForSeeding == false)
}
} header: {
Text("Tirage au sort visuel d'un qualifié").font(.subheadline)
} label: {
Text("Qualifié\(availableQualifiedTeams.count.pluralSuffix) à placer").badge(availableQualifiedTeams.count)
}
} header: {
Text("Tirage au sort visuel d'un qualifié").font(.subheadline)
} footer: {
if availableSeedSpot.isEmpty || isRoundValidForSeeding == false {
Text("Aucune place disponible !")
.foregroundStyle(.red)
}
}
}
if availableSeeds.isEmpty == false {
if seedSpaceLeft.isEmpty == false {
Section {
DisclosureGroup {
ForEach(availableSeeds) { team in
NavigationLink {
SpinDrawView(drawees: [team], segments: seedSpaceLeft) { results in
Task {
results.forEach { drawResult in
team.setSeedPosition(inSpot: seedSpaceLeft[drawResult.drawIndex], slot: nil, opposingSeeding: false)
}
_save(seeds: [team])
}
}
} label: {
TeamRowView(team: team, displayCallDate: false)
}
}
} label: {
Text("Tête\(availableSeeds.count.pluralSuffix) de série à placer").badge(availableSeeds.count)
}
} header: {
Text("Tirage au sort visuel d'une tête de série").font(.subheadline)
}
} else if spaceLeft.isEmpty == false {
Section {
DisclosureGroup {
ForEach(availableSeeds) { team in
NavigationLink {
SpinDrawView(drawees: [team], segments: spaceLeft) { results in
Task {
results.forEach { drawResult in
team.setSeedPosition(inSpot: spaceLeft[drawResult.drawIndex], slot: nil, opposingSeeding: true)
}
_save(seeds: [team])
}
}
} label: {
TeamRowView(team: team, displayCallDate: false)
}
}
} label: {
Text("Tête\(availableSeeds.count.pluralSuffix) de série à placer").badge(availableSeeds.count)
}
} header: {
Text("Tirage au sort visuel d'une tête de série").font(.subheadline)
}
}
let spots = (seedSpaceLeft.isEmpty == false) ? seedSpaceLeft : spaceLeft
let opposingSeeding = (seedSpaceLeft.isEmpty == false) ? false : true
_drawSection(availableSeeds: availableSeeds, spots: spots, opposingSeeding: opposingSeeding, isRoundValidForSeeding: isRoundValidForSeeding)
}
}
if isEditingTournamentSeed.wrappedValue == true {
let slideToDelete = SlideToDeleteSeedTip()
TipView(slideToDelete).tipStyle(tint: .logoRed, asSection: true)
@ -259,11 +219,11 @@ struct RoundView: View {
}
}
#if DEBUG
#if DEBUG
Spacer()
Text(match.index.formatted() + " " + match.teamScores.count.formatted())
#endif
#endif
}
} footer: {
if isEditingTournamentSeed.wrappedValue == true && match.followingMatch()?.disabled == true {
@ -351,7 +311,7 @@ struct RoundView: View {
return spots
}
}
private func _save(seeds: [TeamRegistration]) {
do {
@ -363,10 +323,10 @@ struct RoundView: View {
if tournament.availableSeeds().isEmpty && tournament.availableQualifiedTeams().isEmpty {
self.isEditingTournamentSeed.wrappedValue = false
}
_refreshNames()
}
private func _save() {
if tournament.availableSeeds().isEmpty && tournament.availableQualifiedTeams().isEmpty {
self.isEditingTournamentSeed.wrappedValue = false
@ -399,13 +359,40 @@ struct RoundView: View {
}
}
}
private func _drawSection(availableSeeds: [TeamRegistration], spots: [Match], opposingSeeding: Bool, isRoundValidForSeeding: Bool) -> some View {
Section {
DisclosureGroup {
ForEach(availableSeeds) { team in
NavigationLink {
SpinDrawView(drawees: [team], segments: spots) { results in
Task {
results.forEach { drawResult in
team.setSeedPosition(inSpot: spots[drawResult.drawIndex], slot: nil, opposingSeeding: opposingSeeding)
}
_save(seeds: [team])
}
}
} label: {
TeamRowView(team: team, displayCallDate: false)
}
.disabled(spots.isEmpty || isRoundValidForSeeding == false)
}
} label: {
Text("Tête\(availableSeeds.count.pluralSuffix) de série à placer").badge(availableSeeds.count)
}
} header: {
Text("Tirage au sort visuel d'une tête de série").font(.subheadline)
} footer: {
if spots.isEmpty || isRoundValidForSeeding == false {
Text("Aucune place disponible ! Ajouter une manche via les réglages du tableau.")
.foregroundStyle(.red)
}
}
}
}
//#Preview {
// RoundView(round: Round.mock())
// .environment(Tournament.mock())
//}
struct MatchSpot: SpinDrawable {
let match: Match
let teamPosition: TeamPosition

@ -9,111 +9,81 @@ import SwiftUI
struct TeamRestingView: View {
@Environment(Tournament.self) var tournament: Tournament
@State private var sortingMode: SortingMode = .restingTime
@State private var displayMode: DisplayMode = .teams
@State private var selectedCourt: Int?
@State private var readyMatches: [Match] = []
@State private var matchesLeft: [Match] = []
@State private var teams: [TeamRegistration] = []
enum SortingMode: Int, Identifiable, CaseIterable {
enum DisplayMode: Int, Identifiable, CaseIterable {
var id: Int { self.rawValue }
case index
case teams
case restingTime
case court
func localizedSortingModeLabel() -> String {
switch self {
case .index:
return "Ordre"
case .court:
return "Terrain"
case .teams:
return "Équipes"
case .restingTime:
return "Repos"
return "Matchs"
}
}
}
var sortingModeCases: [SortingMode] {
var sortingModes = [SortingMode]()
sortingModes.append(.index)
sortingModes.append(.restingTime)
sortingModes.append(.court)
return sortingModes
}
func contentUnavailableDescriptionLabel() -> String {
switch sortingMode {
case .index:
return "Ce tournoi n'a aucun match prêt à démarrer"
switch displayMode {
case .restingTime:
return "Ce tournoi n'a aucun match prêt à démarrer"
case .court:
return "Ce tournoi n'a aucun match prêt à démarrer"
case .teams:
return "Ce tournoi n'a aucune équipe ayant déjà terminé un match."
}
}
var sortedMatches: [Match] {
switch sortingMode {
case .index:
return readyMatches
case .restingTime:
return readyMatches.sorted(by: \.restingTimeForSorting)
case .court:
return readyMatches.sorted(using: [.keyPath(\.courtIndexForSorting), .keyPath(\.restingTimeForSorting)], order: .ascending)
}
return readyMatches.sorted(by: \.restingTimeForSorting)
}
var sortedTeams: [TeamRegistration] {
return teams
}
var body: some View {
List {
Section {
Picker(selection: $selectedCourt) {
Text("Aucun").tag(nil as Int?)
ForEach(0..<tournament.courtCount, id: \.self) { courtIndex in
Text(tournament.courtName(atIndex: courtIndex)).tag(courtIndex as Int?)
switch displayMode {
case .teams:
if sortedTeams.isEmpty == false {
ForEach(sortedTeams) { team in
TeamRowView(team: team, displayRestingTime: true)
}
} else {
ContentUnavailableView("Aucune équipe en repos", systemImage: "xmark.circle", description: Text(contentUnavailableDescriptionLabel()))
}
} label: {
Text("Sur le terrain")
}
//
// Toggle(isOn: $checkCanPlay) {
// if isFree {
// Text("Vérifier le paiement ou la présence")
// } else {
// Text("Vérifier la présence")
// }
// }
// } footer: {
// if isFree {
// Text("Masque les matchs où un ou plusieurs joueurs qui ne sont pas encore arrivé")
// } else {
// Text("Masque les matchs où un ou plusieurs joueurs n'ont pas encore réglé ou qui ne sont pas encore arrivé")
// }
}
Section {
if sortedMatches.isEmpty == false {
ForEach(sortedMatches) { match in
MatchRowView(match: match, matchViewStyle: .followUpStyle, updatedField: selectedCourt)
case .restingTime:
if sortedMatches.isEmpty == false {
ForEach(sortedMatches) { match in
MatchRowView(match: match, matchViewStyle: .followUpStyle, updatedField: selectedCourt)
}
} else {
ContentUnavailableView("Aucun match à venir", systemImage: "xmark.circle", description: Text(contentUnavailableDescriptionLabel()))
}
} else {
ContentUnavailableView("Aucun match à venir", systemImage: "xmark.circle", description: Text(contentUnavailableDescriptionLabel()))
}
} header: {
Picker(selection: $sortingMode) {
ForEach(sortingModeCases) { sortingMode in
Picker(selection: $displayMode) {
ForEach(DisplayMode.allCases) { sortingMode in
Text(sortingMode.localizedSortingModeLabel()).tag(sortingMode)
}
} label: {
Text("Méthode de tri")
Text("Affichage")
}
.labelsHidden()
.pickerStyle(.segmented)
}
.headerProminence(.increased)
.textCase(nil)
}
.toolbarBackground(.visible, for: .navigationBar)
.navigationTitle("Match à suivre")
.navigationTitle("Temps de repos")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
.onAppear {
let allMatches = tournament.allMatches()
let matchesLeft = tournament.matchesLeft(allMatches)
@ -121,6 +91,7 @@ struct TeamRestingView: View {
let readyMatches = tournament.readyMatches(allMatches)
self.readyMatches = tournament.availableToStart(readyMatches, in: runningMatches, checkCanPlay: false)
self.matchesLeft = matchesLeft
self.teams = tournament.selectedSortedTeams().filter({ $0.restingTime() != nil }).sorted(by: \.restingTimeForSorting)
}
}
}

@ -12,66 +12,114 @@ struct TeamRowView: View {
var team: TeamRegistration
var teamPosition: TeamPosition? = nil
var displayCallDate: Bool = false
var displayRestingTime: Bool = false
var body: some View {
LabeledContent {
TeamWeightView(team: team, teamPosition: teamPosition)
} label: {
VStack(alignment: .leading) {
HStack {
if let groupStage = team.groupStageObject() {
HStack {
Text(groupStage.groupStageTitle(.title))
if let finalPosition = groupStage.finalPosition(ofTeam: team) {
Text((finalPosition + 1).ordinalFormatted())
}
}
} else if let round = team.initialRound() {
Text(round.roundTitle(.wide))
}
if let wildcardLabel = team.wildcardLabel() {
Text(wildcardLabel).italic().foregroundStyle(.red).font(.caption)
}
TeamHeadlineView(team: team)
TeamView(team: team)
}
if displayCallDate {
TeamCallDateView(team: team)
}
if displayRestingTime {
TeamRestingView(team: team)
}
}
}
struct TeamRestingView: View {
let team: TeamRegistration
@ViewBuilder
var body: some View {
if let restingTime = team.restingTime()?.timeIntervalSinceNow, let value = Date.hourMinuteFormatter.string(from: restingTime * -1) {
if restingTime > -300 {
Text("vient de finir")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Text("en repos depuis " + value)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
struct TeamView: View {
let team: TeamRegistration
if let name = team.name, name.isEmpty == false {
Text(name).foregroundStyle(.secondary).font(.footnote)
if team.players().isEmpty {
Text("Aucun joueur")
} else {
ForEach(team.players()) { player in
Text(player.playerLabel()).lineLimit(1).truncationMode(.tail)
}
}
var body: some View {
if let name = team.name, name.isEmpty == false {
Text(name).foregroundStyle(.secondary).font(.footnote)
if team.players().isEmpty {
Text("Aucun joueur")
} else {
CompactTeamView(team: team)
}
} else {
if team.players().isEmpty == false {
CompactTeamView(team: team)
} else {
if team.players().isEmpty == false {
ForEach(team.players()) { player in
Text(player.playerLabel()).lineLimit(1).truncationMode(.tail)
Text("Place réservée")
Text("Place réservée")
}
}
}
}
struct TeamHeadlineView: View {
let team: TeamRegistration
var body: some View {
HStack {
if let groupStage = team.groupStageObject() {
HStack {
Text(groupStage.groupStageTitle(.title))
if let finalPosition = groupStage.finalPosition(ofTeam: team) {
Text((finalPosition + 1).ordinalFormatted())
}
} else {
Text("Place réservée")
Text("Place réservée")
}
} else if let round = team.initialRound() {
Text(round.roundTitle(.wide))
}
}
if displayCallDate {
if let callDate = team.callDate {
Text("Déjà convoquée \(callDate.localizedDate())")
.foregroundStyle(.logoRed)
.italic()
.font(.caption)
} else {
Text("Pas encore convoquée")
.foregroundStyle(.logoRed)
.italic()
.font(.caption)
if let wildcardLabel = team.wildcardLabel() {
Text(wildcardLabel).italic().foregroundStyle(.red).font(.caption)
}
}
}
}
}
//#Preview {
// TeamRowView(team: TeamRegistration.mock())
//}
struct TeamCallDateView: View {
let team: TeamRegistration
var body: some View {
if let callDate = team.callDate {
Text("Déjà convoquée \(callDate.localizedDate())")
.foregroundStyle(.logoRed)
.italic()
.font(.caption)
} else {
Text("Pas encore convoquée")
.foregroundStyle(.logoRed)
.italic()
.font(.caption)
}
}
}
struct CompactTeamView: View {
let team: TeamRegistration
var body: some View {
ForEach(team.players()) { player in
Text(player.playerLabel()).lineLimit(1).truncationMode(.tail)
}
}
}
}

@ -369,4 +369,22 @@ final class ServerDataTests: XCTestCase {
}
func testDrawLog() async throws {
let tournament: [Tournament] = try await StoreCenter.main.service().get()
guard let tournamentId = tournament.first?.id else {
assertionFailure("missing tournament in database")
return
}
let drawLog = DrawLog(tournament: tournamentId, drawSeed: 1, drawMatchIndex: 1, drawTeamPosition: .two)
let d: DrawLog = try await StoreCenter.main.service().post(drawLog)
assert(d.tournament == drawLog.tournament)
assert(d.drawDate.formatted() == drawLog.drawDate.formatted())
assert(d.drawSeed == drawLog.drawSeed)
assert(d.drawTeamPosition == drawLog.drawTeamPosition)
assert(d.drawMatchIndex == drawLog.drawMatchIndex)
}
}

Loading…
Cancel
Save