| <script lang="ts"> |
| import { onMount, getContext } from 'svelte'; |
| import { getUserAnalytics } from '$lib/apis/analytics'; |
| import Spinner from '$lib/components/common/Spinner.svelte'; |
| import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; |
| import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; |
| |
| const i18n = getContext('i18n'); |
| |
| let userStats: Array<{ user_id: string; count: number }> = []; |
| let loading = true; |
| let orderBy = 'count'; |
| let direction: 'asc' | 'desc' = 'desc'; |
| |
| const toggleSort = (key: string) => { |
| if (orderBy === key) { |
| direction = direction === 'asc' ? 'desc' : 'asc'; |
| } else { |
| orderBy = key; |
| direction = key === 'user_id' ? 'asc' : 'desc'; |
| } |
| }; |
| |
| const loadAnalytics = async () => { |
| loading = true; |
| try { |
| const result = await getUserAnalytics(localStorage.token, null, null, 100); |
| userStats = result?.users ?? []; |
| } catch (err) { |
| console.error('User analytics load failed:', err); |
| } |
| loading = false; |
| }; |
| |
| $: sortedUsers = [...userStats].sort((a, b) => { |
| if (orderBy === 'user_id') { |
| return direction === 'asc' |
| ? a.user_id.localeCompare(b.user_id) |
| : b.user_id.localeCompare(a.user_id); |
| } |
| return direction === 'asc' ? a.count - b.count : b.count - a.count; |
| }); |
| |
| $: totalMessages = userStats.reduce((sum, u) => sum + u.count, 0); |
| |
| onMount(loadAnalytics); |
| </script> |
|
|
| <div |
| class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900" |
| > |
| <div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0"> |
| {$i18n.t('User Activity')} |
| <span class="text-lg text-gray-500">{userStats.length} {$i18n.t('users')}</span> |
| </div> |
| </div> |
|
|
| <div |
| class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]" |
| > |
| {#if loading} |
| <div |
| class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50" |
| > |
| <Spinner className="size-5" /> |
| </div> |
| {/if} |
|
|
| {#if !userStats.length && !loading} |
| <div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div> |
| {:else if userStats.length} |
| <table |
| class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading |
| ? 'opacity-20' |
| : ''}" |
| > |
| <thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200"> |
| <tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30"> |
| <th scope="col" class="px-2.5 py-2 w-8">#</th> |
| <th |
| scope="col" |
| class="px-2.5 py-2 cursor-pointer select-none" |
| on:click={() => toggleSort('user_id')} |
| > |
| <div class="flex gap-1.5 items-center"> |
| {$i18n.t('User')} |
| {#if orderBy === 'user_id'} |
| {#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown |
| className="size-2" |
| />{/if} |
| {:else} |
| <span class="invisible"><ChevronUp className="size-2" /></span> |
| {/if} |
| </div> |
| </th> |
| <th |
| scope="col" |
| class="px-2.5 py-2 cursor-pointer select-none text-right" |
| on:click={() => toggleSort('count')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| {$i18n.t('Messages')} |
| {#if orderBy === 'count'} |
| {#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown |
| className="size-2" |
| />{/if} |
| {:else} |
| <span class="invisible"><ChevronUp className="size-2" /></span> |
| {/if} |
| </div> |
| </th> |
| <th |
| scope="col" |
| class="px-2.5 py-2 cursor-pointer select-none text-right w-24" |
| on:click={() => toggleSort('percentage')} |
| > |
| <div class="flex gap-1.5 items-center justify-end"> |
| {$i18n.t('Share')} |
| {#if orderBy === 'percentage'} |
| {#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown |
| className="size-2" |
| />{/if} |
| {:else} |
| <span class="invisible"><ChevronUp className="size-2" /></span> |
| {/if} |
| </div> |
| </th> |
| </tr> |
| </thead> |
| <tbody> |
| {#each sortedUsers as user, idx (user.user_id)} |
| <tr |
| class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition" |
| > |
| <td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white"> |
| {idx + 1} |
| </td> |
| <td class="px-3 py-1.5"> |
| <span class="font-medium text-gray-800 dark:text-gray-200 font-mono text-xs"> |
| {user.user_id.substring(0, 8)}... |
| </span> |
| </td> |
| <td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white"> |
| {user.count.toLocaleString()} |
| </td> |
| <td class="px-3 py-1.5 text-right font-medium text-blue-500"> |
| {((user.count / totalMessages) * 100).toFixed(1)}% |
| </td> |
| </tr> |
| {/each} |
| </tbody> |
| </table> |
| {/if} |
| </div> |
|
|
| <div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end"> |
| <div class="text-right"> |
| ⓘ {$i18n.t('Showing all messages (user + assistant) per user.')} |
| </div> |
| </div> |
|
|