Don't have an account ?
@@ -60,66 +54,71 @@
diff --git a/frontend/pages/settings/profile.vue b/frontend/pages/settings/profile.vue
index b45ba8f..6860014 100644
--- a/frontend/pages/settings/profile.vue
+++ b/frontend/pages/settings/profile.vue
@@ -8,37 +8,22 @@
-
![Profile Photo]()
+
-
-
+ @change="handleFileUpload" :disabled="true" />
@@ -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/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/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/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/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
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 e110cf6..75940e0 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;
@@ -199,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);