diff --git a/README.md b/README.md index 8efe855b..0cba9893 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ docker logs | jq . ### AI Features (Optional) -- **Daily Memory Job** - Summarizes conversations daily at 3 AM (requires OpenAI API key) +- **Nightly Analysis Sequence** - Runs memory extraction, argument analysis, and trait synthesis daily at 3 AM (requires OpenAI API key) - **Sentiment Analysis** - Analyzes message tone - **AI Summaries** - Generates summaries of message threads @@ -262,12 +262,12 @@ docker logs | jq . Most scheduled jobs run inside the backend Node.js process using `node-cron`. They are started automatically when the server connects to the database. -| Job | Schedule | Location | Description | -| ---------------- | -------------------------------- | ----------- | ---------------------------------------------------------------------------------- | -| **Daily Memory** | `0 3 * * *` (3 AM ET) | In-process | Extracts AI memories from all Slack channels | -| **Fun Fact** | `0 9 * * *` (9 AM ET) | In-process | Posts daily facts, joke, quote, and on-this-day event to Slack | -| **Pricing** | `10 * * * *` (every hour at :10) | In-process | Recalculates item prices based on median reputation | -| **Health Check** | `*/5 * * * *` (every 5 min) | Bash script | Checks the `/health` endpoint from outside the process and alerts Slack on failure | +| Job | Schedule | Location | Description | +| ----------------------------- | -------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- | +| **Nightly Analysis Sequence** | `0 3 * * *` (3 AM ET) | In-process | Runs memory extraction, selective argument leaderboard extraction, and trait synthesis in order | +| **Fun Fact** | `0 9 * * *` (9 AM ET) | In-process | Posts daily facts, joke, quote, and on-this-day event to Slack | +| **Pricing** | `10 * * * *` (every hour at :10) | In-process | Recalculates item prices based on median reputation | +| **Health Check** | `*/5 * * * *` (every 5 min) | Bash script | Checks the `/health` endpoint from outside the process and alerts Slack on failure | #### Fun Fact Job environment variables diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index d930fdcf..8e58e12c 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -207,3 +207,52 @@ Output format: - If no strong traits are present, return []`; export const DAILY_MEMORY_JOB_CONCURRENCY = 50; +export const DAILY_ARGUMENT_JOB_CONCURRENCY = 50; + +export const ARGUMENT_EXTRACTION_PROMPT = `You are a highly selective argument detection tool reviewing one Slack channel's last 24 hours of conversation. + +Your job is to identify every clearly real argument between humans in this channel, if any clearly happened, and determine who won each one. + +You must be INTOLERANT OF FALSE POSITIVES. +Prefer false negatives over false positives. +If there is any meaningful doubt that the conversation was a real argument, return []. + +ONLY treat something as an argument when ALL of the following are true: +- at least two identifiable human participants directly disagreed +- the disagreement involved competing viewpoints, not just different preferences stated once +- there was sustained back-and-forth with rebuttals or direct challenges +- the winner can be identified from the substance of the exchange + +Return [] for: +- jokes, banter, teasing, sarcasm, or friendly ribbing +- brief disagreements without sustained rebuttals +- brainstorming, clarification, or neutral discussion +- factual Q&A where one person is simply correct +- bot interactions or people reacting to Moonbeam +- any conversation where the winner is ambiguous +- weak or low-substance disagreements that do not merit leaderboard tracking + +Return ONLY a JSON array. + +Each array item must be a JSON object in this exact shape: +{ + "summary": "short summary of the argument", + "participants": [ + { "slackId": "U123", "name": "Alice", "viewpoint": "their side of the argument" } + ], + "winnerSlackId": "U123", + "pointValue": 4 +} + +Rules for valid JSON output: +- return [] if no conversation clearly qualifies +- include 0 or more argument objects; do not invent arguments just to fill the array +- each argument object must include at least 2 participants +- every participant must be a human from the conversation +- the winnerSlackId must match one of the listed participants +- pointValue must be an integer from 0 to 5 based on substance/depth +- reserve 4-5 for long, detailed, high-signal arguments +- omit any weak or ambiguous disagreement instead of returning a low-confidence object +- do not include markdown, prose, or extra keys + +If no conversation clearly qualifies, return the exact JSON array [] and nothing else.`; diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 837a4ce1..a72e53d8 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -26,16 +26,11 @@ import { SlackService } from '../shared/services/slack/slack.service'; import { SlackPersistenceService } from '../shared/services/slack/slack.persistence.service'; import { MuzzlePersistenceService } from '../muzzle/muzzle.persistence.service'; import OpenAI from 'openai'; -import type { - ResponseOutputMessage, - ResponseOutputItem, - ResponseOutputText, - ResponseOutputRefusal, -} from 'openai/resources/responses/responses'; import type { Part } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; import { extractParticipantSlackIds } from './helpers/extractParticipantSlackIds'; +import { extractOpenAiResponseText } from './helpers/extractOpenAiResponseText'; import { TraitService } from '../trait/trait.service'; interface ReleaseCommit { @@ -51,17 +46,6 @@ interface ReleaseMetadata { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; -const isResponseOutputMessage = (block: ResponseOutputItem): block is ResponseOutputMessage => block.type === 'message'; - -const isResponseOutputText = (block: ResponseOutputText | ResponseOutputRefusal): block is ResponseOutputText => - block.type === 'output_text'; - -const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { - const textBlock = response.output.find(isResponseOutputMessage); - const outputText = textBlock?.content.find(isResponseOutputText)?.text; - return outputText?.trim(); -}; - const ensureSentenceCaseAndPunctuation = (text: string): string => { const trimmed = text.trim(); if (!trimmed) { @@ -151,7 +135,7 @@ export class AIService { user: `${userId}-DaBros2016`, }) .then((x) => { - return extractAndParseOpenAiResponse(x); + return extractOpenAiResponseText(x); }) .then(async (result) => { await this.redis.removeInflight(userId, teamId); @@ -205,7 +189,7 @@ export class AIService { input: REDPLOY_MOONBEAM_TEXT_PROMPT, user: 'Moonbeam', }) - .then((x) => extractAndParseOpenAiResponse(x)); + .then((x) => extractOpenAiResponseText(x)); const aiImage = this.gemini.models .generateContent({ @@ -370,7 +354,7 @@ export class AIService { return this.openAi.responses .create({ model: GPT_MODEL, input: text, user: 'Moonbeam', instructions: CORPO_SPEAK_INSTRUCTIONS }) .then((x) => { - return extractAndParseOpenAiResponse(x); + return extractOpenAiResponseText(x); }) .catch(async (e) => { logError(this.aiServiceLogger, 'Failed to generate corpo-speak response', e, { @@ -427,7 +411,7 @@ export class AIService { input: prompt, user: `${user_id}-DaBros2016`, }) - .then((x) => extractAndParseOpenAiResponse(x)) + .then((x) => extractOpenAiResponseText(x)) .then(async (result) => { await this.redis.removeInflight(user_id, team_id); if (!result) { @@ -527,7 +511,7 @@ export class AIService { input, user: `participation-${channelId}-${teamId}-DaBros2016`, }) - .then((x) => extractAndParseOpenAiResponse(x)) + .then((x) => extractOpenAiResponseText(x)) .then((result) => { this.aiServiceLogger.info('Received participation model response', { teamId, diff --git a/packages/backend/src/ai/helpers/extractOpenAiResponseText.ts b/packages/backend/src/ai/helpers/extractOpenAiResponseText.ts new file mode 100644 index 00000000..927479f6 --- /dev/null +++ b/packages/backend/src/ai/helpers/extractOpenAiResponseText.ts @@ -0,0 +1,10 @@ +import type OpenAI from 'openai'; + +export const extractOpenAiResponseText = (response: OpenAI.Responses.Response): string | undefined => { + const textBlock = response.output.find((item) => item.type === 'message'); + if (textBlock && 'content' in textBlock) { + const outputText = textBlock.content.find((item) => item.type === 'output_text'); + return outputText?.text.trim(); + } + return undefined; +}; diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts index 1fbb739f..54912216 100644 --- a/packages/backend/src/ai/memory/memory.job.ts +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -7,8 +7,8 @@ import { AIService } from '../ai.service'; import { logger } from '../../shared/logger/logger'; import { DAILY_MEMORY_JOB_CONCURRENCY, GATE_MODEL, MEMORY_EXTRACTION_PROMPT } from '../ai.constants'; import { MOONBEAM_SLACK_ID } from '../ai.constants'; -import type OpenAI from 'openai'; import { extractParticipantSlackIds } from '../helpers/extractParticipantSlackIds'; +import { extractOpenAiResponseText } from '../helpers/extractOpenAiResponseText'; interface ExtractionResult { slackId: string; @@ -17,15 +17,6 @@ interface ExtractionResult { existingMemoryId: number | null; } -const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { - const textBlock = response.output.find((item) => item.type === 'message'); - if (textBlock && 'content' in textBlock) { - const outputText = textBlock.content.find((item) => item.type === 'output_text'); - return outputText?.text.trim(); - } - return undefined; -}; - export class MemoryJob { private historyService = new HistoryPersistenceService(); private memoryPersistenceService = new MemoryPersistenceService(); @@ -111,7 +102,7 @@ export class MemoryJob { instructions: prompt, input: conversationHistory, }) - .then((response) => extractAndParseOpenAiResponse(response)); + .then((response) => extractOpenAiResponseText(response)); if (!result) { this.jobLogger.warn('Extraction returned no result'); diff --git a/packages/backend/src/argument/argument.job.spec.ts b/packages/backend/src/argument/argument.job.spec.ts new file mode 100644 index 00000000..ff529ff7 --- /dev/null +++ b/packages/backend/src/argument/argument.job.spec.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getRepository } from 'typeorm'; +import { ArgumentJob } from './argument.job'; + +describe('ArgumentJob', () => { + let job: ArgumentJob; + let argumentPersistenceService: { + saveArgumentOutcome: ReturnType; + }; + let redis: { + getValue: ReturnType; + setValueWithExpire: ReturnType; + }; + let jobLogger: { + info: ReturnType; + warn: ReturnType; + }; + let historyService: { + getLast24HoursForChannel: ReturnType; + }; + let findUsers: ReturnType; + let aiService: { + formatHistory: ReturnType; + openAi: { + responses: { + create: ReturnType; + }; + }; + }; + + beforeEach(() => { + job = new ArgumentJob({ formatHistory: vi.fn() } as never); + argumentPersistenceService = { + saveArgumentOutcome: vi.fn().mockResolvedValue(null), + }; + redis = { + getValue: vi.fn().mockResolvedValue(null), + setValueWithExpire: vi.fn().mockResolvedValue('OK'), + }; + jobLogger = { + info: vi.fn(), + warn: vi.fn(), + }; + historyService = { + getLast24HoursForChannel: vi.fn(), + }; + findUsers = vi.fn(); + aiService = { + formatHistory: vi.fn().mockReturnValue('formatted history'), + openAi: { + responses: { + create: vi.fn(), + }, + }, + }; + + (job as never as { argumentPersistenceService: unknown }).argumentPersistenceService = argumentPersistenceService; + (job as never as { historyService: unknown }).historyService = historyService; + (job as never as { redis: unknown }).redis = redis; + (job as never as { jobLogger: unknown }).jobLogger = jobLogger; + (job as never as { aiService: unknown }).aiService = aiService; + (getRepository as Mock).mockReturnValue({ find: findUsers }); + }); + + it('returns early when extraction lock exists', async () => { + redis.getValue.mockResolvedValue('1'); + + await ( + job as never as { + extractArgument: ( + teamId: string, + channelId: string, + historyMessages: Array<{ message: string }>, + ) => Promise; + } + ).extractArgument('T1', 'C1', [{ message: 'history' }]); + + expect(jobLogger.info).toHaveBeenCalled(); + expect(aiService.openAi.responses.create).not.toHaveBeenCalled(); + }); + + it('does nothing when extractor returns an empty array', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: '[]' }] }], + }); + + await ( + job as never as { + extractArgument: ( + teamId: string, + channelId: string, + historyMessages: Array<{ message: string }>, + ) => Promise; + } + ).extractArgument('T1', 'C1', [{ message: 'history' }]); + + expect(argumentPersistenceService.saveArgumentOutcome).not.toHaveBeenCalled(); + }); + + it('saves each valid extracted argument outcome', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: JSON.stringify([ + { + summary: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }, + { + summary: 'vim vs emacs', + participants: [ + { slackId: 'U3', name: 'Carol', viewpoint: 'vim is faster' }, + { slackId: 'U4', name: 'Dan', viewpoint: 'emacs is more powerful' }, + ], + winnerSlackId: 'U3', + pointValue: 3, + }, + ]), + }, + ], + }, + ], + }); + argumentPersistenceService.saveArgumentOutcome + .mockResolvedValueOnce({ + id: 1, + argument: 'tabs vs spaces', + participants: [], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }) + .mockResolvedValueOnce({ + id: 2, + argument: 'vim vs emacs', + participants: [], + winner: { name: 'Carol', slackId: 'U3' }, + pointValue: 3, + createdAt: '2026-05-21T00:10:00.000Z', + }); + + await ( + job as never as { + extractArgument: ( + teamId: string, + channelId: string, + historyMessages: Array<{ message: string; slackId: string; name: string }>, + ) => Promise; + } + ).extractArgument('T1', 'C1', [{ slackId: 'U1', name: 'Alice', message: 'history' }]); + + expect(argumentPersistenceService.saveArgumentOutcome).toHaveBeenNthCalledWith(1, { + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }); + expect(argumentPersistenceService.saveArgumentOutcome).toHaveBeenNthCalledWith(2, { + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'vim vs emacs', + participants: [ + { slackId: 'U3', name: 'Carol', viewpoint: 'vim is faster' }, + { slackId: 'U4', name: 'Dan', viewpoint: 'emacs is more powerful' }, + ], + winnerSlackId: 'U3', + pointValue: 3, + }); + expect(jobLogger.info).toHaveBeenCalledWith('Argument extracted for C1: "tabs vs spaces"'); + expect(jobLogger.info).toHaveBeenCalledWith('Argument extracted for C1: "vim vs emacs"'); + }); + + it('skips extraction when fewer than two known human participants remain after filtering bots', async () => { + historyService.getLast24HoursForChannel.mockResolvedValue([ + { slackId: 'U1', name: 'Alice', message: 'hello' }, + { slackId: 'B1', name: 'Build Bot', message: 'beep boop' }, + ]); + findUsers.mockResolvedValue([{ slackId: 'U1', name: 'Alice', isBot: false }]); + + await ( + job as never as { extractArgumentForChannel: (teamId: string, channelId: string) => Promise } + ).extractArgumentForChannel('T1', 'C1'); + + expect(findUsers).toHaveBeenCalledWith({ + where: [ + { slackId: 'U1', teamId: 'T1', isBot: false }, + { slackId: 'B1', teamId: 'T1', isBot: false }, + ], + }); + expect(aiService.openAi.responses.create).not.toHaveBeenCalled(); + }); + + it('continues extraction when at least two known human participants remain after filtering bots', async () => { + historyService.getLast24HoursForChannel.mockResolvedValue([ + { slackId: 'U1', name: 'Alice', message: 'hello' }, + { slackId: 'B1', name: 'Build Bot', message: 'beep boop' }, + { slackId: 'U2', name: 'Bob', message: 'hi' }, + ]); + findUsers.mockResolvedValue([ + { slackId: 'U1', name: 'Alice', isBot: false }, + { slackId: 'U2', name: 'Bob', isBot: false }, + ]); + aiService.openAi.responses.create.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: '[]' }] }], + }); + + await ( + job as never as { extractArgumentForChannel: (teamId: string, channelId: string) => Promise } + ).extractArgumentForChannel('T1', 'C1'); + + expect(aiService.openAi.responses.create).toHaveBeenCalledOnce(); + }); + + it('skips malformed extraction payloads and logs warnings', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: '{"summary":"oops"}' }] }], + }); + + await ( + job as never as { + extractArgument: ( + teamId: string, + channelId: string, + historyMessages: Array<{ message: string }>, + ) => Promise; + } + ).extractArgument('T1', 'C1', [{ message: 'history' }]); + + expect(argumentPersistenceService.saveArgumentOutcome).not.toHaveBeenCalled(); + expect(jobLogger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/argument/argument.job.ts b/packages/backend/src/argument/argument.job.ts new file mode 100644 index 00000000..6bd301b1 --- /dev/null +++ b/packages/backend/src/argument/argument.job.ts @@ -0,0 +1,227 @@ +import { getRepository } from 'typeorm'; +import { SlackChannel } from '../shared/db/models/SlackChannel'; +import type { MessageWithName } from '../shared/models/message/message-with-name'; +import { HistoryPersistenceService } from '../shared/services/history.persistence.service'; +import { RedisPersistenceService } from '../shared/services/redis.persistence.service'; +import { AIService } from '../ai/ai.service'; +import { logger } from '../shared/logger/logger'; +import { logError } from '../shared/logger/error-logging'; +import { SlackUser } from '../shared/db/models/SlackUser'; +import { + DAILY_ARGUMENT_JOB_CONCURRENCY, + ARGUMENT_EXTRACTION_PROMPT, + GATE_MODEL, + MOONBEAM_SLACK_ID, +} from '../ai/ai.constants'; +import { extractParticipantSlackIds } from '../ai/helpers/extractParticipantSlackIds'; +import { extractOpenAiResponseText } from '../ai/helpers/extractOpenAiResponseText'; +import { ArgumentPersistenceService } from './argument.persistence.service'; +import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; + +interface ArgumentExtractionResult { + summary: string; + participants: ArgumentParticipant[]; + winnerSlackId: string; + pointValue: number; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +export class ArgumentJob { + private historyService = new HistoryPersistenceService(); + private argumentPersistenceService = new ArgumentPersistenceService(); + private redis = RedisPersistenceService.getInstance(); + private aiService: AIService; + private jobLogger = logger.child({ module: 'ArgumentJob' }); + + constructor(aiService?: AIService) { + this.aiService = aiService ?? new AIService(); + } + + async run(): Promise { + this.jobLogger.info('Starting argument extraction job'); + + const channels = await getRepository(SlackChannel).find(); + const results = await this.runWithConcurrencyLimit( + channels.map((channel) => () => this.extractArgumentForChannel(channel.teamId, channel.channelId)), + DAILY_ARGUMENT_JOB_CONCURRENCY, + ); + + const failed = results + .map((result, index) => ({ result, index })) + .filter((item): item is { result: PromiseRejectedResult; index: number } => item.result.status === 'rejected'); + + failed.forEach(({ result, index }) => { + const channel = channels[index]; + this.jobLogger.warn( + `Failed to extract argument for channel ${channel.channelId} (team ${channel.teamId}):`, + result.reason, + ); + }); + + const processed = results.length - failed.length; + this.jobLogger.info(`Argument extraction job complete: processed ${processed}/${channels.length} channels`); + } + + private async extractArgumentForChannel(teamId: string, channelId: string): Promise { + const historyMessages = await this.historyService.getLast24HoursForChannel(teamId, channelId); + if (historyMessages.length === 0) return; + + const participantSlackIds = extractParticipantSlackIds(historyMessages, { + excludeSlackIds: [MOONBEAM_SLACK_ID], + }); + if (participantSlackIds.length < 2) return; + + const humanParticipants = await getRepository(SlackUser).find({ + where: participantSlackIds.map((slackId) => ({ slackId, teamId, isBot: false })), + }); + if (humanParticipants.length < 2) return; + + await this.extractArgument(teamId, channelId, historyMessages); + } + + private async extractArgument(teamId: string, channelId: string, historyMessages: MessageWithName[]): Promise { + const lockKey = `argument_extraction_lock:${teamId}:${channelId}`; + const locked = await this.redis.getValue(lockKey); + if (locked) { + this.jobLogger.info(`Argument extraction lock active for ${channelId}-${teamId}, skipping`); + return; + } + await this.redis.setValueWithExpire(lockKey, 1, 'EX', 300); + + try { + const history = this.aiService.formatHistory(historyMessages); + const result = await this.aiService.openAi.responses + .create({ + model: GATE_MODEL, + instructions: ARGUMENT_EXTRACTION_PROMPT, + input: history, + user: `nightly-argument-${channelId}-${teamId}`, + }) + .then((response) => extractOpenAiResponseText(response)); + + const parsedResults = this.parseExtractionResults(result); + if (parsedResults.length === 0) { + return; + } + + for (const parsedResult of parsedResults) { + const saved = await this.argumentPersistenceService.saveArgumentOutcome({ + teamId, + channelId, + argumentSummary: parsedResult.summary, + participants: parsedResult.participants, + winnerSlackId: parsedResult.winnerSlackId, + pointValue: parsedResult.pointValue, + }); + + if (saved) { + this.jobLogger.info(`Argument extracted for ${channelId}: "${saved.argument}"`); + } + } + } catch (error) { + logError(this.jobLogger, 'Argument extraction failed', error, { teamId, channelId }); + } + } + + private parseExtractionResults(result: string | undefined): ArgumentExtractionResult[] { + if (!result) { + this.jobLogger.warn('Argument extraction returned no result'); + return []; + } + + const trimmed = result.trim(); + if (trimmed === 'NONE' || trimmed === '"NONE"') { + return []; + } + + try { + const parsed: unknown = JSON.parse(trimmed); + const rawArguments = Array.isArray(parsed) ? parsed : isRecord(parsed) ? [parsed] : null; + if (!rawArguments) { + this.jobLogger.warn(`Argument extraction returned non-array JSON: ${trimmed}`); + return []; + } + + const parsedArguments = rawArguments + .map((argument) => { + if (!isRecord(argument)) { + return null; + } + + const participants = Array.isArray(argument.participants) + ? argument.participants + .map((participant) => { + if (!isRecord(participant)) { + return null; + } + + return typeof participant.slackId === 'string' && + typeof participant.name === 'string' && + typeof participant.viewpoint === 'string' + ? { + slackId: participant.slackId.trim(), + name: participant.name.trim(), + viewpoint: participant.viewpoint.trim(), + } + : null; + }) + .filter((participant): participant is ArgumentParticipant => participant !== null) + : []; + + const summary = typeof argument.summary === 'string' ? argument.summary.trim() : ''; + const winnerSlackId = typeof argument.winnerSlackId === 'string' ? argument.winnerSlackId.trim() : ''; + const pointValue = + typeof argument.pointValue === 'number' ? argument.pointValue : Number(argument.pointValue); + + if ( + !summary || + participants.length < 2 || + !winnerSlackId || + !participants.some((participant) => participant.slackId === winnerSlackId) + ) { + return null; + } + + return { + summary, + participants, + winnerSlackId, + pointValue: Math.min(5, Math.max(0, Number.isFinite(pointValue) ? Math.round(pointValue) : 0)), + }; + }) + .filter((argument): argument is ArgumentExtractionResult => argument !== null); + + if (parsedArguments.length === 0 && rawArguments.length > 0) { + this.jobLogger.warn(`Argument extraction returned incomplete payload: ${trimmed}`); + } + + return parsedArguments; + } catch { + this.jobLogger.warn(`Argument extraction returned malformed JSON: ${trimmed}`); + return []; + } + } + + private async runWithConcurrencyLimit( + tasks: Array<() => Promise>, + concurrency: number, + ): Promise[]> { + const results: PromiseSettledResult[] = new Array(tasks.length); + let nextIndex = 0; + + const runNext = async (): Promise => { + while (nextIndex < tasks.length) { + const index = nextIndex++; + try { + results[index] = { status: 'fulfilled', value: await tasks[index]() }; + } catch (reason) { + results[index] = { status: 'rejected', reason }; + } + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => runNext())); + return results; + } +} diff --git a/packages/backend/src/argument/argument.model.ts b/packages/backend/src/argument/argument.model.ts new file mode 100644 index 00000000..05ce280e --- /dev/null +++ b/packages/backend/src/argument/argument.model.ts @@ -0,0 +1,36 @@ +import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; + +export interface ArgumentLeaderboardStanding { + name: string; + slackId: string; + wins: number; + points: number; +} + +export interface ArgumentOutcomeWinner { + name: string; + slackId: string; +} + +export interface ArgumentOutcomeEntry { + id: number; + argument: string; + participants: ArgumentParticipant[]; + winner: ArgumentOutcomeWinner; + pointValue: number; + createdAt: string; +} + +export interface ArgumentLeaderboardResponse { + leaderboard: ArgumentLeaderboardStanding[]; + arguments: ArgumentOutcomeEntry[]; +} + +export interface SaveArgumentOutcomeInput { + teamId: string; + channelId: string; + argumentSummary: string; + participants: ArgumentParticipant[]; + winnerSlackId: string; + pointValue: number; +} diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts new file mode 100644 index 00000000..05e0e607 --- /dev/null +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -0,0 +1,235 @@ +import { vi } from 'vitest'; +import { getRepository } from 'typeorm'; +import { ArgumentPersistenceService } from './argument.persistence.service'; + +vi.mock('typeorm', async () => { + const actual = await vi.importActual('typeorm'); + return { + ...actual, + getRepository: vi.fn(), + }; +}); + +describe('ArgumentPersistenceService', () => { + const findOne = vi.fn(); + const findUsers = vi.fn(); + const save = vi.fn(); + const query = vi.fn(); + const findArguments = vi.fn(); + let service: ArgumentPersistenceService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ArgumentPersistenceService(); + (getRepository as Mock).mockImplementation((entity: { name?: string }) => { + if (entity.name === 'SlackUser') { + return { findOne, find: findUsers }; + } + + return { save, query, find: findArguments }; + }); + }); + + it('saves an argument outcome with a resolved winner', async () => { + findOne.mockResolvedValue({ id: 7, slackId: 'U2', name: 'Bob' }); + findUsers.mockResolvedValue([ + { id: 6, slackId: 'U1', name: 'Alice' }, + { id: 7, slackId: 'U2', name: 'Bob' }, + ]); + save.mockImplementation(async (entry: { createdAt?: Date }) => ({ + ...entry, + id: 11, + createdAt: entry.createdAt ?? new Date('2026-05-21T00:00:00.000Z'), + })); + + const result = await service.saveArgumentOutcome({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 6, + }); + + expect(findOne).toHaveBeenCalledWith({ where: { slackId: 'U2', teamId: 'T1', isBot: false } }); + expect(findUsers).toHaveBeenCalledWith({ + where: [ + { slackId: 'U1', teamId: 'T1', isBot: false }, + { slackId: 'U2', teamId: 'T1', isBot: false }, + ], + }); + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { id: 6, slackId: 'U1', name: 'Alice' }, + { id: 7, slackId: 'U2', name: 'Bob' }, + ], + participantViewpoints: { + U1: 'tabs are faster', + U2: 'spaces are clearer', + }, + pointValue: 5, + }), + ); + expect(result).toMatchObject({ + id: 11, + argument: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 5, + }); + }); + + it('returns null when the winner cannot be mapped to a non-bot slack user', async () => { + findOne.mockResolvedValue(null); + + await expect( + service.saveArgumentOutcome({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }), + ).resolves.toBeNull(); + expect(findOne).toHaveBeenCalledWith({ where: { slackId: 'U2', teamId: 'T1', isBot: false } }); + expect(findUsers).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + }); + + it('returns null when fewer than two non-bot participants are resolved', async () => { + findOne.mockResolvedValue({ id: 7, slackId: 'U1', name: 'Alice' }); + findUsers.mockResolvedValue([{ id: 7, slackId: 'U1', name: 'Alice' }]); + + await expect( + service.saveArgumentOutcome({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'UBOT', name: 'HelperBot', viewpoint: 'bots should win' }, + ], + winnerSlackId: 'U1', + pointValue: 4, + }), + ).resolves.toBeNull(); + expect(findUsers).toHaveBeenCalledWith({ + where: [ + { slackId: 'U1', teamId: 'T1', isBot: false }, + { slackId: 'UBOT', teamId: 'T1', isBot: false }, + ], + }); + expect(save).not.toHaveBeenCalled(); + }); + + it('filters participant viewpoints to resolved participants before saving', async () => { + findOne.mockResolvedValue({ id: 7, slackId: 'U2', name: 'Bob' }); + findUsers.mockResolvedValue([ + { id: 6, slackId: 'U1', name: 'Alice' }, + { id: 7, slackId: 'U2', name: 'Bob' }, + ]); + save.mockImplementation(async (entry: { createdAt?: Date }) => ({ + ...entry, + id: 12, + createdAt: entry.createdAt ?? new Date('2026-05-21T00:00:00.000Z'), + })); + + const result = await service.saveArgumentOutcome({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + { slackId: 'U3', name: 'Cara', viewpoint: 'tabs are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + participants: [ + { id: 6, slackId: 'U1', name: 'Alice' }, + { id: 7, slackId: 'U2', name: 'Bob' }, + ], + participantViewpoints: { + U1: 'tabs are faster', + U2: 'spaces are clearer', + }, + }), + ); + expect(result?.participants).toEqual([ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ]); + }); + + it('loads leaderboard standings and detailed argument outcomes', async () => { + query.mockResolvedValueOnce([ + { name: 'Bob', slackId: 'U2', wins: '3', points: '12' }, + { name: 'Alice', slackId: 'U1', wins: '1', points: '4' }, + ]); + findArguments.mockResolvedValue([ + { + id: 1, + argumentSummary: 'tabs vs spaces', + participants: [ + { id: 2, slackId: 'U2', name: 'Bob' }, + { id: 1, slackId: 'U1', name: 'Alice' }, + ], + participantViewpoints: { + U1: 'tabs are faster', + U2: 'spaces are clearer', + }, + winner: { id: 2, slackId: 'U2', name: 'Bob' }, + pointValue: '4', + createdAt: '2026-05-21T00:00:00.000Z', + }, + ]); + + const result = await service.getArgumentLeaderboard('T1'); + + expect(query).toHaveBeenNthCalledWith(1, expect.stringContaining('CAST(COUNT(*) AS SIGNED) AS wins'), ['T1']); + expect(query).toHaveBeenNthCalledWith(1, expect.stringContaining('u.isBot = 0'), ['T1']); + expect(findArguments).toHaveBeenCalledWith({ + where: { teamId: 'T1' }, + relations: ['participants', 'winner'], + order: { createdAt: 'DESC' }, + take: 100, + }); + expect(result).toEqual({ + leaderboard: [ + { name: 'Bob', slackId: 'U2', wins: 3, points: 12 }, + { name: 'Alice', slackId: 'U1', wins: 1, points: 4 }, + ], + arguments: [ + { + id: 1, + argument: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }, + ], + }); + }); +}); diff --git a/packages/backend/src/argument/argument.persistence.service.ts b/packages/backend/src/argument/argument.persistence.service.ts new file mode 100644 index 00000000..1f03d7e7 --- /dev/null +++ b/packages/backend/src/argument/argument.persistence.service.ts @@ -0,0 +1,193 @@ +import { getRepository } from 'typeorm'; +import { ArgumentLeaderboard } from '../shared/db/models/ArgumentLeaderboard'; +import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; +import type { ArgumentParticipantViewpoints } from '../shared/db/models/ArgumentLeaderboard'; +import { SlackUser } from '../shared/db/models/SlackUser'; +import { logger } from '../shared/logger/logger'; +import { logError } from '../shared/logger/error-logging'; +import type { ArgumentLeaderboardResponse, ArgumentOutcomeEntry, SaveArgumentOutcomeInput } from './argument.model'; + +interface LeaderboardRow { + name: string; + slackId: string; + wins: string; + points: string; +} + +const ARGUMENT_HISTORY_LIMIT = 100; + +const clampPointValue = (value: number): number => Math.min(5, Math.max(0, Math.round(value))); + +const normalizeParticipant = (participant: Partial): ArgumentParticipant | null => { + const slackId = participant.slackId?.trim(); + const name = participant.name?.trim(); + const viewpoint = participant.viewpoint?.trim(); + if (!slackId || !name || !viewpoint) { + return null; + } + + return { slackId, name, viewpoint }; +}; + +const buildParticipantViewpoints = (participants: ArgumentParticipant[]): ArgumentParticipantViewpoints => + Object.fromEntries(participants.map((participant) => [participant.slackId, participant.viewpoint])); + +const buildArgumentParticipants = ( + participants: SlackUser[] | undefined, + participantViewpoints: ArgumentParticipantViewpoints | null | undefined, +): ArgumentParticipant[] => { + if (!participants || participants.length === 0 || !participantViewpoints) { + return []; + } + + const participantUserBySlackId = new Map(participants.map((participant) => [participant.slackId, participant])); + + return Object.entries(participantViewpoints).flatMap(([slackId, viewpoint]) => { + const participant = participantUserBySlackId.get(slackId); + const normalizedViewpoint = viewpoint.trim(); + if (!participant || !normalizedViewpoint) { + return []; + } + + return [ + { + slackId: participant.slackId, + name: participant.name, + viewpoint: normalizedViewpoint, + }, + ]; + }); +}; + +export class ArgumentPersistenceService { + private logger = logger.child({ module: 'ArgumentPersistenceService' }); + + async saveArgumentOutcome(input: SaveArgumentOutcomeInput): Promise { + const slackUserRepo = getRepository(SlackUser); + const winner = await slackUserRepo.findOne({ + where: { slackId: input.winnerSlackId, teamId: input.teamId, isBot: false }, + }); + + if (!winner) { + this.logger.warn('Skipping argument outcome save because winner was not found', { + teamId: input.teamId, + winnerSlackId: input.winnerSlackId, + }); + return null; + } + + const participants = Array.from( + new Map( + input.participants + .map(normalizeParticipant) + .filter((participant): participant is ArgumentParticipant => participant !== null) + .map((participant) => [participant.slackId, participant]), + ).values(), + ); + + const participantUsers = await slackUserRepo.find({ + where: participants.map((participant) => ({ slackId: participant.slackId, teamId: input.teamId, isBot: false })), + }); + const extractedParticipantViewpoints = buildParticipantViewpoints(participants); + const resolvedParticipants = buildArgumentParticipants(participantUsers, extractedParticipantViewpoints); + const participantViewpoints = buildParticipantViewpoints(resolvedParticipants); + + if (resolvedParticipants.length < 2) { + this.logger.warn('Skipping argument outcome save because fewer than two participants were extracted', { + teamId: input.teamId, + channelId: input.channelId, + }); + return null; + } + + const argumentSummary = input.argumentSummary.trim(); + if (!argumentSummary) { + this.logger.warn('Skipping argument outcome save because summary was empty', { + teamId: input.teamId, + channelId: input.channelId, + }); + return null; + } + + const entry = new ArgumentLeaderboard(); + entry.teamId = input.teamId; + entry.channelId = input.channelId; + entry.argumentSummary = argumentSummary; + entry.participants = participantUsers; + entry.participantViewpoints = participantViewpoints; + entry.winner = winner; + entry.pointValue = clampPointValue(input.pointValue); + + return getRepository(ArgumentLeaderboard) + .save(entry) + .then((saved) => ({ + id: saved.id, + argument: saved.argumentSummary, + participants: resolvedParticipants, + winner: { + name: winner.name, + slackId: winner.slackId, + }, + pointValue: saved.pointValue, + createdAt: saved.createdAt.toISOString(), + })) + .catch((error) => { + logError(this.logger, 'Failed to save argument outcome', error, { + teamId: input.teamId, + channelId: input.channelId, + winnerSlackId: input.winnerSlackId, + }); + throw error; + }); + } + + async getArgumentLeaderboard(teamId: string): Promise { + const repo = getRepository(ArgumentLeaderboard); + + try { + const [leaderboardRows, argumentRows] = await Promise.all([ + repo.query( + `SELECT u.name AS name, + u.slackId AS slackId, + CAST(COUNT(*) AS SIGNED) AS wins, + CAST(COALESCE(SUM(a.pointValue), 0) AS SIGNED) AS points + FROM argument_leaderboard a + INNER JOIN slack_user u ON u.id = a.winnerId + WHERE a.teamId = ? AND u.isBot = 0 + GROUP BY u.id, u.name, u.slackId + ORDER BY wins DESC, points DESC, u.name ASC`, + [teamId], + ), + repo.find({ + where: { teamId }, + relations: ['participants', 'winner'], + order: { createdAt: 'DESC' }, + take: ARGUMENT_HISTORY_LIMIT, + }), + ]); + + return { + leaderboard: leaderboardRows.map((row) => ({ + name: row.name, + slackId: row.slackId, + wins: Number(row.wins), + points: Number(row.points), + })), + arguments: argumentRows.map((row) => ({ + id: Number(row.id), + argument: row.argumentSummary, + participants: buildArgumentParticipants(row.participants, row.participantViewpoints), + winner: { + name: row.winner.name, + slackId: row.winner.slackId, + }, + pointValue: Number(row.pointValue), + createdAt: new Date(row.createdAt).toISOString(), + })), + }; + } catch (error) { + logError(this.logger, 'Failed to load argument leaderboard', error, { teamId }); + throw error; + } + } +} diff --git a/packages/backend/src/dashboard/dashboard.controller.spec.ts b/packages/backend/src/dashboard/dashboard.controller.spec.ts new file mode 100644 index 00000000..e21f5f9c --- /dev/null +++ b/packages/backend/src/dashboard/dashboard.controller.spec.ts @@ -0,0 +1,110 @@ +import { vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const getDashboardData = vi.fn(); +const getAllMemoriesForUser = vi.fn(); +const getAllTraitsForUser = vi.fn(); +const getArgumentLeaderboard = vi.fn(); + +vi.mock('./dashboard.persistence.service', async () => ({ + DashboardPersistenceService: classMock(() => ({ + getDashboardData, + })), +})); + +vi.mock('../ai/memory/memory.persistence.service', async () => ({ + MemoryPersistenceService: classMock(() => ({ + getAllMemoriesForUser, + })), +})); + +vi.mock('../trait/trait.persistence.service', async () => ({ + TraitPersistenceService: classMock(() => ({ + getAllTraitsForUser, + })), +})); + +vi.mock('../argument/argument.persistence.service', async () => ({ + ArgumentPersistenceService: classMock(() => ({ + getArgumentLeaderboard, + })), +})); + +vi.mock('../shared/logger/logger', async () => ({ + logger: { child: () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() }) }, +})); + +import { dashboardController } from './dashboard.controller'; + +describe('dashboardController', () => { + const app = express(); + app.use((req, _res, next) => { + const requestWithAuthSession = req as typeof req & { + authSession?: { teamId?: string; userId?: string }; + }; + const teamId = req.header('x-team-id'); + const userId = req.header('x-user-id'); + if (teamId || userId) { + requestWithAuthSession.authSession = { teamId: teamId ?? undefined, userId: userId ?? undefined }; + } + next(); + }); + app.use('/', dashboardController); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects unauthorized dashboard requests', async () => { + await request(app).get('/').expect(401, { error: 'Unauthorized' }); + }); + + it('loads dashboard data with a valid period', async () => { + getDashboardData.mockResolvedValue({ leaderboard: [], myStats: {}, repLeaderboard: [] }); + + const res = await request(app).get('/?period=weekly').set('x-team-id', 'T1').set('x-user-id', 'U1').expect(200); + + expect(getDashboardData).toHaveBeenCalledWith('U1', 'T1', 'weekly'); + expect(res.body).toEqual({ leaderboard: [], myStats: {}, repLeaderboard: [] }); + }); + + it('falls back to the default period for unknown values', async () => { + getDashboardData.mockResolvedValue({ leaderboard: [], myStats: {}, repLeaderboard: [] }); + + await request(app).get('/?period=nope').set('x-team-id', 'T1').set('x-user-id', 'U1').expect(200); + + expect(getDashboardData).toHaveBeenCalledWith('U1', 'T1', 'weekly'); + }); + + it('loads personal context data', async () => { + getAllMemoriesForUser.mockResolvedValue([ + { id: 1, content: 'likes coffee', updatedAt: '2026-05-20T00:00:00.000Z' }, + ]); + getAllTraitsForUser.mockResolvedValue([{ id: 2, content: 'curious', updatedAt: '2026-05-21T00:00:00.000Z' }]); + + const res = await request(app).get('/personal-context').set('x-team-id', 'T1').set('x-user-id', 'U1').expect(200); + + expect(getAllMemoriesForUser).toHaveBeenCalledWith('U1', 'T1'); + expect(getAllTraitsForUser).toHaveBeenCalledWith('U1', 'T1'); + expect(res.body).toEqual({ + memories: [{ id: 1, content: 'likes coffee', updatedAt: '2026-05-20T00:00:00.000Z' }], + traits: [{ id: 2, content: 'curious', updatedAt: '2026-05-21T00:00:00.000Z' }], + }); + }); + + it('loads argument leaderboard data', async () => { + getArgumentLeaderboard.mockResolvedValue({ + leaderboard: [{ name: 'Alice', slackId: 'U1', wins: 2, points: 7 }], + arguments: [], + }); + + const res = await request(app).get('/arguments').set('x-team-id', 'T1').set('x-user-id', 'U1').expect(200); + + expect(getArgumentLeaderboard).toHaveBeenCalledWith('T1'); + expect(res.body).toEqual({ + leaderboard: [{ name: 'Alice', slackId: 'U1', wins: 2, points: 7 }], + arguments: [], + }); + }); +}); diff --git a/packages/backend/src/dashboard/dashboard.controller.ts b/packages/backend/src/dashboard/dashboard.controller.ts index a3ded677..da4bf49f 100644 --- a/packages/backend/src/dashboard/dashboard.controller.ts +++ b/packages/backend/src/dashboard/dashboard.controller.ts @@ -8,12 +8,14 @@ import { DEFAULT_PERIOD, VALID_PERIODS } from './dashboard.const'; import type { TimePeriod } from './dashboard.model'; import { MemoryPersistenceService } from '../ai/memory/memory.persistence.service'; import { TraitPersistenceService } from '../trait/trait.persistence.service'; +import { ArgumentPersistenceService } from '../argument/argument.persistence.service'; export const dashboardController: Router = express.Router(); const dashboardPersistenceService = new DashboardPersistenceService(); const memoryPersistenceService = new MemoryPersistenceService(); const traitPersistenceService = new TraitPersistenceService(); +const argumentPersistenceService = new ArgumentPersistenceService(); const dashboardLogger = logger.child({ module: 'DashboardController' }); function parsePeriod(value: unknown): TimePeriod { @@ -73,3 +75,20 @@ dashboardController.get('/personal-context', (req: RequestWithAuthSession, res) res.status(500).send(); }); }); + +dashboardController.get('/arguments', (req: RequestWithAuthSession, res) => { + const { teamId, userId } = req.authSession || {}; + + if (!teamId || !userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + argumentPersistenceService + .getArgumentLeaderboard(teamId) + .then((data) => res.status(200).json(data)) + .catch((e: unknown) => { + logError(dashboardLogger, 'Failed to load argument leaderboard data', e, { userId, teamId }); + res.status(500).send(); + }); +}); diff --git a/packages/backend/src/job.service.spec.ts b/packages/backend/src/job.service.spec.ts index 6c201f12..a7bc245d 100644 --- a/packages/backend/src/job.service.spec.ts +++ b/packages/backend/src/job.service.spec.ts @@ -1,16 +1,23 @@ import { vi } from 'vitest'; import { loggerMock } from './test/mocks/logger.mock'; -const { memoryRunMock, traitRunMock, funFactRunMock, pricingRunMock, eventAlertRunMock, scheduleMock } = vi.hoisted( - () => ({ - memoryRunMock: vi.fn(), - traitRunMock: vi.fn(), - funFactRunMock: vi.fn(), - pricingRunMock: vi.fn(), - eventAlertRunMock: vi.fn(), - scheduleMock: vi.fn(), - }), -); +const { + memoryRunMock, + argumentRunMock, + traitRunMock, + funFactRunMock, + pricingRunMock, + eventAlertRunMock, + scheduleMock, +} = vi.hoisted(() => ({ + memoryRunMock: vi.fn(), + argumentRunMock: vi.fn(), + traitRunMock: vi.fn(), + funFactRunMock: vi.fn(), + pricingRunMock: vi.fn(), + eventAlertRunMock: vi.fn(), + scheduleMock: vi.fn(), +})); vi.mock('node-cron', async () => ({ default: { @@ -24,6 +31,12 @@ vi.mock('./ai/memory/memory.job', async () => ({ })), })); +vi.mock('./argument/argument.job', async () => ({ + ArgumentJob: classMock(() => ({ + run: argumentRunMock, + })), +})); + vi.mock('./trait/trait.job', async () => ({ TraitJob: classMock(() => ({ run: traitRunMock, @@ -54,26 +67,37 @@ describe('JobService', () => { beforeEach(() => { vi.clearAllMocks(); memoryRunMock.mockResolvedValue(undefined); + argumentRunMock.mockResolvedValue(undefined); traitRunMock.mockResolvedValue(undefined); funFactRunMock.mockResolvedValue(undefined); pricingRunMock.mockResolvedValue(undefined); eventAlertRunMock.mockResolvedValue(undefined); }); - it('runs memory and trait jobs sequentially', async () => { + it('runs nightly analysis jobs sequentially', async () => { const service = new JobService(); - await service.runMemoryAndTraitJobs(); + await service.runNightlyAnalysisJobs(); expect(memoryRunMock).toHaveBeenCalledOnce(); + expect(argumentRunMock).toHaveBeenCalledOnce(); expect(traitRunMock).toHaveBeenCalledOnce(); }); - it('throws when memory and trait sequence fails', async () => { + it('throws when nightly analysis sequence fails', async () => { const service = new JobService(); memoryRunMock.mockRejectedValueOnce(new Error('memory-fail')); - await expect(service.runMemoryAndTraitJobs()).rejects.toThrow('memory-fail'); + await expect(service.runNightlyAnalysisJobs()).rejects.toThrow('memory-fail'); + expect(argumentRunMock).not.toHaveBeenCalled(); + expect(traitRunMock).not.toHaveBeenCalled(); + }); + + it('does not run trait job when argument job fails', async () => { + const service = new JobService(); + argumentRunMock.mockRejectedValueOnce(new Error('argument-fail')); + + await expect(service.runNightlyAnalysisJobs()).rejects.toThrow('argument-fail'); expect(traitRunMock).not.toHaveBeenCalled(); }); @@ -81,12 +105,14 @@ describe('JobService', () => { const service = new JobService(); await service.runMemoryJob(); + await service.runArgumentJob(); await service.runTraitJob(); await service.runFunFactJob(); await service.runPricingJob(); await service.runEventAlertJob(); expect(memoryRunMock).toHaveBeenCalled(); + expect(argumentRunMock).toHaveBeenCalled(); expect(traitRunMock).toHaveBeenCalled(); expect(funFactRunMock).toHaveBeenCalled(); expect(pricingRunMock).toHaveBeenCalled(); @@ -118,8 +144,8 @@ describe('JobService', () => { it('logs errors from scheduled callbacks instead of throwing', async () => { const service = new JobService(); - const runMemoryAndTraitJobsSpy = vi - .spyOn(service, 'runMemoryAndTraitJobs') + const runNightlyAnalysisJobsSpy = vi + .spyOn(service, 'runNightlyAnalysisJobs') .mockRejectedValueOnce(new Error('scheduled-failure')); service.scheduleCronJobs(); @@ -130,7 +156,7 @@ describe('JobService', () => { memoryCallback?.(); await Promise.resolve(); - expect(runMemoryAndTraitJobsSpy).toHaveBeenCalledOnce(); - expect(loggerMock.error).toHaveBeenCalledWith('Memory and trait job sequence failed:', expect.any(Error)); + expect(runNightlyAnalysisJobsSpy).toHaveBeenCalledOnce(); + expect(loggerMock.error).toHaveBeenCalledWith('Nightly analysis job sequence failed:', expect.any(Error)); }); }); diff --git a/packages/backend/src/job.service.ts b/packages/backend/src/job.service.ts index a80a8562..afaae036 100644 --- a/packages/backend/src/job.service.ts +++ b/packages/backend/src/job.service.ts @@ -5,9 +5,11 @@ import { PricingJob } from './jobs/pricing.job'; import { EventAlertJob } from './jobs/event-alert.job'; import { logger } from './shared/logger/logger'; import { TraitJob } from './trait/trait.job'; +import { ArgumentJob } from './argument/argument.job'; export class JobService { private memoryJob: MemoryJob; + private argumentJob: ArgumentJob; private traitJob: TraitJob; private funFactJob: FunFactJob; private pricingJob: PricingJob; @@ -16,6 +18,7 @@ export class JobService { constructor() { this.memoryJob = new MemoryJob(); + this.argumentJob = new ArgumentJob(); this.traitJob = new TraitJob(); this.funFactJob = new FunFactJob(); this.pricingJob = new PricingJob(); @@ -23,26 +26,28 @@ export class JobService { } /** - * Run the memory and trait jobs in sequence. - * Memory job runs first, then trait job runs only if memory job succeeds. + * Run the nightly analysis jobs in sequence. + * Memory job runs first, then argument job, then trait job if the earlier jobs succeed. */ - async runMemoryAndTraitJobs(): Promise { - this.jobServiceLogger.info('Starting memory and trait job sequence'); + async runNightlyAnalysisJobs(): Promise { + this.jobServiceLogger.info('Starting nightly analysis job sequence'); try { - // Run memory job first this.jobServiceLogger.info('Running memory job...'); await this.memoryJob.run(); - this.jobServiceLogger.info('Memory job succeeded, proceeding with trait job'); + this.jobServiceLogger.info('Memory job succeeded, proceeding with argument job'); + + this.jobServiceLogger.info('Running argument job...'); + await this.argumentJob.run(); + this.jobServiceLogger.info('Argument job succeeded, proceeding with trait job'); - // Run trait job only if memory job succeeds this.jobServiceLogger.info('Running trait job...'); await this.traitJob.run(); this.jobServiceLogger.info('Trait job succeeded'); - this.jobServiceLogger.info('Memory and trait job sequence completed successfully'); + this.jobServiceLogger.info('Nightly analysis job sequence completed successfully'); } catch (error) { - this.jobServiceLogger.error('Memory and trait job sequence failed:', error); + this.jobServiceLogger.error('Nightly analysis job sequence failed:', error); throw error; } } @@ -103,6 +108,20 @@ export class JobService { } } + /** + * Run the argument job in isolation + */ + async runArgumentJob(): Promise { + this.jobServiceLogger.info('Running argument job in isolation'); + try { + await this.argumentJob.run(); + this.jobServiceLogger.info('Argument job completed successfully'); + } catch (error) { + this.jobServiceLogger.error('Argument job failed:', error); + throw error; + } + } + /** * Run the trait job in isolation */ @@ -119,24 +138,24 @@ export class JobService { /** * Schedule all cron jobs on startup. - * Memory and trait jobs run daily at 3AM. + * Nightly analysis jobs run daily at 3AM. * Fun fact job runs daily at 9AM. * Pricing job runs every hour at minute 10. */ scheduleCronJobs(): void { this.jobServiceLogger.info('Scheduling cron jobs'); - // Memory and trait jobs: daily at 3AM America/New_York + // Nightly analysis jobs: daily at 3AM America/New_York cron.schedule( '0 3 * * *', () => { - this.runMemoryAndTraitJobs().catch((error) => { - this.jobServiceLogger.error('Memory and trait job sequence failed:', error); + this.runNightlyAnalysisJobs().catch((error) => { + this.jobServiceLogger.error('Nightly analysis job sequence failed:', error); }); }, { timezone: 'America/New_York' }, ); - this.jobServiceLogger.info('Memory and trait job sequence scheduled daily at 3AM America/New_York time.'); + this.jobServiceLogger.info('Nightly analysis job sequence scheduled daily at 3AM America/New_York time.'); // Fun fact job: daily at 9AM America/New_York cron.schedule( diff --git a/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts new file mode 100644 index 00000000..a2eecedf --- /dev/null +++ b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts @@ -0,0 +1,41 @@ +import { Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { SlackUser } from './SlackUser'; + +export interface ArgumentParticipant { + slackId: string; + name: string; + viewpoint: string; +} + +export type ArgumentParticipantViewpoints = Record; + +@Entity() +export class ArgumentLeaderboard { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ default: 'NOT_AVAILABLE' }) + public teamId!: string; + + @Column({ default: 'NOT_AVAILABLE' }) + public channelId!: string; + + @Column('text') + public argumentSummary!: string; + + @ManyToMany(() => SlackUser) + @JoinTable() + public participants!: SlackUser[]; + + @Column('simple-json') + public participantViewpoints: ArgumentParticipantViewpoints = {}; + + @ManyToOne(() => SlackUser, (user) => user.argumentWins) + public winner!: SlackUser; + + @Column({ type: 'int' }) + public pointValue!: number; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + public createdAt!: Date; +} diff --git a/packages/backend/src/shared/db/models/SlackUser.ts b/packages/backend/src/shared/db/models/SlackUser.ts index e23ad367..4a3e3f7f 100644 --- a/packages/backend/src/shared/db/models/SlackUser.ts +++ b/packages/backend/src/shared/db/models/SlackUser.ts @@ -1,5 +1,6 @@ import { Column, Entity, PrimaryGeneratedColumn, Unique, OneToMany, OneToOne } from 'typeorm'; import { Activity } from './Activity'; +import { ArgumentLeaderboard } from './ArgumentLeaderboard'; import { Memory } from './Memory'; import { Message } from './Message'; import { Portfolio } from './Portfolio'; @@ -39,6 +40,9 @@ export class SlackUser { @OneToMany(() => Message, (message) => message.userId) public messages?: Message[]; + @OneToMany(() => ArgumentLeaderboard, (argument) => argument.winner) + public argumentWins?: ArgumentLeaderboard[]; + @OneToMany(() => CalendarEvent, (calendarEvent) => calendarEvent.createdByUser) public createdCalendarEvents?: CalendarEvent[]; diff --git a/packages/backend/src/test/mocks/typeorm.mock.ts b/packages/backend/src/test/mocks/typeorm.mock.ts index 6343aa94..d2bda5bc 100644 --- a/packages/backend/src/test/mocks/typeorm.mock.ts +++ b/packages/backend/src/test/mocks/typeorm.mock.ts @@ -10,7 +10,9 @@ export const PrimaryGeneratedColumn = noopDecoratorFactory; export const PrimaryColumn = noopDecoratorFactory; export const OneToMany = noopDecoratorFactory; export const OneToOne = noopDecoratorFactory; +export const ManyToMany = noopDecoratorFactory; export const ManyToOne = noopDecoratorFactory; +export const JoinTable = noopDecoratorFactory; export const Unique = noopDecoratorFactory; export const getRepository = vi.fn(); diff --git a/packages/frontend/src/app.model.ts b/packages/frontend/src/app.model.ts index 9fc9c174..de150716 100644 --- a/packages/frontend/src/app.model.ts +++ b/packages/frontend/src/app.model.ts @@ -76,6 +76,36 @@ export interface PersonalContextResponse { traits: PersonalContextEntry[]; } +export interface ArgumentParticipant { + slackId: string; + name: string; + viewpoint: string; +} + +export interface ArgumentLeaderboardStanding { + name: string; + slackId: string; + wins: number; + points: number; +} + +export interface ArgumentOutcomeEntry { + id: number; + argument: string; + participants: ArgumentParticipant[]; + winner: { + name: string; + slackId: string; + }; + pointValue: number; + createdAt: string; +} + +export interface ArgumentLeaderboardResponse { + leaderboard: ArgumentLeaderboardStanding[]; + arguments: ArgumentOutcomeEntry[]; +} + export type FrontendRecurrenceFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly'; export interface FrontendRecurrenceRule { diff --git a/packages/frontend/src/components/AppShell.model.ts b/packages/frontend/src/components/AppShell.model.ts index 54088aee..7545a114 100644 --- a/packages/frontend/src/components/AppShell.model.ts +++ b/packages/frontend/src/components/AppShell.model.ts @@ -1,6 +1,6 @@ import type { ElementType } from 'react'; -export type Page = 'home' | 'message-search' | 'personal-context' | 'calendar'; +export type Page = 'home' | 'message-search' | 'personal-context' | 'calendar' | 'arguments'; export interface NavItemProps { icon: ElementType; diff --git a/packages/frontend/src/components/AppShell.spec.tsx b/packages/frontend/src/components/AppShell.spec.tsx index f779d870..c806fc5b 100644 --- a/packages/frontend/src/components/AppShell.spec.tsx +++ b/packages/frontend/src/components/AppShell.spec.tsx @@ -36,6 +36,13 @@ describe('AppShell', () => { expect(screen.queryByRole('heading', { name: /^home$/i })).not.toBeInTheDocument(); }); + it('switches to the Argument Leaderboard page when its nav button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /argument leaderboard/i })); + expect(screen.getByRole('heading', { name: /argument leaderboard/i })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /^home$/i })).not.toBeInTheDocument(); + }); + it('calls onLogout when the Sign out button is clicked', () => { const onLogout = vi.fn(); render(); diff --git a/packages/frontend/src/components/AppShell.tsx b/packages/frontend/src/components/AppShell.tsx index cf439b6c..f7249211 100644 --- a/packages/frontend/src/components/AppShell.tsx +++ b/packages/frontend/src/components/AppShell.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; -import { Home, Search, Brain, CalendarDays, LogOut } from 'lucide-react'; +import { Home, Search, Brain, CalendarDays, LogOut, Scale } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { HomePage } from '@/pages/HomePage'; import { MessageSearchPage } from '@/pages/MessageSearchPage'; import { PersonalContextPage } from '@/pages/PersonalContextPage'; import { CalendarPage } from '@/pages/CalendarPage'; +import { ArgumentLeaderboardPage } from '@/pages/ArgumentLeaderboardPage'; import type { Page, NavItemProps, AppShellProps } from '@/components/AppShell.model'; function NavItem({ icon: Icon, label, active, onClick }: NavItemProps) { @@ -50,6 +51,12 @@ export function AppShell({ onLogout }: AppShellProps) { active={currentPage === 'personal-context'} onClick={() => setCurrentPage('personal-context')} /> + setCurrentPage('arguments')} + /> } {currentPage === 'message-search' && } {currentPage === 'personal-context' && } + {currentPage === 'arguments' && } {currentPage === 'calendar' && } diff --git a/packages/frontend/src/hooks/useArgumentLeaderboard.spec.ts b/packages/frontend/src/hooks/useArgumentLeaderboard.spec.ts new file mode 100644 index 00000000..6f5fd727 --- /dev/null +++ b/packages/frontend/src/hooks/useArgumentLeaderboard.spec.ts @@ -0,0 +1,54 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useArgumentLeaderboard } from '@/hooks/useArgumentLeaderboard'; +import { AUTH_TOKEN_KEY } from '@/app.const'; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const mockData = { + leaderboard: [{ name: 'Bob', slackId: 'U2', wins: 3, points: 12 }], + arguments: [ + { + id: 1, + argument: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }, + ], +}; + +beforeEach(() => { + localStorage.setItem(AUTH_TOKEN_KEY, 'test-token'); + mockFetch.mockReset(); +}); + +describe('useArgumentLeaderboard', () => { + it('returns data and clears loading state on success', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => mockData }); + const { result } = renderHook(() => useArgumentLeaderboard(vi.fn())); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual(mockData); + expect(result.current.error).toBeNull(); + }); + + it('calls onLogout when the response status is 401', async () => { + const onLogout = vi.fn(); + mockFetch.mockResolvedValue({ ok: false, status: 401 }); + renderHook(() => useArgumentLeaderboard(onLogout)); + await waitFor(() => expect(onLogout).toHaveBeenCalledOnce()); + }); + + it('includes the auth token in the request headers', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => mockData }); + renderHook(() => useArgumentLeaderboard(vi.fn())); + await waitFor(() => expect(mockFetch).toHaveBeenCalledOnce()); + const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/dashboard/arguments'); + expect((options.headers as Record)['Authorization']).toBe('Bearer test-token'); + }); +}); diff --git a/packages/frontend/src/hooks/useArgumentLeaderboard.ts b/packages/frontend/src/hooks/useArgumentLeaderboard.ts new file mode 100644 index 00000000..264b708c --- /dev/null +++ b/packages/frontend/src/hooks/useArgumentLeaderboard.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from 'react'; +import { AUTH_TOKEN_KEY } from '@/app.const'; +import { API_BASE_URL } from '@/config'; +import type { ArgumentLeaderboardResponse } from '@/app.model'; + +export interface UseArgumentLeaderboardReturn { + data: ArgumentLeaderboardResponse | null; + isLoading: boolean; + error: string | null; +} + +export function useArgumentLeaderboard(onLogout: () => void): UseArgumentLeaderboardReturn { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const onLogoutRef = useRef(onLogout); + onLogoutRef.current = onLogout; + + useEffect(() => { + const abortController = new AbortController(); + + const fetchArgumentLeaderboard = async () => { + setIsLoading(true); + setError(null); + + try { + const token = localStorage.getItem(AUTH_TOKEN_KEY) ?? ''; + const response = await fetch(`${API_BASE_URL}/dashboard/arguments`, { + headers: { Authorization: `Bearer ${token}` }, + signal: abortController.signal, + }); + + if (response.status === 401) { + onLogoutRef.current(); + return; + } + + if (!response.ok) { + throw new Error(`Failed to load argument leaderboard: ${response.statusText}`); + } + + const json = (await response.json()) as ArgumentLeaderboardResponse; + setData(json); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return; + } + + setError(err instanceof Error ? err.message : 'Failed to load argument leaderboard'); + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }; + + void fetchArgumentLeaderboard(); + + return () => { + abortController.abort(); + }; + }, []); + + return { data, isLoading, error }; +} diff --git a/packages/frontend/src/pages/ArgumentLeaderboardPage.model.ts b/packages/frontend/src/pages/ArgumentLeaderboardPage.model.ts new file mode 100644 index 00000000..278989d0 --- /dev/null +++ b/packages/frontend/src/pages/ArgumentLeaderboardPage.model.ts @@ -0,0 +1,3 @@ +export interface ArgumentLeaderboardPageProps { + onLogout: () => void; +} diff --git a/packages/frontend/src/pages/ArgumentLeaderboardPage.spec.tsx b/packages/frontend/src/pages/ArgumentLeaderboardPage.spec.tsx new file mode 100644 index 00000000..220f8dbf --- /dev/null +++ b/packages/frontend/src/pages/ArgumentLeaderboardPage.spec.tsx @@ -0,0 +1,79 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { ArgumentLeaderboardPage } from '@/pages/ArgumentLeaderboardPage'; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const fullData = { + leaderboard: [ + { name: 'Bob', slackId: 'U2', wins: 3, points: 12 }, + { name: 'Alice', slackId: 'U1', wins: 1, points: 4 }, + ], + arguments: [ + { + id: 1, + argument: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }, + { + id: 2, + argument: 'vim vs emacs', + participants: [ + { slackId: 'U3', name: 'Carol', viewpoint: 'vim is faster' }, + { slackId: 'U4', name: 'Dave', viewpoint: 'emacs is more flexible' }, + ], + winner: { name: 'Carol', slackId: 'U3' }, + pointValue: 5, + createdAt: '2026-05-20T00:00:00.000Z', + }, + ], +}; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('ArgumentLeaderboardPage', () => { + it('renders leaderboard standings and the default selected argument', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => fullData }); + render(); + + expect(screen.getByRole('heading', { name: /argument leaderboard/i })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('Top debaters')).toBeInTheDocument()); + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getAllByText('Bob').length).toBeGreaterThan(0); + expect(screen.getAllByText('tabs vs spaces').length).toBeGreaterThan(0); + expect(screen.getByText('spaces are clearer')).toBeInTheDocument(); + }); + + it('switches the detailed outcome when a different argument is selected', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => fullData }); + render(); + + await waitFor(() => expect(screen.getByText('vim vs emacs')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /vim vs emacs/i })); + expect(screen.getByText('emacs is more flexible')).toBeInTheDocument(); + expect(screen.getAllByText('Carol').length).toBeGreaterThan(0); + }); + + it('shows an empty state when there are no saved arguments', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ leaderboard: [], arguments: [] }) }); + render(); + + await waitFor(() => expect(screen.getByText(/no arguments have been judged yet/i)).toBeInTheDocument()); + expect(screen.getByText(/no argument outcomes are available yet/i)).toBeInTheDocument(); + }); + + it('shows an error banner when the fetch fails', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' }); + render(); + + await waitFor(() => expect(screen.getByText(/failed to load argument leaderboard/i)).toBeInTheDocument()); + }); +}); diff --git a/packages/frontend/src/pages/ArgumentLeaderboardPage.tsx b/packages/frontend/src/pages/ArgumentLeaderboardPage.tsx new file mode 100644 index 00000000..a83b6725 --- /dev/null +++ b/packages/frontend/src/pages/ArgumentLeaderboardPage.tsx @@ -0,0 +1,209 @@ +import { useMemo, useState } from 'react'; +import { AlertCircle, MessageSquareQuote, Scale, Trophy } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useArgumentLeaderboard } from '@/hooks/useArgumentLeaderboard'; +import type { ArgumentOutcomeEntry, ArgumentParticipant } from '@/app.model'; +import type { ArgumentLeaderboardPageProps } from '@/pages/ArgumentLeaderboardPage.model'; + +function formatDateLabel(isoDate: string): string { + return new Date(isoDate).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +function WinnerBadge({ participant, isWinner }: { participant: ArgumentParticipant; isWinner: boolean }) { + return ( +
+
+

{participant.name}

+ {isWinner && ( + + + )} +
+

{participant.viewpoint}

+
+ ); +} + +export function ArgumentLeaderboardPage({ onLogout }: ArgumentLeaderboardPageProps) { + const { data, isLoading, error } = useArgumentLeaderboard(onLogout); + const [selectedArgumentId, setSelectedArgumentId] = useState(null); + + const selectedArgument = useMemo(() => { + if (!data?.arguments.length) { + return null; + } + + if (selectedArgumentId === null) { + return data.arguments[0]; + } + + return data.arguments.find((argument) => argument.id === selectedArgumentId) ?? data.arguments[0]; + }, [data, selectedArgumentId]); + + return ( +
+
+

Argument Leaderboard

+

+ See who wins the most debates, how many substance points they have earned, and review past verdicts. +

+
+ + {error && ( + + + + + )} + +
+ + + + + Ranked by argument wins, with substance points as the tiebreaker. + + + {isLoading ? ( +

Loading leaderboard...

+ ) : !data?.leaderboard.length ? ( +

No arguments have been judged yet.

+ ) : ( +
+ + + + + + + + + + + {data.leaderboard.map((entry, index) => ( + + + + + + + ))} + +
RankUserWinsPoints
#{index + 1}{entry.name}{entry.wins}{entry.points}
+
+ )} +
+
+
+ +
+
+ + + + + Pick a verdict to review the argument, sides, winner, and point value. + + + {isLoading ? ( +

Loading argument history...

+ ) : !data?.arguments.length ? ( +

No argument outcomes are available yet.

+ ) : ( +
+ {data.arguments.map((argument) => ( + + ))} +
+ )} +
+
+
+ +
+ + + + + Review how Moonbeam scored the selected debate. + + + {!selectedArgument ? ( +

Select an argument to inspect the full outcome.

+ ) : ( +
+
+

Argument

+

{selectedArgument.argument}

+
+ +
+

Participants

+
+ {selectedArgument.participants.map((participant) => ( + + ))} +
+
+ +
+
+
+

Winner

+

{selectedArgument.winner.name}

+
+ +
+

+ Logged on {formatDateLabel(selectedArgument.createdAt)} +

+
+
+ )} +
+
+
+
+
+ ); +}