Refactoring of calendar widget

This commit is contained in:
2026-05-28 21:28:16 +02:00
parent 61e50c033f
commit 51bf489449
3 changed files with 102 additions and 78 deletions
+2 -3
View File
@@ -3,16 +3,15 @@
data-display-month="{{.DisplayMonth}}">
<div class="panel-header"><a href="{{.DiaryURL}}">Chronological</a></div>
<div class="diary-cal-nav">
<a href="{{.DisplayMonthURL}}" class="diary-cal-heading" data-cal-month-link>{{.DisplayMonthName}}</a>
<div class="dropdown diary-cal-drop">
<button type="button" class="btn btn-small" data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"></button>
<button type="button" class="btn" data-cal-month-link data-action="cal-month-drop" aria-expanded="false" title="Monat wählen"> {{.DisplayMonthName}} </button>
<div class="dropdown-menu scrollable">
{{range .Months}}<a class="btn btn-block" data-cal-month-jump="{{.Num}}" href="{{.AnchorURL}}">{{.Name}}</a>{{end}}
</div>
</div>
<a href="{{.YearURL}}" class="diary-cal-heading">{{.DisplayYear}}</a>
<div class="dropdown diary-cal-drop">
<button type="button" class="btn btn-small" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen"></button>
<button type="button" class="btn" data-action="cal-year-drop" aria-expanded="false" title="Jahr wählen"></button>
<div class="dropdown-menu align-right scrollable">
{{range .Years}}<a class="btn btn-block{{if .IsCurrent}} cal-current{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
</div>
+3 -6
View File
@@ -5,7 +5,7 @@
var displayYear = parseInt(cal.dataset.displayYear, 10);
var current = parseInt(cal.dataset.displayMonth, 10);
var monthLink = cal.querySelector("[data-cal-month-link]");
var monthLabel = cal.querySelector("[data-cal-month-link]");
var months = {};
cal.querySelectorAll("[data-cal-month]").forEach(function (t) {
months[parseInt(t.dataset.calMonth, 10)] = t;
@@ -15,18 +15,15 @@
jumpLinks[parseInt(a.dataset.calMonthJump, 10)] = a;
});
function pad(n) { return n < 10 ? "0" + n : "" + n; }
function show(m) {
if (m === current) return;
if (!months[m]) return;
months[current].hidden = true;
months[m].hidden = false;
current = m;
if (monthLink) {
if (monthLabel) {
var label = jumpLinks[m];
if (label) monthLink.textContent = label.textContent;
monthLink.setAttribute("href", "#" + displayYear + "-" + pad(m));
if (label) monthLabel.textContent = " " + label.textContent + " ";
}
}
+97 -69
View File
@@ -369,7 +369,6 @@ type calendarData struct {
DisplayYear int
DisplayMonth int
DisplayMonthName string // pre-resolved so the template doesn't need arithmetic
DisplayMonthURL string
DiaryURL string
YearURL string
Months []calMonthGrid
@@ -470,7 +469,7 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
Num: m,
Name: germanMonths[time.Month(m)],
AnchorURL: monthAnchor(displayYear, m),
Weeks: buildMonthGrid(displayYear, m, today, cd, hasDayEntryByMonth[m], diaryRootURL, dayAnchor),
Weeks: buildMonthGrid(displayYear, m, today, cd, hasDayEntryByMonth[m], dayAnchor),
}
}
@@ -506,7 +505,6 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
DisplayYear: displayYear,
DisplayMonth: displayMonth,
DisplayMonthName: months[displayMonth-1].Name,
DisplayMonthURL: months[displayMonth-1].AnchorURL,
DiaryURL: diaryRootURL,
YearURL: yearURL,
Months: months,
@@ -523,10 +521,11 @@ func computeCalendarWidget(diaryRootFS, diaryRootURL, fsPath string, depth int)
// buildMonthGrid renders one month's day cells as a Monday-first week grid.
// hasDayEntry maps day-of-month → has a diary entry. dayAnchor produces the
// in-page anchor (or full URL when crossing pages). Empty days always link
// to the full month/day URL with ?edit so the diary handler can route into
// the "insert new day section" editor.
func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, diaryRootURL string, dayAnchor func(int, int, int) string) [][]calDay {
// in-page anchor (or full URL when crossing pages); empty days link to the
// same anchor — every day exists on the year page as either a real or
// virtual section, so navigation is enough. Page creation happens via the
// [edit] button on the heading itself.
func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntry map[int]bool, dayAnchor func(int, int, int) string) [][]calDay {
firstDay := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
startOffset := int(firstDay.Weekday()+6) % 7
daysInMonth := time.Date(year, time.Month(month)+1, 0, 0, 0, 0, 0, time.UTC).Day()
@@ -535,14 +534,10 @@ func buildMonthGrid(year, month int, today time.Time, currentDay int, hasDayEntr
week := make([]calDay, 7)
col := startOffset
for d := 1; d <= daysInMonth; d++ {
cell := calDay{Num: d, HasEntry: hasDayEntry[d]}
if cell.HasEntry {
cell.URL = dayAnchor(year, month, d)
} else {
cell.URL = path.Join(diaryRootURL,
fmt.Sprintf("%d", year),
fmt.Sprintf("%02d", month),
fmt.Sprintf("%02d", d)) + "/?edit"
cell := calDay{
Num: d,
HasEntry: hasDayEntry[d],
URL: dayAnchor(year, month, d),
}
cell.IsCurrent = currentDay > 0 && d == currentDay
cell.IsToday = d == today.Day() &&
@@ -669,7 +664,7 @@ func renderDiaryYear(yearFS, yearURL string) template.HTML {
photos := yearPhotos(yearFS, yearURL)
out := buildSectionsForRange(sections, photos, 1, len(sections), yearURL)
out = appendOrphanPhotoDays(out, photos, year)
out = appendVirtualEntries(out, sections, photos, year, yearURL)
return renderDiaryContent(out)
}
@@ -699,79 +694,112 @@ func buildSectionsForRange(sections [][]byte, photos []diaryPhoto, start, end in
return out
}
// appendOrphanPhotoDays inserts synthetic day sections (no edit button, no
// body) for photo dates not already covered by an explicit day section.
// Orphans are interleaved by date with existing *date* day sections only —
// non-date level-3 headings (e.g. `### Movies` in a year intro) keep their
// original document position. Remaining orphans are appended at the end so
// the year file's intro material always stays above the diary entries.
func appendOrphanPhotoDays(existing []diarySection, photos []diaryPhoto, year int) []diarySection {
covered := map[string]bool{}
// appendVirtualEntries inserts virtual month and day sections for every
// `## YYYY-MM` / `### YYYY-MM-DD` slot in `year` that lacks a real section.
// Virtual day sections carry photos when present. Each virtual entry's
// EditURL routes through the insert-before flow so clicking [edit] splices
// the section into the year file at the right chronological position.
//
// Scope: past years get all 12 months / 365(6) days; the current year stops
// at today; future years are returned unchanged.
//
// Interleave: real date sections (`## YYYY-MM`, `### YYYY-MM-DD`) keep their
// document position; virtual entries are spliced in lexicographic ID order
// before the next real date section. Non-date headings (e.g. `## Events` →
// `### Festival` in a year intro) are left where the user wrote them.
func appendVirtualEntries(existing []diarySection, sections [][]byte, photos []diaryPhoto, year int, yearURL string) []diarySection {
today := time.Now()
if year > today.Year() {
return existing
}
coveredMonth := map[string]bool{}
coveredDay := map[string]bool{}
for _, s := range existing {
if s.Level == 3 {
covered[s.ID] = true
switch {
case s.Level == 2 && len(s.ID) == 7 && isDateHeading(s.ID):
coveredMonth[s.ID] = true
case s.Level == 3 && len(s.ID) == 10 && isDateHeading(s.ID):
coveredDay[s.ID] = true
}
}
type orphan struct {
date time.Time
header string
photos []diaryPhoto
}
orphMap := map[string]*orphan{}
photoByDay := map[string][]diaryPhoto{}
for _, p := range photos {
if p.Date.Year() != year {
continue
}
key := p.Date.Format("2006-01-02")
if covered[key] {
continue
}
o, ok := orphMap[key]
if !ok {
o = &orphan{date: p.Date, header: key}
orphMap[key] = o
}
o.photos = append(o.photos, p)
photoByDay[p.Date.Format("2006-01-02")] = append(photoByDay[p.Date.Format("2006-01-02")], p)
}
if len(orphMap) == 0 {
lastDay := time.Date(year, time.December, 31, 0, 0, 0, 0, time.UTC)
if year == today.Year() {
lastDay = time.Date(year, today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
}
var virtual []diarySection
for d := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC); !d.After(lastDay); d = d.AddDate(0, 0, 1) {
if d.Day() == 1 {
monthID := d.Format("2006-01")
if !coveredMonth[monthID] {
idx := computeInsertIndex(sections, monthID)
virtual = append(virtual, diarySection{
Level: 2,
ID: monthID,
Heading: monthID,
EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s&level=%s",
yearURL, idx, url.QueryEscape(monthID), url.QueryEscape("##")),
})
}
}
dayID := d.Format("2006-01-02")
if !coveredDay[dayID] {
idx := computeInsertIndex(sections, dayID)
virtual = append(virtual, diarySection{
Level: 3,
ID: dayID,
Heading: dayID,
EditURL: fmt.Sprintf("%s?edit&insert_before=%d&heading=%s",
yearURL, idx, url.QueryEscape(dayID)),
Photos: photoByDay[dayID],
})
}
}
if len(virtual) == 0 {
return existing
}
orphans := make([]*orphan, 0, len(orphMap))
for _, o := range orphMap {
orphans = append(orphans, o)
}
sort.Slice(orphans, func(i, j int) bool { return orphans[i].date.Before(orphans[j].date) })
out := make([]diarySection, 0, len(existing)+len(orphans))
oi := 0
out := make([]diarySection, 0, len(existing)+len(virtual))
vi := 0
for _, s := range existing {
if s.Level == 3 {
if _, _, _, ok := parseISODate(s.ID); ok {
for oi < len(orphans) && orphans[oi].header < s.ID {
out = append(out, diarySection{
Level: 3,
ID: orphans[oi].header,
Heading: orphans[oi].header,
Photos: orphans[oi].photos,
})
oi++
}
if isRealDateSection(s) {
for vi < len(virtual) && virtual[vi].ID < s.ID {
out = append(out, virtual[vi])
vi++
}
}
out = append(out, s)
}
for oi < len(orphans) {
out = append(out, diarySection{
Level: 3,
ID: orphans[oi].header,
Heading: orphans[oi].header,
Photos: orphans[oi].photos,
})
oi++
for vi < len(virtual) {
out = append(out, virtual[vi])
vi++
}
return out
}
// isRealDateSection reports whether a rendered diarySection is one of the
// date-headed slots (`## YYYY-MM` or `### YYYY-MM-DD`) the virtual-entry
// interleave sorts against.
func isRealDateSection(s diarySection) bool {
switch s.Level {
case 2:
return len(s.ID) == 7 && isDateHeading(s.ID)
case 3:
return len(s.ID) == 10 && isDateHeading(s.ID)
}
return false
}
// parseISODate parses "YYYY-MM-DD" leading characters of s. Returns ok=false
// if the prefix does not match.
func parseISODate(s string) (year, month, day int, ok bool) {