Implement the settings page and an example chart
This commit is contained in:
parent
564cc84db1
commit
75f50cae88
25 changed files with 902 additions and 52 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@
|
||||||
/dist/
|
/dist/
|
||||||
/.idea/
|
/.idea/
|
||||||
*.env
|
*.env
|
||||||
|
/src/generated-types
|
|
@ -29,8 +29,14 @@
|
||||||
"@iconify-json/svg-spinners": "^1.1.2",
|
"@iconify-json/svg-spinners": "^1.1.2",
|
||||||
"@unhead/vue": "^1.9.10",
|
"@unhead/vue": "^1.9.10",
|
||||||
"@vueuse/core": "^10.9.0",
|
"@vueuse/core": "^10.9.0",
|
||||||
|
"chart.js": "^4.4.3",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
|
"nanoid": "^5.0.7",
|
||||||
|
"temporal-polyfill": "^0.2.4",
|
||||||
"vue": "^3.4.27",
|
"vue": "^3.4.27",
|
||||||
|
"vue-chartjs": "^5.3.1",
|
||||||
"vue-router": "^4.3.2"
|
"vue-router": "^4.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
77
pnpm-lock.yaml
generated
77
pnpm-lock.yaml
generated
|
@ -26,12 +26,30 @@ importers:
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^10.9.0
|
specifier: ^10.9.0
|
||||||
version: 10.9.0(vue@3.4.27(typescript@5.4.5))
|
version: 10.9.0(vue@3.4.27(typescript@5.4.5))
|
||||||
|
chart.js:
|
||||||
|
specifier: ^4.4.3
|
||||||
|
version: 4.4.3
|
||||||
|
chartjs-adapter-date-fns:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.0.0(chart.js@4.4.3)(date-fns@3.6.0)
|
||||||
|
date-fns:
|
||||||
|
specifier: ^3.6.0
|
||||||
|
version: 3.6.0
|
||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
|
nanoid:
|
||||||
|
specifier: ^5.0.7
|
||||||
|
version: 5.0.7
|
||||||
|
temporal-polyfill:
|
||||||
|
specifier: ^0.2.4
|
||||||
|
version: 0.2.4
|
||||||
vue:
|
vue:
|
||||||
specifier: ^3.4.27
|
specifier: ^3.4.27
|
||||||
version: 3.4.27(typescript@5.4.5)
|
version: 3.4.27(typescript@5.4.5)
|
||||||
|
vue-chartjs:
|
||||||
|
specifier: ^5.3.1
|
||||||
|
version: 5.3.1(chart.js@4.4.3)(vue@3.4.27(typescript@5.4.5))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.3.2
|
specifier: ^4.3.2
|
||||||
version: 4.3.2(vue@3.4.27(typescript@5.4.5))
|
version: 4.3.2(vue@3.4.27(typescript@5.4.5))
|
||||||
|
@ -418,6 +436,9 @@ packages:
|
||||||
resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==}
|
resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.2':
|
||||||
|
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
@ -742,6 +763,16 @@ packages:
|
||||||
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
chart.js@4.4.3:
|
||||||
|
resolution: {integrity: sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==}
|
||||||
|
engines: {pnpm: '>=8'}
|
||||||
|
|
||||||
|
chartjs-adapter-date-fns@3.0.0:
|
||||||
|
resolution: {integrity: sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==}
|
||||||
|
peerDependencies:
|
||||||
|
chart.js: '>=2.8.0'
|
||||||
|
date-fns: '>=2.0.0'
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||||
engines: {node: '>= 8.10.0'}
|
engines: {node: '>= 8.10.0'}
|
||||||
|
@ -776,6 +807,9 @@ packages:
|
||||||
csstype@3.1.3:
|
csstype@3.1.3:
|
||||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||||
|
|
||||||
|
date-fns@3.6.0:
|
||||||
|
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
|
||||||
|
|
||||||
debug@4.3.4:
|
debug@4.3.4:
|
||||||
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
|
@ -976,6 +1010,11 @@ packages:
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nanoid@5.0.7:
|
||||||
|
resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==}
|
||||||
|
engines: {node: ^18 || >=20}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
node-fetch-native@1.6.4:
|
node-fetch-native@1.6.4:
|
||||||
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
|
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
|
||||||
|
|
||||||
|
@ -1095,6 +1134,12 @@ packages:
|
||||||
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
temporal-polyfill@0.2.4:
|
||||||
|
resolution: {integrity: sha512-WA5p0CjQTkMjF9m8sP4wSYgpqI8m2d4q7wPUyaJOWhy4bI9mReLb2yGvTV4qf/DPMTe6H6M/Dig5KmTMB7ev6Q==}
|
||||||
|
|
||||||
|
temporal-spec@0.2.4:
|
||||||
|
resolution: {integrity: sha512-lDMFv4nKQrSjlkHKAlHVqKrBG4DyFfa9F74cmBZ3Iy3ed8yvWnlWSIdi4IKfSqwmazAohBNwiN64qGx4y5Q3IQ==}
|
||||||
|
|
||||||
to-fast-properties@2.0.0:
|
to-fast-properties@2.0.0:
|
||||||
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
@ -1210,6 +1255,12 @@ packages:
|
||||||
terser:
|
terser:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vue-chartjs@5.3.1:
|
||||||
|
resolution: {integrity: sha512-rZjqcHBxKiHrBl0CIvcOlVEBwRhpWAVf6rDU3vUfa7HuSRmGtCslc0Oc8m16oAVuk0erzc1FCtH1VCriHsrz+A==}
|
||||||
|
peerDependencies:
|
||||||
|
chart.js: ^4.1.1
|
||||||
|
vue: ^3.0.0-0 || ^2.7.0
|
||||||
|
|
||||||
vue-demi@0.14.7:
|
vue-demi@0.14.7:
|
||||||
resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==}
|
resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
@ -1601,6 +1652,8 @@ snapshots:
|
||||||
string-argv: 0.3.2
|
string-argv: 0.3.2
|
||||||
type-detect: 4.0.8
|
type-detect: 4.0.8
|
||||||
|
|
||||||
|
'@kurkle/color@0.3.2': {}
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
@ -1998,6 +2051,15 @@ snapshots:
|
||||||
escape-string-regexp: 1.0.5
|
escape-string-regexp: 1.0.5
|
||||||
supports-color: 5.5.0
|
supports-color: 5.5.0
|
||||||
|
|
||||||
|
chart.js@4.4.3:
|
||||||
|
dependencies:
|
||||||
|
'@kurkle/color': 0.3.2
|
||||||
|
|
||||||
|
chartjs-adapter-date-fns@3.0.0(chart.js@4.4.3)(date-fns@3.6.0):
|
||||||
|
dependencies:
|
||||||
|
chart.js: 4.4.3
|
||||||
|
date-fns: 3.6.0
|
||||||
|
|
||||||
chokidar@3.6.0:
|
chokidar@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
anymatch: 3.1.3
|
anymatch: 3.1.3
|
||||||
|
@ -2037,6 +2099,8 @@ snapshots:
|
||||||
|
|
||||||
csstype@3.1.3: {}
|
csstype@3.1.3: {}
|
||||||
|
|
||||||
|
date-fns@3.6.0: {}
|
||||||
|
|
||||||
debug@4.3.4:
|
debug@4.3.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.2
|
ms: 2.1.2
|
||||||
|
@ -2218,6 +2282,8 @@ snapshots:
|
||||||
|
|
||||||
nanoid@3.3.7: {}
|
nanoid@3.3.7: {}
|
||||||
|
|
||||||
|
nanoid@5.0.7: {}
|
||||||
|
|
||||||
node-fetch-native@1.6.4: {}
|
node-fetch-native@1.6.4: {}
|
||||||
|
|
||||||
node-releases@2.0.14: {}
|
node-releases@2.0.14: {}
|
||||||
|
@ -2338,6 +2404,12 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 3.0.0
|
has-flag: 3.0.0
|
||||||
|
|
||||||
|
temporal-polyfill@0.2.4:
|
||||||
|
dependencies:
|
||||||
|
temporal-spec: 0.2.4
|
||||||
|
|
||||||
|
temporal-spec@0.2.4: {}
|
||||||
|
|
||||||
to-fast-properties@2.0.0: {}
|
to-fast-properties@2.0.0: {}
|
||||||
|
|
||||||
to-regex-range@5.0.1:
|
to-regex-range@5.0.1:
|
||||||
|
@ -2456,6 +2528,11 @@ snapshots:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
sass: 1.77.2
|
sass: 1.77.2
|
||||||
|
|
||||||
|
vue-chartjs@5.3.1(chart.js@4.4.3)(vue@3.4.27(typescript@5.4.5)):
|
||||||
|
dependencies:
|
||||||
|
chart.js: 4.4.3
|
||||||
|
vue: 3.4.27(typescript@5.4.5)
|
||||||
|
|
||||||
vue-demi@0.14.7(vue@3.4.27(typescript@5.4.5)):
|
vue-demi@0.14.7(vue@3.4.27(typescript@5.4.5)):
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.4.27(typescript@5.4.5)
|
vue: 3.4.27(typescript@5.4.5)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div class="fixed z-100 pointer-events-none -top-20 -left-20 bg-orange-800 rounded-full w-150 h-50 filter blur-250px opacity-60"/>
|
||||||
<NavigationMenuWrapper>
|
<NavigationMenuWrapper>
|
||||||
<div class="p-8 flex-shrink-0 flex-grow-0">
|
<div class="relative p-10 pl-20 pt-15 flex-shrink-0 flex-grow-0">
|
||||||
<SuspenseRouterView/>
|
<SuspenseRouterView/>
|
||||||
</div>
|
</div>
|
||||||
</NavigationMenuWrapper>
|
</NavigationMenuWrapper>
|
||||||
|
@ -12,8 +13,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useHead } from "@unhead/vue"
|
import { useHead } from "@unhead/vue"
|
||||||
import SuspenseRouterView from "./components/reusable/SuspenseRouterView.vue"
|
|
||||||
import NavigationMenuWrapper from "@/components/NavigationMenuWrapper.vue"
|
import NavigationMenuWrapper from "@/components/NavigationMenuWrapper.vue"
|
||||||
|
import SuspenseRouterView from "@/components/SuspenseRouterView.vue"
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
titleTemplate: title => title === undefined ? "Tea Dashboard" : `${title} — Tea Dashboard`
|
titleTemplate: title => title === undefined ? "Tea Dashboard" : `${title} — Tea Dashboard`
|
||||||
|
|
55
src/backend.ts
Normal file
55
src/backend.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { Temporal } from "temporal-polyfill"
|
||||||
|
import { cloneDeep } from "lodash-es"
|
||||||
|
import { fakeServerState } from "@/fakeServerState"
|
||||||
|
|
||||||
|
export interface TypeOfTea {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
steepingTime: Temporal.Duration
|
||||||
|
registrationTimestamp: Temporal.Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteepingTimeChange {
|
||||||
|
id: string
|
||||||
|
typeOfTeaId: string
|
||||||
|
timestamp: Temporal.Instant
|
||||||
|
newValue: Temporal.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrewingEvent {
|
||||||
|
id: string
|
||||||
|
typeOfTeaId: string
|
||||||
|
timestamp: Temporal.Instant
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Configuration {
|
||||||
|
defaultSteepingTime: Temporal.Duration
|
||||||
|
feedbackTimeout: Temporal.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = () => new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
export async function fetchConfiguration(): Promise<Configuration> {
|
||||||
|
await delay()
|
||||||
|
return cloneDeep(fakeServerState.configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateConfiguration(value: Configuration) {
|
||||||
|
await delay()
|
||||||
|
fakeServerState.configuration = cloneDeep(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBrewingEvents(): Promise<BrewingEvent[]> {
|
||||||
|
await delay()
|
||||||
|
return cloneDeep(fakeServerState.brewingEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTypesOfTeaById(): Promise<Map<string, TypeOfTea>> {
|
||||||
|
await delay()
|
||||||
|
return new Map(fakeServerState.typesOfTea.map(t => [t.id, t]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTypeOfTea(id: string, data: Pick<TypeOfTea, "name" | "steepingTime">) {
|
||||||
|
await delay()
|
||||||
|
Object.assign(fakeServerState.typesOfTea.find(t => t.id === id)!, data)
|
||||||
|
}
|
11
src/charts.ts
Normal file
11
src/charts.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
export const borderColors: string[] = [
|
||||||
|
"hsl(20 70% 30%)",
|
||||||
|
"hsl(40,100%,76%)",
|
||||||
|
"hsl(13,100%,66%)"
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function registerChartJsDateFnsAdapter() {
|
||||||
|
// has no type definition
|
||||||
|
// @ts-expect-error
|
||||||
|
await import("chartjs-adapter-date-fns")
|
||||||
|
}
|
21
src/components/Card.vue
Normal file
21
src/components/Card.vue
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<template>
|
||||||
|
<div class="bg-white border-1 border-solid border-gray-300 rounded-2xl shadow-sm flex flex-col gap-3 p-6">
|
||||||
|
<div class="font-bold text-lg font-serif">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div class="h-2px w-full bg-orange-800 opacity-40"/>
|
||||||
|
<div class="relative">
|
||||||
|
<slot/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
}>()
|
||||||
|
</script>
|
84
src/components/DurationInput.vue
Normal file
84
src/components/DurationInput.vue
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div v-if="unitOrder.indexOf(props.largestUnit) >= unitOrder.indexOf('hours')" class="flex gap-0.5 items-baseline">
|
||||||
|
<TextualInput aria-label="Hours" v-model="hours" type="number" min="0" max="23" :disabled="disabled"/>
|
||||||
|
<span>h</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="unitOrder.indexOf(props.largestUnit) >= unitOrder.indexOf('minutes')" class="flex gap-0.5 items-baseline">
|
||||||
|
<TextualInput aria-label="Minutes" v-model="minutes" type="number" min="0" max="59" :disabled="disabled"/>
|
||||||
|
<span>m</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="unitOrder.indexOf(props.largestUnit) >= unitOrder.indexOf('seconds')" class="flex gap-0.5 items-baseline">
|
||||||
|
<TextualInput aria-label="Seconds" v-model="seconds" type="number" min="0" max="59" :disabled="disabled"/>
|
||||||
|
<span>s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import TextualInput from "@/components/TextualInput.vue"
|
||||||
|
import { computed, watchEffect } from "vue"
|
||||||
|
import { Temporal } from "temporal-polyfill"
|
||||||
|
import { getSameButDifferentString } from "@/utils/getSameButDifferentString"
|
||||||
|
|
||||||
|
type TimeUnit = "seconds" | "minutes" | "hours"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: Temporal.Duration
|
||||||
|
minimum: Temporal.Duration
|
||||||
|
disabled: boolean
|
||||||
|
largestUnit: TimeUnit
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const unitOrder: TimeUnit[] = ["seconds", "minutes", "hours"]
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [Temporal.Duration]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.modelValue.total({ unit: "seconds" }) < props.minimum.total({ unit: "seconds" })) {
|
||||||
|
emit("update:modelValue", Temporal.Duration.from(props.minimum))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hours = computed<string>({
|
||||||
|
get() {
|
||||||
|
return getSameButDifferentString(String(props.modelValue.hours))
|
||||||
|
},
|
||||||
|
set(newValue: string) {
|
||||||
|
let parsed = parseInt(newValue, 10)
|
||||||
|
if (isNaN(parsed) || parsed < 0) parsed = 0
|
||||||
|
parsed = Math.min(parsed, 23)
|
||||||
|
emit("update:modelValue", props.modelValue.with({ hours: parsed }).round({ largestUnit: props.largestUnit }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const minutes = computed<string>({
|
||||||
|
get() {
|
||||||
|
return getSameButDifferentString(String(props.modelValue.minutes))
|
||||||
|
},
|
||||||
|
set(newValue: string) {
|
||||||
|
let parsed = parseInt(newValue, 10)
|
||||||
|
if (isNaN(parsed) || parsed < 0) parsed = 0
|
||||||
|
parsed = Math.min(parsed, 59)
|
||||||
|
emit("update:modelValue", props.modelValue.with({ minutes: parsed }).round({ largestUnit: props.largestUnit }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const seconds = computed<string>({
|
||||||
|
get() {
|
||||||
|
return getSameButDifferentString(String(props.modelValue.seconds))
|
||||||
|
},
|
||||||
|
set(newValue: string) {
|
||||||
|
let parsed = parseInt(newValue, 10)
|
||||||
|
if (isNaN(parsed) || parsed < 0) parsed = 0
|
||||||
|
parsed = Math.min(parsed, 59)
|
||||||
|
emit("update:modelValue", props.modelValue.with({ seconds: parsed }).round({ largestUnit: props.largestUnit }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
21
src/components/FieldWrapper.vue
Normal file
21
src/components/FieldWrapper.vue
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label :for="inputId" class="ps-0.5 font-bold text-xs uppercase tracking-widest">{{ label }}</label>
|
||||||
|
<slot :input-id="inputId"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nanoid } from "nanoid/non-secure"
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string,
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputId = computed(() => `field-${nanoid(5)}`)
|
||||||
|
</script>
|
11
src/components/LoadingIcon.vue
Normal file
11
src/components/LoadingIcon.vue
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
<Icon/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Icon from "virtual:icons/svg-spinners/bars-rotate-fade"
|
||||||
|
</script>
|
|
@ -1,15 +1,24 @@
|
||||||
<template>
|
<template>
|
||||||
<nav class="w-70 bg-gray-900 fixed top-0 left-0 bottom-0 p-8 flex flex-col gap-3" :class="$style.root">
|
<nav class="w-70 fixed top-0 left-0 bottom-0 py-8 flex flex-col gap-3 border-r-solid border-1 border-gray-300 bg-gray-100">
|
||||||
<ul class="list-none p-0 grid grid-cols-2 gap-1">
|
<div class="font-serif font-400 text-3xl pl-7">
|
||||||
<li v-for="type in mediaTypes" :key="type.id">
|
Tea Inc.
|
||||||
|
</div>
|
||||||
|
<ul class="list-none p-0 flex flex-col gap-2">
|
||||||
|
<li v-for="item in items" :key="item.target">
|
||||||
<router-link
|
<router-link
|
||||||
class="relative rounded-sm w-full p-2 no-underline flex flex-col items-center gap-1 group"
|
class="relative w-full py-2 pl-8 flex items-center gap-4 bg-orange-800 bg-opacity-0"
|
||||||
:to="`/media/${type.id}`"
|
:class="$style.item"
|
||||||
:style="{ color: type.color }"
|
:to="item.target"
|
||||||
|
:active-class="item.matchExact ? null : $style.active"
|
||||||
|
:exact-active-class="item.matchExact ? $style.active : null"
|
||||||
>
|
>
|
||||||
<component class="absolute -top-5 left-0 opacity-5 blur-5 pointer-events-none group-hover:blur-lg group-hover:opacity-30 transition duration-300" :is="type.blob"/>
|
<div class="text-3xl relative" :class="$style.icons" :style="{ top: item.icon.yOffset === undefined ? null : `${item.icon.yOffset}px` }">
|
||||||
<component class="text-3xl" :is="type.icon"/>
|
<component class="absolute inset-0 opacity-0" :is="item.icon.normal"/>
|
||||||
<span class="text-base">{{ type.label }}</span>
|
<component class="duration-200" :is="item.icon.duotone"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-lg" :class="$style.label">
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -20,52 +29,91 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.root {
|
.item {
|
||||||
box-shadow: 0 0 4px 0 theme("colors.gray.700");
|
transition: background 200ms ease;
|
||||||
|
|
||||||
&::before {
|
&:hover, &.active {
|
||||||
content: "";
|
.label {
|
||||||
position: absolute;
|
font-weight: 600;
|
||||||
pointer-events: none;
|
}
|
||||||
top: 0;
|
|
||||||
left: 0;
|
.icons {
|
||||||
width: 100%;
|
& > :first-child {
|
||||||
height: 100%;
|
opacity: 100;
|
||||||
background: url("../assets/noise.png") repeat;
|
}
|
||||||
opacity: 15%;
|
|
||||||
|
& > :last-child {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
@apply bg-opacity-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
@apply bg-opacity-15 border-r-solid border-2 border-orange-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons > * {
|
||||||
|
transition: opacity 200ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
transition: font-weight 100ms linear;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from "vue"
|
import type { Component } from "vue"
|
||||||
import BooksIcon from "virtual:icons/ph/books-light"
|
import TeaCupOutlineIcon from "virtual:icons/solar/tea-cup-outline"
|
||||||
import FilmStripIcon from "virtual:icons/ph/film-strip-light"
|
import TeaCupLineDuotoneIcon from "virtual:icons/solar/tea-cup-line-duotone"
|
||||||
import LiteratureBlob from "./blobs/LiteratureBlob.vue"
|
import Tuning2OutlineIcon from "virtual:icons/solar/tuning-2-outline"
|
||||||
import MoviesBlob from "./blobs/MoviesBlob.vue"
|
import Tuning2LineDuotoneIcon from "virtual:icons/solar/tuning-2-line-duotone"
|
||||||
|
import ChartOutlineIcon from "virtual:icons/solar/chart-outline"
|
||||||
|
import ChartLineDuotoneIcon from "virtual:icons/solar/chart-line-duotone"
|
||||||
|
|
||||||
interface MediaType {
|
interface NavigationItem {
|
||||||
id: string
|
target: string
|
||||||
|
matchExact?: boolean
|
||||||
label: string
|
label: string
|
||||||
icon: Component
|
icon: {
|
||||||
blob: Component
|
normal: Component
|
||||||
color: string
|
duotone: Component
|
||||||
|
yOffset?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaTypes: MediaType[] = [
|
const items: NavigationItem[] = [
|
||||||
{
|
{
|
||||||
id: "literature",
|
target: "/",
|
||||||
label: "Literature",
|
label: "Overview",
|
||||||
icon: BooksIcon,
|
matchExact: true,
|
||||||
blob: LiteratureBlob,
|
icon: {
|
||||||
color: "#11b970"
|
normal: TeaCupOutlineIcon,
|
||||||
},
|
duotone: TeaCupLineDuotoneIcon,
|
||||||
{
|
yOffset: 2
|
||||||
id: "films",
|
}
|
||||||
label: "Movies",
|
},
|
||||||
icon: FilmStripIcon,
|
{
|
||||||
blob: MoviesBlob,
|
target: "/stats",
|
||||||
color: "#d2b214"
|
label: "Statistics",
|
||||||
}
|
icon: {
|
||||||
|
normal: ChartOutlineIcon,
|
||||||
|
duotone: ChartLineDuotoneIcon,
|
||||||
|
yOffset: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: "/settings",
|
||||||
|
label: "Settings",
|
||||||
|
icon: {
|
||||||
|
normal: Tuning2OutlineIcon,
|
||||||
|
duotone: Tuning2LineDuotoneIcon,
|
||||||
|
yOffset: 5
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
22
src/components/PageTitle.vue
Normal file
22
src/components/PageTitle.vue
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<template>
|
||||||
|
<div class="font-serif font-400 text-5xl text-orange-900 pb-10">
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useHead } from "@unhead/vue"
|
||||||
|
import { toRef } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: toRef(props, "title")
|
||||||
|
})
|
||||||
|
</script>
|
|
@ -27,11 +27,9 @@
|
||||||
&[data-is-visible="true"] {
|
&[data-is-visible="true"] {
|
||||||
opacity: 100%;
|
opacity: 100%;
|
||||||
transition-duration: 200ms;
|
transition-duration: 200ms;
|
||||||
transition-delay: 200ms;
|
|
||||||
|
|
||||||
.loadingIndicator {
|
.loadingIndicator {
|
||||||
opacity: 100%;
|
opacity: 100%;
|
||||||
transition-delay: 1500ms;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
27
src/components/TextButton.vue
Normal file
27
src/components/TextButton.vue
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="bg-green-400 bg-opacity-40 rounded-lg border-none text-black font-bold px-4 py-2.5 outline-none
|
||||||
|
transition duration-200 disabled:opacity-40"
|
||||||
|
:class="$style.root"
|
||||||
|
:type="isSubmit ? 'submit' : 'button'"
|
||||||
|
:disabled="disabled"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.root {
|
||||||
|
&:not(:disabled) {
|
||||||
|
@apply hover:bg-opacity-60 focus-visible:bg-opacity-65 active:bg-opacity-80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
isSubmit?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
label: string
|
||||||
|
}>()
|
||||||
|
</script>
|
35
src/components/TextualInput.vue
Normal file
35
src/components/TextualInput.vue
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="type === 'area' ? 'textarea' : 'input'"
|
||||||
|
:value="modelValue"
|
||||||
|
class="px-2 leading-loose bg-light-300 border-solid border-1 border-gray-300 rounded-sm outline-none focus:bg-light-100 transition duration-200 text-black accent-current disabled:cursor-not-allowed disabled:bg-light-600"
|
||||||
|
:class="[type === 'number' && 'text-end', type === 'area' && 'resize-y min-h-20 max-h-50']"
|
||||||
|
:type="type === 'area' ? undefined : type"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="(event: InputEvent) => modelValue = (event.target as HTMLInputElement | HTMLTextAreaElement).value"
|
||||||
|
@blur="emit('blur')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="Nullability extends null | never">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
type: "normal" | "password" | "datetime-local" | "number" | "area"
|
||||||
|
modelValue: string | Nullability
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue", "blur"])
|
||||||
|
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => props.modelValue ?? "",
|
||||||
|
set: (newValue: string | number) => {
|
||||||
|
emit("update:modelValue", String(newValue))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
131
src/components/charts/TeaConsumptionOverTimeChart.vue
Normal file
131
src/components/charts/TeaConsumptionOverTimeChart.vue
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="relative h-60">
|
||||||
|
<LineChart :data="data" :options="options"/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap px-1 justify-center items-center gap-x-4 gap-y-1 text-sm">
|
||||||
|
<div
|
||||||
|
v-for="(group, index) in groupedTopEvents"
|
||||||
|
:key="group.typeOfTeaId"
|
||||||
|
class="flex gap-1.5 items-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
<div class="h-2px w-5" :style="{ backgroundColor: borderColors[index] }"></div>
|
||||||
|
<div>
|
||||||
|
{{ typesOfTeaById.get(group.typeOfTeaId)!.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Line as LineChart } from "vue-chartjs"
|
||||||
|
import { BrewingEvent, TypeOfTea } from "@/backend"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import {
|
||||||
|
type ChartOptions,
|
||||||
|
type ScriptableContext,
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
TimeScale,
|
||||||
|
ChartData,
|
||||||
|
Filler,
|
||||||
|
} from "chart.js"
|
||||||
|
import { groupBy, take } from "lodash-es"
|
||||||
|
import { borderColors, registerChartJsDateFnsAdapter } from "@/charts"
|
||||||
|
|
||||||
|
await registerChartJsDateFnsAdapter()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
typesOfTeaById: Map<string, TypeOfTea>
|
||||||
|
brewingEvents: BrewingEvent[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, TimeScale, Filler)
|
||||||
|
|
||||||
|
const groupedTopEvents = computed<Array<{ typeOfTeaId: string; events: BrewingEvent[] }>>(() => take(
|
||||||
|
Object
|
||||||
|
.entries(groupBy(props.brewingEvents, e => e.typeOfTeaId))
|
||||||
|
.sort((a, b) => b[1].length - a[1].length),
|
||||||
|
3
|
||||||
|
).map(([typeOfTeaId, events]) => ({
|
||||||
|
typeOfTeaId,
|
||||||
|
events
|
||||||
|
})))
|
||||||
|
|
||||||
|
const options: ChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
minUnit: "month"
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: "linear",
|
||||||
|
grace: "10%",
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 20,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
font: {
|
||||||
|
family: "Manrope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = computed<ChartData>(() => ({
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
pointStyle: false,
|
||||||
|
borderColor: "hsl(20 100% 40%)",
|
||||||
|
fill: "start",
|
||||||
|
backgroundColor: (ctx: ScriptableContext<"line">) => {
|
||||||
|
const canvas = ctx.chart.ctx
|
||||||
|
const gradient = canvas.createLinearGradient(0, -160, 0, 220)
|
||||||
|
|
||||||
|
gradient.addColorStop(0, "hsl(20 100% 40% / 100%)")
|
||||||
|
gradient.addColorStop(1, "hsl(20 100% 50% / 0%)")
|
||||||
|
|
||||||
|
return gradient
|
||||||
|
},
|
||||||
|
data: props.brewingEvents.map((e, index) => ({
|
||||||
|
x: e.timestamp.epochMilliseconds,
|
||||||
|
y: index + 1
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
...groupedTopEvents.value
|
||||||
|
.map((group, index) => ({
|
||||||
|
pointStyle: false,
|
||||||
|
borderColor: borderColors[index],
|
||||||
|
borderWidth: 2,
|
||||||
|
data: group.events.map((e, index) => ({
|
||||||
|
x: e.timestamp.epochMilliseconds,
|
||||||
|
y: index + 1
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
</script>
|
59
src/components/specific/ConfigurationForm.vue
Normal file
59
src/components/specific/ConfigurationForm.vue
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<template>
|
||||||
|
<form class="flex flex-col gap-7" @submit.prevent="onSubmit()">
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<div class="font-serif font-300 text-xl text-orange-900">
|
||||||
|
Default steeping time
|
||||||
|
</div>
|
||||||
|
<p class="text-opacity-80 font-serif font-300 m-0">
|
||||||
|
You can always change the steeping time when you register a new type of tea.<br>
|
||||||
|
This is the time that will be used if you do not change it manually.
|
||||||
|
</p>
|
||||||
|
<DurationInput v-model="data.defaultSteepingTime" largest-unit="minutes" :minimum="Temporal.Duration.from({ seconds: 10 })" :disabled="isLoading"/>
|
||||||
|
</section>
|
||||||
|
<section class="flex flex-col gap-2">
|
||||||
|
<div class="font-serif font-300 text-xl text-orange-900">
|
||||||
|
Feedback timeout
|
||||||
|
</div>
|
||||||
|
<p class="text-opacity-80 font-serif font-300 m-0">
|
||||||
|
After you brew a tea, you can give the machine feedback on how you liked it <em>for this long</em>.<br>
|
||||||
|
It will adjust its steeping time accordingly.
|
||||||
|
</p>
|
||||||
|
<DurationInput v-model="data.feedbackTimeout" largest-unit="hours" :minimum="Temporal.Duration.from({ seconds: 10 })" :disabled="isLoading"/>
|
||||||
|
</section>
|
||||||
|
<TextButton class="self-start max-w-full w-30" is-submit label="Save" :disabled="!isDirty || isLoading"/>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import FieldWrapper from "@/components/FieldWrapper.vue"
|
||||||
|
import { Configuration, updateConfiguration } from "@/backend"
|
||||||
|
import { computed, reactive, ref, toRaw } from "vue"
|
||||||
|
import { cloneDeep, isEqual } from "lodash-es"
|
||||||
|
import DurationInput from "@/components/DurationInput.vue"
|
||||||
|
import { Temporal } from "temporal-polyfill"
|
||||||
|
import TextButton from "@/components/TextButton.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialData: Configuration
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const savedData = ref(cloneDeep(props.initialData))
|
||||||
|
const data = reactive(cloneDeep(props.initialData))
|
||||||
|
|
||||||
|
const isDirty = computed(() => !isEqual(data, savedData.value))
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (isLoading.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
await updateConfiguration(toRaw(data))
|
||||||
|
|
||||||
|
savedData.value = cloneDeep(toRaw(data))
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
46
src/components/specific/TypeOfTeaForm.vue
Normal file
46
src/components/specific/TypeOfTeaForm.vue
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
<template>
|
||||||
|
<form class="flex gap-4 w-full" @submit.prevent="onSubmit()">
|
||||||
|
<FieldWrapper class="flex-grow" label="Name" v-slot="{ inputId }">
|
||||||
|
<TextualInput v-model="data.name" type="normal" :id="inputId" :disabled="isLoading"/>
|
||||||
|
</FieldWrapper>
|
||||||
|
<FieldWrapper label="Steeping time">
|
||||||
|
<DurationInput v-model="data.steepingTime" largest-unit="minutes" :minimum="Temporal.Duration.from({ seconds: 10 })" :disabled="isLoading"/>
|
||||||
|
</FieldWrapper>
|
||||||
|
<TextButton class="self-end" is-submit label="Save" :disabled="!isDirty || isLoading"/>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import FieldWrapper from "@/components/FieldWrapper.vue"
|
||||||
|
import TextualInput from "@/components/TextualInput.vue"
|
||||||
|
import { type TypeOfTea, updateTypeOfTea } from "@/backend"
|
||||||
|
import { computed, reactive, ref, toRaw } from "vue"
|
||||||
|
import { cloneDeep, isEqual } from "lodash-es"
|
||||||
|
import DurationInput from "@/components/DurationInput.vue"
|
||||||
|
import { Temporal } from "temporal-polyfill"
|
||||||
|
import TextButton from "@/components/TextButton.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialData: TypeOfTea
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const savedData = ref(cloneDeep(props.initialData))
|
||||||
|
const data = reactive(cloneDeep(props.initialData))
|
||||||
|
|
||||||
|
const isDirty = computed(() => !isEqual(data, savedData.value))
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (isLoading.value) return
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
await updateTypeOfTea(props.initialData.id, toRaw(data))
|
||||||
|
|
||||||
|
savedData.value = cloneDeep(toRaw(data))
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
</script>
|
97
src/fakeServerState.ts
Normal file
97
src/fakeServerState.ts
Normal file
|
@ -0,0 +1,97 @@
|
||||||
|
import { Temporal } from "temporal-polyfill"
|
||||||
|
import type { BrewingEvent, Configuration, SteepingTimeChange, TypeOfTea } from "@/backend"
|
||||||
|
import { random } from "lodash-es"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
|
||||||
|
const localTimeZoneId = Temporal.Now.timeZoneId()
|
||||||
|
|
||||||
|
const configuration: Configuration = {
|
||||||
|
defaultSteepingTime: Temporal.Duration.from({ minutes: 4, seconds: 30 }),
|
||||||
|
feedbackTimeout: Temporal.Duration.from({ hours: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const typesOfTea: TypeOfTea[] = [
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
name: "Pfefferminz",
|
||||||
|
steepingTime: Temporal.Duration.from({ minutes: 5, seconds: 20 }),
|
||||||
|
registrationTimestamp: Temporal.ZonedDateTime.from({ timeZone: localTimeZoneId, year: 2024, month: 2, day: 20, hour: 14, minute: 24 }).toInstant()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
name: "Türkischer Apfel",
|
||||||
|
steepingTime: Temporal.Duration.from({ minutes: 3, seconds: 50 }),
|
||||||
|
registrationTimestamp: Temporal.ZonedDateTime.from({ timeZone: localTimeZoneId, year: 2024, month: 3, day: 1, hour: 16, minute: 1 }).toInstant()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
name: "Fenchel-Anis-Kümmel",
|
||||||
|
steepingTime: Temporal.Duration.from({ minutes: 8 }),
|
||||||
|
registrationTimestamp: Temporal.ZonedDateTime.from({ timeZone: localTimeZoneId, year: 2024, month: 3, day: 9, hour: 9, minute: 55 }).toInstant()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
name: "Hagebutte",
|
||||||
|
steepingTime: Temporal.Duration.from({ minutes: 4, seconds: 30 }),
|
||||||
|
registrationTimestamp: Temporal.ZonedDateTime.from({ timeZone: localTimeZoneId, year: 2024, month: 3, day: 29, hour: 15, minute: 34 }).toInstant()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const steepingTimeChanges: SteepingTimeChange[] = []
|
||||||
|
const brewingEvents: BrewingEvent[] = []
|
||||||
|
|
||||||
|
for (const type of typesOfTea) {
|
||||||
|
const numberOfBrewingEvents = random(5, 10)
|
||||||
|
let nextTimestamp = type.registrationTimestamp
|
||||||
|
let lastTwoSteepingTimeSeconds: number[] = [type.steepingTime.total({ unit: "seconds" })]
|
||||||
|
|
||||||
|
for (let i = 0; i < numberOfBrewingEvents; i++) {
|
||||||
|
brewingEvents.push({
|
||||||
|
id: nanoid(),
|
||||||
|
typeOfTeaId: type.id,
|
||||||
|
timestamp: nextTimestamp,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (random(0, 100) < 20) {
|
||||||
|
let newSteepingTimeSeconds: number
|
||||||
|
const direction = random(0, 1) === 1 ? "increase" : "decrease"
|
||||||
|
const currentSeconds = lastTwoSteepingTimeSeconds[0]
|
||||||
|
|
||||||
|
if (lastTwoSteepingTimeSeconds.length === 1) {
|
||||||
|
newSteepingTimeSeconds = direction === "increase" ? currentSeconds + 30 : currentSeconds - 30
|
||||||
|
} else {
|
||||||
|
const lastDelta = lastTwoSteepingTimeSeconds[1] - lastTwoSteepingTimeSeconds[0]
|
||||||
|
const lastDirection = lastDelta > 0 ? "increase" : "decrease"
|
||||||
|
if (direction === lastDirection) {
|
||||||
|
newSteepingTimeSeconds = direction === "increase" ? currentSeconds + 30 : currentSeconds - 30
|
||||||
|
} else {
|
||||||
|
newSteepingTimeSeconds = Math.round(Math.abs(lastTwoSteepingTimeSeconds[0] - lastTwoSteepingTimeSeconds[1]) / 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
steepingTimeChanges.push({
|
||||||
|
id: nanoid(),
|
||||||
|
typeOfTeaId: type.id,
|
||||||
|
timestamp: nextTimestamp.add({ hours: random(0, 1), minutes: random(0, 30) }),
|
||||||
|
newValue: Temporal.Duration.from({ seconds: newSteepingTimeSeconds })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTimestamp = nextTimestamp.add(Temporal.Duration.from({ hours: random(0, 14 * 24), minutes: random(0, 60) }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
brewingEvents.sort((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp))
|
||||||
|
steepingTimeChanges.sort((a, b) => Temporal.Instant.compare(a.timestamp, b.timestamp))
|
||||||
|
|
||||||
|
export const fakeServerState: {
|
||||||
|
configuration: Configuration
|
||||||
|
typesOfTea: TypeOfTea[]
|
||||||
|
steepingTimeChanges: SteepingTimeChange[]
|
||||||
|
brewingEvents: BrewingEvent[]
|
||||||
|
} = {
|
||||||
|
configuration,
|
||||||
|
typesOfTea,
|
||||||
|
steepingTimeChanges,
|
||||||
|
brewingEvents
|
||||||
|
}
|
|
@ -1,5 +1,15 @@
|
||||||
@import "modern-normalize/modern-normalize.css";
|
@import "modern-normalize/modern-normalize.css";
|
||||||
|
|
||||||
|
/* fraunces-latin-wght-normal */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Fraunces Variable";
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
font-weight: 100 900;
|
||||||
|
src: url("@fontsource-variable/fraunces/files/fraunces-latin-wght-normal.woff2") format('woff2-variations');
|
||||||
|
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
/* fraunces-latin-wght-italic */
|
/* fraunces-latin-wght-italic */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Fraunces Variable";
|
font-family: "Fraunces Variable";
|
||||||
|
@ -23,7 +33,7 @@
|
||||||
html, body, #app {
|
html, body, #app {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: theme("colors.gray.3");
|
background: theme("colors.background");
|
||||||
color: hsl(0 0% 10%);
|
color: hsl(0 0% 10%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
|
21
src/pages/index.vue
Normal file
21
src/pages/index.vue
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<template>
|
||||||
|
<PageTitle title="Overview"/>
|
||||||
|
<main class="grid grid-cols-1 2xl:grid-cols-2 gap-8 -mx-2">
|
||||||
|
<Card title="Tea consumption over time">
|
||||||
|
<TeaConsumptionOverTimeChart :types-of-tea-by-id="typesOfTeaById" :brewing-events="brewingEvents"/>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Card from "@/components/Card.vue"
|
||||||
|
import TeaConsumptionOverTimeChart from "@/components/charts/TeaConsumptionOverTimeChart.vue"
|
||||||
|
import { fetchBrewingEvents, fetchTypesOfTeaById } from "@/backend"
|
||||||
|
import PageTitle from "@/components/PageTitle.vue"
|
||||||
|
|
||||||
|
const [brewingEvents, typesOfTeaById] = await Promise.all([fetchBrewingEvents(), fetchTypesOfTeaById()])
|
||||||
|
</script>
|
48
src/pages/settings.vue
Normal file
48
src/pages/settings.vue
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<PageTitle title="Settings"/>
|
||||||
|
<main class="max-w-800px flex flex-col gap-10">
|
||||||
|
<section>
|
||||||
|
<div class="bg-white rounded-md border-1 border-solid border-gray-200 p-5">
|
||||||
|
<ConfigurationForm :initial-data="configuration"/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="font-serif font-300 text-2xl text-orange-900">
|
||||||
|
Types of tea
|
||||||
|
</div>
|
||||||
|
<p class="text-opacity-80 font-serif font-300 text-lg m-0 pb-2">
|
||||||
|
New types of tea are registered when you scan them for the first time.
|
||||||
|
</p>
|
||||||
|
<ul v-if="typesOfTea.length > 0" class="list-none m-0 p-0 flex flex-col gap-4 -mx-2">
|
||||||
|
<li
|
||||||
|
v-for="type in typesOfTea"
|
||||||
|
:key="type.id"
|
||||||
|
class="bg-white rounded-md border-1 border-solid border-gray-200 p-5"
|
||||||
|
>
|
||||||
|
<TypeOfTeaForm :initial-data="type"/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="flex flex-col gap-0.5 justify-center py-3 pl-8 pr-2">
|
||||||
|
<div class="font-bold text-xl text-gray-500">
|
||||||
|
No types registered yet.
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-700 text-lg font-300">
|
||||||
|
Get started by scanning a type of tea with the machine.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import PageTitle from "@/components/PageTitle.vue"
|
||||||
|
import { fetchConfiguration, fetchTypesOfTeaById } from "@/backend"
|
||||||
|
import TypeOfTeaForm from "@/components/specific/TypeOfTeaForm.vue"
|
||||||
|
import ConfigurationForm from "@/components/specific/ConfigurationForm.vue"
|
||||||
|
|
||||||
|
const [typesOfTea, configuration] = await Promise.all([fetchTypesOfTeaById().then(v => [...v.values()]), fetchConfiguration()])
|
||||||
|
</script>
|
11
src/pages/stats.vue
Normal file
11
src/pages/stats.vue
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
<template>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
7
src/utils/getSameButDifferentString.ts
Normal file
7
src/utils/getSameButDifferentString.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// Can be used to force watchers to rerun when the string stayed the same value-wise but changed semantically.
|
||||||
|
// It’s a hack.
|
||||||
|
export function getSameButDifferentString(original: string) {
|
||||||
|
return {
|
||||||
|
toString: () => original
|
||||||
|
} as unknown as string
|
||||||
|
}
|
|
@ -20,7 +20,9 @@ export default defineConfig({
|
||||||
colors: {
|
colors: {
|
||||||
dark: colors.dark,
|
dark: colors.dark,
|
||||||
light: colors.light,
|
light: colors.light,
|
||||||
gray: colors.warmGray
|
gray: colors.warmGray,
|
||||||
|
orange: colors.orange,
|
||||||
|
background: "hsl(34, 100%, 99%)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
variants: [
|
variants: [
|
||||||
|
|
Loading…
Add table
Reference in a new issue