github.com/shoshinnikita/budget-manager@v0.7.1-0.20220131195411-8c46ff1c6778/internal/web/pages/pages.go (about) 1 package pages 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "html/template" 8 "net/http" 9 "sort" 10 "strconv" 11 "strings" 12 "time" 13 14 "github.com/ShoshinNikita/budget-manager/internal/db" 15 "github.com/ShoshinNikita/budget-manager/internal/logger" 16 "github.com/ShoshinNikita/budget-manager/internal/pkg/money" 17 "github.com/ShoshinNikita/budget-manager/internal/pkg/reqid" 18 "github.com/ShoshinNikita/budget-manager/internal/web/pages/statistics" 19 ) 20 21 const ( 22 monthsTemplateName = "months.html" 23 monthTemplateName = "month.html" 24 searchSpendsTemplateName = "search_spends.html" 25 errorPageTemplateName = "error_page.html" 26 ) 27 28 type Handlers struct { 29 db DB 30 tplExecutor *templateExecutor 31 log logger.Logger 32 33 version string 34 gitHash string 35 } 36 37 type DB interface { 38 GetMonthByDate(ctx context.Context, year int, month time.Month) (db.Month, error) 39 GetMonths(ctx context.Context, years ...int) ([]db.MonthOverview, error) 40 41 GetSpendTypes(ctx context.Context) ([]db.SpendType, error) 42 43 SearchSpends(ctx context.Context, args db.SearchSpendsArgs) ([]db.Spend, error) 44 } 45 46 func NewHandlers(db DB, log logger.Logger, cacheTemplates bool, version, gitHash string) *Handlers { 47 return &Handlers{ 48 db: db, 49 tplExecutor: newTemplateExecutor(log, cacheTemplates, commonTemplateFuncs()), 50 log: log, 51 // 52 version: version, 53 gitHash: gitHash, 54 } 55 } 56 57 func commonTemplateFuncs() template.FuncMap { 58 return template.FuncMap{ 59 "asStaticURL": func(url string) (string, error) { 60 return url, nil 61 }, 62 "toHTMLAttr": func(s string) template.HTMLAttr { 63 return template.HTMLAttr(s) //nolint:gosec 64 }, 65 } 66 } 67 68 // GET / - redirects to the current month page 69 // 70 func (h Handlers) IndexPage(w http.ResponseWriter, r *http.Request) { 71 year, month, _ := time.Now().Date() 72 73 reqid.FromContextToLogger(r.Context(), h.log). 74 WithFields(logger.Fields{"year": year, "month": int(month)}). 75 Debug("redirect to the current month") 76 77 url := fmt.Sprintf("/months/month?year=%d&month=%d", year, month) 78 http.Redirect(w, r, url, http.StatusSeeOther) 79 } 80 81 // GET /months?offset=0 82 // 83 func (h Handlers) MonthsPage(w http.ResponseWriter, r *http.Request) { 84 ctx := r.Context() 85 log := reqid.FromContextToLogger(ctx, h.log) 86 87 var offset int 88 if value := r.FormValue("offset"); value != "" { 89 var err error 90 offset, err = strconv.Atoi(value) 91 if err != nil || offset < 0 { 92 h.processErrorWithPage(ctx, log, w, newInvalidURLMessage("invalid offset value"), http.StatusBadRequest) 93 return 94 } 95 } 96 97 now := time.Now() 98 endYear := now.Year() - offset 99 years := []int{endYear} 100 if now.Month() != time.December { 101 years = append(years, endYear-1) 102 } 103 months, err := h.db.GetMonths(ctx, years...) 104 if err != nil { 105 h.processInternalErrorWithPage(ctx, log, w, newDBErrorMessage("couldn't get months"), err) 106 return 107 } 108 109 months = getLastTwelveMonths(endYear, now.Month(), months) 110 111 var totalIncome money.Money 112 for _, m := range months { 113 totalIncome = totalIncome.Add(m.TotalIncome) 114 } 115 116 var totalSpend money.Money 117 for _, m := range months { 118 // Use Add because 'TotalSpend' is negative 119 totalSpend = totalSpend.Add(m.TotalSpend) 120 } 121 122 // Use Add because 'annualSpend' is negative 123 result := totalIncome.Add(totalSpend) 124 125 yearInterval := strconv.Itoa(endYear) 126 if len(years) > 1 { 127 yearInterval = strconv.Itoa(endYear-1) + "–" + yearInterval 128 } 129 130 resp := struct { 131 YearInterval string 132 Offset int 133 // 134 Months []db.MonthOverview 135 TotalIncome money.Money 136 TotalSpend money.Money 137 Result money.Money 138 // 139 Footer FooterTemplateData 140 // 141 Add func(int, int) int 142 }{ 143 YearInterval: yearInterval, 144 Offset: offset, 145 // 146 Months: months, 147 TotalIncome: totalIncome, 148 TotalSpend: totalSpend, 149 Result: result, 150 // 151 Footer: FooterTemplateData{ 152 Version: h.version, 153 GitHash: h.gitHash, 154 }, 155 // 156 Add: func(a, b int) int { return a + b }, 157 } 158 if err := h.tplExecutor.Execute(ctx, w, monthsTemplateName, resp); err != nil { 159 h.processInternalErrorWithPage(ctx, log, w, executeErrorMessage, err) 160 } 161 } 162 163 // getLastTwelveMonths returns the last 12 months according to the passed year and month. If some month 164 // can't be found in the passed slice, its id will be 0 165 func getLastTwelveMonths(endYear int, endMonth time.Month, months []db.MonthOverview) []db.MonthOverview { 166 type key struct { 167 year int 168 month time.Month 169 } 170 requiredMonths := make(map[key]db.MonthOverview) 171 172 year, month := endYear, endMonth 173 for i := 0; i < 12; i++ { 174 // Months without data have zero id 175 requiredMonths[key{year, month}] = db.MonthOverview{ID: 0, Year: year, Month: month} 176 177 month-- 178 if month == 0 { 179 month = time.December 180 year-- 181 } 182 } 183 184 for _, m := range months { 185 k := key{m.Year, m.Month} 186 if _, ok := requiredMonths[k]; ok { 187 requiredMonths[k] = m 188 } 189 } 190 191 months = make([]db.MonthOverview, 0, len(requiredMonths)) 192 for _, m := range requiredMonths { 193 months = append(months, m) 194 } 195 sort.Slice(months, func(i, j int) bool { 196 if months[i].Year == months[j].Year { 197 return months[i].Month < months[j].Month 198 } 199 return months[i].Year < months[j].Year 200 }) 201 202 return months 203 } 204 205 // GET /months/month?year={year}&month={month} 206 func (h Handlers) MonthPage(w http.ResponseWriter, r *http.Request) { 207 ctx := r.Context() 208 log := reqid.FromContextToLogger(ctx, h.log) 209 210 year, monthNumber, ok := getYearAndMonth(r) 211 if !ok { 212 h.processErrorWithPage(ctx, log, w, newInvalidURLMessage("invalid date"), http.StatusBadRequest) 213 return 214 } 215 216 month, err := h.db.GetMonthByDate(ctx, year, monthNumber) 217 if err != nil { 218 switch { 219 case errors.Is(err, db.ErrMonthNotExist): 220 h.processErrorWithPage(ctx, log, w, err.Error(), http.StatusNotFound) 221 default: 222 h.processInternalErrorWithPage(ctx, log, w, newDBErrorMessage("couldn't get Month"), err) 223 } 224 return 225 } 226 227 dbSpendTypes, err := h.db.GetSpendTypes(ctx) 228 if err != nil { 229 h.processInternalErrorWithPage(ctx, log, w, newDBErrorMessage("couldn't get Spend Types"), err) 230 return 231 } 232 spendTypes := getSpendTypesWithFullNames(dbSpendTypes) 233 234 populateMonthlyPaymentsWithFullSpendTypeNames(spendTypes, month.MonthlyPayments) 235 for i := range month.Days { 236 populateSpendsWithFullSpendTypeNames(spendTypes, month.Days[i].Spends) 237 } 238 239 // Sort Incomes and Monthly Payments 240 sort.Slice(month.Incomes, func(i, j int) bool { 241 return month.Incomes[i].Income > month.Incomes[j].Income 242 }) 243 sort.Slice(month.MonthlyPayments, func(i, j int) bool { 244 return month.MonthlyPayments[i].Cost > month.MonthlyPayments[j].Cost 245 }) 246 247 var monthlyPaymentsTotalCost money.Money 248 for _, p := range month.MonthlyPayments { 249 monthlyPaymentsTotalCost = monthlyPaymentsTotalCost.Sub(p.Cost) 250 } 251 252 resp := struct { 253 db.Month 254 MonthlyPaymentsTotalCost money.Money 255 SpendTypes []SpendType 256 // 257 Footer FooterTemplateData 258 // 259 ToShortMonth func(time.Month) string 260 SumSpendCosts func([]db.Spend) money.Money 261 ShouldSuggestSpendType func(spendType, option SpendType) bool 262 }{ 263 Month: month, 264 MonthlyPaymentsTotalCost: monthlyPaymentsTotalCost, 265 SpendTypes: spendTypes, 266 // 267 Footer: FooterTemplateData{ 268 Version: h.version, 269 GitHash: h.gitHash, 270 }, 271 // 272 ToShortMonth: toShortMonth, 273 SumSpendCosts: sumSpendCosts, 274 ShouldSuggestSpendType: func(origin, suggestion SpendType) bool { 275 if origin.ID == suggestion.ID { 276 return false 277 } 278 if _, ok := suggestion.parentSpendTypeIDs[origin.ID]; ok { 279 return false 280 } 281 return true 282 }, 283 } 284 if err := h.tplExecutor.Execute(ctx, w, monthTemplateName, resp); err != nil { 285 h.processInternalErrorWithPage(ctx, log, w, executeErrorMessage, err) 286 } 287 } 288 289 // GET /search/spends 290 // 291 // Query Params: 292 // - title - spend title 293 // - notes - spend notes 294 // - min_cost - minimal const 295 // - max_cost - maximal cost 296 // - after - date in format 'yyyy-mm-dd' 297 // - before - date in format 'yyyy-mm-dd' 298 // - type_id - Spend Type id to search (can be passed multiple times: ?type_id=56&type_id=58). 299 // Use id '0' to search for Spends without type 300 // - sort - sort type: 'title', 'date' or 'cost' 301 // - order - sort order: 'asc' or 'desc' 302 // 303 func (h Handlers) SearchSpendsPage(w http.ResponseWriter, r *http.Request) { 304 ctx := r.Context() 305 log := reqid.FromContextToLogger(ctx, h.log) 306 307 args := parseSearchSpendsArgs(r, log) 308 spends, err := h.db.SearchSpends(ctx, args) 309 if err != nil { 310 h.processInternalErrorWithPage(ctx, log, w, newDBErrorMessage("couldn't complete Spend search"), err) 311 return 312 } 313 314 dbSpendTypes, err := h.db.GetSpendTypes(ctx) 315 if err != nil { 316 h.processInternalErrorWithPage(ctx, log, w, newDBErrorMessage("couldn't get Spend Types"), err) 317 return 318 } 319 spendTypes := getSpendTypesWithFullNames(dbSpendTypes) 320 321 populateSpendsWithFullSpendTypeNames(spendTypes, spends) 322 323 spentBySpendTypeDatasets := statistics.CalculateSpentBySpendType(dbSpendTypes, spends) 324 spentByDayDataset := statistics.CalculateSpentByDay(spends, args.After, args.Before) 325 // TODO: support custom interval number? 326 const costIntervalNumber = 15 327 costIntervals := statistics.CalculateCostIntervals(spends, costIntervalNumber) 328 329 // Execute the template 330 resp := struct { 331 // Spends 332 Spends []db.Spend 333 // Statistics 334 SpentBySpendTypeDatasets []statistics.SpentBySpendTypeDataset 335 SpentByDayDataset statistics.SpentByDayDataset 336 CostIntervals []statistics.CostInterval 337 TotalCost money.Money 338 // 339 SpendTypes []SpendType 340 Footer FooterTemplateData 341 }{ 342 Spends: spends, 343 // 344 SpentBySpendTypeDatasets: spentBySpendTypeDatasets, 345 SpentByDayDataset: spentByDayDataset, 346 CostIntervals: costIntervals, 347 TotalCost: sumSpendCosts(spends), 348 // 349 SpendTypes: spendTypes, 350 Footer: FooterTemplateData{ 351 Version: h.version, 352 GitHash: h.gitHash, 353 }, 354 } 355 if err := h.tplExecutor.Execute(ctx, w, searchSpendsTemplateName, resp); err != nil { 356 h.processInternalErrorWithPage(ctx, log, w, executeErrorMessage, err) 357 } 358 } 359 360 //nolint:funlen 361 func parseSearchSpendsArgs(r *http.Request, log logger.Logger) db.SearchSpendsArgs { 362 // Title and Notes 363 title := strings.ToLower(strings.TrimSpace(r.FormValue("title"))) 364 notes := strings.ToLower(strings.TrimSpace(r.FormValue("notes"))) 365 366 // Min and Max Costs 367 parseCost := func(paramName string) money.Money { 368 costParam := r.FormValue(paramName) 369 if costParam == "" { 370 return 0 371 } 372 373 cost, err := strconv.ParseFloat(costParam, 64) 374 if err != nil { 375 log.WithError(err).WithField(paramName, costParam).Warnf("couldn't parse '%s' param", paramName) 376 cost = 0 377 } 378 return money.FromFloat(cost) 379 } 380 minCost := parseCost("min_cost") 381 maxCost := parseCost("max_cost") 382 383 // After and Before 384 parseTime := func(paramName string) time.Time { 385 const timeLayout = "2006-01-02" 386 387 timeParam := r.FormValue(paramName) 388 if timeParam == "" { 389 return time.Time{} 390 } 391 392 t, err := time.Parse(timeLayout, timeParam) 393 if err != nil { 394 log.WithError(err).WithField(paramName, timeParam).Warnf("couldn't parse '%s' param", paramName) 395 t = time.Time{} 396 } 397 return t 398 } 399 after := parseTime("after") 400 before := parseTime("before") 401 402 // Spend Types 403 var typeIDs []uint 404 if ids := r.Form["type_id"]; len(ids) != 0 { 405 typeIDs = make([]uint, 0, len(ids)) 406 for i := range ids { 407 id, err := strconv.ParseUint(ids[i], 10, 0) 408 if err != nil { 409 log.WithError(err).WithField("type_id", ids[i]).Warn("couldn't convert Spend Type id") 410 continue 411 } 412 typeIDs = append(typeIDs, uint(id)) 413 } 414 } 415 416 // Sort 417 sortType := db.SortSpendsByDate 418 switch r.FormValue("sort") { 419 case "title": 420 sortType = db.SortSpendsByTitle 421 case "cost": 422 sortType = db.SortSpendsByCost 423 } 424 425 // Order 426 order := db.OrderByAsc 427 if r.FormValue("order") == "desc" { 428 order = db.OrderByDesc 429 } 430 431 return db.SearchSpendsArgs{ 432 Title: title, 433 Notes: notes, 434 After: after, 435 Before: before, 436 MinCost: minCost, 437 MaxCost: maxCost, 438 TypeIDs: typeIDs, 439 Sort: sortType, 440 Order: order, 441 // 442 TitleExactly: false, 443 NotesExactly: false, 444 } 445 } 446 447 type FooterTemplateData struct { 448 Version string 449 GitHash string 450 } 451 452 const ( 453 executeErrorMessage = "Can't execute template" 454 ) 455 456 func newInvalidURLMessage(err string) string { 457 return "Invalid URL: " + err 458 } 459 460 func newDBErrorMessage(err string) string { 461 return "DB error: " + err 462 }