Display date/time in local time (#69)

This commit is contained in:
Daniel J. Summers 2022-07-30 16:23:42 -04:00
parent e465d9af23
commit 924fbdebe5
8 changed files with 69 additions and 39 deletions

View File

@ -46,16 +46,14 @@ let twoMonths = 86_400.
open System open System
/// Convert from a JavaScript "ticks" value to a date/time /// Format the distance between two instants in approximate English terms
let fromJs ticks = DateTime.UnixEpoch + TimeSpan.FromTicks (ticks * 10_000L) let formatDistance (startOn : Instant) (endOn : Instant) =
let formatDistance (startDate : Instant) (endDate : Instant) =
let format (token, number) locale = let format (token, number) locale =
let labels = locales |> Map.find locale let labels = locales |> Map.find locale
match number with 1 -> fst labels[token] | _ -> sprintf (snd labels[token]) number match number with 1 -> fst labels[token] | _ -> sprintf (snd labels[token]) number
let round (it : float) = Math.Round it |> int let round (it : float) = Math.Round it |> int
let diff = startDate - endDate let diff = startOn - endOn
let minutes = Math.Abs diff.TotalMinutes let minutes = Math.Abs diff.TotalMinutes
let formatToken = let formatToken =
let months = minutes / aMonth |> round let months = minutes / aMonth |> round
@ -74,5 +72,5 @@ let formatDistance (startDate : Instant) (endDate : Instant) =
| _ -> AlmostXYears, years + 1 | _ -> AlmostXYears, years + 1
format formatToken "en-US" 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"

View File

@ -72,6 +72,15 @@ type HttpContext with
/// Get the current instant from the system clock /// Get the current instant from the system clock
member this.Now = this.Clock.GetCurrentInstant 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<IDateTimeZoneProvider>().GetZoneOrNull tz with
| null -> DateTimeZone.Utc
| zone -> zone
| None -> DateTimeZone.Utc
/// Handler helpers /// Handler helpers
@ -247,13 +256,13 @@ module Components =
| _, _ -> false | _, _ -> false
let! journal = Data.journalByUserId ctx.UserId ctx.Db let! journal = Data.journalByUserId ctx.UserId ctx.Db
let shown = journal |> List.filter shouldBeShown 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] // GET /components/request-item/[req-id]
let requestItem reqId : HttpHandler = requireUser >=> fun next ctx -> task { let requestItem reqId : HttpHandler = requireUser >=> fun next ctx -> task {
match! Data.tryJournalById (RequestId.ofString reqId) ctx.UserId ctx.Db with 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 | None -> return! Error.notFound next ctx
} }
@ -264,7 +273,7 @@ module Components =
// GET /components/request/[req-id]/notes // GET /components/request/[req-id]/notes
let notes requestId : HttpHandler = requireUser >=> fun next ctx -> task { let notes requestId : HttpHandler = requireUser >=> fun next ctx -> task {
let! notes = Data.notesById (RequestId.ofString requestId) ctx.UserId ctx.Db 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 // GET /components/request/[req-id]/snooze
@ -367,7 +376,7 @@ module Request =
// GET /requests/active // GET /requests/active
let active : HttpHandler = requireUser >=> fun next ctx -> task { let active : HttpHandler = requireUser >=> fun next ctx -> task {
let! reqs = Data.journalByUserId ctx.UserId ctx.Db 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 // GET /requests/snoozed
@ -376,19 +385,19 @@ module Request =
let now = ctx.Now () let now = ctx.Now ()
let snoozed = reqs let snoozed = reqs
|> List.filter (fun it -> defaultArg (it.SnoozedUntil |> Option.map (fun it -> it > now)) false) |> 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 // GET /requests/answered
let answered : HttpHandler = requireUser >=> fun next ctx -> task { let answered : HttpHandler = requireUser >=> fun next ctx -> task {
let! reqs = Data.answeredRequests ctx.UserId ctx.Db 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 // GET /request/[req-id]/full
let getFull requestId : HttpHandler = requireUser >=> fun next ctx -> task { let getFull requestId : HttpHandler = requireUser >=> fun next ctx -> task {
match! Data.tryFullRequestById (RequestId.ofString requestId) ctx.UserId ctx.Db with 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 | None -> return! Error.notFound next ctx
} }

View File

@ -24,7 +24,7 @@
<PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.6.1" /> <PackageReference Include="Giraffe.ViewEngine.Htmx" Version="1.6.1" />
<PackageReference Include="LiteDB" Version="5.0.11" /> <PackageReference Include="LiteDB" Version="5.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.10" />
<PackageReference Include="NodaTime" Version="3.0.9" /> <PackageReference Include="NodaTime" Version="3.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="wwwroot\" /> <Folder Include="wwwroot\" />

View File

@ -74,7 +74,8 @@ module Configure =
let _ = bldr.Services.AddRouting () let _ = bldr.Services.AddRouting ()
let _ = bldr.Services.AddGiraffe () let _ = bldr.Services.AddGiraffe ()
let _ = bldr.Services.AddSingleton<IClock>(SystemClock.Instance) let _ = bldr.Services.AddSingleton<IClock> SystemClock.Instance
let _ = bldr.Services.AddSingleton<IDateTimeZoneProvider> DateTimeZoneProviders.Tzdb
let _ = let _ =
bldr.Services.Configure<CookiePolicyOptions>(fun (opts : CookiePolicyOptions) -> bldr.Services.Configure<CookiePolicyOptions>(fun (opts : CookiePolicyOptions) ->

View File

@ -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 /// Create a date with a span tag, displaying the relative date with the full date/time in the tooltip
let relativeDate (date : Instant) now = let relativeDate (date : Instant) now (tz : DateTimeZone) =
span [ _title (date.ToDateTimeOffset().ToString ("f", null)) ] [ Dates.formatDistance now date |> str ] span [ _title (date.InZone(tz).ToDateTimeOffset().ToString ("f", null)) ] [ Dates.formatDistance now date |> str ]

View File

@ -7,7 +7,7 @@ open Giraffe.ViewEngine.Htmx
open MyPrayerJournal open MyPrayerJournal
/// Display a card for this prayer request /// Display a card for this prayer request
let journalCard now req = let journalCard now tz req =
let reqId = RequestId.toString req.RequestId let reqId = RequestId.toString req.RequestId
let spacer = span [] [ rawText "&nbsp;" ] let spacer = span [] [ rawText "&nbsp;" ]
div [ _class "col" ] [ div [ _class "col" ] [
@ -50,8 +50,8 @@ let journalCard now req =
div [ _class "card-footer text-end text-muted px-1 py-0" ] [ div [ _class "card-footer text-end text-muted px-1 py-0" ] [
em [] [ em [] [
match req.LastPrayed with match req.LastPrayed with
| Some dt -> str "last prayed "; relativeDate dt now | Some dt -> str "last prayed "; relativeDate dt now tz
| None -> str "last activity "; relativeDate req.AsOf now | None -> str "last activity "; relativeDate req.AsOf now tz
] ]
] ]
] ]
@ -120,7 +120,7 @@ let journal user =
] ]
/// The journal items /// The journal items
let journalItems now items = let journalItems now tz items =
match items |> List.isEmpty with match items |> List.isEmpty with
| true -> | true ->
noResults "No Active Requests" "/request/new/edit" "Add a Request" [ noResults "No Active Requests" "/request/new/edit" "Add a Request" [
@ -129,7 +129,7 @@ let journalItems now items =
] ]
| false -> | false ->
items items
|> List.map (journalCard now) |> List.map (journalCard now tz)
|> section [ _id "journalItems" |> section [ _id "journalItems"
_class "row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3" _class "row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3"
_hxTarget "this" _hxTarget "this"

View File

@ -7,7 +7,7 @@ open MyPrayerJournal
open NodaTime open NodaTime
/// Create a request within the list /// 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 isFuture instant = defaultArg (instant |> Option.map (fun it -> it > now)) false
let reqId = RequestId.toString req.RequestId let reqId = RequestId.toString req.RequestId
let isAnswered = req.LastStatus = Answered let isAnswered = req.LastStatus = Answered
@ -28,32 +28,32 @@ let reqListItem now req =
if isSnoozed || isPending || isAnswered then if isSnoozed || isPending || isAnswered then
br [] br []
small [ _class "text-muted" ] [ small [ _class "text-muted" ] [
if isSnoozed then [ str "Snooze expires "; relativeDate req.SnoozedUntil.Value 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 ] elif isPending then [ str "Request appears next "; relativeDate req.ShowAfter.Value now tz ]
else (* isAnswered *) [ str "Answered "; relativeDate req.AsOf now ] else (* isAnswered *) [ str "Answered "; relativeDate req.AsOf now tz ]
|> em [] |> em []
] ]
] ]
] ]
/// Create a list of requests /// Create a list of requests
let reqList now reqs = let reqList now tz reqs =
reqs reqs
|> List.map (reqListItem now) |> List.map (reqListItem now tz)
|> div [ _class "list-group" ] |> div [ _class "list-group" ]
/// View for Active Requests page /// View for Active Requests page
let active now reqs = let active now tz reqs =
article [ _class "container mt-3" ] [ article [ _class "container mt-3" ] [
h2 [ _class "pb-3" ] [ str "Active Requests" ] h2 [ _class "pb-3" ] [ str "Active Requests" ]
if List.isEmpty reqs then if List.isEmpty reqs then
noResults "No Active Requests" "/journal" "Return to your journal" noResults "No Active Requests" "/journal" "Return to your journal"
[ str "Your prayer journal has no active requests" ] [ str "Your prayer journal has no active requests" ]
else reqList now reqs else reqList now tz reqs
] ]
/// View for Answered Requests page /// View for Answered Requests page
let answered now reqs = let answered now tz reqs =
article [ _class "container mt-3" ] [ article [ _class "container mt-3" ] [
h2 [ _class "pb-3" ] [ str "Answered Requests" ] h2 [ _class "pb-3" ] [ str "Answered Requests" ]
if List.isEmpty reqs then 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 " str "Your prayer journal has no answered requests; once you have marked one as "
rawText "&ldquo;Answered&rdquo;, it will appear here" rawText "&ldquo;Answered&rdquo;, it will appear here"
] ]
else reqList now reqs else reqList now tz reqs
] ]
/// View for Snoozed Requests page /// View for Snoozed Requests page
let snoozed now reqs = let snoozed now tz reqs =
article [ _class "container mt-3" ] [ article [ _class "container mt-3" ] [
h2 [ _class "pb-3" ] [ str "Snoozed Requests" ] h2 [ _class "pb-3" ] [ str "Snoozed Requests" ]
reqList now reqs reqList now tz reqs
] ]
/// View for Full Request page /// View for Full Request page
let full (clock : IClock) (req : Request) = let full (clock : IClock) tz (req : Request) =
let now = clock.GetCurrentInstant () let now = clock.GetCurrentInstant ()
let answered = let answered =
req.History req.History
@ -110,7 +110,7 @@ let full (clock : IClock) (req : Request) =
str "Answered " str "Answered "
date.ToDateTimeOffset().ToString ("D", null) |> str date.ToDateTimeOffset().ToString ("D", null) |> str
str " (" str " ("
relativeDate date now relativeDate date now tz
rawText ") &bull; " rawText ") &bull; "
| None -> () | None -> ()
rawText $"Prayed %s{prayed} times &bull; Open %s{daysOpen} days" rawText $"Prayed %s{prayed} times &bull; Open %s{daysOpen} days"
@ -259,9 +259,9 @@ let edit (req : JournalRequest) returnTo isNew =
] ]
/// Display a list of notes for a request /// Display a list of notes for a request
let notes now notes = let notes now tz notes =
let toItem (note : Note) = 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" ] ] [ p [ _class "text-center" ] [ strong [] [ str "Prior Notes for This Request" ] ]
match notes with match notes with
| [] -> p [ _class "text-center text-muted" ] [ str "There are no prior notes for this request" ] | [] -> p [ _class "text-center text-muted" ] [ str "There are no prior notes for this request" ]

View File

@ -1,7 +1,7 @@
"use strict" "use strict"
/** myPrayerJournal script */ /** myPrayerJournal script */
const mpj = { this.mpj = {
/** /**
* Show a message via toast * Show a message via toast
* @param {string} message The message to show * @param {string} message The message to show
@ -66,6 +66,19 @@ const mpj = {
const isDisabled = target.value === "Immediate" const isDisabled = target.value === "Immediate"
;["recurCount", "recurInterval"].forEach(it => document.getElementById(it).disabled = isDisabled) ;["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() 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()