diff --git a/src/MyPrayerJournal/Dates.fs b/src/MyPrayerJournal/Dates.fs index a425b6d..fff4a8c 100644 --- a/src/MyPrayerJournal/Dates.fs +++ b/src/MyPrayerJournal/Dates.fs @@ -46,16 +46,14 @@ let twoMonths = 86_400. open System -/// Convert from a JavaScript "ticks" value to a date/time -let fromJs ticks = DateTime.UnixEpoch + TimeSpan.FromTicks (ticks * 10_000L) - -let formatDistance (startDate : Instant) (endDate : Instant) = +/// Format the distance between two instants in approximate English terms +let formatDistance (startOn : Instant) (endOn : Instant) = let format (token, number) locale = let labels = locales |> Map.find locale match number with 1 -> fst labels[token] | _ -> sprintf (snd labels[token]) number let round (it : float) = Math.Round it |> int - let diff = startDate - endDate + let diff = startOn - endOn let minutes = Math.Abs diff.TotalMinutes let formatToken = let months = minutes / aMonth |> round @@ -74,5 +72,5 @@ let formatDistance (startDate : Instant) (endDate : Instant) = | _ -> AlmostXYears, years + 1 format formatToken "en-US" - |> match startDate > endDate with true -> sprintf "%s ago" | false -> sprintf "in %s" + |> match startOn > endOn with true -> sprintf "%s ago" | false -> sprintf "in %s" diff --git a/src/MyPrayerJournal/Handlers.fs b/src/MyPrayerJournal/Handlers.fs index 4a5d0ea..77e1bcc 100644 --- a/src/MyPrayerJournal/Handlers.fs +++ b/src/MyPrayerJournal/Handlers.fs @@ -72,6 +72,15 @@ type HttpContext with /// Get the current instant from the system clock member this.Now = this.Clock.GetCurrentInstant + + /// Get the time zone from the X-Time-Zone header (default UTC) + member this.TimeZone = + match this.TryGetRequestHeader "X-Time-Zone" with + | Some tz -> + match this.GetService().GetZoneOrNull tz with + | null -> DateTimeZone.Utc + | zone -> zone + | None -> DateTimeZone.Utc /// Handler helpers @@ -247,13 +256,13 @@ module Components = | _, _ -> false let! journal = Data.journalByUserId ctx.UserId ctx.Db let shown = journal |> List.filter shouldBeShown - return! renderComponent [ Views.Journal.journalItems now shown ] next ctx + return! renderComponent [ Views.Journal.journalItems now ctx.TimeZone shown ] next ctx } // GET /components/request-item/[req-id] let requestItem reqId : HttpHandler = requireUser >=> fun next ctx -> task { match! Data.tryJournalById (RequestId.ofString reqId) ctx.UserId ctx.Db with - | Some req -> return! renderComponent [ Views.Request.reqListItem (ctx.Now ()) req ] next ctx + | Some req -> return! renderComponent [ Views.Request.reqListItem (ctx.Now ()) ctx.TimeZone req ] next ctx | None -> return! Error.notFound next ctx } @@ -264,7 +273,7 @@ module Components = // GET /components/request/[req-id]/notes let notes requestId : HttpHandler = requireUser >=> fun next ctx -> task { let! notes = Data.notesById (RequestId.ofString requestId) ctx.UserId ctx.Db - return! renderComponent (Views.Request.notes (ctx.Now ()) (List.ofArray notes)) next ctx + return! renderComponent (Views.Request.notes (ctx.Now ()) ctx.TimeZone (List.ofArray notes)) next ctx } // GET /components/request/[req-id]/snooze @@ -367,7 +376,7 @@ module Request = // GET /requests/active let active : HttpHandler = requireUser >=> fun next ctx -> task { let! reqs = Data.journalByUserId ctx.UserId ctx.Db - return! partial "Active Requests" (Views.Request.active (ctx.Now ()) reqs) next ctx + return! partial "Active Requests" (Views.Request.active (ctx.Now ()) ctx.TimeZone reqs) next ctx } // GET /requests/snoozed @@ -376,19 +385,19 @@ module Request = let now = ctx.Now () let snoozed = reqs |> List.filter (fun it -> defaultArg (it.SnoozedUntil |> Option.map (fun it -> it > now)) false) - return! partial "Snoozed Requests" (Views.Request.snoozed now snoozed) next ctx + return! partial "Snoozed Requests" (Views.Request.snoozed now ctx.TimeZone snoozed) next ctx } // GET /requests/answered let answered : HttpHandler = requireUser >=> fun next ctx -> task { let! reqs = Data.answeredRequests ctx.UserId ctx.Db - return! partial "Answered Requests" (Views.Request.answered (ctx.Now ()) reqs) next ctx + return! partial "Answered Requests" (Views.Request.answered (ctx.Now ()) ctx.TimeZone reqs) next ctx } // GET /request/[req-id]/full let getFull requestId : HttpHandler = requireUser >=> fun next ctx -> task { match! Data.tryFullRequestById (RequestId.ofString requestId) ctx.UserId ctx.Db with - | Some req -> return! partial "Prayer Request" (Views.Request.full ctx.Clock req) next ctx + | Some req -> return! partial "Prayer Request" (Views.Request.full ctx.Clock ctx.TimeZone req) next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyPrayerJournal/MyPrayerJournal.fsproj b/src/MyPrayerJournal/MyPrayerJournal.fsproj index 8fd1fc1..0c9ca36 100644 --- a/src/MyPrayerJournal/MyPrayerJournal.fsproj +++ b/src/MyPrayerJournal/MyPrayerJournal.fsproj @@ -24,7 +24,7 @@ - + diff --git a/src/MyPrayerJournal/Program.fs b/src/MyPrayerJournal/Program.fs index f2ea129..134068d 100644 --- a/src/MyPrayerJournal/Program.fs +++ b/src/MyPrayerJournal/Program.fs @@ -74,7 +74,8 @@ module Configure = let _ = bldr.Services.AddRouting () let _ = bldr.Services.AddGiraffe () - let _ = bldr.Services.AddSingleton(SystemClock.Instance) + let _ = bldr.Services.AddSingleton SystemClock.Instance + let _ = bldr.Services.AddSingleton DateTimeZoneProviders.Tzdb let _ = bldr.Services.Configure(fun (opts : CookiePolicyOptions) -> diff --git a/src/MyPrayerJournal/Views/Helpers.fs b/src/MyPrayerJournal/Views/Helpers.fs index aeba493..5ab6564 100644 --- a/src/MyPrayerJournal/Views/Helpers.fs +++ b/src/MyPrayerJournal/Views/Helpers.fs @@ -27,5 +27,5 @@ let noResults heading link buttonText text = ] /// Create a date with a span tag, displaying the relative date with the full date/time in the tooltip -let relativeDate (date : Instant) now = - span [ _title (date.ToDateTimeOffset().ToString ("f", null)) ] [ Dates.formatDistance now date |> str ] +let relativeDate (date : Instant) now (tz : DateTimeZone) = + span [ _title (date.InZone(tz).ToDateTimeOffset().ToString ("f", null)) ] [ Dates.formatDistance now date |> str ] diff --git a/src/MyPrayerJournal/Views/Journal.fs b/src/MyPrayerJournal/Views/Journal.fs index b03838c..cab2759 100644 --- a/src/MyPrayerJournal/Views/Journal.fs +++ b/src/MyPrayerJournal/Views/Journal.fs @@ -7,7 +7,7 @@ open Giraffe.ViewEngine.Htmx open MyPrayerJournal /// Display a card for this prayer request -let journalCard now req = +let journalCard now tz req = let reqId = RequestId.toString req.RequestId let spacer = span [] [ rawText " " ] div [ _class "col" ] [ @@ -50,8 +50,8 @@ let journalCard now req = div [ _class "card-footer text-end text-muted px-1 py-0" ] [ em [] [ match req.LastPrayed with - | Some dt -> str "last prayed "; relativeDate dt now - | None -> str "last activity "; relativeDate req.AsOf now + | Some dt -> str "last prayed "; relativeDate dt now tz + | None -> str "last activity "; relativeDate req.AsOf now tz ] ] ] @@ -120,7 +120,7 @@ let journal user = ] /// The journal items -let journalItems now items = +let journalItems now tz items = match items |> List.isEmpty with | true -> noResults "No Active Requests" "/request/new/edit" "Add a Request" [ @@ -129,7 +129,7 @@ let journalItems now items = ] | false -> items - |> List.map (journalCard now) + |> List.map (journalCard now tz) |> section [ _id "journalItems" _class "row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3" _hxTarget "this" diff --git a/src/MyPrayerJournal/Views/Request.fs b/src/MyPrayerJournal/Views/Request.fs index d7df748..b74598d 100644 --- a/src/MyPrayerJournal/Views/Request.fs +++ b/src/MyPrayerJournal/Views/Request.fs @@ -7,7 +7,7 @@ open MyPrayerJournal open NodaTime /// Create a request within the list -let reqListItem now req = +let reqListItem now tz req = let isFuture instant = defaultArg (instant |> Option.map (fun it -> it > now)) false let reqId = RequestId.toString req.RequestId let isAnswered = req.LastStatus = Answered @@ -28,32 +28,32 @@ let reqListItem now req = if isSnoozed || isPending || isAnswered then br [] small [ _class "text-muted" ] [ - if isSnoozed then [ str "Snooze expires "; relativeDate req.SnoozedUntil.Value now ] - elif isPending then [ str "Request appears next "; relativeDate req.ShowAfter.Value now ] - else (* isAnswered *) [ str "Answered "; relativeDate req.AsOf now ] + if isSnoozed then [ str "Snooze expires "; relativeDate req.SnoozedUntil.Value now tz ] + elif isPending then [ str "Request appears next "; relativeDate req.ShowAfter.Value now tz ] + else (* isAnswered *) [ str "Answered "; relativeDate req.AsOf now tz ] |> em [] ] ] ] /// Create a list of requests -let reqList now reqs = +let reqList now tz reqs = reqs - |> List.map (reqListItem now) + |> List.map (reqListItem now tz) |> div [ _class "list-group" ] /// View for Active Requests page -let active now reqs = +let active now tz reqs = article [ _class "container mt-3" ] [ h2 [ _class "pb-3" ] [ str "Active Requests" ] if List.isEmpty reqs then noResults "No Active Requests" "/journal" "Return to your journal" [ str "Your prayer journal has no active requests" ] - else reqList now reqs + else reqList now tz reqs ] /// View for Answered Requests page -let answered now reqs = +let answered now tz reqs = article [ _class "container mt-3" ] [ h2 [ _class "pb-3" ] [ str "Answered Requests" ] if List.isEmpty reqs then @@ -61,18 +61,18 @@ let answered now reqs = str "Your prayer journal has no answered requests; once you have marked one as " rawText "“Answered”, it will appear here" ] - else reqList now reqs + else reqList now tz reqs ] /// View for Snoozed Requests page -let snoozed now reqs = +let snoozed now tz reqs = article [ _class "container mt-3" ] [ h2 [ _class "pb-3" ] [ str "Snoozed Requests" ] - reqList now reqs + reqList now tz reqs ] /// View for Full Request page -let full (clock : IClock) (req : Request) = +let full (clock : IClock) tz (req : Request) = let now = clock.GetCurrentInstant () let answered = req.History @@ -110,7 +110,7 @@ let full (clock : IClock) (req : Request) = str "Answered " date.ToDateTimeOffset().ToString ("D", null) |> str str " (" - relativeDate date now + relativeDate date now tz rawText ") • " | None -> () rawText $"Prayed %s{prayed} times • Open %s{daysOpen} days" @@ -259,9 +259,9 @@ let edit (req : JournalRequest) returnTo isNew = ] /// Display a list of notes for a request -let notes now notes = +let notes now tz notes = let toItem (note : Note) = - p [] [ small [ _class "text-muted" ] [ relativeDate note.AsOf now ]; br []; str note.Notes ] + p [] [ small [ _class "text-muted" ] [ relativeDate note.AsOf now tz ]; br []; str note.Notes ] [ p [ _class "text-center" ] [ strong [] [ str "Prior Notes for This Request" ] ] match notes with | [] -> p [ _class "text-center text-muted" ] [ str "There are no prior notes for this request" ] diff --git a/src/MyPrayerJournal/wwwroot/script/mpj.js b/src/MyPrayerJournal/wwwroot/script/mpj.js index 5b12fd6..946b751 100644 --- a/src/MyPrayerJournal/wwwroot/script/mpj.js +++ b/src/MyPrayerJournal/wwwroot/script/mpj.js @@ -1,7 +1,7 @@ "use strict" /** myPrayerJournal script */ -const mpj = { +this.mpj = { /** * Show a message via toast * @param {string} message The message to show @@ -66,6 +66,19 @@ const mpj = { const isDisabled = target.value === "Immediate" ;["recurCount", "recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled) } + }, + /** + * The time zone of the current browser + * @type {string} + **/ + timeZone: undefined, + /** + * Derive the time zone from the current browser + */ + deriveTimeZone () { + try { + this.timeZone = (new Intl.DateTimeFormat()).resolvedOptions().timeZone + } catch (_) { } } } @@ -80,3 +93,12 @@ htmx.on("htmx:afterOnLoad", function (evt) { document.getElementById(evt.detail.xhr.getResponseHeader("x-hide-modal") + "Dismiss").click() } }) + +htmx.on("htmx:configRequest", function (evt) { + // Send the user's current time zone so that we can display local time + if (mpj.timeZone) { + evt.detail.headers["X-Time-Zone"] = mpj.timeZone + } +}) + +mpj.deriveTimeZone()