Skip to content

Commit 95ae52e

Browse files
committed
feat(dashboard): adding monthly user registration stat
1 parent 1c01a5c commit 95ae52e

16 files changed

+2452
-251
lines changed

app/actions/dashboard/get_dashboard_counts.ts

+10-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1+
import { MonthlyStat } from '#actions/stats/get_monthly'
2+
import UserStats from '#actions/stats/user_stats'
13
import Collection from '#models/collection'
24
import Post from '#models/post'
35
import Taxonomy from '#models/taxonomy'
4-
import User from '#models/user'
56

67
export interface GetDashboardCountsContract {
78
posts: BigInt
89
postSeconds: BigInt
910
series: BigInt
1011
topics: BigInt
11-
users: BigInt
12+
users: {
13+
total: bigint
14+
monthly: MonthlyStat[]
15+
}
1216
}
1317

1418
export default class GetDashboardCounts {
@@ -18,7 +22,10 @@ export default class GetDashboardCounts {
1822
postSeconds: await this.#countPostSeconds(),
1923
series: await this.#countSeries(),
2024
topics: await this.#countTopics(),
21-
users: await this.#countUsers(),
25+
users: {
26+
total: await UserStats.getTotal(),
27+
monthly: await UserStats.getMonthlyRegistrations(),
28+
},
2229
}
2330
}
2431

@@ -41,8 +48,4 @@ export default class GetDashboardCounts {
4148
static async #countTopics() {
4249
return Taxonomy.query().getCount()
4350
}
44-
45-
static async #countUsers() {
46-
return User.query().getCount()
47-
}
4851
}

app/actions/stats/get_monthly.ts

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import db from '@adonisjs/lucid/services/db'
2+
import { DatabaseQueryBuilderContract } from '@adonisjs/lucid/types/querybuilder'
3+
import { DateTime } from 'luxon'
4+
5+
type GetMonthlyOptions = {
6+
monthlyColumn?: string
7+
aggregateColumn?: string
8+
startDate?: DateTime<true>
9+
}
10+
11+
export type MonthlyStat = {
12+
month: string
13+
total: number
14+
}
15+
16+
export default class GetMonthly {
17+
static count(query: DatabaseQueryBuilderContract<MonthlyStat>, options: GetMonthlyOptions) {
18+
const agg = options.aggregateColumn || '*'
19+
const final = this.#group(query, options).count(agg, 'total')
20+
return this.#toType(final)
21+
}
22+
23+
static sum(query: DatabaseQueryBuilderContract<MonthlyStat>, options: GetMonthlyOptions) {
24+
const agg = options.aggregateColumn || '*'
25+
const final = this.#group(query, options).sum(agg, 'total')
26+
return this.#toType(final)
27+
}
28+
29+
static #group(query: DatabaseQueryBuilderContract<MonthlyStat>, options: GetMonthlyOptions) {
30+
const timestamp = options.monthlyColumn || 'created_at'
31+
const startDate = options.startDate || DateTime.now().minus({ year: 1 }).startOf('month')
32+
33+
return query
34+
.whereRaw(db.raw('?? > ?', [timestamp, startDate.toSQLDate()]))
35+
.select(db.raw('SUBSTR(CAST(?? AS TEXT), 1, 7) AS month', timestamp)) // YYYY-MM
36+
.orderByRaw('month')
37+
.groupByRaw('month')
38+
}
39+
40+
static async #toType(query: DatabaseQueryBuilderContract<MonthlyStat>) {
41+
const results = await query
42+
return results.map((r) => ({
43+
month: DateTime.fromFormat(r.month, 'yyyy-MM').toFormat('MMM yyyy'),
44+
total: Number(r.total),
45+
}))
46+
}
47+
}

app/actions/stats/user_stats.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import User from '#models/user'
2+
import db from '@adonisjs/lucid/services/db'
3+
import { DateTime } from 'luxon'
4+
import GetMonthly, { MonthlyStat } from './get_monthly.js'
5+
6+
export default class UserStats {
7+
static async getTotal() {
8+
return User.query().getCount()
9+
}
10+
11+
static async getMonthlyRegistrations(
12+
startDate: DateTime<true> = DateTime.now().minus({ year: 1 }).startOf('month')
13+
) {
14+
return GetMonthly.count(db.from('users'), { startDate })
15+
}
16+
}

components.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ declare module 'vue' {
1919
AlertDialogTitle: typeof import('./inertia/components/ui/alert-dialog/AlertDialogTitle.vue')['default']
2020
AlertDialogTrigger: typeof import('./inertia/components/ui/alert-dialog/AlertDialogTrigger.vue')['default']
2121
AlertTitle: typeof import('./inertia/components/ui/alert/AlertTitle.vue')['default']
22+
AreaChart: typeof import('./inertia/components/ui/chart-area/AreaChart.vue')['default']
2223
AssetUpload: typeof import('./inertia/components/AssetUpload.vue')['default']
2324
Autocomplete: typeof import('./inertia/components/Autocomplete.vue')['default']
2425
Avatar: typeof import('./inertia/components/ui/avatar/Avatar.vue')['default']
@@ -39,6 +40,10 @@ declare module 'vue' {
3940
CardFooter: typeof import('./inertia/components/ui/card/CardFooter.vue')['default']
4041
CardHeader: typeof import('./inertia/components/ui/card/CardHeader.vue')['default']
4142
CardTitle: typeof import('./inertia/components/ui/card/CardTitle.vue')['default']
43+
ChartCrosshair: typeof import('./inertia/components/ui/chart/ChartCrosshair.vue')['default']
44+
ChartLegend: typeof import('./inertia/components/ui/chart/ChartLegend.vue')['default']
45+
ChartSingleTooltip: typeof import('./inertia/components/ui/chart/ChartSingleTooltip.vue')['default']
46+
ChartTooltip: typeof import('./inertia/components/ui/chart/ChartTooltip.vue')['default']
4247
Checkbox: typeof import('./inertia/components/ui/checkbox/Checkbox.vue')['default']
4348
Collapsible: typeof import('./inertia/components/ui/collapsible/Collapsible.vue')['default']
4449
CollapsibleContent: typeof import('./inertia/components/ui/collapsible/CollapsibleContent.vue')['default']
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<script setup lang="ts" generic="T extends Record<string, any>">
2+
import type { BaseChartProps } from '.'
3+
import { ChartCrosshair, ChartLegend, defaultColors } from '~/components/ui/chart'
4+
import { cn } from '~/lib/utils'
5+
import { type BulletLegendItemInterface, CurveType } from '@unovis/ts'
6+
import { Area, Axis, Line } from '@unovis/ts'
7+
import { VisArea, VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
8+
import { useMounted } from '@vueuse/core'
9+
import { useId } from 'radix-vue'
10+
import { type Component, computed, ref } from 'vue'
11+
12+
const props = withDefaults(defineProps<BaseChartProps<T> & {
13+
/**
14+
* Render custom tooltip component.
15+
*/
16+
customTooltip?: Component
17+
/**
18+
* Type of curve
19+
*/
20+
curveType?: CurveType
21+
/**
22+
* Controls the visibility of gradient.
23+
* @default true
24+
*/
25+
showGradiant?: boolean
26+
}>(), {
27+
curveType: CurveType.MonotoneX,
28+
filterOpacity: 0.2,
29+
margin: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
30+
showXAxis: true,
31+
showYAxis: true,
32+
showTooltip: true,
33+
showLegend: true,
34+
showGridLine: true,
35+
showGradiant: true,
36+
})
37+
38+
const emits = defineEmits<{
39+
legendItemClick: [d: BulletLegendItemInterface, i: number]
40+
}>()
41+
42+
type KeyOfT = Extract<keyof T, string>
43+
type Data = typeof props.data[number]
44+
45+
const chartRef = useId()
46+
47+
const index = computed(() => props.index as KeyOfT)
48+
const colors = computed(() => props.colors?.length ? props.colors : defaultColors(props.categories.length))
49+
50+
const legendItems = ref<BulletLegendItemInterface[]>(props.categories.map((category, i) => ({
51+
name: category,
52+
color: colors.value[i],
53+
inactive: false,
54+
})))
55+
56+
const isMounted = useMounted()
57+
58+
function handleLegendItemClick(d: BulletLegendItemInterface, i: number) {
59+
emits('legendItemClick', d, i)
60+
}
61+
</script>
62+
63+
<template>
64+
<div :class="cn('w-full h-[400px] flex flex-col items-end', $attrs.class ?? '')">
65+
<ChartLegend v-if="showLegend" v-model:items="legendItems" @legend-item-click="handleLegendItemClick" />
66+
67+
<VisXYContainer :style="{ height: isMounted ? '100%' : 'auto' }" :margin="{ left: 20, right: 20 }" :data="data">
68+
<svg width="0" height="0">
69+
<defs>
70+
<linearGradient v-for="(color, i) in colors" :id="`${chartRef}-color-${i}`" :key="i" x1="0" y1="0" x2="0" y2="1">
71+
<template v-if="showGradiant">
72+
<stop offset="5%" :stop-color="color" stop-opacity="0.4" />
73+
<stop offset="95%" :stop-color="color" stop-opacity="0" />
74+
</template>
75+
<template v-else>
76+
<stop offset="0%" :stop-color="color" />
77+
</template>
78+
</linearGradient>
79+
</defs>
80+
</svg>
81+
82+
<ChartCrosshair v-if="showTooltip" :colors="colors" :items="legendItems" :index="index" :custom-tooltip="customTooltip" />
83+
84+
<template v-for="(category, i) in categories" :key="category">
85+
<VisArea
86+
:x="(d: Data, i: number) => i"
87+
:y="(d: Data) => d[category]"
88+
color="auto"
89+
:curve-type="curveType"
90+
:attributes="{
91+
[Area.selectors.area]: {
92+
fill: `url(#${chartRef}-color-${i})`,
93+
},
94+
}"
95+
:opacity="legendItems.find(item => item.name === category)?.inactive ? filterOpacity : 1"
96+
/>
97+
</template>
98+
99+
<template v-for="(category, i) in categories" :key="category">
100+
<VisLine
101+
:x="(d: Data, i: number) => i"
102+
:y="(d: Data) => d[category]"
103+
:color="colors[i]"
104+
:curve-type="curveType"
105+
:attributes="{
106+
[Line.selectors.line]: {
107+
opacity: legendItems.find(item => item.name === category)?.inactive ? filterOpacity : 1,
108+
},
109+
}"
110+
/>
111+
</template>
112+
113+
<VisAxis
114+
v-if="showXAxis"
115+
type="x"
116+
:tick-format="xFormatter ?? ((v: number) => data[v]?.[index])"
117+
:grid-line="false"
118+
:tick-line="false"
119+
tick-text-color="hsl(var(--vis-text-color))"
120+
/>
121+
<VisAxis
122+
v-if="showYAxis"
123+
type="y"
124+
:tick-line="false"
125+
:tick-format="yFormatter"
126+
:domain-line="false"
127+
:grid-line="showGridLine"
128+
:attributes="{
129+
[Axis.selectors.grid]: {
130+
class: 'text-muted',
131+
},
132+
}"
133+
tick-text-color="hsl(var(--vis-text-color))"
134+
/>
135+
136+
<slot />
137+
</VisXYContainer>
138+
</div>
139+
</template>
+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
export { default as AreaChart } from './AreaChart.vue'
2+
3+
import type { Spacing } from '@unovis/ts'
4+
5+
type KeyOf<T extends Record<string, any>> = Extract<keyof T, string>
6+
7+
export interface BaseChartProps<T extends Record<string, any>> {
8+
/**
9+
* The source data, in which each entry is a dictionary.
10+
*/
11+
data: T[]
12+
/**
13+
* Select the categories from your data. Used to populate the legend and toolip.
14+
*/
15+
categories: KeyOf<T>[]
16+
/**
17+
* Sets the key to map the data to the axis.
18+
*/
19+
index: KeyOf<T>
20+
/**
21+
* Change the default colors.
22+
*/
23+
colors?: string[]
24+
/**
25+
* Margin of each the container
26+
*/
27+
margin?: Spacing
28+
/**
29+
* Change the opacity of the non-selected field
30+
* @default 0.2
31+
*/
32+
filterOpacity?: number
33+
/**
34+
* Function to format X label
35+
*/
36+
xFormatter?: (tick: number | Date, i: number, ticks: number[] | Date[]) => string
37+
/**
38+
* Function to format Y label
39+
*/
40+
yFormatter?: (tick: number | Date, i: number, ticks: number[] | Date[]) => string
41+
/**
42+
* Controls the visibility of the X axis.
43+
* @default true
44+
*/
45+
showXAxis?: boolean
46+
/**
47+
* Controls the visibility of the Y axis.
48+
* @default true
49+
*/
50+
showYAxis?: boolean
51+
/**
52+
* Controls the visibility of tooltip.
53+
* @default true
54+
*/
55+
showTooltip?: boolean
56+
/**
57+
* Controls the visibility of legend.
58+
* @default true
59+
*/
60+
showLegend?: boolean
61+
/**
62+
* Controls the visibility of gridline.
63+
* @default true
64+
*/
65+
showGridLine?: boolean
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script setup lang="ts">
2+
import type { BulletLegendItemInterface } from '@unovis/ts'
3+
import { omit } from '@unovis/ts'
4+
import { VisCrosshair, VisTooltip } from '@unovis/vue'
5+
import { type Component, createApp } from 'vue'
6+
import { ChartTooltip } from '.'
7+
8+
const props = withDefaults(defineProps<{
9+
colors: string[]
10+
index: string
11+
items: BulletLegendItemInterface[]
12+
customTooltip?: Component
13+
}>(), {
14+
colors: () => [],
15+
})
16+
17+
// Use weakmap to store reference to each datapoint for Tooltip
18+
const wm = new WeakMap()
19+
function template(d: any) {
20+
if (wm.has(d)) {
21+
return wm.get(d)
22+
}
23+
else {
24+
const componentDiv = document.createElement('div')
25+
const omittedData = Object.entries(omit(d, [props.index])).map(([key, value]) => {
26+
const legendReference = props.items.find(i => i.name === key)
27+
return { ...legendReference, value }
28+
})
29+
const TooltipComponent = props.customTooltip ?? ChartTooltip
30+
createApp(TooltipComponent, { title: d[props.index].toString(), data: omittedData }).mount(componentDiv)
31+
wm.set(d, componentDiv.innerHTML)
32+
return componentDiv.innerHTML
33+
}
34+
}
35+
36+
function color(d: unknown, i: number) {
37+
return props.colors[i] ?? 'transparent';
38+
}
39+
</script>
40+
41+
<template>
42+
<VisTooltip :horizontal-shift="20" :vertical-shift="20" />
43+
<VisCrosshair :template="template" :color="color" />
44+
</template>

0 commit comments

Comments
 (0)