Version 8 #43

Merged
danieljsummers merged 37 commits from version-8 into main 2022-08-19 19:08:31 +00:00
15 changed files with 147 additions and 112 deletions
Showing only changes of commit e0d2be41d8 - Show all commits

View File

@ -2,6 +2,7 @@
module PrayerTracker.DataAccess module PrayerTracker.DataAccess
open System.Linq open System.Linq
open NodaTime
open PrayerTracker.Entities open PrayerTracker.Entities
[<AutoOpen>] [<AutoOpen>]
@ -24,7 +25,6 @@ module private Helpers =
if pageNbr > 0 then q.Skip((pageNbr - 1) * pageSize).Take pageSize else q if pageNbr > 0 then q.Skip((pageNbr - 1) * pageSize).Take pageSize else q
open System
open Microsoft.EntityFrameworkCore open Microsoft.EntityFrameworkCore
open Microsoft.FSharpLu open Microsoft.FSharpLu
@ -90,12 +90,14 @@ type AppDbContext with
/// Get all (or active) requests for a small group as of now or the specified date /// Get all (or active) requests for a small group as of now or the specified date
member this.AllRequestsForSmallGroup (grp : SmallGroup) clock listDate activeOnly pageNbr = backgroundTask { member this.AllRequestsForSmallGroup (grp : SmallGroup) clock listDate activeOnly pageNbr = backgroundTask {
let theDate = match listDate with Some dt -> dt | _ -> grp.LocalDateNow clock let theDate = match listDate with Some dt -> dt | _ -> SmallGroup.localDateNow clock grp
let query = let query =
this.PrayerRequests.Where(fun req -> req.SmallGroupId = grp.Id) this.PrayerRequests.Where(fun req -> req.SmallGroupId = grp.Id)
|> function |> function
| q when activeOnly -> | q when activeOnly ->
let asOf = DateTime (theDate.AddDays(-(float grp.Preferences.DaysToExpire)).Date.Ticks, DateTimeKind.Utc) let asOf =
(theDate.AtStartOfDayInZone(SmallGroup.timeZone grp) - Duration.FromDays grp.Preferences.DaysToExpire)
.ToInstant ()
q.Where(fun req -> q.Where(fun req ->
( req.UpdatedDate > asOf ( req.UpdatedDate > asOf
|| req.Expiration = Manual || req.Expiration = Manual

View File

@ -1,7 +1,5 @@
namespace PrayerTracker.Entities namespace PrayerTracker.Entities
// fsharplint:disable RecordFieldNames MemberNames
(*-- SUPPORT TYPES --*) (*-- SUPPORT TYPES --*)
/// How as-of dates should (or should not) be displayed with requests /// How as-of dates should (or should not) be displayed with requests
@ -633,10 +631,10 @@ and [<CLIMutable; NoComparison; NoEquality>] PrayerRequest =
SmallGroupId : SmallGroupId SmallGroupId : SmallGroupId
/// The date/time on which this request was entered /// The date/time on which this request was entered
EnteredDate : DateTime EnteredDate : Instant
/// The date/time this request was last updated /// The date/time this request was last updated
UpdatedDate : DateTime UpdatedDate : Instant
/// The name of the requestor or subject, or title of announcement /// The name of the requestor or subject, or title of announcement
Requestor : string option Requestor : string option
@ -664,8 +662,8 @@ with
RequestType = CurrentRequest RequestType = CurrentRequest
UserId = UserId Guid.Empty UserId = UserId Guid.Empty
SmallGroupId = SmallGroupId Guid.Empty SmallGroupId = SmallGroupId Guid.Empty
EnteredDate = DateTime.MinValue EnteredDate = Instant.MinValue
UpdatedDate = DateTime.MinValue UpdatedDate = Instant.MinValue
Requestor = None Requestor = None
Text = "" Text = ""
NotifyChaplain = false NotifyChaplain = false
@ -674,20 +672,6 @@ with
Expiration = Automatic Expiration = Automatic
} }
/// Is this request expired?
member this.IsExpired (curr : DateTime) expDays =
match this.Expiration, this.RequestType with
| Forced, _ -> true
| Manual, _
| Automatic, LongTermRequest
| Automatic, Expecting -> false
| Automatic, _ -> curr.AddDays(-(float expDays)).Date > this.UpdatedDate.Date // Automatic expiration
/// Is an update required for this long-term request?
member this.UpdateRequired curr expDays updWeeks =
if this.IsExpired curr expDays then false
else curr.AddDays(-(float (updWeeks * 7))).Date > this.UpdatedDate.Date
/// Configure EF for this entity /// Configure EF for this entity
static member internal ConfigureEF (mb : ModelBuilder) = static member internal ConfigureEF (mb : ModelBuilder) =
mb.Entity<PrayerRequest> (fun it -> mb.Entity<PrayerRequest> (fun it ->
@ -759,19 +743,6 @@ with
Users = ResizeArray<UserSmallGroup> () Users = ResizeArray<UserSmallGroup> ()
} }
/// Get the local date for this group
member this.LocalTimeNow (clock : IClock) =
if isNull clock then nullArg (nameof clock)
let tzId = TimeZoneId.toString this.Preferences.TimeZoneId
let tz =
if DateTimeZoneProviders.Tzdb.Ids.Contains tzId then DateTimeZoneProviders.Tzdb[tzId]
else DateTimeZone.Utc
clock.GetCurrentInstant().InZone(tz).ToDateTimeUnspecified ()
/// Get the local date for this group
member this.LocalDateNow clock =
(this.LocalTimeNow clock).Date
/// Configure EF for this entity /// Configure EF for this entity
static member internal ConfigureEF (mb : ModelBuilder) = static member internal ConfigureEF (mb : ModelBuilder) =
mb.Entity<SmallGroup> (fun it -> mb.Entity<SmallGroup> (fun it ->
@ -855,7 +826,7 @@ and [<CLIMutable; NoComparison; NoEquality>] User =
Salt : Guid option Salt : Guid option
/// The last time the user was seen (set whenever the user is loaded into a session) /// The last time the user was seen (set whenever the user is loaded into a session)
LastSeen : DateTime option LastSeen : Instant option
/// The small groups which this user is authorized /// The small groups which this user is authorized
SmallGroups : ResizeArray<UserSmallGroup> SmallGroups : ResizeArray<UserSmallGroup>
@ -900,7 +871,7 @@ with
mb.Model.FindEntityType(typeof<User>).FindProperty(nameof User.empty.Salt) mb.Model.FindEntityType(typeof<User>).FindProperty(nameof User.empty.Salt)
.SetValueConverter (OptionConverter<Guid> ()) .SetValueConverter (OptionConverter<Guid> ())
mb.Model.FindEntityType(typeof<User>).FindProperty(nameof User.empty.LastSeen) mb.Model.FindEntityType(typeof<User>).FindProperty(nameof User.empty.LastSeen)
.SetValueConverter (OptionConverter<DateTime> ()) .SetValueConverter (OptionConverter<Instant> ())
/// Cross-reference between user and small group /// Cross-reference between user and small group
@ -948,3 +919,43 @@ with
mb.Model.FindEntityType(typeof<UserSmallGroup>).FindProperty(nameof UserSmallGroup.empty.SmallGroupId) mb.Model.FindEntityType(typeof<UserSmallGroup>).FindProperty(nameof UserSmallGroup.empty.SmallGroupId)
.SetValueConverter (Converters.SmallGroupIdConverter ()) .SetValueConverter (Converters.SmallGroupIdConverter ())
/// Support functions for small groups
module SmallGroup =
/// The DateTimeZone for the time zone ID for this small group
let timeZone group =
let tzId = TimeZoneId.toString group.Preferences.TimeZoneId
if DateTimeZoneProviders.Tzdb.Ids.Contains tzId then DateTimeZoneProviders.Tzdb[tzId]
else DateTimeZone.Utc
/// Get the local date/time for this group
let localTimeNow (clock : IClock) group =
if isNull clock then nullArg (nameof clock)
clock.GetCurrentInstant().InZone(timeZone group).LocalDateTime
/// Get the local date for this group
let localDateNow clock group =
(localTimeNow clock group).Date
/// Support functions for prayer requests
module PrayerRequest =
/// Is this request expired?
let isExpired (asOf : LocalDate) group req =
match req.Expiration, req.RequestType with
| Forced, _ -> true
| Manual, _
| Automatic, LongTermRequest
| Automatic, Expecting -> false
| Automatic, _ ->
// Automatic expiration
asOf.PlusDays -group.Preferences.DaysToExpire > req.UpdatedDate.InZone(SmallGroup.timeZone group).Date
/// Is an update required for this long-term request?
let updateRequired asOf group req =
if isExpired asOf group req then false
else asOf.PlusWeeks -group.Preferences.LongTermUpdateWeeks
>= req.UpdatedDate.InZone(SmallGroup.timeZone group).Date

View File

@ -17,7 +17,7 @@
<PackageReference Include="Giraffe" Version="6.0.0" /> <PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Microsoft.FSharpLu" Version="0.11.7" /> <PackageReference Include="Microsoft.FSharpLu" Version="0.11.7" />
<PackageReference Include="NodaTime" Version="3.1.0" /> <PackageReference Include="NodaTime" Version="3.1.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.5" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.6" />
<PackageReference Update="FSharp.Core" Version="6.0.5" /> <PackageReference Update="FSharp.Core" Version="6.0.5" />
</ItemGroup> </ItemGroup>

View File

@ -255,13 +255,13 @@ let private messages viewInfo =
|> List.singleton |> List.singleton
open System open NodaTime
/// Render the <footer> at the bottom of the page /// Render the <footer> at the bottom of the page
let private htmlFooter viewInfo = let private htmlFooter viewInfo =
let s = I18N.localizer.Force () let s = I18N.localizer.Force ()
let imgText = $"""%O{s["PrayerTracker"]} %O{s["from Bit Badger Solutions"]}""" let imgText = $"""%O{s["PrayerTracker"]} %O{s["from Bit Badger Solutions"]}"""
let resultTime = TimeSpan(DateTime.Now.Ticks - viewInfo.RequestStart).TotalSeconds let resultTime = (SystemClock.Instance.GetCurrentInstant () - viewInfo.RequestStart).TotalSeconds
footer [ _class "pt-footer" ] [ footer [ _class "pt-footer" ] [
div [ _id "pt-legal" ] [ div [ _id "pt-legal" ] [
a [ _href "/legal/privacy-policy" ] [ locStr s["Privacy Policy"] ] a [ _href "/legal/privacy-policy" ] [ locStr s["Privacy Policy"] ]

View File

@ -154,19 +154,18 @@ let maintain (model : MaintainRequests) (ctx : HttpContext) viewInfo =
let l = I18N.forView "Requests/Maintain" let l = I18N.forView "Requests/Maintain"
use sw = new StringWriter () use sw = new StringWriter ()
let raw = rawLocText sw let raw = rawLocText sw
let now = model.SmallGroup.LocalDateNow (ctx.GetService<IClock> ()) let group = model.SmallGroup
let prefs = model.SmallGroup.Preferences let now = SmallGroup.localDateNow (ctx.GetService<IClock> ()) group
let types = ReferenceList.requestTypeList s |> Map.ofList let types = ReferenceList.requestTypeList s |> Map.ofList
let updReq (req : PrayerRequest) =
if req.UpdateRequired now prefs.DaysToExpire prefs.LongTermUpdateWeeks then "cell pt-request-update" else "cell"
|> _class
let reqExp (req : PrayerRequest) =
_class (if req.IsExpired now prefs.DaysToExpire then "cell pt-request-expired" else "cell")
let vi = AppViewInfo.withScopedStyles [ "#requestList { grid-template-columns: repeat(5, auto); }" ] viewInfo let vi = AppViewInfo.withScopedStyles [ "#requestList { grid-template-columns: repeat(5, auto); }" ] viewInfo
/// Iterate the sequence once, before we render, so we can get the count of it at the top of the table /// Iterate the sequence once, before we render, so we can get the count of it at the top of the table
let requests = let requests =
model.Requests model.Requests
|> List.map (fun req -> |> List.map (fun req ->
let updateClass =
_class (if PrayerRequest.updateRequired now group req then "cell pt-request-update" else "cell")
let isExpired = PrayerRequest.isExpired now group req
let expiredClass = _class (if isExpired then "cell pt-request-expired" else "cell")
let reqId = shortGuid req.Id.Value let reqId = shortGuid req.Id.Value
let reqText = htmlToPlainText req.Text let reqText = htmlToPlainText req.Text
let delAction = $"/prayer-request/{reqId}/delete" let delAction = $"/prayer-request/{reqId}/delete"
@ -183,7 +182,7 @@ let maintain (model : MaintainRequests) (ctx : HttpContext) viewInfo =
a [ _href $"/prayer-request/{reqId}/edit"; _title l["Edit This Prayer Request"].Value ] [ a [ _href $"/prayer-request/{reqId}/edit"; _title l["Edit This Prayer Request"].Value ] [
iconSized 18 "edit" iconSized 18 "edit"
] ]
if req.IsExpired now prefs.DaysToExpire then if isExpired then
a [ _href $"/prayer-request/{reqId}/restore" a [ _href $"/prayer-request/{reqId}/restore"
_title l["Restore This Inactive Request"].Value ] [ _title l["Restore This Inactive Request"].Value ] [
iconSized 18 "visibility" iconSized 18 "visibility"
@ -200,11 +199,11 @@ let maintain (model : MaintainRequests) (ctx : HttpContext) viewInfo =
iconSized 18 "delete_forever" iconSized 18 "delete_forever"
] ]
] ]
div [ updReq req ] [ div [ updateClass ] [
str (req.UpdatedDate.ToString(s["MMMM d, yyyy"].Value, Globalization.CultureInfo.CurrentUICulture)) str (req.UpdatedDate.ToString(s["MMMM d, yyyy"].Value, Globalization.CultureInfo.CurrentUICulture))
] ]
div [ _class "cell" ] [ locStr types[req.RequestType] ] div [ _class "cell" ] [ locStr types[req.RequestType] ]
div [ reqExp req ] [ str (match req.Requestor with Some r -> r | None -> " ") ] div [ expiredClass ] [ str (match req.Requestor with Some r -> r | None -> " ") ]
div [ _class "cell" ] [ div [ _class "cell" ] [
match reqText.Length with match reqText.Length with
| len when len < 60 -> rawText reqText | len when len < 60 -> rawText reqText
@ -316,7 +315,7 @@ let view model viewInfo =
let s = I18N.localizer.Force () let s = I18N.localizer.Force ()
let pageTitle = $"""{s["Prayer Requests"].Value} {model.SmallGroup.Name}""" let pageTitle = $"""{s["Prayer Requests"].Value} {model.SmallGroup.Name}"""
let spacer = rawText " &nbsp; &nbsp; &nbsp; " let spacer = rawText " &nbsp; &nbsp; &nbsp; "
let dtString = model.Date.ToString "yyyy-MM-dd" let dtString = model.Date.ToString ("yyyy-MM-dd", null) // TODO: this should be invariant
[ div [ _class "pt-center-text" ] [ [ div [ _class "pt-center-text" ] [
br [] br []
a [ _class "pt-icon-link" a [ _class "pt-icon-link"
@ -327,12 +326,12 @@ let view model viewInfo =
] ]
if model.CanEmail then if model.CanEmail then
spacer spacer
if model.Date.DayOfWeek <> DayOfWeek.Sunday then if model.Date.DayOfWeek <> IsoDayOfWeek.Sunday then
let rec findSunday (date : DateTime) = let rec findSunday (date : LocalDate) =
if date.DayOfWeek = DayOfWeek.Sunday then date else findSunday (date.AddDays 1.) if date.DayOfWeek = IsoDayOfWeek.Sunday then date else findSunday (date.PlusDays 1)
let sunday = findSunday model.Date let sunday = findSunday model.Date
a [ _class "pt-icon-link" a [ _class "pt-icon-link"
_href $"""/prayer-requests/view/{sunday.ToString "yyyy-MM-dd"}""" _href $"""/prayer-requests/view/{sunday.ToString ("yyyy-MM-dd", null)}""" // TODO: make invariant
_title s["List for Next Sunday"].Value ] [ _title s["List for Next Sunday"].Value ] [
icon "update"; rawText " &nbsp;"; locStr s["List for Next Sunday"] icon "update"; rawText " &nbsp;"; locStr s["List for Next Sunday"]
] ]

View File

@ -217,7 +217,10 @@ let maintain (users : User list) ctx viewInfo =
] ]
div [ _class "cell" ] [ str user.Name ] div [ _class "cell" ] [ str user.Name ]
div [ _class "cell" ] [ div [ _class "cell" ] [
str (match user.LastSeen with Some dt -> dt.ToString s["MMMM d, yyyy"] | None -> "--") match user.LastSeen with
| Some dt -> dt.ToString (s["MMMM d, yyyy"].Value, null)
| None -> "--"
|> str
] ]
div [ _class "cell pt-center-text" ] [ div [ _class "cell pt-center-text" ] [
if user.IsAdmin then strong [] [ locStr s["Yes"] ] else locStr s["No"] if user.IsAdmin then strong [] [ locStr s["Yes"] ] else locStr s["No"]

View File

@ -116,7 +116,7 @@ type LayoutType =
| ContentOnly | ContentOnly
open System open NodaTime
/// View model required by the layout template, given as first parameter for all pages in PrayerTracker /// View model required by the layout template, given as first parameter for all pages in PrayerTracker
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
@ -134,7 +134,7 @@ type AppViewInfo =
Version : string Version : string
/// The ticks when the request started /// The ticks when the request started
RequestStart : int64 RequestStart : Instant
/// The currently logged on user, if there is one /// The currently logged on user, if there is one
User : User option User : User option
@ -151,7 +151,6 @@ type AppViewInfo =
/// A JavaScript function to run on page load /// A JavaScript function to run on page load
OnLoadScript : string option OnLoadScript : string option
} }
// TODO: add onload script option to this, modify layout to add it
/// Support for the AppViewInfo type /// Support for the AppViewInfo type
module AppViewInfo = module AppViewInfo =
@ -162,7 +161,7 @@ module AppViewInfo =
HelpLink = None HelpLink = None
Messages = [] Messages = []
Version = "" Version = ""
RequestStart = DateTime.Now.Ticks RequestStart = Instant.MinValue
User = None User = None
Group = None Group = None
Layout = FullPage Layout = FullPage
@ -467,7 +466,7 @@ type EditRequest =
RequestType : string RequestType : string
/// The date of the request /// The date of the request
EnteredDate : DateTime option EnteredDate : string option
/// Whether to update the date or not /// Whether to update the date or not
SkipDateUpdate : bool option SkipDateUpdate : bool option
@ -724,6 +723,7 @@ module UserLogOn =
} }
open System
open Giraffe.ViewEngine open Giraffe.ViewEngine
/// This represents a list of requests /// This represents a list of requests
@ -732,7 +732,7 @@ type RequestList =
Requests : PrayerRequest list Requests : PrayerRequest list
/// The date for which this list is being generated /// The date for which this list is being generated
Date : DateTime Date : LocalDate
/// The small group to which this list belongs /// The small group to which this list belongs
SmallGroup : SmallGroup SmallGroup : SmallGroup
@ -767,30 +767,31 @@ with
/// Is this request new? /// Is this request new?
member this.IsNew (req : PrayerRequest) = member this.IsNew (req : PrayerRequest) =
(this.Date - req.UpdatedDate).Days <= this.SmallGroup.Preferences.DaysToKeepNew let reqDate = req.UpdatedDate.InZone(SmallGroup.timeZone this.SmallGroup).Date
Period.Between(this.Date, reqDate, PeriodUnits.Days).Days <= this.SmallGroup.Preferences.DaysToKeepNew
/// Generate this list as HTML /// Generate this list as HTML
member this.AsHtml (s : IStringLocalizer) = member this.AsHtml (s : IStringLocalizer) =
let prefs = this.SmallGroup.Preferences let p = this.SmallGroup.Preferences
let asOfSize = Math.Round (float prefs.TextFontSize * 0.8, 2) let asOfSize = Math.Round (float p.TextFontSize * 0.8, 2)
[ if this.ShowHeader then [ if this.ShowHeader then
div [ _style $"text-align:center;font-family:{prefs.Fonts}" ] [ div [ _style $"text-align:center;font-family:{p.Fonts}" ] [
span [ _style $"font-size:%i{prefs.HeadingFontSize}pt;" ] [ span [ _style $"font-size:%i{p.HeadingFontSize}pt;" ] [
strong [] [ str s["Prayer Requests"].Value ] strong [] [ str s["Prayer Requests"].Value ]
] ]
br [] br []
span [ _style $"font-size:%i{prefs.TextFontSize}pt;" ] [ span [ _style $"font-size:%i{p.TextFontSize}pt;" ] [
strong [] [ str this.SmallGroup.Name ] strong [] [ str this.SmallGroup.Name ]
br [] br []
str (this.Date.ToString s["MMMM d, yyyy"].Value) str (this.Date.ToString (s["MMMM d, yyyy"].Value, null))
] ]
] ]
br [] br []
for _, name, reqs in this.RequestsByType s do for _, name, reqs in this.RequestsByType s do
div [ _style "padding-left:10px;padding-bottom:.5em;" ] [ div [ _style "padding-left:10px;padding-bottom:.5em;" ] [
table [ _style $"font-family:{prefs.Fonts};page-break-inside:avoid;" ] [ table [ _style $"font-family:{p.Fonts};page-break-inside:avoid;" ] [
tr [] [ tr [] [
td [ _style $"font-size:%i{prefs.HeadingFontSize}pt;color:{prefs.HeadingColor};padding:3px 0;border-top:solid 3px {prefs.LineColor};border-bottom:solid 3px {prefs.LineColor};font-weight:bold;" ] [ td [ _style $"font-size:%i{p.HeadingFontSize}pt;color:{p.HeadingColor};padding:3px 0;border-top:solid 3px {p.LineColor};border-bottom:solid 3px {p.LineColor};font-weight:bold;" ] [
rawText "&nbsp; &nbsp; "; str name.Value; rawText "&nbsp; &nbsp; " rawText "&nbsp; &nbsp; "; str name.Value; rawText "&nbsp; &nbsp; "
] ]
] ]
@ -799,7 +800,7 @@ with
reqs reqs
|> List.map (fun req -> |> List.map (fun req ->
let bullet = if this.IsNew req then "circle" else "disc" let bullet = if this.IsNew req then "circle" else "disc"
li [ _style $"list-style-type:{bullet};font-family:{prefs.Fonts};font-size:%i{prefs.TextFontSize}pt;padding-bottom:.25em;" ] [ li [ _style $"list-style-type:{bullet};font-family:{p.Fonts};font-size:%i{p.TextFontSize}pt;padding-bottom:.25em;" ] [
match req.Requestor with match req.Requestor with
| Some r when r <> "" -> | Some r when r <> "" ->
strong [] [ str r ] strong [] [ str r ]
@ -807,14 +808,14 @@ with
| Some _ -> () | Some _ -> ()
| None -> () | None -> ()
rawText req.Text rawText req.Text
match prefs.AsOfDateDisplay with match p.AsOfDateDisplay with
| NoDisplay -> () | NoDisplay -> ()
| ShortDate | ShortDate
| LongDate -> | LongDate ->
let dt = let dt =
match prefs.AsOfDateDisplay with match p.AsOfDateDisplay with
| ShortDate -> req.UpdatedDate.ToShortDateString () | ShortDate -> req.UpdatedDate.ToString ("d", null)
| LongDate -> req.UpdatedDate.ToLongDateString () | LongDate -> req.UpdatedDate.ToString ("D", null)
| _ -> "" | _ -> ""
i [ _style $"font-size:%.2f{asOfSize}pt" ] [ i [ _style $"font-size:%.2f{asOfSize}pt" ] [
rawText "&nbsp; ("; str s["as of"].Value; str " "; str dt; rawText ")" rawText "&nbsp; ("; str s["as of"].Value; str " "; str dt; rawText ")"
@ -830,7 +831,7 @@ with
seq { seq {
this.SmallGroup.Name this.SmallGroup.Name
s["Prayer Requests"].Value s["Prayer Requests"].Value
this.Date.ToString s["MMMM d, yyyy"].Value this.Date.ToString (s["MMMM d, yyyy"].Value, null)
" " " "
for _, name, reqs in this.RequestsByType s do for _, name, reqs in this.RequestsByType s do
let dashes = String.replicate (name.Value.Length + 4) "-" let dashes = String.replicate (name.Value.Length + 4) "-"
@ -845,8 +846,8 @@ with
| _ -> | _ ->
let dt = let dt =
match this.SmallGroup.Preferences.AsOfDateDisplay with match this.SmallGroup.Preferences.AsOfDateDisplay with
| ShortDate -> req.UpdatedDate.ToShortDateString () | ShortDate -> req.UpdatedDate.ToString ("d", null)
| LongDate -> req.UpdatedDate.ToLongDateString () | LongDate -> req.UpdatedDate.ToString ("D", null)
| _ -> "" | _ -> ""
$""" ({s["as of"].Value} {dt})""" $""" ({s["as of"].Value} {dt})"""
|> sprintf " %s %s%s%s" bullet requestor (htmlToPlainText req.Text) |> sprintf " %s %s%s%s" bullet requestor (htmlToPlainText req.Text)

View File

@ -1,17 +1,17 @@
namespace PrayerTracker namespace PrayerTracker
open System
open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Http
/// Middleware to add the starting ticks for the request /// Middleware to add the starting ticks for the request
type RequestStartMiddleware (next : RequestDelegate) = type RequestStartMiddleware (next : RequestDelegate) =
member this.InvokeAsync (ctx : HttpContext) = task { member this.InvokeAsync (ctx : HttpContext) = task {
ctx.Items[Key.startTime] <- DateTime.Now.Ticks ctx.Items[Key.startTime] <- ctx.Now
return! next.Invoke ctx return! next.Invoke ctx
} }
open System
open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting open Microsoft.AspNetCore.Hosting
@ -71,9 +71,13 @@ module Configure =
let _ = svc.AddSingleton<IClock> SystemClock.Instance let _ = svc.AddSingleton<IClock> SystemClock.Instance
let config = svc.BuildServiceProvider().GetRequiredService<IConfiguration> () let config = svc.BuildServiceProvider().GetRequiredService<IConfiguration> ()
//NpgsqlConnection.GlobalTypeMapper.
let _ = let _ =
svc.AddDbContext<AppDbContext> ( svc.AddDbContext<AppDbContext> (
(fun options -> options.UseNpgsql (config.GetConnectionString "PrayerTracker") |> ignore), (fun options ->
options.UseNpgsql (
config.GetConnectionString "PrayerTracker", fun o -> o.UseNodaTime () |> ignore)
|> ignore),
ServiceLifetime.Scoped, ServiceLifetime.Singleton) ServiceLifetime.Scoped, ServiceLifetime.Singleton)
() ()

View File

@ -44,6 +44,7 @@ let appVersion =
open Giraffe open Giraffe
open Giraffe.Htmx open Giraffe.Htmx
open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Http
open NodaTime
open PrayerTracker open PrayerTracker
open PrayerTracker.ViewModels open PrayerTracker.ViewModels
@ -63,7 +64,7 @@ let viewInfo (ctx : HttpContext) =
{ AppViewInfo.fresh with { AppViewInfo.fresh with
Version = appVersion Version = appVersion
Messages = msg Messages = msg
RequestStart = ctx.Items[Key.startTime] :?> int64 RequestStart = ctx.Items[Key.startTime] :?> Instant
User = ctx.Session.CurrentUser User = ctx.Session.CurrentUser
Group = ctx.Session.CurrentGroup Group = ctx.Session.CurrentGroup
Layout = layout Layout = layout

View File

@ -1,7 +1,6 @@
[<AutoOpen>] [<AutoOpen>]
module PrayerTracker.Extensions module PrayerTracker.Extensions
open System
open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Http
open Microsoft.FSharpLu open Microsoft.FSharpLu
open Newtonsoft.Json open Newtonsoft.Json
@ -76,6 +75,9 @@ type HttpContext with
/// The system clock (via DI) /// The system clock (via DI)
member this.Clock = this.GetService<IClock> () member this.Clock = this.GetService<IClock> ()
/// The current instant
member this.Now = this.Clock.GetCurrentInstant ()
/// The currently logged on small group (sets the value in the session if it is missing) /// The currently logged on small group (sets the value in the session if it is missing)
member this.CurrentGroup () = task { member this.CurrentGroup () = task {
match this.Session.CurrentGroup with match this.Session.CurrentGroup with
@ -101,7 +103,7 @@ type HttpContext with
match! this.Db.TryUserById userId with match! this.Db.TryUserById userId with
| Some user -> | Some user ->
// Set last seen for user // Set last seen for user
this.Db.UpdateEntry { user with LastSeen = Some DateTime.UtcNow } this.Db.UpdateEntry { user with LastSeen = Some this.Now }
let! _ = this.Db.SaveChangesAsync () let! _ = this.Db.SaveChangesAsync ()
this.Session.CurrentUser <- Some user this.Session.CurrentUser <- Some user
return Some user return Some user

View File

@ -20,7 +20,7 @@ let private findRequest (ctx : HttpContext) reqId = task {
/// Generate a list of requests for the given date /// Generate a list of requests for the given date
let private generateRequestList (ctx : HttpContext) date = task { let private generateRequestList (ctx : HttpContext) date = task {
let group = ctx.Session.CurrentGroup.Value let group = ctx.Session.CurrentGroup.Value
let listDate = match date with Some d -> d | None -> group.LocalDateNow ctx.Clock let listDate = match date with Some d -> d | None -> SmallGroup.localDateNow ctx.Clock group
let! reqs = ctx.Db.AllRequestsForSmallGroup group ctx.Clock (Some listDate) true 0 let! reqs = ctx.Db.AllRequestsForSmallGroup group ctx.Clock (Some listDate) true 0
return return
{ Requests = reqs { Requests = reqs
@ -32,29 +32,31 @@ let private generateRequestList (ctx : HttpContext) date = task {
} }
} }
open System open NodaTime.Text
/// Parse a string into a date (optionally, of course) /// Parse a string into a date (optionally, of course)
let private parseListDate (date : string option) = let private parseListDate (date : string option) =
match date with match date with
| Some dt -> match DateTime.TryParse dt with true, d -> Some d | false, _ -> None | Some dt -> match LocalDatePattern.Iso.Parse dt with it when it.Success -> Some it.Value | _ -> None
| None -> None | None -> None
open System
/// GET /prayer-request/[request-id]/edit /// GET /prayer-request/[request-id]/edit
let edit reqId : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task { let edit reqId : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task {
let group = ctx.Session.CurrentGroup.Value let group = ctx.Session.CurrentGroup.Value
let now = group.LocalDateNow ctx.Clock let now = SmallGroup.localDateNow ctx.Clock group
let requestId = PrayerRequestId reqId let requestId = PrayerRequestId reqId
if requestId.Value = Guid.Empty then if requestId.Value = Guid.Empty then
return! return!
{ viewInfo ctx with HelpLink = Some Help.editRequest } { viewInfo ctx with HelpLink = Some Help.editRequest }
|> Views.PrayerRequest.edit EditRequest.empty (now.ToString "yyyy-MM-dd") ctx |> Views.PrayerRequest.edit EditRequest.empty (now.ToString ("R", null)) ctx
|> renderHtml next ctx |> renderHtml next ctx
else else
match! findRequest ctx requestId with match! findRequest ctx requestId with
| Ok req -> | Ok req ->
let s = Views.I18N.localizer.Force () let s = Views.I18N.localizer.Force ()
if req.IsExpired now group.Preferences.DaysToExpire then if PrayerRequest.isExpired now group req then
{ UserMessage.warning with { UserMessage.warning with
Text = htmlLocString s["This request is expired."] Text = htmlLocString s["This request is expired."]
Description = Description =
@ -128,7 +130,7 @@ let list groupId : HttpHandler = requireAccess [ AccessLevel.Public ] >=> fun ne
viewInfo ctx viewInfo ctx
|> Views.PrayerRequest.list |> Views.PrayerRequest.list
{ Requests = reqs { Requests = reqs
Date = group.LocalDateNow ctx.Clock Date = SmallGroup.localDateNow ctx.Clock group
SmallGroup = group SmallGroup = group
ShowHeader = true ShowHeader = true
CanEmail = Option.isSome ctx.User.UserId CanEmail = Option.isSome ctx.User.UserId
@ -199,7 +201,7 @@ let restore reqId : HttpHandler = requireAccess [ User ] >=> fun next ctx -> tas
match! findRequest ctx requestId with match! findRequest ctx requestId with
| Ok req -> | Ok req ->
let s = Views.I18N.localizer.Force () let s = Views.I18N.localizer.Force ()
ctx.Db.UpdateEntry { req with Expiration = Automatic; UpdatedDate = DateTime.Now } ctx.Db.UpdateEntry { req with Expiration = Automatic; UpdatedDate = ctx.Now }
let! _ = ctx.Db.SaveChangesAsync () let! _ = ctx.Db.SaveChangesAsync ()
addInfo ctx s["Successfully {0} prayer request", s["Restored"].Value.ToLower ()] addInfo ctx s["Successfully {0} prayer request", s["Restored"].Value.ToLower ()]
return! redirectTo false "/prayer-requests" next ctx return! redirectTo false "/prayer-requests" next ctx
@ -226,10 +228,13 @@ let save : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun next ct
Expiration = Expiration.fromCode model.Expiration Expiration = Expiration.fromCode model.Expiration
} }
let group = ctx.Session.CurrentGroup.Value let group = ctx.Session.CurrentGroup.Value
let now = group.LocalDateNow ctx.Clock let now = SmallGroup.localDateNow ctx.Clock group
match model.IsNew with match model.IsNew with
| true -> | true ->
let dt = defaultArg model.EnteredDate now let dt =
(defaultArg (parseListDate model.EnteredDate) now)
.AtStartOfDayInZone(SmallGroup.timeZone group)
.ToInstant()
{ upd8 with { upd8 with
SmallGroupId = group.Id SmallGroupId = group.Id
UserId = ctx.User.UserId.Value UserId = ctx.User.UserId.Value
@ -237,7 +242,7 @@ let save : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun next ct
UpdatedDate = dt UpdatedDate = dt
} }
| false when defaultArg model.SkipDateUpdate false -> upd8 | false when defaultArg model.SkipDateUpdate false -> upd8
| false -> { upd8 with UpdatedDate = now } | false -> { upd8 with UpdatedDate = ctx.Now }
|> if model.IsNew then ctx.Db.AddEntry else ctx.Db.UpdateEntry |> if model.IsNew then ctx.Db.AddEntry else ctx.Db.UpdateEntry
let! _ = ctx.Db.SaveChangesAsync () let! _ = ctx.Db.SaveChangesAsync ()
let s = Views.I18N.localizer.Force () let s = Views.I18N.localizer.Force ()

View File

@ -27,8 +27,9 @@
<PackageReference Include="Giraffe" Version="6.0.0" /> <PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Giraffe.Htmx" Version="1.8.0" /> <PackageReference Include="Giraffe.Htmx" Version="1.8.0" />
<PackageReference Include="NeoSmart.Caching.Sqlite" Version="6.0.1" /> <PackageReference Include="NeoSmart.Caching.Sqlite" Version="6.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.5" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.6" />
<PackageReference Update="FSharp.Core" Version="6.0.5" /> <PackageReference Update="FSharp.Core" Version="6.0.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="6.0.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -261,14 +261,14 @@ let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
match! ctx.TryBindFormAsync<Announcement> () with match! ctx.TryBindFormAsync<Announcement> () with
| Ok model -> | Ok model ->
let group = ctx.Session.CurrentGroup.Value let group = ctx.Session.CurrentGroup.Value
let prefs = group.Preferences let pref = group.Preferences
let usr = ctx.Session.CurrentUser.Value let usr = ctx.Session.CurrentUser.Value
let now = group.LocalTimeNow ctx.Clock let now = SmallGroup.localTimeNow ctx.Clock group
let s = Views.I18N.localizer.Force () let s = Views.I18N.localizer.Force ()
// Reformat the text to use the class's font stylings // Reformat the text to use the class's font stylings
let requestText = ckEditorToText model.Text let requestText = ckEditorToText model.Text
let htmlText = let htmlText =
p [ _style $"font-family:{prefs.Fonts};font-size:%d{prefs.TextFontSize}pt;" ] [ rawText requestText ] p [ _style $"font-family:{pref.Fonts};font-size:%d{pref.TextFontSize}pt;" ] [ rawText requestText ]
|> renderHtmlNode |> renderHtmlNode
let plainText = (htmlToPlainText >> wordWrap 74) htmlText let plainText = (htmlToPlainText >> wordWrap 74) htmlText
// Send the e-mails // Send the e-mails
@ -282,7 +282,7 @@ let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
Recipients = recipients Recipients = recipients
Group = group Group = group
Subject = s["Announcement for {0} - {1:MMMM d, yyyy} {2}", group.Name, now.Date, Subject = s["Announcement for {0} - {1:MMMM d, yyyy} {2}", group.Name, now.Date,
(now.ToString "h:mm tt").ToLower ()].Value (now.ToString ("h:mm tt", null)).ToLower ()].Value
HtmlBody = htmlText HtmlBody = htmlText
PlainTextBody = plainText PlainTextBody = plainText
Strings = s Strings = s
@ -293,14 +293,15 @@ let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
| _, None -> () | _, None -> ()
| _, Some x when not x -> () | _, Some x when not x -> ()
| _, _ -> | _, _ ->
let zone = SmallGroup.timeZone group
{ PrayerRequest.empty with { PrayerRequest.empty with
Id = (Guid.NewGuid >> PrayerRequestId) () Id = (Guid.NewGuid >> PrayerRequestId) ()
SmallGroupId = group.Id SmallGroupId = group.Id
UserId = usr.Id UserId = usr.Id
RequestType = (Option.get >> PrayerRequestType.fromCode) model.RequestType RequestType = (Option.get >> PrayerRequestType.fromCode) model.RequestType
Text = requestText Text = requestText
EnteredDate = now EnteredDate = now.Date.AtStartOfDayInZone(zone).ToInstant()
UpdatedDate = now UpdatedDate = now.InZoneLeniently(zone).ToInstant()
} }
|> ctx.Db.AddEntry |> ctx.Db.AddEntry
let! _ = ctx.Db.SaveChangesAsync () let! _ = ctx.Db.SaveChangesAsync ()

View File

@ -145,7 +145,7 @@ let doLogOn : HttpHandler = requireAccess [ AccessLevel.Public ] >=> validateCsr
AuthenticationProperties ( AuthenticationProperties (
IssuedUtc = DateTimeOffset.UtcNow, IssuedUtc = DateTimeOffset.UtcNow,
IsPersistent = defaultArg model.RememberMe false)) IsPersistent = defaultArg model.RememberMe false))
ctx.Db.UpdateEntry { user with LastSeen = Some DateTime.UtcNow } ctx.Db.UpdateEntry { user with LastSeen = Some ctx.Now }
let! _ = ctx.Db.SaveChangesAsync () let! _ = ctx.Db.SaveChangesAsync ()
addHtmlInfo ctx s["Log On Successful Welcome to {0}", s["PrayerTracker"]] addHtmlInfo ctx s["Log On Successful Welcome to {0}", s["PrayerTracker"]]
return! redirectTo false (sanitizeUrl model.RedirectUrl "/small-group") next ctx return! redirectTo false (sanitizeUrl model.RedirectUrl "/small-group") next ctx

View File

@ -94,7 +94,7 @@ ALTER TABLE pt."User" RENAME COLUMN "PasswordHash" TO password_hash;
ALTER TABLE pt."User" RENAME COLUMN "Salt" TO salt; ALTER TABLE pt."User" RENAME COLUMN "Salt" TO salt;
ALTER TABLE pt."User" RENAME CONSTRAINT "PK_User" TO pk_pt_user; ALTER TABLE pt."User" RENAME CONSTRAINT "PK_User" TO pk_pt_user;
ALTER TABLE pt."User" RENAME TO pt_user; ALTER TABLE pt."User" RENAME TO pt_user;
ALTER TABLE pt.pt_user ADD COLUMN last_seen timestamp; ALTER TABLE pt.pt_user ADD COLUMN last_seen timestamptz;
-- User / Small Group -- User / Small Group
ALTER TABLE pt."User_SmallGroup" RENAME COLUMN "UserId" TO user_id; ALTER TABLE pt."User_SmallGroup" RENAME COLUMN "UserId" TO user_id;
@ -105,3 +105,8 @@ ALTER TABLE pt."User_SmallGroup" RENAME CONSTRAINT "FK_User_SmallGroup_SmallGrou
ALTER TABLE pt."User_SmallGroup" RENAME TO user_small_group; ALTER TABLE pt."User_SmallGroup" RENAME TO user_small_group;
ALTER INDEX pt."IX_User_SmallGroup_SmallGroupId" RENAME TO ix_user_small_group_small_group_id; ALTER INDEX pt."IX_User_SmallGroup_SmallGroupId" RENAME TO ix_user_small_group_small_group_id;
-- #41 - change to timestamptz
SET TimeZone = 'UTC';
ALTER TABLE pt.prayer_request ALTER COLUMN entered_date TYPE timestamptz;
ALTER TABLE pt.prayer_request ALTER COLUMN updated_date TYPE timestamptz;