Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Datetime fields ignore the chosen time format** — Native `datetime-local` inputs (the "Forgot to end your workday?" workday modals, workday history corrections, contact/deal/lead activity forms) render in the browser locale — e.g. 12h AM/PM on en-US — no matter what time format the user or system settings chose. They are now initialized as Flatpickr datetime pickers that display in the user's preferred date + time format (24h by default) while still submitting the same wire format; min/max bounds move to the picker. A regression test enforces that every `datetime-local` input stays prefs-aware. The recurring-tasks "last run" cell and the admin version-update published timestamp also now honor the preference instead of the browser locale.

## [5.13.2] - 2026-08-27

### Added
Expand Down
1 change: 0 additions & 1 deletion app/routes/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -2113,4 +2113,3 @@ def utilization_report():
non_billable_hours=non_billable,
can_view_all=can_view_all,
)

10 changes: 6 additions & 4 deletions app/static/admin-version-update.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,12 @@
if (data.published_at) {
try {
var d = new Date(data.published_at);
published.textContent = d.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
published.textContent = window.formatUserDateTime
? window.formatUserDateTime(d)
: d.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
} catch (e) {
published.textContent = data.published_at;
}
Expand Down
57 changes: 57 additions & 0 deletions app/static/date-picker-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@
}
}

function getDateTimeAltFormat() {
return getFlatpickrAltFormat() + ' ' + getTimeAltFormat();
}

/**
* Parse a wire datetime-local value ("YYYY-MM-DDTHH:MM") into a Date.
*/
function parseWireDateTime(value) {
if (!value) return undefined;
var d = new Date(value);
return isNaN(d.getTime()) ? undefined : d;
}

function getFirstDayOfWeek() {
if (window.userPrefs && typeof window.userPrefs.weekStartDay === 'number' && window.userPrefs.weekStartDay >= 0 && window.userPrefs.weekStartDay <= 6) {
return window.userPrefs.weekStartDay;
Expand Down Expand Up @@ -194,9 +207,53 @@
});
}

/**
* Initialize Flatpickr on datetime-local inputs so they display using the
* user's preferred date + time formats while still submitting
* YYYY-MM-DDTHH:MM. Native datetime-local controls render in the browser
* locale (e.g. 12h AM/PM on en-US), ignoring the in-app preference.
*/
function initUserDateTimeInputs() {
if (typeof flatpickr === 'undefined') return;
var inputs = document.querySelectorAll('input.user-datetime-input[type="datetime-local"]');
var altFormat = getDateTimeAltFormat();
var use24hr = timePickerUses24hr();
var firstDay = getFirstDayOfWeek();
inputs.forEach(function (el) {
if (el._flatpickr) return;
// Preserve existing classes on the visible alt input (form-input, form-control, sizing).
var altClass = (el.className || 'form-input').replace(/\buser-datetime-input\b/g, '').trim() || 'form-input';
var minDate = parseWireDateTime(el.getAttribute('min'));
var maxDate = parseWireDateTime(el.getAttribute('max'));
// Bounds move to Flatpickr; native min/max on the (now hidden) wire
// input would still block submission with browser validation.
el.removeAttribute('min');
el.removeAttribute('max');
flatpickr(el, {
enableTime: true,
dateFormat: 'Y-m-dTH:i',
time_24hr: use24hr,
altInput: true,
altFormat: altFormat,
altInputClass: altClass,
allowInput: false,
minDate: minDate,
maxDate: maxDate,
locale: { firstDayOfWeek: firstDay },
// type=datetime-local fights Flatpickr; hide the native control.
onReady: function (_selectedDates, _dateStr, instance) {
if (instance.input) {
instance.input.style.display = 'none';
}
}
});
});
}

function initAll() {
initUserDateInputs();
initUserTimeInputs();
initUserDateTimeInputs();
}

// Test / debug hooks
Expand Down
2 changes: 1 addition & 1 deletion app/templates/client_portal/quote_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ <h3 class="text-lg font-semibold mb-4">{{ _('Sign & Accept') }}</h3>
</div>
</form>
<script src="{{ url_for('static', filename='js/signature_pad.js') }}"></script>
<script>
<script nonce="{{ csp_nonce() }}">
(function() {
const canvas = document.getElementById('signaturePad');
if (!canvas || typeof SignaturePad === 'undefined') return;
Expand Down
4 changes: 2 additions & 2 deletions app/templates/contacts/communication_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@

<div>
<label for="communication_date" class="block text-sm font-medium mb-2">{{ _('Date') }} *</label>
<input type="datetime-local" name="communication_date" id="communication_date" class="form-input" required>
<input type="datetime-local" name="communication_date" id="communication_date" class="form-input user-datetime-input" required>
</div>

<div>
Expand All @@ -65,7 +65,7 @@

<div>
<label for="follow_up_date" class="block text-sm font-medium mb-2">{{ _('Follow-up Date') }}</label>
<input type="datetime-local" name="follow_up_date" id="follow_up_date" class="form-input">
<input type="datetime-local" name="follow_up_date" id="follow_up_date" class="form-input user-datetime-input">
</div>

<div class="md:col-span-2">
Expand Down
4 changes: 2 additions & 2 deletions app/templates/deals/activity_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@
</div>
<div>
<label for="activity_date" class="block text-sm font-medium mb-2">{{ _('Activity Date') }} *</label>
<input type="datetime-local" name="activity_date" id="activity_date" class="form-input" required>
<input type="datetime-local" name="activity_date" id="activity_date" class="form-input user-datetime-input" required>
</div>
<div>
<label for="due_date" class="block text-sm font-medium mb-2">{{ _('Due Date') }}</label>
<input type="datetime-local" name="due_date" id="due_date" class="form-input">
<input type="datetime-local" name="due_date" id="due_date" class="form-input user-datetime-input">
</div>
<div class="md:col-span-2">
<label for="subject" class="block text-sm font-medium mb-2">{{ _('Subject') }}</label>
Expand Down
2 changes: 1 addition & 1 deletion app/templates/expenses/approvals.html
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ <h3 class="text-lg font-semibold mb-3">{{ _('Reject expense') }}</h3>
{% endblock %}

{% block extra_js %}
<script>
<script nonce="{{ csp_nonce() }}">
document.getElementById('selectAllApprovals')?.addEventListener('change', function() {
document.querySelectorAll('.approval-check').forEach(cb => { cb.checked = this.checked; });
});
Expand Down
4 changes: 2 additions & 2 deletions app/templates/leads/activity_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@
</div>
<div>
<label for="activity_date" class="block text-sm font-medium mb-2">{{ _('Activity Date') }} *</label>
<input type="datetime-local" name="activity_date" id="activity_date" class="form-input" required>
<input type="datetime-local" name="activity_date" id="activity_date" class="form-input user-datetime-input" required>
</div>
<div>
<label for="due_date" class="block text-sm font-medium mb-2">{{ _('Due Date') }}</label>
<input type="datetime-local" name="due_date" id="due_date" class="form-input">
<input type="datetime-local" name="due_date" id="due_date" class="form-input user-datetime-input">
</div>
<div class="md:col-span-2">
<label for="subject" class="block text-sm font-medium mb-2">{{ _('Subject') }}</label>
Expand Down
2 changes: 1 addition & 1 deletion app/templates/projects/health.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ <h2 class="text-lg font-semibold mt-6 mb-4">{{ _('Top contributors this month')
{% endblock %}

{% block extra_js %}
<script>
<script nonce="{{ csp_nonce() }}">
(function() {
const ctx = document.getElementById('burnChart');
if (!ctx || typeof Chart === 'undefined') return;
Expand Down
4 changes: 3 additions & 1 deletion app/templates/recurring_tasks/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,9 @@
if (!res.ok) throw new Error(data.error || 'Run failed');
var lastCell = document.querySelector('.last-run-cell[data-task-id="' + taskId + '"]');
if (lastCell && data.last_created_at) {
lastCell.textContent = new Date(data.last_created_at).toLocaleString();
lastCell.textContent = window.formatUserDateTime
? window.formatUserDateTime(new Date(data.last_created_at))
: new Date(data.last_created_at).toLocaleString(
}
if (window.toastManager?.success) {
window.toastManager.success({{ _('Task created successfully')|tojson }}, '', 3000);
Expand Down
2 changes: 1 addition & 1 deletion app/templates/reports/utilization.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
{% endblock %}

{% block extra_js %}
<script>
<script nonce="{{ csp_nonce() }}">
(function() {
const ctx = document.getElementById('utilizationChart');
if (!ctx || typeof Chart === 'undefined') return;
Expand Down
2 changes: 1 addition & 1 deletion app/templates/workday/_auto_closed_clock_out_modal.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ <h3 class="text-lg font-semibold text-text-light dark:text-text-dark" id="autoCl
value="{{ auto_closed_suggested_leave_time }}"
{% if auto_closed_max_leave_time %}max="{{ auto_closed_max_leave_time }}"{% endif %}
required
class="form-input w-full">
class="form-input w-full user-datetime-input">
<div class="flex flex-col sm:flex-row gap-2 pt-2">
<button type="submit" class="btn btn-primary flex-1">
<i class="fas fa-check mr-2" aria-hidden="true"></i>{{ _('Save leave time') }}
Expand Down
2 changes: 1 addition & 1 deletion app/templates/workday/_overnight_clock_out_modal.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ <h3 class="text-lg font-semibold text-text-light dark:text-text-dark" id="overni
<input type="datetime-local" id="overnightLeaveTime" name="end_time"
value="{{ suggested_leave_time }}"
required
class="form-input w-full">
class="form-input w-full user-datetime-input">
<div class="flex flex-col sm:flex-row gap-2 pt-2">
<button type="submit" class="btn btn-primary flex-1">
<i class="fas fa-check mr-2" aria-hidden="true"></i>{{ _('Save leave time') }}
Expand Down
8 changes: 4 additions & 4 deletions app/templates/workday/history.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ <h2 class="text-lg font-semibold mb-3">{{ _('Request missing workday') }}</h2>
</div>
<div>
<label class="block text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Start time') }}</label>
<input type="datetime-local" name="start_time" class="form-input w-full" required>
<input type="datetime-local" name="start_time" class="form-input w-full user-datetime-input" required>
</div>
<div>
<label class="block text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('End time (optional)') }}</label>
<input type="datetime-local" name="end_time" class="form-input w-full">
<input type="datetime-local" name="end_time" class="form-input w-full user-datetime-input">
</div>
<div>
<label class="block text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Notes (optional)') }}</label>
Expand Down Expand Up @@ -145,8 +145,8 @@ <h2 class="text-lg font-semibold mb-3">{{ _('My correction requests') }}</h2>
<input type="hidden" name="attendance_day_id" value="{{ record.id }}">
<input type="hidden" name="entity_type" value="AttendanceWorkPeriod">
<input type="hidden" name="entity_id" value="{{ p.id }}">
<input type="datetime-local" name="start_time" class="form-input text-xs w-full" value="{{ p.start_time|user_datetime_local if p.start_time else '' }}">
<input type="datetime-local" name="end_time" class="form-input text-xs w-full" value="{{ p.end_time|user_datetime_local if p.end_time else '' }}">
<input type="datetime-local" name="start_time" class="form-input text-xs w-full user-datetime-input" value="{{ p.start_time|user_datetime_local if p.start_time else '' }}">
<input type="datetime-local" name="end_time" class="form-input text-xs w-full user-datetime-input" value="{{ p.end_time|user_datetime_local if p.end_time else '' }}">
<input type="text" name="reason" class="form-input text-xs w-full" required placeholder="{{ _('Reason') }}">
<button type="submit" class="btn btn-secondary btn-xs">{{ _('Request change') }}</button>
</form>
Expand Down
42 changes: 42 additions & 0 deletions tests/test_time_format_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,48 @@ def test_date_picker_init_uses_24hr_from_prefs():
assert "formatDate: formatTimeDate" in src


def test_date_picker_init_covers_datetime_local_inputs():
"""datetime-local inputs must be flatpickr-initialized per user prefs.

Native datetime-local controls render in the browser locale (12h AM/PM on
en-US) and ignore the in-app time format setting — regression test for the
"Forgot to end your workday?" modal showing a 12h leave time.
"""
src = Path("app/static/date-picker-init.js").read_text(encoding="utf-8")
assert "user-datetime-input" in src
assert "initUserDateTimeInputs" in src
# Wire format must stay datetime-local compatible (YYYY-MM-DDTHH:MM)
assert "'Y-m-dTH:i'" in src
# time_24hr must be honoured for the clock part
assert "time_24hr: use24hr" in src
# Native min/max bounds must move to Flatpickr, not stay on the hidden input
assert "removeAttribute('min')" in src
assert "removeAttribute('max')" in src


def test_workday_modals_use_user_datetime_input():
"""Both "Forgot to end your workday?" modals must use the prefs-aware picker."""
for name in (
"app/templates/workday/_auto_closed_clock_out_modal.html",
"app/templates/workday/_overnight_clock_out_modal.html",
):
src = Path(name).read_text(encoding="utf-8")
assert "datetime-local" in src
assert "user-datetime-input" in src


def test_all_datetime_local_inputs_are_prefs_aware():
"""Every datetime-local input in the app must carry user-datetime-input."""
tag_re = re.compile(r"<input\b[^>]*>", re.S)
for path in Path("app/templates").rglob("*.html"):
src = path.read_text(encoding="utf-8", errors="ignore")
for tag in tag_re.findall(src):
if 'type="datetime-local"' in tag.replace("\n", " "):
assert "user-datetime-input" in tag.replace("\n", " "), (
f"{path}: datetime-local input without user-datetime-input: {tag!r}"
)


def _run_parse_user_time_cases():
"""Evaluate __parseUserTimeInput in Node with a minimal DOM stub."""
js_path = Path("app/static/date-picker-init.js").resolve()
Expand Down
Loading