feat(profile): ship profile timezone to everyone (#3034)

This commit is contained in:
Hampus
2026-09-29 16:28:40 +02:00
committed by GitHub
parent b30ea361d3
commit d433a039b5
28 changed files with 71 additions and 971 deletions
@@ -27,10 +27,6 @@ import {
InstanceBillingResponse,
InstanceBillingUpdateRequest,
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
import {
ProfileTimezoneConfigResponse,
ProfileTimezoneConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {PushRelayConfigResponse, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {
ExperimentDeliveryConfigResponse,
@@ -656,7 +652,6 @@ export const InstanceConfigResponse = z.object({
push_relay: PushRelayConfigResponse,
domain_migration: DomainMigrationConfigResponse,
altcha_captcha: AltchaCaptchaConfigResponse,
profile_timezone: ProfileTimezoneConfigResponse,
experiment_delivery: ExperimentDeliveryConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
@@ -689,7 +684,6 @@ export const InstanceConfigUpdateRequest = z.object({
push_relay: PushRelayConfigUpdateRequest.nullish(),
domain_migration: DomainMigrationConfigUpdateRequest.nullish(),
altcha_captcha: AltchaCaptchaConfigUpdateRequest.nullish(),
profile_timezone: ProfileTimezoneConfigUpdateRequest.nullish(),
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
registration: z
.object({
@@ -1,123 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
DEFAULT_PROFILE_TIMEZONE_CONFIG,
type ProfileTimezoneConfig,
ProfileTimezoneConfigSchema,
ProfileTimezoneConfigUpdateRequest,
resolveProfileTimezoneAssignment,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, test} from 'vitest';
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false};
const TARGETED_USER_ID = '1000000000000000001';
function createConfig(overrides: Partial<ProfileTimezoneConfig> = {}): ProfileTimezoneConfig {
return {...DEFAULT_PROFILE_TIMEZONE_CONFIG, included_user_ids: [], excluded_user_ids: [], ...overrides};
}
function syntheticUserIds(count: number): Array<string> {
return Array.from({length: count}, (_, index) => (1400000000000000000n + BigInt(index)).toString());
}
describe('profile timezone configuration', () => {
test('defaults to disabled', () => {
expect(ProfileTimezoneConfigSchema.parse({})).toEqual({
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: 'profile-timezone-v1',
included_user_ids: [],
excluded_user_ids: [],
included_guild_ids: [],
include_premium_users: false,
});
});
test('strips config_version from admin updates', () => {
expect(ProfileTimezoneConfigUpdateRequest.safeParse({config_version: 3}).data).toEqual({});
expect(ProfileTimezoneConfigUpdateRequest.safeParse({rollout_basis_points: 10001}).success).toBe(false);
});
});
describe('resolveProfileTimezoneAssignment', () => {
test('serves nobody while disabled, even included users', () => {
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('applies exclusions before inclusions', () => {
const config = createConfig({
enabled: true,
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('serves included users at zero rollout', () => {
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: true});
});
test('buckets the rollout by salt and user id', () => {
const config = createConfig({enabled: true, rollout_basis_points: 2500});
for (const userId of syntheticUserIds(200)) {
expect(resolveProfileTimezoneAssignment(config, userId, NO_TARGETING).enabled).toBe(
experimentBucket(userId, config.rollout_salt) < 2500,
);
}
});
});
describe('resolveProfileTimezoneAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('serves members of an included guild at zero rollout', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('keeps user exclusions ahead of guild membership', () => {
const config = createConfig({
enabled: true,
included_guild_ids: [INCLUDED_GUILD_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
test('serves no guild members while disabled', () => {
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
});
describe('resolveProfileTimezoneAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('serves premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveProfileTimezoneAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true});
expect(resolveProfileTimezoneAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
expect(resolveProfileTimezoneAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({
enabled: false,
});
});
test('keeps user exclusions ahead of the premium switch', () => {
const config = createConfig({
enabled: true,
include_premium_users: true,
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
});
});
@@ -1,79 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
const PROFILE_TIMEZONE_MAX_TARGETED_USERS = 1000;
const DEFAULT_PROFILE_TIMEZONE_SALT = 'profile-timezone-v1';
const PROFILE_TIMEZONE_SALT_PATTERN = /^[\x20-\x7e]+$/u;
const ProfileTimezoneTargetIdSchema = z.string().regex(/^\d{1,20}$/u);
const ProfileTimezoneTargetedUserIdsSchema = z
.array(ProfileTimezoneTargetIdSchema)
.max(PROFILE_TIMEZONE_MAX_TARGETED_USERS);
const profileTimezoneConfigFields = {
enabled: z.boolean(),
config_version: z.number().int().min(0),
rollout_basis_points: z.number().int().min(0).max(PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(PROFILE_TIMEZONE_SALT_PATTERN),
included_user_ids: ProfileTimezoneTargetedUserIdsSchema,
included_guild_ids: ProfileTimezoneTargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: ProfileTimezoneTargetedUserIdsSchema,
};
export const ProfileTimezoneConfigSchema = z.object({
enabled: profileTimezoneConfigFields.enabled.default(false),
config_version: profileTimezoneConfigFields.config_version.default(0),
rollout_basis_points: profileTimezoneConfigFields.rollout_basis_points.default(0),
rollout_salt: profileTimezoneConfigFields.rollout_salt.default(DEFAULT_PROFILE_TIMEZONE_SALT),
included_user_ids: profileTimezoneConfigFields.included_user_ids.default([]),
included_guild_ids: profileTimezoneConfigFields.included_guild_ids.default([]),
include_premium_users: profileTimezoneConfigFields.include_premium_users.default(false),
excluded_user_ids: profileTimezoneConfigFields.excluded_user_ids.default([]),
});
export type ProfileTimezoneConfig = z.infer<typeof ProfileTimezoneConfigSchema>;
export const DEFAULT_PROFILE_TIMEZONE_CONFIG: ProfileTimezoneConfig = ProfileTimezoneConfigSchema.parse({});
export const ProfileTimezoneConfigUpdateRequest = z
.object(profileTimezoneConfigFields)
.omit({config_version: true})
.partial();
export type ProfileTimezoneConfigUpdateRequest = z.infer<typeof ProfileTimezoneConfigUpdateRequest>;
export const ProfileTimezoneConfigResponse = ProfileTimezoneConfigSchema;
export type ProfileTimezoneConfigResponse = z.infer<typeof ProfileTimezoneConfigResponse>;
export const ProfileTimezoneAssignmentResponse = z.object({
enabled: profileTimezoneConfigFields.enabled,
});
export type ProfileTimezoneAssignmentResponse = z.infer<typeof ProfileTimezoneAssignmentResponse>;
export const INERT_PROFILE_TIMEZONE_ASSIGNMENT: ProfileTimezoneAssignmentResponse = {
enabled: false,
};
export function resolveProfileTimezoneAssignment(
config: ProfileTimezoneConfig,
userId: string,
targeting: ExperimentTargeting,
): ProfileTimezoneAssignmentResponse {
if (!config.enabled) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
if (experimentAudienceIncludes(config, targeting)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
@@ -5,10 +5,6 @@ import {
DomainMigrationAssignmentResponse,
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {
INERT_PROFILE_TIMEZONE_ASSIGNMENT,
ProfileTimezoneAssignmentResponse,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {z} from 'zod';
export const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS = 60;
@@ -48,7 +44,6 @@ export type ExperimentDeliveryConfigResponse = z.infer<typeof ExperimentDelivery
const ExperimentAssignmentsSchema = z.object({
domain_migration: DomainMigrationAssignmentResponse.optional(),
altcha_captcha: AltchaCaptchaAssignmentResponse.optional(),
profile_timezone: ProfileTimezoneAssignmentResponse.optional(),
});
export const ExperimentAssignmentsResponse = z.object({
@@ -70,9 +65,3 @@ export function readDomainMigrationAssignment(
): DomainMigrationAssignmentResponse {
return response.assignments.domain_migration ?? INERT_DOMAIN_MIGRATION_ASSIGNMENT;
}
export function readProfileTimezoneAssignment(
response: ExperimentAssignmentsResponse,
): ProfileTimezoneAssignmentResponse {
return response.assignments.profile_timezone ?? INERT_PROFILE_TIMEZONE_ASSIGNMENT;
}
@@ -75,19 +75,13 @@ export const UserUpdateRequest = z
bio: createStringType(1, 320).nullish().describe('User biography text (max 320 characters)'),
pronouns: createStringType(1, 40).nullish().describe('User pronouns (max 40 characters)'),
accent_color: ColorType.nullish().describe('Profile accent color as integer'),
timezone: createStringType(1, 128)
.nullish()
.describe(
'IANA timezone identifier saved for profile local time. Ignored unless the profile_timezone experiment serves the user.',
),
timezone: createStringType(1, 128).nullish().describe('IANA timezone identifier saved for profile local time'),
timezone_privacy_flags: createBitflagInt32Type(
ProfileFieldPrivacyFlags,
ProfileFieldPrivacyFlagsDescriptions,
'Bitfield controlling who can see the profile timezone',
'ProfileFieldPrivacyFlags',
).describe(
'Bitfield controlling who can see the profile timezone. Ignored unless the profile_timezone experiment serves the user.',
),
).describe('Bitfield controlling who can see the profile timezone'),
premium_badge_hidden: z.boolean().describe('Whether to hide the premium badge'),
premium_badge_masked: z.boolean().describe('Whether to mask the premium badge'),
premium_badge_timestamp_hidden: z.boolean().describe('Whether to hide premium badge timestamp'),