diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts index 505772e2..133089ce 100644 --- a/artifacts/api-server/src/routes/apps.ts +++ b/artifacts/api-server/src/routes/apps.ts @@ -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 => { res.json(app); }); +router.post("/apps/:id/open", requireAuth, async (req, res): Promise => { + 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 => { const params = DeleteAppParams.safeParse(req.params); if (!params.success) { diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts index 845f3ac3..0a21094e 100644 --- a/artifacts/api-server/src/routes/stats.ts +++ b/artifacts/api-server/src/routes/stats.ts @@ -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(); + 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`to_char(date_trunc('day', ${appOpensTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`, + count: sql`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`count(*)` }) + .from(appOpensTable) + .where(sql`${appOpensTable.createdAt} >= ${last7Start.toISOString()}`); + + const [appOpensPrev7Row] = await db + .select({ count: sql`count(*)` }) + .from(appOpensTable) + .where( + and( + sql`${appOpensTable.createdAt} >= ${prev7Start.toISOString()}`, + sql`${appOpensTable.createdAt} < ${last7Start.toISOString()}`, + ), + ); + + const servicesCreatedRows = await db + .select({ + day: sql`to_char(date_trunc('day', ${servicesTable.createdAt} AT TIME ZONE 'UTC'), 'YYYY-MM-DD')`, + count: sql`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`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), }); }); diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg index 7dfb16c8..003adf17 100644 Binary files a/artifacts/teaboy-os/public/opengraph.jpg and b/artifacts/teaboy-os/public/opengraph.jpg differ diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 466080a4..39a7aab2 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -150,7 +150,11 @@ "activeServices": "الخدمات النشطة", "inactiveServices": "غير نشطة: {{count}}", "signupsTrend": "التسجيلات", - "last7Days": "آخر ٧ أيام" + "last7Days": "آخر ٧ أيام", + "appOpensTrend": "فتح التطبيقات", + "appOpensSummary": "{{count}} إجمالاً · {{delta}}", + "servicesCreatedTrend": "خدمات جديدة", + "servicesCreatedSummary": "{{count}} مضافة" } }, "notFound": { diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index eb4a5492..35189ecc 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -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": { diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index d1c1deb7..2411a938 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -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, + ) => ( +
+
+
{title}
+
{subtitle ?? t("admin.dashboard.last7Days")}
+
+
+ {series.map((s) => { + const heightPct = (s.count / max) * 100; + return ( +
+
{s.count}
+
+
0 ? 6 : 2)}%` }} + /> +
+
+ {dayLabel(s.date)} +
+
+ ); + })} +
+
+ ); + return (
@@ -880,32 +923,36 @@ function DashboardSection({
-
-
-
- {t("admin.dashboard.signupsTrend")} -
-
{t("admin.dashboard.last7Days")}
-
-
- {signups.map((s) => { - const heightPct = (s.count / maxSignup) * 100; - return ( -
-
{s.count}
-
-
0 ? 6 : 2)}%` }} - /> -
-
- {dayLabel(s.date)} -
-
- ); - })} -
+ {renderChart( + t("admin.dashboard.signupsTrend"), + signups, + maxSignup, + "from-primary/40 to-primary", + )} + +
+ {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 }), + )}
diff --git a/artifacts/teaboy-os/src/pages/home.tsx b/artifacts/teaboy-os/src/pages/home.tsx index 54dc4095..703a5e0a 100644 --- a/artifacts/teaboy-os/src/pages/home.tsx +++ b/artifacts/teaboy-os/src/pages/home.tsx @@ -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 ( - + ); } @@ -110,9 +108,17 @@ function SortableAppIcon({ app, onClick }: { app: App; onClick: () => void }) { touchAction: "none", }; return ( -
- -
+ ); } @@ -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() { setLocation(app.route)} + onClick={() => openApp(app)} /> ))}
@@ -392,7 +407,7 @@ export default function HomePage() { return (