diff --git a/services/web/app/src/Features/Project/UserSettingsHelper.mjs b/services/web/app/src/Features/Project/UserSettingsHelper.mjs index 03c946d7f7..285305137c 100644 --- a/services/web/app/src/Features/Project/UserSettingsHelper.mjs +++ b/services/web/app/src/Features/Project/UserSettingsHelper.mjs @@ -30,6 +30,33 @@ async function getEnableNewEditorLegacyDefault(req, res, user) { return false } +const SYSTEM_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 1, 15, 12, 0, 0)) // 12pm GMT on February 15, 2026 + +async function getOverallTheme(req, res, user) { + if (user.ace.overallTheme != null) { + return user.ace.overallTheme + } + + if (user.signUpDate < SYSTEM_THEME_USER_CUTOFF_DATE) { + // default / dark + return '' + } + + const systemOverallSplitTestAssignment = + await SplitTestHandler.promises.getAssignment( + req, + res, + 'new-user-system-overall-theme' + ) + + if (systemOverallSplitTestAssignment.variant === 'system') { + return 'system' + } + + // default / dark + return '' +} + async function buildUserSettings(req, res, user) { const defaultLegacyEnableNewEditor = await getEnableNewEditorLegacyDefault( req, @@ -53,7 +80,7 @@ async function buildUserSettings(req, res, user) { syntaxValidation: user.ace.syntaxValidation, fontFamily: user.ace.fontFamily || 'lucida', lineHeight: user.ace.lineHeight || 'normal', - overallTheme: user.ace.overallTheme, + overallTheme: await getOverallTheme(req, res, user), mathPreview: user.ace.mathPreview, breadcrumbs: user.ace.breadcrumbs, referencesSearchMode: user.ace.referencesSearchMode, diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index 454d378622..9b325af27f 100644 --- a/services/web/app/src/models/User.mjs +++ b/services/web/app/src/models/User.mjs @@ -77,7 +77,7 @@ export const UserSchema = new Schema( ace: { mode: { type: String, default: 'none' }, theme: { type: String, default: 'textmate' }, - overallTheme: { type: String, default: '' }, + overallTheme: { type: String }, // When overallTheme is `system`, we switch between `lightTheme` and `darkTheme` based on system settings // When overallTheme is `light-` or empty, we use the `theme` option. lightTheme: { type: String, default: 'textmate' }, diff --git a/services/web/test/unit/src/Project/UserSettingsHelper.test.mjs b/services/web/test/unit/src/Project/UserSettingsHelper.test.mjs new file mode 100644 index 0000000000..1077eeaeed --- /dev/null +++ b/services/web/test/unit/src/Project/UserSettingsHelper.test.mjs @@ -0,0 +1,165 @@ +import { vi, expect } from 'vitest' +import sinon from 'sinon' +const modulePath = '../../../../app/src/Features/Project/UserSettingsHelper.mjs' + +describe('UserSettingsHelper', function () { + beforeEach(async function (ctx) { + ctx.SplitTestHandler = { + promises: { getAssignment: sinon.stub() }, + } + + vi.doMock( + '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs', + () => ({ + default: ctx.SplitTestHandler, + }) + ) + + ctx.req = { query: {} } + ctx.res = {} + ctx.UserSettingsHelper = (await import(modulePath)).default + }) + + describe('for user with overall theme set to value', function () { + beforeEach(async function (ctx) { + const user = { + ace: { + overallTheme: 'light', + }, + signUpDate: new Date('2022-01-01'), + } + + ctx.settings = await ctx.UserSettingsHelper.buildUserSettings( + ctx.req, + ctx.res, + user + ) + }) + + it('should return the user settings with the correct overall theme', function (ctx) { + expect(ctx.settings.overallTheme).toBe('light') + }) + + it('should not check split test', function (ctx) { + expect(ctx.SplitTestHandler.promises.getAssignment).not.toHaveBeenCalled + }) + }) + + describe('for user with no overall theme set', function () { + describe('for new users in treatment group', function () { + beforeEach(async function (ctx) { + const user = { + ace: {}, + signUpDate: new Date('2026-02-16T00:00:00Z'), + } + + ctx.SplitTestHandler.promises.getAssignment + .withArgs(ctx.req, ctx.res, 'new-user-system-overall-theme') + .resolves({ + variant: 'system', + }) + + ctx.settings = await ctx.UserSettingsHelper.buildUserSettings( + ctx.req, + ctx.res, + user + ) + }) + + it('should default to system theme', function (ctx) { + expect(ctx.settings.overallTheme).toBe('system') + }) + + it('should check split test', function (ctx) { + expect(ctx.SplitTestHandler.promises.getAssignment).toHaveBeenCalled + }) + }) + + describe('for new users in control group', function () { + beforeEach(async function (ctx) { + const user = { + ace: {}, + signUpDate: new Date('2026-02-16T00:00:00Z'), + } + + ctx.SplitTestHandler.promises.getAssignment + .withArgs(ctx.req, ctx.res, 'new-user-system-overall-theme') + .resolves({ + variant: 'default', + }) + + ctx.settings = await ctx.UserSettingsHelper.buildUserSettings( + ctx.req, + ctx.res, + user + ) + }) + + it('should default to dark theme', function (ctx) { + expect(ctx.settings.overallTheme).toBe('') + }) + + it('should check split test', function (ctx) { + expect(ctx.SplitTestHandler.promises.getAssignment).toHaveBeenCalled + }) + }) + + describe('for old users in control group', function () { + beforeEach(async function (ctx) { + const user = { + ace: {}, + signUpDate: new Date('2025-02-15T00:00:00Z'), + } + + ctx.SplitTestHandler.promises.getAssignment + .withArgs(ctx.req, ctx.res, 'new-user-system-overall-theme') + .resolves({ + variant: 'default', + }) + + ctx.settings = await ctx.UserSettingsHelper.buildUserSettings( + ctx.req, + ctx.res, + user + ) + }) + + it('should default to dark theme', function (ctx) { + expect(ctx.settings.overallTheme).toBe('') + }) + + it('should not check split test', function (ctx) { + expect(ctx.SplitTestHandler.promises.getAssignment).not.toHaveBeenCalled + }) + }) + + describe('for old users in treatment group', function () { + beforeEach(async function (ctx) { + const user = { + ace: {}, + signUpDate: new Date('2025-02-15T00:00:00Z'), + } + + ctx.SplitTestHandler.promises.getAssignment + .withArgs(ctx.req, ctx.res, 'new-user-system-overall-theme') + .resolves({ + variant: 'system', + }) + + ctx.settings = await ctx.UserSettingsHelper.buildUserSettings( + ctx.req, + ctx.res, + user + ) + }) + + it('should default to dark theme', function (ctx) { + expect(ctx.settings.overallTheme).toBe('') + }) + + it('should not check split test', function (ctx) { + expect(ctx.SplitTestHandler.promises.getAssignment).not.toHaveBeenCalled + }) + }) + }) +})