Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
6a77efe2cf | |||
a1dc067d17 | |||
23600cedee | |||
c032bf10e7 | |||
f4f695ce80 | |||
c685f55d15 | |||
e9de9aebf3 | |||
cf69ff2fa1 | |||
baf37698b3 | |||
3dd7169b8b | |||
53a11b9e57 | |||
ff17d9bf7a | |||
9c9fe8786c | |||
b64a3996e5 | |||
c8abfd00d0 |
5
api/database-dev.json
Normal file
5
api/database-dev.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"driver": "postgres",
|
||||||
|
"connectionString": "host=localhost port=5999 dbname=personal_measure_dev user=postgres",
|
||||||
|
"sqlDir": "src/main/sql/migrations"
|
||||||
|
}
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
include "src/main/nim/personal_measure_apipkg/version.nim"
|
include "src/main/nim/personal_measure_apipkg/version.nim"
|
||||||
|
|
||||||
version = "0.8.0"
|
version = "0.9.0"
|
||||||
author = "Jonathan Bernard"
|
author = "Jonathan Bernard"
|
||||||
description = "JDB\'s Personal Measures API"
|
description = "JDB\'s Personal Measures API"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
@ -392,6 +392,8 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
|
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
|
||||||
except: statusResp(Http500)
|
except: statusResp(Http500)
|
||||||
|
|
||||||
|
# Measure
|
||||||
|
|
||||||
get "/measures":
|
get "/measures":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|
||||||
@ -445,6 +447,37 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
|
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
|
||||||
statusResp(Http500)
|
statusResp(Http500)
|
||||||
|
|
||||||
|
post "/measures/@slug":
|
||||||
|
checkAuth()
|
||||||
|
|
||||||
|
try:
|
||||||
|
let jsonBody = parseJson(request.body)
|
||||||
|
var existingMeasure = ctx.getMeasureForSlug(session.user.id, @"slug")
|
||||||
|
|
||||||
|
if not (jsonBody.hasKey("slug") or jsonBody.hasKey("name")):
|
||||||
|
raiseEx BadRequestError, "body must contain either the 'slug' field (short name), or the 'name' field, or both"
|
||||||
|
|
||||||
|
existingMeasure.slug =
|
||||||
|
if jsonBody.hasKey("slug"): jsonBody["slug"].getStr.nameToSlug
|
||||||
|
else: jsonBody["name"].getStr.nameToSlug
|
||||||
|
|
||||||
|
existingMeasure.name =
|
||||||
|
if jsonBody.hasKey("name"): jsonBody["name"].getStr
|
||||||
|
else: jsonBody["slug"].getStr.capitalize
|
||||||
|
|
||||||
|
|
||||||
|
if jsonBody.hasKey("config"): existingMeasure.config = jsonBody["config"]
|
||||||
|
|
||||||
|
if jsonBody.hasKey("description"): existingMeasure.description = jsonBody["description"].getStr
|
||||||
|
|
||||||
|
jsonResp($(%ctx.db.updateMeasure(existingMeasure)))
|
||||||
|
|
||||||
|
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
|
||||||
|
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
|
||||||
|
except:
|
||||||
|
error "unable to update measure:\n\t" & getCurrentExceptionMsg()
|
||||||
|
statusResp(Http500)
|
||||||
|
|
||||||
delete "/measures/@slug":
|
delete "/measures/@slug":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|
||||||
@ -457,7 +490,8 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
|
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
|
||||||
statusResp(Http500)
|
statusResp(Http500)
|
||||||
|
|
||||||
get "/measure/@slug":
|
# Measurements
|
||||||
|
get "/measurements/@slug":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -468,7 +502,7 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
|
error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
|
||||||
statusResp(Http500)
|
statusResp(Http500)
|
||||||
|
|
||||||
post "/measure/@slug":
|
post "/measurements/@slug":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -494,7 +528,7 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
|
error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
|
||||||
statusResp(Http500)
|
statusResp(Http500)
|
||||||
|
|
||||||
get "/measure/@slug/@id":
|
get "/measurements/@slug/@id":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -509,7 +543,7 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
|
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
|
||||||
statusResp(Http500)
|
statusResp(Http500)
|
||||||
|
|
||||||
put "/measure/@slug/@id":
|
put "/measurements/@slug/@id":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -529,7 +563,7 @@ proc start*(ctx: PMApiContext): void =
|
|||||||
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
|
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
|
||||||
statusResp(Http500)
|
statusResp(Http500)
|
||||||
|
|
||||||
delete "/measure/@slug/@id":
|
delete "/measurements/@slug/@id":
|
||||||
checkAuth()
|
checkAuth()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -1 +1 @@
|
|||||||
const PM_API_VERSION* = "0.8.0"
|
const PM_API_VERSION* = "0.9.0"
|
||||||
|
@ -0,0 +1 @@
|
|||||||
|
### Add the ability to delete measures.
|
@ -0,0 +1,5 @@
|
|||||||
|
### Add support for text entries.
|
||||||
|
|
||||||
|
Thinking is to allow recording an arbitrary text value alonside a timestamp.
|
||||||
|
Presentation would be something like a list of the values. Probably no graph
|
||||||
|
(maybe something like a histogram for some use cases?).
|
9
doc/issues/new-issue.sh
Executable file
9
doc/issues/new-issue.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
id=$(cat next-issue-number.txt)
|
||||||
|
printf "%03d" "$(expr $id + 1)" > next-issue-number.txt
|
||||||
|
|
||||||
|
printf "Title/Summary?\n> "
|
||||||
|
read -r summary
|
||||||
|
|
||||||
|
slugSummary=$(echo "$summary" | tr "[:upper:]" "[:lower:]" | tr ' ' - )
|
||||||
|
slugSummary="${slugSummary//.}"
|
||||||
|
echo "### $summary" > "$id-$slugSummary.md"
|
1
doc/issues/next-issue-number.txt
Normal file
1
doc/issues/next-issue-number.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
007
|
@ -0,0 +1,8 @@
|
|||||||
|
### Provide options for graphing measures.
|
||||||
|
|
||||||
|
As a user I would like to be able to configure the graph used for a measure.
|
||||||
|
|
||||||
|
Needed:
|
||||||
|
- General pattern for graph configuration.
|
||||||
|
- Support for different graph types (line, bar, pie, area, others?).
|
||||||
|
- Support for pre-processing of data (graph rolling average, etc).
|
@ -0,0 +1,10 @@
|
|||||||
|
### Add a timestamp meaure type (no value).
|
||||||
|
|
||||||
|
As a user I would like to be able to measure when things happen (fall asleep)
|
||||||
|
with a simple timestamp.
|
||||||
|
|
||||||
|
#### Implementation notes:
|
||||||
|
|
||||||
|
This may not require a new storage type (just use the existing SimpleMeasure)
|
||||||
|
but UI (input, graphs, etc.) would just ignore the value and only consider the
|
||||||
|
timestamp.
|
@ -0,0 +1 @@
|
|||||||
|
### Add the ability to delete measurements.
|
@ -0,0 +1 @@
|
|||||||
|
### Add the ability to edit measurements.
|
1
doc/issues/open/006-add-the-ability-to-edit-measures.md
Normal file
1
doc/issues/open/006-add-the-ability-to-edit-measures.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
### Add the ability to edit measures.
|
15
web/package-lock.json
generated
15
web/package-lock.json
generated
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "personal-measure-web",
|
"name": "personal-measure-web",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -1059,6 +1059,14 @@
|
|||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/lodash.omit": {
|
||||||
|
"version": "4.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash.omit/-/lodash.omit-4.5.6.tgz",
|
||||||
|
"integrity": "sha512-KXPpOSNX2h0DAG2w7ajpk7TXvWF28ZHs5nJhOJyP0BQHkehgr948RVsToItMme6oi0XJkp19CbuNXkIX8FiBlQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/lodash": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/minimatch": {
|
"@types/minimatch": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||||
@ -9991,6 +9999,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
||||||
},
|
},
|
||||||
|
"lodash.omit": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz",
|
||||||
|
"integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA="
|
||||||
|
},
|
||||||
"lodash.sortby": {
|
"lodash.sortby": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "personal-measure-web",
|
"name": "personal-measure-web",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
@ -18,6 +18,7 @@
|
|||||||
"@types/lodash.assign": "^4.2.6",
|
"@types/lodash.assign": "^4.2.6",
|
||||||
"@types/lodash.findindex": "^4.6.6",
|
"@types/lodash.findindex": "^4.6.6",
|
||||||
"@types/lodash.merge": "^4.6.6",
|
"@types/lodash.merge": "^4.6.6",
|
||||||
|
"@types/lodash.omit": "^4.5.6",
|
||||||
"apexcharts": "^3.15.6",
|
"apexcharts": "^3.15.6",
|
||||||
"axios": "^0.18.1",
|
"axios": "^0.18.1",
|
||||||
"js-cookie": "^2.2.1",
|
"js-cookie": "^2.2.1",
|
||||||
@ -27,6 +28,7 @@
|
|||||||
"lodash.findindex": "^4.6.0",
|
"lodash.findindex": "^4.6.0",
|
||||||
"lodash.keyby": "^4.6.0",
|
"lodash.keyby": "^4.6.0",
|
||||||
"lodash.merge": "^4.6.2",
|
"lodash.merge": "^4.6.2",
|
||||||
|
"lodash.omit": "^4.5.0",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
"register-service-worker": "^1.5.2",
|
"register-service-worker": "^1.5.2",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
|
@ -2,19 +2,40 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<div>
|
<div>
|
||||||
<label for=measureType>Type</label>
|
<label for=measureType>Type</label>
|
||||||
|
<span v-if=measureExists>{{value.type}}</span>
|
||||||
<select
|
<select
|
||||||
:disabled=disabled
|
:disabled=disabled
|
||||||
name=measureType
|
name=measureType
|
||||||
|
v-if="!measureExists"
|
||||||
v-model=value.type>
|
v-model=value.type>
|
||||||
<option value=simple>Simple</option>
|
<option value=simple>Simple</option>
|
||||||
<option value=list>List</option>
|
<option value=text>Text</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for=measureIsVisible>Show by default.</label>
|
<label for=measureIsVisible>Show by default.</label>
|
||||||
<input type=checkbox v-model=value.isVisible :disabled=disabled />
|
<input type=checkbox v-model=value.isVisible :disabled=disabled />
|
||||||
</div>
|
</div>
|
||||||
<!--<ListMeasureConfigForm :config=config v-show="config.type === 'list'"/>-->
|
<div>
|
||||||
|
<label for=timestampDisplayFormat>Timestamp Format</label>
|
||||||
|
<select
|
||||||
|
v-on:change=formatSelectionChanged
|
||||||
|
:disabled=disabled
|
||||||
|
v-model=selectedFormat
|
||||||
|
name=timestampDisplayFormat>
|
||||||
|
<option v-for="fmtStr in formatStrings"
|
||||||
|
:value=fmtStr>{{now.format(fmtStr)}}</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedFormat === 'custom'">
|
||||||
|
<label for=timestampCustomDisplayFormat>
|
||||||
|
Custom Timestamp Format
|
||||||
|
(<a target="_blank" href="https://momentjs.com/docs/#/displaying/format/">see formatting options</a>)
|
||||||
|
</label>
|
||||||
|
<input type=text v-model=value.timestampDisplayFormat />
|
||||||
|
</div>
|
||||||
|
<TextMeasureConfigForm v-model=value v-show="value.type === 'text'" :disabled=disabled />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</template>
|
</template>
|
||||||
<script lang=ts src=./measure-config-form.ts></script>
|
<script lang=ts src=./measure-config-form.ts></script>
|
||||||
|
10
web/src/components/measure-config/TextMeasureConfigForm.vue
Normal file
10
web/src/components/measure-config/TextMeasureConfigForm.vue
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label for=textEntryShowTimestamp>Show Timestamps.</label>
|
||||||
|
<input name=textEntryShowTimestamp
|
||||||
|
:disabled=disabled
|
||||||
|
type=checkbox
|
||||||
|
v-model=value.showTimestamp />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang=ts src=./text-measure-config-form.ts></script>
|
@ -1,17 +1,60 @@
|
|||||||
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
|
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||||
import { logService } from '@/services/logging';
|
import { logService } from '@/services/logging';
|
||||||
import { Measure, MeasureConfig } from '@/models';
|
import { Measure, MeasureConfig } from '@/models';
|
||||||
|
import TextMeasureConfigForm from './TextMeasureConfigForm.vue';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
@Component({})
|
@Component({
|
||||||
|
components: {
|
||||||
|
TextMeasureConfigForm
|
||||||
|
}
|
||||||
|
})
|
||||||
export class MeasureConfigForm extends Vue {
|
export class MeasureConfigForm extends Vue {
|
||||||
@Prop({}) public value!: MeasureConfig;
|
@Prop({}) public value!: MeasureConfig;
|
||||||
@Prop({}) public disabled: boolean = false;
|
@Prop({}) public disabled: boolean = false;
|
||||||
|
@Prop({}) public measureExists!: boolean;
|
||||||
|
|
||||||
|
public now = moment();
|
||||||
|
public formatStrings = [
|
||||||
|
'l',
|
||||||
|
'L',
|
||||||
|
'll',
|
||||||
|
'LL',
|
||||||
|
'lll',
|
||||||
|
'LLL',
|
||||||
|
'llll',
|
||||||
|
'LLLL',
|
||||||
|
'Y-MM-DD',
|
||||||
|
'Y-MM-DDTHH:mm',
|
||||||
|
'Y-MM-DDTHH:mm:ss',
|
||||||
|
'Y-MM-DDTHH:mm:ss.SSSZZ',
|
||||||
|
'MM/DD',
|
||||||
|
'MMM Do',
|
||||||
|
'HH:mm',
|
||||||
|
'hh:mmA'
|
||||||
|
];
|
||||||
|
|
||||||
|
private selectedFormat: string = 'l';
|
||||||
|
|
||||||
@Watch('value', { immediate: true, deep: true })
|
@Watch('value', { immediate: true, deep: true })
|
||||||
@Emit('input')
|
@Emit('input')
|
||||||
private onConfigChanged(newVal: MeasureConfig, oldVal: MeasureConfig) {
|
private onConfigChanged(newVal: MeasureConfig, oldVal: MeasureConfig) {
|
||||||
return newVal;
|
return newVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatSelectionChanged() {
|
||||||
|
if (this.selectedFormat !== 'custom') {
|
||||||
|
this.value.timestampDisplayFormat = this.selectedFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mounted() {
|
||||||
|
if (this.formatStrings.includes(this.value.timestampDisplayFormat)) {
|
||||||
|
this.selectedFormat = this.value.timestampDisplayFormat;
|
||||||
|
} else {
|
||||||
|
this.selectedFormat = 'custom';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MeasureConfigForm;
|
export default MeasureConfigForm;
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||||
|
import { logService } from '@/services/logging';
|
||||||
|
import { Measure, MeasureConfig, TextMeasureConfig } from '@/models';
|
||||||
|
|
||||||
|
@Component({})
|
||||||
|
export class TextMeasureConfigForm extends Vue {
|
||||||
|
@Prop({}) public value!: MeasureConfig;
|
||||||
|
@Prop({}) public disabled: boolean = false;
|
||||||
|
|
||||||
|
@Watch('value', { immediate: true, deep: true })
|
||||||
|
@Emit('input')
|
||||||
|
private onConfigChanged(newVal: TextMeasureConfig, oldVal: TextMeasureConfig) {
|
||||||
|
return newVal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextMeasureConfigForm;
|
@ -2,6 +2,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<SimpleDetails v-if="measure.config.type === 'simple'"
|
<SimpleDetails v-if="measure.config.type === 'simple'"
|
||||||
:measure=measure :measurements=measurements />
|
:measure=measure :measurements=measurements />
|
||||||
|
<TextDetails v-if="measure.config.type === 'text'"
|
||||||
|
:measure=measure :measurements=measurements />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" src="./measure-details.ts"></script>
|
<script lang="ts" src="./measure-details.ts"></script>
|
||||||
|
@ -15,7 +15,6 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</v-table>
|
</v-table>
|
||||||
<SimpleEntry :measure=measure v-model=
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" src="./simple-details.ts"></script>
|
<script lang="ts" src="./simple-details.ts"></script>
|
||||||
|
22
web/src/components/measure-details/TextDetails.vue
Normal file
22
web/src/components/measure-details/TextDetails.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<div class=text-details>
|
||||||
|
<v-table :data=measurementTableData>
|
||||||
|
<thead slot=head>
|
||||||
|
<tr>
|
||||||
|
<v-th
|
||||||
|
v-if="measure.config.showTimestamp"
|
||||||
|
sortKey=tsSort
|
||||||
|
defaultSort=asc>Timestamp</v-th>
|
||||||
|
<v-th sortKey=value>{{measure.name}}</v-th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody slot=body slot-scope={displayData} >
|
||||||
|
<tr v-for="row in displayData" :key="row.id">
|
||||||
|
<td v-if="measure.config.showTimestamp">{{row.tsDisplay}}</td>
|
||||||
|
<td>{{row.extData.entry}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang=ts src=./text-details.ts></script>
|
@ -1,9 +1,13 @@
|
|||||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||||
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||||
import SimpleDetails from './SimpleDetails.vue';
|
import SimpleDetails from './SimpleDetails.vue';
|
||||||
|
import TextDetails from './TextDetails.vue';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { SimpleDetails }
|
components: {
|
||||||
|
SimpleDetails,
|
||||||
|
TextDetails
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class MeasureDetails extends Vue {
|
export class MeasureDetails extends Vue {
|
||||||
@Prop() private measure!: Measure<MeasureConfig>;
|
@Prop() private measure!: Measure<MeasureConfig>;
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||||
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||||
import moment from 'moment';
|
|
||||||
import assign from 'lodash.assign';
|
import assign from 'lodash.assign';
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||||
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { byTimestampComparator, formatTS } from '@/util';
|
||||||
|
|
||||||
library.add(faPencilAlt);
|
library.add(faPencilAlt);
|
||||||
|
|
||||||
@ -12,8 +12,6 @@ export class SimpleDetails extends Vue {
|
|||||||
@Prop() private measure!: Measure<MeasureConfig>;
|
@Prop() private measure!: Measure<MeasureConfig>;
|
||||||
@Prop() private measurements!: Array<Measurement<MeasurementMeta>>;
|
@Prop() private measurements!: Array<Measurement<MeasurementMeta>>;
|
||||||
|
|
||||||
// private newMeasurement;
|
|
||||||
private moment = moment;
|
|
||||||
private chartOptions = {
|
private chartOptions = {
|
||||||
markers: { size: 6 },
|
markers: { size: 6 },
|
||||||
noData: { text: 'no data',
|
noData: { text: 'no data',
|
||||||
@ -28,7 +26,7 @@ export class SimpleDetails extends Vue {
|
|||||||
return [{
|
return [{
|
||||||
name: this.measure.name,
|
name: this.measure.name,
|
||||||
data: measurementData
|
data: measurementData
|
||||||
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
|
.sort(byTimestampComparator)
|
||||||
.map((m) => ({ x: m.timestamp.toISOString(), y: m.value }))
|
.map((m) => ({ x: m.timestamp.toISOString(), y: m.value }))
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
@ -36,7 +34,7 @@ export class SimpleDetails extends Vue {
|
|||||||
private get measurementTableData() {
|
private get measurementTableData() {
|
||||||
return (this.measurements || []).map((m) => {
|
return (this.measurements || []).map((m) => {
|
||||||
return assign({}, m, {
|
return assign({}, m, {
|
||||||
tsDisplay: moment(m.timestamp).format('MMM Do, HH:mm'),
|
tsDisplay: formatTS(this.measure, m),
|
||||||
tsSort: m.timestamp.toISOString()
|
tsSort: m.timestamp.toISOString()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
22
web/src/components/measure-details/text-details.ts
Normal file
22
web/src/components/measure-details/text-details.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||||
|
import assign from 'lodash.assign';
|
||||||
|
import { Measure, Measurement, TextMeasureConfig, TextMeasurementMeta } from '@/models';
|
||||||
|
import { formatTS } from '@/util';
|
||||||
|
|
||||||
|
@Component({})
|
||||||
|
export class TextDetails extends Vue {
|
||||||
|
@Prop() private measure!: Measure<TextMeasureConfig>;
|
||||||
|
@Prop() private measurements!: Array<Measurement<TextMeasurementMeta>>;
|
||||||
|
|
||||||
|
private get measurementTableData() {
|
||||||
|
return (this.measurements || []).map((m) => {
|
||||||
|
return assign({}, m, {
|
||||||
|
tsDisplay: formatTS(this.measure, m),
|
||||||
|
tsSort: m.timestamp.toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextDetails;
|
@ -1,6 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ul>
|
|
||||||
<li v-for="m in top5">{{m.extData.entry}}</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
<script lang="ts" src="./list-summary.ts"></script>
|
|
@ -5,7 +5,7 @@
|
|||||||
{{measure.name}}</router-link></h2>
|
{{measure.name}}</router-link></h2>
|
||||||
<SimpleSummaryGraph v-if="measure.config.type === 'simple'"
|
<SimpleSummaryGraph v-if="measure.config.type === 'simple'"
|
||||||
:measure=measure :measurements=measurements />
|
:measure=measure :measurements=measurements />
|
||||||
<ListSummary v-if="measure.config.type === 'list'"
|
<TextSummary v-if="measure.config.type === 'text'"
|
||||||
:measure=measure :measurements=measurements />
|
:measure=measure :measurements=measurements />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
13
web/src/components/measure-summaries/TextSummary.vue
Normal file
13
web/src/components/measure-summaries/TextSummary.vue
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="m in top5"
|
||||||
|
v-bind:class="{ 'show-timestamp': measure.config.showTimestamp,
|
||||||
|
'full-timestamp': !withinLastYear }">
|
||||||
|
<span class=timestamp>{{formatDate(m.timestamp)}}</span>
|
||||||
|
<span class=entry>{{m.extData.entry}}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" src="./text-summary.ts"></script>
|
||||||
|
<style scoped lang="scss" src="./text-summary.scss"></script>
|
@ -1,16 +0,0 @@
|
|||||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
|
||||||
import { Measure, ListMeasureConfig, Measurement, ListMeasurementMeta } from '@/models';
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export class ListSummary extends Vue {
|
|
||||||
@Prop() private measure!: Measure<ListMeasureConfig>;
|
|
||||||
@Prop() private measurements!: Array<Measurement<ListMeasurementMeta>>;
|
|
||||||
|
|
||||||
private top5(): Array<Measurement<ListMeasurementMeta>> {
|
|
||||||
return this.measurements
|
|
||||||
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
|
||||||
.slice(0, 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ListSummary;
|
|
@ -1,12 +1,12 @@
|
|||||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||||
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||||
import { measurementStore } from '@/store';
|
import { measurementStore } from '@/store';
|
||||||
import ListSummary from './ListSummary.vue';
|
import TextSummary from './TextSummary.vue';
|
||||||
import SimpleSummaryGraph from './SimpleSummaryGraph.vue';
|
import SimpleSummaryGraph from './SimpleSummaryGraph.vue';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
ListSummary,
|
TextSummary,
|
||||||
SimpleSummaryGraph
|
SimpleSummaryGraph
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
39
web/src/components/measure-summaries/text-summary.scss
Normal file
39
web/src/components/measure-summaries/text-summary.scss
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
@import '~@/styles/vars';
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: bottom;
|
||||||
|
|
||||||
|
&.timestamp {
|
||||||
|
color: $color2;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.entry {
|
||||||
|
overflow-x: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.show-timestamp) {
|
||||||
|
span.timestamp { display: none; }
|
||||||
|
span.entry { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show-timestamp {
|
||||||
|
span.timestamp { width: 5rem; }
|
||||||
|
span.entry { width: calc(100% - 5rem); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.show-timestamp.full-timestamp {
|
||||||
|
span.timestamp { width: 6rem; }
|
||||||
|
span.entry { width: calc(100% - 6rem); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
33
web/src/components/measure-summaries/text-summary.ts
Normal file
33
web/src/components/measure-summaries/text-summary.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { Measure, TextMeasureConfig, Measurement, TextMeasurementMeta } from '@/models';
|
||||||
|
import { byTimestampComparator } from '@/util';
|
||||||
|
|
||||||
|
const YEAR_START = moment().startOf('year');
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export class TextSummary extends Vue {
|
||||||
|
@Prop() private measure!: Measure<TextMeasureConfig>;
|
||||||
|
@Prop() private measurements!: Array<Measurement<TextMeasurementMeta>>;
|
||||||
|
|
||||||
|
private top5: Array<Measurement<TextMeasurementMeta>> = [];
|
||||||
|
private withinLastYear: boolean = true;
|
||||||
|
|
||||||
|
@Watch('measurements')
|
||||||
|
private onMeasurementsChanged() {
|
||||||
|
this.top5 = this.measurements
|
||||||
|
.slice(0)
|
||||||
|
.sort(byTimestampComparator)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
this.withinLastYear = this.top5.every((entry) => YEAR_START.isBefore(entry.timestamp));
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDate(ts: Date) {
|
||||||
|
if (this.withinLastYear) { return moment(ts).format('MMM. Do'); }
|
||||||
|
else { return moment(ts).format('YYYY-MM-DD'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextSummary;
|
@ -2,6 +2,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<SimpleEntry v-if="measure.config.type === 'simple'"
|
<SimpleEntry v-if="measure.config.type === 'simple'"
|
||||||
:measure=measure v-model=value />
|
:measure=measure v-model=value />
|
||||||
|
<TextEntry v-if="measure.config.type === 'text'"
|
||||||
|
:measure=measure v-model=value />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" src="./measurement-entry.ts"></script>
|
<script lang="ts" src="./measurement-entry.ts"></script>
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
<fieldset>
|
<fieldset>
|
||||||
<div>
|
<div>
|
||||||
<label for=timestamp>Timestamp</label>
|
<label for=timestamp>Timestamp</label>
|
||||||
<input type=datetime-local
|
<input
|
||||||
|
name=timestamp
|
||||||
|
type=datetime-local
|
||||||
v-model=value.timestamp
|
v-model=value.timestamp
|
||||||
v-show=editTimestamp
|
v-show=editTimestamp
|
||||||
:disabled=disabled />
|
:disabled=disabled />
|
||||||
|
26
web/src/components/measurement-entry/TextEntry.vue
Normal file
26
web/src/components/measurement-entry/TextEntry.vue
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<fieldset>
|
||||||
|
<div>
|
||||||
|
<label for=timestamp>Timestamp</label>
|
||||||
|
<input
|
||||||
|
name=timestamp
|
||||||
|
type=datetime-local
|
||||||
|
v-model=value.timestamp
|
||||||
|
v-show=editTimestamp
|
||||||
|
:disabled=disabled />
|
||||||
|
<span v-show="!editTimestamp">
|
||||||
|
now <a href="#" v-on:click.stop.prevent="editTimestamp = true"> (set a time)</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for=measurementEntry>{{measure.name}}</label>
|
||||||
|
<input
|
||||||
|
name=measurementEntry
|
||||||
|
required
|
||||||
|
type=text
|
||||||
|
v-model=value.extData.entry
|
||||||
|
:disabled=disabled />
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" src="./text-entry.ts"></script>
|
@ -1,9 +1,13 @@
|
|||||||
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
|
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||||
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||||
import SimpleEntry from './SimpleEntry.vue';
|
import SimpleEntry from './SimpleEntry.vue';
|
||||||
|
import TextEntry from './TextEntry.vue';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { SimpleEntry }
|
components: {
|
||||||
|
SimpleEntry,
|
||||||
|
TextEntry
|
||||||
|
}
|
||||||
})
|
})
|
||||||
export class MeasurementEntry extends Vue {
|
export class MeasurementEntry extends Vue {
|
||||||
@Prop() private measure!: Measure<MeasureConfig>;
|
@Prop() private measure!: Measure<MeasureConfig>;
|
||||||
|
@ -11,8 +11,6 @@ export class SimpleEntry extends Vue {
|
|||||||
@Watch('value', { immediate: true, deep: true })
|
@Watch('value', { immediate: true, deep: true })
|
||||||
@Emit('input')
|
@Emit('input')
|
||||||
private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) {
|
private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) {
|
||||||
newVal.extData.measureType = 'simple' as MeasureType;
|
|
||||||
|
|
||||||
if (typeof(newVal.value) === 'string' ) {
|
if (typeof(newVal.value) === 'string' ) {
|
||||||
newVal.value = parseInt(newVal.value, 10);
|
newVal.value = parseInt(newVal.value, 10);
|
||||||
}
|
}
|
||||||
|
13
web/src/components/measurement-entry/text-entry.ts
Normal file
13
web/src/components/measurement-entry/text-entry.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Component, Emit, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||||
|
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||||
|
|
||||||
|
@Component({})
|
||||||
|
export class TextEntry extends Vue {
|
||||||
|
@Prop() public measure!: Measure<MeasureConfig>;
|
||||||
|
@Prop() public value!: Measurement<MeasurementMeta>;
|
||||||
|
@Prop() public disabled!: boolean;
|
||||||
|
private editTimestamp: boolean = false;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextEntry;
|
8
web/src/models.d.ts
vendored
8
web/src/models.d.ts
vendored
@ -1,4 +1,4 @@
|
|||||||
export enum MeasureType { List = 'list', Simple = 'simple' }
|
export enum MeasureType { Text = 'text', Simple = 'simple' }
|
||||||
|
|
||||||
export interface ApiToken {
|
export interface ApiToken {
|
||||||
id: string;
|
id: string;
|
||||||
@ -17,9 +17,10 @@ export interface LoginSubmit {
|
|||||||
export interface MeasureConfig {
|
export interface MeasureConfig {
|
||||||
type: MeasureType;
|
type: MeasureType;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
|
timestampDisplayFormat: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListMeasureConfig extends MeasureConfig {
|
export interface TextMeasureConfig extends MeasureConfig {
|
||||||
showTimestamp: boolean;
|
showTimestamp: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,10 +34,9 @@ export interface Measure<C extends MeasureConfig> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MeasurementMeta {
|
export interface MeasurementMeta {
|
||||||
measureType: MeasureType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListMeasurementMeta extends MeasurementMeta {
|
export interface TextMeasurementMeta extends MeasurementMeta {
|
||||||
entry: string;
|
entry: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,8 @@ import Measure from '@/views/Measure.vue';
|
|||||||
import Measures from '@/views/Measures.vue';
|
import Measures from '@/views/Measures.vue';
|
||||||
import NewMeasure from '@/views/NewMeasure.vue';
|
import NewMeasure from '@/views/NewMeasure.vue';
|
||||||
import NewMeasurement from '@/views/NewMeasurement.vue';
|
import NewMeasurement from '@/views/NewMeasurement.vue';
|
||||||
|
import DeleteMeasure from '@/views/DeleteMeasure.vue';
|
||||||
|
import EditMeasure from '@/views/EditMeasure.vue';
|
||||||
import NotFound from '@/views/NotFound.vue';
|
import NotFound from '@/views/NotFound.vue';
|
||||||
import QuickPanels from '@/views/QuickPanels.vue';
|
import QuickPanels from '@/views/QuickPanels.vue';
|
||||||
import UserAccount from '@/views/UserAccount.vue';
|
import UserAccount from '@/views/UserAccount.vue';
|
||||||
@ -68,6 +70,16 @@ const router = new Router({
|
|||||||
name: 'new-measurement',
|
name: 'new-measurement',
|
||||||
component: NewMeasurement
|
component: NewMeasurement
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/delete/measure/:slug',
|
||||||
|
name: 'delete-measure',
|
||||||
|
component: DeleteMeasure
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/edit/measure/:slug',
|
||||||
|
name: 'edit-measure',
|
||||||
|
component: EditMeasure
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '*',
|
path: '*',
|
||||||
name: 'not-found',
|
name: 'not-found',
|
||||||
|
@ -123,6 +123,11 @@ export class PmApiClient {
|
|||||||
return resp.data;
|
return resp.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateMeasure<T extends MeasureConfig>(measure: Measure<T>): Promise<Measure<T>> {
|
||||||
|
const resp = await this.http.post(`/measures/${measure.slug}`, measure);
|
||||||
|
return resp.data;
|
||||||
|
}
|
||||||
|
|
||||||
public async deleteMeasure(slug: string): Promise<boolean> {
|
public async deleteMeasure(slug: string): Promise<boolean> {
|
||||||
const resp = await this.http.delete(`/measures/${slug}`);
|
const resp = await this.http.delete(`/measures/${slug}`);
|
||||||
return true;
|
return true;
|
||||||
@ -131,7 +136,7 @@ export class PmApiClient {
|
|||||||
public async getMeasurements(measureSlug: string)
|
public async getMeasurements(measureSlug: string)
|
||||||
: Promise<Array<Measurement<MeasurementMeta>>> {
|
: Promise<Array<Measurement<MeasurementMeta>>> {
|
||||||
|
|
||||||
const resp = await this.http.get(`/measure/${measureSlug}`);
|
const resp = await this.http.get(`/measurements/${measureSlug}`);
|
||||||
return resp.data.map(this.fromMeasurementDTO);
|
return resp.data.map(this.fromMeasurementDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,7 +146,7 @@ export class PmApiClient {
|
|||||||
: Promise<Measurement<MeasurementMeta>> {
|
: Promise<Measurement<MeasurementMeta>> {
|
||||||
|
|
||||||
const resp = await this.http.post(
|
const resp = await this.http.post(
|
||||||
`/measure/${measureSlug}`,
|
`/measurements/${measureSlug}`,
|
||||||
this.toMeasurementDTO(measurement));
|
this.toMeasurementDTO(measurement));
|
||||||
return this.fromMeasurementDTO(resp.data);
|
return this.fromMeasurementDTO(resp.data);
|
||||||
}
|
}
|
||||||
@ -152,7 +157,7 @@ export class PmApiClient {
|
|||||||
: Promise<Measurement<MeasurementMeta>> {
|
: Promise<Measurement<MeasurementMeta>> {
|
||||||
|
|
||||||
const resp = await this.http
|
const resp = await this.http
|
||||||
.get(`/measure/${measureSlug}/${measurementId}`);
|
.get(`/measurements/${measureSlug}/${measurementId}`);
|
||||||
return this.fromMeasurementDTO(resp.data);
|
return this.fromMeasurementDTO(resp.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,7 +167,7 @@ export class PmApiClient {
|
|||||||
: Promise<Measurement<MeasurementMeta>> {
|
: Promise<Measurement<MeasurementMeta>> {
|
||||||
|
|
||||||
const resp = await this.http.put(
|
const resp = await this.http.put(
|
||||||
`/measure/${measureSlug}/${measurement.id}`,
|
`/measurements/${measureSlug}/${measurement.id}`,
|
||||||
this.toMeasurementDTO(measurement));
|
this.toMeasurementDTO(measurement));
|
||||||
return this.fromMeasurementDTO(resp.data);
|
return this.fromMeasurementDTO(resp.data);
|
||||||
}
|
}
|
||||||
@ -173,7 +178,7 @@ export class PmApiClient {
|
|||||||
: Promise<boolean> {
|
: Promise<boolean> {
|
||||||
|
|
||||||
const resp = await this.http
|
const resp = await this.http
|
||||||
.delete(`/measure/${measureSlug}/${measurementId}`);
|
.delete(`/measurements/${measureSlug}/${measurementId}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,9 @@ import {
|
|||||||
MutationAction,
|
MutationAction,
|
||||||
VuexModule
|
VuexModule
|
||||||
} from 'vuex-module-decorators';
|
} from 'vuex-module-decorators';
|
||||||
|
import assign from 'lodash.assign';
|
||||||
import keyBy from 'lodash.keyby';
|
import keyBy from 'lodash.keyby';
|
||||||
|
import omit from 'lodash.omit';
|
||||||
import { User, Measure, MeasureConfig } from '@/models';
|
import { User, Measure, MeasureConfig } from '@/models';
|
||||||
import api from '@/services/pm-api-client';
|
import api from '@/services/pm-api-client';
|
||||||
|
|
||||||
@ -28,13 +30,29 @@ export class MeasureStoreModule extends VuexModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Action({ rawError: true })
|
@Action({ rawError: true })
|
||||||
public async createMeasure(m: Measure<MeasureConfig>) {
|
public async createMeasure<T extends MeasureConfig>(m: Measure<T>) {
|
||||||
const newMeasure = await api.createMeasure(m);
|
const newMeasure = await api.createMeasure(m);
|
||||||
this.context.commit('SET_MEASURE', newMeasure);
|
this.context.commit('SET_MEASURE', newMeasure);
|
||||||
return newMeasure;
|
return newMeasure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Action({ rawError: true })
|
||||||
|
public async deleteMeasure<T extends MeasureConfig>(m: Measure<T>) {
|
||||||
|
const delResponse = await api.deleteMeasure(m.slug);
|
||||||
|
this.context.commit('DELETE_MEASURE', m);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Action({ rawError: true })
|
||||||
|
public async updateMeasure<T extends MeasureConfig>(m: Measure<T>) {
|
||||||
|
const updatedMeasure = await api.updateMeasure(m);
|
||||||
|
return updatedMeasure;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation private SET_MEASURE<T extends MeasureConfig>(measure: Measure<T>) {
|
@Mutation private SET_MEASURE<T extends MeasureConfig>(measure: Measure<T>) {
|
||||||
this.measures[measure.slug] = measure;
|
this.measures = assign({}, this.measures, {[measure.slug]: measure});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation private DELETE_MEASURE<T extends MeasureConfig>(measure: Measure<T>) {
|
||||||
|
this.measures = assign({}, omit(this.measures, measure.slug));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
@import '~@/styles/vars';
|
@import '~@/styles/vars';
|
||||||
|
|
||||||
|
button,
|
||||||
.btn,
|
.btn,
|
||||||
.btn-action {
|
.btn-action,
|
||||||
|
.btn-icon {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: .25em;
|
border-radius: .25em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -13,14 +15,27 @@
|
|||||||
a { text-decoration: none; }
|
a { text-decoration: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn, .btn-icon { color: $fg-primary; }
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
|
||||||
|
border-radius: 1em;
|
||||||
|
padding: .5em;
|
||||||
|
margin: 0 .5em;
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background-color: darken($bg-primary, 20%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.btn-action {
|
.btn-action {
|
||||||
background-color: $color2;
|
background-color: $color2;
|
||||||
color: $color3;
|
color: $color3;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover, &:focus {
|
||||||
background-color: darken($color2, 5%);
|
background-color: lighten($color2, 20%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
25
web/src/util.ts
Normal file
25
web/src/util.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Measure, MeasureConfig, Measurement, MeasurementMeta } from '@/models';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
export function byTimestampComparator<T extends MeasurementMeta>(
|
||||||
|
a: Measurement<T>,
|
||||||
|
b: Measurement<T>): number {
|
||||||
|
return a.timestamp.getTime() - b.timestamp.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTS(
|
||||||
|
m: Measure<MeasureConfig>,
|
||||||
|
mm: Measurement<MeasurementMeta>
|
||||||
|
): string {
|
||||||
|
return moment(mm.timestamp).format(
|
||||||
|
m.config.timestampDisplayFormat || 'MMM Do');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s\-]/g, '')
|
||||||
|
.replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
25
web/src/views/DeleteMeasure.vue
Normal file
25
web/src/views/DeleteMeasure.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="measure">
|
||||||
|
<div class=header>
|
||||||
|
<h1>Delete Measure</h1>
|
||||||
|
<h2>Are you sure you want to delete {{measure.name}}?</h2>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent=deleteMeasure() >
|
||||||
|
This will delete all measurements associated with this measure. This
|
||||||
|
cannot be undone.
|
||||||
|
<div v-if='!waiting' class=form-actions>
|
||||||
|
<button class=btn-action>Delete</button>
|
||||||
|
<a class=btn @click="$router.go(-1)">Cancel</a>
|
||||||
|
</div>
|
||||||
|
<div v-if='waiting' class=form-waiting>
|
||||||
|
<div class=wait-spinner>working <fa-icon icon=sync spin /></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class=header-action>
|
||||||
|
<h1>There is no measure named {{$route.params.slug}}.</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang=ts src=./delete-measure.ts></script>
|
52
web/src/views/EditMeasure.vue
Normal file
52
web/src/views/EditMeasure.vue
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<template>
|
||||||
|
<div id=edit-measure v-if=measure>
|
||||||
|
<div class=header>
|
||||||
|
<h1>Edit Measure</h1>
|
||||||
|
<h2>{{measure.name}}</h2>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent=updateMeasure() class=edit-measure-form>
|
||||||
|
<fieldset>
|
||||||
|
<div>
|
||||||
|
<label for=measureName>Display Name</label>
|
||||||
|
<input
|
||||||
|
:disabled=waiting
|
||||||
|
type=text
|
||||||
|
name=measureName
|
||||||
|
placeholder="what you are measuring"
|
||||||
|
required
|
||||||
|
v-model="measure.name" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for=measureDescription>Description</label>
|
||||||
|
<textarea
|
||||||
|
:disabled=waiting
|
||||||
|
name=measureDescription
|
||||||
|
placeholder="optional description"
|
||||||
|
v-model="measure.description" ></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for=measureSlug>Short name (slug)</label>
|
||||||
|
<input
|
||||||
|
:disabled=waiting
|
||||||
|
type=text
|
||||||
|
name=measureDescription
|
||||||
|
:placeholder='slugFromName + " (default)"'
|
||||||
|
:value="measure.slug"
|
||||||
|
@input="measure.slug = slugify($event.target.value)"/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<MeasureConfigForm
|
||||||
|
v-model=measure.config
|
||||||
|
:disabled=waiting
|
||||||
|
measureExists=false />
|
||||||
|
<div v-if='!waiting' class=form-actions>
|
||||||
|
<button class=btn-action>Update</button>
|
||||||
|
<a class=btn @click="$router.go(-1)">Cancel</a>
|
||||||
|
</div>
|
||||||
|
<div v-if='waiting' class=form-waiting>
|
||||||
|
<div class=wait-spinner>working <fa-icon icon=sync spin /></div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang=ts src=./edit-measure.ts></script>
|
@ -5,7 +5,26 @@
|
|||||||
<h1>{{measure.name}}</h1>
|
<h1>{{measure.name}}</h1>
|
||||||
<h2>{{measure.description}}</h2>
|
<h2>{{measure.description}}</h2>
|
||||||
</div>
|
</div>
|
||||||
<router-link :to="'/new/measurement/' + measure.slug" class=btn-action>Add Measurement</router-link>
|
<div class=actions>
|
||||||
|
<router-link
|
||||||
|
title="Delete Measure"
|
||||||
|
:to="'/delete/measure/' + measure.slug"
|
||||||
|
class=btn-icon >
|
||||||
|
<fa-icon icon=trash></fa-icon>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
title="Edit Measure"
|
||||||
|
:to="'/edit/measure/' + measure.slug"
|
||||||
|
class=btn-icon>
|
||||||
|
<fa-icon icon=pencil-alt></fa-icon>
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
title="Add Measurement"
|
||||||
|
:to="'/new/measurement/' + measure.slug"
|
||||||
|
class=btn-action>
|
||||||
|
Add Measurement
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MeasureDetails :measure=measure :measurements=measurements />
|
<MeasureDetails :measure=measure :measurements=measurements />
|
||||||
</div>
|
</div>
|
||||||
@ -16,4 +35,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" src="./measure.ts"></script>
|
<script lang="ts" src="./measure.ts"></script>
|
||||||
<style lang="scss" src="./measure.scss"></style>
|
<style scoped lang="scss" src="./measure.scss"></style>
|
||||||
|
@ -12,12 +12,9 @@
|
|||||||
<div class=measure-list>
|
<div class=measure-list>
|
||||||
<MeasureSummary
|
<MeasureSummary
|
||||||
v-for="(measure, slug) in measures"
|
v-for="(measure, slug) in measures"
|
||||||
v-show="measure.slug.startsWith(filter)"
|
v-bind:key="measure.id"
|
||||||
|
v-show="measure.slug.startsWith(filter.toLowerCase())"
|
||||||
:measure=measure />
|
:measure=measure />
|
||||||
<!--<MeasureSummary
|
|
||||||
v-for="(measure, slug) in measures"
|
|
||||||
:key="slug"
|
|
||||||
:measure=measure />-->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
38
web/src/views/delete-measure.ts
Normal file
38
web/src/views/delete-measure.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||||
|
import { Measure as MeasureModel, MeasureConfig } from '@/models';
|
||||||
|
import { measureStore, measurementStore } from '@/store';
|
||||||
|
import { logService } from '@/services/logging';
|
||||||
|
|
||||||
|
const logger = logService.getLogger('/views/delete-measure');
|
||||||
|
|
||||||
|
@Component({})
|
||||||
|
export class DeleteMeasure extends Vue {
|
||||||
|
private waiting: boolean = false;
|
||||||
|
|
||||||
|
private get measure(): MeasureModel<MeasureConfig> | null {
|
||||||
|
return measureStore.measures[this.$route.params.slug] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mounted() {
|
||||||
|
if (!this.measure) {
|
||||||
|
await measureStore.fetchMeasure(this.$route.params.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteMeasure() {
|
||||||
|
if (this.measure) {
|
||||||
|
this.waiting = true;
|
||||||
|
try {
|
||||||
|
await measureStore.deleteMeasure(this.measure);
|
||||||
|
this.$router.push({ name: 'measures' });
|
||||||
|
} catch (e) {
|
||||||
|
// TODO: show errors
|
||||||
|
logger.error('Unable to delete measure. \n\t ' + JSON.stringify(this.measure), e.stack);
|
||||||
|
} finally {
|
||||||
|
this.waiting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteMeasure;
|
56
web/src/views/edit-measure.ts
Normal file
56
web/src/views/edit-measure.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||||
|
import { logService } from '@/services/logging';
|
||||||
|
import { measureStore, userStore } from '@/store';
|
||||||
|
import { Measure, MeasureConfig, MeasureType } from '@/models';
|
||||||
|
import MeasureConfigForm from '@/components/measure-config/MeasureConfigForm.vue';
|
||||||
|
import { slugify } from '@/util';
|
||||||
|
|
||||||
|
const logger = logService.getLogger('/views/edit-measure');
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
components: { MeasureConfigForm }
|
||||||
|
})
|
||||||
|
export class EditMeasure extends Vue {
|
||||||
|
private waiting = false;
|
||||||
|
|
||||||
|
private get measure(): Measure<MeasureConfig> | null {
|
||||||
|
return measureStore.measures[this.$route.params.slug] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get slugFromName() {
|
||||||
|
if (this.measure) {
|
||||||
|
return slugify(this.measure.name);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateMeasure() {
|
||||||
|
if (this.measure) {
|
||||||
|
if (!this.measure.slug) {
|
||||||
|
this.measure.slug = slugify(this.measure.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.waiting = true;
|
||||||
|
try {
|
||||||
|
await measureStore.updateMeasure(this.measure);
|
||||||
|
this.$router.push({name: 'measure', params: { slug: this.measure.slug }});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Unable to update measure. \n\t' + JSON.stringify(this.measure), e.stack);
|
||||||
|
} finally {
|
||||||
|
this.waiting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async mounted() {
|
||||||
|
// good chance we've already fetched this
|
||||||
|
// TODO: centralize this caching behavior?
|
||||||
|
if (!this.measure) {
|
||||||
|
await measureStore.fetchMeasure(this.$route.params.slug);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditMeasure;
|
@ -1,3 +1 @@
|
|||||||
@import '~@/styles/vars';
|
@import '~@/styles/vars';
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,8 +1,14 @@
|
|||||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||||
|
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||||
|
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { Measure as MeasureModel, MeasureConfig } from '@/models';
|
import { Measure as MeasureModel, MeasureConfig } from '@/models';
|
||||||
import { measureStore, measurementStore } from '@/store';
|
import { measureStore, measurementStore } from '@/store';
|
||||||
import MeasureDetails from '@/components/measure-details/MeasureDetails.vue';
|
import MeasureDetails from '@/components/measure-details/MeasureDetails.vue';
|
||||||
|
|
||||||
|
library.add(faPencilAlt);
|
||||||
|
library.add(faTrash);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
MeasureDetails
|
MeasureDetails
|
||||||
|
@ -5,6 +5,7 @@ import { logService } from '@/services/logging';
|
|||||||
import { measureStore, userStore } from '@/store';
|
import { measureStore, userStore } from '@/store';
|
||||||
import { Measure, MeasureConfig, MeasureType } from '@/models';
|
import { Measure, MeasureConfig, MeasureType } from '@/models';
|
||||||
import MeasureConfigForm from '@/components/measure-config/MeasureConfigForm.vue';
|
import MeasureConfigForm from '@/components/measure-config/MeasureConfigForm.vue';
|
||||||
|
import { slugify } from '@/util';
|
||||||
|
|
||||||
library.add(faSync);
|
library.add(faSync);
|
||||||
|
|
||||||
@ -19,7 +20,8 @@ export class NewMeasure extends Vue {
|
|||||||
id: '',
|
id: '',
|
||||||
config: {
|
config: {
|
||||||
type: 'simple' as MeasureType,
|
type: 'simple' as MeasureType,
|
||||||
isVisible: true
|
isVisible: true,
|
||||||
|
timestampDisplayFormat: 'l'
|
||||||
},
|
},
|
||||||
description: '',
|
description: '',
|
||||||
name: '',
|
name: '',
|
||||||
@ -28,19 +30,12 @@ export class NewMeasure extends Vue {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private get slugFromName() {
|
private get slugFromName() {
|
||||||
return this.slugify(this.measure.name);
|
return slugify(this.measure.name);
|
||||||
}
|
|
||||||
|
|
||||||
private slugify(s: string): string {
|
|
||||||
return s
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^\w\s\-]/g, '')
|
|
||||||
.replace(/\s+/g, '-');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createMeasure() {
|
private async createMeasure() {
|
||||||
if (!this.measure.slug) {
|
if (!this.measure.slug) {
|
||||||
this.measure.slug = this.slugify(this.measure.name);
|
this.measure.slug = slugify(this.measure.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.waiting = true;
|
this.waiting = true;
|
||||||
|
@ -22,9 +22,7 @@ export class NewMeasurement extends Vue {
|
|||||||
measureId: '',
|
measureId: '',
|
||||||
value: 0,
|
value: 0,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
extData: {
|
extData: { }
|
||||||
measureType: 'simple' as MeasureType
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private async mounted() {
|
private async mounted() {
|
||||||
|
Reference in New Issue
Block a user