From 2d3aef5b94c60c3389a256ca5dd535365f5cd08c Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Tue, 3 Feb 2026 12:55:57 +0200 Subject: [PATCH 1/6] feat: #86ewfyjet Implement user timezone setting --- frontend/locales/en.json | 5 +- frontend/pages/auth/login.vue | 169 +++++++------- frontend/pages/settings/profile.vue | 321 +++++++++++++------------- frontend/stores/profile.ts | 17 +- frontend/types/database.type.ts | 1 + server/src/modules/auth/router.ts | 40 +++- server/src/modules/profile/db.ts | 1 + server/src/modules/profile/service.ts | 10 +- 8 files changed, 293 insertions(+), 271 deletions(-) diff --git a/frontend/locales/en.json b/frontend/locales/en.json index f968a99..48896b0 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -186,7 +186,10 @@ "reset-password": "Reset Password", "uploading": "Uploading...", "profile-updated": "Profile updated successfully", - "profile-update-failed": "Failed to update profile" + "profile-update-failed": "Failed to update profile", + "timezone": "Timezone", + "select_timezone": "Select Timezone", + "timezone_desc": "Select your local timezone so reviews appear at the correct time." }, "billing": { "billing": "Billing", diff --git a/frontend/pages/auth/login.vue b/frontend/pages/auth/login.vue index 0a84bb7..19a2003 100644 --- a/frontend/pages/auth/login.vue +++ b/frontend/pages/auth/login.vue @@ -3,50 +3,44 @@
background gradient
-
- coming soon object 1 - coming soon object 2 +
+ coming soon object 1 + coming soon object 2 coming soon object 3 polygon object
+ class="relative flex w-full max-w-[1502px] flex-col justify-between overflow-hidden rounded-md bg-white/60 backdrop-blur-lg dark:bg-black/50 lg:min-h-[700px] lg:flex-row lg:gap-10 xl:gap-0">
Cover Image
-
+
-

{{ t('auth.signin') }}

+

{{ + t('auth.signin') }}

Use one of your social accounts to {{ t('auth.signin') }} / {{ t('auth.signup') }}.

-
@@ -100,125 +82,136 @@ + // Call the store function + await profileStore.updateProfile(profileData); - diff --git a/frontend/stores/profile.ts b/frontend/stores/profile.ts index 7edd7ef..7af6028 100644 --- a/frontend/stores/profile.ts +++ b/frontend/stores/profile.ts @@ -89,8 +89,12 @@ export const useProfileStore = defineStore('profile', () => { .catch((error) => null); } - function updateProfile(profileData: { name?: string; profileImage?: File; preferences?: Record }) { - if (!profileData.name) { + function updateProfile(profileData: { name?: string; profileImage?: File; preferences?: Record; timeZone?: string }) { + const updatePayload: any = {}; + if (profileData.name) updatePayload.name = profileData.name; + if (profileData.timeZone) updatePayload.timeZone = profileData.timeZone; + + if (Object.keys(updatePayload).length === 0) { return Promise.resolve(userDetail.value); } @@ -101,12 +105,13 @@ export const useProfileStore = defineStore('profile', () => { query: { refId: authentication.user?.id, }, - update: { - name: profileData.name, - }, + update: updatePayload, }) .then((res) => { - userDetail.value!.name = profileData.name || ''; + if (userDetail.value) { + if (profileData.name) userDetail.value.name = profileData.name; + if (profileData.timeZone) userDetail.value.timeZone = profileData.timeZone; + } return res; }); } diff --git a/frontend/types/database.type.ts b/frontend/types/database.type.ts index 976c4cb..2045076 100644 --- a/frontend/types/database.type.ts +++ b/frontend/types/database.type.ts @@ -24,6 +24,7 @@ export interface ProfileType { refId: string; name: string; gPicture: string; + timeZone?: string; images: FileDocument[]; } diff --git a/server/src/modules/auth/router.ts b/server/src/modules/auth/router.ts index 5c5b1ad..3de1064 100644 --- a/server/src/modules/auth/router.ts +++ b/server/src/modules/auth/router.ts @@ -38,8 +38,13 @@ auth.get("/google", async (ctx) => { scope: SCOPE, // Pass through the redirect parameter as state to preserve it through OAuth flow state: ctx.query.redirect - ? JSON.stringify({ redirect: ctx.query.redirect }) - : undefined, + ? JSON.stringify({ + redirect: ctx.query.redirect, + timezone: ctx.query.timezone + }) + : JSON.stringify({ + timezone: ctx.query.timezone + }), }); ctx.response.redirect(url); @@ -82,6 +87,16 @@ auth.get("/google/code-login", async (ctx) => { } } + let timeZone: string | null = null; + if (ctx.query.state) { + try { + const state = JSON.parse(ctx.query.state as string); + timeZone = state.timezone; + } catch (error) { + // ignore + } + } + const { tokens } = await oauth2Client.getToken(ctx.query.code as string); if (!tokens) { @@ -124,6 +139,7 @@ auth.get("/google/code-login", async (ctx) => { refId: userId as string, gPicture: picture as string, name: name as string, + timeZone: timeZone || undefined, }, false ); @@ -185,17 +201,17 @@ auth.get("/google/access-token-login", async (ctx) => { // Initialize Leitner System if (registeredUser || (await userManager.getUserByIdentity(googleEmail, "email").catch(() => null))) { - // We need the userId. If registeredUser was null but we just registered, fetch ID? - // userManager.registerUser returns userId. - // Re-fetching user to get ID if we didn't have it (though we just registered it). - const user = await userManager.getUserByIdentity(googleEmail, "email"); - if (user) { - try { - await LeitnerService.ensureInitialized(user.id); - } catch (e) { - console.error("Failed to initialize Leitner System on token login", e); - } + // We need the userId. If registeredUser was null but we just registered, fetch ID? + // userManager.registerUser returns userId. + // Re-fetching user to get ID if we didn't have it (though we just registered it). + const user = await userManager.getUserByIdentity(googleEmail, "email"); + if (user) { + try { + await LeitnerService.ensureInitialized(user.id); + } catch (e) { + console.error("Failed to initialize Leitner System on token login", e); } + } } ctx.body = reply.create("s", { token }); diff --git a/server/src/modules/profile/db.ts b/server/src/modules/profile/db.ts index 6e33885..57190cc 100644 --- a/server/src/modules/profile/db.ts +++ b/server/src/modules/profile/db.ts @@ -15,6 +15,7 @@ const profileCollection = defineCollection({ gPicture: String, name: String, refId: String, + timeZone: String, images: [schemas.file], }, { timestamps: true } diff --git a/server/src/modules/profile/service.ts b/server/src/modules/profile/service.ts index 643e9eb..c64cf10 100644 --- a/server/src/modules/profile/service.ts +++ b/server/src/modules/profile/service.ts @@ -5,10 +5,11 @@ interface UserProfile { refId: string; gPicture?: string; name?: string; + timeZone?: string; } export const updateUserProfile = async ( - { refId, gPicture, name }: UserProfile, + { refId, gPicture, name, timeZone }: UserProfile, rewrite = false ): Promise => { const profileCollection = getCollection(DATABASE, PROFILE_COLLECTION); @@ -16,11 +17,14 @@ export const updateUserProfile = async ( const isExist = await profileCollection.findOne({ refId }); if (!isExist) { - await profileCollection.insertMany({ refId, gPicture, name }); + await profileCollection.insertMany({ refId, gPicture, name, timeZone }); return; } if (rewrite) { - await profileCollection.updateOne({ refId }, { gPicture, name }); + await profileCollection.updateOne({ refId }, { gPicture, name, timeZone }); + } else if (timeZone && !(isExist as any).timeZone) { + // Only set timezone if it's not already set (ignore browser timezone on subsequent logins) + await profileCollection.updateOne({ refId }, { $set: { timeZone } }); } }; \ No newline at end of file From b3f240edeaefe3fd4639e96a497e34fe98a39db8 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Tue, 3 Feb 2026 12:56:45 +0200 Subject: [PATCH 2/6] feat: #86ewfyjet Implement timezone support for Leitner box review scheduling and display. --- frontend/components/Leitner/LeitnerSettings.vue | 3 ++- server/src/modules/leitner_box/service.ts | 17 ++++++++++++++--- server/src/modules/schedule/service.ts | 7 +++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/frontend/components/Leitner/LeitnerSettings.vue b/frontend/components/Leitner/LeitnerSettings.vue index 80c1c6c..1dbd8b6 100644 --- a/frontend/components/Leitner/LeitnerSettings.vue +++ b/frontend/components/Leitner/LeitnerSettings.vue @@ -22,7 +22,8 @@ $t('smart_review.next_session') }}

{{ localSettings.reviewHour }}:00

{{ localSettings.reviewInterval === 1 ? $t('smart_review.daily') : - $t('smart_review.every_days_reminder', { days: localSettings.reviewInterval }) }}

+ $t('smart_review.every_days_reminder', { days: localSettings.reviewInterval }) }} ({{ + profileStore.userDetail?.timeZone || 'UTC' }})

diff --git a/server/src/modules/leitner_box/service.ts b/server/src/modules/leitner_box/service.ts index 9be6739..1fb163d 100644 --- a/server/src/modules/leitner_box/service.ts +++ b/server/src/modules/leitner_box/service.ts @@ -1,5 +1,5 @@ import { LeitnerItem } from "./db"; -import { DATABASE, PHRASE_COLLECTION, DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION, BUNDLE_COLLECTION } from "../../config"; +import { DATABASE, PHRASE_COLLECTION, DATABASE_LEITNER, LEITNER_SYSTEM_COLLECTION, BUNDLE_COLLECTION, PROFILE_COLLECTION } from "../../config"; import { getCollection } from "@modular-rest/server"; import { Document } from "mongoose"; import { BoardService } from "../board/service"; @@ -51,7 +51,13 @@ export class LeitnerService { this.syncScheduledJob(userId).catch(e => console.error(`[LeitnerService] Async job sync failed for ${userId}:`, e)); } - return settings; + const profileCol = await getCollection(DATABASE, PROFILE_COLLECTION); + const profile = await profileCol.findOne({ refId: userId }) as any; + + return { + ...settings, + timeZone: profile?.timeZone + }; } static async ensureInitialized(userId: string) { @@ -462,6 +468,10 @@ export class LeitnerService { // Cron: 0 minute, reviewHour hour, every X days, *, * const cron = `0 ${reviewHour} */${reviewInterval} * *`; + const profileCol = await getCollection(DATABASE, PROFILE_COLLECTION); + const profile = await profileCol.findOne({ refId: userId }) as any; + const timeZone = profile?.timeZone; + await ScheduleService.createJob( `leitner-review-${userId}`, 'leitner-review-job', @@ -469,7 +479,8 @@ export class LeitnerService { cronExpression: cron, args: { userId }, jobType: 'recurrent', - catchUp: true + catchUp: true, + timeZone } ); diff --git a/server/src/modules/schedule/service.ts b/server/src/modules/schedule/service.ts index e110cf6..650faeb 100644 --- a/server/src/modules/schedule/service.ts +++ b/server/src/modules/schedule/service.ts @@ -38,6 +38,7 @@ export class ScheduleService { executionType?: "Immediate" | "normal"; jobType?: "recurrent" | "once"; catchUp?: boolean; + timeZone?: string; } ) { const { @@ -46,7 +47,8 @@ export class ScheduleService { args = {}, executionType = "normal", jobType = "recurrent", - catchUp = false + catchUp = false, + timeZone } = options; const collection = await getCollection(DATABASE_SCHEDULE, SCHEDULE_JOB_COLLECTION); @@ -62,6 +64,7 @@ export class ScheduleService { jobType, state: "scheduled" as const, catchUp, + timeZone, }; if (existing) { @@ -98,7 +101,7 @@ export class ScheduleService { schedule.scheduledJobs[jobData.name].cancel(); } - const scheduleParam = jobData.jobType === "once" ? jobData.runAt : jobData.cronExpression; + const scheduleParam = jobData.jobType === "once" ? jobData.runAt : (jobData.timeZone ? { rule: jobData.cronExpression, tz: jobData.timeZone } : jobData.cronExpression); if (!scheduleParam) { console.warn(`[ScheduleService] No schedule parameter for job: ${jobData.name}`); return; From bb7d15526a4573f9000a5286a15f13ac060ddf2e Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Tue, 3 Feb 2026 13:40:19 +0200 Subject: [PATCH 3/6] fix(schedule): #86ewfyjet add timezone support testing and fix catch-up logic --- .../leitner_box/__tests__/service.test.ts | 92 +++++++++++++++++- .../modules/profile/__tests__/service.test.ts | 96 +++++++++++++++++++ .../schedule/__tests__/service.test.ts | 90 +++++++++++++++++ server/src/modules/schedule/db.ts | 2 + server/src/modules/schedule/service.ts | 2 +- 5 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 server/src/modules/profile/__tests__/service.test.ts diff --git a/server/src/modules/leitner_box/__tests__/service.test.ts b/server/src/modules/leitner_box/__tests__/service.test.ts index 1134ff7..bc075be 100644 --- a/server/src/modules/leitner_box/__tests__/service.test.ts +++ b/server/src/modules/leitner_box/__tests__/service.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect, jest, beforeEach } from "@jest/globals"; import { LeitnerService } from "../service"; import { getCollection } from "@modular-rest/server"; +import { ScheduleService } from "../../schedule/service"; +import { DATABASE, PROFILE_COLLECTION } from "../../../config"; + // Mock modular-rest/server jest.mock("@modular-rest/server", () => ({ getCollection: jest.fn(), @@ -11,8 +14,26 @@ jest.mock("@modular-rest/server", () => ({ schemas: { file: {} }, })); +// Mock ScheduleService +jest.mock("../../schedule/service", () => ({ + ScheduleService: { + createJob: jest.fn(), + register: jest.fn(), + }, +})); + +jest.mock("../../../config", () => ({ + DATABASE: "db", + PROFILE_COLLECTION: "profile", + DATABASE_LEITNER: "leitner_db", + LEITNER_SYSTEM_COLLECTION: "leitner_col", + PHRASE_COLLECTION: "phrase", + BUNDLE_COLLECTION: "bundle", +})); + describe("LeitnerService", () => { let mockCollection: any; + let mockProfileCollection: any; beforeEach(() => { jest.clearAllMocks(); @@ -21,7 +42,18 @@ describe("LeitnerService", () => { create: jest.fn(), updateOne: jest.fn(), }; - (getCollection as any).mockResolvedValue(mockCollection); + // Default mock for getCollection to return generic collection + (getCollection as any).mockImplementation((db: string, col: string) => { + if (col === PROFILE_COLLECTION) { + if (!mockProfileCollection) { + mockProfileCollection = { + findOne: jest.fn(), + }; + } + return Promise.resolve(mockProfileCollection); + } + return Promise.resolve(mockCollection); + }); }); describe("getSettings", () => { @@ -70,4 +102,62 @@ describe("LeitnerService", () => { expect(mockCollection.create).not.toHaveBeenCalled(); }); }); + + describe("syncScheduledJob (via updateSettings)", () => { + it("should schedule job with user timezone from profile", async () => { + const userId = "user1"; + const timeZone = "Asia/Tokyo"; + const settings = { reviewInterval: 1, reviewHour: 9 }; + + // Mock existing system + // We need to simulate the DB update: + // 1. First call (updateSettings -> getSystem) returns old settings. + // 2. Second call (syncScheduledJob -> getSettings -> getSystem) should return NEW settings (reviewHour: 10). + mockCollection.findOne + .mockResolvedValueOnce({ _id: "sys1", userId, settings }) // First call + .mockResolvedValueOnce({ _id: "sys1", userId, settings: { ...settings, reviewHour: 10 } }); // Second call + + // Mock profile with timezone + mockProfileCollection = { + findOne: jest.fn().mockResolvedValue({ refId: userId, timeZone }), + }; + + // We use updateSettings to trigger syncScheduledJob since it's private + await LeitnerService.updateSettings(userId, { reviewHour: 10 }); + + expect(ScheduleService.createJob).toHaveBeenCalledWith( + `leitner-review-${userId}`, + "leitner-review-job", + expect.objectContaining({ + timeZone: timeZone, + cronExpression: expect.stringContaining("10"), // 10 from update + }) + ); + }); + + it("should use default setting if no timezone set", async () => { + const userId = "user2"; + // Mock existing system + mockCollection.findOne.mockResolvedValue({ + _id: "sys2", + userId, + settings: { reviewInterval: 1, reviewHour: 9 } + }); + + // Mock profile WITHOUT timezone + mockProfileCollection = { + findOne: jest.fn().mockResolvedValue({ refId: userId }), + }; + + await LeitnerService.updateSettings(userId, { reviewHour: 10 }); + + expect(ScheduleService.createJob).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + timeZone: undefined, + }) + ); + }); + }); }); diff --git a/server/src/modules/profile/__tests__/service.test.ts b/server/src/modules/profile/__tests__/service.test.ts new file mode 100644 index 0000000..5f090ad --- /dev/null +++ b/server/src/modules/profile/__tests__/service.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { updateUserProfile } from "../service"; +import { getCollection } from "@modular-rest/server"; + +// Mock modular-rest/server +jest.mock("@modular-rest/server", () => ({ + getCollection: jest.fn(), + Schema: class { }, + defineCollection: jest.fn(), +})); + +jest.mock("../../../config", () => ({ + DATABASE: "test_db", + PROFILE_COLLECTION: "profile_col", +})); + +describe("ProfileService", () => { + let mockCollection: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockCollection = { + findOne: jest.fn(), + insertMany: jest.fn(), + updateOne: jest.fn(), + }; + (getCollection as any).mockReturnValue(mockCollection); + }); + + describe("updateUserProfile", () => { + const refId = "user123"; + const gPicture = "pic.jpg"; + const name = "Test User"; + + it("should create a new profile with timezone if it does not exist", async () => { + mockCollection.findOne.mockResolvedValue(null); + + await updateUserProfile({ refId, gPicture, name, timeZone: "UTC" }); + + expect(mockCollection.insertMany).toHaveBeenCalledWith({ + refId, + gPicture, + name, + timeZone: "UTC", + }); + }); + + it("should update profile with new timezone if full rewrite is requested", async () => { + mockCollection.findOne.mockResolvedValue({ refId, timeZone: "Old/Zone" }); + + await updateUserProfile({ refId, gPicture, name, timeZone: "New/Zone" }, true); + + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { refId }, + { gPicture, name, timeZone: "New/Zone" } + ); + }); + + it("should set timezone if user exists but has no timezone set (login flow)", async () => { + // simulate existing user with no timezone + mockCollection.findOne.mockResolvedValue({ refId }); + + await updateUserProfile({ refId, gPicture, name, timeZone: "New/Zone" }, false); + + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { refId }, + { $set: { timeZone: "New/Zone" } } + ); + }); + + it("should NOT update timezone if user already has one (login flow)", async () => { + // simulate valid existing timezone + mockCollection.findOne.mockResolvedValue({ refId, timeZone: "Existing/Zone" }); + + await updateUserProfile({ refId, gPicture, name, timeZone: "New/Zone" }, false); + + // Should NOT call updateOne for timezone + expect(mockCollection.updateOne).not.toHaveBeenCalledWith( + { refId }, + { $set: { timeZone: "New/Zone" } } + ); + // It might call other updates if we had passed them differently, but based on current logic: + // if rewrite=false, it only checks 'else if (timeZone...)'. + // Since it has existing timezone, it enters NO block. + expect(mockCollection.updateOne).not.toHaveBeenCalled(); + }); + + it("should not update timezone if none provided", async () => { + mockCollection.findOne.mockResolvedValue({ refId, timeZone: "Existing/Zone" }); + + await updateUserProfile({ refId, gPicture, name }, false); + + expect(mockCollection.updateOne).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/modules/schedule/__tests__/service.test.ts b/server/src/modules/schedule/__tests__/service.test.ts index d282e9f..8fdf73e 100644 --- a/server/src/modules/schedule/__tests__/service.test.ts +++ b/server/src/modules/schedule/__tests__/service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals import { ScheduleService } from "../service"; import schedule from "node-schedule"; import { getCollection } from "@modular-rest/server"; +import parser from "cron-parser"; // Mock modular-rest/server jest.mock("@modular-rest/server", () => ({ @@ -11,6 +12,15 @@ jest.mock("@modular-rest/server", () => ({ Permission: class { }, })); +// Mock cron-parser +jest.mock("cron-parser", () => ({ + parseExpression: jest.fn(() => ({ + prev: () => ({ + toDate: () => new Date(Date.now() - 1000) // Default to 1 second ago for tests + }) + })) +})); + // Mock node-schedule jest.mock("node-schedule", () => ({ scheduleJob: jest.fn(), @@ -96,6 +106,22 @@ describe("ScheduleService", () => { ); expect(schedule.scheduleJob).toHaveBeenCalled(); }); + + it("should save timezone to database", async () => { + mockCollection.findOne.mockResolvedValue(null); + const options = { + cronExpression: "* * * * *", + functionId: "test-fn", + timeZone: "Asia/Tokyo" + }; + + await ScheduleService.createJob("tz-create-job", "test-fn", options); + + expect(mockCollection.create).toHaveBeenCalledWith(expect.objectContaining({ + name: "tz-create-job", + timeZone: "Asia/Tokyo" + })); + }); }); describe("deleteJob", () => { @@ -350,5 +376,69 @@ describe("ScheduleService", () => { executedTime: expect.any(Date) })); }); + + it("should use timezone when calculating past occurrences", async () => { + const pastDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + const mockJobs = [ + { + name: "catchup-tz-job", + cronExpression: "0 9 */1 * *", + functionId: "fn", + jobType: "recurrent", + catchUp: true, + lastRun: pastDate, + createdAt: pastDate, + timeZone: "America/New_York" + } + ]; + mockCollection.find.mockResolvedValue(mockJobs); + mockCollection.findOneAndUpdate.mockResolvedValue(null); + + await ScheduleService.init(); + + expect(parser.parseExpression).toHaveBeenCalledWith( + "0 9 */1 * *", + expect.objectContaining({ tz: "America/New_York" }) + ); + }); + }); + + describe("TimeZone Support", () => { + it("should schedule job with timezone if provided", async () => { + const jobData = { + name: "tz-job", + functionId: "fn", + cronExpression: "0 0 12 * * *", + timeZone: "America/New_York", + state: "scheduled", + jobType: "recurrent", + }; + + await ScheduleService.scheduleJobInternal(jobData); + + expect(schedule.scheduleJob).toHaveBeenCalledWith( + "tz-job", + { rule: "0 0 12 * * *", tz: "America/New_York" }, + expect.any(Function) + ); + }); + + it("should schedule normal cron string if no timezone provided", async () => { + const jobData = { + name: "no-tz-job", + functionId: "fn", + cronExpression: "0 0 12 * * *", + state: "scheduled", + jobType: "recurrent", + }; + + await ScheduleService.scheduleJobInternal(jobData); + + expect(schedule.scheduleJob).toHaveBeenCalledWith( + "no-tz-job", + "0 0 12 * * *", + expect.any(Function) + ); + }); }); }); diff --git a/server/src/modules/schedule/db.ts b/server/src/modules/schedule/db.ts index d237a98..5d84a52 100644 --- a/server/src/modules/schedule/db.ts +++ b/server/src/modules/schedule/db.ts @@ -12,6 +12,7 @@ interface ScheduleJobSchema { lastRun?: Date; state: "scheduled" | "queued" | "executing" | "executed" | "failed"; catchUp?: boolean; + timeZone?: string; } const scheduleJobSchema = new Schema( @@ -38,6 +39,7 @@ const scheduleJobSchema = new Schema( default: "scheduled", }, catchUp: { type: Boolean, default: false }, + timeZone: { type: String }, }, { timestamps: true } ); diff --git a/server/src/modules/schedule/service.ts b/server/src/modules/schedule/service.ts index 650faeb..75940e0 100644 --- a/server/src/modules/schedule/service.ts +++ b/server/src/modules/schedule/service.ts @@ -202,7 +202,7 @@ export class ScheduleService { if (job.jobType === "recurrent" && shouldCatchUp && job.cronExpression) { try { - const interval = parser.parseExpression(job.cronExpression); + const interval = parser.parseExpression(job.cronExpression, job.timeZone ? { tz: job.timeZone } : undefined); const lastOccurrence = interval.prev().toDate(); const lastRun = job.lastRun ? new Date(job.lastRun) : new Date(job.createdAt); From c2348f2e200de41eeee00826bfc9d536fc15b9eb Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Tue, 3 Feb 2026 15:14:25 +0200 Subject: [PATCH 4/6] feat: #86ewfyjet Enhance Leitner settings with timezone clarification for review intervals and add related translations. --- frontend/components/Leitner/LeitnerSettings.vue | 15 ++++++++++++--- frontend/locales/en.json | 2 ++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/components/Leitner/LeitnerSettings.vue b/frontend/components/Leitner/LeitnerSettings.vue index 1dbd8b6..1ca8165 100644 --- a/frontend/components/Leitner/LeitnerSettings.vue +++ b/frontend/components/Leitner/LeitnerSettings.vue @@ -112,7 +112,7 @@ $t('smart_review.global_daily_limit') }}

{{ $t('smart_review.max_phrases_desc') - }}

+ }}

{{ $t('smart_review.review_interval') }}
-

{{ - $t('smart_review.session_frequency_desc') }}

+

+ {{ $t('smart_review.session_frequency_desc') }} +
+ + {{ $t('smart_review.based_on_local_time') }} + ( + {{ $t('smart_review.setup_timezone') }} + ) + +

{{ $t('smart_review.each') }} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 48896b0..c59fd92 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -263,6 +263,8 @@ "session_frequency_desc": "Session frequency and time", "each": "Each", "day_at": "day at", + "based_on_local_time": "Time is based on your local time", + "setup_timezone": "Check TimeZone", "cards": "Cards", "entrance": "Entrance", "mature": "Mature", From f747592008985a60639b4ab996bf73a2ce071c9e Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Wed, 4 Feb 2026 12:37:01 +0200 Subject: [PATCH 5/6] feat: #86ewfyjet Implement automatic Leitner box schedule resynchronization when a user's timezone is updated. --- server/src/modules/leitner_box/service.ts | 5 + .../profile/__tests__/triggers.test.ts | 93 +++++++++++++++++++ server/src/modules/profile/db.ts | 3 + server/src/modules/profile/triggers.ts | 55 +++++++++++ server/src/modules/schedule/service.ts | 2 +- 5 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 server/src/modules/profile/__tests__/triggers.test.ts create mode 100644 server/src/modules/profile/triggers.ts diff --git a/server/src/modules/leitner_box/service.ts b/server/src/modules/leitner_box/service.ts index 1fb163d..e664c4a 100644 --- a/server/src/modules/leitner_box/service.ts +++ b/server/src/modules/leitner_box/service.ts @@ -486,6 +486,11 @@ export class LeitnerService { this.syncedUsers.add(userId.toString()); } + + static async resyncSchedule(userId: string) { + this.syncedUsers.delete(userId.toString()); + await this.syncScheduledJob(userId); + } } // Register global schedule job diff --git a/server/src/modules/profile/__tests__/triggers.test.ts b/server/src/modules/profile/__tests__/triggers.test.ts new file mode 100644 index 0000000..38ab90c --- /dev/null +++ b/server/src/modules/profile/__tests__/triggers.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, jest, afterEach } from "@jest/globals"; +import triggers from "../triggers"; +import { LeitnerService } from "../../leitner_box/service"; + +// Mock LeitnerService +jest.mock("../../leitner_box/service", () => ({ + LeitnerService: { + resyncSchedule: jest.fn(), + }, +})); + +// Mock DatabaseTrigger class since triggers.ts imports it +jest.mock("@modular-rest/server", () => ({ + DatabaseTrigger: class { + constructor(public operation: string, public callback: (context: any) => void) { } + }, +})); + +describe("Profile Triggers", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should trigger resyncSchedule when timeZone is updated in context.update (updateOne scenario)", async () => { + const updateOneTrigger = triggers.find((t: any) => t.operation === "update-one"); + expect(updateOneTrigger).toBeDefined(); + + const mockContext = { + query: { refId: "user123_q" }, + update: { $set: { timeZone: "New/Zone" } }, + queryResult: { modifiedCount: 1 } + }; + + await (updateOneTrigger as any).callback(mockContext); + + expect(LeitnerService.resyncSchedule).toHaveBeenCalledWith("user123_q"); + }); + + it("should NOT trigger resyncSchedule when timeZone is NOT updated (updateOne scenario)", async () => { + const updateOneTrigger = triggers.find((t: any) => t.operation === "update-one"); + + const mockContext = { + query: { refId: "user123_no_sync" }, + update: { $set: { name: "New Name" } }, // Timezone not touched + queryResult: { modifiedCount: 1 } + }; + + await (updateOneTrigger as any).callback(mockContext); + + expect(LeitnerService.resyncSchedule).not.toHaveBeenCalledWith("user123_no_sync"); + }); + + it("should trigger resyncSchedule when timeZone is updated directly (flat update object)", async () => { + const updateOneTrigger = triggers.find((t: any) => t.operation === "update-one"); + + const mockContext = { + query: { refId: "user123_flat" }, + update: { timeZone: "New/Zone" }, + queryResult: { modifiedCount: 1 } + }; + + await (updateOneTrigger as any).callback(mockContext); + + expect(LeitnerService.resyncSchedule).toHaveBeenCalledWith("user123_flat"); + }); + + it("should trigger resyncSchedule for find-one-and-update when timezone changes", async () => { + const findTrigger = triggers.find((t: any) => t.operation === "find-one-and-update"); + expect(findTrigger).toBeDefined(); + + const mockContext = { + update: { $set: { timeZone: "New/Zone" } }, + queryResult: { refId: "user789" } + }; + + await (findTrigger as any).callback(mockContext); + + expect(LeitnerService.resyncSchedule).toHaveBeenCalledWith("user789"); + }); + + it("should NOT trigger resyncSchedule for find-one-and-update if timezone not changed", async () => { + const findTrigger = triggers.find((t: any) => t.operation === "find-one-and-update"); + + const mockContext = { + update: { $set: { name: "Other" } }, + queryResult: { refId: "user789_no" } + }; + + await (findTrigger as any).callback(mockContext); + + expect(LeitnerService.resyncSchedule).not.toHaveBeenCalledWith("user789_no"); + }); +}); diff --git a/server/src/modules/profile/db.ts b/server/src/modules/profile/db.ts index 57190cc..6bf39e8 100644 --- a/server/src/modules/profile/db.ts +++ b/server/src/modules/profile/db.ts @@ -7,6 +7,8 @@ import { import { DATABASE, PROFILE_COLLECTION } from "../../config"; +import triggers from "./triggers"; + const profileCollection = defineCollection({ database: DATABASE, collection: PROFILE_COLLECTION, @@ -29,6 +31,7 @@ const profileCollection = defineCollection({ ownerIdField: "refId", }), ], + triggers: triggers, }); module.exports = [profileCollection]; diff --git a/server/src/modules/profile/triggers.ts b/server/src/modules/profile/triggers.ts new file mode 100644 index 0000000..8e29c71 --- /dev/null +++ b/server/src/modules/profile/triggers.ts @@ -0,0 +1,55 @@ +import { DatabaseTrigger } from "@modular-rest/server"; +import { LeitnerService } from "../leitner_box/service"; + +/** + * Synchronizes the Leitner schedule when a user's timezone changes. + * + * Use Case: + * A user travels from London to Tokyo and updates their profile timezone. + * Without this sync, their daily review notification would still trigger at 10 AM London time (which is evening in Tokyo). + * This trigger detects the change and reschedules the job to 10 AM Tokyo time immediately. + */ +const syncScheduleOnTimezoneChange = async (context: any) => { + const { queryResult, query, update } = context; + + // Optimization: Only sync if timeZone is being updated + const isTimezoneUpdated = + (update && update.timeZone) || + (update && update.$set && update.$set.timeZone); + + if (!isTimezoneUpdated) { + return; + } + + try { + // If we have a document with refId (standard profile doc) + let refId = null; + + if (queryResult && queryResult.refId) { + refId = queryResult.refId; + } + + // If updateOne was used, the filter is usually in context.query (modular-rest specific) + if (!refId && query && query.refId) { + refId = query.refId; + } + + if (refId) { + console.log(`[ProfileTrigger] Syncing schedule for user ${refId}`); + await LeitnerService.resyncSchedule(refId); + } + } catch (err) { + console.error("[ProfileTrigger] Error syncing schedule:", err); + } +}; + +const scheduleSyncTrigger = new DatabaseTrigger("update-one", async (context) => { + await syncScheduleOnTimezoneChange(context); +}); + +// We also want to catch findOneAndUpdate if used. +const scheduleSyncTriggerFindAndModify = new DatabaseTrigger("find-one-and-update", async (context) => { + await syncScheduleOnTimezoneChange(context); +}); + +export default [scheduleSyncTrigger, scheduleSyncTriggerFindAndModify]; diff --git a/server/src/modules/schedule/service.ts b/server/src/modules/schedule/service.ts index 75940e0..d3a03d2 100644 --- a/server/src/modules/schedule/service.ts +++ b/server/src/modules/schedule/service.ts @@ -70,7 +70,7 @@ export class ScheduleService { if (existing) { await collection.updateOne( { name }, - { $set: { cronExpression, runAt, functionId, args, executionType, jobType, state: "scheduled", catchUp } } + { $set: { cronExpression, runAt, functionId, args, executionType, jobType, state: "scheduled", catchUp, timeZone } } ); this.cancelJob(name); } else { From b2dc50c2705a5db234ee22456357bc829ff675cb Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Wed, 4 Feb 2026 14:08:44 +0200 Subject: [PATCH 6/6] feat: #86ewfyjet Implement a new TimezonePicker component and integrate it into the profile settings page, replacing the native select dropdown. --- .../components/Leitner/LeitnerSettings.vue | 6 +- frontend/components/common/TimezonePicker.vue | 170 ++++++++++++++++++ frontend/locales/en.json | 4 +- frontend/pages/settings/profile.vue | 8 +- 4 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 frontend/components/common/TimezonePicker.vue diff --git a/frontend/components/Leitner/LeitnerSettings.vue b/frontend/components/Leitner/LeitnerSettings.vue index 1ca8165..54b2d60 100644 --- a/frontend/components/Leitner/LeitnerSettings.vue +++ b/frontend/components/Leitner/LeitnerSettings.vue @@ -112,7 +112,7 @@ $t('smart_review.global_daily_limit') }}

{{ $t('smart_review.max_phrases_desc') - }}

+ }}

{{ $t('smart_review.based_on_local_time') }} - ( {{ $t('smart_review.setup_timezone') }} - ) +

diff --git a/frontend/components/common/TimezonePicker.vue b/frontend/components/common/TimezonePicker.vue new file mode 100644 index 0000000..a6dcd28 --- /dev/null +++ b/frontend/components/common/TimezonePicker.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/frontend/locales/en.json b/frontend/locales/en.json index c59fd92..acaff00 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -189,7 +189,8 @@ "profile-update-failed": "Failed to update profile", "timezone": "Timezone", "select_timezone": "Select Timezone", - "timezone_desc": "Select your local timezone so reviews appear at the correct time." + "timezone_desc": "Select your local timezone so reviews appear at the correct time.", + "current_timezone": "Current Timezone" }, "billing": { "billing": "Billing", @@ -207,6 +208,7 @@ "ai-coaching": "AI Coaching", "or": "or", "cancel": "Cancel", + "confirm": "Confirm", "save": "Save", "delete": "Delete", "edit": "Edit", diff --git a/frontend/pages/settings/profile.vue b/frontend/pages/settings/profile.vue index 6860014..729c1b3 100644 --- a/frontend/pages/settings/profile.vue +++ b/frontend/pages/settings/profile.vue @@ -52,12 +52,7 @@
- +

{{ t('profile.timezone_desc') }}

@@ -85,6 +80,7 @@ import { ref, computed, onMounted } from 'vue'; import { useProfileStore } from '~/stores/profile'; import { Card, Input, Button, CheckboxInput } from '@codebridger/lib-vue-components/elements.ts'; +import TimezonePicker from '~/components/common/TimezonePicker.vue'; import { toastSuccess, toastError } from '@codebridger/lib-vue-components/toast.ts'; const profileStore = useProfileStore();