inflation calculator implementation
All checks were successful
build / build (push) Successful in 9m36s
release-tag / release-image (push) Successful in 15m53s

This commit is contained in:
Tijl 2024-09-08 01:01:29 +02:00
parent 5d0bcaec77
commit c944e84771
Signed by: tijl
GPG Key ID: DAE24BFCD722F053
18 changed files with 424 additions and 43 deletions

1
.gitignore vendored
View File

@ -9,4 +9,5 @@ config.dev.yaml
blog/
.data/
data/
main

1
go.mod
View File

@ -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
View File

@ -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=

View 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)
}

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
{}

View 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
}

View 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
}

View File

@ -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()

View File

@ -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"
}

View File

@ -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"
}

BIN
main

Binary file not shown.

26
package-lock.json generated
View File

@ -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"
}
}
}
}

View File

@ -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"

View File

@ -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>

View 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>

View File

@ -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>

View File

@ -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>