Add a SwiftTUI terminal app that lists race runners from the start list and results files, with an interactive menu to sort, filter by age group, toggle finishers vs. non-starters, and live-search by name. Parsing and transforms are written as pure functions over the file contents. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
6.3 KiB
Swift
213 lines
6.3 KiB
Swift
import Foundation
|
|
import SwiftTUI
|
|
|
|
// MARK: - Models
|
|
|
|
enum AgeGroup: Int, CustomStringConvertible, CaseIterable {
|
|
case junior = 1
|
|
case adult = 2
|
|
case senior = 3
|
|
|
|
var description: String {
|
|
switch self {
|
|
case .junior: return "Junior"
|
|
case .adult: return "Adult"
|
|
case .senior: return "Senior"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct Runner {
|
|
let name: String
|
|
let ageGroup: AgeGroup
|
|
let time: String
|
|
let number: Int
|
|
}
|
|
|
|
enum SortKey: String, CaseIterable {
|
|
case name
|
|
case ageGroup
|
|
case time
|
|
case number
|
|
|
|
func sort(_ lhs: Runner, _ rhs: Runner) -> Bool {
|
|
switch self {
|
|
case .name: return lhs.name < rhs.name
|
|
case .ageGroup: return lhs.ageGroup.rawValue < rhs.ageGroup.rawValue
|
|
case .time: return lhs.time < rhs.time
|
|
case .number: return lhs.number < rhs.number
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - IO
|
|
|
|
func readFile(_ path: String) -> String {
|
|
(try? String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)) ?? ""
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
func parseResults(_ content: String, separator: String) -> [Int: String] {
|
|
let pairs = content
|
|
.components(separatedBy: .newlines)
|
|
.compactMap { line -> (Int, String)? in
|
|
let cols = line.components(separatedBy: separator)
|
|
guard cols.count >= 2, let key = Int(cols[0]) else { return nil }
|
|
return (key, cols[1])
|
|
}
|
|
return Dictionary(pairs, uniquingKeysWith: { _, last in last })
|
|
}
|
|
|
|
func parseStarters(_ content: String, separator: String) -> [(nr: Int, ageGroup: Int, name: String)] {
|
|
content
|
|
.components(separatedBy: .newlines)
|
|
.compactMap { line in
|
|
let cols = line.components(separatedBy: separator)
|
|
guard cols.count >= 3, let nr = Int(cols[0]), let ageGroup = Int(cols[1])
|
|
else { return nil }
|
|
return (nr: nr, ageGroup: ageGroup, name: cols[2])
|
|
}
|
|
}
|
|
|
|
func assembleRunners(starters: [(nr: Int, ageGroup: Int, name: String)], results: [Int: String])
|
|
-> [Runner]
|
|
{
|
|
starters.compactMap { starter in
|
|
guard let time = results[starter.nr],
|
|
let group = AgeGroup(rawValue: starter.ageGroup)
|
|
else {
|
|
return nil
|
|
}
|
|
return Runner(name: starter.name, ageGroup: group, time: time, number: starter.nr)
|
|
}
|
|
}
|
|
|
|
func nonParticipants(starters: [(nr: Int, ageGroup: Int, name: String)], results: [Int: String])
|
|
-> [Runner]
|
|
{
|
|
starters.compactMap { starter in
|
|
guard results[starter.nr] == nil,
|
|
let group = AgeGroup(rawValue: starter.ageGroup)
|
|
else {
|
|
return nil
|
|
}
|
|
return Runner(name: starter.name, ageGroup: group, time: "DNS", number: starter.nr)
|
|
}
|
|
}
|
|
|
|
// MARK: - Transforms
|
|
|
|
func visibleRunners(
|
|
_ runners: [Runner],
|
|
query: String,
|
|
includedGroups: Set<AgeGroup>,
|
|
sortKey: SortKey
|
|
) -> [Runner] {
|
|
let needle = query.lowercased()
|
|
return runners
|
|
.filter { includedGroups.contains($0.ageGroup) }
|
|
.filter { needle.isEmpty || $0.name.lowercased().contains(needle) }
|
|
.sorted(by: sortKey.sort)
|
|
}
|
|
|
|
func toggling(_ groups: Set<AgeGroup>, _ group: AgeGroup) -> Set<AgeGroup> {
|
|
groups.contains(group) ? groups.subtracting([group]) : groups.union([group])
|
|
}
|
|
|
|
// MARK: - Formatting
|
|
|
|
func menuLabel(_ text: String, on: Bool) -> String {
|
|
on ? "[\(text)]" : " \(text) "
|
|
}
|
|
|
|
func formatRow(_ nr: String, _ name: String, _ group: String, _ time: String) -> String {
|
|
nr.padding(toLength: 6, withPad: " ", startingAt: 0)
|
|
+ name.padding(toLength: 22, withPad: " ", startingAt: 0)
|
|
+ group.padding(toLength: 9, withPad: " ", startingAt: 0)
|
|
+ time
|
|
}
|
|
|
|
// MARK: - Configuration
|
|
|
|
let resultsURL = "/Users/cediackermann/Projects/m323/Sources/MiniProject/data/results.txt"
|
|
let startListURL = "/Users/cediackermann/Projects/m323/Sources/MiniProject/data/startList.txt"
|
|
|
|
// MARK: - Entry Point
|
|
|
|
let results = parseResults(readFile(resultsURL), separator: "\t")
|
|
let starters = parseStarters(readFile(startListURL), separator: "\t")
|
|
let finished = assembleRunners(starters: starters, results: results)
|
|
let absent = nonParticipants(starters: starters, results: results)
|
|
|
|
Application(rootView: RunnerBrowser(finished: finished, absent: absent)).start()
|
|
|
|
// MARK: - TUI
|
|
|
|
struct RunnerBrowser: View {
|
|
let finished: [Runner]
|
|
let absent: [Runner]
|
|
|
|
@State private var query: String = ""
|
|
@State private var sortKey: SortKey = .number
|
|
@State private var includedGroups: Set<AgeGroup> = Set(AgeGroup.allCases)
|
|
@State private var showAbsent: Bool = false
|
|
|
|
private var source: [Runner] {
|
|
showAbsent ? absent : finished
|
|
}
|
|
|
|
private var visible: [Runner] {
|
|
visibleRunners(source, query: query, includedGroups: includedGroups, sortKey: sortKey)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading) {
|
|
Text("Stadt-Rundlauf - \(visible.count)/\(source.count) runners")
|
|
.bold()
|
|
|
|
HStack {
|
|
Text("Show:")
|
|
Button(menuLabel("Finished", on: !showAbsent)) {
|
|
showAbsent = false
|
|
}
|
|
Button(menuLabel("Did not start", on: showAbsent)) {
|
|
showAbsent = true
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Text("Sort:")
|
|
ForEach(SortKey.allCases, id: \.rawValue) { key in
|
|
Button(menuLabel(key.rawValue, on: key == sortKey)) {
|
|
sortKey = key
|
|
}
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Text("Groups:")
|
|
ForEach(AgeGroup.allCases, id: \.rawValue) { group in
|
|
Button(menuLabel("\(group)", on: includedGroups.contains(group))) {
|
|
includedGroups = toggling(includedGroups, group)
|
|
}
|
|
}
|
|
}
|
|
|
|
TextField(placeholder: "Search by name…") { value in
|
|
query = value
|
|
}
|
|
|
|
Divider()
|
|
|
|
Text(formatRow("Nr", "Name", "Group", "Time")).bold()
|
|
|
|
ScrollView {
|
|
ForEach(visible, id: \.number) { runner in
|
|
Text(formatRow("#\(runner.number)", runner.name, "\(runner.ageGroup)", runner.time))
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|