diff --git a/.eslintignore b/.eslintignore index cc37f2484..2a5c2cbab 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ backend/build -docker \ No newline at end of file +backend/__migration__ +docker diff --git a/backend/__migration__/testActivity.ts b/backend/__migration__/testActivity.ts new file mode 100644 index 000000000..157a8ad4c --- /dev/null +++ b/backend/__migration__/testActivity.ts @@ -0,0 +1,235 @@ +import "dotenv/config"; +import * as DB from "../src/init/db"; +import { Collection, Db } from "mongodb"; +import { DBResult } from "../src/dal/result"; +import readlineSync from "readline-sync"; + +let appRunning = true; +let db: Db | undefined; +let userCollection: Collection; +let resultCollection: Collection; + +const filter = { testActivity: { $exists: false } }; + +process.on("SIGINT", () => { + console.log("\nshutting down..."); + appRunning = false; +}); + +void main(); + +async function main(): Promise { + try { + console.log( + `Connecting to database ${process.env["DB_NAME"]} on ${process.env["DB_URI"]}...` + ); + + //@ts-ignore + if (!readlineSync.keyInYN("Ready to start migration?")) { + appRunning = false; + } + + if (appRunning) { + await DB.connect(); + console.log("Connected to database"); + db = DB.getDb(); + if (db === undefined) { + throw Error("db connection failed"); + } + + await migrate(); + } + + console.log(`\nMigration ${appRunning ? "done" : "aborted"}.`); + } catch (e) { + console.log("error occured:", { e }); + } finally { + await DB.close(); + } +} + +export async function migrate(): Promise { + userCollection = DB.collection("users"); + resultCollection = DB.collection("results"); + + console.log("Creating index on users collection..."); + await userCollection.createIndex({ uid: 1 }, { unique: true }); + await migrateResults(); +} + +async function migrateResults(batchSize = 50): Promise { + const allUsersCount = await userCollection.countDocuments(filter); + if (allUsersCount === 0) { + console.log("No users to migrate."); + return; + } else { + console.log("Users to migrate:", allUsersCount); + } + + console.log(`Migrating ~${allUsersCount} users using batchSize=${batchSize}`); + + let count = 0; + const start = new Date().valueOf(); + let uids: string[] = []; + do { + uids = await getUsersToMigrate(batchSize); + + //migrate + await migrateUsers(uids); + await handleUsersWithNoResults(uids); + + //progress tracker + count += uids.length; + updateProgress(allUsersCount, count, start); + } while (uids.length > 0 && appRunning); + + if (appRunning) updateProgress(100, 100, start); +} + +async function getUsersToMigrate(limit: number): Promise { + return ( + await userCollection + .find(filter, { limit }) + .project({ uid: 1, _id: 0 }) + .toArray() + ).map((it) => it["uid"]); +} + +async function migrateUsers(uids: string[]): Promise { + console.log("migrateUsers:", uids.join(",")); + await resultCollection + .aggregate( + [ + { + $match: { + uid: { $in: uids }, + }, + }, + { + $project: { + _id: 0, + timestamp: -1, + uid: 1, + }, + }, + { + $addFields: { + date: { + $toDate: "$timestamp", + }, + }, + }, + { + $replaceWith: { + uid: "$uid", + year: { + $year: "$date", + }, + day: { + $dayOfYear: "$date", + }, + }, + }, + { + $group: { + _id: { + uid: "$uid", + year: "$year", + day: "$day", + }, + count: { + $sum: 1, + }, + }, + }, + { + $group: { + _id: { + uid: "$_id.uid", + year: "$_id.year", + }, + days: { + $addToSet: { + day: "$_id.day", + tests: "$count", + }, + }, + }, + }, + { + $replaceWith: { + uid: "$_id.uid", + days: { + $function: { + lang: "js", + args: ["$days", "$_id.year"], + body: `function (days, year) { + var max = Math.max( + ...days.map((it) => it.day) + )-1; + var arr = new Array(max).fill(null); + for (day of days) { + arr[day.day-1] = day.tests; + } + let result = {}; + result[year] = arr; + return result; + }`, + }, + }, + }, + }, + { + $group: { + _id: "$uid", + testActivity: { + $mergeObjects: "$days", + }, + }, + }, + { + $addFields: { + uid: "$_id", + }, + }, + { + $project: { + _id: 0, + }, + }, + { + $merge: { + into: "users", + on: "uid", + whenMatched: "merge", + whenNotMatched: "discard", + }, + }, + ], + { allowDiskUse: true } + ) + .toArray(); +} + +async function handleUsersWithNoResults(uids: string[]): Promise { + console.log("handleUsersWithNoResults:", uids.join(",")); + await userCollection.updateMany( + { + $and: [{ uid: { $in: uids } }, filter], + }, + { $set: { testActivity: {} } } + ); +} + +function updateProgress(all: number, current: number, start: number): void { + const percentage = (current / all) * 100; + const timeLeft = Math.round( + (((new Date().valueOf() - start) / percentage) * (100 - percentage)) / 1000 + ); + + process.stdout.clearLine?.(0); + process.stdout.cursorTo?.(0); + process.stdout.write( + `${Math.round(percentage)}% done, estimated time left ${timeLeft} seconds.` + ); +} diff --git a/backend/__tests__/__migration__/testActivity.spec.ts b/backend/__tests__/__migration__/testActivity.spec.ts new file mode 100644 index 000000000..badc6f7f0 --- /dev/null +++ b/backend/__tests__/__migration__/testActivity.spec.ts @@ -0,0 +1,73 @@ +import * as Migration from "../../__migration__/testActivity"; +import * as UserTestData from "../__testData__/users"; +import * as UserDal from "../../src/dal/user"; +import * as ResultDal from "../../src/dal/result"; + +describe("testActivity migration", () => { + it("migrates users without results", async () => { + //given + const user1 = await UserTestData.createUser(); + const user2 = await UserTestData.createUser(); + + //when + await Migration.migrate(); + + //then + const readUser1 = await UserDal.getUser(user1.uid, ""); + expect(readUser1.testActivity).toEqual({}); + + const readUser2 = await UserDal.getUser(user2.uid, ""); + expect(readUser2.testActivity).toEqual({}); + }); + + it("migrates users with results", async () => { + //given + const withResults = await UserTestData.createUserWithoutMigration(); + const withoutResults = await UserTestData.createUserWithoutMigration(); + + const uid = withResults.uid; + + //2023-01-02 + await createResult(uid, 1672621200000); + + //2024-01-01 + await createResult(uid, 1704070800000); + await createResult(uid, 1704070800000 + 3600000); + await createResult(uid, 1704070800000 + 3600000); + + //2024-01-02 + await createResult(uid, 1704157200000); + //2024-01-03 + await createResult(uid, 1704243600000); + + //when + await Migration.migrate(); + + //then + const readWithResults = await UserDal.getUser(withResults.uid, ""); + expect(readWithResults.testActivity).toEqual({ + "2023": [null, 1], + "2024": [3, 1, 1], + }); + + const readWithoutResults = await UserDal.getUser(withoutResults.uid, ""); + expect(readWithoutResults.testActivity).toEqual({}); + }); +}); + +async function createResult(uid: string, timestamp: number): Promise { + await ResultDal.addResult(uid, { + wpm: 0, + rawWpm: 0, + charStats: [1, 2, 3, 4], + acc: 0, + mode: "time", + mode2: "60", + timestamp: timestamp, + testDuration: 1, + consistency: 0, + keyConsistency: 0, + chartData: "toolong", + name: "", + } as unknown as ResultDal.DBResult); +} diff --git a/backend/__tests__/__testData__/users.ts b/backend/__tests__/__testData__/users.ts new file mode 100644 index 000000000..21156d902 --- /dev/null +++ b/backend/__tests__/__testData__/users.ts @@ -0,0 +1,26 @@ +import * as DB from "../../src/init/db"; +import * as UserDAL from "../../src/dal/user"; +import { ObjectId } from "mongodb"; + +export async function createUser( + user?: Partial +): Promise { + const uid = new ObjectId().toHexString(); + await UserDAL.addUser("user" + uid, uid + "@example.com", uid); + await DB.collection("users").updateOne({ uid }, { $set: { ...user } }); + return await UserDAL.getUser(uid, "test"); +} + +export async function createUserWithoutMigration( + user?: Partial +): Promise { + const uid = new ObjectId().toHexString(); + await UserDAL.addUser("user" + uid, uid + "@example.com", uid); + await DB.collection("users").updateOne({ uid }, { $set: { ...user } }); + await DB.collection("users").updateOne( + { uid }, + { $unset: { testActivity: "" } } + ); + + return await UserDAL.getUser(uid, "test"); +} diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index e1d83e187..c26a70725 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -1,6 +1,9 @@ import request from "supertest"; import app from "../../../src/app"; import * as Configuration from "../../../src/init/configuration"; +import { getCurrentTestActivity } from "../../../src/api/controllers/user"; +import * as UserDal from "../../../src/dal/user"; +import _ from "lodash"; const mockApp = request(app); @@ -93,4 +96,128 @@ describe("user controller test", () => { vi.restoreAllMocks(); }); }); + + describe("getTestActivity", () => { + it("should return 503 for non premium users", async () => { + //given + vi.spyOn(UserDal, "getUser").mockResolvedValue({ + testActivity: { "2023": [1, 2, 3], "2024": [4, 5, 6] }, + } as unknown as MonkeyTypes.DBUser); + + //when + const response = await mockApp + .get("/users/testActivity") + .set("authorization", "Uid 123456789") + .send() + .expect(503); + }); + it("should send data for premium users", async () => { + //given + vi.spyOn(UserDal, "getUser").mockResolvedValue({ + testActivity: { "2023": [1, 2, 3], "2024": [4, 5, 6] }, + } as unknown as MonkeyTypes.DBUser); + vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); + await enablePremiumFeatures(true); + + //when + const response = await mockApp + .get("/users/testActivity") + .set("authorization", "Uid 123456789") + .send() + .expect(200); + + //%hen + const result = response.body.data; + expect(result["2023"]).toEqual([1, 2, 3]); + expect(result["2024"]).toEqual([4, 5, 6]); + }); + }); + + describe("getCurrentTestActivity", () => { + beforeAll(() => { + vi.useFakeTimers().setSystemTime(1712102400000); + }); + it("without any data", () => { + expect(getCurrentTestActivity(undefined)).toBeUndefined(); + }); + it("with current year only", () => { + //given + const data = { + "2024": fillYearWithDay(94).map((it) => 2024000 + it), + }; + + //when + const testActivity = getCurrentTestActivity(data); + + //then + expect(testActivity?.lastDay).toEqual(1712102400000); + + const testsByDays = testActivity?.testsByDays ?? []; + expect(testsByDays).toHaveLength(366); + expect(testsByDays[0]).toEqual(undefined); //2023-04-04 + expect(testsByDays[271]).toEqual(undefined); //2023-12-31 + expect(testsByDays[272]).toEqual(2024001); //2024-01-01 + expect(testsByDays[365]).toEqual(2024094); //2024-01 + }); + it("with current and last year", () => { + //given + const data = { + "2023": fillYearWithDay(365).map((it) => 2023000 + it), + "2024": fillYearWithDay(94).map((it) => 2024000 + it), + }; + + //when + const testActivity = getCurrentTestActivity(data); + + //then + expect(testActivity?.lastDay).toEqual(1712102400000); + + const testsByDays = testActivity?.testsByDays ?? []; + expect(testsByDays).toHaveLength(366); + expect(testsByDays[0]).toEqual(2023094); //2023-04-04 + expect(testsByDays[271]).toEqual(2023365); //2023-12-31 + expect(testsByDays[272]).toEqual(2024001); //2024-01-01 + expect(testsByDays[365]).toEqual(2024094); //2024-01 + }); + it("with current and missing days of last year", () => { + //given + const data = { + "2023": fillYearWithDay(20).map((it) => 2023000 + it), + "2024": fillYearWithDay(94).map((it) => 2024000 + it), + }; + + //when + const testActivity = getCurrentTestActivity(data); + + //then + expect(testActivity?.lastDay).toEqual(1712102400000); + + const testsByDays = testActivity?.testsByDays ?? []; + expect(testsByDays).toHaveLength(366); + expect(testsByDays[0]).toEqual(undefined); //2023-04-04 + expect(testsByDays[271]).toEqual(undefined); //2023-12-31 + expect(testsByDays[272]).toEqual(2024001); //2024-01-01 + expect(testsByDays[365]).toEqual(2024094); //2024-01 + }); + }); }); + +function fillYearWithDay(days: number): number[] { + const result: number[] = []; + for (let i = 0; i < days; i++) { + result.push(i + 1); + } + return result; +} + +const configuration = Configuration.getCachedConfiguration(); + +async function enablePremiumFeatures(premium: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { premium: { enabled: premium } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/dal/user.spec.ts b/backend/__tests__/dal/user.spec.ts index ce9d420b8..779e69618 100644 --- a/backend/__tests__/dal/user.spec.ts +++ b/backend/__tests__/dal/user.spec.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import { updateStreak } from "../../src/dal/user"; import * as UserDAL from "../../src/dal/user"; +import * as UserTestData from "../__testData__/users"; const mockPersonalBest = { acc: 1, @@ -784,4 +785,69 @@ describe("UserDal", () => { await expect(streak).toBe(expectedStreak); } }); + describe("incrementTestActivity", () => { + it("ignores user without migration", async () => { + // given + const user = await UserTestData.createUserWithoutMigration(); + + //when + await UserDAL.incrementTestActivity(user, 1712102400000); + + //then + const read = await UserDAL.getUser(user.uid, ""); + expect(read.testActivity).toBeUndefined(); + }); + it("increments for new year", async () => { + // given + const user = await UserTestData.createUser({ + testActivity: { "2023": [null, 1] }, + }); + + //when + await UserDAL.incrementTestActivity(user, 1712102400000); + + //then + const read = (await UserDAL.getUser(user.uid, "")).testActivity || {}; + expect(read).toHaveProperty("2024"); + const year2024 = read["2024"]; + expect(year2024).toHaveLength(94); + //fill previous days with null + expect(year2024.slice(0, 93)).toEqual(new Array(93).fill(null)); + expect(year2024[93]).toEqual(1); + }); + it("increments for existing year", async () => { + // given + const user = await UserTestData.createUser({ + testActivity: { "2024": [null, 5] }, + }); + + //when + await UserDAL.incrementTestActivity(user, 1712102400000); + + //then + const read = (await UserDAL.getUser(user.uid, "")).testActivity || {}; + expect(read).toHaveProperty("2024"); + const year2024 = read["2024"]; + expect(year2024).toHaveLength(94); + + expect(year2024[0]).toBeNull(); + expect(year2024[1]).toEqual(5); + expect(year2024.slice(2, 91)).toEqual(new Array(89).fill(null)); + expect(year2024[93]).toEqual(1); + }); + it("increments for existing day", async () => { + // given + let user = await UserTestData.createUser({ testActivity: {} }); + await UserDAL.incrementTestActivity(user, 1712102400000); + user = await UserDAL.getUser(user.uid, ""); + + //when + await UserDAL.incrementTestActivity(user, 1712102400000); + + //then + const read = (await UserDAL.getUser(user.uid, "")).testActivity || {}; + const year2024 = read["2024"]; + expect(year2024[93]).toEqual(2); + }); + }); }); diff --git a/backend/__tests__/setup-tests.ts b/backend/__tests__/setup-tests.ts index 14f23e5c9..ccf048ef9 100644 --- a/backend/__tests__/setup-tests.ts +++ b/backend/__tests__/setup-tests.ts @@ -9,6 +9,7 @@ vi.mock("../src/init/db", () => ({ getDb: (): Db => db, collection: (name: string): Collection> => db.collection>(name), + close: () => client?.close(), })); vi.mock("../src/utils/logger", () => ({ diff --git a/backend/__tests__/tsconfig.json b/backend/__tests__/tsconfig.json index c407cc74f..0e08eb150 100644 --- a/backend/__tests__/tsconfig.json +++ b/backend/__tests__/tsconfig.json @@ -21,6 +21,7 @@ }, "files": ["../src/types/types.d.ts"], "include": [ + "./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts", "../../shared-types/**/*.d.ts" diff --git a/backend/package-lock.json b/backend/package-lock.json index 23593356a..c214593d8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,11 +9,13 @@ "version": "1.14.3", "license": "GPL-3.0", "dependencies": { + "@date-fns/utc": "1.2.0", "bcrypt": "5.1.1", "bullmq": "1.91.1", "chalk": "4.1.2", "cors": "2.8.5", "cron": "2.3.0", + "date-fns": "3.6.0", "dotenv": "10.0.0", "express": "4.17.3", "express-rate-limit": "6.2.1", @@ -53,6 +55,7 @@ "@types/node-fetch": "2.6.1", "@types/nodemailer": "6.4.7", "@types/object-hash": "2.2.1", + "@types/readline-sync": "1.4.8", "@types/string-similarity": "4.0.0", "@types/supertest": "2.0.12", "@types/swagger-stats": "0.95.4", @@ -61,6 +64,7 @@ "@types/uuid": "8.3.4", "@vitest/coverage-v8": "^1.6.0", "ioredis-mock": "7.4.0", + "readline-sync": "1.4.10", "supertest": "6.2.3", "ts-node-dev": "2.0.0", "typescript": "5.3.3", @@ -858,6 +862,11 @@ "kuler": "^2.0.0" } }, + "node_modules/@date-fns/utc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/utc/-/utc-1.2.0.tgz", + "integrity": "sha512-YLq+crMPJiBmIdkRmv9nZuZy1mVtMlDcUKlg4mvI0UsC/dZeIaGoGB5p/C4FrpeOhZ7zBTK03T58S0DFkRNMnw==" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -2903,6 +2912,12 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, + "node_modules/@types/readline-sync": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/@types/readline-sync/-/readline-sync-1.4.8.tgz", + "integrity": "sha512-BL7xOf0yKLA6baAX6MMOnYkoflUyj/c7y3pqMRfU0va7XlwHAOTOIo4x55P/qLfMsuaYdJJKubToLqRVmRtRZA==", + "dev": true + }, "node_modules/@types/request": { "version": "2.48.12", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz", @@ -4190,6 +4205,15 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "2.6.9", "license": "MIT", diff --git a/backend/package.json b/backend/package.json index 9b344818d..93785b499 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,11 +20,13 @@ "npm": "10.2.4" }, "dependencies": { + "@date-fns/utc": "1.2.0", "bcrypt": "5.1.1", "bullmq": "1.91.1", "chalk": "4.1.2", "cors": "2.8.5", "cron": "2.3.0", + "date-fns": "3.6.0", "dotenv": "10.0.0", "express": "4.17.3", "express-rate-limit": "6.2.1", @@ -64,6 +66,7 @@ "@types/node-fetch": "2.6.1", "@types/nodemailer": "6.4.7", "@types/object-hash": "2.2.1", + "@types/readline-sync": "1.4.8", "@types/string-similarity": "4.0.0", "@types/supertest": "2.0.12", "@types/swagger-stats": "0.95.4", @@ -72,6 +75,7 @@ "@types/uuid": "8.3.4", "@vitest/coverage-v8": "1.6.0", "ioredis-mock": "7.4.0", + "readline-sync": "1.4.10", "supertest": "6.2.3", "ts-node-dev": "2.0.0", "typescript": "5.3.3", diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 820c1112c..e21b959cb 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -1,12 +1,4 @@ import * as ResultDAL from "../../dal/result"; -import { - getUser, - checkIfPb, - checkIfTagPb, - incrementBananas, - updateTypingStats, - recordAutoBanEvent, -} from "../../dal/user"; import * as PublicDAL from "../../dal/public"; import { getCurrentDayTimestamp, @@ -165,8 +157,8 @@ export async function updateTags( result.numbers = false; } - const user = await getUser(uid, "update tags"); - const tagPbs = await checkIfTagPb(uid, user, result); + const user = await UserDAL.getUser(uid, "update tags"); + const tagPbs = await UserDAL.checkIfTagPb(uid, user, result); return new MonkeyResponse("Result tags updated", { tagPbs, }); @@ -177,7 +169,7 @@ export async function addResult( ): Promise { const { uid } = req.ctx.decodedToken; - const user = await getUser(uid, "add result"); + const user = await UserDAL.getUser(uid, "add result"); if (user.needsToChangeName) { throw new MonkeyError( @@ -348,7 +340,7 @@ export async function addResult( //autoban const autoBanConfig = req.ctx.configuration.users.autoBan; if (autoBanConfig.enabled) { - const didUserGetBanned = await recordAutoBanEvent( + const didUserGetBanned = await UserDAL.recordAutoBanEvent( uid, autoBanConfig.maxCount, autoBanConfig.maxHours @@ -425,13 +417,13 @@ export async function addResult( if (!completedEvent.bailedOut) { [isPb, tagPbs] = await Promise.all([ - checkIfPb(uid, user, completedEvent), - checkIfTagPb(uid, user, completedEvent), + UserDAL.checkIfPb(uid, user, completedEvent), + UserDAL.checkIfTagPb(uid, user, completedEvent), ]); } if (completedEvent.mode === "time" && completedEvent.mode2 === "60") { - void incrementBananas(uid, completedEvent.wpm); + void UserDAL.incrementBananas(uid, completedEvent.wpm); if (isPb && user.discordId !== undefined && user.discordId !== "") { void GeorgeQueue.updateDiscordRole(user.discordId, completedEvent.wpm); } @@ -452,7 +444,7 @@ export async function addResult( const afk = completedEvent.afkDuration ?? 0; const totalDurationTypedSeconds = completedEvent.testDuration + completedEvent.incompleteTestSeconds - afk; - void updateTypingStats( + void UserDAL.updateTypingStats( uid, completedEvent.restartCount, totalDurationTypedSeconds @@ -590,10 +582,10 @@ export async function addResult( } const dbresult = buildDbResult(completedEvent, user.name, isPb); - const addedResult = await ResultDAL.addResult(uid, dbresult); await UserDAL.incrementXp(uid, xpGained.xp); + await UserDAL.incrementTestActivity(user, completedEvent.timestamp); if (isPb) { void Logger.logToDb( diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index c7d2d695e..568d5186b 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -30,6 +30,8 @@ import { removeTokensFromCacheByUid, deleteUser as firebaseDeleteUser, } from "../../utils/auth"; +import * as Dates from "date-fns"; +import { UTCDateMini } from "@date-fns/utc"; async function verifyCaptcha(captcha: string): Promise { if (!(await verify(captcha))) { @@ -350,6 +352,7 @@ function getRelevantUserInfo( "lastResultHashes", "note", "ips", + "testActivity", ]); } @@ -416,12 +419,14 @@ export async function getUser( const isPremium = await UserDAL.checkIfUserIsPremium(uid, userInfo); const allTimeLbs = await getAllTimeLbs(uid); + const testActivity = getCurrentTestActivity(userInfo.testActivity); const userData = { ...getRelevantUserInfo(userInfo), inboxUnreadSize: inboxUnreadSize, isPremium, allTimeLbs, + testActivity, }; return new MonkeyResponse("User data retrieved", userData); @@ -973,3 +978,58 @@ async function getAllTimeLbs(uid: string): Promise { }, }; } + +export function getCurrentTestActivity( + testActivity: SharedTypes.CountByYearAndDay | undefined +): SharedTypes.TestActivity | undefined { + const thisYear = Dates.startOfYear(new UTCDateMini()); + const lastYear = Dates.startOfYear(Dates.subYears(thisYear, 1)); + + let thisYearData = testActivity?.[thisYear.getFullYear().toString()]; + let lastYearData = testActivity?.[lastYear.getFullYear().toString()]; + + if (lastYearData === undefined && thisYearData === undefined) + return undefined; + + lastYearData = lastYearData ?? []; + thisYearData = thisYearData ?? []; + + //make sure lastYearData covers the full year + if (lastYearData.length < Dates.getDaysInYear(lastYear)) { + lastYearData.push( + ...new Array(Dates.getDaysInYear(lastYear) - lastYearData.length).fill( + undefined + ) + ); + } + //use enough days of the last year to have 366 days in total + lastYearData = lastYearData.slice(-366 + thisYearData.length); + + const lastDay = Dates.startOfDay( + Dates.addDays(thisYear, thisYearData.length - 1) + ); + + return { + testsByDays: [...lastYearData, ...thisYearData], + lastDay: lastDay.valueOf(), + }; +} + +export async function getTestActivity( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled; + const user = await UserDAL.getUser(uid, "testActivity"); + const userHasPremium = await UserDAL.checkIfUserIsPremium(uid, user); + + if (!premiumFeaturesEnabled) { + throw new MonkeyError(503, "Premium features are disabled"); + } + + if (!userHasPremium) { + throw new MonkeyError(503, "User does not have premium"); + } + + return new MonkeyResponse("Test activity data retrieved", user.testActivity); +} diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index fcc1c4e09..32ba25712 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -659,4 +659,11 @@ router.post( asyncHandler(UserController.revokeAllTokens) ); +router.get( + "/testActivity", + authenticateRequest(), + RateLimit.userTestActivity, + asyncHandler(UserController.getTestActivity) +); + export default router; diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts index b1e28ad26..85128d109 100644 --- a/backend/src/dal/result.ts +++ b/backend/src/dal/result.ts @@ -5,13 +5,9 @@ import * as db from "../init/db"; import { getUser, getTags } from "./user"; -type DBResult = MonkeyTypes.WithObjectId< - SharedTypes.DBResult ->; - export async function addResult( uid: string, - result: DBResult + result: MonkeyTypes.DBResult ): Promise<{ insertedId: ObjectId }> { let user: MonkeyTypes.DBUser | null = null; try { @@ -22,14 +18,18 @@ export async function addResult( if (!user) throw new MonkeyError(404, "User not found", "add result"); if (result.uid === undefined) result.uid = uid; // result.ir = true; - const res = await db.collection("results").insertOne(result); + const res = await db + .collection("results") + .insertOne(result); return { insertedId: res.insertedId, }; } export async function deleteAll(uid: string): Promise { - return await db.collection("results").deleteMany({ uid }); + return await db + .collection("results") + .deleteMany({ uid }); } export async function updateTags( @@ -38,7 +38,7 @@ export async function updateTags( tags: string[] ): Promise { const result = await db - .collection("results") + .collection("results") .findOne({ _id: new ObjectId(resultId), uid }); if (!result) throw new MonkeyError(404, "Result not found"); const userTags = await getTags(uid); @@ -51,13 +51,16 @@ export async function updateTags( throw new MonkeyError(422, "One of the tag id's is not valid"); } return await db - .collection("results") + .collection("results") .updateOne({ _id: new ObjectId(resultId), uid }, { $set: { tags } }); } -export async function getResult(uid: string, id: string): Promise { +export async function getResult( + uid: string, + id: string +): Promise { const result = await db - .collection("results") + .collection("results") .findOne({ _id: new ObjectId(id), uid }); if (!result) throw new MonkeyError(404, "Result not found"); return result; @@ -65,9 +68,9 @@ export async function getResult(uid: string, id: string): Promise { export async function getLastResult( uid: string -): Promise> { +): Promise> { const [lastResult] = await db - .collection("results") + .collection("results") .find({ uid }) .sort({ timestamp: -1 }) .limit(1) @@ -79,8 +82,10 @@ export async function getLastResult( export async function getResultByTimestamp( uid: string, timestamp -): Promise { - return await db.collection("results").findOne({ uid, timestamp }); +): Promise { + return await db + .collection("results") + .findOne({ uid, timestamp }); } type GetResultsOpts = { @@ -92,10 +97,10 @@ type GetResultsOpts = { export async function getResults( uid: string, opts?: GetResultsOpts -): Promise { +): Promise { const { onOrAfterTimestamp, offset, limit } = opts ?? {}; let query = db - .collection("results") + .collection("results") .find({ uid, ...(!_.isNil(onOrAfterTimestamp) && diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index e7d38e767..bb2f50bd9 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -8,6 +8,8 @@ import { Collection, ObjectId, Long, UpdateFilter } from "mongodb"; import Logger from "../utils/logger"; import { flattenObjectDeep, isToday, isYesterday } from "../utils/misc"; import { getCachedConfiguration } from "../init/configuration"; +import { getDayOfYear } from "date-fns"; +import { UTCDate } from "@date-fns/utc"; const SECONDS_PER_HOUR = 3600; @@ -37,6 +39,7 @@ export async function addUser( zen: {}, custom: {}, }, + testActivity: {}, }; const result = await getUsersCollection().updateOne( @@ -602,6 +605,32 @@ export async function incrementXp(uid: string, xp: number): Promise { await getUsersCollection().updateOne({ uid }, { $inc: { xp: new Long(xp) } }); } +export async function incrementTestActivity( + user: MonkeyTypes.DBUser, + timestamp: number +): Promise { + if (user.testActivity === undefined) { + //migration script did not run yet + return; + } + + const date = new UTCDate(timestamp); + const dayOfYear = getDayOfYear(date); + const year = date.getFullYear(); + + if (user.testActivity[year] === undefined) { + await getUsersCollection().updateOne( + { uid: user.uid }, + { $set: { [`testActivity.${date.getFullYear()}`]: [] } } + ); + } + + await getUsersCollection().updateOne( + { uid: user.uid }, + { $inc: { [`testActivity.${date.getFullYear()}.${dayOfYear - 1}`]: 1 } } + ); +} + export function themeDoesNotExist(customThemes, id): boolean { return ( (customThemes ?? []).filter((t) => t._id.toString() === id).length === 0 @@ -1052,7 +1081,7 @@ export async function checkIfUserIsPremium( ): Promise { const premiumFeaturesEnabled = (await getCachedConfiguration(true)).users .premium.enabled; - if (!premiumFeaturesEnabled) { + if (premiumFeaturesEnabled !== true) { return false; } const user = userInfoOverride ?? (await getUser(uid, "checkIfUserIsPremium")); diff --git a/backend/src/init/db.ts b/backend/src/init/db.ts index 3de0f8c3c..ca86b0c70 100644 --- a/backend/src/init/db.ts +++ b/backend/src/init/db.ts @@ -10,6 +10,7 @@ import MonkeyError from "../utils/error"; import Logger from "../utils/logger"; let db: Db; +let mongoClient: MongoClient; export async function connect(): Promise { const { @@ -48,7 +49,7 @@ export async function connect(): Promise { authSource: DB_AUTH_SOURCE, }; - const mongoClient = new MongoClient( + mongoClient = new MongoClient( (DB_URI as string) ?? global.__MONGO_URI__, // Set in tests only connectionOptions ); @@ -74,3 +75,6 @@ export function collection(collectionName: string): Collection> { return db.collection>(collectionName); } +export async function close(): Promise { + await mongoClient?.close(); +} diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index 714aa68c7..1103d6f18 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -519,6 +519,13 @@ export const userMailUpdate = rateLimit({ handler: customHandler, }); +export const userTestActivity = rateLimit({ + windowMs: ONE_HOUR_MS, + max: 60 * REQUEST_MULTIPLIER, + keyGenerator: getKeyWithUid, + handler: customHandler, +}); + // ApeKeys Routing export const apeKeysGet = rateLimit({ windowMs: ONE_HOUR_MS, diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 0944ee5df..f767247bc 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -20,7 +20,12 @@ declare namespace MonkeyTypes { type DBUser = Omit< SharedTypes.User, - "resultFilterPresets" | "tags" | "customThemes" | "isPremium" | "allTimeLbs" + | "resultFilterPresets" + | "tags" + | "customThemes" + | "isPremium" + | "allTimeLbs" + | "testActivity" > & { _id: ObjectId; resultFilterPresets?: WithObjectIdArray; @@ -34,6 +39,7 @@ declare namespace MonkeyTypes { lastNameChange?: number; canManageApeKeys?: boolean; bananas?: number; + testActivity?: SharedTypes.CountByYearAndDay; }; type DBCustomTheme = WithObjectId; @@ -100,4 +106,8 @@ declare namespace MonkeyTypes { frontendForcedConfig?: Record; frontendFunctions?: string[]; }; + + type DBResult = MonkeyTypes.WithObjectId< + SharedTypes.DBResult + >; } diff --git a/frontend/__tests__/elements/test-activity-calendar.spec.ts b/frontend/__tests__/elements/test-activity-calendar.spec.ts new file mode 100644 index 000000000..1a8e9ac24 --- /dev/null +++ b/frontend/__tests__/elements/test-activity-calendar.spec.ts @@ -0,0 +1,761 @@ +import { + TestActivityCalendar, + ModifiableTestActivityCalendar, +} from "../../src/ts/elements/test-activity-calendar"; +import * as Dates from "date-fns"; +import { MatcherResult } from "../vitest"; +import { UTCDateMini } from "@date-fns/utc/date/mini"; + +describe("test-activity-calendar.ts", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + describe("TestActivityCalendar", () => { + describe("getMonths", () => { + it("for lastDay in april", () => { + //set today + vi.setSystemTime(getDate("2024-04-10")); + const calendar = new TestActivityCalendar([], getDate("2024-04-10")); + + expect(calendar.getMonths()).toEqual([ + { + text: "apr", + weeks: 4, + }, + { + text: "may", + weeks: 4, + }, + { + text: "jun", + weeks: 4, + }, + { + text: "jul", + weeks: 5, + }, + { + text: "aug", + weeks: 4, + }, + { + text: "sep", + weeks: 4, + }, + { + text: "oct", + weeks: 5, + }, + { + text: "nov", + weeks: 4, + }, + { + text: "dec", + weeks: 5, + }, + { + text: "jan", + weeks: 4, + }, + { + text: "feb", + weeks: 4, + }, + { + text: "mar", + weeks: 5, + }, + ]); + }); + + it("for lastDay in april, not test for the current week", () => { + //set today + vi.setSystemTime(getDate("2024-04-24")); + const calendar = new TestActivityCalendar([], getDate("2024-04-10")); + + expect(calendar.getMonths()).toEqual([ + { + text: "may", + weeks: 4, + }, + { + text: "jun", + weeks: 4, + }, + { + text: "jul", + weeks: 5, + }, + { + text: "aug", + weeks: 4, + }, + { + text: "sep", + weeks: 4, + }, + { + text: "oct", + weeks: 5, + }, + { + text: "nov", + weeks: 4, + }, + { + text: "dec", + weeks: 5, + }, + { + text: "jan", + weeks: 4, + }, + { + text: "feb", + weeks: 4, + }, + { + text: "mar", + weeks: 5, + }, + { + text: "apr", + weeks: 3, + }, + ]); + }); + it("for lastDay in january", () => { + //set today + vi.setSystemTime(getDate("2023-01-01")); + const calendar = new TestActivityCalendar([], getDate("2023-01-01")); + + expect(calendar.getMonths()).toEqual([ + { + text: "jan", + weeks: 5, + }, + { + text: "feb", + weeks: 4, + }, + { + text: "mar", + weeks: 4, + }, + { + text: "apr", + weeks: 4, + }, + { + text: "may", + weeks: 5, + }, + { + text: "jun", + weeks: 4, + }, + { + text: "jul", + weeks: 5, + }, + { + text: "aug", + weeks: 4, + }, + { + text: "sep", + weeks: 4, + }, + { + text: "oct", + weeks: 5, + }, + { + text: "nov", + weeks: 4, + }, + { + text: "dec", + weeks: 4, + }, + ]); + }); + it("for lastDay and full year starting with sunday", () => { + const calendar = new TestActivityCalendar( + [], + getDate("2023-05-10"), + true + ); + + expect(calendar.getMonths()).toEqual([ + { + text: "jan", + weeks: 5, + }, + { + text: "feb", + weeks: 4, + }, + { + text: "mar", + weeks: 4, + }, + { + text: "apr", + weeks: 5, + }, + { + text: "may", + weeks: 4, + }, + { + text: "jun", + weeks: 4, + }, + { + text: "jul", + weeks: 5, + }, + { + text: "aug", + weeks: 4, + }, + { + text: "sep", + weeks: 4, + }, + { + text: "oct", + weeks: 5, + }, + { + text: "nov", + weeks: 4, + }, + { + text: "dec", + weeks: 5, + }, + ]); + }); + it("for lastDay and full year starting with monday", () => { + const calendar = new TestActivityCalendar( + [], + getDate("2024-05-10"), + true + ); + + expect(calendar.getMonths()).toEqual([ + { + text: "jan", + weeks: 5, + }, + { + text: "feb", + weeks: 4, + }, + { + text: "mar", + weeks: 5, + }, + { + text: "apr", + weeks: 4, + }, + { + text: "may", + weeks: 4, + }, + { + text: "jun", + weeks: 5, + }, + { + text: "jul", + weeks: 4, + }, + { + text: "aug", + weeks: 4, + }, + { + text: "sep", + weeks: 5, + }, + { + text: "oct", + weeks: 4, + }, + { + text: "nov", + weeks: 4, + }, + { + text: "dec", + weeks: 5, + }, + ]); + }); + }); + + describe("getDays", () => { + it("for lastDay in april", () => { + const data = getData("2023-04-10", "2024-04-10"); + vi.setSystemTime(getDate("2024-04-30")); + const calendar = new TestActivityCalendar(data, getDate("2024-04-10")); + const days = calendar.getDays(); + + expect(days).toHaveLength(1 + 366 + 4); //one filler on the start, 366 days in leap year, four fillers at the end + + //may 23 starts with a monday, we use sunday from last month + expect(days[0]).toBeDate("2023-04-30").toHaveTests(120); + + expect(days[1]).toBeDate("2023-05-01").toHaveTests(121).toHaveLevel(2); + + expect(days[245]) + .toBeDate("2023-12-31") + .toHaveTests(365) + .toHaveLevel(4); + + expect(days[246]).toBeDate("2024-01-01").toHaveTests(1).toHaveLevel(1); + + expect(days[305]).toBeDate("2024-02-29").toHaveTests(60).toHaveLevel(1); + + expect(days[346]) + .toBeDate("2024-04-10") + .toHaveTests(101) + .toHaveLevel(2); + + //days from April 11th to April 30th + for (let day = 347; day <= 366; day++) { + expect(days[day]).toHaveLevel(0); + } + }); + + it("for full leap year", () => { + //GIVEN + const data = getData("2024-01-01", "2024-12-31"); + vi.setSystemTime(getDate("2024-12-31")); + const calendar = new TestActivityCalendar(data, getDate("2024-12-31")); + + //WHEN + const days = calendar.getDays(); + + //THEN + expect(days).toHaveLength(1 + 366 + 4); //one filler on the start, 366 days in leap year, four fillers at the end + + //2024 starts with a monday + expect(days[0]).toBeDate("2023-12-31"); + + expect(days[1]).toBeDate("2024-01-01").toHaveTests(1).toHaveLevel(1); + expect(days[60]).toBeDate("2024-02-29").toHaveTests(60).toHaveLevel(1); + expect(days[366]) + .toBeDate("2024-12-31") + .toHaveTests(366) + .toHaveLevel(4); + + //2024 ends with a thuesday + for (let day = 367; day < 1 + 366 + 4; day++) { + expect(days[day]).toBeFiller(); + } + }); + + it("for full year", () => { + //GIVEN + const data = getData("2022-11-30", "2023-12-31"); + vi.setSystemTime(getDate("2023-12-31")); + const calendar = new TestActivityCalendar( + data, + new Date("2023-12-31T23:59:59Z") + ); //2023-12-31T23:59:59Z + + //WHEN + const days = calendar.getDays(); + + //THEN + expect(days).toHaveLength(0 + 365 + 6); //no filler on the start, 365 days in leap year, six fillers at the end + + //2023 starts with a sunday + expect(days[0]).toBeDate("2023-01-01").toHaveTests(1).toHaveLevel(1); + + expect(days[1]).toBeDate("2023-01-02").toHaveTests(2).toHaveLevel(1); + expect(days[364]) + .toBeDate("2023-12-31") + .toHaveTests(365) + .toHaveLevel(4); + + //2023 ends with a sunday + for (let day = 365; day < 365 + 6; day++) { + expect(days[day]).toBeFiller(); + } + + //december 24 ends with a tuesday + expect(days[367]).toBeFiller(); + expect(days[368]).toBeFiller(); + expect(days[369]).toBeFiller(); + expect(days[370]).toBeFiller(); + }); + + it("ignores data before calendar range", () => { + //GIVEN + const data = getData("2023-03-28", "2024-04-10"); //extra data in front of the calendar + vi.setSystemTime(getDate("2024-04-30")); + const calendar = new TestActivityCalendar(data, getDate("2024-04-10")); + + //WHEN + const days = calendar.getDays(); + + //THEN + expect(days).toHaveLength(1 + 366 + 4); //one filler on the start, 366 days in leap year, four fillers at the end + + //may 23 starts with a monday, so we skip one day + expect(days[0]).toBeDate("2023-04-30").toHaveTests(120); + + expect(days[1]).toBeDate("2023-05-01").toHaveTests(121).toHaveLevel(2); + expect(days[346]) + .toBeDate("2024-04-10") + .toHaveTests(101) + .toHaveLevel(2); + }); + + it("handles missing data in calendar range", () => { + //GIVEN + const data = getData("2024-04-01", "2024-04-10"); + vi.setSystemTime(getDate("2024-04-30")); + const calendar = new TestActivityCalendar(data, getDate("2024-04-10")); + + //WHEN + const days = calendar.getDays(); + + //THEN + expect(days).toHaveLength(1 + 366 + 4); //one filler on the start, 366 days in leap year, four fillers at the end + + expect(days[0]).toBeDate("2023-04-30"); + for (let day = 1; day <= 336; day++) { + expect(days[day]).toHaveLevel(0); + } + + expect(days[337]).toBeDate("2024-04-01").toHaveTests(92).toHaveLevel(2); + expect(days[346]) + .toBeDate("2024-04-10") + .toHaveTests(101) + .toHaveLevel(3); + + for (let day = 347; day <= 366; day++) { + expect(days[day]).toHaveLevel(0); + } + }); + + it("for lastDay in february", () => { + //GIVEN + const data = getData("2022-02-10", "2023-02-10"); + vi.setSystemTime(getDate("2023-02-28")); + const calendar = new TestActivityCalendar(data, getDate("2023-02-10")); + + //WHEN + const days = calendar.getDays(); + + //THEN + expect(days).toHaveLength(2 + 365 + 4); //two filler on the start, 365 days in the year, four fillers at the end + + //march 22 starts with a tuesday, two days from february + expect(days[0]).toBeDate("2022-02-27").toHaveTests(58); + expect(days[1]).toBeDate("2022-02-28").toHaveTests(59); + + expect(days[2]).toBeDate("2022-03-01").toHaveTests(60).toHaveLevel(1); + expect(days[307]) + .toBeDate("2022-12-31") + .toHaveTests(365) + .toHaveLevel(4); + expect(days[308]).toBeDate("2023-01-01").toHaveTests(1).toHaveLevel(1); + expect(days[348]).toBeDate("2023-02-10").toHaveTests(41).toHaveLevel(1); + + //days from 11th till 28 Februar + for (let day = 349; day <= 365; day++) { + expect(days[day]).toHaveLevel(0); + } + //februar 23 ends with tuesday, add four fillers + for (let day = 367; day <= 370; day++) { + expect(days[day]).toBeFiller(); + } + }); + + it("current day mid of month", () => { + //GIVEN + const data = getData("2022-02-10", "2023-02-10"); + vi.setSystemTime(getDate("2023-02-12")); + const calendar = new TestActivityCalendar(data, getDate("2023-02-10")); + + //WHEN + const days = calendar.getDays(); + + //THEN + expect(days).toHaveLength(2 + 365 + 4); //two filler on the start, 365 days in the year, four fillers at the end + + expect(days[0]).toBeDate("2022-02-13").toHaveTests(44); + expect(days[1]).toBeDate("2022-02-14").toHaveTests(45); + + expect(days[16]).toBeDate("2022-03-01").toHaveTests(60).toHaveLevel(1); + expect(days[321]) + .toBeDate("2022-12-31") + .toHaveTests(365) + .toHaveLevel(4); + expect(days[322]).toBeDate("2023-01-01").toHaveTests(1).toHaveLevel(1); + expect(days[364]).toBeDate("2023-02-12").toHaveLevel("0"); + + //fillers + for (let day = 365; day <= 370; day++) { + expect(days[day]).toBeFiller(); + } + }); + + it("for lastDay in february full year", () => { + //GIVEN + const data = getData("2023-02-10", "2024-02-10"); + const calendar = new TestActivityCalendar( + data, + getDate("2024-02-10"), + true + ); + + //WHEN + const days = calendar.getDays(); + + //THEN + //january 24 starts with a monday, skip one day + expect(days[0]).toBeFiller(); + + expect(days[1]).toBeDate("2024-01-01").toHaveTests(1).toHaveLevel(1); + expect(days[41]).toBeDate("2024-02-10").toHaveTests(41).toHaveLevel(4); + + //days from 11th february to 31th december + for (let day = 42; day <= 366; day++) { + expect(days[day]).toHaveLevel(0); + } + //december 24 ends with a tuesday + expect(days[367]).toBeFiller(); + expect(days[368]).toBeFiller(); + expect(days[369]).toBeFiller(); + expect(days[370]).toBeFiller(); + }); + }); + }); + describe("ModifiableTestActivityCalendar", () => { + describe("increment", () => { + it("increments on lastDay", () => { + //GIVEN + const lastDate = getDate("2024-04-10"); + vi.setSystemTime(getDate("2024-04-30")); + const calendar = new ModifiableTestActivityCalendar( + [1, 2, 3], + lastDate + ); + + //WHEN + calendar.increment(lastDate); + + //THEN + const days = calendar.getDays(); + + expect(days[343]).toHaveLevel(0); + expect(days[344]).toBeDate("2024-04-08").toHaveTests(1); + expect(days[345]).toBeDate("2024-04-09").toHaveTests(2); + expect(days[346]).toBeDate("2024-04-10").toHaveTests(4); + expect(days[347]).toHaveLevel(0); + }); + it("increments after lastDay", () => { + //GIVEN + const lastDate = getDate("2024-04-10"); + vi.setSystemTime(getDate("2024-04-10")); + const calendar = new ModifiableTestActivityCalendar( + [1, 2, 3], + lastDate + ); + + //WHEN + vi.setSystemTime(getDate("2024-04-12")); + calendar.increment(getDate("2024-04-12")); + + //THEN + let days = calendar.getDays(); + expect(days[364]).toHaveLevel(0); + expect(days[365]).toBeDate("2024-04-08").toHaveTests(1); + expect(days[366]).toBeDate("2024-04-09").toHaveTests(2); + expect(days[367]).toBeDate("2024-04-10").toHaveTests(3); + expect(days[368]).toHaveLevel(0); + expect(days[369]).toBeDate("2024-04-12").toHaveTests(1); + expect(days[370]).toBeFiller(); + + //WHEN + calendar.increment(getDate("2024-04-12")); + + //THEN + days = calendar.getDays(); + + expect(days[364]).toHaveLevel(0); + expect(days[365]).toBeDate("2024-04-08").toHaveTests(1); + expect(days[366]).toBeDate("2024-04-09").toHaveTests(2); + expect(days[367]).toBeDate("2024-04-10").toHaveTests(3); + expect(days[368]).toHaveLevel(0); + expect(days[369]).toBeDate("2024-04-12").toHaveTests(2); + expect(days[370]).toBeFiller(); + }); + + it("increments after two months", () => { + //GIVEN + vi.setSystemTime(getDate("2024-04-10")); + const calendar = new ModifiableTestActivityCalendar( + [1, 2, 3], + getDate("2024-04-10") + ); + + //WHEN + vi.setSystemTime(getDate("2024-06-12")); + calendar.increment(getDate("2024-06-12")); + + //THEN + const days = calendar.getDays(); + expect(days[301]).toHaveLevel(0); + expect(days[302]).toBeDate("2024-04-08").toHaveTests(1); + expect(days[303]).toBeDate("2024-04-09").toHaveTests(2); + expect(days[304]).toBeDate("2024-04-10").toHaveTests(3); + expect(days[305]).toHaveLevel(0); + + expect(days[366]).toHaveLevel(0); + expect(days[367]).toBeDate("2024-06-12").toHaveTests(1); + expect(days[368]).toBeFiller; + }); + it("increments in new year", () => { + //GIVEN + vi.setSystemTime(getDate("2024-12-24")); + const calendar = new ModifiableTestActivityCalendar( + getData("2023-12-20", "2024-12-24"), + getDate("2024-12-24") + ); + + //WHEN + vi.setSystemTime(getDate("2025-01-02")); + calendar.increment(getDate("2025-01-02")); + + //THEN + const days = calendar.getDays(); + expect(days[359]).toBeDate("2024-12-24").toHaveTests(359); + for (let day = 360; day <= 367; day++) { + expect(days[day]).toHaveLevel(0); + } + expect(days[368]).toBeDate("2025-01-02").toHaveTests(1); + expect(days[369]).toBeFiller(); + }); + it("fails increment in the past", () => { + //GIVEN + const calendar = new ModifiableTestActivityCalendar( + [1, 2, 3], + getDate("2024-04-10") + ); + + //WHEN + expect(() => calendar.increment(getDate("2024-04-09"))).toThrowError( + new Error("cannot alter data in the past.") + ); + }); + }); + }); + describe("getFullYearCalendar", () => { + it("gets calendar", () => { + //GIVEN + const lastDate = getDate("2024-01-02"); + const calendar = new ModifiableTestActivityCalendar( + [1, 2, 3, 4], + lastDate + ); + + //WHEN + const fullYear = calendar.getFullYearCalendar(); + + //THEN + const days = fullYear.getDays(); + + //2024 starts with a monday + expect(days).toHaveLength(1 + 366 + 4); + expect(days[0]).toBeFiller(); + expect(days[1]).toBeDate("2024-01-01").toHaveTests(3); + expect(days[2]).toBeDate("2024-01-02").toHaveTests(4); + + for (let day = 3; day <= 366; day++) { + expect(days[day]).toHaveLevel(0); + } + expect(days[367]).toBeFiller(); + expect(days[368]).toBeFiller(); + expect(days[369]).toBeFiller(); + expect(days[370]).toBeFiller(); + }); + }); +}); + +function getDate(date: string): Date { + return new UTCDateMini(Dates.parseISO(date + "T00:00:00Z")); +} + +function getData(from: string, to: string): number[] { + const start = getDate(from); + const end = getDate(to); + + return Dates.eachDayOfInterval({ start, end }).map((it) => + Dates.getDayOfYear(it) + ); +} + +expect.extend({ + toBeDate( + received: MonkeyTypes.TestActivityDay, + expected: string + ): MatcherResult { + const expectedDate = Dates.format(getDate(expected), "EEEE dd MMM yyyy"); + const actual = received.label?.substring(received.label.indexOf("on") + 3); + + return { + pass: actual === expectedDate, + message: () => `Date ${actual} is not ${expectedDate}`, + actual: actual, + expected: expectedDate, + }; + }, + toHaveTests( + received: MonkeyTypes.TestActivityDay, + expected: number + ): MatcherResult { + const expectedLabel = `${expected} ${expected == 1 ? "test" : "tests"}`; + const actual = received.label?.substring(0, received.label.indexOf(" on")); + + return { + pass: actual == expectedLabel, + message: () => `Tests ${actual} is not ${expectedLabel}`, + actual: actual, + expected: expectedLabel, + }; + }, + toHaveLevel( + received: MonkeyTypes.TestActivityDay, + expected: string | number + ): MatcherResult { + return { + pass: received.level === expected.toString(), + message: () => `Level ${received.level} is not ${expected}`, + actual: received.level, + expected: expected, + }; + }, + + toBeFiller(received: MonkeyTypes.TestActivityDay): MatcherResult { + return { + pass: received.level === "filler", + message: () => `Is not a filler.`, + actual: received.level, + expected: "filler", + }; + }, +}); diff --git a/frontend/__tests__/tsconfig.json b/frontend/__tests__/tsconfig.json index 75effbd27..55fb82015 100644 --- a/frontend/__tests__/tsconfig.json +++ b/frontend/__tests__/tsconfig.json @@ -19,7 +19,7 @@ "ts-node": { "files": true }, - "files": ["../src/ts/types/types.d.ts"], + "files": ["../src/ts/types/types.d.ts", "vitest.d.ts"], "include": [ "./**/*.spec.ts", "./setup-tests.ts", diff --git a/frontend/__tests__/vitest.d.ts b/frontend/__tests__/vitest.d.ts new file mode 100644 index 000000000..afee5606d --- /dev/null +++ b/frontend/__tests__/vitest.d.ts @@ -0,0 +1,20 @@ +import type { Assertion, AsymmetricMatchersContaining } from "vitest"; + +interface ActivityDayMatchers { + toBeDate: (date: string) => ActivityDayMatchers; + toHaveTests: (tests: number) => ActivityDayMatchers; + toHaveLevel: (level?: string | number) => ActivityDayMatchers; + toBeFiller: () => ActivityDayMatchers; +} + +declare module "vitest" { + interface Assertion extends ActivityDayMatchers {} + interface AsymmetricMatchersContaining extends ActivityDayMatchers {} +} + +interface MatcherResult { + pass: boolean; + message: () => string; + actual?: unknown; + expected?: unknown; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84d7f28ef..0c62aa883 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,7 @@ "name": "monkeytype-frontend", "license": "GPL-3.0", "dependencies": { + "@date-fns/utc": "1.2.0", "axios": "1.6.4", "canvas-confetti": "1.5.1", "chart.js": "3.7.1", @@ -15,7 +16,7 @@ "chartjs-plugin-trendline": "1.0.2", "color-blend": "4.0.0", "damerau-levenshtein": "1.0.8", - "date-fns": "2.28.0", + "date-fns": "3.6.0", "firebase": "10.8.0", "hangul-js": "0.2.6", "howler": "2.2.3", @@ -39,6 +40,10 @@ "@types/node": "18.19.1", "@types/object-hash": "2.2.1", "@types/throttle-debounce": "2.1.0", + + + + "@vitest/coverage-v8": "^1.6.0", "ajv": "8.12.0", "autoprefixer": "10.4.14", @@ -1910,11 +1915,19 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/utc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/utc/-/utc-1.2.0.tgz", + "integrity": "sha512-YLq+crMPJiBmIdkRmv9nZuZy1mVtMlDcUKlg4mvI0UsC/dZeIaGoGB5p/C4FrpeOhZ7zBTK03T58S0DFkRNMnw==" + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true + + + }, "node_modules/@dependents/detective-less": { "version": "3.0.2", @@ -4996,15 +5009,12 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" }, "node_modules/date-fns": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", - "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==", - "engines": { - "node": ">=0.11" - }, + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/debug": { diff --git a/frontend/package.json b/frontend/package.json index 27b1cdf7b..3f3308441 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "audit": "vite-bundle-visualizer", "dep-graph": "madge -c -i \"dep-graph.png\" ./src/ts", "build": "npm run madge && vite build", - "madge": " madge --circular --extensions ts ./", + "madge": " madge --circular --extensions ts ./src", "live": "npm run build && vite preview --port 3000", "dev": "vite dev", "deploy-live": "npm run validate-json && npm run build && firebase deploy -P live --only hosting", @@ -67,7 +67,8 @@ "chartjs-plugin-trendline": "1.0.2", "color-blend": "4.0.0", "damerau-levenshtein": "1.0.8", - "date-fns": "2.28.0", + "date-fns": "3.6.0", + "@date-fns/utc": "1.2.0", "firebase": "10.8.0", "hangul-js": "0.2.6", "howler": "2.2.3", diff --git a/frontend/src/html/pages/account.html b/frontend/src/html/pages/account.html index 431724b2c..718d163a8 100644 --- a/frontend/src/html/pages/account.html +++ b/frontend/src/html/pages/account.html @@ -300,6 +300,45 @@ + +