DEV Community

Dan Greene
Dan Greene

Posted on

When to avoid squishing dates together (a lesson in UI <--> backend team collaboration)

One of the most dangerous places for bugs to occur is the API contract between the UI and the server. No, I'm not talking about breaking contracts. I'm talking about when the backend team tells the UI devs what the contract is going to be instead of making it a conversation.

In this article we'll discuss a scenario where, if I was in either devs shoes, I would recommend not using a Date at all.

What Are We Trying To Build?

Our customer wants a UI where they can schedule jobs to run daily.

They want to make sure that it always runs at the set time even if Daylight Savings is or is not observed.

Some Context First

At my current company (and at any company I would consult with), I encourage the UI developers to avoid Date in general and instead to use Temporal or js-joda (click here to learn why). And if you don't know what Temporal or js-joda is, you can read my primer here.

Our company wrote an in-house set of widgets that wrap react-date-picker and expose js-joda types (and you can too if you follow my guide).

When we did that, we made the decision to split react-datepicker into two separate components (date and time) for accessibility reasons. Namely, if you were navigating a date and time picker with a keyboard and a screen reader, you would be able to press "enter" on the selected day and you would automatically get the preselected time without knowing that you needed to make a time election. We solved that by making our pickers independent.

Before:

Image description

After (time get's moved to it's own component and removed from the day picker):

Image description

Let's Build It The Wrong Way First

Imagine a conversation like this:

Backend Dev: "You UI devs only have a Date object right?"
UI Dev: "Well no, we have Temporal coming up and Js-Joda..."
Backend Dev: "Cool cool. We already built the service and it only takes a Date. I thought you'd like that."
UI Dev: "I wish we had a conversation first."
Backend Dev: "Sorry! Here's the schema"

interface BadSchema {
    jobName: string;
    runTime: Date;
    /**
     * So that we can determine if runTime was in Daylight Savings Time
     */
    usersZone: string;
}
Enter fullscreen mode Exit fullscreen mode

So the backend dev, who thought he was doing the UI dev a favor (he wasn't), goes to write the scheduler code.

By sticking to just a Date in the contract, the dev accidentally introduces a bug. Note: The backend dev is using Joda here since most recent Java versions have that js-joda API natively. But for consistency, this is Typescript.

const jobStartDateTimeFromUI = "2016-03-18T16:00:00Z";

// Convert that value that came across the wire into a useful type
const jobStartDateTime = ZonedDateTime.ofInstant(Instant.parse(jobStartDateTimeFromUI), ZoneId.of(usersZone));

console.log(jobStartDateTime.format(DateTimeFormatter.RFC_1123_DATE_TIME)); // Fri, 18 Mar 2016 17:00:00 Europe/Berlin

// adding a date unit of 2 weeks, crossing a daylight saving transition
jobStartDateTime.plusWeeks(2); // 2016-04-01T17:00+02:00[Europe/Berlin] (still 17:00)

// adding a time unit of 2 weeks (2 * 7 * 24)
jobStartDateTime.plusHours(2 * 7 * 24); // 2016-04-01T18:00+02:00[Europe/Berlin] (now 18:00)
Enter fullscreen mode Exit fullscreen mode

What you'll notice there is that the server code broke the user's requirement that the scheduled job be run at the same time every day. But in this case it's going to be run at 6pm instead of 5pm. They wanted it sent at 5pm.

How Can We Prevent This?

In cases like this, you want the API contract to communicate as much as possible (within reason) so that the server can make informed decisions.

That means that instead of sending new Date().toISOString() the UI should be sending the first day of the scheduled job as a LocalDate, then the time they want it to run daily as a LocalTime, and then finally the ZoneId of the user so we can determine if Daylight Savings Time is even observed.

interface FormItems {
    jobName: string;
    /**
     * Most servers will accept YYYY-MM-DD format since it's ISO-8601 and JSON Schema has support for format: date and format: date-time (https://swagger.io/docs/specification/data-models/data-types/#format)
     */
    firstJobDay: LocalDate;
    /**
     * preserve this so that we can make sure that the scheduled job happens at the same time if we are or are not observing Daylight Savings
     */
    jobDailyTime: LocalTime;
}

interface ScheduledJobPostBodyDeserialized extends FormItems {
    /**
     * So that we can determine if firstJobDate was in Daylight Savings Time so we can ensure that jobDailyTime will remain the right time for any date in the future
     */
    usersZone: string;
}
Enter fullscreen mode Exit fullscreen mode

Full Solution

Below is code for how the UI and the server would schedule this job. Note: I don't do any React code here since I'm trying to focus on just controller logic and the conversation between the UI and the backend over "the wire" (i.e. HTTP).

import {LocalDate, LocalTime, LocalDateTime, ZonedDateTime, ZoneId } from "@js-joda/core";

interface FormItems {
    jobName: string;
    /**
     * Most servers will accept YYYY-MM-DD format since it's ISO-8601 and JSON Schema has support for format: date and format: date-time (https://swagger.io/docs/specification/data-models/data-types/#format)
     */
    firstJobDay: LocalDate;
    /**
     * preserve this so that we can make sure that the scheduled job happens at the same time if we are or are not observing Daylight Savings
     */
    jobDailyTime: LocalTime;
}

interface ScheduledJobPostBodyDeserialized extends FormItems {
    /**
     * So that we can determine if firstJobDate was in Daylight Savings Time so we can ensure that jobDailyTime will remain the right time for any date in the future
     */
    usersZone: string;
}

// START OF MOCK UI CODE
()=>{

    // This would be React code normally, but for brevity this is what I have
    function getFormItems(): FormItems {

        throw new Error("Pretend implementation");
    }

    function prepareForServer(formItems: FormItems): ScheduledJobPostBodyDeserialized {
        const { firstJobDay, jobDailyTime, jobName} = formItems;

        // We have to pass the server the real offset instead of the value of ZoneId.SYSTEM since no one knows what that is besides js-joda
        // Read more at: https://js-joda.github.io/js-joda/manual/ZonedDateTime.html#the--code-system--code--zone-id
        const usersZone = Intl.DateTimeFormat().resolvedOptions().timeZone;

        const serverBody: ScheduledJobPostBodyDeserialized = {
            jobName,
            firstJobDay,
            jobDailyTime,
            usersZone
        }

        return serverBody;
    }

    function sendToServer(jobPostBody: ScheduledJobPostBodyDeserialized): Promise<void> {
        // psuedo-code

        // fetch({
        //     method: "POST", // *GET, POST, PUT, DELETE, etc.
        //     url: "fake url",
        //     body: JSON.stringify(jobPostBody),
        // })

        throw new Error("Not Implemented");
    }

    sendToServer(prepareForServer(getFormItems()));
}

// END OF MOCK UI CODE
() => {

    const mockDB: {
        jobs: ScheduledJobPostBodyDeserialized[];
    } = {
        jobs: []
    }

    function postNewScheduledJob(body: ScheduledJobPostBodyDeserialized){
        mockDB.jobs.push(body);
    }

    function runJob(job: ScheduledJobPostBodyDeserialized){
        console.log(`Let's pretend that we ran job named "${job.jobName}"`);
    }

    // We'd never do it this way, but it communicates how you'd work out Daylight Savings Time
    function tryToRunJobs(){
        // Create the time once so that it's fixed for all of the jobs
        const now = ZonedDateTime.now();

        mockDB.jobs.forEach(aJob => {
            const { firstJobDay, jobDailyTime, usersZone } = aJob;

            // You avoid the Daylight Savings Time problem by creating an instant with the component parseStrict
            const thisJobsTimeButToday = ZonedDateTime.of(
                LocalDateTime.of(firstJobDay, jobDailyTime),
                ZoneId.of(usersZone)
            );

            if(thisJobsTimeButToday.equals(now)){
                runJob(aJob);
            }
        })
    }

    // Again, we'd never do it this way, but it communicates the idea
    setInterval(tryToRunJobs, 1000 /* ms */);
}
// START OF MOCK SERVER CODE
Enter fullscreen mode Exit fullscreen mode

And here's a link to this in TypeScript playground to help you noodle around with it.

Lessons

  • Send component parts of the Date if you want to ensure you handle Daylight Savings Time
  • UI developers should make sure that contract discussions happen long before each team starts working
  • Ideally, combine your teams into fully-autonomous team that has both backend and UI devs on it. But that's a topic for another day!

Top comments (0)