Implement the settings page and an example chart

This commit is contained in:
Moritz Ruth 2024-05-20 17:32:16 +02:00
parent 564cc84db1
commit 75f50cae88
Signed by: moritzruth
GPG key ID: C9BBAB79405EE56D
25 changed files with 902 additions and 52 deletions

3
.gitignore vendored
View file

@ -1,4 +1,5 @@
/node_modules/
/dist/
/.idea/
*.env
*.env
/src/generated-types

View file

@ -29,8 +29,14 @@
"@iconify-json/svg-spinners": "^1.1.2",
"@unhead/vue": "^1.9.10",
"@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",
"nanoid": "^5.0.7",
"temporal-polyfill": "^0.2.4",
"vue": "^3.4.27",
"vue-chartjs": "^5.3.1",
"vue-router": "^4.3.2"
}
}

77
pnpm-lock.yaml generated
View file

@ -26,12 +26,30 @@ importers:
'@vueuse/core':
specifier: ^10.9.0
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:
specifier: ^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:
specifier: ^3.4.27
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:
specifier: ^4.3.2
version: 4.3.2(vue@3.4.27(typescript@5.4.5))
@ -418,6 +436,9 @@ packages:
resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==}
engines: {node: '>=10'}
'@kurkle/color@0.3.2':
resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -742,6 +763,16 @@ packages:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
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:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@ -776,6 +807,9 @@ packages:
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
@ -976,6 +1010,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.0.7:
resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==}
engines: {node: ^18 || >=20}
hasBin: true
node-fetch-native@1.6.4:
resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
@ -1095,6 +1134,12 @@ packages:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
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:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
@ -1210,6 +1255,12 @@ packages:
terser:
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:
resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==}
engines: {node: '>=12'}
@ -1601,6 +1652,8 @@ snapshots:
string-argv: 0.3.2
type-detect: 4.0.8
'@kurkle/color@0.3.2': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -1998,6 +2051,15 @@ snapshots:
escape-string-regexp: 1.0.5
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:
dependencies:
anymatch: 3.1.3
@ -2037,6 +2099,8 @@ snapshots:
csstype@3.1.3: {}
date-fns@3.6.0: {}
debug@4.3.4:
dependencies:
ms: 2.1.2
@ -2218,6 +2282,8 @@ snapshots:
nanoid@3.3.7: {}
nanoid@5.0.7: {}
node-fetch-native@1.6.4: {}
node-releases@2.0.14: {}
@ -2338,6 +2404,12 @@ snapshots:
dependencies:
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-regex-range@5.0.1:
@ -2456,6 +2528,11 @@ snapshots:
fsevents: 2.3.3
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)):
dependencies:
vue: 3.4.27(typescript@5.4.5)

View file

@ -1,6 +1,7 @@
<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>
<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/>
</div>
</NavigationMenuWrapper>
@ -12,8 +13,8 @@
<script setup lang="ts">
import { useHead } from "@unhead/vue"
import SuspenseRouterView from "./components/reusable/SuspenseRouterView.vue"
import NavigationMenuWrapper from "@/components/NavigationMenuWrapper.vue"
import SuspenseRouterView from "@/components/SuspenseRouterView.vue"
useHead({
titleTemplate: title => title === undefined ? "Tea Dashboard" : `${title} — Tea Dashboard`

55
src/backend.ts Normal file
View 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
View 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
View 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>

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

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

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

View file

@ -1,15 +1,24 @@
<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">
<ul class="list-none p-0 grid grid-cols-2 gap-1">
<li v-for="type in mediaTypes" :key="type.id">
<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">
<div class="font-serif font-400 text-3xl pl-7">
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
class="relative rounded-sm w-full p-2 no-underline flex flex-col items-center gap-1 group"
:to="`/media/${type.id}`"
:style="{ color: type.color }"
class="relative w-full py-2 pl-8 flex items-center gap-4 bg-orange-800 bg-opacity-0"
:class="$style.item"
: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"/>
<component class="text-3xl" :is="type.icon"/>
<span class="text-base">{{ type.label }}</span>
<div class="text-3xl relative" :class="$style.icons" :style="{ top: item.icon.yOffset === undefined ? null : `${item.icon.yOffset}px` }">
<component class="absolute inset-0 opacity-0" :is="item.icon.normal"/>
<component class="duration-200" :is="item.icon.duotone"/>
</div>
<div class="text-lg" :class="$style.label">
{{ item.label }}
</div>
</router-link>
</li>
</ul>
@ -20,52 +29,91 @@
</template>
<style module lang="scss">
.root {
box-shadow: 0 0 4px 0 theme("colors.gray.700");
.item {
transition: background 200ms ease;
&::before {
content: "";
position: absolute;
pointer-events: none;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: url("../assets/noise.png") repeat;
opacity: 15%;
&:hover, &.active {
.label {
font-weight: 600;
}
.icons {
& > :first-child {
opacity: 100;
}
& > :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>
<script setup lang="ts">
import type { Component } from "vue"
import BooksIcon from "virtual:icons/ph/books-light"
import FilmStripIcon from "virtual:icons/ph/film-strip-light"
import LiteratureBlob from "./blobs/LiteratureBlob.vue"
import MoviesBlob from "./blobs/MoviesBlob.vue"
import TeaCupOutlineIcon from "virtual:icons/solar/tea-cup-outline"
import TeaCupLineDuotoneIcon from "virtual:icons/solar/tea-cup-line-duotone"
import Tuning2OutlineIcon from "virtual:icons/solar/tuning-2-outline"
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 {
id: string
interface NavigationItem {
target: string
matchExact?: boolean
label: string
icon: Component
blob: Component
color: string
icon: {
normal: Component
duotone: Component
yOffset?: number
}
}
const mediaTypes: MediaType[] = [
{
id: "literature",
label: "Literature",
icon: BooksIcon,
blob: LiteratureBlob,
color: "#11b970"
},
{
id: "films",
label: "Movies",
icon: FilmStripIcon,
blob: MoviesBlob,
color: "#d2b214"
}
const items: NavigationItem[] = [
{
target: "/",
label: "Overview",
matchExact: true,
icon: {
normal: TeaCupOutlineIcon,
duotone: TeaCupLineDuotoneIcon,
yOffset: 2
}
},
{
target: "/stats",
label: "Statistics",
icon: {
normal: ChartOutlineIcon,
duotone: ChartLineDuotoneIcon,
yOffset: 2
}
},
{
target: "/settings",
label: "Settings",
icon: {
normal: Tuning2OutlineIcon,
duotone: Tuning2LineDuotoneIcon,
yOffset: 5
}
}
]
</script>

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

View file

@ -27,11 +27,9 @@
&[data-is-visible="true"] {
opacity: 100%;
transition-duration: 200ms;
transition-delay: 200ms;
.loadingIndicator {
opacity: 100%;
transition-delay: 1500ms;
}
}

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

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

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

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

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

View file

@ -1,5 +1,15 @@
@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 */
@font-face {
font-family: "Fraunces Variable";
@ -23,7 +33,7 @@
html, body, #app {
@apply font-sans;
width: 100%;
background: theme("colors.gray.3");
background: theme("colors.background");
color: hsl(0 0% 10%);
min-height: 100vh;
line-height: 1.5;

21
src/pages/index.vue Normal file
View 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
View 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
View file

@ -0,0 +1,11 @@
<template>
</template>
<style module lang="scss">
</style>
<script setup lang="ts">
</script>

View file

@ -0,0 +1,7 @@
// Can be used to force watchers to rerun when the string stayed the same value-wise but changed semantically.
// Its a hack.
export function getSameButDifferentString(original: string) {
return {
toString: () => original
} as unknown as string
}

View file

@ -20,7 +20,9 @@ export default defineConfig({
colors: {
dark: colors.dark,
light: colors.light,
gray: colors.warmGray
gray: colors.warmGray,
orange: colors.orange,
background: "hsl(34, 100%, 99%)"
}
},
variants: [