package http import ( "embed" "encoding/json" "fmt" "io" "io/fs" "math" "net/http" "os" "slices" "strconv" "strings" "time" "gogs.inhome.blapointe.com/ana-ledger/src/ana" "gogs.inhome.blapointe.com/ana-ledger/src/ledger" "gogs.inhome.blapointe.com/ana-ledger/src/view" ) //go:embed public/* var _staticFileDir embed.FS type Router struct { files ledger.Files like struct { name string before string after string } group struct { name string date string } bpiPath string } func NewRouter(files ledger.Files, likeName, likeBefore, likeAfter string, groupName, groupDate string, bpiPath string) Router { r := Router{} r.files = files r.like.name = likeName r.like.before = likeBefore r.like.after = likeAfter r.group.name = groupName r.group.date = groupDate r.bpiPath = bpiPath return r } func (router Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch strings.Split(r.URL.Path, "/")[1] { case "api": router.API(w, r) default: router.FS(w, r) } } func (router Router) FS(w http.ResponseWriter, r *http.Request) { if os.Getenv("DEBUG") != "" { http.FileServer(http.Dir("./http/public")).ServeHTTP(w, r) } else { sub, err := fs.Sub(_staticFileDir, "public") if err != nil { panic(err) } http.FileServer(http.FS(sub)).ServeHTTP(w, r) } } func (router Router) API(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/transactions": router.APITransactions(w, r) case "/api/amend": router.APIAmend(w, r) case "/api/create": router.APICreate(w, r) case "/api/trends": router.APITrends(w, r) case "/api/reg", "/api/bal": router.APIReg(w, r) default: http.NotFound(w, r) } } func (router Router) APITransactions(w http.ResponseWriter, r *http.Request) { bpis, err := router.bpis() if err != nil { panic(err) } deltas, err := router.files.Deltas() if err != nil { panic(err) } houseRelatedDeltas := deltas.Like(ledger.LikeTransactions( deltas.Like(ledger.LikeName(`^House`))..., )) recent := time.Hour * 24 * 365 / 6 normalizer := ana.NewDefaultNormalizer() { deltas := houseRelatedDeltas. Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01"))) balances := houseRelatedDeltas.Balances(). Like(`^(Zach|Bel|House[^:]*:Debts:)`). Group(`^[^:]*`). WithBPIs(bpis) transactions := houseRelatedDeltas. Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01"))). Transactions() normalized := normalizer.Normalize(houseRelatedDeltas).Balances(). Like(`^(Zach|Bel):`). Group(`^[^:]*:`). WithBPIs(bpis) var biggest float64 for _, v := range normalized { if v := math.Abs(v["$"]); v > biggest { biggest = v } } ks := []string{} for k := range normalized { ks = append(ks, k) } for _, k := range ks { v := normalized[k] if v := math.Abs(v["$"]); v < biggest { normalizedDelta := biggest - v normalizedFactor := normalizer.NormalizeFactor(k, time.Now().Format("2006-01-02")) normalized[fmt.Sprintf(`(%s trailing $)`, k)] = ledger.Balance{"$": normalizedDelta * normalizedFactor} } } json.NewEncoder(w).Encode(map[string]any{ "deltas": deltas, "balances": balances, "normalized": normalized, "transactions": transactions, }) } } func (router Router) APITrends(w http.ResponseWriter, r *http.Request) { bpis, err := router.bpis() if err != nil { panic(err) } deltas, err := router.files.Deltas() if err != nil { panic(err) } recent := time.Hour * 24 * 365 / 2 pie := func(title, groupName string, min int) { recentHouseRelatedDeltas := deltas. Like(ledger.LikeTransactions( deltas.Like(ledger.LikeName(`^House`))..., )). Like(ledger.LikeAfter(time.Now().Add(-1 * recent).Format("2006-01"))). Group(ledger.GroupName(groupName)). Group(ledger.GroupDate(`^[0-9]*-[0-9]*`)). Like(ledger.LikeNotName(`^$`)) monthsToDeltas := map[string]ledger.Deltas{} for _, delta := range recentHouseRelatedDeltas { monthsToDeltas[delta.Date] = append(monthsToDeltas[delta.Date], delta) } months := []string{} for k := range monthsToDeltas { months = append(months, k) } slices.Sort(months) catToMonth := map[string][]int{} for _, month := range months { balances := monthsToDeltas[month].Balances().WithBPIs(bpis) for category, balance := range balances { catToMonth[category] = append(catToMonth[category], int(balance[ledger.USD])) } } chart := view.NewChart("pie") ttl := 0 for cat, month := range catToMonth { for i := 0; i < len(months)-len(month); i++ { month = append(month, 0) } slices.Sort(month) median := month[len(month)/2] ttl += median if median > min { chart.AddY(cat, []int{median}) } } fmt.Fprintln(w, "

", title, "($", ttl, ")

") if err := chart.Render(w); err != nil { panic(err) } } pie(fmt.Sprintf("Median Monthly Spending Since %dmo ago", int(recent/time.Hour/24/30)), `Withdrawal:[0-9]*`, 50) pie("Median Monthly Spending (detailed)", `Withdrawal:[0-9]*:[^:]*`, 25) pie("Median Monthly Spending (MORE detailed)", `Withdrawal:[0-9]*:[^:]*:[^:]*`, 10) } func (router Router) APICreate(w http.ResponseWriter, r *http.Request) { new := ledger.Delta{ Name: "TODO", Date: time.Now().Format(`2006-01-02`), Description: "TODO", Currency: "$", Value: 0.01, } if err := router.files.Add("HouseyMcHouseface:Withdrawal:0:TODO", new); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } func (router Router) APIAmend(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) var req struct { Old ledger.Delta Now ledger.Delta } if err := json.Unmarshal(b, &req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } req.Now.Name = strings.ReplaceAll(req.Now.Name, " ", "_") if err := router.files.Amend(req.Old, req.Now); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } func (router Router) APIReg(w http.ResponseWriter, r *http.Request) { deltas, err := router.files.Deltas() if err != nil { panic(err) } deltas = deltas.Group(ledger.GroupName(router.group.name), ledger.GroupDate(router.group.date)) like := ledger.Likes{ ledger.LikeName(router.like.name), ledger.LikeBefore(router.like.before), ledger.LikeAfter(router.like.after), } foolike := make(ledger.Likes, 0) for _, v := range r.URL.Query()["likeName"] { foolike = append(foolike, ledger.LikeName(v)) } for _, v := range r.URL.Query()["likeAfter"] { foolike = append(foolike, ledger.LikeAfter(v)) } for _, v := range r.URL.Query()["likeBefore"] { foolike = append(foolike, ledger.LikeBefore(v)) } if len(foolike) == 0 { foolike = like } deltas = deltas.Like(foolike...) // MODIFIERS for i, whatIf := range r.URL.Query()["whatIf"] { fields := strings.Fields(whatIf) date := "2001-01" name := fields[0] currency := ledger.Currency(fields[1]) value, err := strconv.ParseFloat(fields[2], 64) if err != nil { panic(err) } deltas = append(deltas, ledger.Delta{ Date: date, Name: name, Value: value, Currency: currency, Description: fmt.Sprintf("?whatIf[%d]", i), }) } register := deltas.Register() predicted := make(ledger.Register) if predictionMonths, err := strconv.ParseInt(r.URL.Query().Get("predictionMonths"), 10, 16); err == nil && predictionMonths > 0 { window := time.Hour * 24.0 * 365.0 / 12.0 * time.Duration(predictionMonths) // TODO whatif prediction := make(ana.Prediction, 0) for _, spec := range r.URL.Query()["prediction"] { idx := strings.Index(spec, "=") k := spec[:idx] fields := strings.Fields(spec[idx+1:]) switch k { case "interest": apy, err := strconv.ParseFloat(fields[2], 64) if err != nil { panic(err) } prediction = append(prediction, ana.NewInterestPredictor(fields[0], fields[1], apy)) case "autoContributions": prediction = append(prediction, ana.NewAutoContributionPredictor(register)) case "contributions": name := fields[0] currency := ledger.Currency(fields[1]) value, err := strconv.ParseFloat(fields[2], 64) if err != nil { panic(err) } prediction = append(prediction, ana.NewContributionPredictor(ledger.Balances{name: ledger.Balance{currency: value}})) default: panic(k) } } predicted = prediction.Predict(register, window) for _, currencyRate := range r.URL.Query()["predictFixedGrowth"] { currency := strings.Split(currencyRate, "=")[0] rate, err := strconv.ParseFloat(strings.Split(currencyRate, "=")[1], 64) if err != nil { panic(err) } bpis, err := router.bpis() if err != nil { panic(err) } bpis, err = ana.BPIsWithFixedGrowthPrediction(bpis, window, currency, rate) if err != nil { panic(err) } } } if r.URL.Query().Get("bpi") == "true" { bpis, err := router.bpis() if err != nil { panic(err) } register = register.WithBPIs(bpis) predicted = predicted.WithBPIs(bpis) } if zoomStart, err := time.ParseInLocation("2006-01", r.URL.Query().Get("zoomStart"), time.Local); err == nil { register = register.Between(zoomStart, time.Now().Add(time.Hour*24*365*100)) predicted = predicted.Between(zoomStart, time.Now().Add(time.Hour*24*365*100)) } // /MODIFIERS dates := register.Dates() names := register.Names() for _, date := range predicted.Dates() { found := false for i := range dates { found = found || dates[i] == date } if !found { dates = append(dates, date) } } for _, name := range predicted.Names() { found := false for i := range names { found = found || names[i] == name } if !found { names = append(names, name) } } instant := map[string]string{} toChart := func(cumulative bool, display string, reg ledger.Register) view.Chart { nameCurrencyDateValue := map[string]map[ledger.Currency]map[string]float64{} for date, balances := range reg { for name, balance := range balances { for currency, value := range balance { if _, ok := nameCurrencyDateValue[name]; !ok { nameCurrencyDateValue[name] = make(map[ledger.Currency]map[string]float64) } if _, ok := nameCurrencyDateValue[name][currency]; !ok { nameCurrencyDateValue[name][currency] = make(map[string]float64) } nameCurrencyDateValue[name][currency][date] += value } } } chart := view.NewChart("line") if v := display; v != "" { chart = view.NewChart(v) } chart.AddX(dates) if cumulative { for _, name := range names { currencyDateValue := nameCurrencyDateValue[name] for currency, dateValue := range currencyDateValue { series := make([]int, len(dates)) for i := range dates { var lastValue float64 for j := range dates[:i+1] { if newLastValue, ok := dateValue[dates[j]]; ok { lastValue = newLastValue } } series[i] = int(lastValue) } key := fmt.Sprintf("%s (%s)", name, currency) for i := range dates { if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) { series[i] = 0 } else { instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i]) } } if slices.Min(series) != 0 || slices.Max(series) != 0 { chart.AddY(key, series) } } } } else { for _, name := range names { currencyDateValue := nameCurrencyDateValue[name] for currency, dateValue := range currencyDateValue { series := make([]int, len(dates)) for i := range dates { var prevValue float64 var lastValue float64 for j := range dates[:i+1] { if newLastValue, ok := dateValue[dates[j]]; ok { prevValue = lastValue lastValue = newLastValue } } series[i] = int(lastValue - prevValue) } for i := range series { // TODO no prior so no delta if series[i] != 0 { series[i] = 0 break } } key := fmt.Sprintf("%s (%s)", name, currency) for i := range dates { if !(reg.Dates()[0] <= dates[i] && dates[i] <= reg.Dates()[len(reg.Dates())-1]) { series[i] = 0 } else { instant[key] = fmt.Sprintf("@%s %v", dates[i], series[i]) } } if slices.Min(series) != 0 || slices.Max(series) != 0 { chart.AddY(key, series) } } } } return chart } primary := toChart(r.URL.Path == "/api/bal", r.URL.Query().Get("chart"), register) if len(predicted) > 0 { primary.Overlap(toChart(r.URL.Path == "/api/bal", "line", predicted)) } if err := primary.Render(w); err != nil { panic(err) } for k, v := range instant { fmt.Fprintf(w, "
\n%s = %s", k, v) } } func (r Router) bpis() (ledger.BPIs, error) { if r.bpiPath == "" { return make(ledger.BPIs), nil } return ledger.NewBPIs(r.bpiPath) }