From ea3cd21d0d3be442992d0709aef7ddf4875eee5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Snorre=20Magnus=20Dav=C3=B8en?= Date: Tue, 17 Oct 2023 19:12:06 +0200 Subject: [PATCH] feat: Dashboard posts aggregation levels (#8) Allow users to aggregate posts dashboard graphs by hour, day and week. --- db/reader.go | 54 ++++++++++++-- frontend/App.tsx | 180 +++++++++++++++++++++++++++++++--------------- package-lock.json | 74 +++++++++---------- package.json | 24 +++---- postcss.config.js | 2 +- server/server.go | 26 ++++--- 6 files changed, 238 insertions(+), 122 deletions(-) diff --git a/db/reader.go b/db/reader.go index d346634..abfbf3a 100644 --- a/db/reader.go +++ b/db/reader.go @@ -3,6 +3,7 @@ package db import ( "database/sql" "norsky/models" + "strconv" "strings" "time" @@ -71,16 +72,55 @@ func (reader *Reader) GetFeed(lang string, limit int, postId int64) ([]models.Po } // Returns the number of posts for each hour of the day from the last 24 hours -func (reader *Reader) GetPostCountPerHour(lang string) ([]models.PostsAggregatedByTime, error) { - timeAgg := "STRFTIME('%Y-%m-%d-%H', created_at, 'unixepoch')" +func (reader *Reader) GetPostCountPerTime(lang string, timeAgg string) ([]models.PostsAggregatedByTime, error) { + var sqlFormat string + var timeParse func(string) (time.Time, error) + + switch timeAgg { + case "hour": + sqlFormat = `STRFTIME('%Y-%m-%d-%H', created_at, 'unixepoch')` + timeParse = func(str string) (time.Time, error) { + return time.Parse("2006-01-02-15", str) + } + case "day": + sqlFormat = `STRFTIME('%Y-%m-%d', created_at, 'unixepoch')` + timeParse = func(str string) (time.Time, error) { + return time.Parse("2006-01-02", str) + } + case "week": + sqlFormat = "STRFTIME('%Y-%W', created_at, 'unixepoch')" + timeParse = func(str string) (time.Time, error) { + // Manually parse year and week number as separate integers + year, err := time.Parse("2006", str[:4]) + if err != nil { + return time.Time{}, err + } + week, err := strconv.ParseInt(str[5:], 10, 64) + if err != nil { + return time.Time{}, err + } + + _, weekOffset := year.ISOWeek() + weekOffset = weekOffset - 1 + firstDay := year.AddDate(0, 0, -int(year.Weekday())+weekOffset*7) + + // Add the number of weeks to the first day of the week + return firstDay.AddDate(0, 0, int(week)*7), nil + } + default: + sqlFormat = `STRFTIME('%Y-%m-%d-%H', created_at, 'unixepoch')` + timeParse = func(str string) (time.Time, error) { + return time.Parse("2006-01-02-15", str) + } + } sb := sqlbuilder.NewSelectBuilder() - sb.Select(timeAgg, "count(*) as count").From("posts").GroupBy(timeAgg) + sb.Select(sqlFormat, "count(*) as count").From("posts").GroupBy(sqlFormat) if lang != "" { sb.Join("post_languages", "posts.id = post_languages.post_id") sb.Where(sb.Equal("language", lang)) } - sb.GroupBy("strftime('%H', datetime(created_at, 'unixepoch'))") + sb.GroupBy(sqlFormat) sb.OrderBy("created_at").Asc() sql, args := sb.BuildWithFlavor(sqlbuilder.Flavor(sqlbuilder.SQLite)) @@ -99,14 +139,14 @@ func (reader *Reader) GetPostCountPerHour(lang string) ([]models.PostsAggregated var postCounts []models.PostsAggregatedByTime for rows.Next() { - var hour string + var sqlTime string var postCount models.PostsAggregatedByTime - if err := rows.Scan(&hour, &postCount.Count); err != nil { + if err := rows.Scan(&sqlTime, &postCount.Count); err != nil { continue // Skip this row } // Parse from YYYY-MM-DD-HH - postTime, error := time.Parse("2006-01-02-15", hour) + postTime, error := timeParse(sqlTime) if error == nil { postCount.Time = postTime diff --git a/frontend/App.tsx b/frontend/App.tsx index 1a4d6a4..408aa8d 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -4,78 +4,117 @@ import { onMount, createResource, Resource, + Accessor, + createComputed, + createMemo, + createEffect, } from "solid-js"; -import { Chart, Title, Tooltip, Legend, Colors, TimeScale } from "chart.js"; +import { Chart, Title, Tooltip, Legend, Colors, TimeScale, ChartDataset, ChartType, Point, TimeUnit, TimeSeriesScale } from "chart.js"; import { Line } from "solid-chartjs"; import { type ChartData, type ChartOptions } from "chart.js"; import colors from "tailwindcss/colors"; -import 'chartjs-adapter-date-fns'; +import "chartjs-adapter-date-fns"; import icon from "../assets/favicon.png"; // Get the URL from the import.meta object const host = import.meta.env.VITE_API_HOST; -const mapData = (data: any) => { - const mapped = data?.map(({ time, count }: any) => ({ x: time, y: count })).slice(data.length - 24, data.length)?? [] - return mapped + +interface Data { + time: string, + count: number +} + + +const mapData = (data: Data[]): Point[] => { + const mapped = + data + ?.map(({ time, count }: any) => ({ x: time, y: count })) + .slice(data.length - 24, data.length) ?? []; + return mapped; }; -const PostPerHourChart: Component<{ data: Resource }> = ({ data }) => { - /** - * You must register optional elements before using the chart, - * otherwise you will have the most primitive UI - */ - onMount(() => { - Chart.register(Title, Tooltip, Legend, Colors, TimeScale); - }); + +interface ChartDataProps { + data: Point[], + time: string +} + +const chartData = ({data, time}: ChartDataProps) => { + interface ChartData { + datasets: ChartDataset[], + labels: string[] + } const chartData: ChartData = { datasets: [ { - label: "Posts per hour", borderColor: colors.blue[500], fill: false, - data: [], tension: 0.2, + label: `Posts per ${time}`, + data, + type: 'line', }, ], + labels: [] }; - const chartOptions: ChartOptions = { + return chartData; +} + +const timeToUnit = (time: string): TimeUnit => { + switch (time) { + case "hour": + return 'hour' + case "day": + return 'day' + case "week": + return 'week' + default: + return 'hour' + } +} + +const chartOptions = ({time}: {time: string}) => { + + const options: ChartOptions = { responsive: true, maintainAspectRatio: false, scales: { x: { - type: 'time', + type: "timeseries", time: { // Luxon format string - tooltipFormat: 'hh:mm', + minUnit: timeToUnit(time), displayFormats: { - hour: 'E hh:mm' - } + hour: "HH:mm", + day: "EE dd.MM", + week: "I", + }, + tooltipFormat: "dd.MM.yyyy HH:mm", }, title: { display: true, - text: 'Date', + text: "Time", color: colors.zinc[400], }, adapters: { - date: { - } + date: {}, }, grid: { color: colors.zinc[800], }, ticks: { + maxRotation: 160, color: colors.zinc[400], - }, }, y: { title: { display: true, - text: 'Count', + text: "Count", color: colors.zinc[400], }, grid: { @@ -83,70 +122,97 @@ const PostPerHourChart: Component<{ data: Resource }> = ({ data }) => { }, ticks: { color: colors.zinc[400], - } - } + }, + }, }, plugins: { legend: { - display: false, - } - } - + display: false, + }, + }, }; + console.log(options) + return options; +} + + +const PostPerHourChart: Component<{ data: Resource, time: Accessor }> = ({ data, time }) => { + /** + * You must register optional elements before using the chart, + * otherwise you will have the most primitive UI + */ + onMount(() => { + Chart.register(Title, Tooltip, Legend, Colors, TimeScale, TimeSeriesScale); + }); + + const cdata = () => chartData({data: mapData(data()), time: time()}) + const coptions = createMemo(() => chartOptions({time: time()})) + + return ( -
+
+
); }; -const fetcher = (url: string) => fetch(url).then((res) => res.json()); +const fetcher = ([time, lang]: readonly string[]) => + fetch(`${host}/dashboard/posts-per-time?lang=${lang}&time=${time}`).then( + (res) => res.json() as Promise + ); -const PostPerHour: Component<{ lang: string, label: string }> = ({ lang, label }) => { +const PostPerTime: Component<{ + lang: string; + label: string; + time: Accessor; +}> = ({ lang, label, time }) => { // Create a new resource signal to fetch data from the API // That is createResource('http://localhost:3000/dashboard/posts-per-hour'); - const [data] = createResource( - `${host}/dashboard/posts-per-hour?lang=${lang}`, - fetcher - ); + + const [data] = createResource(() => [time(), lang] as const, fetcher); return (

{label}

- +
); }; const App: Component = () => { + const [time, setTime] = createSignal("hour"); return ( -
+
{/* Add a header here showing the Norsky logo and the name */}
Norsky logo

Norsky

-
-
- - - - +
+
+ {/* Selector to select time level: hour, day, week */} + +
+
+ + + +
diff --git a/package-lock.json b/package-lock.json index 388002d..5b40b92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,17 +14,17 @@ "chartjs-adapter-date-fns": "^3.0.0", "date-fns": "^2.30.0", "solid-chartjs": "^1.3.8", - "solid-js": "^1.7.6" + "solid-js": "^1.8.1" }, "devDependencies": { - "autoprefixer": "^10.4.14", + "autoprefixer": "^10.4.16", "concurrently": "^8.2.1", - "postcss": "^8.4.24", - "solid-devtools": "^0.27.3", - "tailwindcss": "^3.3.2", - "typescript": "^5.1.3", - "vite": "^4.3.9", - "vite-plugin-solid": "^2.7.0" + "postcss": "^8.4.31", + "solid-devtools": "^0.27.7", + "tailwindcss": "^3.3.3", + "typescript": "^5.2.2", + "vite": "^4.4.11", + "vite-plugin-solid": "^2.7.1" } }, "node_modules/@alloc/quick-lru": { @@ -1344,9 +1344,9 @@ } }, "node_modules/babel-plugin-jsx-dom-expressions": { - "version": "0.36.18", - "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.36.18.tgz", - "integrity": "sha512-8K0CHgzNMB0+1OC+GQf1O49Nc6DfHAoWDjY4YTW3W/3il5KrDKAj65723oPmya68kKKOkqDKuz+Zh1u7VFHthw==", + "version": "0.37.2", + "resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.37.2.tgz", + "integrity": "sha512-u3VKB+On86cYSLAbw9j0m0X8ZejL4MR7oG7TRlrMQ/y1mauR/ZpM2xkiOPZEUlzHLo1GYGlTdP9s5D3XuA6iSQ==", "dev": true, "dependencies": { "@babel/helper-module-imports": "7.18.6", @@ -1372,12 +1372,12 @@ } }, "node_modules/babel-preset-solid": { - "version": "1.7.12", - "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.7.12.tgz", - "integrity": "sha512-vNZn34Dv6IsWK/F59HhZlN8gP0ihZfkhPp8Lx/nxlY+rKtSZEAmmYlXWtds6EDKSiXoj2TEHuCcuqp6cO7oLSg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.8.0.tgz", + "integrity": "sha512-TCsC3kTNYRi+0/mHYFvC2VsSq++GZPFyHF3QTP7L37TXaVFD0HZQPyLQnf+waOGPHQuAhKXo0GEQziquSwBAVw==", "dev": true, "dependencies": { - "babel-plugin-jsx-dom-expressions": "^0.36.18" + "babel-plugin-jsx-dom-expressions": "^0.37.0" }, "peerDependencies": { "@babel/core": "^7.0.0" @@ -2635,9 +2635,9 @@ } }, "node_modules/seroval": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/seroval/-/seroval-0.5.1.tgz", - "integrity": "sha512-ZfhQVB59hmIauJG5Ydynupy8KHyr5imGNtdDhbZG68Ufh1Ynkv9KOYOAABf71oVbQxJ8VkWnMHAjEHE7fWkH5g==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-0.10.4.tgz", + "integrity": "sha512-TdaE9JkoATjKu+vjwllieX8zWyBTUVxbgWDnOsDJFfmKbM7vLSukuCXuD3pO3kkCtX4daywOW8ps2VCdPhS8/w==", "engines": { "node": ">=10" } @@ -2691,12 +2691,12 @@ } }, "node_modules/solid-js": { - "version": "1.7.12", - "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.7.12.tgz", - "integrity": "sha512-QoyoOUKu14iLoGxjxWFIU8+/1kLT4edQ7mZESFPonsEXZ//VJtPKD8Ud1aTKzotj+MNWmSs9YzK6TdY+fO9Eww==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.8.1.tgz", + "integrity": "sha512-HU4tB/vWY5/0P9GzbvePjK1aucNqUcF1XlAirZBjKkrkWG8XNIN9HSjscTC/nbl3A6JWjrW+OLcPEvWxsMhdng==", "dependencies": { "csstype": "^3.1.0", - "seroval": "^0.5.0" + "seroval": "^0.10.4" } }, "node_modules/solid-refresh": { @@ -2964,9 +2964,9 @@ "dev": true }, "node_modules/vite": { - "version": "4.4.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.10.tgz", - "integrity": "sha512-TzIjiqx9BEXF8yzYdF2NTf1kFFbjMjUSV0LFZ3HyHoI3SGSPLnnFUKiIQtL3gl2AjHvMrprOvQ3amzaHgQlAxw==", + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.4.11.tgz", + "integrity": "sha512-ksNZJlkcU9b0lBwAGZGGaZHCMqHsc8OpgtoYhsQ4/I2v5cnpmmmqe5pM4nv/4Hn6G/2GhTdj0DhZh2e+Er1q5A==", "dev": true, "dependencies": { "esbuild": "^0.18.10", @@ -3019,18 +3019,18 @@ } }, "node_modules/vite-plugin-solid": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.7.0.tgz", - "integrity": "sha512-avp/Jl5zOp/Itfo67xtDB2O61U7idviaIp4mLsjhCa13PjKNasz+IID0jYTyqUp9SFx6/PmBr6v4KgDppqompg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.20.5", - "@babel/preset-typescript": "^7.18.6", - "@types/babel__core": "^7.1.20", - "babel-preset-solid": "^1.7.2", - "merge-anything": "^5.1.4", - "solid-refresh": "^0.5.0", - "vitefu": "^0.2.3" + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.7.1.tgz", + "integrity": "sha512-1rTuJdsDqCZiAWfiEkUeRRz4MaxWhqzgJ6PURN+bBSp4uACA/esgYN9Un++YE+xeXUOHlOwTF+VSdvieEcMz0w==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.0", + "@babel/preset-typescript": "^7.23.0", + "@types/babel__core": "^7.20.2", + "babel-preset-solid": "^1.8.0", + "merge-anything": "^5.1.7", + "solid-refresh": "^0.5.3", + "vitefu": "^0.2.4" }, "peerDependencies": { "solid-js": "^1.7.2", diff --git a/package.json b/package.json index c25c450..6eb2cf1 100644 --- a/package.json +++ b/package.json @@ -5,22 +5,22 @@ "type": "module", "scripts": { "start:go": "NORSKY_DATABASE=feed.db NORSKY_HOSTNAME=norsky.snorre.io go run main.go serve", - "start:vite": "bunx --bun vite", + "start:vite": "vite", "start": "concurrently \"npm run start:vite\" \"npm run start:go\"", - "dev": "bunx --bun vite", - "build": "bunx --bun vite build", - "serve": "bunx --bun vite preview" + "dev": "vite", + "build": "vite build", + "serve": "vite preview" }, "license": "MIT", "devDependencies": { - "autoprefixer": "^10.4.14", + "autoprefixer": "^10.4.16", "concurrently": "^8.2.1", - "postcss": "^8.4.24", - "solid-devtools": "^0.27.3", - "tailwindcss": "^3.3.2", - "typescript": "^5.1.3", - "vite": "^4.3.9", - "vite-plugin-solid": "^2.7.0" + "postcss": "^8.4.31", + "solid-devtools": "^0.27.7", + "tailwindcss": "^3.3.3", + "typescript": "^5.2.2", + "vite": "^4.4.11", + "vite-plugin-solid": "^2.7.1" }, "dependencies": { "@solid-primitives/refs": "^1.0.5", @@ -28,6 +28,6 @@ "chartjs-adapter-date-fns": "^3.0.0", "date-fns": "^2.30.0", "solid-chartjs": "^1.3.8", - "solid-js": "^1.7.6" + "solid-js": "^1.8.1" } } diff --git a/postcss.config.js b/postcss.config.js index 5d74a80..6f8cf08 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,4 +1,4 @@ -module.exports = { +export default { purge: ['./index.html', './frontend/**/*.{vue,js,ts,jsx,tsx}'], plugins: { tailwindcss: {}, diff --git a/server/server.go b/server/server.go index bf1a09e..72dd105 100644 --- a/server/server.go +++ b/server/server.go @@ -164,26 +164,36 @@ func Server(config *ServerConfig) *fiber.App { return c.Status(400).SendString("Invalid feed") }) - app.Get("/dashboard/posts-per-hour", func(c *fiber.Ctx) error { + app.Get("/dashboard/posts-per-time", func(c *fiber.Ctx) error { // Get the feed query parameters and parse the limit lang := c.Query("lang", "") + time := c.Query("time", "") - // Get the posts per hour - postsPerHour, err := config.Reader.GetPostCountPerHour(lang) + if time == "" { + time = "hour" + } + + // check if time is hour, day or week + if time != "hour" && time != "day" && time != "week" { + return c.Status(400).SendString("Invalid time") + } + + // Get posts per time + postsPerTime, err := config.Reader.GetPostCountPerTime(lang, time) if err != nil { log.WithFields(log.Fields{ "error": err, - }).Error("Error getting posts per hour") + }).Error("Error getting posts per time") - return c.Status(500).SendString("Error getting posts per hour") + return c.Status(500).SendString("Error getting posts per time") } log.WithFields(log.Fields{ "lang": lang, - "count": len(postsPerHour), - }).Info("Get posts per hour") + "count": len(postsPerTime), + }).Info("Get posts per time") - return c.Status(200).JSON(postsPerHour) + return c.Status(200).JSON(postsPerTime) }) app.Get("/dashboard/feed", func(c *fiber.Ctx) error {