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:
Christian Fehmer 2024-05-15 15:23:36 +02:00 committed by GitHub
parent 42ddc256bd
commit 59615fb02c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2255 additions and 68 deletions

View file

@ -1,2 +1,3 @@
backend/build
docker
backend/__migration__
docker

View 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.`
);
}

View 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);
}

View 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");
}

View file

@ -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
);
}

View file

@ -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);
});
});
});

View file

@ -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", () => ({

View file

@ -21,6 +21,7 @@
},
"files": ["../src/types/types.d.ts"],
"include": [
"./**/*.ts",
"./**/*.spec.ts",
"./setup-tests.ts",
"../../shared-types/**/*.d.ts"

View file

@ -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",

View file

@ -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",

View file

@ -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(

View file

@ -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);
}

View file

@ -659,4 +659,11 @@ router.post(
asyncHandler(UserController.revokeAllTokens)
);
router.get(
"/testActivity",
authenticateRequest(),
RateLimit.userTestActivity,
asyncHandler(UserController.getTestActivity)
);
export default router;

View file

@ -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) &&

View file

@ -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"));

View file

@ -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();
}

View file

@ -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,

View file

@ -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>
>;
}

View 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",
};
},
});

View file

@ -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
View 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;
}

View file

@ -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": {

View file

@ -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",

View file

@ -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>

View file

@ -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);
}
}
}

View file

@ -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) {

View file

@ -54,4 +54,7 @@
border-radius: 0.3rem;
font-size: 0.5rem;
}
#testActivity {
display: none;
}
}

View file

@ -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;
// }
}
}

View file

@ -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;
// }
}
}

View file

@ -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;
}
}
}

View file

@ -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;
}
}
}

View file

@ -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 {

View file

@ -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`);
}
}

View file

@ -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";

View file

@ -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;

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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 {

View 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);
}
}
}

View 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("");
}

View file

@ -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";

View file

@ -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";

View file

@ -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[] = [];

View file

@ -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";

View file

@ -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 {

View file

@ -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();

View file

@ -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";

View file

@ -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;
};
}

View file

@ -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)[] };
}