Skip to content

Commit e0d58d5

Browse files
committed
feat(issue-60): group by date
1 parent bb1b81d commit e0d58d5

File tree

6 files changed

+269
-24
lines changed

6 files changed

+269
-24
lines changed

services/poc/ui/get_content_preview.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,17 @@ type streamResponse struct {
9999

100100
func (r *streamResponse) WriteResponse(ctx context.Context, w http.ResponseWriter) error {
101101
defer r.reader.Close()
102+
103+
// Set headers first
102104
w.Header().Set("Content-Type", r.contentType)
103105
w.Header().Set("Content-Length", fmt.Sprintf("%d", r.contentLength))
106+
w.Header().Set("Accept-Ranges", "bytes")
107+
108+
// Explicitly write the status code before copying the body
109+
// This prevents OpenTelemetry middleware from calling WriteHeader multiple times
110+
w.WriteHeader(http.StatusOK)
111+
112+
// Copy the content to the response
104113
_, err := io.Copy(w, r.reader)
105114
return err
106115
}

services/poc/ui/get_journey.go

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"fmt"
1414
"html/template"
1515
"net/http"
16+
"sort"
1617
"time"
1718

1819
"github.com/dgraph-io/dgo/v240"
@@ -46,6 +47,81 @@ func GetJourney(dgraph *dgo.Dgraph) rest.ApiOption {
4647
)
4748
}
4849

50+
// groupContentByDate groups and sorts content by captured date
51+
func groupContentByDate(contents []Content) []ContentSection {
52+
// Map to accumulate content by date key
53+
sectionMap := make(map[string]*ContentSection)
54+
55+
for _, content := range contents {
56+
var dateKey string
57+
var displayDate string
58+
var sortOrder int64
59+
60+
if content.CapturedAt == nil {
61+
dateKey = "Unknown"
62+
displayDate = "Unknown Date"
63+
sortOrder = -9223372036854775807 // Very negative to sort first
64+
} else {
65+
// Normalize to UTC date for grouping
66+
utcDate := content.CapturedAt.UTC()
67+
dateKey = utcDate.Format("2006-01-02")
68+
displayDate = dateKey
69+
// Use negative timestamp for descending sort (newer dates have less negative values)
70+
sortOrder = -time.Date(
71+
utcDate.Year(), utcDate.Month(), utcDate.Day(),
72+
0, 0, 0, 0, time.UTC,
73+
).Unix()
74+
}
75+
76+
// Get or create section
77+
section, exists := sectionMap[dateKey]
78+
if !exists {
79+
section = &ContentSection{
80+
DateKey: dateKey,
81+
DisplayDate: displayDate,
82+
SortOrder: sortOrder,
83+
Contents: []Content{},
84+
}
85+
sectionMap[dateKey] = section
86+
}
87+
88+
section.Contents = append(section.Contents, content)
89+
}
90+
91+
// Convert map to slice
92+
sections := make([]ContentSection, 0, len(sectionMap))
93+
for _, section := range sectionMap {
94+
// Sort contents within section by time (earliest to latest)
95+
sort.Slice(section.Contents, func(i, j int) bool {
96+
a, b := section.Contents[i], section.Contents[j]
97+
98+
// Items with CapturedAt come before those without
99+
if a.CapturedAt == nil && b.CapturedAt != nil {
100+
return false
101+
}
102+
if a.CapturedAt != nil && b.CapturedAt == nil {
103+
return true
104+
}
105+
if a.CapturedAt == nil && b.CapturedAt == nil {
106+
// Both nil: sort by upload time
107+
return a.UploadedAt.Before(b.UploadedAt)
108+
}
109+
110+
// Both have CapturedAt: sort by captured time
111+
return a.CapturedAt.Before(*b.CapturedAt)
112+
})
113+
114+
sections = append(sections, *section)
115+
}
116+
117+
// Sort sections by SortOrder (Unknown=0 first, then negative timestamps)
118+
sort.Slice(sections, func(i, j int) bool {
119+
return sections[i].SortOrder < sections[j].SortOrder
120+
})
121+
122+
return sections
123+
}
124+
49125
func (h *getJourneyHandler) Handle(ctx context.Context, req *rest.EmptyRequest) (*HtmlResponse, error) {
50126
journeyID := rest.PathParamValue(ctx, "id")
51127
if journeyID == "" {
@@ -136,14 +212,18 @@ func (h *getJourneyHandler) Handle(ctx context.Context, req *rest.EmptyRequest)
136212
}
137213
}
138214

139-
journeyModel := journey{
215+
// Group content by date
216+
sections := groupContentByDate(contents)
217+
218+
// Create view model
219+
viewModel := journeyViewModel{
140220
ID: j.ID,
141221
Title: j.Title,
142-
Contents: contents,
222+
Sections: sections,
143223
}
144224

145225
var buf bytes.Buffer
146-
err = h.template.Execute(&buf, journeyModel)
226+
err = h.template.Execute(&buf, viewModel)
147227
if err != nil {
148228
return nil, fmt.Errorf("failed to render template: %w", err)
149229
}

services/poc/ui/get_journey_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,13 @@ func TestGetJourney_Success_WithContent(t *testing.T) {
267267
assert.Contains(t, bodyStr, "Bridge Timelapse")
268268
assert.Contains(t, bodyStr, "photo-1")
269269
assert.Contains(t, bodyStr, "video-1")
270+
271+
// Verify section headers appear
272+
assert.Contains(t, bodyStr, "Unknown Date") // for video-1 without CapturedAt
273+
274+
// Verify date section appears (capturedAt was yesterday)
275+
yesterday := time.Now().Add(-24 * time.Hour).UTC().Format("2006-01-02")
276+
assert.Contains(t, bodyStr, yesterday)
270277
}
271278

272279
func TestGetJourney_Success_NoContent(t *testing.T) {
@@ -402,3 +409,127 @@ func TestGetJourney_EmptyID(t *testing.T) {
402409
// Assertions - should return error
403410
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
404411
}
412+
413+
// Helper for tests
414+
func ptrTime(t time.Time) *time.Time {
415+
return &t
416+
}
417+
418+
func TestGroupContentByDate(t *testing.T) {
419+
tests := []struct {
420+
name string
421+
contents []Content
422+
want []ContentSection
423+
}{
424+
{
425+
name: "empty content",
426+
contents: []Content{},
427+
want: []ContentSection{},
428+
},
429+
{
430+
name: "all content without CapturedAt",
431+
contents: []Content{
432+
{ID: "1", Title: "Photo 1", UploadedAt: time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)},
433+
{ID: "2", Title: "Photo 2", UploadedAt: time.Date(2025, 1, 15, 11, 0, 0, 0, time.UTC)},
434+
},
435+
want: []ContentSection{
436+
{
437+
DateKey: "Unknown",
438+
DisplayDate: "Unknown Date",
439+
SortOrder: -9223372036854775807,
440+
Contents: []Content{
441+
{ID: "1", Title: "Photo 1", UploadedAt: time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)},
442+
{ID: "2", Title: "Photo 2", UploadedAt: time.Date(2025, 1, 15, 11, 0, 0, 0, time.UTC)},
443+
},
444+
},
445+
},
446+
},
447+
{
448+
name: "content grouped by two different dates",
449+
contents: []Content{
450+
{ID: "1", CapturedAt: ptrTime(time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC))},
451+
{ID: "2", CapturedAt: ptrTime(time.Date(2025, 1, 15, 14, 0, 0, 0, time.UTC))},
452+
{ID: "3", CapturedAt: ptrTime(time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC))},
453+
},
454+
want: []ContentSection{
455+
{
456+
DateKey: "2025-01-16",
457+
DisplayDate: "2025-01-16",
458+
Contents: []Content{{ID: "3", CapturedAt: ptrTime(time.Date(2025, 1, 16, 9, 0, 0, 0, time.UTC))}},
459+
},
460+
{
461+
DateKey: "2025-01-15",
462+
DisplayDate: "2025-01-15",
463+
Contents: []Content{
464+
{ID: "1", CapturedAt: ptrTime(time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC))},
465+
{ID: "2", CapturedAt: ptrTime(time.Date(2025, 1, 15, 14, 0, 0, 0, time.UTC))},
466+
},
467+
},
468+
},
469+
},
470+
{
471+
name: "mixed: unknown date appears first",
472+
contents: []Content{
473+
{ID: "1", CapturedAt: ptrTime(time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC))},
474+
{ID: "2", CapturedAt: nil, UploadedAt: time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)},
475+
{ID: "3", CapturedAt: ptrTime(time.Date(2025, 1, 16, 10, 0, 0, 0, time.UTC))},
476+
},
477+
want: []ContentSection{
478+
{
479+
DateKey: "Unknown",
480+
DisplayDate: "Unknown Date",
481+
SortOrder: -9223372036854775807,
482+
Contents: []Content{{ID: "2", CapturedAt: nil, UploadedAt: time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)}},
483+
},
484+
{
485+
DateKey: "2025-01-16",
486+
DisplayDate: "2025-01-16",
487+
Contents: []Content{{ID: "3", CapturedAt: ptrTime(time.Date(2025, 1, 16, 10, 0, 0, 0, time.UTC))}},
488+
},
489+
{
490+
DateKey: "2025-01-15",
491+
DisplayDate: "2025-01-15",
492+
Contents: []Content{{ID: "1", CapturedAt: ptrTime(time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC))}},
493+
},
494+
},
495+
},
496+
{
497+
name: "within section sorting: earliest to latest",
498+
contents: []Content{
499+
{ID: "1", CapturedAt: ptrTime(time.Date(2025, 1, 15, 14, 0, 0, 0, time.UTC))},
500+
{ID: "2", CapturedAt: ptrTime(time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC))},
501+
{ID: "3", CapturedAt: ptrTime(time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC))},
502+
},
503+
want: []ContentSection{
504+
{
505+
DateKey: "2025-01-15",
506+
DisplayDate: "2025-01-15",
507+
Contents: []Content{
508+
{ID: "2", CapturedAt: ptrTime(time.Date(2025, 1, 15, 9, 0, 0, 0, time.UTC))}, // 9:00
509+
{ID: "3", CapturedAt: ptrTime(time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC))}, // 12:00
510+
{ID: "1", CapturedAt: ptrTime(time.Date(2025, 1, 15, 14, 0, 0, 0, time.UTC))}, // 14:00
511+
},
512+
},
513+
},
514+
},
515+
}
516+
517+
for _, tt := range tests {
518+
t.Run(tt.name, func(t *testing.T) {
519+
got := groupContentByDate(tt.contents)
520+
521+
assert.Equal(t, len(tt.want), len(got), "section count mismatch")
522+
523+
for i := range tt.want {
524+
assert.Equal(t, tt.want[i].DateKey, got[i].DateKey, "DateKey mismatch at section %d", i)
525+
assert.Equal(t, tt.want[i].DisplayDate, got[i].DisplayDate, "DisplayDate mismatch at section %d", i)
526+
assert.Equal(t, len(tt.want[i].Contents), len(got[i].Contents), "Contents count mismatch at section %d", i)
527+
528+
// Verify content IDs in correct order
529+
for j := range tt.want[i].Contents {
530+
assert.Equal(t, tt.want[i].Contents[j].ID, got[i].Contents[j].ID, "Content ID mismatch at section %d, item %d", i, j)
531+
}
532+
}
533+
})
534+
}
535+
}

services/poc/ui/model.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ type Content struct {
1818
CapturedAt *time.Time
1919
}
2020

21+
// ContentSection represents content grouped by a specific date
22+
type ContentSection struct {
23+
DateKey string // "2025-01-15" or "Unknown"
24+
DisplayDate string // Display text for section header
25+
SortOrder int64 // 0 for Unknown, negative unix timestamp for dates
26+
Contents []Content // Already sorted by time
27+
}
28+
29+
// journeyViewModel is the data passed to the journey view template
30+
type journeyViewModel struct {
31+
ID string
32+
Title string
33+
Sections []ContentSection
34+
}
35+
2136
type journey struct {
2237
ID string
2338
Title string

services/poc/ui/templates/content_item.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
{{if eq .Type "photo"}}
1111
<img src="/app/content/{{.ID}}/preview" alt="{{.Title}}" style="max-width: 100%; height: auto; border-radius: 4px;">
1212
{{else}}
13-
<video style="max-width: 100%; height: auto; border-radius: 4px; pointer-events: none;">
13+
<video controls style="max-width: 100%; height: auto; border-radius: 4px;" onclick="event.stopPropagation();">
1414
<source src="/app/content/{{.ID}}/preview" type="{{.MimeType}}">
1515
Your browser does not support the video tag.
1616
</video>

services/poc/ui/templates/journey_view.html

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,28 +36,38 @@ <h1>{{.Title}}</h1>
3636

3737
<h2>Content</h2>
3838
<div id="content-gallery">
39-
{{range .Contents}}
40-
<a href="/app/content/{{.ID}}" hx-boost="true" style="text-decoration: none; color: inherit; display: block;">
41-
<div class="content-item" data-content-id="{{.ID}}" style="margin: 1rem 0; padding: 1rem; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; transition: box-shadow 0.2s, transform 0.1s;">
42-
{{if eq .Type "photo"}}
43-
<img src="/app/content/{{.ID}}/preview" alt="{{.Title}}" style="max-width: 100%; height: auto; border-radius: 4px; pointer-events: none;">
44-
{{else}}
45-
<video controls style="max-width: 100%; height: auto; border-radius: 4px;" onclick="event.stopPropagation();">
46-
<source src="/app/content/{{.ID}}/preview" type="{{.MimeType}}">
47-
Your browser does not support the video tag.
48-
</video>
49-
{{end}}
39+
{{if .Sections}}
40+
{{range .Sections}}
41+
<div class="date-section" style="margin: 2rem 0;">
42+
<h3 style="margin: 1rem 0; padding-bottom: 0.5rem; border-bottom: 2px solid #ddd; color: #333;">
43+
{{.DisplayDate}}
44+
</h3>
45+
46+
{{range .Contents}}
47+
<a href="/app/content/{{.ID}}" hx-boost="true" style="text-decoration: none; color: inherit; display: block;">
48+
<div class="content-item" data-content-id="{{.ID}}" style="margin: 1rem 0; padding: 1rem; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; transition: box-shadow 0.2s, transform 0.1s;">
49+
{{if eq .Type "photo"}}
50+
<img src="/app/content/{{.ID}}/preview" alt="{{.Title}}" style="max-width: 100%; height: auto; border-radius: 4px; pointer-events: none;">
51+
{{else}}
52+
<video controls style="max-width: 100%; height: auto; border-radius: 4px;" onclick="event.stopPropagation();">
53+
<source src="/app/content/{{.ID}}/preview" type="{{.MimeType}}">
54+
Your browser does not support the video tag.
55+
</video>
56+
{{end}}
5057

51-
<div class="content-meta" style="margin-top: 0.5rem;">
52-
<h3 style="margin: 0.5rem 0;">{{.Title}}</h3>
53-
{{if .Description}}
54-
<p style="margin: 0.5rem 0; color: #666;">{{.Description}}</p>
55-
{{end}}
56-
<time style="font-size: 0.875rem; color: #999;">{{.UploadedAt.Format "2006-01-02 15:04:05"}}</time>
57-
<p style="font-size: 0.875rem; color: #999;">Size: {{.FileSize}} bytes</p>
58-
</div>
58+
<div class="content-meta" style="margin-top: 0.5rem;">
59+
<h3 style="margin: 0.5rem 0;">{{.Title}}</h3>
60+
{{if .Description}}
61+
<p style="margin: 0.5rem 0; color: #666;">{{.Description}}</p>
62+
{{end}}
63+
<time style="font-size: 0.875rem; color: #999;">{{.UploadedAt.Format "2006-01-02 15:04:05"}}</time>
64+
<p style="font-size: 0.875rem; color: #999;">Size: {{.FileSize}} bytes</p>
65+
</div>
66+
</div>
67+
</a>
68+
{{end}}
5969
</div>
60-
</a>
70+
{{end}}
6171
{{else}}
6272
<p style="color: #999; font-style: italic;">No content yet. Click "Add Content" to upload photos or videos.</p>
6373
{{end}}

0 commit comments

Comments
 (0)