Files
fuelboard/Shared/StationAddress.swift
T
FuelBoard Contributor bb053de8b1 Listing rows: verdict text hugs its icon, and the address stops repeating the name
Two things the listing row was saying twice, or too loosely.

- The verdict capsule used a Label, and a Label lays its icon and title out
  across the width it is given: in a listing row that left 22pt of empty space
  between the symbol and "Save £1.07 after travel". It is now an HStack with
  an explicit 3pt spacing (5pt in the detail sheet), so the text sits beside its
  icon and the capsule background hugs the pair. Measured on the same row: the
  capsule text moved x 412 -> 341 and the pill 270-831 -> 270-691.

- The address line printed the station name a second time. The official Fuel
  Finder address field usually opens with the station's own name ("CHIDSWELL
  SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell"): 14% of
  the dump's addresses start with the exact name, and 13% also end with the
  postcode the row appended again. StationAddressLine (new, in Shared/, so the
  row and the detail sheet share one rule) drops a leading repeat of the name
  and writes the postcode exactly once.

The rules are deliberately conservative: a leading segment carrying a house
number is a real street address and is left alone ("41 ANNACLOY RD ANNACLOY"),
and a partial-word match ("Jet" against "JETSON FILLING STATION") never cuts
into a word. 14 new tests (169 total, 0 failures) pin the shapes found in the
real dump as well as the examples above.

Verified on the simulator: the first listing row's second line went from
"CHIDSWELL SERVICE STA..." to "LEEDS ROAD, DEWSBURY...", and the detail sheet
reads "Sainsbury's Halifax / WADE STREET, HX1 1SB".
2026-10-11 19:33:26 +01:00

122 lines
6.0 KiB
Swift

import Foundation
/// How a station's address should read underneath its name.
///
/// The official Fuel Finder `address` field is not a street address on its own.
/// It normally opens with the station's own name — 14% of stations start with
/// the exact name, and many more repeat it in the first segment ("CHIDSWELL
/// SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell") — and
/// 13% end with the postcode the row appends again. With the name already in the
/// row's header (and in the detail sheet's "Name" row), both repetitions read as
/// the name stated twice.
///
/// The rules are deliberately conservative: a leading segment carrying a house
/// number is a real street address and is left alone, and a partial-word match
/// ("Jet" against "JETSON FILLING STATION") never cuts into a word.
enum StationAddressLine {
/// Words that carry no identity, so a leading segment made only of these
/// plus words taken from the name still counts as the name.
private static let filler: Set<String> = [
"service", "services", "station", "stations", "filling", "petrol",
"garage", "motors", "motor", "centre", "center", "store", "superstore",
"ltd", "limited", "trading", "the", "and", "of", "at",
]
/// The line to display: any leading repeat of the name removed, and the
/// postcode written exactly once (whatever the address already carries).
static func display(address: String, postcode: String, name: String) -> String {
let postcodeText = postcode.trimmingCharacters(in: .whitespacesAndNewlines)
var text = collapsingSpaces(address)
if text.isEmpty { return postcodeText }
text = removingLeadingName(from: text, name: name)
text = removingTrailingPostcode(from: text, postcode: postcodeText)
text = text.trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
if text.isEmpty { return postcodeText }
if postcodeText.isEmpty { return text }
return "\(text), \(postcodeText)"
}
// MARK: - Internals
private static func collapsingSpaces(_ text: String) -> String {
text.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
.joined(separator: " ")
.trimmingCharacters(in: .whitespaces)
}
/// Letters and digits only, lowercased — so punctuation and spacing cannot
/// make two spellings of the same name look different.
private static func key(_ text: String) -> String {
text.lowercased().filter { $0.isLetter || $0.isNumber }
}
private static func removingLeadingName(from text: String, name: String) -> String {
let nameKey = key(name)
guard !nameKey.isEmpty else { return text }
if key(text).hasPrefix(nameKey), endsAtWordBoundary(text, characters: nameKey.count) {
return droppingFirst(text, characters: nameKey.count)
}
return removingLeadingNameSegment(from: text, nameKey: nameKey)
}
/// True when the first `count` letters/digits of `text` are followed by
/// something that is not part of a word — a partial-word match must never
/// cut a station out of the middle of a word.
private static func endsAtWordBoundary(_ text: String, characters count: Int) -> Bool {
var seen = 0
var index = text.startIndex
while index < text.endIndex, seen < count {
let character = text[index]
if character.isLetter || character.isNumber { seen += 1 }
index = text.index(after: index)
}
guard index < text.endIndex else { return true }
let next = text[index]
return !(next.isLetter || next.isNumber)
}
/// Drops the first `count` letters/digits of `text` and any separator after
/// them, leaving the rest of the original spelling untouched.
private static func droppingFirst(_ text: String, characters count: Int) -> String {
var seen = 0
var index = text.startIndex
while index < text.endIndex, seen < count {
let character = text[index]
if character.isLetter || character.isNumber { seen += 1 }
index = text.index(after: index)
}
return String(text[index...]).trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
}
/// Drops a trailing repeat of the postcode.
private static func removingTrailingPostcode(from text: String, postcode: String) -> String {
let postcodeKey = key(postcode)
guard !postcodeKey.isEmpty else { return text }
let textKey = key(text)
guard textKey.hasSuffix(postcodeKey), textKey.count > postcodeKey.count else { return text }
var count = postcodeKey.count
var index = text.endIndex
while index > text.startIndex, count > 0 {
index = text.index(before: index)
let character = text[index]
if character.isLetter || character.isNumber { count -= 1 }
}
return String(text[text.startIndex..<index]).trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
}
/// "CHIDSWELL SERVICE STATION, LEEDS ROAD…" holds the name in its first
/// segment but never as a literal prefix. Drop that segment only when it
/// carries no digits — a house number means it is really a street address —
/// and every word in it is either filler or part of the name.
private static func removingLeadingNameSegment(from text: String, nameKey: String) -> String {
guard let comma = text.firstIndex(of: ",") else { return text }
let segment = String(text[text.startIndex..<comma])
if segment.contains(where: { $0.isNumber }) { return text }
let words = segment.split(whereSeparator: { !$0.isLetter }).map { $0.lowercased() }
let significant = words.filter { $0.count >= 3 && !filler.contains($0) }
guard !significant.isEmpty, significant.allSatisfy({ nameKey.contains($0) }) else { return text }
return String(text[text.index(after: comma)...])
.trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
}
}