feat: relative time labels in session sidebar (#406)
* feat: add relative time to session sidebar (cherry picked from commit 272be9787fdff75d3da2dbc73175820477a3390e) * fix: address session sidebar relative-time review feedback * docs: v0.50.27 release — version badge and CHANGELOG --------- Co-authored-by: Jordan SkyLF <jordan@skylinkfiber.net> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.27] feat: relative time labels in session sidebar (#394)
|
||||||
|
|
||||||
|
- `static/sessions.js`: new `_sessionCalendarBoundaries()` (DST-safe via `new Date(y,m,d)` construction), `_localDayOrdinal()`, `_formatSessionDate()` (includes year for dates from prior years); `_formatRelativeSessionTime()` now uses calendar midnight boundaries consistent with `_sessionTimeBucketLabel()` — no more label/bucket mismatch; all relative time strings call `t()` for localization; meta row only appended when non-empty (removes redundant group-header fallback); dead `ONE_DAY` constant removed
|
||||||
|
- `static/style.css`: add `session-item.active .session-title{color:#1a5a8a}` to light-theme block (fixes active title color in light mode)
|
||||||
|
- `static/i18n.js`: 11 new i18n keys (`session_time_*`) in both English and Spanish locale blocks; callable keys use arrow-function pattern consistent with existing `n_messages`
|
||||||
|
- `tests/test_session_sidebar_relative_time.py`: 5 tests — structural presence checks, behavioral Node.js tests via subprocess (yesterday/week boundary correctness, `just now` threshold, year-in-date for old sessions, full i18n key coverage for en+es)
|
||||||
|
- Original PR by @Jordan-SkyLF (two-pass review: blocking issues fixed in second commit)
|
||||||
|
- 1027 tests total (up from 1022)
|
||||||
|
|
||||||
## [v0.50.26] fix(sessions): redact sensitive titles in session list and search responses [SECURITY] (#400)
|
## [v0.50.26] fix(sessions): redact sensitive titles in session list and search responses [SECURITY] (#400)
|
||||||
|
|
||||||
- `api/routes.py`: apply `_redact_text()` to session titles in all four response paths — `/api/sessions` merged list, `/api/sessions/search` empty-q, title-match, and content-match; use `dict(s)` copy before mutating to avoid corrupting the in-memory session cache
|
- `api/routes.py`: apply `_redact_text()` to session titles in all four response paths — `/api/sessions` merged list, `/api/sessions/search` empty-q, title-match, and content-match; use `dict(s)` copy before mutating to avoid corrupting the in-memory session cache
|
||||||
|
|||||||
@@ -166,6 +166,17 @@ const LOCALES = {
|
|||||||
tab_todos: 'Todos',
|
tab_todos: 'Todos',
|
||||||
new_conversation: 'New conversation',
|
new_conversation: 'New conversation',
|
||||||
filter_conversations: 'Filter conversations...',
|
filter_conversations: 'Filter conversations...',
|
||||||
|
session_time_unknown: 'Unknown',
|
||||||
|
session_time_just_now: 'just now',
|
||||||
|
session_time_minutes_ago: (n) => `${n} minute${n === 1 ? '' : 's'} ago`,
|
||||||
|
session_time_hours_ago: (n) => `${n} hour${n === 1 ? '' : 's'} ago`,
|
||||||
|
session_time_days_ago: (n) => `${n} day${n === 1 ? '' : 's'} ago`,
|
||||||
|
session_time_last_week: 'last week',
|
||||||
|
session_time_bucket_today: 'Today',
|
||||||
|
session_time_bucket_yesterday: 'Yesterday',
|
||||||
|
session_time_bucket_this_week: 'This week',
|
||||||
|
session_time_bucket_last_week: 'Last week',
|
||||||
|
session_time_bucket_older: 'Older',
|
||||||
scheduled_jobs: 'Scheduled jobs',
|
scheduled_jobs: 'Scheduled jobs',
|
||||||
new_job: 'New job',
|
new_job: 'New job',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
@@ -433,6 +444,17 @@ const LOCALES = {
|
|||||||
tab_todos: 'Todos',
|
tab_todos: 'Todos',
|
||||||
new_conversation: 'Nueva conversación',
|
new_conversation: 'Nueva conversación',
|
||||||
filter_conversations: 'Filtrar conversaciones...',
|
filter_conversations: 'Filtrar conversaciones...',
|
||||||
|
session_time_unknown: 'Desconocido',
|
||||||
|
session_time_just_now: 'justo ahora',
|
||||||
|
session_time_minutes_ago: (n) => `hace ${n} minuto${n === 1 ? '' : 's'}`,
|
||||||
|
session_time_hours_ago: (n) => `hace ${n} hora${n === 1 ? '' : 's'}`,
|
||||||
|
session_time_days_ago: (n) => `hace ${n} día${n === 1 ? '' : 's'}`,
|
||||||
|
session_time_last_week: 'la semana pasada',
|
||||||
|
session_time_bucket_today: 'Hoy',
|
||||||
|
session_time_bucket_yesterday: 'Ayer',
|
||||||
|
session_time_bucket_this_week: 'Esta semana',
|
||||||
|
session_time_bucket_last_week: 'La semana pasada',
|
||||||
|
session_time_bucket_older: 'Más antiguo',
|
||||||
scheduled_jobs: 'Tareas programadas',
|
scheduled_jobs: 'Tareas programadas',
|
||||||
new_job: 'Nueva tarea',
|
new_job: 'Nueva tarea',
|
||||||
loading: 'Cargando...',
|
loading: 'Cargando...',
|
||||||
|
|||||||
@@ -535,7 +535,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.26</span>
|
<span class="settings-version-badge">v0.50.27</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
@@ -344,6 +344,72 @@ function filterSessions(){
|
|||||||
}, 350);
|
}, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _sessionTimestampMs(session) {
|
||||||
|
const raw = Number(session && (session.updated_at || session.created_at || 0));
|
||||||
|
return Number.isFinite(raw) ? raw * 1000 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _localDayOrdinal(timestampMs) {
|
||||||
|
const date = new Date(timestampMs);
|
||||||
|
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sessionCalendarBoundaries(nowMs = Date.now()) {
|
||||||
|
const now = new Date(nowMs);
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const startOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||||
|
const startOfWeek = new Date(startOfToday);
|
||||||
|
startOfWeek.setDate(startOfWeek.getDate() - ((startOfWeek.getDay() + 6) % 7));
|
||||||
|
const startOfLastWeek = new Date(startOfWeek);
|
||||||
|
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
|
||||||
|
return {
|
||||||
|
startOfToday: startOfToday.getTime(),
|
||||||
|
startOfYesterday: startOfYesterday.getTime(),
|
||||||
|
startOfWeek: startOfWeek.getTime(),
|
||||||
|
startOfLastWeek: startOfLastWeek.getTime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatSessionDate(timestampMs, nowMs = Date.now()) {
|
||||||
|
const date = new Date(timestampMs);
|
||||||
|
const now = new Date(nowMs);
|
||||||
|
const options = {month:'short', day:'numeric'};
|
||||||
|
if (date.getFullYear() !== now.getFullYear()) options.year = 'numeric';
|
||||||
|
return date.toLocaleDateString(undefined, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatRelativeSessionTime(timestampMs, nowMs = Date.now()) {
|
||||||
|
if (!timestampMs) return t('session_time_unknown');
|
||||||
|
const diffMs = Math.max(0, nowMs - timestampMs);
|
||||||
|
const minute = 60 * 1000;
|
||||||
|
const hour = 60 * minute;
|
||||||
|
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
|
||||||
|
const dayDiff = Math.max(0, _localDayOrdinal(nowMs) - _localDayOrdinal(timestampMs));
|
||||||
|
if (timestampMs >= startOfToday) {
|
||||||
|
if (diffMs < minute) return t('session_time_just_now');
|
||||||
|
if (diffMs < hour) {
|
||||||
|
const minutes = Math.floor(diffMs / minute);
|
||||||
|
return t('session_time_minutes_ago', minutes);
|
||||||
|
}
|
||||||
|
const hours = Math.floor(diffMs / hour);
|
||||||
|
return t('session_time_hours_ago', hours);
|
||||||
|
}
|
||||||
|
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
|
||||||
|
if (timestampMs >= startOfWeek) return t('session_time_days_ago', dayDiff);
|
||||||
|
if (timestampMs >= startOfLastWeek) return t('session_time_last_week');
|
||||||
|
return _formatSessionDate(timestampMs, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sessionTimeBucketLabel(timestampMs, nowMs = Date.now()) {
|
||||||
|
if (!timestampMs) return t('session_time_bucket_older');
|
||||||
|
const {startOfToday, startOfYesterday, startOfWeek, startOfLastWeek} = _sessionCalendarBoundaries(nowMs);
|
||||||
|
if (timestampMs >= startOfToday) return t('session_time_bucket_today');
|
||||||
|
if (timestampMs >= startOfYesterday) return t('session_time_bucket_yesterday');
|
||||||
|
if (timestampMs >= startOfWeek) return t('session_time_bucket_this_week');
|
||||||
|
if (timestampMs >= startOfLastWeek) return t('session_time_bucket_last_week');
|
||||||
|
return t('session_time_bucket_older');
|
||||||
|
}
|
||||||
|
|
||||||
function renderSessionListFromCache(){
|
function renderSessionListFromCache(){
|
||||||
// Don't re-render while user is actively renaming a session (would destroy the input)
|
// Don't re-render while user is actively renaming a session (would destroy the input)
|
||||||
if(_renamingSid) return;
|
if(_renamingSid) return;
|
||||||
@@ -430,12 +496,12 @@ function renderSessionListFromCache(){
|
|||||||
empty.textContent='No sessions in this project yet.';
|
empty.textContent='No sessions in this project yet.';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
}
|
}
|
||||||
|
const orderedSessions=[...sessions].sort((a,b)=>_sessionTimestampMs(b)-_sessionTimestampMs(a));
|
||||||
// Separate pinned from unpinned
|
// Separate pinned from unpinned
|
||||||
const pinned=sessions.filter(s=>s.pinned);
|
const pinned=orderedSessions.filter(s=>s.pinned);
|
||||||
const unpinned=sessions.filter(s=>!s.pinned);
|
const unpinned=orderedSessions.filter(s=>!s.pinned);
|
||||||
// Date grouping: Pinned / Today / Yesterday / Earlier
|
// Date grouping: Pinned / Today / Yesterday / This week / Last week / Older
|
||||||
const now=Date.now();
|
const now=Date.now();
|
||||||
const ONE_DAY=86400000;
|
|
||||||
// Collapse state persisted in localStorage
|
// Collapse state persisted in localStorage
|
||||||
let _groupCollapsed={};
|
let _groupCollapsed={};
|
||||||
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
|
try{_groupCollapsed=JSON.parse(localStorage.getItem('hermes-date-groups-collapsed')||'{}');}catch(e){}
|
||||||
@@ -445,8 +511,8 @@ function renderSessionListFromCache(){
|
|||||||
let curLabel=null,curItems=[];
|
let curLabel=null,curItems=[];
|
||||||
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
|
if(pinned.length) groups.push({label:'\u2605 Pinned',items:pinned,isPinned:true});
|
||||||
for(const s of unpinned){
|
for(const s of unpinned){
|
||||||
const ts=(s.updated_at||s.created_at||0)*1000;
|
const ts=_sessionTimestampMs(s);
|
||||||
const label=ts>now-ONE_DAY?'Today':ts>now-2*ONE_DAY?'Yesterday':'Earlier';
|
const label=_sessionTimeBucketLabel(ts, now);
|
||||||
if(label!==curLabel){
|
if(label!==curLabel){
|
||||||
if(curItems.length) groups.push({label:curLabel,items:curItems});
|
if(curItems.length) groups.push({label:curLabel,items:curItems});
|
||||||
curLabel=label;curItems=[s];
|
curLabel=label;curItems=[s];
|
||||||
@@ -491,10 +557,32 @@ function renderSessionListFromCache(){
|
|||||||
const rawTitle=s.title||'Untitled';
|
const rawTitle=s.title||'Untitled';
|
||||||
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
||||||
const cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
|
const cleanTitle=tags.length?rawTitle.replace(/#[\w-]+/g,'').trim():rawTitle;
|
||||||
|
const sessionText=document.createElement('div');
|
||||||
|
sessionText.className='session-text';
|
||||||
|
const titleRow=document.createElement('div');
|
||||||
|
titleRow.className='session-title-row';
|
||||||
const title=document.createElement('span');
|
const title=document.createElement('span');
|
||||||
title.className='session-title';
|
title.className='session-title';
|
||||||
title.textContent=cleanTitle||'Untitled';
|
title.textContent=cleanTitle||'Untitled';
|
||||||
title.title='Double-click to rename';
|
title.title='Double-click to rename';
|
||||||
|
const tsMs=_sessionTimestampMs(s);
|
||||||
|
const timeLabel=document.createElement('span');
|
||||||
|
timeLabel.className='session-time';
|
||||||
|
timeLabel.textContent=_formatRelativeSessionTime(tsMs, now);
|
||||||
|
if(tsMs) timeLabel.title=new Date(tsMs).toLocaleString();
|
||||||
|
titleRow.appendChild(title);
|
||||||
|
titleRow.appendChild(timeLabel);
|
||||||
|
const metaBits=[];
|
||||||
|
if(s.is_cli_session && s.source_tag) metaBits.push(s.source_tag);
|
||||||
|
if(s.message_count) metaBits.push(t('n_messages', s.message_count));
|
||||||
|
if(s.model) metaBits.push(String(s.model).split('/').pop());
|
||||||
|
sessionText.appendChild(titleRow);
|
||||||
|
if(metaBits.length){
|
||||||
|
const meta=document.createElement('div');
|
||||||
|
meta.className='session-meta';
|
||||||
|
meta.textContent=metaBits.join(' · ');
|
||||||
|
sessionText.appendChild(meta);
|
||||||
|
}
|
||||||
// Append tag chips after the title text
|
// Append tag chips after the title text
|
||||||
for(const tag of tags){
|
for(const tag of tags){
|
||||||
const chip=document.createElement('span');
|
const chip=document.createElement('span');
|
||||||
@@ -561,7 +649,7 @@ function renderSessionListFromCache(){
|
|||||||
title.appendChild(dot);
|
title.appendChild(dot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
el.appendChild(title);
|
el.appendChild(sessionText);
|
||||||
// Single trigger button that opens a shared dropdown menu
|
// Single trigger button that opens a shared dropdown menu
|
||||||
const actions=document.createElement('div');
|
const actions=document.createElement('div');
|
||||||
actions.className='session-actions';
|
actions.className='session-actions';
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
:root[data-theme="light"] .session-item{color:#5a544a;}
|
:root[data-theme="light"] .session-item{color:#5a544a;}
|
||||||
:root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
|
:root[data-theme="light"] .session-item:hover{background:rgba(0,0,0,.06);color:#2c2825;}
|
||||||
:root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;}
|
:root[data-theme="light"] .session-item.active{background:rgba(45,111,163,.1);color:#1a5a8a;}
|
||||||
|
:root[data-theme="light"] .session-item.active .session-title{color:#1a5a8a;}
|
||||||
:root[data-theme="light"] .session-pin-indicator{color:#996b15;}
|
:root[data-theme="light"] .session-pin-indicator{color:#996b15;}
|
||||||
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
|
:root[data-theme="light"] .session-date-header.pinned{color:#996b15;}
|
||||||
:root[data-theme="light"] .session-actions-trigger.active,
|
:root[data-theme="light"] .session-actions-trigger.active,
|
||||||
@@ -129,10 +130,15 @@
|
|||||||
.session-search input::placeholder{color:var(--muted);opacity:.7;}
|
.session-search input::placeholder{color:var(--muted);opacity:.7;}
|
||||||
/* Inline session title edit */
|
/* Inline session title edit */
|
||||||
.session-title-input{flex:1;background:var(--surface);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
|
.session-title-input{flex:1;background:var(--surface);border:1px solid rgba(124,185,255,.6);border-radius:6px;color:var(--text);padding:3px 8px;font-size:13px;outline:none;min-width:0;box-shadow:0 0 0 2px rgba(124,185,255,.15);font-family:inherit;}
|
||||||
.session-item{padding:8px 40px 8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:background .15s,color .15s;display:flex;align-items:center;gap:6px;min-width:0;position:relative;}
|
.session-item{padding:8px 40px 8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;}
|
||||||
.session-item:hover{background:var(--hover-bg);color:var(--text);}
|
.session-item:hover{background:var(--hover-bg);color:var(--text);}
|
||||||
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;}
|
.session-item.active{background:rgba(232,160,48,0.12);color:#e8a030;}
|
||||||
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
.session-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px;overflow:hidden;}
|
||||||
|
.session-title-row{display:flex;align-items:flex-start;gap:8px;min-width:0;}
|
||||||
|
.session-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);}
|
||||||
|
.session-item.active .session-title{color:#e8a030;}
|
||||||
|
.session-time{flex-shrink:0;font-size:11px;line-height:1.4;color:var(--muted);text-transform:lowercase;}
|
||||||
|
.session-meta{font-size:11px;line-height:1.35;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
/* ── Session action trigger + dropdown ── */
|
/* ── Session action trigger + dropdown ── */
|
||||||
.session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;}
|
.session-actions{position:absolute;right:6px;top:50%;transform:translateY(-50%);display:flex;align-items:center;justify-content:center;opacity:0;pointer-events:none;transition:opacity .15s ease;}
|
||||||
.session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;}
|
.session-item:hover .session-actions,.session-item:focus-within .session-actions,.session-item.menu-open .session-actions{opacity:1;pointer-events:auto;}
|
||||||
|
|||||||
139
tests/test_session_sidebar_relative_time.py
Normal file
139
tests/test_session_sidebar_relative_time.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||||
|
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
|
||||||
|
STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||||
|
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_function(source: str, name: str) -> str:
|
||||||
|
marker = f"function {name}"
|
||||||
|
start = source.index(marker)
|
||||||
|
brace_start = source.index("{", start)
|
||||||
|
depth = 0
|
||||||
|
for idx in range(brace_start, len(source)):
|
||||||
|
ch = source[idx]
|
||||||
|
if ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return source[start : idx + 1]
|
||||||
|
raise AssertionError(f"Could not extract {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_session_time_case(script_body: str) -> dict:
|
||||||
|
functions = "\n\n".join(
|
||||||
|
_extract_function(SESSIONS_JS, name)
|
||||||
|
for name in (
|
||||||
|
"_localDayOrdinal",
|
||||||
|
"_sessionCalendarBoundaries",
|
||||||
|
"_formatSessionDate",
|
||||||
|
"_formatRelativeSessionTime",
|
||||||
|
"_sessionTimeBucketLabel",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
script = textwrap.dedent(
|
||||||
|
f"""
|
||||||
|
process.env.TZ = 'UTC';
|
||||||
|
const translations = {{
|
||||||
|
session_time_unknown: 'Unknown',
|
||||||
|
session_time_just_now: 'just now',
|
||||||
|
session_time_minutes_ago: (n) => `${{n}} minute${{n === 1 ? '' : 's'}} ago`,
|
||||||
|
session_time_hours_ago: (n) => `${{n}} hour${{n === 1 ? '' : 's'}} ago`,
|
||||||
|
session_time_days_ago: (n) => `${{n}} day${{n === 1 ? '' : 's'}} ago`,
|
||||||
|
session_time_last_week: 'last week',
|
||||||
|
session_time_bucket_today: 'Today',
|
||||||
|
session_time_bucket_yesterday: 'Yesterday',
|
||||||
|
session_time_bucket_this_week: 'This week',
|
||||||
|
session_time_bucket_last_week: 'Last week',
|
||||||
|
session_time_bucket_older: 'Older',
|
||||||
|
}};
|
||||||
|
function t(key, ...args) {{
|
||||||
|
const val = translations[key];
|
||||||
|
return typeof val === 'function' ? val(...args) : val;
|
||||||
|
}}
|
||||||
|
{functions}
|
||||||
|
{script_body}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
|
||||||
|
return json.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_sidebar_js_has_dynamic_relative_time_helpers():
|
||||||
|
assert "function _sessionCalendarBoundaries" in SESSIONS_JS
|
||||||
|
assert "function _formatRelativeSessionTime" in SESSIONS_JS
|
||||||
|
assert "function _sessionTimeBucketLabel" in SESSIONS_JS
|
||||||
|
assert "session_time_bucket_last_week" in SESSIONS_JS
|
||||||
|
assert "session_time_bucket_this_week" in SESSIONS_JS
|
||||||
|
assert "session_time_bucket_older" in SESSIONS_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_sidebar_renders_relative_time_and_meta_rows():
|
||||||
|
assert "session-time" in SESSIONS_JS
|
||||||
|
assert "session-meta" in SESSIONS_JS
|
||||||
|
assert "orderedSessions" in SESSIONS_JS
|
||||||
|
assert ".session-time" in STYLE_CSS
|
||||||
|
assert ".session-meta" in STYLE_CSS
|
||||||
|
assert ".session-title-row" in STYLE_CSS
|
||||||
|
assert ".session-item.active .session-title" in STYLE_CSS
|
||||||
|
assert "metaBits.join(' · ')" in SESSIONS_JS
|
||||||
|
assert "|| _sessionTimeBucketLabel" not in SESSIONS_JS
|
||||||
|
assert "const ONE_DAY=86400000;" not in SESSIONS_JS
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_time_uses_calendar_boundaries_and_year_for_old_sessions():
|
||||||
|
result = _run_session_time_case(
|
||||||
|
"""
|
||||||
|
const now = Date.UTC(2026, 3, 15, 1, 0, 0);
|
||||||
|
const mondayLate = Date.UTC(2026, 3, 13, 23, 0, 0);
|
||||||
|
const oldSession = Date.UTC(2024, 2, 5, 12, 0, 0);
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
relative: _formatRelativeSessionTime(mondayLate, now),
|
||||||
|
bucket: _sessionTimeBucketLabel(mondayLate, now),
|
||||||
|
oldDate: _formatRelativeSessionTime(oldSession, now),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert result["relative"] == "2 days ago"
|
||||||
|
assert result["bucket"] == "This week"
|
||||||
|
assert "2024" in result["oldDate"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_time_handles_just_now_and_dst_safe_yesterday_boundary():
|
||||||
|
result = _run_session_time_case(
|
||||||
|
"""
|
||||||
|
const now = Date.UTC(2026, 2, 9, 12, 0, 0);
|
||||||
|
const justNow = now - 30 * 1000;
|
||||||
|
const yesterday = Date.UTC(2026, 2, 8, 23, 30, 0);
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
justNow: _formatRelativeSessionTime(justNow, now),
|
||||||
|
yesterday: _formatRelativeSessionTime(yesterday, now),
|
||||||
|
yesterdayBucket: _sessionTimeBucketLabel(yesterday, now),
|
||||||
|
}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert result["justNow"] == "just now"
|
||||||
|
assert result["yesterday"] == "Yesterday"
|
||||||
|
assert result["yesterdayBucket"] == "Yesterday"
|
||||||
|
|
||||||
|
|
||||||
|
def test_relative_time_strings_are_localized_in_english_and_spanish_bundles():
|
||||||
|
for key in (
|
||||||
|
"session_time_unknown",
|
||||||
|
"session_time_just_now",
|
||||||
|
"session_time_minutes_ago",
|
||||||
|
"session_time_hours_ago",
|
||||||
|
"session_time_days_ago",
|
||||||
|
"session_time_last_week",
|
||||||
|
"session_time_bucket_today",
|
||||||
|
"session_time_bucket_yesterday",
|
||||||
|
"session_time_bucket_this_week",
|
||||||
|
"session_time_bucket_last_week",
|
||||||
|
"session_time_bucket_older",
|
||||||
|
):
|
||||||
|
assert key in I18N_JS
|
||||||
Reference in New Issue
Block a user