Skip to content

Commit 6bd4a2a

Browse files
committed
feat(ama): unique asker count + per-tag question counts
1 parent aba621e commit 6bd4a2a

6 files changed

Lines changed: 153 additions & 106 deletions

File tree

apps/website/src/app/dashboard/[id]/ama/amas/[amaId]/_components/AMADetails.tsx

Lines changed: 79 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { updateAMAConfigSchema } from '@chatsift/api/ama-schemas';
44
import { amaQuestionsChannel } from '@chatsift/core';
55
import { useQueryClient } from '@tanstack/react-query';
66
import { ChannelType } from 'discord-api-types/v10';
7+
import Link from 'next/link';
78
import { useParams, useRouter } from 'next/navigation';
89
import { useState } from 'react';
910
import { NormalPromptFields } from '../../_components/NormalPromptFields';
@@ -12,6 +13,7 @@ import { PromptModeToggle } from '../../_components/PromptModeToggle';
1213
import { PromptPreview } from '../../_components/PromptPreview';
1314
import { AuthorAvatar } from '../questions/_components/AuthorAvatar';
1415
import { userLabel } from '../questions/_components/userLabel';
16+
import { StatChip } from './StatChip';
1517
import { QUESTION_STATE_TILES, valenceClass } from './questionStateTiles';
1618
import { APIError } from '@/api/error';
1719
import type { AMAStats, PossiblyMissingChannelInfo, UpdateAMABody } from '@/api/routes/ama';
@@ -573,6 +575,83 @@ export function AMADetails() {
573575
</p>
574576
)}
575577

578+
{/* Analytics & Export Card -- sits directly under the Question Triage banner rather than down with
579+
the config cards: it's the "how is this AMA doing" answer someone opens this page for, and its tag
580+
chips are a second entry point into Triage, so it belongs next to the primary one. */}
581+
<div className="rounded-lg border border-on-secondary bg-card p-6 dark:border-on-secondary-dark dark:bg-card-dark lg:col-span-2">
582+
<div className="mb-4 flex items-center justify-between">
583+
<h2 className="text-xl font-medium text-primary dark:text-primary-dark">Analytics &amp; Export</h2>
584+
{canManage && (
585+
<Button
586+
className="px-3 py-1.5 text-sm bg-on-tertiary dark:bg-on-tertiary-dark text-primary dark:text-primary-dark rounded-md hover:bg-on-secondary dark:hover:bg-on-secondary-dark transition-colors disabled:opacity-50"
587+
isDisabled={exportQuestions.isPending}
588+
onPress={handleExport}
589+
type="button"
590+
>
591+
{exportQuestions.isPending ? 'Exporting…' : 'Export CSV'}
592+
</Button>
593+
)}
594+
</div>
595+
596+
{isStatsLoading ? (
597+
<Skeleton className="h-24 w-full" />
598+
) : stats ? (
599+
<div className="flex flex-col gap-6">
600+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-7">
601+
<div className="rounded-lg border border-on-secondary p-4 text-center dark:border-on-secondary-dark">
602+
<p className="text-2xl font-semibold text-primary dark:text-primary-dark">{stats.total}</p>
603+
<p className="mt-1 text-xs text-secondary dark:text-secondary-dark">Total Questions</p>
604+
</div>
605+
<div className="rounded-lg border border-on-secondary p-4 text-center dark:border-on-secondary-dark">
606+
<p className="text-2xl font-semibold text-primary dark:text-primary-dark">{stats.uniqueAskerCount}</p>
607+
<p className="mt-1 text-xs text-secondary dark:text-secondary-dark">Unique Askers</p>
608+
</div>
609+
{/* "Duplicates", not "Merged Duplicates" -- at lg:grid-cols-7 the longer label is the only one
610+
that wraps to two lines, and a grid row stretches every other tile to match it. */}
611+
<div className="rounded-lg border border-on-secondary p-4 text-center dark:border-on-secondary-dark">
612+
<p className="text-2xl font-semibold text-primary dark:text-primary-dark">
613+
{stats.mergedDuplicatesCount}
614+
</p>
615+
<p className="mt-1 text-xs text-secondary dark:text-secondary-dark">Duplicates</p>
616+
</div>
617+
{QUESTION_STATE_TILES.map(({ state, label, valence }) => (
618+
<div
619+
className="rounded-lg border border-on-secondary p-4 text-center dark:border-on-secondary-dark"
620+
key={state}
621+
>
622+
<p className={`text-2xl font-semibold ${valenceClass[valence]}`}>
623+
{stats.byState[state as keyof AMAStats['byState']]}
624+
</p>
625+
<p className="mt-1 text-xs text-secondary dark:text-secondary-dark">{label}</p>
626+
</div>
627+
))}
628+
</div>
629+
630+
{/* Chips rather than more tiles: tag count is unbounded, so a grid row would blow the card up
631+
once a session has more than a handful. Each one deep-links into Triage pre-filtered to that
632+
tag -- `?tag=` is exactly the param `useTagFilter` reads (QuestionTagFilter.tsx). */}
633+
{stats.byTag.length > 0 && (
634+
<div>
635+
<h3 className="mb-2 text-sm font-medium text-primary dark:text-primary-dark">Questions by Tag</h3>
636+
<div className="flex flex-wrap gap-2">
637+
{stats.byTag.map((tag) => (
638+
<Link
639+
className="rounded-md transition-opacity hover:opacity-80"
640+
href={`/dashboard/${params.id}/ama/amas/${params.amaId}/questions?tag=${tag.id}`}
641+
key={tag.id}
642+
>
643+
<StatChip label={tag.name} value={tag.count} />
644+
</Link>
645+
))}
646+
</div>
647+
</div>
648+
)}
649+
</div>
650+
) : (
651+
<p className="text-sm text-secondary dark:text-secondary-dark">Unable to load question stats.</p>
652+
)}
653+
</div>
654+
576655
{/* Session Information Card */}
577656
<div className="rounded-lg border border-on-secondary bg-card p-6 dark:border-on-secondary-dark dark:bg-card-dark">
578657
<div className="mb-4 flex items-center justify-between">
@@ -1021,53 +1100,6 @@ export function AMADetails() {
10211100
</div>
10221101
</div>
10231102

1024-
{/* Analytics & Export Card */}
1025-
<div className="rounded-lg border border-on-secondary bg-card p-6 dark:border-on-secondary-dark dark:bg-card-dark lg:col-span-2">
1026-
<div className="mb-4 flex items-center justify-between">
1027-
<h2 className="text-xl font-medium text-primary dark:text-primary-dark">Analytics &amp; Export</h2>
1028-
{canManage && (
1029-
<Button
1030-
className="px-3 py-1.5 text-sm bg-on-tertiary dark:bg-on-tertiary-dark text-primary dark:text-primary-dark rounded-md hover:bg-on-secondary dark:hover:bg-on-secondary-dark transition-colors disabled:opacity-50"
1031-
isDisabled={exportQuestions.isPending}
1032-
onPress={handleExport}
1033-
type="button"
1034-
>
1035-
{exportQuestions.isPending ? 'Exporting…' : 'Export CSV'}
1036-
</Button>
1037-
)}
1038-
</div>
1039-
1040-
{isStatsLoading ? (
1041-
<Skeleton className="h-24 w-full" />
1042-
) : stats ? (
1043-
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
1044-
<div className="rounded-lg border border-on-secondary p-4 text-center dark:border-on-secondary-dark">
1045-
<p className="text-2xl font-semibold text-primary dark:text-primary-dark">{stats.total}</p>
1046-
<p className="mt-1 text-xs text-secondary dark:text-secondary-dark">Total Questions</p>
1047-
</div>
1048-
<div className="rounded-lg border border-on-secondary p-4 text-center dark:border-on-secondary-dark">
1049-
<p className="text-2xl font-semibold text-primary dark:text-primary-dark">
1050-
{stats.mergedDuplicatesCount}
1051-
</p>
1052-
<p className="mt-1 text-xs text-secondary dark:text-secondary-dark">Merged Duplicates</p>
1053-
</div>
1054-
{QUESTION_STATE_TILES.map(({ state, label, valence }) => (
1055-
<div
1056-
className="rounded-lg border border-on-secondary p-4 text-center dark:border-on-secondary-dark"
1057-
key={state}
1058-
>
1059-
<p className={`text-2xl font-semibold ${valenceClass[valence]}`}>
1060-
{stats.byState[state as keyof AMAStats['byState']]}
1061-
</p>
1062-
<p className="mt-1 text-xs text-secondary dark:text-secondary-dark">{label}</p>
1063-
</div>
1064-
))}
1065-
</div>
1066-
) : (
1067-
<p className="text-sm text-secondary dark:text-secondary-dark">Unable to load question stats.</p>
1068-
)}
1069-
</div>
1070-
10711103
{/* Question Submissions Card */}
10721104
{canManage && (
10731105
<div className="rounded-lg border border-on-secondary bg-card p-6 dark:border-on-secondary-dark dark:bg-card-dark lg:col-span-2">
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { valenceClass } from './questionStateTiles';
2+
3+
interface StatChipProps {
4+
readonly label: string;
5+
readonly valence?: keyof typeof valenceClass;
6+
readonly value: number;
7+
}
8+
9+
/**
10+
* Compact number+label pill. Lifted out of the Triage page's old `QuestionStatsSummary` (removed in #322,
11+
* it duplicated the Overview card's tiles) -- the pill itself is still the right shape for the per-tag
12+
* counts in `AMADetails.tsx`'s "Analytics & Export" card, where a tile grid would fall apart once a
13+
* session has more than a handful of tags.
14+
*/
15+
export function StatChip({ label, valence = 'neutral', value }: StatChipProps) {
16+
return (
17+
<span className="flex items-center gap-1.5 rounded-md border border-on-secondary bg-card px-2.5 py-1 text-xs dark:border-on-secondary-dark dark:bg-card-dark">
18+
<span className={`font-semibold ${valenceClass[valence]}`}>{value}</span>
19+
<span className="text-secondary dark:text-secondary-dark">{label}</span>
20+
</span>
21+
);
22+
}

apps/website/src/app/dashboard/[id]/ama/amas/[amaId]/_components/questionStateTiles.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
// Shared between `AMADetails.tsx`'s full stat card and the `analytics/` subpage's simplified one, so a
2-
// state's label/color can't drift between the two views. Labels mirror the Triage page's tab names
1+
// Backs the state tiles in `AMADetails.tsx`'s "Analytics & Export" card. Kept as its own module rather
2+
// than inlined: `valenceClass` is also what `StatChip.tsx` colors by, and `AMADetails.tsx` is long enough
3+
// without a config table in the middle of it. Labels mirror the Triage page's tab names
34
// (`QuestionStateTabs.tsx`) -- "Guest Questions"/"Asked Questions" describe who acts on/what happens to
45
// a question in that state, not the raw `PENDING_REVIEW`/`APPROVED`/`ASKED` state name.
56
//

apps/website/src/app/dashboard/[id]/ama/amas/[amaId]/questions/_components/QuestionStatsSummary.tsx

Lines changed: 0 additions & 52 deletions
This file was deleted.

apps/website/src/app/dashboard/[id]/ama/amas/[amaId]/questions/page.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { QuestionStateTabs } from './_components/QuestionStateTabs';
2-
import { QuestionStatsSummary } from './_components/QuestionStatsSummary';
32
import { QuestionTagFilter } from './_components/QuestionTagFilter';
43
import { QuestionsList } from './_components/QuestionsList';
54
import { Heading } from '@/components/common/Heading';
@@ -12,7 +11,6 @@ export default function AMAQuestionsPage() {
1211
<div className="flex flex-col [&>*:not(:first-of-type)]:mt-8 [&>*]:first-of-type:mb-4">
1312
<AMADashboardCrumbs />
1413
<Heading subtitle="Triage, tag, prepare answers, and merge duplicates" title="Questions" />
15-
<QuestionStatsSummary />
1614
<QuestionStateTabs />
1715
<SearchBar placeholder="Search question content...">
1816
<QuestionTagFilter />

services/api/src/routes/ama/getAMAStats.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getContext } from '@chatsift/backend-core';
2-
import type { AmaQuestionState, AmaSessions, AmaSessionsId } from '@chatsift/db';
2+
import type { AmaQuestionState, AmaQuestionTagsId, AmaSessions, AmaSessionsId } from '@chatsift/db';
33
import { notFound } from '@hapi/boom';
44
import { z } from 'zod';
55
import { defineRoute } from '../../core/route.js';
@@ -16,15 +16,34 @@ const paramsSchema = z.object({
1616
.transform((value) => value as AmaSessionsId),
1717
});
1818

19+
export interface AMATagCount {
20+
count: number;
21+
id: AmaQuestionTagsId;
22+
name: string;
23+
}
24+
1925
export interface AMAStats {
2026
byState: Record<AmaQuestionState, number>;
27+
/**
28+
* Question count per custom tag in this session, ordered by tag name to match `tags/listTags.ts` (and
29+
* therefore the Triage page's tag filter). Tags with no assignments are included at `0` rather than
30+
* omitted -- an unused tag is exactly what a maintainer wants to see here.
31+
*/
32+
byTag: AMATagCount[];
2133
/**
2234
* Total number of duplicate questions merged away into another question in this AMA (i.e. rows in
2335
* `ama_question_askers` across every question still in this session) -- not itself a question count,
2436
* so it's surfaced alongside `byState`/`total` rather than folded into either.
2537
*/
2638
mergedDuplicatesCount: number;
2739
total: number;
40+
/**
41+
* Distinct people who asked at least one question in this session, counting every state (a denied or
42+
* still-pending question was still asked by someone). Deliberately unaffected by merging: a merged-away
43+
* question's author moves into `ama_question_askers`, so they keep counting as a participant even
44+
* though `total` drops by one.
45+
*/
46+
uniqueAskerCount: number;
2847
}
2948

3049
export default defineRoute({
@@ -50,7 +69,7 @@ export default defineRoute({
5069
throw notFound('ama session not found');
5170
}
5271

53-
const [counts, [mergedDuplicates]] = await Promise.all([
72+
const [counts, [mergedDuplicates], [uniqueAskers], tagCounts] = await Promise.all([
5473
db<{ count: string; state: AmaQuestionState }[]>`
5574
SELECT state, COUNT(*) AS count FROM ama_questions WHERE ama_id = ${amaId} GROUP BY state
5675
`,
@@ -59,6 +78,27 @@ export default defineRoute({
5978
INNER JOIN ama_questions q ON q.id = a.question_id
6079
WHERE q.ama_id = ${amaId}
6180
`,
81+
// An asker id lives in two places -- on the question they authored, and (once their question is
82+
// merged away into a survivor) on `ama_question_askers`. `UNION` rather than `UNION ALL` so someone
83+
// appearing in both, or asking several times, still counts once.
84+
db<{ count: string }[]>`
85+
SELECT COUNT(*) AS count FROM (
86+
SELECT author_id FROM ama_questions WHERE ama_id = ${amaId}
87+
UNION
88+
SELECT a.author_id FROM ama_question_askers a
89+
INNER JOIN ama_questions q ON q.id = a.question_id
90+
WHERE q.ama_id = ${amaId}
91+
) AS askers
92+
`,
93+
// LEFT JOIN, so a tag nobody has used yet reports 0 instead of dropping out of the list entirely.
94+
db<{ count: string; id: AmaQuestionTagsId; name: string }[]>`
95+
SELECT t.id, t.name, COUNT(ta.question_id) AS count
96+
FROM ama_question_tags t
97+
LEFT JOIN ama_question_tag_assignments ta ON ta.tag_id = t.id
98+
WHERE t.ama_id = ${amaId}
99+
GROUP BY t.id, t.name
100+
ORDER BY t.name ASC
101+
`,
62102
]);
63103

64104
const byState = Object.fromEntries(QUESTION_STATES.map((state) => [state, 0])) as Record<AmaQuestionState, number>;
@@ -70,6 +110,12 @@ export default defineRoute({
70110
total += parsed;
71111
}
72112

73-
return { byState, mergedDuplicatesCount: Number(mergedDuplicates?.count ?? 0), total };
113+
return {
114+
byState,
115+
byTag: tagCounts.map(({ id, name, count }) => ({ id, name, count: Number(count) })),
116+
mergedDuplicatesCount: Number(mergedDuplicates?.count ?? 0),
117+
total,
118+
uniqueAskerCount: Number(uniqueAskers?.count ?? 0),
119+
};
74120
},
75121
});

0 commit comments

Comments
 (0)