Streak hour offset (#4357)

* backend flow to set hour offset

* added frontend popup to set streak hour offset

* displaying a message that the user can change offset
displaying the actual offset if its already set

* applying offset when checking streaks

* added tests for the new offset
updated old streak tests

* defaulting to undefined

* removing content if offset is already set

* updated the way offset is displayed

* also updating in the local snapshot
This commit is contained in:
Jack 2023-06-12 14:38:44 +02:00 committed by GitHub
parent 303e2b2628
commit 27b1a7fedb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 441 additions and 23 deletions

View file

@ -615,22 +615,140 @@ describe("UserDal", () => {
it("updateStreak should update streak", async () => {
await UserDAL.addUser("testStack", "test email", "TestID");
Date.now = jest.fn(() => 1662372000000);
const testSteps = [
{
date: "2023/06/07 21:00:00 UTC",
expectedStreak: 1,
},
{
date: "2023/06/07 23:00:00 UTC",
expectedStreak: 1,
},
{
date: "2023/06/08 00:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/08 23:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/09 00:00:00 UTC",
expectedStreak: 3,
},
{
date: "2023/06/11 00:00:00 UTC",
expectedStreak: 1,
},
];
const streak1 = await updateStreak("TestID", 1662372000000);
for (const { date, expectedStreak } of testSteps) {
const milis = new Date(date).getTime();
Date.now = jest.fn(() => milis);
await expect(streak1).toBe(1);
const streak = await updateStreak("TestID", milis);
Date.now = jest.fn(() => 1662458400000);
await expect(streak).toBe(expectedStreak);
}
});
const streak2 = await updateStreak("TestID", 1662458400000);
it("positive streak offset should award streak correctly", async () => {
await UserDAL.addUser("testStack", "test email", "TestID");
await expect(streak2).toBe(2);
await UserDAL.setStreakHourOffset("TestID", 10);
Date.now = jest.fn(() => 1999969721000000);
const testSteps = [
{
date: "2023/06/06 21:00:00 UTC",
expectedStreak: 1,
},
{
date: "2023/06/07 01:00:00 UTC",
expectedStreak: 1,
},
{
date: "2023/06/07 09:00:00 UTC",
expectedStreak: 1,
},
{
date: "2023/06/07 10:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/07 23:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/08 00:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/08 01:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/08 09:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/08 10:00:00 UTC",
expectedStreak: 3,
},
{
date: "2023/06/10 10:00:00 UTC",
expectedStreak: 1,
},
];
const streak3 = await updateStreak("TestID", 1999969721000);
for (const { date, expectedStreak } of testSteps) {
const milis = new Date(date).getTime();
Date.now = jest.fn(() => milis);
await expect(streak3).toBe(1);
const streak = await updateStreak("TestID", milis);
await expect(streak).toBe(expectedStreak);
}
});
it("negative streak offset should award streak correctly", async () => {
await UserDAL.addUser("testStack", "test email", "TestID");
await UserDAL.setStreakHourOffset("TestID", -4);
const testSteps = [
{
date: "2023/06/06 19:00:00 UTC",
expectedStreak: 1,
},
{
date: "2023/06/06 20:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/07 01:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/07 19:00:00 UTC",
expectedStreak: 2,
},
{
date: "2023/06/07 20:00:00 UTC",
expectedStreak: 3,
},
{
date: "2023/06/09 23:00:00 UTC",
expectedStreak: 1,
},
];
for (const { date, expectedStreak } of testSteps) {
const milis = new Date(date).getTime();
Date.now = jest.fn(() => milis);
const streak = await updateStreak("TestID", milis);
await expect(streak).toBe(expectedStreak);
}
});
});

View file

@ -832,6 +832,23 @@ export async function reportUser(
return new MonkeyResponse("User reported");
}
export async function setStreakHourOffset(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { hourOffset } = req.body;
const user = await UserDAL.getUser(uid, "update user profile");
if (user.streak?.hourOffset !== undefined) {
throw new MonkeyError(403, "Streak hour offset already set");
}
await UserDAL.setStreakHourOffset(uid, hourOffset);
return new MonkeyResponse("Streak hour offset set");
}
export async function toggleBan(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {

View file

@ -419,6 +419,18 @@ router.get(
asyncHandler(UserController.getStats)
);
router.post(
"/setStreakHourOffset",
authenticateRequest(),
RateLimit.setStreakHourOffset,
validateRequest({
body: {
hourOffset: joi.number().min(-11).max(12).required(),
},
}),
asyncHandler(UserController.setStreakHourOffset)
);
router.get(
"/favoriteQuotes",
authenticateRequest(),

View file

@ -973,11 +973,12 @@ export async function updateStreak(
lastResultTimestamp: user.streak?.lastResultTimestamp ?? 0,
length: user.streak?.length ?? 0,
maxLength: user.streak?.maxLength ?? 0,
hourOffset: user.streak?.hourOffset ?? 0,
};
if (isYesterday(streak.lastResultTimestamp)) {
if (isYesterday(streak.lastResultTimestamp, streak.hourOffset)) {
streak.length += 1;
} else if (!isToday(streak.lastResultTimestamp)) {
} else if (!isToday(streak.lastResultTimestamp, streak.hourOffset)) {
Logger.logToDb("streak_lost", { streak }, uid);
streak.length = 1;
}
@ -992,6 +993,21 @@ export async function updateStreak(
return streak.length;
}
export async function setStreakHourOffset(
uid: string,
hourOffset: number
): Promise<void> {
await getUsersCollection().updateOne(
{ uid },
{
$set: {
"streak.hourOffset": hourOffset,
"streak.lastResultTimestamp": Date.now(),
},
}
);
}
export async function setBanned(uid: string, banned: boolean): Promise<void> {
if (banned) {
await getUsersCollection().updateOne({ uid }, { $set: { banned: true } });

View file

@ -299,6 +299,13 @@ export const userGet = rateLimit({
handler: customHandler,
});
export const setStreakHourOffset = rateLimit({
windowMs: ONE_HOUR_MS,
max: 5 * REQUEST_MULTIPLIER,
keyGenerator: getKeyWithUid,
handler: customHandler,
});
export const userSignup = rateLimit({
windowMs: 24 * ONE_HOUR_MS, // 1 day
max: 2 * REQUEST_MULTIPLIER,

View file

@ -199,6 +199,7 @@ declare namespace MonkeyTypes {
lastResultTimestamp: number;
length: number;
maxLength: number;
hourOffset?: number;
}
interface UserInventory {

View file

@ -81,10 +81,16 @@ export function padNumbers(
);
}
export const MILISECONDS_IN_HOUR = 3600000;
export const MILLISECONDS_IN_DAY = 86400000;
export function getStartOfDayTimestamp(timestamp: number): number {
return timestamp - (timestamp % MILLISECONDS_IN_DAY);
export function getStartOfDayTimestamp(
timestamp: number,
offsetMilis = 0
): number {
return (
timestamp - offsetMilis - ((timestamp - offsetMilis) % MILLISECONDS_IN_DAY)
);
}
export function getCurrentDayTimestamp(): number {
@ -165,16 +171,21 @@ export function getOrdinalNumberString(number: number): string {
return `${number}${suffix}`;
}
export function isYesterday(timestamp: number): boolean {
const yesterday = getStartOfDayTimestamp(Date.now() - MILLISECONDS_IN_DAY);
const date = getStartOfDayTimestamp(timestamp);
export function isYesterday(timestamp: number, hourOffset = 0): boolean {
const offsetMilis = hourOffset * MILISECONDS_IN_HOUR;
const yesterday = getStartOfDayTimestamp(
Date.now() - MILLISECONDS_IN_DAY,
offsetMilis
);
const date = getStartOfDayTimestamp(timestamp, offsetMilis);
return yesterday === date;
}
export function isToday(timestamp: number): boolean {
const today = getStartOfDayTimestamp(Date.now());
const date = getStartOfDayTimestamp(timestamp);
export function isToday(timestamp: number, hourOffset = 0): boolean {
const offsetMilis = hourOffset * MILISECONDS_IN_HOUR;
const today = getStartOfDayTimestamp(Date.now(), offsetMilis);
const date = getStartOfDayTimestamp(timestamp, offsetMilis);
return today === date;
}

View file

@ -1505,6 +1505,25 @@
}
}
#streakHourOffsetPopupWrapper {
#streakHourOffsetPopup {
.title {
font-size: 1.5rem;
color: var(--sub-color);
}
width: 400px;
background: var(--bg-color);
border-radius: var(--roundness);
padding: 2rem;
display: grid;
gap: 1rem;
overflow-y: scroll;
.red {
color: var(--error-color);
}
}
}
#tagsWrapper,
#newResultFilterPresetPopupWrapper,
#editProfilePopupWrapper {

View file

@ -255,4 +255,10 @@ export default class Users {
payload: { email },
});
}
async setStreakHourOffset(hourOffset: number): Ape.EndpointData {
return await this.httpClient.post(`${BASE_PATH}/setStreakHourOffset`, {
payload: { hourOffset },
});
}
}

View file

@ -30,4 +30,5 @@ export const defaultSnap: MonkeyTypes.Snapshot = {
inboxUnreadSize: 0,
streak: 0,
maxStreak: 0,
streakHourOffset: undefined,
};

View file

@ -131,6 +131,7 @@ export async function initSnapshot(): Promise<
snap.inboxUnreadSize = userData.inboxUnreadSize ?? 0;
snap.streak = userData?.streak?.length ?? 0;
snap.maxStreak = userData?.streak?.maxLength ?? 0;
snap.streakHourOffset = userData?.streak?.hourOffset;
if (userData.lbMemory?.time15 || userData.lbMemory?.time60) {
//old memory format

View file

@ -117,19 +117,31 @@ export async function update(
const lastResult = results?.[0];
const dayInMilis = 1000 * 60 * 60 * 24;
const milisOffset = (profile.streakHourOffset ?? 0) * 3600000;
const timeDif = formatDistanceToNowStrict(
Misc.getCurrentDayTimestamp() + dayInMilis
Misc.getCurrentDayTimestamp() + dayInMilis + milisOffset
);
if (lastResult) {
//check if the last result is from today
const isToday = Misc.isToday(lastResult.timestamp);
const offsetString = profile.streakHourOffset
? `(${profile.streakHourOffset > 0 ? "+" : ""}${
profile.streakHourOffset
} offset)`
: "";
if (isToday) {
hoverText += `\nClaimed today: yes`;
hoverText += `\nCome back in: ${timeDif}`;
hoverText += `\nCome back in: ${timeDif} ${offsetString}`;
} else {
hoverText += `\nClaimed today: no`;
hoverText += `\nStreak lost in: ${timeDif}`;
hoverText += `\nStreak lost in: ${timeDif} ${offsetString}`;
}
if (profile.streakHourOffset === undefined) {
hoverText += `\n\nIf the streak reset time doesn't line up with your timezone, you can change it in Settings > Danger zone > Update streak hour offset.`;
}
}
}

View file

@ -20,6 +20,7 @@ import "./popups/support-popup";
import "./popups/contact-popup";
import "./popups/version-popup";
import "./popups/edit-preset-popup";
import "./popups/set-streak-hour-offset";
import "./popups/simple-popups";
import "./controllers/input-controller";
import "./ready";

View file

@ -0,0 +1,147 @@
import Ape from "../ape";
// import * as DB from "../db";
import * as Notifications from "../elements/notifications";
import * as Loader from "../elements/loader";
// import * as Settings from "../pages/settings";
import * as ConnectionState from "../states/connection";
import * as Skeleton from "./skeleton";
import { isPopupVisible } from "../utils/misc";
import { getSnapshot, setSnapshot } from "../db";
const wrapperId = "streakHourOffsetPopupWrapper";
export function show(): void {
if (!ConnectionState.get()) {
Notifications.add("You are offline", 0, {
duration: 2,
});
return;
}
Skeleton.append(wrapperId);
if (!isPopupVisible(wrapperId)) {
if (getSnapshot()?.streakHourOffset !== undefined) {
$(`#${wrapperId} .text`).html(
"You have already set your streak hour offset."
);
$(`#${wrapperId} input`).remove();
$(`#${wrapperId} .preview`).remove();
$(`#${wrapperId} .button`).remove();
} else {
updatePreview();
}
$(`#${wrapperId}`)
.stop(true, true)
.css("opacity", 0)
.removeClass("hidden")
.animate({ opacity: 1 }, 125, () => {
$(`#${wrapperId} input`).trigger("focus");
});
}
}
function updatePreview(): void {
const inputValue = $(`#${wrapperId} input`).val() as number;
const preview = $(`#${wrapperId} .preview`);
const date = new Date();
date.setUTCHours(0);
date.setUTCMinutes(0);
date.setUTCSeconds(0);
date.setUTCMilliseconds(0);
const newDate = new Date();
newDate.setUTCHours(0);
newDate.setUTCMinutes(0);
newDate.setUTCSeconds(0);
newDate.setUTCMilliseconds(0);
newDate.setHours(newDate.getHours() - -1 * inputValue); //idk why, but it only works when i subtract (so i have to negate inputValue)
preview.html(`
Current local reset time: ${date.toLocaleTimeString()}<br>
New local reset time: ${newDate.toLocaleTimeString()}
`);
}
function hide(): void {
if (isPopupVisible(wrapperId)) {
$(`#${wrapperId}`)
.stop(true, true)
.css("opacity", 1)
.animate(
{
opacity: 0,
},
125,
() => {
$(`#${wrapperId}`).addClass("hidden");
Skeleton.remove(wrapperId);
}
);
}
}
async function apply(): Promise<void> {
const value = parseInt($(`#${wrapperId} input`).val() as string);
if (isNaN(value)) {
Notifications.add("Streak hour offset must be a number", 0);
return;
}
if (value < -11 || value > 12) {
Notifications.add("Streak hour offset must be between -11 and 12", 0);
return;
}
Loader.show();
const response = await Ape.users.setStreakHourOffset(value);
Loader.hide();
if (response.status !== 200) {
Notifications.add(
"Failed to set streak hour offset: " + response.message,
-1
);
} else {
Notifications.add("Streak hour offset set", 1);
const snap = getSnapshot() as MonkeyTypes.Snapshot;
snap.streakHourOffset = value;
setSnapshot(snap);
hide();
}
}
$(`#${wrapperId}`).on("mousedown", (e) => {
if ($(e.target).attr("id") === wrapperId) {
hide();
}
});
$(`#${wrapperId} .button`).on("click", () => {
apply();
});
$(`#${wrapperId} input`).on("keypress", (e) => {
if (e.key === "Enter") {
apply();
}
});
$(".pageSettings .section.setStreakHourOffset").on(
"click",
"#setStreakHourOffset",
() => {
show();
}
);
$(`#${wrapperId} input`).on("input", () => {
updatePreview();
});
Skeleton.save(wrapperId);

View file

@ -566,6 +566,7 @@ declare namespace MonkeyTypes {
inboxUnreadSize: number;
streak: number;
maxStreak: number;
streakHourOffset?: number;
lbOptOut?: boolean;
}

View file

@ -33,7 +33,13 @@
<div class="badges"></div>
<div class="allBadges"></div>
<div class="joined" data-balloon-pos="up">-</div>
<div class="streak" data-balloon-pos="up">-</div>
<div
class="streak"
data-balloon-pos="up"
data-balloon-length="large"
>
-
</div>
</div>
<div class="levelAndBar">
<div class="level" data-balloon-pos="up">-</div>

View file

@ -2697,6 +2697,29 @@
<div class="button" tabindex="0" onclick="this.blur();">open</div>
</div>
</div>
<div class="section setStreakHourOffset needsAccount hidden">
<div class="groupTitle">
<i class="fas fa-clock"></i>
<span>set streak hour offset</span>
</div>
<div class="text">
Streaks reset at midnight UTC by default. If this is not convenient for
you (for example if it means that streaks reset in the middle of the
day), you can change the hour offset here.
<br />
<span class="red">You can only do this once!</span>
</div>
<div class="buttons">
<div
class="button danger"
id="setStreakHourOffset"
tabindex="0"
onclick="this.blur();"
>
update hour offset
</div>
</div>
</div>
<div class="section updateAccountName needsAccount hidden">
<div class="groupTitle">
<i class="fas fa-user"></i>

View file

@ -844,6 +844,25 @@
<div class="button"><i class="fas fa-plus"></i></div>
</div>
</div>
<div id="streakHourOffsetPopupWrapper" class="popupWrapper hidden">
<div id="streakHourOffsetPopup">
<div class="title">Set Streak Hour Offset</div>
<div class="text">
Streaks reset at midnight UTC by default. If this is not convenient for
you (for example if it means that streaks reset in the middle of the day),
you can change the hour offset here.
<br />
<br />
This will not take daylight savings time into consideration!
<br />
<br />
<span class="red">You can only do this once!</span>
</div>
<input type="number" min="-11" max="12" value="0" />
<div class="preview"></div>
<div class="button">set</div>
</div>
</div>
<div id="resultEditTagsPanelWrapper" class="popupWrapper hidden">
<div id="resultEditTagsPanel" resultid="">
<div class="buttons"></div>