mirror of
https://github.com/KevinMidboe/brewPi.git
synced 2026-02-05 07:35:28 +00:00
New and improved in sveltekit
This commit is contained in:
14
src/app.html
14
src/app.html
@@ -3,11 +3,17 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description" content="" />
|
||||
<link rel="icon" href="%svelte.assets%/favicon.png" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="stylesheet" type="text/css" href="/global.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/variables.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%svelte.head%
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div id="svelte">%svelte.body%</div>
|
||||
|
||||
<body data-sveltekit-prefetch>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style lang="scss"></style>
|
||||
|
||||
91
src/brews.json
Normal file
91
src/brews.json
Normal file
@@ -0,0 +1,91 @@
|
||||
[{
|
||||
"beer": {
|
||||
"name": "Kveldsbris",
|
||||
"brewery": "Kinn Bryggeri",
|
||||
"category": "Pilsner/Lys Lager",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1682272800",
|
||||
"by": ["Alf", "Kevin"],
|
||||
"abv": "5.6",
|
||||
"description": "",
|
||||
"image": "kinn_kveldsbris.png",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://oslo.bryggselv.no/finest/104923/finest-originals-utepils-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "FUCK YEAH IPA",
|
||||
"brewery": "Finest",
|
||||
"category": "American IPA",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1648922400",
|
||||
"by": ["Alf", "Kevin"],
|
||||
"abv": "7",
|
||||
"description": "",
|
||||
"image": "finest_fuck-yeah-IPA.jpg",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://web.archive.org/web/20210225043236/https://www.bryggselv.no/finest/105943/fuck-yeah-ipa-ultra-american-west-coast-ipa-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "Love in a canoe",
|
||||
"brewery": "Finest",
|
||||
"category": "Mexican Lager",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1646420400",
|
||||
"by": ["Alf", "Kevin"],
|
||||
"abv": "4.7",
|
||||
"description": "",
|
||||
"image": "finest_love-in-a-canoe.jpeg",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://oslo.bryggselv.no/finest/104092/love-in-a-canoe-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "Utepils",
|
||||
"brewery": "Finest",
|
||||
"category": "",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1637694000",
|
||||
"by": ["Alf", "Kevin"],
|
||||
"abv": "5.0",
|
||||
"description": "",
|
||||
"image": "finest_utepils.jpeg",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://www.bryggselv.no/finest/105932/kinn-kveldsbris-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "HELLES Tysk Lager",
|
||||
"brewery": "Münchener Helles",
|
||||
"category": "Tysk Lager",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1629396000",
|
||||
"by": ["Adrian", "Kevin", "Mats"],
|
||||
"abv": "5.3",
|
||||
"description": "",
|
||||
"image": "helles_tysk-lager.jpeg",
|
||||
"recipe": "https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ",
|
||||
"order_page": "https://oslo.bryggselv.no/finest/106231/finest-helles-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}, {
|
||||
"beer": {
|
||||
"name": "Lazy Days Weiss",
|
||||
"brewery": "Finest",
|
||||
"category": "Weissbier",
|
||||
"description": ""
|
||||
},
|
||||
"date": "1621706400",
|
||||
"by": ["Alf", "Kevin", "Kristian"],
|
||||
"abv": "5.3",
|
||||
"description": "",
|
||||
"image": "finest_lazy-days.jpeg",
|
||||
"recipe": "https://docs.google.com/document/u/0/d/1I6qX4l4jDzK51GxBt3IdEv-HyNQHAx8ijc5dMlG1Xkk",
|
||||
"order_page": "https://oslo.bryggselv.no/finest/106231/finest-helles-allgrain-ølsett-25-liter",
|
||||
"untapped": "https://untappd.com/b/kinn-bryggeri-kveldsbris/695024"
|
||||
}]
|
||||
7
src/global.d.ts
vendored
7
src/global.d.ts
vendored
@@ -1 +1,6 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
string;
|
||||
}
|
||||
|
||||
25
src/lib/components/CardButton.svelte
Normal file
25
src/lib/components/CardButton.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<div class="button-circle">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.button-circle {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
background-color: var(--backdrop);
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
transition: all 0.25s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transition: scale(1.15);
|
||||
scale: 1.15;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
46
src/lib/components/Darkmode.svelte
Normal file
46
src/lib/components/Darkmode.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
// import { setTheme, theme } from '../themeStore'
|
||||
import type { Theme } from '../types'
|
||||
|
||||
function toggleDarkmode() {
|
||||
// setTheme(nextTheme);
|
||||
|
||||
// document.body.className = $theme
|
||||
}
|
||||
|
||||
function systemDarkModeEnabled() {
|
||||
const computedStyle = window.getComputedStyle(document.body);
|
||||
if (computedStyle['colorScheme'] != null) {
|
||||
return computedStyle.colorScheme.includes('dark');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
$: icon = true ? '🌝' : '🌚';
|
||||
// $: icon = $theme === 'dark' ? '🌝' : '🌚';
|
||||
// $: nextTheme = ($theme === 'dark' ? 'light' : 'dark') as Theme
|
||||
|
||||
// onMount(() => document.body.className = $theme)
|
||||
</script>
|
||||
|
||||
<div class="darkToggle">
|
||||
<span on:click={() => toggleDarkmode()}>{icon}</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.darkToggle {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
cursor: pointer;
|
||||
// background-color: red;
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 2rem;
|
||||
z-index: 10;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
199
src/lib/components/Graph.svelte
Normal file
199
src/lib/components/Graph.svelte
Normal file
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { onMount, afterUpdate } from 'svelte';
|
||||
import {
|
||||
Chart,
|
||||
LineElement,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Legend
|
||||
} from 'chart.js';
|
||||
|
||||
import type { ChartDataset } from 'chart.js';
|
||||
import type IChartFrame from '../interfaces/IChartFrame';
|
||||
|
||||
Chart.register(
|
||||
LineElement,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Legend
|
||||
);
|
||||
|
||||
export let name: string;
|
||||
export let dataFrames: IChartFrame[];
|
||||
export let beginAtZero: boolean = true;
|
||||
let chartCanvas: HTMLCanvasElement;
|
||||
let chart: Chart;
|
||||
let prevData: any = {};
|
||||
|
||||
interface IDataset {
|
||||
labels: string[];
|
||||
data?: ChartDataset<'line', number[]>;
|
||||
}
|
||||
|
||||
interface ITemperatureDataset extends IDataset {
|
||||
inside: ChartDataset<'line', number[]>;
|
||||
outside?: ChartDataset<'line', number[]>;
|
||||
}
|
||||
|
||||
interface IHumidityDataset extends IDataset {}
|
||||
interface IPressureDataset extends IDataset {}
|
||||
|
||||
function pad(num) {
|
||||
if (num < 10) {
|
||||
return `0${num}`;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function prettierDateString(date) {
|
||||
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${pad(date.getYear() - 100)}`;
|
||||
}
|
||||
|
||||
function computeTemperatureDataset(): ITemperatureDataset {
|
||||
const labels: string[] = dataFrames.map(
|
||||
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
|
||||
);
|
||||
const data: number[] = dataFrames.map((frame) => frame.value);
|
||||
|
||||
return {
|
||||
labels,
|
||||
inside: {
|
||||
label: '℃ inside',
|
||||
borderColor: '#10e783',
|
||||
backgroundColor: '#c8f9df',
|
||||
lineTension: 0.5,
|
||||
borderWidth: 3,
|
||||
data
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function computeHumidityDataset(): IHumidityDataset {
|
||||
const labels: string[] = dataFrames.map(
|
||||
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
|
||||
);
|
||||
const data: number[] = dataFrames.map((frame) => frame.value);
|
||||
|
||||
return {
|
||||
labels,
|
||||
data: {
|
||||
label: '% humidity',
|
||||
borderColor: '#57d2fb',
|
||||
backgroundColor: '#d4f2fe',
|
||||
lineTension: 0.5,
|
||||
borderWidth: 3,
|
||||
data
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function computePressureDataset(): IPressureDataset {
|
||||
const labels: string[] = dataFrames.map(
|
||||
(frame) => prettierDateString(new Date(frame.key)) || String(frame.key_as_string)
|
||||
);
|
||||
const data: number[] = dataFrames.map((frame) => frame.value);
|
||||
|
||||
return {
|
||||
labels,
|
||||
data: {
|
||||
label: 'Bar of pressure',
|
||||
borderColor: '#ef5878',
|
||||
backgroundColor: '#fbd7de',
|
||||
lineTension: 0.5,
|
||||
borderWidth: 3,
|
||||
data
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
const context: CanvasRenderingContext2D = chartCanvas.getContext('2d');
|
||||
|
||||
let dataset: IDataset | ITemperatureDataset | IHumidityDataset | IPressureDataset;
|
||||
if (name === 'Temperature') dataset = computeTemperatureDataset();
|
||||
else if (name === 'Humidity') dataset = computeHumidityDataset();
|
||||
else if (name === 'Pressure') dataset = computePressureDataset();
|
||||
|
||||
chart = new Chart(context, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dataset.labels,
|
||||
datasets: [dataset?.inside || dataset.data]
|
||||
},
|
||||
options: {
|
||||
elements: {
|
||||
point: {
|
||||
radius: 1
|
||||
}
|
||||
},
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
position: 'left',
|
||||
text: `${name} over time`,
|
||||
font: {
|
||||
size: 20
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: true,
|
||||
usePointStyle: true,
|
||||
borderRadius: 10,
|
||||
labels: {
|
||||
padding: 12,
|
||||
boxWidth: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true,
|
||||
speed: 0.001
|
||||
},
|
||||
// pinch: {
|
||||
// enabled: true
|
||||
// },
|
||||
mode: 'xy'
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
offset: true,
|
||||
ticks: {
|
||||
color: 'black'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.06)'
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(0,0,0,0.06)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chart.update();
|
||||
}
|
||||
|
||||
onMount(() => renderChart());
|
||||
afterUpdate(() => {
|
||||
console.log('after update run');
|
||||
chart.destroy();
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas id="{name}" bind:this="{chartCanvas}" width="400" height="400"></canvas>
|
||||
193
src/lib/components/Header.svelte
Normal file
193
src/lib/components/Header.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import Navigation from './Navigation.svelte';
|
||||
import GithubIcon from '../icons/Github.svelte';
|
||||
|
||||
let open: boolean = false;
|
||||
|
||||
function toggleMenu() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
$: headerText = !open ? 'Menu' : 'Close';
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="slideout-menu" transition:fly="{{ x: 550, duration: 300 }}">
|
||||
<h1>Navigation</h1>
|
||||
|
||||
<Navigation on:click={close} />
|
||||
|
||||
<ul class="bottom-content">
|
||||
<li>
|
||||
<a href="https://github.com/kevinmidboe/brewpi">
|
||||
<GithubIcon />
|
||||
<span class="meta">View on Github</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<header>
|
||||
<div on:click={toggleMenu} class:open aria-label="Open menu" class="menu">
|
||||
{#if !open}
|
||||
<span class="page-header-buttons__open">
|
||||
<span /> <span /> <span />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="page-header-buttons__close">
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="page-header-text">{headerText}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.slideout-menu {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
max-width: 550px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
background-color: #fff3f6;
|
||||
color: black;
|
||||
padding: calc(100px + 2rem) 2rem 1rem;
|
||||
border-top-left-radius: 4rem;
|
||||
|
||||
h1 {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
padding: 100px 2rem 2rem;
|
||||
}
|
||||
|
||||
.bottom-content {
|
||||
margin-top: auto;
|
||||
|
||||
li, li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.meta {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
li:not(:last-of-type) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
padding: 0 1rem;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--header-height);
|
||||
background-color: transparent;
|
||||
pointer-events: none;
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
background-color: var(--green);
|
||||
color: #fff3f6;
|
||||
padding: 14px 20px;
|
||||
border-radius: 25px;
|
||||
-webkit-transition: all 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
&.open {
|
||||
background-color: salmon;
|
||||
color: black;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-text {
|
||||
padding-left: 11px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.page-header-buttons__open {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
|
||||
&:first-child {
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
top: 11px;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
top: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page-header-buttons__close {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
|
||||
&:first-child {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
52
src/lib/components/Livestream.svelte
Normal file
52
src/lib/components/Livestream.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<div class="livestream-container">
|
||||
<img src="https://i.imgur.com/T4fCMI5.png" alt="livestream" />
|
||||
|
||||
<div class="pulse"></div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.livestream-container {
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
border: 0.5rem solid var(--background);
|
||||
transition: border-color var(--color-transition-duration) ease-in-out;
|
||||
display: inline-block;
|
||||
height: fit-content;
|
||||
|
||||
img {
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
position: absolute;
|
||||
top: 1.2rem;
|
||||
left: 1.2rem;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 82, 82, 1);
|
||||
box-shadow: 0 0 0 0 rgba(255, 82, 82, 1);
|
||||
height: 1.7rem;
|
||||
width: 1.7rem;
|
||||
transform: scale(1);
|
||||
animation: pulse-red 2s infinite;
|
||||
|
||||
@keyframes pulse-red {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0.7);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 12px rgba(255, 82, 82, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
src/lib/components/Navigation.svelte
Normal file
57
src/lib/components/Navigation.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import ArrowRight from "../icons/ArrowRight.svelte";
|
||||
|
||||
interface IRoute {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const routes: Array<IRoute> = [{
|
||||
name: 'Home',
|
||||
path: '/'
|
||||
}, {
|
||||
name: 'Past brews',
|
||||
path: '/brews'
|
||||
}, {
|
||||
name: 'Graphs',
|
||||
path: '/graphs'
|
||||
}]
|
||||
</script>
|
||||
|
||||
<ul class="navigation-cards" on:click>
|
||||
{#each routes as route}
|
||||
<a href={route.path}>
|
||||
<li>
|
||||
<span>{ route.name }</span>
|
||||
<ArrowRight />
|
||||
</li>
|
||||
</a>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.navigation-cards a {
|
||||
display: block;
|
||||
border-radius: 2rem;
|
||||
background: var(--green);
|
||||
transition: background-color var(--color-transition-duration) ease-in-out, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
margin: 1rem 0;
|
||||
&:first-of-type {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.75rem;
|
||||
color: white;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
15
src/lib/components/NavigationFrontMenu.svelte
Normal file
15
src/lib/components/NavigationFrontMenu.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Navigation from './Navigation.svelte'
|
||||
</script>
|
||||
|
||||
<div class="front-menu">
|
||||
<Navigation />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.front-menu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
27
src/lib/components/PageHeader.svelte
Normal file
27
src/lib/components/PageHeader.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="page-header">
|
||||
<h1>Schleppe Brew</h1>
|
||||
<span class="subtitle">Monitor beer brewing refridgerator</span>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.page-header {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-family: 'Overpass';
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.2rem;
|
||||
|
||||
@include tablet {
|
||||
font-size: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
90
src/lib/components/RelayControls.svelte
Normal file
90
src/lib/components/RelayControls.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
// import { toggleRelay } from '$lib/server/relayToggle'
|
||||
import Switch from './Switch.svelte';
|
||||
|
||||
export let relays = [];
|
||||
|
||||
function toggleRelay(location) {
|
||||
const url = `/api/relay/${location}`;
|
||||
const options = {
|
||||
method: 'POST'
|
||||
};
|
||||
|
||||
fetch(url, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(console.log);
|
||||
}
|
||||
|
||||
function handleChange(event) {
|
||||
let isChecked = event.detail.checked;
|
||||
// Perform any desired actions based on the new value
|
||||
console.log('New value:', isChecked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relays-container">
|
||||
<h1>Manual relay controls</h1>
|
||||
|
||||
<div class="vertical-sensor-display">
|
||||
{#each relays as relay}
|
||||
<div>
|
||||
<h2>{relay.location} relay</h2>
|
||||
|
||||
<div class="sensor-reading">
|
||||
<Switch checked="{relay.state}" on:change="{() => toggleRelay(relay.location)}" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.relays-container {
|
||||
height: fit-content;
|
||||
border-radius: 12px;
|
||||
background-color: var(--background);
|
||||
transition: background-color var(--color-transition-duration) ease-in-out;
|
||||
padding: 2.25rem 1rem;
|
||||
|
||||
@include tablet {
|
||||
padding: 2.25rem 3rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-sensor-display {
|
||||
position: relative;
|
||||
height: fit-content;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-color);
|
||||
text-transform: capitalize;
|
||||
|
||||
@include tablet {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-reading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
</style>
|
||||
177
src/lib/components/Switch.svelte
Normal file
177
src/lib/components/Switch.svelte
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let checked = false;
|
||||
|
||||
function handleChange() {
|
||||
dispatch('change', { checked });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="switch-wrapper">
|
||||
<div class="switch-button-container">
|
||||
<input class="switch-checkbox" type="checkbox" bind:checked="{checked}" on:change="{handleChange}" >
|
||||
<div class="switch-button">
|
||||
<div class="switch-button-top">
|
||||
<svg class="switch-icon on" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44">
|
||||
<path d="M19 3h6v38h-6z"/>
|
||||
</svg>
|
||||
<svg class="switch-icon off" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44 22C44 34.1503 34.1503 44 22 44C9.84974 44 0 34.1503 0 22C0 9.84974 9.84974 0 22 0C34.1503 0 44 9.84974 44 22ZM5.5 22C5.5 31.1127 12.8873 38.5 22 38.5C31.1127 38.5 38.5 31.1127 38.5 22C38.5 12.8873 31.1127 5.5 22 5.5C12.8873 5.5 5.5 12.8873 5.5 22Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.switch-wrapper {
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
width: 100px;
|
||||
height: 125px;
|
||||
background-image: linear-gradient(to bottom, #414049, #30282c);
|
||||
box-shadow:
|
||||
0 0 1px #050506,
|
||||
inset 0 0 0 2px #050506,
|
||||
inset 0 3px 1px #66646c;
|
||||
}
|
||||
|
||||
.switch-button-container {
|
||||
position: relative;
|
||||
border-radius: 3px;
|
||||
padding: 3px 2px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #000;
|
||||
box-shadow: 0 0 1px #000;
|
||||
}
|
||||
|
||||
.switch-checkbox {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch-button {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
border-radius: 5px 5px 2px 2px / 16px 16px 2px 2px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #7f070d;
|
||||
background-image:
|
||||
linear-gradient(to bottom, rgba(#770505, .6) 40%, rgba(#ff8d93, .6) 60% 75%, rgba(#fff, .9) 90%),
|
||||
linear-gradient(to bottom, rgba(#710206, .6), rgba(#d12127, .6))
|
||||
;
|
||||
background-size: 100% calc(15% + 1px), 100% 0%;
|
||||
background-position: top, bottom;
|
||||
background-repeat: no-repeat;
|
||||
box-shadow:
|
||||
inset 0 -3px 2px rgba(#000, .4),
|
||||
inset 0 3px 1px rgba(#000, .8),
|
||||
inset 1px 0 0 rgba(#691016, .8),
|
||||
inset -1px 0 0 rgba(#691016, .8),
|
||||
inset 1px 0 0 rgba(#d6585f, .8),
|
||||
inset -1px 0 0 rgba(#d6585f, .8);
|
||||
transition: border-radius .4s, background-size .4s;
|
||||
overflow: hidden;
|
||||
|
||||
.switch-checkbox:checked + & {
|
||||
border-radius: 2px 2px 5px 5px / 2px 2px 16px 16px;
|
||||
background-size: 100% 0, 100% 15%;
|
||||
}
|
||||
}
|
||||
|
||||
.switch-button-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 15%;
|
||||
width: 100%;
|
||||
height: 85%;
|
||||
background-color: rgba(#770505, .8);
|
||||
background-image: radial-gradient(rgba(#900006, .8) 1px, transparent 0);
|
||||
background-size: 3px 3px;
|
||||
background-position: 50%;
|
||||
box-shadow:
|
||||
inset 1px 0 0 rgba(#691016, .8),
|
||||
inset -1px 0 0 rgba(#691016, .8),
|
||||
inset 1px 0 0 rgba(#d6585f, .8),
|
||||
inset -1px 0 0 rgba(#d6585f, .8),
|
||||
inset 0 -1px 0 rgba(#fff, .2),
|
||||
inset 0 1px 0 rgba(#fff, .9);
|
||||
transition: all .4s;
|
||||
|
||||
.switch-checkbox:checked + .switch-button > & {
|
||||
top: 0;
|
||||
background-color: rgba(#e30320, .2);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-image: radial-gradient(closest-side at 50% 50%, #ffaf0f 0% 10%, #fc071e 30%, #4b0100);
|
||||
opacity: 0;
|
||||
|
||||
.switch-checkbox:checked + .switch-button > & {
|
||||
opacity: 1;
|
||||
animation: flick .2s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
background-color: rgba(#fdd8d8, .14);
|
||||
filter: blur(4px);
|
||||
transition: transform .4s, background-color .4s;
|
||||
|
||||
.switch-checkbox:checked + .switch-button > & {
|
||||
background-color: rgba(#fdd8d8, .2);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: #aa9094;
|
||||
transition: fill .4s;
|
||||
|
||||
.switch-checkbox:checked + .switch-button > .switch-button-top > & {
|
||||
fill: #f3d5df;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flick {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: .8;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
135
src/lib/components/VerticalSensorDisplay.svelte
Normal file
135
src/lib/components/VerticalSensorDisplay.svelte
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import CardButton from "./CardButton.svelte";
|
||||
import Activity from "../icons/Activity.svelte";
|
||||
|
||||
export let inside;
|
||||
export let outside;
|
||||
|
||||
let loadedTime: number = new Date().getTime();
|
||||
let currentTime: number = new Date().getTime()
|
||||
|
||||
function updateTime() {
|
||||
currentTime = new Date().getTime();
|
||||
}
|
||||
|
||||
function flipCard(): void {
|
||||
console.log("flip-a-delphia")
|
||||
}
|
||||
|
||||
onMount(() => setInterval(updateTime, 1000));
|
||||
|
||||
$: secondsSinceUpdate = Math.floor((currentTime - loadedTime) / 1000)
|
||||
</script>
|
||||
|
||||
|
||||
<div class="vertical-sensor-display">
|
||||
<CardButton>
|
||||
<Activity on:click={flipCard} />
|
||||
</CardButton>
|
||||
|
||||
<h2>Current target temperature</h2>
|
||||
<div class="sensor-reading">
|
||||
<div class="red">
|
||||
<span class="value">16</span>
|
||||
<span class="unit">°C</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Inside temperature</h2>
|
||||
<div class="sensor-reading">
|
||||
<div class="blue">
|
||||
<span class="value">{ inside?.temperature }</span>
|
||||
<span class="unit">{ inside?.temperature_unit }</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="value">{ Math.floor(inside?.humidity) }</span>
|
||||
<span class="unit">{ inside?.humidity_unit || '%' }</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Outside temperature</h2>
|
||||
<div class="sensor-reading">
|
||||
<div class="blue">
|
||||
<span class="value">{ outside?.temperature }</span>
|
||||
<span class="unit">{ outside?.temperature_unit }</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="value">{ Math.floor(outside?.humidity) }</span>
|
||||
<span class="unit">{ outside?.humidity_unit }</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Pressure</h2>
|
||||
<div class="sensor-reading">
|
||||
<span class="value">{ inside?.pressure || 0}</span>
|
||||
<span class="unit">bar</span>
|
||||
</div>
|
||||
|
||||
<div class="button-timer">
|
||||
<span>Updated { secondsSinceUpdate === 0 ? 'now' : secondsSinceUpdate + 's ago' }</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.vertical-sensor-display {
|
||||
position: relative;
|
||||
padding: 2.25rem 1rem;
|
||||
border-radius: 12px;
|
||||
background-color: var(--background);
|
||||
transition: background-color var(--color-transition-duration) ease-in-out;
|
||||
|
||||
@include tablet {
|
||||
padding: 2.25rem 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-color);
|
||||
|
||||
@include tablet {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sensor-reading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.75rem;
|
||||
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
|
||||
@include tablet {
|
||||
font-size: 4.5rem;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.red {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.blue {
|
||||
color: var(--blue);
|
||||
}
|
||||
}
|
||||
|
||||
.button-timer {
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
color: rgba(0, 0, 0, .5);
|
||||
}
|
||||
|
||||
</style>
|
||||
20
src/lib/components/display.svelte
Normal file
20
src/lib/components/display.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
export const title: string = "Temperature"
|
||||
|
||||
let temp = 23
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<header>{ title }</header>
|
||||
|
||||
<div class="body">
|
||||
<p>Inside temperature: {23}℃</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
270
src/lib/graphQueryGenerator.ts
Normal file
270
src/lib/graphQueryGenerator.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type IESTelemetry from './interfaces/IESTelemetry';
|
||||
import type IChartFrame from './interfaces/IChartFrame';
|
||||
|
||||
const TELEMETRY_ENDPOINT = 'REPLACE_WITH_ES_HOST/brewlogger-*/_search';
|
||||
const ES_APIKEY = '***REMOVED***';
|
||||
|
||||
function dateToESString(date: Date) {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
const buildLatestQuery = (place: string) => {
|
||||
return {
|
||||
sort: [{ '@timestamp': { order: 'desc', mode: 'max' } }],
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
match_all: {}
|
||||
},
|
||||
{
|
||||
match_phrase: {
|
||||
'location.keyword': place
|
||||
}
|
||||
},
|
||||
{
|
||||
match_phrase: {
|
||||
'message.keyword': 'Sensor readings'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
size: 1
|
||||
};
|
||||
};
|
||||
|
||||
function buildQuery(field: String, from: Date, to: Date, interval: String) {
|
||||
const fromDateString: string = dateToESString(from);
|
||||
const toDateString: string = dateToESString(to);
|
||||
|
||||
return {
|
||||
aggs: {
|
||||
data: {
|
||||
date_histogram: {
|
||||
field: '@timestamp',
|
||||
fixed_interval: interval,
|
||||
time_zone: 'Europe/Oslo',
|
||||
min_doc_count: 1
|
||||
},
|
||||
aggs: {
|
||||
maxValue: {
|
||||
max: {
|
||||
field: field
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
size: 0,
|
||||
fields: [
|
||||
{
|
||||
field: '@timestamp',
|
||||
format: 'date_time'
|
||||
}
|
||||
],
|
||||
script_fields: {},
|
||||
stored_fields: ['*'],
|
||||
_source: {
|
||||
excludes: []
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
must: [],
|
||||
filter: [
|
||||
{
|
||||
match_all: {}
|
||||
},
|
||||
{
|
||||
match_phrase: {
|
||||
'location.keyword': 'inside'
|
||||
}
|
||||
},
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: toDateString,
|
||||
lte: fromDateString,
|
||||
format: 'strict_date_optional_time'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
should: [],
|
||||
must_not: []
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function roundInterval(interval) {
|
||||
switch (true) {
|
||||
case interval <= 500:
|
||||
return '100ms';
|
||||
case interval <= 5e3:
|
||||
return '1s';
|
||||
case interval <= 7500:
|
||||
return '5s';
|
||||
case interval <= 15e3:
|
||||
return '10s';
|
||||
case interval <= 45e3:
|
||||
return '30s';
|
||||
case interval <= 18e4:
|
||||
return '1m';
|
||||
case interval <= 45e4:
|
||||
return '5m';
|
||||
case interval <= 12e5:
|
||||
return '10m';
|
||||
case interval <= 27e5:
|
||||
return '30m';
|
||||
case interval <= 72e5:
|
||||
return '1h';
|
||||
case interval <= 216e5:
|
||||
return '3h';
|
||||
case interval <= 864e5:
|
||||
return '12h';
|
||||
case interval <= 6048e5:
|
||||
return '24h';
|
||||
case interval <= 18144e5:
|
||||
return '3d';
|
||||
case interval < 36288e5:
|
||||
return '30d';
|
||||
default:
|
||||
return '1y';
|
||||
}
|
||||
}
|
||||
|
||||
function calculateInterval(from, to, interval, size) {
|
||||
if (interval !== 'auto') {
|
||||
return interval;
|
||||
}
|
||||
const dateMathInterval = roundInterval((from - to) / size);
|
||||
// const dateMathIntervalMs = toMS(dateMathInterval);
|
||||
// const minMs = toMS(min);
|
||||
// if (dateMathIntervalMs !== undefined && minMs !== undefined && dateMathIntervalMs < minMs) {
|
||||
// return min;
|
||||
// }
|
||||
return dateMathInterval;
|
||||
}
|
||||
|
||||
function parseTempResponse(data: IESTelemetry): IChartFrame[] {
|
||||
return data?.aggregations?.data?.buckets.map((bucket) => {
|
||||
return {
|
||||
value: bucket?.maxValue?.value,
|
||||
key_as_string: bucket?.key_as_string,
|
||||
key: bucket?.key
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseLatestResponse(data: IESTelemetry) {
|
||||
return data?.hits?.hits[0]?._source;
|
||||
}
|
||||
|
||||
export function fetchTemperature(
|
||||
from: Date,
|
||||
to: Date,
|
||||
size: number = 50,
|
||||
fetch: Function
|
||||
): Promise<IChartFrame[]> {
|
||||
const fromMS = from.getTime();
|
||||
const toMS = to.getTime();
|
||||
const interval = calculateInterval(fromMS, toMS, 'auto', size);
|
||||
const fieldName = 'temperature';
|
||||
const esSearchQuery = buildQuery(fieldName, from, to, interval);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(esSearchQuery)
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseTempResponse);
|
||||
}
|
||||
|
||||
export function fetchHumidity(
|
||||
from: Date,
|
||||
to: Date,
|
||||
size: number = 50,
|
||||
fetch: Function
|
||||
): Promise<IChartFrame[]> {
|
||||
const fromMS = from.getTime();
|
||||
const toMS = to.getTime();
|
||||
const interval = calculateInterval(fromMS, toMS, 'auto', size);
|
||||
const fieldName = 'humidity';
|
||||
const esSearchQuery = buildQuery(fieldName, from, to, interval);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(esSearchQuery)
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseTempResponse);
|
||||
}
|
||||
|
||||
export function fetchPressure(
|
||||
from: Date,
|
||||
to: Date,
|
||||
size: number = 50,
|
||||
fetch: Function
|
||||
): Promise<IChartFrame[]> {
|
||||
const fromMS = from.getTime();
|
||||
const toMS = to.getTime();
|
||||
const interval = calculateInterval(fromMS, toMS, 'auto', size);
|
||||
const fieldName = 'pressure';
|
||||
const esSearchQuery = buildQuery(fieldName, from, to, interval);
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(esSearchQuery)
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseTempResponse);
|
||||
}
|
||||
|
||||
export function getLatestInsideReadings(fetch: Function) {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(buildLatestQuery('inside'))
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseLatestResponse);
|
||||
}
|
||||
|
||||
export function getLatestOutsideReadings(fetch: Function) {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `ApiKey ${ES_APIKEY}`
|
||||
},
|
||||
body: JSON.stringify(buildLatestQuery('outside'))
|
||||
};
|
||||
|
||||
return fetch(TELEMETRY_ENDPOINT, options)
|
||||
.then((resp) => resp.json())
|
||||
.then(parseLatestResponse);
|
||||
}
|
||||
1
src/lib/icons/Activity.svelte
Normal file
1
src/lib/icons/Activity.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-activity"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 291 B |
1
src/lib/icons/AlignLeft.svelte
Normal file
1
src/lib/icons/AlignLeft.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-left"><line x1="17" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="17" y1="18" x2="3" y2="18"></line></svg>
|
||||
|
After Width: | Height: | Size: 405 B |
1
src/lib/icons/ArrowRight.svelte
Normal file
1
src/lib/icons/ArrowRight.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-arrow-right"><line x1="5" y1="12" x2="19" y2="12"></line><polyline points="12 5 19 12 12 19"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 323 B |
1
src/lib/icons/Github.svelte
Normal file
1
src/lib/icons/Github.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
|
After Width: | Height: | Size: 814 B |
1
src/lib/icons/TrendingUp.svelte
Normal file
1
src/lib/icons/TrendingUp.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<svg on:click xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trending-up"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"></polyline><polyline points="17 6 23 6 23 12"></polyline></svg>
|
||||
|
After Width: | Height: | Size: 338 B |
5
src/lib/interfaces/IChartFrame.ts
Normal file
5
src/lib/interfaces/IChartFrame.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default interface IChartFrame {
|
||||
value: number;
|
||||
key: number;
|
||||
key_as_string: string;
|
||||
}
|
||||
52
src/lib/interfaces/IESTelemetry.ts
Normal file
52
src/lib/interfaces/IESTelemetry.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type ESRelation = 'eq' | 'lt' | 'gt';
|
||||
|
||||
interface IESHit {
|
||||
_index: string;
|
||||
_type: string;
|
||||
_id: string;
|
||||
_score: number;
|
||||
_source: IESSource;
|
||||
}
|
||||
|
||||
interface IESSource {
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
location: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
'@timestamp': string;
|
||||
sessionID: string;
|
||||
}
|
||||
|
||||
export default interface ESTelemetry {
|
||||
took: number;
|
||||
timed_out: boolean;
|
||||
_shards: {
|
||||
total: number;
|
||||
successful: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
hits: {
|
||||
total: {
|
||||
value: number;
|
||||
relation: ESRelation;
|
||||
};
|
||||
max_score: null;
|
||||
hits: Array<IESHit>;
|
||||
};
|
||||
aggregations: {
|
||||
data: {
|
||||
buckets: [
|
||||
{
|
||||
maxValue: {
|
||||
value: number;
|
||||
};
|
||||
key_as_string: string;
|
||||
key: number;
|
||||
doc_count: number;
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
17
src/lib/themeStore.ts
Normal file
17
src/lib/themeStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { writable, get, derived } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
// import { session } from '$app/stores';
|
||||
import type { Theme } from './types';
|
||||
|
||||
export const theme = derived<Writable<App.Session>, Theme>(session, ($session, set) => {
|
||||
if ($session.theme) {
|
||||
set($session.theme);
|
||||
} else if (browser) {
|
||||
set(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
|
||||
export const setTheme = (theme: Theme) => {
|
||||
session.update(($session) => ({ ...$session, theme }));
|
||||
fetch('/theme', { method: 'PUT', body: theme });
|
||||
};
|
||||
4
src/lib/types.ts
Normal file
4
src/lib/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
const themes = ['light', 'dark'] as const;
|
||||
|
||||
export type Theme = typeof themes[number];
|
||||
export const isTheme = (theme: string): theme is Theme => themes.includes(theme as Theme);
|
||||
29
src/routes/+layout.svelte
Normal file
29
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import HeaderComponent from '$lib/components/Header.svelte';
|
||||
// import Darkmode from '$lib/components/Darkmode.svelte'
|
||||
</script>
|
||||
|
||||
<HeaderComponent />
|
||||
<!-- <Darkmode/> -->
|
||||
|
||||
<div class="page-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/media-queries.scss';
|
||||
|
||||
.page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: 2.5em;
|
||||
margin-top: var(--header-height);
|
||||
|
||||
@include mobile {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
src/routes/+page.server.ts
Normal file
38
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { getLatestInsideReadings, getLatestOutsideReadings } from '$lib/graphQueryGenerator';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
let DEFAULT_MINUTES = 14400;
|
||||
const host = 'http://brewpi.schleppe:5000';
|
||||
const sensorsUrl = `${host}/api/sensors`;
|
||||
const relaysUrl = `${host}/api/relays`;
|
||||
|
||||
async function getSensors() {
|
||||
return fetch(sensorsUrl)
|
||||
.then((resp) => resp.json())
|
||||
.then((response) => {
|
||||
return response?.sensors;
|
||||
});
|
||||
}
|
||||
|
||||
async function getRelays() {
|
||||
return fetch(relaysUrl)
|
||||
.then((resp) => resp.json())
|
||||
.then((response) => {
|
||||
return response?.relays || [];
|
||||
});
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const [sensors, relays] = await Promise.all([getSensors(), getRelays()]);
|
||||
console.log('got sensors and relays');
|
||||
console.log(sensors, relays);
|
||||
|
||||
const inside = sensors.find((sensor) => sensor.location === 'inside');
|
||||
const outside = sensors.find((sensor) => sensor.location === 'outside');
|
||||
|
||||
return {
|
||||
inside: inside || null,
|
||||
outside: outside || null,
|
||||
relays
|
||||
};
|
||||
};
|
||||
41
src/routes/+page.svelte
Normal file
41
src/routes/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Display from '$lib/components/display.svelte'
|
||||
import VerticalSensorDisplay from '$lib/components/VerticalSensorDisplay.svelte'
|
||||
import Livestream from '$lib/components/Livestream.svelte'
|
||||
import Navigation from '$lib/components/Navigation.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import RelayControls from '../lib/components/RelayControls.svelte';
|
||||
|
||||
export let data: PageData
|
||||
const { inside, outside, relays } = data;
|
||||
</script>
|
||||
|
||||
<PageHeader />
|
||||
|
||||
<div class="vertical-grid">
|
||||
<Navigation />
|
||||
|
||||
<VerticalSensorDisplay {inside} {outside} />
|
||||
|
||||
<RelayControls {relays} />
|
||||
|
||||
<!-- <Livestream /> -->
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../styles/media-queries.scss';
|
||||
|
||||
.vertical-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
column-gap: 2rem;
|
||||
row-gap: 15px;
|
||||
margin: 1rem;
|
||||
|
||||
@include desktop {
|
||||
grid-template-columns: 1fr 2fr 3fr;
|
||||
margin: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
src/routes/api/relay/[location]/+server.ts
Normal file
14
src/routes/api/relay/[location]/+server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// const BREWPI_URL = ''
|
||||
const BREWPI_URL = 'http://brewpi.schleppe:5000';
|
||||
|
||||
export const POST = (async ({ request }) => {
|
||||
const { pathname } = new URL(request.url);
|
||||
|
||||
const options = { method: 'POST' };
|
||||
return fetch(BREWPI_URL + pathname, options)
|
||||
.then((resp) => resp.json())
|
||||
.then((response) => json(response));
|
||||
}) satisfies RequestHandler;
|
||||
6
src/routes/brews/+page.server.ts
Normal file
6
src/routes/brews/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import brews from '../../brews.json'
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return { brews };
|
||||
};
|
||||
38
src/routes/brews/+page.svelte
Normal file
38
src/routes/brews/+page.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
export let data;
|
||||
|
||||
const brews = data?.brews || [];
|
||||
const path = (date: string) => '/brews/' + String(date);
|
||||
|
||||
const dateFormat = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
const dateString = (date) => new Date(date * 1000).toLocaleDateString('no-NB', dateFormat);
|
||||
|
||||
</script>
|
||||
|
||||
<main class="page-content">
|
||||
<h1>Past brews</h1>
|
||||
|
||||
<ul>
|
||||
{#each brews as brew}
|
||||
<li><a href="{path(brew.date)}">{ brew.beer.name } av { brew.beer.brewery }</a> - {dateString(brew.date)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</main>
|
||||
|
||||
<style lang="scss">
|
||||
main.page-content {
|
||||
|
||||
ul {
|
||||
margin-left: 1.2em;
|
||||
}
|
||||
|
||||
ul li {
|
||||
list-style-type: disc;
|
||||
line-height: 1.5;
|
||||
|
||||
a {
|
||||
color: #19A786;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
0
src/routes/brews/[date]/+error.svelte
Normal file
0
src/routes/brews/[date]/+error.svelte
Normal file
14
src/routes/brews/[date]/+page.server.ts
Normal file
14
src/routes/brews/[date]/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import brews from '../../../brews.json';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (({ params }) => {
|
||||
const { date } = params;
|
||||
const brew = brews.find((b) => b?.date === date);
|
||||
|
||||
if (!brew) {
|
||||
throw error(404, 'Brew not found');
|
||||
}
|
||||
|
||||
return { brew };
|
||||
}) satisfies PageLoad;
|
||||
182
src/routes/brews/[date]/+page.svelte
Normal file
182
src/routes/brews/[date]/+page.svelte
Normal file
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
let height: number;
|
||||
|
||||
// let brew = {
|
||||
// recipe: 'https://docs.google.com/document/d/1FL7ibXxW1r_zFNLK338pyjfMiCCaTOi2fzuMoInA3dQ',
|
||||
// bryggselv: 'https://www.bryggselv.no/finest/105932/kinn-kveldsbris-allgrain-ølsett-25-liter',
|
||||
// untapped: 'https://untappd.com/b/kinn-bryggeri-kveldsbris/695024'
|
||||
// }
|
||||
|
||||
export let data;
|
||||
let brew = data.brew;
|
||||
|
||||
const dateFormat = { weekday: 'long', year: 'numeric', month: 'short', day: 'numeric' };
|
||||
const dateString = new Date(Number(brew.date * 1000)).toLocaleDateString('no-NB', dateFormat);
|
||||
|
||||
const wizards = brew.by.join(', ');
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="desktop-only image-container" style="height: {height}px">
|
||||
<img src="/images/{ brew.image }" alt="Tuborg Sommerøl" aria-label="Tuborg Sommerøl" />
|
||||
</div>
|
||||
|
||||
<div class="beer-container" bind:clientHeight="{height}">
|
||||
<h1>{brew.beer.name}</h1>
|
||||
|
||||
<div class="links">
|
||||
<a href="{brew.recipe}" target="_blank" rel="noreferrer">Recipe</a>
|
||||
<a href="{brew.order_page}" target="_blank" rel="noreferrer">Bryggselv</a>
|
||||
<a href="{brew.untapped}" target="_blank" rel="noreferrer">Untapped</a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>Brygget:</b></td>
|
||||
<td>{ dateString }</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Laget av:</b></td>
|
||||
<td>{ wizards }</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Kategori:</b></td>
|
||||
<td>{ brew.beer.category }</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><b>Alkoholprosent:</b></td>
|
||||
<td>~ { brew.abv }%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="mobile-only image-container">
|
||||
<img src="/images/{ brew.image }" alt="Tuborg Sommerøl" aria-label="Tuborg Sommerøl" />
|
||||
</div>
|
||||
|
||||
<h3>Historie</h3>
|
||||
<p>
|
||||
I 1873 ble Tuborg Bryggeri grunnlagt av Carl Frederik Tietgen på Hellerud i Danmark. I 1970
|
||||
ble Tuborg Bryggeri en del av Carlsberg.
|
||||
</p>
|
||||
|
||||
<h3>Smak</h3>
|
||||
<p>
|
||||
Tuborg Sommerøl er en nordisk pilsner med en svært lys strågul farge. Aromaen er preget av
|
||||
fruktighet fra gjær, noter av blomster fra humle og lette noter av halm og honning fra malt.
|
||||
Ølet har en svært lett karakter med en lett maltsødme som er godt balansert mot en lav
|
||||
bitterhet.
|
||||
</p>
|
||||
|
||||
<h3>Mat</h3>
|
||||
<p>
|
||||
Tuborg Sommerøl egner seg godt til lys sommermat som pizza, pastaretter, salat, fisk og
|
||||
skalldyr.
|
||||
</p>
|
||||
|
||||
<p>Bruk av alkohol kan gi ulike skadevirkninger. Nærmere informasjon finner du her.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../../../styles/media-queries.scss';
|
||||
|
||||
section {
|
||||
@import url('https://fonts.googleapis.com/css2?family=Epilogue:wght@200;300;400;500;600;700;800&display=swap');
|
||||
font-family: 'Epilogue', sans-serif;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 1px;
|
||||
background-color: #93a4a0;
|
||||
padding: 3rem 1rem;
|
||||
|
||||
@include tablet {
|
||||
width: 33.33%;
|
||||
float: left;
|
||||
padding: 5rem 1rem;
|
||||
}
|
||||
|
||||
@include mobile {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: top;
|
||||
width: calc(100% - 3rem);
|
||||
}
|
||||
}
|
||||
|
||||
.beer-container {
|
||||
background-color: rgba(215, 224, 223, 0.6);
|
||||
padding: 2rem 1rem;
|
||||
|
||||
@include tablet {
|
||||
float: left;
|
||||
width: 66.66%;
|
||||
padding: 4rem 3rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 2.4rem;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
a {
|
||||
position: relative;
|
||||
|
||||
&:not(&:first-of-type) {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
a::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
bottom: 1.5px;
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
max-width: 550px;
|
||||
list-style: none;
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 1px solid #bdc8ca;
|
||||
}
|
||||
|
||||
td {
|
||||
border-top: 1px solid #bdc8ca;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.2;
|
||||
font-weight: 300;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
src/routes/graphs/+page.server.ts
Normal file
38
src/routes/graphs/+page.server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { fetchTemperature, fetchHumidity, fetchPressure } from '$lib/graphQueryGenerator';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type IChartFrame from '$lib/interfaces/IChartFrame';
|
||||
|
||||
let DEFAULT_MINUTES = 10080;
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const temperatureData: IChartFrame[] = await getTemp(DEFAULT_MINUTES, fetch);
|
||||
const humidityData: IChartFrame[] = await getHumidity(DEFAULT_MINUTES, fetch);
|
||||
const pressureData: IChartFrame[] = await getPressure(DEFAULT_MINUTES, fetch);
|
||||
|
||||
return {
|
||||
temperatureData,
|
||||
humidityData,
|
||||
pressureData,
|
||||
DEFAULT_MINUTES
|
||||
};
|
||||
};
|
||||
|
||||
function getSensor(func: Function, minutes: number, fetch: Function) {
|
||||
const from: Date = new Date();
|
||||
const to = new Date(from.getTime() - minutes * 60 * 1000);
|
||||
const size = 40;
|
||||
|
||||
return func(from, to, size, fetch);
|
||||
}
|
||||
|
||||
function getTemp(minutes: number, fetch: Function): IChartFrame[] {
|
||||
return getSensor(fetchTemperature, minutes, fetch);
|
||||
}
|
||||
|
||||
function getHumidity(minutes: number, fetch: Function): IChartFrame[] {
|
||||
return getSensor(fetchHumidity, minutes, fetch);
|
||||
}
|
||||
|
||||
function getPressure(minutes: number, fetch: Function): IChartFrame[] {
|
||||
return getSensor(fetchPressure, minutes, fetch);
|
||||
}
|
||||
128
src/routes/graphs/+page.svelte
Normal file
128
src/routes/graphs/+page.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchTemperature, fetchHumidity, fetchPressure } from '$lib/graphQueryGenerator';
|
||||
import Graph from '$lib/components/Graph.svelte';
|
||||
import type IChartFrame from '$lib/interfaces/IChartFrame';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData
|
||||
|
||||
let temperatureData: IChartFrame[] = data?.temperatureData;
|
||||
let humidityData: IChartFrame[] = data?.temperatureData;
|
||||
let pressureData: IChartFrame[] = data?.temperatureData;
|
||||
let DEFAULT_MINUTES: number = data?.DEFAULT_MINUTES
|
||||
let minutes: number = DEFAULT_MINUTES;
|
||||
|
||||
const buttonMinutes = [{
|
||||
value: 15,
|
||||
name: 'Last 15 minutes'
|
||||
}, {
|
||||
value: 60,
|
||||
name: 'Last hour'
|
||||
}, {
|
||||
value: 1440,
|
||||
name: 'Last day'
|
||||
}, {
|
||||
value: 10080,
|
||||
name: 'Last week'
|
||||
}, {
|
||||
value: 43200,
|
||||
name: 'Last month'
|
||||
}, {
|
||||
value: 129600,
|
||||
name: 'Last 3 months'
|
||||
}, {
|
||||
value: 259200,
|
||||
name: 'Last 6 months'
|
||||
}, {
|
||||
value: 518400,
|
||||
name: 'Last year',
|
||||
}]
|
||||
|
||||
function reload(mins: number) {
|
||||
minutes = mins
|
||||
const from: Date = new Date();
|
||||
const to = new Date(from.getTime() - minutes * 60 * 1000);
|
||||
const size = 40;
|
||||
|
||||
fetchTemperature(from, to, size, window.fetch).then((resp) => (temperatureData = resp));
|
||||
fetchHumidity(from, to, size, window.fetch).then((resp) => (humidityData = resp));
|
||||
fetchPressure(from, to, size, window.fetch).then((resp) => (pressureData = resp));
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- <h1>Server: {emoji.emoji}</h1> -->
|
||||
|
||||
<input type="number" bind:value={minutes} on:input={() => reload(minutes)} />
|
||||
|
||||
<div class="button-wrapper">
|
||||
{#each buttonMinutes as button}
|
||||
<button on:click={() => reload(button.value)} class="{button.value === minutes ? 'selected' : ''}">{ button.name }</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<section class="graphs">
|
||||
{#if temperatureData}
|
||||
<div class="graphWrapper">
|
||||
<Graph dataFrames={temperatureData} name="Temperature" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if humidityData}
|
||||
<div class="graphWrapper">
|
||||
<Graph dataFrames={humidityData} name="Humidity" beginAtZero={false} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if pressureData}
|
||||
<div class="graphWrapper">
|
||||
<Graph dataFrames={pressureData} name="Pressure" beginAtZero={false} />
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style lang="scss" module="scoped">
|
||||
@import '../../styles/media-queries.scss';
|
||||
|
||||
.graphs {
|
||||
display: grid;
|
||||
grid-gap: 1rem;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
width: 100%;
|
||||
|
||||
@include mobile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.graphWrapper {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.button-wrapper {
|
||||
display: flex;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
border-radius: 2rem;
|
||||
white-space: nowrap;
|
||||
font-size: 1.1rem;
|
||||
border: none;
|
||||
width: content;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.3rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
background: var(--green);
|
||||
transition: background-color var(--color-transition-duration) ease-in-out, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: salmon;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,2 +0,0 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://kit.svelte.dev">kit.svelte.dev</a> to read the documentation</p>
|
||||
32
src/styles/media-queries.scss
Normal file
32
src/styles/media-queries.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
$tablet-width: 1200px;
|
||||
$mobile-width: 768px;
|
||||
|
||||
@mixin tablet {
|
||||
@media (min-width: #{$mobile-width}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin mobile {
|
||||
@media (max-width: #{$mobile-width}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
@mixin desktop {
|
||||
@media (min-width: #{$tablet-width + 1px}) {
|
||||
@content;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
@include mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
@include tablet {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
35
src/styles/variables.css
Normal file
35
src/styles/variables.css
Normal file
@@ -0,0 +1,35 @@
|
||||
:root {
|
||||
--background: white;
|
||||
--backdrop: #f5f5f7;
|
||||
--text-color: black;
|
||||
--red: #ff97a3;
|
||||
--blue: #9ad9ff;
|
||||
--header-height: 200px;
|
||||
|
||||
--color-transition-duration: 0.4s;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: pink;
|
||||
--backdrop: #202124;
|
||||
--text-color: white;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit; /* blue colors for links too */
|
||||
text-decoration: inherit; /* no underline */
|
||||
}
|
||||
|
||||
ul,
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
Reference in New Issue
Block a user