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  }