Skip to content

Commit 1be1191

Browse files
committed
Enh(viewer): Add time column, allow to sort
1 parent c6ee821 commit 1be1191

4 files changed

Lines changed: 183 additions & 7 deletions

File tree

codeclash/viewer/app.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,32 @@ def get_readme_first_line(log_dir: Path) -> str:
146146
return ""
147147

148148

149+
def get_creation_time_from_metadata(log_dir: Path) -> str:
150+
"""Extract creation time from metadata.json and format as MM/DD-HH:MM"""
151+
from datetime import datetime
152+
153+
metadata = load_metadata(log_dir)
154+
if not metadata:
155+
return ""
156+
157+
# Try to get creation timestamp from metadata
158+
timestamp = None
159+
if "created_timestamp" in metadata:
160+
timestamp = metadata["created_timestamp"]
161+
elif "game" in metadata and "created_timestamp" in metadata["game"]:
162+
timestamp = metadata["game"]["created_timestamp"]
163+
164+
if timestamp:
165+
try:
166+
# Convert timestamp to datetime and format as MM/DD-HH:MM
167+
dt = datetime.fromtimestamp(timestamp)
168+
return dt.strftime("%m/%d-%H:%M")
169+
except (ValueError, OSError):
170+
return ""
171+
172+
return ""
173+
174+
149175
def get_agent_info_from_metadata(metadata: dict[str, Any]) -> list[AgentInfo]:
150176
"""Extract detailed agent information from metadata"""
151177
agents = []
@@ -166,7 +192,7 @@ def get_agent_info_from_metadata(metadata: dict[str, Any]) -> list[AgentInfo]:
166192
return agents
167193

168194

169-
def find_all_game_folders(base_dir: Path) -> list[dict[str, Any]]:
195+
def find_all_game_folders(base_dir: Path, sort_by: str = "name", sort_order: str = "asc") -> list[dict[str, Any]]:
170196
"""Recursively find all folders and mark which ones contain metadata.json"""
171197
all_folders = []
172198
game_folders = set() # Track which folders are actual game folders
@@ -188,6 +214,7 @@ def scan_directory(directory: Path, relative_path: str = ""):
188214
models = get_models_from_metadata(item)
189215
readme_first_line = get_readme_first_line(item)
190216
game_name = get_game_name_from_metadata(item)
217+
creation_time = get_creation_time_from_metadata(item)
191218
game_folders.add(current_relative)
192219
all_folders.append(
193220
{
@@ -197,6 +224,7 @@ def scan_directory(directory: Path, relative_path: str = ""):
197224
"models": models,
198225
"readme_first_line": readme_first_line,
199226
"game_name": game_name,
227+
"creation_time": creation_time,
200228
"is_game": True,
201229
"depth": depth,
202230
"parent": relative_path if relative_path else None,
@@ -212,6 +240,7 @@ def scan_directory(directory: Path, relative_path: str = ""):
212240
"models": [],
213241
"readme_first_line": "",
214242
"game_name": "",
243+
"creation_time": "",
215244
"is_game": False,
216245
"depth": depth,
217246
"parent": relative_path if relative_path else None,
@@ -228,7 +257,7 @@ def scan_directory(directory: Path, relative_path: str = ""):
228257

229258
# Filter out intermediate folders that don't lead to any game folders
230259
filtered_folders = []
231-
for folder in sorted(all_folders, key=lambda x: x["name"]):
260+
for folder in all_folders:
232261
if folder["is_game"]:
233262
# Always include game folders
234263
filtered_folders.append(folder)
@@ -239,6 +268,32 @@ def scan_directory(directory: Path, relative_path: str = ""):
239268
if has_game_descendants:
240269
filtered_folders.append(folder)
241270

271+
# Sort the filtered folders
272+
def get_sort_key(folder):
273+
if sort_by == "time":
274+
# For time sorting, prioritize game folders and sort by creation time
275+
if folder["is_game"] and folder["creation_time"]:
276+
# Parse time back to comparable format for sorting
277+
try:
278+
from datetime import datetime
279+
280+
time_str = folder["creation_time"]
281+
# Convert MM/DD-HH:MM back to datetime for comparison
282+
# Assume current year for comparison
283+
current_year = datetime.now().year
284+
dt = datetime.strptime(f"{current_year}/{time_str}", "%Y/%m/%d-%H:%M")
285+
return (0, dt) # 0 for game folders (higher priority)
286+
except (ValueError, TypeError):
287+
return (1, folder["name"]) # Fallback to name sorting
288+
else:
289+
return (1, folder["name"]) # Non-game folders or no time, sort by name
290+
else: # sort_by == "name" or default
291+
# For name sorting, sort by folder depth first, then by name
292+
return (folder["depth"], folder["name"])
293+
294+
reverse_sort = sort_order == "desc"
295+
filtered_folders.sort(key=get_sort_key, reverse=reverse_sort)
296+
242297
return filtered_folders
243298

244299

@@ -813,9 +868,19 @@ def game_view(folder_path):
813868
def game_picker():
814869
"""Game picker page with recursive folder support"""
815870
logs_dir = LOG_BASE_DIR
816-
game_folders = find_all_game_folders(logs_dir)
871+
sort_by = request.args.get("sort", "name") # Default to name
872+
sort_order = request.args.get("order", "asc") # Default to ascending
873+
874+
game_folders = find_all_game_folders(logs_dir, sort_by=sort_by, sort_order=sort_order)
817875

818-
return render_template("picker.html", game_folders=game_folders, base_dir=str(logs_dir), is_static=STATIC_MODE)
876+
return render_template(
877+
"picker.html",
878+
game_folders=game_folders,
879+
base_dir=str(logs_dir),
880+
is_static=STATIC_MODE,
881+
current_sort=sort_by,
882+
current_order=sort_order,
883+
)
819884

820885

821886
@app.route("/delete-experiment", methods=["POST"])

codeclash/viewer/static/css/picker.css

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,15 @@
195195
padding: 1rem;
196196
border-bottom: 1px solid var(--border-color);
197197
display: grid;
198-
grid-template-columns: 60px 2.5fr 2fr 100px 3fr 180px;
198+
grid-template-columns: 60px 2.5fr 100px 2.5fr 100px 3fr 180px;
199199
gap: 1rem;
200200
font-weight: 600;
201201
color: var(--text-primary);
202202
}
203203

204204
.game-row {
205205
display: grid;
206-
grid-template-columns: 60px 2.5fr 2fr 100px 3fr 180px;
206+
grid-template-columns: 60px 2.5fr 100px 2.5fr 100px 3fr 180px;
207207
gap: 1rem;
208208
padding: 0.75rem 1rem;
209209
border-bottom: 1px solid var(--border-color);
@@ -496,6 +496,65 @@
496496
font-style: italic;
497497
}
498498

499+
.time-cell {
500+
display: flex;
501+
align-items: center;
502+
justify-content: center;
503+
width: 100px;
504+
}
505+
506+
.time-text {
507+
background-color: var(--bg-tertiary);
508+
color: var(--text-primary);
509+
padding: 0.2rem 0.4rem;
510+
border-radius: 0.25rem;
511+
font-size: 0.75rem;
512+
font-weight: 500;
513+
font-family: monospace;
514+
min-width: 3.5rem;
515+
text-align: center;
516+
border: 1px solid var(--border-color);
517+
}
518+
519+
.time-unknown {
520+
color: var(--text-muted);
521+
font-size: 0.8rem;
522+
font-style: italic;
523+
}
524+
525+
.sortable-header {
526+
cursor: pointer;
527+
display: flex;
528+
align-items: center;
529+
gap: 0.5rem;
530+
transition: all 0.2s ease;
531+
padding: 0.25rem;
532+
border-radius: 0.25rem;
533+
user-select: none;
534+
}
535+
536+
.sortable-header:hover {
537+
background-color: var(--bg-primary);
538+
color: var(--accent-color);
539+
}
540+
541+
.sortable-header i {
542+
font-size: 0.8rem;
543+
opacity: 0.7;
544+
}
545+
546+
.sortable-header:hover i {
547+
opacity: 1;
548+
}
549+
550+
.sort-inactive {
551+
opacity: 0.4;
552+
}
553+
554+
.sortable-header:hover .sort-inactive {
555+
opacity: 0.7;
556+
}
557+
499558
/* Clickable filter styles */
500559
.clickable-filter {
501560
cursor: pointer;
@@ -750,6 +809,7 @@
750809

751810
.readme-cell,
752811
.models-cell,
812+
.time-cell,
753813
.action-cell {
754814
display: none; /* Hide on mobile to save space */
755815
}

codeclash/viewer/static/js/picker.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,3 +1031,23 @@ function handleModelTagClick(event, modelName) {
10311031
event.stopPropagation();
10321032
setModelFilter(modelName);
10331033
}
1034+
1035+
function sortBy(column) {
1036+
const urlParams = new URLSearchParams(window.location.search);
1037+
const currentSort = urlParams.get("sort") || "name";
1038+
const currentOrder = urlParams.get("order") || "asc";
1039+
1040+
let newOrder = "asc";
1041+
1042+
// If clicking the same column, toggle the order
1043+
if (currentSort === column) {
1044+
newOrder = currentOrder === "asc" ? "desc" : "asc";
1045+
}
1046+
1047+
// Update URL parameters
1048+
urlParams.set("sort", column);
1049+
urlParams.set("order", newOrder);
1050+
1051+
// Navigate to the new URL
1052+
window.location.search = urlParams.toString();
1053+
}

codeclash/viewer/templates/picker.html

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,30 @@
6161
</div>
6262
<div class="table-header">
6363
<div><i class="bi bi-check-square"></i></div>
64-
<div>Name</div>
64+
<div class="sortable-header" onclick="sortBy('name')">
65+
Name
66+
{% if current_sort == 'name' %}
67+
{% if current_order == 'asc' %}
68+
<i class="bi bi-arrow-up"></i>
69+
{% else %}
70+
<i class="bi bi-arrow-down"></i>
71+
{% endif %}
72+
{% else %}
73+
<i class="bi bi-arrow-up-down sort-inactive"></i>
74+
{% endif %}
75+
</div>
76+
<div class="sortable-header" onclick="sortBy('time')">
77+
<i class="bi bi-clock"></i> Time
78+
{% if current_sort == 'time' %}
79+
{% if current_order == 'asc' %}
80+
<i class="bi bi-arrow-up"></i>
81+
{% else %}
82+
<i class="bi bi-arrow-down"></i>
83+
{% endif %}
84+
{% else %}
85+
<i class="bi bi-arrow-up-down sort-inactive"></i>
86+
{% endif %}
87+
</div>
6588
<select id="game-filter" onchange="applyFilters()" class="header-filter-only">
6689
<option value="">All Games</option>
6790
</select>
@@ -98,6 +121,14 @@
98121
<span class="game-name">{{ folder_name }}</span>
99122
</div>
100123

124+
<div class="time-cell">
125+
{% if game.is_game and game.creation_time %}
126+
<span class="time-text">{{ game.creation_time }}</span>
127+
{% else %}
128+
<span class="time-unknown">-</span>
129+
{% endif %}
130+
</div>
131+
101132
<div class="game-name-cell">
102133
{% if game.is_game and game.game_name %}
103134
<span class="game-name-text clickable-filter" onclick="handleGameNameClick(event, '{{ game.game_name }}')" title="Click to filter by this game">{{ game.game_name }}</span>

0 commit comments

Comments
 (0)