Welkom op julesgraus.nl

Google Podcasts

20-10-2020

Laravel + Javascript + Datum / Tijd

Wanneer is het vrijdag!?

Werken met tijd is altijd pittig geweest in programmeerwereld. Moment.js, waarmee je makkelijker met datums en tijd in javascript kan werken word momenteel afgeraden om te gebruiken. Mede daardoor worden we als developer richting de native date functies gepushed of libraries as date-fns. Hierdoor zijn sommige dingen zoals het werken met tijdzones iets moeilijker geworden. Laten we kijken hoe we met Laravel, php, mysql en javascript met tijd kunnen werken.

De uitdaging met datums is vaak niet om ermee te rekenen. Dat is een relatief makkelijk kunstje. De echte uitdaging is als er tijdzones bij komen kijken. Je moet dan soms datums converteren zodat ze in dezelfde tijdzone zitten, voordat je ze in veel gevallen op een zinnige manier kan vergelijken.

We beginnen met het kijken naar de database (MySQL in het voorbeeld), dan naar php / laravel, en dan javascript. Pas als je full stack alles goed geregeld hebt, werkt het werken met datums en tijd goed. Eerst voel ik me verplicht om je uit te leggen wat UTC is.

Universele tijd en tijdzones

Over de hele wereld heb je verschillende tijdzones. Als het in Nederland 18:00 is, dan is het door de tijdzones bijvoorbeeld in New York 12:00. Hier maken we ons op voor de avond, en daar zitten ze aan de lunch. Stel nou, dat je met iemand in New York wil gaan video bellen. Dan moet je hem niet bellen om 09:00 's morgens. Omdat de ander ligt te slapen! Niet handig dus. Als je nu dezelfde tijd kon gebruiken als die andere persoon, dan zou dat toch veel handiger zijn?

Daarvoor hebben ze de universeel gecoördineerde tijd uitgevonden, UTC genoemd.

Ze hebben besloten dat de UTC tijd, de tijd is in Greenwich, Verenigd Koninkrijk. Daar noemen ze de tijd ook wel UTC+0. Bij ons in Nederland, is het in de zomer door de zomertijd 2 uur later dan in Engeland. Dat word dan als UTC+2 genoteerd. En in de Winter is het 1 uur later dan in Engeland. Hoe meer je op de kaart naar het oosten gaat, hoe meer uren je er bij op moet tellen. Andersom is ook waar, ga je westelijker op de kaart dan moet je er uren van af trekken. In new York is het met zomertijd 4 uur eerder als in Engeland.

Nu kun je de ideale tijd uitrekenen in UTC waarop je het beste afspreekt. Je weet al dat de New Yorker eerder wakker is. Daardoor kun je het beste met hem beginnen. Stel dat je hem in zijn tijd om 09:00 's morgens wil spreken, dan tel je er 4 uur bij op, en dan heb je je de UTC tijd. 9 + 4 is 13:00. Jij zit 2 uur later dan UTC dus moet je daar 2 uur bij optellen, Dan is het sommetje 9 + 4 + 2 = 15. Dus als je hem in Nederland om 15:00 belt, spreek je hem om 09:00 's morgens, zijn tijd. In het vervolg kun je dus zeggen, we spreken elkaar op 13:00 UTC. Dan weten jullie precies op welke tijd jullie kunnen praten.

MySQL

Je richt je database het beste in zodat alle datums op een manier opgeslagen kunnen worden zodat ze allemaal in UTC staan. Als je datum kolommen in de database aanmaakt kun je wat mij betreft kiezen tussen 2 typen. DATETIME en TIMESTAMP. Laravel gebruikt standaard TIMESTAMP in zijn migrations. Wanneer je een TIMESTAMP kolom een datum / tijd geeft, zal hij deze converteren naar UTC. En weer terug als je hem ophaalt. DATETIME doet dit niet. Het is goed om te weten dat ze hierin verschillen. Ikzelf gebruik TIMESTAMP. Als alle datums in de database staan, berekend naar eenzelfde tijdzone. Dan kan je er ook makkelijker mee rekenen. Bijvoorbeeld om het aantal uren verschil uit te rekenen. Lees er hier meer over.

PHP / Laravel

In Laravel heb je Eloquent modellen. Eloquent is de naam voor de ORM-laag van Laravel. Deze koppelt databases zoals bijvoorbeeld MySQL aan php door middel van objecten die we modellen noemen. Elk Laravel project heeft in het begin een User model. Om een user uit de database te halen met Eloquent kun je dat bijvoorbeeld zo doen:

$user = User::find(1);

Als je dit model returned in een laravel controller op deze manier, als antwoord op een AJAX request. Zal laravel datgene wat je returned, dus het model, proberen te coderen als JSON. Standaard ziet dat er dan zo uit (in Laravel 8.6):

{
  "id": 1,
  "email": "admin@example.org",
  "first_name": "Ad",
  "last_name": "Min",
  "email_verified_at": "2020-08-23T19:57:00.000000Z",
  "created_at": "2020-08-23T19:57:00.000000Z",
  "updated_at":"2020-09-22T18: 48:24.000000Z"
}

Als je de datums in javascript wil gebruiken. Word aanbevolen deze datums niet als een dateString aan de Date constructor mee te geven. We kunnen dan beter kiezen om deze als een timestamp in milliseconden sinds 1 januari 1970 mee te geven. Dit omdat de verschillende browsers dateString waarden op verschillende manieren opvatten op een niet consistente manier.

Custom casts

Als we een User model retourneren, moeten we dus datums naar milliseconden converteren. En als we van een AJAX request een datum in milliseconden ontvangen, moeten we deze converteren naar een formaat dat we kunnen opslaan in de database. In Laravel hebben ze daar custom casts voor uitgevonden. Ik heb precies daarvoor deze cast class uitgevonden:

<?php

namespace App\Casts;

use Carbon\Carbon;
use Illuminate\Support\Facades\Date;

/**
 * @package App\Casts
 */
class JsTimeStamp
{
    /**
     * Cast the given value so you can return it in an ajax request.
     *
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  mixed  $value
     * @param  array  $attributes
     * @return Carbon|false
     */
    public function get($model, $key, $value, $attributes)
    {
        if(!$value) return null;

        $format = $model->getDateFormat();
        if (Date::hasFormat($value, $format)) {
            $date = Date::createFromFormat($format, $value);
        } else {
            $date = Date::parse($value);
        }

        return $date ? $date->timestamp * 1000 : null;
    }

    /**
     * Prepare the given value for storage.
     *
     * @param  \Illuminate\Database\Eloquent\Model  $model
     * @param  string  $key
     * @param  string  $value
     * @param  array  $attributes
     */
    public function set($model, $key, $value, $attributes)
    {
        $instance = null;
        if(is_numeric($value)) {
            $instance = Date::createFromTimestamp(intval($value) / 1000);
        }

        return ($instance ? $instance->format($model->getDateFormat()) : null);
    }
}

Deze class heb ik dan ingesteld op mijn User model als volgt:

<?php

// Imports en namespace hier

class User extends Authenticatable implements MustVerifyEmail
{
    protected $casts = [
        'email_verified_at' => JsTimeStamp::class,
        'created_at' => JsTimeStamp::class,
        'updated_at' => JsTimeStamp::class,
    ];

    // Andere functies hier
}

Get: In de get functie wordt eerst het datumformaat bepaald van hoe datums in de database worden opgeslagen worden met de getDateFormat call (voorbeeld: 'Y-m-d H:i:s'). Dan proberen we een Carbon instance via de Laravel Date facade te instantiëren met dat formaat. Lukt dat niet, dan gebruiken we Carbon's parse functie. Als we dan een instance hebben, vermenigvuldigen we de timestamp, die in seconden is, met 1000, om er milliseconden van te maken. Of we retourneren null als dat niet lukt. Dan hebben we de timestamp die we aan Javascripts date constructor kunnen geven.

Set: In de set functie kijk ik of de waarde numeric is. Dus een string met een nummer, number of float. Als dat zo is, dan deel ik dat getal door 1000 om de milliseconden van javascript naar seconden om te zetten voor Carbon. Anders nullen we de datum omdat we er niets mee kunnen, en ik niet wil dat het programma crashed.

Javascript

Nu de meest uitdagende stap. En dat is de datum gebruiken in Javascript. Om de uitdaging te illustreren Stop ik deze datum handmatig met een MySQL GUI in de updated_at kolom van de user: "2020-10-06 20:52:35". Vervolgens haal ik de datum op via het terminal commando 'php artisan tinker':

C:\Users\demo\Documents\Projects\SampleProject>php artisan tinker
Psy Shell v0.10.4 (PHP 7.4.9 — cli) by Justin Hileman
>>> json_encode(\App\User\User::where('id', '=', 1)->get(['id', 'updated_at']));
=> "[{"id":1,"updated_at":1602017555000}]"
>>>

Ik krijg dan dankzij de eerder gemaakte custom cast de updated_at timestamp in milliseconden. Met de timestamp kunnen we nu een date object instantiëren. Ik gebruik daarvoor de console van de browser:

> let date = new Date(1602017555000);
<- undefined
> date
<- Tue Oct 06 2020 22:52:35 GMT+0200 (Midden-Europese zomertijd)

Zoals je ziet, is dit niet de datum die je zou verwachten. We hebben immers "2020-10-06 20:52:35" in de database gestopt. De tijd die je ziet is ineens 2 uur later. Waarom? Omdat de timestamp geen tijdzone-informatie bevat, neemt de Date constructor aan dat je de tijd opgeeft als UTC. Omdat je computer wellicht in de Nederlandse tijdzone zit, telt hij daar 2 uur bij op. Je hebt een paar keuzes om hiermee om te gaan:

Om optie 2 te implementeren moet je dus eerst weten met hoeveel tijd je de datum moet corrigeren. Ook dit kan ik demonstreren in de console van de browser:

> let correctionValue = date.getTimezoneOffset() * 60 * 1000
<- undefined
> date.setTime(date.getTime() + correctionValue);
<- 1602010355000
> date
<- Tue Oct 06 2020 20:52:35 GMT+0200 (Midden-Europese zomertijd)

Vervolgens kun je er in je programma mee doen wat je er maar mee wilt. Net voordat je de datum uiteindelijk de backend post, moet je zoals gezegd de 2 uur er weer bij optellen:

> date.setTime(date.getTime() - correctionValue);
<- 1602017555000
> date
<- Tue Oct 06 2020 22:52:35 GMT+0200 (Midden-Europese zomertijd)

Zoals je ziet, is de timestamp (1602017555000) weer dezelfde als wat er in het begin in ging. Ook al wordt hij "verkeerd" getoond als string. Dit is omdat het Date object intern de datum in UTC bijhoud, maar bij het weergeven, de weer te geven waarde converteert (niet de interne UTC waarde). Erg verwarrend nietwaar?

Tips

Het werken met het date object is soms nogal omslachtig. Daarom zijn libraries als moment.js en date-fns uitgevonden. moment.js wordt afgeraden onder andere omdat deze niet tree-shakable is. Omdat het date-object nog meer van die, sorry voor mijn taalgebruik, lompe dingen heeft, raad ik je aan om tijdens het ontwikkelen, net zoals hierboven in de console dingen verifieert voordat je zelfs de meest logische dingen aanneemt rondom Date Objects. De getMonth() functie op een date is daar een voorbeeld van. Deze retourneert de nummer van de maand tussen 0 en 11, terwijl je zou verwachten, dat deze tussen de 1 en 12 zou moeten zijn. Zeker als je beseft dat getDate() de dag van de maand retourneert, startend vanaf 1 in plaats van 0!