diff --git a/assets/diary/calendar.html b/assets/diary/calendar.html
index f8912d9..86a1c16 100644
--- a/assets/diary/calendar.html
+++ b/assets/diary/calendar.html
@@ -3,16 +3,15 @@
data-display-month="{{.DisplayMonth}}">
-
{{.DisplayMonthName}}
-
+
{{.DisplayYear}}
-
+
diff --git a/assets/diary/calendar.js b/assets/diary/calendar.js
index ffe1464..fa87767 100644
--- a/assets/diary/calendar.js
+++ b/assets/diary/calendar.js
@@ -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 + " ";
}
}
diff --git a/diary.go b/diary.go
index 92ff981..ed4a5ef 100644
--- a/diary.go
+++ b/diary.go
@@ -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) {