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
15 changes: 11 additions & 4 deletions symfony/src/Service/DisplayPreferencesService.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,27 +135,34 @@ public function formatDate(?\DateTimeInterface $dt): ?string

/**
* Formatted time string according to the user's chosen time format.
*
* $withSeconds is opt-in so the common case stays "14:30" / "2:30 PM";
* log and command tables that genuinely need second precision pass true.
*/
public function formatTime(?\DateTimeInterface $dt): ?string
public function formatTime(?\DateTimeInterface $dt, bool $withSeconds = false): ?string
{
if ($dt === null) {
return null;
}
$dt = $this->toUserTimezone($dt);

return $this->getTimeFormat() === '12h' ? $dt->format('g:i A') : $dt->format('H:i');
$fmt = $this->getTimeFormat() === '12h'
? ($withSeconds ? 'g:i:s A' : 'g:i A')
: ($withSeconds ? 'H:i:s' : 'H:i');

return $dt->format($fmt);
}

/**
* Date + time combined, honoring both format preferences.
*/
public function formatDateTime(?\DateTimeInterface $dt): ?string
public function formatDateTime(?\DateTimeInterface $dt, bool $withSeconds = false): ?string
{
if ($dt === null) {
return null;
}

return $this->formatDate($dt) . ' · ' . $this->formatTime($dt);
return $this->formatDate($dt) . ' · ' . $this->formatTime($dt, $withSeconds);
}

private function toUserTimezone(\DateTimeInterface $dt): \DateTimeImmutable
Expand Down
11 changes: 7 additions & 4 deletions symfony/src/Twig/DisplayPreferencesExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* {{ item.date|prismarr_date }} {# "21/04/2026" (per user format) #}
* {{ item.date|prismarr_time }} {# "14:30" or "2:30 PM" #}
* {{ item.date|prismarr_datetime }} {# "21/04/2026 · 14:30" #}
* {{ item.date|prismarr_datetime(true) }} {# "21/04/2026 · 14:30:07" (seconds) #}
*/
class DisplayPreferencesExtension extends AbstractExtension
{
Expand Down Expand Up @@ -129,14 +130,16 @@ public function filterDate(mixed $dt): ?string
return $this->prefs->formatDate($this->asDateTime($dt));
}

public function filterTime(mixed $dt): ?string
/** `{{ d|prismarr_time }}` → "14:30"; `{{ d|prismarr_time(true) }}` → "14:30:07". */
public function filterTime(mixed $dt, bool $withSeconds = false): ?string
{
return $this->prefs->formatTime($this->asDateTime($dt));
return $this->prefs->formatTime($this->asDateTime($dt), $withSeconds);
}

public function filterDateTime(mixed $dt): ?string
/** `{{ d|prismarr_datetime(true) }}` adds seconds to the time half. */
public function filterDateTime(mixed $dt, bool $withSeconds = false): ?string
{
return $this->prefs->formatDateTime($this->asDateTime($dt));
return $this->prefs->formatDateTime($this->asDateTime($dt), $withSeconds);
}

public function pref(string $key): mixed
Expand Down
62 changes: 62 additions & 0 deletions symfony/templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,68 @@
};
}

// Global preference-aware date/time formatters (#54) — the JS mirror of the
// |prismarr_date / |prismarr_time / |prismarr_datetime Twig filters, so rows
// rebuilt by a poller read the same as the server-rendered ones instead of
// falling back to the UI locale (or a hardcoded fr-FR).
//
// Defined here in <head> for exactly the reason prismarrBytes is above: the
// child templates' "javascripts" block renders far higher up the body than
// the page-lifecycle script near the end of this template, and callers such
// as films.html.twig's renderQueue() run during the initial parse — a later
// definition would race with the first call.
//
// These take a JS Date, so the zone is the browser's. That matches the
// pre-existing client-side behaviour; only the *format* becomes a
// preference. The server-side filters remain the timezone-aware path.

// The prefs payload is re-stamped on EVERY document, outside the guard below:
// after an admin changes the Display preference, a Turbo-navigated page must
// not keep serving the old format from a stale global while the
// server-rendered dates around it have already updated. The function bodies
// are pure, so those stay guarded and are defined once.
window._prismarrDatePrefs = {
date: {{ display_pref('date_format')|json_encode|raw }},
time: {{ display_pref('time_format')|json_encode|raw }}
};
if (!window._prismarrFmtDate) {
// An unparseable payload yields an Invalid Date; callers only guard
// truthiness, so return the pages' em-dash placeholder rather than
// "NaN-NaN-NaN" / "Invalid Date".
window._prismarrFmtDate = function (d) {
if (isNaN(d.getTime())) return '—';
var p = window._prismarrDatePrefs || {};
if (p.date === 'iso') {
var m = ('0' + (d.getMonth() + 1)).slice(-2), day = ('0' + d.getDate()).slice(-2);
return d.getFullYear() + '-' + m + '-' + day;
}
// 'us' mirrors formatDate()'s 'M j, Y' → "Apr 21, 2026", not en-US's
// default numeric 4/21/2026.
if (p.date === 'us') {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
return d.toLocaleDateString('fr-FR');
};
window._prismarrFmtTime = function (d, withSeconds) {
if (isNaN(d.getTime())) return '—';
var p = window._prismarrDatePrefs || {};
// 12h uses a bare hour to match PHP 'g' ("2:30 PM"); 24h pads to match 'H'.
var opts = {
hour: p.time === '12h' ? 'numeric' : '2-digit',
minute: '2-digit',
hour12: p.time === '12h'
};
if (withSeconds) opts.second = '2-digit';
return d.toLocaleTimeString(p.time === '12h' ? 'en-US' : 'fr-FR', opts);
};
// ' · ' is the separator formatDateTime() uses server-side — the two must
// agree, since a poller can rewrite an element Twig already rendered.
window._prismarrFmtDateTime = function (d, withSeconds) {
if (isNaN(d.getTime())) return '—';
return window._prismarrFmtDate(d) + ' · ' + window._prismarrFmtTime(d, withSeconds);
};
}

// HTML escape helper used wherever JS builds strings via innerHTML with
// operator-side-controlled values (instance names, search results titles,
// upstream messages). Self-XSS today (ROLE_ADMIN is the only writer for
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/dashboard/_quicklook_body.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
{% for d in ql.releaseDates|default([]) %}
<span class="ql-date{% if d.upcoming %} is-upcoming{% endif %}">
<span class="ql-date-label">{{ d.label }}</span>
<span class="ql-date-val">{{ d.date|date('M j, Y') }}</span>
<span class="ql-date-val">{{ d.date|prismarr_date }}</span>
</span>
{% endfor %}
</div>
Expand Down
16 changes: 5 additions & 11 deletions symfony/templates/deluge/index.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,7 @@ body[data-bs-theme="dark"] .qbt-torrent-list[data-view="table"] .qbt-cell-progre
<span class="item" title="{{ 'deluge.row.title_ratio'|trans }}">{{ ico.icon('scale', '', 14) }} {{ (t.ratio ?? 0)|number_format(2) }}</span>
{% if t.added_on %}
<span class="sep"></span>
<span class="item" title="{{ 'deluge.row.title_added_tpl'|trans|replace({'__DATE__': t.added_on|date('d/m/Y H:i')}) }}">{{ ico.icon('calendar', '', 14) }} {{ t.added_on|date('d/m/y') }}</span>
<span class="item" title="{{ 'deluge.row.title_added_tpl'|trans|replace({'__DATE__': t.added_on|prismarr_datetime}) }}">{{ ico.icon('calendar', '', 14) }} {{ t.added_on|prismarr_date }}</span>
{% endif %}
</div>
</div>
Expand All @@ -789,7 +789,7 @@ body[data-bs-theme="dark"] .qbt-torrent-list[data-view="table"] .qbt-cell-progre
<div class="qbt-table-cell">{{ (t.ratio ?? 0)|number_format(2) }}</div>
<div class="qbt-table-cell qbt-cell-uploaded">{{ t.uploaded|prismarr_bytes }}</div>
<div class="qbt-table-cell qbt-cell-completed">{{ t.completion_on ? (t.completion_on|prismarr_date) : '—' }}</div>
<div class="qbt-table-cell qbt-cell-added" data-added="{{ t.added_on ?? 0 }}">{% if t.added_on %}{{ t.added_on|date('d/m/y') }}{% else %}—{% endif %}</div>
<div class="qbt-table-cell qbt-cell-added" data-added="{{ t.added_on ?? 0 }}">{% if t.added_on %}{{ t.added_on|prismarr_date }}{% else %}—{% endif %}</div>

{# Cellule mode compact — % seul #}
<div class="qbt-compact-pct">{{ t.progress }}%</div>
Expand All @@ -810,7 +810,6 @@ body[data-bs-theme="dark"] .qbt-torrent-list[data-view="table"] .qbt-cell-progre
<script>
(function(){
var BASE = '{{ path("app_deluge_index") }}';
var LOCALE = {{ app.request.locale|default('fr')|json_encode|raw }};
var currentFilter = 'all';
var selectedHashes = {};
var deleteTarget = { hashes: [], mode: 'single' };
Expand Down Expand Up @@ -992,9 +991,7 @@ body[data-bs-theme="dark"] .qbt-torrent-list[data-view="table"] .qbt-cell-progre
}
function fmtDate(ts) {
if (!ts || ts < 0) return '-';
var d = new Date(ts * 1000);
var loc = (LOCALE === 'en') ? 'en-US' : 'fr-FR';
return d.toLocaleDateString(loc) + ' ' + d.toLocaleTimeString(loc, {hour:'2-digit',minute:'2-digit'});
return window._prismarrFmtDateTime(new Date(ts * 1000));
}
function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }

Expand Down Expand Up @@ -1372,14 +1369,11 @@ body[data-bs-theme="dark"] .qbt-torrent-list[data-view="table"] .qbt-cell-progre
}
function fmtShortDate(ts) {
if (!ts) return '—';
var d = new Date(ts * 1000);
return ('0' + d.getDate()).slice(-2) + '/' + ('0' + (d.getMonth() + 1)).slice(-2) + '/' + String(d.getFullYear()).slice(-2);
return window._prismarrFmtDate(new Date(ts * 1000));
}
function fmtLongDate(ts) {
if (!ts) return '—';
var d = new Date(ts * 1000);
return ('0' + d.getDate()).slice(-2) + '/' + ('0' + (d.getMonth() + 1)).slice(-2) + '/' + d.getFullYear()
+ ' ' + ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2);
return window._prismarrFmtDateTime(new Date(ts * 1000));
}

function buildStatsLine(t) {
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/jellyseerr/settings/logs.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<tr data-level="{{ lvl }}">
<td class="text-muted small" style="font-family:monospace;font-size:.72rem;">
{% if log.timestamp is defined %}
{{ log.timestamp|date('d/m H:i:s') }}
{{ log.timestamp|prismarr_datetime(true) }}
{% else %}—{% endif %}
</td>
<td>
Expand Down
6 changes: 3 additions & 3 deletions symfony/templates/jellyseerr/settings/tasks_cache.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
</button>
</td>
<td class="text-muted small" id="next-{{ job.id }}">
{% if job.nextExecutionTime %}{{ job.nextExecutionTime|date('d/m/Y H:i') }}{% else %}—{% endif %}
{% if job.nextExecutionTime %}{{ job.nextExecutionTime|prismarr_datetime }}{% else %}—{% endif %}
</td>
<td>
<span id="status-{{ job.id }}" class="badge {{ job.running ?? false ? 'bg-purple-lt text-purple js-running' : 'bg-green-lt text-green' }}">
Expand Down Expand Up @@ -302,7 +302,7 @@
if (d.ok && d.job && d.job.nextExecutionTime) {
var dt = new Date(d.job.nextExecutionTime);
var nextEl = document.getElementById('next-' + jobId);
if (nextEl) nextEl.textContent = dt.toLocaleDateString('fr-FR') + ' ' + dt.toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit'});
if (nextEl) nextEl.textContent = window._prismarrFmtDateTime(dt);
}
setTimeout(function(){ location.reload(); }, 3000);
})
Expand Down Expand Up @@ -536,7 +536,7 @@
if (d.job && d.job.nextExecutionTime) {
var dt = new Date(d.job.nextExecutionTime);
var nextEl = document.getElementById('next-' + jobId);
if (nextEl) nextEl.textContent = dt.toLocaleDateString('fr-FR') + ' ' + dt.toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit'});
if (nextEl) nextEl.textContent = window._prismarrFmtDateTime(dt);
}
}
cronSave.disabled = false;
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/jellyseerr/settings/updates.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
{% endif %}
</td>
<td class="text-muted small">
{% if rel.date %}{{ rel.date|date('d/m/Y') }}{% else %}—{% endif %}
{% if rel.date %}{{ rel.date|prismarr_date }}{% else %}—{% endif %}
</td>
<td>
{% if rel.current %}
Expand Down
4 changes: 2 additions & 2 deletions symfony/templates/media/films.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -2070,7 +2070,7 @@ body.density-compact #modal-film .film-action-btn { font-size: .78rem; padding:

items.slice(0, 15).forEach(function (item) {
var evType = item.eventType || '';
var date = item.date ? new Date(item.date).toLocaleDateString('fr-FR') + ' ' + new Date(item.date).toLocaleTimeString('fr-FR', {hour:'2-digit',minute:'2-digit'}) : '—';
var date = item.date ? window._prismarrFmtDateTime(new Date(item.date)) : '—';
var quality = (item.quality && item.quality.quality) ? item.quality.quality.name : '';
var source = (item.data && item.data.releaseTitle) ? item.data.releaseTitle : ((item.data && item.data.importedPath) ? item.data.importedPath : '');
var lbl = evLabel[evType] || evType;
Expand Down Expand Up @@ -3478,7 +3478,7 @@ body.density-compact #modal-film .film-action-btn { font-size: .78rem; padding:
'<div class="queue-progress flex-grow-1"><div class="queue-progress-bar" style="width:' + pct + '%"></div></div>' +
'<span class="text-muted small">' + pct + '%</span></div>' +
'<div class="text-muted" style="font-size:.7rem">' + sizeLabel +
(q.eta ? FILM_I18N.q_eta_prefix + new Date(q.eta).toLocaleTimeString(document.documentElement.lang || 'fr-FR', {hour:'2-digit',minute:'2-digit'}) : '') + '</div>';
(q.eta ? FILM_I18N.q_eta_prefix + window._prismarrFmtTime(new Date(q.eta)) : '') + '</div>';
}

// Status badge — import state takes priority over DL status
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/media/films_blocklist.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
</td>
<td class="text-muted small text-nowrap">
{% if item.date is defined %}
{{ item.date|date('d/m/Y H:i') }}
{{ item.date|prismarr_datetime }}
{% else %}
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/media/films_cutoff.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
</td>
<td class="text-muted small">
{% if m.inCinemas is defined and m.inCinemas %}
{{ m.inCinemas|date('d/m/Y') }}
{{ m.inCinemas|prismarr_date }}
{% else %}
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/media/films_history.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
<tr>
<td class="text-muted small text-nowrap">
{% if item.date is defined %}
{{ item.date|date('d/m/Y H:i') }}
{{ item.date|prismarr_datetime }}
{% else %}
{% endif %}
Expand Down
4 changes: 2 additions & 2 deletions symfony/templates/media/films_missing.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@
</td>
<td class="text-muted" style="font-size:.78rem;">
{% if m.digitalRelease is defined and m.digitalRelease %}
{{ m.digitalRelease|date('d/m/Y') }}
{{ m.digitalRelease|prismarr_date }}
{% elseif m.inCinemas is defined and m.inCinemas %}
{{ m.inCinemas|date('d/m/Y') }}
{{ m.inCinemas|prismarr_date }}
{% else %}
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/media/indexeurs.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
{% for s in searches %}
<tr>
<td class="text-muted small text-nowrap">
{{ s.date ? s.date|date('d/m H:i') : '—' }}
{{ s.date ? s.date|prismarr_datetime : '—' }}
</td>
<td class="small">{{ s.indexer }}</td>
<td class="small text-truncate" style="max-width:300px;" title="{{ s.query }}">{{ s.query }}</td>
Expand Down
4 changes: 2 additions & 2 deletions symfony/templates/media/radarr_system.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@

{% if status.startTime is defined %}
<dt class="col-5 text-muted small">{{ 'media.arr_system.status.started'|trans }}</dt>
<dd class="col-7 small mb-0">{{ status.startTime|date('d/m/Y H:i') }}</dd>
<dd class="col-7 small mb-0">{{ status.startTime|prismarr_datetime }}</dd>
{% endif %}

<dt class="col-5 text-muted small">{{ 'media.arr_system.status.url_base'|trans }}</dt>
Expand Down Expand Up @@ -274,7 +274,7 @@
{% for log in logs %}
<tr>
<td class="text-muted small text-nowrap">
{% if log.time is defined %}{{ log.time|date('d/m/Y H:i:s') }}{% else %}—{% endif %}
{% if log.time is defined %}{{ log.time|prismarr_datetime(true) }}{% else %}—{% endif %}
</td>
<td>
{% set lvl = log.level ?? '' %}
Expand Down
6 changes: 5 additions & 1 deletion symfony/templates/media/series.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,11 @@ body.density-compact #modal-serie .modal-body .card-header { padding-top: .4rem
S{{ '%02d'|format(ep.season) }}E{{ '%02d'|format(ep.episode) }} — {{ ep.title }}
</td>
<td class="text-muted" style="font-size:.78rem;">
{% if ep.airDate %}{{ ep.airDate|date('D d/m H:i', 'Europe/Paris') }}{% else %}—{% endif %}
{# The weekday is the useful part of an air date, so it survives the
preference sweep (#54) as a separate |date('D') — the date and time
halves go through |prismarr_datetime, which applies the user's
timezone itself, so the hardcoded zone is gone. #}
{% if ep.airDate %}{{ ep.airDate|date('D', display_pref('timezone')) }} {{ ep.airDate|prismarr_datetime }}{% else %}—{% endif %}
</td>
<td>
{% if ep.hasFile %}
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/media/series_blocklist.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
<span class="badge bg-blue-lt">{{ b.quality.quality.name }}</span>
{% else %}—{% endif %}
</td>
<td class="text-muted">{{ b.date ? b.date|date('d/m/Y H:i', 'Europe/Paris') : '—' }}</td>
<td class="text-muted">{{ b.date ? b.date|prismarr_datetime : '—' }}</td>
<td class="text-end">
<button class="btn btn-ghost-danger btn-icon btn-sm btn-blocklist-del" data-id="{{ b.id }}" title="{{ 'media.series_blocklist.delete'|trans }}">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
Expand Down
2 changes: 1 addition & 1 deletion symfony/templates/media/series_history.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
<span class="badge bg-blue-lt">{{ h.quality.quality.name }}</span>
{% else %}—{% endif %}
</td>
<td class="text-muted">{{ h.date ? h.date|date('d/m/Y H:i', 'Europe/Paris') : '—' }}</td>
<td class="text-muted">{{ h.date ? h.date|prismarr_datetime : '—' }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="text-center text-muted py-4">{{ 'media.series_history.empty'|trans }}</td></tr>
Expand Down
Loading