Skip to content

Add quote-related achievements system#288

Draft
Copilot wants to merge 3 commits intomainfrom
copilot/add-archivements-to-quotes
Draft

Add quote-related achievements system#288
Copilot wants to merge 3 commits intomainfrom
copilot/add-archivements-to-quotes

Conversation

Copy link
Contributor

Copilot AI commented Feb 10, 2026

Implements achievement tracking for quote feature across three categories: authorship (being quoted), contribution (adding quotes), and engagement (quote popularity).

Changes

QuoteService - Added statistics methods for achievement queries:

  • getQuotesAddedByUser(userId) - Count of quotes user has added
  • getQuotesAuthoredByUser(userId) - Count of times user has been quoted
  • getMostLikedQuoteByAuthor(authorId) - Top quote by likes
  • hasQuoteWithLikes(authorId, minLikes) - Check for viral quotes

AchievementsService - Extended with 7 new accolades:

  • Being quoted: quotable (1), widely_quoted (25), quote_icon (50)
  • Adding quotes: quote_master (10), quote_collector (50), quote_legend (100)
  • Engagement: viral_quote (10+ likes)

Each accolade includes check functions using QuoteService queries and metadata functions for display.

Example

// Accolade unlocks automatically when thresholds are met
quotable: {
  emoji: "🗣️",
  name: "Quotable",
  description: "Been quoted for the first time",
  checkFunction: async (userId: string) => {
    const count = await quoteService.getQuotesAuthoredByUser(userId);
    return count >= 1;
  }
}

Users receive DM notifications on earning new accolades via existing achievement notification system.


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

Copilot AI and others added 2 commits February 10, 2026 12:59
Co-authored-by: lonix <2330355+lonix@users.noreply.github.com>
Co-authored-by: lonix <2330355+lonix@users.noreply.github.com>
Copilot AI changed the title [WIP] Add archivements feature for quotes section Add quote-related achievements system Feb 10, 2026
Copilot AI requested a review from lonix February 10, 2026 13:04
@lonix lonix requested review from Copilot and removed request for lonix February 10, 2026 13:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds quote-driven accolades to the existing achievements system by introducing QuoteService statistics helpers and wiring seven new quote-related accolade definitions into AchievementsService.

Changes:

  • Added quote statistics/query helpers to QuoteService for counting authored/added quotes and checking likes.
  • Added 7 new quote-related accolade types + definitions to AchievementsService (authorship, contribution, engagement).
  • Added/updated tests and documentation to reflect the new accolades.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/services/quote-service.ts Adds DB query helpers used to evaluate quote-related accolades.
src/services/achievements-service.ts Introduces new quote accolade types and definitions that call into QuoteService.
tests/services/quote-service-achievements.test.ts Adds tests for the new QuoteService methods (currently structure-only).
tests/services/achievements-quote-accolades.test.ts Adds tests describing quote accolade thresholds/categories (currently constant-only).
COMMANDS.md Documents the new quote-related accolades in the achievements list.
package-lock.json Lockfile metadata changes (adds peer: true flags).

Comment on lines +1 to +52
import { describe, it, expect } from "@jest/globals";

describe("AchievementsService - Quote Accolades", () => {
describe("Quote Accolade Structure", () => {
it("should have seven quote-related accolade types", () => {
const quoteAccoladeCount = 7;
expect(quoteAccoladeCount).toBe(7);
});

it("should have appropriate thresholds for quote accolades", () => {
const thresholds = {
quotable: 1, // First quote
quote_master: 10, // 10 quotes added
quote_collector: 50, // 50 quotes added
quote_legend: 100, // 100 quotes added
widely_quoted: 25, // 25 times quoted
quote_icon: 50, // 50 times quoted
viral_quote: 10, // 10+ likes
};

// Validate thresholds are reasonable
Object.values(thresholds).forEach((threshold) => {
expect(threshold).toBeGreaterThan(0);
});

// Ensure progression makes sense for adding quotes
expect(thresholds.quotable).toBeLessThan(thresholds.quote_master);
expect(thresholds.quote_master).toBeLessThan(
thresholds.quote_collector,
);
expect(thresholds.quote_collector).toBeLessThan(thresholds.quote_legend);

// Ensure progression makes sense for being quoted
expect(thresholds.widely_quoted).toBeLessThan(thresholds.quote_icon);
});

it("should use distinct emoji for each quote accolade", () => {
const expectedEmojis = {
quotable: "🗣️",
quote_master: "📝",
quote_collector: "📚",
quote_legend: "🏆",
widely_quoted: "⭐",
quote_icon: "💫",
viral_quote: "🔥",
};

// Validate all emojis are unique
const emojiValues = Object.values(expectedEmojis);
const uniqueEmojis = new Set(emojiValues);
expect(uniqueEmojis.size).toBe(emojiValues.length);
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests currently only assert hard-coded constants/arrays and don’t exercise AchievementsService or verify that the new accolade definitions are wired correctly (names/emojis/descriptions + that check/metadata functions call QuoteService as expected). Add tests that instantiate the service (or access definitions) with a mocked quoteService and assert the quote accolade behaviors so refactors won’t silently break unlocking.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +226 to +259
async getQuotesAddedByUser(userId: string): Promise<number> {
return this.model.countDocuments({ addedById: userId });
}

/**
* Get the count of quotes where a specific user is the author (being quoted)
*/
async getQuotesAuthoredByUser(userId: string): Promise<number> {
return this.model.countDocuments({ authorId: userId });
}

/**
* Get the most liked quote for a specific author
*/
async getMostLikedQuoteByAuthor(authorId: string): Promise<IQuote | null> {
const quotes = await this.model
.find({ authorId })
.sort({ likes: -1 })
.limit(1);
return quotes.length > 0 ? quotes[0] : null;
}

/**
* Check if user has a quote with at least the specified number of likes
*/
async hasQuoteWithLikes(
authorId: string,
minLikes: number,
): Promise<boolean> {
const count = await this.model.countDocuments({
authorId,
likes: { $gte: minLikes },
});
return count > 0;
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The quote-achievement query methods match authorId/addedById exactly, but the codebase explicitly handles legacy quote records where IDs may be stored in mention/"@..." formats (see normalize usage when editing). As written, legacy quotes won’t be counted toward achievements. Consider normalizing userId/authorId and querying for both normalized and common legacy encodings (e.g., numeric id plus <@id>, <@!id>, @id) so older data contributes correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +241 to +245
const quotes = await this.model
.find({ authorId })
.sort({ likes: -1 })
.limit(1);
return quotes.length > 0 ? quotes[0] : null;
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMostLikedQuoteByAuthor fetches an array with find().limit(1) and then indexes [0]. Using findOne({ authorId }).sort({ likes: -1 }) avoids allocating an array and is more direct (and can be paired with .lean() if you only need plain data).

Suggested change
const quotes = await this.model
.find({ authorId })
.sort({ likes: -1 })
.limit(1);
return quotes.length > 0 ? quotes[0] : null;
return this.model.findOne({ authorId }).sort({ likes: -1 });

Copilot uses AI. Check for mistakes.
Comment on lines +527 to +643
quotable: {
emoji: "🗣️",
name: "Quotable",
description: "Been quoted for the first time",
checkFunction: async (userId: string) => {
const count = await quoteService.getQuotesAuthoredByUser(userId);
return count >= 1;
},
metadataFunction: async (userId: string) => {
const count = await quoteService.getQuotesAuthoredByUser(userId);
return {
value: count,
description: "First quote",
unit: "quotes",
};
},
},
quote_master: {
emoji: "📝",
name: "Quote Master",
description: "Added 10 quotes to the collection",
checkFunction: async (userId: string) => {
const count = await quoteService.getQuotesAddedByUser(userId);
return count >= 10;
},
metadataFunction: async (userId: string) => {
const count = await quoteService.getQuotesAddedByUser(userId);
return {
value: count,
description: "10+ quotes added",
unit: "quotes",
};
},
},
quote_collector: {
emoji: "📚",
name: "Quote Collector",
description: "Added 50 quotes to the collection",
checkFunction: async (userId: string) => {
const count = await quoteService.getQuotesAddedByUser(userId);
return count >= 50;
},
metadataFunction: async (userId: string) => {
const count = await quoteService.getQuotesAddedByUser(userId);
return {
value: count,
description: "50+ quotes added",
unit: "quotes",
};
},
},
quote_legend: {
emoji: "🏆",
name: "Quote Legend",
description: "Added 100 quotes to the collection",
checkFunction: async (userId: string) => {
const count = await quoteService.getQuotesAddedByUser(userId);
return count >= 100;
},
metadataFunction: async (userId: string) => {
const count = await quoteService.getQuotesAddedByUser(userId);
return {
value: count,
description: "100+ quotes added",
unit: "quotes",
};
},
},
widely_quoted: {
emoji: "⭐",
name: "Widely Quoted",
description: "Been quoted 25 times",
checkFunction: async (userId: string) => {
const count = await quoteService.getQuotesAuthoredByUser(userId);
return count >= 25;
},
metadataFunction: async (userId: string) => {
const count = await quoteService.getQuotesAuthoredByUser(userId);
return {
value: count,
description: "25+ times quoted",
unit: "quotes",
};
},
},
quote_icon: {
emoji: "💫",
name: "Quote Icon",
description: "Been quoted 50 times",
checkFunction: async (userId: string) => {
const count = await quoteService.getQuotesAuthoredByUser(userId);
return count >= 50;
},
metadataFunction: async (userId: string) => {
const count = await quoteService.getQuotesAuthoredByUser(userId);
return {
value: count,
description: "50+ times quoted",
unit: "quotes",
};
},
},
viral_quote: {
emoji: "🔥",
name: "Viral Quote",
description: "Have a quote with 10+ likes",
checkFunction: async (userId: string) => {
return await quoteService.hasQuoteWithLikes(userId, 10);
},
metadataFunction: async (userId: string) => {
const mostLiked = await quoteService.getMostLikedQuoteByAuthor(userId);
return {
value: mostLiked?.likes || 0,
description: "10+ likes on a quote",
unit: "likes",
};
},
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During a single achievements check, these quote-related accolade definitions can trigger multiple redundant DB queries (e.g., 3 separate getQuotesAuthoredByUser counts + 3 separate getQuotesAddedByUser counts, plus viral checks). This can become expensive as user count grows. Consider precomputing quote stats once per user per run (authoredCount, addedCount, mostLikedLikes/viral) and reusing those values across check/metadata functions.

Copilot uses AI. Check for mistakes.
name: "Viral Quote",
description: "Have a quote with 10+ likes",
checkFunction: async (userId: string) => {
return await quoteService.hasQuoteWithLikes(userId, 10);
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant return await here adds an unnecessary async hop and may violate common lint rules (e.g., no-return-await). You can directly return the promise from quoteService.hasQuoteWithLikes(...).

Suggested change
return await quoteService.hasQuoteWithLikes(userId, 10);
return quoteService.hasQuoteWithLikes(userId, 10);

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +37
describe("achievement-related methods", () => {
it("should have getQuotesAddedByUser method", () => {
expect(typeof quoteService.getQuotesAddedByUser).toBe("function");
expect(quoteService.getQuotesAddedByUser.length).toBe(1);
});

it("should have getQuotesAuthoredByUser method", () => {
expect(typeof quoteService.getQuotesAuthoredByUser).toBe("function");
expect(quoteService.getQuotesAuthoredByUser.length).toBe(1);
});

it("should have getMostLikedQuoteByAuthor method", () => {
expect(typeof quoteService.getMostLikedQuoteByAuthor).toBe("function");
expect(quoteService.getMostLikedQuoteByAuthor.length).toBe(1);
});

it("should have hasQuoteWithLikes method", () => {
expect(typeof quoteService.hasQuoteWithLikes).toBe("function");
expect(quoteService.hasQuoteWithLikes.length).toBe(2);
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test suite only checks that methods exist / arity, but never asserts the actual query behavior (e.g., that the correct filters are passed to countDocuments, that likes sorting is descending, etc.). Since these methods directly drive achievements unlocking, add behavior-focused tests by mocking the mongoose model and asserting the right calls/results.

Copilot generated this review using guidance from repository custom instructions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants