diff options
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 | ||