mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
feat(account page): add test activity graph (fehmer, Singh233) (#5309)
* wip * wip frontend * cleanup * wip * refactoring * wip * first working version * wip * update calendar with new result * add migration script * dates are hard * fix naming inconsistencies * requested changes on migration * timezones * update date-fns, use date-fns/utc * resolve cyclic dependency by extracting test activity calender into new file * cleanup * fix increment * fix * tests * test coverage * test migration * migration more logging * migration add unique index on uid if missing * update legend styling * 53 columns * wip * move dropdown and legend to the top add dropdown border yeet hotpink invisible filler boxes remove year from month format * responsive update * lowercase months * handle current year, fix tests * handle year change * make days square again * handle newly created users correctly * move css * add wrapper for easier styling rework some font sizes/widths reorder styles * media queries * align * rework styling once more * dont commit debug * add days full to fill the space a bit * show partial months * hover on 0 tests * start dynamic calendar on sunday * no activity * hover * remove label on fillers * remove label on fillers * fix months, update tests for months * adjust tests to new requirements * cleanup * fix migration * impr(commandline): add "add/remove quote to favorites" commands closes #5368 * chore: remove daily lb which is no longer in the backend * fix: dropdown element flashing for couple frames on page load * feat(language): add japanese romaji 1k (nthngnssmnnglss) * fix: optional chaining !nuf * fix(words generator): infinite custom text tests not working correctly * chore: missing languages in list and group files * fix(language): remove duplicates !nuf * add readline sync to confirm * gh action complaints * unnecessary check * premium only * add years to drop down only if premium * Update setup-tests.ts * test fix * cleanup --------- Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
parent
42ddc256bd
commit
59615fb02c
|
@ -1,2 +1,3 @@
|
|||
backend/build
|
||||
docker
|
||||
backend/__migration__
|
||||
docker
|
||||
|
|
235
backend/__migration__/testActivity.ts
Normal file
235
backend/__migration__/testActivity.ts
Normal file
|
@ -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<MonkeyTypes.DBUser>;
|
||||
let resultCollection: Collection<DBResult>;
|
||||
|
||||
const filter = { testActivity: { $exists: false } };
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
console.log("\nshutting down...");
|
||||
appRunning = false;
|
||||
});
|
||||
|
||||
void main();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
return (
|
||||
await userCollection
|
||||
.find(filter, { limit })
|
||||
.project({ uid: 1, _id: 0 })
|
||||
.toArray()
|
||||
).map((it) => it["uid"]);
|
||||
}
|
||||
|
||||
async function migrateUsers(uids: string[]): Promise<void> {
|
||||
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<void> {
|
||||
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.`
|
||||
);
|
||||
}
|
73
backend/__tests__/__migration__/testActivity.spec.ts
Normal file
73
backend/__tests__/__migration__/testActivity.spec.ts
Normal file
|
@ -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<void> {
|
||||
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);
|
||||
}
|
26
backend/__tests__/__testData__/users.ts
Normal file
26
backend/__tests__/__testData__/users.ts
Normal file
|
@ -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<MonkeyTypes.DBUser>
|
||||
): Promise<MonkeyTypes.DBUser> {
|
||||
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<MonkeyTypes.DBUser>
|
||||
): Promise<MonkeyTypes.DBUser> {
|
||||
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");
|
||||
}
|
|
@ -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<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
users: { premium: { enabled: premium } },
|
||||
});
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,7 @@ vi.mock("../src/init/db", () => ({
|
|||
getDb: (): Db => db,
|
||||
collection: <T>(name: string): Collection<WithId<T>> =>
|
||||
db.collection<WithId<T>>(name),
|
||||
close: () => client?.close(),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/logger", () => ({
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
},
|
||||
"files": ["../src/types/types.d.ts"],
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.spec.ts",
|
||||
"./setup-tests.ts",
|
||||
"../../shared-types/**/*.d.ts"
|
||||
|
|
24
backend/package-lock.json
generated
24
backend/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<MonkeyResponse> {
|
||||
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(
|
||||
|
|
|
@ -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<void> {
|
||||
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<SharedTypes.AllTimeLbs> {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
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<MonkeyResponse> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -659,4 +659,11 @@ router.post(
|
|||
asyncHandler(UserController.revokeAllTokens)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/testActivity",
|
||||
authenticateRequest(),
|
||||
RateLimit.userTestActivity,
|
||||
asyncHandler(UserController.getTestActivity)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -5,13 +5,9 @@ import * as db from "../init/db";
|
|||
|
||||
import { getUser, getTags } from "./user";
|
||||
|
||||
type DBResult = MonkeyTypes.WithObjectId<
|
||||
SharedTypes.DBResult<SharedTypes.Config.Mode>
|
||||
>;
|
||||
|
||||
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<DBResult>("results").insertOne(result);
|
||||
const res = await db
|
||||
.collection<MonkeyTypes.DBResult>("results")
|
||||
.insertOne(result);
|
||||
return {
|
||||
insertedId: res.insertedId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteAll(uid: string): Promise<DeleteResult> {
|
||||
return await db.collection<DBResult>("results").deleteMany({ uid });
|
||||
return await db
|
||||
.collection<MonkeyTypes.DBResult>("results")
|
||||
.deleteMany({ uid });
|
||||
}
|
||||
|
||||
export async function updateTags(
|
||||
|
@ -38,7 +38,7 @@ export async function updateTags(
|
|||
tags: string[]
|
||||
): Promise<UpdateResult> {
|
||||
const result = await db
|
||||
.collection<DBResult>("results")
|
||||
.collection<MonkeyTypes.DBResult>("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<DBResult>("results")
|
||||
.collection<MonkeyTypes.DBResult>("results")
|
||||
.updateOne({ _id: new ObjectId(resultId), uid }, { $set: { tags } });
|
||||
}
|
||||
|
||||
export async function getResult(uid: string, id: string): Promise<DBResult> {
|
||||
export async function getResult(
|
||||
uid: string,
|
||||
id: string
|
||||
): Promise<MonkeyTypes.DBResult> {
|
||||
const result = await db
|
||||
.collection<DBResult>("results")
|
||||
.collection<MonkeyTypes.DBResult>("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<DBResult> {
|
|||
|
||||
export async function getLastResult(
|
||||
uid: string
|
||||
): Promise<Omit<DBResult, "uid">> {
|
||||
): Promise<Omit<MonkeyTypes.DBResult, "uid">> {
|
||||
const [lastResult] = await db
|
||||
.collection<DBResult>("results")
|
||||
.collection<MonkeyTypes.DBResult>("results")
|
||||
.find({ uid })
|
||||
.sort({ timestamp: -1 })
|
||||
.limit(1)
|
||||
|
@ -79,8 +82,10 @@ export async function getLastResult(
|
|||
export async function getResultByTimestamp(
|
||||
uid: string,
|
||||
timestamp
|
||||
): Promise<DBResult | null> {
|
||||
return await db.collection<DBResult>("results").findOne({ uid, timestamp });
|
||||
): Promise<MonkeyTypes.DBResult | null> {
|
||||
return await db
|
||||
.collection<MonkeyTypes.DBResult>("results")
|
||||
.findOne({ uid, timestamp });
|
||||
}
|
||||
|
||||
type GetResultsOpts = {
|
||||
|
@ -92,10 +97,10 @@ type GetResultsOpts = {
|
|||
export async function getResults(
|
||||
uid: string,
|
||||
opts?: GetResultsOpts
|
||||
): Promise<DBResult[]> {
|
||||
): Promise<MonkeyTypes.DBResult[]> {
|
||||
const { onOrAfterTimestamp, offset, limit } = opts ?? {};
|
||||
let query = db
|
||||
.collection<DBResult>("results")
|
||||
.collection<MonkeyTypes.DBResult>("results")
|
||||
.find({
|
||||
uid,
|
||||
...(!_.isNil(onOrAfterTimestamp) &&
|
||||
|
|
|
@ -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<void> {
|
|||
await getUsersCollection().updateOne({ uid }, { $inc: { xp: new Long(xp) } });
|
||||
}
|
||||
|
||||
export async function incrementTestActivity(
|
||||
user: MonkeyTypes.DBUser,
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
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<boolean> {
|
||||
const premiumFeaturesEnabled = (await getCachedConfiguration(true)).users
|
||||
.premium.enabled;
|
||||
if (!premiumFeaturesEnabled) {
|
||||
if (premiumFeaturesEnabled !== true) {
|
||||
return false;
|
||||
}
|
||||
const user = userInfoOverride ?? (await getUser(uid, "checkIfUserIsPremium"));
|
||||
|
|
|
@ -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<void> {
|
||||
const {
|
||||
|
@ -48,7 +49,7 @@ export async function connect(): Promise<void> {
|
|||
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<T>(collectionName: string): Collection<WithId<T>> {
|
|||
|
||||
return db.collection<WithId<T>>(collectionName);
|
||||
}
|
||||
export async function close(): Promise<void> {
|
||||
await mongoClient?.close();
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
12
backend/src/types/types.d.ts
vendored
12
backend/src/types/types.d.ts
vendored
|
@ -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<SharedTypes.ResultFilters[]>;
|
||||
|
@ -34,6 +39,7 @@ declare namespace MonkeyTypes {
|
|||
lastNameChange?: number;
|
||||
canManageApeKeys?: boolean;
|
||||
bananas?: number;
|
||||
testActivity?: SharedTypes.CountByYearAndDay;
|
||||
};
|
||||
|
||||
type DBCustomTheme = WithObjectId<SharedTypes.CustomTheme>;
|
||||
|
@ -100,4 +106,8 @@ declare namespace MonkeyTypes {
|
|||
frontendForcedConfig?: Record<string, string[] | boolean[]>;
|
||||
frontendFunctions?: string[];
|
||||
};
|
||||
|
||||
type DBResult = MonkeyTypes.WithObjectId<
|
||||
SharedTypes.DBResult<SharedTypes.Config.Mode>
|
||||
>;
|
||||
}
|
||||
|
|
761
frontend/__tests__/elements/test-activity-calendar.spec.ts
Normal file
761
frontend/__tests__/elements/test-activity-calendar.spec.ts
Normal file
|
@ -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",
|
||||
};
|
||||
},
|
||||
});
|
|
@ -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",
|
||||
|
|
20
frontend/__tests__/vitest.d.ts
vendored
Normal file
20
frontend/__tests__/vitest.d.ts
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
import type { Assertion, AsymmetricMatchersContaining } from "vitest";
|
||||
|
||||
interface ActivityDayMatchers<R = MonkeyTypes.TestActivityDay> {
|
||||
toBeDate: (date: string) => ActivityDayMatchers<R>;
|
||||
toHaveTests: (tests: number) => ActivityDayMatchers<R>;
|
||||
toHaveLevel: (level?: string | number) => ActivityDayMatchers<R>;
|
||||
toBeFiller: () => ActivityDayMatchers<R>;
|
||||
}
|
||||
|
||||
declare module "vitest" {
|
||||
interface Assertion<T = any> extends ActivityDayMatchers<T> {}
|
||||
interface AsymmetricMatchersContaining extends ActivityDayMatchers {}
|
||||
}
|
||||
|
||||
interface MatcherResult {
|
||||
pass: boolean;
|
||||
message: () => string;
|
||||
actual?: unknown;
|
||||
expected?: unknown;
|
||||
}
|
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -300,6 +300,45 @@
|
|||
|
||||
<!-- <div class="group createdDate">Account created on -</div> -->
|
||||
|
||||
<div id="testActivity" class="hidden">
|
||||
<div class="wrapper">
|
||||
<div class="top">
|
||||
<div class="year"><select class="yearSelect"></select></div>
|
||||
<!-- <div class="title">test activity</div> -->
|
||||
<div class="legend">
|
||||
<span>less</span>
|
||||
<div data-level="0"></div>
|
||||
<div data-level="1"></div>
|
||||
<div data-level="2"></div>
|
||||
<div data-level="3"></div>
|
||||
<div data-level="4"></div>
|
||||
<span>more</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity"></div>
|
||||
<div class="months"></div>
|
||||
<div class="daysFull">
|
||||
<div></div>
|
||||
<div><div class="text">monday</div></div>
|
||||
<div></div>
|
||||
<div><div class="text">wednesday</div></div>
|
||||
<div></div>
|
||||
<div><div class="text">friday</div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="days">
|
||||
<div></div>
|
||||
<div><div class="text">mon</div></div>
|
||||
<div></div>
|
||||
<div><div class="text">wed</div></div>
|
||||
<div></div>
|
||||
<div><div class="text">fri</div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div class="nodata hidden">No data found.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ad-account-1-wrapper" class="ad full-width advertisement ad-h">
|
||||
<div class="icon"><i class="fas fa-ad"></i></div>
|
||||
<div id="ad-account-1"></div>
|
||||
|
|
|
@ -469,3 +469,196 @@
|
|||
user-select: none;
|
||||
background-color: var(--sub-alt-color);
|
||||
}
|
||||
|
||||
#testActivity {
|
||||
// width: max-content;
|
||||
// justify-self: center;
|
||||
background: var(--sub-alt-color);
|
||||
border-radius: var(--roundness);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
--box-size: 1.1em;
|
||||
--gap-size: calc(var(--box-size) / 4);
|
||||
--font-size: 1em;
|
||||
|
||||
.wrapper {
|
||||
width: max-content;
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-template-rows: min-content 1fr min-content;
|
||||
gap: 1em 1em;
|
||||
grid-template-areas:
|
||||
"top top"
|
||||
"day chart"
|
||||
"empty month";
|
||||
// grid-template-areas:
|
||||
// "empty month"
|
||||
// "day chart"
|
||||
// "top top";
|
||||
// font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.top {
|
||||
grid-area: top;
|
||||
display: grid;
|
||||
grid-template-columns: 15rem 1fr max-content;
|
||||
grid-template-areas: "year title legend";
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ss-main {
|
||||
border: 0.2em solid var(--bg-color);
|
||||
}
|
||||
|
||||
.yearSelect,
|
||||
.months div,
|
||||
.days div,
|
||||
.daysFull div,
|
||||
.legend {
|
||||
color: var(--sub-color);
|
||||
}
|
||||
|
||||
.year {
|
||||
grid-area: year;
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: title;
|
||||
text-align: center;
|
||||
// font-size: 1.5em;
|
||||
color: var(--sub-color);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.months {
|
||||
grid-area: month;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(53, 1fr);
|
||||
|
||||
div {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.days {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.daysFull {
|
||||
margin-right: 2rem;
|
||||
}
|
||||
|
||||
.days,
|
||||
.daysFull {
|
||||
grid-area: day;
|
||||
display: grid;
|
||||
grid-template-rows: repeat(7, 1fr);
|
||||
align-items: center;
|
||||
// div {
|
||||
// display: grid;
|
||||
// overflow: hidden;
|
||||
// align-items: center;
|
||||
// }
|
||||
.text {
|
||||
// align-self: center;
|
||||
// margin-top: 0.33em;\
|
||||
display: flex;
|
||||
// transform: translateY(-50%);
|
||||
// height: 0px;
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
}
|
||||
|
||||
.nodata {
|
||||
grid-area: chart;
|
||||
}
|
||||
|
||||
.activity {
|
||||
grid-area: chart;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(7, 1fr);
|
||||
grid-template-columns: repeat(53, 1fr);
|
||||
gap: var(--gap-size);
|
||||
// width: max-content;
|
||||
// max-width: 100%;
|
||||
|
||||
div {
|
||||
&:hover {
|
||||
border: 2px solid var(--text-color);
|
||||
}
|
||||
&[data-level="filler"]:hover {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.legend {
|
||||
grid-area: legend;
|
||||
display: flex;
|
||||
gap: var(--gap-size);
|
||||
justify-content: flex-end;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-size: var(--font-size);
|
||||
&:first-child {
|
||||
margin-right: var(--gap-size);
|
||||
}
|
||||
&:last-child {
|
||||
margin-left: var(--gap-size);
|
||||
}
|
||||
}
|
||||
}
|
||||
.activity div,
|
||||
.legend div {
|
||||
width: var(--box-size);
|
||||
height: 1em;
|
||||
border-radius: var(--gap-size);
|
||||
place-self: center;
|
||||
|
||||
&[data-level="filler"] {
|
||||
background: none;
|
||||
}
|
||||
&[data-level="0"] {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bg-color) 50%,
|
||||
var(--sub-alt-color)
|
||||
);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
&[data-level="1"] {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--main-color) 20%,
|
||||
var(--sub-alt-color)
|
||||
);
|
||||
}
|
||||
|
||||
&[data-level="2"] {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--main-color) 50%,
|
||||
var(--sub-alt-color)
|
||||
);
|
||||
}
|
||||
|
||||
&[data-level="3"] {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--main-color) 75%,
|
||||
var(--sub-alt-color)
|
||||
);
|
||||
}
|
||||
|
||||
&[data-level="4"] {
|
||||
background-color: var(--main-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -191,6 +191,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
#testActivity {
|
||||
--box-size: 0.58em;
|
||||
// .activity div,
|
||||
// .legend div {
|
||||
// width: 0.7em;
|
||||
// height: 0.7em;
|
||||
// border-radius: 0.15em;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) and (max-width: 778px) {
|
||||
|
|
|
@ -54,4 +54,7 @@
|
|||
border-radius: 0.3rem;
|
||||
font-size: 0.5rem;
|
||||
}
|
||||
#testActivity {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -251,4 +251,22 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
#testActivity {
|
||||
--box-size: 0.7em;
|
||||
.wrapper {
|
||||
grid-template-areas:
|
||||
"top top"
|
||||
"chart chart"
|
||||
"month month";
|
||||
}
|
||||
.days {
|
||||
display: none;
|
||||
}
|
||||
// .activity div,
|
||||
// .legend div {
|
||||
// width: 0.9em;
|
||||
// height: 0.9em;
|
||||
// border-radius: 0.2em;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,4 +8,15 @@
|
|||
.content-grid {
|
||||
--content-max-width: 1280px;
|
||||
}
|
||||
#testActivity {
|
||||
--box-size: 1.05em;
|
||||
.daysFull {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
// .activity div,
|
||||
// .legend div {
|
||||
// width: 1.6em;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -243,4 +243,21 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
#testActivity {
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
.top {
|
||||
grid-template-columns: 12rem 1fr 8rem;
|
||||
}
|
||||
}
|
||||
// .activity {
|
||||
// gap: 0.1em;
|
||||
// }
|
||||
.activity div,
|
||||
.legend div {
|
||||
width: 100%;
|
||||
height: unset;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,4 +47,21 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
#testActivity {
|
||||
--box-size: 0.9em;
|
||||
--font-size: 0.9em;
|
||||
// .days {
|
||||
// font-size: 0.8em;
|
||||
// }
|
||||
.activity div,
|
||||
.legend div {
|
||||
height: var(--box-size);
|
||||
}
|
||||
.days {
|
||||
display: grid;
|
||||
}
|
||||
.daysFull {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Config from "../config";
|
||||
import dateFormat from "date-fns/format";
|
||||
import { format as dateFormat } from "date-fns/format";
|
||||
import Format from "../utils/format";
|
||||
|
||||
function clearTables(isProfile: boolean): void {
|
||||
|
|
|
@ -274,4 +274,8 @@ export default class Users {
|
|||
async revokeAllTokens(): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/revokeAllTokens`);
|
||||
}
|
||||
|
||||
async getTestActivity(): Ape.EndpointResponse<SharedTypes.CountByYearAndDay> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/testActivity`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,7 +58,7 @@ Chart.defaults.elements.line.tension = 0.3;
|
|||
Chart.defaults.elements.line.fill = "origin";
|
||||
|
||||
import "chartjs-adapter-date-fns";
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import Config from "../config";
|
||||
import * as ThemeColors from "../elements/theme-colors";
|
||||
import * as ConfigEvent from "../observables/config-event";
|
||||
|
|
|
@ -8,6 +8,12 @@ import * as ConnectionState from "./states/connection";
|
|||
import { lastElementFromArray } from "./utils/arrays";
|
||||
import { getFunboxList } from "./utils/json-data";
|
||||
import { mergeWithDefaultConfig } from "./utils/config";
|
||||
import * as Dates from "date-fns";
|
||||
import {
|
||||
TestActivityCalendar,
|
||||
ModifiableTestActivityCalendar,
|
||||
} from "./elements/test-activity-calendar";
|
||||
import * as Loader from "./elements/loader";
|
||||
|
||||
let dbSnapshot: MonkeyTypes.Snapshot | undefined;
|
||||
|
||||
|
@ -134,6 +140,13 @@ export async function initSnapshot(): Promise<
|
|||
snap.isPremium = userData?.isPremium;
|
||||
snap.allTimeLbs = userData.allTimeLbs;
|
||||
|
||||
if (userData.testActivity !== undefined) {
|
||||
snap.testActivity = new ModifiableTestActivityCalendar(
|
||||
userData.testActivity.testsByDays,
|
||||
new Date(userData.testActivity.lastDay)
|
||||
);
|
||||
}
|
||||
|
||||
const hourOffset = userData?.streak?.hourOffset;
|
||||
snap.streakHourOffset =
|
||||
hourOffset === undefined || hourOffset === null ? undefined : hourOffset;
|
||||
|
@ -897,6 +910,11 @@ export function saveLocalResult(
|
|||
|
||||
setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
if (snapshot.testActivity !== undefined) {
|
||||
snapshot.testActivity.increment(new Date(result.timestamp));
|
||||
setSnapshot(snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateLocalStats(started: number, time: number): void {
|
||||
|
@ -954,6 +972,55 @@ export function setStreak(streak: number): void {
|
|||
setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
export async function getTestActivityCalendar(
|
||||
yearString: string
|
||||
): Promise<MonkeyTypes.TestActivityCalendar | undefined> {
|
||||
if (!isAuthenticated() || dbSnapshot === undefined) return undefined;
|
||||
|
||||
if (yearString === "current") return dbSnapshot.testActivity;
|
||||
|
||||
const currentYear = new Date().getFullYear().toString();
|
||||
if (yearString === currentYear) {
|
||||
return dbSnapshot.testActivity?.getFullYearCalendar();
|
||||
}
|
||||
|
||||
if (dbSnapshot.testActivityByYear === undefined) {
|
||||
if (!ConnectionState.get()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Loader.show();
|
||||
const response = await Ape.users.getTestActivity();
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(
|
||||
"Error getting test activities: " + response.message,
|
||||
-1
|
||||
);
|
||||
Loader.hide();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
dbSnapshot.testActivityByYear = {};
|
||||
for (const year in response.data) {
|
||||
if (year === currentYear) continue;
|
||||
const testsByDays = response.data[year] ?? [];
|
||||
const lastDay = Dates.addDays(
|
||||
new Date(parseInt(year), 0, 1),
|
||||
testsByDays.length
|
||||
);
|
||||
|
||||
dbSnapshot.testActivityByYear[year] = new TestActivityCalendar(
|
||||
testsByDays,
|
||||
lastDay,
|
||||
true
|
||||
);
|
||||
}
|
||||
Loader.hide();
|
||||
}
|
||||
|
||||
return dbSnapshot.testActivityByYear[yearString];
|
||||
}
|
||||
|
||||
// export async function DB.getLocalTagPB(tagId) {
|
||||
// function cont() {
|
||||
// let ret = 0;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
|
||||
import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
|
||||
import Ape from "../ape";
|
||||
import { isAuthenticated } from "../firebase";
|
||||
import * as AccountButton from "../elements/account-button";
|
||||
|
|
|
@ -6,9 +6,9 @@ import * as Misc from "../utils/misc";
|
|||
import * as Arrays from "../utils/arrays";
|
||||
import * as Numbers from "../utils/numbers";
|
||||
import * as Notifications from "./notifications";
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import { isAuthenticated } from "../firebase";
|
||||
import differenceInSeconds from "date-fns/differenceInSeconds";
|
||||
import { differenceInSeconds } from "date-fns/differenceInSeconds";
|
||||
import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller";
|
||||
import * as ConnectionState from "../states/connection";
|
||||
import * as Skeleton from "../utils/skeleton";
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as DB from "../db";
|
||||
import format from "date-fns/format";
|
||||
import differenceInDays from "date-fns/differenceInDays";
|
||||
import { format } from "date-fns/format";
|
||||
import { differenceInDays } from "date-fns/differenceInDays";
|
||||
import * as Misc from "../utils/misc";
|
||||
import * as Numbers from "../utils/numbers";
|
||||
import * as Levels from "../utils/levels";
|
||||
|
@ -8,7 +8,7 @@ import * as DateTime from "../utils/date-and-time";
|
|||
import { getHTMLById } from "../controllers/badge-controller";
|
||||
import { throttle } from "throttle-debounce";
|
||||
import * as ActivePage from "../states/active-page";
|
||||
import formatDistanceToNowStrict from "date-fns/formatDistanceToNowStrict";
|
||||
import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
|
||||
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
|
||||
import Format from "../utils/format";
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import Ape from "../ape";
|
|||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { secondsToString } from "../utils/date-and-time";
|
||||
import * as Notifications from "./notifications";
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import * as Alerts from "./alerts";
|
||||
|
||||
function clearMemory(): void {
|
||||
|
|
202
frontend/src/ts/elements/test-activity-calendar.ts
Normal file
202
frontend/src/ts/elements/test-activity-calendar.ts
Normal file
|
@ -0,0 +1,202 @@
|
|||
import type { Interval } from "date-fns/types";
|
||||
import { UTCDateMini } from "@date-fns/utc/date/mini";
|
||||
import {
|
||||
format,
|
||||
endOfMonth,
|
||||
subYears,
|
||||
addDays,
|
||||
differenceInDays,
|
||||
eachMonthOfInterval,
|
||||
isSameDay,
|
||||
isBefore,
|
||||
endOfYear,
|
||||
startOfYear,
|
||||
differenceInWeeks,
|
||||
startOfMonth,
|
||||
nextSunday,
|
||||
previousSunday,
|
||||
isSunday,
|
||||
nextSaturday,
|
||||
isSaturday,
|
||||
} from "date-fns";
|
||||
|
||||
export class TestActivityCalendar implements MonkeyTypes.TestActivityCalendar {
|
||||
protected data: (number | null | undefined)[];
|
||||
protected startDay: Date;
|
||||
protected endDay: Date;
|
||||
protected isFullYear: boolean;
|
||||
|
||||
constructor(
|
||||
data: (number | null | undefined)[],
|
||||
lastDay: Date,
|
||||
fullYear = false
|
||||
) {
|
||||
const local = new UTCDateMini(lastDay);
|
||||
const interval = this.getInterval(local, fullYear);
|
||||
|
||||
this.startDay = interval.start as Date;
|
||||
this.endDay = interval.end as Date;
|
||||
this.data = this.buildData(data, local);
|
||||
this.isFullYear = fullYear;
|
||||
}
|
||||
|
||||
protected getInterval(lastDay: Date, fullYear = false): Interval {
|
||||
const end = fullYear ? endOfYear(lastDay) : new Date();
|
||||
let start = startOfYear(lastDay);
|
||||
if (!fullYear) {
|
||||
start = addDays(subYears(end, 1), 1);
|
||||
if (!isSunday(start)) start = previousSunday(start);
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
protected buildData(
|
||||
data: (number | null | undefined)[],
|
||||
lastDay: Date
|
||||
): (number | null | undefined)[] {
|
||||
//fill calendar with enough values
|
||||
const values = new Array(Math.max(0, 386 - data.length)).fill(undefined);
|
||||
values.push(...data);
|
||||
|
||||
//discard values outside the calendar range
|
||||
const days = differenceInDays(this.endDay, this.startDay) + 1;
|
||||
const offset =
|
||||
values.length - days + differenceInDays(this.endDay, lastDay);
|
||||
return values.slice(offset);
|
||||
}
|
||||
|
||||
getMonths(): MonkeyTypes.TestActivityMonth[] {
|
||||
const months: Date[] = eachMonthOfInterval({
|
||||
start: this.startDay,
|
||||
end: this.endDay,
|
||||
});
|
||||
|
||||
const results: MonkeyTypes.TestActivityMonth[] = [];
|
||||
|
||||
for (let i = 0; i < months.length; i++) {
|
||||
const month: Date = months[i] as Date;
|
||||
let start =
|
||||
i === 0 ? new UTCDateMini(this.startDay) : startOfMonth(month);
|
||||
let end = i === 12 ? new UTCDateMini(this.endDay) : endOfMonth(start);
|
||||
|
||||
if (!isSunday(start))
|
||||
start = (i === 0 ? previousSunday : nextSunday)(start);
|
||||
if (!isSaturday(end)) end = nextSaturday(end);
|
||||
|
||||
const weeks = differenceInWeeks(end, start, { roundingMethod: "ceil" });
|
||||
if (weeks > 2)
|
||||
results.push({
|
||||
text: format(month, "MMM").toLowerCase(),
|
||||
weeks: weeks,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
getDays(): MonkeyTypes.TestActivityDay[] {
|
||||
const result: MonkeyTypes.TestActivityDay[] = [];
|
||||
const buckets = this.getBuckets();
|
||||
const getValue = (v: number | null | undefined): string => {
|
||||
if (v === undefined) return "0";
|
||||
if (v === null || v === 0) return "0";
|
||||
for (let b = 0; b < 4; b++)
|
||||
if (v <= (buckets[b] ?? 0)) return (1 + b).toString();
|
||||
|
||||
return "4";
|
||||
};
|
||||
|
||||
//skip weekdays in the previous month
|
||||
for (let i = 0; i < this.startDay.getDay(); i++) {
|
||||
result.push({
|
||||
level: "filler",
|
||||
});
|
||||
}
|
||||
|
||||
const days = differenceInDays(this.endDay, this.startDay);
|
||||
|
||||
let currentDate = this.startDay;
|
||||
for (let i = 0; i <= days; i++) {
|
||||
const count = this.data[i];
|
||||
const day = format(currentDate, "EEEE dd MMM yyyy");
|
||||
|
||||
result.push({
|
||||
level: getValue(count),
|
||||
label:
|
||||
count !== undefined && count !== null
|
||||
? `${count} ${count == 1 ? "test" : "tests"} on ${day}`
|
||||
: `no activity on ${day}`,
|
||||
});
|
||||
currentDate = addDays(currentDate, 1);
|
||||
}
|
||||
|
||||
//add weekdays missing
|
||||
for (let i = this.endDay.getDay(); i < 6; i++) {
|
||||
result.push({
|
||||
level: "filler",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private getBuckets(): number[] {
|
||||
const filtered = this.data.filter(
|
||||
(it) => it !== null && it !== undefined
|
||||
) as number[];
|
||||
const sorted = filtered.sort((a, b) => a - b);
|
||||
|
||||
const trimmed = sorted.slice(
|
||||
Math.round(sorted.length * 0.1),
|
||||
sorted.length - Math.round(sorted.length * 0.1)
|
||||
);
|
||||
const sum = trimmed.reduce((a, c) => a + c, 0);
|
||||
const mid = sum / trimmed.length;
|
||||
return [Math.floor(mid / 2), Math.round(mid), Math.round(mid * 1.5)];
|
||||
}
|
||||
}
|
||||
|
||||
export class ModifiableTestActivityCalendar
|
||||
extends TestActivityCalendar
|
||||
implements MonkeyTypes.ModifiableTestActivityCalendar
|
||||
{
|
||||
private lastDay: Date;
|
||||
|
||||
constructor(data: (number | null)[], lastDay: Date) {
|
||||
super(data, lastDay);
|
||||
this.lastDay = new UTCDateMini(lastDay);
|
||||
}
|
||||
|
||||
increment(utcDate: Date): void {
|
||||
const date = new UTCDateMini(utcDate);
|
||||
const lastDay = new UTCDateMini(this.lastDay);
|
||||
if (isSameDay(date, lastDay)) {
|
||||
const last = this.data.length - 1;
|
||||
this.data[last] = (this.data[last] || 0) + 1;
|
||||
} else if (isBefore(date, lastDay)) {
|
||||
throw new Error("cannot alter data in the past.");
|
||||
} else {
|
||||
const missedDays = differenceInDays(date, lastDay) - 1;
|
||||
for (let i = 0; i < missedDays; i++) {
|
||||
this.data.push(undefined);
|
||||
}
|
||||
this.data.push(1);
|
||||
//update timeframe
|
||||
const interval = this.getInterval(date);
|
||||
this.startDay = interval.start as Date;
|
||||
this.endDay = interval.end as Date;
|
||||
this.lastDay = date;
|
||||
}
|
||||
|
||||
this.data = this.buildData(this.data, this.lastDay);
|
||||
}
|
||||
|
||||
getFullYearCalendar(): MonkeyTypes.TestActivityCalendar {
|
||||
const today = new Date();
|
||||
if (this.lastDay.getFullYear() !== new UTCDateMini(today).getFullYear()) {
|
||||
return new TestActivityCalendar([], today, true);
|
||||
} else {
|
||||
return new TestActivityCalendar(this.data, this.lastDay, true);
|
||||
}
|
||||
}
|
||||
}
|
104
frontend/src/ts/elements/test-activity.ts
Normal file
104
frontend/src/ts/elements/test-activity.ts
Normal file
|
@ -0,0 +1,104 @@
|
|||
import SlimSelect from "slim-select";
|
||||
import type { DataObjectPartial } from "slim-select/dist/store";
|
||||
import { getTestActivityCalendar } from "../db";
|
||||
import * as ServerConfiguration from "../ape/server-configuration";
|
||||
import * as DB from "../db";
|
||||
|
||||
const yearSelector = new SlimSelect({
|
||||
select: "#testActivity .yearSelect",
|
||||
settings: {
|
||||
showSearch: false,
|
||||
},
|
||||
events: {
|
||||
afterChange: async (newVal): Promise<void> => {
|
||||
yearSelector?.disable();
|
||||
const selected = newVal[0]?.value as string;
|
||||
const activity = await getTestActivityCalendar(selected);
|
||||
update(activity);
|
||||
if ((yearSelector?.getData() ?? []).length > 1) {
|
||||
yearSelector?.enable();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function init(
|
||||
calendar?: MonkeyTypes.TestActivityCalendar,
|
||||
userSignUpDate?: Date
|
||||
): void {
|
||||
if (calendar === undefined) {
|
||||
$("#testActivity").addClass("hidden");
|
||||
return;
|
||||
}
|
||||
$("#testActivity").removeClass("hidden");
|
||||
initYearSelector("current", userSignUpDate?.getFullYear() || 2022);
|
||||
update(calendar);
|
||||
}
|
||||
|
||||
function update(calendar?: MonkeyTypes.TestActivityCalendar): void {
|
||||
const container = document.querySelector(
|
||||
"#testActivity .activity"
|
||||
) as HTMLElement;
|
||||
container.innerHTML = "";
|
||||
|
||||
if (calendar === undefined) {
|
||||
updateMonths([]);
|
||||
$("#testActivity .nodata").removeClass("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
updateMonths(calendar.getMonths());
|
||||
$("#testActivity .nodata").addClass("hidden");
|
||||
|
||||
for (const day of calendar.getDays()) {
|
||||
const elem = document.createElement("div");
|
||||
elem.setAttribute("data-level", day.level);
|
||||
if (day.label !== undefined) {
|
||||
elem.setAttribute("aria-label", day.label);
|
||||
elem.setAttribute("data-balloon-pos", "up");
|
||||
}
|
||||
container.appendChild(elem);
|
||||
}
|
||||
}
|
||||
|
||||
export function initYearSelector(
|
||||
selectedYear: number | "current",
|
||||
startYear: number
|
||||
): void {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const years: DataObjectPartial[] = [
|
||||
{
|
||||
text: "last 12 months",
|
||||
value: "current",
|
||||
selected: selectedYear === "current",
|
||||
},
|
||||
];
|
||||
for (let year = currentYear; year >= startYear; year--) {
|
||||
if (
|
||||
years.length < 2 ||
|
||||
(ServerConfiguration.get()?.users.premium.enabled &&
|
||||
DB.getSnapshot()?.isPremium)
|
||||
) {
|
||||
years.push({
|
||||
text: year.toString(),
|
||||
value: year.toString(),
|
||||
selected: year === selectedYear,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yearSelector.setData(years);
|
||||
years.length > 1 ? yearSelector.enable() : yearSelector.disable();
|
||||
}
|
||||
|
||||
function updateMonths(months: MonkeyTypes.TestActivityMonth[]): void {
|
||||
const element = document.querySelector("#testActivity .months") as Element;
|
||||
|
||||
element.innerHTML = months
|
||||
.map(
|
||||
(month) =>
|
||||
`<div style="grid-column: span ${month.weeks}">${month.text}</div>`
|
||||
)
|
||||
.join("");
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import Ape from "../ape";
|
||||
import * as Loader from "../elements/loader";
|
||||
import * as Notifications from "../elements/notifications";
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import * as ConnectionState from "../states/connection";
|
||||
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
|
||||
import { showPopup } from "./simple-modals";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as DB from "../db";
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import { getLanguageDisplayString } from "../utils/strings";
|
||||
import Config from "../config";
|
||||
import Format from "../utils/format";
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Ape from "../ape";
|
||||
import * as Loader from "../elements/loader";
|
||||
import * as Notifications from "../elements/notifications";
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
|
||||
|
||||
let quotes: Ape.Quotes.Quote[] = [];
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import { getReleasesFromGitHub } from "../utils/json-data";
|
||||
import AnimatedModal from "../utils/animated-modal";
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import Ape from "../ape";
|
|||
import * as Notifications from "../elements/notifications";
|
||||
import * as ChartController from "../controllers/chart-controller";
|
||||
import * as ConnectionState from "../states/connection";
|
||||
import intervalToDuration from "date-fns/intervalToDuration";
|
||||
import { intervalToDuration } from "date-fns/intervalToDuration";
|
||||
import * as Skeleton from "../utils/skeleton";
|
||||
|
||||
function reset(): void {
|
||||
|
|
|
@ -17,7 +17,7 @@ import * as Arrays from "../utils/arrays";
|
|||
import * as Numbers from "../utils/numbers";
|
||||
import { get as getTypingSpeedUnit } from "../utils/typing-speed-units";
|
||||
import * as Profile from "../elements/profile";
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import * as ConnectionState from "../states/connection";
|
||||
import * as Skeleton from "../utils/skeleton";
|
||||
import type { ScaleChartOptions, LinearScaleOptions } from "chart.js";
|
||||
|
@ -27,6 +27,7 @@ import { Auth } from "../firebase";
|
|||
import * as Loader from "../elements/loader";
|
||||
import * as ResultBatches from "../elements/result-batches";
|
||||
import Format from "../utils/format";
|
||||
import * as TestActivity from "../elements/test-activity";
|
||||
|
||||
let filterDebug = false;
|
||||
//toggle filterdebug
|
||||
|
@ -214,7 +215,8 @@ async function fillContent(): Promise<void> {
|
|||
PbTables.update(snapshot.personalBests);
|
||||
void Profile.update("account", snapshot);
|
||||
|
||||
void ResultBatches.update();
|
||||
void TestActivity.init(snapshot.testActivity, new Date(snapshot.addedAt));
|
||||
void void ResultBatches.update();
|
||||
|
||||
chartData = [];
|
||||
accChartData = [];
|
||||
|
@ -1271,7 +1273,8 @@ export const page = new Page({
|
|||
},
|
||||
beforeShow: async (): Promise<void> => {
|
||||
Skeleton.append("pageAccount", "main");
|
||||
if (DB.getSnapshot()?.results === undefined) {
|
||||
const snapshot = DB.getSnapshot();
|
||||
if (snapshot?.results === undefined) {
|
||||
$(".pageLoading .fill, .pageAccount .fill").css("width", "0%");
|
||||
$(".pageAccount .content").addClass("hidden");
|
||||
$(".pageAccount .preloader").removeClass("hidden");
|
||||
|
@ -1281,6 +1284,11 @@ export const page = new Page({
|
|||
ResultFilters.updateActive();
|
||||
await Misc.sleep(0);
|
||||
|
||||
TestActivity.initYearSelector(
|
||||
"current",
|
||||
snapshot !== undefined ? new Date(snapshot.addedAt).getFullYear() : 2020
|
||||
);
|
||||
|
||||
void update().then(() => {
|
||||
void updateChartColors();
|
||||
$(".pageAccount .content p.accountVerificatinNotice").remove();
|
||||
|
|
|
@ -18,7 +18,7 @@ import * as SlowTimer from "../states/slow-timer";
|
|||
import * as CompositionState from "../states/composition";
|
||||
import * as ConfigEvent from "../observables/config-event";
|
||||
import * as Hangul from "hangul-js";
|
||||
import format from "date-fns/format";
|
||||
import { format } from "date-fns/format";
|
||||
import { isAuthenticated } from "../firebase";
|
||||
import { skipXpBreakdown } from "../elements/account-button";
|
||||
import * as FunboxList from "./funbox/funbox-list";
|
||||
|
|
23
frontend/src/ts/types/types.d.ts
vendored
23
frontend/src/ts/types/types.d.ts
vendored
|
@ -218,6 +218,7 @@ declare namespace MonkeyTypes {
|
|||
| "resultFilterPresets"
|
||||
| "tags"
|
||||
| "xp"
|
||||
| "testActivity"
|
||||
> & {
|
||||
typingStats: {
|
||||
timeTyping: number;
|
||||
|
@ -236,6 +237,8 @@ declare namespace MonkeyTypes {
|
|||
presets: SnapshotPreset[];
|
||||
results?: SharedTypes.Result<SharedTypes.Config.Mode>[];
|
||||
xp: number;
|
||||
testActivity?: ModifiableTestActivityCalendar;
|
||||
testActivityByYear?: { [key: string]: TestActivityCalendar };
|
||||
};
|
||||
|
||||
type Group<
|
||||
|
@ -447,4 +450,24 @@ declare namespace MonkeyTypes {
|
|||
histogramDataBucketSize: number;
|
||||
historyStepSize: number;
|
||||
};
|
||||
|
||||
type TestActivityCalendar = {
|
||||
getMonths: () => TestActivityMonth[];
|
||||
getDays: () => TestActivityDay[];
|
||||
};
|
||||
|
||||
type ModifiableTestActivityCalendar = TestActivityCalendar & {
|
||||
increment: (date: Date) => void;
|
||||
getFullYearCalendar: () => TestActivityCalendar;
|
||||
};
|
||||
|
||||
type TestActivityDay = {
|
||||
level: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type TestActivityMonth = {
|
||||
text: string;
|
||||
weeks: number;
|
||||
};
|
||||
}
|
||||
|
|
8
shared-types/types.d.ts
vendored
8
shared-types/types.d.ts
vendored
|
@ -550,6 +550,7 @@ declare namespace SharedTypes {
|
|||
needsToChangeName?: boolean;
|
||||
quoteMod?: boolean | string;
|
||||
resultFilterPresets?: ResultFilters[];
|
||||
testActivity?: TestActivity;
|
||||
};
|
||||
|
||||
type Reward<T> = {
|
||||
|
@ -620,4 +621,11 @@ declare namespace SharedTypes {
|
|||
rank?: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type TestActivity = {
|
||||
testsByDays: (number | null)[];
|
||||
lastDay: number;
|
||||
};
|
||||
|
||||
type CountByYearAndDay = { [key: string]: (number | null)[] };
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue