15 Commits
0.8.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
54 changed files with 680 additions and 84 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 = "0.8.0" version = "0.9.0"
author = "Jonathan Bernard" author = "Jonathan Bernard"
description = "JDB\'s Personal Measures API" description = "JDB\'s Personal Measures API"
license = "MIT" license = "MIT"

View File

@ -392,6 +392,8 @@ proc start*(ctx: PMApiContext): void =
except NotFoundError: statusResp(Http404, getCurrentExceptionMsg()) except NotFoundError: statusResp(Http404, getCurrentExceptionMsg())
except: statusResp(Http500) except: statusResp(Http500)
# Measure
get "/measures": get "/measures":
checkAuth() checkAuth()
@ -445,6 +447,37 @@ proc start*(ctx: PMApiContext): void =
error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg() error "unable to look up a measure by id:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
post "/measures/@slug":
checkAuth()
try:
let jsonBody = parseJson(request.body)
var existingMeasure = ctx.getMeasureForSlug(session.user.id, @"slug")
if not (jsonBody.hasKey("slug") or jsonBody.hasKey("name")):
raiseEx BadRequestError, "body must contain either the 'slug' field (short name), or the 'name' field, or both"
existingMeasure.slug =
if jsonBody.hasKey("slug"): jsonBody["slug"].getStr.nameToSlug
else: jsonBody["name"].getStr.nameToSlug
existingMeasure.name =
if jsonBody.hasKey("name"): jsonBody["name"].getStr
else: jsonBody["slug"].getStr.capitalize
if jsonBody.hasKey("config"): existingMeasure.config = jsonBody["config"]
if jsonBody.hasKey("description"): existingMeasure.description = jsonBody["description"].getStr
jsonResp($(%ctx.db.updateMeasure(existingMeasure)))
except JsonParsingError: statusResp(Http400, getCurrentExceptionMsg())
except BadRequestError: statusResp(Http400, getCurrentExceptionMsg())
except:
error "unable to update measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500)
delete "/measures/@slug": delete "/measures/@slug":
checkAuth() checkAuth()
@ -457,7 +490,8 @@ proc start*(ctx: PMApiContext): void =
error "unable to delete a measure:\n\t" & getCurrentExceptionMsg() error "unable to delete a measure:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
get "/measure/@slug": # Measurements
get "/measurements/@slug":
checkAuth() checkAuth()
try: try:
@ -468,7 +502,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to list measurements:\n\t" & getCurrentExceptionMsg() error "unable to list measurements:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
post "/measure/@slug": post "/measurements/@slug":
checkAuth() checkAuth()
try: try:
@ -494,7 +528,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to add measurement:\n\t" & getCurrentExceptionMsg() error "unable to add measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
get "/measure/@slug/@id": get "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:
@ -509,7 +543,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg() error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
put "/measure/@slug/@id": put "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:
@ -529,7 +563,7 @@ proc start*(ctx: PMApiContext): void =
error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg() error "unable to retrieve measurement:\n\t" & getCurrentExceptionMsg()
statusResp(Http500) statusResp(Http500)
delete "/measure/@slug/@id": delete "/measurements/@slug/@id":
checkAuth() checkAuth()
try: try:

View File

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

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.

15
web/package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "personal-measure-web", "name": "personal-measure-web",
"version": "0.8.0", "version": "0.9.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -1059,6 +1059,14 @@
"@types/lodash": "*" "@types/lodash": "*"
} }
}, },
"@types/lodash.omit": {
"version": "4.5.6",
"resolved": "https://registry.npmjs.org/@types/lodash.omit/-/lodash.omit-4.5.6.tgz",
"integrity": "sha512-KXPpOSNX2h0DAG2w7ajpk7TXvWF28ZHs5nJhOJyP0BQHkehgr948RVsToItMme6oi0XJkp19CbuNXkIX8FiBlQ==",
"requires": {
"@types/lodash": "*"
}
},
"@types/minimatch": { "@types/minimatch": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -9991,6 +9999,11 @@
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
}, },
"lodash.omit": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz",
"integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA="
},
"lodash.sortby": { "lodash.sortby": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",

View File

@ -1,6 +1,6 @@
{ {
"name": "personal-measure-web", "name": "personal-measure-web",
"version": "0.8.0", "version": "0.9.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
@ -18,6 +18,7 @@
"@types/lodash.assign": "^4.2.6", "@types/lodash.assign": "^4.2.6",
"@types/lodash.findindex": "^4.6.6", "@types/lodash.findindex": "^4.6.6",
"@types/lodash.merge": "^4.6.6", "@types/lodash.merge": "^4.6.6",
"@types/lodash.omit": "^4.5.6",
"apexcharts": "^3.15.6", "apexcharts": "^3.15.6",
"axios": "^0.18.1", "axios": "^0.18.1",
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
@ -27,6 +28,7 @@
"lodash.findindex": "^4.6.0", "lodash.findindex": "^4.6.0",
"lodash.keyby": "^4.6.0", "lodash.keyby": "^4.6.0",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"lodash.omit": "^4.5.0",
"moment": "^2.24.0", "moment": "^2.24.0",
"register-service-worker": "^1.5.2", "register-service-worker": "^1.5.2",
"vue": "^2.6.11", "vue": "^2.6.11",

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,8 +12,6 @@ export class SimpleDetails extends Vue {
@Prop() private measure!: Measure<MeasureConfig>; @Prop() private measure!: Measure<MeasureConfig>;
@Prop() private measurements!: Array<Measurement<MeasurementMeta>>; @Prop() private measurements!: Array<Measurement<MeasurementMeta>>;
// private newMeasurement;
private moment = moment;
private chartOptions = { private chartOptions = {
markers: { size: 6 }, markers: { size: 6 },
noData: { text: 'no data', noData: { text: 'no data',
@ -28,7 +26,7 @@ export class SimpleDetails extends Vue {
return [{ return [{
name: this.measure.name, name: this.measure.name,
data: measurementData data: measurementData
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) .sort(byTimestampComparator)
.map((m) => ({ x: m.timestamp.toISOString(), y: m.value })) .map((m) => ({ x: m.timestamp.toISOString(), y: m.value }))
}]; }];
} }
@ -36,7 +34,7 @@ export class SimpleDetails extends Vue {
private get measurementTableData() { private get measurementTableData() {
return (this.measurements || []).map((m) => { return (this.measurements || []).map((m) => {
return assign({}, m, { return assign({}, m, {
tsDisplay: moment(m.timestamp).format('MMM Do, HH:mm'), tsDisplay: formatTS(this.measure, m),
tsSort: m.timestamp.toISOString() tsSort: m.timestamp.toISOString()
}); });
}); });

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

@ -5,7 +5,7 @@
{{measure.name}}</router-link></h2> {{measure.name}}</router-link></h2>
<SimpleSummaryGraph v-if="measure.config.type === 'simple'" <SimpleSummaryGraph v-if="measure.config.type === 'simple'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
<ListSummary v-if="measure.config.type === 'list'" <TextSummary v-if="measure.config.type === 'text'"
:measure=measure :measurements=measurements /> :measure=measure :measurements=measurements />
</div> </div>
</template> </template>

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

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

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

@ -11,8 +11,6 @@ export class SimpleEntry extends Vue {
@Watch('value', { immediate: true, deep: true }) @Watch('value', { immediate: true, deep: true })
@Emit('input') @Emit('input')
private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) { private onMeasurementChanged(newVal: Measurement<MeasurementMeta>, oldVal: Measurement<MeasurementMeta>) {
newVal.extData.measureType = 'simple' as MeasureType;
if (typeof(newVal.value) === 'string' ) { if (typeof(newVal.value) === 'string' ) {
newVal.value = parseInt(newVal.value, 10); newVal.value = parseInt(newVal.value, 10);
} }

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

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