aboutsummaryrefslogtreecommitdiff
path: root/fe/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'fe/src/lib')
-rw-r--r--fe/src/lib/Card.svelte23
-rw-r--r--fe/src/lib/Chart.svelte63
-rw-r--r--fe/src/lib/Column.svelte13
-rw-r--r--fe/src/lib/DataView.svelte228
-rw-r--r--fe/src/lib/Layout.svelte74
-rw-r--r--fe/src/lib/LoginForm.svelte86
-rw-r--r--fe/src/lib/PreferencesForm.svelte119
-rw-r--r--fe/src/lib/Table.svelte114
-rw-r--r--fe/src/lib/forms/AddForm.svelte78
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">
2import { onDestroy } from "svelte";
3import ChartJS from "chart.js/auto";
4
5export let data;
6export let labels;
7export let type = 'bar';
8
9let ref: HTMLCanvasElement;
10let chart
11
12function 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