Add app opens & services activity charts to admin dashboard

- New `app_opens` table (id, user_id, app_id, created_at) and Drizzle
  schema; exported from lib/db schema index.
- New `POST /api/apps/{id}/open` endpoint logs an open for the current
  user (auth required, 204 on success, 404 for unknown app id).
- Extended `GET /api/stats/admin` with appOpensByDay/appOpensLast7Days/
  appOpensPrev7Days and servicesCreatedByDay/servicesCreatedLast7Days,
  refactored around a shared buildSeries helper.
- Regenerated api-client/zod and added two new bar charts (App opens,
  Services added) to the admin Dashboard alongside the existing
  Sign-ups chart, with EN/AR translations.
- Home page fires bare `logAppOpen(id, { keepalive: true })` before
  navigating so the request survives client-side route changes; the
  bottom dock buttons also use this helper.
- Restructured SortableAppIcon so the click target and dnd-kit
  listeners live on the same <button>, fixing a click-vs-drag
  interaction that prevented the open event from firing in tests.

Rebase notes:
- home.tsx: incoming main reworked AppIcon styling, the apps grid
  header (with count and empty state), and the dock button. Kept all
  incoming visual changes while preserving this task's
  AppIconContent fragment, single-button SortableAppIcon, and
  openApp() wiring (so dock + grid both log opens).
- opengraph.jpg: kept incoming binary version.

E2E verified: clicking app icons increments app_opens; admin dashboard
renders all three charts.

Replit-Task-Id: 776c14f7-4e5a-4bf6-80e2-a6c7586c1fcb
This commit is contained in:
riyadhafraa
2026-04-20 15:16:19 +00:00
parent 2c8394e7e3
commit a5484283f4
13 changed files with 393 additions and 39 deletions
+23
View File
@@ -8,6 +8,7 @@ import {
rolePermissionsTable,
rolesTable,
userAppOrdersTable,
appOpensTable,
} from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
import {
@@ -187,6 +188,28 @@ router.patch("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
res.json(app);
});
router.post("/apps/:id/open", requireAuth, async (req, res): Promise<void> => {
const userId = req.session.userId!;
const params = GetAppParams.safeParse(req.params);
if (!params.success) {
res.status(400).json({ error: params.error.message });
return;
}
const [app] = await db
.select({ id: appsTable.id })
.from(appsTable)
.where(eq(appsTable.id, params.data.id));
if (!app) {
res.status(404).json({ error: "App not found" });
return;
}
await db.insert(appOpensTable).values({ userId, appId: app.id });
res.sendStatus(204);
});
router.delete("/apps/:id", requireAdmin, async (req, res): Promise<void> => {
const params = DeleteAppParams.safeParse(req.params);
if (!params.success) {
+60
View File
@@ -12,6 +12,7 @@ import {
usersTable,
messagesTable,
messageReadsTable,
appOpensTable,
} from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
@@ -165,12 +166,71 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (_req, res): Promise
signupsByDay.push({ date: key, count: countsByDay.get(key) ?? 0 });
}
const buildSeries = (
rows: { day: string; count: number }[],
): { date: string; count: number }[] => {
const map = new Map<string, number>();
for (const r of rows) map.set(r.day, Number(r.count));
const out: { date: string; count: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(startOfTodayUtc.getTime() - i * 24 * 60 * 60 * 1000);
const key = d.toISOString().slice(0, 10);
out.push({ date: key, count: map.get(key) ?? 0 });
}
return out;
};
const appOpenRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
count: sql<number>`count(*)`,
})
.from(appOpensTable)
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`)
.groupBy(sql`date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC')`);
const appOpensByDay = buildSeries(appOpenRows);
const [appOpensLast7Row] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`);
const [appOpensPrev7Row] = await db
.select({ count: sql<number>`count(*)` })
.from(appOpensTable)
.where(
and(
sql`${appOpensTable.createdAt} >= ${prev7Start.toISOString()}`,
sql`${appOpensTable.createdAt} < ${last7Start.toISOString()}`,
),
);
const servicesCreatedRows = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`,
count: sql<number>`count(*)`,
})
.from(servicesTable)
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`)
.groupBy(sql`date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC')`);
const servicesCreatedByDay = buildSeries(servicesCreatedRows);
const [servicesCreatedLast7Row] = await db
.select({ count: sql<number>`count(*)` })
.from(servicesTable)
.where(sql`${servicesTable.createdAt} >= ${last7Start.toISOString()}`);
res.json({
newUsersLast7Days: Number(newUsersLast7Row?.count ?? 0),
newUsersPrev7Days: Number(newUsersPrev7Row?.count ?? 0),
activeServices: Number(activeServicesRow?.count ?? 0),
inactiveServices: Number(inactiveServicesRow?.count ?? 0),
signupsByDay,
appOpensByDay,
appOpensLast7Days: Number(appOpensLast7Row?.count ?? 0),
appOpensPrev7Days: Number(appOpensPrev7Row?.count ?? 0),
servicesCreatedByDay,
servicesCreatedLast7Days: Number(servicesCreatedLast7Row?.count ?? 0),
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+5 -1
View File
@@ -150,7 +150,11 @@
"activeServices": "الخدمات النشطة",
"inactiveServices": "غير نشطة: {{count}}",
"signupsTrend": "التسجيلات",
"last7Days": "آخر ٧ أيام"
"last7Days": "آخر ٧ أيام",
"appOpensTrend": "فتح التطبيقات",
"appOpensSummary": "{{count}} إجمالاً · {{delta}}",
"servicesCreatedTrend": "خدمات جديدة",
"servicesCreatedSummary": "{{count}} مضافة"
}
},
"notFound": {
+5 -1
View File
@@ -150,7 +150,11 @@
"activeServices": "Active services",
"inactiveServices": "Inactive: {{count}}",
"signupsTrend": "Sign-ups",
"last7Days": "Last 7 days"
"last7Days": "Last 7 days",
"appOpensTrend": "App opens",
"appOpensSummary": "{{count}} total · {{delta}}",
"servicesCreatedTrend": "Services added",
"servicesCreatedSummary": "{{count}} added"
}
},
"notFound": {
+73 -26
View File
@@ -808,6 +808,15 @@ function DashboardSection({
const inactiveServices = adminStats?.inactiveServices ?? 0;
const signups = adminStats?.signupsByDay ?? [];
const maxSignup = Math.max(1, ...signups.map((s) => s.count));
const opens = adminStats?.appOpensByDay ?? [];
const maxOpens = Math.max(1, ...opens.map((s) => s.count));
const opensTotal = adminStats?.appOpensLast7Days ?? 0;
const opensPrev = adminStats?.appOpensPrev7Days ?? 0;
const opensDelta = opensTotal - opensPrev;
const opensDeltaPct = opensPrev > 0 ? Math.round((opensDelta / opensPrev) * 100) : null;
const servicesCreated = adminStats?.servicesCreatedByDay ?? [];
const maxServicesCreated = Math.max(1, ...servicesCreated.map((s) => s.count));
const servicesCreatedTotal = adminStats?.servicesCreatedLast7Days ?? 0;
const dayLabel = (iso: string) => {
try {
return formatWeekdayUtil(new Date(iso + "T00:00:00Z"), lang, "short");
@@ -816,6 +825,40 @@ function DashboardSection({
}
};
const renderChart = (
title: string,
series: { date: string; count: number }[],
max: number,
accent: string,
subtitle?: string,
) => (
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground truncate">{title}</div>
<div className="text-xs text-muted-foreground shrink-0">{subtitle ?? t("admin.dashboard.last7Days")}</div>
</div>
<div className="flex items-end justify-between gap-1.5 h-28 px-1" dir="ltr">
{series.map((s) => {
const heightPct = (s.count / max) * 100;
return (
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
<div className="w-full flex-1 flex items-end">
<div
className={cn("w-full rounded-t-md transition-all bg-gradient-to-t", accent)}
style={{ height: `${Math.max(heightPct, s.count > 0 ? 6 : 2)}%` }}
/>
</div>
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{dayLabel(s.date)}
</div>
</div>
);
})}
</div>
</div>
);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
@@ -880,32 +923,36 @@ function DashboardSection({
</div>
</div>
<div className="glass-panel rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold text-foreground">
{t("admin.dashboard.signupsTrend")}
</div>
<div className="text-xs text-muted-foreground">{t("admin.dashboard.last7Days")}</div>
</div>
<div className="flex items-end justify-between gap-1.5 h-28 px-1" dir="ltr">
{signups.map((s) => {
const heightPct = (s.count / maxSignup) * 100;
return (
<div key={s.date} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
<div className="text-[10px] font-medium text-foreground leading-none">{s.count}</div>
<div className="w-full flex-1 flex items-end">
<div
className="w-full rounded-t-md bg-gradient-to-t from-primary/40 to-primary transition-all"
style={{ height: `${Math.max(heightPct, s.count > 0 ? 6 : 2)}%` }}
/>
</div>
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{dayLabel(s.date)}
</div>
</div>
);
})}
</div>
{renderChart(
t("admin.dashboard.signupsTrend"),
signups,
maxSignup,
"from-primary/40 to-primary",
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{renderChart(
t("admin.dashboard.appOpensTrend"),
opens,
maxOpens,
"from-sky-300/40 to-sky-500",
t("admin.dashboard.appOpensSummary", {
count: opensTotal,
delta:
opensDelta === 0
? "±0"
: opensDeltaPct !== null
? `${opensDelta > 0 ? "+" : ""}${opensDeltaPct}%`
: `${opensDelta > 0 ? "+" : ""}${opensDelta}`,
}),
)}
{renderChart(
t("admin.dashboard.servicesCreatedTrend"),
servicesCreated,
maxServicesCreated,
"from-amber-300/40 to-amber-500",
t("admin.dashboard.servicesCreatedSummary", { count: servicesCreatedTotal }),
)}
</div>
<div className="glass-panel rounded-2xl p-4 space-y-3">
+26 -11
View File
@@ -11,6 +11,7 @@ import {
useLogout,
useUpdateLanguage,
useUpdateMyAppOrder,
logAppOpen,
getGetMeQueryKey,
type App,
} from "@workspace/api-client-react";
@@ -78,7 +79,7 @@ function gradientForColor(color: string): string {
return "icon-tile-blue";
}
function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; iconName: string; color: string; route: string }; onClick: () => void }) {
function AppIconContent({ app }: { app: { nameAr: string; nameEn: string; iconName: string; color: string } }) {
const { i18n: i18nInstance } = useTranslation();
const lang = i18nInstance.language;
const name = lang === "ar" ? app.nameAr : app.nameEn;
@@ -86,17 +87,14 @@ function AppIcon({ app, onClick }: { app: { nameAr: string; nameEn: string; icon
const Icon = resolveIcon(app.iconName);
return (
<button
onClick={onClick}
className="flex flex-col items-center gap-2 group select-none"
>
<>
<div className={`icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
<Icon size={30} color="#FFFFFF" />
</div>
<span className="text-[11px] text-foreground/85 font-medium text-center leading-tight max-w-[78px] truncate">
{name}
</span>
</button>
</>
);
}
@@ -110,9 +108,17 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) {
touchAction: "none",
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<AppIcon app={app} onClick={onClick} />
</div>
<button
type="button"
ref={setNodeRef}
style={style}
onClick={onClick}
className="flex flex-col items-center gap-2 group"
{...attributes}
{...listeners}
>
<AppIconContent app={app} />
</button>
);
}
@@ -199,6 +205,15 @@ export default function HomePage() {
const logout = useLogout();
const updateLanguage = useUpdateLanguage();
const openApp = (app: App) => {
// Fire-and-forget: keepalive ensures the request survives any
// imminent navigation triggered by setLocation below.
logAppOpen(app.id, { keepalive: true }).catch(() => {
// Best-effort tracking; ignore failures so navigation isn't blocked.
});
setLocation(app.route);
};
useEffect(() => {
const timer = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(timer);
@@ -373,7 +388,7 @@ export default function HomePage() {
<SortableAppIcon
key={app.id}
app={app}
onClick={() => setLocation(app.route)}
onClick={() => openApp(app)}
/>
))}
</div>
@@ -392,7 +407,7 @@ export default function HomePage() {
return (
<button
key={app.id}
onClick={() => setLocation(app.route)}
onClick={() => openApp(app)}
className="relative flex flex-col items-center gap-1 group"
>
<div
@@ -323,10 +323,25 @@ export type AdminStatsSignupsByDayItem = {
count: number;
};
export type AdminStatsAppOpensByDayItem = {
date: string;
count: number;
};
export type AdminStatsServicesCreatedByDayItem = {
date: string;
count: number;
};
export interface AdminStats {
newUsersLast7Days: number;
newUsersPrev7Days: number;
activeServices: number;
inactiveServices: number;
signupsByDay: AdminStatsSignupsByDayItem[];
appOpensByDay: AdminStatsAppOpensByDayItem[];
appOpensLast7Days: number;
appOpensPrev7Days: number;
servicesCreatedByDay: AdminStatsServicesCreatedByDayItem[];
servicesCreatedLast7Days: number;
}
+84
View File
@@ -930,6 +930,90 @@ export const useDeleteApp = <
return useMutation(getDeleteAppMutationOptions(options));
};
/**
* @summary Log an app open event for the current user
*/
export const getLogAppOpenUrl = (id: number) => {
return `/api/apps/${id}/open`;
};
export const logAppOpen = async (
id: number,
options?: RequestInit,
): Promise<void> => {
return customFetch<void>(getLogAppOpenUrl(id), {
...options,
method: "POST",
});
};
export const getLogAppOpenMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof logAppOpen>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof logAppOpen>>,
TError,
{ id: number },
TContext
> => {
const mutationKey = ["logAppOpen"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof logAppOpen>>,
{ id: number }
> = (props) => {
const { id } = props ?? {};
return logAppOpen(id, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type LogAppOpenMutationResult = NonNullable<
Awaited<ReturnType<typeof logAppOpen>>
>;
export type LogAppOpenMutationError = ErrorType<ErrorResponse>;
/**
* @summary Log an app open event for the current user
*/
export const useLogAppOpen = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof logAppOpen>>,
TError,
{ id: number },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof logAppOpen>>,
TError,
{ id: number },
TContext
> => {
return useMutation(getLogAppOpenMutationOptions(options));
};
/**
* @summary Set the current user's preferred home apps order
*/
+56
View File
@@ -236,6 +236,27 @@ paths:
"204":
description: Deleted
/apps/{id}/open:
post:
operationId: logAppOpen
tags: [apps]
summary: Log an app open event for the current user
parameters:
- name: id
in: path
required: true
schema:
type: integer
responses:
"204":
description: Logged
"404":
description: App not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/me/app-order:
put:
operationId: updateMyAppOrder
@@ -1336,9 +1357,44 @@ components:
required:
- date
- count
appOpensByDay:
type: array
items:
type: object
properties:
date:
type: string
count:
type: integer
required:
- date
- count
appOpensLast7Days:
type: integer
appOpensPrev7Days:
type: integer
servicesCreatedByDay:
type: array
items:
type: object
properties:
date:
type: string
count:
type: integer
required:
- date
- count
servicesCreatedLast7Days:
type: integer
required:
- newUsersLast7Days
- newUsersPrev7Days
- activeServices
- inactiveServices
- signupsByDay
- appOpensByDay
- appOpensLast7Days
- appOpensPrev7Days
- servicesCreatedByDay
- servicesCreatedLast7Days
+22
View File
@@ -197,6 +197,13 @@ export const DeleteAppParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Log an app open event for the current user
*/
export const LogAppOpenParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Set the current user's preferred home apps order
*/
@@ -721,4 +728,19 @@ export const GetAdminStatsResponse = zod.object({
count: zod.number(),
}),
),
appOpensByDay: zod.array(
zod.object({
date: zod.string(),
count: zod.number(),
}),
),
appOpensLast7Days: zod.number(),
appOpensPrev7Days: zod.number(),
servicesCreatedByDay: zod.array(
zod.object({
date: zod.string(),
count: zod.number(),
}),
),
servicesCreatedLast7Days: zod.number(),
});
+23
View File
@@ -0,0 +1,23 @@
import { pgTable, serial, integer, timestamp, index } from "drizzle-orm/pg-core";
import { usersTable } from "./users";
import { appsTable } from "./apps";
export const appOpensTable = pgTable(
"app_opens",
{
id: serial("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
appId: integer("app_id")
.notNull()
.references(() => appsTable.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("app_opens_created_at_idx").on(t.createdAt),
index("app_opens_app_id_idx").on(t.appId),
],
);
export type AppOpen = typeof appOpensTable.$inferSelect;
+1
View File
@@ -6,3 +6,4 @@ export * from "./conversations";
export * from "./notifications";
export * from "./settings";
export * from "./user-app-orders";
export * from "./app-opens";