Parsing a past time from text in Swift
In Digest, a food journal I'm working on, you can log a meal by typing it the way you'd say it: “two eggs and toast with butter around 8”. The app reads the foods and the time out of that sentence on every keystroke, and shows both in a preview you can edit before saving.
The parser behind it is deliberately small, a hand-written heuristic rather than a grammar. It splits on connectors (commas, “and”, “with”), drops a leading quantity, and only reads a time when the text clearly signals one: “at” or “around” before a number, am/pm, 8:30, or a phrase like “for dinner”; that way, “2 eggs” never turns into 2 o'clock.
I didn't look at NSDataDetector at the time, and it turns out it wouldn't have helped much. Given a handful of notes at 17:00, it found no time at all in “around 8”, “eggs at 9”, “for dinner” or “last night”, and it put “toast at 8pm” at 20:00 today, 3 hours from then, which is the exact problem below.
Reading the time is a bit trickier, though.
The first version put every time on today's date. “Pasta last night” landed at 21:00 today, and “toast at 8pm” typed at 14:00 was saved 6 hours in the future, which can't be right for something you're logging: the meal already happened. I only noticed by using it myself.
The most recent past
Most things we log after the fact probably work the same way, meals, workouts, expenses etc: a time later than now most likely means that time yesterday, so “pasta for dinner” typed at 08:00 is last night's dinner, not tonight's. In code, we only need a single comparison:
func mostRecent(_ date: Date, notAfter now: Date, calendar: Calendar) -> Date {
date > now ? dayBefore(date, calendar: calendar) : date
}
func dayBefore(_ date: Date, calendar: Calendar) -> Date {
calendar.date(byAdding: .day, value: -1, to: date) ?? date.addingTimeInterval(-86_400) // ❶
}In dayBefore, we ask the calendar for the same wall-clock time a day earlier (❶) instead of subtracting 86,400 seconds; that would be an hour off around a daylight saving change. The fixed interval is just a fallback, in case the calendar can't answer.
What about a bare hour?
“Eggs around 8” has 2 readings today, 08:00 and 20:00, so we take the latest one that isn't in the future, and we put yesterday's 2 in the pool as well (dateToday just sets the hour and minute on today's date):
let am = dateToday(hour: hour, minute: minute, now: now, calendar: calendar)
let pm = dateToday(hour: hour + 12, minute: minute, now: now, calendar: calendar)
let readings = [am, pm, dayBefore(am, calendar: calendar), dayBefore(pm, calendar: calendar)]
return readings.filter { $0 <= now }.max() ?? amAt 14:00, “eggs around 8” is 08:00 and “eggs around 1” is 13:00. At 08:00, “eggs at 9” has both of today's readings still ahead, so it's 21:00 last night.
An explicit am/pm or a 24-hour time like 21:00 only has 1 reading, which goes through mostRecent.
Phrases that name the day
Some phrases shouldn't move, though. “This morning” and “tonight” name today on purpose, and “last night” always means yesterday, even typed at 21:00, so each day-part carries the day it names, next to its hour:
private static let dayParts: [(phrase: String, hour: Int, day: NamedDay)] = [
("this morning", 8, .today),
("for breakfast", 8, .unnamed),
("for dinner", 19, .unnamed),
("last night", 21, .yesterday),
("tonight", 21, .today),
// [...]
]
// [...]
switch namedDay {
case .yesterday:
return (.custom(dayBefore(today, calendar: calendar)), range)
case .today:
return (today > now ? .now : .custom(today), range) // ❶
case .unnamed:
return (.custom(mostRecent(today, notAfter: now, calendar: calendar)), range)
}Every phrase has its context word (“for”, “at”, “this”), so we don't read a bare “dinner” inside a food's name as a time. .now keeps the sheet's own time, .custom is a specific date, and range is where the phrase sat in the note.
The odd one out is “popcorn tonight”, typed at 14:00. Tonight hasn't come yet, and moving it to last night would contradict the word itself, so we drop the time (❶) and the meal keeps the sheet's own; the phrase still goes back with its range, which keeps “tonight” out of the food's name. What's left is just “popcorn”.
All of this only applies on the sheet that logs a new meal today. On a sheet logging into a past day, or in the editor for a meal that's already saved, “for dinner” means dinner on that day, so the parser keeps every reading on it.
And there's a test for the whole point: 13 notes, from “eggs this morning” to “tea at 7am”, parsed at 08:00 and again at 14:00, and none of them may land after now.
The rule caught me too, in the end. App Store screenshots tend to show 9:41 in the status bar, but Digest's first screenshot types “around 12”, which at 9:41 is yesterday's noon, so Digest's screenshots are set at 12:41 instead.
One thing I'm still not sure about is the bare hours. “Eggs at 9”, typed at 08:00, lands at 21:00 last night, because that's the latest reading that already happened, but 09:00 yesterday is just as possible, and for eggs, probably the likelier one. Picking between them would mean guessing from the food, which the parser doesn't do.
If you've handled the bare hours differently, tell me @roland.leth.ro or @rolandleth!