diff options
author | Zach Berwaldt <zberwaldt@tutamail.com> | 2024-03-02 16:52:55 -0500 |
---|---|---|
committer | Zach Berwaldt <zberwaldt@tutamail.com> | 2024-03-02 16:52:55 -0500 |
commit | cf2113e77edabf8e3a632c7b76c769752039ba88 (patch) | |
tree | 874872f22aa63df532769de62119816748b167f8 | |
parent | 326f186d67017f87e631a1fbcdf3f184cbc42d7d (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.go | 384 | ||||
-rw-r--r-- | fe/src/app.css | 101 | ||||
-rw-r--r-- | fe/src/lib/Card.svelte | 30 | ||||
-rw-r--r-- | fe/src/lib/Column.svelte | 13 | ||||
-rw-r--r-- | fe/src/lib/DataView.svelte | 129 | ||||
-rw-r--r-- | fe/src/lib/Layout.svelte | 116 | ||||
-rw-r--r-- | fe/src/lib/Table.svelte | 172 | ||||
-rw-r--r-- | fe/src/stores/forms.ts | 6 |
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 | ||
3 | import ( | 3 | import ( |
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 | ||
17 | func CORSMiddleware() gin.HandlerFunc { | 18 | func 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 |
34 | func generateToken() string { | 39 | func 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 | ||
40 | func establishDBConnection() *sql.DB { | 45 | func 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 | |||
49 | func checkForTokenInContext(c *gin.Context) (string, error) { | 53 | func 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 | |||
66 | func TokenRequired() gin.HandlerFunc { | 68 | func 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 | ||
80 | func setupRouter() *gin.Engine { | 82 | func 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 | ||
18 | a { | 18 | a { |
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 | |||
23 | a:hover { | 24 | a:hover { |
24 | color: #535bf2; | 25 | color: #535bf2; |
25 | } | 26 | } |
26 | 27 | ||
27 | body { | 28 | body { |
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 | ||
35 | h1 { | 36 | h1 { |
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 | ||
50 | button { | 51 | button { |
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 | |||
61 | button:hover { | 63 | button:hover { |
62 | border-color: #646cff; | 64 | border-color: #646cff; |
63 | } | 65 | } |
66 | |||
64 | button:focus, | 67 | button:focus, |
65 | button:focus-visible { | 68 | button: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 @@ | |||
1 | import type { Writable } from "svelte/store"; | ||
2 | import { writable } from "svelte/store"; | ||
3 | |||
4 | |||
5 | export const preferencesFormOpen: Writable<boolean> = writable<boolean>(false); | ||
6 | export const addFormOpen: Writable<boolean> = writable<boolean>(false); \ No newline at end of file | ||