inflation calculator implementation
This commit is contained in:
parent
5d0bcaec77
commit
c944e84771
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,4 +9,5 @@ config.dev.yaml
|
||||
blog/
|
||||
.data/
|
||||
data/
|
||||
main
|
||||
|
||||
|
1
go.mod
1
go.mod
@ -21,6 +21,7 @@ require (
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/go-echarts/go-echarts/v2 v2.4.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
|
||||
github.com/gofiber/template v1.8.3 // indirect
|
||||
github.com/gofiber/utils v1.1.0 // indirect
|
||||
|
2
go.sum
2
go.sum
@ -23,6 +23,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog=
|
||||
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
|
||||
github.com/go-echarts/go-echarts/v2 v2.4.1 h1:imBFGngJ9zv/2zJVjK3k0uLL+LzyPDgzeV7MWzxH0rs=
|
||||
github.com/go-echarts/go-echarts/v2 v2.4.1/go.mod h1:56YlvzhW/a+du15f3S2qUGNDfKnFOeJSThBIrVFHDtI=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
|
54
internal/apps/inflation/chart_renderer.go
Normal file
54
internal/apps/inflation/chart_renderer.go
Normal file
@ -0,0 +1,54 @@
|
||||
package inflation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
|
||||
log "git.tijl.dev/tijl/tijl.dev-core/modules/logger"
|
||||
"github.com/go-echarts/go-echarts/v2/render"
|
||||
tpls "github.com/go-echarts/go-echarts/v2/templates"
|
||||
)
|
||||
|
||||
type InternalRenderer struct {
|
||||
render.BaseRender
|
||||
c interface{}
|
||||
before []func()
|
||||
}
|
||||
|
||||
func NewRenderer(c interface{}, before ...func()) render.Renderer {
|
||||
return &InternalRenderer{c: c, before: before}
|
||||
}
|
||||
|
||||
func CustomRender(r *InternalRenderer) (string, error) {
|
||||
for _, fn := range r.before {
|
||||
fn()
|
||||
}
|
||||
|
||||
contents := []string{tpls.BaseTpl, tpls.ChartTpl}
|
||||
tpl := render.MustTemplate("chart", contents)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.ExecuteTemplate(&buf, "chart", r.c); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
log.Debug().Interface("bufst", buf.String()).Msg("t")
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (r *InternalRenderer) Render(w io.Writer) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var scriptRegex = regexp.MustCompile(`<script[^>]*>([\s\S]*?)<\/script>`)
|
||||
|
||||
func handleScriptElement(scriptTag string) string {
|
||||
matches := scriptRegex.FindStringSubmatch(scriptTag)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`window.onload = function() {%v}`, scriptTag)
|
||||
}
|
1
internal/apps/inflation/locales/en.json
Normal file
1
internal/apps/inflation/locales/en.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
1
internal/apps/inflation/locales/nl.json
Normal file
1
internal/apps/inflation/locales/nl.json
Normal file
@ -0,0 +1 @@
|
||||
{}
|
170
internal/apps/inflation/main.go
Normal file
170
internal/apps/inflation/main.go
Normal file
@ -0,0 +1,170 @@
|
||||
package inflation
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.tijl.dev/tijl/tijl.dev-core/internal/config"
|
||||
"git.tijl.dev/tijl/tijl.dev-core/modules/i18n"
|
||||
log "git.tijl.dev/tijl/tijl.dev-core/modules/logger"
|
||||
"git.tijl.dev/tijl/tijl.dev-core/modules/web"
|
||||
"github.com/go-echarts/go-echarts/v2/charts"
|
||||
"github.com/go-echarts/go-echarts/v2/opts"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type Datas struct {
|
||||
Name string
|
||||
ProcessFunction func(map[time.Time]float32) map[time.Time]float32
|
||||
}
|
||||
|
||||
var rawData = make(map[string]map[time.Time]float32)
|
||||
|
||||
var datas = map[string]Datas{
|
||||
/*
|
||||
"FPCPITOTLZGUSA": {
|
||||
Name: "CPI USA",
|
||||
ProcessFunction: func(m map[time.Time]float32) map[time.Time]float32 { return m },
|
||||
},
|
||||
*/
|
||||
"CPIAUCSL": {
|
||||
Name: "CPI USA BASE",
|
||||
ProcessFunction: toPercent,
|
||||
},
|
||||
"M2SL": {
|
||||
Name: "M2 USA",
|
||||
ProcessFunction: toPercent,
|
||||
},
|
||||
"CP0000EZ19M086NEST": {
|
||||
Name: "HICP ECB",
|
||||
ProcessFunction: toPercent,
|
||||
},
|
||||
}
|
||||
|
||||
//go:embed locales/*
|
||||
var Embed embed.FS
|
||||
|
||||
func Setup() {
|
||||
err := LoadData()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("inflation.Setup: Error loading data")
|
||||
}
|
||||
|
||||
i18n.RegisterTranslations(Embed, "locales")
|
||||
|
||||
web.RegisterAppSetupFunc(func(a *fiber.App) {
|
||||
a.Get("/app/inflation", func(c *fiber.Ctx) error {
|
||||
|
||||
processedData := make(map[string]map[time.Time]float32)
|
||||
for id, data := range datas {
|
||||
processedData[id] = data.ProcessFunction(rawData[id])
|
||||
}
|
||||
|
||||
line := charts.NewLine()
|
||||
|
||||
startYear, endYear := getYearRange(processedData)
|
||||
yearList := createYearList(startYear, endYear)
|
||||
for seriesID, timeData := range processedData {
|
||||
|
||||
aggregatedData := aggregateDataByYear(timeData)
|
||||
|
||||
yAxisT := []opts.LineData{}
|
||||
for _, year := range yearList {
|
||||
if value, exists := aggregatedData[year]; exists {
|
||||
yAxisT = append(yAxisT, opts.LineData{Value: value})
|
||||
} else {
|
||||
yAxisT = append(yAxisT, opts.LineData{Value: nil})
|
||||
}
|
||||
}
|
||||
|
||||
line.AddSeries(datas[seriesID].Name, yAxisT)
|
||||
}
|
||||
|
||||
line.SetXAxis(yearList)
|
||||
|
||||
line.SetGlobalOptions(
|
||||
charts.WithTitleOpts(opts.Title{
|
||||
Title: "Inflation",
|
||||
Subtitle: "Copyright Tijl 2024",
|
||||
}),
|
||||
charts.WithDataZoomOpts(opts.DataZoom{
|
||||
Orient: "horizontal",
|
||||
Type: "slider",
|
||||
}),
|
||||
)
|
||||
|
||||
rendered := line.RenderSnippet()
|
||||
|
||||
data := *web.Common(c)
|
||||
data["Title"] = "tmp"
|
||||
data["RenderedScript"] = template.JS(handleScriptElement(rendered.Script))
|
||||
data["RenderedElement"] = template.HTML(rendered.Element)
|
||||
return c.Render("apps/inflation/index", data, "layouts/base")
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
func LoadData() error {
|
||||
|
||||
var baseDataPath = config.Config.DataLocation + "/apps/inflation/data/"
|
||||
if _, err := os.Stat(baseDataPath); os.IsNotExist(err) {
|
||||
err := os.MkdirAll(baseDataPath, os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Load fred data
|
||||
const fredBaseUrl = "https://fred.stlouisfed.org/graph/fredgraph.csv?id="
|
||||
|
||||
for id := range datas {
|
||||
info, err := os.Stat(baseDataPath + id + ".csv")
|
||||
if !os.IsNotExist(err) {
|
||||
modTime := info.ModTime()
|
||||
currentTime := time.Now()
|
||||
|
||||
if modTime.Year() == currentTime.Year() && modTime.Month() == currentTime.Month() {
|
||||
csvdata, err := readCSV(baseDataPath + id + ".csv")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawData[id] = csvdata
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.Get(fredBaseUrl + id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to fetch data: %v", resp.Status)
|
||||
}
|
||||
|
||||
file, err := os.Create(baseDataPath + id + ".csv")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
csvdata, err := readCSV(baseDataPath + id + ".csv")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawData[id] = csvdata
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
113
internal/apps/inflation/util.go
Normal file
113
internal/apps/inflation/util.go
Normal file
@ -0,0 +1,113 @@
|
||||
package inflation
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getYearRange(processedData map[string]map[time.Time]float32) (int, int) {
|
||||
var startYear, endYear int
|
||||
|
||||
for _, timeData := range processedData {
|
||||
for date := range timeData {
|
||||
year := date.Year()
|
||||
if year < startYear || startYear == 0 {
|
||||
startYear = year
|
||||
}
|
||||
if year > endYear || endYear == 0 {
|
||||
endYear = year
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return startYear, endYear
|
||||
}
|
||||
func createYearList(startYear, endYear int) []int32 {
|
||||
years := make([]int32, 0, endYear-startYear+1)
|
||||
for year := startYear; year <= endYear; year++ {
|
||||
years = append(years, int32(year))
|
||||
}
|
||||
return years
|
||||
}
|
||||
|
||||
func aggregateDataByYear(data map[time.Time]float32) map[int32]float32 {
|
||||
yearlyData := make(map[int]float32)
|
||||
|
||||
for k, v := range data {
|
||||
if _, exists := yearlyData[k.Year()]; !exists {
|
||||
yearlyData[k.Year()] = 0
|
||||
}
|
||||
yearlyData[k.Year()] += v
|
||||
}
|
||||
|
||||
aggregatedData := make(map[int32]float32)
|
||||
for k, v := range yearlyData {
|
||||
aggregatedData[int32(k)] = v
|
||||
}
|
||||
|
||||
return aggregatedData
|
||||
}
|
||||
|
||||
func toPercent(m map[time.Time]float32) map[time.Time]float32 {
|
||||
var times []time.Time
|
||||
for t := range m {
|
||||
times = append(times, t)
|
||||
}
|
||||
sort.Slice(times, func(i, j int) bool {
|
||||
return times[i].Before(times[j])
|
||||
})
|
||||
percentageChanges := make(map[time.Time]float32)
|
||||
var previousValue float32
|
||||
for i, t := range times {
|
||||
currentValue := m[t]
|
||||
if i == 0 {
|
||||
percentageChanges[t] = 0
|
||||
} else {
|
||||
percentageChange := ((currentValue - previousValue) / previousValue) * 100
|
||||
percentageChanges[t] = percentageChange
|
||||
}
|
||||
previousValue = currentValue
|
||||
}
|
||||
|
||||
return percentageChanges
|
||||
}
|
||||
|
||||
func readCSV(filePath string) (map[time.Time]float32, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
csvReader := csv.NewReader(file)
|
||||
records, err := csvReader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dataMap := make(map[time.Time]float32)
|
||||
|
||||
for i, record := range records {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
date, err := time.Parse("2006-01-02", record[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing date on line %d: %v", i+1, err)
|
||||
}
|
||||
|
||||
value, err := strconv.ParseFloat(record[1], 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing float on line %d: %v", i+1, err)
|
||||
}
|
||||
|
||||
dataMap[date] = float32(value)
|
||||
}
|
||||
|
||||
return dataMap, nil
|
||||
}
|
@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.tijl.dev/tijl/tijl.dev-core/internal/apps/flags"
|
||||
"git.tijl.dev/tijl/tijl.dev-core/internal/apps/inflation"
|
||||
"git.tijl.dev/tijl/tijl.dev-core/internal/apps/uploader"
|
||||
"git.tijl.dev/tijl/tijl.dev-core/internal/assets"
|
||||
"git.tijl.dev/tijl/tijl.dev-core/internal/config"
|
||||
@ -50,6 +51,7 @@ func Listen() {
|
||||
// setup apps
|
||||
flags.Setup()
|
||||
uploader.Setup()
|
||||
inflation.Setup()
|
||||
|
||||
// setup web
|
||||
webinternal.Load()
|
||||
|
@ -14,5 +14,7 @@
|
||||
"logged_in": "You are now logged in!",
|
||||
"services": "Services",
|
||||
"error_long": "Something went wrong",
|
||||
"copy": "Copy"
|
||||
"open": "Open",
|
||||
"copy": "Copy",
|
||||
"info": "Info"
|
||||
}
|
||||
|
@ -14,5 +14,7 @@
|
||||
"logged_in": "Je bent nu ingelogd!",
|
||||
"services": "Services",
|
||||
"error_long": "Er ging iets fout",
|
||||
"copy": "Copy"
|
||||
"open": "Open",
|
||||
"copy": "Kopieer",
|
||||
"info": "Info"
|
||||
}
|
||||
|
26
package-lock.json
generated
26
package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"@tailwindcss/typography": "^0.5.14",
|
||||
"@types/node": "^22.4.2",
|
||||
"daisyui": "^4.12.10",
|
||||
"echarts": "^5.5.1",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.0.0"
|
||||
@ -771,6 +772,16 @@
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "5.5.1",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.1.tgz",
|
||||
"integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@ -1777,6 +1788,12 @@
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
@ -1974,6 +1991,15 @@
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz",
|
||||
"integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@
|
||||
"@tailwindcss/typography": "^0.5.14",
|
||||
"@types/node": "^22.4.2",
|
||||
"daisyui": "^4.12.10",
|
||||
"echarts": "^5.5.1",
|
||||
"tailwindcss": "^3.4.10",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^4.0.0"
|
||||
|
@ -14,10 +14,10 @@
|
||||
{{if (ne .Value "")}}
|
||||
<div class="join w-full">
|
||||
<input id="{{.Id}}" tabindex="-1" value="{{.Value}}" class="input input-bordered join-item w-full">
|
||||
<button onclick="copyToClipBoard('{{.Id}}')" class="btn join-item">Copy</button>
|
||||
<button onclick="copyToClipBoard('{{.Id}}')" class="btn join-item">{{$.T.copy}}</button>
|
||||
</div>
|
||||
{{end}}
|
||||
<a href="{{safeurl .Url}}" class='w-full btn {{if (ne .Value "")}}mt-2{{end}}'>Open</a>
|
||||
<a href="{{safeurl .Url}}" class='w-full btn {{if (ne .Value "")}}mt-2{{end}}'>{{$.T.open}}</a>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
|
11
web/views/apps/inflation/index.html
Normal file
11
web/views/apps/inflation/index.html
Normal file
@ -0,0 +1,11 @@
|
||||
<div class="text-white">
|
||||
{{.RenderedElement}}
|
||||
</div>
|
||||
|
||||
<script type="module" defer src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
const script = `{{.RenderedScript}}`;
|
||||
eval(script);
|
||||
};
|
||||
</script>
|
@ -1,4 +1,5 @@
|
||||
<!doctype html>
|
||||
<!-- Copyright 2024 Tijl -->
|
||||
<html lang='{{.Language}}'>
|
||||
|
||||
<head>
|
||||
@ -20,9 +21,9 @@
|
||||
{{embed}}
|
||||
</main>
|
||||
<!--
|
||||
<div id="loader" class="w-full h-full fixed top-0 left-0 bg-base-100 opacity-75 z-[10000] text-center content-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
<div id="loader" class="w-full h-full fixed top-0 left-0 bg-base-100 opacity-75 z-[10000] text-center content-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
-->
|
||||
</body>
|
||||
|
||||
|
@ -1,10 +1,4 @@
|
||||
<div class="mx-2">
|
||||
<div class="prose mb-4">
|
||||
<h1 class="m-0">Service's</h1>
|
||||
<hr class="m-0 w-full" />
|
||||
</div>
|
||||
|
||||
<div class="z-[99] mt-4 flex justify-center">
|
||||
<div class="z-[99] mt-4 flex justify-center">
|
||||
<div class="flex gap-2">
|
||||
{{range .Services}}
|
||||
<a href="#{{.Slug}}" id="service-{{.Slug}}" class="group"
|
||||
@ -24,20 +18,19 @@
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-2xl border-base-300 border-2" id="services-info">
|
||||
<div class="bg-base-100 rounded-2xl border-base-300 border-2" id="services-info">
|
||||
{{range .Services}}
|
||||
<div class="card-body hidden" id="service-{{.Slug}}-info">
|
||||
<h2 class="card-title">{{.Name}}</h2>
|
||||
<p>{{.Description}}</p>
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/service/{{.Slug}}/info" class="btn">Info</a>
|
||||
<a href="/service/{{.Slug}}" class="btn btn-primary">Open</a>
|
||||
<a href="/service/{{.Slug}}/info" class="btn">{{$.T.info}}</a>
|
||||
<a href="/service/{{.Slug}}" class="btn btn-primary">{{$.T.open}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
Loading…
Reference in New Issue
Block a user