Skip to content

Commit

Permalink
feat(dashboard): adding monthly user registration stat
Browse files Browse the repository at this point in the history
  • Loading branch information
tomgobich committed Jan 4, 2025
1 parent 1c01a5c commit 95ae52e
Show file tree
Hide file tree
Showing 16 changed files with 2,452 additions and 251 deletions.
17 changes: 10 additions & 7 deletions app/actions/dashboard/get_dashboard_counts.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { MonthlyStat } from '#actions/stats/get_monthly'
import UserStats from '#actions/stats/user_stats'
import Collection from '#models/collection'
import Post from '#models/post'
import Taxonomy from '#models/taxonomy'
import User from '#models/user'

export interface GetDashboardCountsContract {
posts: BigInt
postSeconds: BigInt
series: BigInt
topics: BigInt
users: BigInt
users: {
total: bigint
monthly: MonthlyStat[]
}
}

export default class GetDashboardCounts {
Expand All @@ -18,7 +22,10 @@ export default class GetDashboardCounts {
postSeconds: await this.#countPostSeconds(),
series: await this.#countSeries(),
topics: await this.#countTopics(),
users: await this.#countUsers(),
users: {
total: await UserStats.getTotal(),

Check failure on line 26 in app/actions/dashboard/get_dashboard_counts.ts

View workflow job for this annotation

GitHub Actions / build

Type 'BigInt' is not assignable to type 'bigint'.
monthly: await UserStats.getMonthlyRegistrations(),
},
}
}

Expand All @@ -41,8 +48,4 @@ export default class GetDashboardCounts {
static async #countTopics() {
return Taxonomy.query().getCount()
}

static async #countUsers() {
return User.query().getCount()
}
}
47 changes: 47 additions & 0 deletions app/actions/stats/get_monthly.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import db from '@adonisjs/lucid/services/db'
import { DatabaseQueryBuilderContract } from '@adonisjs/lucid/types/querybuilder'
import { DateTime } from 'luxon'

type GetMonthlyOptions = {
monthlyColumn?: string
aggregateColumn?: string
startDate?: DateTime<true>
}

export type MonthlyStat = {
month: string
total: number
}

export default class GetMonthly {
static count(query: DatabaseQueryBuilderContract<MonthlyStat>, options: GetMonthlyOptions) {
const agg = options.aggregateColumn || '*'
const final = this.#group(query, options).count(agg, 'total')
return this.#toType(final)
}

static sum(query: DatabaseQueryBuilderContract<MonthlyStat>, options: GetMonthlyOptions) {
const agg = options.aggregateColumn || '*'
const final = this.#group(query, options).sum(agg, 'total')
return this.#toType(final)
}

static #group(query: DatabaseQueryBuilderContract<MonthlyStat>, options: GetMonthlyOptions) {
const timestamp = options.monthlyColumn || 'created_at'
const startDate = options.startDate || DateTime.now().minus({ year: 1 }).startOf('month')

return query
.whereRaw(db.raw('?? > ?', [timestamp, startDate.toSQLDate()]))
.select(db.raw('SUBSTR(CAST(?? AS TEXT), 1, 7) AS month', timestamp)) // YYYY-MM
.orderByRaw('month')
.groupByRaw('month')
}

static async #toType(query: DatabaseQueryBuilderContract<MonthlyStat>) {
const results = await query
return results.map((r) => ({
month: DateTime.fromFormat(r.month, 'yyyy-MM').toFormat('MMM yyyy'),
total: Number(r.total),
}))
}
}
16 changes: 16 additions & 0 deletions app/actions/stats/user_stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import User from '#models/user'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import GetMonthly, { MonthlyStat } from './get_monthly.js'

Check failure on line 4 in app/actions/stats/user_stats.ts

View workflow job for this annotation

GitHub Actions / build

'MonthlyStat' is declared but its value is never read.

export default class UserStats {
static async getTotal() {
return User.query().getCount()
}

static async getMonthlyRegistrations(
startDate: DateTime<true> = DateTime.now().minus({ year: 1 }).startOf('month')
) {
return GetMonthly.count(db.from('users'), { startDate })
}
}
5 changes: 5 additions & 0 deletions components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare module 'vue' {
AlertDialogTitle: typeof import('./inertia/components/ui/alert-dialog/AlertDialogTitle.vue')['default']
AlertDialogTrigger: typeof import('./inertia/components/ui/alert-dialog/AlertDialogTrigger.vue')['default']
AlertTitle: typeof import('./inertia/components/ui/alert/AlertTitle.vue')['default']
AreaChart: typeof import('./inertia/components/ui/chart-area/AreaChart.vue')['default']
AssetUpload: typeof import('./inertia/components/AssetUpload.vue')['default']
Autocomplete: typeof import('./inertia/components/Autocomplete.vue')['default']
Avatar: typeof import('./inertia/components/ui/avatar/Avatar.vue')['default']
Expand All @@ -39,6 +40,10 @@ declare module 'vue' {
CardFooter: typeof import('./inertia/components/ui/card/CardFooter.vue')['default']
CardHeader: typeof import('./inertia/components/ui/card/CardHeader.vue')['default']
CardTitle: typeof import('./inertia/components/ui/card/CardTitle.vue')['default']
ChartCrosshair: typeof import('./inertia/components/ui/chart/ChartCrosshair.vue')['default']
ChartLegend: typeof import('./inertia/components/ui/chart/ChartLegend.vue')['default']
ChartSingleTooltip: typeof import('./inertia/components/ui/chart/ChartSingleTooltip.vue')['default']
ChartTooltip: typeof import('./inertia/components/ui/chart/ChartTooltip.vue')['default']
Checkbox: typeof import('./inertia/components/ui/checkbox/Checkbox.vue')['default']
Collapsible: typeof import('./inertia/components/ui/collapsible/Collapsible.vue')['default']
CollapsibleContent: typeof import('./inertia/components/ui/collapsible/CollapsibleContent.vue')['default']
Expand Down
139 changes: 139 additions & 0 deletions inertia/components/ui/chart-area/AreaChart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<script setup lang="ts" generic="T extends Record<string, any>">
import type { BaseChartProps } from '.'
import { ChartCrosshair, ChartLegend, defaultColors } from '~/components/ui/chart'
import { cn } from '~/lib/utils'
import { type BulletLegendItemInterface, CurveType } from '@unovis/ts'
import { Area, Axis, Line } from '@unovis/ts'
import { VisArea, VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
import { useMounted } from '@vueuse/core'
import { useId } from 'radix-vue'
import { type Component, computed, ref } from 'vue'
const props = withDefaults(defineProps<BaseChartProps<T> & {
/**
* Render custom tooltip component.
*/
customTooltip?: Component
/**
* Type of curve
*/
curveType?: CurveType
/**
* Controls the visibility of gradient.
* @default true
*/
showGradiant?: boolean
}>(), {
curveType: CurveType.MonotoneX,
filterOpacity: 0.2,
margin: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
showXAxis: true,
showYAxis: true,
showTooltip: true,
showLegend: true,
showGridLine: true,
showGradiant: true,
})
const emits = defineEmits<{
legendItemClick: [d: BulletLegendItemInterface, i: number]
}>()
type KeyOfT = Extract<keyof T, string>
type Data = typeof props.data[number]
const chartRef = useId()
const index = computed(() => props.index as KeyOfT)
const colors = computed(() => props.colors?.length ? props.colors : defaultColors(props.categories.length))
const legendItems = ref<BulletLegendItemInterface[]>(props.categories.map((category, i) => ({
name: category,
color: colors.value[i],
inactive: false,
})))
const isMounted = useMounted()
function handleLegendItemClick(d: BulletLegendItemInterface, i: number) {
emits('legendItemClick', d, i)
}
</script>

<template>
<div :class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')">
<ChartLegend v-if="showLegend" v-model:items="legendItems" @legend-item-click="handleLegendItemClick" />

<VisXYContainer :style="{ height: isMounted ? '100%' : 'auto' }" :margin="{ left: 20, right: 20 }" :data="data">
<svg width="0" height="0">
<defs>
<linearGradient v-for="(color, i) in colors" :id="`${chartRef}-color-${i}`" :key="i" x1="0" y1="0" x2="0" y2="1">
<template v-if="showGradiant">
<stop offset="5%" :stop-color="color" stop-opacity="0.4" />
<stop offset="95%" :stop-color="color" stop-opacity="0" />
</template>
<template v-else>
<stop offset="0%" :stop-color="color" />
</template>
</linearGradient>
</defs>
</svg>

<ChartCrosshair v-if="showTooltip" :colors="colors" :items="legendItems" :index="index" :custom-tooltip="customTooltip" />

<template v-for="(category, i) in categories" :key="category">
<VisArea
:x="(d: Data, i: number) => i"
:y="(d: Data) => d[category]"
color="auto"
:curve-type="curveType"
:attributes="{
[Area.selectors.area]: {
fill: `url(#${chartRef}-color-${i})`,
},
}"
:opacity="legendItems.find(item => item.name === category)?.inactive ? filterOpacity : 1"
/>
</template>

<template v-for="(category, i) in categories" :key="category">
<VisLine
:x="(d: Data, i: number) => i"
:y="(d: Data) => d[category]"
:color="colors[i]"
:curve-type="curveType"
:attributes="{
[Line.selectors.line]: {
opacity: legendItems.find(item => item.name === category)?.inactive ? filterOpacity : 1,
},
}"
/>
</template>

<VisAxis
v-if="showXAxis"
type="x"
:tick-format="xFormatter ?? ((v: number) => data[v]?.[index])"
:grid-line="false"
:tick-line="false"
tick-text-color="hsl(var(--vis-text-color))"
/>
<VisAxis
v-if="showYAxis"
type="y"
:tick-line="false"
:tick-format="yFormatter"
:domain-line="false"
:grid-line="showGridLine"
:attributes="{
[Axis.selectors.grid]: {
class: 'text-muted',
},
}"
tick-text-color="hsl(var(--vis-text-color))"
/>

<slot />
</VisXYContainer>
</div>
</template>
66 changes: 66 additions & 0 deletions inertia/components/ui/chart-area/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export { default as AreaChart } from './AreaChart.vue'

import type { Spacing } from '@unovis/ts'

type KeyOf<T extends Record<string, any>> = Extract<keyof T, string>

export interface BaseChartProps<T extends Record<string, any>> {
/**
* The source data, in which each entry is a dictionary.
*/
data: T[]
/**
* Select the categories from your data. Used to populate the legend and toolip.
*/
categories: KeyOf<T>[]
/**
* Sets the key to map the data to the axis.
*/
index: KeyOf<T>
/**
* Change the default colors.
*/
colors?: string[]
/**
* Margin of each the container
*/
margin?: Spacing
/**
* Change the opacity of the non-selected field
* @default 0.2
*/
filterOpacity?: number
/**
* Function to format X label
*/
xFormatter?: (tick: number | Date, i: number, ticks: number[] | Date[]) => string
/**
* Function to format Y label
*/
yFormatter?: (tick: number | Date, i: number, ticks: number[] | Date[]) => string
/**
* Controls the visibility of the X axis.
* @default true
*/
showXAxis?: boolean
/**
* Controls the visibility of the Y axis.
* @default true
*/
showYAxis?: boolean
/**
* Controls the visibility of tooltip.
* @default true
*/
showTooltip?: boolean
/**
* Controls the visibility of legend.
* @default true
*/
showLegend?: boolean
/**
* Controls the visibility of gridline.
* @default true
*/
showGridLine?: boolean
}
44 changes: 44 additions & 0 deletions inertia/components/ui/chart/ChartCrosshair.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { BulletLegendItemInterface } from '@unovis/ts'
import { omit } from '@unovis/ts'
import { VisCrosshair, VisTooltip } from '@unovis/vue'
import { type Component, createApp } from 'vue'
import { ChartTooltip } from '.'
const props = withDefaults(defineProps<{
colors: string[]
index: string
items: BulletLegendItemInterface[]
customTooltip?: Component
}>(), {
colors: () => [],
})
// Use weakmap to store reference to each datapoint for Tooltip
const wm = new WeakMap()
function template(d: any) {
if (wm.has(d)) {
return wm.get(d)
}
else {
const componentDiv = document.createElement('div')
const omittedData = Object.entries(omit(d, [props.index])).map(([key, value]) => {
const legendReference = props.items.find(i => i.name === key)
return { ...legendReference, value }
})
const TooltipComponent = props.customTooltip ?? ChartTooltip
createApp(TooltipComponent, { title: d[props.index].toString(), data: omittedData }).mount(componentDiv)
wm.set(d, componentDiv.innerHTML)
return componentDiv.innerHTML
}
}
function color(d: unknown, i: number) {
return props.colors[i] ?? 'transparent';
}
</script>

<template>
<VisTooltip :horizontal-shift="20" :vertical-shift="20" />
<VisCrosshair :template="template" :color="color" />
</template>
Loading

0 comments on commit 95ae52e

Please sign in to comment.