20 Commits
0.9.7 ... 1.0.1

Author SHA1 Message Date
Daniel J. Summers
c0def7623f Updated docs link to new subdomain 2018-09-09 21:08:22 -05:00
Daniel J. Summers
ab8fab51c5 Create CNAME 2018-09-07 17:43:59 -05:00
Daniel J. Summers
96717ae7c4 Delete CNAME 2018-09-07 17:43:43 -05:00
Daniel J. Summers
b11ff0ff3d Create CNAME 2018-09-07 16:41:38 -05:00
Daniel J. Summers
6486763297 Delete CNAME 2018-09-07 16:41:23 -05:00
Daniel J. Summers
e94e28880b Create CNAME 2018-09-07 16:39:43 -05:00
Daniel J. Summers
16c3a9fd44 Updated docs for 1.0! 2018-08-21 20:53:44 -05:00
Daniel J. Summers
473359c645 Sort journal by appearance time (#21) 2018-08-21 20:44:39 -05:00
Daniel J. Summers
334710065c Upgrade to Vue CLI 3 / Webpack 4 (#22)
Fixes #19
2018-08-21 20:39:41 -05:00
Daniel J. Summers
ee9326d7a5 Updated docs for recurrence (#16) 2018-08-18 20:12:30 -05:00
Daniel J. Summers
2c34650ceb Completed recurrence work (#16)
Requests can now:
- be entered with recurrence
- be updated with recurrence
- manually skip recurrence period

Also did an app-wide clean-up to ensure that everything is done the same way in all places
2018-08-18 19:32:48 -05:00
Daniel J. Summers
9f1e258180 Added recurrence to new request add/edit page (#16)
still need to get the actual non-immediate recurrence stuff to work correctly
2018-08-18 13:46:03 -05:00
Daniel J. Summers
0c21e6c1c0 Unsnooze works; #20 is complete
also:
- wrapped up some other stylings
- snoozed requests still show in the journal; this logic will change with recurrence work, so that bug is deferred
2018-08-18 11:07:30 -05:00
Daniel J. Summers
a6144f67ec Converted notes modal (#20) 2018-08-18 09:13:38 -05:00
Daniel J. Summers
e351fe5b56 Active request page (#16)
Also:
- refactored snoozed and answered list pages to use a common list format
- reworked the URLs to make them more consistent
- eliminated current "full" API endpoint, and renamed existing "complete" endpoint to "full"
2018-08-17 22:12:14 -05:00
Daniel J. Summers
d3aff4a110 Add/Edit request works
No longer a modal (#20)
2018-08-17 20:59:57 -05:00
Daniel J. Summers
c0dd6b5dd6 A few more minor tweaks 2018-08-16 22:09:39 -05:00
Daniel J. Summers
5bba499251 -BootstrapVue -VueAwesome
- All non-modals no longer reference Bootstrap or Font Awesome (#20)
- Removed "full request" button from journal page (#16)
- Added role attributes in many places
2018-08-16 21:57:54 -05:00
Daniel J. Summers
96f2f2f7e0 Added recurrence SQL; updated API
API should support recurrence (#16); also updated for new F# match! statement
2018-08-14 21:01:21 -05:00
Daniel J. Summers
2bf3bc4865 Working toward hosting
Seeing different behavior when this is sitting behind nginx; working toward a solution
2018-08-07 08:17:56 -05:00
71 changed files with 4321 additions and 4758 deletions

4
.gitignore vendored
View File

@@ -254,8 +254,10 @@ paket-files/
# Compiled files / application # Compiled files / application
src/api/build src/api/build
src/api/MyPrayerJournal.Api/wwwroot/favicon.ico
src/api/MyPrayerJournal.Api/wwwroot/index.html src/api/MyPrayerJournal.Api/wwwroot/index.html
src/api/MyPrayerJournal.Api/wwwroot/static src/api/MyPrayerJournal.Api/wwwroot/css
src/api/MyPrayerJournal.Api/wwwroot/js
src/api/MyPrayerJournal.Api/appsettings.development.json src/api/MyPrayerJournal.Api/appsettings.development.json
/build /build
src/*.exe src/*.exe

1
docs/CNAME Normal file
View File

@@ -0,0 +1 @@
docs.prayerjournal.me

View File

@@ -16,15 +16,19 @@ myPrayerJournal uses login services using Google or Microsoft accounts. The only
## Your Prayer Journal ## Your Prayer Journal
Your current requests will be presented in three columns (or two or one, depending on the size of your screen or device). Each request is in its own card, and the buttons at the top of each card apply to that request. The last line of each request also tells you how long it has been since anything has been done on that request. Any time you see something like "a few minutes ago," you can hover over that to see the actual date/time the action was taken. Your current requests will be presented in columns (usually three, but it could be more or less, depending on the size of your screen or device). Each request is in its own card, and the buttons at the top of each card apply to that request. The last line of each request also tells you how long it has been since anything has been done on that request. Any time you see something like "a few minutes ago," you can hover over that to see the actual date/time the action was taken.
## Adding a Request ## Adding a Request
To add a request, click the "Add a New Request" button at the top of your journal. Then, enter the text of the request as you see fit; there is no right or wrong way, and you are the only person who will see the text you enter. When you save the request, it will go to the bottom of the list of requests. To add a request, click the "Add a New Request" button at the top of your journal. Then, enter the text of the request as you see fit; there is no right or wrong way, and you are the only person who will see the text you enter. When you save the request, it will go to the bottom of the list of requests.
## Setting Request Recurrence
When you add or update a request, you can choose whether requests go to the bottom of the journal once they have been marked "Prayed" or whether they will reappear after a delay. You can set recurrence in terms of hours, days, or weeks, but it cannot be longer than 365 days. If you decide you want a request to reappear sooner, you can skip the current delay; click the "Active" menu link, find the request in the list (likely near the bottom), and click the "Show Now" button.
## Praying for Requests ## Praying for Requests
The first button for each request has a checkmark icon; clicking this button will mark the request as "Prayed" and move it to the bottom of the list. This allows you, if you're praying through your requests, to start at the top left (with the request that it's been the longest since you've prayed) and click the button as you pray; when the request goes to the bottom of the list, the next-least-recently-prayed request will take the top spot. The first button for each request has a checkmark icon; clicking this button will mark the request as "Prayed" and move it to the bottom of the list (or off, if you've set a recurrence period for the request). This allows you, if you're praying through your requests, to start at the top left (with the request that it's been the longest since you've prayed) and click the button as you pray; when the request move below or away, the next-least-recently-prayed request will take the top spot.
## Editing Requests ## Editing Requests
@@ -32,22 +36,20 @@ The second button for each request has a pencil icon. This allows you to edit th
## Adding Notes ## Adding Notes
The third button for each request has an icon that looks like a piece of paper with writing; this lets you record notes about the request. If there is something you want to record that doesn't change the text of the request, this is the place to do it. For example, you may be praying for a long-term health issue, and that person tells you that their status is the same; or, you may want to record something God said to you while you were praying for that request. The third button for each request has an icon that looks like a speech bubble with lines on it; this lets you record notes about the request. If there is something you want to record that doesn't change the text of the request, this is the place to do it. For example, you may be praying for a long-term health issue, and that person tells you that their status is the same; or, you may want to record something God said to you while you were praying for that request.
## Viewing a Request and Its History
myPrayerJournal tracks all of the actions related to a request; the fourth button, with the magnifying glass icon, will show you the entire history, including the text as it changed, and all the times "Prayed" was recorded.
## Snoozing Requests ## Snoozing Requests
There may be a time where a request does not need to appear. The fifth button, with the clock icon, allows you to snooze requests until the day you specify. Additionally, if you have any snoozed requests, a "Snoozed" menu item will appear next to the "Journal" one; this page allows you to see what requests are snoozed, and return them to your journal by canceling the snooze. There may be a time where a request does not need to appear. The fourth button, with the clock icon, allows you to snooze requests until the day you specify. Additionally, if you have any snoozed requests, a "Snoozed" menu item will appear next to the "Journal" one; this page allows you to see what requests are snoozed, and return them to your journal by canceling the snooze.
## Answered Requests ## Viewing a Request and Its History
Next to "Journal" on the top navigation is the word "Answered." This page lists all answered requests, from most recent to least recent, along with the text of the request at the time it was marked as answered. It will also show you when it was marked answered. The button at the bottom of each request, with the magnifying glass and the words "Show Full Request", link to a page that shows that request's complete history and notes, along with a few statistics about that request. The history and notes are listed from most recent to least recent; if you want to read it chronologically, just press the "End" key on your keyboard and read it from the bottom up. myPrayerJournal tracks all of the actions related to a request; from the "Active" and "Answered" menu links (and "Snoozed", if it's showing), there is a "View Full Request" button. That page will show the current text of the request; how many times it has been marked as prayed; how long it has been an active request; and a log of all updates, prayers, and notes you have recorded. That log is listed from most recent to least recent; if you want to read it chronologically, just press the "End" key on your keyboard and read it from the bottom up.
The "Active" link will show all requests that have not yet been marked answered, including snoozed and recurring requests. If requests are snoozed, or in a recurrence period off the journal, there will be a button where you can return the request to the list (either "Cancel Snooze" or "Show Now"). The "Answered" link shows all requests that have been marked answered. The "Snoozed" link just shows snoozed requests.
## Final Notes ## Final Notes
- myPrayerJournal is currently in public beta. If you encounter errors, please [file an issue on GitHub](https://github.com/bit-badger/myPrayerJournal/issues) with as much detail as possible. You can also browse the list of issues to see what has been done and what is still left to do. - If you encounter errors, please [file an issue on GitHub](https://github.com/bit-badger/myPrayerJournal/issues) with as much detail as possible. You can also provide suggestions, or browse the list of currently open issues.
- Prayer requests and their history are securely backed up nightly along with other Bit Badger Solutions data. - Prayer requests and their history are securely backed up nightly along with other Bit Badger Solutions data.
- Prayer changes things - most of all, the one doing the praying. I pray that this tool enables you to deepen and strengthen your prayer life. - Prayer changes things - most of all, the one doing the praying. I pray that this tool enables you to deepen and strengthen your prayer life.

View File

@@ -88,7 +88,7 @@ module Entities =
m.Property(fun e -> e.notes).IsRequired () |> ignore) m.Property(fun e -> e.notes).IsRequired () |> ignore)
|> ignore |> ignore
// Request is the identifying record for a prayer request. /// Request is the identifying record for a prayer request
and [<CLIMutable; NoComparison; NoEquality>] Request = and [<CLIMutable; NoComparison; NoEquality>] Request =
{ /// The ID of the request { /// The ID of the request
requestId : RequestId requestId : RequestId
@@ -96,8 +96,14 @@ module Entities =
enteredOn : int64 enteredOn : int64
/// The ID of the user to whom this request belongs ("sub" from the JWT) /// The ID of the user to whom this request belongs ("sub" from the JWT)
userId : string userId : string
/// The time that this request should reappear in the user's journal /// The time at which this request should reappear in the user's journal by manual user choice
snoozedUntil : int64 snoozedUntil : int64
/// The time at which this request should reappear in the user's journal by recurrence
showAfter : int64
/// 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 /// The history entries for this request
history : ICollection<History> history : ICollection<History>
/// The notes for this request /// The notes for this request
@@ -110,6 +116,9 @@ module Entities =
enteredOn = 0L enteredOn = 0L
userId = "" userId = ""
snoozedUntil = 0L snoozedUntil = 0L
showAfter = 0L
recurType = "immediate"
recurCount = 0s
history = List<History> () history = List<History> ()
notes = List<Note> () notes = List<Note> ()
} }
@@ -123,6 +132,9 @@ module Entities =
m.Property(fun e -> e.enteredOn).IsRequired () |> ignore m.Property(fun e -> e.enteredOn).IsRequired () |> ignore
m.Property(fun e -> e.userId).IsRequired () |> ignore m.Property(fun e -> e.userId).IsRequired () |> ignore
m.Property(fun e -> e.snoozedUntil).IsRequired () |> ignore m.Property(fun e -> e.snoozedUntil).IsRequired () |> ignore
m.Property(fun e -> e.showAfter).IsRequired () |> ignore
m.Property(fun e -> e.recurType).IsRequired() |> ignore
m.Property(fun e -> e.recurCount).IsRequired() |> ignore
m.HasMany(fun e -> e.history :> IEnumerable<History>) m.HasMany(fun e -> e.history :> IEnumerable<History>)
.WithOne() .WithOne()
.HasForeignKey(fun e -> e.requestId :> obj) .HasForeignKey(fun e -> e.requestId :> obj)
@@ -149,6 +161,12 @@ module Entities =
lastStatus : string lastStatus : string
/// The time that this request should reappear in the user's journal /// The time that this request should reappear in the user's journal
snoozedUntil : int64 snoozedUntil : int64
/// The time after which this request should reappear in the user's journal by configured recurrence
showAfter : int64
/// The type of recurrence for this request
recurType : string
/// How many of the recurrence intervals should occur between appearances in the journal
recurCount : int16
/// History entries for the request /// History entries for the request
history : History list history : History list
/// Note entries for the request /// Note entries for the request
@@ -224,10 +242,10 @@ type AppDbContext (opts : DbContextOptions<AppDbContext>) =
member this.JournalByUserId userId : JournalRequest seq = member this.JournalByUserId userId : JournalRequest seq =
upcast this.Journal upcast this.Journal
.Where(fun r -> r.userId = userId && r.lastStatus <> "Answered") .Where(fun r -> r.userId = userId && r.lastStatus <> "Answered")
.OrderBy(fun r -> r.asOf) .OrderBy(fun r -> r.showAfter)
/// Retrieve a request by its ID and user ID /// Retrieve a request by its ID and user ID
member this.TryRequestById reqId userId : Task<Request option> = member this.TryRequestById reqId userId =
task { task {
let! req = this.Requests.AsNoTracking().FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId) let! req = this.Requests.AsNoTracking().FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId)
return toOption req return toOption req
@@ -236,8 +254,7 @@ type AppDbContext (opts : DbContextOptions<AppDbContext>) =
/// Retrieve notes for a request by its ID and user ID /// Retrieve notes for a request by its ID and user ID
member this.NotesById reqId userId = member this.NotesById reqId userId =
task { task {
let! req = this.TryRequestById reqId userId match! this.TryRequestById reqId userId with
match req with
| Some _ -> return this.Notes.AsNoTracking().Where(fun n -> n.requestId = reqId) |> List.ofSeq | Some _ -> return this.Notes.AsNoTracking().Where(fun n -> n.requestId = reqId) |> List.ofSeq
| None -> return [] | None -> return []
} }
@@ -250,34 +267,17 @@ type AppDbContext (opts : DbContextOptions<AppDbContext>) =
} }
/// Retrieve a request, including its history and notes, by its ID and user ID /// Retrieve a request, including its history and notes, by its ID and user ID
member this.TryCompleteRequestById requestId userId = member this.TryFullRequestById requestId userId =
task { task {
let! req = this.TryJournalById requestId userId match! this.TryJournalById requestId userId with
match req with | Some req ->
| Some r ->
let! fullReq = let! fullReq =
this.Requests.AsNoTracking() this.Requests.AsNoTracking()
.Include(fun r -> r.history) .Include(fun r -> r.history)
.Include(fun r -> r.notes) .Include(fun r -> r.notes)
.FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId) .FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId)
match toOption fullReq with match toOption fullReq with
| Some _ -> return Some { r with history = List.ofSeq fullReq.history; notes = List.ofSeq fullReq.notes } | Some _ -> return Some { req with history = List.ofSeq fullReq.history; notes = List.ofSeq fullReq.notes }
| None -> return None
| None -> return None
}
/// Retrieve a request, including its history, by its ID and user ID
member this.TryFullRequestById requestId userId =
task {
let! req = this.TryJournalById requestId userId
match req with
| Some r ->
let! fullReq =
this.Requests.AsNoTracking()
.Include(fun r -> r.history)
.FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId)
match toOption fullReq with
| Some _ -> return Some { r with history = List.ofSeq fullReq.history }
| None -> return None | None -> return None
| None -> return None | None -> return None
} }

View File

@@ -6,6 +6,14 @@ open Giraffe
open MyPrayerJournal open MyPrayerJournal
open System open System
/// Handler to return Vue files
module Vue =
/// The application index page
let app : HttpHandler = htmlFile "wwwroot/index.html"
/// Handlers for error conditions
module Error = module Error =
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
@@ -18,12 +26,12 @@ module Error =
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
let notFound : HttpHandler = let notFound : HttpHandler =
fun next ctx -> fun next ctx ->
[ "/answered"; "/journal"; "/snoozed"; "/user" ] [ "/journal"; "/legal"; "/request"; "/user" ]
|> List.filter ctx.Request.Path.Value.StartsWith |> List.filter ctx.Request.Path.Value.StartsWith
|> List.length |> List.length
|> function |> function
| 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx | 0 -> (setStatusCode 404 >=> json ([ "error", "not found" ] |> dict)) next ctx
| _ -> htmlFile "wwwroot/index.html" next ctx | _ -> Vue.app next ctx
/// Handler helpers /// Handler helpers
@@ -87,11 +95,31 @@ module Models =
notes : string notes : string
} }
/// Recurrence update
[<CLIMutable>]
type Recurrence =
{ /// The recurrence type
recurType : string
/// The recurrence cound
recurCount : int16
}
/// A prayer request /// A prayer request
[<CLIMutable>] [<CLIMutable>]
type Request = type Request =
{ /// The text of the request { /// The text of the request
requestText : string requestText : string
/// The recurrence type
recurType : string
/// The recurrence count
recurCount : int16
}
/// Reset the "showAfter" property on a request
[<CLIMutable>]
type Show =
{ /// The time after which the request should appear
showAfter : int64
} }
/// The time until which a request should not appear in the journal /// The time until which a request should not appear in the journal
@@ -119,6 +147,15 @@ module Request =
open NCuid open NCuid
/// Ticks per recurrence
let private recurrence =
[ "immediate", 0L
"hours", 3600000L
"days", 86400000L
"weeks", 604800000L
]
|> Map.ofList
/// POST /api/request /// POST /api/request
let add : HttpHandler = let add : HttpHandler =
authorize authorize
@@ -133,7 +170,9 @@ module Request =
requestId = reqId requestId = reqId
userId = usrId userId = usrId
enteredOn = now enteredOn = now
snoozedUntil = 0L showAfter = now
recurType = r.recurType
recurCount = r.recurCount
} }
|> db.AddEntry |> db.AddEntry
{ History.empty with { History.empty with
@@ -144,9 +183,8 @@ module Request =
} }
|> db.AddEntry |> db.AddEntry
let! _ = db.SaveChangesAsync () let! _ = db.SaveChangesAsync ()
let! req = db.TryJournalById reqId usrId match! db.TryJournalById reqId usrId with
match req with | Some req -> return! (setStatusCode 201 >=> json req) next ctx
| Some rqst -> return! (setStatusCode 201 >=> json rqst) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@@ -156,17 +194,21 @@ module Request =
>=> fun next ctx -> >=> fun next ctx ->
task { task {
let db = db ctx let db = db ctx
let! req = db.TryRequestById reqId (userId ctx) match! db.TryRequestById reqId (userId ctx) with
match req with | Some req ->
| Some _ ->
let! hist = ctx.BindJsonAsync<Models.HistoryEntry> () let! hist = ctx.BindJsonAsync<Models.HistoryEntry> ()
let now = jsNow ()
{ History.empty with { History.empty with
requestId = reqId requestId = reqId
asOf = jsNow () asOf = now
status = hist.status status = hist.status
text = match hist.updateText with null | "" -> None | x -> Some x text = match hist.updateText with null | "" -> None | x -> Some x
} }
|> db.AddEntry |> db.AddEntry
match hist.status with
| "Prayed" ->
db.UpdateEntry { req with showAfter = now + (recurrence.[req.recurType] * int64 req.recurCount) }
| _ -> ()
let! _ = db.SaveChangesAsync () let! _ = db.SaveChangesAsync ()
return! created next ctx return! created next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
@@ -178,8 +220,7 @@ module Request =
>=> fun next ctx -> >=> fun next ctx ->
task { task {
let db = db ctx let db = db ctx
let! req = db.TryRequestById reqId (userId ctx) match! db.TryRequestById reqId (userId ctx) with
match req with
| Some _ -> | Some _ ->
let! notes = ctx.BindJsonAsync<Models.NoteEntry> () let! notes = ctx.BindJsonAsync<Models.NoteEntry> ()
{ Note.empty with { Note.empty with
@@ -206,20 +247,8 @@ module Request =
authorize authorize
>=> fun next ctx -> >=> fun next ctx ->
task { task {
let! req = (db ctx).TryJournalById reqId (userId ctx) match! (db ctx).TryJournalById reqId (userId ctx) with
match req with | Some req -> return! json req next ctx
| Some r -> return! json r next ctx
| None -> return! Error.notFound next ctx
}
/// GET /api/request/[req-id]/complete
let getComplete reqId : HttpHandler =
authorize
>=> fun next ctx ->
task {
let! req = (db ctx).TryCompleteRequestById reqId (userId ctx)
match req with
| Some r -> return! json r next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@@ -228,9 +257,8 @@ module Request =
authorize authorize
>=> fun next ctx -> >=> fun next ctx ->
task { task {
let! req = (db ctx).TryFullRequestById reqId (userId ctx) match! (db ctx).TryFullRequestById reqId (userId ctx) with
match req with | Some req -> return! json req next ctx
| Some r -> return! json r next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@@ -243,17 +271,48 @@ module Request =
return! json notes next ctx return! json notes next ctx
} }
/// POST /api/request/[req-id]/snooze /// PATCH /api/request/[req-id]/show
let snooze reqId : HttpHandler = let show reqId : HttpHandler =
authorize authorize
>=> fun next ctx -> >=> fun next ctx ->
task { task {
let db = db ctx let db = db ctx
let! req = db.TryRequestById reqId (userId ctx) match! db.TryRequestById reqId (userId ctx) with
match req with | Some req ->
| Some r -> let! show = ctx.BindJsonAsync<Models.Show> ()
let! until = ctx.BindJsonAsync<Models.SnoozeUntil> () { req with showAfter = show.showAfter }
{ r with snoozedUntil = until.until } |> db.UpdateEntry
let! _ = db.SaveChangesAsync ()
return! setStatusCode 204 next ctx
| None -> return! Error.notFound next ctx
}
/// PATCH /api/request/[req-id]/snooze
let snooze reqId : HttpHandler =
authorize
>=> fun next ctx ->
task {
let db = db ctx
match! db.TryRequestById reqId (userId ctx) with
| Some req ->
let! until = ctx.BindJsonAsync<Models.SnoozeUntil> ()
{ req with snoozedUntil = until.until; showAfter = until.until }
|> db.UpdateEntry
let! _ = db.SaveChangesAsync ()
return! setStatusCode 204 next ctx
| None -> return! Error.notFound next ctx
}
/// PATCH /api/request/[req-id]/recurrence
let updateRecurrence reqId : HttpHandler =
authorize
>=> fun next ctx ->
task {
let db = db ctx
match! db.TryRequestById reqId (userId ctx) with
| Some req ->
let! recur = ctx.BindJsonAsync<Models.Recurrence> ()
{ req with recurType = recur.recurType; recurCount = recur.recurCount }
|> db.UpdateEntry |> db.UpdateEntry
let! _ = db.SaveChangesAsync () let! _ = db.SaveChangesAsync ()
return! setStatusCode 204 next ctx return! setStatusCode 204 next ctx

View File

@@ -53,23 +53,29 @@ module Configure =
/// Routes for the available URLs within myPrayerJournal /// Routes for the available URLs within myPrayerJournal
let webApp = let webApp =
router Handlers.Error.notFound [ router Handlers.Error.notFound [
route "/" Handlers.Vue.app
subRoute "/api/" [ subRoute "/api/" [
GET [ GET [
route "journal" Handlers.Journal.journal route "journal" Handlers.Journal.journal
subRoute "request" [ subRoute "request" [
route "s/answered" Handlers.Request.answered route "s/answered" Handlers.Request.answered
routef "/%s/complete" Handlers.Request.getComplete
routef "/%s/full" Handlers.Request.getFull routef "/%s/full" Handlers.Request.getFull
routef "/%s/notes" Handlers.Request.getNotes routef "/%s/notes" Handlers.Request.getNotes
routef "/%s" Handlers.Request.get routef "/%s" Handlers.Request.get
] ]
] ]
PATCH [
subRoute "request" [
routef "/%s/recurrence" Handlers.Request.updateRecurrence
routef "/%s/show" Handlers.Request.show
routef "/%s/snooze" Handlers.Request.snooze
]
]
POST [ POST [
subRoute "request" [ subRoute "request" [
route "" Handlers.Request.add route "" Handlers.Request.add
routef "/%s/history" Handlers.Request.addHistory routef "/%s/history" Handlers.Request.addHistory
routef "/%s/note" Handlers.Request.addNote routef "/%s/note" Handlers.Request.addNote
routef "/%s/snooze" Handlers.Request.snooze
] ]
] ]
] ]

View File

@@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15 # Visual Studio 15
VisualStudioVersion = 15.0.27703.2035 VisualStudioVersion = 15.0.27703.2035
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyPrayerJournal.Api", "MyPrayerJournal.Api\MyPrayerJournal.Api.fsproj", "{E0E5240C-00DC-428A-899A-DA4F06625B8A}" Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyPrayerJournal.Api", "MyPrayerJournal.Api\MyPrayerJournal.Api.fsproj", "{E0E5240C-00DC-428A-899A-DA4F06625B8A}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@@ -1,18 +0,0 @@
{
"presets": [
["env", {
"modules": false,
"targets": {
"browsers": ["> 1%", "last 2 versions", "not ie <= 8"]
}
}],
"stage-2"
],
"plugins": ["transform-runtime"],
"env": {
"test": {
"presets": ["env", "stage-2"],
"plugins": ["istanbul"]
}
}
}

3
src/app/.browserslistrc Normal file
View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie <= 8

View File

@@ -1,9 +0,0 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -1,2 +0,0 @@
build/*.js
config/*.js

View File

@@ -1,27 +1,17 @@
// http://eslint.org/docs/user-guide/configuring
module.exports = { module.exports = {
root: true, root: true,
parser: 'babel-eslint',
parserOptions: {
sourceType: 'module'
},
env: { env: {
browser: true, node: true
}, },
// https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style 'extends': [
extends: 'standard', 'plugin:vue/essential',
// required to lint *.vue files '@vue/standard'
plugins: [
'html'
], ],
// add your custom rules here rules: {
'rules': { 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
// allow paren-less arrow functions 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
'arrow-parens': 0, },
// allow async-await parserOptions: {
'generator-star-spacing': 0, parser: 'babel-eslint'
// allow debugger during development
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0
} }
} }

15
src/app/.gitignore vendored
View File

@@ -1,19 +1,24 @@
.DS_Store .DS_Store
node_modules/ node_modules
dist/ /dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
test/unit/coverage
test/e2e/reports
selenium-debug.log
# Editor directories and files # Editor directories and files
.idea .idea
.vscode
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
*.sln *.sln
*.sw*
# Auth0 settings # Auth0 settings
src/auth/auth0-variables.js src/auth/auth0-variables.js

View File

@@ -1,8 +1,5 @@
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = { module.exports = {
"plugins": { plugins: {
// to edit target browsers: use "browserslist" field in package.json autoprefixer: {}
"autoprefixer": {}
} }
} }

5
src/app/babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/app'
]
}

View File

@@ -1,35 +0,0 @@
require('./check-versions')()
process.env.NODE_ENV = 'production'
var ora = require('ora')
var rm = require('rimraf')
var path = require('path')
var chalk = require('chalk')
var webpack = require('webpack')
var config = require('../config')
var webpackConfig = require('./webpack.prod.conf')
var spinner = ora('building for production...')
spinner.start()
rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => {
if (err) throw err
webpack(webpackConfig, function (err, stats) {
spinner.stop()
if (err) throw err
process.stdout.write(stats.toString({
colors: true,
modules: false,
children: false,
chunks: false,
chunkModules: false
}) + '\n\n')
console.log(chalk.cyan(' Build complete.\n'))
console.log(chalk.yellow(
' Tip: built files are meant to be served over an HTTP server.\n' +
' Opening index.html over file:// won\'t work.\n'
))
})
})

View File

@@ -1,48 +0,0 @@
var chalk = require('chalk')
var semver = require('semver')
var packageConfig = require('../package.json')
var shell = require('shelljs')
function exec (cmd) {
return require('child_process').execSync(cmd).toString().trim()
}
var versionRequirements = [
{
name: 'node',
currentVersion: semver.clean(process.version),
versionRequirement: packageConfig.engines.node
},
]
if (shell.which('npm')) {
versionRequirements.push({
name: 'npm',
currentVersion: exec('npm --version'),
versionRequirement: packageConfig.engines.npm
})
}
module.exports = function () {
var warnings = []
for (var i = 0; i < versionRequirements.length; i++) {
var mod = versionRequirements[i]
if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
warnings.push(mod.name + ': ' +
chalk.red(mod.currentVersion) + ' should be ' +
chalk.green(mod.versionRequirement)
)
}
}
if (warnings.length) {
console.log('')
console.log(chalk.yellow('To use this template, you must update following to modules:'))
console.log()
for (var i = 0; i < warnings.length; i++) {
var warning = warnings[i]
console.log(' ' + warning)
}
console.log()
process.exit(1)
}
}

View File

@@ -1,9 +0,0 @@
/* eslint-disable */
require('eventsource-polyfill')
var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true')
hotClient.subscribe(function (event) {
if (event.action === 'reload') {
window.location.reload()
}
})

View File

@@ -1,92 +0,0 @@
require('./check-versions')()
var config = require('../config')
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV)
}
var opn = require('opn')
var path = require('path')
var express = require('express')
var webpack = require('webpack')
var proxyMiddleware = require('http-proxy-middleware')
var webpackConfig = process.env.NODE_ENV === 'testing'
? require('./webpack.prod.conf')
: require('./webpack.dev.conf')
// default port where dev server listens for incoming traffic
var port = process.env.PORT || config.dev.port
// automatically open browser, if not set will be false
var autoOpenBrowser = !!config.dev.autoOpenBrowser
// Define HTTP proxies to your custom API backend
// https://github.com/chimurai/http-proxy-middleware
var proxyTable = config.dev.proxyTable
var app = express()
var compiler = webpack(webpackConfig)
var devMiddleware = require('webpack-dev-middleware')(compiler, {
publicPath: webpackConfig.output.publicPath,
quiet: true
})
var hotMiddleware = require('webpack-hot-middleware')(compiler, {
log: () => {},
heartbeat: 2000
})
// force page reload when html-webpack-plugin template changes
compiler.plugin('compilation', function (compilation) {
compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
hotMiddleware.publish({ action: 'reload' })
cb()
})
})
// proxy api requests
Object.keys(proxyTable).forEach(function (context) {
var options = proxyTable[context]
if (typeof options === 'string') {
options = { target: options }
}
app.use(proxyMiddleware(options.filter || context, options))
})
// handle fallback for HTML5 history API
app.use(require('connect-history-api-fallback')())
// serve webpack bundle output
app.use(devMiddleware)
// enable hot-reload and state-preserving
// compilation error display
app.use(hotMiddleware)
// serve pure static assets
var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)
app.use(staticPath, express.static('./static'))
var uri = 'http://localhost:' + port
var _resolve
var readyPromise = new Promise(resolve => {
_resolve = resolve
})
console.log('> Starting dev server...')
devMiddleware.waitUntilValid(() => {
console.log('> Listening at ' + uri + '\n')
// when env is testing, don't need open it
if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') {
opn(uri)
}
_resolve()
})
var server = app.listen(port)
module.exports = {
ready: readyPromise,
close: () => {
server.close()
}
}

View File

@@ -1,71 +0,0 @@
var path = require('path')
var config = require('../config')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
exports.assetsPath = function (_path) {
var assetsSubDirectory = process.env.NODE_ENV === 'production'
? config.build.assetsSubDirectory
: config.dev.assetsSubDirectory
return path.posix.join(assetsSubDirectory, _path)
}
exports.cssLoaders = function (options) {
options = options || {}
var cssLoader = {
loader: 'css-loader',
options: {
minimize: process.env.NODE_ENV === 'production',
sourceMap: options.sourceMap
}
}
// generate loader string to be used with extract text plugin
function generateLoaders (loader, loaderOptions) {
var loaders = [cssLoader]
if (loader) {
loaders.push({
loader: loader + '-loader',
options: Object.assign({}, loaderOptions, {
sourceMap: options.sourceMap
})
})
}
// Extract CSS when that option is specified
// (which is the case during production build)
if (options.extract) {
return ExtractTextPlugin.extract({
use: loaders,
fallback: 'vue-style-loader'
})
} else {
return ['vue-style-loader'].concat(loaders)
}
}
// https://vue-loader.vuejs.org/en/configurations/extract-css.html
return {
css: generateLoaders(),
postcss: generateLoaders(),
less: generateLoaders('less'),
sass: generateLoaders('sass', { indentedSyntax: true }),
scss: generateLoaders('sass'),
stylus: generateLoaders('stylus'),
styl: generateLoaders('stylus')
}
}
// Generate loaders for standalone style files (outside of .vue)
exports.styleLoaders = function (options) {
var output = []
var loaders = exports.cssLoaders(options)
for (var extension in loaders) {
var loader = loaders[extension]
output.push({
test: new RegExp('\\.' + extension + '$'),
use: loader
})
}
return output
}

View File

@@ -1,18 +0,0 @@
var utils = require('./utils')
var config = require('../config')
var isProduction = process.env.NODE_ENV === 'production'
module.exports = {
loaders: utils.cssLoaders({
sourceMap: isProduction
? config.build.productionSourceMap
: config.dev.cssSourceMap,
extract: isProduction
}),
transformToRequire: {
video: 'src',
source: 'src',
img: 'src',
image: 'xlink:href'
}
}

View File

@@ -1,75 +0,0 @@
var path = require('path')
var utils = require('./utils')
var config = require('../config')
var vueLoaderConfig = require('./vue-loader.conf')
function resolve (dir) {
return path.join(__dirname, '..', dir)
}
module.exports = {
entry: {
app: './src/main.js'
},
output: {
path: config.build.assetsRoot,
filename: '[name].js',
publicPath: process.env.NODE_ENV === 'production'
? config.build.assetsPublicPath
: config.dev.assetsPublicPath
},
resolve: {
extensions: ['.js', '.vue', '.json'],
alias: {
'vue$': 'vue/dist/vue.esm.js',
'@': resolve('src')
}
},
module: {
rules: [
{
test: /\.(js|vue)$/,
loader: 'eslint-loader',
enforce: 'pre',
include: [resolve('src'), resolve('test')],
options: {
formatter: require('eslint-friendly-formatter')
}
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: vueLoaderConfig
},
{
test: /\.js$/,
loader: 'babel-loader',
include: [resolve('src'), resolve('test')]
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('img/[name].[hash:7].[ext]')
}
},
{
test: /\.(mp4|webm|ogg|mp3|wav|flac|aac)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('media/[name].[hash:7].[ext]')
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
loader: 'url-loader',
options: {
limit: 10000,
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
}
]
}
}

View File

@@ -1,35 +0,0 @@
var utils = require('./utils')
var webpack = require('webpack')
var config = require('../config')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var HtmlWebpackPlugin = require('html-webpack-plugin')
var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
// add hot-reload related code to entry chunks
Object.keys(baseWebpackConfig.entry).forEach(function (name) {
baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name])
})
module.exports = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap })
},
// cheap-module-eval-source-map is faster for development
devtool: '#cheap-module-eval-source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': config.dev.env
}),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
// https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'index.html',
inject: true
}),
new FriendlyErrorsPlugin()
]
})

View File

@@ -1,125 +0,0 @@
var path = require('path')
var utils = require('./utils')
var webpack = require('webpack')
var config = require('../config')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var CopyWebpackPlugin = require('copy-webpack-plugin')
var HtmlWebpackPlugin = require('html-webpack-plugin')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin')
var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env')
: config.build.env
var webpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({
sourceMap: config.build.productionSourceMap,
extract: true
}),
noParse: [/moment.js/]
},
devtool: config.build.productionSourceMap ? '#source-map' : false,
output: {
path: config.build.assetsRoot,
filename: utils.assetsPath('js/[name].[chunkhash].js'),
chunkFilename: utils.assetsPath('js/[id].[chunkhash].js')
},
plugins: [
// http://vuejs.github.io/vue-loader/en/workflow/production.html
new webpack.DefinePlugin({
'process.env': env
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
},
sourceMap: true
}),
// extract css into its own file
new ExtractTextPlugin({
filename: utils.assetsPath('css/[name].[contenthash].css')
}),
// Compress extracted CSS. We are using this plugin so that possible
// duplicated CSS from different components can be deduped.
new OptimizeCSSPlugin({
cssProcessorOptions: {
safe: true
}
}),
// generate dist index.html with correct asset hash for caching.
// you can customize output by editing /index.html
// see https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename: process.env.NODE_ENV === 'testing'
? 'index.html'
: config.build.index,
template: 'index.html',
inject: true,
minify: {
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true
// more options:
// https://github.com/kangax/html-minifier#options-quick-reference
},
// necessary to consistently work with multiple chunks via CommonsChunkPlugin
chunksSortMode: 'dependency'
}),
// split vendor js into its own file
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module, count) {
// any required modules inside node_modules are extracted to vendor
return (
module.resource &&
/\.js$/.test(module.resource) &&
module.resource.indexOf(
path.join(__dirname, '../node_modules')
) === 0
)
}
}),
// extract webpack runtime and module manifest to its own file in order to
// prevent vendor hash from being updated whenever app bundle is updated
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
chunks: ['vendor']
}),
// copy custom static assets
new CopyWebpackPlugin([
{
from: path.resolve(__dirname, '../static'),
to: config.build.assetsSubDirectory,
ignore: ['.*']
}
])
]
})
if (config.build.productionGzip) {
var CompressionWebpackPlugin = require('compression-webpack-plugin')
webpackConfig.plugins.push(
new CompressionWebpackPlugin({
asset: '[path].gz[query]',
algorithm: 'gzip',
test: new RegExp(
'\\.(' +
config.build.productionGzipExtensions.join('|') +
')$'
),
threshold: 10240,
minRatio: 0.8
})
)
}
if (config.build.bundleAnalyzerReport) {
var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
webpackConfig.plugins.push(new BundleAnalyzerPlugin())
}
module.exports = webpackConfig

View File

@@ -1,31 +0,0 @@
// This is the webpack config used for unit tests.
var utils = require('./utils')
var webpack = require('webpack')
var merge = require('webpack-merge')
var baseConfig = require('./webpack.base.conf')
var webpackConfig = merge(baseConfig, {
// use inline sourcemap for karma-sourcemap-loader
module: {
rules: utils.styleLoaders()
},
devtool: '#inline-source-map',
resolveLoader: {
alias: {
// necessary to to make lang="scss" work in test when using vue-loader's ?inject option
// see discussion at https://github.com/vuejs/vue-loader/issues/724
'scss-loader': 'sass-loader'
}
},
plugins: [
new webpack.DefinePlugin({
'process.env': require('../config/test.env')
})
]
})
// no need for app entry during tests
delete webpackConfig.entry
module.exports = webpackConfig

View File

@@ -1,6 +0,0 @@
var merge = require('webpack-merge')
var prodEnv = require('./prod.env')
module.exports = merge(prodEnv, {
NODE_ENV: '"development"'
})

View File

@@ -1,38 +0,0 @@
// see http://vuejs-templates.github.io/webpack for documentation.
var path = require('path')
module.exports = {
build: {
env: require('./prod.env'),
index: path.resolve(__dirname, '../../api/MyPrayerJournal.Api/wwwroot/index.html'),
assetsRoot: path.resolve(__dirname, '../../api/MyPrayerJournal.Api/wwwroot'),
assetsSubDirectory: 'static',
assetsPublicPath: '/',
productionSourceMap: true,
// Gzip off by default as many popular static hosts such as
// Surge or Netlify already gzip all static assets for you.
// Before setting to `true`, make sure to:
// npm install --save-dev compression-webpack-plugin
productionGzip: false,
productionGzipExtensions: ['js', 'css'],
// Run the build command with an extra argument to
// View the bundle analyzer report after build finishes:
// `npm run build --report`
// Set to `true` or `false` to always turn it on or off
bundleAnalyzerReport: process.env.npm_config_report
},
dev: {
env: require('./dev.env'),
port: 8080,
autoOpenBrowser: true,
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README
// (https://github.com/webpack/css-loader#sourcemaps)
// In our experience, they generally work as expected,
// just be aware of this issue when enabling this option.
cssSourceMap: false
}
}

View File

@@ -1,3 +0,0 @@
module.exports = {
NODE_ENV: '"production"'
}

View File

@@ -1,6 +0,0 @@
var merge = require('webpack-merge')
var devEnv = require('./dev.env')
module.exports = merge(devEnv, {
NODE_ENV: '"testing"'
})

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>myPrayerJournal</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
</head>
<body>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -1,107 +1,33 @@
{ {
"name": "my-prayer-journal", "name": "my-prayer-journal",
"version": "0.9.7", "version": "1.0.1",
"description": "myPrayerJournal - Front End", "description": "myPrayerJournal - Front End",
"author": "Daniel J. Summers <daniel@bitbadger.solutions>", "author": "Daniel J. Summers <daniel@bitbadger.solutions>",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node build/dev-server.js", "serve": "vue-cli-service serve",
"start": "node build/dev-server.js", "build": "vue-cli-service build",
"build": "node build/build.js", "lint": "vue-cli-service lint",
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
"e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
"apistart": "cd ../api/MyPrayerJournal.Api && dotnet run", "apistart": "cd ../api/MyPrayerJournal.Api && dotnet run",
"vue": "node build/build.js prod && cd ../api/MyPrayerJournal.Api && dotnet run" "vue": "vue-cli-service build --modern && cd ../api/MyPrayerJournal.Api && dotnet run"
}, },
"dependencies": { "dependencies": {
"auth0-js": "^9.3.3", "auth0-js": "^9.7.3",
"axios": "^0.18.0", "axios": "^0.18.0",
"bootstrap": "^4.0.0",
"bootstrap-vue": "^1.0.0-beta.9",
"moment": "^2.18.1", "moment": "^2.18.1",
"pug": "^2.0.1",
"vue": "^2.5.15", "vue": "^2.5.15",
"vue-awesome": "^2.3.3",
"vue-progressbar": "^0.7.3", "vue-progressbar": "^0.7.3",
"vue-router": "^3.0.0", "vue-router": "^3.0.0",
"vue-toast": "^3.1.0", "vue-toast": "^3.1.0",
"vuex": "^3.0.0" "vuex": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"autoprefixer": "^7.1.4", "@vue/cli-plugin-babel": "^3.0.0",
"babel-core": "^6.26.0", "@vue/cli-plugin-eslint": "^3.0.0",
"babel-eslint": "^7.1.1", "@vue/cli-service": "^3.0.0",
"babel-loader": "^7.1.2", "@vue/eslint-config-standard": "^3.0.0",
"babel-plugin-istanbul": "^4.1.5", "pug": "^2.0.1",
"babel-plugin-transform-runtime": "^6.22.0", "pug-plain-loader": "^1.0.0",
"babel-preset-env": "^1.3.2", "vue-template-compiler": "^2.5.17"
"babel-preset-stage-2": "^6.22.0", }
"babel-register": "^6.26.0",
"chai": "^3.5.0",
"chalk": "^2.1.0",
"chromedriver": "^2.32.3",
"connect-history-api-fallback": "^1.3.0",
"copy-webpack-plugin": "^4.0.1",
"cross-env": "^5.0.5",
"cross-spawn": "^5.0.1",
"css-loader": "^0.28.7",
"cssnano": "^3.10.0",
"eslint": "^3.19.0",
"eslint-config-standard": "^6.2.1",
"eslint-friendly-formatter": "^3.0.0",
"eslint-loader": "^1.7.1",
"eslint-plugin-html": "^3.2.2",
"eslint-plugin-promise": "^3.4.0",
"eslint-plugin-standard": "^2.0.1",
"eventsource-polyfill": "^0.9.6",
"express": "^4.15.4",
"extract-text-webpack-plugin": "^2.0.0",
"file-loader": "^0.11.1",
"friendly-errors-webpack-plugin": "^1.1.3",
"html-webpack-plugin": "^2.30.1",
"http-proxy-middleware": "^0.17.3",
"inject-loader": "^3.0.1",
"karma": "^1.7.1",
"karma-coverage": "^1.1.1",
"karma-mocha": "^1.3.0",
"karma-phantomjs-launcher": "^1.0.2",
"karma-phantomjs-shim": "^1.4.0",
"karma-sinon-chai": "^1.3.2",
"karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.31",
"karma-webpack": "^2.0.2",
"lolex": "^1.5.2",
"mocha": "^3.5.3",
"nightwatch": "^0.9.12",
"opn": "^5.1.0",
"optimize-css-assets-webpack-plugin": "^2.0.0",
"ora": "^1.2.0",
"phantomjs-prebuilt": "^2.1.15",
"rimraf": "^2.6.2",
"selenium-server": "^3.5.3",
"semver": "^5.4.1",
"shelljs": "^0.7.6",
"sinon": "^2.4.1",
"sinon-chai": "^2.13.0",
"url-loader": "^0.5.8",
"vue-loader": "^12.1.0",
"vue-style-loader": "^3.0.3",
"vue-template-compiler": "^2.4.4",
"webpack": "^2.6.1",
"webpack-bundle-analyzer": "^2.9.0",
"webpack-dev-middleware": "^1.12.0",
"webpack-hot-middleware": "^2.19.1",
"webpack-merge": "^4.1.0"
},
"engines": {
"node": ">= 4.0.0",
"npm": ">= 3.0.0"
},
"browserslist": [
"> 1%",
"last 2 versions",
"not ie <= 8"
]
} }

BIN
src/app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

19
src/app/public/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="preload" as="style">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<title>myPrayerJournal</title>
</head>
<body>
<noscript>
<strong>We're sorry but newapp doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -1,25 +1,25 @@
<template lang="pug"> <template lang="pug">
#app #app(role='application')
navigation navigation
#content.container #content
router-view router-view
vue-progress-bar vue-progress-bar
toast(ref='toast') toast(ref='toast')
footer footer.mpj-text-right.mpj-muted-text
p.text-right.text-muted p
| myPrayerJournal v{{ version }} | myPrayerJournal v{{ version }}
br br
em: small. em: small.
#[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] &bull; #[router-link(:to="{ name: 'PrivacyPolicy' }") Privacy Policy] &bull;
#[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] &bull; #[router-link(:to="{ name: 'TermsOfService' }") Terms of Service] &bull;
#[a(href='https://github.com/bit-badger/myprayerjournal') Developed] and hosted by #[a(href='https://github.com/bit-badger/myprayerjournal' target='_blank') Developed] and hosted by
#[a(href='https://bitbadger.solutions') Bit Badger Solutions] #[a(href='https://bitbadger.solutions' target='_blank') Bit Badger Solutions]
</template> </template>
<script> <script>
'use strict' 'use strict'
import Navigation from './components/Navigation.vue' import Navigation from './components/common/Navigation.vue'
import { version } from '../package.json' import { version } from '../package.json'
@@ -29,7 +29,7 @@ export default {
Navigation Navigation
}, },
data () { data () {
return { version } return {}
}, },
mounted () { mounted () {
this.$refs.toast.setOptions({ position: 'bottom right' }) this.$refs.toast.setOptions({ position: 'bottom right' })
@@ -37,6 +37,9 @@ export default {
computed: { computed: {
toast () { toast () {
return this.$refs.toast return this.$refs.toast
},
version () {
return version.endsWith('.0') ? version.substr(0, version.length - 2) : version
} }
} }
} }
@@ -45,9 +48,76 @@ export default {
<style> <style>
html, body { html, body {
background-color: whitesmoke; background-color: whitesmoke;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;
font-size: 1rem;
} }
body { body {
padding-top: 60px; padding-top: 50px;
margin: 0;
}
h1, h2, h3, h4, h5 {
font-weight: 500;
margin-top: 0;
}
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.5rem;
}
h5 {
font-size: 1.25rem;
}
p {
margin-bottom: 0;
}
input, textarea, select {
border-radius: .25rem;
font-size: 1rem;
}
textarea {
font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace;
}
input, select {
font-family: inherit;
}
button,
a[role="button"] {
border: solid 1px #050;
border-radius: .5rem;
background-color: rgb(235, 235, 235);
padding: .25rem;
font-size: 1rem;
}
a[role="button"]:link,
a[role="button"]:visited {
color: black;
}
button.primary,
a[role="button"].primary {
background-color: white;
border-width: 3px;
}
button:hover,
a[role="button"]:hover {
cursor: pointer;
background-color: #050;
color: white;
text-decoration: none;
}
label {
font-variant: small-caps;
font-size: 1.1rem;
}
label.normal {
font-variant: unset;
font-size: unset;
} }
footer { footer {
border-top: solid 1px lightgray; border-top: solid 1px lightgray;
@@ -59,14 +129,116 @@ footer p {
} }
a:link, a:visited { a:link, a:visited {
color: #050; color: #050;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.mpj-main-content {
max-width: 60rem;
margin: auto;
}
.mpj-main-content-wide {
margin: .5rem;
}
@media screen and (max-width: 21rem) {
.mpj-main-content-wide {
margin: 0;
}
} }
.mpj-request-text { .mpj-request-text {
white-space: pre-line; white-space: pre-line;
} }
.bg-mpj { .mpj-request-list p {
border-top: solid 1px lightgray;
}
.mpj-request-list p:first-child {
border-top: none;
}
.mpj-request-log {
width: 100%;
}
.mpj-request-log thead th {
border-top: solid 1px lightgray;
border-bottom: solid 2px lightgray;
text-align: left;
}
.mpj-request-log tbody td {
border-bottom: dotted 1px lightgray;
vertical-align: top;
}
.mpj-bg {
background-image: -webkit-gradient(linear, left top, left bottom, from(#050), to(whitesmoke)); background-image: -webkit-gradient(linear, left top, left bottom, from(#050), to(whitesmoke));
background-image: -webkit-linear-gradient(top, #050, whitesmoke); background-image: -webkit-linear-gradient(top, #050, whitesmoke);
background-image: -moz-linear-gradient(top, #050, whitesmoke); background-image: -moz-linear-gradient(top, #050, whitesmoke);
background-image: linear-gradient(to bottom, #050, whitesmoke); background-image: linear-gradient(to bottom, #050, whitesmoke);
} }
.mpj-text-center {
text-align: center;
}
.mpj-text-nowrap {
white-space: nowrap;
}
.mpj-text-right {
text-align: right;
}
.mpj-muted-text {
color: rgba(0, 0, 0, .6);
}
.mpj-narrow {
max-width: 40rem;
margin: auto;
}
.mpj-skinny {
max-width: 20rem;
margin: auto;
}
.mpj-full-width {
width: 100%;
}
.mpj-modal {
position: fixed;
z-index: 8;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, .4);
}
.mpj-modal-content {
background-color: whitesmoke;
border: solid 1px #050;
border-radius: .5rem;
animation-name: animatetop;
animation-duration: 0.4s;
padding: 1rem;
margin-top: 4rem;
}
@keyframes animatetop {
from {
top: -300px;
opacity: 0;
}
to {
top: 0;
opacity: 1;
}
}
.mpj-modal-content header {
margin: -1rem -1rem .5rem;
border-radius: .4rem;
}
.mpj-modal-content header h5 {
color: white;
margin: 0;
padding: 1rem;
}
.mpj-margin {
margin-left: 1rem;
margin-right: 1rem;
}
.material-icons {
vertical-align: middle;
}
</style> </style>

View File

@@ -1,3 +1,5 @@
'use strict'
import axios from 'axios' import axios from 'axios'
const http = axios.create({ const http = axios.create({
@@ -30,8 +32,10 @@ export default {
/** /**
* Add a new prayer request * Add a new prayer request
* @param {string} requestText The text of the request to be added * @param {string} requestText The text of the request to be added
* @param {string} recurType The type of recurrence for this request
* @param {number} recurCount The number of intervals of recurrence
*/ */
addRequest: requestText => http.post('request', { requestText }), addRequest: (requestText, recurType, recurCount) => http.post('request', { requestText, recurType, recurCount }),
/** /**
* Get all answered requests, along with the text they had when it was answered * Get all answered requests, along with the text they had when it was answered
@@ -39,7 +43,7 @@ export default {
getAnsweredRequests: () => http.get('requests/answered'), getAnsweredRequests: () => http.get('requests/answered'),
/** /**
* Get a prayer request (full; includes all history) * Get a prayer request (full; includes all history and notes)
* @param {string} requestId The Id of the request to retrieve * @param {string} requestId The Id of the request to retrieve
*/ */
getFullRequest: requestId => http.get(`request/${requestId}/full`), getFullRequest: requestId => http.get(`request/${requestId}/full`),
@@ -56,30 +60,39 @@ export default {
*/ */
getRequest: requestId => http.get(`request/${requestId}`), getRequest: requestId => http.get(`request/${requestId}`),
/**
* Get a complete request; equivalent of "full" and "notes" combined
*/
getRequestComplete: requestId => http.get(`request/${requestId}/complete`),
/** /**
* Get all prayer requests and their most recent updates * Get all prayer requests and their most recent updates
*/ */
journal: () => http.get('journal'), journal: () => http.get('journal'),
/** /**
* Snooze a request until the given time * Show a request after the given date (used for "show now")
* @param requestId {string} The ID of the prayer request to be snoozed * @param {string} requestId The ID of the request which should be shown
* @param until {number} The ticks until which the request should be snoozed * @param {number} showAfter The ticks after which the request should be shown
*/ */
snoozeRequest: (requestId, until) => http.post(`request/${requestId}/snooze`, { until }), showRequest: (requestId, showAfter) => http.patch(`request/${requestId}/show`, { showAfter }),
/**
* Snooze a request until the given time
* @param {string} requestId The ID of the prayer request to be snoozed
* @param {number} until The ticks until which the request should be snoozed
*/
snoozeRequest: (requestId, until) => http.patch(`request/${requestId}/snooze`, { until }),
/**
* Update recurrence for a prayer request
* @param {string} requestId The ID of the prayer request for which recurrence is being updated
* @param {string} recurType The type of recurrence to set
* @param {number} recurCount The number of recurrence intervals to set
*/
updateRecurrence: (requestId, recurType, recurCount) =>
http.patch(`request/${requestId}/recurrence`, { recurType, recurCount }),
/** /**
* Update a prayer request * Update a prayer request
* @param request The request (should have requestId, status, and updateText properties) * @param {string} requestId The ID of the request to be updated
* @param {string} status The status of the update
* @param {string} updateText The text of the update (optional)
*/ */
updateRequest: request => http.post(`request/${request.requestId}/history`, { updateRequest: (requestId, status, updateText) => http.post(`request/${requestId}/history`, { status, updateText })
status: request.status,
updateText: request.updateText
})
} }

View File

@@ -8,7 +8,6 @@ import mutations from '@/store/mutation-types'
var tokenRenewalTimeout var tokenRenewalTimeout
export default class AuthService { export default class AuthService {
constructor () { constructor () {
this.login = this.login.bind(this) this.login = this.login.bind(this)
this.setSession = this.setSession.bind(this) this.setSession = this.setSession.bind(this)

View File

@@ -1,83 +0,0 @@
<template lang="pug">
article
page-title(title='Answered Request')
p(v-if='!request') Loading request...
template(v-if='request')
p.
Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')]) &nbsp;
#[small: em.text-muted prayed {{ prayedCount }} times, open {{ openDays }} days]
p.mpj-request-text {{ lastText }}
b-table(small hover :fields='fields' :items='log')
template(slot='action' scope='data').
{{ data.item.status }} on #[span.text-nowrap {{ formatDate(data.item.asOf) }}]
template(slot='text' scope='data' v-if='data.item.text') {{ data.item.text.fields[0] }}
</template>
<script>
'use strict'
import moment from 'moment'
import api from '@/api'
const asOfDesc = (a, b) => b.asOf - a.asOf
export default {
name: 'answer-detail',
props: {
id: {
type: String,
required: true
}
},
data () {
return {
request: null,
fields: [
{ key: 'action', label: 'Action' },
{ key: 'text', label: 'Update / Notes' }
]
}
},
computed: {
answered () {
return this.request.history.find(hist => hist.status === 'Answered').asOf
},
lastText () {
return this.request.history
.filter(hist => hist.text)
.sort(asOfDesc)[0].text.fields[0]
},
log () {
return (this.request.notes || [])
.map(note => ({ asOf: note.asOf, text: { case: 'Some', fields: [ note.notes ] }, status: 'Notes' }))
.concat(this.request.history)
.sort(asOfDesc)
.slice(1)
},
openDays () {
return Math.floor(
(this.answered - this.request.history.find(hist => hist.status === 'Created').asOf) / 1000 / 60 / 60 / 24)
},
prayedCount () {
return this.request.history.filter(hist => hist.status === 'Prayed').length
}
},
async mounted () {
this.$Progress.start()
try {
const req = await api.getRequestComplete(this.id)
this.request = req.data
this.$Progress.finish()
} catch (e) {
console.log(e)
this.$Progress.fail()
}
},
methods: {
formatDate (asOf) {
return moment(asOf).format('LL')
}
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template lang="pug"> <template lang="pug">
article article.mpj-main-content(role='main')
page-title(title='Welcome!' page-title(title='Welcome!'
hideOnPage='true') hideOnPage='true')
p &nbsp; p &nbsp;

View File

@@ -1,23 +1,23 @@
<template lang="pug"> <template lang="pug">
article article.mpj-main-content-wide(role='main')
page-title(:title='title') page-title(:title='title')
p(v-if='isLoadingJournal') Loading your prayer journal... p(v-if='isLoadingJournal') Loading your prayer journal...
template(v-if='!isLoadingJournal') template(v-else)
new-request .mpj-text-center
router-link(:to="{ name: 'EditRequest', params: { id: 'new' } }"
role='button').
#[md-icon(icon='add_box')] Add a New Request
br br
b-row(v-if='journal.length > 0') .mpj-journal(v-if='journal.length > 0')
request-card(v-for='request in journal' request-card(v-for='request in journal'
:key='request.requestId' :key='request.requestId'
:request='request' :request='request'
:events='eventBus' :events='eventBus'
:toast='toast') :toast='toast')
p.text-center(v-if='journal.length === 0'): em. p.text-center(v-else): em.
No requests found; click the &ldquo;Add a New Request&rdquo; button to add one No requests found; click the &ldquo;Add a New Request&rdquo; button to add one
edit-request(:events='eventBus'
:toast='toast')
notes-edit(:events='eventBus' notes-edit(:events='eventBus'
:toast='toast') :toast='toast')
full-request(:events='eventBus')
snooze-request(:events='eventBus' snooze-request(:events='eventBus'
:toast='toast') :toast='toast')
</template> </template>
@@ -28,9 +28,6 @@ article
import Vue from 'vue' import Vue from 'vue'
import { mapState } from 'vuex' import { mapState } from 'vuex'
import EditRequest from './request/EditRequest'
import FullRequest from './request/FullRequest'
import NewRequest from './request/NewRequest'
import NotesEdit from './request/NotesEdit' import NotesEdit from './request/NotesEdit'
import RequestCard from './request/RequestCard' import RequestCard from './request/RequestCard'
import SnoozeRequest from './request/SnoozeRequest' import SnoozeRequest from './request/SnoozeRequest'
@@ -40,9 +37,6 @@ import actions from '@/store/action-types'
export default { export default {
name: 'journal', name: 'journal',
components: { components: {
EditRequest,
FullRequest,
NewRequest,
NotesEdit, NotesEdit,
RequestCard, RequestCard,
SnoozeRequest SnoozeRequest
@@ -67,3 +61,12 @@ export default {
} }
} }
</script> </script>
<style>
.mpj-journal {
display: flex;
flex-flow: row wrap;
justify-content: center;
align-items: flex-start;
}
</style>

View File

@@ -1,56 +0,0 @@
<template lang="pug">
b-navbar(toggleable='sm'
type='dark'
variant='mpj'
fixed='top')
b-nav-toggle(target='nav_collapse')
b-navbar-brand(to='/')
span(style='font-weight:100;') my
span(style='font-weight:600;') Prayer
span(style='font-weight:700;') Journal
b-collapse#nav_collapse(is-nav)
b-navbar-nav
b-nav-item(v-if='isAuthenticated'
to='/journal') Journal
b-nav-item(v-if='hasSnoozed'
to='/snoozed') Snoozed
b-nav-item(v-if='isAuthenticated'
to='/answered') Answered
b-nav-item(v-if='isAuthenticated'): a(@click.stop='logOff()') Log Off
b-nav-item(v-if='!isAuthenticated'): a(@click.stop='logOn()') Log On
b-nav-item(href='https://bit-badger.github.io/myPrayerJournal/'
target='_blank'
@click.stop='') Docs
</template>
<script>
'use strict'
import { mapState } from 'vuex'
import AuthService from '@/auth/AuthService'
export default {
name: 'navigation',
data () {
return {
auth0: new AuthService()
}
},
computed: {
hasSnoozed () {
return this.isAuthenticated &&
Array.isArray(this.journal) &&
this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0
},
...mapState([ 'journal', 'isAuthenticated' ])
},
methods: {
logOn () {
this.auth0.login()
},
logOff () {
this.auth0.logout(this.$store, this.$router)
}
}
}
</script>

View File

@@ -1,76 +0,0 @@
<template lang="pug">
article
page-title(title='Snoozed Requests')
p(v-if='!loaded') Loading journal...
div(v-if='loaded').mpj-snoozed-list
p.text-center(v-if='requests.length === 0'): em.
No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
p.mpj-snoozed-text(v-for='req in requests' :key='req.requestId')
| {{ req.text }}
br
br
b-btn(@click='cancelSnooze(req.requestId)'
size='sm'
variant='outline-secondary')
icon(name='times')
= ' Cancel Snooze'
small.text-muted: em.
&nbsp; Snooze expires #[date-from-now(:value='req.snoozedUntil')]
</template>
<script>
'use static'
import { mapState } from 'vuex'
import actions from '@/store/action-types'
export default {
name: 'answered',
data () {
return {
requests: [],
loaded: false
}
},
computed: {
toast () {
return this.$parent.$refs.toast
},
...mapState(['journal', 'isLoadingJournal'])
},
methods: {
async ensureJournal () {
if (!Array.isArray(this.journal)) {
this.loaded = false
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
}
this.requests = this.journal
.filter(req => req.snoozedUntil > Date.now())
.sort((a, b) => a.snoozedUntil - b.snoozedUntil)
this.loaded = true
},
async cancelSnooze (requestId) {
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
progress: this.$Progress,
requestId: requestId,
until: 0
})
this.toast.showToast('Request un-snoozed', { theme: 'success' })
this.ensureJournal()
}
},
async mounted () {
await this.ensureJournal()
}
}
</script>
<style>
.mpj-snoozed-list p {
border-top: solid 1px lightgray;
}
.mpj-snoozed-list p:first-child {
border-top: none;
}
</style>

View File

@@ -0,0 +1,15 @@
<template lang="pug">
i.material-icons(v-html='icon')
</template>
<script>
export default {
name: 'md-icon',
props: {
icon: {
type: String,
required: true
}
}
}
</script>

View File

@@ -0,0 +1,96 @@
<template lang="pug">
nav.mpj-top-nav.mpj-bg(role='menubar')
router-link.title(:to="{ name: 'Home' }"
role='menuitem')
span(style='font-weight:100;') my
span(style='font-weight:600;') Prayer
span(style='font-weight:700;') Journal
router-link(v-if='isAuthenticated'
:to="{ name: 'Journal' }"
role='menuitem') Journal
router-link(v-if='isAuthenticated'
:to="{ name: 'ActiveRequests' }"
role='menuitem') Active
router-link(v-if='hasSnoozed'
:to="{ name: 'SnoozedRequests' }"
role='menuitem') Snoozed
router-link(v-if='isAuthenticated'
:to="{ name: 'AnsweredRequests' }"
role='menuitem') Answered
a(v-if='isAuthenticated'
href='#'
role='menuitem'
@click.stop='logOff()') Log Off
a(v-if='!isAuthenticated'
href='#'
role='menuitem'
@click.stop='logOn()') Log On
a(href='https://docs.prayerjournal.me'
target='_blank'
role='menuitem'
@click.stop='') Docs
</template>
<script>
'use strict'
import { mapState } from 'vuex'
import AuthService from '@/auth/AuthService'
export default {
name: 'navigation',
data () {
return {
auth0: new AuthService()
}
},
computed: {
hasSnoozed () {
return this.isAuthenticated &&
Array.isArray(this.journal) &&
this.journal.filter(req => req.snoozedUntil > Date.now()).length > 0
},
...mapState([ 'journal', 'isAuthenticated' ])
},
methods: {
logOn () {
this.auth0.login()
},
logOff () {
this.auth0.logout(this.$store, this.$router)
}
}
}
</script>
<style>
.mpj-top-nav {
position: fixed;
display: flex;
flex-flow: row wrap;
align-items: center;
top: 0;
left: 0;
width: 100%;
padding-left: .5rem;
min-height: 50px;
}
.mpj-top-nav a:link,
.mpj-top-nav a:visited {
text-decoration: none;
color: rgba(255, 255, 255, .75);
padding-left: 1rem;
}
.mpj-top-nav a:link.router-link-active,
.mpj-top-nav a:visited.router-link-active,
.mpj-top-nav a:hover {
color: white;
}
.mpj-top-nav .title {
font-size: 1.25rem;
color: white;
padding-left: 1.25rem;
padding-right: 1.25rem;
}
</style>

View File

@@ -0,0 +1,59 @@
<template lang="pug">
article.mpj-main-content(role='main')
page-title(title='Active Requests')
div(v-if='loaded').mpj-request-list
p.mpj-text-center(v-if='requests.length === 0'): em.
No active requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
request-list-item(v-for='req in requests'
:key='req.requestId'
:request='req'
:toast='toast')
p(v-else) Loading journal...
</template>
<script>
'use strict'
import { mapState } from 'vuex'
import RequestListItem from '@/components/request/RequestListItem'
import actions from '@/store/action-types'
export default {
name: 'active-requests',
components: {
RequestListItem
},
data () {
return {
requests: [],
loaded: false
}
},
computed: {
toast () {
return this.$parent.$refs.toast
},
...mapState(['journal', 'isLoadingJournal'])
},
created () {
this.$on('requestUnsnoozed', this.ensureJournal)
this.$on('requestNowShown', this.ensureJournal)
},
methods: {
async ensureJournal () {
if (!Array.isArray(this.journal)) {
this.loaded = false
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
}
this.requests = this.journal
.sort((a, b) => a.showAfter - b.showAfter)
this.loaded = true
}
},
async mounted () {
await this.ensureJournal()
}
}
</script>

View File

@@ -1,30 +1,28 @@
<template lang="pug"> <template lang="pug">
article article.mpj-main-content(role='main')
page-title(title='Answered Requests') page-title(title='Answered Requests')
p(v-if='!loaded') Loading answered requests... div(v-if='loaded').mpj-request-list
div(v-if='loaded').mpj-answered-list
p.text-center(v-if='requests.length === 0'): em. p.text-center(v-if='requests.length === 0'): em.
No answered requests found; once you have marked one as &ldquo;Answered&rdquo;, it will appear here No answered requests found; once you have marked one as &ldquo;Answered&rdquo;, it will appear here
p.mpj-request-text(v-for='req in requests' :key='req.requestId') request-list-item(v-for='req in requests'
| {{ req.text }} :key='req.requestId'
br :request='req'
br :toast='toast')
b-btn(:to='{ name: "AnsweredDetail", params: { id: req.requestId }}' p(v-else) Loading answered requests...
size='sm'
variant='outline-secondary')
icon(name='search')
= ' View Full Request'
small.text-muted: em.
&nbsp; Answered #[date-from-now(:value='req.asOf')]
</template> </template>
<script> <script>
'use static' 'use strict'
import api from '@/api' import api from '@/api'
import RequestListItem from '@/components/request/RequestListItem'
export default { export default {
name: 'answered', name: 'answered-requests',
components: {
RequestListItem
},
data () { data () {
return { return {
requests: [], requests: [],
@@ -52,12 +50,3 @@ export default {
} }
} }
</script> </script>
<style>
.mpj-answered-list p {
border-top: solid 1px lightgray;
}
.mpj-answered-list p:first-child {
border-top: none;
}
</style>

View File

@@ -1,89 +1,204 @@
<template lang="pug"> <template lang="pug">
b-modal(v-model='editVisible' article.mpj-main-content(role='main')
header-bg-variant='mpj' page-title(:title='title')
header-text-variant='light' .mpj-narrow
size='lg' label(for='request_text')
title='Edit Prayer Request' | Prayer Request
@edit='openDialog()' br
@shows='focusRequestText') textarea(v-model='form.requestText'
b-form
b-form-group(label='Prayer Request'
label-for='request_text')
b-textarea#request_text(ref='toFocus'
v-model='form.requestText'
:rows='10' :rows='10'
@blur='trimText()') @blur='trimText()'
b-form-group(label='Also Mark As') autofocus).mpj-full-width
b-radio-group(v-model='form.status' br
buttons) template(v-if='!isNew')
b-radio(value='Updated') Updated label Also Mark As
b-radio(value='Prayed') Prayed br
b-radio(value='Answered') Answered label.normal
div.w-100.text-right(slot='modal-footer') input(v-model='form.status'
b-btn(variant='primary' type='radio'
@click='saveRequest()') Save name='status'
value='Updated')
| Updated
| &nbsp; &nbsp; | &nbsp; &nbsp;
b-btn(variant='outline-secondary' label.normal
@click='closeDialog()') Cancel input(v-model='form.status'
type='radio'
name='status'
value='Prayed')
| Prayed
| &nbsp; &nbsp;
label.normal
input(v-model='form.status'
type='radio'
name='status'
value='Answered')
| Answered
br
label Recurrence
| &nbsp; &nbsp;
em.mpj-muted-text After prayer, request reappears...
br
label.normal
input(v-model='form.recur.typ'
type='radio'
name='recur'
value='immediate')
| Immediately
| &nbsp; &nbsp;
label.normal
input(v-model='form.recur.typ'
type='radio'
name='recur'
value='other')
| Every...
input(v-model='form.recur.count'
type='number'
:disabled='!showRecurrence').mpj-recur-count
select(v-model='form.recur.other'
:disabled='!showRecurrence').mpj-recur-type
option(value='hours') hours
option(value='days') days
option(value='weeks') weeks
.mpj-text-right
button(:disabled='!isValidRecurrence'
@click.stop='saveRequest()').primary.
#[md-icon(icon='save')] Save
| &nbsp; &nbsp;
button(@click.stop='goBack()').
#[md-icon(icon='arrow_back')] Cancel
</template> </template>
<script> <script>
'use strict' 'use strict'
import { mapState } from 'vuex'
import actions from '@/store/action-types' import actions from '@/store/action-types'
export default { export default {
name: 'edit-request', name: 'edit-request',
props: { props: {
toast: { required: true }, id: {
events: { required: true } type: String,
required: true
}
}, },
data () { data () {
return { return {
editVisible: false, title: 'Edit Prayer Request',
isNew: false,
form: { form: {
requestId: '', requestId: '',
requestText: '', requestText: '',
status: 'Updated' status: 'Updated',
recur: {
typ: 'immediate',
other: '',
count: ''
}
} }
} }
}, },
created () { computed: {
this.events.$on('edit', this.openDialog) isValidRecurrence () {
if (this.form.recur.typ === 'immediate') return true
const count = Number.parseInt(this.form.recur.count)
if (isNaN(count) || this.form.recur.other === '') return false
if (this.form.recur.other === 'hours' && count > (365 * 24)) return false
if (this.form.recur.other === 'days' && count > 365) return false
if (this.form.recur.other === 'weeks' && count > 52) return false
return true
}, },
methods: { showRecurrence () {
closeDialog () { return this.form.recur.typ !== 'immediate'
},
toast () {
return this.$parent.$refs.toast
},
...mapState(['journal'])
},
async mounted () {
await this.ensureJournal()
if (this.id === 'new') {
this.title = 'Add Prayer Request'
this.isNew = true
this.form.requestId = '' this.form.requestId = ''
this.form.requestText = '' this.form.requestText = ''
this.form.status = 'Created'
this.form.recur.typ = 'immediate'
this.form.recur.other = ''
this.form.recur.count = ''
} else {
this.title = 'Edit Prayer Request'
this.isNew = false
if (this.journal.length === 0) {
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
}
const req = this.journal.filter(r => r.requestId === this.id)[0]
this.form.requestId = this.id
this.form.requestText = req.text
this.form.status = 'Updated' this.form.status = 'Updated'
this.editVisible = false if (req.recurType === 'immediate') {
this.form.recur.typ = 'immediate'
this.form.recur.other = ''
this.form.recur.count = ''
} else {
this.form.recur.typ = 'other'
this.form.recur.other = req.recurType
this.form.recur.count = req.recurCount
}
}
}, },
focusRequestText (e) { methods: {
this.$refs.toFocus.focus() goBack () {
}, this.$router.go(-1)
openDialog (request) {
this.form.requestId = request.requestId
this.form.requestText = request.text
this.editVisible = true
this.focusRequestText(null)
}, },
trimText () { trimText () {
this.form.requestText = this.form.requestText.trim() this.form.requestText = this.form.requestText.trim()
}, },
async ensureJournal () {
if (!Array.isArray(this.journal)) {
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
}
},
async saveRequest () { async saveRequest () {
if (this.isNew) {
await this.$store.dispatch(actions.ADD_REQUEST, {
progress: this.$Progress,
requestText: this.form.requestText,
recurType: this.form.recur.typ === 'immediate' ? 'immediate' : this.form.recur.other,
recurCount: this.form.recur.typ === 'immediate' ? 0 : Number.parseInt(this.form.recur.count)
})
this.toast.showToast('New prayer request added', { theme: 'success' })
} else {
await this.$store.dispatch(actions.UPDATE_REQUEST, { await this.$store.dispatch(actions.UPDATE_REQUEST, {
progress: this.$Progress, progress: this.$Progress,
requestId: this.form.requestId, requestId: this.form.requestId,
updateText: this.form.requestText, updateText: this.form.requestText,
status: this.form.status status: this.form.status,
recurType: this.form.recur.typ === 'immediate' ? 'immediate' : this.form.recur.other,
recurCount: this.form.recur.typ === 'immediate' ? 0 : Number.parseInt(this.form.recur.count)
}) })
if (this.form.status === 'Answered') { if (this.form.status === 'Answered') {
this.toast.showToast('Request updated and removed from active journal', { theme: 'success' }) this.toast.showToast('Request updated and removed from active journal', { theme: 'success' })
} else { } else {
this.toast.showToast('Request updated', { theme: 'success' }) this.toast.showToast('Request updated', { theme: 'success' })
} }
this.closeDialog() }
this.goBack()
} }
} }
} }
</script> </script>
<style>
.mpj-recur-count {
width: 3rem;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.mpj-recur-type {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>

View File

@@ -1,56 +1,90 @@
<template lang="pug"> <template lang="pug">
span article.mpj-main-content(role='main')
b-modal(v-model='historyVisible' page-title(title='Full Prayer Request')
header-bg-variant='mpj' template(v-if='request')
header-text-variant='light' p
size='lg' span(v-if='isAnswered') Answered {{ formatDate(answered) }} (#[date-from-now(:value='answered')]) &nbsp;
title='Prayer Request History' small: em.mpj-muted-text prayed {{ prayedCount }} times, open {{ openDays }} days
@shows='focusRequestText') p.mpj-request-text {{ lastText }}
b-list-group(v-if='null !== full' br
flush) table.mpj-request-log
full-request-history(v-for='item in full.history' thead
:key='item.asOf' tr
:history='item') th Action
div.w-100.text-right(slot='modal-footer') th Update / Notes
b-btn(variant='primary' tbody
@click='closeDialog()') Close tr(v-for='item in log' :key='item.asOf')
td {{ item.status }} on #[span.mpj-text-nowrap {{ formatDate(item.asOf) }}]
td(v-if='item.text').mpj-request-text {{ item.text.fields[0] }}
td(v-else) &nbsp;
p(v-else) Loading request...
</template> </template>
<script> <script>
'use strict' 'use strict'
import FullRequestHistory from './FullRequestHistory' import moment from 'moment'
import api from '@/api' import api from '@/api'
const asOfDesc = (a, b) => b.asOf - a.asOf
export default { export default {
name: 'full-request', name: 'full-request',
components: {
FullRequestHistory
},
props: { props: {
events: { required: true } id: {
type: String,
required: true
}
}, },
data () { data () {
return { return {
historyVisible: false, request: null
full: null
} }
}, },
created () { computed: {
this.events.$on('full', this.openDialog) answered () {
return this.request.history.find(hist => hist.status === 'Answered').asOf
},
isAnswered () {
return this.request.history.filter(hist => hist.status === 'Answered').length > 0
},
lastText () {
return this.request.history
.filter(hist => hist.text)
.sort(asOfDesc)[0].text.fields[0]
},
log () {
const allHistory = (this.request.notes || [])
.map(note => ({ asOf: note.asOf, text: { case: 'Some', fields: [ note.notes ] }, status: 'Notes' }))
.concat(this.request.history)
.sort(asOfDesc)
// Skip the first entry for answered requests; that info is already displayed
return this.isAnswered ? allHistory.slice(1) : allHistory
},
openDays () {
const asOf = this.isAnswered ? this.answered : Date.now()
return Math.floor(
(asOf - this.request.history.find(hist => hist.status === 'Created').asOf) / 1000 / 60 / 60 / 24)
},
prayedCount () {
return this.request.history.filter(hist => hist.status === 'Prayed').length
}
},
async mounted () {
this.$Progress.start()
try {
const req = await api.getFullRequest(this.id)
this.request = req.data
this.$Progress.finish()
} catch (e) {
console.log(e)
this.$Progress.fail()
}
}, },
methods: { methods: {
closeDialog () { formatDate (asOf) {
this.full = null return moment(asOf).format('LL')
this.historyVisible = false
},
async openDialog (requestId) {
this.historyVisible = true
this.$Progress.start()
const req = await api.getFullRequest(requestId)
this.full = req.data
this.$Progress.finish()
} }
} }
} }

View File

@@ -1,28 +0,0 @@
<template lang="pug">
b-list-group-item
| {{ history.status }}
|
small.text-muted(:title='actualDate') {{ asOf }}
div(v-if='history.text').mpj-request-text {{ history.text.fields[0] }}
</template>
<script>
'use strict'
import moment from 'moment'
export default {
name: 'full-request-history',
props: {
history: { required: true }
},
computed: {
asOf () {
return moment(this.history.asOf).fromNow()
},
actualDate () {
return moment(this.history.asOf).format('LLLL')
}
}
}
</script>

View File

@@ -1,71 +0,0 @@
<template lang="pug">
div
b-btn(@click='openDialog()' size='sm' variant='primary')
icon(name='plus')
| &nbsp; Add a New Request
b-modal(v-model='showNewVisible'
header-bg-variant='mpj'
header-text-variant='light'
size='lg'
title='Add a New Prayer Request'
@shown='focusRequestText')
b-form
b-form-group(label='Prayer Request'
label-for='request_text')
b-textarea#request_text(ref='toFocus'
v-model='form.requestText'
:rows='10'
@blur='trimText()')
div.w-100.text-right(slot='modal-footer')
b-btn(variant='primary'
@click='saveRequest()') Save
| &nbsp; &nbsp;
b-btn(variant='outline-secondary'
@click='closeDialog()') Cancel
toast(ref='toast')
</template>
<script>
'use strict'
import actions from '@/store/action-types'
export default {
name: 'new-request',
data () {
return {
showNewVisible: false,
form: {
requestText: ''
},
formLabelWidth: '120px'
}
},
mounted () {
this.$refs.toast.setOptions({ position: 'bottom right' })
},
methods: {
closeDialog () {
this.form.requestText = ''
this.showNewVisible = false
},
focusRequestText (e) {
this.$refs.toFocus.focus()
},
openDialog () {
this.showNewVisible = true
},
trimText () {
this.form.requestText = this.form.requestText.trim()
},
async saveRequest () {
await this.$store.dispatch(actions.ADD_REQUEST, {
progress: this.$Progress,
requestText: this.form.requestText
})
this.$refs.toast.showToast('New prayer request added', { theme: 'success' })
this.closeDialog()
}
}
}
</script>

View File

@@ -1,36 +1,33 @@
<template lang="pug"> <template lang="pug">
b-modal(v-model='notesVisible' .mpj-modal(v-show='notesVisible')
header-bg-variant='mpj' .mpj-modal-content.mpj-narrow
header-text-variant='light' header.mpj-bg
size='lg' h5 Add Notes to Prayer Request
title='Add Notes to Prayer Request' label
@edit='openDialog()' | Notes
@shows='focusNotes')
b-form
b-form-group(label='Notes'
label-for='notes')
b-textarea#notes(ref='toFocus'
v-model='form.notes'
:rows='10'
@blur='trimText()')
div(v-if='hasPriorNotes')
p.text-center: strong Prior Notes for This Request
b-list-group(flush)
b-list-group-item(v-for='note in priorNotes'
:key='note.asOf')
small.text-muted: date-from-now(:value='note.asOf')
br br
div.mpj-request-text {{ note.notes }} textarea(v-model='form.notes'
div(v-else-if='noPriorNotes').text-center.text-muted There are no prior notes for this request :rows='10'
div(v-else).text-center @blur='trimText()').mpj-full-width
b-btn(variant='outline-secondary' .mpj-text-right
@click='loadNotes()') Load Prior Notes button(@click='saveNotes()').primary.
div.w-100.text-right(slot='modal-footer') #[md-icon(icon='save')] Save
b-btn(variant='primary'
@click='saveNotes()') Save
| &nbsp; &nbsp; | &nbsp; &nbsp;
b-btn(variant='outline-secondary' button(@click='closeDialog()').
@click='closeDialog()') Cancel #[md-icon(icon='undo')] Cancel
hr
div(v-if='hasPriorNotes')
p.mpj-text-center: strong Prior Notes for This Request
.mpj-note-list
p(v-for='note in priorNotes'
:key='note.asOf')
small.mpj-muted-text: date-from-now(:value='note.asOf')
br
span.mpj-request-text {{ note.notes }}
div(v-else-if='noPriorNotes').mpj-text-center.mpj-muted-text There are no prior notes for this request
div(v-else).mpj-text-center
button(@click='loadNotes()').
#[md-icon(icon='cloud_download')] Load Prior Notes
</template> </template>
<script> <script>
@@ -74,15 +71,11 @@ export default {
this.priorNotesLoaded = false this.priorNotesLoaded = false
this.notesVisible = false this.notesVisible = false
}, },
focusNotes (e) {
this.$refs.toFocus.focus()
},
async loadNotes () { async loadNotes () {
this.$Progress.start() this.$Progress.start()
try { try {
const notes = await api.getNotes(this.form.requestId) const notes = await api.getNotes(this.form.requestId)
this.priorNotes = notes.data this.priorNotes = notes.data
console.log(this.priorNotes)
this.$Progress.finish() this.$Progress.finish()
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@@ -94,7 +87,6 @@ export default {
openDialog (request) { openDialog (request) {
this.form.requestId = request.requestId this.form.requestId = request.requestId
this.notesVisible = true this.notesVisible = true
this.focusNotes(null)
}, },
async saveNotes () { async saveNotes () {
this.$Progress.start() this.$Progress.start()
@@ -114,3 +106,9 @@ export default {
} }
} }
</script> </script>
<style>
.mpj-note-list p {
border-top: dotted 1px lightgray;
}
</style>

View File

@@ -1,16 +1,14 @@
<template lang="pug"> <template lang="pug">
b-col(v-if="!isSnoozed" md='6' lg='4') .mpj-request-card(v-if='shouldDisplay')
.mpj-request-card header.mpj-card-header(role='toolbar').
b-card-header.text-center.py-1. #[button(@click='markPrayed()' title='Pray').primary: md-icon(icon='done')]
#[b-btn(@click='markPrayed()' variant='outline-primary' title='Pray' size='sm'): icon(name='check')] #[button(@click.stop='showEdit()' title='Edit'): md-icon(icon='edit')]
#[b-btn(@click.stop='showEdit()' variant='outline-secondary' title='Edit' size='sm'): icon(name='pencil')] #[button(@click.stop='showNotes()' title='Add Notes'): md-icon(icon='comment')]
#[b-btn(@click.stop='showNotes()' variant='outline-secondary' title='Add Notes' size='sm'): icon(name='file-text-o')] #[button(@click.stop='snooze()' title='Snooze Request'): md-icon(icon='schedule')]
#[b-btn(@click.stop='showFull()' variant='outline-secondary' title='View Full Request' size='sm'): icon(name='search')] div
#[b-btn(@click.stop='snooze()' variant='outline-secondary' title='Snooze Request' size='sm'): icon(name='clock-o')] p.card-text.mpj-request-text
b-card-body.p-0
p.card-text.mpj-request-text.mb-1.px-3.pt-3
| {{ request.text }} | {{ request.text }}
p.card-text.p-0.pr-1.text-right: small.text-muted: em p.as-of.mpj-text-right: small.mpj-muted-text: em
= '(last activity ' = '(last activity '
date-from-now(:value='request.asOf') date-from-now(:value='request.asOf')
| ) | )
@@ -29,8 +27,9 @@ export default {
events: { required: true } events: { required: true }
}, },
computed: { computed: {
isSnoozed () { shouldDisplay () {
return Date.now() < this.request.snoozedUntil const now = Date.now()
return Math.max(now, this.request.showAfter, this.request.snoozedUntil) === now
} }
}, },
methods: { methods: {
@@ -44,10 +43,7 @@ export default {
this.toast.showToast('Request marked as prayed', { theme: 'success' }) this.toast.showToast('Request marked as prayed', { theme: 'success' })
}, },
showEdit () { showEdit () {
this.events.$emit('edit', this.request) this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } })
},
showFull () {
this.events.$emit('full', this.request.requestId)
}, },
showNotes () { showNotes () {
this.events.$emit('notes', this.request) this.events.$emit('notes', this.request)
@@ -63,6 +59,35 @@ export default {
.mpj-request-card { .mpj-request-card {
border: solid 1px darkgray; border: solid 1px darkgray;
border-radius: 5px; border-radius: 5px;
margin-bottom: 15px; width: 20rem;
margin: .5rem;
}
@media screen and (max-width: 20rem) {
.mpj-request-card {
width: 100%;
}
}
.mpj-card-header {
display: flex;
flex-flow: row;
justify-content: center;
background-image: -webkit-gradient(linear, left top, left bottom, from(lightgray), to(whitesmoke));
background-image: -webkit-linear-gradient(top, lightgray, whitesmoke);
background-image: -moz-linear-gradient(top, lightgray, whitesmoke);
background-image: linear-gradient(to bottom, lightgray, whitesmoke);
}
.mpj-card-header button {
margin: .25rem;
padding: 0 .25rem;
}
.mpj-card-header button .material-icons {
font-size: 1.3rem;
}
.mpj-request-card .card-text {
margin-left: 1rem;
margin-right: 1rem;
}
.mpj-request-card .as-of {
margin-right: .25rem;
} }
</style> </style>

View File

@@ -0,0 +1,86 @@
<template lang="pug">
p.mpj-request-text
| {{ request.text }}
br
br
button(@click='viewFull'
title='View Full Request').
#[md-icon(icon='description')] View Full Request
| &nbsp; &nbsp;
template(v-if='!isAnswered')
button(@click='editRequest'
title='Edit Request').
#[md-icon(icon='edit')] Edit Request
| &nbsp; &nbsp;
template(v-if='isSnoozed')
button(@click='cancelSnooze()').
#[md-icon(icon='restore')] Cancel Snooze
| &nbsp; &nbsp;
template(v-if='isPending')
button(@click='showNow()').
#[md-icon(icon='restore')] Show Now
br(v-if='isSnoozed || isPending || isAnswered')
small(v-if='isSnoozed').mpj-muted-text: em.
&nbsp; Snooze expires #[date-from-now(:value='request.snoozedUntil')]
small(v-if='isPending').mpj-muted-text: em.
&nbsp; Request scheduled to reappear #[date-from-now(:value='request.showAfter')]
small(v-if='isAnswered').mpj-muted-text: em.
&nbsp; Answered #[date-from-now(:value='request.asOf')]
</template>
<script>
'use strict'
import actions from '@/store/action-types'
export default {
name: 'request-list-item',
props: {
request: { required: true },
toast: { required: true }
},
data () {
return {}
},
computed: {
answered () {
return this.request.history.find(hist => hist.status === 'Answered').asOf
},
isAnswered () {
return this.request.lastStatus === 'Answered'
},
isPending () {
return !this.isSnoozed && this.request.showAfter > Date.now()
},
isSnoozed () {
return this.request.snoozedUntil > Date.now()
}
},
methods: {
async cancelSnooze () {
await this.$store.dispatch(actions.SNOOZE_REQUEST, {
progress: this.$Progress,
requestId: this.request.requestId,
until: 0
})
this.toast.showToast('Request un-snoozed', { theme: 'success' })
this.$parent.$emit('requestUnsnoozed')
},
editRequest () {
this.$router.push({ name: 'EditRequest', params: { id: this.request.requestId } })
},
async showNow () {
await this.$store.dispatch(actions.SHOW_REQUEST_NOW, {
progress: this.$Progress,
requestId: this.request.requestId,
showAfter: Date.now()
})
this.toast.showToast('Recurrence skipped; request now shows in journal', { theme: 'success' })
this.$parent.$emit('requestNowShown')
},
viewFull () {
this.$router.push({ name: 'FullRequest', params: { id: this.request.requestId } })
}
}
}
</script>

View File

@@ -1,23 +1,22 @@
<template lang="pug"> <template lang="pug">
b-modal(v-model='snoozeVisible' .mpj-modal(v-show='snoozeVisible')
header-bg-variant='mpj' .mpj-modal-content.mpj-skinny
header-text-variant='light' header.mpj-bg
size='lg' h5 Snooze Prayer Request
title='Snooze Prayer Request' p.mpj-text-center
@edit='openDialog()') label
b-form = 'Until '
b-form-group(label='Until' input(v-model='form.snoozedUntil'
label-for='until') type='date'
b-input#until(type='date'
v-model='form.snoozedUntil'
autofocus) autofocus)
div.w-100.text-right(slot='modal-footer') br
b-btn(variant='primary' .mpj-text-right
:disabled='!isValid' button.primary(:disabled='!isValid'
@click='snoozeRequest()') Snooze @click='snoozeRequest()').
#[md-icon(icon='snooze')] Snooze
| &nbsp; &nbsp; | &nbsp; &nbsp;
b-btn(variant='outline-secondary' button(@click='closeDialog()').
@click='closeDialog()') Cancel #[md-icon(icon='undo')] Cancel
</template> </template>
<script> <script>

View File

@@ -0,0 +1,59 @@
<template lang="pug">
article.mpj-main-content(role='main')
page-title(title='Snoozed Requests')
div(v-if='loaded').mpj-request-list
p.mpj-text-center(v-if='requests.length === 0'): em.
No snoozed requests found; return to #[router-link(:to='{ name: "Journal" } ') your journal]
request-list-item(v-for='req in requests'
:key='req.requestId'
:request='req'
:toast='toast')
p(v-else) Loading journal...
</template>
<script>
'use strict'
import { mapState } from 'vuex'
import actions from '@/store/action-types'
import RequestListItem from '@/components/request/RequestListItem'
export default {
name: 'snoozed-requests',
components: {
RequestListItem
},
data () {
return {
requests: [],
loaded: false
}
},
computed: {
toast () {
return this.$parent.$refs.toast
},
...mapState(['journal', 'isLoadingJournal'])
},
created () {
this.$on('requestUnsnoozed', this.ensureJournal)
},
methods: {
async ensureJournal () {
if (!Array.isArray(this.journal)) {
this.loaded = false
await this.$store.dispatch(actions.LOAD_JOURNAL, this.$Progress)
}
this.requests = this.journal
.filter(req => req.snoozedUntil > Date.now())
.sort((a, b) => a.snoozedUntil - b.snoozedUntil)
this.loaded = true
}
},
async mounted () {
await this.ensureJournal()
}
}
</script>

View File

@@ -1,5 +1,5 @@
<template lang="pug"> <template lang="pug">
article article.mpj-main-content(role='main')
pageTitle(title='Logging On') pageTitle(title='Logging On')
p Logging you on... p Logging you on...
</template> </template>

View File

@@ -1,33 +1,18 @@
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from 'vue' import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue'
import Icon from 'vue-awesome/components/Icon'
import VueProgressBar from 'vue-progressbar' import VueProgressBar from 'vue-progressbar'
import VueToast from 'vue-toast' import VueToast from 'vue-toast'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import 'bootstrap/dist/css/bootstrap.css'
import 'vue-toast/dist/vue-toast.min.css' import 'vue-toast/dist/vue-toast.min.css'
// Only import the icons we need; the whole set is ~500K!
import 'vue-awesome/icons/check'
import 'vue-awesome/icons/clock-o'
import 'vue-awesome/icons/file-text-o'
import 'vue-awesome/icons/pencil'
import 'vue-awesome/icons/plus'
import 'vue-awesome/icons/search'
import 'vue-awesome/icons/times'
import App from './App' import App from './App'
import router from './router' import router from './router'
import store from './store' import store from './store'
import DateFromNow from './components/common/DateFromNow' import DateFromNow from './components/common/DateFromNow'
import MaterialDesignIcon from './components/common/MaterialDesignIcon'
import PageTitle from './components/common/PageTitle' import PageTitle from './components/common/PageTitle'
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.use(BootstrapVue)
Vue.use(VueProgressBar, { Vue.use(VueProgressBar, {
color: 'yellow', color: 'yellow',
failedColor: 'red', failedColor: 'red',
@@ -39,16 +24,13 @@ Vue.use(VueProgressBar, {
} }
}) })
Vue.component('icon', Icon)
Vue.component('date-from-now', DateFromNow) Vue.component('date-from-now', DateFromNow)
Vue.component('md-icon', MaterialDesignIcon)
Vue.component('page-title', PageTitle) Vue.component('page-title', PageTitle)
Vue.component('toast', VueToast) Vue.component('toast', VueToast)
/* eslint-disable no-new */
new Vue({ new Vue({
el: '#app',
router, router,
store, store,
template: '<App/>', render: h => h(App)
components: { App } }).$mount('#app')
})

83
src/app/src/router.js Normal file
View File

@@ -0,0 +1,83 @@
'use strict'
import Vue from 'vue'
import Router from 'vue-router'
import ActiveRequests from '@/components/request/ActiveRequests'
import AnsweredRequests from '@/components/request/AnsweredRequests'
import EditRequest from '@/components/request/EditRequest'
import FullRequest from '@/components/request/FullRequest'
import Home from '@/components/Home'
import Journal from '@/components/Journal'
import LogOn from '@/components/user/LogOn'
import PrivacyPolicy from '@/components/legal/PrivacyPolicy'
import SnoozedRequests from '@/components/request/SnoozedRequests'
import TermsOfService from '@/components/legal/TermsOfService'
Vue.use(Router)
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
scrollBehavior (to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { x: 0, y: 0 }
}
},
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/journal',
name: 'Journal',
component: Journal
},
{
path: '/legal/privacy-policy',
name: 'PrivacyPolicy',
component: PrivacyPolicy
},
{
path: '/legal/terms-of-service',
name: 'TermsOfService',
component: TermsOfService
},
{
path: '/request/:id/edit',
name: 'EditRequest',
component: EditRequest,
props: true
},
{
path: '/request/:id/full',
name: 'FullRequest',
component: FullRequest,
props: true
},
{
path: '/requests/active',
name: 'ActiveRequests',
component: ActiveRequests
},
{
path: '/requests/answered',
name: 'AnsweredRequests',
component: AnsweredRequests
},
{
path: '/requests/snoozed',
name: 'SnoozedRequests',
component: SnoozedRequests
},
{
path: '/user/log-on',
name: 'LogOn',
component: LogOn
}
]
})

View File

@@ -1,60 +0,0 @@
import Vue from 'vue'
import Router from 'vue-router'
import Answered from '@/components/Answered'
import AnsweredDetail from '@/components/AnsweredDetail'
import Home from '@/components/Home'
import Journal from '@/components/Journal'
import LogOn from '@/components/user/LogOn'
import PrivacyPolicy from '@/components/legal/PrivacyPolicy'
import Snoozed from '@/components/Snoozed'
import TermsOfService from '@/components/legal/TermsOfService'
Vue.use(Router)
export default new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/answered/:id',
name: 'AnsweredDetail',
component: AnsweredDetail,
props: true
},
{
path: '/answered',
name: 'Answered',
component: Answered
},
{
path: '/journal',
name: 'Journal',
component: Journal
},
{
path: '/legal/privacy-policy',
name: 'PrivacyPolicy',
component: PrivacyPolicy
},
{
path: '/legal/terms-of-service',
name: 'TermsOfService',
component: TermsOfService
},
{
path: '/snoozed',
name: 'Snoozed',
component: Snoozed
},
{
path: '/user/log-on',
name: 'LogOn',
component: LogOn
}
]
})

View File

@@ -7,6 +7,8 @@ export default {
LOAD_JOURNAL: 'load-journal', LOAD_JOURNAL: 'load-journal',
/** Action to update a request */ /** Action to update a request */
UPDATE_REQUEST: 'update-request', UPDATE_REQUEST: 'update-request',
/** Action to skip the remaining recurrence period */
SHOW_REQUEST_NOW: 'show-request-now',
/** Action to snooze a request */ /** Action to snooze a request */
SNOOZE_REQUEST: 'snooze-request' SNOOZE_REQUEST: 'snooze-request'
} }

View File

@@ -1,3 +1,5 @@
'use strict'
import Vue from 'vue' import Vue from 'vue'
import Vuex from 'vuex' import Vuex from 'vuex'
@@ -9,7 +11,7 @@ import actions from './action-types'
Vue.use(Vuex) Vue.use(Vuex)
this.auth0 = new AuthService() const auth0 = new AuthService()
const logError = function (error) { const logError = function (error) {
if (error.response) { if (error.response) {
@@ -34,11 +36,11 @@ export default new Vuex.Store({
state: { state: {
user: JSON.parse(localStorage.getItem('user_profile') || '{}'), user: JSON.parse(localStorage.getItem('user_profile') || '{}'),
isAuthenticated: (() => { isAuthenticated: (() => {
this.auth0.scheduleRenewal() auth0.scheduleRenewal()
if (this.auth0.isAuthenticated()) { if (auth0.isAuthenticated()) {
api.setBearer(localStorage.getItem('id_token')) api.setBearer(localStorage.getItem('id_token'))
} }
return this.auth0.isAuthenticated() return auth0.isAuthenticated()
})(), })(),
journal: {}, journal: {},
isLoadingJournal: false isLoadingJournal: false
@@ -71,10 +73,10 @@ export default new Vuex.Store({
} }
}, },
actions: { actions: {
async [actions.ADD_REQUEST] ({ commit }, { progress, requestText }) { async [actions.ADD_REQUEST] ({ commit }, { progress, requestText, recurType, recurCount }) {
progress.start() progress.start()
try { try {
const newRequest = await api.addRequest(requestText) const newRequest = await api.addRequest(requestText, recurType, recurCount)
commit(mutations.REQUEST_ADDED, newRequest.data) commit(mutations.REQUEST_ADDED, newRequest.data)
progress.finish() progress.finish()
} catch (err) { } catch (err) {
@@ -98,10 +100,28 @@ export default new Vuex.Store({
commit(mutations.LOADING_JOURNAL, false) commit(mutations.LOADING_JOURNAL, false)
} }
}, },
async [actions.UPDATE_REQUEST] ({ commit }, { progress, requestId, status, updateText }) { async [actions.UPDATE_REQUEST] ({ commit, state }, { progress, requestId, status, updateText, recurType, recurCount }) {
progress.start() progress.start()
try { try {
await api.updateRequest({ requestId, status, updateText }) let oldReq = (state.journal.filter(req => req.requestId === requestId) || [])[0] || {}
if (status !== 'Updated' || oldReq.text !== updateText) {
await api.updateRequest(requestId, status, updateText)
}
if (status === 'Updated' && (oldReq.recurType !== recurType || oldReq.recurCount !== recurCount)) {
await api.updateRecurrence(requestId, recurType, recurCount)
}
const request = await api.getRequest(requestId)
commit(mutations.REQUEST_UPDATED, request.data)
progress.finish()
} catch (err) {
logError(err)
progress.fail()
}
},
async [actions.SHOW_REQUEST_NOW] ({ commit }, { progress, requestId, showAfter }) {
progress.start()
try {
await api.showRequest(requestId, showAfter)
const request = await api.getRequest(requestId) const request = await api.getRequest(requestId)
commit(mutations.REQUEST_UPDATED, request.data) commit(mutations.REQUEST_UPDATED, request.data)
progress.finish() progress.finish()

View File

@@ -1,26 +0,0 @@
// A custom Nightwatch assertion.
// the name of the method is the filename.
// can be used in tests like this:
//
// browser.assert.elementCount(selector, count)
//
// for how to write custom assertions see
// http://nightwatchjs.org/guide#writing-custom-assertions
exports.assertion = function (selector, count) {
this.message = 'Testing if element <' + selector + '> has count: ' + count
this.expected = count
this.pass = function (val) {
return val === this.expected
}
this.value = function (res) {
return res.value
}
this.command = function (cb) {
var self = this
return this.api.execute(function (selector) {
return document.querySelectorAll(selector).length
}, [selector], function (res) {
cb.call(self, res)
})
}
}

View File

@@ -1,46 +0,0 @@
require('babel-register')
var config = require('../../config')
// http://nightwatchjs.org/gettingstarted#settings-file
module.exports = {
src_folders: ['test/e2e/specs'],
output_folder: 'test/e2e/reports',
custom_assertions_path: ['test/e2e/custom-assertions'],
selenium: {
start_process: true,
server_path: require('selenium-server').path,
host: '127.0.0.1',
port: 4444,
cli_args: {
'webdriver.chrome.driver': require('chromedriver').path
}
},
test_settings: {
default: {
selenium_port: 4444,
selenium_host: 'localhost',
silent: true,
globals: {
devServerURL: 'http://localhost:' + (process.env.PORT || config.dev.port)
}
},
chrome: {
desiredCapabilities: {
browserName: 'chrome',
javascriptEnabled: true,
acceptSslCerts: true
}
},
firefox: {
desiredCapabilities: {
browserName: 'firefox',
javascriptEnabled: true,
acceptSslCerts: true
}
}
}
}

View File

@@ -1,33 +0,0 @@
// 1. start the dev server using production config
process.env.NODE_ENV = 'testing'
var server = require('../../build/dev-server.js')
server.ready.then(() => {
// 2. run the nightwatch test suite against it
// to run in additional browsers:
// 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings"
// 2. add it to the --env flag below
// or override the environment flag, for example: `npm run e2e -- --env chrome,firefox`
// For more information on Nightwatch's config file, see
// http://nightwatchjs.org/guide#settings-file
var opts = process.argv.slice(2)
if (opts.indexOf('--config') === -1) {
opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js'])
}
if (opts.indexOf('--env') === -1) {
opts = opts.concat(['--env', 'chrome'])
}
var spawn = require('cross-spawn')
var runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' })
runner.on('exit', function (code) {
server.close()
process.exit(code)
})
runner.on('error', function (err) {
server.close()
throw err
})
})

View File

@@ -1,19 +0,0 @@
// For authoring Nightwatch tests, see
// http://nightwatchjs.org/guide#usage
module.exports = {
'default e2e tests': function (browser) {
// automatically uses dev Server port from /config.index.js
// default: http://localhost:8080
// see nightwatch.conf.js
const devServer = browser.globals.devServerURL
browser
.url(devServer)
.waitForElementVisible('#app', 5000)
.assert.elementPresent('.hello')
.assert.containsText('h1', 'Welcome to Your Vue.js App')
.assert.elementCount('img', 1)
.end()
}
}

View File

@@ -1,9 +0,0 @@
{
"env": {
"mocha": true
},
"globals": {
"expect": true,
"sinon": true
}
}

View File

@@ -1,13 +0,0 @@
import Vue from 'vue'
Vue.config.productionTip = false
// require all test files (files that ends with .spec.js)
const testsContext = require.context('./specs', true, /\.spec$/)
testsContext.keys().forEach(testsContext)
// require all src files except main.js for coverage.
// you can also change this to match only the subset of files that
// you want coverage for.
const srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/)
srcContext.keys().forEach(srcContext)

View File

@@ -1,33 +0,0 @@
// This is a karma config file. For more details see
// http://karma-runner.github.io/0.13/config/configuration-file.html
// we are also using it with karma-webpack
// https://github.com/webpack/karma-webpack
var webpackConfig = require('../../build/webpack.test.conf')
module.exports = function (config) {
config.set({
// to run in additional browsers:
// 1. install corresponding karma launcher
// http://karma-runner.github.io/0.13/config/browsers.html
// 2. add it to the `browsers` array below.
browsers: ['PhantomJS'],
frameworks: ['mocha', 'sinon-chai', 'phantomjs-shim'],
reporters: ['spec', 'coverage'],
files: ['./index.js'],
preprocessors: {
'./index.js': ['webpack', 'sourcemap']
},
webpack: webpackConfig,
webpackMiddleware: {
noInfo: true
},
coverageReporter: {
dir: './coverage',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' }
]
}
})
}

9
src/app/vue.config.js Normal file
View File

@@ -0,0 +1,9 @@
const webpack = require('webpack')
module.exports = {
outputDir: '../api/MyPrayerJournal.Api/wwwroot',
configureWebpack: {
plugins: [
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
]
}
}

File diff suppressed because it is too large Load Diff

31
src/sql/16-recurrence.sql Normal file
View File

@@ -0,0 +1,31 @@
ALTER TABLE mpj.request
ADD COLUMN "showAfter" BIGINT NOT NULL DEFAULT 0;
ALTER TABLE mpj.request
ADD COLUMN "recurType" VARCHAR(10) NOT NULL DEFAULT 'immediate';
ALTER TABLE mpj.request
ADD COLUMN "recurCount" SMALLINT NOT NULL DEFAULT 0;
CREATE OR REPLACE VIEW mpj.journal AS
SELECT
request."requestId",
request."userId",
(SELECT "text"
FROM mpj.history
WHERE history."requestId" = request."requestId"
AND "text" IS NOT NULL
ORDER BY "asOf" DESC
LIMIT 1) AS "text",
(SELECT "asOf"
FROM mpj.history
WHERE history."requestId" = request."requestId"
ORDER BY "asOf" DESC
LIMIT 1) AS "asOf",
(SELECT "status"
FROM mpj.history
WHERE history."requestId" = request."requestId"
ORDER BY "asOf" DESC
LIMIT 1) AS "lastStatus",
request."snoozedUntil",
request."showAfter",
request."recurType",
request."recurCount"
FROM mpj.request;