Repository: streetcomplete/StreetComplete (language: kotlin)

Issue:
Multiplatform opening hours parser library
For the opening hours quest and the postbox collection times quest, the app needs to parse the OSM opening hours syntax. 

Currently, the app uses the Java library [`ch.poole.OpeningHoursParser`](https://github.com/simonpoole/OpeningHoursParser). Internally, this parser is not generated by ANTLR but JavaCC because the OSM opening hours schema is too complex to be projected by an EBNF notation according to the aforementioned library's author.

### Implementation

So, a replacement library in pure kotlin can evidently not be generated by e.g. [antlr-kotlin](https://github.com/Strumenta/antlr-kotlin) but would need to be just as custom of a parser as the Java library. 

It should be developed in a separate repository as a separate multiplatform library. It can be pure Kotlin with no platform-dependent implementations.

#### Notes / Hints

- `ch.poole.OpeningHoursParser` is quite well-tested and MIT-licensed. Could use the tests from that library to confirm that the Kotlin reimplementation behaves similar (i.e. test driven development)

- For the `ElementFiltersParser` that parses expressions like `nodes, ways with (shop = supermarket or amenity = cafe) and !opening_hours`, I use the class [`StringWithCursor`](https://github.com/search?q=repo%3Astreetcomplete%2FStreetComplete+StringWithCursor.kt&type=code), which might be a very handy base to write a custom parser


Discussion hints:
I am going to reserve this one for myself
Or try to run https://github.com/opening-hours/opening_hours.js
Status update, I advanced quite a bit, but I poured too much time into that, so I'll continue with it later. Preliminary results:

- average parsing time of an opening hours string is more than 10x as fast as the Java [OpeningHoursParser](https://github.com/simonpoole/OpeningHoursParser). This means quest generation will be slightly faster, as the `AddOpeningHours` quest has always taken a long time (relatively speaking) to process due to the parsing of the opening hours (well, up to ~1 second on my phone... 🤷)
- 95.90% of opening hours strings in the wild are considered valid, 99.04% are understood
Step 1 done: https://github.com/westnordost/osm-opening-hours

Now just use that in StreetComplete. (More work than it sounds, because there is quite a bit of code specific to Java OpeningHoursParser in SC)
By the way, you are welcome to look at the source an comment on the design decisions (data structures etc) I made, always is a good idea to have a second pair of eyes looking into whether something makes sense. If you like.

Pull request: New Opening Hours Parser
Fixes #5411

Additionally, made it so that opening hours which contain rules that overwrite each other but are otherwise supported are not excluded from re-survey, e.g. `Mo-Fr 08:00-12:00; Tu,Th 14:00-16:00` is likely a tagging mistake.

Test command: ./gradlew :app:testDebugUnitTest --tests "de.westnordost.streetcomplete.quests.opening_hours.AddOpeningHoursTest" --tests "de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursParserAndGeneratorTest" --tests "de.westnordost.streetcomplete.quests.parking_fee.FeeTest" --tests "de.westnordost.streetcomplete.quests.parking_fee.MaxStayTest" --tests "de.westnordost.streetcomplete.quests.postbox_collection_times.AddPostboxCollectionTimesTest"

Fix patch:
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4b591db5f74..f6bec0fec69 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -182,7 +182,7 @@ dependencies {
     implementation("com.mapzen.tangram:tangram:0.17.1")
 
     // opening hours parser
-    implementation("ch.poole:OpeningHoursParser:0.28.1")
+    implementation("de.westnordost:osm-opening-hours:0.1.0")
 
     // image view that allows zoom and pan
     implementation("com.github.chrisbanes:PhotoView:2.3.0")
diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/model/TimeRange.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/model/TimeRange.kt
index a48d73ca8a1..2c10c0a3abc 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/model/TimeRange.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/model/TimeRange.kt
@@ -36,7 +36,7 @@ data class TimeRange(val start: Int, val end: Int, val isOpenEnded: Boolean = fa
         sb.append(timeOfDayToString(locale, start))
         if (start != end || !isOpenEnded) {
             sb.append(range)
-            var displayEnd = timeOfDayToString(locale, end)
+            var displayEnd = timeOfDayToString(locale, end % (24 * 60))
             if (displayEnd == "00:00") displayEnd = "24:00"
             sb.append(displayEnd)
         }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHours.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHours.kt
deleted file mode 100644
index ca220f3d4ca..00000000000
--- a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHours.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package de.westnordost.streetcomplete.osm.opening_hours.parser
-
-import ch.poole.openinghoursparser.Holiday
-import ch.poole.openinghoursparser.Rule
-import ch.poole.openinghoursparser.Util
-import ch.poole.openinghoursparser.WeekDayRange
-
-/* intermediate data structures for opening hours parsing and writing */
-
-data class OpeningHoursRuleList(val rules: List<Rule>) {
-    override fun toString(): String = Util.rulesToOpeningHoursString(rules)
-}
-
-data class WeekDayRangesAndHolidays(
-    val weekdayRanges: List<WeekDayRange>? = null,
-    val holidays: List<Holiday>? = null
-)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursGenerator.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursGenerator.kt
index d0f005876d1..1315fe859a4 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursGenerator.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursGenerator.kt
@@ -1,212 +1,171 @@
 package de.westnordost.streetcomplete.osm.opening_hours.parser
 
-import ch.poole.openinghoursparser.DateRange
-import ch.poole.openinghoursparser.DateWithOffset
-import ch.poole.openinghoursparser.Holiday
-import ch.poole.openinghoursparser.Month
-import ch.poole.openinghoursparser.Rule
-import ch.poole.openinghoursparser.RuleModifier
-import ch.poole.openinghoursparser.TimeSpan
-import ch.poole.openinghoursparser.WeekDay
-import ch.poole.openinghoursparser.WeekDayRange
+import de.westnordost.osm_opening_hours.model.ClockTime
+import de.westnordost.osm_opening_hours.model.ExtendedClockTime
+import de.westnordost.osm_opening_hours.model.Holiday
+import de.westnordost.osm_opening_hours.model.Month
+import de.westnordost.osm_opening_hours.model.MonthRange
+import de.westnordost.osm_opening_hours.model.SingleMonth
+import de.westnordost.osm_opening_hours.model.MonthsOrDateSelector
+import de.westnordost.osm_opening_hours.model.OpeningHours
+import de.westnordost.osm_opening_hours.model.Range
+import de.westnordost.osm_opening_hours.model.Rule
+import de.westnordost.osm_opening_hours.model.RuleOperator
+import de.westnordost.osm_opening_hours.model.RuleType
+import de.westnordost.osm_opening_hours.model.StartingAtTime
+import de.westnordost.osm_opening_hours.model.Time
+import de.westnordost.osm_opening_hours.model.TimeSpan
+import de.westnordost.osm_opening_hours.model.TimeSpansSelector
+import de.westnordost.osm_opening_hours.model.TimesSelector
+import de.westnordost.osm_opening_hours.model.Weekday
+import de.westnordost.osm_opening_hours.model.WeekdayRange
+import de.westnordost.osm_opening_hours.model.WeekdaysSelector
 import de.westnordost.streetcomplete.osm.opening_hours.model.CircularSection
 import de.westnordost.streetcomplete.osm.opening_hours.model.Months
-import de.westnordost.streetcomplete.osm.opening_hours.model.NumberSystem
 import de.westnordost.streetcomplete.osm.opening_hours.model.TimeRange
 import de.westnordost.streetcomplete.osm.opening_hours.model.Weekdays
-import de.westnordost.streetcomplete.osm.opening_hours.model.Weekdays.Companion.PUBLIC_HOLIDAY
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OffDaysRow
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningHoursRow
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningMonthsRow
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningWeekdaysRow
 import de.westnordost.streetcomplete.quests.postbox_collection_times.CollectionTimesRow
 
-@JvmName("openingHoursRowsToOpeningHoursRules")
-fun List<OpeningHoursRow>.toOpeningHoursRules(): OpeningHoursRuleList {
+@JvmName("openingHoursRowsToOpeningHours")
+fun List<OpeningHoursRow>.toOpeningHours(): OpeningHours {
     val rules = mutableListOf<Rule>()
 
-    var currentDateRanges: List<DateRange>? = null
-    var currentWeekdays: WeekDayRangesAndHolidays? = null
-    var currentTimeSpans: MutableList<TimeSpan> = mutableListOf()
+    var currentMonths: List<MonthsOrDateSelector>? = null
+    var currentWds: WeekdaysAndHolidays? = null
+    var currentTimeSpans: MutableList<TimeSpansSelector> = mutableListOf()
 
     for (row in this) when (row) {
         is OpeningMonthsRow -> {
-            val dateRanges = row.months.toDateRanges()
-
             // new rule if we were constructing one
-            if (currentWeekdays != null) {
-                rules.add(createRule(currentDateRanges, currentWeekdays.weekdayRanges, currentWeekdays.holidays, currentTimeSpans))
-                currentWeekdays = null
+            if (currentWds != null) {
+                rules.add(createRule(currentMonths, currentWds, currentTimeSpans))
+                currentWds = null
                 currentTimeSpans = mutableListOf()
             }
 
-            currentDateRanges = dateRanges
+            currentMonths = row.months.toMonthsSelectors()
         }
         is OpeningWeekdaysRow -> {
-            val timeSpan = row.timeRange.toTimeSpan()
-            val weekdays =
-                if (!row.weekdays.isSelectionEmpty()) {
-                    row.weekdays.toWeekDayRangesAndHolidays()
-                } else {
-                    WeekDayRangesAndHolidays()
-                }
+            val wds =
+                if (!row.weekdays.isSelectionEmpty()) row.weekdays.toWeekdaysAndHolidays()
+                else WeekdaysAndHolidays(null, null)
 
             // new weekdays -> new rule
-            if (currentWeekdays != null && weekdays != currentWeekdays) {
-                rules.add(createRule(currentDateRanges, currentWeekdays.weekdayRanges, currentWeekdays.holidays, currentTimeSpans))
+            if (currentWds != null && wds != currentWds) {
+                rules.add(createRule(currentMonths, currentWds, currentTimeSpans))
                 currentTimeSpans = mutableListOf()
             }
 
-            currentTimeSpans.add(timeSpan)
-            currentWeekdays = weekdays
+            currentTimeSpans.add(row.timeRange.toTimeSpansSelector())
+            currentWds = wds
         }
         is OffDaysRow -> {
             // new rule if we were constructing one
-            if (currentWeekdays != null) {
-                rules.add(createRule(currentDateRanges, currentWeekdays.weekdayRanges, currentWeekdays.holidays, currentTimeSpans))
-                currentWeekdays = null
+            if (currentWds != null) {
+                rules.add(createRule(currentMonths, currentWds, currentTimeSpans))
+                currentWds = null
                 currentTimeSpans = mutableListOf()
             }
 
-            val weekdays = row.weekdays.toWeekDayRangesAndHolidays()
-            rules.add(createOffRule(currentDateRanges, weekdays.weekdayRanges, weekdays.holidays))
+            val wds = row.weekdays.toWeekdaysAndHolidays()
+            rules.add(createRule(currentMonths, wds, null, RuleType.Off))
         }
     }
-    if (currentWeekdays != null) {
-        rules.add(createRule(currentDateRanges, currentWeekdays.weekdayRanges, currentWeekdays.holidays, currentTimeSpans))
-    }
-
-    /* if any rule collides with another, e.g. "Mo-Fr 10:00-12:00; We 14:00-16:00", switch to
-       additive rules e.g. "Mo-Fr 10:00-12:00, We 14:00-16:00" */
-    if (rules.weekdaysCollideWithAnother()) {
-        rules.makeAdditive()
+    if (currentWds != null) {
+        rules.add(createRule(currentMonths, currentWds, currentTimeSpans))
     }
 
-    return OpeningHoursRuleList(rules)
+    return OpeningHours(rules.asNonColliding())
 }
 
-@JvmName("makeAdditiveInRuleList")
-private fun List<Rule>.makeAdditive() {
-    for (rule in this) {
-        // "off" rules stay non-additive
-        if (rule.modifier?.isSimpleOff() != true) {
-            rule.isAdditive = true
-        }
-    }
-}
-
-@JvmName("collectionTimesRowsToOpeningHoursRules")
-fun List<CollectionTimesRow>.toOpeningHoursRules(): OpeningHoursRuleList {
+@JvmName("collectionTimesRowsToOpeningHours")
+fun List<CollectionTimesRow>.toOpeningHours(): OpeningHours {
     val rules = mutableListOf<Rule>()
 
-    var currentWeekdays: WeekDayRangesAndHolidays? = null
-    var currentTimeSpans: MutableList<TimeSpan> = mutableListOf()
+    var currentWds: WeekdaysAndHolidays? = null
+    var currentTimes: MutableList<Time> = mutableListOf()
 
     for (row in this) {
-        val time = row.time
-        val weekdays =
-            if (!row.weekdays.isSelectionEmpty()) {
-                row.weekdays.toWeekDayRangesAndHolidays()
-            } else {
-                WeekDayRangesAndHolidays()
-            }
+        val wds =
+            if (!row.weekdays.isSelectionEmpty()) row.weekdays.toWeekdaysAndHolidays()
+            else WeekdaysAndHolidays(null, null)
 
         // new weekdays -> new rule
-        if (currentWeekdays != null && weekdays != currentWeekdays) {
-            rules.add(createRule(null, currentWeekdays.weekdayRanges, currentWeekdays.holidays, currentTimeSpans))
-            currentTimeSpans = mutableListOf()
+        if (currentWds != null && wds != currentWds) {
+            rules.add(createRule(null, currentWds, currentTimes))
+            currentTimes = mutableListOf()
         }
 
-        currentTimeSpans.add(time.toTimeSpan())
-        currentWeekdays = weekdays
-    }
-    if (currentWeekdays != null) {
-        rules.add(createRule(null, currentWeekdays.weekdayRanges, currentWeekdays.holidays, currentTimeSpans))
+        currentTimes.add(row.time.toClockTime())
+        currentWds = wds
     }
-
-    /* if any rule collides with another, e.g. "Mo-Fr 10:00; We 14:00", switch to
-       additive rules e.g. "Mo-Fr 10:00, We 14:00" */
-    if (rules.weekdaysCollideWithAnother()) {
-        rules.makeAdditive()
+    if (currentWds != null) {
+        rules.add(createRule(null, currentWds, currentTimes))
     }
 
-    return OpeningHoursRuleList(rules)
+    return OpeningHours(rules.asNonColliding())
 }
 
-private fun createRule(
-    dateRanges: List<DateRange>?,
-    weekDayRanges: List<WeekDayRange>?,
-    holidays: List<Holiday>?,
-    timeSpans: List<TimeSpan>
-) = Rule().also { r ->
-
-    require(timeSpans.isNotEmpty())
-
-    r.dates = dateRanges?.toMutableList()
-    r.days = weekDayRanges?.toMutableList()
-    r.holidays = holidays?.toMutableList()
-    r.times = timeSpans.toMutableList()
-}
-
-private fun createOffRule(
-    dateRanges: List<DateRange>?,
-    weekDayRanges: List<WeekDayRange>?,
-    holidays: List<Holiday>?
-) = Rule().also { r ->
-
-    r.dates = dateRanges?.toMutableList()
-    r.days = weekDayRanges?.toMutableList()
-    r.holidays = holidays?.toMutableList()
-    r.modifier = RuleModifier().also { it.modifier = RuleModifier.Modifier.OFF }
-}
-
-private fun Int.toTimeSpan() = TimeSpan().also {
-    it.start = this
-}
+/* if any rule collides with another, e.g. "Mo-Fr 10:00-12:00; We 14:00-16:00", switch to
+   additive rules e.g. "Mo-Fr 10:00-12:00, We 14:00-16:00" */
+private fun List<Rule>.asNonColliding(): List<Rule> =
+    if (!hasCollidingWeekdays()) this
+    else map { rule ->
+        // "off" rules stay non-additive
+        if (rule.ruleType == RuleType.Off) rule
+        else rule.copy(ruleOperator = RuleOperator.Additional)
+    }
 
-private fun TimeRange.toTimeSpan() = TimeSpan().also {
-    it.start = start
-    val tEnd = if (end == 0) 24 * 60 else end
-    if (start != tEnd) it.end = tEnd
-    it.isOpenEnded = isOpenEnded
+private fun createRule(
+    months: List<MonthsOrDateSelector>?,
+    weekdaysAndHolidays: WeekdaysAndHolidays?,
+    times: List<TimesSelector>?,
+    ruleType: RuleType? = null
+) = Rule(
+    Range(
+        months = months,
+        weekdays = weekdaysAndHolidays?.weekdays,
+        holidays = weekdaysAndHolidays?.holidays,
+        times = times
+    ),
+    ruleType = ruleType
+)
+
+private fun Months.toMonthsSelectors(): List<MonthsOrDateSelector> =
+    toCircularSections().map { it.toMonthsSelector() }
+
+private fun CircularSection.toMonthsSelector(): MonthsOrDateSelector {
+    val start = Month.entries[start]
+    val end = Month.entries[end]
+    return if (start != end) MonthRange(start, end) else SingleMonth(start)
 }
 
-private fun Weekdays.toWeekDayRangesAndHolidays(): WeekDayRangesAndHolidays {
-    val weekdayRanges = toCircularSections().flatMap { it.toWeekDayRanges() }
-
-    val holidays: List<Holiday>? =
-        if (selection[PUBLIC_HOLIDAY]) {
-            listOf(Holiday().also { it.type = Holiday.Type.PH })
-        } else {
-            null
-        }
-
-    return WeekDayRangesAndHolidays(weekdayRanges.takeIf { it.isNotEmpty() }, holidays)
+private fun Weekdays.toWeekdaysAndHolidays(): WeekdaysAndHolidays {
+    val weekdays = toCircularSections().flatMap { it.toWeekdayAndHolidaySelectors() }
+    val holidays = if (selection[Weekdays.PUBLIC_HOLIDAY]) listOf(Holiday.PublicHoliday) else null
+    return WeekdaysAndHolidays(weekdays, holidays)
 }
 
-private fun CircularSection.toWeekDayRanges(): List<WeekDayRange> {
-    val size = NumberSystem(0, Weekdays.WEEKDAY_COUNT - 1).getSize(this)
-    // if the range is very short (e.g. Mo-Tu), rather save it as Mo,Tu
-    return if (size == 2) {
-        listOf(
-            WeekDayRange().also { it.startDay = WeekDay.entries[start] },
-            WeekDayRange().also { it.startDay = WeekDay.entries[end] }
-        )
-    } else {
-        listOf(WeekDayRange().also {
-            it.startDay = WeekDay.entries[start]
-            it.endDay = if (start != end) WeekDay.entries[end] else null
-        })
+private fun CircularSection.toWeekdayAndHolidaySelectors(): List<WeekdaysSelector> {
+    val s = Weekday.entries[start]
+    val e = Weekday.entries[end]
+    return when {
+        start == end -> listOf(s)
+        (start + 1) % Weekday.entries.size == end -> listOf(s, e) // Mo,Tu better readable than Mo-Tu
+        else -> listOf(WeekdayRange(s, e))
     }
 }
 
-private fun Months.toDateRanges(): List<DateRange> {
-    return toCircularSections().map { it.toDateRange() }
-}
+private fun TimeRange.toTimeSpansSelector(): TimeSpansSelector {
+    val startTime = ClockTime(start / 60, start % 60)
+    val endTime = ExtendedClockTime(end / 60, end % 60)
 
-private fun CircularSection.toDateRange() = DateRange().also {
-    it.startDate = createMonthDate(Month.entries[start])
-    it.endDate = if (start != end) createMonthDate(Month.entries[end]) else null
+    return if (start == end && isOpenEnded) StartingAtTime(startTime)
+    else TimeSpan(startTime, endTime, isOpenEnded)
 }
 
-private fun createMonthDate(month: Month) = DateWithOffset().also { it.month = month }
+private fun Int.toClockTime() = ClockTime(this / 60, this % 60)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursParser.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursParser.kt
index 4c119408bef..c6a7ff5bf35 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursParser.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursParser.kt
@@ -1,44 +1,43 @@
 package de.westnordost.streetcomplete.osm.opening_hours.parser
 
-import ch.poole.openinghoursparser.DateRange
-import ch.poole.openinghoursparser.DateWithOffset
-import ch.poole.openinghoursparser.Holiday
-import ch.poole.openinghoursparser.OpeningHoursParser
-import ch.poole.openinghoursparser.ParseException
-import ch.poole.openinghoursparser.Rule
-import ch.poole.openinghoursparser.RuleModifier
-import ch.poole.openinghoursparser.TimeSpan
-import ch.poole.openinghoursparser.WeekDay
-import ch.poole.openinghoursparser.WeekDayRange
-import ch.poole.openinghoursparser.YearRange
+import de.westnordost.osm_opening_hours.model.ClockTime
+import de.westnordost.osm_opening_hours.model.ExtendedClockTime
+import de.westnordost.osm_opening_hours.model.ExtendedTime
+import de.westnordost.osm_opening_hours.model.Holiday
+import de.westnordost.osm_opening_hours.model.HolidaySelector
+import de.westnordost.osm_opening_hours.model.MonthRange
+import de.westnordost.osm_opening_hours.model.MonthsOrDateSelector
+import de.westnordost.osm_opening_hours.model.OpeningHours
+import de.westnordost.osm_opening_hours.model.Range
+import de.westnordost.osm_opening_hours.model.Rule
+import de.westnordost.osm_opening_hours.model.RuleOperator
+import de.westnordost.osm_opening_hours.model.RuleType
+import de.westnordost.osm_opening_hours.model.Selector
+import de.westnordost.osm_opening_hours.model.SingleMonth
+import de.westnordost.osm_opening_hours.model.SpecificWeekdays
+import de.westnordost.osm_opening_hours.model.StartingAtTime
+import de.westnordost.osm_opening_hours.model.TimeIntervals
+import de.westnordost.osm_opening_hours.model.TimeSpan
+import de.westnordost.osm_opening_hours.model.TimeSpansSelector
+import de.westnordost.osm_opening_hours.model.TimesSelector
+import de.westnordost.osm_opening_hours.model.TwentyFourSeven
+import de.westnordost.osm_opening_hours.model.VariableTime
+import de.westnordost.osm_opening_hours.model.Weekday
+import de.westnordost.osm_opening_hours.model.WeekdayRange
+import de.westnordost.osm_opening_hours.model.WeekdaysSelector
 import de.westnordost.streetcomplete.osm.opening_hours.model.CircularSection
 import de.westnordost.streetcomplete.osm.opening_hours.model.Months
 import de.westnordost.streetcomplete.osm.opening_hours.model.TimeRange
 import de.westnordost.streetcomplete.osm.opening_hours.model.Weekdays
-import de.westnordost.streetcomplete.osm.opening_hours.model.Weekdays.Companion.OSM_ABBR_WEEKDAYS
 import de.westnordost.streetcomplete.osm.opening_hours.model.Weekdays.Companion.PUBLIC_HOLIDAY
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OffDaysRow
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningHoursRow
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningMonthsRow
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningWeekdaysRow
 import de.westnordost.streetcomplete.quests.postbox_collection_times.CollectionTimesRow
-import java.io.ByteArrayInputStream
-
-/** Returns null if the opening hours string is invalid */
-fun String.toOpeningHoursRules(): OpeningHoursRuleList? {
-    val rules: List<Rule>
-    try {
-        val parser = OpeningHoursParser(ByteArrayInputStream(toByteArray()))
-        rules = parser.rules(false)
-    } catch (e: ParseException) {
-        // parsing failed, value is malformed
-        return null
-    }
-    return OpeningHoursRuleList(rules)
-}
 
 /** returns null if the list of rules cannot be displayed by the opening hours widget */
-fun OpeningHoursRuleList.toOpeningHoursRows(): List<OpeningHoursRow>? {
+fun OpeningHours.toOpeningHoursRows(): List<OpeningHoursRow>? {
     if (!isSupportedOpeningHours()) {
         // parsable, but not supported by StreetComplete
         return null
@@ -49,319 +48,114 @@ fun OpeningHoursRuleList.toOpeningHoursRows(): List<OpeningHoursRow>? {
     var currentMonths: Months? = null
 
     for (rule in rules) {
-        val dates = rule.dates
+        val range = rule.selector as Range
+
+        val months = range.months?.toMonths()
 
-        val months = dates?.toMonths()
         if (months != currentMonths) {
             result.add(OpeningMonthsRow(months ?: Months()))
         }
         currentMonths = months
 
-        if (rule.modifier != null && rule.modifier!!.isSimpleOff()) {
-            result.add(rule.createOffDays())
+        val weekdaysAndHolidays = WeekdaysAndHolidays(range.weekdays, range.holidays).toWeekdays()
+
+        if (rule.ruleType != null && rule.ruleType in listOf(RuleType.Off, RuleType.Closed)) {
+            result.add(OffDaysRow(weekdaysAndHolidays))
         } else {
-            result.addAll(rule.createOpeningWeekdays())
+            for (time in range.times!!) {
+                val timeRange = (time as TimeSpansSelector).toTimeRange()
+                result.add(OpeningWeekdaysRow(weekdaysAndHolidays, timeRange))
+            }
         }
     }
 
     return result
 }
 
-/** returns null if the list of rules cannot be displayed by the collection times widget */
-fun OpeningHoursRuleList.toCollectionTimesRows(): List<CollectionTimesRow>? {
+fun OpeningHours.toCollectionTimesRows(): List<CollectionTimesRow>? {
     if (!isSupportedCollectionTimes()) {
         // parsable, but not supported by StreetComplete
         return null
     }
-    return rules.flatMap { it.createCollectionTimesWeekdays() }
-}
-
-/* ---------------------------------- Checks if it is supported --------------------------------- */
-
-fun OpeningHoursRuleList.isSupportedOpeningHours(): Boolean = rules.isSupportedOpeningHours()
-fun OpeningHoursRuleList.isSupportedCollectionTimes(): Boolean = rules.isSupportedCollectionTimes()
-
-/** Returns true if supported by StreetComplete
- * Returns false otherwise, in cases where it is not directly representable
- *
- * It is first checking each rule (parts of opening_hours tag separated by ; sign)
- * is it possible to recreate it by taking only supported parts
- * later it checks also some additional limitations imposed by SC */
-@JvmName("isSupportedRuleList")
-fun List<Rule>.isSupportedOpeningHours(): Boolean =
-    // all rules must be supported
-    all { it.isSupportedOpeningHours() }
-    // this kind of opening hours specification likely require fix
-    // anyway, it is not representable directly by SC
-    && !weekdaysCollideWithAnother()
-
-/** Returns true if the collection times are supported by StreetComplete
- * Returns false otherwise, in cases where it is not directly representable */
-@JvmName("isSupportedCollectionTimesRuleList")
-fun List<Rule>.isSupportedCollectionTimes(): Boolean =
-    // all rules must be supported
-    all { it.isSupportedCollectionTimes() }
-        // this kind of collection times specification likely require fix
-        // anyway, it is not representable directly by SC
-        && !weekdaysCollideWithAnother()
-
-fun Rule.isSupportedOpeningHours(): Boolean =
-    !isEmpty
-    // 24/7 not supported
-    && !isTwentyfourseven
-    // comments not supported
-    && comment == null
-    // fallback rules are not supported
-    && !isFallBack
-    // "1995-1998 08:00-11:00" not supported
-    && years == null
-    // "05-08 08:00-11:00" not supported
-    && weeks == null
-    && (
-        // for normal rules, only "Mo-Fr" not supported. "open" modifier is ok as long as it does not have a comment
-        (modifier == null || modifier!!.isSimpleOpen()) && !times.isNullOrEmpty()
-            // "off"/"closed" only compatible without comment and no times
-            || (modifier != null && times.isNullOrEmpty() && modifier!!.isSimpleOff())
-        )
-    // all sub-elements must be supported if specified
-    && holidays?.all { it.isSupported() } ?: true
-    && days?.all { it.isSupported() } ?: true
-    && times?.all { it.isSupportedOpeningHours() } ?: true
-    && dates?.all { it.isSupportedOpeningHours() } ?: true
-
-fun Rule.isSupportedCollectionTimes(): Boolean =
-    !isEmpty
-    // 24/7 not supported
-    && !isTwentyfourseven
-    // comments not supported
-    && comment == null
-    // fallback rules are not supported
-    && !isFallBack
-    // "1995-1998 08:00-11:00" not supported
-    && years == null
-    // "05-08 08:00-11:00" not supported
-    && weeks == null
-    // modifiers are not supported
-    && modifier == null
-    // all sub-elements must be supported if specified
-    && holidays?.all { it.isSupported() } ?: true
-    && days?.all { it.isSupported() } ?: true
-    // times must be defined, and may not be ranges or something that implies ranges
-    && !times.isNullOrEmpty()
-    && times?.all { it.isSupportedCollectionTimes() } ?: true
-    // months not supported
-    && dates == null
-
-fun DateRange.isSupportedOpeningHours(): Boolean =
-    startDate.isSupportedOpeningHours() && (endDate?.isSupportedOpeningHours() ?: true) && interval == 0
-
-fun RuleModifier.isSimpleOpen(): Boolean =
-    comment == null && modifier == RuleModifier.Modifier.OPEN
+    val result = mutableListOf<CollectionTimesRow>()
 
-fun RuleModifier.isSimpleOff(): Boolean =
-    comment == null && (modifier == RuleModifier.Modifier.OFF || modifier == RuleModifier.Modifier.CLOSED)
-
-fun DateWithOffset.isSupportedOpeningHours(): Boolean =
-    // "Jan+" not supported
-    !isOpenEnded
-    // "Jan +Fr" not supported
-    && nthWeekDay == null && weekDayOffset == null && nth == 0
-    // "Jan +Fr + 3 days" not supported
-    && dayOffset == 0
-    // "1995 Jan" not supported
-    && year == YearRange.UNDEFINED_YEAR
-    // "Jan 5" not supported
-    && day == DateWithOffset.UNDEFINED_MONTH_DAY
-    // "easter" not supported
-    && varDate == null
-    // month is required
-    && month != null
-
-fun Holiday.isSupported(): Boolean =
-    // only PH is supported
-    type == Holiday.Type.PH
-    // "PH Mo" (public holidays on Monday) not supported
-    && useAsWeekDay
-    // "PH +5 days" not supported
-    && offset == 0
-
-fun WeekDayRange.isSupported(): Boolean =
-    nths == null && offset == 0
-    // not sure how/if this can be null, just to be sure
-    && startDay != null
-
-fun TimeSpan.isSupportedOpeningHours(): Boolean =
-    // "sunrise" etc not supported
-    startEvent == null && endEvent == null
-    && interval == 0
-    && start != TimeSpan.UNDEFINED_TIME
-
-fun TimeSpan.isSupportedCollectionTimes(): Boolean =
-    // "sunrise" etc not supported
-    startEvent == null && endEvent == null
-    && interval == 0
-    && start != TimeSpan.UNDEFINED_TIME
-    // only support time points
-    && end == TimeSpan.UNDEFINED_TIME
-    && !isOpenEnded
-
-/* ------------------------------ Collision/Intersection checking ------------------------------- */
+    for (rule in rules) {
+        val range = rule.selector as Range
+        val weekdaysAndHolidays = WeekdaysAndHolidays(range.weekdays, range.holidays).toWeekdays()
 
-/** For example, "Mo-Fr 10:00-12:00; We 14:00-16:00" self-collides: Wednesday is overwritten
- *  to only be open 14:00 to 16:00. A rule collides with another whenever the days overlap. When
- *  non-additive rules and additive rules mix, it becomes a bit difficult to find it out */
-@JvmName("weekdaysCollideWithAnotherInRuleList")
-fun List<Rule>.weekdaysCollideWithAnother(): Boolean {
-    for (i in 0 until size) {
-        val rule1 = get(i)
-        var additive = true
-        for (j in i + 1 until size) {
-            val rule2 = get(j)
-            // off/closed rules may collide because they overwrite their whole weekdays anyway
-            // (because we do not support times for off-rules in StreetComplete)
-            if (rule2.modifier?.isSimpleOff() == true) {
-                continue
-            }
-            // additive rules do not collide
-            if (!rule2.isAdditive) {
-                additive = false
-            }
-            if (!additive) {
-                if (rule1.collidesWith(rule2)) return true
-            }
+        for (time in range.times!!) {
+            result.add(CollectionTimesRow(weekdaysAndHolidays, (time as ClockTime).toMinutesOfDay()))
         }
     }
-    return false
+    return result
 }
 
-private fun Rule.collidesWith(other: Rule): Boolean {
-    // if both rules have date ranges and no date range of the first collides with any date range
-    // of the second, they do not collide
-    val dates1 = dates
-    if (dates1 != null) {
-        val dates2 = other.dates
-        if (dates2 != null && dates1.all { date1 -> dates2.none { date2 -> date2.intersectWith(date1) } }) {
-            return false
+private fun Collection<MonthsOrDateSelector>.toMonths(): Months {
+    val monthsData = BooleanArray(Months.MONTHS_COUNT)
+    for (selector in this) {
+        when (selector) {
+            is SingleMonth -> {
+                monthsData[selector.month.ordinal] = true
+            }
+            is MonthRange -> {
+                val start = selector.start.ordinal
+                val end = selector.end.ordinal
+                if (start <= end) { // ranges like Jan-Feb
+                    for (i in start..end) monthsData[i] = true
+                } else { // ranges like Oct-Jan
+                    for (i in start..<Months.MONTHS_COUNT) monthsData[i] = true
+                    for (i in 0..end) monthsData[i] = true
+                }
+            }
+            else -> throw IllegalArgumentException()
         }
     }
-    // if no weekdays specified: collides with everything
-    if (days.isNullOrEmpty() && holidays.isNullOrEmpty()) return true
-    if (other.days.isNullOrEmpty() && other.holidays.isNullOrEmpty()) return true
-
-    // handle times looping into next day
-    val compareDays =
-        if (times?.any { it.expandsIntoNextDay() } == true) {
-            days?.map { it.expandedToNextDay() }
-        } else {
-            days
-        }
-    val compareOtherDays =
-        if (other.times?.any { it.expandsIntoNextDay() } == true) {
-            other.days?.map { it.expandedToNextDay() }
-        } else {
-            other.days
-        }
-
-    if (holidays.orEmpty().intersectsWith(other.holidays.orEmpty())) return true
-    if (compareDays.orEmpty().intersectsWith(compareOtherDays.orEmpty())) return true
-
-    return false
-}
-
-private fun DateRange.intersectWith(other: DateRange): Boolean {
-    return toCircularSection().intersects(other.toCircularSection())
-}
-
-@JvmName("intersectsWithWeekDayRange")
-private fun List<WeekDayRange>.intersectsWith(other: List<WeekDayRange>): Boolean =
-    any { range1 -> other.any { range2 -> range1.intersectsWith(range2) } }
-
-private fun WeekDayRange.intersectsWith(other: WeekDayRange): Boolean {
-    return toCircularSection().intersects(other.toCircularSection())
-}
-
-@JvmName("intersectsWithHolidays")
-private fun List<Holiday>.intersectsWith(other: List<Holiday>): Boolean =
-    any { holiday1 -> other.any { holiday2 -> holiday1.intersectsWith(holiday2) } }
-
-private fun Holiday.intersectsWith(other: Holiday): Boolean {
-    require(isSupported())
-    require(other.isSupported())
-    return type == other.type
-}
-
-private fun WeekDayRange.expandedToNextDay(): WeekDayRange = WeekDayRange().also {
-    it.startDay = startDay
-    val end = if (endDay == null) startDay else endDay
-    val values = WeekDay.entries.toTypedArray()
-    it.endDay = values[(end.ordinal + 1) % values.size]
-}
-
-private fun TimeSpan.expandsIntoNextDay(): Boolean =
-    end != TimeSpan.UNDEFINED_TIME && (end < start || end > 24 * 60)
-
-private fun Rule.createOpeningWeekdays(): List<OpeningWeekdaysRow> {
-    val weekdays = WeekDayRangesAndHolidays(days, holidays).toWeekdays()
-    val timeRanges = times!!.map { it.toTimeRange() }
-    return timeRanges.map { OpeningWeekdaysRow(weekdays, it) }
-}
-
-private fun Rule.createCollectionTimesWeekdays(): List<CollectionTimesRow> {
-    val weekdays = WeekDayRangesAndHolidays(days, holidays).toWeekdays()
-    val minutes = times!!.map { it.start }
-    return minutes.map { CollectionTimesRow(weekdays, it) }
+    return Months(monthsData)
 }
 
-private fun Rule.createOffDays(): OffDaysRow {
-    val weekdays = WeekDayRangesAndHolidays(days, holidays).toWeekdays()
-    return OffDaysRow(weekdays)
-}
+data class WeekdaysAndHolidays(
+    val weekdays: List<WeekdaysSelector>?,
+    val holidays: List<HolidaySelector>?
+)
 
-private fun WeekDayRangesAndHolidays.toWeekdays(): Weekdays {
-    val dayData = BooleanArray(OSM_ABBR_WEEKDAYS.size) { false }
-    if (weekdayRanges != null) {
-        for (weekDayRange in weekdayRanges) {
-            val range = weekDayRange.toCircularSection()
-            if (!range.loops) { // ranges like Tuesday-Saturday
-                for (i in range.start..range.end) dayData[i] = true
-            } else { // ranges like Saturday-Tuesday
-                for (i in range.start until Weekdays.WEEKDAY_COUNT) dayData[i] = true
-                for (i in 0..range.end) dayData[i] = true
+private fun WeekdaysAndHolidays.toWeekdays(): Weekdays {
+    val dayData = BooleanArray(Weekdays.OSM_ABBR_WEEKDAYS.size) { false }
+    for (weekday in weekdays.orEmpty()) {
+        when (weekday) {
+            is Weekday -> {
+                dayData[weekday.ordinal] = true
+            }
+            is WeekdayRange -> {
+                val start = weekday.start.ordinal
+                val end = weekday.end.ordinal
+                if (start <= end) { // ranges like Tuesday-Saturday
+                    for (i in start..end) dayData[i] = true
+                } else { // ranges like Saturday-Tuesday
+                    for (i in start..<Weekdays.WEEKDAY_COUNT) dayData[i] = true
+                    for (i in 0..end) dayData[i] = true
+                }
             }
+            else -> throw IllegalArgumentException()
         }
     }
-    if (holidays?.singleOrNull() != null) {
-        dayData[PUBLIC_HOLIDAY] = true
+    for (holiday in holidays.orEmpty()) {
+        when (holiday) {
+            Holiday.PublicHoliday -> dayData[PUBLIC_HOLIDAY] = true
+            else -> throw IllegalArgumentException()
+        }
     }
     return Weekdays(dayData)
 }
 
-private fun List<DateRange>.toMonths(): Months {
-    val monthsData = BooleanArray(Months.MONTHS_COUNT)
-    for (dateRange in this) {
-        val range = dateRange.toCircularSection()
-        if (!range.loops) { // ranges like Jan-Feb
-            for (i in range.start..range.end) monthsData[i] = true
-        } else { // ranges like Oct-Jan
-            for (i in range.start until Months.MONTHS_COUNT) monthsData[i] = true
-            for (i in 0..range.end) monthsData[i] = true
-        }
-    }
-    return Months(monthsData)
+private fun TimeSpansSelector.toTimeRange(): TimeRange = when (this) {
+    is StartingAtTime -> TimeRange(start.toMinutesOfDay(), start.toMinutesOfDay(), true)
+    is TimeSpan ->       TimeRange(start.toMinutesOfDay(), end.toMinutesOfDay(), openEnd)
 }
 
-private fun TimeSpan.toTimeRange() = TimeRange(
-    start,
-    if (end != TimeSpan.UNDEFINED_TIME) end % (24 * 60) else start,
-    isOpenEnded
-)
-
-private fun DateRange.toCircularSection(): CircularSection {
-    require(isSupportedOpeningHours())
-    return CircularSection(startDate.month.ordinal, (endDate ?: startDate).month.ordinal)
-}
+internal fun ClockTime.toMinutesOfDay(): Int = hour * 60 + minutes
 
-private fun WeekDayRange.toCircularSection(): CircularSection {
-    require(isSupported())
-    return CircularSection(startDay.ordinal, (endDay ?: startDay).ordinal)
+internal fun ExtendedTime.toMinutesOfDay(): Int = when (this) {
+    is ExtendedClockTime -> hour * 60 + minutes
+    is ClockTime -> toMinutesOfDay()
+    is VariableTime -> throw IllegalArgumentException()
 }
diff --git a/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursValidator.kt b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursValidator.kt
new file mode 100644
index 00000000000..11abb4f293f
--- /dev/null
+++ b/app/src/main/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursValidator.kt
@@ -0,0 +1,292 @@
+package de.westnordost.streetcomplete.osm.opening_hours.parser
+
+import de.westnordost.osm_opening_hours.model.ClockTime
+import de.westnordost.osm_opening_hours.model.ExtendedClockTime
+import de.westnordost.osm_opening_hours.model.Holiday
+import de.westnordost.osm_opening_hours.model.HolidaySelector
+import de.westnordost.osm_opening_hours.model.MonthRange
+import de.westnordost.osm_opening_hours.model.MonthsOrDateSelector
+import de.westnordost.osm_opening_hours.model.OpeningHours
+import de.westnordost.osm_opening_hours.model.Range
+import de.westnordost.osm_opening_hours.model.Rule
+import de.westnordost.osm_opening_hours.model.RuleOperator
+import de.westnordost.osm_opening_hours.model.RuleType
+import de.westnordost.osm_opening_hours.model.Selector
+import de.westnordost.osm_opening_hours.model.SingleMonth
+import de.westnordost.osm_opening_hours.model.SpecificWeekdays
+import de.westnordost.osm_opening_hours.model.StartingAtTime
+import de.westnordost.osm_opening_hours.model.TimeIntervals
+import de.westnordost.osm_opening_hours.model.TimeSpan
+import de.westnordost.osm_opening_hours.model.TimesSelector
+import de.westnordost.osm_opening_hours.model.TwentyFourSeven
+import de.westnordost.osm_opening_hours.model.Weekday
+import de.westnordost.osm_opening_hours.model.WeekdayRange
+import de.westnordost.osm_opening_hours.model.WeekdaysSelector
+import de.westnordost.streetcomplete.osm.opening_hours.model.CircularSection
+
+/** Returns true if supported by StreetComplete, i.e. can be displayed in the
+ *  widget.
+ *
+ *  Supported are (plain)
+ *  - months, month ranges
+ *  - weekdays, weekday ranges, PH
+ *  - "off" rules with exclusively weekdays, weekday ranges, PH
+ *  - time spans, open end
+ *  */
+fun OpeningHours.isSupportedOpeningHours(): Boolean =
+    rules.all { rule -> rule.isSupportedOpeningHours() } &&
+        !containsTimePoints() &&
+        !rules.hasCollidingWeekdays()
+
+fun Rule.isSupportedOpeningHours(): Boolean =
+    // comments not supported
+    comment == null &&
+    // fallback rules not supported
+    ruleOperator != RuleOperator.Fallback &&
+    when (ruleType) {
+        // open and implictly open rules are supported, but times must be defined
+        RuleType.Open, null ->
+            selector.hasTimes()
+        // unknown-rules are not supported
+        RuleType.Unknown -> false
+        // only closed-rules that specify weekdays are supported
+        RuleType.Closed, RuleType.Off ->
+            ruleOperator == RuleOperator.Normal &&
+                !selector.hasTimes()
+    } &&
+    (selector as? Range)?.isSupportedOpeningHours() == true
+
+private fun Selector.hasTimes(): Boolean =
+    this is Range && !times.isNullOrEmpty()
+
+// only months ranges, weekdays+holidays and times are supported
+private fun Range.isSupportedOpeningHours(): Boolean =
+    text == null &&
+    years.isNullOrEmpty() &&
+    weeks.isNullOrEmpty() &&
+    months.orEmpty().all { it.isSupported() } &&
+    weekdays.orEmpty().all { it.isSupported() } &&
+    holidays.orEmpty().all { it.isSupported() } &&
+    times.orEmpty().all { it.isSupported() } &&
+    // "weekdays on holidays" not supported
+    !isRestrictedByHolidays &&
+    !isEmpty()
+
+// only plain months or month ranges (without years) are supported
+private fun MonthsOrDateSelector.isSupported(): Boolean = when (this) {
+    is SingleMonth -> year == null
+    is MonthRange -> year == null
+    else -> false
+}
+
+// only plain weekdays, weekday ranges are supported
+internal fun WeekdaysSelector.isSupported(): Boolean = when (this) {
+    is Weekday, is WeekdayRange -> true
+    is SpecificWeekdays -> false
+}
+
+internal fun HolidaySelector.isSupported(): Boolean =
+    this == Holiday.PublicHoliday
+
+// only plain times and time spans with optional open end supported
+internal fun TimesSelector.isSupported(): Boolean = when (this) {
+    is ClockTime -> true
+    is StartingAtTime -> start is ClockTime
+    is TimeSpan -> start is ClockTime && (end is ClockTime || end is ExtendedClockTime)
+    else -> false
+}
+
+/** Returns true if supported by StreetComplete, i.e. can be displayed in the
+ *  widget.
+ *
+ *  Supported are (plain)
+ *  - weekdays, weekday ranges, PH
+ *  - time points
+ *  */
+fun OpeningHours.isSupportedCollectionTimes(): Boolean =
+    rules.all { rule -> rule.isSupportedCollectionTimes() } &&
+    containsTimePoints() &&
+    !rules.hasCollidingWeekdays()
+
+fun Rule.isSupportedCollectionTimes(): Boolean =
+    // comments not supported
+    comment == null &&
+    // fallback rules not supported
+    ruleOperator != RuleOperator.Fallback &&
+    // no rule types supported
+    ruleType == null &&
+    (selector as? Range)?.isSupportedCollectionTimes() == true
+
+// only weekdays+holidays and times are supported
+private fun Range.isSupportedCollectionTimes(): Boolean =
+    text == null &&
+    years.isNullOrEmpty() &&
+    weeks.isNullOrEmpty() &&
+    // months not supported for collection times
+    months.isNullOrEmpty() &&
+    weekdays.orEmpty().all { it.isSupported() } &&
+    holidays.orEmpty().all { it.isSupported() } &&
+    !isRestrictedByHolidays &&
+    !times.isNullOrEmpty() &&
+    times.orEmpty().all { it.isSupported() } &&
+    !isEmpty()
+
+
+/** For example, "Mo-Fr 10:00-12:00; We 14:00-16:00" self-collides: Wednesday is overwritten
+ *  to only be open 14:00 to 16:00. A rule collides with another whenever the days overlap. When
+ *  non-additive rules and additive rules mix, it becomes a bit difficult to find it out */
+fun List<Rule>.hasCollidingWeekdays(): Boolean {
+    for (i in indices) {
+        val rule1 = get(i)
+        if (rule1.ruleType == RuleType.Off || rule1.ruleType == RuleType.Closed) {
+            continue
+        }
+        var additive = true
+        for (j in i + 1 until size) {
+            val rule2 = get(j)
+            // off/closed rules may collide because they overwrite their whole weekdays anyway
+            // (because we do not support times for off-rules in StreetComplete)
+            if (rule2.ruleType == RuleType.Off || rule2.ruleType == RuleType.Closed) {
+                continue
+            }
+            // additive rules do not collide
+            if (rule2.ruleOperator != RuleOperator.Additional) {
+                additive = false
+            }
+            if (!additive) {
+                if (rule1.selector.collidesWith(rule2.selector)) return true
+            }
+        }
+    }
+    return false
+}
+
+private fun Selector.collidesWith(other: Selector): Boolean = when (this) {
+    is Range -> when (other) {
+        is Range -> collidesWith(other)
+        TwentyFourSeven -> true
+    }
+    TwentyFourSeven -> true
+}
+
+private fun Range.collidesWith(other: Range): Boolean {
+    // don't check for unsupported things
+    require(years.isNullOrEmpty())
+    require(other.years.isNullOrEmpty())
+    require(weeks.isNullOrEmpty())
+    require(other.weeks.isNullOrEmpty())
+    require(text == null)
+    require(other.text == null)
+    require(!isRestrictedByHolidays)
+    require(!other.isRestrictedByHolidays)
+
+    // checks assume that times are actually specified
+    require(!times.isNullOrEmpty())
+    require(!other.times.isNullOrEmpty())
+
+    // if both rules have date ranges and no date range of the first collides with any date range
+    // of the second, they do not collide
+    val months = months
+    val otherMonths = other.months
+    if (!months.isNullOrEmpty() && !otherMonths.isNullOrEmpty() && !months.intersectWith(otherMonths)) {
+        return false
+    }
+
+    // if no weekdays specified: collides with everything
+    if (weekdays.isNullOrEmpty() && holidays.isNullOrEmpty()) return true
+    if (other.weekdays.isNullOrEmpty() && other.holidays.isNullOrEmpty()) return true
+
+    // handle times looping into next day
+    val compareWeekdays = weekdays?.expandedToNextDayIfAnyTimeExpandsIntoNextDay(times)
+    val compareOtherWeekdays = other.weekdays?.expandedToNextDayIfAnyTimeExpandsIntoNextDay(other.times)
+
+    if (holidays.orEmpty().intersectWith(other.holidays.orEmpty())) return true
+    if (compareWeekdays.orEmpty().intersectWith(compareOtherWeekdays.orEmpty())) return true
+
+    return false
+}
+
+@JvmName("monthsOrDateSelectorsIntersectWith")
+private fun Collection<MonthsOrDateSelector>.intersectWith(other: Collection<MonthsOrDateSelector>): Boolean =
+    any { dates1 -> other.any { dates2 -> dates1.intersectsWith(dates2) } }
+
+private fun MonthsOrDateSelector.intersectsWith(other: MonthsOrDateSelector): Boolean =
+    toCircularSection().intersects(other.toCircularSection())
+
+private fun MonthsOrDateSelector.toCircularSection(): CircularSection {
+    return when (this) {
+        is SingleMonth -> {
+            require(year == null)
+            CircularSection(month.ordinal, month.ordinal)
+        }
+        is MonthRange -> {
+            require(year == null)
+            CircularSection(start.ordinal, end.ordinal)
+        }
+        else -> throw IllegalArgumentException()
+    }
+}
+
+@JvmName("weekdaysSelectorsIntersectWith")
+private fun Collection<WeekdaysSelector>.intersectWith(other: Collection<WeekdaysSelector>): Boolean =
+    any { weekdays1 -> other.any { weekdays2 -> weekdays1.intersectsWith(weekdays2) } }
+
+private fun WeekdaysSelector.intersectsWith(other: WeekdaysSelector): Boolean =
+    toCircularSection().intersects(other.toCircularSection())
+
+private fun WeekdaysSelector.toCircularSection(): CircularSection = when (this) {
+    is Weekday -> CircularSection(ordinal, ordinal)
+    is WeekdayRange -> CircularSection(start.ordinal, end.ordinal)
+    is SpecificWeekdays -> throw IllegalArgumentException()
+}
+
+@JvmName("holidaySelectorsIntersectWith")
+private fun Collection<HolidaySelector>.intersectWith(other: Collection<HolidaySelector>): Boolean =
+    any { holiday1 -> other.any { holiday2 -> holiday1.intersectsWith(holiday2) } }
+
+private fun HolidaySelector.intersectsWith(other: HolidaySelector): Boolean =
+    this == other
+
+private fun List<WeekdaysSelector>.expandedToNextDayIfAnyTimeExpandsIntoNextDay(
+    times: Collection<TimesSelector>?
+): List<WeekdaysSelector> =
+    if (times?.any { it.expandsIntoNextDay() } == true) map { it.expandedToNextDay() }
+    else this
+
+private fun WeekdaysSelector.expandedToNextDay(): WeekdaysSelector {
+    val s: Weekday
+    val e: Weekday
+    when (this) {
+        is Weekday -> {
+            s = this
+            e = this
+        }
+        is WeekdayRange -> {
+            // already goes full-circle
+            if (start.ordinal == end.ordinal + 1 ||
+                start.ordinal == 0 && end.ordinal == Weekday.entries.size -1) return this
+
+            s = start
+            e = end
+        }
+        is SpecificWeekdays -> throw IllegalArgumentException()
+    }
+    return WeekdayRange(s, Weekday.entries[(e.ordinal + 1) % Weekday.entries.size])
+}
+
+private fun TimesSelector.expandsIntoNextDay(): Boolean {
+    val s: Int
+    val e: Int
+    when (this) {
+        is TimeIntervals -> {
+            s = start.toMinutesOfDay()
+            e = end.toMinutesOfDay()
+        }
+        is TimeSpan -> {
+            s = start.toMinutesOfDay()
+            e = end.toMinutesOfDay()
+        }
+        else -> return false
+    }
+    return e < s || e > 24 * 60
+}
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt
index 66f8e66ffa8..7e1dbc48b8e 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHours.kt
@@ -1,5 +1,6 @@
 package de.westnordost.streetcomplete.quests.opening_hours
 
+import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull
 import de.westnordost.osmfeatures.Feature
 import de.westnordost.streetcomplete.R
 import de.westnordost.streetcomplete.data.elementfilter.filters.RelativeDate
@@ -14,7 +15,6 @@ import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.
 import de.westnordost.streetcomplete.osm.IS_SHOP_OR_DISUSED_SHOP_EXPRESSION
 import de.westnordost.streetcomplete.osm.Tags
 import de.westnordost.streetcomplete.osm.opening_hours.parser.isSupportedOpeningHours
-import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRules
 import de.westnordost.streetcomplete.osm.updateCheckDateForKey
 import de.westnordost.streetcomplete.osm.updateWithCheckDate
 
@@ -130,8 +130,9 @@ class AddOpeningHours(
 
     override fun getTitle(tags: Map<String, String>): Int {
         // treat invalid opening hours like it is not set at all
-        val hasValidOpeningHours = tags["opening_hours"]?.toOpeningHoursRules() != null
-        return if (hasValidOpeningHours) {
+        val oh = tags["opening_hours"]?.toOpeningHoursOrNull(lenient = true)
+        val hasSupportedOpeningHours = oh != null && oh.isSupportedOpeningHours()
+        return if (hasSupportedOpeningHours) {
             R.string.quest_openingHours_resurvey_title
         } else {
             R.string.quest_openingHours_title
@@ -147,14 +148,16 @@ class AddOpeningHours(
         // only show places that can be named somehow
         if (!hasName(tags)) return false
         // no opening_hours yet -> new survey
-        val oh = tags["opening_hours"] ?: return true
+        val ohStr = tags["opening_hours"] ?: return true
         /* don't show if it was recently checked (actually already checked by filter, but it is a
            performance improvement to avoid parsing the opening hours en masse if possible) */
         if (!olderThan1Year.matches(element)) return false
         // invalid opening_hours rules -> applicable because we want to ask for opening hours again
-        val rules = oh.toOpeningHoursRules() ?: return true
-        // only display supported rules
-        return rules.isSupportedOpeningHours()
+        // be strict
+        val oh = ohStr.toOpeningHoursOrNull(lenient = false) ?: return true
+        // only display supported rules, however, those that are supported but have colliding
+        // weekdays should be shown (->resurveyed), as they are likely mistakes
+        return oh.rules.all { rule -> rule.isSupportedOpeningHours() } && !oh.containsTimePoints()
     }
 
     override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) =
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursForm.kt
index b55db1d07e2..658196cde27 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursForm.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursForm.kt
@@ -8,18 +8,18 @@ import androidx.appcompat.widget.PopupMenu
 import androidx.core.view.isGone
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
+import de.westnordost.osm_opening_hours.parser.toOpeningHours
+import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull
 import de.westnordost.streetcomplete.R
 import de.westnordost.streetcomplete.databinding.QuestOpeningHoursBinding
 import de.westnordost.streetcomplete.databinding.QuestOpeningHoursCommentBinding
 import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRows
-import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRules
 import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm
 import de.westnordost.streetcomplete.quests.AnswerItem
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningHoursAdapter
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningMonthsRow
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningWeekdaysRow
 import de.westnordost.streetcomplete.view.AdapterDataChangedWatcher
-import kotlinx.serialization.decodeFromString
 import kotlinx.serialization.encodeToString
 import kotlinx.serialization.json.Json
 
@@ -33,7 +33,9 @@ class AddOpeningHoursForm : AbstractOsmQuestForm<OpeningHoursAnswer>() {
             listOf(
                 AnswerItem(R.string.quest_generic_hasFeature_no) { setAsResurvey(false) },
                 AnswerItem(R.string.quest_generic_hasFeature_yes) {
-                    applyAnswer(RegularOpeningHours(element.tags["opening_hours"]!!.toOpeningHoursRules()!!))
+                    applyAnswer(RegularOpeningHours(
+                        element.tags["opening_hours"]!!.toOpeningHours(lenient = true)
+                    ))
                 }
             )
         } else {
@@ -112,7 +114,7 @@ class AddOpeningHoursForm : AbstractOsmQuestForm<OpeningHoursAnswer>() {
 
     private fun initStateFromTags() {
         val oh = element.tags["opening_hours"]
-        val rows = oh?.toOpeningHoursRules()?.toOpeningHoursRows()
+        val rows = oh?.toOpeningHoursOrNull(lenient = true)?.toOpeningHoursRows()
         if (rows != null) {
             openingHoursAdapter.rows = rows.toMutableList()
             setAsResurvey(true)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/OpeningHoursAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/OpeningHoursAnswer.kt
index 2bf7daf6936..e40d7ed1751 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/OpeningHoursAnswer.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/OpeningHoursAnswer.kt
@@ -1,10 +1,10 @@
 package de.westnordost.streetcomplete.quests.opening_hours
 
-import de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursRuleList
+import de.westnordost.osm_opening_hours.model.OpeningHours
 
 sealed interface OpeningHoursAnswer
 
-data class RegularOpeningHours(val hours: OpeningHoursRuleList) : OpeningHoursAnswer
+data class RegularOpeningHours(val hours: OpeningHours) : OpeningHoursAnswer
 data object AlwaysOpen : OpeningHoursAnswer
 data class DescribeOpeningHours(val text: String) : OpeningHoursAnswer
 data object NoOpeningHoursSign : OpeningHoursAnswer
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/adapter/OpeningHoursAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/adapter/OpeningHoursAdapter.kt
index 3d13ad29694..650f432362f 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/adapter/OpeningHoursAdapter.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/opening_hours/adapter/OpeningHoursAdapter.kt
@@ -14,7 +14,7 @@ import de.westnordost.streetcomplete.databinding.QuestTimesWeekdayRowBinding
 import de.westnordost.streetcomplete.osm.opening_hours.model.Months
 import de.westnordost.streetcomplete.osm.opening_hours.model.TimeRange
 import de.westnordost.streetcomplete.osm.opening_hours.model.Weekdays
-import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRules
+import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHours
 import de.westnordost.streetcomplete.quests.opening_hours.MonthsPickerDialog
 import de.westnordost.streetcomplete.quests.opening_hours.TimeRangePickerDialog
 import de.westnordost.streetcomplete.quests.opening_hours.WeekdaysPickerDialog
@@ -175,7 +175,7 @@ class OpeningHoursAdapter(private val context: Context) :
         notifyItemInserted(insertIndex)
     }
 
-    fun createOpeningHours() = rows.toOpeningHoursRules()
+    fun createOpeningHours() = rows.toOpeningHours()
 
     fun changeToMonthsMode() {
         if (rows.isEmpty()) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/AddParkingFeeForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/AddParkingFeeForm.kt
index 259f767ba5b..5a1ebe12207 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/AddParkingFeeForm.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/AddParkingFeeForm.kt
@@ -3,7 +3,7 @@ package de.westnordost.streetcomplete.quests.parking_fee
 import de.westnordost.streetcomplete.R
 import de.westnordost.streetcomplete.databinding.QuestFeeHoursBinding
 import de.westnordost.streetcomplete.databinding.QuestMaxstayBinding
-import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRules
+import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHours
 import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm
 import de.westnordost.streetcomplete.quests.AnswerItem
 import de.westnordost.streetcomplete.quests.parking_fee.AddParkingFeeForm.Mode.FEE_AT_HOURS
@@ -100,7 +100,7 @@ class AddParkingFeeForm : AbstractOsmQuestForm<FeeAndMaxStay>() {
     override fun onClickOk() {
         when (mode) {
             FEE_AT_HOURS -> {
-                val hours = feeAtHoursSelect!!.times.toOpeningHoursRules()
+                val hours = feeAtHoursSelect!!.times.toOpeningHours()
                 val fee = when (feeAtHoursSelect!!.timeRestriction) {
                     AT_ANY_TIME -> HasFee
                     ONLY_AT_HOURS -> HasFeeAtHours(hours)
@@ -117,7 +117,7 @@ class AddParkingFeeForm : AbstractOsmQuestForm<FeeAndMaxStay>() {
                         DurationUnit.DAYS -> MaxStay.Unit.DAYS
                     }
                 )
-                val hours = maxstayAtHoursSelect!!.times.toOpeningHoursRules()
+                val hours = maxstayAtHoursSelect!!.times.toOpeningHours()
                 val maxstay = when (maxstayAtHoursSelect!!.timeRestriction) {
                     AT_ANY_TIME -> duration
                     ONLY_AT_HOURS -> MaxStayAtHours(duration, hours)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/Fee.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/Fee.kt
index 6e879aa1ebe..c2d431cdf83 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/Fee.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/Fee.kt
@@ -1,15 +1,15 @@
 package de.westnordost.streetcomplete.quests.parking_fee
 
+import de.westnordost.osm_opening_hours.model.OpeningHours
 import de.westnordost.streetcomplete.osm.Tags
-import de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursRuleList
 import de.westnordost.streetcomplete.osm.updateWithCheckDate
 
 sealed interface Fee
 
 data object HasFee : Fee
 data object HasNoFee : Fee
-data class HasFeeAtHours(val hours: OpeningHoursRuleList) : Fee
-data class HasFeeExceptAtHours(val hours: OpeningHoursRuleList) : Fee
+data class HasFeeAtHours(val hours: OpeningHours) : Fee
+data class HasFeeExceptAtHours(val hours: OpeningHours) : Fee
 
 fun Fee.applyTo(tags: Tags) {
     when (this) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/MaxStay.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/MaxStay.kt
index fb448ca557d..89a3ce3695c 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/MaxStay.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/parking_fee/MaxStay.kt
@@ -1,7 +1,7 @@
 package de.westnordost.streetcomplete.quests.parking_fee
 
+import de.westnordost.osm_opening_hours.model.OpeningHours
 import de.westnordost.streetcomplete.osm.Tags
-import de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursRuleList
 import de.westnordost.streetcomplete.osm.updateWithCheckDate
 import de.westnordost.streetcomplete.quests.parking_fee.MaxStay.Unit.DAYS
 import de.westnordost.streetcomplete.quests.parking_fee.MaxStay.Unit.HOURS
@@ -14,8 +14,8 @@ sealed interface MaxStay {
 
 data object NoMaxStay : MaxStay
 data class MaxStayDuration(val value: Double, val unit: MaxStay.Unit) : MaxStay
-data class MaxStayAtHours(val duration: MaxStayDuration, val hours: OpeningHoursRuleList) : MaxStay
-data class MaxStayExceptAtHours(val duration: MaxStayDuration, val hours: OpeningHoursRuleList) : MaxStay
+data class MaxStayAtHours(val duration: MaxStayDuration, val hours: OpeningHours) : MaxStay
+data class MaxStayExceptAtHours(val duration: MaxStayDuration, val hours: OpeningHours) : MaxStay
 
 fun MaxStayDuration.toOsmValue(): String =
     value.toShortString() + " " + when (unit) {
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimes.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimes.kt
index 80fac5c1c3c..f2970c85903 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimes.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimes.kt
@@ -1,5 +1,6 @@
 package de.westnordost.streetcomplete.quests.postbox_collection_times
 
+import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull
 import de.westnordost.streetcomplete.R
 import de.westnordost.streetcomplete.data.elementfilter.toElementFilterExpression
 import de.westnordost.streetcomplete.data.osm.geometry.ElementGeometry
@@ -11,7 +12,7 @@ import de.westnordost.streetcomplete.data.quest.NoCountriesExcept
 import de.westnordost.streetcomplete.data.user.achievements.EditTypeAchievement.POSTMAN
 import de.westnordost.streetcomplete.osm.Tags
 import de.westnordost.streetcomplete.osm.opening_hours.parser.isSupportedCollectionTimes
-import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRules
+import de.westnordost.streetcomplete.osm.opening_hours.parser.isSupportedOpeningHours
 import de.westnordost.streetcomplete.osm.updateWithCheckDate
 
 class AddPostboxCollectionTimes : OsmElementQuestType<CollectionTimesAnswer> {
@@ -56,8 +57,9 @@ class AddPostboxCollectionTimes : OsmElementQuestType<CollectionTimesAnswer> {
         /* treat invalid collection times like it is not set at all. Any opening hours are
            legal tagging for collection times, even though they are not supported in
            this app, i.e. are never asked again */
-        val hasValidCollectionTimes = tags["collection_times"]?.toOpeningHoursRules() != null
-        return if (hasValidCollectionTimes) {
+        val oh = tags["collection_times"]?.toOpeningHoursOrNull(lenient = true)
+        val hasSupportedCollectionTimes = oh != null && oh.isSupportedCollectionTimes()
+        return if (hasSupportedCollectionTimes) {
             R.string.quest_postboxCollectionTimes_resurvey_title
         } else {
             R.string.quest_postboxCollectionTimes_title
@@ -73,9 +75,11 @@ class AddPostboxCollectionTimes : OsmElementQuestType<CollectionTimesAnswer> {
         // no collection_times yet -> new survey
         val ct = tags["collection_times"] ?: return true
         // invalid opening_hours rules -> applicable because we want to ask for opening hours again
-        val rules = ct.toOpeningHoursRules() ?: return true
-        // only display supported rules
-        return rules.isSupportedCollectionTimes()
+        // be strict
+        val oh = ct.toOpeningHoursOrNull(lenient = false) ?: return true
+        // only display supported rules, however, those that are supported but have colliding
+        // weekdays should be shown (->resurveyed), as they are likely mistakes
+        return oh.rules.all { rule -> rule.isSupportedCollectionTimes() } && oh.containsTimePoints()
     }
 
     override fun getHighlightedElements(element: Element, getMapData: () -> MapDataWithGeometry) =
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimesForm.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimesForm.kt
index 1c4a444c211..d4d97b656a9 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimesForm.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimesForm.kt
@@ -8,14 +8,14 @@ import androidx.appcompat.widget.PopupMenu
 import androidx.core.view.isGone
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
+import de.westnordost.osm_opening_hours.parser.toOpeningHours
+import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull
 import de.westnordost.streetcomplete.R
 import de.westnordost.streetcomplete.databinding.QuestCollectionTimesBinding
 import de.westnordost.streetcomplete.osm.opening_hours.parser.toCollectionTimesRows
-import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRules
 import de.westnordost.streetcomplete.quests.AbstractOsmQuestForm
 import de.westnordost.streetcomplete.quests.AnswerItem
 import de.westnordost.streetcomplete.view.AdapterDataChangedWatcher
-import kotlinx.serialization.decodeFromString
 import kotlinx.serialization.encodeToString
 import kotlinx.serialization.json.Json
 
@@ -29,7 +29,9 @@ class AddPostboxCollectionTimesForm : AbstractOsmQuestForm<CollectionTimesAnswer
             listOf(
                 AnswerItem(R.string.quest_generic_hasFeature_no) { setAsResurvey(false) },
                 AnswerItem(R.string.quest_generic_hasFeature_yes) {
-                    applyAnswer(CollectionTimes(element.tags["collection_times"]!!.toOpeningHoursRules()!!))
+                    applyAnswer(CollectionTimes(
+                        element.tags["collection_times"]!!.toOpeningHours(lenient = true)
+                    ))
                 }
             )
         } else {
@@ -93,7 +95,7 @@ class AddPostboxCollectionTimesForm : AbstractOsmQuestForm<CollectionTimesAnswer
 
     private fun initStateFromTags() {
         val ct = element.tags["collection_times"]
-        val rows = ct?.toOpeningHoursRules()?.toCollectionTimesRows()
+        val rows = ct?.toOpeningHoursOrNull(lenient = true)?.toCollectionTimesRows()
         if (rows != null) {
             collectionTimesAdapter.collectionTimesRows = rows.toMutableList()
             setAsResurvey(true)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/CollectionTimesAdapter.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/CollectionTimesAdapter.kt
index 493fe739fac..95ad05ca4f9 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/CollectionTimesAdapter.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/CollectionTimesAdapter.kt
@@ -9,7 +9,7 @@ import androidx.recyclerview.widget.RecyclerView
 import de.westnordost.streetcomplete.data.meta.CountryInfo
 import de.westnordost.streetcomplete.databinding.QuestTimesWeekdayRowBinding
 import de.westnordost.streetcomplete.osm.opening_hours.model.Weekdays
-import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRules
+import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHours
 import de.westnordost.streetcomplete.quests.opening_hours.WeekdaysPickerDialog
 import de.westnordost.streetcomplete.util.timeOfDayToString
 import de.westnordost.streetcomplete.view.dialogs.TimePickerDialog
@@ -36,7 +36,7 @@ class CollectionTimesAdapter(
             notifyDataSetChanged()
         }
 
-    fun createCollectionTimes() = collectionTimesRows.toOpeningHoursRules()
+    fun createCollectionTimes() = collectionTimesRows.toOpeningHours()
 
     override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
         val inflater = LayoutInflater.from(parent.context)
diff --git a/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/CollectionTimesAnswer.kt b/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/CollectionTimesAnswer.kt
index fb5c63dcf68..487862409db 100644
--- a/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/CollectionTimesAnswer.kt
+++ b/app/src/main/java/de/westnordost/streetcomplete/quests/postbox_collection_times/CollectionTimesAnswer.kt
@@ -1,8 +1,8 @@
 package de.westnordost.streetcomplete.quests.postbox_collection_times
 
-import de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursRuleList
+import de.westnordost.osm_opening_hours.model.OpeningHours
 
 sealed interface CollectionTimesAnswer
 
-data class CollectionTimes(val times: OpeningHoursRuleList) : CollectionTimesAnswer
+data class CollectionTimes(val times: OpeningHours) : CollectionTimesAnswer
 data object NoCollectionTimesSign : CollectionTimesAnswer


Test patch:
diff --git a/app/src/test/java/de/westnordost/streetcomplete/OpeningHoursParsingTest.kt b/app/src/test/java/de/westnordost/streetcomplete/OpeningHoursParsingTest.kt
index b55352aabd0..75b5e3aeaa1 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/OpeningHoursParsingTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/OpeningHoursParsingTest.kt
@@ -1,20 +1,46 @@
 package de.westnordost.streetcomplete
 
-import ch.poole.openinghoursparser.YearRange
+import de.westnordost.osm_opening_hours.model.CalendarDate
+import de.westnordost.osm_opening_hours.model.ClockTime
+import de.westnordost.osm_opening_hours.model.Date
+import de.westnordost.osm_opening_hours.model.DateRange
+import de.westnordost.osm_opening_hours.model.DatesInMonth
+import de.westnordost.osm_opening_hours.model.MonthRange
+import de.westnordost.osm_opening_hours.model.MonthsOrDateSelector
+import de.westnordost.osm_opening_hours.model.Range
+import de.westnordost.osm_opening_hours.model.RuleOperator
+import de.westnordost.osm_opening_hours.model.RuleType
+import de.westnordost.osm_opening_hours.model.SingleMonth
+import de.westnordost.osm_opening_hours.model.SpecificWeekdayDate
+import de.westnordost.osm_opening_hours.model.StartingAtDate
+import de.westnordost.osm_opening_hours.model.StartingAtTime
+import de.westnordost.osm_opening_hours.model.TimeIntervals
+import de.westnordost.osm_opening_hours.model.TimeSpan
+import de.westnordost.osm_opening_hours.model.TimesSelector
+import de.westnordost.osm_opening_hours.model.TwentyFourSeven
+import de.westnordost.osm_opening_hours.model.VariableDate
+import de.westnordost.osm_opening_hours.model.VariableTime
+import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull
+import de.westnordost.streetcomplete.osm.opening_hours.parser.hasCollidingWeekdays
 import de.westnordost.streetcomplete.osm.opening_hours.parser.isSupported
 import de.westnordost.streetcomplete.osm.opening_hours.parser.isSupportedOpeningHours
+import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHours
 import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRows
-import de.westnordost.streetcomplete.osm.opening_hours.parser.toOpeningHoursRules
-import de.westnordost.streetcomplete.osm.opening_hours.parser.weekdaysCollideWithAnother
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
 import java.net.HttpURLConnection
 import java.net.URL
-import java.nio.charset.StandardCharsets
+import java.net.URLEncoder
 
-fun main() = runBlocking {
-    val limit: Int? = null
+private const val qleverBaseUrl = "https://qlever.cs.uni-freiburg.de/api/osm-planet"
 
+private val qleverQuery = """
+    PREFIX osmkey: <https://www.openstreetmap.org/wiki/Key:>
+    SELECT ?times (COUNT(?times) as ?count)
+    WHERE {?osm_id osmkey:opening_hours ?times}
+    GROUP BY ?times
+    ORDER BY DESC(?count) DESC(?times)
+""".trimIndent().replace("\n"," ").replace("\r","")
+
+fun main() {
     var total = 0
     var parsed = 0
     var supported = 0
@@ -33,82 +59,94 @@ fun main() = runBlocking {
     var noHours = 0
     var timeEvents = 0
 
-    // or go to https://sophox.org/#select%20%3Fopening_hours%20where%20%7B%3Felement%20osmt%3Aopening_hours%20%3Fopening_hours%7D
-    // execute query, download as CSV and use
-    // File(path to downloaded CSV).bufferedReader().useLines { lines ->
-
-    val limitStr = limit?.let { "%0D%0Alimit+$limit" } ?: ""
-    val url = URL("https://sophox.org/sparql?query=select%20%3Fopening_hours%20where%20%7B%3Felement%20osmt%3Aopening_hours%20%3Fopening_hours%7D%20$limitStr")
+    val url = URL("$qleverBaseUrl?query=${URLEncoder.encode(qleverQuery, "UTF-8")}&action=tsv_export")
     val connection = url.openConnection() as HttpURLConnection
-    try {
-        connection.setRequestProperty("Accept", "text/csv")
+    val lines = try {
         connection.setRequestProperty("User-Agent", "StreetComplete opening hours test")
-        connection.setRequestProperty("charset", StandardCharsets.UTF_8.name())
         connection.doOutput = true
-        connection.inputStream.bufferedReader().useLines { lines -> runBlocking {
-            for (line in lines) { launch {
-                total++
-                var oh = line
-                // CSV output wraps string in "..." if it contains a ,
-                if (oh.contains(',') && oh.startsWith('"')) {
-                    oh = oh.trim { it == '"' }.replace("\"\"", "\"")
-                }
-                val rules = oh.toOpeningHoursRules()
-                if (rules != null) {
-                    parsed++
-                    val oh2 = rules.toOpeningHoursRows()?.toOpeningHoursRules()?.toString()
-                    if (oh2 != null) {
-                        supported++
-                    } else {
-                        val r = rules.rules
-                        if (r.any { it.isTwentyfourseven }) {
-                            containsTwentyfourseven++
-                        }
-                        if (r.any { it.comment != null }) {
-                            containsComments++
-                        }
-                        if (r.any { it.isFallBack }) {
-                            containsFallback++
-                        }
-                        if (r.any { it.modifier != null }) {
-                            containingOff++
-                        }
-                        if (r.any { rule -> rule.years != null || rule.dates?.any { it.startDate.year != YearRange.UNDEFINED_YEAR } == true }) {
-                            containsYears++
-                        }
-                        if (r.any { it.weeks != null }) {
-                            containsWeeks++
-                        }
-                        if (r.any { it.times == null && !it.isTwentyfourseven && it.modifier == null }) {
-                            noHours++
-                        }
-                        if (r.any { rule -> rule.holidays?.any { !it.isSupported() } == true }) {
-                            complicatedHolidays++
-                        }
-                        if (r.any { rule -> rule.days?.any { !it.isSupported() } == true }) {
-                            complicatedWeekdayRanges++
-                        }
-                        if (r.any { rule -> rule.times?.any { it.startEvent != null || it.endEvent != null } == true }) {
-                            timeEvents++
-                        }
-                        if (r.any { rule -> rule.times?.any { !it.isSupportedOpeningHours() && it.startEvent == null && it.endEvent == null } == true }) {
-                            complicatedTimes++
-                        }
-                        if (r.any { rule -> rule.dates?.any { !it.isSupportedOpeningHours() } == true }) {
-                            complicatedDates++
-                        }
-                        if (r.all { it.isSupportedOpeningHours() } && r.weekdaysCollideWithAnother()) {
-                            selfColliding++
-                        }
-                    }
-                }
-                if (total % 100000 == 0) print(".")
-            } }
-        } }
+
+        connection.inputStream.bufferedReader()
+            .lineSequence()
+            .drop(1)
+            .filterNot { it.isBlank() }
+            .map { line ->
+                val t = line.lastIndexOf('\t')
+                val oh = line.substring(1, t - 1)
+                val count = line.substring(t + 1).toInt()
+                oh to count
+            }.toList()
     } finally {
         connection.disconnect()
     }
 
+    for ((oh, count) in lines) {
+        total += count
+
+        val rules = oh.toOpeningHoursOrNull(lenient = true)
+        if (rules != null) {
+            parsed += count
+            val rows = rules.toOpeningHoursRows()?.toOpeningHours()?.toString()
+            if (rows != null) {
+                supported += count
+            } else {
+                val r = rules.rules
+                if (r.any { it.selector is TwentyFourSeven }) {
+                    containsTwentyfourseven += count
+                }
+                if (r.any { it.comment != null }) {
+                    containsComments += count
+                }
+                if (r.any { it.ruleOperator == RuleOperator.Fallback }) {
+                    containsFallback += count
+                }
+                if (r.any { it.ruleType == RuleType.Off || it.ruleType == RuleType.Closed }) {
+                    containingOff += count
+                }
+                if (r.any { rule ->
+                    val selector = rule.selector
+                    selector is Range && (!selector.years.isNullOrEmpty() || selector.months.orEmpty().any { it.hasYear() })
+                }) {
+                    containsYears += count
+                }
+                if (r.any { !(it.selector as? Range)?.weeks.isNullOrEmpty() }) {
+                    containsWeeks += count
+                }
+                if (r.any { it.selector is Range && it.ruleType == null && (it.selector as? Range)?.times == null }) {
+                    noHours += count
+                }
+                if (r.any { rule ->
+                    (rule.selector as? Range)?.holidays.orEmpty().any { !it.isSupported() }
+                }) {
+                    complicatedHolidays += count
+                }
+                if (r.any { rule ->
+                    (rule.selector as? Range)?.weekdays.orEmpty().any { !it.isSupported() }
+                }) {
+                    complicatedWeekdayRanges += count
+                }
+                if (r.any { rule ->
+                    (rule.selector as? Range)?.times.orEmpty().any { it.hasVariableTime() }
+                }) {
+                    timeEvents += count
+                }
+                if (r.any { rule ->
+                    (rule.selector as? Range)?.times.orEmpty().any { !it.isSupported() && !it.hasVariableTime() }
+                }) {
+                    complicatedTimes += count
+                }
+                if (r.any { rule ->
+                    (rule.selector as? Range)?.months.orEmpty().any { it !is SingleMonth && it !is MonthRange }
+                }) {
+                    complicatedDates += count
+                }
+                if (r.all { it.isSupportedOpeningHours() } && r.hasCollidingWeekdays()) {
+                    selfColliding += count
+                }
+            }
+        }
+        if (total % 100000 == 0) print(".")
+    }
+
     println()
     print("$total opening hours")
     if (total > 0) {
@@ -137,3 +175,26 @@ fun main() = runBlocking {
 }
 
 private fun percent(v: Double): String = "%.1f".format(100 * v) + "%"
+
+private fun TimesSelector.hasVariableTime(): Boolean = when (this) {
+    is ClockTime -> false
+    is VariableTime -> true
+    is TimeIntervals -> start is VariableTime || end is VariableTime
+    is StartingAtTime -> start is VariableTime
+    is TimeSpan -> start is VariableTime || end is VariableTime
+}
+
+private fun MonthsOrDateSelector.hasYear(): Boolean = when (this) {
+    is Date -> hasYear()
+    is DateRange -> start.hasYear()  || end.hasYear()
+    is DatesInMonth -> year != null
+    is MonthRange -> year != null
+    is SingleMonth -> year != null
+    is StartingAtDate -> start.hasYear()
+}
+
+private fun Date.hasYear(): Boolean = when (this) {
+    is CalendarDate -> year != null
+    is SpecificWeekdayDate -> year != null
+    is VariableDate -> year != null
+}
diff --git a/app/src/test/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursParserAndGeneratorTest.kt b/app/src/test/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursParserAndGeneratorTest.kt
index 04c0d312899..aacdda27983 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursParserAndGeneratorTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/osm/opening_hours/parser/OpeningHoursParserAndGeneratorTest.kt
@@ -1,5 +1,6 @@
 package de.westnordost.streetcomplete.osm.opening_hours.parser
 
+import de.westnordost.osm_opening_hours.parser.toOpeningHoursOrNull
 import de.westnordost.streetcomplete.quests.opening_hours.adapter.OpeningHoursRow
 import de.westnordost.streetcomplete.quests.postbox_collection_times.CollectionTimesRow
 import kotlin.test.Test
@@ -111,8 +112,8 @@ class OpeningHoursParserAndGeneratorTest {
         accept("Mo 06:00-03:00")
         // weekdays and PH
         accept("PH 08:00-10:00")
-        accept("PH,Mo-Th 08:00-10:00")
-        accept("Mo-Th,PH 08:00-10:00", "PH,Mo-Th 08:00-10:00")
+        accept("PH,Mo-Th 08:00-10:00", "Mo-Th,PH 08:00-10:00")
+        accept("Mo-Th,PH 08:00-10:00")
         // weekday ranges
         accept("Mo-Th 08:00-10:00")
         accept("Th-Tu 08:00-10:00")
@@ -127,33 +128,33 @@ class OpeningHoursParserAndGeneratorTest {
         accept("Mo-We,Fr-Su 08:00-10:00", "Fr-We 08:00-10:00")
         accept("Mo-We,Fr-Sa 08:00-10:00", "Mo-We,Fr,Sa 08:00-10:00")
         // with date range
-        accept("Jun: Th 17:30-19:30", "Jun Th 17:30-19:30")
-        accept("Jun Th 17:30-19:30")
-        accept("Jul-Sep Mo 17:00-19:00")
-        accept("Sep-Feb Mo 17:00-19:00")
-        accept("Jan-Feb Mo 17:00-19:00")
+        accept("Jun Th 17:30-19:30", "Jun: Th 17:30-19:30")
+        accept("Jun: Th 17:30-19:30")
+        accept("Jul-Sep: Mo 17:00-19:00")
+        accept("Sep-Feb: Mo 17:00-19:00")
+        accept("Jan-Feb: Mo 17:00-19:00")
         // multiple date ranges
-        accept("Mar,Oct Mo-Su 07:00-18:00")
-        accept("Mar,Oct-Feb Mo-Su 7:00-18:00", "Oct-Mar Mo-Su 07:00-18:00")
-        accept("Apr,Oct-Feb Mo-Su 07:00-18:00")
-        accept("Jun-Jul,Nov-Dec Mo 08:30-09:00")
-        accept("Jan-Jul,Nov-Dec Mo 08:30-09:00", "Nov-Jul Mo 08:30-09:00")
+        accept("Mar,Oct: Mo-Su 07:00-18:00")
+        accept("Mar,Oct-Feb: Mo-Su 7:00-18:00", "Oct-Mar: Mo-Su 07:00-18:00")
+        accept("Apr,Oct-Feb: Mo-Su 07:00-18:00")
+        accept("Jun-Jul,Nov-Dec: Mo 08:30-09:00")
+        accept("Jan-Jul,Nov-Dec: Mo 08:30-09:00", "Nov-Jul: Mo 08:30-09:00")
         // partially month based
-        accept("Th 17:30-19:30; Jul-Sep Mo 17:00-19:00")
-        accept("Jul-Sep Mo 17:00-19:00; Th 17:30-19:30")
+        accept("Th 17:30-19:30; Jul-Sep: Mo 17:00-19:00")
+        accept("Jul-Sep: Mo 17:00-19:00; Th 17:30-19:30")
 
         // all together now
-        accept("Jun,Sep-Feb PH,Mo-We,Fr,Sa 15:00-18:00,20:00-02:00+")
+        accept("Jun,Sep-Feb: Mo-We,Fr,Sa,PH 15:00-18:00,20:00-02:00+")
     }
 
     @Test fun `accepted valid and supported rules`() {
         accept("Mo-We 09:00-20:00; Th 08:00-16:00")
         accept("Mo 09:00-20:00; Su 10:00-11:00; Tu 09:00-20:00")
         // months
-        accept("Jun Th 17:30-19:30; Jul-Sep Mo 17:00-19:00")
-        accept("Jun Th 17:30-19:30; Jun Fr 10:30-20:30; Jul-Sep Mo 17:00-19:00")
-        accept("Jun Th 17:30-19:30; Jun Fr 10:30-20:30; Jul-Sep Mo 17:00-19:00; Oct Mo 22:00-23:00")
-        accept("Mar-Oct Tu-Su 10:30-18:00; Mar-Oct Mo 10:30-14:00; Nov-Dec Tu-Su 11:00-17:00; Nov-Dec Mo 11:00-14:00")
+        accept("Jun: Th 17:30-19:30; Jul-Sep: Mo 17:00-19:00")
+        accept("Jun: Th 17:30-19:30; Jun: Fr 10:30-20:30; Jul-Sep: Mo 17:00-19:00")
+        accept("Jun: Th 17:30-19:30; Jun: Fr 10:30-20:30; Jul-Sep: Mo 17:00-19:00; Oct: Mo 22:00-23:00")
+        accept("Mar-Oct: Tu-Su 10:30-18:00; Mar-Oct: Mo 10:30-14:00; Nov-Dec: Tu-Su 11:00-17:00; Nov-Dec: Mo 11:00-14:00")
         // use ";" if weekdays don't collide
         accept("Su-Tu 09:00-12:00; We-Sa 10:10-10:11")
         accept("Sa-Tu 09:00-12:00; We-Fr 10:10-10:11")
@@ -163,25 +164,25 @@ class OpeningHoursParserAndGeneratorTest {
         accept("Mo 17:30-19:30, Th 17:00-19:00", "Mo 17:30-19:30; Th 17:00-19:00")
         accept("Mo-Sa 07:00-20:00; PH 07:00-07:05")
         // use ";" if weekdays don't collide for months-rules
-        accept("Jun Mo 17:30-19:30; Jul-Sep Mo 17:00-19:00")
-        accept("Jun Mo 17:30-19:30, Jul-Sep Mo 17:00-19:00", "Jun Mo 17:30-19:30; Jul-Sep Mo 17:00-19:00")
-        accept("Jun Th 17:30-19:30; Jun Fr 10:30-20:30; Jul-Sep Th 17:00-19:00")
-        accept("Jun Th 17:30-19:30, Jun Fr 10:30-20:30; Jul-Sep Th 17:00-19:00", "Jun Th 17:30-19:30; Jun Fr 10:30-20:30; Jul-Sep Th 17:00-19:00")
+        accept("Jun: Mo 17:30-19:30; Jul-Sep: Mo 17:00-19:00")
+        accept("Jun Mo 17:30-19:30, Jul-Sep Mo 17:00-19:00", "Jun: Mo 17:30-19:30; Jul-Sep: Mo 17:00-19:00")
+        accept("Jun: Th 17:30-19:30; Jun: Fr 10:30-20:30; Jul-Sep: Th 17:00-19:00")
+        accept("Jun Th 17:30-19:30, Jun Fr 10:30-20:30; Jul-Sep Th 17:00-19:00", "Jun: Th 17:30-19:30; Jun: Fr 10:30-20:30; Jul-Sep: Th 17:00-19:00")
         // use "," if weekdays collide
         accept("Mo-We 09:00-12:00, Tu 16:00-18:00")
         accept("Mo-We 09:00-20:00, We-Fr 21:00-22:00")
-        accept("Mo-Sa 07:00-20:00, PH,Sa 22:00-23:00")
+        accept("Mo-Sa 07:00-20:00, PH,Sa 22:00-23:00", "Mo-Sa 07:00-20:00, Sa,PH 22:00-23:00")
         accept("Mo-We 09:00-12:00, Tu 16:00-18:00; Sa 12:00-18:00", "Mo-We 09:00-12:00, Tu 16:00-18:00, Sa 12:00-18:00")
         // use "," if weekdays collide for months-rules
-        accept("Jun Th 17:30-19:30, Jun-Aug Th 10:30-14:30")
-        accept("Dec-Jun Th 17:30-19:30, Jun-Aug Th 10:30-14:30")
-        accept("Jun Th 17:30-19:30; Jun Fr 10:30-20:30; Jul-Sep Mo 17:00-19:00, Jul-Sep Mo,Tu 22:00-23:00", "Jun Th 17:30-19:30, Jun Fr 10:30-20:30, Jul-Sep Mo 17:00-19:00, Jul-Sep Mo,Tu 22:00-23:00")
+        accept("Jun: Th 17:30-19:30, Jun-Aug: Th 10:30-14:30")
+        accept("Dec-Jun: Th 17:30-19:30, Jun-Aug: Th 10:30-14:30")
+        accept("Jun Th 17:30-19:30; Jun Fr 10:30-20:30; Jul-Sep Mo 17:00-19:00, Jul-Sep Mo,Tu 22:00-23:00", "Jun: Th 17:30-19:30, Jun: Fr 10:30-20:30, Jul-Sep: Mo 17:00-19:00, Jul-Sep: Mo,Tu 22:00-23:00")
         // merging consecutive rules with same days
         accept("Mo 09:00-20:00, Mo 21:00-22:00", "Mo 09:00-20:00,21:00-22:00")
         accept("Mo-Fr 09:00-20:00, Mo-Fr 21:00-22:00", "Mo-Fr 09:00-20:00,21:00-22:00")
-        accept("Mo-Fr,Su,PH 09:00-20:00, Mo-Fr,Su,PH 21:00-22:00", "PH,Su-Fr 09:00-20:00,21:00-22:00")
-        accept("Jun Th 17:30-19:30, Jun Th 10:30-14:30", "Jun Th 17:30-19:30,10:30-14:30")
-        accept("Jun Th 17:30-19:30, Jun Th 10:30-14:30; Jul Mo 08:30-11:00", "Jun Th 17:30-19:30,10:30-14:30; Jul Mo 08:30-11:00")
+        accept("Mo-Fr,Su,PH 09:00-20:00, Mo-Fr,Su,PH 21:00-22:00", "Su-Fr,PH 09:00-20:00,21:00-22:00")
+        accept("Jun Th 17:30-19:30, Jun Th 10:30-14:30", "Jun: Th 17:30-19:30,10:30-14:30")
+        accept("Jun Th 17:30-19:30, Jun Th 10:30-14:30; Jul Mo 08:30-11:00", "Jun: Th 17:30-19:30,10:30-14:30; Jul: Mo 08:30-11:00")
         // looping into next day
         accept("Mo-We 20:00-02:00, Th 08:00-16:00")
         // off rules
@@ -191,18 +192,18 @@ class OpeningHoursParserAndGeneratorTest {
         accept("Mo-Fr 08:00-18:00; PH off")
         accept("Tu-Fr 08:00-10:00; Mo closed", "Tu-Fr 08:00-10:00; Mo off")
         accept("Tu-Fr 08:00-10:00, We 12:00-18:00; Mo off")
-        accept("Tu-Fr 08:00-10:00; Sa 12:00-18:00; PH,Mo off")
+        accept("Tu-Fr 08:00-10:00; Sa 12:00-18:00; Mo,PH off")
         accept("Tu-Fr 08:00-10:00; Mo off, We 12:00-18:00")
         accept("Tu-Fr 08:00-10:00; Mo off, We 12:00-18:00; PH off")
         accept("Mo-Fr 08:00-10:00; We off") // off rules do not collide
     }
 
     private fun parseAndGenerate(oh: String): String? {
-        return oh.toOpeningHoursRules()?.toOpeningHoursRows()?.toOpeningHoursRules()?.toString()
+        return oh.toOpeningHoursOrNull(lenient = true)?.toOpeningHoursRows()?.toOpeningHours()?.toString()
     }
 
     private fun parse(oh: String): List<OpeningHoursRow>? {
-        return oh.toOpeningHoursRules()?.toOpeningHoursRows()
+        return oh.toOpeningHoursOrNull(lenient = true)?.toOpeningHoursRows()
     }
 
     private fun reject(oh: String) {
@@ -219,6 +220,8 @@ class CollectionTimesParserAndGeneratorTest {
     @Test fun `reject invalid or unsupported syntax`() {
         reject("gibberish")
 
+        reject("Mo 24:00")
+
         reject("Mo-Fr 09:00 \"comment text\"") // comments
         reject("08:00 || 10:00") // with fallback
 
@@ -303,7 +306,6 @@ class CollectionTimesParserAndGeneratorTest {
         accept("Mo: 09:00", "Mo 09:00")
         accept("Mo 09:35")
         accept("Mo 00:00")
-        accept("Mo 24:00")
         accept("Mo 5:00", "Mo 05:00")
         accept("Mo 09h00", "Mo 09:00")
         accept("Mo 09.00", "Mo 09:00")
@@ -318,7 +320,7 @@ class CollectionTimesParserAndGeneratorTest {
         accept("Mo 08:00,13:00,18:00")
         // weekdays and PH
         accept("PH 08:00")
-        accept("PH,Mo-Th 08:00")
+        accept("PH,Mo-Th 08:00", "Mo-Th,PH 08:00")
         // weekday ranges
         accept("Mo-Th 08:00")
         accept("Th-Tu 08:00")
@@ -347,20 +349,20 @@ class CollectionTimesParserAndGeneratorTest {
         // use "," if weekdays collide
         accept("Mo-We 09:00, Tu 16:00")
         accept("Mo-We 09:00, We-Fr 21:00")
-        accept("Mo-Sa 07:00, PH,Sa 22:00")
+        accept("Mo-Sa 07:00, PH,Sa 22:00", "Mo-Sa 07:00, Sa,PH 22:00")
         accept("Mo-We 09:00, Tu 16:00; Sa 12:00", "Mo-We 09:00, Tu 16:00, Sa 12:00")
         // merging consecutive rules with same days
         accept("Mo 09:00, Mo 21:00", "Mo 09:00,21:00")
         accept("Mo-Fr 09:00, Mo-Fr 21:00", "Mo-Fr 09:00,21:00")
-        accept("Mo-Fr,Su,PH 09:00, Mo-Fr,Su,PH 21:00", "PH,Su-Fr 09:00,21:00")
+        accept("Mo-Fr,Su,PH 09:00, Mo-Fr,Su,PH 21:00", "Su-Fr,PH 09:00,21:00")
     }
 
     private fun parseAndGenerate(oh: String): String? {
-        return oh.toOpeningHoursRules()?.toCollectionTimesRows()?.toOpeningHoursRules()?.toString()
+        return oh.toOpeningHoursOrNull(lenient = true)?.toCollectionTimesRows()?.toOpeningHours()?.toString()
     }
 
     private fun parse(oh: String): List<CollectionTimesRow>? {
-        return oh.toOpeningHoursRules()?.toCollectionTimesRows()
+        return oh.toOpeningHoursOrNull(lenient = true)?.toCollectionTimesRows()
     }
 
     private fun reject(oh: String) {
diff --git a/app/src/test/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursTest.kt b/app/src/test/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursTest.kt
index 8ae48233ac2..fad735f1727 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/quests/opening_hours/AddOpeningHoursTest.kt
@@ -1,13 +1,14 @@
 package de.westnordost.streetcomplete.quests.opening_hours
 
-import ch.poole.openinghoursparser.Rule
-import ch.poole.openinghoursparser.TimeSpan
-import ch.poole.openinghoursparser.WeekDay
-import ch.poole.openinghoursparser.WeekDayRange
+import de.westnordost.osm_opening_hours.model.ClockTime
+import de.westnordost.osm_opening_hours.model.OpeningHours
+import de.westnordost.osm_opening_hours.model.Range
+import de.westnordost.osm_opening_hours.model.Rule
+import de.westnordost.osm_opening_hours.model.TimeSpan
+import de.westnordost.osm_opening_hours.model.Weekday
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify
 import de.westnordost.streetcomplete.osm.nowAsCheckDateString
-import de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursRuleList
 import de.westnordost.streetcomplete.osm.toCheckDate
 import de.westnordost.streetcomplete.quests.answerApplied
 import de.westnordost.streetcomplete.quests.answerAppliedTo
@@ -157,11 +158,11 @@ class AddOpeningHoursTest {
         assertEquals(
             setOf(StringMapEntryAdd("opening_hours", "Mo 10:00-12:00")),
             questType.answerApplied(
-                RegularOpeningHours(OpeningHoursRuleList(listOf(
-                    Rule().apply {
-                        days = listOf(WeekDayRange().also { it.startDay = WeekDay.MO })
-                        times = listOf(TimeSpan().also { it.start = 60 * 10; it.end = 60 * 12 })
-                    }
+                RegularOpeningHours(OpeningHours(listOf(
+                    Rule(Range(
+                        weekdays = listOf(Weekday.Monday),
+                        times = listOf(TimeSpan(ClockTime(10), ClockTime(12)))
+                    ))
                 )))
             )
         )
@@ -171,11 +172,11 @@ class AddOpeningHoursTest {
         assertEquals(
             setOf(StringMapEntryModify("opening_hours", "hohoho", "Mo 10:00-12:00")),
             questType.answerAppliedTo(
-                RegularOpeningHours(OpeningHoursRuleList(listOf(
-                    Rule().apply {
-                        days = listOf(WeekDayRange().also { it.startDay = WeekDay.MO })
-                        times = listOf(TimeSpan().also { it.start = 60 * 10; it.end = 60 * 12 })
-                    }
+                RegularOpeningHours(OpeningHours(listOf(
+                    Rule(Range(
+                        weekdays = listOf(Weekday.Monday),
+                        times = listOf(TimeSpan(ClockTime(10), ClockTime(12)))
+                    ))
                 ))),
                 mapOf("opening_hours" to "hohoho")
             )
@@ -189,11 +190,11 @@ class AddOpeningHoursTest {
                 StringMapEntryAdd("check_date:opening_hours", nowAsCheckDateString())
             ),
             questType.answerAppliedTo(
-                RegularOpeningHours(OpeningHoursRuleList(listOf(
-                    Rule().apply {
-                        days = listOf(WeekDayRange().also { it.startDay = WeekDay.MO })
-                        times = listOf(TimeSpan().also { it.start = 60 * 10; it.end = 60 * 12 })
-                    }
+                RegularOpeningHours(OpeningHours(listOf(
+                    Rule(Range(
+                        weekdays = listOf(Weekday.Monday),
+                        times = listOf(TimeSpan(ClockTime(10), ClockTime(12)))
+                    ))
                 ))),
                 mapOf("opening_hours" to "Mo 10:00-12:00")
             )
@@ -256,6 +257,17 @@ class AddOpeningHoursTest {
         )))
     }
 
+    @Test fun `isApplicableTo returns true if the opening hours collide with themselves`() {
+        assertTrue(questType.isApplicableTo(node(
+            tags = mapOf(
+                "shop" to "supermarket",
+                "name" to "Supi",
+                "opening_hours" to "Mo-Fr 18:00-20:00; We 08:00-12:00"
+            ),
+            timestamp = "2000-11-11".toCheckDate()?.toEpochMilli()
+        )))
+    }
+
     @Test fun `isApplicableTo returns false if the opening hours are not supported`() {
         assertFalse(questType.isApplicableTo(node(
             tags = mapOf(
diff --git a/app/src/test/java/de/westnordost/streetcomplete/quests/parking_fee/FeeTest.kt b/app/src/test/java/de/westnordost/streetcomplete/quests/parking_fee/FeeTest.kt
index 9d1345098e2..496e32b3975 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/quests/parking_fee/FeeTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/quests/parking_fee/FeeTest.kt
@@ -1,18 +1,19 @@
 package de.westnordost.streetcomplete.quests.parking_fee
 
-import ch.poole.openinghoursparser.Rule
+import de.westnordost.osm_opening_hours.model.OpeningHours
+import de.westnordost.osm_opening_hours.model.Rule
+import de.westnordost.osm_opening_hours.model.TwentyFourSeven
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryChange
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryDelete
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify
 import de.westnordost.streetcomplete.osm.nowAsCheckDateString
-import de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursRuleList
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
 class FeeTest {
-    private val oh = OpeningHoursRuleList(listOf(Rule().apply { isTwentyfourseven = true }))
+    private val oh = OpeningHours(listOf(Rule(TwentyFourSeven)))
     private val ohStr = "24/7"
 
     @Test fun `apply HasNoFee`() {
diff --git a/app/src/test/java/de/westnordost/streetcomplete/quests/parking_fee/MaxStayTest.kt b/app/src/test/java/de/westnordost/streetcomplete/quests/parking_fee/MaxStayTest.kt
index 9043c0c3503..c74be73c410 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/quests/parking_fee/MaxStayTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/quests/parking_fee/MaxStayTest.kt
@@ -1,13 +1,14 @@
 package de.westnordost.streetcomplete.quests.parking_fee
 
-import ch.poole.openinghoursparser.Rule
+import de.westnordost.osm_opening_hours.model.OpeningHours
+import de.westnordost.osm_opening_hours.model.Rule
+import de.westnordost.osm_opening_hours.model.TwentyFourSeven
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapChangesBuilder
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryChange
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryDelete
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryModify
 import de.westnordost.streetcomplete.osm.nowAsCheckDateString
-import de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursRuleList
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
@@ -16,7 +17,7 @@ class MaxStayTest {
     private val oneHour = MaxStayDuration(1.0, MaxStay.Unit.HOURS)
     private val oneHourStr = "1 hour"
 
-    private val oh = OpeningHoursRuleList(listOf(Rule().apply { isTwentyfourseven = true }))
+    private val oh = OpeningHours(listOf(Rule(TwentyFourSeven)))
     private val ohStr = "24/7"
 
     @Test fun `MaxStayDuration to OSM value`() {
diff --git a/app/src/test/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimesTest.kt b/app/src/test/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimesTest.kt
index c1ceff28a89..de0a6e2ba87 100644
--- a/app/src/test/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimesTest.kt
+++ b/app/src/test/java/de/westnordost/streetcomplete/quests/postbox_collection_times/AddPostboxCollectionTimesTest.kt
@@ -1,8 +1,9 @@
 package de.westnordost.streetcomplete.quests.postbox_collection_times
 
-import ch.poole.openinghoursparser.Rule
+import de.westnordost.osm_opening_hours.model.OpeningHours
+import de.westnordost.osm_opening_hours.model.Rule
+import de.westnordost.osm_opening_hours.model.TwentyFourSeven
 import de.westnordost.streetcomplete.data.osm.edits.update_tags.StringMapEntryAdd
-import de.westnordost.streetcomplete.osm.opening_hours.parser.OpeningHoursRuleList
 import de.westnordost.streetcomplete.quests.answerApplied
 import kotlin.test.Test
 import kotlin.test.assertEquals
@@ -21,9 +22,7 @@ class AddPostboxCollectionTimesTest {
     @Test fun `apply times answer`() {
         assertEquals(
             setOf(StringMapEntryAdd("collection_times", "24/7")),
-            questType.answerApplied(CollectionTimes(OpeningHoursRuleList(
-                listOf(Rule().apply { isTwentyfourseven = true })
-            )))
+            questType.answerApplied(CollectionTimes(OpeningHours(listOf(Rule(TwentyFourSeven)))))
         )
     }
 }