m323/Sources/MiniProject/main.swift
cediackermann 7903970f08
feat(MiniProject): searchable Stadt-Rundlauf runner TUI
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>
2026-06-29 12:05:50 +02:00

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()
}
}