Skip to content

Commit

Permalink
feat: Dashboard posts aggregation levels (#8)
Browse files Browse the repository at this point in the history
Allow users to aggregate posts dashboard graphs by hour, day and week.
  • Loading branch information
snorremd authored Oct 17, 2023
1 parent 6961669 commit ea3cd21
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 122 deletions.
54 changes: 47 additions & 7 deletions db/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package db
import (
"database/sql"
"norsky/models"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down
180 changes: 123 additions & 57 deletions frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,149 +4,215 @@ 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<any> }> = ({ 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: {
color: colors.zinc[800],
},
ticks: {
color: colors.zinc[400],
}
}
},
},
},
plugins: {
legend: {
display: false,
}
}

display: false,
},
},
};

console.log(options)
return options;
}


const PostPerHourChart: Component<{ data: Resource<Data[]>, time: Accessor<string> }> = ({ 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 (
<div>
<div class="flex flex-col">

<Line
data={{
...chartData,
datasets: [
{
...chartData.datasets[0],
data: data.state === 'ready' ? mapData(data()) : [],
},
],
}}
options={chartOptions}
data={cdata()}
options={coptions()}
width={500}
height={200}

/>
</div>
);
};

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<Data[]>
);

const PostPerHour: Component<{ lang: string, label: string }> = ({ lang, label }) => {
const PostPerTime: Component<{
lang: string;
label: string;
time: Accessor<string>;
}> = ({ 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 (
<div>
<h1 class="text-2xl text-sky-300 text-center pb-8">{label}</h1>
<PostPerHourChart data={data} />
<PostPerHourChart time={time} data={data} />
</div>
);
};

const App: Component = () => {
const [time, setTime] = createSignal<string>("hour");

return (
<div class="flex flex-col p-16 gap-16">
<div class="flex flex-col p-6 md:p-8 lg:p-16 gap-16">
{/* Add a header here showing the Norsky logo and the name */}
<div class="flex justify-start items-center gap-4">
<img src={icon} alt="Norsky logo" class="w-16 h-16" />
<h1 class="text-4xl text-sky-300">Norsky</h1>
</div>
<div class="flex">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-16 w-full">
<PostPerHour lang="" label="All languages"/>
<PostPerHour lang="nb" label="Norwegian bokmål"/>
<PostPerHour lang="nn" label="Norwegian nynorsk"/>
<PostPerHour lang="smi" label="Sami"/>
<div class="flex flex-col">
<div class="flex flex-row gap-4 justify-end mb-8">
{/* Selector to select time level: hour, day, week */}
<select
class="bg-zinc-800 text-zinc-300 rounded-md p-2"
value={time()}
onChange={(e) => setTime(e.currentTarget.value)}
>
<option value="hour">Hour</option>
<option value="day">Day</option>
<option value="week">Week</option>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-4 gap-16 w-full">
<PostPerTime time={time} lang="" label="All languages" />
<PostPerTime time={time} lang="nb" label="Norwegian bokmål" />
<PostPerTime time={time} lang="nn" label="Norwegian nynorsk" />
<PostPerTime time={time} lang="smi" label="Sami" />
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit ea3cd21

Please sign in to comment.