Skip to content

Commit 633de0b

Browse files
romainhildRomain Hild
andauthored
feat: add recipe sorting by name, modified date, and created date (#356)
* feat: add recipe sorting by name, modified date, and created date * chore: format code * test: fix accessibility label --------- Co-authored-by: Romain Hild <romain@MacBook-Pro-de-Romain.local>
1 parent 5f66deb commit 633de0b

12 files changed

Lines changed: 647 additions & 24 deletions

File tree

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
# Recipe Sorting Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Add client-side sorting (by name, last modified, creation date) to the recipes list page via a field dropdown + direction toggle.
6+
7+
**Architecture:** Rust populates `modified_at`/`created_at` Unix timestamps on each `RecipeItem`; the Askama template embeds them as `data-*` attributes on recipe cards; inline JavaScript handles DOM reordering without any page reload.
8+
9+
**Tech Stack:** Rust (Askama templates, std::fs::metadata), Tailwind CSS, vanilla JavaScript
10+
11+
---
12+
13+
## File Map
14+
15+
| File | Change |
16+
|------|--------|
17+
| `src/server/templates.rs` | Add `modified_at: Option<i64>` and `created_at: Option<i64>` to `RecipeItem` |
18+
| `src/server/builders.rs` | Populate those fields from `std::fs::metadata` when building recipe items |
19+
| `templates/recipes.html` | Add `data-modified`/`data-created`/`data-type` attributes to cards; add sort control UI; add sort JS |
20+
21+
---
22+
23+
## Task 1: Add timestamp fields to `RecipeItem`
24+
25+
**Files:**
26+
- Modify: `src/server/templates.rs:469-479`
27+
28+
- [ ] **Step 1: Add the two new fields to `RecipeItem`**
29+
30+
Open `src/server/templates.rs`. Find `RecipeItem` (around line 469) and add `modified_at` and `created_at`:
31+
32+
```rust
33+
#[derive(Debug, Clone, Serialize, Deserialize)]
34+
pub struct RecipeItem {
35+
pub name: String,
36+
pub path: String,
37+
pub is_directory: bool,
38+
pub count: Option<usize>,
39+
pub description: Option<String>,
40+
pub tags: Vec<String>,
41+
pub image_path: Option<String>,
42+
pub is_menu: bool,
43+
pub modified_at: Option<i64>,
44+
pub created_at: Option<i64>,
45+
}
46+
```
47+
48+
- [ ] **Step 2: Verify it compiles (errors expected in builders.rs — that's fine)**
49+
50+
```bash
51+
cd /Users/romain/Projects/cooklang/cookcli && cargo build 2>&1 | grep "error"
52+
```
53+
54+
Expected: errors about missing fields in `RecipeItem { ... }` struct literals in `builders.rs`. That confirms the field was added and is now required.
55+
56+
---
57+
58+
## Task 2: Populate timestamps in `build_recipes_template`
59+
60+
**Files:**
61+
- Modify: `src/server/builders.rs:64-108`
62+
63+
- [ ] **Step 1: Extract timestamps alongside tags/image in the recipe branch**
64+
65+
In `src/server/builders.rs`, find the block starting at line 64:
66+
67+
```rust
68+
// Extract tags, image, and is_menu if this is a recipe
69+
let (tags, image_path, is_menu) = if let Some(ref recipe) = child.recipe {
70+
```
71+
72+
Replace it with:
73+
74+
```rust
75+
// Extract tags, image, is_menu, and file timestamps if this is a recipe
76+
let (tags, image_path, is_menu, modified_at, created_at) = if let Some(ref recipe) = child.recipe {
77+
let img_path = recipe.title_image().clone().and_then(|img| {
78+
if img.starts_with("http://") || img.starts_with("https://") {
79+
Some(img)
80+
} else {
81+
// Make path relative to base and accessible via /api/static
82+
let img_path = camino::Utf8Path::new(&img);
83+
if let Ok(relative) = img_path.strip_prefix(base_path) {
84+
Some(format!("{url_prefix}/api/static/{relative}"))
85+
} else if !img_path.is_absolute() {
86+
Some(format!("{url_prefix}/api/static/{img_path}"))
87+
} else {
88+
img_path
89+
.file_name()
90+
.map(|name| format!("{url_prefix}/api/static/{name}"))
91+
}
92+
}
93+
});
94+
95+
let (modified_at, created_at) = recipe.path().map(|p| {
96+
let meta = std::fs::metadata(p).ok();
97+
let modified = meta.as_ref()
98+
.and_then(|m| m.modified().ok())
99+
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
100+
.map(|d| d.as_secs() as i64);
101+
let created = meta.as_ref()
102+
.and_then(|m| m.created().ok())
103+
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
104+
.map(|d| d.as_secs() as i64);
105+
(modified, created)
106+
}).unwrap_or((None, None));
107+
108+
(recipe.tags(), img_path, recipe.is_menu(), modified_at, created_at)
109+
} else {
110+
(Vec::new(), None, false, None, None)
111+
};
112+
```
113+
114+
- [ ] **Step 2: Pass the new fields into the `RecipeItem` push**
115+
116+
Still in `builders.rs`, find the `items.push(RecipeItem { ... })` call (around line 88) and add the two new fields:
117+
118+
```rust
119+
items.push(RecipeItem {
120+
name: name.to_string(),
121+
path: item_path,
122+
is_directory: is_dir,
123+
count: if is_dir {
124+
count_recipes_tree(child)
125+
} else {
126+
None
127+
},
128+
description: None,
129+
tags,
130+
image_path,
131+
is_menu,
132+
modified_at,
133+
created_at,
134+
});
135+
```
136+
137+
- [ ] **Step 3: Verify clean compile**
138+
139+
```bash
140+
cd /Users/romain/Projects/cooklang/cookcli && cargo build 2>&1 | grep "error"
141+
```
142+
143+
Expected: no errors.
144+
145+
- [ ] **Step 4: Commit**
146+
147+
```bash
148+
cd /Users/romain/Projects/cooklang/cookcli
149+
git add src/server/templates.rs src/server/builders.rs
150+
git commit -m "feat: add modified_at and created_at timestamps to RecipeItem"
151+
```
152+
153+
---
154+
155+
## Task 3: Embed data attributes and add sort controls to template
156+
157+
**Files:**
158+
- Modify: `templates/recipes.html`
159+
160+
- [ ] **Step 1: Add `id` and `data-type` attributes to the grid and cards**
161+
162+
In `templates/recipes.html`, find the grid opening tag (line 58):
163+
164+
```html
165+
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
166+
```
167+
168+
Replace with:
169+
170+
```html
171+
<div id="recipes-grid" class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
172+
```
173+
174+
Find the directory card `<a>` tag (line 61):
175+
176+
```html
177+
<a href="{{ prefix }}/directory/{{ item.path }}{% if static_mode %}.html{% endif %}" class="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all hover:scale-105 recipe-card flex flex-col">
178+
```
179+
180+
Add `data-type="directory"`:
181+
182+
```html
183+
<a href="{{ prefix }}/directory/{{ item.path }}{% if static_mode %}.html{% endif %}" data-type="directory" class="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all hover:scale-105 recipe-card flex flex-col">
184+
```
185+
186+
Find the recipe card `<a>` tag (line 75):
187+
188+
```html
189+
<a href="{{ prefix }}/{% if static_mode %}{% if item.is_menu %}menu{% else %}recipe{% endif %}{% else %}recipe{% endif %}/{{ item.path }}{% if static_mode %}.html{% endif %}" class="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all hover:scale-105 recipe-card flex flex-col">
190+
```
191+
192+
Add `data-type="recipe"` and the timestamp attributes:
193+
194+
```html
195+
<a href="{{ prefix }}/{% if static_mode %}{% if item.is_menu %}menu{% else %}recipe{% endif %}{% else %}recipe{% endif %}/{{ item.path }}{% if static_mode %}.html{% endif %}"
196+
data-type="recipe"
197+
{% if let Some(ts) = item.modified_at %}data-modified="{{ ts }}"{% endif %}
198+
{% if let Some(ts) = item.created_at %}data-created="{{ ts }}"{% endif %}
199+
class="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-xl transition-all hover:scale-105 recipe-card flex flex-col">
200+
```
201+
202+
- [ ] **Step 2: Add the sort controls row**
203+
204+
Find the closing `</div>` of the title row (line 33, after the `{% endif %}` that closes the `{% if !static_mode %}` new-recipe button block):
205+
206+
```html
207+
</div>
208+
209+
{% match todays_menu %}
210+
```
211+
212+
Insert the sort controls between the title row and the today's menu block:
213+
214+
```html
215+
</div>
216+
217+
<div id="sort-controls" class="hidden items-center gap-2 mb-6">
218+
<span class="text-sm text-gray-500 dark:text-gray-400">Sort by:</span>
219+
<select id="sort-field" class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-2 py-1.5 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-300">
220+
<option value="name">Name</option>
221+
<option value="modified">Modified</option>
222+
<option value="created" id="sort-created-option">Created</option>
223+
</select>
224+
<button id="sort-dir" class="px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors" title="Toggle sort direction">↑</button>
225+
</div>
226+
227+
{% match todays_menu %}
228+
```
229+
230+
- [ ] **Step 3: Verify template renders (start dev server and check the page)**
231+
232+
```bash
233+
cd /Users/romain/Projects/cooklang/cookcli && cargo run -- server ./seed
234+
```
235+
236+
Open http://localhost:9080 — the recipes grid should still render correctly with no visible change yet (sort controls are hidden by default until JS runs).
237+
238+
- [ ] **Step 4: Commit**
239+
240+
```bash
241+
cd /Users/romain/Projects/cooklang/cookcli
242+
git add templates/recipes.html
243+
git commit -m "feat: add data attributes and sort controls markup to recipes template"
244+
```
245+
246+
---
247+
248+
## Task 4: Add client-side sort JavaScript
249+
250+
**Files:**
251+
- Modify: `templates/recipes.html``{% block scripts %}` at the bottom
252+
253+
- [ ] **Step 1: Add the sort script inside `{% block scripts %}`**
254+
255+
At the bottom of `templates/recipes.html`, inside the existing `{% block scripts %}{% endblock %}` block, add:
256+
257+
```html
258+
{% block scripts %}
259+
<script>
260+
(function () {
261+
const grid = document.getElementById('recipes-grid');
262+
const controls = document.getElementById('sort-controls');
263+
const fieldSelect = document.getElementById('sort-field');
264+
const dirBtn = document.getElementById('sort-dir');
265+
const createdOption = document.getElementById('sort-created-option');
266+
267+
if (!grid) return;
268+
269+
let sortField = 'name';
270+
let sortDir = 'asc';
271+
272+
// Hide Created option if no recipe has a creation date
273+
if (!grid.querySelector('[data-created]') && createdOption) {
274+
createdOption.remove();
275+
}
276+
277+
// Show controls only when there are at least 2 recipe cards
278+
const recipeCount = grid.querySelectorAll('[data-type="recipe"]').length;
279+
if (recipeCount >= 2) {
280+
controls.classList.remove('hidden');
281+
controls.classList.add('flex');
282+
}
283+
284+
function getValue(el) {
285+
if (sortField === 'name') {
286+
return (el.querySelector('h3')?.textContent ?? '').trim().toLowerCase();
287+
}
288+
const attr = sortField === 'modified' ? 'data-modified' : 'data-created';
289+
const raw = el.getAttribute(attr);
290+
return raw !== null ? parseInt(raw, 10) : null;
291+
}
292+
293+
function applySort() {
294+
const all = Array.from(grid.children);
295+
const dirs = all.filter(el => el.dataset.type === 'directory');
296+
const recipes = all.filter(el => el.dataset.type === 'recipe');
297+
const others = all.filter(el => !el.dataset.type); // empty-state div
298+
299+
recipes.sort((a, b) => {
300+
const av = getValue(a);
301+
const bv = getValue(b);
302+
if (av === null && bv === null) return 0;
303+
if (av === null) return 1;
304+
if (bv === null) return -1;
305+
const cmp = typeof av === 'string' ? av.localeCompare(bv) : av - bv;
306+
return sortDir === 'asc' ? cmp : -cmp;
307+
});
308+
309+
dirs.forEach(el => grid.appendChild(el));
310+
recipes.forEach(el => grid.appendChild(el));
311+
others.forEach(el => grid.appendChild(el));
312+
313+
dirBtn.textContent = sortDir === 'asc' ? '' : '';
314+
}
315+
316+
fieldSelect.addEventListener('change', function () {
317+
sortField = this.value;
318+
// Default to desc for date sorts (newest first), asc for name
319+
sortDir = sortField === 'name' ? 'asc' : 'desc';
320+
applySort();
321+
});
322+
323+
dirBtn.addEventListener('click', function () {
324+
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
325+
applySort();
326+
});
327+
})();
328+
</script>
329+
{% endblock %}
330+
```
331+
332+
- [ ] **Step 2: Test name sort**
333+
334+
With the dev server running (`cargo run -- server ./seed`), open http://localhost:9080.
335+
- Sort controls should be visible if there are ≥ 2 recipes.
336+
- Select "Name" — recipes should be in A→Z order.
337+
- Click ↑ to flip to ↓ — recipes should reverse to Z→A.
338+
339+
- [ ] **Step 3: Test modified sort**
340+
341+
- Select "Modified" in the dropdown — recipes should reorder by last-modified date, newest first (↓).
342+
- Click the direction button to verify oldest-first order.
343+
- Right-click a recipe card, inspect element — verify `data-modified` contains a plausible Unix timestamp (e.g. ~1700000000).
344+
345+
- [ ] **Step 4: Test created sort (if available)**
346+
347+
- If on macOS, the "Created" option should appear in the dropdown. Select it — recipes reorder by creation date.
348+
- If on a Linux filesystem that doesn't support birth time, the option should not appear.
349+
350+
- [ ] **Step 5: Test directory pinning**
351+
352+
If any subdirectories exist in the seed recipes, verify they always appear before recipe cards regardless of the selected sort.
353+
354+
- [ ] **Step 6: Commit**
355+
356+
```bash
357+
cd /Users/romain/Projects/cooklang/cookcli
358+
git add templates/recipes.html
359+
git commit -m "feat: add client-side recipe sorting by name, modified date, and created date"
360+
```
361+
362+
---
363+
364+
## Self-Review Checklist
365+
366+
- [x] **Spec coverage:** All spec sections covered — data fields (Task 1+2), template attributes (Task 3), sort controls UI (Task 3), JS logic with Created visibility, directory pinning, direction toggle (Task 4).
367+
- [x] **No placeholders:** All steps contain complete code.
368+
- [x] **Type consistency:** `modified_at: Option<i64>` / `created_at: Option<i64>` defined in Task 1, populated in Task 2, read as `data-modified`/`data-created` string attributes in Task 4 — consistent throughout.
369+
- [x] **Static site:** Both `ui.rs` and `build/renderer.rs` call `build_recipes_template`, so the static builder gets timestamps for free.
370+
- [x] **Empty state:** The empty-state `<div>` has no `data-type` attribute and is handled by the `others` bucket in `applySort()`, so it stays at the end without breaking the sort.

0 commit comments

Comments
 (0)