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
This commit is contained in:
Daniel J. Summers 2018-08-18 19:32:48 -05:00
parent 9f1e258180
commit 2c34650ceb
18 changed files with 228 additions and 183 deletions

View File

@ -245,7 +245,7 @@ type AppDbContext (opts : DbContextOptions<AppDbContext>) =
.OrderBy(fun r -> r.asOf)
/// Retrieve a request by its ID and user ID
member this.TryRequestById reqId userId : Task<Request option> =
member this.TryRequestById reqId userId =
task {
let! req = this.Requests.AsNoTracking().FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId)
return toOption req

View File

@ -95,6 +95,15 @@ module Models =
notes : string
}
/// Recurrence update
[<CLIMutable>]
type Recurrence =
{ /// The recurrence type
recurType : string
/// The recurrence cound
recurCount : int16
}
/// A prayer request
[<CLIMutable>]
type Request =
@ -103,7 +112,14 @@ module Models =
/// The recurrence type
recurType : string
/// The recurrence count
recurCount : int16 option
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
@ -156,7 +172,7 @@ module Request =
enteredOn = now
showAfter = now
recurType = r.recurType
recurCount = defaultArg r.recurCount 0s
recurCount = r.recurCount
}
|> db.AddEntry
{ History.empty with
@ -255,7 +271,23 @@ module Request =
return! json notes next ctx
}
/// POST /api/request/[req-id]/snooze
/// PATCH /api/request/[req-id]/show
let show reqId : HttpHandler =
authorize
>=> fun next ctx ->
task {
let db = db ctx
match! db.TryRequestById reqId (userId ctx) with
| Some req ->
let! show = ctx.BindJsonAsync<Models.Show> ()
{ req with showAfter = show.showAfter }
|> 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 ->
@ -270,3 +302,19 @@ module Request =
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
let! _ = db.SaveChangesAsync ()
return! setStatusCode 204 next ctx
| None -> return! Error.notFound next ctx
}

View File

@ -64,12 +64,18 @@ module Configure =
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 [
subRoute "request" [
route "" Handlers.Request.add
routef "/%s/history" Handlers.Request.addHistory
routef "/%s/note" Handlers.Request.addNote
routef "/%s/snooze" Handlers.Request.snooze
]
]
]

View File

@ -19,7 +19,7 @@
<script>
'use strict'
import Navigation from './components/Navigation.vue'
import Navigation from './components/common/Navigation.vue'
import { version } from '../package.json'
@ -74,8 +74,15 @@ h5 {
p {
margin-bottom: 0;
}
input, textarea {
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"] {
@ -183,6 +190,9 @@ a:hover {
max-width: 20rem;
margin: auto;
}
.mpj-full-width {
width: 100%;
}
.mpj-modal {
position: fixed;
z-index: 8;

View File

@ -32,8 +32,10 @@ export default {
/**
* Add a new prayer request
* @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, recurType: 'immediate' }),
addRequest: (requestText, recurType, recurCount) => http.post('request', { requestText, recurType, recurCount }),
/**
* Get all answered requests, along with the text they had when it was answered
@ -64,19 +66,33 @@ export default {
journal: () => http.get('journal'),
/**
* Snooze a request until the given time
* @param requestId {string} The ID of the prayer request to be snoozed
* @param until {number} The ticks until which the request should be snoozed
* Show a request after the given date (used for "show now")
* @param {string} requestId The ID of the request which should be shown
* @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
* @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`, {
status: request.status,
updateText: request.updateText
})
updateRequest: (requestId, status, updateText) => http.post(`request/${requestId}/history`, { status, updateText })
}

View File

@ -38,7 +38,8 @@ export default {
...mapState(['journal', 'isLoadingJournal'])
},
created () {
this.$on('requestSnoozed', this.ensureJournal)
this.$on('requestUnsnoozed', this.ensureJournal)
this.$on('requestNowShown', this.ensureJournal)
},
methods: {
async ensureJournal () {

View File

@ -2,12 +2,13 @@
article.mpj-main-content(role='main')
page-title(:title='title')
.mpj-narrow
label(for='request_text') Prayer Request
label(for='request_text')
| Prayer Request
br
textarea#request_text(v-model='form.requestText'
textarea(v-model='form.requestText'
:rows='10'
@blur='trimText()'
autofocus)
autofocus).mpj-full-width
br
template(v-if='!isNew')
label Also Mark As
@ -50,22 +51,21 @@ article.mpj-main-content(role='main')
name='recur'
value='other')
| Every...
input#recur_count(v-model='form.recur.count'
input(v-model='form.recur.count'
type='number'
:disabled='!showRecurrence')
:disabled='!showRecurrence').mpj-recur-count
select(v-model='form.recur.other'
:disabled='!showRecurrence')
:disabled='!showRecurrence').mpj-recur-type
option(value='hours') hours
option(value='days') days
option(value='weeks') weeks
.mpj-text-right
button(@click.stop='saveRequest()').primary
md-icon(icon='save')
= ' Save'
button(:disabled='!isValidRecurrence'
@click.stop='saveRequest()').primary.
#[md-icon(icon='save')] Save
| &nbsp; &nbsp;
button(@click.stop='goBack()')
md-icon(icon='arrow_back')
= ' Cancel'
button(@click.stop='goBack()').
#[md-icon(icon='arrow_back')] Cancel
</template>
<script>
@ -100,15 +100,25 @@ export default {
}
},
computed: {
toast () {
return this.$parent.$refs.toast
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
},
showRecurrence () {
this.form.recur.typ !== 'immediate'
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
@ -146,11 +156,18 @@ export default {
trimText () {
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 () {
if (this.isNew) {
await this.$store.dispatch(actions.ADD_REQUEST, {
progress: this.$Progress,
requestText: this.form.requestText
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 {
@ -158,7 +175,9 @@ export default {
progress: this.$Progress,
requestId: this.form.requestId,
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') {
this.toast.showToast('Request updated and removed from active journal', { theme: 'success' })
@ -172,11 +191,14 @@ export default {
}
</script>
<style scoped>
#request_text {
width: 100%;
}
#recur_count {
<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

@ -55,11 +55,12 @@ export default {
.sort(asOfDesc)[0].text.fields[0]
},
log () {
return (this.request.notes || [])
const allHistory = (this.request.notes || [])
.map(note => ({ asOf: note.asOf, text: { case: 'Some', fields: [ note.notes ] }, status: 'Notes' }))
.concat(this.request.history)
.sort(asOfDesc)
.slice(1)
// 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()

View File

@ -1,100 +0,0 @@
<template lang="pug">
div
button(@click='openDialog()')
md-icon(icon='add_box')
| &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()')
b-form-group(label='Recurrence')
| After prayer, request reappears
b-radio(v-model='form.recur.typ'
label='Immediately'
value='immediately'
checked='checked'
@click='checkRadios')
b-radio(v-model='form.recur.typ'
label='Every...'
value='other'
@click='checkRadios')
b-input(v-model='form.recur.count'
placeholder='##')
b-select(v-model='form.recur.other')
b-option(value='hours') hours
b-option(value='days') days
b-option(value='weeks') weeks
div.w-100.text-right(slot='modal-footer')
b-btn(variant='primary'
:disabled='!isValid'
@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: '',
recur: {
typ: 'immediate',
other: '',
count: ''
}
},
formLabelWidth: '120px'
}
},
mounted () {
this.$refs.toast.setOptions({ position: 'bottom right' })
},
computed: {
isValid () {
// TODO disallow submission if recurrence is too long
return true
}
},
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

@ -3,11 +3,12 @@
.mpj-modal-content.mpj-narrow
header.mpj-bg
h5 Add Notes to Prayer Request
label(for='notes') Notes
label
| Notes
br
textarea#notes(v-model='form.notes'
textarea(v-model='form.notes'
:rows='10'
@blur='trimText()')
@blur='trimText()').mpj-full-width
.mpj-text-right
button(@click='saveNotes()').primary.
#[md-icon(icon='save')] Save
@ -75,7 +76,6 @@ export default {
try {
const notes = await api.getNotes(this.form.requestId)
this.priorNotes = notes.data
console.log(this.priorNotes)
this.$Progress.finish()
} catch (e) {
console.error(e)
@ -85,7 +85,6 @@ export default {
}
},
openDialog (request) {
console.log('Received openDialog event')
this.form.requestId = request.requestId
this.notesVisible = true
},
@ -108,10 +107,7 @@ export default {
}
</script>
<style scoped>
#notes {
width: 100%;
}
<style>
.mpj-note-list p {
border-top: dotted 1px lightgray;
}

View File

@ -1,7 +1,7 @@
<template lang="pug">
.mpj-request-card
.mpj-request-card(v-if='shouldDisplay')
header.mpj-card-header(role='toolbar').
#[button.primary(@click='markPrayed()' title='Pray'): md-icon(icon='done')]
#[button(@click='markPrayed()' title='Pray').primary: md-icon(icon='done')]
#[button(@click.stop='showEdit()' title='Edit'): md-icon(icon='edit')]
#[button(@click.stop='showNotes()' title='Add Notes'): md-icon(icon='comment')]
#[button(@click.stop='snooze()' title='Snooze Request'): md-icon(icon='schedule')]

View File

@ -7,17 +7,23 @@ p.mpj-request-text
title='View Full Request').
#[md-icon(icon='description')] View Full Request
| &nbsp; &nbsp;
button(v-if='!isAnswered'
@click='editRequest'
template(v-if='!isAnswered')
button(@click='editRequest'
title='Edit Request').
#[md-icon(icon='edit')] Edit Request
| &nbsp; &nbsp;
button(v-if='isSnoozed'
@click='cancelSnooze()').
template(v-if='isSnoozed')
button(@click='cancelSnooze()').
#[md-icon(icon='restore')] Cancel Snooze
br(v-if='isSnoozed || isAnswered')
| &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>
@ -43,6 +49,9 @@ export default {
isAnswered () {
return this.request.lastStatus === 'Answered'
},
isPending () {
return !this.isSnoozed && this.request.showAfter > Date.now()
},
isSnoozed () {
return this.request.snoozedUntil > Date.now()
}
@ -60,6 +69,15 @@ export default {
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 } })
}

View File

@ -4,10 +4,10 @@
header.mpj-bg
h5 Snooze Prayer Request
p.mpj-text-center
label(for='until') Until
= ' '
input#until(type='date'
v-model='form.snoozedUntil'
label
= 'Until '
input(v-model='form.snoozedUntil'
type='date'
autofocus)
br
.mpj-text-right

View File

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

View File

@ -18,6 +18,13 @@ Vue.use(Router)
export default new Router({
mode: 'history',
scrollBehavior (to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { x: 0, y: 0 }
}
},
routes: [
{
path: '/',

View File

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

View File

@ -73,10 +73,10 @@ export default new Vuex.Store({
}
},
actions: {
async [actions.ADD_REQUEST] ({ commit }, { progress, requestText }) {
async [actions.ADD_REQUEST] ({ commit }, { progress, requestText, recurType, recurCount }) {
progress.start()
try {
const newRequest = await api.addRequest(requestText)
const newRequest = await api.addRequest(requestText, recurType, recurCount)
commit(mutations.REQUEST_ADDED, newRequest.data)
progress.finish()
} catch (err) {
@ -100,10 +100,28 @@ export default new Vuex.Store({
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()
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)
commit(mutations.REQUEST_UPDATED, request.data)
progress.finish()