aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorZach Berwaldt <zberwaldt@tutamail.com>2024-03-02 16:52:55 -0500
committerZach Berwaldt <zberwaldt@tutamail.com>2024-03-02 16:52:55 -0500
commitcf2113e77edabf8e3a632c7b76c769752039ba88 (patch)
tree874872f22aa63df532769de62119816748b167f8
parent326f186d67017f87e631a1fbcdf3f184cbc42d7d (diff)
feat: Add API logging
Add logging to the API to keep track of specific requests and headers. Also added CORS middleware to handle OPTIONS requests. --- The commit adds logging functionality to the API and includes a middleware function to handle CORS OPTIONS requests. This will allow us to track specific requests and headers received by the API. [API/main.go](/api/main.go): - Added import for the 'log' package - Added logging statements to print the request headers and "_I am here_" message - Removed unnecessary newlines and comments [fe/src/app.css](/fe/src/app.css): - Added a new style for button hover effects [fe/src/lib/Card.svelte](/fe/src/lib/Card.svelte): - Added a new `height` prop to the Card component [fe/src/lib/Column.svelte](/fe/src/lib/Column.svelte): - Added a new CSS class for column layout - Set the width and gap using CSS variables [fe/src/lib/DataView.svelte](/fe/src/lib/DataView.svelte): - Updated the 'fetchData' function to also fetch 'totals' and 'userStats' data - Added canvas references and chart variables for bar and line charts - Added a new 'getLastSevenDays' function to calculate the labels for the charts - Updated the 'onMount' function to initialize the bar and line charts using the canvas references and data - Updated the 'onDestroy' function to destroy the bar and line charts - Added a new 'addFormOpen' store and imported it - Added a new 'onClick' handler for the Add button to open the AddForm modal - Updated the layout and added Card components to display the bar and line charts and the JSON data - Added a new 'fetchTotals' function to fetch data for the 'totals' section - Added a new 'fetchStatsForUser' function to fetch data for the 'userStats' section [fe/src/lib/Layout.svelte](/fe/src/lib/Layout.svelte): - Added a new 'preferenceFormOpen' variable and initialized it to 'false' - Added a new 'showPreferencesDialog' function to set 'preferenceFormOpen' to 'true' - Added a new 'closePreferenceDialog' function to set 'preferenceFormOpen' to 'false' - Added a new 'showAddDialog' function to open the AddForm modal
-rw-r--r--api/main.go384
-rw-r--r--fe/src/app.css101
-rw-r--r--fe/src/lib/Card.svelte30
-rw-r--r--fe/src/lib/Column.svelte13
-rw-r--r--fe/src/lib/DataView.svelte129
-rw-r--r--fe/src/lib/Layout.svelte116
-rw-r--r--fe/src/lib/Table.svelte172
-rw-r--r--fe/src/stores/forms.ts6
8 files changed, 574 insertions, 377 deletions
diff --git a/api/main.go b/api/main.go
index 91b7929..57feb09 100644
--- a/api/main.go
+++ b/api/main.go
@@ -2,193 +2,267 @@ package main
2 2
3import ( 3import (
4 "net/http" 4 "net/http"
5 "crypto/rand" 5 "crypto/rand"
6 "encoding/base64" 6 "encoding/base64"
7 "database/sql" 7 "database/sql"
8 "strings" 8 "strings"
9 "errors" 9 "errors"
10 "log"
10 11
11 "github.com/gin-gonic/gin" 12 "github.com/gin-gonic/gin"
12 _ "github.com/mattn/go-sqlite3" 13 _ "github.com/mattn/go-sqlite3"
13 "golang.org/x/crypto/bcrypt" 14 "golang.org/x/crypto/bcrypt"
14 "water/api/lib" 15 "water/api/lib"
15) 16)
16 17
17func CORSMiddleware() gin.HandlerFunc { 18func CORSMiddleware() gin.HandlerFunc {
18 return func(c *gin.Context) { 19 return func(c *gin.Context) {
19 c.Writer.Header().Set("Access-Control-Allow-Origin", "*") 20 c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
20 c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") 21 c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
21 c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") 22 c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
22 c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") 23 c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
23 24
24 if c.Request.Method == "OPTIONS" { 25 log.Println("I am here")
25 c.AbortWithStatus(204) 26
26 return 27 if c.Request.Method == "OPTIONS" {
27 } 28 log.Println(c.Request.Header)
28 29 c.AbortWithStatus(204)
29 c.Next() 30 return
30 } 31 }
32
33 log.Println(c.Request.Header)
34 c.Next()
35 }
31} 36}
32 37
33// generatToken will g 38// generatToken will g
34func generateToken() string { 39func generateToken() string {
35 token := make([]byte, 32) 40 token := make([]byte, 32)
36 rand.Read(token) 41 rand.Read(token)
37 return base64.StdEncoding.EncodeToString(token) 42 return base64.StdEncoding.EncodeToString(token)
38} 43}
39 44
40func establishDBConnection() *sql.DB { 45func establishDBConnection() *sql.DB {
41 db, err := sql.Open("sqlite3", "../db/water.sqlite3") 46 db, err := sql.Open("sqlite3", "../db/water.sqlite3")
42 if err != nil { 47 if err != nil {
43 panic(err) 48 panic(err)
44 } 49 }
45 return db 50 return db
46} 51}
47 52
48
49func checkForTokenInContext(c *gin.Context) (string, error) { 53func checkForTokenInContext(c *gin.Context) (string, error) {
50 authorizationHeader := c.GetHeader("Authorization") 54 authorizationHeader := c.GetHeader("Authorization")
51 if authorizationHeader == "" { 55 if authorizationHeader == "" {
52 return "", errors.New("Authorization header is missing") 56 return "", errors.New("Authorization header is missing")
53 } 57 }
54 58
55 parts := strings.Split(authorizationHeader, " ") 59 parts := strings.Split(authorizationHeader, " ")
56 60
57 if len(parts) != 2 || parts[0] != "Bearer" { 61 if len(parts) != 2 || parts[0] != "Bearer" {
58 return "", errors.New("Invalid Authorization header format") 62 return "", errors.New("Invalid Authorization header format")
59 } 63 }
60 64
61 65 return parts[1], nil
62 return parts[1], nil
63} 66}
64 67
65
66func TokenRequired() gin.HandlerFunc { 68func TokenRequired() gin.HandlerFunc {
67 return func(c *gin.Context) { 69 return func(c *gin.Context) {
68 _, err := checkForTokenInContext(c) 70 _, err := checkForTokenInContext(c)
69 71
70 if err != nil { 72 if err != nil {
71 c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) 73 c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
72 c.Abort() 74 c.Abort()
73 return 75 return
74 } 76 }
75 77
76 c.Next() 78 c.Next()
77 } 79 }
78} 80}
79 81
80func setupRouter() *gin.Engine { 82func setupRouter() *gin.Engine {
81 // Disable Console Color 83 // Disable Console Color
82 // gin.DisableConsoleColor() 84 // gin.DisableConsoleColor()
83 r := gin.Default() 85 r := gin.Default()
84 r.Use(CORSMiddleware()) 86 r.Use(CORSMiddleware())
85 r.Use(gin.Logger()) 87 r.Use(gin.Logger())
86 r.Use(gin.Recovery()) 88 r.Use(gin.Recovery())
87 89
88 api := r.Group("api/v1") 90 api := r.Group("api/v1")
89 91
90 api.POST("/auth", func(c *gin.Context) { 92 api.POST("/auth", func(c *gin.Context) {
91 username, password, ok := c.Request.BasicAuth() 93 username, password, ok := c.Request.BasicAuth()
92 if !ok { 94 if !ok {
93 c.Header("WWW-Authenticate", `Basic realm="Please enter your username and password."`) 95 c.Header("WWW-Authenticate", `Basic realm="Please enter your username and password."`)
94 c.AbortWithStatus(http.StatusUnauthorized) 96 c.AbortWithStatus(http.StatusUnauthorized)
95 return 97 return
96 } 98 }
97 99
98 db := establishDBConnection() 100 db := establishDBConnection()
99 defer db.Close() 101 defer db.Close()
100 102
101 var user models.User 103 var user models.User
102 var preference models.Preference 104 var preference models.Preference
103 var size models.Size 105 var size models.Size
104 106
105 row := db.QueryRow("SELECT name, uuid, password, color, size, unit FROM Users u INNER JOIN Preferences p ON p.user_id = u.id INNER JOIN Sizes s ON p.size_id = s.id WHERE u.name = ?", username) 107 row := db.QueryRow("SELECT name, uuid, password, color, size, unit FROM Users u INNER JOIN Preferences p ON p.user_id = u.id INNER JOIN Sizes s ON p.size_id = s.id WHERE u.name = ?", username)
106 if err := row.Scan(&user.Name, &user.UUID, &user.Password, &preference.Color, &size.Size, &size.Unit); err != nil { 108 if err := row.Scan(&user.Name, &user.UUID, &user.Password, &preference.Color, &size.Size, &size.Unit); err != nil {
107 if err == sql.ErrNoRows { 109 if err == sql.ErrNoRows {
108 c.AbortWithStatus(http.StatusUnauthorized) 110 c.AbortWithStatus(http.StatusUnauthorized)
109 return 111 return
110 } 112 }
111 } 113 }
112 114
113 if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { 115 if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
114 c.AbortWithStatus(http.StatusUnauthorized) 116 c.AbortWithStatus(http.StatusUnauthorized)
115 return 117 return
116 } 118 }
117 119
118 preference.Size = size 120 preference.Size = size
119 121
120 // Generate a simple API token 122 // Generate a simple API token
121 apiToken := generateToken() 123 apiToken := generateToken()
122 c.JSON(http.StatusOK, gin.H{"token": apiToken, "user": user, "preferences": preference}) 124 c.JSON(http.StatusOK, gin.H{"token": apiToken, "user": user, "preferences": preference})
123 }) 125 })
124 126
125 stats := api.Group("stats") 127 stats := api.Group("/stats")
126 stats.Use(TokenRequired()) 128 stats.Use(TokenRequired())
127 { 129 {
128 stats.GET("/", func(c *gin.Context) { 130 stats.GET("/", func(c *gin.Context) {
129 db := establishDBConnection() 131 db := establishDBConnection()
130 defer db.Close() 132 defer db.Close()
131 133
132 rows, err := db.Query("SELECT s.date, s.quantity, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id"); 134 rows, err := db.Query("SELECT s.date, s.quantity, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id")
133 if err != nil { 135 if err != nil {
134 c.JSON(500, gin.H{"error": err.Error()}) 136 c.JSON(500, gin.H{"error": err.Error()})
135 return 137 return
136 } 138 }
137 defer rows.Close() 139 defer rows.Close()
138 140
139 var data []models.Statistic 141 var data []models.Statistic
140 for rows.Next() { 142
141 var stat models.Statistic 143 for rows.Next() {
142 var user models.User 144 var stat models.Statistic
143 if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil { 145 var user models.User
144 c.JSON(500, gin.H{"error": err.Error()}) 146 if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil {
145 return 147 c.JSON(500, gin.H{"error": err.Error()})
146 } 148 return
147 stat.User = user 149 }
148 data = append(data, stat) 150 stat.User = user
149 } 151 data = append(data, stat)
150 152 }
151 c.JSON(http.StatusOK, data) 153
152 }) 154
153 155 // TODO: return to this and figure out how to best collect the data you are looking for for each user (zach and parker)
154 stats.POST("/", func(c *gin.Context) { 156 rows, err = db.Query("SELECT date(s.date), SUM(s.quantity) as total FROM Statistics s WHERE s.date >= date('now', '-7 days') GROUP BY DATE(s.date)")
155 var stat models.Statistic 157 if err != nil {
156 158 c.JSON(500, gin.H{"error": err.Error()})
157 if err := c.BindJSON(&stat); err != nil { 159 return
158 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 160 }
159 return 161 defer rows.Close()
160 } 162
161 163 var dailySummaries []models.DailySummary
162 db := establishDBConnection() 164 for rows.Next() {
163 defer db.Close() 165 var summary models.DailySummary
164 166 if err := rows.Scan(&summary.Date, &summary.Total); err != nil {
165 result, err := db.Exec("INSERT INTO statistics (date, user_id, quantity) values (?, ?, ?)", stat.Date, 1, stat.Quantity) 167 c.JSON(500, gin.H{"error": err.Error()})
166 168 return
167 if err != nil { 169 }
168 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 170 dailySummaries = append(dailySummaries, summary)
169 } 171 }
170 172
171 id, err := result.LastInsertId() 173 c.JSON(http.StatusOK, gin.H{"stats": data, "totals": dailySummaries})
172 if err != nil { 174 rows, err = db.Query("SELECT s.date, SUM(s.quantity) as total, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id WHERE s.date >= date('now', '-7 days') GROUP BY s.date, s.user_id")
173 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 175
174 } 176 if err != nil {
175 177 c.JSON(500, gin.H{"error": err.Error()})
176 c.JSON(http.StatusCreated, gin.H{"status": "created", "id": id}) 178 return
177 }) 179 }
178 180 defer rows.Close()
179 stats.GET("/:uuid", func(c *gin.Context) { 181
180 c.JSON(http.StatusOK, gin.H{"status": "ok", "uuid": c.Param("uuid")}) 182 var totals []interface{}
181 }) 183 for rows.Next() {
182 184 var stat models.Statistic
183 stats.PATCH("/:uuid", func(c *gin.Context) { 185 var user models.User
184 c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) 186 if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil {
185 }) 187 c.JSON(500, gin.H{"error": err.Error()})
186 188 return
187 stats.DELETE("/:uuid", func(c *gin.Context) { 189 }
188 c.JSON(http.StatusNoContent, gin.H{"status": "No Content"}) 190 stat.User = user
189 }) 191 totals = append(totals, stat)
190 } 192 }
191 193
194 c.JSON(http.StatusOK, gin.H{"stats": data, "totals": totals})
195 })
196
197 stats.POST("/", func(c *gin.Context) {
198 var stat models.Statistic
199
200 if err := c.BindJSON(&stat); err != nil {
201 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
202 return
203 }
204
205 db := establishDBConnection()
206 defer db.Close()
207
208 result, err := db.Exec("INSERT INTO statistics (date, user_id, quantity) values (?, ?, ?)", stat.Date, 1, stat.Quantity)
209
210 if err != nil {
211 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
212 }
213
214 id, err := result.LastInsertId()
215 if err != nil {
216 c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
217 }
218
219 c.JSON(http.StatusCreated, gin.H{"status": "created", "id": id})
220 })
221
222 stats.GET("/totals/", func(c *gin.Context) {
223 c.JSON(http.StatusOK, gin.H{"status": "ok"})
224 })
225
226 // stats.GET("/totals/", func(c *gin.Context) {
227 // db := establishDBConnection()
228 // defer db.Close()
229 //
230 // rows, err := db.Query("SELECT s.date, SUM(s.quantity) as total, u.uuid, u.name FROM Statistics s INNER JOIN Users u ON u.id = s.user_id WHERE s.date >= date('now', '-7 days') GROUP BY s.date, s.user_id")
231 //
232 // if err != nil {
233 // c.JSON(500, gin.H{"error": err.Error()})
234 // return
235 // }
236 // defer rows.Close()
237 //
238 // var data []models.Statistic
239 // for rows.Next() {
240 // var stat models.Statistic
241 // var user models.User
242 // if err := rows.Scan(&stat.Date, &stat.Quantity, &user.UUID, &user.Name); err != nil {
243 // c.JSON(500, gin.H{"error": err.Error()})
244 // return
245 // }
246 // stat.User = user
247 // data = append(data, stat)
248 // }
249 //
250 // c.JSON(http.StatusOK, data)
251 //
252 // })
253
254 stats.GET("user/:uuid", func(c *gin.Context) {
255 c.JSON(http.StatusOK, gin.H{"status": "ok", "uuid": c.Param("uuid")})
256 })
257
258 stats.PATCH("user/:uuid", func(c *gin.Context) {
259 c.JSON(http.StatusNoContent, gin.H{"status": "No Content"})
260 })
261
262 stats.DELETE("user/:uuid", func(c *gin.Context) {
263 c.JSON(http.StatusNoContent, gin.H{"status": "No Content"})
264 })
265 }
192 266
193 return r 267 return r
194} 268}
diff --git a/fe/src/app.css b/fe/src/app.css
index 0d5fa90..de19b52 100644
--- a/fe/src/app.css
+++ b/fe/src/app.css
@@ -1,82 +1,87 @@
1:root { 1:root {
2 font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 2 font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 line-height: 1.5; 3 line-height: 1.5;
4 font-weight: 400; 4 font-weight: 400;
5 5
6 color-scheme: light dark; 6 color-scheme: light dark;
7 color: rgba(255, 255, 255, 0.87); 7 color: rgba(255, 255, 255, 0.87);
8 background-color: #242424; 8 background-color: #242424;
9 9
10 font-synthesis: none; 10 font-synthesis: none;
11 text-rendering: optimizeLegibility; 11 text-rendering: optimizeLegibility;
12 -webkit-font-smoothing: antialiased; 12 -webkit-font-smoothing: antialiased;
13 -moz-osx-font-smoothing: grayscale; 13 -moz-osx-font-smoothing: grayscale;
14 14
15 --submit: #28a745; 15 --submit: #28a745;
16} 16}
17 17
18a { 18a {
19 font-weight: 500; 19 font-weight: 500;
20 color: #646cff; 20 color: #646cff;
21 text-decoration: inherit; 21 text-decoration: inherit;
22} 22}
23
23a:hover { 24a:hover {
24 color: #535bf2; 25 color: #535bf2;
25} 26}
26 27
27body { 28body {
28 margin: 0; 29 margin: 0;
29 display: flex; 30 display: flex;
30 place-items: center; 31 place-items: center;
31 min-width: 320px; 32 min-width: 320px;
32 min-height: 100vh; 33 min-height: 100vh;
33} 34}
34 35
35h1 { 36h1 {
36 font-size: 3.2em; 37 font-size: 3.2em;
37 line-height: 1.1; 38 line-height: 1.1;
38} 39}
39 40
40.card { 41.card {
41 padding: 2em; 42 padding: 2em;
42} 43}
43 44
44#app { 45#app {
45 flex-grow: 2; 46 flex-grow: 2;
46 max-width: 1280px; 47 max-width: 1280px;
47 margin: 0 auto; 48 margin: 0 auto;
48} 49}
49 50
50button { 51button {
51 border-radius: 8px; 52 border-radius: 8px;
52 border: 1px solid transparent; 53 border: 1px solid transparent;
53 padding: 0.6em 1.2em; 54 padding: 0.6em 1.2em;
54 font-size: 1em; 55 font-size: 1em;
55 font-weight: 500; 56 font-weight: 500;
56 font-family: inherit; 57 font-family: inherit;
57 background-color: #1a1a1a; 58 background-color: #1a1a1a;
58 cursor: pointer; 59 cursor: pointer;
59 transition: border-color 0.25s; 60 transition: border-color 0.25s;
60} 61}
62
61button:hover { 63button:hover {
62 border-color: #646cff; 64 border-color: #646cff;
63} 65}
66
64button:focus, 67button:focus,
65button:focus-visible { 68button:focus-visible {
66 outline: 4px auto -webkit-focus-ring-color; 69 outline: 4px auto -webkit-focus-ring-color;
67} 70}
68 71
69@media (prefers-color-scheme: light) { 72@media (prefers-color-scheme: light) {
70 :root { 73 :root {
71 color: #213547; 74 color: #213547;
72 background-color: #ffffff; 75 background-color: #ffffff;
73 } 76 }
74 a:hover { 77
75 color: #747bff; 78 a:hover {
76 } 79 color: #747bff;
77 button { 80 }
78 background-color: #f9f9f9; 81
79 } 82 button {
83 background-color: #f9f9f9;
84 }
80} 85}
81 86
82@media (prefers-color-scheme: dark) { 87@media (prefers-color-scheme: dark) {
@@ -97,7 +102,7 @@ button:focus-visible {
97} 102}
98 103
99.form.input.group label { 104.form.input.group label {
100 margin-bottom: .5em; 105 margin-bottom: .5em;
101} 106}
102 107
103.form.input.group input { 108.form.input.group input {
diff --git a/fe/src/lib/Card.svelte b/fe/src/lib/Card.svelte
index 0835940..d7cd900 100644
--- a/fe/src/lib/Card.svelte
+++ b/fe/src/lib/Card.svelte
@@ -1,22 +1,24 @@
1<script lang="ts"> 1<script lang="ts">
2 export let title = ""; 2 export let title = "";
3 export let height: number | undefined = undefined;
3</script> 4</script>
4 5
5<div class="card"> 6<div class="card">
6 {#if title} 7 {#if title}
7 <h2>{title}</h2> 8 <h2>{title}</h2>
8 {/if} 9 {/if}
9 <slot /> 10 <slot />
10</div> 11</div>
11 12
12<style> 13<style>
13 .card { 14 .card {
14 background: #fff; 15 background: #fff;
15 width: 16rem; 16 display: flex;
16 display: flex; 17 flex-direction: column;
17 flex-direction: column; 18 align-items: flex-start;
18 align-items: left; 19 border: solid 2px #00000066;
19 border: solid 2px #00000066; 20 border-radius: 0.25em;
20 border-radius: 0.25em; 21 height: var(--height, fit-content);
21 } 22 overflow-y: var(--overflow, initial);
23 }
22</style> 24</style>
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
index 5182a85..2b1b8b9 100644
--- a/fe/src/lib/DataView.svelte
+++ b/fe/src/lib/DataView.svelte
@@ -1,16 +1,21 @@
1<script lang="ts"> 1<script lang="ts">
2 import { onDestroy, onMount } from "svelte"; 2 import { onDestroy, onMount } from "svelte";
3 import { token } from "../stores/auth"; 3 import { token } from "../stores/auth";
4 import { addFormOpen } from "../stores/forms";
4 import Table from "./Table.svelte"; 5 import Table from "./Table.svelte";
5 import Chart from "chart.js/auto"; 6 import Chart from "chart.js/auto";
7 import Card from "./Card.svelte";
8 import Column from "./Column.svelte";
6 import AddForm from "./forms/AddForm.svelte"; 9 import AddForm from "./forms/AddForm.svelte";
7 10
8 let open: boolean = false;
9
10 let json: Promise<any>; 11 let json: Promise<any>;
12 let totals: Promise<any>;
13 let userStats: Promise<any>;
11 14
12 let canvasRef: HTMLCanvasElement; 15 let barCanvasRef: HTMLCanvasElement;
13 let chart: any; 16 let lineCanvasRef: HTMLCanvasElement;
17 let barChart: any;
18 let lineChart: any;
14 19
15 let lastSevenDays: string[]; 20 let lastSevenDays: string[];
16 21
@@ -28,6 +33,37 @@
28 } 33 }
29 } 34 }
30 35
36 async function fetchTotals() {
37 const res = await fetch("http://localhost:8080/api/v1/stats/totals/", {
38 method: 'GET',
39 mode: 'no-cors',
40 headers: {
41 Authorization: `Bearer ${$token}`
42 }
43 });
44
45 if (res.ok) {
46 totals = res.json();
47 } else {
48 throw new Error("There was a problem with your request");
49 }
50 }
51
52 async function fetchStatsForUser() {
53 const res = await fetch("http://localhost:8080/api/v1/stats/user/1aa668f3-7527-4a67-9c24-fdf307542eeb", {
54 method: "GET",
55 headers: {
56 Authorization: `Bearer ${$token}`
57 }
58 });
59
60 if (res.ok) {
61 userStats = res.json();
62 } else {
63 throw new Error("There was a problem with your request");
64 }
65 }
66
31 function getLastSevenDays() { 67 function getLastSevenDays() {
32 const result = []; 68 const result = [];
33 for (let i = 0; i < 7; i++) { 69 for (let i = 0; i < 7; i++) {
@@ -38,23 +74,21 @@
38 return result; 74 return result;
39 } 75 }
40 76
41 function handleClick() {
42 open = true;
43 }
44
45 function closeDialog() { 77 function closeDialog() {
46 open = false; 78 addFormOpen.set(false);
47 } 79 }
48 80
49 function onStatisticAdd() { 81 function onStatisticAdd() {
50 open = false; 82 closeDialog();
51 fetchData(); 83 fetchData();
52 } 84 }
53 85
54 onMount(() => { 86 onMount(() => {
55 fetchData(); 87 fetchData();
88// fetchTotals();
89 fetchStatsForUser();
56 lastSevenDays = getLastSevenDays(); 90 lastSevenDays = getLastSevenDays();
57 chart = new Chart(canvasRef, { 91 barChart = new Chart(barCanvasRef, {
58 type: "bar", 92 type: "bar",
59 data: { 93 data: {
60 labels: lastSevenDays, 94 labels: lastSevenDays,
@@ -73,27 +107,74 @@
73 borderWidth: 1 107 borderWidth: 1
74 } 108 }
75 ] 109 ]
110 },
111 options: {
112 responsive: true
113 }
114 });
115 lineChart = new Chart(lineCanvasRef, {
116 type: "line",
117 data: {
118 labels: lastSevenDays,
119 datasets: [
120 {
121 label: "Zach",
122 data: [1, 2, 8, 2, 5, 5, 1],
123 backgroundColor: "rgba(255, 192, 192, 0.2)",
124 borderColor: "rgba(75, 192, 192, 1)",
125 borderWidth: 1
126 }, {
127 label: "Parker",
128 data: [6, 1, 1, 4, 3, 5, 1],
129 backgroundColor: "rgba(75, 192, 192, 0.2)",
130 borderColor: "rgba(75, 192, 192, 1)",
131 borderWidth: 1
132 }
133 ]
134 },
135 options: {
136 responsive: true
76 } 137 }
77 }); 138 });
78 }); 139 });
79 140
80 onDestroy(() => { 141 onDestroy(() => {
81 if (chart) chart.destroy(); 142 if (barChart) barChart.destroy();
82 chart = null; 143 if (lineChart) lineChart.destroy();
144 barChart = null;
145 lineChart = null;
83 }); 146 });
84</script> 147</script>
85 148
86<div> 149<Column --width="500px">
87 <button on:click={handleClick}>Add</button> 150 <Card>
88 <AddForm {open} on:submit={onStatisticAdd} on:close={closeDialog} /> 151 <canvas bind:this={barCanvasRef} width="" />
89 <canvas bind:this={canvasRef} /> 152 </Card>
90 {#await json then data} 153 <Card>
91 <Table {data} nofooter /> 154 <canvas bind:this={lineCanvasRef} />
92 {:catch error} 155 </Card>
93 <p>{error}</p> 156</Column>
94 {/await} 157
95 <!-- <Chart /> --> 158<AddForm open={$addFormOpen} on:submit={onStatisticAdd} on:close={closeDialog} />
96</div> 159<Column>
160 <Card>
161 {#await json then data}
162 <Table {data} nofooter />
163 {:catch error}
164 <p>{error}</p>
165 {/await}
166 </Card>
167 <Card>
168 <button on:click={() => fetchTotals()}>Get totals</button>
169 {#await totals then data}
170 {JSON.stringify(data)}
171 {:catch error}
172 <p>{error}</p>
173 {/await}
174 </Card>
175</Column>
176<!-- <Chart /> -->
177
97 178
98<style> 179<style>
99 dialog { 180 dialog {
diff --git a/fe/src/lib/Layout.svelte b/fe/src/lib/Layout.svelte
index 94ce84d..f208f34 100644
--- a/fe/src/lib/Layout.svelte
+++ b/fe/src/lib/Layout.svelte
@@ -1,62 +1,70 @@
1<script> 1<script>
2 import { authenticated, token } from "../stores/auth"; 2 import { authenticated, token } from "../stores/auth";
3 import PreferencesForm from "./PreferencesForm.svelte"; 3 import PreferencesForm from "./PreferencesForm.svelte";
4 const logout = () => token.unauthenticate(); 4 import { addFormOpen } from "../stores/forms";
5 let open = false; 5
6 6 const logout = () => token.unauthenticate();
7 function showSettingsDialog() { 7 let preferenceFormOpen = false;
8 open = true; 8
9 } 9 function showPreferencesDialog() {
10 10 preferenceFormOpen = true;
11 function closeDialog() { 11 }
12 open = false; 12
13 } 13 function closePreferenceDialog() {
14 preferenceFormOpen = false;
15 }
16
17 function showAddDialog() {
18 addFormOpen.set(true);
19 }
14</script> 20</script>
15 21
16<div class="layout"> 22<div class="layout">
17 {#if $authenticated} 23 {#if $authenticated}
18 <nav> 24 <nav>
19 <div> 25 <div>
20 <h1>Water</h1> 26 <h1>Water</h1>
21 </div> 27 </div>
22 <div> 28 <div>
23 <button on:click={showSettingsDialog}>Settings</button> 29 <button on:click={showAddDialog}>Log Water</button>
24 <button on:click={logout}>Logout</button> 30 <button on:click={showPreferencesDialog}>Preference</button>
25 </div> 31 <button on:click={logout}>Logout</button>
26 </nav> 32 </div>
27 <PreferencesForm {open} on:close={closeDialog} /> 33 </nav>
28 {/if} 34 <PreferencesForm open={preferenceFormOpen} on:close={closePreferenceDialog} />
29 <div id="content"> 35 {/if}
30 <slot /> 36 <div id="content">
31 </div> 37 <slot />
38 </div>
32</div> 39</div>
33 40
34<style> 41<style>
35 .layout { 42 .layout {
36 height: 100vh; 43 height: 100vh;
37 } 44 }
38 nav { 45
39 display: flex; 46 nav {
40 flex-direction: row; 47 display: flex;
41 align-items: center; 48 flex-direction: row;
42 justify-content: space-between; 49 align-items: center;
43 height: 64px; 50 justify-content: space-between;
44 padding: 0 2em; 51 height: 64px;
45 } 52 padding: 0 2em;
46 53 }
47 nav div { 54
48 width: fit-content; 55 nav div {
49 } 56 width: fit-content;
50 57 }
51 nav div h1 { 58
52 font-size: 1.75em; 59 nav div h1 {
53 } 60 font-size: 1.75em;
54 61 }
55 #content { 62
56 display: flex; 63 #content {
57 flex-direction: column; 64 display: flex;
58 justify-content: center; 65 flex-direction: row;
59 align-items: center; 66 justify-content: center;
60 padding: 3em 0; 67 gap: 2em;
61 } 68 margin-top: 4em;
69 }
62</style> 70</style>
diff --git a/fe/src/lib/Table.svelte b/fe/src/lib/Table.svelte
index 3a66e0d..d1cd7da 100644
--- a/fe/src/lib/Table.svelte
+++ b/fe/src/lib/Table.svelte
@@ -1,105 +1,113 @@
1<script lang="ts"> 1<script lang="ts">
2 export let data: Array<any> | undefined = undefined; 2 export let data: Array<any> | undefined = undefined;
3 export let nofooter: boolean = false; 3 export let nofooter: boolean = false;
4 export let noheader: boolean = false; 4 export let noheader: boolean = false;
5 export let omit: string[] = ["id"]; 5 export let omit: string[] = ["id"];
6 export let title: string | undefined = undefined; 6 export let title: string | undefined = undefined;
7 7
8 function getDataKeys(data: any[]): string[] { 8 function getDataKeys(data: any[]): string[] {
9 if (!data || data.length === 0) return []; 9 if (!data || data.length === 0) return [];
10 return Object.keys(data[0]) 10 return Object.keys(data[0])
11 .map((k) => k.split("_").join(" ")) 11 .map((k) => k.split("_").join(" "))
12 .filter((k) => !omit.includes(k)); 12 .filter((k) => !omit.includes(k));
13 } 13 }
14 14
15 function getRow(row: Record<string, any>): Array<any> { 15 function getRow(row: Record<string, any>): Array<any> {
16 return Object.entries(row).filter((r) => !omit.includes(r[0])); 16 return Object.entries(row).filter((r) => !omit.includes(r[0]));
17 } 17 }
18 18
19 const formatter = new Intl.DateTimeFormat("en", {
20 year: "numeric",
21 month: "numeric",
22 day: "numeric",
23 hour: "numeric",
24 minute: "2-digit",
25 second: "2-digit",
26 timeZone: "America/New_York",
27 });
28 19
29 function formatDatum([key, value]: any[]) { 20 let limitedData: Array<any> = [];
30 if (key === "date") {
31 const parsedDate = new Date(value);
32 return formatter.format(parsedDate);
33 }
34 21
35 if (key === "user") { 22 if (data && (data as any[]).length > 0) {
36 return value["name"]; 23 limitedData = (data as any[]).slice(0, 4);
37 } 24 }
38 25
39 return value; 26 const formatter = new Intl.DateTimeFormat("en", {
40 } 27 year: "numeric",
28 month: "numeric",
29 day: "numeric",
30 hour: "numeric",
31 minute: "2-digit",
32 second: "2-digit",
33 timeZone: "America/New_York"
34 });
35
36 function formatDatum([key, value]: any[]) {
37 if (key === "date") {
38 const parsedDate = new Date(value);
39 return formatter.format(parsedDate);
40 }
41
42 if (key === "user") {
43 return value["name"];
44 }
45
46 return value;
47 }
41</script> 48</script>
42 49
43<table> 50<table>
44 {#if title} 51 {#if title}
45 <h2>{title}</h2> 52 <h2>{title}</h2>
46 {/if} 53 {/if}
47 {#if !noheader && data} 54 {#if !noheader && data}
48 <thead> 55 <thead>
49 <tr>
50 {#each getDataKeys(data) as header}
51 <th>{header}</th>
52 {/each}
53 </tr>
54 </thead>
55 {/if}
56 <tbody>
57 {#if data}
58 {#each data as row}
59 <tr> 56 <tr>
60 {#each getRow(row) as datum} 57 {#each getDataKeys(data) as header}
61 <td>{formatDatum(datum)}</td> 58 <th>{header}</th>
62 {/each} 59 {/each}
63 </tr> 60 </tr>
64 {/each} 61 </thead>
62 {/if}
63 <tbody>
64 {#if data}
65 {#each limitedData as row}
66 <tr>
67 {#each getRow(row) as datum}
68 <td>{formatDatum(datum)}</td>
69 {/each}
70 </tr>
71 {/each}
65 {:else} 72 {:else}
66 <tr> There is not data. </tr> 73 <tr> There is not data.</tr>
74 {/if}
75 </tbody>
76 {#if !nofooter}
77 <slot name="footer">
78 <tfoot>
79 <tr>
80 <td>Table Footer</td>
81 </tr>
82 </tfoot>
83 </slot>
67 {/if} 84 {/if}
68 </tbody>
69 {#if !nofooter}
70 <slot name="footer">
71 <tfoot>
72 <tr>
73 <td>Table Footer</td>
74 </tr>
75 </tfoot>
76 </slot>
77 {/if}
78</table> 85</table>
79 86
80<style> 87<style>
81 table { 88 table {
82 padding: 16px; 89 padding: 16px;
83 margin: 8px; 90 margin: 8px;
84 border: solid 1px black; 91 border: solid 1px black;
85 border-collapse: collapse; 92 border-collapse: collapse;
86 } 93 overflow-y: hidden;
94 }
87 95
88 th { 96 th {
89 text-transform: capitalize; 97 text-transform: capitalize;
90 } 98 }
91 99
92 thead tr { 100 thead tr {
93 background: rgba(0, 0, 23, 0.34); 101 background: rgba(0, 0, 23, 0.34);
94 } 102 }
95 103
96 tbody tr:nth-child(odd) { 104 tbody tr:nth-child(odd) {
97 background: rgba(0, 0, 23, 0.14); 105 background: rgba(0, 0, 23, 0.14);
98 } 106 }
99 107
100 th, 108 th,
101 td { 109 td {
102 padding: 1em; 110 padding: 1em;
103 border: 1px solid rgba(0, 0, 0, 1); 111 border: 1px solid rgba(0, 0, 0, 1);
104 } 112 }
105</style> 113</style>
diff --git a/fe/src/stores/forms.ts b/fe/src/stores/forms.ts
new file mode 100644
index 0000000..daf9181
--- /dev/null
+++ b/fe/src/stores/forms.ts
@@ -0,0 +1,6 @@
1import type { Writable } from "svelte/store";
2import { writable } from "svelte/store";
3
4
5export const preferencesFormOpen: Writable<boolean> = writable<boolean>(false);
6export const addFormOpen: Writable<boolean> = writable<boolean>(false); \ No newline at end of file