fix humanized times to use small JS shim to auto set/update
feat: populate WhenISO field for commits feat: populate WhenISO field for tree items feat: populate ISO timestamp fields for issues and comments feat: add data-time attribute to header timestamp feat: add data-time attribute to tree view timestamps feat: add data-time attribute to issues list timestamps feat: add data-time attributes to issue detail timestamps feat: add client-side time humanization JavaScript chore: regenerate test site with client-side time humanization feat: convert comment RFC1123 timestamps to ISO8601 for client-side humanization fix: use UTC format for data-time timestamps to avoid HTML escaping fix: use static date format (Apr 7 or Apr 2023) for no-JS fallback text
8 files changed,  +694, -54
A docs/plans/2026-04-08-client-side-time-humanization.md
+533, -0
  1@@ -0,0 +1,533 @@
  2+# Client-Side Time Humanization Implementation Plan
  3+
  4+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
  5+
  6+**Goal:** Replace server-side time humanization with client-side JavaScript to ensure relative times remain accurate as pages age.
  7+
  8+**Architecture:** Add ISO 8601 timestamp fields to data structures, update templates to include `data-time` attributes with default MMM dd fallback text, and add inline JavaScript to the base layout that queries for these elements and updates their text content dynamically with relative times (minutes/hours/days ago).
  9+
 10+**Tech Stack:** Go templates, vanilla JavaScript (ES5-compatible for broad browser support), ISO 8601 timestamps.
 11+
 12+---
 13+
 14+## Prerequisites
 15+
 16+- The codebase is a Go static site generator using embedded templates
 17+- Templates are in `html/*.tmpl` files
 18+- Current time humanization uses `humanize.Time()` from Go
 19+- Output is static HTML files
 20+
 21+---
 22+
 23+## Task 1: Add ISO Timestamp Fields to Data Structures
 24+
 25+**Files:**
 26+- Modify: `main.go:121-131` (CommitData struct)
 27+- Modify: `main.go:133-149` (TreeItem struct)
 28+- Modify: `issues.go:14-27` (IssueData struct)
 29+- Modify: `issues.go:29-35` (CommentData struct)
 30+
 31+**Step 1: Add WhenISO field to CommitData struct**
 32+
 33+Add field after `WhenStr`:
 34+
 35+```go
 36+type CommitData struct {
 37+	SummaryStr   string
 38+	URL          template.URL
 39+	WhenStr      string
 40+	WhenISO      string  // ISO 8601 format for JavaScript parsing
 41+	HumanWhenStr string
 42+	AuthorStr    string
 43+	ShortID      string
 44+	ParentID     string
 45+	Refs         []*RefInfo
 46+	*git.Commit
 47+}
 48+```
 49+
 50+**Step 2: Add WhenISO field to TreeItem struct**
 51+
 52+Add field after `When`:
 53+
 54+```go
 55+type TreeItem struct {
 56+	IsTextFile bool
 57+	IsDir      bool
 58+	Size       string
 59+	NumLines   int
 60+	Name       string
 61+	Icon       string
 62+	Path       string
 63+	URL        template.URL
 64+	CommitID   string
 65+	CommitURL  template.URL
 66+	Summary    string
 67+	When       string
 68+	WhenISO    string  // ISO 8601 format for JavaScript parsing
 69+	Author     *git.Signature
 70+	Entry      *git.TreeEntry
 71+	Crumbs     []*Breadcrumb
 72+}
 73+```
 74+
 75+**Step 3: Add CreatedAtISO field to IssueData struct**
 76+
 77+```go
 78+type IssueData struct {
 79+	ID           string
 80+	FullID       string
 81+	Title        string
 82+	Status       string
 83+	Author       string
 84+	CreatedAt    string
 85+	CreatedAtISO string  // ISO 8601 format for JavaScript parsing
 86+	HumanDate    string
 87+	Labels       []string
 88+	CommentCount int
 89+	Description  template.HTML
 90+	Comments     []CommentData
 91+	URL          template.URL
 92+}
 93+```
 94+
 95+**Step 4: Add CreatedAtISO field to CommentData struct**
 96+
 97+```go
 98+type CommentData struct {
 99+	Author       string
100+	CreatedAt    string
101+	CreatedAtISO string  // ISO 8601 format for JavaScript parsing
102+	HumanDate    string
103+	Body         template.HTML
104+}
105+```
106+
107+**Step 5: Commit**
108+
109+```bash
110+jj commit -m "feat: add ISO timestamp fields to data structures"
111+```
112+
113+---
114+
115+## Task 2: Populate ISO Timestamp Fields in CommitData
116+
117+**Files:**
118+- Modify: `main.go:1157-1168`
119+
120+**Step 1: Update commit data population**
121+
122+Find the `CommitData` initialization around line 1157 and add `WhenISO`:
123+
124+```go
125+cd := &CommitData{
126+	ParentID:     parentID,
127+	URL:          c.getCommitURL(commit.ID.String()),
128+	ShortID:      getShortID(commit.ID.String()),
129+	SummaryStr:   commit.Summary(),
130+	AuthorStr:    commit.Author.Name,
131+	WhenStr:      commit.Author.When.Format(time.DateOnly),
132+	WhenISO:      commit.Author.When.Format(time.RFC3339),
133+	HumanWhenStr: humanizeTime(commit.Author.When),
134+	Commit:       commit,
135+	Refs:         tags,
136+}
137+```
138+
139+**Step 2: Commit**
140+
141+```bash
142+jj commit -m "feat: populate WhenISO field for commits"
143+```
144+
145+---
146+
147+## Task 3: Populate ISO Timestamp Fields in TreeItem
148+
149+**Files:**
150+- Modify: `main.go:1001-1055` (NewTreeItem function)
151+
152+**Step 1: Update TreeItem population**
153+
154+Find where `item.When` is set (around line 1032) and add `WhenISO`:
155+
156+```go
157+if len(lastCommits) > 0 {
158+	lc := lastCommits[0]
159+	item.CommitURL = tw.Config.getCommitURL(lc.ID.String())
160+	item.CommitID = getShortID(lc.ID.String())
161+	item.Summary = lc.Summary()
162+	item.When = lc.Author.When.Format(time.DateOnly)
163+	item.WhenISO = lc.Author.When.Format(time.RFC3339)
164+	item.Author = lc.Author
165+}
166+```
167+
168+**Step 2: Commit**
169+
170+```bash
171+jj commit -m "feat: populate WhenISO field for tree items"
172+```
173+
174+---
175+
176+## Task 4: Populate ISO Timestamp Fields in IssueData and CommentData
177+
178+**Files:**
179+- Modify: `issues.go:95-130`
180+
181+**Step 1: Update CommentData population**
182+
183+Find where `comments` slice is built and add `CreatedAtISO`:
184+
185+```go
186+comments = append(comments, CommentData{
187+	Author:       comment.Author.Name(),
188+	CreatedAt:    comment.FormatTime(),
189+	CreatedAtISO: comment.FormatTimeRFC3339(),
190+	HumanDate:    comment.FormatTimeRel(),
191+	Body:         c.renderMarkdown(comment.Message),
192+})
193+```
194+
195+**Note:** `FormatTimeRFC3339()` may not exist on the git-bug comment type. Check if available, otherwise use:
196+```go
197+CreatedAtISO: comment.UnixTime.Format(time.RFC3339),
198+```
199+
200+Or check what methods are available on the comment object.
201+
202+**Step 2: Update IssueData population**
203+
204+Find where `IssueData` is created (around line 116-130) and add `CreatedAtISO`:
205+
206+```go
207+issue := &IssueData{
208+	ID:           getShortID(fullID),
209+	FullID:       fullID,
210+	Title:        snap.Title,
211+	Status:       snap.Status.String(),
212+	Author:       snap.Author.Name(),
213+	CreatedAt:    snap.CreateTime.Format("Mon Jan 2 15:04:05 2006 -0700"),
214+	CreatedAtISO: snap.CreateTime.Format(time.RFC3339),
215+	HumanDate:    humanizeTime(snap.CreateTime),
216+	Labels:       labels,
217+	CommentCount: commentCount,
218+	Description:  description,
219+	Comments:     comments,
220+	URL:          c.getIssueURL(fullID),
221+}
222+```
223+
224+**Step 3: Commit**
225+
226+```bash
227+jj commit -m "feat: populate ISO timestamp fields for issues and comments"
228+```
229+
230+---
231+
232+## Task 5: Update Header Partial Template
233+
234+**Files:**
235+- Modify: `html/header.partial.tmpl:34-36`
236+
237+**Step 1: Update last commit bar time display**
238+
239+Replace:
240+```html
241+<div class="last-commit-bar__time" title="{{.LastCommit.WhenStr}}">
242+  {{.LastCommit.HumanWhenStr}}
243+</div>
244+```
245+
246+With:
247+```html
248+<div class="last-commit-bar__time" title="{{.LastCommit.WhenStr}}">
249+  <span class="human-time" data-time="{{.LastCommit.WhenISO}}">{{.LastCommit.WhenStr}}</span>
250+</div>
251+```
252+
253+**Step 2: Commit**
254+
255+```bash
256+jj commit -m "feat: add data-time attribute to header timestamp"
257+```
258+
259+---
260+
261+## Task 6: Update Tree Page Template
262+
263+**Files:**
264+- Modify: `html/tree.page.tmpl:38`
265+
266+**Step 1: Update file listing timestamp**
267+
268+Replace:
269+```html
270+<a href="{{.CommitURL}}" title="{{.Summary}}">{{.When}}</a>
271+```
272+
273+With:
274+```html
275+<a href="{{.CommitURL}}" title="{{.Summary}}">
276+  <span class="human-time" data-time="{{.WhenISO}}">{{.When}}</span>
277+</a>
278+```
279+
280+**Step 2: Commit**
281+
282+```bash
283+jj commit -m "feat: add data-time attribute to tree view timestamps"
284+```
285+
286+---
287+
288+## Task 7: Update Issues List Template
289+
290+**Files:**
291+- Modify: `html/issues_list.page.tmpl:52`
292+
293+**Step 1: Update issue list timestamp**
294+
295+Replace:
296+```html
297+<span class="issue-date" title="{{.CreatedAt}}">{{.HumanDate}}</span>
298+```
299+
300+With:
301+```html
302+<span class="issue-date" title="{{.CreatedAt}}">
303+  <span class="human-time" data-time="{{.CreatedAtISO}}">{{.CreatedAt}}</span>
304+</span>
305+```
306+
307+**Step 2: Commit**
308+
309+```bash
310+jj commit -m "feat: add data-time attribute to issues list timestamps"
311+```
312+
313+---
314+
315+## Task 8: Update Issue Detail Template
316+
317+**Files:**
318+- Modify: `html/issue_detail.page.tmpl:16,30,43`
319+
320+**Step 1: Update issue header timestamp (line 16)**
321+
322+Replace:
323+```html
324+<span class="issue-date" title="{{.Issue.CreatedAt}}">{{.Issue.HumanDate}}</span>
325+```
326+
327+With:
328+```html
329+<span class="issue-date" title="{{.Issue.CreatedAt}}">
330+  <span class="human-time" data-time="{{.Issue.CreatedAtISO}}">{{.Issue.CreatedAt}}</span>
331+</span>
332+```
333+
334+**Step 2: Update issue description timestamp (line 30)**
335+
336+Replace:
337+```html
338+<span class="issue-comment__date" title="{{.Issue.CreatedAt}}">{{.Issue.HumanDate}}</span>
339+```
340+
341+With:
342+```html
343+<span class="issue-comment__date" title="{{.Issue.CreatedAt}}">
344+  <span class="human-time" data-time="{{.Issue.CreatedAtISO}}">{{.Issue.CreatedAt}}</span>
345+</span>
346+```
347+
348+**Step 3: Update comment timestamp (line 43)**
349+
350+Replace:
351+```html
352+<span class="issue-comment__date" title="{{.CreatedAt}}">{{.HumanDate}}</span>
353+```
354+
355+With:
356+```html
357+<span class="issue-comment__date" title="{{.CreatedAt}}">
358+  <span class="human-time" data-time="{{.CreatedAtISO}}">{{.CreatedAt}}</span>
359+</span>
360+```
361+
362+**Step 4: Commit**
363+
364+```bash
365+jj commit -m "feat: add data-time attributes to issue detail timestamps"
366+```
367+
368+---
369+
370+## Task 9: Add JavaScript to Base Layout
371+
372+**Files:**
373+- Modify: `html/base.layout.tmpl:15-21`
374+
375+**Step 1: Add inline JavaScript before closing body tag**
376+
377+Add this script before the closing `</body>` tag:
378+
379+```html
380+<script>
381+(function() {
382+  var MINUTE_MS = 60000;
383+  var HOUR_MS = 3600000;
384+  var DAY_MS = 86400000;
385+  var MONTH_MS = 30 * DAY_MS;
386+  
387+  function updateTimes() {
388+    var elements = document.querySelectorAll('[data-time]');
389+    var now = new Date();
390+    var minDiffMs = Infinity;
391+    
392+    elements.forEach(function(el) {
393+      var date = new Date(el.getAttribute('data-time'));
394+      var diffMs = now - date;
395+      
396+      // Track the smallest difference for interval calculation
397+      if (diffMs < minDiffMs && diffMs >= 0) {
398+        minDiffMs = diffMs;
399+      }
400+      
401+      var diffMins = Math.floor(diffMs / MINUTE_MS);
402+      var diffHours = Math.floor(diffMs / HOUR_MS);
403+      var diffDays = Math.floor(diffMs / DAY_MS);
404+      
405+      var text;
406+      if (diffMins < 1) {
407+        text = 'just now';
408+      } else if (diffMins < 60) {
409+        text = diffMins + ' minute' + (diffMins === 1 ? '' : 's') + ' ago';
410+      } else if (diffHours < 24) {
411+        text = diffHours + ' hour' + (diffHours === 1 ? '' : 's') + ' ago';
412+      } else if (diffDays < 30) {
413+        text = diffDays + ' day' + (diffDays === 1 ? '' : 's') + ' ago';
414+      } else {
415+        // Keep default MMM dd format (already in element text)
416+        return;
417+      }
418+      el.textContent = text;
419+    });
420+    
421+    return minDiffMs;
422+  }
423+  
424+  function scheduleUpdate() {
425+    var minDiffMs = updateTimes();
426+    var intervalMs;
427+    
428+    // Determine interval based on smallest time difference
429+    if (minDiffMs < HOUR_MS) {
430+      // Smallest diff is in minutes - update every minute
431+      intervalMs = MINUTE_MS;
432+    } else if (minDiffMs < DAY_MS) {
433+      // Smallest diff is in hours - update every hour
434+      intervalMs = HOUR_MS;
435+    } else if (minDiffMs < MONTH_MS) {
436+      // Smallest diff is in days - update every day
437+      intervalMs = DAY_MS;
438+    } else {
439+      // All timestamps are > 30 days, no updates needed
440+      return;
441+    }
442+    
443+    setTimeout(scheduleUpdate, intervalMs);
444+  }
445+  
446+  scheduleUpdate();
447+})();
448+</script>
449+```
450+
451+**Rationale:** This approach dynamically adjusts the update interval based on the granularity needed:
452+- If the most recent timestamp is < 1 hour old โ†’ update every minute (to track "X minutes ago")
453+- If the most recent timestamp is 1-24 hours old โ†’ update every hour (to track "X hours ago")  
454+- If the most recent timestamp is 1-30 days old โ†’ update every day (to track "X days ago")
455+- If all timestamps are > 30 days โ†’ no interval needed (static dates don't change)
456+
457+**Step 2: Commit**
458+
459+```bash
460+jj commit -m "feat: add client-side time humanization JavaScript"
461+```
462+
463+---
464+
465+## Task 10: Build and Test
466+
467+**Files:**
468+- Run: Build commands
469+- Verify: Output HTML in testdata.site/
470+
471+**Step 1: Build the project**
472+
473+```bash
474+cd /home/btburke/projects/pgit
475+go build -o pgit .
476+```
477+
478+Expected: Successful build with no errors.
479+
480+**Step 2: Regenerate test site**
481+
482+```bash
483+./pgit --repo ./testdata --out ./testdata.site --revs main,branch-c --home-url https://test.com/test --clone-url https://test.com/test/test2 --issues
484+```
485+
486+Expected: Site generates successfully.
487+
488+**Step 3: Verify HTML output**
489+
490+Check `testdata.site/index.html`:
491+- Should contain `<span class="human-time" data-time="2026-04-...">`
492+- Should contain the inline JavaScript
493+
494+Check `testdata.site/issues/open/index.html`:
495+- Should contain `data-time` attributes on issue timestamps
496+
497+Check `testdata.site/logs/main/index.html`:
498+- Should NOT have data-time (log page uses WhenStr without humanization currently - this is acceptable)
499+
500+**Step 4: Manual browser test**
501+
502+Open `testdata.site/index.html` in a browser:
503+- The timestamp should show relative time (e.g., "2 days ago") if the commit is recent
504+- Or it should show the default date format ("2026-04-07") if older than 30 days
505+- View page source to verify `data-time` attributes are present
506+
507+**Step 5: Commit**
508+
509+```bash
510+jj commit -m "chore: regenerate test site with client-side time humanization"
511+```
512+
513+---
514+
515+## Summary
516+
517+This plan implements client-side time humanization by:
518+
519+1. Adding ISO 8601 timestamp fields to all data structures that display times
520+2. Populating those fields using `time.RFC3339` format
521+3. Wrapping time display elements with `<span class="human-time" data-time="...">`
522+4. Adding vanilla JavaScript to the base layout that:
523+   - Queries all `[data-time]` elements
524+   - Parses the ISO timestamp
525+   - Calculates relative time (minutes/hours/days ago)
526+   - Updates text content dynamically
527+   - Dynamically schedules updates based on the smallest time difference on the page
528+   - Uses minute/hour/day intervals as appropriate to minimize unnecessary work
529+
530+**Edge cases handled:**
531+- Times < 1 minute show "just now"
532+- Times > 30 days keep the default MMM dd format
533+- No-JS users see the default date format (graceful degradation)
534+- Uses ES5-compatible syntax for broad browser support
M html/base.layout.tmpl
+69, -0
 1@@ -18,6 +18,75 @@
 2     <main>{{template "content" .}}</main>
 3     <hr />
 4     <footer>{{template "footer" .}}</footer>
 5+    <script>
 6+    (function() {
 7+      var MINUTE_MS = 60000;
 8+      var HOUR_MS = 3600000;
 9+      var DAY_MS = 86400000;
10+      var MONTH_MS = 30 * DAY_MS;
11+
12+      function updateTimes() {
13+        var elements = document.querySelectorAll('[data-time]');
14+        var now = new Date();
15+        var minDiffMs = Infinity;
16+
17+        elements.forEach(function(el) {
18+          var date = new Date(el.getAttribute('data-time'));
19+          var diffMs = now - date;
20+
21+          // Track the smallest difference for interval calculation
22+          if (diffMs < minDiffMs && diffMs >= 0) {
23+            minDiffMs = diffMs;
24+          }
25+
26+          var diffMins = Math.floor(diffMs / MINUTE_MS);
27+          var diffHours = Math.floor(diffMs / HOUR_MS);
28+          var diffDays = Math.floor(diffMs / DAY_MS);
29+
30+          var text;
31+          if (diffMins < 1) {
32+            text = 'just now';
33+          } else if (diffMins < 60) {
34+            text = diffMins + ' minute' + (diffMins === 1 ? '' : 's') + ' ago';
35+          } else if (diffHours < 24) {
36+            text = diffHours + ' hour' + (diffHours === 1 ? '' : 's') + ' ago';
37+          } else if (diffDays < 30) {
38+            text = diffDays + ' day' + (diffDays === 1 ? '' : 's') + ' ago';
39+          } else {
40+            // Keep default MMM dd format (already in element text)
41+            return;
42+          }
43+          el.textContent = text;
44+        });
45+
46+        return minDiffMs;
47+      }
48+
49+      function scheduleUpdate() {
50+        var minDiffMs = updateTimes();
51+        var intervalMs;
52+
53+        // Determine interval based on smallest time difference
54+        if (minDiffMs < HOUR_MS) {
55+          // Smallest diff is in minutes - update every minute
56+          intervalMs = MINUTE_MS;
57+        } else if (minDiffMs < DAY_MS) {
58+          // Smallest diff is in hours - update every hour
59+          intervalMs = HOUR_MS;
60+        } else if (minDiffMs < MONTH_MS) {
61+          // Smallest diff is in days - update every day
62+          intervalMs = DAY_MS;
63+        } else {
64+          // All timestamps are > 30 days, no updates needed
65+          return;
66+        }
67+
68+        setTimeout(scheduleUpdate, intervalMs);
69+      }
70+
71+      scheduleUpdate();
72+    })();
73+    </script>
74   </body>
75 </html>
76 {{end}}
M html/header.partial.tmpl
+2, -2
 1@@ -31,8 +31,8 @@
 2       <span>&nbsp;&centerdot;&nbsp;</span>
 3       <a href="{{.LastCommit.URL}}">{{.LastCommit.SummaryStr}}</a>
 4     </div>
 5-    <div class="last-commit-bar__time" title="{{.LastCommit.WhenStr}}">
 6-      {{.LastCommit.HumanWhenStr}}
 7+    <div class="last-commit-bar__time">
 8+      <span class="human-time" data-time="{{.LastCommit.WhenISO}}">{{.LastCommit.WhenDisplay}}</span>
 9     </div>
10   </div>
11   {{end}}
M html/issue_detail.page.tmpl
+9, -3
 1@@ -13,7 +13,9 @@
 2     <div class="issue-detail__meta">
 3       <span class="issue-id">#{{.Issue.ID}}</span>
 4       <span class="issue-author">opened by {{.Issue.Author}}</span>
 5-      <span class="issue-date" title="{{.Issue.CreatedAt}}">{{.Issue.HumanDate}}</span>
 6+      <span class="issue-date">
 7+        <span class="human-time" data-time="{{.Issue.CreatedAtISO}}">{{.Issue.CreatedAtDisp}}</span>
 8+      </span>
 9     </div>
10     {{if .Issue.Labels}}
11     <div class="issue-detail__labels">
12@@ -27,7 +29,9 @@
13     <div class="issue-comment">
14       <div class="issue-comment__header">
15         <span class="issue-comment__author">{{.Issue.Author}}</span>
16-        <span class="issue-comment__date" title="{{.Issue.CreatedAt}}">{{.Issue.HumanDate}}</span>
17+        <span class="issue-comment__date">
18+          <span class="human-time" data-time="{{.Issue.CreatedAtISO}}">{{.Issue.CreatedAtDisp}}</span>
19+        </span>
20       </div>
21       <div class="issue-comment__body markdown">{{.Issue.Description}}</div>
22     </div>
23@@ -40,7 +44,9 @@
24     <div class="issue-comment">
25       <div class="issue-comment__header">
26         <span class="issue-comment__author">{{.Author}}</span>
27-        <span class="issue-comment__date" title="{{.CreatedAt}}">{{.HumanDate}}</span>
28+        <span class="issue-comment__date">
29+          <span class="human-time" data-time="{{.CreatedAtISO}}">{{.CreatedAtDisp}}</span>
30+        </span>
31       </div>
32       <div class="issue-comment__body markdown">{{.Body}}</div>
33     </div>
M html/issues_list.page.tmpl
+3, -1
 1@@ -49,7 +49,9 @@
 2         <span class="issue-meta">#{{.ID}}{{if gt .CommentCount 0}} ยท {{.CommentCount}} comment{{if ne .CommentCount 1}}s{{end}}{{end}}</span>
 3       </div>
 4       <div class="issue-item__stats">
 5-        <span class="issue-date" title="{{.CreatedAt}}">{{.HumanDate}}</span>
 6+        <span class="issue-date">
 7+          <span class="human-time" data-time="{{.CreatedAtISO}}">{{.CreatedAtDisp}}</span>
 8+        </span>
 9         {{if .Labels}}
10         <span class="issue-labels-text">{{range $i, $label := .Labels}}{{if $i}}, {{end}}{{$label}}{{end}}</span>
11         {{end}}
M html/tree.page.tmpl
+3, -1
 1@@ -35,7 +35,9 @@
 2           {{if $.Repo.HideTreeLastCommit}}
 3           {{else}}
 4           <div class="file-list__commit">
 5-            <a href="{{.CommitURL}}" title="{{.Summary}}">{{.When}}</a>
 6+            <a href="{{.CommitURL}}" title="{{.Summary}}">
 7+              <span class="human-time" data-time="{{.WhenISO}}">{{.WhenDisplay}}</span>
 8+            </a>
 9           </div>
10           {{end}}
11           <div class="file-list__size">
M issues.go
+43, -32
  1@@ -5,6 +5,7 @@ import (
  2 	"html/template"
  3 	"net/url"
  4 	"path/filepath"
  5+	"time"
  6 
  7 	"github.com/git-bug/git-bug/entities/bug"
  8 	"github.com/git-bug/git-bug/repository"
  9@@ -12,26 +13,30 @@ import (
 10 
 11 // IssueData represents a git-bug issue for templates
 12 type IssueData struct {
 13-	ID           string
 14-	FullID       string
 15-	Title        string
 16-	Status       string // "open" or "closed"
 17-	Author       string
 18-	CreatedAt    string
 19-	HumanDate    string
 20-	Labels       []string
 21-	CommentCount int // excludes original description
 22-	Description  template.HTML
 23-	Comments     []CommentData
 24-	URL          template.URL
 25+	ID            string
 26+	FullID        string
 27+	Title         string
 28+	Status        string // "open" or "closed"
 29+	Author        string
 30+	CreatedAt     string
 31+	CreatedAtISO  string
 32+	CreatedAtDisp string
 33+	HumanDate     string
 34+	Labels        []string
 35+	CommentCount  int // excludes original description
 36+	Description   template.HTML
 37+	Comments      []CommentData
 38+	URL           template.URL
 39 }
 40 
 41 // CommentData represents a comment on an issue
 42 type CommentData struct {
 43-	Author    string
 44-	CreatedAt string
 45-	HumanDate string
 46-	Body      template.HTML
 47+	Author        string
 48+	CreatedAt     string
 49+	CreatedAtISO  string
 50+	CreatedAtDisp string
 51+	HumanDate     string
 52+	Body          template.HTML
 53 }
 54 
 55 // IssuesListPageData for list pages (open, closed, by label)
 56@@ -98,11 +103,15 @@ func (c *Config) loadIssues() ([]*IssueData, error) {
 57 				// Skip the original description
 58 				continue
 59 			}
 60+			// Parse RFC1123 format and convert to ISO 8601 (UTC) for JavaScript
 61+			createdAtTime, _ := time.Parse("Mon Jan 2 15:04:05 2006 -0700", comment.FormatTime())
 62 			comments = append(comments, CommentData{
 63-				Author:    comment.Author.Name(),
 64-				CreatedAt: comment.FormatTime(),
 65-				HumanDate: comment.FormatTimeRel(),
 66-				Body:      c.renderMarkdown(comment.Message),
 67+				Author:        comment.Author.Name(),
 68+				CreatedAt:     comment.FormatTime(),
 69+				CreatedAtISO:  createdAtTime.UTC().Format(time.RFC3339),
 70+				CreatedAtDisp: formatDateForDisplay(createdAtTime),
 71+				HumanDate:     comment.FormatTimeRel(),
 72+				Body:          c.renderMarkdown(comment.Message),
 73 			})
 74 		}
 75 
 76@@ -114,18 +123,20 @@ func (c *Config) loadIssues() ([]*IssueData, error) {
 77 
 78 		fullID := b.Id().String()
 79 		issue := &IssueData{
 80-			ID:           getShortID(fullID),
 81-			FullID:       fullID,
 82-			Title:        snap.Title,
 83-			Status:       snap.Status.String(),
 84-			Author:       snap.Author.Name(),
 85-			CreatedAt:    snap.CreateTime.Format("Mon Jan 2 15:04:05 2006 -0700"),
 86-			HumanDate:    humanizeTime(snap.CreateTime),
 87-			Labels:       labels,
 88-			CommentCount: commentCount,
 89-			Description:  description,
 90-			Comments:     comments,
 91-			URL:          c.getIssueURL(fullID),
 92+			ID:            getShortID(fullID),
 93+			FullID:        fullID,
 94+			Title:         snap.Title,
 95+			Status:        snap.Status.String(),
 96+			Author:        snap.Author.Name(),
 97+			CreatedAt:     snap.CreateTime.Format("Mon Jan 2 15:04:05 2006 -0700"),
 98+			CreatedAtISO:  snap.CreateTime.UTC().Format(time.RFC3339),
 99+			CreatedAtDisp: formatDateForDisplay(snap.CreateTime),
100+			HumanDate:     humanizeTime(snap.CreateTime),
101+			Labels:        labels,
102+			CommentCount:  commentCount,
103+			Description:   description,
104+			Comments:      comments,
105+			URL:           c.getIssueURL(fullID),
106 		}
107 		issues = append(issues, issue)
108 	}
M main.go
+32, -15
 1@@ -122,6 +122,8 @@ type CommitData struct {
 2 	SummaryStr   string
 3 	URL          template.URL
 4 	WhenStr      string
 5+	WhenISO      string
 6+	WhenDisplay  string
 7 	HumanWhenStr string
 8 	AuthorStr    string
 9 	ShortID      string
10@@ -131,21 +133,23 @@ type CommitData struct {
11 }
12 
13 type TreeItem struct {
14-	IsTextFile bool
15-	IsDir      bool
16-	Size       string
17-	NumLines   int
18-	Name       string
19-	Icon       string
20-	Path       string
21-	URL        template.URL
22-	CommitID   string
23-	CommitURL  template.URL
24-	Summary    string
25-	When       string
26-	Author     *git.Signature
27-	Entry      *git.TreeEntry
28-	Crumbs     []*Breadcrumb
29+	IsTextFile  bool
30+	IsDir       bool
31+	Size        string
32+	NumLines    int
33+	Name        string
34+	Icon        string
35+	Path        string
36+	URL         template.URL
37+	CommitID    string
38+	CommitURL   template.URL
39+	Summary     string
40+	When        string
41+	WhenISO     string
42+	WhenDisplay string
43+	Author      *git.Signature
44+	Entry       *git.TreeEntry
45+	Crumbs      []*Breadcrumb
46 }
47 
48 type DiffRender struct {
49@@ -451,6 +455,15 @@ func humanizeTime(t time.Time) string {
50 	return humanize.Time(t)
51 }
52 
53+// formatDateForDisplay returns a static date format for non-JS display:
54+// "Jan 2" for dates less than a year ago, "Jan 2006" for older dates
55+func formatDateForDisplay(t time.Time) string {
56+	if time.Since(t).Hours() > 365*24 {
57+		return t.Format("Jan 2006")
58+	}
59+	return t.Format("Jan 2")
60+}
61+
62 func repoName(root string) string {
63 	_, file := filepath.Split(root)
64 	return file
65@@ -1030,6 +1043,8 @@ func (tw *TreeWalker) NewTreeItem(entry *git.TreeEntry, curpath string, crumbs [
66 			item.CommitID = getShortID(lc.ID.String())
67 			item.Summary = lc.Summary()
68 			item.When = lc.Author.When.Format(time.DateOnly)
69+			item.WhenISO = lc.Author.When.UTC().Format(time.RFC3339)
70+			item.WhenDisplay = formatDateForDisplay(lc.Author.When)
71 			item.Author = lc.Author
72 		}
73 	}
74@@ -1161,6 +1176,8 @@ func (c *Config) writeRevision(repo *git.Repository, pageData *PageData, refs []
75 				SummaryStr:   commit.Summary(),
76 				AuthorStr:    commit.Author.Name,
77 				WhenStr:      commit.Author.When.Format(time.DateOnly),
78+				WhenISO:      commit.Author.When.UTC().Format(time.RFC3339),
79+				WhenDisplay:  formatDateForDisplay(commit.Author.When),
80 				HumanWhenStr: humanizeTime(commit.Author.When),
81 				Commit:       commit,
82 				Refs:         tags,