Conversation
Co-authored-by: lonix <2330355+lonix@users.noreply.github.com>
Co-authored-by: lonix <2330355+lonix@users.noreply.github.com>
There was a problem hiding this comment.
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
QuoteServicefor 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). |
| 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); | ||
| }); |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
| const quotes = await this.model | ||
| .find({ authorId }) | ||
| .sort({ likes: -1 }) | ||
| .limit(1); | ||
| return quotes.length > 0 ? quotes[0] : null; |
There was a problem hiding this comment.
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).
| 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 }); |
| 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", | ||
| }; | ||
| }, |
There was a problem hiding this comment.
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.
| name: "Viral Quote", | ||
| description: "Have a quote with 10+ likes", | ||
| checkFunction: async (userId: string) => { | ||
| return await quoteService.hasQuoteWithLikes(userId, 10); |
There was a problem hiding this comment.
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(...).
| return await quoteService.hasQuoteWithLikes(userId, 10); | |
| return quoteService.hasQuoteWithLikes(userId, 10); |
| 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); | ||
| }); |
There was a problem hiding this comment.
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.
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 addedgetQuotesAuthoredByUser(userId)- Count of times user has been quotedgetMostLikedQuoteByAuthor(authorId)- Top quote by likeshasQuoteWithLikes(authorId, minLikes)- Check for viral quotesAchievementsService - Extended with 7 new accolades:
quotable(1),widely_quoted(25),quote_icon(50)quote_master(10),quote_collector(50),quote_legend(100)viral_quote(10+ likes)Each accolade includes check functions using QuoteService queries and metadata functions for display.
Example
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.