22 Commits
0.7.0 ... 0.9.0

Author SHA1 Message Date
6a77efe2cf Update package version to 0.9.0 2020-03-15 17:19:14 -05:00
a1dc067d17 web: Open time format reference link in a new window. 2020-03-15 17:18:57 -05:00
23600cedee web: Adjust to new API URLs, implement update for Measure. 2020-03-15 17:18:40 -05:00
c032bf10e7 api: Refactor measurements URLs, add Measure update endpoint. 2020-03-15 17:17:59 -05:00
f4f695ce80 web: WIP edit measure configuration. 2020-03-14 22:48:30 -05:00
c685f55d15 web: Add basic detail view of text measures. 2020-03-14 22:46:46 -05:00
e9de9aebf3 web: Add timestamp display format to measure configuration. 2020-03-14 22:45:59 -05:00
cf69ff2fa1 Make the measure filter case-insensitive. 2020-03-14 16:22:22 -05:00
baf37698b3 Issue 002: Delete functionality for measures. 2020-03-14 16:21:57 -05:00
3dd7169b8b WIP Adding support for Text entry measurements (renamed from List). 2020-03-13 23:09:01 -05:00
53a11b9e57 Add delete button for Measure (UI only, not hooked up). 2020-03-13 23:08:21 -05:00
ff17d9bf7a Move timestamp comparator function into shared util module. 2020-03-13 23:07:06 -05:00
9c9fe8786c Issue tracking: add things and start 004. 2020-03-13 23:05:31 -05:00
b64a3996e5 Add simple issue tracking system. 2020-03-13 17:12:50 -05:00
c8abfd00d0 api: Add dev database configuration. 2020-02-17 00:05:26 -06:00
b4b125d750 Update package version to 0.8.0 2020-02-16 23:55:25 -06:00
826f0eaa73 web: Add support for decimal measure values. 2020-02-16 23:21:56 -06:00
adddef3188 api: Support for decimal values in measures. 2020-02-16 23:20:02 -06:00
3e2faf9554 Update dependencies. 2020-02-16 21:09:25 -06:00
744ad9211b Use straight lines on simple measure graphs. 2020-02-16 21:09:14 -06:00
35a116abbb Fix insert/update logic of the measurements store. 2020-02-16 21:08:05 -06:00
4cb5b8d814 Add version tag in HTML, fix lint error. 2020-02-09 05:09:49 -06:00
62 changed files with 3658 additions and 2659 deletions

5
api/database-dev.json Normal file
View File

@ -0,0 +1,5 @@
{
"driver": "postgres",
"connectionString": "host=localhost port=5999 dbname=personal_measure_dev user=postgres",
"sqlDir": "src/main/sql/migrations"
}

View File

@ -2,7 +2,7 @@
include "src/main/nim/personal_measure_apipkg/version.nim" include "src/main/nim/personal_measure_apipkg/version.nim"
version = PM_API_VERSION 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"
@ -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" ] "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-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/nim-time-utils.git >= 0.5.2"
requires "https://git.jdb-labs.com/jdb-labs/fiber-orm-nim.git >= 0.2.0" requires "https://git.jdb-labs.com/jdb-labs/fiber-orm-nim.git >= 0.3.0"

View File

@ -1,7 +1,8 @@
import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils, import asyncdispatch, base64, jester, json, jwt, logging, options, sequtils,
strutils, times, uuids times, uuids
from unicode import capitalize from unicode import capitalize
import timeutils except `<` import strutils except capitalize
import timeutils
import ./db, ./configuration, ./models, ./service, ./version import ./db, ./configuration, ./models, ./service, ./version
@ -391,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()
@ -444,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()
@ -456,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:
@ -467,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:
@ -476,7 +511,7 @@ proc start*(ctx: PMApiContext): void =
let newMeasurement = Measurement( let newMeasurement = Measurement(
measureId: measure.id, measureId: measure.id,
value: jsonBody.getOrFail("value").getInt, value: jsonBody.getOrFail("value").getFloat,
timestamp: timestamp:
if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc if jsonBody.hasKey("timestamp"): jsonBody["timestamp"].getStr.parseIso8601.utc
else: getTime().utc, else: getTime().utc,
@ -493,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:
@ -508,14 +543,14 @@ 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:
let measure = ctx.getMeasureForSlug(session.user.id, @"slug") let measure = ctx.getMeasureForSlug(session.user.id, @"slug")
var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id")) var measurement = ctx.getMeasurementForMeasure(measure.id, parseUUID(@"id"))
let jsonBody = parseJson(request.body) 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("timestamp"): measurement.timestamp = jsonBody["timestamp"].getStr.parseIso8601
if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"] if jsonBody.hasKey("extData"): measurement.extData = jsonBody["extData"]
jsonResp($(%ctx.db.updateMeasurement(measurement))) jsonResp($(%ctx.db.updateMeasurement(measurement)))
@ -528,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:

View File

@ -28,7 +28,7 @@ type
Measurement* = object Measurement* = object
id*: UUID id*: UUID
measureId*: UUID measureId*: UUID
value*: int value*: float
timestamp*: DateTime timestamp*: DateTime
extData*: JsonNode extData*: JsonNode

View File

@ -1 +1 @@
const PM_API_VERSION* = "0.7.0" const PM_API_VERSION* = "0.9.0"

View File

@ -0,0 +1,2 @@
-- DOWN script for measure-value-is-numeric (20200216230431)
alter table "measurements" alter column "value" type integer;

View File

@ -0,0 +1,2 @@
-- UP script for measure-value-is-numeric (20200216230431)
alter table "measurements" alter column "value" type numeric;

View File

@ -0,0 +1 @@
### Add the ability to delete measures.

View File

@ -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
View 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"

View File

@ -0,0 +1 @@
007

View File

@ -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).

View File

@ -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.

View File

@ -0,0 +1 @@
### Add the ability to delete measurements.

View File

@ -0,0 +1 @@
### Add the ability to edit measurements.

View File

@ -0,0 +1 @@
### Add the ability to edit measures.

View File

@ -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" 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 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 ">> Committing new version.\n"
printf "git add web/package.json web/package-lock.json api/src/main/nim/personal_measure_apipkg/version.nim" 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" printf "git commit -m \"Update package version to %s\"\n" "$newVersion"
git commit -m "Update package version to ${newVersion}" git commit -m "Update package version to ${newVersion}"

5475
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "personal-measure-web", "name": "personal-measure-web",
"version": "0.7.0", "version": "0.9.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
@ -10,52 +10,54 @@
"test:unit": "vue-cli-service test:unit" "test:unit": "vue-cli-service test:unit"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.15", "@fortawesome/fontawesome-svg-core": "^1.2.27",
"@fortawesome/free-solid-svg-icons": "^5.7.2", "@fortawesome/free-solid-svg-icons": "^5.12.1",
"@fortawesome/vue-fontawesome": "^0.1.5", "@fortawesome/vue-fontawesome": "^0.1.9",
"@types/js-cookie": "^2.2.1", "@types/js-cookie": "^2.2.4",
"@types/jwt-decode": "^2.2.1", "@types/jwt-decode": "^2.2.1",
"@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.5", "@types/lodash.merge": "^4.6.6",
"apexcharts": "^3.6.5", "@types/lodash.omit": "^4.5.6",
"apexcharts": "^3.15.6",
"axios": "^0.18.1", "axios": "^0.18.1",
"js-cookie": "^2.2.0", "js-cookie": "^2.2.1",
"jwt-decode": "^2.2.0", "jwt-decode": "^2.2.0",
"keen-ui": "^1.1.2", "keen-ui": "^1.2.1",
"lodash.assign": "^4.2.0", "lodash.assign": "^4.2.0",
"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.6", "vue": "^2.6.11",
"vue-apexcharts": "^1.3.2", "vue-apexcharts": "^1.5.2",
"vue-class-component": "^6.0.0", "vue-class-component": "^6.0.0",
"vue-property-decorator": "^7.0.0", "vue-property-decorator": "^7.0.0",
"vue-router": "^3.0.1", "vue-router": "^3.1.5",
"vuejs-smart-table": "0.0.3", "vuejs-smart-table": "0.0.3",
"vuex": "^3.0.1", "vuex": "^3.1.2",
"vuex-module-decorators": "^0.9.8" "vuex-module-decorators": "^0.9.11"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^23.1.4", "@types/jest": "^23.1.4",
"@types/lodash.keyby": "^4.6.6", "@types/lodash.keyby": "^4.6.6",
"@vue/cli-plugin-babel": "^3.4.0", "@vue/cli-plugin-babel": "^3.12.1",
"@vue/cli-plugin-pwa": "^3.4.0", "@vue/cli-plugin-pwa": "^3.12.1",
"@vue/cli-plugin-typescript": "^3.4.0", "@vue/cli-plugin-typescript": "^3.12.1",
"@vue/cli-plugin-unit-jest": "^3.7.0", "@vue/cli-plugin-unit-jest": "^3.12.1",
"@vue/cli-service": "^3.5.3", "@vue/cli-service": "^3.12.1",
"@vue/test-utils": "^1.0.0-beta.20", "@vue/test-utils": "^1.0.0-beta.31",
"babel-core": "7.0.0-bridge.0", "babel-core": "7.0.0-bridge.0",
"lint-staged": "^8.1.0", "lint-staged": "^8.2.1",
"live-server": "^1.2.1", "live-server": "^1.2.1",
"node-sass": "^4.13.1", "node-sass": "^4.13.1",
"sass-loader": "^7.1.0", "sass-loader": "^7.3.1",
"ts-jest": "^23.0.0", "ts-jest": "^23.0.0",
"typescript": "^3.0.0", "typescript": "^3.7.5",
"vue-cli-plugin-webpack-bundle-analyzer": "^1.3.0", "vue-cli-plugin-webpack-bundle-analyzer": "^1.4.0",
"vue-template-compiler": "^2.5.21" "vue-template-compiler": "^2.6.11"
}, },
"gitHooks": { "gitHooks": {
"pre-commit": "lint-staged" "pre-commit": "lint-staged"

View File

@ -2,6 +2,7 @@
<div id="app"> <div id="app">
<NavBar></NavBar> <NavBar></NavBar>
<router-view class=main /> <router-view class=main />
<span id="personal-measure-version" hidden>{{ version }}</span>
</div> </div>
</template> </template>
<script lang="ts" src="./app.ts"></script> <script lang="ts" src="./app.ts"></script>

View File

@ -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>

View 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>

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>;

View File

@ -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,12 +12,11 @@ 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 },
noData: { text: 'no data', noData: { text: 'no data',
style: { fontSize: '18px' } }, style: { fontSize: '18px' } },
stroke: { curve: 'smooth' }, stroke: { curve: 'straight' },
xaxis: { type: 'datetime' } xaxis: { type: 'datetime' }
}; };
@ -27,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 }))
}]; }];
} }
@ -35,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()
}); });
}); });

View 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;

View File

@ -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>

View File

@ -1,11 +1,11 @@
<template> <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 <h2><router-link
:to="'/measures/' + measure.slug"> :to="'/measures/' + measure.slug">
{{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>

View 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>

View File

@ -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;

View File

@ -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
} }
}) })

View File

@ -9,14 +9,14 @@ export class SimpleSummaryGraph extends Vue {
private chartOptions = { private chartOptions = {
chart: { sparkline: { enabled: true } }, chart: { sparkline: { enabled: true } },
grid: { padding: { top: 20 }}, grid: { padding: { top: 20 }},
stroke: { curve: 'smooth' }, stroke: { curve: 'straight' },
noData: { text: 'no data', noData: { text: 'no data',
style: { fontSize: '18px' } }, style: { fontSize: '18px' } },
xaxis: { type: 'datetime' } xaxis: { type: 'datetime' }
}; };
private get measurementData(): ApexAxisChartSeries { private get measurementData(): ApexAxisChartSeries {
let measurementData = this.measurements.slice() || []; const measurementData = this.measurements.slice() || [];
return [{ return [{
name: this.measure.name, name: this.measure.name,

View 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); }
}
}
}

View 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;

View File

@ -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>

View File

@ -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 />
@ -12,7 +14,7 @@
</div> </div>
<div> <div>
<label for=measurementValue>{{measure.name}}</label> <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> </div>
</fieldset> </fieldset>
</template> </template>

View 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>

View File

@ -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>;

View File

@ -5,14 +5,12 @@ import { Measure, MeasureConfig, MeasureType, Measurement, MeasurementMeta } fro
export class SimpleEntry extends Vue { export class SimpleEntry extends Vue {
@Prop() public measure!: Measure<MeasureConfig>; @Prop() public measure!: Measure<MeasureConfig>;
@Prop() public value!: Measurement<MeasurementMeta>; @Prop() public value!: Measurement<MeasurementMeta>;
@Prop() public disabled: boolean = false; @Prop() public disabled!: boolean;
private editTimestamp: boolean = false; private editTimestamp: boolean = false;
@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);
} }

View 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
View File

@ -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;
} }

View File

@ -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',

View File

@ -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;
} }

View File

@ -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));
} }
} }

View File

@ -54,7 +54,7 @@ export class MeasurementStoreModule extends VuexModule {
const newMeasurements = existing.slice(); const newMeasurements = existing.slice();
const index = findIndex(existing, { id: measurement.id }); const index = findIndex(existing, { id: measurement.id });
if (index > 0) { newMeasurements.push(measurement); } if (index < 0) { newMeasurements.push(measurement); }
else { newMeasurements[index] = measurement; } else { newMeasurements[index] = measurement; }
this.measurements = assign({}, this.measurements, { [measure.id]: newMeasurements }); this.measurements = assign({}, this.measurements, { [measure.id]: newMeasurements });
} }

View File

@ -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
View 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, '-');
}

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View 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;

View 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;

View File

@ -1,3 +1 @@
@import '~@/styles/vars'; @import '~@/styles/vars';

View File

@ -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

View File

@ -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;

View File

@ -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() {

View File

@ -1,5 +1,7 @@
.user-account { .user-account {
justify-content: flex-start;
section { section {
margin-top: 1rem; margin-top: 2rem;
} }
} }