diff options
| author | zberwaldt <17715430+zberwaldt@users.noreply.github.com> | 2024-03-15 22:03:11 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-03-15 22:03:11 -0400 |
| commit | 6f8cfbd6cc3d5adbda38e74013c68e3d4745766d (patch) | |
| tree | b3f045cd06d6622e23441b442e8f3861050ed444 /fe/src/lib | |
| parent | fac21fa0a72d4a7f1a01ccd44e3acf9c90fd95bd (diff) | |
| parent | fd1332a3df191577e91c6d846a8b5db1747099fd (diff) | |
Merge pull request #1 from zberwaldt/staging
Staging to Prod
Diffstat (limited to 'fe/src/lib')
| -rw-r--r-- | fe/src/lib/Card.svelte | 23 | ||||
| -rw-r--r-- | fe/src/lib/Chart.svelte | 63 | ||||
| -rw-r--r-- | fe/src/lib/Column.svelte | 13 | ||||
| -rw-r--r-- | fe/src/lib/DataView.svelte | 228 | ||||
| -rw-r--r-- | fe/src/lib/Layout.svelte | 74 | ||||
| -rw-r--r-- | fe/src/lib/LoginForm.svelte | 86 | ||||
| -rw-r--r-- | fe/src/lib/PreferencesForm.svelte | 119 | ||||
| -rw-r--r-- | fe/src/lib/Table.svelte | 114 | ||||
| -rw-r--r-- | fe/src/lib/forms/AddForm.svelte | 78 |
9 files changed, 798 insertions, 0 deletions
diff --git a/fe/src/lib/Card.svelte b/fe/src/lib/Card.svelte new file mode 100644 index 0000000..cd1e02c --- /dev/null +++ b/fe/src/lib/Card.svelte | |||
| @@ -0,0 +1,23 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | export let title = ""; | ||
| 3 | </script> | ||
| 4 | |||
| 5 | <div class="card"> | ||
| 6 | {#if title} | ||
| 7 | <h2>{title}</h2> | ||
| 8 | {/if} | ||
| 9 | <slot /> | ||
| 10 | </div> | ||
| 11 | |||
| 12 | <style> | ||
| 13 | .card { | ||
| 14 | background: #fff; | ||
| 15 | display: flex; | ||
| 16 | flex-direction: column; | ||
| 17 | align-items: flex-start; | ||
| 18 | border: solid 2px #00000066; | ||
| 19 | border-radius: 0.25em; | ||
| 20 | height: var(--height, fit-content); | ||
| 21 | overflow-y: var(--overflow, initial); | ||
| 22 | } | ||
| 23 | </style> | ||
diff --git a/fe/src/lib/Chart.svelte b/fe/src/lib/Chart.svelte new file mode 100644 index 0000000..b19d932 --- /dev/null +++ b/fe/src/lib/Chart.svelte | |||
| @@ -0,0 +1,63 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { onDestroy } from "svelte"; | ||
| 3 | import ChartJS from "chart.js/auto"; | ||
| 4 | |||
| 5 | export let data; | ||
| 6 | export let labels; | ||
| 7 | export let type = 'bar'; | ||
| 8 | |||
| 9 | let ref: HTMLCanvasElement; | ||
| 10 | let chart | ||
| 11 | |||
| 12 | function setupChart(result) { | ||
| 13 | [labels, data] = result; | ||
| 14 | chart = new ChartJS(ref, { | ||
| 15 | type, | ||
| 16 | data: { | ||
| 17 | labels, | ||
| 18 | datasets: [ | ||
| 19 | { | ||
| 20 | label: "Totals", | ||
| 21 | data, | ||
| 22 | backgroundColor: "rgba(255, 192, 192, 0.2)" | ||
| 23 | } | ||
| 24 | ] | ||
| 25 | }, | ||
| 26 | options: { | ||
| 27 | responsive: true, | ||
| 28 | maintainAspectRatio: false, | ||
| 29 | scales: { | ||
| 30 | y: { | ||
| 31 | suggestedMax: 30, | ||
| 32 | beginAtZero: true, | ||
| 33 | ticks: { | ||
| 34 | autoSkip: true, | ||
| 35 | stepSize: 5 | ||
| 36 | } | ||
| 37 | } | ||
| 38 | }, | ||
| 39 | plugins: { | ||
| 40 | legend: { | ||
| 41 | display: false | ||
| 42 | }, | ||
| 43 | title: { | ||
| 44 | display: true, | ||
| 45 | text: "Weekly Breakdown" | ||
| 46 | }, | ||
| 47 | subtitle: { | ||
| 48 | display: true, | ||
| 49 | text: "Water consumption over the last week", | ||
| 50 | padding: {bottom: 10} | ||
| 51 | } | ||
| 52 | } | ||
| 53 | } | ||
| 54 | }); | ||
| 55 | |||
| 56 | onDestroy(() => { | ||
| 57 | if (chart) chart.destroy(); | ||
| 58 | chart = null; | ||
| 59 | }) | ||
| 60 | } | ||
| 61 | </script> | ||
| 62 | |||
| 63 | <canvas bind:this={ref} /> \ No newline at end of file | ||
diff --git a/fe/src/lib/Column.svelte b/fe/src/lib/Column.svelte new file mode 100644 index 0000000..f036073 --- /dev/null +++ b/fe/src/lib/Column.svelte | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | <div class="column"> | ||
| 2 | <slot /> | ||
| 3 | </div> | ||
| 4 | |||
| 5 | <style> | ||
| 6 | .column { | ||
| 7 | display: flex; | ||
| 8 | flex-direction: column; | ||
| 9 | height: 100%; | ||
| 10 | gap: var(--gap, 32px); | ||
| 11 | width: var(--width, initial); | ||
| 12 | } | ||
| 13 | </style> \ No newline at end of file | ||
diff --git a/fe/src/lib/DataView.svelte b/fe/src/lib/DataView.svelte new file mode 100644 index 0000000..ffc2fe8 --- /dev/null +++ b/fe/src/lib/DataView.svelte | |||
| @@ -0,0 +1,228 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { onDestroy, onMount } from "svelte"; | ||
| 3 | import http from "../http"; | ||
| 4 | import { token } from "../stores/auth"; | ||
| 5 | import { addFormOpen } from "../stores/forms"; | ||
| 6 | import Table from "./Table.svelte"; | ||
| 7 | import ChartJS from "chart.js/auto"; | ||
| 8 | import Chart from './Chart.svelte' | ||
| 9 | import Card from "./Card.svelte"; | ||
| 10 | import Column from "./Column.svelte"; | ||
| 11 | import AddForm from "./forms/AddForm.svelte"; | ||
| 12 | import { apiURL } from "../utils"; | ||
| 13 | |||
| 14 | let json: Promise<any>; | ||
| 15 | |||
| 16 | let barCanvasRef: HTMLCanvasElement; | ||
| 17 | let lineCanvasRef: HTMLCanvasElement; | ||
| 18 | let barChart: any; | ||
| 19 | let lineChart: any; | ||
| 20 | |||
| 21 | let lastSevenDays: string[]; | ||
| 22 | let lastSevenDaysData: number[]; | ||
| 23 | |||
| 24 | let userTotalsLabels: string[]; | ||
| 25 | let userTotalsData: number[]; | ||
| 26 | |||
| 27 | async function fetchData() { | ||
| 28 | const res = await fetch(apiURL("stats"), { | ||
| 29 | method: "GET", | ||
| 30 | headers: { | ||
| 31 | Authorization: `Bearer ${$token}` | ||
| 32 | } | ||
| 33 | }); | ||
| 34 | if (res.ok) { | ||
| 35 | json = res.json(); | ||
| 36 | } else { | ||
| 37 | throw new Error("There was a problem with your request"); | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | async function fetchDailyUserStatistics() { | ||
| 42 | const res = await fetch(apiURL("stats/daily"), { | ||
| 43 | method: "GET", | ||
| 44 | headers: { | ||
| 45 | Authorization: `Bearer ${$token}` | ||
| 46 | } | ||
| 47 | }); | ||
| 48 | |||
| 49 | if (res.ok) { | ||
| 50 | const json = await res.json(); | ||
| 51 | let labels = json.map((d: any) => d.name); | ||
| 52 | let data = json.map((d: any) => d.total); | ||
| 53 | return [labels, data]; | ||
| 54 | } else { | ||
| 55 | throw new Error("There was a problem with your request"); | ||
| 56 | } | ||
| 57 | |||
| 58 | } | ||
| 59 | |||
| 60 | async function fetchWeeklyTotals() { | ||
| 61 | const res = await fetch(apiURL("stats/weekly"), { | ||
| 62 | method: "GET", | ||
| 63 | headers: { | ||
| 64 | Authorization: `Bearer ${$token}` | ||
| 65 | } | ||
| 66 | }); | ||
| 67 | |||
| 68 | if (res.ok) { | ||
| 69 | const json = await res.json(); | ||
| 70 | let labels = json.map((d: any) => d.date); | ||
| 71 | let data = json.map((d: any) => d.total); | ||
| 72 | return [labels, data]; | ||
| 73 | } else { | ||
| 74 | throw new Error("There was a problem with your request"); | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | function closeDialog() { | ||
| 79 | addFormOpen.set(false); | ||
| 80 | } | ||
| 81 | |||
| 82 | function onStatisticAdd() { | ||
| 83 | closeDialog(); | ||
| 84 | fetchData(); | ||
| 85 | fetchWeeklyTotals().then(updateWeeklyTotalsChart).catch(err => console.error(err)); | ||
| 86 | fetchDailyUserStatistics().then(updateDailyUserTotalsChart).catch(err => console.error(err)); | ||
| 87 | } | ||
| 88 | |||
| 89 | function setupWeeklyTotalsChart(result: any) { | ||
| 90 | [lastSevenDays, lastSevenDaysData] = result; | ||
| 91 | lineChart = new ChartJS(lineCanvasRef, { | ||
| 92 | type: "line", | ||
| 93 | data: { | ||
| 94 | labels: lastSevenDays, | ||
| 95 | datasets: [ | ||
| 96 | { | ||
| 97 | label: "Totals", | ||
| 98 | data: lastSevenDaysData, | ||
| 99 | backgroundColor: "rgba(255, 192, 192, 0.2)" | ||
| 100 | } | ||
| 101 | ] | ||
| 102 | }, | ||
| 103 | options: { | ||
| 104 | responsive: true, | ||
| 105 | maintainAspectRatio: false, | ||
| 106 | scales: { | ||
| 107 | y: { | ||
| 108 | suggestedMax: 30, | ||
| 109 | beginAtZero: true, | ||
| 110 | ticks: { | ||
| 111 | autoSkip: true, | ||
| 112 | stepSize: 5 | ||
| 113 | } | ||
| 114 | } | ||
| 115 | }, | ||
| 116 | plugins: { | ||
| 117 | legend: { | ||
| 118 | display: false | ||
| 119 | }, | ||
| 120 | title: { | ||
| 121 | display: true, | ||
| 122 | text: "Weekly Breakdown" | ||
| 123 | }, | ||
| 124 | subtitle: { | ||
| 125 | display: true, | ||
| 126 | text: "Water consumption over the last week", | ||
| 127 | padding: {bottom: 10} | ||
| 128 | } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | }); | ||
| 132 | } | ||
| 133 | |||
| 134 | function setupDailyUserTotalsChart(result: any) { | ||
| 135 | [userTotalsLabels, userTotalsData] = result; | ||
| 136 | |||
| 137 | barChart = new ChartJS(barCanvasRef, { | ||
| 138 | type: "bar", | ||
| 139 | data: { | ||
| 140 | labels: userTotalsLabels, | ||
| 141 | datasets: [ | ||
| 142 | { | ||
| 143 | data: userTotalsData, | ||
| 144 | backgroundColor: [ | ||
| 145 | "#330000", | ||
| 146 | "rgba(100, 200, 192, 0.2)" | ||
| 147 | ] | ||
| 148 | } | ||
| 149 | ] | ||
| 150 | }, | ||
| 151 | options: { | ||
| 152 | responsive: true, | ||
| 153 | maintainAspectRatio: false, | ||
| 154 | scales: { | ||
| 155 | y: { | ||
| 156 | beginAtZero: true, | ||
| 157 | suggestedMax: 10, | ||
| 158 | ticks: { | ||
| 159 | autoSkip: true, | ||
| 160 | stepSize: 1 | ||
| 161 | } | ||
| 162 | } | ||
| 163 | }, | ||
| 164 | plugins: { | ||
| 165 | legend: { | ||
| 166 | display: false | ||
| 167 | }, | ||
| 168 | title: { | ||
| 169 | display: true, | ||
| 170 | text: "Daily Total" | ||
| 171 | }, | ||
| 172 | subtitle: { | ||
| 173 | display: true, | ||
| 174 | text: "Water Drank Today", | ||
| 175 | padding: {bottom: 10} | ||
| 176 | } | ||
| 177 | } | ||
| 178 | } | ||
| 179 | }); | ||
| 180 | } | ||
| 181 | |||
| 182 | function updateWeeklyTotalsChart(result: any) { | ||
| 183 | [, lastSevenDaysData] = result; | ||
| 184 | lineChart.data.datasets[0].data = lastSevenDaysData; | ||
| 185 | lineChart.update(); | ||
| 186 | } | ||
| 187 | |||
| 188 | function updateDailyUserTotalsChart(result: any) { | ||
| 189 | [, userTotalsData] = result; | ||
| 190 | barChart.data.datasets[0].data = userTotalsData; | ||
| 191 | barChart.update(); | ||
| 192 | } | ||
| 193 | |||
| 194 | onMount(() => { | ||
| 195 | fetchData(); | ||
| 196 | fetchWeeklyTotals().then(setupWeeklyTotalsChart); | ||
| 197 | fetchDailyUserStatistics().then(setupDailyUserTotalsChart); | ||
| 198 | }); | ||
| 199 | |||
| 200 | onDestroy(() => { | ||
| 201 | if (barChart) barChart.destroy(); | ||
| 202 | if (lineChart) lineChart.destroy(); | ||
| 203 | barChart = null; | ||
| 204 | lineChart = null; | ||
| 205 | }); | ||
| 206 | </script> | ||
| 207 | |||
| 208 | <Column --width="500px"> | ||
| 209 | <Card --height="300px"> | ||
| 210 | <!--<Chart />--> | ||
| 211 | <canvas bind:this={barCanvasRef} /> | ||
| 212 | </Card> | ||
| 213 | <Card --height="300px"> | ||
| 214 | <canvas bind:this={lineCanvasRef} /> | ||
| 215 | </Card> | ||
| 216 | </Column> | ||
| 217 | |||
| 218 | <AddForm open={$addFormOpen} on:submit={onStatisticAdd} on:close={closeDialog} /> | ||
| 219 | <Column> | ||
| 220 | <Card> | ||
| 221 | {#await json then data} | ||
| 222 | <Table {data} nofooter /> | ||
| 223 | {:catch error} | ||
| 224 | <p>{error}</p> | ||
| 225 | {/await} | ||
| 226 | </Card> | ||
| 227 | </Column> | ||
| 228 | <!-- <Chart /> --> | ||
diff --git a/fe/src/lib/Layout.svelte b/fe/src/lib/Layout.svelte new file mode 100644 index 0000000..2728dd3 --- /dev/null +++ b/fe/src/lib/Layout.svelte | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | <script> | ||
| 2 | import { authenticated, token, user, preferences } from "../stores/auth"; | ||
| 3 | import PreferencesForm from "./PreferencesForm.svelte"; | ||
| 4 | import { addFormOpen } from "../stores/forms"; | ||
| 5 | |||
| 6 | const logout = () => { | ||
| 7 | preferences.reset(); | ||
| 8 | user.reset(); | ||
| 9 | token.unauthenticate(); | ||
| 10 | } | ||
| 11 | let preferenceFormOpen = false; | ||
| 12 | |||
| 13 | function showPreferencesDialog() { | ||
| 14 | preferenceFormOpen = true; | ||
| 15 | } | ||
| 16 | |||
| 17 | function closePreferenceDialog() { | ||
| 18 | preferenceFormOpen = false; | ||
| 19 | } | ||
| 20 | |||
| 21 | function showAddDialog() { | ||
| 22 | addFormOpen.set(true); | ||
| 23 | } | ||
| 24 | </script> | ||
| 25 | |||
| 26 | <div class="layout"> | ||
| 27 | {#if $authenticated} | ||
| 28 | <nav> | ||
| 29 | <div> | ||
| 30 | <h1>Water</h1> | ||
| 31 | </div> | ||
| 32 | <div> | ||
| 33 | <button on:click={showAddDialog}>Log Water</button> | ||
| 34 | <button on:click={showPreferencesDialog}>Preference</button> | ||
| 35 | <button on:click={logout}>Logout</button> | ||
| 36 | </div> | ||
| 37 | </nav> | ||
| 38 | <PreferencesForm open={preferenceFormOpen} on:close={closePreferenceDialog} /> | ||
| 39 | {/if} | ||
| 40 | <div id="content"> | ||
| 41 | <slot /> | ||
| 42 | </div> | ||
| 43 | </div> | ||
| 44 | |||
| 45 | <style> | ||
| 46 | .layout { | ||
| 47 | height: 100vh; | ||
| 48 | } | ||
| 49 | |||
| 50 | nav { | ||
| 51 | display: flex; | ||
| 52 | flex-direction: row; | ||
| 53 | align-items: center; | ||
| 54 | justify-content: space-between; | ||
| 55 | height: 64px; | ||
| 56 | padding: 0 2em; | ||
| 57 | } | ||
| 58 | |||
| 59 | nav div { | ||
| 60 | width: fit-content; | ||
| 61 | } | ||
| 62 | |||
| 63 | nav div h1 { | ||
| 64 | font-size: 1.75em; | ||
| 65 | } | ||
| 66 | |||
| 67 | #content { | ||
| 68 | display: flex; | ||
| 69 | flex-direction: row; | ||
| 70 | justify-content: center; | ||
| 71 | gap: 2em; | ||
| 72 | margin-top: 4em; | ||
| 73 | } | ||
| 74 | </style> | ||
diff --git a/fe/src/lib/LoginForm.svelte b/fe/src/lib/LoginForm.svelte new file mode 100644 index 0000000..cf5febf --- /dev/null +++ b/fe/src/lib/LoginForm.svelte | |||
| @@ -0,0 +1,86 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { token, user, preferences } from "../stores/auth"; | ||
| 3 | import Card from "./Card.svelte"; | ||
| 4 | import { apiURL } from "../utils"; | ||
| 5 | |||
| 6 | let credentials: CredentialObject = { | ||
| 7 | username: "", | ||
| 8 | password: "", | ||
| 9 | }; | ||
| 10 | |||
| 11 | let error: string | null = null; | ||
| 12 | |||
| 13 | interface CredentialObject { | ||
| 14 | username: string; | ||
| 15 | password: string; | ||
| 16 | } | ||
| 17 | |||
| 18 | function prepareCredentials({ | ||
| 19 | username, | ||
| 20 | password, | ||
| 21 | }: CredentialObject): string { | ||
| 22 | return btoa(`${username}:${password}`); | ||
| 23 | } | ||
| 24 | |||
| 25 | async function onSubmit(e: Event) { | ||
| 26 | if (!credentials.username || !credentials.password) { | ||
| 27 | error = "please enter your username and password"; | ||
| 28 | return; | ||
| 29 | } | ||
| 30 | const auth = prepareCredentials(credentials); | ||
| 31 | |||
| 32 | const response = await fetch(apiURL("auth"), { | ||
| 33 | method: "POST", | ||
| 34 | headers: { | ||
| 35 | Authorization: `Basic ${auth}`, | ||
| 36 | }, | ||
| 37 | }); | ||
| 38 | |||
| 39 | if (response.status === 401) { | ||
| 40 | error = "Your username or password is wrong"; | ||
| 41 | return; | ||
| 42 | } | ||
| 43 | |||
| 44 | if (response.ok) { | ||
| 45 | const { | ||
| 46 | token: apiToken, | ||
| 47 | user: userData, | ||
| 48 | preferences: userPreferences, | ||
| 49 | } = await response.json(); | ||
| 50 | user.setUser(userData); | ||
| 51 | preferences.setPreference(userPreferences); | ||
| 52 | token.authenticate(apiToken); | ||
| 53 | } | ||
| 54 | |||
| 55 | error = null; | ||
| 56 | } | ||
| 57 | </script> | ||
| 58 | |||
| 59 | <Card> | ||
| 60 | <form class="form" on:submit|preventDefault={onSubmit}> | ||
| 61 | <div class="form input group"> | ||
| 62 | <label for="username">Username</label> | ||
| 63 | <input | ||
| 64 | bind:value={credentials.username} | ||
| 65 | id="username" | ||
| 66 | name="username" | ||
| 67 | type="text" | ||
| 68 | autocomplete="username" | ||
| 69 | /> | ||
| 70 | </div> | ||
| 71 | <div class="form input group"> | ||
| 72 | <label for="password">Password</label> | ||
| 73 | <input | ||
| 74 | bind:value={credentials.password} | ||
| 75 | id="password" | ||
| 76 | name="password" | ||
| 77 | type="password" | ||
| 78 | autocomplete="current-password" | ||
| 79 | /> | ||
| 80 | </div> | ||
| 81 | {#if error} | ||
| 82 | <p class="error">{error}</p> | ||
| 83 | {/if} | ||
| 84 | <button type="submit">Log in</button> | ||
| 85 | </form> | ||
| 86 | </Card> | ||
diff --git a/fe/src/lib/PreferencesForm.svelte b/fe/src/lib/PreferencesForm.svelte new file mode 100644 index 0000000..74b8a63 --- /dev/null +++ b/fe/src/lib/PreferencesForm.svelte | |||
| @@ -0,0 +1,119 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { user, preferences, token } from "../stores/auth"; | ||
| 3 | import { createEventDispatcher, onDestroy, onMount } from "svelte"; | ||
| 4 | import type { User } from "../types"; | ||
| 5 | import { apiURL } from "../utils"; | ||
| 6 | |||
| 7 | export let open: boolean; | ||
| 8 | |||
| 9 | let sizes: Array<any>; | ||
| 10 | let selectedSize: number = 1; | ||
| 11 | let color: string = "#000000"; | ||
| 12 | |||
| 13 | const dispatch = createEventDispatcher(); | ||
| 14 | |||
| 15 | const unsubscribe = preferences.subscribe( | ||
| 16 | (value: any) => { | ||
| 17 | if (value) { | ||
| 18 | color = value.color; | ||
| 19 | selectedSize = value.size_id; | ||
| 20 | } | ||
| 21 | }, | ||
| 22 | ); | ||
| 23 | |||
| 24 | function closeDialog() { | ||
| 25 | dispatch("close"); | ||
| 26 | } | ||
| 27 | |||
| 28 | async function updateUserPreferences() { | ||
| 29 | const res = await fetch(apiURL("user/preferences"), { | ||
| 30 | method: "PATCH", | ||
| 31 | headers: { | ||
| 32 | Authorization: `Bearer ${$token}`, | ||
| 33 | }, | ||
| 34 | body: JSON.stringify($preferences), | ||
| 35 | }); | ||
| 36 | } | ||
| 37 | |||
| 38 | async function getUserPreferences() { | ||
| 39 | const res = await fetch(apiURL(`user/${($user as User)!.id}/preferences`), | ||
| 40 | { | ||
| 41 | method: "GET", | ||
| 42 | headers: { | ||
| 43 | Authorization: `Bearer ${$token}`, | ||
| 44 | }, | ||
| 45 | }, | ||
| 46 | ); | ||
| 47 | const updatePreferences = await res.json(); | ||
| 48 | preferences.set(updatePreferences); | ||
| 49 | } | ||
| 50 | |||
| 51 | async function onPreferencesSave(): Promise<void> { | ||
| 52 | preferences.update((value) => ({ | ||
| 53 | ...value!, | ||
| 54 | size_id: selectedSize, | ||
| 55 | color: color, | ||
| 56 | })); | ||
| 57 | |||
| 58 | await updateUserPreferences(); | ||
| 59 | await getUserPreferences(); | ||
| 60 | |||
| 61 | dispatch("close"); | ||
| 62 | } | ||
| 63 | |||
| 64 | onMount(() => { | ||
| 65 | fetch(apiURL("sizes"), { | ||
| 66 | method: "GET", | ||
| 67 | headers: { | ||
| 68 | Authorization: `Bearer ${$token}`, | ||
| 69 | }, | ||
| 70 | }) | ||
| 71 | .then((res) => res.json()) | ||
| 72 | .then((val) => (sizes = val)); | ||
| 73 | }); | ||
| 74 | |||
| 75 | onDestroy(() => { | ||
| 76 | unsubscribe(); | ||
| 77 | }); | ||
| 78 | </script> | ||
| 79 | |||
| 80 | <dialog {open} on:submit|preventDefault={onPreferencesSave}> | ||
| 81 | <h2>User Preferences</h2> | ||
| 82 | <form method="dialog"> | ||
| 83 | <div class="form input group"> | ||
| 84 | <label for="color">Color</label> | ||
| 85 | <input | ||
| 86 | id="color" | ||
| 87 | name="color" | ||
| 88 | type="color" | ||
| 89 | bind:value={color} | ||
| 90 | /> | ||
| 91 | </div> | ||
| 92 | <div class="form input group"> | ||
| 93 | <label for="size">Bottle Size</label> | ||
| 94 | <select | ||
| 95 | bind:value={selectedSize} | ||
| 96 | > | ||
| 97 | {#if sizes} | ||
| 98 | {#each sizes as size} | ||
| 99 | <option value={size.id}>{size.size} {size.unit}</option> | ||
| 100 | {/each} | ||
| 101 | {/if} | ||
| 102 | </select> | ||
| 103 | </div> | ||
| 104 | <button on:click={closeDialog}>Cancel</button> | ||
| 105 | <button type="submit">Save</button> | ||
| 106 | </form> | ||
| 107 | </dialog> | ||
| 108 | |||
| 109 | <style> | ||
| 110 | dialog { | ||
| 111 | background: white; | ||
| 112 | color: black; | ||
| 113 | } | ||
| 114 | |||
| 115 | input[type="color"] { | ||
| 116 | width: 4em; | ||
| 117 | height: 4em; | ||
| 118 | } | ||
| 119 | </style> | ||
diff --git a/fe/src/lib/Table.svelte b/fe/src/lib/Table.svelte new file mode 100644 index 0000000..621157e --- /dev/null +++ b/fe/src/lib/Table.svelte | |||
| @@ -0,0 +1,114 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | export let data: Array<any> | undefined = undefined; | ||
| 3 | export let nofooter: boolean = false; | ||
| 4 | export let noheader: boolean = false; | ||
| 5 | export let omit: string[] = ["id"]; | ||
| 6 | export let title: string | undefined = undefined; | ||
| 7 | |||
| 8 | export let sortBy: string = 'date'; | ||
| 9 | |||
| 10 | type SortComparator = (a, b) => number | ||
| 11 | |||
| 12 | function getDataKeys(data: any[]): string[] { | ||
| 13 | if (!data || data.length === 0) return []; | ||
| 14 | return Object.keys(data[0]) | ||
| 15 | .map((k) => k.split("_").join(" ")) | ||
| 16 | .filter((k) => !omit.includes(k)); | ||
| 17 | } | ||
| 18 | |||
| 19 | function getRow(row: Record<string, any>): Array<any> { | ||
| 20 | return Object.entries(row).filter((r) => !omit.includes(r[0])); | ||
| 21 | } | ||
| 22 | |||
| 23 | function sort(arr: Array<Record<string, any>>, fn: SortComparator = (a , b) => new Date(b[sortBy]) - new Date(a[sortBy])) { | ||
| 24 | return arr.sort(fn) | ||
| 25 | } | ||
| 26 | |||
| 27 | const formatter = new Intl.DateTimeFormat("en", { | ||
| 28 | year: "numeric", | ||
| 29 | month: "numeric", | ||
| 30 | day: "numeric", | ||
| 31 | hour: "numeric", | ||
| 32 | minute: "2-digit", | ||
| 33 | second: "2-digit", | ||
| 34 | timeZone: "America/New_York" | ||
| 35 | }); | ||
| 36 | |||
| 37 | function formatDatum([key, value]: any[]) { | ||
| 38 | if (key === "date") { | ||
| 39 | const parsedDate = new Date(value); | ||
| 40 | return formatter.format(parsedDate); | ||
| 41 | } | ||
| 42 | |||
| 43 | if (key === "user") { | ||
| 44 | return value["name"]; | ||
| 45 | } | ||
| 46 | |||
| 47 | return value; | ||
| 48 | } | ||
| 49 | </script> | ||
| 50 | |||
| 51 | <table> | ||
| 52 | {#if title} | ||
| 53 | <h2>{title}</h2> | ||
| 54 | {/if} | ||
| 55 | {#if !noheader && data} | ||
| 56 | <thead> | ||
| 57 | <tr> | ||
| 58 | {#each getDataKeys(data) as header} | ||
| 59 | <th>{header}</th> | ||
| 60 | {/each} | ||
| 61 | </tr> | ||
| 62 | </thead> | ||
| 63 | {/if} | ||
| 64 | <tbody> | ||
| 65 | {#if data} | ||
| 66 | {#each sort(data) as row} | ||
| 67 | <tr> | ||
| 68 | {#each getRow(row) as datum} | ||
| 69 | <td>{formatDatum(datum)}</td> | ||
| 70 | {/each} | ||
| 71 | </tr> | ||
| 72 | {/each} | ||
| 73 | {:else} | ||
| 74 | <tr> There is not data.</tr> | ||
| 75 | {/if} | ||
| 76 | </tbody> | ||
| 77 | {#if !nofooter} | ||
| 78 | <slot name="footer"> | ||
| 79 | <tfoot> | ||
| 80 | <tr> | ||
| 81 | <td>Table Footer</td> | ||
| 82 | </tr> | ||
| 83 | </tfoot> | ||
| 84 | </slot> | ||
| 85 | {/if} | ||
| 86 | </table> | ||
| 87 | |||
| 88 | <style> | ||
| 89 | table { | ||
| 90 | padding: 16px; | ||
| 91 | margin: 8px; | ||
| 92 | border: solid 1px black; | ||
| 93 | border-collapse: collapse; | ||
| 94 | overflow-y: hidden; | ||
| 95 | } | ||
| 96 | |||
| 97 | th { | ||
| 98 | text-transform: capitalize; | ||
| 99 | } | ||
| 100 | |||
| 101 | thead tr { | ||
| 102 | background: rgba(0, 0, 23, 0.34); | ||
| 103 | } | ||
| 104 | |||
| 105 | tbody tr:nth-child(odd) { | ||
| 106 | background: rgba(0, 0, 23, 0.14); | ||
| 107 | } | ||
| 108 | |||
| 109 | th, | ||
| 110 | td { | ||
| 111 | padding: 1em; | ||
| 112 | border: 1px solid rgba(0, 0, 0, 1); | ||
| 113 | } | ||
| 114 | </style> | ||
diff --git a/fe/src/lib/forms/AddForm.svelte b/fe/src/lib/forms/AddForm.svelte new file mode 100644 index 0000000..f85cce6 --- /dev/null +++ b/fe/src/lib/forms/AddForm.svelte | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | <script lang='ts'> | ||
| 2 | import { createEventDispatcher } from "svelte"; | ||
| 3 | import { token, user } from "../../stores/auth"; | ||
| 4 | import type { Statistic } from "../../types"; | ||
| 5 | import { apiURL } from "../../utils"; | ||
| 6 | |||
| 7 | export let open: boolean; | ||
| 8 | |||
| 9 | const dispatch = createEventDispatcher(); | ||
| 10 | |||
| 11 | const statistic: Statistic = newStatistic(); | ||
| 12 | |||
| 13 | function newStatistic(): Statistic { | ||
| 14 | let now = new Date(), | ||
| 15 | month, | ||
| 16 | day, | ||
| 17 | year; | ||
| 18 | |||
| 19 | month = `${now.getMonth() + 1}`; | ||
| 20 | day = `${now.getDate()}`; | ||
| 21 | year = now.getFullYear(); | ||
| 22 | if (month.length < 2) month = "0" + month; | ||
| 23 | if (day.length < 2) day = "0" + day; | ||
| 24 | |||
| 25 | const date = [year, month, day].join("-"); | ||
| 26 | |||
| 27 | return { | ||
| 28 | user_id: $user!.uuid, | ||
| 29 | date, | ||
| 30 | quantity: 1 | ||
| 31 | }; | ||
| 32 | } | ||
| 33 | |||
| 34 | function closeDialog() { | ||
| 35 | dispatch("close"); | ||
| 36 | } | ||
| 37 | |||
| 38 | async function handleSubmitStat() | ||
| 39 | { | ||
| 40 | const { date, quantity } = statistic; | ||
| 41 | await fetch(apiURL("stats"), { | ||
| 42 | method: "POST", | ||
| 43 | headers: { | ||
| 44 | Authorization: `Bearer ${$token}` | ||
| 45 | }, | ||
| 46 | body: JSON.stringify({ | ||
| 47 | date: new Date(date), | ||
| 48 | user_id: 2, | ||
| 49 | quantity | ||
| 50 | }) | ||
| 51 | }); | ||
| 52 | dispatch("submit"); | ||
| 53 | } | ||
| 54 | |||
| 55 | </script> | ||
| 56 | |||
| 57 | <dialog {open} on:submit={handleSubmitStat}> | ||
| 58 | <h2>Add Water</h2> | ||
| 59 | <form method="dialog"> | ||
| 60 | <div class="form input group"> | ||
| 61 | <label for="date">Date:</label> | ||
| 62 | <input bind:value={statistic.date} id="date" name="date" type="date" /> | ||
| 63 | </div> | ||
| 64 | <div class="form input group"> | ||
| 65 | <label for="quantity">Quantity:</label> | ||
| 66 | <input | ||
| 67 | bind:value={statistic.quantity} | ||
| 68 | id="quantity" | ||
| 69 | name="quantity" | ||
| 70 | type="number" | ||
| 71 | min="0" | ||
| 72 | autocomplete="off" | ||
| 73 | /> | ||
| 74 | </div> | ||
| 75 | <button on:click={closeDialog}>Cancel</button> | ||
| 76 | <button type="submit">Submit</button> | ||
| 77 | </form> | ||
| 78 | </dialog> \ No newline at end of file | ||
