Version 3.1 #71
@ -3,26 +3,56 @@ open NodaTime
 | 
			
		||||
 | 
			
		||||
/// Request is the identifying record for a prayer request
 | 
			
		||||
[<CLIMutable; NoComparison; NoEquality>]
 | 
			
		||||
type OldRequest = {
 | 
			
		||||
  /// The ID of the request
 | 
			
		||||
  id           : RequestId
 | 
			
		||||
  /// The time this request was initially entered
 | 
			
		||||
  enteredOn    : Instant
 | 
			
		||||
  /// The ID of the user to whom this request belongs ("sub" from the JWT)
 | 
			
		||||
  userId       : UserId
 | 
			
		||||
  /// The time at which this request should reappear in the user's journal by manual user choice
 | 
			
		||||
  snoozedUntil : Instant
 | 
			
		||||
  /// The time at which this request should reappear in the user's journal by recurrence
 | 
			
		||||
  showAfter    : Instant
 | 
			
		||||
  /// The type of recurrence for this request
 | 
			
		||||
  recurType    : string
 | 
			
		||||
  /// How many of the recurrence intervals should occur between appearances in the journal
 | 
			
		||||
  recurCount   : int16
 | 
			
		||||
  /// The history entries for this request
 | 
			
		||||
  history      : History array
 | 
			
		||||
  /// The notes for this request
 | 
			
		||||
  notes        : Note array
 | 
			
		||||
  }
 | 
			
		||||
type OldRequest =
 | 
			
		||||
    {   /// The ID of the request
 | 
			
		||||
        id           : RequestId
 | 
			
		||||
        
 | 
			
		||||
        /// The time this request was initially entered
 | 
			
		||||
        enteredOn    : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The ID of the user to whom this request belongs ("sub" from the JWT)
 | 
			
		||||
        userId       : UserId
 | 
			
		||||
        
 | 
			
		||||
        /// The time at which this request should reappear in the user's journal by manual user choice
 | 
			
		||||
        snoozedUntil : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The time at which this request should reappear in the user's journal by recurrence
 | 
			
		||||
        showAfter    : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The type of recurrence for this request
 | 
			
		||||
        recurType    : string
 | 
			
		||||
        
 | 
			
		||||
        /// How many of the recurrence intervals should occur between appearances in the journal
 | 
			
		||||
        recurCount   : int16
 | 
			
		||||
        
 | 
			
		||||
        /// The history entries for this request
 | 
			
		||||
        history      : History array
 | 
			
		||||
        
 | 
			
		||||
        /// The notes for this request
 | 
			
		||||
        notes        : Note array
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
/// The old definition of the history entry
 | 
			
		||||
[<CLIMutable; NoComparison; NoEquality>]
 | 
			
		||||
type OldHistory =
 | 
			
		||||
    {   /// The time when this history entry was made
 | 
			
		||||
        asOf   : Instant
 | 
			
		||||
        /// The status for this history entry
 | 
			
		||||
        status : RequestAction
 | 
			
		||||
        /// The text of the update, if applicable
 | 
			
		||||
        text   : string option
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
/// The old definition of of the note entry
 | 
			
		||||
[<CLIMutable; NoComparison; NoEquality>]
 | 
			
		||||
type OldNote =
 | 
			
		||||
    {   /// The time when this note was made
 | 
			
		||||
        asOf  : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The text of the notes
 | 
			
		||||
        notes : string
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
open LiteDB
 | 
			
		||||
open MyPrayerJournal.Data
 | 
			
		||||
@ -32,36 +62,33 @@ Startup.ensureDb db
 | 
			
		||||
 | 
			
		||||
/// Map the old recurrence to the new style
 | 
			
		||||
let mapRecurrence old =
 | 
			
		||||
  match old.recurType with
 | 
			
		||||
  | "Days" -> Days old.recurCount
 | 
			
		||||
  | "Hours" -> Hours old.recurCount
 | 
			
		||||
  | "Weeks" -> Weeks old.recurCount
 | 
			
		||||
  | _ -> Immediate
 | 
			
		||||
    match old.recurType with
 | 
			
		||||
    | "Days" -> Days old.recurCount
 | 
			
		||||
    | "Hours" -> Hours old.recurCount
 | 
			
		||||
    | "Weeks" -> Weeks old.recurCount
 | 
			
		||||
    | _ -> Immediate
 | 
			
		||||
 | 
			
		||||
/// Map the old request to the new request
 | 
			
		||||
let convert old = {
 | 
			
		||||
  id           = old.id
 | 
			
		||||
  enteredOn    = old.enteredOn
 | 
			
		||||
  userId       = old.userId
 | 
			
		||||
  snoozedUntil = old.snoozedUntil
 | 
			
		||||
  showAfter    = old.showAfter
 | 
			
		||||
  recurrence   = mapRecurrence old
 | 
			
		||||
  history      = Array.toList old.history
 | 
			
		||||
  notes        = Array.toList old.notes
 | 
			
		||||
  }
 | 
			
		||||
let convert old =
 | 
			
		||||
    {   id           = old.id
 | 
			
		||||
        enteredOn    = old.enteredOn
 | 
			
		||||
        userId       = old.userId
 | 
			
		||||
        snoozedUntil = old.snoozedUntil
 | 
			
		||||
        showAfter    = old.showAfter
 | 
			
		||||
        recurrence   = mapRecurrence old
 | 
			
		||||
        history      = Array.toList old.history
 | 
			
		||||
        notes        = Array.toList old.notes
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
/// Remove the old request, add the converted one (removes recurType / recurCount fields)
 | 
			
		||||
let replace (req : Request) =
 | 
			
		||||
  db.requests.Delete(Mapping.RequestId.toBson req.id) |> ignore
 | 
			
		||||
  db.requests.Insert(req) |> ignore
 | 
			
		||||
  db.Checkpoint()
 | 
			
		||||
    db.requests.Delete(Mapping.RequestId.toBson req.id) |> ignore
 | 
			
		||||
    db.requests.Insert(req) |> ignore
 | 
			
		||||
    db.Checkpoint()
 | 
			
		||||
 | 
			
		||||
let reqs = db.GetCollection<OldRequest>("request").FindAll()
 | 
			
		||||
let rList = reqs |> Seq.toList
 | 
			
		||||
let mapped = rList |> List.map convert
 | 
			
		||||
//let reqList = mapped |> List.ofSeq
 | 
			
		||||
 | 
			
		||||
mapped |> List.iter replace
 | 
			
		||||
db.GetCollection<OldRequest>("request").FindAll()
 | 
			
		||||
|> Seq.map convert
 | 
			
		||||
|> Seq.iter replace
 | 
			
		||||
 | 
			
		||||
// For more information see https://aka.ms/fsharp-console-apps
 | 
			
		||||
printfn "Done"
 | 
			
		||||
 | 
			
		||||
@ -11,15 +11,17 @@ open System.Threading.Tasks
 | 
			
		||||
[<AutoOpen>]
 | 
			
		||||
module Extensions =
 | 
			
		||||
  
 | 
			
		||||
  /// Extensions on the LiteDatabase class
 | 
			
		||||
  type LiteDatabase with
 | 
			
		||||
    /// The Request collection
 | 
			
		||||
    member this.requests
 | 
			
		||||
      with get () = this.GetCollection<Request> "request"
 | 
			
		||||
    /// Async version of the checkpoint command (flushes log)
 | 
			
		||||
    member this.saveChanges () =
 | 
			
		||||
      this.Checkpoint ()
 | 
			
		||||
      Task.CompletedTask
 | 
			
		||||
    /// Extensions on the LiteDatabase class
 | 
			
		||||
    type LiteDatabase with
 | 
			
		||||
        
 | 
			
		||||
        /// The Request collection
 | 
			
		||||
        member this.requests
 | 
			
		||||
          with get () = this.GetCollection<Request> "request"
 | 
			
		||||
        
 | 
			
		||||
        /// Async version of the checkpoint command (flushes log)
 | 
			
		||||
        member this.saveChanges () =
 | 
			
		||||
            this.Checkpoint ()
 | 
			
		||||
            Task.CompletedTask
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Map domain to LiteDB
 | 
			
		||||
@ -27,162 +29,162 @@ module Extensions =
 | 
			
		||||
[<RequireQualifiedAccess>]
 | 
			
		||||
module Mapping =
 | 
			
		||||
  
 | 
			
		||||
  /// Mapping for NodaTime's Instant type
 | 
			
		||||
  module Instant =
 | 
			
		||||
    let fromBson (value : BsonValue) = Instant.FromUnixTimeMilliseconds value.AsInt64
 | 
			
		||||
    let toBson (value : Instant) : BsonValue = value.ToUnixTimeMilliseconds ()
 | 
			
		||||
  
 | 
			
		||||
  /// Mapping for option types
 | 
			
		||||
  module Option =
 | 
			
		||||
    let stringFromBson (value : BsonValue) = match value.AsString with "" -> None | x -> Some x
 | 
			
		||||
    let stringToBson (value : string option) : BsonValue = match value with Some txt -> txt | None -> ""
 | 
			
		||||
  
 | 
			
		||||
  /// Mapping for Recurrence
 | 
			
		||||
  module Recurrence =
 | 
			
		||||
    let fromBson (value : BsonValue) = Recurrence.ofString value
 | 
			
		||||
    let toBson (value : Recurrence) : BsonValue = Recurrence.toString value
 | 
			
		||||
  
 | 
			
		||||
  /// Mapping for RequestAction
 | 
			
		||||
  module RequestAction =
 | 
			
		||||
    let fromBson (value : BsonValue) = RequestAction.ofString value.AsString
 | 
			
		||||
    let toBson (value : RequestAction) : BsonValue = RequestAction.toString value
 | 
			
		||||
  
 | 
			
		||||
  /// Mapping for RequestId
 | 
			
		||||
  module RequestId =
 | 
			
		||||
    let fromBson (value : BsonValue) = RequestId.ofString value.AsString
 | 
			
		||||
    let toBson (value : RequestId) : BsonValue = RequestId.toString value
 | 
			
		||||
  
 | 
			
		||||
  /// Mapping for UserId
 | 
			
		||||
  module UserId =
 | 
			
		||||
    let fromBson (value : BsonValue) = UserId value.AsString
 | 
			
		||||
    let toBson (value : UserId) : BsonValue = UserId.toString value
 | 
			
		||||
    /// Mapping for NodaTime's Instant type
 | 
			
		||||
    module Instant =
 | 
			
		||||
        let fromBson (value : BsonValue) = Instant.FromUnixTimeMilliseconds value.AsInt64
 | 
			
		||||
        let toBson (value : Instant) : BsonValue = value.ToUnixTimeMilliseconds ()
 | 
			
		||||
    
 | 
			
		||||
  /// Set up the mapping
 | 
			
		||||
  let register () = 
 | 
			
		||||
    BsonMapper.Global.RegisterType<Instant>(Instant.toBson, Instant.fromBson)
 | 
			
		||||
    BsonMapper.Global.RegisterType<Recurrence>(Recurrence.toBson, Recurrence.fromBson)
 | 
			
		||||
    BsonMapper.Global.RegisterType<RequestAction>(RequestAction.toBson, RequestAction.fromBson)
 | 
			
		||||
    BsonMapper.Global.RegisterType<RequestId>(RequestId.toBson, RequestId.fromBson)
 | 
			
		||||
    BsonMapper.Global.RegisterType<string option>(Option.stringToBson, Option.stringFromBson)
 | 
			
		||||
    BsonMapper.Global.RegisterType<UserId>(UserId.toBson, UserId.fromBson)
 | 
			
		||||
    /// Mapping for option types
 | 
			
		||||
    module Option =
 | 
			
		||||
        let stringFromBson (value : BsonValue) = match value.AsString with "" -> None | x -> Some x
 | 
			
		||||
        let stringToBson (value : string option) : BsonValue = match value with Some txt -> txt | None -> ""
 | 
			
		||||
    
 | 
			
		||||
    /// Mapping for Recurrence
 | 
			
		||||
    module Recurrence =
 | 
			
		||||
        let fromBson (value : BsonValue) = Recurrence.ofString value
 | 
			
		||||
        let toBson (value : Recurrence) : BsonValue = Recurrence.toString value
 | 
			
		||||
    
 | 
			
		||||
    /// Mapping for RequestAction
 | 
			
		||||
    module RequestAction =
 | 
			
		||||
        let fromBson (value : BsonValue) = RequestAction.ofString value.AsString
 | 
			
		||||
        let toBson (value : RequestAction) : BsonValue = RequestAction.toString value
 | 
			
		||||
    
 | 
			
		||||
    /// Mapping for RequestId
 | 
			
		||||
    module RequestId =
 | 
			
		||||
        let fromBson (value : BsonValue) = RequestId.ofString value.AsString
 | 
			
		||||
        let toBson (value : RequestId) : BsonValue = RequestId.toString value
 | 
			
		||||
    
 | 
			
		||||
    /// Mapping for UserId
 | 
			
		||||
    module UserId =
 | 
			
		||||
        let fromBson (value : BsonValue) = UserId value.AsString
 | 
			
		||||
        let toBson (value : UserId) : BsonValue = UserId.toString value
 | 
			
		||||
    
 | 
			
		||||
    /// Set up the mapping
 | 
			
		||||
    let register () = 
 | 
			
		||||
        BsonMapper.Global.RegisterType<Instant>(Instant.toBson, Instant.fromBson)
 | 
			
		||||
        BsonMapper.Global.RegisterType<Recurrence>(Recurrence.toBson, Recurrence.fromBson)
 | 
			
		||||
        BsonMapper.Global.RegisterType<RequestAction>(RequestAction.toBson, RequestAction.fromBson)
 | 
			
		||||
        BsonMapper.Global.RegisterType<RequestId>(RequestId.toBson, RequestId.fromBson)
 | 
			
		||||
        BsonMapper.Global.RegisterType<string option>(Option.stringToBson, Option.stringFromBson)
 | 
			
		||||
        BsonMapper.Global.RegisterType<UserId>(UserId.toBson, UserId.fromBson)
 | 
			
		||||
 | 
			
		||||
/// Code to be run at startup
 | 
			
		||||
module Startup =
 | 
			
		||||
  
 | 
			
		||||
  /// Ensure the database is set up
 | 
			
		||||
  let ensureDb (db : LiteDatabase) =
 | 
			
		||||
    db.requests.EnsureIndex (fun it -> it.userId) |> ignore
 | 
			
		||||
    Mapping.register ()
 | 
			
		||||
    /// Ensure the database is set up
 | 
			
		||||
    let ensureDb (db : LiteDatabase) =
 | 
			
		||||
        db.requests.EnsureIndex (fun it -> it.userId) |> ignore
 | 
			
		||||
        Mapping.register ()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Async wrappers for LiteDB, and request -> journal mappings
 | 
			
		||||
[<AutoOpen>]
 | 
			
		||||
module private Helpers =
 | 
			
		||||
    
 | 
			
		||||
    open System.Linq
 | 
			
		||||
 | 
			
		||||
  open System.Linq
 | 
			
		||||
    /// Convert a sequence to a list asynchronously (used for LiteDB IO)
 | 
			
		||||
    let toListAsync<'T> (q : 'T seq) =
 | 
			
		||||
        (q.ToList >> Task.FromResult) ()
 | 
			
		||||
 | 
			
		||||
  /// Convert a sequence to a list asynchronously (used for LiteDB IO)
 | 
			
		||||
  let toListAsync<'T> (q : 'T seq) =
 | 
			
		||||
    (q.ToList >> Task.FromResult) ()
 | 
			
		||||
    /// Convert a sequence to a list asynchronously (used for LiteDB IO)
 | 
			
		||||
    let firstAsync<'T> (q : 'T seq) =
 | 
			
		||||
        q.FirstOrDefault () |> Task.FromResult
 | 
			
		||||
 | 
			
		||||
  /// Convert a sequence to a list asynchronously (used for LiteDB IO)
 | 
			
		||||
  let firstAsync<'T> (q : 'T seq) =
 | 
			
		||||
    q.FirstOrDefault () |> Task.FromResult
 | 
			
		||||
 | 
			
		||||
  /// Async wrapper around a request update
 | 
			
		||||
  let doUpdate (db : LiteDatabase) (req : Request) =
 | 
			
		||||
    db.requests.Update req |> ignore
 | 
			
		||||
    Task.CompletedTask
 | 
			
		||||
    /// Async wrapper around a request update
 | 
			
		||||
    let doUpdate (db : LiteDatabase) (req : Request) =
 | 
			
		||||
        db.requests.Update req |> ignore
 | 
			
		||||
        Task.CompletedTask
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Retrieve a request, including its history and notes, by its ID and user ID
 | 
			
		||||
let tryFullRequestById reqId userId (db : LiteDatabase) = backgroundTask {
 | 
			
		||||
  let! req = db.requests.Find (Query.EQ ("_id", RequestId.toString reqId)) |> firstAsync
 | 
			
		||||
  return match box req with null -> None | _ when req.userId = userId -> Some req | _ -> None
 | 
			
		||||
  }
 | 
			
		||||
    let! req = db.requests.Find (Query.EQ ("_id", RequestId.toString reqId)) |> firstAsync
 | 
			
		||||
    return match box req with null -> None | _ when req.userId = userId -> Some req | _ -> None
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Add a history entry
 | 
			
		||||
let addHistory reqId userId hist db = backgroundTask {
 | 
			
		||||
  match! tryFullRequestById reqId userId db with
 | 
			
		||||
  | Some req -> do! doUpdate db { req with history = hist :: req.history }
 | 
			
		||||
  | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
  }
 | 
			
		||||
    match! tryFullRequestById reqId userId db with
 | 
			
		||||
    | Some req -> do! doUpdate db { req with history = hist :: req.history }
 | 
			
		||||
    | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Add a note
 | 
			
		||||
let addNote reqId userId note db = backgroundTask {
 | 
			
		||||
  match! tryFullRequestById reqId userId db with
 | 
			
		||||
  | Some req -> do! doUpdate db { req with notes = note :: req.notes }
 | 
			
		||||
  | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
  }
 | 
			
		||||
    match! tryFullRequestById reqId userId db with
 | 
			
		||||
    | Some req -> do! doUpdate db { req with notes = note :: req.notes }
 | 
			
		||||
    | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Add a request
 | 
			
		||||
let addRequest (req : Request) (db : LiteDatabase) =
 | 
			
		||||
  db.requests.Insert req |> ignore
 | 
			
		||||
    db.requests.Insert req |> ignore
 | 
			
		||||
 | 
			
		||||
// FIXME: make a common function here
 | 
			
		||||
 | 
			
		||||
/// Retrieve all answered requests for the given user
 | 
			
		||||
let answeredRequests userId (db : LiteDatabase) = backgroundTask {
 | 
			
		||||
  let! reqs = db.requests.Find (Query.EQ ("userId", UserId.toString userId)) |> toListAsync
 | 
			
		||||
  return
 | 
			
		||||
    reqs
 | 
			
		||||
    |> Seq.map JournalRequest.ofRequestFull
 | 
			
		||||
    |> Seq.filter (fun it -> it.lastStatus = Answered)
 | 
			
		||||
    |> Seq.sortByDescending (fun it -> it.asOf)
 | 
			
		||||
    |> List.ofSeq
 | 
			
		||||
  }
 | 
			
		||||
    let! reqs = db.requests.Find (Query.EQ ("userId", UserId.toString userId)) |> toListAsync
 | 
			
		||||
    return
 | 
			
		||||
        reqs
 | 
			
		||||
        |> Seq.map JournalRequest.ofRequestFull
 | 
			
		||||
        |> Seq.filter (fun it -> it.lastStatus = Answered)
 | 
			
		||||
        |> Seq.sortByDescending (fun it -> it.asOf)
 | 
			
		||||
        |> List.ofSeq
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Retrieve the user's current journal
 | 
			
		||||
let journalByUserId userId (db : LiteDatabase) = backgroundTask {
 | 
			
		||||
  let! jrnl = db.requests.Find (Query.EQ ("userId", UserId.toString userId)) |> toListAsync
 | 
			
		||||
  return
 | 
			
		||||
    jrnl
 | 
			
		||||
    |> Seq.map JournalRequest.ofRequestLite
 | 
			
		||||
    |> Seq.filter (fun it -> it.lastStatus <> Answered)
 | 
			
		||||
    |> Seq.sortBy (fun it -> it.asOf)
 | 
			
		||||
    |> List.ofSeq
 | 
			
		||||
  }
 | 
			
		||||
    let! jrnl = db.requests.Find (Query.EQ ("userId", UserId.toString userId)) |> toListAsync
 | 
			
		||||
    return
 | 
			
		||||
        jrnl
 | 
			
		||||
        |> Seq.map JournalRequest.ofRequestLite
 | 
			
		||||
        |> Seq.filter (fun it -> it.lastStatus <> Answered)
 | 
			
		||||
        |> Seq.sortBy (fun it -> it.asOf)
 | 
			
		||||
        |> List.ofSeq
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Does the user have any snoozed requests?
 | 
			
		||||
let hasSnoozed userId now (db : LiteDatabase) = backgroundTask {
 | 
			
		||||
  let! jrnl = journalByUserId userId db
 | 
			
		||||
  return jrnl |> List.exists (fun r -> r.snoozedUntil > now)
 | 
			
		||||
  }
 | 
			
		||||
    let! jrnl = journalByUserId userId db
 | 
			
		||||
    return jrnl |> List.exists (fun r -> r.snoozedUntil > now)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Retrieve a request by its ID and user ID (without notes and history)
 | 
			
		||||
let tryRequestById reqId userId db = backgroundTask {
 | 
			
		||||
  let! req = tryFullRequestById reqId userId db
 | 
			
		||||
  return req |> Option.map (fun r -> { r with history = []; notes = [] })
 | 
			
		||||
  }
 | 
			
		||||
    let! req = tryFullRequestById reqId userId db
 | 
			
		||||
    return req |> Option.map (fun r -> { r with history = []; notes = [] })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Retrieve notes for a request by its ID and user ID
 | 
			
		||||
let notesById reqId userId (db : LiteDatabase) = backgroundTask {
 | 
			
		||||
  match! tryFullRequestById reqId userId db with | Some req -> return req.notes | None -> return []
 | 
			
		||||
  }
 | 
			
		||||
    match! tryFullRequestById reqId userId db with | Some req -> return req.notes | None -> return []
 | 
			
		||||
}
 | 
			
		||||
    
 | 
			
		||||
/// Retrieve a journal request by its ID and user ID
 | 
			
		||||
let tryJournalById reqId userId (db : LiteDatabase) = backgroundTask {
 | 
			
		||||
  let! req = tryFullRequestById reqId userId db
 | 
			
		||||
  return req |> Option.map JournalRequest.ofRequestLite
 | 
			
		||||
  }
 | 
			
		||||
    let! req = tryFullRequestById reqId userId db
 | 
			
		||||
    return req |> Option.map JournalRequest.ofRequestLite
 | 
			
		||||
}
 | 
			
		||||
    
 | 
			
		||||
/// Update the recurrence for a request
 | 
			
		||||
let updateRecurrence reqId userId recurType db = backgroundTask {
 | 
			
		||||
  match! tryFullRequestById reqId userId db with
 | 
			
		||||
  | Some req -> do! doUpdate db { req with recurrence = recurType }
 | 
			
		||||
  | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
  }
 | 
			
		||||
    match! tryFullRequestById reqId userId db with
 | 
			
		||||
    | Some req -> do! doUpdate db { req with recurrence = recurType }
 | 
			
		||||
    | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Update a snoozed request
 | 
			
		||||
let updateSnoozed reqId userId until db = backgroundTask {
 | 
			
		||||
  match! tryFullRequestById reqId userId db with
 | 
			
		||||
  | Some req -> do! doUpdate db { req with snoozedUntil = until; showAfter = until }
 | 
			
		||||
  | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
  }
 | 
			
		||||
    match! tryFullRequestById reqId userId db with
 | 
			
		||||
    | Some req -> do! doUpdate db { req with snoozedUntil = until; showAfter = until }
 | 
			
		||||
    | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Update the "show after" timestamp for a request
 | 
			
		||||
let updateShowAfter reqId userId showAfter db = backgroundTask {
 | 
			
		||||
  match! tryFullRequestById reqId userId db with
 | 
			
		||||
  | Some req -> do! doUpdate db { req with showAfter = showAfter }
 | 
			
		||||
  | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
  }
 | 
			
		||||
    match! tryFullRequestById reqId userId db with
 | 
			
		||||
    | Some req -> do! doUpdate db { req with showAfter = showAfter }
 | 
			
		||||
    | None     -> invalidOp $"{RequestId.toString reqId} not found"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -5,39 +5,39 @@ module MyPrayerJournal.Dates
 | 
			
		||||
open NodaTime
 | 
			
		||||
 | 
			
		||||
type internal FormatDistanceToken =
 | 
			
		||||
  | LessThanXMinutes
 | 
			
		||||
  | XMinutes
 | 
			
		||||
  | AboutXHours
 | 
			
		||||
  | XHours
 | 
			
		||||
  | XDays
 | 
			
		||||
  | AboutXWeeks
 | 
			
		||||
  | XWeeks
 | 
			
		||||
  | AboutXMonths
 | 
			
		||||
  | XMonths
 | 
			
		||||
  | AboutXYears
 | 
			
		||||
  | XYears
 | 
			
		||||
  | OverXYears
 | 
			
		||||
  | AlmostXYears
 | 
			
		||||
    | LessThanXMinutes
 | 
			
		||||
    | XMinutes
 | 
			
		||||
    | AboutXHours
 | 
			
		||||
    | XHours
 | 
			
		||||
    | XDays
 | 
			
		||||
    | AboutXWeeks
 | 
			
		||||
    | XWeeks
 | 
			
		||||
    | AboutXMonths
 | 
			
		||||
    | XMonths
 | 
			
		||||
    | AboutXYears
 | 
			
		||||
    | XYears
 | 
			
		||||
    | OverXYears
 | 
			
		||||
    | AlmostXYears
 | 
			
		||||
 | 
			
		||||
let internal locales =
 | 
			
		||||
  let format = PrintfFormat<int -> string, unit, string, string>
 | 
			
		||||
  Map.ofList [
 | 
			
		||||
    "en-US", Map.ofList [
 | 
			
		||||
      LessThanXMinutes, ("less than a minute", format "less than %i minutes")
 | 
			
		||||
      XMinutes,         ("a minute",           format "%i minutes")
 | 
			
		||||
      AboutXHours,      ("about an hour",      format "about %i hours")
 | 
			
		||||
      XHours,           ("an hour",            format "%i hours")
 | 
			
		||||
      XDays,            ("a day",              format "%i days")
 | 
			
		||||
      AboutXWeeks,      ("about a week",       format "about %i weeks")
 | 
			
		||||
      XWeeks,           ("a week",             format "%i weeks")
 | 
			
		||||
      AboutXMonths,     ("about a month",      format "about %i months")
 | 
			
		||||
      XMonths,          ("a month",            format "%i months")
 | 
			
		||||
      AboutXYears,      ("about a year",       format "about %i years")
 | 
			
		||||
      XYears,           ("a year",             format "%i years")
 | 
			
		||||
      OverXYears,       ("over a year",        format "over %i years")
 | 
			
		||||
      AlmostXYears,     ("almost a year",      format "almost %i years")
 | 
			
		||||
    let format = PrintfFormat<int -> string, unit, string, string>
 | 
			
		||||
    Map.ofList [
 | 
			
		||||
        "en-US", Map.ofList [
 | 
			
		||||
            LessThanXMinutes, ("less than a minute", format "less than %i minutes")
 | 
			
		||||
            XMinutes,         ("a minute",           format "%i minutes")
 | 
			
		||||
            AboutXHours,      ("about an hour",      format "about %i hours")
 | 
			
		||||
            XHours,           ("an hour",            format "%i hours")
 | 
			
		||||
            XDays,            ("a day",              format "%i days")
 | 
			
		||||
            AboutXWeeks,      ("about a week",       format "about %i weeks")
 | 
			
		||||
            XWeeks,           ("a week",             format "%i weeks")
 | 
			
		||||
            AboutXMonths,     ("about a month",      format "about %i months")
 | 
			
		||||
            XMonths,          ("a month",            format "%i months")
 | 
			
		||||
            AboutXYears,      ("about a year",       format "about %i years")
 | 
			
		||||
            XYears,           ("a year",             format "%i years")
 | 
			
		||||
            OverXYears,       ("over a year",        format "over %i years")
 | 
			
		||||
            AlmostXYears,     ("almost a year",      format "almost %i years")
 | 
			
		||||
        ]
 | 
			
		||||
    ]
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
let aDay        =  1_440.
 | 
			
		||||
let almost2Days =  2_520.
 | 
			
		||||
@ -50,29 +50,29 @@ open System
 | 
			
		||||
let fromJs ticks = DateTime.UnixEpoch + TimeSpan.FromTicks (ticks * 10_000L)
 | 
			
		||||
 | 
			
		||||
let formatDistance (startDate : Instant) (endDate : 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 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 minutes     = Math.Abs diff.TotalMinutes
 | 
			
		||||
  let formatToken =
 | 
			
		||||
    let months = minutes / aMonth |> round
 | 
			
		||||
    let years  = months / 12
 | 
			
		||||
    match true with
 | 
			
		||||
    | _ when minutes < 1.          -> LessThanXMinutes, 1
 | 
			
		||||
    | _ when minutes < 45.         -> XMinutes, round minutes
 | 
			
		||||
    | _ when minutes < 90.         -> AboutXHours, 1
 | 
			
		||||
    | _ when minutes < aDay        -> AboutXHours, round (minutes / 60.)
 | 
			
		||||
    | _ when minutes < almost2Days -> XDays, 1
 | 
			
		||||
    | _ when minutes < aMonth      -> XDays, round (minutes / aDay)
 | 
			
		||||
    | _ when minutes < twoMonths   -> AboutXMonths, round (minutes / aMonth)
 | 
			
		||||
    | _ when months      < 12      -> XMonths, round (minutes / aMonth)
 | 
			
		||||
    | _ when months % 12 < 3       -> AboutXYears, years
 | 
			
		||||
    | _ when months % 12 < 9       -> OverXYears, years
 | 
			
		||||
    | _                            -> AlmostXYears, years + 1
 | 
			
		||||
    let diff        = startDate - endDate
 | 
			
		||||
    let minutes     = Math.Abs diff.TotalMinutes
 | 
			
		||||
    let formatToken =
 | 
			
		||||
        let months = minutes / aMonth |> round
 | 
			
		||||
        let years  = months / 12
 | 
			
		||||
        match true with
 | 
			
		||||
        | _ when minutes < 1.          -> LessThanXMinutes, 1
 | 
			
		||||
        | _ when minutes < 45.         -> XMinutes, round minutes
 | 
			
		||||
        | _ when minutes < 90.         -> AboutXHours, 1
 | 
			
		||||
        | _ when minutes < aDay        -> AboutXHours, round (minutes / 60.)
 | 
			
		||||
        | _ when minutes < almost2Days -> XDays, 1
 | 
			
		||||
        | _ when minutes < aMonth      -> XDays, round (minutes / aDay)
 | 
			
		||||
        | _ when minutes < twoMonths   -> AboutXMonths, round (minutes / aMonth)
 | 
			
		||||
        | _ when months      < 12      -> XMonths, round (minutes / aMonth)
 | 
			
		||||
        | _ when months % 12 < 3       -> AboutXYears, years
 | 
			
		||||
        | _ when months % 12 < 9       -> OverXYears, years
 | 
			
		||||
        | _                            -> AlmostXYears, years + 1
 | 
			
		||||
  
 | 
			
		||||
  format formatToken "en-US"
 | 
			
		||||
  |> match startDate > endDate with true -> sprintf "%s ago" | false -> sprintf "in %s"
 | 
			
		||||
    format formatToken "en-US"
 | 
			
		||||
    |> match startDate > endDate with true -> sprintf "%s ago" | false -> sprintf "in %s"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -9,205 +9,244 @@ open Cuid
 | 
			
		||||
open NodaTime
 | 
			
		||||
 | 
			
		||||
/// An identifier for a request
 | 
			
		||||
type RequestId =
 | 
			
		||||
  | RequestId of Cuid
 | 
			
		||||
type RequestId = RequestId of Cuid
 | 
			
		||||
 | 
			
		||||
/// Functions to manipulate request IDs
 | 
			
		||||
module RequestId =
 | 
			
		||||
  /// The string representation of the request ID
 | 
			
		||||
  let toString = function RequestId x -> Cuid.toString x
 | 
			
		||||
  /// Create a request ID from a string representation
 | 
			
		||||
  let ofString = Cuid >> RequestId
 | 
			
		||||
    
 | 
			
		||||
    /// The string representation of the request ID
 | 
			
		||||
    let toString = function RequestId x -> Cuid.toString x
 | 
			
		||||
    
 | 
			
		||||
    /// Create a request ID from a string representation
 | 
			
		||||
    let ofString = Cuid >> RequestId
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// The identifier of a user (the "sub" part of the JWT)
 | 
			
		||||
type UserId =
 | 
			
		||||
  | UserId of string
 | 
			
		||||
type UserId = UserId of string
 | 
			
		||||
 | 
			
		||||
/// Functions to manipulate user IDs
 | 
			
		||||
module UserId =
 | 
			
		||||
  /// The string representation of the user ID
 | 
			
		||||
  let toString = function UserId x -> x
 | 
			
		||||
    
 | 
			
		||||
    /// The string representation of the user ID
 | 
			
		||||
    let toString = function UserId x -> x
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// How frequently a request should reappear after it is marked "Prayed"
 | 
			
		||||
type Recurrence =
 | 
			
		||||
  | Immediate
 | 
			
		||||
  | Hours of int16
 | 
			
		||||
  | Days  of int16
 | 
			
		||||
  | Weeks of int16
 | 
			
		||||
    /// A request should reappear immediately at the bottom of the list
 | 
			
		||||
    | Immediate
 | 
			
		||||
    /// A request should reappear in the given number of hours
 | 
			
		||||
    | Hours of int16
 | 
			
		||||
    /// A request should reappear in the given number of days
 | 
			
		||||
    | Days  of int16
 | 
			
		||||
    /// A request should reappear in the given number of weeks (7-day increments)
 | 
			
		||||
    | Weeks of int16
 | 
			
		||||
 | 
			
		||||
/// Functions to manipulate recurrences
 | 
			
		||||
module Recurrence =
 | 
			
		||||
  /// Create a string representation of a recurrence
 | 
			
		||||
  let toString =
 | 
			
		||||
    function
 | 
			
		||||
    | Immediate -> "Immediate"
 | 
			
		||||
    | Hours   h -> $"{h} Hours"
 | 
			
		||||
    | Days    d -> $"{d} Days"
 | 
			
		||||
    | Weeks   w -> $"{w} Weeks"
 | 
			
		||||
  /// Create a recurrence value from a string
 | 
			
		||||
  let ofString =
 | 
			
		||||
    function
 | 
			
		||||
    | "Immediate" -> Immediate
 | 
			
		||||
    | it when it.Contains " " ->
 | 
			
		||||
        let parts = it.Split " "
 | 
			
		||||
        let length = Convert.ToInt16 parts[0]
 | 
			
		||||
        match parts[1] with
 | 
			
		||||
        | "Hours" -> Hours length
 | 
			
		||||
        | "Days"  -> Days  length
 | 
			
		||||
        | "Weeks" -> Weeks length
 | 
			
		||||
        | _       -> invalidOp $"{parts[1]} is not a valid recurrence"
 | 
			
		||||
    | it -> invalidOp $"{it} is not a valid recurrence"
 | 
			
		||||
  /// An hour's worth of seconds
 | 
			
		||||
  let private oneHour = 3_600L
 | 
			
		||||
  /// The duration of the recurrence (in milliseconds)
 | 
			
		||||
  let duration =
 | 
			
		||||
    function
 | 
			
		||||
    | Immediate -> 0L
 | 
			
		||||
    | Hours   h -> int64 h * oneHour
 | 
			
		||||
    | Days    d -> int64 d * oneHour * 24L
 | 
			
		||||
    | Weeks   w -> int64 w * oneHour * 24L * 7L
 | 
			
		||||
    
 | 
			
		||||
    /// Create a string representation of a recurrence
 | 
			
		||||
    let toString =
 | 
			
		||||
        function
 | 
			
		||||
        | Immediate -> "Immediate"
 | 
			
		||||
        | Hours   h -> $"{h} Hours"
 | 
			
		||||
        | Days    d -> $"{d} Days"
 | 
			
		||||
        | Weeks   w -> $"{w} Weeks"
 | 
			
		||||
    
 | 
			
		||||
    /// Create a recurrence value from a string
 | 
			
		||||
    let ofString =
 | 
			
		||||
        function
 | 
			
		||||
        | "Immediate" -> Immediate
 | 
			
		||||
        | it when it.Contains " " ->
 | 
			
		||||
            let parts = it.Split " "
 | 
			
		||||
            let length = Convert.ToInt16 parts[0]
 | 
			
		||||
            match parts[1] with
 | 
			
		||||
            | "Hours" -> Hours length
 | 
			
		||||
            | "Days"  -> Days  length
 | 
			
		||||
            | "Weeks" -> Weeks length
 | 
			
		||||
            | _       -> invalidOp $"{parts[1]} is not a valid recurrence"
 | 
			
		||||
        | it -> invalidOp $"{it} is not a valid recurrence"
 | 
			
		||||
    
 | 
			
		||||
    /// An hour's worth of seconds
 | 
			
		||||
    let private oneHour = 3_600L
 | 
			
		||||
    
 | 
			
		||||
    /// The duration of the recurrence (in milliseconds)
 | 
			
		||||
    let duration =
 | 
			
		||||
        function
 | 
			
		||||
        | Immediate -> 0L
 | 
			
		||||
        | Hours   h -> int64 h * oneHour
 | 
			
		||||
        | Days    d -> int64 d * oneHour * 24L
 | 
			
		||||
        | Weeks   w -> int64 w * oneHour * 24L * 7L
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// The action taken on a request as part of a history entry
 | 
			
		||||
type RequestAction =
 | 
			
		||||
  | Created
 | 
			
		||||
  | Prayed
 | 
			
		||||
  | Updated
 | 
			
		||||
  | Answered
 | 
			
		||||
    | Created
 | 
			
		||||
    | Prayed
 | 
			
		||||
    | Updated
 | 
			
		||||
    | Answered
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// History is a record of action taken on a prayer request, including updates to its text
 | 
			
		||||
[<CLIMutable; NoComparison; NoEquality>]
 | 
			
		||||
type History = {
 | 
			
		||||
  /// The time when this history entry was made
 | 
			
		||||
  asOf   : Instant
 | 
			
		||||
  /// The status for this history entry
 | 
			
		||||
  status : RequestAction
 | 
			
		||||
  /// The text of the update, if applicable
 | 
			
		||||
  text   : string option
 | 
			
		||||
  }
 | 
			
		||||
type History =
 | 
			
		||||
    {   /// The time when this history entry was made
 | 
			
		||||
        asOf   : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The status for this history entry
 | 
			
		||||
        status : RequestAction
 | 
			
		||||
        
 | 
			
		||||
        /// The text of the update, if applicable
 | 
			
		||||
        text   : string option
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Note is a note regarding a prayer request that does not result in an update to its text
 | 
			
		||||
[<CLIMutable; NoComparison; NoEquality>]
 | 
			
		||||
type Note = {
 | 
			
		||||
  /// The time when this note was made
 | 
			
		||||
  asOf  : Instant
 | 
			
		||||
  /// The text of the notes
 | 
			
		||||
  notes : string
 | 
			
		||||
  }
 | 
			
		||||
type Note =
 | 
			
		||||
    {   /// The time when this note was made
 | 
			
		||||
        asOf  : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The text of the notes
 | 
			
		||||
        notes : string
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Request is the identifying record for a prayer request
 | 
			
		||||
[<CLIMutable; NoComparison; NoEquality>]
 | 
			
		||||
type Request = {
 | 
			
		||||
  /// The ID of the request
 | 
			
		||||
  id           : RequestId
 | 
			
		||||
  /// The time this request was initially entered
 | 
			
		||||
  enteredOn    : Instant
 | 
			
		||||
  /// The ID of the user to whom this request belongs ("sub" from the JWT)
 | 
			
		||||
  userId       : UserId
 | 
			
		||||
  /// The time at which this request should reappear in the user's journal by manual user choice
 | 
			
		||||
  snoozedUntil : Instant
 | 
			
		||||
  /// The time at which this request should reappear in the user's journal by recurrence
 | 
			
		||||
  showAfter    : Instant
 | 
			
		||||
  /// The recurrence for this request
 | 
			
		||||
  recurrence   : Recurrence
 | 
			
		||||
  /// The history entries for this request
 | 
			
		||||
  history      : History list
 | 
			
		||||
  /// The notes for this request
 | 
			
		||||
  notes        : Note list
 | 
			
		||||
  }
 | 
			
		||||
with
 | 
			
		||||
  /// An empty request
 | 
			
		||||
  static member empty =
 | 
			
		||||
    { id           = Cuid.generate () |> RequestId
 | 
			
		||||
      enteredOn    = Instant.MinValue
 | 
			
		||||
      userId       = UserId ""
 | 
			
		||||
      snoozedUntil = Instant.MinValue
 | 
			
		||||
      showAfter    = Instant.MinValue
 | 
			
		||||
      recurrence   = Immediate
 | 
			
		||||
      history      = []
 | 
			
		||||
      notes        = []
 | 
			
		||||
      }
 | 
			
		||||
type Request =
 | 
			
		||||
    {   /// The ID of the request
 | 
			
		||||
        id           : RequestId
 | 
			
		||||
        
 | 
			
		||||
        /// The time this request was initially entered
 | 
			
		||||
        enteredOn    : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The ID of the user to whom this request belongs ("sub" from the JWT)
 | 
			
		||||
        userId       : UserId
 | 
			
		||||
        
 | 
			
		||||
        /// The time at which this request should reappear in the user's journal by manual user choice
 | 
			
		||||
        snoozedUntil : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The time at which this request should reappear in the user's journal by recurrence
 | 
			
		||||
        showAfter    : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The recurrence for this request
 | 
			
		||||
        recurrence   : Recurrence
 | 
			
		||||
        
 | 
			
		||||
        /// The history entries for this request
 | 
			
		||||
        history      : History list
 | 
			
		||||
        
 | 
			
		||||
        /// The notes for this request
 | 
			
		||||
        notes        : Note list
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
/// Functions to support requests
 | 
			
		||||
module Request =
 | 
			
		||||
    
 | 
			
		||||
    /// An empty request
 | 
			
		||||
    let empty =
 | 
			
		||||
        {   id           = Cuid.generate () |> RequestId
 | 
			
		||||
            enteredOn    = Instant.MinValue
 | 
			
		||||
            userId       = UserId ""
 | 
			
		||||
            snoozedUntil = Instant.MinValue
 | 
			
		||||
            showAfter    = Instant.MinValue
 | 
			
		||||
            recurrence   = Immediate
 | 
			
		||||
            history      = []
 | 
			
		||||
            notes        = []
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains
 | 
			
		||||
/// properties that may be filled for history and notes.
 | 
			
		||||
[<NoComparison; NoEquality>]
 | 
			
		||||
type JournalRequest = {
 | 
			
		||||
  /// The ID of the request (just the CUID part)
 | 
			
		||||
  requestId    : RequestId
 | 
			
		||||
  /// The ID of the user to whom the request belongs
 | 
			
		||||
  userId       : UserId
 | 
			
		||||
  /// The current text of the request
 | 
			
		||||
  text         : string
 | 
			
		||||
  /// The last time action was taken on the request
 | 
			
		||||
  asOf         : Instant
 | 
			
		||||
  /// The last status for the request
 | 
			
		||||
  lastStatus   : RequestAction
 | 
			
		||||
  /// The time that this request should reappear in the user's journal
 | 
			
		||||
  snoozedUntil : Instant
 | 
			
		||||
  /// The time after which this request should reappear in the user's journal by configured recurrence
 | 
			
		||||
  showAfter    : Instant
 | 
			
		||||
  /// The recurrence for this request
 | 
			
		||||
  recurrence   : Recurrence
 | 
			
		||||
  /// History entries for the request
 | 
			
		||||
  history      : History list
 | 
			
		||||
  /// Note entries for the request
 | 
			
		||||
  notes        : Note list
 | 
			
		||||
  }
 | 
			
		||||
type JournalRequest =
 | 
			
		||||
    {   /// The ID of the request (just the CUID part)
 | 
			
		||||
        requestId    : RequestId
 | 
			
		||||
        
 | 
			
		||||
        /// The ID of the user to whom the request belongs
 | 
			
		||||
        userId       : UserId
 | 
			
		||||
        
 | 
			
		||||
        /// The current text of the request
 | 
			
		||||
        text         : string
 | 
			
		||||
        
 | 
			
		||||
        /// The last time action was taken on the request
 | 
			
		||||
        asOf         : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The last status for the request
 | 
			
		||||
        lastStatus   : RequestAction
 | 
			
		||||
        
 | 
			
		||||
        /// The time that this request should reappear in the user's journal
 | 
			
		||||
        snoozedUntil : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The time after which this request should reappear in the user's journal by configured recurrence
 | 
			
		||||
        showAfter    : Instant
 | 
			
		||||
        
 | 
			
		||||
        /// The recurrence for this request
 | 
			
		||||
        recurrence   : Recurrence
 | 
			
		||||
        
 | 
			
		||||
        /// History entries for the request
 | 
			
		||||
        history      : History list
 | 
			
		||||
        
 | 
			
		||||
        /// Note entries for the request
 | 
			
		||||
        notes        : Note list
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
/// Functions to manipulate journal requests
 | 
			
		||||
module JournalRequest =
 | 
			
		||||
 | 
			
		||||
  /// Convert a request to the form used for the journal (precomputed values, no notes or history)
 | 
			
		||||
  let ofRequestLite (req : Request) =
 | 
			
		||||
    let hist = req.history |> List.sortByDescending (fun it -> it.asOf) |> List.tryHead
 | 
			
		||||
    { requestId    = req.id
 | 
			
		||||
      userId       = req.userId
 | 
			
		||||
      text         = req.history
 | 
			
		||||
                      |> List.filter (fun it -> Option.isSome it.text)
 | 
			
		||||
                      |> List.sortByDescending (fun it -> it.asOf)
 | 
			
		||||
                      |> List.tryHead
 | 
			
		||||
                      |> Option.map (fun h -> Option.get h.text)
 | 
			
		||||
                      |> Option.defaultValue ""
 | 
			
		||||
      asOf         = match hist with Some h -> h.asOf   | None -> Instant.MinValue
 | 
			
		||||
      lastStatus   = match hist with Some h -> h.status | None -> Created
 | 
			
		||||
      snoozedUntil = req.snoozedUntil
 | 
			
		||||
      showAfter    = req.showAfter
 | 
			
		||||
      recurrence   = req.recurrence
 | 
			
		||||
      history      = []
 | 
			
		||||
      notes        = []
 | 
			
		||||
      }
 | 
			
		||||
    /// Convert a request to the form used for the journal (precomputed values, no notes or history)
 | 
			
		||||
    let ofRequestLite (req : Request) =
 | 
			
		||||
        let hist = req.history |> List.sortByDescending (fun it -> it.asOf) |> List.tryHead
 | 
			
		||||
        {   requestId    = req.id
 | 
			
		||||
            userId       = req.userId
 | 
			
		||||
            text         = req.history
 | 
			
		||||
                           |> List.filter (fun it -> Option.isSome it.text)
 | 
			
		||||
                           |> List.sortByDescending (fun it -> it.asOf)
 | 
			
		||||
                           |> List.tryHead
 | 
			
		||||
                           |> Option.map (fun h -> Option.get h.text)
 | 
			
		||||
                           |> Option.defaultValue ""
 | 
			
		||||
            asOf         = match hist with Some h -> h.asOf   | None -> Instant.MinValue
 | 
			
		||||
            lastStatus   = match hist with Some h -> h.status | None -> Created
 | 
			
		||||
            snoozedUntil = req.snoozedUntil
 | 
			
		||||
            showAfter    = req.showAfter
 | 
			
		||||
            recurrence   = req.recurrence
 | 
			
		||||
            history      = []
 | 
			
		||||
            notes        = []
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
  /// Same as `ofRequestLite`, but with notes and history
 | 
			
		||||
  let ofRequestFull req =
 | 
			
		||||
    { ofRequestLite req with 
 | 
			
		||||
        history = req.history
 | 
			
		||||
        notes   = req.notes
 | 
			
		||||
      }
 | 
			
		||||
    /// Same as `ofRequestLite`, but with notes and history
 | 
			
		||||
    let ofRequestFull req =
 | 
			
		||||
        { ofRequestLite req with 
 | 
			
		||||
            history = req.history
 | 
			
		||||
            notes   = req.notes
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
/// Functions to manipulate request actions
 | 
			
		||||
module RequestAction =
 | 
			
		||||
  /// Create a string representation of an action
 | 
			
		||||
  let toString =
 | 
			
		||||
    function
 | 
			
		||||
    | Created  -> "Created"
 | 
			
		||||
    | Prayed   -> "Prayed"
 | 
			
		||||
    | Updated  -> "Updated"
 | 
			
		||||
    | Answered -> "Answered"
 | 
			
		||||
  /// Create a RequestAction from a string
 | 
			
		||||
  let ofString =
 | 
			
		||||
    function
 | 
			
		||||
    | "Created"  -> Created
 | 
			
		||||
    | "Prayed"   -> Prayed
 | 
			
		||||
    | "Updated"  -> Updated
 | 
			
		||||
    | "Answered" -> Answered
 | 
			
		||||
    | it         -> invalidOp $"Bad request action {it}"
 | 
			
		||||
  /// Determine if a history's status is `Created`
 | 
			
		||||
  let isCreated hist = hist.status = Created
 | 
			
		||||
  /// Determine if a history's status is `Prayed`
 | 
			
		||||
  let isPrayed hist = hist.status = Prayed
 | 
			
		||||
  /// Determine if a history's status is `Answered`
 | 
			
		||||
  let isAnswered hist = hist.status = Answered
 | 
			
		||||
  
 | 
			
		||||
    /// Create a string representation of an action
 | 
			
		||||
    let toString =
 | 
			
		||||
        function
 | 
			
		||||
        | Created  -> "Created"
 | 
			
		||||
        | Prayed   -> "Prayed"
 | 
			
		||||
        | Updated  -> "Updated"
 | 
			
		||||
        | Answered -> "Answered"
 | 
			
		||||
    
 | 
			
		||||
    /// Create a RequestAction from a string
 | 
			
		||||
    let ofString =
 | 
			
		||||
        function
 | 
			
		||||
        | "Created"  -> Created
 | 
			
		||||
        | "Prayed"   -> Prayed
 | 
			
		||||
        | "Updated"  -> Updated
 | 
			
		||||
        | "Answered" -> Answered
 | 
			
		||||
        | it         -> invalidOp $"Bad request action {it}"
 | 
			
		||||
    
 | 
			
		||||
    /// Determine if a history's status is `Created`
 | 
			
		||||
    let isCreated hist = hist.status = Created
 | 
			
		||||
    
 | 
			
		||||
    /// Determine if a history's status is `Prayed`
 | 
			
		||||
    let isPrayed hist = hist.status = Prayed
 | 
			
		||||
    
 | 
			
		||||
    /// Determine if a history's status is `Answered`
 | 
			
		||||
    let isAnswered hist = hist.status = Answered
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -9,23 +9,23 @@ open NodaTime
 | 
			
		||||
 | 
			
		||||
/// Create a link that targets the `#top` element and pushes a URL to history
 | 
			
		||||
let pageLink href attrs =
 | 
			
		||||
  attrs
 | 
			
		||||
  |> List.append [ _href href; _hxBoost; _hxTarget "#top"; _hxSwap HxSwap.InnerHtml; _hxPushUrl ]
 | 
			
		||||
  |> a
 | 
			
		||||
    attrs
 | 
			
		||||
    |> List.append [ _href href; _hxBoost; _hxTarget "#top"; _hxSwap HxSwap.InnerHtml; _hxPushUrl ]
 | 
			
		||||
    |> a
 | 
			
		||||
 | 
			
		||||
/// Create a Material icon
 | 
			
		||||
let icon name = span [ _class "material-icons" ] [ str name ]
 | 
			
		||||
 | 
			
		||||
/// Create a card when there are no results found
 | 
			
		||||
let noResults heading link buttonText text =
 | 
			
		||||
  div [ _class "card" ] [
 | 
			
		||||
    h5 [ _class "card-header"] [ str heading ]
 | 
			
		||||
    div [ _class "card-body text-center" ] [
 | 
			
		||||
      p [ _class "card-text" ] text
 | 
			
		||||
      pageLink link [ _class "btn btn-primary" ] [ str buttonText ]
 | 
			
		||||
      ]
 | 
			
		||||
    div [ _class "card" ] [
 | 
			
		||||
        h5 [ _class "card-header"] [ str heading ]
 | 
			
		||||
        div [ _class "card-body text-center" ] [
 | 
			
		||||
            p [ _class "card-text" ] text
 | 
			
		||||
            pageLink link [ _class "btn btn-primary" ] [ str buttonText ]
 | 
			
		||||
        ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// 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 ]
 | 
			
		||||
    span [ _title (date.ToDateTimeOffset().ToString ("f", null)) ] [ Dates.formatDistance now date |> str ]
 | 
			
		||||
 | 
			
		||||
@ -8,170 +8,167 @@ open MyPrayerJournal
 | 
			
		||||
 | 
			
		||||
/// Display a card for this prayer request
 | 
			
		||||
let journalCard now req =
 | 
			
		||||
  let reqId = RequestId.toString req.requestId
 | 
			
		||||
  let spacer = span [] [ rawText " " ]
 | 
			
		||||
  div [ _class "col" ] [
 | 
			
		||||
    div [ _class "card h-100" ] [
 | 
			
		||||
      div [ _class "card-header p-0 d-flex"; _roleToolBar ] [
 | 
			
		||||
        pageLink $"/request/{reqId}/edit" [ _class  "btn btn-secondary"; _title "Edit Request" ] [ icon "edit" ]
 | 
			
		||||
        spacer
 | 
			
		||||
        button [
 | 
			
		||||
          _type     "button"
 | 
			
		||||
          _class    "btn btn-secondary"
 | 
			
		||||
          _title    "Add Notes"
 | 
			
		||||
          _data     "bs-toggle" "modal"
 | 
			
		||||
          _data     "bs-target" "#notesModal"
 | 
			
		||||
          _hxGet    $"/components/request/{reqId}/add-notes"
 | 
			
		||||
          _hxTarget "#notesBody"
 | 
			
		||||
          _hxSwap   HxSwap.InnerHtml
 | 
			
		||||
          ] [ icon "comment" ]
 | 
			
		||||
        spacer
 | 
			
		||||
        button [
 | 
			
		||||
          _type     "button"
 | 
			
		||||
          _class    "btn btn-secondary"
 | 
			
		||||
          _title    "Snooze Request"
 | 
			
		||||
          _data     "bs-toggle" "modal"
 | 
			
		||||
          _data     "bs-target" "#snoozeModal"
 | 
			
		||||
          _hxGet    $"/components/request/{reqId}/snooze"
 | 
			
		||||
          _hxTarget "#snoozeBody"
 | 
			
		||||
          _hxSwap   HxSwap.InnerHtml
 | 
			
		||||
          ] [ icon "schedule" ]
 | 
			
		||||
        div [ _class "flex-grow-1" ] []
 | 
			
		||||
        button [
 | 
			
		||||
          _type    "button"
 | 
			
		||||
          _class   "btn btn-success w-25"
 | 
			
		||||
          _hxPatch $"/request/{reqId}/prayed"
 | 
			
		||||
          _title   "Mark as Prayed"
 | 
			
		||||
          ] [ icon "done" ]
 | 
			
		||||
    let reqId = RequestId.toString req.requestId
 | 
			
		||||
    let spacer = span [] [ rawText " " ]
 | 
			
		||||
    div [ _class "col" ] [
 | 
			
		||||
        div [ _class "card h-100" ] [
 | 
			
		||||
            div [ _class "card-header p-0 d-flex"; _roleToolBar ] [
 | 
			
		||||
                pageLink $"/request/{reqId}/edit" [ _class  "btn btn-secondary"; _title "Edit Request" ] [ icon "edit" ]
 | 
			
		||||
                spacer
 | 
			
		||||
                button [ _type     "button"
 | 
			
		||||
                         _class    "btn btn-secondary"
 | 
			
		||||
                         _title    "Add Notes"
 | 
			
		||||
                         _data     "bs-toggle" "modal"
 | 
			
		||||
                         _data     "bs-target" "#notesModal"
 | 
			
		||||
                         _hxGet    $"/components/request/{reqId}/add-notes"
 | 
			
		||||
                         _hxTarget "#notesBody"
 | 
			
		||||
                         _hxSwap   HxSwap.InnerHtml ] [
 | 
			
		||||
                    icon "comment"
 | 
			
		||||
                ]
 | 
			
		||||
                spacer
 | 
			
		||||
                button [ _type     "button"
 | 
			
		||||
                         _class    "btn btn-secondary"
 | 
			
		||||
                         _title    "Snooze Request"
 | 
			
		||||
                         _data     "bs-toggle" "modal"
 | 
			
		||||
                         _data     "bs-target" "#snoozeModal"
 | 
			
		||||
                         _hxGet    $"/components/request/{reqId}/snooze"
 | 
			
		||||
                         _hxTarget "#snoozeBody"
 | 
			
		||||
                         _hxSwap   HxSwap.InnerHtml ] [
 | 
			
		||||
                    icon "schedule"
 | 
			
		||||
                ]
 | 
			
		||||
                div [ _class "flex-grow-1" ] []
 | 
			
		||||
                button [ _type    "button"
 | 
			
		||||
                         _class   "btn btn-success w-25"
 | 
			
		||||
                         _hxPatch $"/request/{reqId}/prayed"
 | 
			
		||||
                         _title   "Mark as Prayed" ] [
 | 
			
		||||
                    icon "done"
 | 
			
		||||
                ]
 | 
			
		||||
            ]
 | 
			
		||||
            div [ _class "card-body" ] [
 | 
			
		||||
                p [ _class "request-text" ] [ str req.text ]
 | 
			
		||||
            ]
 | 
			
		||||
            div [ _class "card-footer text-end text-muted px-1 py-0" ] [
 | 
			
		||||
                em [] [ str "last activity "; relativeDate req.asOf now ]
 | 
			
		||||
            ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "card-body" ] [
 | 
			
		||||
        p [ _class "request-text" ] [ str req.text ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "card-footer text-end text-muted px-1 py-0" ] [
 | 
			
		||||
        em [] [ str "last activity "; relativeDate req.asOf now ]
 | 
			
		||||
        ]
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// The journal loading page
 | 
			
		||||
let journal user = article [ _class "container-fluid mt-3" ] [
 | 
			
		||||
  h2 [ _class "pb-3" ] [
 | 
			
		||||
    str user
 | 
			
		||||
    match user with "Your" -> () | _ -> rawText "’s"
 | 
			
		||||
    str " Prayer Journal"
 | 
			
		||||
    ]
 | 
			
		||||
  p [ _class "pb-3 text-center" ] [
 | 
			
		||||
    pageLink "/request/new/edit" [ _class "btn btn-primary "] [ icon "add_box"; str " Add a Prayer Request" ]
 | 
			
		||||
    ]
 | 
			
		||||
  p [ _hxGet "/components/journal-items"; _hxSwap HxSwap.OuterHtml; _hxTrigger HxTrigger.Load ] [
 | 
			
		||||
    rawText "Loading your prayer journal…"
 | 
			
		||||
    ]
 | 
			
		||||
  div [
 | 
			
		||||
    _id             "notesModal"
 | 
			
		||||
    _class          "modal fade"
 | 
			
		||||
    _tabindex       "-1"
 | 
			
		||||
    _ariaLabelledBy "nodesModalLabel"
 | 
			
		||||
    _ariaHidden     "true"
 | 
			
		||||
    ] [
 | 
			
		||||
    div [ _class "modal-dialog modal-dialog-scrollable" ] [
 | 
			
		||||
      div [ _class "modal-content" ] [
 | 
			
		||||
        div [ _class "modal-header" ] [
 | 
			
		||||
          h5 [ _class "modal-title"; _id "nodesModalLabel" ] [ str "Add Notes to Prayer Request" ]
 | 
			
		||||
          button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
 | 
			
		||||
          ]
 | 
			
		||||
        div [ _class "modal-body"; _id "notesBody" ] [ ]
 | 
			
		||||
        div [ _class "modal-footer" ] [
 | 
			
		||||
          button [ _type "button"; _id "notesDismiss"; _class "btn btn-secondary"; _data "bs-dismiss" "modal" ] [
 | 
			
		||||
            str "Close"
 | 
			
		||||
            ]
 | 
			
		||||
          ]
 | 
			
		||||
let journal user =
 | 
			
		||||
    article [ _class "container-fluid mt-3" ] [
 | 
			
		||||
        h2 [ _class "pb-3" ] [
 | 
			
		||||
            str user
 | 
			
		||||
            match user with "Your" -> () | _ -> rawText "’s"
 | 
			
		||||
            str " Prayer Journal"
 | 
			
		||||
        ]
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
  div [
 | 
			
		||||
    _id             "snoozeModal"
 | 
			
		||||
    _class          "modal fade"
 | 
			
		||||
    _tabindex       "-1"
 | 
			
		||||
    _ariaLabelledBy "snoozeModalLabel"
 | 
			
		||||
    _ariaHidden     "true"
 | 
			
		||||
    ] [
 | 
			
		||||
    div [ _class "modal-dialog modal-sm" ] [
 | 
			
		||||
      div [ _class "modal-content" ] [
 | 
			
		||||
        div [ _class "modal-header" ] [
 | 
			
		||||
          h5 [ _class "modal-title"; _id "snoozeModalLabel" ] [ str "Snooze Prayer Request" ]
 | 
			
		||||
          button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
 | 
			
		||||
          ]
 | 
			
		||||
        div [ _class "modal-body"; _id "snoozeBody" ] [ ]
 | 
			
		||||
        div [ _class "modal-footer" ] [
 | 
			
		||||
          button [ _type "button"; _id "snoozeDismiss"; _class "btn btn-secondary"; _data "bs-dismiss" "modal" ] [
 | 
			
		||||
            str "Close"
 | 
			
		||||
            ]
 | 
			
		||||
          ]
 | 
			
		||||
        p [ _class "pb-3 text-center" ] [
 | 
			
		||||
            pageLink "/request/new/edit" [ _class "btn btn-primary "] [ icon "add_box"; str " Add a Prayer Request" ]
 | 
			
		||||
        ]
 | 
			
		||||
        p [ _hxGet "/components/journal-items"; _hxSwap HxSwap.OuterHtml; _hxTrigger HxTrigger.Load ] [
 | 
			
		||||
            rawText "Loading your prayer journal…"
 | 
			
		||||
        ]
 | 
			
		||||
        div [ _id             "notesModal"
 | 
			
		||||
              _class          "modal fade"
 | 
			
		||||
              _tabindex       "-1"
 | 
			
		||||
              _ariaLabelledBy "nodesModalLabel"
 | 
			
		||||
              _ariaHidden     "true" ] [
 | 
			
		||||
            div [ _class "modal-dialog modal-dialog-scrollable" ] [
 | 
			
		||||
                div [ _class "modal-content" ] [
 | 
			
		||||
                    div [ _class "modal-header" ] [
 | 
			
		||||
                        h5 [ _class "modal-title"; _id "nodesModalLabel" ] [ str "Add Notes to Prayer Request" ]
 | 
			
		||||
                        button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
 | 
			
		||||
                    ]
 | 
			
		||||
                    div [ _class "modal-body"; _id "notesBody" ] [ ]
 | 
			
		||||
                    div [ _class "modal-footer" ] [
 | 
			
		||||
                        button [ _type  "button"
 | 
			
		||||
                                 _id    "notesDismiss"
 | 
			
		||||
                                 _class "btn btn-secondary"
 | 
			
		||||
                                 _data  "bs-dismiss" "modal" ] [
 | 
			
		||||
                            str "Close"
 | 
			
		||||
                        ]
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
            ]
 | 
			
		||||
        ]
 | 
			
		||||
        div [ _id             "snoozeModal"
 | 
			
		||||
              _class          "modal fade"
 | 
			
		||||
              _tabindex       "-1"
 | 
			
		||||
              _ariaLabelledBy "snoozeModalLabel"
 | 
			
		||||
              _ariaHidden     "true" ] [
 | 
			
		||||
            div [ _class "modal-dialog modal-sm" ] [
 | 
			
		||||
                div [ _class "modal-content" ] [
 | 
			
		||||
                    div [ _class "modal-header" ] [
 | 
			
		||||
                        h5 [ _class "modal-title"; _id "snoozeModalLabel" ] [ str "Snooze Prayer Request" ]
 | 
			
		||||
                        button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "modal"; _ariaLabel "Close" ] []
 | 
			
		||||
                    ]
 | 
			
		||||
                    div [ _class "modal-body"; _id "snoozeBody" ] [ ]
 | 
			
		||||
                    div [ _class "modal-footer" ] [
 | 
			
		||||
                        button [ _type "button"
 | 
			
		||||
                                 _id "snoozeDismiss"
 | 
			
		||||
                                 _class "btn btn-secondary"
 | 
			
		||||
                                 _data "bs-dismiss" "modal" ] [
 | 
			
		||||
                            str "Close"
 | 
			
		||||
                        ]
 | 
			
		||||
                      ]
 | 
			
		||||
                ]
 | 
			
		||||
            ]
 | 
			
		||||
        ]
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
/// The journal items
 | 
			
		||||
let journalItems now items =
 | 
			
		||||
  match items |> List.isEmpty with
 | 
			
		||||
  | true ->
 | 
			
		||||
      noResults "No Active Requests" "/request/new/edit" "Add a Request" [
 | 
			
		||||
        rawText "You have no requests to be shown; see the “Active” link above for snoozed or deferred "
 | 
			
		||||
        rawText "requests, and the “Answered” link for answered requests"
 | 
			
		||||
    match items |> List.isEmpty with
 | 
			
		||||
    | true ->
 | 
			
		||||
        noResults "No Active Requests" "/request/new/edit" "Add a Request" [
 | 
			
		||||
            rawText "You have no requests to be shown; see the “Active” link above for snoozed or deferred "
 | 
			
		||||
            rawText "requests, and the “Answered” link for answered requests"
 | 
			
		||||
        ]
 | 
			
		||||
  | false ->
 | 
			
		||||
      items
 | 
			
		||||
      |> List.map (journalCard now)
 | 
			
		||||
      |> section [
 | 
			
		||||
          _id       "journalItems"
 | 
			
		||||
          _class    "row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3"
 | 
			
		||||
          _hxTarget "this"
 | 
			
		||||
          _hxSwap   HxSwap.OuterHtml
 | 
			
		||||
          ]
 | 
			
		||||
    | false ->
 | 
			
		||||
        items
 | 
			
		||||
        |> List.map (journalCard now)
 | 
			
		||||
        |> section [ _id       "journalItems"
 | 
			
		||||
                     _class    "row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-3"
 | 
			
		||||
                     _hxTarget "this"
 | 
			
		||||
                     _hxSwap   HxSwap.OuterHtml ]
 | 
			
		||||
 | 
			
		||||
/// The notes edit modal body
 | 
			
		||||
let notesEdit requestId =
 | 
			
		||||
  let reqId = RequestId.toString requestId
 | 
			
		||||
  [ form [ _hxPost $"/request/{reqId}/note" ] [
 | 
			
		||||
      div [ _class "form-floating pb-3" ] [
 | 
			
		||||
        textarea [
 | 
			
		||||
          _id          "notes"
 | 
			
		||||
          _name        "notes"
 | 
			
		||||
          _class       "form-control"
 | 
			
		||||
          _style       "min-height: 8rem;"
 | 
			
		||||
          _placeholder "Notes"
 | 
			
		||||
          _autofocus;  _required
 | 
			
		||||
          ] [ ]
 | 
			
		||||
        label [ _for "notes" ] [ str "Notes" ]
 | 
			
		||||
    let reqId = RequestId.toString requestId
 | 
			
		||||
    [   form [ _hxPost $"/request/{reqId}/note" ] [
 | 
			
		||||
            div [ _class "form-floating pb-3" ] [
 | 
			
		||||
                textarea [ _id          "notes"
 | 
			
		||||
                           _name        "notes"
 | 
			
		||||
                           _class       "form-control"
 | 
			
		||||
                           _style       "min-height: 8rem;"
 | 
			
		||||
                           _placeholder "Notes"
 | 
			
		||||
                           _autofocus;  _required ] [ ]
 | 
			
		||||
                label [ _for "notes" ] [ str "Notes" ]
 | 
			
		||||
            ]
 | 
			
		||||
            p [ _class "text-end" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Add Notes" ] ]
 | 
			
		||||
        ]
 | 
			
		||||
      p [ _class "text-end" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Add Notes" ] ]
 | 
			
		||||
      ]
 | 
			
		||||
    hr [ _style "margin: .5rem -1rem" ]
 | 
			
		||||
    div [ _id "priorNotes" ] [
 | 
			
		||||
      p [ _class "text-center pt-3" ] [
 | 
			
		||||
        button [
 | 
			
		||||
          _type     "button"
 | 
			
		||||
          _class    "btn btn-secondary"
 | 
			
		||||
          _hxGet    $"/components/request/{reqId}/notes"
 | 
			
		||||
          _hxSwap   HxSwap.OuterHtml
 | 
			
		||||
          _hxTarget "#priorNotes"
 | 
			
		||||
          ] [str "Load Prior Notes" ]
 | 
			
		||||
        hr [ _style "margin: .5rem -1rem" ]
 | 
			
		||||
        div [ _id "priorNotes" ] [
 | 
			
		||||
            p [ _class "text-center pt-3" ] [
 | 
			
		||||
                button [ _type     "button"
 | 
			
		||||
                         _class    "btn btn-secondary"
 | 
			
		||||
                         _hxGet    $"/components/request/{reqId}/notes"
 | 
			
		||||
                         _hxSwap   HxSwap.OuterHtml
 | 
			
		||||
                         _hxTarget "#priorNotes" ] [
 | 
			
		||||
                    str "Load Prior Notes"
 | 
			
		||||
                ]
 | 
			
		||||
            ]
 | 
			
		||||
        ]
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// The snooze edit form
 | 
			
		||||
let snooze requestId =
 | 
			
		||||
  let today = System.DateTime.Today.ToString "yyyy-MM-dd"
 | 
			
		||||
  form [
 | 
			
		||||
    _hxPatch  $"/request/{RequestId.toString requestId}/snooze"
 | 
			
		||||
    _hxTarget "#journalItems"
 | 
			
		||||
    _hxSwap   HxSwap.OuterHtml
 | 
			
		||||
    ] [
 | 
			
		||||
    div [ _class "form-floating pb-3" ] [
 | 
			
		||||
      input [ _type "date"; _id "until"; _name "until"; _class "form-control"; _min today; _required ]
 | 
			
		||||
      label [ _for "until" ] [ str "Until" ]
 | 
			
		||||
      ]
 | 
			
		||||
    p [ _class "text-end mb-0" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Snooze" ] ]
 | 
			
		||||
    let today = System.DateTime.Today.ToString "yyyy-MM-dd"
 | 
			
		||||
    form [ _hxPatch  $"/request/{RequestId.toString requestId}/snooze"
 | 
			
		||||
           _hxTarget "#journalItems"
 | 
			
		||||
           _hxSwap   HxSwap.OuterHtml ] [
 | 
			
		||||
        div [ _class "form-floating pb-3" ] [
 | 
			
		||||
            input [ _type "date"; _id "until"; _name "until"; _class "form-control"; _min today; _required ]
 | 
			
		||||
            label [ _for "until" ] [ str "Until" ]
 | 
			
		||||
        ]
 | 
			
		||||
        p [ _class "text-end mb-0" ] [ button [ _type "submit"; _class "btn btn-primary" ] [ str "Snooze" ] ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -7,141 +7,144 @@ open Giraffe.ViewEngine
 | 
			
		||||
open Giraffe.ViewEngine.Accessibility
 | 
			
		||||
 | 
			
		||||
/// The data needed to render a page-level view
 | 
			
		||||
type PageRenderContext = {
 | 
			
		||||
  /// Whether the user is authenticated
 | 
			
		||||
  isAuthenticated : bool
 | 
			
		||||
  /// Whether the user has snoozed requests
 | 
			
		||||
  hasSnoozed      : bool
 | 
			
		||||
  /// The current URL
 | 
			
		||||
  currentUrl      : string
 | 
			
		||||
  /// The title for the page to be rendered
 | 
			
		||||
  pageTitle       : string
 | 
			
		||||
  /// The content of the page
 | 
			
		||||
  content         : XmlNode
 | 
			
		||||
  }
 | 
			
		||||
type PageRenderContext =
 | 
			
		||||
    {   /// Whether the user is authenticated
 | 
			
		||||
        isAuthenticated : bool
 | 
			
		||||
        
 | 
			
		||||
        /// Whether the user has snoozed requests
 | 
			
		||||
        hasSnoozed      : bool
 | 
			
		||||
        
 | 
			
		||||
        /// The current URL
 | 
			
		||||
        currentUrl      : string
 | 
			
		||||
        
 | 
			
		||||
        /// The title for the page to be rendered
 | 
			
		||||
        pageTitle       : string
 | 
			
		||||
        
 | 
			
		||||
        /// The content of the page
 | 
			
		||||
        content         : XmlNode
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
/// The home page
 | 
			
		||||
let home = article [ _class "container mt-3" ] [
 | 
			
		||||
  p [] [ rawText " " ]
 | 
			
		||||
  p [] [
 | 
			
		||||
    str "myPrayerJournal is a place where individuals can record their prayer requests, record that they prayed for "
 | 
			
		||||
    str "them, update them as God moves in the situation, and record a final answer received on that request. It also "
 | 
			
		||||
    str "allows individuals to review their answered prayers."
 | 
			
		||||
let home =
 | 
			
		||||
    article [ _class "container mt-3" ] [
 | 
			
		||||
        p [] [ rawText " " ]
 | 
			
		||||
        p [] [
 | 
			
		||||
            str "myPrayerJournal is a place where individuals can record their prayer requests, record that they "
 | 
			
		||||
            str "prayed for them, update them as God moves in the situation, and record a final answer received on "
 | 
			
		||||
            str "that request. It also allows individuals to review their answered prayers."
 | 
			
		||||
        ]
 | 
			
		||||
        p [] [
 | 
			
		||||
            str "This site is open and available to the general public. To get started, simply click the "
 | 
			
		||||
            rawText "“Log On” link above, and log on with either a Microsoft or Google account. You can "
 | 
			
		||||
            rawText "also learn more about the site at the “Docs” link, also above."
 | 
			
		||||
        ]
 | 
			
		||||
    ]
 | 
			
		||||
  p [] [
 | 
			
		||||
    str "This site is open and available to the general public. To get started, simply click the "
 | 
			
		||||
    rawText "“Log On” link above, and log on with either a Microsoft or Google account. You can also "
 | 
			
		||||
    rawText "learn more about the site at the “Docs” link, also above."
 | 
			
		||||
    ]
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
/// The default navigation bar, which will load the items on page load, and whenever a refresh event occurs
 | 
			
		||||
let private navBar ctx =
 | 
			
		||||
  nav [ _class "navbar navbar-dark"; _roleNavigation ] [
 | 
			
		||||
    div [ _class "container-fluid" ] [
 | 
			
		||||
      pageLink "/" [ _class "navbar-brand" ] [
 | 
			
		||||
        span [ _class "m" ] [ str "my" ]
 | 
			
		||||
        span [ _class "p" ] [ str "Prayer" ]
 | 
			
		||||
        span [ _class "j" ] [ str "Journal" ]
 | 
			
		||||
      ]
 | 
			
		||||
      seq {
 | 
			
		||||
        let navLink (matchUrl : string) =
 | 
			
		||||
          match ctx.currentUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> []
 | 
			
		||||
          |> pageLink matchUrl
 | 
			
		||||
        match ctx.isAuthenticated with
 | 
			
		||||
        | true ->
 | 
			
		||||
            li [ _class "nav-item" ] [ navLink "/journal" [ str "Journal" ] ]
 | 
			
		||||
            li [ _class "nav-item" ] [ navLink "/requests/active" [ str "Active" ] ]
 | 
			
		||||
            if ctx.hasSnoozed then li [ _class "nav-item" ] [ navLink "/requests/snoozed" [ str "Snoozed" ] ]
 | 
			
		||||
            li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ]
 | 
			
		||||
            li [ _class "nav-item" ] [ a [ _href "/user/log-off" ] [ str "Log Off" ] ]
 | 
			
		||||
        | false -> li [ _class "nav-item"] [ a [ _href "/user/log-on" ] [ str "Log On" ] ]
 | 
			
		||||
        li [ _class "nav-item" ] [
 | 
			
		||||
          a [ _href "https://docs.prayerjournal.me"; _target "_blank"; _rel "noopener" ] [ str "Docs" ]
 | 
			
		||||
          ]
 | 
			
		||||
        }
 | 
			
		||||
      |> List.ofSeq
 | 
			
		||||
      |> ul [ _class "navbar-nav me-auto d-flex flex-row" ]
 | 
			
		||||
      ]
 | 
			
		||||
    nav [ _class "navbar navbar-dark"; _roleNavigation ] [
 | 
			
		||||
        div [ _class "container-fluid" ] [
 | 
			
		||||
            pageLink "/" [ _class "navbar-brand" ] [
 | 
			
		||||
                span [ _class "m" ] [ str "my" ]
 | 
			
		||||
                span [ _class "p" ] [ str "Prayer" ]
 | 
			
		||||
                span [ _class "j" ] [ str "Journal" ]
 | 
			
		||||
            ]
 | 
			
		||||
            seq {
 | 
			
		||||
                let navLink (matchUrl : string) =
 | 
			
		||||
                    match ctx.currentUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> []
 | 
			
		||||
                    |> pageLink matchUrl
 | 
			
		||||
                if ctx.isAuthenticated then
 | 
			
		||||
                    li [ _class "nav-item" ] [ navLink "/journal" [ str "Journal" ] ]
 | 
			
		||||
                    li [ _class "nav-item" ] [ navLink "/requests/active" [ str "Active" ] ]
 | 
			
		||||
                    if ctx.hasSnoozed then li [ _class "nav-item" ] [ navLink "/requests/snoozed" [ str "Snoozed" ] ]
 | 
			
		||||
                    li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ]
 | 
			
		||||
                    li [ _class "nav-item" ] [ a [ _href "/user/log-off" ] [ str "Log Off" ] ]
 | 
			
		||||
                else li [ _class "nav-item"] [ a [ _href "/user/log-on" ] [ str "Log On" ] ]
 | 
			
		||||
                li [ _class "nav-item" ] [
 | 
			
		||||
                    a [ _href "https://docs.prayerjournal.me"; _target "_blank"; _rel "noopener" ] [ str "Docs" ]
 | 
			
		||||
                ]
 | 
			
		||||
            }
 | 
			
		||||
            |> List.ofSeq
 | 
			
		||||
            |> ul [ _class "navbar-nav me-auto d-flex flex-row" ]
 | 
			
		||||
        ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// The title tag with the application name appended
 | 
			
		||||
let titleTag ctx = title [] [ str ctx.pageTitle; rawText " « myPrayerJournal" ]
 | 
			
		||||
let titleTag ctx =
 | 
			
		||||
    title [] [ str ctx.pageTitle; rawText " « myPrayerJournal" ]
 | 
			
		||||
 | 
			
		||||
/// The HTML `head` element
 | 
			
		||||
let htmlHead ctx =
 | 
			
		||||
  head [ _lang "en" ] [
 | 
			
		||||
    meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
 | 
			
		||||
    meta [ _name "description"; _content "Online prayer journal - free w/Google or Microsoft account" ]
 | 
			
		||||
    titleTag ctx
 | 
			
		||||
    link [
 | 
			
		||||
      _href        "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
 | 
			
		||||
      _rel         "stylesheet"
 | 
			
		||||
      _integrity   "sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
 | 
			
		||||
      _crossorigin "anonymous"
 | 
			
		||||
      ]
 | 
			
		||||
    link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
 | 
			
		||||
    link [ _href "/style/style.css"; _rel "stylesheet" ]
 | 
			
		||||
    head [ _lang "en" ] [
 | 
			
		||||
        meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
 | 
			
		||||
        meta [ _name "description"; _content "Online prayer journal - free w/Google or Microsoft account" ]
 | 
			
		||||
        titleTag ctx
 | 
			
		||||
        link [ _href        "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
 | 
			
		||||
               _rel         "stylesheet"
 | 
			
		||||
               _integrity   "sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC"
 | 
			
		||||
               _crossorigin "anonymous" ]
 | 
			
		||||
        link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
 | 
			
		||||
        link [ _href "/style/style.css"; _rel "stylesheet" ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// Element used to display toasts
 | 
			
		||||
let toaster =
 | 
			
		||||
  div [ _ariaLive "polite"; _ariaAtomic "true"; _id "toastHost" ] [
 | 
			
		||||
    div [ _class "toast-container position-absolute p-3 bottom-0 end-0"; _id "toasts" ] []
 | 
			
		||||
  ]
 | 
			
		||||
    div [ _ariaLive "polite"; _ariaAtomic "true"; _id "toastHost" ] [
 | 
			
		||||
        div [ _class "toast-container position-absolute p-3 bottom-0 end-0"; _id "toasts" ] []
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// The page's `footer` element
 | 
			
		||||
let htmlFoot =
 | 
			
		||||
  footer [ _class "container-fluid" ] [
 | 
			
		||||
    p [ _class "text-muted text-end" ] [
 | 
			
		||||
      str "myPrayerJournal v3"
 | 
			
		||||
      br []
 | 
			
		||||
      em [] [
 | 
			
		||||
        small [] [
 | 
			
		||||
          pageLink "/legal/privacy-policy" [] [ str "Privacy Policy" ]
 | 
			
		||||
          rawText " • "
 | 
			
		||||
          pageLink "/legal/terms-of-service" [] [ str "Terms of Service" ]
 | 
			
		||||
          rawText " • "
 | 
			
		||||
          a [ _href "https://github.com/bit-badger/myprayerjournal"; _target "_blank"; _rel "noopener" ] [
 | 
			
		||||
            str "Developed"
 | 
			
		||||
    footer [ _class "container-fluid" ] [
 | 
			
		||||
        p [ _class "text-muted text-end" ] [
 | 
			
		||||
            str "myPrayerJournal v3"
 | 
			
		||||
            br []
 | 
			
		||||
            em [] [
 | 
			
		||||
                small [] [
 | 
			
		||||
                    pageLink "/legal/privacy-policy" [] [ str "Privacy Policy" ]
 | 
			
		||||
                    rawText " • "
 | 
			
		||||
                    pageLink "/legal/terms-of-service" [] [ str "Terms of Service" ]
 | 
			
		||||
                    rawText " • "
 | 
			
		||||
                    a [ _href "https://github.com/bit-badger/myprayerjournal"; _target "_blank"; _rel "noopener" ] [
 | 
			
		||||
                        str "Developed"
 | 
			
		||||
                    ]
 | 
			
		||||
                    str " and hosted by "
 | 
			
		||||
                    a [ _href "https://bitbadger.solutions"; _target "_blank"; _rel "noopener" ] [
 | 
			
		||||
                        str "Bit Badger Solutions"
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
            ]
 | 
			
		||||
          str " and hosted by "
 | 
			
		||||
          a [ _href "https://bitbadger.solutions"; _target "_blank"; _rel "noopener" ] [ str "Bit Badger Solutions" ]
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      ]
 | 
			
		||||
    Htmx.Script.minified
 | 
			
		||||
    script [] [
 | 
			
		||||
      rawText "if (!htmx) document.write('<script src=\"/script/htmx-1.5.0.min.js\"><\/script>')"
 | 
			
		||||
      ]
 | 
			
		||||
    script [
 | 
			
		||||
      _async
 | 
			
		||||
      _src         "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
 | 
			
		||||
      _integrity   "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
 | 
			
		||||
      _crossorigin "anonymous"
 | 
			
		||||
      ] []
 | 
			
		||||
    script [] [
 | 
			
		||||
      rawText "setTimeout(function () { "
 | 
			
		||||
      rawText "if (!bootstrap) document.write('<script src=\"/script/bootstrap.bundle.min.js\"><\/script>') "
 | 
			
		||||
      rawText "}, 2000)"
 | 
			
		||||
      ]
 | 
			
		||||
    script [ _src "/script/mpj.js" ] []
 | 
			
		||||
        Htmx.Script.minified
 | 
			
		||||
        script [] [
 | 
			
		||||
            rawText "if (!htmx) document.write('<script src=\"/script/htmx-1.5.0.min.js\"><\/script>')"
 | 
			
		||||
        ]
 | 
			
		||||
        script [ _async
 | 
			
		||||
                 _src         "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
 | 
			
		||||
                 _integrity   "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
 | 
			
		||||
                 _crossorigin "anonymous" ] []
 | 
			
		||||
        script [] [
 | 
			
		||||
            rawText "setTimeout(function () { "
 | 
			
		||||
            rawText "if (!bootstrap) document.write('<script src=\"/script/bootstrap.bundle.min.js\"><\/script>') "
 | 
			
		||||
            rawText "}, 2000)"
 | 
			
		||||
        ]
 | 
			
		||||
        script [ _src "/script/mpj.js" ] []
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// Create the full view of the page
 | 
			
		||||
let view ctx =
 | 
			
		||||
  html [ _lang "en" ] [
 | 
			
		||||
    htmlHead ctx
 | 
			
		||||
    body [] [
 | 
			
		||||
      section [ _id "top" ] [ navBar ctx; main [ _roleMain ] [ ctx.content ] ]
 | 
			
		||||
      toaster
 | 
			
		||||
      htmlFoot
 | 
			
		||||
      ]
 | 
			
		||||
    html [ _lang "en" ] [
 | 
			
		||||
        htmlHead ctx
 | 
			
		||||
        body [] [
 | 
			
		||||
            section [ _id "top"; _ariaLabel "Top navigation" ] [ navBar ctx; main [ _roleMain ] [ ctx.content ] ]
 | 
			
		||||
            toaster
 | 
			
		||||
            htmlFoot
 | 
			
		||||
        ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// Create a partial view
 | 
			
		||||
let partial ctx =
 | 
			
		||||
  html [ _lang "en" ] [
 | 
			
		||||
    head [] [ titleTag ctx ]
 | 
			
		||||
    body [] [ navBar ctx; main [ _roleMain ] [ ctx.content ] ]
 | 
			
		||||
    html [ _lang "en" ] [
 | 
			
		||||
        head [] [ titleTag ctx ]
 | 
			
		||||
        body [] [ navBar ctx; main [ _roleMain ] [ ctx.content ] ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
@ -4,150 +4,159 @@ module MyPrayerJournal.Views.Legal
 | 
			
		||||
open Giraffe.ViewEngine
 | 
			
		||||
 | 
			
		||||
/// View for the "Privacy Policy" page
 | 
			
		||||
let privacyPolicy = article [ _class "container mt-3" ] [
 | 
			
		||||
  h2 [ _class "mb-2" ] [ str "Privacy Policy" ]
 | 
			
		||||
  h6 [ _class "text-muted pb-3" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ]
 | 
			
		||||
  p [] [
 | 
			
		||||
    str "The nature of the service is one where privacy is a must. The items below will help you understand the data "
 | 
			
		||||
    str "we collect, access, and store on your behalf as you use this service."
 | 
			
		||||
let privacyPolicy =
 | 
			
		||||
    article [ _class "container mt-3" ] [
 | 
			
		||||
        h2 [ _class "mb-2" ] [ str "Privacy Policy" ]
 | 
			
		||||
        h6 [ _class "text-muted pb-3" ] [ str "as of May 21"; sup [] [ str "st"]; str ", 2018" ]
 | 
			
		||||
        p [] [
 | 
			
		||||
            str "The nature of the service is one where privacy is a must. The items below will help you understand "
 | 
			
		||||
            str "the data we collect, access, and store on your behalf as you use this service."
 | 
			
		||||
        ]
 | 
			
		||||
        div [ _class "card" ] [
 | 
			
		||||
            div [ _class "list-group list-group-flush" ] [
 | 
			
		||||
                div [ _class "list-group-item"] [
 | 
			
		||||
                    h3 [] [ str "Third Party Services" ]
 | 
			
		||||
                    p [ _class "card-text" ] [
 | 
			
		||||
                        str "myPrayerJournal utilizes a third-party authentication and identity provider. You should "
 | 
			
		||||
                        str "familiarize yourself with the privacy policy for "
 | 
			
		||||
                        a [ _href "https://auth0.com/privacy"; _target "_blank" ] [ str "Auth0" ]
 | 
			
		||||
                        str ", as well as your chosen provider ("
 | 
			
		||||
                        a [ _href "https://privacy.microsoft.com/en-us/privacystatement"; _target "_blank" ] [
 | 
			
		||||
                            str "Microsoft"
 | 
			
		||||
                        ]
 | 
			
		||||
                        str " or "
 | 
			
		||||
                        a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ]
 | 
			
		||||
                        str ")."
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
                div [ _class "list-group-item" ] [
 | 
			
		||||
                    h3 [] [ str "What We Collect" ]
 | 
			
		||||
                    h4 [] [ str "Identifying Data" ]
 | 
			
		||||
                    ul [] [
 | 
			
		||||
                        li [] [
 | 
			
		||||
                            str "The only identifying data myPrayerJournal stores is the subscriber "
 | 
			
		||||
                            rawText "(“sub”) field from the token we receive from Auth0, once you have "
 | 
			
		||||
                            str "signed in through their hosted service. All information is associated with you via "
 | 
			
		||||
                            str "this field."
 | 
			
		||||
                        ]
 | 
			
		||||
                        li [] [
 | 
			
		||||
                            str "While you are signed in, within your browser, the service has access to your first "
 | 
			
		||||
                            str "and last names, along with a URL to the profile picture (provided by your selected "
 | 
			
		||||
                            str "identity provider). This information is not transmitted to the server, and is removed "
 | 
			
		||||
                            rawText "when “Log Off” is clicked."
 | 
			
		||||
                        ]
 | 
			
		||||
                    ]
 | 
			
		||||
                    h4 [] [ str "User Provided Data" ]
 | 
			
		||||
                    ul [ _class "mb-0" ] [
 | 
			
		||||
                        li [] [
 | 
			
		||||
                            str "myPrayerJournal stores the information you provide, including the text of prayer "
 | 
			
		||||
                            str "requests, updates, and notes; and the date/time when certain actions are taken."
 | 
			
		||||
                        ]
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
                div [ _class "list-group-item" ] [
 | 
			
		||||
                    h3 [] [ str "How Your Data Is Accessed / Secured" ]
 | 
			
		||||
                    ul [ _class "mb-0" ] [
 | 
			
		||||
                        li [] [
 | 
			
		||||
                            str "Your provided data is returned to you, as required, to display your journal or your "
 | 
			
		||||
                            str "answered requests. On the server, it is stored in a controlled-access database."
 | 
			
		||||
                        ]
 | 
			
		||||
                        li [] [
 | 
			
		||||
                            str "Your data is backed up, along with other Bit Badger Solutions hosted systems, in a "
 | 
			
		||||
                            str "rolling manner; backups are preserved for the prior 7 days, and backups from the 1"
 | 
			
		||||
                            sup [] [ str "st" ]
 | 
			
		||||
                            str " and 15"
 | 
			
		||||
                            sup [] [ str "th" ]
 | 
			
		||||
                            str " are preserved for 3 months. These backups are stored in a private cloud data "
 | 
			
		||||
                            str "repository."
 | 
			
		||||
                        ]
 | 
			
		||||
                        li [] [
 | 
			
		||||
                            str "The data collected and stored is the absolute minimum necessary for the functionality "
 | 
			
		||||
                            rawText "of the service. There are no plans to “monetize” this service, and "
 | 
			
		||||
                            str "storing the minimum amount of information means that the data we have is not "
 | 
			
		||||
                            str "interesting to purchasers (or those who may have more nefarious purposes)."
 | 
			
		||||
                        ]
 | 
			
		||||
                        li [] [
 | 
			
		||||
                            str "Access to servers and backups is strictly controlled and monitored for unauthorized "
 | 
			
		||||
                            str "access attempts."
 | 
			
		||||
                        ]
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
                div [ _class "list-group-item" ] [
 | 
			
		||||
                    h3 [] [ str "Removing Your Data" ]
 | 
			
		||||
                    p [ _class "card-text" ] [
 | 
			
		||||
                        str "At any time, you may choose to discontinue using this service. Both Microsoft and Google "
 | 
			
		||||
                        str "provide ways to revoke access from this application. However, if you want your data "
 | 
			
		||||
                        str "removed from the database, please contact daniel at bitbadger.solutions (via e-mail, "
 | 
			
		||||
                        str "replacing at with @) prior to doing so, to ensure we can determine which subscriber ID "
 | 
			
		||||
                        str "belongs to you."
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
            ]
 | 
			
		||||
        ]
 | 
			
		||||
    ]
 | 
			
		||||
  div [ _class "card" ] [
 | 
			
		||||
    div [ _class "list-group list-group-flush" ] [
 | 
			
		||||
      div [ _class "list-group-item"] [
 | 
			
		||||
        h3 [] [ str "Third Party Services" ]
 | 
			
		||||
        p [ _class "card-text" ] [
 | 
			
		||||
          str "myPrayerJournal utilizes a third-party authentication and identity provider. You should familiarize "
 | 
			
		||||
          str "yourself with the privacy policy for "
 | 
			
		||||
          a [ _href "https://auth0.com/privacy"; _target "_blank" ] [ str "Auth0" ]
 | 
			
		||||
          str ", as well as your chosen provider ("
 | 
			
		||||
          a [ _href "https://privacy.microsoft.com/en-us/privacystatement"; _target "_blank" ] [ str "Microsoft"]
 | 
			
		||||
          str " or "
 | 
			
		||||
          a [ _href "https://policies.google.com/privacy"; _target "_blank" ] [ str "Google" ]
 | 
			
		||||
          str ")."
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "list-group-item" ] [
 | 
			
		||||
        h3 [] [ str "What We Collect" ]
 | 
			
		||||
        h4 [] [ str "Identifying Data" ]
 | 
			
		||||
        ul [] [
 | 
			
		||||
          li [] [
 | 
			
		||||
            rawText "The only identifying data myPrayerJournal stores is the subscriber (“sub”) field from "
 | 
			
		||||
            str "the token we receive from Auth0, once you have signed in through their hosted service. All "
 | 
			
		||||
            str "information is associated with you via this field."
 | 
			
		||||
            ]
 | 
			
		||||
          li [] [
 | 
			
		||||
            str "While you are signed in, within your browser, the service has access to your first and last names, "
 | 
			
		||||
            str "along with a URL to the profile picture (provided by your selected identity provider). This "
 | 
			
		||||
            rawText "information is not transmitted to the server, and is removed when “Log Off” is "
 | 
			
		||||
            str "clicked."
 | 
			
		||||
            ]
 | 
			
		||||
          ]
 | 
			
		||||
        h4 [] [ str "User Provided Data" ]
 | 
			
		||||
        ul [ _class "mb-0" ] [
 | 
			
		||||
          li [] [
 | 
			
		||||
            str "myPrayerJournal stores the information you provide, including the text of prayer requests, updates, "
 | 
			
		||||
            str "and notes; and the date/time when certain actions are taken."
 | 
			
		||||
            ]
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "list-group-item" ] [
 | 
			
		||||
        h3 [] [ str "How Your Data Is Accessed / Secured" ]
 | 
			
		||||
        ul [ _class "mb-0" ] [
 | 
			
		||||
          li [] [
 | 
			
		||||
            str "Your provided data is returned to you, as required, to display your journal or your answered "
 | 
			
		||||
            str "requests. On the server, it is stored in a controlled-access database."
 | 
			
		||||
            ]
 | 
			
		||||
          li [] [
 | 
			
		||||
            str "Your data is backed up, along with other Bit Badger Solutions hosted systems, in a rolling manner; "
 | 
			
		||||
            str "backups are preserved for the prior 7 days, and backups from the 1"
 | 
			
		||||
            sup [] [ str "st" ]
 | 
			
		||||
            str " and 15"
 | 
			
		||||
            sup [] [ str "th" ]
 | 
			
		||||
            str " are preserved for 3 months. These backups are stored in a private cloud data repository."
 | 
			
		||||
            ]
 | 
			
		||||
          li [] [
 | 
			
		||||
            str "The data collected and stored is the absolute minimum necessary for the functionality of the service. "
 | 
			
		||||
            rawText "There are no plans to “monetize” this service, and storing the minimum amount of "
 | 
			
		||||
            str "information means that the data we have is not interesting to purchasers (or those who may have more "
 | 
			
		||||
            str "nefarious purposes)."
 | 
			
		||||
            ]
 | 
			
		||||
          li [] [
 | 
			
		||||
            str "Access to servers and backups is strictly controlled and monitored for unauthorized access attempts."
 | 
			
		||||
            ]
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "list-group-item" ] [
 | 
			
		||||
        h3 [] [ str "Removing Your Data" ]
 | 
			
		||||
        p [ _class "card-text" ] [
 | 
			
		||||
          str "At any time, you may choose to discontinue using this service. Both Microsoft and Google provide ways "
 | 
			
		||||
          str "to revoke access from this application. However, if you want your data removed from the database, "
 | 
			
		||||
          str "please contact daniel at bitbadger.solutions (via e-mail, replacing at with @) prior to doing so, to "
 | 
			
		||||
          str "ensure we can determine which subscriber ID belongs to you."
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
/// View for the "Terms of Service" page
 | 
			
		||||
let termsOfService = article [ _class "container mt-3" ] [
 | 
			
		||||
  h2 [ _class "mb-2" ] [ str "Terms of Service" ]
 | 
			
		||||
  h6 [ _class "text-muted pb-3"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ]
 | 
			
		||||
  div [ _class "card" ] [
 | 
			
		||||
    div [ _class "list-group list-group-flush" ] [
 | 
			
		||||
      div [ _class "list-group-item" ] [
 | 
			
		||||
        h3 [] [ str "1. Acceptance of Terms" ]
 | 
			
		||||
        p [ _class "card-text" ] [
 | 
			
		||||
          str "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, and that you "
 | 
			
		||||
          str "are responsible to ensure that your use of this site complies with all applicable laws. Your continued "
 | 
			
		||||
          str "use of this site implies your acceptance of these terms."
 | 
			
		||||
          ]
 | 
			
		||||
let termsOfService =
 | 
			
		||||
    article [ _class "container mt-3" ] [
 | 
			
		||||
        h2 [ _class "mb-2" ] [ str "Terms of Service" ]
 | 
			
		||||
        h6 [ _class "text-muted pb-3"] [ str "as of May 21"; sup [] [ str "st" ]; str ", 2018" ]
 | 
			
		||||
        div [ _class "card" ] [
 | 
			
		||||
            div [ _class "list-group list-group-flush" ] [
 | 
			
		||||
                div [ _class "list-group-item" ] [
 | 
			
		||||
                    h3 [] [ str "1. Acceptance of Terms" ]
 | 
			
		||||
                    p [ _class "card-text" ] [
 | 
			
		||||
                        str "By accessing this web site, you are agreeing to be bound by these Terms and Conditions, "
 | 
			
		||||
                        str "and that you are responsible to ensure that your use of this site complies with all "
 | 
			
		||||
                        str "applicable laws. Your continued use of this site implies your acceptance of these terms."
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
                div [ _class "list-group-item" ] [
 | 
			
		||||
                    h3 [] [ str "2. Description of Service and Registration" ]
 | 
			
		||||
                    p [ _class "card-text" ] [
 | 
			
		||||
                        str "myPrayerJournal is a service that allows individuals to enter and amend their prayer "
 | 
			
		||||
                        str "requests. It requires no registration by itself, but access is granted based on a "
 | 
			
		||||
                        str "successful login with an external identity provider. See "
 | 
			
		||||
                        pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ]
 | 
			
		||||
                        str " for details on how that information is accessed and stored."
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
                div [ _class "list-group-item" ] [
 | 
			
		||||
                    h3 [] [ str "3. Third Party Services" ]
 | 
			
		||||
                    p [ _class "card-text" ] [
 | 
			
		||||
                        str "This service utilizes a third-party service provider for identity management. Review the "
 | 
			
		||||
                        str "terms of service for "
 | 
			
		||||
                        a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
 | 
			
		||||
                        str ", as well as those for the selected authorization provider ("
 | 
			
		||||
                        a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [
 | 
			
		||||
                            str "Microsoft"
 | 
			
		||||
                        ]
 | 
			
		||||
                        str " or "
 | 
			
		||||
                        a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
 | 
			
		||||
                        str ")."
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
                div [ _class "list-group-item" ] [
 | 
			
		||||
                    h3 [] [ str "4. Liability" ]
 | 
			
		||||
                    p [ _class "card-text" ] [
 | 
			
		||||
                        rawText "This service is provided “as is”, and no warranty (express or implied) "
 | 
			
		||||
                        str "exists. The service and its developers may not be held liable for any damages that may "
 | 
			
		||||
                        str "arise through the use of this service."
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
                div [ _class "list-group-item" ] [
 | 
			
		||||
                    h3 [] [ str "5. Updates to Terms" ]
 | 
			
		||||
                    p [ _class "card-text" ] [
 | 
			
		||||
                        str "These terms and conditions may be updated at any time, and this service does not have the "
 | 
			
		||||
                        str "capability to notify users when these change. The date at the top of the page will be "
 | 
			
		||||
                        str "updated when any of the text of these terms is updated."
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
            ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "list-group-item" ] [
 | 
			
		||||
        h3 [] [ str "2. Description of Service and Registration" ]
 | 
			
		||||
        p [ _class "card-text" ] [
 | 
			
		||||
          str "myPrayerJournal is a service that allows individuals to enter and amend their prayer requests. It "
 | 
			
		||||
          str "requires no registration by itself, but access is granted based on a successful login with an external "
 | 
			
		||||
          str "identity provider. See "
 | 
			
		||||
          pageLink "/legal/privacy-policy" [] [ str "our privacy policy" ]
 | 
			
		||||
          str " for details on how that information is accessed and stored."
 | 
			
		||||
          ]
 | 
			
		||||
        p [ _class "pt-3" ] [
 | 
			
		||||
            str "You may also wish to review our "
 | 
			
		||||
            pageLink "/legal/privacy-policy" [] [ str "privacy policy" ]
 | 
			
		||||
            str " to learn how we handle your data."
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "list-group-item" ] [
 | 
			
		||||
        h3 [] [ str "3. Third Party Services" ]
 | 
			
		||||
        p [ _class "card-text" ] [
 | 
			
		||||
          str "This service utilizes a third-party service provider for identity management. Review the terms of "
 | 
			
		||||
          str "service for "
 | 
			
		||||
          a [ _href "https://auth0.com/terms"; _target "_blank" ] [ str "Auth0"]
 | 
			
		||||
          str ", as well as those for the selected authorization provider ("
 | 
			
		||||
          a [ _href "https://www.microsoft.com/en-us/servicesagreement"; _target "_blank" ] [ str "Microsoft"]
 | 
			
		||||
          str " or "
 | 
			
		||||
          a [ _href "https://policies.google.com/terms"; _target "_blank" ] [ str "Google" ]
 | 
			
		||||
          str ")."
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "list-group-item" ] [
 | 
			
		||||
        h3 [] [ str "4. Liability" ]
 | 
			
		||||
        p [ _class "card-text" ] [
 | 
			
		||||
          rawText "This service is provided “as is”, and no warranty (express or implied) exists. The "
 | 
			
		||||
          str "service and its developers may not be held liable for any damages that may arise through the use of "
 | 
			
		||||
          str "this service."
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "list-group-item" ] [
 | 
			
		||||
        h3 [] [ str "5. Updates to Terms" ]
 | 
			
		||||
        p [ _class "card-text" ] [
 | 
			
		||||
          str "These terms and conditions may be updated at any time, and this service does not have the capability to "
 | 
			
		||||
          str "notify users when these change. The date at the top of the page will be updated when any of the text of "
 | 
			
		||||
          str "these terms is updated."
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
  p [ _class "pt-3" ] [
 | 
			
		||||
    str "You may also wish to review our "
 | 
			
		||||
    pageLink "/legal/privacy-policy" [] [ str "privacy policy" ]
 | 
			
		||||
    str " to learn how we handle your data."
 | 
			
		||||
    ]
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -8,268 +8,260 @@ open NodaTime
 | 
			
		||||
 | 
			
		||||
/// Create a request within the list
 | 
			
		||||
let reqListItem now req =
 | 
			
		||||
  let reqId      = RequestId.toString req.requestId
 | 
			
		||||
  let isAnswered = req.lastStatus = Answered
 | 
			
		||||
  let isSnoozed  = req.snoozedUntil > now
 | 
			
		||||
  let isPending  = (not isSnoozed) && req.showAfter > now
 | 
			
		||||
  let btnClass   = _class "btn btn-light mx-2"
 | 
			
		||||
  let restoreBtn (link : string) title =
 | 
			
		||||
    button [ btnClass; _hxPatch $"/request/{reqId}/{link}"; _title title ] [ icon "restore" ]
 | 
			
		||||
  div [ _class "list-group-item px-0 d-flex flex-row align-items-start"; _hxTarget "this"; _hxSwap HxSwap.OuterHtml ] [
 | 
			
		||||
    pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ]
 | 
			
		||||
    match isAnswered with
 | 
			
		||||
    | true  -> ()
 | 
			
		||||
    | false -> pageLink $"/request/{reqId}/edit" [ btnClass; _title "Edit Request" ] [ icon "edit" ]
 | 
			
		||||
    match true with
 | 
			
		||||
    | _ when isSnoozed -> restoreBtn "cancel-snooze" "Cancel Snooze"
 | 
			
		||||
    | _ when isPending -> restoreBtn "show"          "Show Now"
 | 
			
		||||
    | _ -> ()
 | 
			
		||||
    p [ _class "request-text mb-0" ] [
 | 
			
		||||
      str req.text
 | 
			
		||||
      match isSnoozed || isPending || isAnswered with
 | 
			
		||||
      | true ->
 | 
			
		||||
          br []
 | 
			
		||||
          small [ _class "text-muted" ] [
 | 
			
		||||
            match () with
 | 
			
		||||
            | _ when isSnoozed   -> [ str "Snooze expires ";       relativeDate req.snoozedUntil now ]
 | 
			
		||||
            | _ when isPending   -> [ str "Request appears next "; relativeDate req.showAfter    now ]
 | 
			
		||||
            | _ (* isAnswered *) -> [ str "Answered ";             relativeDate req.asOf         now ]
 | 
			
		||||
            |> em []
 | 
			
		||||
            ]
 | 
			
		||||
      | false -> ()
 | 
			
		||||
      ]
 | 
			
		||||
    let reqId      = RequestId.toString req.requestId
 | 
			
		||||
    let isAnswered = req.lastStatus = Answered
 | 
			
		||||
    let isSnoozed  = req.snoozedUntil > now
 | 
			
		||||
    let isPending  = (not isSnoozed) && req.showAfter > now
 | 
			
		||||
    let btnClass   = _class "btn btn-light mx-2"
 | 
			
		||||
    let restoreBtn (link : string) title =
 | 
			
		||||
        button [ btnClass; _hxPatch $"/request/{reqId}/{link}"; _title title ] [ icon "restore" ]
 | 
			
		||||
    div [ _class "list-group-item px-0 d-flex flex-row align-items-start"
 | 
			
		||||
          _hxTarget "this"
 | 
			
		||||
          _hxSwap HxSwap.OuterHtml ] [
 | 
			
		||||
        pageLink $"/request/{reqId}/full" [ btnClass; _title "View Full Request" ] [ icon "description" ]
 | 
			
		||||
        if not isAnswered then pageLink $"/request/{reqId}/edit" [ btnClass; _title "Edit Request" ] [ icon "edit" ]
 | 
			
		||||
        if   isSnoozed then restoreBtn "cancel-snooze" "Cancel Snooze"
 | 
			
		||||
        elif isPending then restoreBtn "show"          "Show Now"
 | 
			
		||||
        p [ _class "request-text mb-0" ] [
 | 
			
		||||
            str req.text
 | 
			
		||||
            if isSnoozed || isPending || isAnswered then
 | 
			
		||||
                br []
 | 
			
		||||
                small [ _class "text-muted" ] [
 | 
			
		||||
                    if   isSnoozed then   [ str "Snooze expires ";       relativeDate req.snoozedUntil now ]
 | 
			
		||||
                    elif isPending then   [ str "Request appears next "; relativeDate req.showAfter    now ]
 | 
			
		||||
                    else (* isAnswered *) [ str "Answered ";             relativeDate req.asOf         now ]
 | 
			
		||||
                    |> em []
 | 
			
		||||
                ]
 | 
			
		||||
          ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// Create a list of requests
 | 
			
		||||
let reqList now reqs =
 | 
			
		||||
  reqs
 | 
			
		||||
  |> List.map (reqListItem now)
 | 
			
		||||
  |> div [ _class "list-group" ]
 | 
			
		||||
    reqs
 | 
			
		||||
    |> List.map (reqListItem now)
 | 
			
		||||
    |> div [ _class "list-group" ]
 | 
			
		||||
 | 
			
		||||
/// View for Active Requests page
 | 
			
		||||
let active now reqs = article [ _class "container mt-3" ] [
 | 
			
		||||
  h2 [ _class "pb-3" ] [ str "Active Requests" ]
 | 
			
		||||
  match reqs |> List.isEmpty with
 | 
			
		||||
  | true ->
 | 
			
		||||
      noResults "No Active Requests" "/journal" "Return to your journal"
 | 
			
		||||
        [ str "Your prayer journal has no active requests" ]
 | 
			
		||||
  | false -> reqList now reqs
 | 
			
		||||
  ]
 | 
			
		||||
let active now 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
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// View for Answered Requests page
 | 
			
		||||
let answered now reqs = article [ _class "container mt-3" ] [
 | 
			
		||||
  h2 [ _class "pb-3" ] [ str "Answered Requests" ]
 | 
			
		||||
  match reqs |> List.isEmpty with
 | 
			
		||||
  | true ->
 | 
			
		||||
      noResults "No Active Requests" "/journal" "Return to your journal" [
 | 
			
		||||
        rawText "Your prayer journal has no answered requests; once you have marked one as “Answered”, "
 | 
			
		||||
        str "it will appear here"
 | 
			
		||||
        ]
 | 
			
		||||
  | false -> reqList now reqs
 | 
			
		||||
  ]
 | 
			
		||||
let answered now reqs =
 | 
			
		||||
    article [ _class "container mt-3" ] [
 | 
			
		||||
        h2 [ _class "pb-3" ] [ str "Answered Requests" ]
 | 
			
		||||
        if List.isEmpty reqs then
 | 
			
		||||
            noResults "No Active Requests" "/journal" "Return to your journal" [
 | 
			
		||||
                str "Your prayer journal has no answered requests; once you have marked one as "
 | 
			
		||||
                rawText "“Answered”, it will appear here"
 | 
			
		||||
            ]
 | 
			
		||||
        else reqList now reqs
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// View for Snoozed Requests page
 | 
			
		||||
let snoozed now reqs = article [ _class "container mt-3" ] [
 | 
			
		||||
  h2 [ _class "pb-3" ] [ str "Snoozed Requests" ]
 | 
			
		||||
  reqList now reqs
 | 
			
		||||
  ]
 | 
			
		||||
let snoozed now reqs =
 | 
			
		||||
    article [ _class "container mt-3" ] [
 | 
			
		||||
        h2 [ _class "pb-3" ] [ str "Snoozed Requests" ]
 | 
			
		||||
        reqList now reqs
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// View for Full Request page
 | 
			
		||||
let full (clock : IClock) (req : Request) =
 | 
			
		||||
  let now = clock.GetCurrentInstant ()
 | 
			
		||||
  let answered =
 | 
			
		||||
    req.history
 | 
			
		||||
    |> List.filter RequestAction.isAnswered
 | 
			
		||||
    |> List.tryHead
 | 
			
		||||
    |> Option.map (fun x -> x.asOf)
 | 
			
		||||
  let prayed = (req.history |> List.filter RequestAction.isPrayed |> List.length).ToString "N0"
 | 
			
		||||
  let daysOpen =
 | 
			
		||||
    let asOf = defaultArg answered now
 | 
			
		||||
    ((asOf - (req.history |> List.filter RequestAction.isCreated |> List.head).asOf).TotalDays |> int).ToString "N0"
 | 
			
		||||
  let lastText =
 | 
			
		||||
    req.history
 | 
			
		||||
    |> List.filter (fun h -> Option.isSome h.text)
 | 
			
		||||
    |> List.sortByDescending (fun h -> h.asOf)
 | 
			
		||||
    |> List.map (fun h -> Option.get h.text)
 | 
			
		||||
    |> List.head
 | 
			
		||||
  // The history log including notes (and excluding the final entry for answered requests)
 | 
			
		||||
  let log =
 | 
			
		||||
    let toDisp (h : History) = {| asOf = h.asOf; text = h.text; status = RequestAction.toString h.status |}
 | 
			
		||||
    let all =
 | 
			
		||||
      req.notes
 | 
			
		||||
      |> List.map (fun n -> {| asOf = n.asOf; text = Some n.notes; status = "Notes" |})
 | 
			
		||||
      |> List.append (req.history |> List.map toDisp)
 | 
			
		||||
      |> List.sortByDescending (fun it -> it.asOf)
 | 
			
		||||
    // Skip the first entry for answered requests; that info is already displayed
 | 
			
		||||
    match answered with Some _ -> all |> List.skip 1 | None -> all
 | 
			
		||||
  article [ _class "container mt-3" ] [
 | 
			
		||||
    div [_class "card" ] [
 | 
			
		||||
      h5 [ _class "card-header" ] [ str "Full Prayer Request" ]
 | 
			
		||||
      div [ _class "card-body" ] [
 | 
			
		||||
        h6 [ _class "card-subtitle text-muted mb-2"] [
 | 
			
		||||
          match answered with
 | 
			
		||||
          | Some date ->
 | 
			
		||||
              str "Answered "
 | 
			
		||||
              date.ToDateTimeOffset().ToString ("D", null) |> str
 | 
			
		||||
              str " ("
 | 
			
		||||
              relativeDate date now
 | 
			
		||||
              rawText ") • "
 | 
			
		||||
          | None -> ()
 | 
			
		||||
          sprintf "Prayed %s times • Open %s days" prayed daysOpen |> rawText
 | 
			
		||||
          ]
 | 
			
		||||
        p [ _class "card-text" ] [ str lastText ]
 | 
			
		||||
    let now = clock.GetCurrentInstant ()
 | 
			
		||||
    let answered =
 | 
			
		||||
        req.history
 | 
			
		||||
        |> List.filter RequestAction.isAnswered
 | 
			
		||||
        |> List.tryHead
 | 
			
		||||
        |> Option.map (fun x -> x.asOf)
 | 
			
		||||
    let prayed = (req.history |> List.filter RequestAction.isPrayed |> List.length).ToString "N0"
 | 
			
		||||
    let daysOpen =
 | 
			
		||||
        let asOf = defaultArg answered now
 | 
			
		||||
        ((asOf - (req.history |> List.filter RequestAction.isCreated |> List.head).asOf).TotalDays |> int).ToString "N0"
 | 
			
		||||
    let lastText =
 | 
			
		||||
        req.history
 | 
			
		||||
        |> List.filter (fun h -> Option.isSome h.text)
 | 
			
		||||
        |> List.sortByDescending (fun h -> h.asOf)
 | 
			
		||||
        |> List.map (fun h -> Option.get h.text)
 | 
			
		||||
        |> List.head
 | 
			
		||||
    // The history log including notes (and excluding the final entry for answered requests)
 | 
			
		||||
    let log =
 | 
			
		||||
        let toDisp (h : History) = {| asOf = h.asOf; text = h.text; status = RequestAction.toString h.status |}
 | 
			
		||||
        let all =
 | 
			
		||||
            req.notes
 | 
			
		||||
            |> List.map (fun n -> {| asOf = n.asOf; text = Some n.notes; status = "Notes" |})
 | 
			
		||||
            |> List.append (req.history |> List.map toDisp)
 | 
			
		||||
            |> List.sortByDescending (fun it -> it.asOf)
 | 
			
		||||
        // Skip the first entry for answered requests; that info is already displayed
 | 
			
		||||
        match answered with Some _ -> all |> List.skip 1 | None -> all
 | 
			
		||||
    article [ _class "container mt-3" ] [
 | 
			
		||||
        div [_class "card" ] [
 | 
			
		||||
            h5 [ _class "card-header" ] [ str "Full Prayer Request" ]
 | 
			
		||||
            div [ _class "card-body" ] [
 | 
			
		||||
                h6 [ _class "card-subtitle text-muted mb-2"] [
 | 
			
		||||
                    match answered with
 | 
			
		||||
                    | Some date ->
 | 
			
		||||
                        str "Answered "
 | 
			
		||||
                        date.ToDateTimeOffset().ToString ("D", null) |> str
 | 
			
		||||
                        str " ("
 | 
			
		||||
                        relativeDate date now
 | 
			
		||||
                        rawText ") • "
 | 
			
		||||
                    | None -> ()
 | 
			
		||||
                    sprintf "Prayed %s times • Open %s days" prayed daysOpen |> rawText
 | 
			
		||||
                ]
 | 
			
		||||
                p [ _class "card-text" ] [ str lastText ]
 | 
			
		||||
            ]
 | 
			
		||||
            log
 | 
			
		||||
            |> List.map (fun it ->
 | 
			
		||||
                li [ _class "list-group-item" ] [
 | 
			
		||||
                    p [ _class "m-0" ] [
 | 
			
		||||
                        str it.status
 | 
			
		||||
                        rawText "  "
 | 
			
		||||
                        small [] [ em [] [ it.asOf.ToDateTimeOffset().ToString ("D", null) |> str ] ]
 | 
			
		||||
                    ]
 | 
			
		||||
                    match it.text with
 | 
			
		||||
                    | Some txt -> p [ _class "mt-2 mb-0" ] [ str txt ]
 | 
			
		||||
                    | None -> ()
 | 
			
		||||
                ])
 | 
			
		||||
            |> ul [ _class "list-group list-group-flush" ]
 | 
			
		||||
        ]
 | 
			
		||||
      log
 | 
			
		||||
      |> List.map (fun it -> li [ _class "list-group-item" ] [
 | 
			
		||||
        p [ _class "m-0" ] [
 | 
			
		||||
          str it.status
 | 
			
		||||
          rawText "  "
 | 
			
		||||
          small [] [ em [] [ it.asOf.ToDateTimeOffset().ToString ("D", null) |> str ] ]
 | 
			
		||||
          ]
 | 
			
		||||
        match it.text with
 | 
			
		||||
        | Some txt -> p [ _class "mt-2 mb-0" ] [ str txt ]
 | 
			
		||||
        | None -> ()
 | 
			
		||||
      ])
 | 
			
		||||
      |> ul [ _class "list-group list-group-flush" ]
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// View for the edit request component
 | 
			
		||||
let edit (req : JournalRequest) returnTo isNew =
 | 
			
		||||
  let cancelLink =
 | 
			
		||||
    match returnTo with
 | 
			
		||||
    | "active"          -> "/requests/active"
 | 
			
		||||
    | "snoozed"         -> "/requests/snoozed"
 | 
			
		||||
    | _ (* "journal" *) -> "/journal"
 | 
			
		||||
  let recurCount =
 | 
			
		||||
    match req.recurrence with
 | 
			
		||||
    | Immediate -> None
 | 
			
		||||
    | Hours   h -> Some h
 | 
			
		||||
    | Days    d -> Some d
 | 
			
		||||
    | Weeks   w -> Some w
 | 
			
		||||
    |> Option.map string
 | 
			
		||||
    |> Option.defaultValue ""
 | 
			
		||||
  article [ _class "container" ] [
 | 
			
		||||
    h2 [ _class "pb-3" ] [ (match isNew with true -> "Add" | false -> "Edit") |> strf "%s Prayer Request" ]
 | 
			
		||||
    form [
 | 
			
		||||
      _hxBoost
 | 
			
		||||
      _hxTarget "#top"
 | 
			
		||||
      _hxPushUrl
 | 
			
		||||
      "/request" |> match isNew with true -> _hxPost | false -> _hxPatch
 | 
			
		||||
      ] [
 | 
			
		||||
      input [
 | 
			
		||||
        _type  "hidden"
 | 
			
		||||
        _name  "requestId"
 | 
			
		||||
        _value (match isNew with true -> "new" | false -> RequestId.toString req.requestId)
 | 
			
		||||
        ]
 | 
			
		||||
      input [ _type "hidden"; _name "returnTo"; _value returnTo ]
 | 
			
		||||
      div [ _class "form-floating pb-3" ] [
 | 
			
		||||
        textarea [
 | 
			
		||||
          _id          "requestText"
 | 
			
		||||
          _name        "requestText"
 | 
			
		||||
          _class       "form-control"
 | 
			
		||||
          _style       "min-height: 8rem;"
 | 
			
		||||
          _placeholder "Enter the text of the request"
 | 
			
		||||
          _autofocus;  _required
 | 
			
		||||
          ] [ str req.text ]
 | 
			
		||||
        label [ _for "requestText" ] [ str "Prayer Request" ]
 | 
			
		||||
        ]
 | 
			
		||||
      br []
 | 
			
		||||
      match isNew with
 | 
			
		||||
      | true -> ()
 | 
			
		||||
      | false ->
 | 
			
		||||
          div [ _class "pb-3" ] [
 | 
			
		||||
            label [] [ str "Also Mark As" ]
 | 
			
		||||
    let cancelLink =
 | 
			
		||||
        match returnTo with
 | 
			
		||||
        | "active"          -> "/requests/active"
 | 
			
		||||
        | "snoozed"         -> "/requests/snoozed"
 | 
			
		||||
        | _ (* "journal" *) -> "/journal"
 | 
			
		||||
    let recurCount =
 | 
			
		||||
        match req.recurrence with
 | 
			
		||||
        | Immediate -> None
 | 
			
		||||
        | Hours   h -> Some h
 | 
			
		||||
        | Days    d -> Some d
 | 
			
		||||
        | Weeks   w -> Some w
 | 
			
		||||
        |> Option.map string
 | 
			
		||||
        |> Option.defaultValue ""
 | 
			
		||||
    article [ _class "container" ] [
 | 
			
		||||
        h2 [ _class "pb-3" ] [ (match isNew with true -> "Add" | false -> "Edit") |> strf "%s Prayer Request" ]
 | 
			
		||||
        form [ _hxBoost
 | 
			
		||||
               _hxTarget "#top"
 | 
			
		||||
               _hxPushUrl
 | 
			
		||||
               "/request" |> match isNew with true -> _hxPost | false -> _hxPatch ] [
 | 
			
		||||
            input [ _type  "hidden"
 | 
			
		||||
                    _name  "requestId"
 | 
			
		||||
                    _value (match isNew with true -> "new" | false -> RequestId.toString req.requestId) ]
 | 
			
		||||
            input [ _type "hidden"; _name "returnTo"; _value returnTo ]
 | 
			
		||||
            div [ _class "form-floating pb-3" ] [
 | 
			
		||||
                textarea [ _id          "requestText"
 | 
			
		||||
                           _name        "requestText"
 | 
			
		||||
                           _class       "form-control"
 | 
			
		||||
                           _style       "min-height: 8rem;"
 | 
			
		||||
                           _placeholder "Enter the text of the request"
 | 
			
		||||
                           _autofocus;  _required ] [ str req.text ]
 | 
			
		||||
                label [ _for "requestText" ] [ str "Prayer Request" ]
 | 
			
		||||
            ]
 | 
			
		||||
            br []
 | 
			
		||||
            div [ _class "form-check form-check-inline" ] [
 | 
			
		||||
              input [ _type "radio"; _class "form-check-input"; _id "sU"; _name "status"; _value "Updated"; _checked ]
 | 
			
		||||
              label [ _for "sU" ] [ str "Updated" ]
 | 
			
		||||
              ]
 | 
			
		||||
            div [ _class "form-check form-check-inline" ] [
 | 
			
		||||
              input [ _type "radio"; _class "form-check-input"; _id "sP"; _name "status"; _value "Prayed" ]
 | 
			
		||||
              label [ _for "sP" ] [ str "Prayed" ]
 | 
			
		||||
              ]
 | 
			
		||||
            div [ _class "form-check form-check-inline" ] [
 | 
			
		||||
              input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ]
 | 
			
		||||
              label [ _for "sA" ] [ str "Answered" ]
 | 
			
		||||
              ]
 | 
			
		||||
            if not isNew then
 | 
			
		||||
                div [ _class "pb-3" ] [
 | 
			
		||||
                    label [] [ str "Also Mark As" ]
 | 
			
		||||
                    br []
 | 
			
		||||
                    div [ _class "form-check form-check-inline" ] [
 | 
			
		||||
                        input [ _type  "radio"
 | 
			
		||||
                                _class "form-check-input"
 | 
			
		||||
                                _id    "sU"
 | 
			
		||||
                                _name  "status"
 | 
			
		||||
                                _value "Updated"
 | 
			
		||||
                                _checked ]
 | 
			
		||||
                        label [ _for "sU" ] [ str "Updated" ]
 | 
			
		||||
                    ]
 | 
			
		||||
                    div [ _class "form-check form-check-inline" ] [
 | 
			
		||||
                        input [ _type "radio"; _class "form-check-input"; _id "sP"; _name "status"; _value "Prayed" ]
 | 
			
		||||
                        label [ _for "sP" ] [ str "Prayed" ]
 | 
			
		||||
                    ]
 | 
			
		||||
                    div [ _class "form-check form-check-inline" ] [
 | 
			
		||||
                        input [ _type "radio"; _class "form-check-input"; _id "sA"; _name "status"; _value "Answered" ]
 | 
			
		||||
                        label [ _for "sA" ] [ str "Answered" ]
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
            div [ _class "row" ] [
 | 
			
		||||
                div [ _class "col-12 offset-md-2 col-md-8 offset-lg-3 col-lg-6" ] [
 | 
			
		||||
                    p [] [
 | 
			
		||||
                        strong [] [ rawText "Recurrence   " ]
 | 
			
		||||
                        em [ _class "text-muted" ] [ rawText "After prayer, request reappears…" ]
 | 
			
		||||
                    ]
 | 
			
		||||
                    div [ _class "d-flex flex-row flex-wrap justify-content-center align-items-center" ] [
 | 
			
		||||
                        div [ _class "form-check mx-2" ] [
 | 
			
		||||
                            input [ _type    "radio"
 | 
			
		||||
                                    _class   "form-check-input"
 | 
			
		||||
                                    _id      "rI"
 | 
			
		||||
                                    _name    "recurType"
 | 
			
		||||
                                    _value   "Immediate"
 | 
			
		||||
                                    _onclick "mpj.edit.toggleRecurrence(event)"
 | 
			
		||||
                                    match req.recurrence with Immediate -> _checked | _ -> () ]
 | 
			
		||||
                            label [ _for "rI" ] [ str "Immediately" ]
 | 
			
		||||
                        ]
 | 
			
		||||
                        div [ _class "form-check mx-2"] [
 | 
			
		||||
                            input [ _type    "radio"
 | 
			
		||||
                                    _class   "form-check-input"
 | 
			
		||||
                                    _id      "rO"
 | 
			
		||||
                                    _name    "recurType"
 | 
			
		||||
                                    _value   "Other"
 | 
			
		||||
                                    _onclick "mpj.edit.toggleRecurrence(event)"
 | 
			
		||||
                                    match req.recurrence with Immediate -> () | _ -> _checked ]
 | 
			
		||||
                            label [ _for "rO" ] [ rawText "Every…" ]
 | 
			
		||||
                        ]
 | 
			
		||||
                        div [ _class "form-floating mx-2"] [
 | 
			
		||||
                            input [ _type        "number"
 | 
			
		||||
                                    _class       "form-control"
 | 
			
		||||
                                    _id          "recurCount"
 | 
			
		||||
                                    _name        "recurCount"
 | 
			
		||||
                                    _placeholder "0"
 | 
			
		||||
                                    _value       recurCount
 | 
			
		||||
                                    _style       "width:6rem;"
 | 
			
		||||
                                    _required
 | 
			
		||||
                                    match req.recurrence with Immediate -> _disabled | _ -> () ]
 | 
			
		||||
                            label [ _for "recurCount" ] [ str "Count" ]
 | 
			
		||||
                        ]
 | 
			
		||||
                        div [ _class "form-floating mx-2" ] [
 | 
			
		||||
                            select [ _class    "form-control"
 | 
			
		||||
                                     _id       "recurInterval"
 | 
			
		||||
                                     _name     "recurInterval"
 | 
			
		||||
                                     _style    "width:6rem;"
 | 
			
		||||
                                     _required
 | 
			
		||||
                                     match req.recurrence with Immediate -> _disabled | _ -> () ] [
 | 
			
		||||
                                option [ _value "Hours"; match req.recurrence with Hours _ -> _selected | _ -> () ] [
 | 
			
		||||
                                    str "hours"
 | 
			
		||||
                                ]
 | 
			
		||||
                                option [ _value "Days";  match req.recurrence with Days  _ -> _selected | _ -> () ] [
 | 
			
		||||
                                    str "days"
 | 
			
		||||
                                ]
 | 
			
		||||
                                option [ _value "Weeks"; match req.recurrence with Weeks _ -> _selected | _ -> () ] [
 | 
			
		||||
                                    str "weeks"
 | 
			
		||||
                                ]
 | 
			
		||||
                            ]
 | 
			
		||||
                            label [ _form "recurInterval" ] [ str "Interval" ]
 | 
			
		||||
                        ]
 | 
			
		||||
                    ]
 | 
			
		||||
                ]
 | 
			
		||||
            ]
 | 
			
		||||
      div [ _class "row" ] [
 | 
			
		||||
        div [ _class "col-12 offset-md-2 col-md-8 offset-lg-3 col-lg-6" ] [
 | 
			
		||||
          p [] [
 | 
			
		||||
            strong [] [ rawText "Recurrence   " ]
 | 
			
		||||
            em [ _class "text-muted" ] [ rawText "After prayer, request reappears…" ]
 | 
			
		||||
            div [ _class "text-end pt-3" ] [
 | 
			
		||||
                button [ _class "btn btn-primary me-2"; _type "submit" ] [ icon "save"; str " Save" ]
 | 
			
		||||
                pageLink cancelLink [ _class "btn btn-secondary ms-2" ] [ icon "arrow_back"; str " Cancel" ]
 | 
			
		||||
            ]
 | 
			
		||||
          div [ _class "d-flex flex-row flex-wrap justify-content-center align-items-center" ] [
 | 
			
		||||
            div [ _class "form-check mx-2" ] [
 | 
			
		||||
              input [
 | 
			
		||||
                _type    "radio"
 | 
			
		||||
                _class   "form-check-input"
 | 
			
		||||
                _id      "rI"
 | 
			
		||||
                _name    "recurType"
 | 
			
		||||
                _value   "Immediate"
 | 
			
		||||
                _onclick "mpj.edit.toggleRecurrence(event)"
 | 
			
		||||
                match req.recurrence with Immediate -> _checked | _ -> ()
 | 
			
		||||
                ]
 | 
			
		||||
              label [ _for "rI" ] [ str "Immediately" ]
 | 
			
		||||
              ]
 | 
			
		||||
            div [ _class "form-check mx-2"] [
 | 
			
		||||
              input [
 | 
			
		||||
                _type    "radio"
 | 
			
		||||
                _class   "form-check-input"
 | 
			
		||||
                _id      "rO"
 | 
			
		||||
                _name    "recurType"
 | 
			
		||||
                _value   "Other"
 | 
			
		||||
                _onclick "mpj.edit.toggleRecurrence(event)"
 | 
			
		||||
                match req.recurrence with Immediate -> () | _ -> _checked
 | 
			
		||||
                ]
 | 
			
		||||
              label [ _for "rO" ] [ rawText "Every…" ]
 | 
			
		||||
              ]
 | 
			
		||||
            div [ _class "form-floating mx-2"] [
 | 
			
		||||
              input [
 | 
			
		||||
                _type        "number"
 | 
			
		||||
                _class       "form-control"
 | 
			
		||||
                _id          "recurCount"
 | 
			
		||||
                _name        "recurCount"
 | 
			
		||||
                _placeholder "0"
 | 
			
		||||
                _value       recurCount
 | 
			
		||||
                _style       "width:6rem;"
 | 
			
		||||
                _required
 | 
			
		||||
                match req.recurrence with Immediate -> _disabled | _ -> ()
 | 
			
		||||
                ]
 | 
			
		||||
              label [ _for "recurCount" ] [ str "Count" ]
 | 
			
		||||
              ]
 | 
			
		||||
            div [ _class "form-floating mx-2" ] [
 | 
			
		||||
              select [
 | 
			
		||||
                _class    "form-control"
 | 
			
		||||
                _id       "recurInterval"
 | 
			
		||||
                _name     "recurInterval"
 | 
			
		||||
                _style    "width:6rem;"
 | 
			
		||||
                _required
 | 
			
		||||
                match req.recurrence with Immediate -> _disabled | _ -> ()
 | 
			
		||||
                ] [
 | 
			
		||||
                option [ _value "Hours"; match req.recurrence with Hours _ -> _selected | _ -> () ] [ str "hours" ]
 | 
			
		||||
                option [ _value "Days";  match req.recurrence with Days  _ -> _selected | _ -> () ] [ str "days" ]
 | 
			
		||||
                option [ _value "Weeks"; match req.recurrence with Weeks _ -> _selected | _ -> () ] [ str "weeks" ]
 | 
			
		||||
                ]
 | 
			
		||||
              label [ _form "recurInterval" ] [ str "Interval" ]
 | 
			
		||||
              ]
 | 
			
		||||
            ]
 | 
			
		||||
          ]
 | 
			
		||||
        ]
 | 
			
		||||
      div [ _class "text-end pt-3" ] [
 | 
			
		||||
        button [ _class "btn btn-primary me-2"; _type "submit" ] [ icon "save"; str " Save" ]
 | 
			
		||||
        pageLink cancelLink [ _class "btn btn-secondary ms-2" ] [ icon "arrow_back"; str " Cancel" ]
 | 
			
		||||
        ]
 | 
			
		||||
      ]
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
/// Display a list of notes for a request
 | 
			
		||||
let notes now notes =
 | 
			
		||||
  let toItem (note : Note) =
 | 
			
		||||
    p [] [ small [ _class "text-muted" ] [ relativeDate note.asOf now ]; 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" ]
 | 
			
		||||
    | _  -> yield! notes |> List.map toItem
 | 
			
		||||
    let toItem (note : Note) =
 | 
			
		||||
        p [] [ small [ _class "text-muted" ] [ relativeDate note.asOf now ]; 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" ]
 | 
			
		||||
        | _  -> yield! notes |> List.map toItem
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user