@@ -40,6 +40,7 @@ import {
4040} from "./boardRepo" ;
4141import { createBoardSSEResponse , createPublicBoardSSEResponse } from "./boardSSE" ;
4242import { cliVersionMiddleware } from "./cliVersion" ;
43+ import type { D1 } from "./db" ;
4344import { addAgentEmail , getGithubToken , removeAgentEmail , syncGpgKey } from "./githubService" ;
4445import { getArmoredPrivateKey , getRootKeyInfo , getRootPublicKey , getSubkeyIds } from "./gpgKeyRepo" ;
4546import { createLogger } from "./logger" ;
@@ -257,45 +258,8 @@ api.get("/api/share/:slug/badge.svg", async (c) => {
257258 const board = await getBoardBySlug ( c . env . DB , c . req . param ( "slug" ) ) ;
258259 if ( ! board ) throw new HTTPException ( 404 , { message : "Board not found" } ) ;
259260
260- const counts = { todo : 0 , in_progress : 0 , in_review : 0 , done : 0 } ;
261- for ( const t of board . tasks ) {
262- if ( t . status === "todo" ) counts . todo ++ ;
263- else if ( t . status === "in_progress" ) counts . in_progress ++ ;
264- else if ( t . status === "in_review" ) counts . in_review ++ ;
265- else if ( t . status === "done" ) counts . done ++ ;
266- }
267-
268- function escapeXml ( s : string ) : string {
269- return s . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) . replace ( / " / g, """ ) . replace ( / ' / g, "'" ) ;
270- }
271-
272- const label = escapeXml ( board . name ) ;
273- const value = escapeXml ( `${ counts . todo } todo · ${ counts . in_progress } active · ${ counts . in_review } review · ${ counts . done } done` ) ;
274-
275- const labelWidth = Math . max ( label . length * 7 + 16 , 60 ) ;
276- const valueWidth = value . length * 6.5 + 16 ;
277- const totalWidth = labelWidth + valueWidth ;
278-
279- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${ totalWidth } " height="20">
280- <linearGradient id="s" x2="0" y2="100%">
281- <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
282- <stop offset="1" stop-opacity=".1"/>
283- </linearGradient>
284- <clipPath id="r">
285- <rect width="${ totalWidth } " height="20" rx="3" fill="#fff"/>
286- </clipPath>
287- <g clip-path="url(#r)">
288- <rect width="${ labelWidth } " height="20" fill="#1e293b"/>
289- <rect x="${ labelWidth } " width="${ valueWidth } " height="20" fill="#0891b2"/>
290- <rect width="${ totalWidth } " height="20" fill="url(#s)"/>
291- </g>
292- <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
293- <text x="${ labelWidth / 2 } " y="15" fill="#010101" fill-opacity=".3">${ label } </text>
294- <text x="${ labelWidth / 2 } " y="14">${ label } </text>
295- <text x="${ labelWidth + valueWidth / 2 } " y="15" fill="#010101" fill-opacity=".3">${ value } </text>
296- <text x="${ labelWidth + valueWidth / 2 } " y="14">${ value } </text>
297- </g>
298- </svg>` ;
261+ const badge = await getShareBadge ( c . env . DB , board . id , board . owner_id , c . req . query ( "type" ) ) ;
262+ const svg = renderMetricBadge ( "AK" , badge . value ) ;
299263
300264 return new Response ( svg , {
301265 headers : {
@@ -1097,6 +1061,87 @@ function escapeHtml(s: string): string {
10971061 return s . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) . replace ( / " / g, """ ) ;
10981062}
10991063
1064+ type ShareBadgeType = "agents" | "tasks" | "tokens" ;
1065+
1066+ const SHARE_BADGE_TYPES = new Set < ShareBadgeType > ( [ "agents" , "tasks" , "tokens" ] ) ;
1067+
1068+ async function getShareBadge ( db : D1 , boardId : string , ownerId : string , type : string | undefined ) : Promise < { value : string } > {
1069+ const badgeType = SHARE_BADGE_TYPES . has ( type as ShareBadgeType ) ? ( type as ShareBadgeType ) : "agents" ;
1070+ if ( badgeType === "agents" ) return { value : `${ await countOwnerAgents ( db , ownerId ) } agents` } ;
1071+ if ( badgeType === "tasks" ) return { value : `${ await countDoneTasks ( db , boardId ) } tasks` } ;
1072+ return { value : `${ formatMetric ( await sumOwnerTokens ( db , ownerId ) ) } tokens` } ;
1073+ }
1074+
1075+ async function countOwnerAgents ( db : D1 , ownerId : string ) : Promise < number > {
1076+ const row = await db
1077+ . prepare ( "SELECT COUNT(*) as count FROM agents WHERE owner_id = ? AND COALESCE(version, 'latest') = 'latest'" )
1078+ . bind ( ownerId )
1079+ . first < { count : number } > ( ) ;
1080+ return row ?. count ?? 0 ;
1081+ }
1082+
1083+ async function countDoneTasks ( db : D1 , boardId : string ) : Promise < number > {
1084+ const row = await db . prepare ( "SELECT COUNT(*) as count FROM tasks WHERE board_id = ? AND status = 'done'" ) . bind ( boardId ) . first < { count : number } > ( ) ;
1085+ return row ?. count ?? 0 ;
1086+ }
1087+
1088+ async function sumOwnerTokens ( db : D1 , ownerId : string ) : Promise < number > {
1089+ const row = await db
1090+ . prepare ( `
1091+ SELECT COALESCE(SUM(s.input_tokens + s.output_tokens + s.cache_read_tokens + s.cache_creation_tokens), 0) as tokens
1092+ FROM agent_sessions s
1093+ JOIN agents a ON a.id = s.agent_id
1094+ WHERE a.owner_id = ?
1095+ ` )
1096+ . bind ( ownerId )
1097+ . first < { tokens : number } > ( ) ;
1098+ return row ?. tokens ?? 0 ;
1099+ }
1100+
1101+ function formatMetric ( value : number ) : string {
1102+ if ( value >= 1_000_000_000 ) return `${ trimMetric ( value / 1_000_000_000 ) } B` ;
1103+ if ( value >= 1_000_000 ) return `${ trimMetric ( value / 1_000_000 ) } M` ;
1104+ if ( value >= 1_000 ) return `${ trimMetric ( value / 1_000 ) } K` ;
1105+ return String ( value ) ;
1106+ }
1107+
1108+ function trimMetric ( value : number ) : string {
1109+ return value >= 10 ? String ( Math . round ( value ) ) : value . toFixed ( 1 ) . replace ( / \. 0 $ / , "" ) ;
1110+ }
1111+
1112+ function renderMetricBadge ( label : string , value : string ) : string {
1113+ const safeLabel = escapeXml ( label ) ;
1114+ const safeValue = escapeXml ( value ) ;
1115+ const labelWidth = Math . max ( safeLabel . length * 7 + 16 , 32 ) ;
1116+ const valueWidth = Math . max ( safeValue . length * 6.5 + 16 , 64 ) ;
1117+ const totalWidth = labelWidth + valueWidth ;
1118+
1119+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${ totalWidth } " height="20">
1120+ <linearGradient id="s" x2="0" y2="100%">
1121+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
1122+ <stop offset="1" stop-opacity=".1"/>
1123+ </linearGradient>
1124+ <clipPath id="r">
1125+ <rect width="${ totalWidth } " height="20" rx="3" fill="#fff"/>
1126+ </clipPath>
1127+ <g clip-path="url(#r)">
1128+ <rect width="${ labelWidth } " height="20" fill="#18181b"/>
1129+ <rect x="${ labelWidth } " width="${ valueWidth } " height="20" fill="#0891b2"/>
1130+ <rect width="${ totalWidth } " height="20" fill="url(#s)"/>
1131+ </g>
1132+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
1133+ <text x="${ labelWidth / 2 } " y="15" fill="#010101" fill-opacity=".3">${ safeLabel } </text>
1134+ <text x="${ labelWidth / 2 } " y="14">${ safeLabel } </text>
1135+ <text x="${ labelWidth + valueWidth / 2 } " y="15" fill="#010101" fill-opacity=".3">${ safeValue } </text>
1136+ <text x="${ labelWidth + valueWidth / 2 } " y="14">${ safeValue } </text>
1137+ </g>
1138+ </svg>` ;
1139+ }
1140+
1141+ function escapeXml ( s : string ) : string {
1142+ return s . replace ( / & / g, "&" ) . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) . replace ( / " / g, """ ) . replace ( / ' / g, "'" ) ;
1143+ }
1144+
11001145function agentEmail ( username : string ) : string {
11011146 return `${ username } @mails.agent-kanban.dev` ;
11021147}
0 commit comments