Skip to content

Commit 355f71b

Browse files
authored
Merge pull request #6359 from christianbeeznest/fide-22706
Internal: Unify timezone handling and remove obsolete extra field - refs BT#22706
2 parents 50933b8 + b2cae91 commit 355f71b

File tree

9 files changed

+189
-88
lines changed

9 files changed

+189
-88
lines changed

assets/vue/composables/formatDate.js

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
11
import { DateTime } from "luxon"
22
import { useLocale } from "./locale"
3+
import { usePlatformConfig } from "../store/platformConfig"
4+
import { useSecurityStore } from "../store/securityStore"
35

46
export function useFormatDate() {
57
const { appParentLocale } = useLocale()
8+
const platformConfigStore = usePlatformConfig()
9+
const securityStore = useSecurityStore()
10+
11+
function getCurrentTimezone() {
12+
const allowUserTimezone = platformConfigStore.getSetting("profile.use_users_timezone") === "true"
13+
const userTimezone = securityStore.user?.timezone
14+
const platformTimezone = platformConfigStore.getSetting("platform.timezone")
15+
16+
if (allowUserTimezone && userTimezone) {
17+
return userTimezone
18+
}
19+
20+
if (platformTimezone && platformTimezone !== "false") {
21+
return platformTimezone
22+
}
23+
24+
return Intl.DateTimeFormat().resolvedOptions().timeZone
25+
}
626

7-
/**
8-
* @param {Date|string} datetime
9-
* @returns {DateTime|null}
10-
*/
1127
function getDateTimeObject(datetime) {
1228
if (!datetime) {
1329
return null
@@ -16,16 +32,18 @@ export function useFormatDate() {
1632
let dt
1733

1834
if (typeof datetime === "string") {
19-
dt = DateTime.fromISO(datetime)
20-
} else if (typeof datetime === "object") {
21-
dt = DateTime.fromJSDate(datetime)
35+
dt = DateTime.fromISO(datetime, { zone: "utc" })
36+
} else if (datetime instanceof Date) {
37+
dt = DateTime.fromJSDate(datetime, { zone: "utc" })
38+
} else {
39+
return null
2240
}
2341

2442
if (!dt.isValid) {
2543
return null
2644
}
2745

28-
return dt.setLocale(appParentLocale.value)
46+
return dt.setZone(getCurrentTimezone()).setLocale(appParentLocale.value)
2947
}
3048

3149
const abbreviatedDatetime = (datetime) =>
@@ -34,10 +52,12 @@ export function useFormatDate() {
3452
month: "long",
3553
})
3654

37-
const relativeDatetime = (datetime) => getDateTimeObject(datetime)?.toRelative()
55+
const relativeDatetime = (datetime) =>
56+
getDateTimeObject(datetime)?.toRelative()
3857

3958
return {
4059
abbreviatedDatetime,
4160
relativeDatetime,
61+
getCurrentTimezone,
4262
}
4363
}

assets/vue/utils/dates.js

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,30 @@
1-
const { DateTime } = require("luxon")
1+
import { DateTime } from "luxon"
2+
import { useFormatDate } from "../composables/formatDate"
23

3-
const formatDateTime = function (date) {
4-
if (!date) return null
4+
const { getCurrentTimezone } = useFormatDate()
55

6-
return DateTime(date).format("DD/MM/YYYY")
6+
/**
7+
* Format a JS Date object to string using the current or provided timezone.
8+
* @param {Date} date - JavaScript Date object
9+
* @param {string} [timezone] - Optional timezone (e.g. "America/Lima")
10+
* @returns {string}
11+
*/
12+
const formatDateTime = function (date, timezone) {
13+
if (!date) return ""
14+
const tz = timezone || getCurrentTimezone()
15+
return DateTime.fromJSDate(date, { zone: "utc" }).setZone(tz).toFormat("dd/LL/yyyy HH:mm")
716
}
817

9-
const formatDateTimeFromISO = function (dateStr) {
18+
/**
19+
* Format an ISO string to readable string using the current or provided timezone.
20+
* @param {string} dateStr - ISO date string (e.g. "2025-06-17T14:00:00Z")
21+
* @param {string} [timezone] - Optional timezone
22+
* @returns {string}
23+
*/
24+
const formatDateTimeFromISO = function (dateStr, timezone) {
1025
if (!dateStr) return ""
11-
12-
return DateTime.fromISO(dateStr).toFormat("dd/LL/yyyy HH:mm")
26+
const tz = timezone || getCurrentTimezone()
27+
return DateTime.fromISO(dateStr, { zone: "utc" }).setZone(tz).toFormat("dd/LL/yyyy HH:mm")
1328
}
1429

1530
export { formatDateTime, formatDateTimeFromISO }

assets/vue/views/ccalendarevent/CCalendarEventList.vue

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -150,16 +150,15 @@ import { useCalendarEvent } from "../../composables/calendar/calendarEvent"
150150
import resourceLinkService from "../../services/resourceLinkService"
151151
import { useSecurityStore } from "../../store/securityStore"
152152
import { useCourseSettings } from "../../store/courseSettingStore"
153+
import { DateTime } from "luxon"
153154
154155
const store = useStore()
155156
const securityStore = useSecurityStore()
156157
const confirm = useConfirm()
157158
const cidReqStore = useCidReqStore()
158159
159160
const { course, session, group } = storeToRefs(cidReqStore)
160-
161-
const { abbreviatedDatetime } = useFormatDate()
162-
161+
const { abbreviatedDatetime, getCurrentTimezone } = useFormatDate()
163162
const { showAddButton } = useCalendarActionButtons()
164163
165164
const { isEditableByUser, allowSubscribeToEvent, allowUnsubscribeToEvent } = useCalendarEvent()
@@ -235,13 +234,15 @@ async function getCalendarEvents({ start, end }) {
235234
const calendarEvents = await cCalendarEventService.findAll({ params }).then((response) => response.json())
236235
237236
return calendarEvents["hydra:member"].map((event) => {
238-
let color = event.color || "#007BFF"
237+
const timezone = getCurrentTimezone()
238+
const start = DateTime.fromISO(event.startDate, { zone: "utc" }).setZone(timezone)
239+
const end = DateTime.fromISO(event.endDate, { zone: "utc" }).setZone(timezone)
239240
240241
return {
241242
...event,
242-
start: event.startDate,
243-
end: event.endDate,
244-
color,
243+
start: start.toString(),
244+
end: end.toString(),
245+
color: event.color || "#007BFF",
245246
}
246247
})
247248
}
@@ -257,16 +258,9 @@ const showAddEventDialog = () => {
257258
258259
dialog.value = true
259260
}
260-
261-
const goToMyStudentsSchedule = () => {
262-
window.location.href = "/main/calendar/planification.php"
263-
}
264-
265-
const goToSessionPanning = () => {
266-
window.location.href = "/main/my_space/calendar_plan.php"
267-
}
268-
261+
const timezone = getCurrentTimezone()
269262
const calendarOptions = ref({
263+
timeZone: timezone,
270264
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
271265
locales: allLocales,
272266
locale: calendarLocale?.code ?? "en-GB",

public/main/inc/lib/internationalization.lib.php

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -192,29 +192,28 @@ function api_get_timezones()
192192
*
193193
* @return string The timezone chosen
194194
*/
195-
function api_get_timezone()
195+
function api_get_timezone(): string
196196
{
197197
$timezone = Session::read('system_timezone');
198198
if (empty($timezone)) {
199-
// First, get the default timezone of the server
199+
// 1. Default server timezone
200200
$timezone = date_default_timezone_get();
201-
// Second, see if a timezone has been chosen for the platform
202-
$timezoneFromSettings = api_get_setting('platform.timezone', false, 'timezones');
203201

204-
if (null != $timezoneFromSettings) {
202+
// 2. Platform-specific timezone setting (overrides server default)
203+
$timezoneFromSettings = api_get_setting('platform.timezone', false, 'timezones');
204+
if (!empty($timezoneFromSettings)) {
205205
$timezone = $timezoneFromSettings;
206206
}
207207

208-
// If allowed by the administrator
208+
// 3. User-specific timezone if allowed
209209
$allowUserTimezones = api_get_setting('profile.use_users_timezone', false, 'timezones');
210210
$userId = api_get_user_id();
211211

212212
if ('true' === $allowUserTimezones && !empty($userId)) {
213-
// Get the timezone based on user preference, if it exists
214-
$newExtraField = new ExtraFieldValue('user');
215-
$data = $newExtraField->get_values_by_handler_and_field_variable($userId, 'timezone');
216-
if (!empty($data) && isset($data['timezone']) && !empty($data['timezone'])) {
217-
$timezone = $data['timezone'];
213+
$user = api_get_user_entity($userId);
214+
215+
if ($user && $user->getTimezone()) {
216+
$timezone = $user->getTimezone();
218217
}
219218
}
220219
Session::write('system_timezone', $timezone);

src/CoreBundle/Controller/PlatformConfigurationController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ public function list(SettingsManager $settingsManager): Response
112112
'course.show_courses_descriptions_in_catalog',
113113
'session.session_automatic_creation_user_id',
114114
'session.session_list_view_remaining_days',
115+
'profile.use_users_timezone',
115116
];
116117

117118
$user = $this->userHelper->getCurrent();

src/CoreBundle/Controller/SocialController.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,6 @@ private function getExtraFieldBlock(
568568
}
569569

570570
break;
571-
572571
case ExtraField::FIELD_TYPE_GEOLOCALIZATION_COORDINATES:
573572
case ExtraField::FIELD_TYPE_GEOLOCALIZATION:
574573
$geoData = explode('::', $fieldValue);

src/CoreBundle/DataFixtures/SettingsCurrentFixtures.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public static function getExistingSettings(): array
9090
[
9191
'name' => 'use_users_timezone',
9292
'title' => 'Enable users timezones',
93-
'comment' => 'Enable the possibility for users to select their own timezone. The timezone field should be set to visible and changeable in the Profiling menu in the administration section before users can choose their own. Once configured, users will be able to see assignment deadlines and other time references in their own timezone, which will reduce errors at delivery time.',
93+
'comment' => 'Enable the possibility for users to select their own timezone. Once configured, users will be able to see assignment deadlines and other time references in their own timezone, which will reduce errors at delivery time.',
9494
],
9595
[
9696
'name' => 'allow_show_linkedin_url',

src/CoreBundle/Form/ProfileType.php

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
use Symfony\Component\Form\Extension\Core\Type\EmailType;
1616
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
1717
use Symfony\Component\Form\Extension\Core\Type\TextType;
18-
use Symfony\Component\Form\Extension\Core\Type\TimezoneType;
1918
use Symfony\Component\Form\FormBuilderInterface;
2019
use Symfony\Component\OptionsResolver\OptionsResolver;
2120

@@ -32,64 +31,83 @@ public function __construct(
3231
public function buildForm(FormBuilderInterface $builder, array $options): void
3332
{
3433
$changeableOptions = $this->settingsManager->getSetting('profile.changeable_options', true) ?? [];
35-
$visibleOptions = $this->settingsManager->getSetting('profile.visible_options', true) ?? [];
34+
$visibleOptions = $this->settingsManager->getSetting('profile.visible_options', true) ?? [];
35+
3636
$languages = array_flip($this->languageRepository->getAllAvailableToArray(true));
3737

3838
$fieldsMap = [
39-
'name' => ['field' => 'firstname', 'type' => TextType::class, 'label' => 'Firstname'],
40-
'officialcode' => ['field' => 'official_code', 'type' => TextType::class, 'label' => 'Official Code'],
41-
'email' => ['field' => 'email', 'type' => EmailType::class, 'label' => 'Email'],
42-
'picture' => [
43-
'field' => 'illustration',
44-
'type' => IllustrationType::class,
45-
'label' => 'Picture',
39+
'name' => ['field' => 'firstname', 'type' => TextType::class, 'label' => 'Firstname'],
40+
'officialcode' => ['field' => 'official_code', 'type' => TextType::class, 'label' => 'Official Code'],
41+
'email' => ['field' => 'email', 'type' => EmailType::class, 'label' => 'Email'],
42+
'picture' => [
43+
'field' => 'illustration',
44+
'type' => IllustrationType::class,
45+
'label' => 'Picture',
4646
'mapped' => false,
4747
],
48-
'login' => ['field' => 'login', 'type' => TextType::class, 'label' => 'Login'],
48+
'login' => ['field' => 'login', 'type' => TextType::class, 'label' => 'Login'],
4949
'password' => [
50-
'field' => 'password',
51-
'type' => PasswordType::class,
52-
'label' => 'Password',
53-
'mapped' => false,
50+
'field' => 'password',
51+
'type' => PasswordType::class,
52+
'label' => 'Password',
53+
'mapped' => false,
5454
'required' => false,
5555
],
5656
'language' => [
57-
'field' => 'locale',
58-
'type' => ChoiceType::class,
59-
'label' => 'Language',
60-
'choices' => $languages,
57+
'field' => 'locale',
58+
'type' => ChoiceType::class,
59+
'label' => 'Language',
60+
'choices' => $languages,
61+
'required' => true,
62+
'placeholder'=> null,
63+
'choice_translation_domain' => false,
6164
],
6265
'phone' => ['field' => 'phone', 'type' => TextType::class, 'label' => 'Phone Number'],
6366
'theme' => ['field' => 'theme', 'type' => TextType::class, 'label' => 'Theme'],
6467
];
6568

6669
foreach ($fieldsMap as $key => $fieldConfig) {
67-
if (\in_array($key, $visibleOptions)) {
68-
$isEditable = \in_array($key, $changeableOptions);
69-
$builder->add(
70-
$fieldConfig['field'],
71-
$fieldConfig['type'],
72-
array_merge(
73-
[
74-
'label' => $fieldConfig['label'],
75-
'required' => $fieldConfig['required'] ?? false,
76-
'mapped' => $fieldConfig['mapped'] ?? true,
77-
'attr' => !$isEditable ? ['readonly' => true] : [],
78-
],
79-
isset($fieldConfig['choices']) ? ['choices' => $fieldConfig['choices']] : []
80-
)
81-
);
70+
if (\in_array($key, $visibleOptions, true)) {
71+
$isEditable = \in_array($key, $changeableOptions, true);
72+
73+
$options = [
74+
'label' => $fieldConfig['label'],
75+
'required' => $fieldConfig['required'] ?? false,
76+
'mapped' => $fieldConfig['mapped'] ?? true,
77+
];
78+
79+
if (isset($fieldConfig['choices'])) {
80+
$options['choices'] = $fieldConfig['choices'];
81+
if (isset($fieldConfig['placeholder'])) {
82+
$options['placeholder'] = $fieldConfig['placeholder'];
83+
}
84+
if (isset($fieldConfig['choice_translation_domain'])) {
85+
$options['choice_translation_domain'] = $fieldConfig['choice_translation_domain'];
86+
}
87+
}
88+
89+
if (!$isEditable) {
90+
$options['disabled'] = true;
91+
}
92+
93+
$builder->add($fieldConfig['field'], $fieldConfig['type'], $options);
8294
}
8395
}
8496

85-
if ('true' === $this->settingsManager->getSetting('use_users_timezone') && \in_array('timezone', $visibleOptions)) {
97+
if ('true' === $this->settingsManager->getSetting('profile.use_users_timezone', true)) {
98+
$timezones = \DateTimeZone::listIdentifiers();
99+
sort($timezones);
100+
$timezoneChoices = array_combine($timezones, $timezones);
101+
86102
$builder->add(
87103
'timezone',
88-
TimezoneType::class,
104+
ChoiceType::class,
89105
[
90-
'label' => 'Timezone',
91-
'required' => true,
92-
'attr' => !\in_array('timezone', $changeableOptions) ? ['readonly' => true] : [],
106+
'label' => 'Timezone',
107+
'choices' => $timezoneChoices,
108+
'required' => false,
109+
'placeholder' => '',
110+
'choice_translation_domain' => false,
93111
]
94112
);
95113
}
@@ -99,10 +117,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void
99117

100118
public function configureOptions(OptionsResolver $resolver): void
101119
{
102-
$resolver->setDefaults(
103-
[
104-
'data_class' => User::class,
105-
]
106-
);
120+
$resolver->setDefaults([
121+
'data_class' => User::class,
122+
]);
107123
}
108124
}

0 commit comments

Comments
 (0)