"By Friday" Is Not a Timestamp: Resolving Dates in Chat-Created Tasks
Somebody sends this message to a team chat: Can you send the client report by Friday? A human reads it and knows what to do. Software has to decide a lot of things at once.
Which Friday? Friday in whose time zone? Friday at what time? And what if the message was sent on a Friday?
If your product creates tasks from chat messages, you are answering those questions whether you meant to or not. Usually the answer is "whatever the model guessed", and that is how you end up with deadlines that are off by a day and a very confused customer.
This post is about taking that decision away from the guesswork and putting it somewhere you can test. The examples are TypeScript with Luxon, but the ideas don't depend on either.
Four kinds of date phrases
"Parse the date" sounds like one problem. In practice, chat messages contain at least four different kinds, and they need different handling.
| Kind | Examples | Difficulty |
|---|---|---|
| Absolute | "Nov 20", "20/11" | Locale and format ambiguity (03/04 ?) |
| Weekday | "Friday", "this Friday", "next Friday" | Needs a clear policy |
| Relative | "tomorrow", "in 3 days", "end of week" | Needs a reliable anchor |
| Fuzzy | "EOD", "ASAP", "after the launch" | Often shouldn't be resolved at all |
Most bugs come from treating all four the same way.
Don't let the model do the date math
Language models are good at spotting that a message contains a deadline. They are a poor place to do calendar arithmetic. A model may not know today's date unless you tell it. It may do the arithmetic slightly differently on Tuesday than on Monday. And you can't write a reliable unit test against "usually right".
A cleaner split looks like this:
- The model extracts what the person said, as structured data.
- Plain code resolves that into a real date, against an anchor you control.
The extraction output is small and boring:
{ "kind" : "weekday" , "weekday" : 5 , "modifier" : "none" , "raw" : "by Friday" }
Everything after that is deterministic. That means it's testable, and when it's wrong you can find out why.
The anchor is more than "now"
Relative phrases only mean something relative to a moment and a place. Your anchor needs three things.
- The time the message was sent, not the time you processed it. If a message arrives at 11:58 PM and a queue delay means you handle it at 12:02 AM, "tomorrow" has shifted by a whole day. Always anchor on the message's own timestamp.
- A time zone you actually know. Take it from the user's profile, not from guesswork in the message. Store an IANA name like
Asia/Kolkata, not an offset like+05:30. Offsets don't know about daylight saving. - Workday conventions. When does the week start? What does "EOD" mean here, 5 PM or 6 PM? Make these settings, with sensible defaults, rather than constants buried in a function.
import { DateTime } from "luxon" ;
export function makeAnchor ( sentAtUtc : string , userZone : string ) : DateTime { return DateTime . fromISO ( sentAtUtc , { zone : "utc" } ) . setZone ( userZone ) ; }
A resolver that is allowed to say "I'm not sure"
The most important design choice is the return type. A resolver that always returns a date will eventually return the wrong one with total confidence. Let it return three outcomes instead:
export type Precision = "date" | "datetime" ;
export type Candidate = { at : DateTime ; precision : Precision } ;
export type Resolution = | { status : "resolved" ; value : Candidate } | { status : "ambiguous" ; reason : string ; options : Candidate [ ] } | { status : "unresolved" ; reason : string } ;
export type Modifier = "none" | "this" | "next" ;
const day = ( at : DateTime ) : Candidate => ( { at : at . startOf ( "day" ) , precision : "date" , } ) ;
Now the weekday rules. These are policies, so write them down rather than leaving them implicit:
Plain "Friday" means the nearest upcoming Friday. If today is Friday, plain "Friday" is ambiguous. Don't guess. "This Friday" means the Friday of the current week. If it has passed, say so. "Next Friday" means the Friday of the following calendar week.
// weekday uses Luxon numbering: Monday = 1 ... Sunday = 7
export function resolveWeekday ( anchor : DateTime , weekday : number , modifier : Modifier ) : Resolution { const today = anchor . startOf ( "day" ) ; if ( modifier === "this" || modifier === "next" ) { const target = anchor . startOf ( "week" ) . plus ( { weeks : modifier === "next" ? 1 : 0 , days : weekday - 1 } ) ; if ( target < today ) { return { status : "unresolved" , reason : "That day has already passed this week" } ; } return { status : "resolved" , value : day ( target ) } ; } const daysAhead = ( weekday - today . weekday + 7 ) % 7 ; if ( daysAhead === 0 ) { return { status : "ambiguous" , reason : "The weekday named is today" , options : [ day ( today ) , day ( today . plus ( { weeks : 1 } ) ) ] , } ; } return { status : "resolved" , value : day ( today . plus ( { days : daysAhead } ) ) } ; }
"EOD" gets the same treatment. If it's already past your configured end of day, don't quietly push it to tomorrow.
export function resolveEod ( anchor : DateTime , endHour = 18 ) : Resolution { const eod = anchor . startOf ( "day" ) . set ( { hour : endHour } ) ; if ( anchor >= eod ) { return { status : "ambiguous" , reason : "End of day has already passed" , options : [ { at : eod . plus ( { days : 1 } ) , precision : "datetime" } ] , } ; } return { status : "resolved" , value : { at : eod , precision : "datetime" } } ; }
A date-only deadline is not an instant
This is the bug I would check for first in any task system. "By Friday" is a date. It isn't "Friday at 00:00 UTC". If you store it as a UTC timestamp, a user west of UTC can see the deadline show up as Thursday. People often get a date shifted by exactly one day and spend an hour blaming the front end.
Store date-only deadlines as dates, alongside the zone that gave them meaning:
{ "dueDate" : "2026-11-13" , "dueTime" : null , "timeZone" : "Asia/Kolkata" }
That's why Candidate carries a precision flag. A weekday gives you date. "EOD" gives you datetime. Your storage and your display should treat them differently.
Days and hours are not the same unit
A small Luxon detail worth knowing, because it's easy to get wrong in any library: adding "a day" and adding "24 hours" are different operations when daylight saving ends.
const ny = DateTime . fromISO ( "2026-10-31T21:00" , { zone : "America/New_York" } ) ;
ny . plus ( { days : 1 } ) ; // 2026-11-01T21:00 (same wall-clock time)
ny . plus ( { hours : 24 } ) ; // 2026-11-01T20:00 (an hour earlier on the clock)
For "tomorrow", you want calendar days. For "in 24 hours", you want elapsed time. Your resolver should know which one the person meant.
Whose Friday is it?
When one person assigns a task to someone in another time zone, "by Friday" has two reasonable readings. One is the assigner's Friday, where the sentence was written. The other is the assignee's Friday, where the work happens.
I'd default to the assigner's zone, because the phrase was theirs, but the more important rule is to never leave it implicit. Show both zones when they differ. A task that says "Friday 5:00 PM IST (Friday 11:30 AM in London)" prevents a conversation that would otherwise happen on Thursday night.
Echo the result back
The cheapest safeguard is also the best one. After you resolve a date, tell the person what you understood, in a format that's hard to misread:
export function echo ( c : Candidate ) : string { const fmt = c . precision === "date" ? "cccc d LLL" : "cccc d LLL, h:mm a" ; return `${c.at.toFormat(fmt)} (${c.at.zoneName})` ; }
// echo(resolved) -> "Friday 13 Nov (Asia/Kolkata)"
Two details matter here. First, build the text from the resolved value, not from the model's wording. Second, include the weekday and the date. "Friday 13 Nov" lets a human catch a mismatch in half a second. "13 Nov" alone doesn't.
When not to resolve
Some phrases should stay unresolved: "ASAP", "soon", "after the launch", "when you get a chance". They aren't dates, and inventing one creates a deadline nobody agreed to.
Better options are to leave the task undated, or to ask one short question: "Do you want to set a date for this?" An honest "no date yet" is more useful than a confident wrong one.
Test it with a fixed clock
None of this is worth much without tests, and the nice thing about a deterministic resolver is that tests are easy. Fix the anchor, and never depend on the real current time.
import { DateTime } from "luxon" ;
import { expect , test } from "vitest" ;
const at = ( iso : string , zone : string ) => DateTime . fromISO ( iso , { zone } ) ;
test ( "plain weekday resolves to the upcoming one" , ( ) => { const r = resolveWeekday ( at ( "2026-11-12T10:00" , "Asia/Kolkata" ) , 5 , "none" ) ; expect ( r . status ) . toBe ( "resolved" ) ; if ( r . status === "resolved" ) expect ( r . value . at . toISODate ( ) ) . toBe ( "2026-11-13" ) ; } ) ;
test ( "weekday named on that weekday is ambiguous" , ( ) => { const r = resolveWeekday ( at ( "2026-11-13T10:00" , "Asia/Kolkata" ) , 5 , "none" ) ; expect ( r . status ) . toBe ( "ambiguous" ) ; } ) ;
test ( "'next Friday' means the following calendar week" , ( ) => { const r = resolveWeekday ( at ( "2026-11-09T09:00" , "Asia/Kolkata" ) , 5 , "next" ) ; if ( r . status === "resolved" ) expect ( r . value . at . toISODate ( ) ) . toBe ( "2026-11-20" ) ; } ) ;
test ( "anchor on sent time, not processing time" , ( ) => { const sent = makeAnchor ( "2026-11-12T18:28:00Z" , "Asia/Kolkata" ) ; // 23:58 IST const late = makeAnchor ( "2026-11-12T18:32:00Z" , "Asia/Kolkata" ) ; // 00:02 IST next day expect ( resolveTomorrow ( sent ) ) . toMatchObject ( { value : { at : expect . anything ( ) } } ) ; // 'tomorrow' differs by a day depending on which timestamp you trust } ) ;
test ( "days vs hours across a DST change" , ( ) => { const ny = at ( "2026-10-31T21:00" , "America/New_York" ) ; expect ( ny . plus ( { days : 1 } ) . hour ) . toBe ( 21 ) ; expect ( ny . plus ( { hours : 24 } ) . hour ) . toBe ( 20 ) ; } ) ;
A useful habit is to add one test for every date bug that reaches a user. Over time, that list becomes the most valuable documentation in the module.
Where this shows up
Any assistant that lets people create tasks from a conversation has to settle these questions somewhere, whether the front end is a chat window, an email thread or WhatsApp. That includes assistants like Atlas in ProjTL, where users can add tasks and assign them to teammates through WhatsApp. The questions are the same regardless of product: what is the anchor, which policy applies, and what happens when the answer is unclear.
The short version
Let the model extract the date phrase. Let plain code resolve it. Anchor on the message's sent time, a known IANA zone, and explicit workday settings. Return resolved, ambiguous or unresolved, not always a date. Store date-only deadlines as dates, not UTC instants. Use calendar days for "tomorrow" and elapsed time for "in 24 hours". Echo the result back with weekday, date and zone. Don't invent dates for "ASAP" and friends. Test with a fixed clock.
What's the strangest date phrase your users have typed that your parser had to deal with? I'd like to hear it in the comments.
Comments
No comments yet. Start the discussion.