diff --git a/sql/reports/payment/member-payment-accrual.sql b/sql/reports/payment/member-payment-accrual.sql index 10780f4..f3ec48d 100644 --- a/sql/reports/payment/member-payment-accrual.sql +++ b/sql/reports/payment/member-payment-accrual.sql @@ -50,7 +50,9 @@ recent_payments AS ( JOIN params pr ON TRUE WHERE w.type = 'PAYMENT' AND p.created_at >= pr.start_date - AND p.created_at <= pr.end_date + AND p.created_at < ( + DATE_TRUNC('day', pr.end_date) + INTERVAL '1 day' + ) ), categorized_payments AS ( SELECT diff --git a/sql/reports/sfdc/ba-fees-monthly.sql b/sql/reports/sfdc/ba-fees-monthly.sql index 8545bb1..7501ac9 100644 --- a/sql/reports/sfdc/ba-fees-monthly.sql +++ b/sql/reports/sfdc/ba-fees-monthly.sql @@ -21,7 +21,7 @@ WITH filtered_payments AS ( ON w.winning_id = p.winnings_id WHERE ($1::timestamptz IS NULL OR p.created_at >= $1::timestamptz) - AND ($2::timestamptz IS NULL OR p.created_at <= $2::timestamptz) + AND ($2::timestamptz IS NULL OR p.created_at < (DATE_TRUNC('day', $2::timestamptz) + INTERVAL '1 day')) AND ($3::text[] IS NULL OR p.billing_account = ANY($3::text[])) AND ($4::text[] IS NULL OR p.billing_account != ALL($4::text[])) ), diff --git a/sql/reports/sfdc/ba-fees.sql b/sql/reports/sfdc/ba-fees.sql index 8e8cbe2..5ad9f28 100644 --- a/sql/reports/sfdc/ba-fees.sql +++ b/sql/reports/sfdc/ba-fees.sql @@ -20,7 +20,7 @@ WITH filtered_payments AS ( ON w.winning_id = p.winnings_id WHERE ($1::timestamptz IS NULL OR p.created_at >= $1::timestamptz) - AND ($2::timestamptz IS NULL OR p.created_at <= $2::timestamptz) + AND ($2::timestamptz IS NULL OR p.created_at < (DATE_TRUNC('day', $2::timestamptz) + INTERVAL '1 day')) AND ($3::text[] IS NULL OR p.billing_account = ANY($3::text[])) AND ($4::text[] IS NULL OR p.billing_account != ALL($4::text[])) ), diff --git a/sql/reports/sfdc/payments.sql b/sql/reports/sfdc/payments.sql index a3a0987..bf463c9 100644 --- a/sql/reports/sfdc/payments.sql +++ b/sql/reports/sfdc/payments.sql @@ -74,7 +74,7 @@ WHERE )) AND ($6::text IS NULL OR challenge_name ILIKE '%' || $6 || '%') AND created_at >= COALESCE($7::timestamptz, (NOW() AT TIME ZONE 'UTC') - INTERVAL '45 days') - AND ($8::timestamptz IS NULL OR created_at <= $8::timestamptz) + AND ($8::timestamptz IS NULL OR created_at < (DATE_TRUNC('day', $8::timestamptz) + INTERVAL '1 day')) AND ($9::numeric IS NULL OR total_amount >= $9::numeric) AND ($10::numeric IS NULL OR total_amount <= $10::numeric) AND ($11::text[] IS NULL OR reported_challenge_status::text = ANY($11::text[])) diff --git a/sql/reports/statistics/general/country-member-details.sql b/sql/reports/statistics/general/country-member-details.sql new file mode 100644 index 0000000..3b9e38a --- /dev/null +++ b/sql/reports/statistics/general/country-member-details.sql @@ -0,0 +1,143 @@ +WITH member_profiles AS ( + SELECT + m."userId" AS user_id, + COALESCE( + NULLIF(TRIM(m."competitionCountryCode"), ''), + NULLIF(TRIM(m."homeCountryCode"), '') + ) AS country_code, + m.handle, + m."photoURL" AS photo_url + FROM members.member m +), +country_members AS ( + SELECT + country_code, + COUNT(*)::bigint AS members_count + FROM member_profiles + WHERE country_code IS NOT NULL + GROUP BY country_code +), +country_member_skills AS ( + SELECT DISTINCT + members.country_code, + members.user_id, + skill.id AS skill_id, + skill.name + FROM member_profiles members + JOIN skills.user_skill user_skill + ON user_skill.user_id::bigint = members.user_id + JOIN skills.user_skill_level skill_level + ON skill_level.id = user_skill.user_skill_level_id + AND LOWER(skill_level.name) IN ('verified', 'self-declared') + JOIN skills.skill skill + ON skill.id = user_skill.skill_id + AND skill.deleted_at IS NULL + WHERE members.country_code IS NOT NULL +), +country_skill_counts AS ( + SELECT + owned.country_code, + owned.skill_id, + owned.name, + COUNT(*)::bigint AS owned_count + FROM country_member_skills owned + GROUP BY owned.country_code, owned.skill_id, owned.name +), +country_skills AS ( + SELECT + country_code, + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'name', name, + 'count', owned_count, + 'ownedCount', owned_count + ) + ORDER BY owned_count DESC, name ASC, skill_id ASC + ) AS skills + FROM country_skill_counts + GROUP BY country_code +), +history_wins AS ( + SELECT + history."userId", + history."trackId", + history."typeId", + COUNT(*)::bigint AS wins + FROM members."memberStatsHistory" history + WHERE history.placement = 1 + GROUP BY history."userId", history."trackId", history."typeId" +), +stats_wins AS ( + SELECT + stats."userId", + SUM(COALESCE(stats.wins, history.wins, 0))::bigint AS wins + FROM members."memberStats" stats + LEFT JOIN history_wins history + ON history."userId" = stats."userId" + AND history."trackId" = stats."trackId" + AND history."typeId" = stats."typeId" + LEFT JOIN challenges."ChallengeTrack" track + ON track.id::text = stats."trackId" + WHERE stats."isPrivate" = false + AND ( + UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DEVELOP%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DESIGN%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DATA%SCIENCE%' + OR UPPER(COALESCE(track.name, stats."trackId")) = 'QA' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%QUALITY%ASSURANCE%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%COPILOT%' + ) + GROUP BY stats."userId" +), +winner_counts AS ( + SELECT + members.country_code, + members.user_id, + members.handle, + members.photo_url, + rating.rating AS max_rating, + wins.wins + FROM stats_wins wins + JOIN member_profiles members + ON members.user_id = wins."userId" + LEFT JOIN members."memberMaxRating" rating + ON rating."userId" = members.user_id + WHERE members.country_code IS NOT NULL + AND NULLIF(TRIM(members.handle), '') IS NOT NULL + AND wins.wins > 0 +), +ranked_members AS ( + SELECT + winner_counts.*, + ROW_NUMBER() OVER ( + PARTITION BY country_code + ORDER BY wins DESC, handle ASC, user_id ASC + ) AS member_rank + FROM winner_counts +), +top_members AS ( + SELECT + country_code, + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'handle', handle, + 'wins', wins, + 'photoURL', photo_url, + 'maxRating', max_rating + ) + ORDER BY member_rank + ) FILTER (WHERE member_rank <= 3) AS top_members + FROM ranked_members + GROUP BY country_code +) +SELECT + countries.country_code, + countries.members_count AS "user.count", + COALESCE(skills.skills, '[]'::jsonb) AS skills, + COALESCE(members.top_members, '[]'::jsonb) AS top_members +FROM country_members countries +LEFT JOIN country_skills skills + ON skills.country_code = countries.country_code +LEFT JOIN top_members members + ON members.country_code = countries.country_code +ORDER BY "user.count" DESC, country_code ASC; diff --git a/sql/reports/statistics/general/first-place-by-country.sql b/sql/reports/statistics/general/first-place-by-country.sql index 866de99..0a3aa15 100644 --- a/sql/reports/statistics/general/first-place-by-country.sql +++ b/sql/reports/statistics/general/first-place-by-country.sql @@ -1,16 +1,45 @@ -WITH winners AS ( - SELECT s."memberId"::text AS member_id - FROM reviews.submission s - WHERE s.placement = 1 +WITH history_wins AS ( + SELECT + history."userId", + history."trackId", + history."typeId", + COUNT(*)::bigint AS wins + FROM members."memberStatsHistory" history + WHERE history.placement = 1 + GROUP BY history."userId", history."trackId", history."typeId" +), +member_wins AS ( + SELECT + stats."userId", + SUM(COALESCE(stats.wins, history.wins, 0))::bigint AS wins + FROM members."memberStats" stats + LEFT JOIN history_wins history + ON history."userId" = stats."userId" + AND history."trackId" = stats."trackId" + AND history."typeId" = stats."typeId" + LEFT JOIN challenges."ChallengeTrack" track + ON track.id::text = stats."trackId" + WHERE stats."isPrivate" = false + AND ( + UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DEVELOP%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DESIGN%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DATA%SCIENCE%' + OR UPPER(COALESCE(track.name, stats."trackId")) = 'QA' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%QUALITY%ASSURANCE%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%COPILOT%' + ) + GROUP BY stats."userId" ), winners_country AS ( SELECT COALESCE( - NULLIF(TRIM(m."homeCountryCode"), ''), - NULLIF(TRIM(m."competitionCountryCode"), '') - ) AS country_code - FROM winners w - JOIN members.member m ON m."userId"::text = w.member_id + NULLIF(TRIM(m."competitionCountryCode"), ''), + NULLIF(TRIM(m."homeCountryCode"), '') + ) AS country_code, + w.wins + FROM member_wins w + JOIN members.member m ON m."userId" = w."userId" + WHERE w.wins > 0 ) SELECT country_code, @@ -19,7 +48,7 @@ SELECT FROM ( SELECT country_code, - COUNT(*)::bigint AS first_place_count + SUM(wins)::bigint AS first_place_count FROM winners_country WHERE country_code IS NOT NULL GROUP BY country_code diff --git a/sql/reports/statistics/general/top-winners-by-country.sql b/sql/reports/statistics/general/top-winners-by-country.sql new file mode 100644 index 0000000..65a68af --- /dev/null +++ b/sql/reports/statistics/general/top-winners-by-country.sql @@ -0,0 +1,97 @@ +WITH history_wins AS ( + SELECT + history."userId", + history."trackId", + history."typeId", + COUNT(*)::bigint AS wins + FROM members."memberStatsHistory" history + WHERE history.placement = 1 + GROUP BY history."userId", history."trackId", history."typeId" +), +stats_wins AS ( + SELECT + stats."userId", + SUM(COALESCE(stats.wins, history.wins, 0))::bigint AS wins + FROM members."memberStats" stats + LEFT JOIN history_wins history + ON history."userId" = stats."userId" + AND history."trackId" = stats."trackId" + AND history."typeId" = stats."typeId" + LEFT JOIN challenges."ChallengeTrack" track + ON track.id::text = stats."trackId" + WHERE stats."isPrivate" = false + AND ( + UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DEVELOP%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DESIGN%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%DATA%SCIENCE%' + OR UPPER(COALESCE(track.name, stats."trackId")) = 'QA' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%QUALITY%ASSURANCE%' + OR UPPER(COALESCE(track.name, stats."trackId")) LIKE '%COPILOT%' + ) + GROUP BY stats."userId" +), +winner_profiles AS ( + SELECT + COALESCE( + NULLIF(TRIM(m."competitionCountryCode"), ''), + NULLIF(TRIM(m."homeCountryCode"), '') + ) AS country_code, + m."userId" AS user_id, + m.handle, + m."photoURL" AS photo_url, + mmr.rating AS max_rating, + wins.wins + FROM stats_wins wins + JOIN members.member m + ON m."userId" = wins."userId" + LEFT JOIN members."memberMaxRating" mmr + ON mmr."userId" = m."userId" + WHERE COALESCE( + NULLIF(TRIM(m."competitionCountryCode"), ''), + NULLIF(TRIM(m."homeCountryCode"), '') + ) IS NOT NULL + AND wins.wins > 0 +), +country_totals AS ( + SELECT + country_code, + SUM(wins)::bigint AS first_place_count + FROM winner_profiles + GROUP BY country_code +), +ranked_winners AS ( + SELECT + winner_profiles.*, + ROW_NUMBER() OVER ( + PARTITION BY country_code + ORDER BY wins DESC, handle ASC, user_id ASC + ) AS winner_rank + FROM winner_profiles + WHERE NULLIF(TRIM(handle), '') IS NOT NULL +), +country_winners AS ( + SELECT + country_code, + JSONB_AGG( + JSONB_BUILD_OBJECT( + 'handle', handle, + 'wins', wins, + 'photoURL', photo_url, + 'maxRating', max_rating + ) + ORDER BY winner_rank + ) FILTER (WHERE winner_rank <= 3) AS top_winners + FROM ranked_winners + GROUP BY country_code +) +SELECT + totals.country_code, + totals.first_place_count AS "challenge_stats.count", + DENSE_RANK() OVER ( + ORDER BY totals.first_place_count DESC, totals.country_code ASC + )::int AS rank, + COALESCE(winners.top_winners, '[]'::jsonb) AS top_winners +FROM country_totals totals +LEFT JOIN country_winners winners + ON winners.country_code = totals.country_code +ORDER BY "challenge_stats.count" DESC, country_code ASC; diff --git a/src/reports/payment/dto/member-payment-accrual.dto.ts b/src/reports/payment/dto/member-payment-accrual.dto.ts index e42c412..e96736a 100644 --- a/src/reports/payment/dto/member-payment-accrual.dto.ts +++ b/src/reports/payment/dto/member-payment-accrual.dto.ts @@ -13,8 +13,8 @@ export class MemberPaymentAccrualQueryDto { @ApiPropertyOptional({ description: - "End date (inclusive) for filtering payment creation date in ISO 8601 format", - example: "2024-01-31T23:59:59.000Z", + "End date (inclusive through the full calendar day) for filtering payment creation date in ISO 8601 format", + example: "2024-01-31", }) @IsOptional() @IsDateString() diff --git a/src/reports/payment/payment-reports.service.spec.ts b/src/reports/payment/payment-reports.service.spec.ts index 978c934..b5a2e16 100644 --- a/src/reports/payment/payment-reports.service.spec.ts +++ b/src/reports/payment/payment-reports.service.spec.ts @@ -72,4 +72,16 @@ describe("PaymentReportsService", () => { ["Challenge Payment"], ]); }); + + it("uses the full inclusive end date in the payment SQL", () => { + const paymentSql = new SqlLoaderService().load( + "reports/payment/member-payment-accrual.sql", + ); + + expect(paymentSql).toContain("p.created_at >= pr.start_date"); + expect(paymentSql).toContain( + "DATE_TRUNC('day', pr.end_date) + INTERVAL '1 day'", + ); + expect(paymentSql).not.toContain("p.created_at <= pr.end_date"); + }); }); diff --git a/src/reports/report-directory.data.spec.ts b/src/reports/report-directory.data.spec.ts index 7d255df..3c75ab3 100644 --- a/src/reports/report-directory.data.spec.ts +++ b/src/reports/report-directory.data.spec.ts @@ -19,6 +19,12 @@ describe("getAccessibleReportsDirectory", () => { "/payment/member-payment-accrual-task", "/payment/member-payment-accrual-challenge", ]); + expect(directory.statistics?.reports.map((report) => report.path)).toEqual( + expect.arrayContaining([ + "/statistics/general/country-member-details", + "/statistics/general/top-winners-by-country", + ]), + ); }); it("returns public reports plus all challenge reports for product managers", () => { diff --git a/src/reports/report-directory.data.ts b/src/reports/report-directory.data.ts index 3c110a2..b39b712 100644 --- a/src/reports/report-directory.data.ts +++ b/src/reports/report-directory.data.ts @@ -540,11 +540,21 @@ const REGISTERED_REPORTS_DIRECTORY: RegisteredReportsDirectory = { "/statistics/general/countries-represented", "Member count by country (desc)", ), + publicReport( + "Country Member Details", + "/statistics/general/country-member-details", + "Member, skill, and top-member details by country (desc)", + ), publicReport( "First Place by Country", "/statistics/general/first-place-by-country", "First place finishes by country (desc)", ), + publicReport( + "Top Winners by Country", + "/statistics/general/top-winners-by-country", + "First place finishes and top three winners by country (desc)", + ), publicReport( "Copiloted Challenges", "/statistics/general/copiloted-challenges", diff --git a/src/reports/sfdc/sfdc-reports.dto.ts b/src/reports/sfdc/sfdc-reports.dto.ts index 0ad9685..20a8ae8 100644 --- a/src/reports/sfdc/sfdc-reports.dto.ts +++ b/src/reports/sfdc/sfdc-reports.dto.ts @@ -250,8 +250,9 @@ export class PaymentsReportQueryDto { @ApiProperty({ required: false, - description: "End date for the report query in ISO 8601 format", - example: "2023-01-31T23:59:59.000Z", + description: + "End date (inclusive through the full calendar day) for the report query in ISO 8601 format", + example: "2023-01-31", }) @IsOptional() @IsDateString() @@ -862,8 +863,9 @@ export class BaFeesReportQueryDto { @ApiProperty({ required: false, - description: "End date for the report query in ISO 8601 format", - example: "2023-01-31T23:59:59.000Z", + description: + "End date (inclusive through the full calendar day) for the report query in ISO 8601 format", + example: "2023-01-31", }) @IsOptional() @IsDateString() diff --git a/src/reports/sfdc/sfdc-reports.service.spec.ts b/src/reports/sfdc/sfdc-reports.service.spec.ts index f54f947..f9d41ca 100644 --- a/src/reports/sfdc/sfdc-reports.service.spec.ts +++ b/src/reports/sfdc/sfdc-reports.service.spec.ts @@ -331,6 +331,18 @@ describe("SfdcReportsService - getPaymentsReport", () => { ); }); + it("uses the full inclusive end date in the payments SQL", () => { + const paymentsSql = readFileSync( + join(__dirname, "../../../sql/reports/sfdc/payments.sql"), + "utf8", + ); + + expect(paymentsSql).toContain( + "DATE_TRUNC('day', $8::timestamptz) + INTERVAL '1 day'", + ); + expect(paymentsSql).not.toContain("created_at <= $8::timestamptz"); + }); + it("returns mixed challenge, engagement, and unresolved challenge payments successfully", async () => { const result = await service.getPaymentsReport( mockPaymentQueryDto.billingAccount, @@ -1108,6 +1120,21 @@ describe("SfdcReportsService - getBaFeesReport", () => { ); }); + it.each(["ba-fees.sql", "ba-fees-monthly.sql"])( + "uses the full inclusive end date in %s", + (fileName) => { + const baFeesSql = readFileSync( + join(__dirname, `../../../sql/reports/sfdc/${fileName}`), + "utf8", + ); + + expect(baFeesSql).toContain( + "DATE_TRUNC('day', $2::timestamptz) + INTERVAL '1 day'", + ); + expect(baFeesSql).not.toContain("p.created_at <= $2::timestamptz"); + }, + ); + it("runs a basic query successfully", async () => { const result = await service.getBaFeesReport( mockBaFeesQueryDto.byBillingAccount, diff --git a/src/statistics/general-statistics.service.spec.ts b/src/statistics/general-statistics.service.spec.ts new file mode 100644 index 0000000..044617b --- /dev/null +++ b/src/statistics/general-statistics.service.spec.ts @@ -0,0 +1,182 @@ +import { DbService } from "../db/db.service"; +import { SqlLoaderService } from "../common/sql-loader.service"; +import { GeneralStatisticsService } from "./general-statistics.service"; + +describe("GeneralStatisticsService", () => { + const db = { + query: jest.fn(), + }; + const sql = { + load: jest.fn().mockReturnValue("SELECT tooltip data"), + }; + const service = new GeneralStatisticsService( + db as unknown as DbService, + sql as unknown as SqlLoaderService, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("normalizes top winners and nullable profile fields", async () => { + db.query.mockResolvedValue([ + { + country_code: "IND", + "challenge_stats.count": "42", + rank: "2", + top_winners: [ + { + handle: "winner", + maxRating: "1499", + photoURL: null, + wins: "12", + }, + { + handle: "newcomer", + maxRating: null, + photoURL: "https://example.com/avatar.png", + wins: 5, + }, + ], + }, + ]); + + await expect(service.getTopWinnersByCountry()).resolves.toEqual([ + { + "country.country_name": "India", + "challenge_stats.count": 42, + rank: 2, + topWinners: [ + { + handle: "winner", + maxRating: 1499, + photoURL: null, + wins: 12, + }, + { + handle: "newcomer", + maxRating: null, + photoURL: "https://example.com/avatar.png", + wins: 5, + }, + ], + }, + ]); + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/general/top-winners-by-country.sql", + ); + }); + + it("uses safe defaults when winner details are absent", async () => { + db.query.mockResolvedValue([ + { + country_code: null, + "challenge_stats.count": null, + rank: null, + top_winners: null, + }, + ]); + + await expect(service.getTopWinnersByCountry()).resolves.toEqual([ + { + "country.country_name": "", + "challenge_stats.count": 0, + rank: null, + topWinners: [], + }, + ]); + }); + + it("normalizes country member, skill, and top-member details", async () => { + db.query.mockResolvedValue([ + { + country_code: "IND", + "user.count": "730554", + top_members: [ + { + handle: "top-member", + maxRating: "2200", + photoURL: null, + wins: "1768", + }, + { + handle: "third-member", + maxRating: null, + photoURL: null, + wins: "100", + }, + ], + skills: [ + { count: "50", name: "JavaScript", ownedCount: "50" }, + { count: "30", name: "Python", ownedCount: "30" }, + { count: "20", name: "Swift", ownedCount: "20" }, + ], + }, + { + country_code: "IN", + "user.count": "10", + top_members: [ + { + handle: "higher-winner", + maxRating: "2400", + photoURL: "https://example.com/higher.png", + wins: "2000", + }, + { + handle: "second-member", + maxRating: "2300", + photoURL: null, + wins: "1800", + }, + ], + skills: [ + { count: "10", name: "javascript", ownedCount: "10" }, + { count: "25", name: "Rust", ownedCount: "25" }, + { count: "15", name: "Go", ownedCount: "15" }, + ], + }, + ]); + + await expect(service.getCountryMemberDetails()).resolves.toEqual([ + { + "country.country_name": "India", + "country.country_code": "IND", + "user.count": 730564, + rank: 1, + skillsBreakdown: [ + { count: 60, name: "JavaScript", percentage: 40 }, + { count: 30, name: "Python", percentage: 20 }, + { + count: 25, + name: "Rust", + percentage: 16.666666666666664, + }, + ], + totalSkills: 5, + topMembers: [ + { + handle: "higher-winner", + maxRating: 2400, + photoURL: "https://example.com/higher.png", + wins: 2000, + }, + { + handle: "second-member", + maxRating: 2300, + photoURL: null, + wins: 1800, + }, + { + handle: "top-member", + maxRating: 2200, + photoURL: null, + wins: 1768, + }, + ], + }, + ]); + expect(sql.load).toHaveBeenCalledWith( + "reports/statistics/general/country-member-details.sql", + ); + }); +}); diff --git a/src/statistics/general-statistics.service.ts b/src/statistics/general-statistics.service.ts index 290d220..ece3315 100644 --- a/src/statistics/general-statistics.service.ts +++ b/src/statistics/general-statistics.service.ts @@ -3,6 +3,29 @@ import { DbService } from "../db/db.service"; import { SqlLoaderService } from "../common/sql-loader.service"; import { alpha3ToCountryName } from "../common/country.util"; +type CountryMember = { + handle: string; + maxRating: number | null; + photoURL: string | null; + wins: number; +}; + +type CountryMemberDetailRow = { + country_code: string | null; + "user.count": number | string | null; + skills: Array<{ + count?: number | string | null; + name?: string | null; + ownedCount?: number | string | null; + }> | null; + top_members: Array<{ + handle?: string | null; + maxRating?: number | string | null; + photoURL?: string | null; + wins?: number | string | null; + }> | null; +}; + @Injectable() export class GeneralStatisticsService { constructor( @@ -54,6 +77,129 @@ export class GeneralStatisticsService { }); } + async getCountryMemberDetails() { + const q = this.sql.load( + "reports/statistics/general/country-member-details.sql", + ); + const rows = await this.db.query(q); + const countries = new Map< + string, + { + countryCode: string | null; + memberCount: number; + skills: Map< + string, + { count: number; name: string; ownedCount: number } + >; + topMembers: CountryMember[]; + } + >(); + + rows.forEach((row) => { + const countryName = + alpha3ToCountryName(row.country_code) ?? row.country_code ?? ""; + const current = countries.get(countryName) ?? { + countryCode: row.country_code, + memberCount: 0, + skills: new Map< + string, + { count: number; name: string; ownedCount: number } + >(), + topMembers: [], + }; + const candidates = ( + Array.isArray(row.top_members) ? row.top_members : [] + ).map((member) => ({ + handle: String(member.handle ?? ""), + maxRating: + member.maxRating !== null && member.maxRating !== undefined + ? Number(member.maxRating) + : null, + photoURL: member.photoURL ?? null, + wins: Number(member.wins ?? 0), + })); + + current.memberCount += Number(row["user.count"] ?? 0); + (Array.isArray(row.skills) ? row.skills : []).forEach((skill) => { + const name = String(skill.name ?? "").trim(); + const count = Number(skill.count ?? 0); + const ownedCount = Number(skill.ownedCount ?? 0); + if ( + !name || + !Number.isFinite(count) || + !Number.isFinite(ownedCount) || + ownedCount <= 0 + ) { + return; + } + + const key = name.toLowerCase(); + const existing = current.skills.get(key); + current.skills.set(key, { + count: (existing?.count ?? 0) + count, + name: existing?.name ?? name, + ownedCount: (existing?.ownedCount ?? 0) + ownedCount, + }); + }); + current.topMembers.push(...candidates); + countries.set(countryName, current); + }); + + const mergedCountries = Array.from(countries.entries()).sort( + ([nameA, countryA], [nameB, countryB]) => + countryB.memberCount - countryA.memberCount || + nameA.localeCompare(nameB), + ); + let rank = 0; + let previousCount: number | undefined; + + return mergedCountries.map(([countryName, country]) => { + if (country.memberCount !== previousCount) { + rank += 1; + previousCount = country.memberCount; + } + const skills = Array.from(country.skills.values()).sort( + (skillA, skillB) => + skillB.count - skillA.count || skillA.name.localeCompare(skillB.name), + ); + const totalOwnedSkills = skills.reduce( + (total, skill) => total + skill.ownedCount, + 0, + ); + const topMembersByHandle = new Map(); + country.topMembers + .sort( + (memberA, memberB) => + memberB.wins - memberA.wins || + memberA.handle.localeCompare(memberB.handle), + ) + .forEach((member) => { + const key = member.handle.toLowerCase(); + if (key && !topMembersByHandle.has(key)) { + topMembersByHandle.set(key, member); + } + }); + const topMembers = Array.from(topMembersByHandle.values()).slice(0, 3); + + return { + "country.country_name": countryName, + "country.country_code": country.countryCode, + "user.count": country.memberCount, + rank, + skillsBreakdown: skills.slice(0, 3).map((skill) => ({ + count: skill.count, + name: skill.name, + percentage: + totalOwnedSkills > 0 + ? (skill.ownedCount / totalOwnedSkills) * 100 + : 0, + })), + totalSkills: skills.length, + topMembers, + }; + }); + } + async getFirstPlaceByCountry() { const q = this.sql.load( "reports/statistics/general/first-place-by-country.sql", @@ -75,6 +221,47 @@ export class GeneralStatisticsService { }); } + async getTopWinnersByCountry() { + const q = this.sql.load( + "reports/statistics/general/top-winners-by-country.sql", + ); + const rows = await this.db.query<{ + country_code: string | null; + "challenge_stats.count": number | string | null; + rank: number | string | null; + top_winners: Array<{ + handle?: string | null; + maxRating?: number | string | null; + photoURL?: string | null; + wins?: number | string | null; + }> | null; + }>(q); + + return rows.map((row) => { + const countryName = + alpha3ToCountryName(row.country_code) ?? row.country_code ?? ""; + const topWinners = Array.isArray(row.top_winners) + ? row.top_winners.map((winner) => ({ + handle: String(winner.handle ?? ""), + maxRating: + winner.maxRating !== null && winner.maxRating !== undefined + ? Number(winner.maxRating) + : null, + photoURL: winner.photoURL ?? null, + wins: Number(winner.wins ?? 0), + })) + : []; + + return { + "country.country_name": countryName, + "challenge_stats.count": Number(row["challenge_stats.count"] ?? 0), + rank: + row.rank !== null && row.rank !== undefined ? Number(row.rank) : null, + topWinners, + }; + }); + } + async getCopilotedChallenges() { const q = this.sql.load( "reports/statistics/general/copiloted-challenges.sql", diff --git a/src/statistics/statistics-general.controller.ts b/src/statistics/statistics-general.controller.ts index a5c5206..16df9bf 100644 --- a/src/statistics/statistics-general.controller.ts +++ b/src/statistics/statistics-general.controller.ts @@ -33,12 +33,28 @@ export class StatisticsGeneralController { return this.general.getCountriesRepresented(); } + @Get("/country-member-details") + @ApiOperation({ + summary: "Member, skill, and top-member details by country (desc)", + }) + getCountryMemberDetails() { + return this.general.getCountryMemberDetails(); + } + @Get("/first-place-by-country") @ApiOperation({ summary: "First place finishes by country (desc)" }) getFirstPlaceByCountry() { return this.general.getFirstPlaceByCountry(); } + @Get("/top-winners-by-country") + @ApiOperation({ + summary: "First place finishes and top three winners by country (desc)", + }) + getTopWinnersByCountry() { + return this.general.getTopWinnersByCountry(); + } + @Get("/copiloted-challenges") @ApiOperation({ summary: "Copiloted challenges by member (desc)" }) getCopilotedChallenges() { diff --git a/src/statistics/statistics-general.sql.spec.ts b/src/statistics/statistics-general.sql.spec.ts new file mode 100644 index 0000000..ea46b2c --- /dev/null +++ b/src/statistics/statistics-general.sql.spec.ts @@ -0,0 +1,64 @@ +import { SqlLoaderService } from "../common/sql-loader.service"; + +describe("General statistics SQL", () => { + const sqlLoader = new SqlLoaderService(); + + function expectMemberStatsWins(sql: string) { + expect(sql).toContain('FROM members."memberStats" stats'); + expect(sql).toContain('FROM members."memberStatsHistory" history'); + expect(sql).toContain("history.placement = 1"); + expect(sql).toContain('stats."isPrivate" = false'); + expect(sql).toContain("SUM(COALESCE(stats.wins, history.wins, 0))"); + expect(sql).toContain('JOIN challenges."ChallengeTrack" track'); + expect(sql).not.toContain('challenges."ChallengeWinner"'); + expect(sql).not.toContain("reviews.submission"); + } + + it("returns at most three deterministically ranked winners per country", () => { + const sql = sqlLoader.load( + "reports/statistics/general/top-winners-by-country.sql", + ); + + expectMemberStatsWins(sql); + expect(sql).toContain("PARTITION BY country_code"); + expect(sql).toContain("ORDER BY wins DESC, handle ASC, user_id ASC"); + expect(sql).toContain("winner_rank <= 3"); + expect(sql).toContain('m."photoURL" AS photo_url'); + expect(sql).toContain('members."memberMaxRating"'); + expect(sql).toContain("'maxRating', max_rating"); + expect(sql).toContain('first_place_count AS "challenge_stats.count"'); + }); + + it("aggregates owned skills and three deterministic top members", () => { + const sql = sqlLoader.load( + "reports/statistics/general/country-member-details.sql", + ); + + expect(sql).toContain("NULLIF(TRIM(m.\"homeCountryCode\"), '')"); + expect(sql).toContain("JOIN skills.user_skill user_skill"); + expect(sql).toContain("JOIN skills.user_skill_level skill_level"); + expect(sql).toContain( + "LOWER(skill_level.name) IN ('verified', 'self-declared')", + ); + expect(sql).toContain("COUNT(*)::bigint AS owned_count"); + expect(sql).not.toContain("user_skill_win_summary"); + expect(sql).toContain("'ownedCount', owned_count"); + expect(sql).toContain("JOIN skills.skill skill"); + expect(sql).toContain("skill.deleted_at IS NULL"); + expect(sql).toContain("ORDER BY owned_count DESC, name ASC, skill_id ASC"); + expect(sql).toContain("AS skills"); + expect(sql).not.toContain("skill_rank <= 3"); + expectMemberStatsWins(sql); + expect(sql).toContain("ORDER BY wins DESC, handle ASC, user_id ASC"); + expect(sql).toContain("member_rank <= 3"); + expect(sql).toContain('countries.members_count AS "user.count"'); + }); + + it("uses public member stats wins for country totals", () => { + const sql = sqlLoader.load( + "reports/statistics/general/first-place-by-country.sql", + ); + + expectMemberStatsWins(sql); + }); +});