Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
6a77efe2cf | |||
a1dc067d17 | |||
23600cedee | |||
c032bf10e7 | |||
f4f695ce80 | |||
c685f55d15 | |||
e9de9aebf3 | |||
cf69ff2fa1 | |||
baf37698b3 | |||
3dd7169b8b | |||
53a11b9e57 | |||
ff17d9bf7a | |||
9c9fe8786c | |||
b64a3996e5 | |||
c8abfd00d0 | |||
b4b125d750 | |||
826f0eaa73 | |||
adddef3188 | |||
3e2faf9554 | |||
744ad9211b | |||
35a116abbb | |||
4cb5b8d814 |
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"
|
||||
|
||||
version = PM_API_VERSION
|
||||
version = "0.9.0"
|
||||
author = "Jonathan Bernard"
|
||||
description = "JDB\'s Personal Measures API"
|
||||
license = "MIT"
|
||||
@ -17,5 +17,5 @@ requires @["nim >= 0.19.4", "bcrypt", "docopt >= 0.6.8", "isaac >= 0.1.3",
|
||||
"jester >= 0.4.3", "jwt", "tempfile", "uuids >= 0.1.10" ]
|
||||
|
||||
requires "https://git.jdb-labs.com/jdb/nim-cli-utils.git >= 0.6.3"
|
||||
requires "https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.5.0"
|
||||
requires "https://git.jdb-labs.com/jdb-labs/fiber-orm-nim.git >= 0.2.0"
|
||||
requires "https://git.jdb-labs.com/jdb/nim-time-utils.git >= 0.5.2"
|
||||
requires "https://git.jdb-labs.com/jdb-labs/fiber-orm-nim.git >= 0.3.0"
|
||||
|
@ -1,7 +1,8 @@
|
||||
import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
|
||||
strutils, times, uuids
|
||||
times, uuids
|
||||
from unicode import capitalize
|
||||
import timeutils except `<`
|
||||
import strutils except capitalize
|
||||
import timeutils
|
||||
|
||||
import ./db, ./configuration, ./models, ./service, ./version
|
||||
|
||||
@ -391,6 +392,8 @@ proc start*(ctx: PMApiContext): void =
|
||||
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
|
||||
except: statusResp(Http500)
|
||||
|
||||
# Measure
|
||||
|
||||
get "/measures":
|
||||
checkAuth()
|
||||
|
||||
@ -444,6 +447,37 @@ proc start*(ctx: PMApiContext): void =
|
||||
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
|
||||
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":
|
||||
checkAuth()
|
||||
|
||||
@ -456,7 +490,8 @@ proc start*(ctx: PMApiContext): void =
|
||||
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
|
||||
statusResp(Http500)
|
||||
|
||||
get "/measure/@slug":
|
||||
# Measurements
|
||||
get "/measurements/@slug":
|
||||
checkAuth()
|
||||
|
||||
try:
|
||||
@ -467,7 +502,7 @@ proc start*(ctx: PMApiContext): void =
|
||||
error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
|
||||
statusResp(Http500)
|
||||
|
||||
post "/measure/@slug":
|
||||
post "/measurements/@slug":
|
||||
checkAuth()
|
||||
|
||||
try:
|
||||
@ -476,7 +511,7 @@ proc start*(ctx: PMApiContext): void =
|
||||
|
||||
let newMeasurement = Measurement(
|
||||
measureId: measure.id,
|
||||
value: jsonBody.getOrFail("value").getInt,
|
||||
value: jsonBody.getOrFail("value").getFloat,
|
||||
timestamp:
|
||||
if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc
|
||||
else: getTime().utc,
|
||||
@ -493,7 +528,7 @@ proc start*(ctx: PMApiContext): void =
|
||||
error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
|
||||
statusResp(Http500)
|
||||
|
||||
get "/measure/@slug/@id":
|
||||
get "/measurements/@slug/@id":
|
||||
checkAuth()
|
||||
|
||||
try:
|
||||
@ -508,14 +543,14 @@ proc start*(ctx: PMApiContext): void =
|
||||
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
|
||||
statusResp(Http500)
|
||||
|
||||
put "/measure/@slug/@id":
|
||||
put "/measurements/@slug/@id":
|
||||
checkAuth()
|
||||
|
||||
try:
|
||||
let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
|
||||
var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
|
||||
let jsonBody = parseJson(request.body)
|
||||
if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getInt
|
||||
if jsonBody.hasKey("value"): measurement.value = jsonBody["value"].getFloat
|
||||
if jsonBody.hasKey("timestamp"): measurement.timestamp = jsonBody["timestamp"].getStr.parseIso8601
|
||||
if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"]
|
||||
jsonResp($(%ctx.db.updateMeasurement(measurement)))
|
||||
@ -528,7 +563,7 @@ proc start*(ctx: PMApiContext): void =
|
||||
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
|
||||
statusResp(Http500)
|
||||
|
||||
delete "/measure/@slug/@id":
|
||||
delete "/measurements/@slug/@id":
|
||||
checkAuth()
|
||||
|
||||
try:
|
||||
|
@ -28,7 +28,7 @@ type
|
||||
Measurement* = object
|
||||
id*: UUID
|
||||
measureId*: UUID
|
||||
value*: int
|
||||
value*: float
|
||||
timestamp*: DateTime
|
||||
extData*: JsonNode
|
||||
|
||||
|
@ -1 +1 @@
|
||||
const PM_API_VERSION* = "0.7.0"
|
||||
const PM_API_VERSION* = "0.9.0"
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- DOWN script for measure-value-is-numeric (20200216230431)
|
||||
alter table "measurements" alter column "value" type integer;
|
@ -0,0 +1,2 @@
|
||||
-- UP script for measure-value-is-numeric (20200216230431)
|
||||
alter table "measurements" alter column "value" type numeric;
|
@ -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.
|
@ -47,9 +47,13 @@ printf ">> Updating /api/src/main/nim/personal_measure_apipkg/version.nim with P
|
||||
printf "sed -i \"s/%s/%s/\" api/src/main/nim/personal_measure_apipkg/version.nim" "$lastVersion" "$newVersion"
|
||||
sed -i "s/${lastVersion}/${newVersion}/" api/src/main/nim/personal_measure_apipkg/version.nim
|
||||
|
||||
printf ">> Updating /api/personal_measure_api.nimble with version = \"%s\"" "$newVersion"
|
||||
printf "sed -i \"s/%s/%s/\" api/personal_measure_api.nimble" "$lastVersion" "$newVersion"
|
||||
sed -i "s/${lastVersion}/${newVersion}/" api/personal_measure_api.nimble
|
||||
|
||||
printf ">> Committing new version.\n"
|
||||
printf "git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim"
|
||||
git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim
|
||||
git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim api/personal_measure_api.nimble
|
||||
printf "git commit -m \"Update package version to %s\"\n" "$newVersion"
|
||||
git commit -m "Update package version to ${newVersion}"
|
||||
|
||||
|
5475
web/package-lock.json
generated
5475
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "personal-measure-web",
|
||||
"version": "0.7.0",
|
||||
"version": "0.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
@ -10,52 +10,54 @@
|
||||
"test:unit": "vue-cli-service test:unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.15",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.7.2",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.5",
|
||||
"@types/js-cookie": "^2.2.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.27",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.12.1",
|
||||
"@fortawesome/vue-fontawesome": "^0.1.9",
|
||||
"@types/js-cookie": "^2.2.4",
|
||||
"@types/jwt-decode": "^2.2.1",
|
||||
"@types/lodash.assign": "^4.2.6",
|
||||
"@types/lodash.findindex": "^4.6.6",
|
||||
"@types/lodash.merge": "^4.6.5",
|
||||
"apexcharts": "^3.6.5",
|
||||
"@types/lodash.merge": "^4.6.6",
|
||||
"@types/lodash.omit": "^4.5.6",
|
||||
"apexcharts": "^3.15.6",
|
||||
"axios": "^0.18.1",
|
||||
"js-cookie": "^2.2.0",
|
||||
"js-cookie": "^2.2.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"keen-ui": "^1.1.2",
|
||||
"keen-ui": "^1.2.1",
|
||||
"lodash.assign": "^4.2.0",
|
||||
"lodash.findindex": "^4.6.0",
|
||||
"lodash.keyby": "^4.6.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.omit": "^4.5.0",
|
||||
"moment": "^2.24.0",
|
||||
"register-service-worker": "^1.5.2",
|
||||
"vue": "^2.6.6",
|
||||
"vue-apexcharts": "^1.3.2",
|
||||
"vue": "^2.6.11",
|
||||
"vue-apexcharts": "^1.5.2",
|
||||
"vue-class-component": "^6.0.0",
|
||||
"vue-property-decorator": "^7.0.0",
|
||||
"vue-router": "^3.0.1",
|
||||
"vue-router": "^3.1.5",
|
||||
"vuejs-smart-table": "0.0.3",
|
||||
"vuex": "^3.0.1",
|
||||
"vuex-module-decorators": "^0.9.8"
|
||||
"vuex": "^3.1.2",
|
||||
"vuex-module-decorators": "^0.9.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^23.1.4",
|
||||
"@types/lodash.keyby": "^4.6.6",
|
||||
"@vue/cli-plugin-babel": "^3.4.0",
|
||||
"@vue/cli-plugin-pwa": "^3.4.0",
|
||||
"@vue/cli-plugin-typescript": "^3.4.0",
|
||||
"@vue/cli-plugin-unit-jest": "^3.7.0",
|
||||
"@vue/cli-service": "^3.5.3",
|
||||
"@vue/test-utils": "^1.0.0-beta.20",
|
||||
"@vue/cli-plugin-babel": "^3.12.1",
|
||||
"@vue/cli-plugin-pwa": "^3.12.1",
|
||||
"@vue/cli-plugin-typescript": "^3.12.1",
|
||||
"@vue/cli-plugin-unit-jest": "^3.12.1",
|
||||
"@vue/cli-service": "^3.12.1",
|
||||
"@vue/test-utils": "^1.0.0-beta.31",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"lint-staged": "^8.1.0",
|
||||
"lint-staged": "^8.2.1",
|
||||
"live-server": "^1.2.1",
|
||||
"node-sass": "^4.13.1",
|
||||
"sass-loader": "^7.1.0",
|
||||
"sass-loader": "^7.3.1",
|
||||
"ts-jest": "^23.0.0",
|
||||
"typescript": "^3.0.0",
|
||||
"vue-cli-plugin-webpack-bundle-analyzer": "^1.3.0",
|
||||
"vue-template-compiler": "^2.5.21"
|
||||
"typescript": "^3.7.5",
|
||||
"vue-cli-plugin-webpack-bundle-analyzer": "^1.4.0",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
},
|
||||
"gitHooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
|
@ -2,6 +2,7 @@
|
||||
<div id="app">
|
||||
<NavBar></NavBar>
|
||||
<router-view class=main />
|
||||
<span id="personal-measure-version" hidden>{{ version }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" src="./app.ts"></script>
|
||||
|
@ -2,19 +2,40 @@
|
||||
<fieldset>
|
||||
<div>
|
||||
<label for=measureType>Type</label>
|
||||
<span v-if=measureExists>{{value.type}}</span>
|
||||
<select
|
||||
:disabled=disabled
|
||||
name=measureType
|
||||
v-if="!measureExists"
|
||||
v-model=value.type>
|
||||
<option value=simple>Simple</option>
|
||||
<option value=list>List</option>
|
||||
<option value=text>Text</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for=measureIsVisible>Show by default.</label>
|
||||
<input type=checkbox v-model=value.isVisible :disabled=disabled />
|
||||
</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>
|
||||
</template>
|
||||
<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 { logService } from '@/services/logging';
|
||||
import { Measure, MeasureConfig } from '@/models';
|
||||
import TextMeasureConfigForm from './TextMeasureConfigForm.vue';
|
||||
import moment from 'moment';
|
||||
|
||||
@Component({})
|
||||
@Component({
|
||||
components: {
|
||||
TextMeasureConfigForm
|
||||
}
|
||||
})
|
||||
export class MeasureConfigForm extends Vue {
|
||||
@Prop({}) public value!: MeasureConfig;
|
||||
@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 })
|
||||
@Emit('input')
|
||||
private onConfigChanged(newVal: MeasureConfig, oldVal: MeasureConfig) {
|
||||
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;
|
||||
|
@ -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>
|
||||
<SimpleDetails v-if="measure.config.type === 'simple'"
|
||||
:measure=measure :measurements=measurements />
|
||||
<TextDetails v-if="measure.config.type === 'text'"
|
||||
:measure=measure :measurements=measurements />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" src="./measure-details.ts"></script>
|
||||
|
@ -15,7 +15,6 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
<SimpleEntry :measure=measure v-model=
|
||||
</div>
|
||||
</template>
|
||||
<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 { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||
import SimpleDetails from './SimpleDetails.vue';
|
||||
import TextDetails from './TextDetails.vue';
|
||||
|
||||
@Component({
|
||||
components: { SimpleDetails }
|
||||
components: {
|
||||
SimpleDetails,
|
||||
TextDetails
|
||||
}
|
||||
})
|
||||
export class MeasureDetails extends Vue {
|
||||
@Prop() private measure!: Measure<MeasureConfig>;
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||
import moment from 'moment';
|
||||
import assign from 'lodash.assign';
|
||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { byTimestampComparator, formatTS } from '@/util';
|
||||
|
||||
library.add(faPencilAlt);
|
||||
|
||||
@ -12,12 +12,11 @@ export class SimpleDetails extends Vue {
|
||||
@Prop() private measure!: Measure<MeasureConfig>;
|
||||
@Prop() private measurements!: Array<Measurement<MeasurementMeta>>;
|
||||
|
||||
// private newMeasurement;
|
||||
private moment = moment;
|
||||
private chartOptions = {
|
||||
markers: { size: 6 },
|
||||
noData: { text: 'no data',
|
||||
style: { fontSize: '18px' } },
|
||||
stroke: { curve: 'smooth' },
|
||||
stroke: { curve: 'straight' },
|
||||
xaxis: { type: 'datetime' }
|
||||
};
|
||||
|
||||
@ -27,7 +26,7 @@ export class SimpleDetails extends Vue {
|
||||
return [{
|
||||
name: this.measure.name,
|
||||
data: measurementData
|
||||
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
|
||||
.sort(byTimestampComparator)
|
||||
.map((m) => ({ x: m.timestamp.toISOString(), y: m.value }))
|
||||
}];
|
||||
}
|
||||
@ -35,7 +34,7 @@ export class SimpleDetails extends Vue {
|
||||
private get measurementTableData() {
|
||||
return (this.measurements || []).map((m) => {
|
||||
return assign({}, m, {
|
||||
tsDisplay: moment(m.timestamp).format('MMM Do, HH:mm'),
|
||||
tsDisplay: formatTS(this.measure, m),
|
||||
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>
|
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div v-if="measure.config.isVisible" class="measure-summary" :data-name="'measure-' + measure.slug">
|
||||
<div v-if="measure.config.isVisible" v-bind:key="measure.slug" class="measure-summary" :data-name="'measure-' + measure.slug">
|
||||
<h2><router-link
|
||||
:to="'/measures/' + measure.slug">
|
||||
{{measure.name}}</router-link></h2>
|
||||
<SimpleSummaryGraph v-if="measure.config.type === 'simple'"
|
||||
:measure=measure :measurements=measurements />
|
||||
<ListSummary v-if="measure.config.type === 'list'"
|
||||
<TextSummary v-if="measure.config.type === 'text'"
|
||||
:measure=measure :measurements=measurements />
|
||||
</div>
|
||||
</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 { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||
import { measurementStore } from '@/store';
|
||||
import ListSummary from './ListSummary.vue';
|
||||
import TextSummary from './TextSummary.vue';
|
||||
import SimpleSummaryGraph from './SimpleSummaryGraph.vue';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
ListSummary,
|
||||
TextSummary,
|
||||
SimpleSummaryGraph
|
||||
}
|
||||
})
|
||||
|
@ -9,14 +9,14 @@ export class SimpleSummaryGraph extends Vue {
|
||||
private chartOptions = {
|
||||
chart: { sparkline: { enabled: true } },
|
||||
grid: { padding: { top: 20 }},
|
||||
stroke: { curve: 'smooth' },
|
||||
stroke: { curve: 'straight' },
|
||||
noData: { text: 'no data',
|
||||
style: { fontSize: '18px' } },
|
||||
xaxis: { type: 'datetime' }
|
||||
};
|
||||
|
||||
private get measurementData(): ApexAxisChartSeries {
|
||||
let measurementData = this.measurements.slice() || [];
|
||||
const measurementData = this.measurements.slice() || [];
|
||||
|
||||
return [{
|
||||
name: this.measure.name,
|
||||
|
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>
|
||||
<SimpleEntry v-if="measure.config.type === 'simple'"
|
||||
:measure=measure v-model=value />
|
||||
<TextEntry v-if="measure.config.type === 'text'"
|
||||
:measure=measure v-model=value />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" src="./measurement-entry.ts"></script>
|
||||
|
@ -2,7 +2,9 @@
|
||||
<fieldset>
|
||||
<div>
|
||||
<label for=timestamp>Timestamp</label>
|
||||
<input type=datetime-local
|
||||
<input
|
||||
name=timestamp
|
||||
type=datetime-local
|
||||
v-model=value.timestamp
|
||||
v-show=editTimestamp
|
||||
:disabled=disabled />
|
||||
@ -12,7 +14,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<label for=measurementValue>{{measure.name}}</label>
|
||||
<input required type=number v-model=value.value :disabled=disabled />
|
||||
<input name=measurementValue required type=number step=any v-model.number=value.value :disabled=disabled />
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
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 { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } from '@/models';
|
||||
import SimpleEntry from './SimpleEntry.vue';
|
||||
import TextEntry from './TextEntry.vue';
|
||||
|
||||
@Component({
|
||||
components: { SimpleEntry }
|
||||
components: {
|
||||
SimpleEntry,
|
||||
TextEntry
|
||||
}
|
||||
})
|
||||
export class MeasurementEntry extends Vue {
|
||||
@Prop() private measure!: Measure<MeasureConfig>;
|
||||
|
@ -5,14 +5,12 @@ import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } fro
|
||||
export class SimpleEntry extends Vue {
|
||||
@Prop() public measure!: Measure<MeasureConfig>;
|
||||
@Prop() public value!: Measurement<MeasurementMeta>;
|
||||
@Prop() public disabled: boolean = false;
|
||||
@Prop() public disabled!: boolean;
|
||||
private editTimestamp: boolean = false;
|
||||
|
||||
@Watch('value', { immediate: true, deep: true })
|
||||
@Emit('input')
|
||||
private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) {
|
||||
newVal.extData.measureType = 'simple' as MeasureType;
|
||||
|
||||
if (typeof(newVal.value) === 'string' ) {
|
||||
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 {
|
||||
id: string;
|
||||
@ -17,9 +17,10 @@ export interface LoginSubmit {
|
||||
export interface MeasureConfig {
|
||||
type: MeasureType;
|
||||
isVisible: boolean;
|
||||
timestampDisplayFormat: string;
|
||||
}
|
||||
|
||||
export interface ListMeasureConfig extends MeasureConfig {
|
||||
export interface TextMeasureConfig extends MeasureConfig {
|
||||
showTimestamp: boolean;
|
||||
}
|
||||
|
||||
@ -33,10 +34,9 @@ export interface Measure<C extends MeasureConfig> {
|
||||
}
|
||||
|
||||
export interface MeasurementMeta {
|
||||
measureType: MeasureType;
|
||||
}
|
||||
|
||||
export interface ListMeasurementMeta extends MeasurementMeta {
|
||||
export interface TextMeasurementMeta extends MeasurementMeta {
|
||||
entry: string;
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,8 @@ import Measure from '@/views/Measure.vue';
|
||||
import Measures from '@/views/Measures.vue';
|
||||
import NewMeasure from '@/views/NewMeasure.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 QuickPanels from '@/views/QuickPanels.vue';
|
||||
import UserAccount from '@/views/UserAccount.vue';
|
||||
@ -68,6 +70,16 @@ const router = new Router({
|
||||
name: 'new-measurement',
|
||||
component: NewMeasurement
|
||||
},
|
||||
{
|
||||
path: '/delete/measure/:slug',
|
||||
name: 'delete-measure',
|
||||
component: DeleteMeasure
|
||||
},
|
||||
{
|
||||
path: '/edit/measure/:slug',
|
||||
name: 'edit-measure',
|
||||
component: EditMeasure
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
name: 'not-found',
|
||||
|
@ -123,6 +123,11 @@ export class PmApiClient {
|
||||
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> {
|
||||
const resp = await this.http.delete(`/measures/${slug}`);
|
||||
return true;
|
||||
@ -131,7 +136,7 @@ export class PmApiClient {
|
||||
public async getMeasurements(measureSlug: string)
|
||||
: 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);
|
||||
}
|
||||
|
||||
@ -141,7 +146,7 @@ export class PmApiClient {
|
||||
: Promise<Measurement<MeasurementMeta>> {
|
||||
|
||||
const resp = await this.http.post(
|
||||
`/measure/${measureSlug}`,
|
||||
`/measurements/${measureSlug}`,
|
||||
this.toMeasurementDTO(measurement));
|
||||
return this.fromMeasurementDTO(resp.data);
|
||||
}
|
||||
@ -152,7 +157,7 @@ export class PmApiClient {
|
||||
: Promise<Measurement<MeasurementMeta>> {
|
||||
|
||||
const resp = await this.http
|
||||
.get(`/measure/${measureSlug}/${measurementId}`);
|
||||
.get(`/measurements/${measureSlug}/${measurementId}`);
|
||||
return this.fromMeasurementDTO(resp.data);
|
||||
}
|
||||
|
||||
@ -162,7 +167,7 @@ export class PmApiClient {
|
||||
: Promise<Measurement<MeasurementMeta>> {
|
||||
|
||||
const resp = await this.http.put(
|
||||
`/measure/${measureSlug}/${measurement.id}`,
|
||||
`/measurements/${measureSlug}/${measurement.id}`,
|
||||
this.toMeasurementDTO(measurement));
|
||||
return this.fromMeasurementDTO(resp.data);
|
||||
}
|
||||
@ -173,7 +178,7 @@ export class PmApiClient {
|
||||
: Promise<boolean> {
|
||||
|
||||
const resp = await this.http
|
||||
.delete(`/measure/${measureSlug}/${measurementId}`);
|
||||
.delete(`/measurements/${measureSlug}/${measurementId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -6,7 +6,9 @@ import {
|
||||
MutationAction,
|
||||
VuexModule
|
||||
} from 'vuex-module-decorators';
|
||||
import assign from 'lodash.assign';
|
||||
import keyBy from 'lodash.keyby';
|
||||
import omit from 'lodash.omit';
|
||||
import { User, Measure, MeasureConfig } from '@/models';
|
||||
import api from '@/services/pm-api-client';
|
||||
|
||||
@ -28,13 +30,29 @@ export class MeasureStoreModule extends VuexModule {
|
||||
}
|
||||
|
||||
@Action({ rawError: true })
|
||||
public async createMeasure(m: Measure<MeasureConfig>) {
|
||||
public async createMeasure<T extends MeasureConfig>(m: Measure<T>) {
|
||||
const newMeasure = await api.createMeasure(m);
|
||||
this.context.commit('SET_MEASURE', 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>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ export class MeasurementStoreModule extends VuexModule {
|
||||
const newMeasurements = existing.slice();
|
||||
|
||||
const index = findIndex(existing, { id: measurement.id });
|
||||
if (index > 0) { newMeasurements.push(measurement); }
|
||||
if (index < 0) { newMeasurements.push(measurement); }
|
||||
else { newMeasurements[index] = measurement; }
|
||||
this.measurements = assign({}, this.measurements, { [measure.id]: newMeasurements });
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
@import '~@/styles/vars';
|
||||
|
||||
button,
|
||||
.btn,
|
||||
.btn-action {
|
||||
.btn-action,
|
||||
.btn-icon {
|
||||
border: 0;
|
||||
border-radius: .25em;
|
||||
cursor: pointer;
|
||||
@ -13,14 +15,27 @@
|
||||
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 {
|
||||
background-color: $color2;
|
||||
color: $color3;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: darken($color2, 5%);
|
||||
&:hover, &:focus {
|
||||
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>
|
||||
<h2>{{measure.description}}</h2>
|
||||
</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>
|
||||
<MeasureDetails :measure=measure :measurements=measurements />
|
||||
</div>
|
||||
@ -16,4 +35,4 @@
|
||||
</div>
|
||||
</template>
|
||||
<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>
|
||||
<MeasureSummary
|
||||
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 />
|
||||
<!--<MeasureSummary
|
||||
v-for="(measure, slug) in measures"
|
||||
:key="slug"
|
||||
:measure=measure />-->
|
||||
</div>
|
||||
</div>
|
||||
</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';
|
||||
|
||||
|
||||
|
@ -1,8 +1,14 @@
|
||||
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 { measureStore, measurementStore } from '@/store';
|
||||
import MeasureDetails from '@/components/measure-details/MeasureDetails.vue';
|
||||
|
||||
library.add(faPencilAlt);
|
||||
library.add(faTrash);
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
MeasureDetails
|
||||
|
@ -5,6 +5,7 @@ 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';
|
||||
|
||||
library.add(faSync);
|
||||
|
||||
@ -19,7 +20,8 @@ export class NewMeasure extends Vue {
|
||||
id: '',
|
||||
config: {
|
||||
type: 'simple' as MeasureType,
|
||||
isVisible: true
|
||||
isVisible: true,
|
||||
timestampDisplayFormat: 'l'
|
||||
},
|
||||
description: '',
|
||||
name: '',
|
||||
@ -28,19 +30,12 @@ export class NewMeasure extends Vue {
|
||||
};
|
||||
|
||||
private get slugFromName() {
|
||||
return this.slugify(this.measure.name);
|
||||
}
|
||||
|
||||
private slugify(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s\-]/g, '')
|
||||
.replace(/\s+/g, '-');
|
||||
return slugify(this.measure.name);
|
||||
}
|
||||
|
||||
private async createMeasure() {
|
||||
if (!this.measure.slug) {
|
||||
this.measure.slug = this.slugify(this.measure.name);
|
||||
this.measure.slug = slugify(this.measure.name);
|
||||
}
|
||||
|
||||
this.waiting = true;
|
||||
|
@ -22,9 +22,7 @@ export class NewMeasurement extends Vue {
|
||||
measureId: '',
|
||||
value: 0,
|
||||
timestamp: new Date(),
|
||||
extData: {
|
||||
measureType: 'simple' as MeasureType
|
||||
}
|
||||
extData: { }
|
||||
};
|
||||
|
||||
private async mounted() {
|
||||
|
@ -1,5 +1,7 @@
|
||||
.user-account {
|
||||
justify-content: flex-start;
|
||||
|
||||
section {
|
||||
margin-top: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user