mirror of
https://github.com/khairul169/github-leaderboard.git
synced 2025-04-28 15:39:31 +07:00
feat: update language leaderboard calculation
This commit is contained in:
parent
a5cd6383f3
commit
0142224f60
@ -1,3 +1,11 @@
|
|||||||
|
CREATE TABLE `repository_languages` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`repo_id` integer NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`percentage` real NOT NULL,
|
||||||
|
FOREIGN KEY (`repo_id`) REFERENCES `repositories`(`id`) ON UPDATE no action ON DELETE no action
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
CREATE TABLE `repositories` (
|
CREATE TABLE `repositories` (
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
`user_id` integer NOT NULL,
|
`user_id` integer NOT NULL,
|
||||||
@ -7,7 +15,6 @@ CREATE TABLE `repositories` (
|
|||||||
`stars` integer NOT NULL,
|
`stars` integer NOT NULL,
|
||||||
`forks` integer NOT NULL,
|
`forks` integer NOT NULL,
|
||||||
`last_update` text NOT NULL,
|
`last_update` text NOT NULL,
|
||||||
`languages` text,
|
|
||||||
`contributors` text,
|
`contributors` text,
|
||||||
`is_pending` integer DEFAULT false NOT NULL,
|
`is_pending` integer DEFAULT false NOT NULL,
|
||||||
`is_error` integer DEFAULT false NOT NULL,
|
`is_error` integer DEFAULT false NOT NULL,
|
||||||
@ -34,6 +41,7 @@ CREATE TABLE `users` (
|
|||||||
`updated_at` text NOT NULL
|
`updated_at` text NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `repository_languages_name_idx` ON `repository_languages` (`name`);--> statement-breakpoint
|
||||||
CREATE INDEX `repositories_name_idx` ON `repositories` (`name`);--> statement-breakpoint
|
CREATE INDEX `repositories_name_idx` ON `repositories` (`name`);--> statement-breakpoint
|
||||||
CREATE INDEX `repositories_uri_idx` ON `repositories` (`uri`);--> statement-breakpoint
|
CREATE INDEX `repositories_uri_idx` ON `repositories` (`uri`);--> statement-breakpoint
|
||||||
CREATE INDEX `repositories_language_idx` ON `repositories` (`language`);--> statement-breakpoint
|
CREATE INDEX `repositories_language_idx` ON `repositories` (`language`);--> statement-breakpoint
|
@ -1,9 +1,68 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "13d859a6-4c32-46a6-90e9-19740b2bb13a",
|
"id": "a268ec23-239a-4d86-8830-2f3c1c2110df",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
|
"repository_languages": {
|
||||||
|
"name": "repository_languages",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"repo_id": {
|
||||||
|
"name": "repo_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"percentage": {
|
||||||
|
"name": "percentage",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"repository_languages_name_idx": {
|
||||||
|
"name": "repository_languages_name_idx",
|
||||||
|
"columns": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"repository_languages_repo_id_repositories_id_fk": {
|
||||||
|
"name": "repository_languages_repo_id_repositories_id_fk",
|
||||||
|
"tableFrom": "repository_languages",
|
||||||
|
"tableTo": "repositories",
|
||||||
|
"columnsFrom": [
|
||||||
|
"repo_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
"repositories": {
|
"repositories": {
|
||||||
"name": "repositories",
|
"name": "repositories",
|
||||||
"columns": {
|
"columns": {
|
||||||
@ -63,13 +122,6 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"languages": {
|
|
||||||
"name": "languages",
|
|
||||||
"type": "text",
|
|
||||||
"primaryKey": false,
|
|
||||||
"notNull": false,
|
|
||||||
"autoincrement": false
|
|
||||||
},
|
|
||||||
"contributors": {
|
"contributors": {
|
||||||
"name": "contributors",
|
"name": "contributors",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1723210265266,
|
"when": 1723211354217,
|
||||||
"tag": "0000_young_emma_frost",
|
"tag": "0000_tough_jubilee",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
34
server/import-user.ts
Normal file
34
server/import-user.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import db from "./db";
|
||||||
|
import queue from "./lib/queue";
|
||||||
|
import { users } from "./models";
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const username = process.argv[2];
|
||||||
|
if (!username) {
|
||||||
|
throw new Error("Missing username");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({ username, name: username })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: users.username,
|
||||||
|
set: { username },
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await queue.add(
|
||||||
|
"fetchUserProfile",
|
||||||
|
{ userId: user.id },
|
||||||
|
{ jobId: `fetchUserProfile:${user.id}` }
|
||||||
|
);
|
||||||
|
await queue.add(
|
||||||
|
"fetchUserRepos",
|
||||||
|
{ userId: user.id },
|
||||||
|
{ jobId: `fetchUserRepos:${user.id}` }
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit();
|
||||||
|
};
|
||||||
|
|
||||||
|
main();
|
@ -37,15 +37,15 @@ export const calculateUserPoints = async (data: CalculateUserPointsType) => {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// User repositories
|
// User repositories
|
||||||
const repos = await db
|
const repos = await db.query.repositories.findMany({
|
||||||
.select()
|
where: eq(repositories.userId, user.id),
|
||||||
.from(repositories)
|
with: { languages: true },
|
||||||
.where(eq(repositories.userId, user.id));
|
});
|
||||||
points += repos.length * weights.repositories;
|
points += repos.length * weights.repositories;
|
||||||
|
|
||||||
// Languages known
|
// Languages known
|
||||||
const languages = new Set(
|
const languages = new Set(
|
||||||
repos.flatMap((i) => i.languages?.map((j) => j.lang))
|
repos.flatMap((i) => i.languages?.map((j) => j.name))
|
||||||
);
|
);
|
||||||
points += languages.size * weights.languagesKnown;
|
points += languages.size * weights.languagesKnown;
|
||||||
|
|
||||||
|
@ -2,7 +2,8 @@ import db from "@server/db";
|
|||||||
import github from "@server/lib/github";
|
import github from "@server/lib/github";
|
||||||
import queue from "@server/lib/queue";
|
import queue from "@server/lib/queue";
|
||||||
import { repositories } from "@server/models";
|
import { repositories } from "@server/models";
|
||||||
import { eq } from "drizzle-orm";
|
import { repoLanguages } from "@server/models/repo-languages";
|
||||||
|
import { and, eq, notInArray } from "drizzle-orm";
|
||||||
|
|
||||||
export type FetchRepoDataJobType = {
|
export type FetchRepoDataJobType = {
|
||||||
id: number;
|
id: number;
|
||||||
@ -10,17 +11,55 @@ export type FetchRepoDataJobType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRepoData = async (data: FetchRepoDataJobType) => {
|
export const fetchRepoData = async (data: FetchRepoDataJobType) => {
|
||||||
const details = await github.getRepoDetails(data.uri);
|
const [repository] = await db
|
||||||
|
.select()
|
||||||
|
.from(repositories)
|
||||||
|
.where(eq(repositories.id, data.id));
|
||||||
|
|
||||||
const [result] = await db
|
if (!repository) {
|
||||||
.update(repositories)
|
|
||||||
.set({ languages: details.languages })
|
|
||||||
.where(eq(repositories.id, data.id))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
throw new Error("Repository not found!");
|
throw new Error("Repository not found!");
|
||||||
}
|
}
|
||||||
|
|
||||||
await queue.add("calculateUserPoints", { userId: result.userId });
|
const details = await github.getRepoDetails(data.uri);
|
||||||
|
const { languages } = details;
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// Remove languages that don't exist anymore
|
||||||
|
const purgeLangFilter = and(
|
||||||
|
eq(repoLanguages.repoId, repository.id),
|
||||||
|
notInArray(
|
||||||
|
repoLanguages.name,
|
||||||
|
languages.map((i) => i.lang)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await tx.delete(repoLanguages).where(purgeLangFilter);
|
||||||
|
|
||||||
|
// Add or update languages
|
||||||
|
for (const lang of languages) {
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ id: repoLanguages.id })
|
||||||
|
.from(repoLanguages)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(repoLanguages.repoId, repository.id),
|
||||||
|
eq(repoLanguages.name, lang.lang)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await tx
|
||||||
|
.update(repoLanguages)
|
||||||
|
.set({ percentage: lang.amount })
|
||||||
|
.where(eq(repoLanguages.id, existing.id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(repoLanguages).values({
|
||||||
|
repoId: repository.id,
|
||||||
|
name: lang.lang,
|
||||||
|
percentage: lang.amount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await queue.add("calculateUserPoints", { userId: repository.userId });
|
||||||
};
|
};
|
||||||
|
@ -19,6 +19,7 @@ export const fetchUserProfile = async (data: FetchUserProfileType) => {
|
|||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
name: details.name,
|
name: details.name,
|
||||||
|
avatar: details.avatar,
|
||||||
followers: details.followers,
|
followers: details.followers,
|
||||||
following: details.following,
|
following: details.following,
|
||||||
location: details.location,
|
location: details.location,
|
||||||
|
@ -8,6 +8,7 @@ const GITHUB_API_URL = "https://api.github.com";
|
|||||||
const selectors = {
|
const selectors = {
|
||||||
user: {
|
user: {
|
||||||
name: "h1.vcard-names > span.vcard-fullname",
|
name: "h1.vcard-names > span.vcard-fullname",
|
||||||
|
avatar: ".js-profile-editable-replace img.avatar-user",
|
||||||
location: "li[itemprop='homeLocation'] span",
|
location: "li[itemprop='homeLocation'] span",
|
||||||
followers: ".js-profile-editable-area a[href$='?tab=followers'] > span",
|
followers: ".js-profile-editable-area a[href$='?tab=followers'] > span",
|
||||||
following: ".js-profile-editable-area a[href$='?tab=following'] > span",
|
following: ".js-profile-editable-area a[href$='?tab=following'] > span",
|
||||||
@ -30,6 +31,8 @@ const github = {
|
|||||||
const $ = cheerio.load(response);
|
const $ = cheerio.load(response);
|
||||||
|
|
||||||
const name = $(selectors.user.name).text().trim();
|
const name = $(selectors.user.name).text().trim();
|
||||||
|
const avatar = $(selectors.user.avatar).attr("src");
|
||||||
|
console.log({ avatar });
|
||||||
const location = $(selectors.user.location).text().trim();
|
const location = $(selectors.user.location).text().trim();
|
||||||
const followers = intval($(selectors.user.followers).text().trim());
|
const followers = intval($(selectors.user.followers).text().trim());
|
||||||
const following = intval($(selectors.user.following).text().trim());
|
const following = intval($(selectors.user.following).text().trim());
|
||||||
@ -43,6 +46,7 @@ const github = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: name || username,
|
name: name || username,
|
||||||
|
avatar,
|
||||||
username,
|
username,
|
||||||
location,
|
location,
|
||||||
followers,
|
followers,
|
||||||
|
@ -12,6 +12,9 @@ export const intval = (value: any) => {
|
|||||||
export const getLanguageLogo = (language: string) => {
|
export const getLanguageLogo = (language: string) => {
|
||||||
const alias: Record<string, string> = {
|
const alias: Record<string, string> = {
|
||||||
gdscript: "godot",
|
gdscript: "godot",
|
||||||
|
gap: "godot",
|
||||||
|
html: "html5",
|
||||||
|
css: "css3",
|
||||||
};
|
};
|
||||||
|
|
||||||
let lang = language.toLowerCase().replace(/[^a-zA-Z]/g, "");
|
let lang = language.toLowerCase().replace(/[^a-zA-Z]/g, "");
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export { users } from "./users";
|
export { users } from "./users";
|
||||||
export { repositories } from "./repositories";
|
export { repositories, repositoriesRelations } from "./repositories";
|
||||||
|
export { repoLanguages, repoLanguagesRelations } from "./repo-languages";
|
||||||
|
35
server/models/repo-languages.ts
Normal file
35
server/models/repo-languages.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { InferInsertModel, InferSelectModel, relations } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
text,
|
||||||
|
sqliteTable,
|
||||||
|
integer,
|
||||||
|
index,
|
||||||
|
real,
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import { repositories } from "./repositories";
|
||||||
|
|
||||||
|
export const repoLanguages = sqliteTable(
|
||||||
|
"repository_languages",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
repoId: integer("repo_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => repositories.id),
|
||||||
|
|
||||||
|
name: text("name").notNull(),
|
||||||
|
percentage: real("percentage").notNull(),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
nameIdx: index("repository_languages_name_idx").on(t.name),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const repoLanguagesRelations = relations(repoLanguages, ({ one }) => ({
|
||||||
|
repository: one(repositories, {
|
||||||
|
fields: [repoLanguages.repoId],
|
||||||
|
references: [repositories.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type RepositoryLanguage = InferSelectModel<typeof repoLanguages>;
|
||||||
|
export type CreateRepositoryLanguage = InferInsertModel<typeof repoLanguages>;
|
@ -1,7 +1,13 @@
|
|||||||
import { Contributor, Language } from "@server/lib/github";
|
import { Contributor } from "@server/lib/github";
|
||||||
import { InferInsertModel, InferSelectModel, sql } from "drizzle-orm";
|
import {
|
||||||
|
InferInsertModel,
|
||||||
|
InferSelectModel,
|
||||||
|
relations,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm";
|
||||||
import { text, sqliteTable, integer, index } from "drizzle-orm/sqlite-core";
|
import { text, sqliteTable, integer, index } from "drizzle-orm/sqlite-core";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
|
import { repoLanguages } from "./repo-languages";
|
||||||
|
|
||||||
export const repositories = sqliteTable(
|
export const repositories = sqliteTable(
|
||||||
"repositories",
|
"repositories",
|
||||||
@ -17,7 +23,6 @@ export const repositories = sqliteTable(
|
|||||||
stars: integer("stars").notNull(),
|
stars: integer("stars").notNull(),
|
||||||
forks: integer("forks").notNull(),
|
forks: integer("forks").notNull(),
|
||||||
lastUpdate: text("last_update").notNull(),
|
lastUpdate: text("last_update").notNull(),
|
||||||
languages: text("languages", { mode: "json" }).$type<Language[]>(),
|
|
||||||
contributors: text("contributors", { mode: "json" }).$type<Contributor[]>(),
|
contributors: text("contributors", { mode: "json" }).$type<Contributor[]>(),
|
||||||
|
|
||||||
isPending: integer("is_pending", { mode: "boolean" })
|
isPending: integer("is_pending", { mode: "boolean" })
|
||||||
@ -39,5 +44,9 @@ export const repositories = sqliteTable(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const repositoriesRelations = relations(repositories, ({ many }) => ({
|
||||||
|
languages: many(repoLanguages),
|
||||||
|
}));
|
||||||
|
|
||||||
export type Repository = InferSelectModel<typeof repositories>;
|
export type Repository = InferSelectModel<typeof repositories>;
|
||||||
export type CreateRepository = InferInsertModel<typeof repositories>;
|
export type CreateRepository = InferInsertModel<typeof repositories>;
|
||||||
|
@ -22,7 +22,7 @@ const onJobRetriesExhausted = async (job: Job) => {
|
|||||||
|
|
||||||
const worker = new Worker(BULLMQ_JOB_NAME, handler, {
|
const worker = new Worker(BULLMQ_JOB_NAME, handler, {
|
||||||
connection: BULLMQ_CONNECTION,
|
connection: BULLMQ_CONNECTION,
|
||||||
concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 1,
|
concurrency: Number(import.meta.env.QUEUE_CONCURRENCY) || 10,
|
||||||
removeOnComplete: { count: 0 },
|
removeOnComplete: { count: 0 },
|
||||||
removeOnFail: { count: 0 },
|
removeOnFail: { count: 0 },
|
||||||
});
|
});
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { getLanguageLogo } from "@server/lib/utils";
|
import { getLanguageLogo } from "@server/lib/utils";
|
||||||
import { repositories, users } from "@server/models";
|
import { repoLanguages, repositories, users } from "@server/models";
|
||||||
import { count, desc, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
import { count, desc, eq, getTableColumns, inArray, sql } from "drizzle-orm";
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
|
||||||
export type GetLeaderboardRes = {
|
export type GetLeaderboardRes = {
|
||||||
columns: { title: string; selector: string }[];
|
columns: { title: string; selector: string; hideOnSmall?: boolean }[];
|
||||||
rows: {
|
rows: {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
rank: number;
|
rank: number;
|
||||||
@ -48,12 +48,18 @@ export class LeaderboardRepository {
|
|||||||
async getTopLanguages() {
|
async getTopLanguages() {
|
||||||
const data = await db
|
const data = await db
|
||||||
.select({
|
.select({
|
||||||
language: repositories.language,
|
language: repoLanguages.name,
|
||||||
count: count(),
|
count: count(),
|
||||||
|
total: sql<number>`sum(${repoLanguages.percentage})`.as("total"),
|
||||||
|
avg: sql<number>`avg(${repoLanguages.percentage})`.as("avg"),
|
||||||
|
coverage: sql<number>`count(*) * avg(${repoLanguages.percentage})`.as(
|
||||||
|
"coverage"
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.from(repositories)
|
.from(repositories)
|
||||||
.groupBy(repositories.language)
|
.innerJoin(repoLanguages, eq(repoLanguages.repoId, repositories.id))
|
||||||
.orderBy(desc(count()));
|
.groupBy(repoLanguages.name)
|
||||||
|
.orderBy(desc(sql`count(*) * avg(${repoLanguages.percentage})`));
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
@ -62,8 +68,21 @@ export class LeaderboardRepository {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total Repo",
|
title: "Total Repo",
|
||||||
|
selector: "repo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Rerata",
|
||||||
|
selector: "avg",
|
||||||
|
hideOnSmall: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cakupan",
|
||||||
selector: "sub",
|
selector: "sub",
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// title: "Total Persentase",
|
||||||
|
// selector: "total",
|
||||||
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = data
|
const rows = data
|
||||||
@ -71,8 +90,11 @@ export class LeaderboardRepository {
|
|||||||
.map((data, idx) => ({
|
.map((data, idx) => ({
|
||||||
rank: idx + 1,
|
rank: idx + 1,
|
||||||
name: data.language,
|
name: data.language,
|
||||||
sub: `${data.count} repo`,
|
sub: data.coverage.toFixed(0),
|
||||||
image: getLanguageLogo(data.language),
|
image: getLanguageLogo(data.language),
|
||||||
|
repo: `${data.count} repo`,
|
||||||
|
total: data.total,
|
||||||
|
avg: data.avg.toFixed(1),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { columns, rows } satisfies GetLeaderboardRes;
|
return { columns, rows } satisfies GetLeaderboardRes;
|
||||||
@ -91,7 +113,8 @@ export class LeaderboardRepository {
|
|||||||
})
|
})
|
||||||
.from(repositories)
|
.from(repositories)
|
||||||
.innerJoin(users, eq(users.id, repositories.userId))
|
.innerJoin(users, eq(users.id, repositories.userId))
|
||||||
.where(inArray(sql`lower(${repositories.language})`, languages))
|
.innerJoin(repoLanguages, eq(repoLanguages.repoId, repositories.id))
|
||||||
|
.where(inArray(sql`lower(${repoLanguages.name})`, languages))
|
||||||
.groupBy(users.id)
|
.groupBy(users.id)
|
||||||
.orderBy(desc(count()));
|
.orderBy(desc(count()));
|
||||||
|
|
||||||
@ -141,18 +164,18 @@ export class LeaderboardRepository {
|
|||||||
throw new HTTPException(404, { message: "User not found!" });
|
throw new HTTPException(404, { message: "User not found!" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const repos = await db
|
const repos = await db.query.repositories.findMany({
|
||||||
.select()
|
where: eq(repositories.userId, user.id),
|
||||||
.from(repositories)
|
orderBy: [desc(repositories.stars), desc(repositories.forks)],
|
||||||
.where(eq(repositories.userId, user.id))
|
with: { languages: true },
|
||||||
.orderBy(desc(repositories.stars), desc(repositories.forks));
|
});
|
||||||
|
|
||||||
const languageMap: Record<string, number> = {};
|
const languageMap: Record<string, number> = {};
|
||||||
repos
|
repos
|
||||||
.flatMap((i) => i.languages || [])
|
.flatMap((i) => i.languages || [])
|
||||||
.forEach((i) => {
|
.forEach((i) => {
|
||||||
if (!languageMap[i.lang]) languageMap[i.lang] = 0;
|
if (!languageMap[i.name]) languageMap[i.name] = 0;
|
||||||
languageMap[i.lang] += i.amount;
|
languageMap[i.name] += i.percentage;
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalLangWeight = Object.values(languageMap).reduce(
|
const totalLangWeight = Object.values(languageMap).reduce(
|
||||||
|
19
src/components/ui/image.tsx
Normal file
19
src/components/ui/image.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
type Props = React.ComponentPropsWithoutRef<"img"> & {
|
||||||
|
fallback?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Image = ({ src, fallback, ...props }: Props) => {
|
||||||
|
const [isError, setError] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
src={isError && (fallback || !src) ? fallback : src}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Image;
|
@ -49,7 +49,8 @@ const RankListItem = ({
|
|||||||
"flex-1 truncate",
|
"flex-1 truncate",
|
||||||
idx === 0 && data.image
|
idx === 0 && data.image
|
||||||
? "flex flex-row items-center gap-x-2"
|
? "flex flex-row items-center gap-x-2"
|
||||||
: ""
|
: "",
|
||||||
|
col.hideOnSmall && "hidden md:flex"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{idx === 0 && data.image ? (
|
{idx === 0 && data.image ? (
|
||||||
@ -78,7 +79,10 @@ export const RankListHeader = ({ columns }: RankListHeaderProps) => {
|
|||||||
<div className="flex flex-row items-center gap-x-2 w-full text-sm bg-base-300 text-base-content/80 p-4 py-2 mt-2 sticky z-[2] top-0 rounded-lg">
|
<div className="flex flex-row items-center gap-x-2 w-full text-sm bg-base-300 text-base-content/80 p-4 py-2 mt-2 sticky z-[2] top-0 rounded-lg">
|
||||||
<div className="w-10" />
|
<div className="w-10" />
|
||||||
{columns.map((col, idx) => (
|
{columns.map((col, idx) => (
|
||||||
<p key={idx} className="flex-1">
|
<p
|
||||||
|
key={idx}
|
||||||
|
className={cn("flex-1", col.hideOnSmall && "hidden md:flex")}
|
||||||
|
>
|
||||||
{col.title}
|
{col.title}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
|
@ -22,7 +22,7 @@ const UserRank = () => {
|
|||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-base-100 mx-4 rounded-lg px-6 py-4 sticky bottom-4 z-[2]">
|
<div className="bg-base-100 mx-4 mt-4 rounded-lg px-6 py-4 sticky bottom-4 z-[2]">
|
||||||
<p>Pengen nama kamu masuk list ini juga?</p>
|
<p>Pengen nama kamu masuk list ini juga?</p>
|
||||||
<p className="inline">Hayuk</p>
|
<p className="inline">Hayuk</p>
|
||||||
<Button size="sm" color="primary" className="mx-2" onClick={onLogin}>
|
<Button size="sm" color="primary" className="mx-2" onClick={onLogin}>
|
||||||
|
@ -3,7 +3,7 @@ import BottomSheet, {
|
|||||||
BottomSheetTitle,
|
BottomSheetTitle,
|
||||||
} from "@client/components/ui/bottom-sheet";
|
} from "@client/components/ui/bottom-sheet";
|
||||||
import { memo, useEffect, useMemo } from "react";
|
import { memo, useEffect, useMemo } from "react";
|
||||||
import { Avatar, Badge, Card, Progress } from "react-daisyui";
|
import { Avatar, Badge, Card, Dropdown, Progress } from "react-daisyui";
|
||||||
import { useGetUserLeaderboard } from "../hooks";
|
import { useGetUserLeaderboard } from "../hooks";
|
||||||
import { dummyAvatar } from "@client/lib/utils";
|
import { dummyAvatar } from "@client/lib/utils";
|
||||||
import { FiGitMerge, FiStar, FiType, FiUsers } from "react-icons/fi";
|
import { FiGitMerge, FiStar, FiType, FiUsers } from "react-icons/fi";
|
||||||
@ -11,6 +11,7 @@ import { FaCode, FaRegStar, FaTrophy } from "react-icons/fa";
|
|||||||
import { LuFolderGit } from "react-icons/lu";
|
import { LuFolderGit } from "react-icons/lu";
|
||||||
import { IoMdGitBranch, IoMdGitCommit } from "react-icons/io";
|
import { IoMdGitBranch, IoMdGitCommit } from "react-icons/io";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { pointWeights } from "../data";
|
||||||
|
|
||||||
const ViewSheet = () => {
|
const ViewSheet = () => {
|
||||||
const { type, id } = useParams();
|
const { type, id } = useParams();
|
||||||
@ -25,8 +26,6 @@ const ViewSheet = () => {
|
|||||||
return data?.repositories.filter((i) => i.isPending).length || 0;
|
return data?.repositories.filter((i) => i.isPending).length || 0;
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
console.log({ pendingRepos, totalRepo });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingRepos) {
|
if (!pendingRepos) {
|
||||||
return;
|
return;
|
||||||
@ -115,13 +114,40 @@ const ViewSheet = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-neutral text-neutral-content px-6 py-3 w-full md:w-auto rounded-lg">
|
<Dropdown className="dropdown-end w-full md:w-auto">
|
||||||
|
<Dropdown.Toggle button={false}>
|
||||||
|
<button className="bg-neutral hover:bg-neutral/80 active:opacity-50 text-neutral-content px-6 py-3 w-full rounded-lg">
|
||||||
<div className="flex flex-row items-center justify-center font-mono gap-2 text-4xl md:text-3xl text-primary">
|
<div className="flex flex-row items-center justify-center font-mono gap-2 text-4xl md:text-3xl text-primary">
|
||||||
<FaTrophy size={24} />
|
<FaTrophy size={24} />
|
||||||
<p>{data?.user.rank}</p>
|
<p>{data?.user.rank}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs">{data?.user.points + " pts"}</p>
|
<p className="text-xs">{data?.user.points + " pts"}</p>
|
||||||
</div>
|
</button>
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu className="card card-compact w-64 p-2 z-10 shadow-lg bg-base-300 text-base-content my-2 text-left">
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Title tag="h3" className="text-base">
|
||||||
|
Perhitungan Point
|
||||||
|
</Card.Title>
|
||||||
|
|
||||||
|
<pre className="font-mono whitespace-break-spaces">
|
||||||
|
{JSON.stringify(pointWeights, null, 2)}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Cek lebih lengkap{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/khairul169/github-leaderboard/blob/main/server/jobs/calculate-user-points.ts"
|
||||||
|
target="_blank"
|
||||||
|
className="link"
|
||||||
|
>
|
||||||
|
disini
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && pendingRepos > 0 && (
|
{data && pendingRepos > 0 && (
|
||||||
@ -202,7 +228,7 @@ const ViewSheet = () => {
|
|||||||
<div className="flex-1 truncate">
|
<div className="flex-1 truncate">
|
||||||
<p className="mt-1 truncate">{item.name}</p>
|
<p className="mt-1 truncate">{item.name}</p>
|
||||||
<p className="text-xs truncate mt-0.5 text-base-content/80">
|
<p className="text-xs truncate mt-0.5 text-base-content/80">
|
||||||
{item.languages?.map((i) => i.lang).join(", ") ||
|
{item.languages?.map((i) => i.name).join(", ") ||
|
||||||
item.language}
|
item.language}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -28,3 +28,16 @@ export type LeaderboardType = {
|
|||||||
value: LeaderboardTypes;
|
value: LeaderboardTypes;
|
||||||
query?: any;
|
query?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const pointWeights = {
|
||||||
|
followers: 20,
|
||||||
|
following: 10,
|
||||||
|
achievements: 100,
|
||||||
|
repositories: 1,
|
||||||
|
contributorsAmount: 25,
|
||||||
|
stars: 10,
|
||||||
|
forks: 10,
|
||||||
|
languagesKnown: 50,
|
||||||
|
commits: 1,
|
||||||
|
lineOfCodes: 0.01,
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user