From 8e8c9367df37a127cb7000233be6b9f1accae91c Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 21 Sep 2025 10:21:18 +0100 Subject: [PATCH 1/3] [WIP] Calendar --- internal/app/controller/web/calendar.go | 32 ++++++ internal/app/router/router.go | 16 ++- web/templates/_layout/default.html.tmpl | 5 +- web/templates/calendar.html.tmpl | 141 ++++++++++++++++++++++++ web/themes/default/style.css | 72 +++++++++++- 5 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 internal/app/controller/web/calendar.go create mode 100644 web/templates/calendar.html.tmpl diff --git a/internal/app/controller/web/calendar.go b/internal/app/controller/web/calendar.go new file mode 100644 index 0000000..7b2d6fc --- /dev/null +++ b/internal/app/controller/web/calendar.go @@ -0,0 +1,32 @@ +package web + +import ( + "net/http" + "text/template" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/pkg/controller" +) + +// Calendar Handle displaying a calendar with blog entries for given days +type Calendar struct { + controller.Super +} + +type calendarTemplateData struct { + Container interface{} +} + +// Run Calendar action +func (c *Calendar) Run(response http.ResponseWriter, request *http.Request) { + + data := calendarTemplateData{} + + container := c.Super.Container().(*app.Container) + data.Container = container + + template, _ := template.ParseFiles( + "./web/templates/_layout/default.html.tmpl", + "./web/templates/calendar.html.tmpl") + template.ExecuteTemplate(response, "layout", data) +} diff --git a/internal/app/router/router.go b/internal/app/router/router.go index 170a6ac..8e33bc5 100644 --- a/internal/app/router/router.go +++ b/internal/app/router/router.go @@ -17,17 +17,23 @@ func NewRouter(app *app.Container) *pkgrouter.Router { app.Configuration.StaticPath, } - rtr.Get("/sitemap.xml", &web.Sitemap{}) - rtr.Get("/stats", &web.Stats{}) - rtr.Get("/new", &web.New{}) - rtr.Post("/new", &web.New{}) - rtr.Get("/random", &web.Random{}) + // API v1 rtr.Get("/api/v1/stats", &apiv1.Stats{}) rtr.Get("/api/v1/post", &apiv1.List{}) rtr.Put("/api/v1/post", &apiv1.Create{}) rtr.Get("/api/v1/post/random", &apiv1.Random{}) rtr.Get("/api/v1/post/[%s]", &apiv1.Single{}) rtr.Post("/api/v1/post/[%s]", &apiv1.Update{}) + + // Web + rtr.Get("/sitemap.xml", &web.Sitemap{}) + rtr.Get("/stats", &web.Stats{}) + rtr.Get("/new", &web.New{}) + rtr.Post("/new", &web.New{}) + rtr.Get("/random", &web.Random{}) + rtr.Get("/calendar/[%s]/[%s]", &web.Calendar{}) + rtr.Get("/calendar/[%s]", &web.Calendar{}) + rtr.Get("/calendar", &web.Calendar{}) rtr.Get("/[%s]/edit", &web.Edit{}) rtr.Post("/[%s]/edit", &web.Edit{}) rtr.Get("/[%s]", &web.View{}) diff --git a/web/templates/_layout/default.html.tmpl b/web/templates/_layout/default.html.tmpl index 97365fb..ad31246 100644 --- a/web/templates/_layout/default.html.tmpl +++ b/web/templates/_layout/default.html.tmpl @@ -16,9 +16,10 @@
{{.Container.Configuration.Title}}
diff --git a/web/templates/calendar.html.tmpl b/web/templates/calendar.html.tmpl new file mode 100644 index 0000000..350f8d2 --- /dev/null +++ b/web/templates/calendar.html.tmpl @@ -0,0 +1,141 @@ +{{define "title"}}Calendar - {{end}} +{{define "content"}} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SunMonTueWedThuFriSat
    +

1

+
+

2

+
+

3

+
+

4

+
+

5

+
+

6

+
+

7

+
+

8

+
+

9

+
+

10

+
+

11

+
+

12

+
+

13

+
+

14

+
+

15

+
+

16

+
+

17

+
+

18

+
+

19

+
+

20

+
+

21

+
+

22

+
+

23

+
+

24

+ Big Mouth +
+

25

+
+

26

+
+

27

+
+

28

+
+

29

+
+

30

+
+

31

+
 
+
+ + +{{end}} diff --git a/web/themes/default/style.css b/web/themes/default/style.css index dc87106..23d9cfe 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -85,7 +85,12 @@ header[role=banner] p { padding-top: .5rem; } -#menu .button { +#menu{ + padding-top: .375rem; +} + +#menu a { + font-size: 1rem; margin-left: 15px; } @@ -542,3 +547,68 @@ section.stats { .visits th:nth-child(4) { text-align: right; } + +.calendar-top { + clear: both; + position: relative; + text-align: center; +} + +.calendar-top h2 { + margin: 0 auto 2rem; +} + +.calendar-top a:nth-child(2) { + left: 0; + position: absolute; + text-align: left; + top: .5rem; +} + +.calendar-top a:nth-child(3) { + position: absolute; + right: 0; + top: .5rem; + text-align: right; +} + +.calendar { + border: 2px solid #111; + border-collapse: collapse; + margin-top: 4rem; + width: 100%; +} + +.calendar th, +.calendar td { + border: 1px solid #dedede; + padding: .25rem .5rem; + vertical-align: top; +} + +.calendar th { + background-color: #dedede; + font-weight: bold; + text-align: right; +} + +.calendar td { + height: 3rem; + padding-top: 2.25rem; + position: relative; +} + +.calendar td h3 { + font-size: 1.25rem; + margin: 0; + position: absolute; + right: .5rem; + text-align: right; + top: .5rem; +} + +.calendar td a { + font-size: 1rem; + font-style: italic; + line-height: 1.25rem; +} \ No newline at end of file From 21465518d6f1e01d78c57531a5aaa6733780ddd8 Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Sun, 5 Oct 2025 20:04:26 +0100 Subject: [PATCH 2/3] [WIP] Calendar functionality --- go.mod | 5 +- internal/app/controller/web/calendar.go | 106 +++++++++++++++++- internal/app/model/journal.go | 10 ++ web/templates/calendar.html.tmpl | 143 +++++------------------- web/themes/default/style.css | 38 +++++-- 5 files changed, 178 insertions(+), 124 deletions(-) diff --git a/go.mod b/go.mod index e37fdf2..b99d9dd 100644 --- a/go.mod +++ b/go.mod @@ -12,4 +12,7 @@ require ( golang.org/x/sys v0.33.0 // indirect ) -require github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b +require ( + github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b + golang.org/x/text v0.25.0 +) diff --git a/internal/app/controller/web/calendar.go b/internal/app/controller/web/calendar.go index 7b2d6fc..db788f0 100644 --- a/internal/app/controller/web/calendar.go +++ b/internal/app/controller/web/calendar.go @@ -2,10 +2,16 @@ package web import ( "net/http" + "strconv" + "strings" "text/template" + "time" "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/internal/app/model" "github.com/jamiefdhurst/journal/pkg/controller" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // Calendar Handle displaying a calendar with blog entries for given days @@ -13,8 +19,24 @@ type Calendar struct { controller.Super } +type day struct { + Date time.Time + IsEmpty bool +} + type calendarTemplateData struct { - Container interface{} + Container interface{} + Days map[int][]model.Journal + Weeks [][]day + CurrentDate time.Time + PrevYear int + PrevYearUrl string + NextYear int + NextYearUrl string + PrevMonth string + PrevMonthUrl string + NextMonth string + NextMonthUrl string } // Run Calendar action @@ -24,6 +46,88 @@ func (c *Calendar) Run(response http.ResponseWriter, request *http.Request) { container := c.Super.Container().(*app.Container) data.Container = container + js := model.Journals{Container: container} + + // Load date from parameters if available (either 2006/jan or 2006) + date := time.Now() + var err error + if len(c.Params()) == 3 { + date, err = time.Parse("2006 Jan 02", c.Params()[1]+" "+cases.Title(language.English, cases.NoLower).String(c.Params()[2])+" 25") + } else if len(c.Params()) == 2 { + date, err = time.Parse("2006-01-02", c.Params()[1]+"-01-01") + } + if err != nil { + RunBadRequest(response, request, c.Super.Container) + return + } + + firstOfMonth := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location()) + startWeekday := int(firstOfMonth.Weekday()) + + // Find number of days in month + nextMonth := firstOfMonth.AddDate(0, 1, 0) + lastOfMonth := nextMonth.AddDate(0, 0, -1) + daysInMonth := lastOfMonth.Day() + + data.Days = map[int][]model.Journal{} + data.Weeks = [][]day{} + week := []day{} + + // Fill in blanks before first day + for range startWeekday { + week = append(week, day{IsEmpty: true}) + } + + // Fill in actual days + for d := 1; d <= daysInMonth; d++ { + thisDate := time.Date(date.Year(), date.Month(), d, 0, 0, 0, 0, date.Location()) + data.Days[d] = js.FetchByDate(thisDate.Format("2006-01-02")) + week = append(week, day{ + Date: thisDate, + IsEmpty: false, + }) + + // If Saturday, start a new week + if thisDate.Weekday() == time.Saturday { + data.Weeks = append(data.Weeks, week) + week = []day{} + } + } + + // Fill in blanks after last day + if len(week) > 0 { + for len(week) < 7 { + week = append(week, day{IsEmpty: true}) + } + data.Weeks = append(data.Weeks, week) + } + + // Load prev/next year and month + firstEntry := js.FindNext(0) + firstEntryDate, _ := time.Parse("2006-01-02", firstEntry.GetEditableDate()) + if date.Year() < time.Now().Year() { + data.NextYear = date.Year() + 1 + data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(date.Format("Jan")) + if date.AddDate(1, 0, 0).After(time.Now()) { + data.NextYearUrl = strconv.Itoa(data.NextYear) + "/" + strings.ToLower(time.Now().Format("Jan")) + } + } + if date.Year() > firstEntryDate.Year() { + data.PrevYear = date.Year() - 1 + data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(date.Format("Jan")) + if date.AddDate(-1, 0, 0).Before(firstEntryDate) { + data.PrevYearUrl = strconv.Itoa(data.PrevYear) + "/" + strings.ToLower(firstEntryDate.Format("Jan")) + } + } + if date.Year() < time.Now().Year() || date.Month() < time.Now().Month() { + data.NextMonth = date.AddDate(0, 0, 31).Format("January") + data.NextMonthUrl = strings.ToLower(date.AddDate(0, 0, 31).Format("2006/Jan")) + } + if date.Year() > firstEntryDate.Year() || date.Month() > firstEntryDate.Month() { + data.PrevMonth = date.AddDate(0, 0, -31).Format("January") + data.PrevMonthUrl = strings.ToLower(date.AddDate(0, 0, -31).Format("2006/Jan")) + } + data.CurrentDate = date template, _ := template.ParseFiles( "./web/templates/_layout/default.html.tmpl", diff --git a/internal/app/model/journal.go b/internal/app/model/journal.go index c2468fb..4d52b87 100644 --- a/internal/app/model/journal.go +++ b/internal/app/model/journal.go @@ -171,6 +171,16 @@ func (js *Journals) FetchAll() []Journal { return js.loadFromRows(rows) } +// FetchByDate Get all journal entries on a given date +func (js *Journals) FetchByDate(date string) []Journal { + rows, err := js.Container.Db.Query("SELECT * FROM `"+journalTable+"` WHERE `date` LIKE ? ORDER BY `id`", date+"%") + if err != nil { + return []Journal{} + } + + return js.loadFromRows(rows) +} + // FetchPaginated returns a set of paginated journal entries func (js *Journals) FetchPaginated(query database.PaginationQuery) ([]Journal, database.PaginationInformation) { pagination := database.PaginationInformation{ diff --git a/web/templates/calendar.html.tmpl b/web/templates/calendar.html.tmpl index 350f8d2..ef27e00 100644 --- a/web/templates/calendar.html.tmpl +++ b/web/templates/calendar.html.tmpl @@ -3,13 +3,22 @@
@@ -25,114 +34,22 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {{range .Weeks}} + + {{range .}} + {{if .IsEmpty}} + + {{else}} + + {{end}} + {{end}} + + {{end}}
    -

1

-
-

2

-
-

3

-
-

4

-
-

5

-
-

6

-
-

7

-
-

8

-
-

9

-
-

10

-
-

11

-
-

12

-
-

13

-
-

14

-
-

15

-
-

16

-
-

17

-
-

18

-
-

19

-
-

20

-
-

21

-
-

22

-
-

23

-
-

24

- Big Mouth -
-

25

-
-

26

-
-

27

-
-

28

-
-

29

-
-

30

-
-

31

-
 
  +

{{.Date.Day}}

+ {{range (index $.Days .Date.Day)}} + {{.Title}} + {{end}} +
diff --git a/web/themes/default/style.css b/web/themes/default/style.css index 23d9cfe..718d9e8 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -558,14 +558,14 @@ section.stats { margin: 0 auto 2rem; } -.calendar-top a:nth-child(2) { +.calendar-top .prev { left: 0; position: absolute; text-align: left; top: .5rem; } -.calendar-top a:nth-child(3) { +.calendar-top .next { position: absolute; right: 0; top: .5rem; @@ -579,6 +579,10 @@ section.stats { width: 100%; } +.calendar thead { + display: none; +} + .calendar th, .calendar td { border: 1px solid #dedede; @@ -593,11 +597,15 @@ section.stats { } .calendar td { - height: 3rem; - padding-top: 2.25rem; + display: block; + padding: 1.75rem 1rem .5rem; position: relative; } +.calendar td.empty { + display: none; +} + .calendar td h3 { font-size: 1.25rem; margin: 0; @@ -607,8 +615,20 @@ section.stats { top: .5rem; } -.calendar td a { - font-size: 1rem; - font-style: italic; - line-height: 1.25rem; -} \ No newline at end of file +@media screen and (min-width: 768px) { + .calendar thead { + display: table-header-group; + } + + .calendar td.empty, .calendar td { + display: table-cell; + height: 3rem; + padding: 1.75rem .5rem .5rem; + width: 14.27%; + } + + .calendar td a { + font-size: 1rem; + line-height: 1.25rem; + } +} From 79e306c566fac33c2f99faeff14a1a877dade0fd Mon Sep 17 00:00:00 2001 From: Jamie Hurst Date: Mon, 27 Oct 2025 10:15:06 +0000 Subject: [PATCH 3/3] Calendar tests and fixes for bad requests and container issues --- .gitignore | 6 +- internal/app/controller/web/badrequest.go | 10 +- internal/app/controller/web/calendar.go | 4 +- internal/app/controller/web/calendar_test.go | 154 +++++++++++++++++++ internal/app/controller/web/edit.go | 4 +- internal/app/controller/web/view.go | 2 +- internal/app/model/journal_test.go | 27 ++++ web/templates/calendar.html.tmpl | 8 +- 8 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 internal/app/controller/web/calendar_test.go diff --git a/.gitignore b/.gitignore index b3ab3c6..e098088 100644 --- a/.gitignore +++ b/.gitignore @@ -29,11 +29,7 @@ data journal node_modules test/data/test.db +tests.xml .vscode .DS_Store .history -bootstrap -*.zip -*.pem -!test/cert.pem -!test/key.pem diff --git a/internal/app/controller/web/badrequest.go b/internal/app/controller/web/badrequest.go index bcd483c..00334b6 100644 --- a/internal/app/controller/web/badrequest.go +++ b/internal/app/controller/web/badrequest.go @@ -4,6 +4,7 @@ import ( "net/http" "text/template" + "github.com/jamiefdhurst/journal/internal/app" "github.com/jamiefdhurst/journal/pkg/controller" ) @@ -12,15 +13,22 @@ type BadRequest struct { controller.Super } +type badRequestTemplateData struct { + Container interface{} +} + // Run BadRequest func (c *BadRequest) Run(response http.ResponseWriter, request *http.Request) { + data := badRequestTemplateData{} + data.Container = c.Super.Container().(*app.Container) + response.WriteHeader(http.StatusNotFound) c.SaveSession(response) template, _ := template.ParseFiles( "./web/templates/_layout/default.html.tmpl", "./web/templates/error.html.tmpl") - template.ExecuteTemplate(response, "layout", c) + template.ExecuteTemplate(response, "layout", data) } // RunBadRequest calls the bad request from an existing controller diff --git a/internal/app/controller/web/calendar.go b/internal/app/controller/web/calendar.go index db788f0..cc2f798 100644 --- a/internal/app/controller/web/calendar.go +++ b/internal/app/controller/web/calendar.go @@ -1,6 +1,7 @@ package web import ( + "log" "net/http" "strconv" "strings" @@ -57,7 +58,8 @@ func (c *Calendar) Run(response http.ResponseWriter, request *http.Request) { date, err = time.Parse("2006-01-02", c.Params()[1]+"-01-01") } if err != nil { - RunBadRequest(response, request, c.Super.Container) + log.Print(err) + RunBadRequest(response, request, container) return } diff --git a/internal/app/controller/web/calendar_test.go b/internal/app/controller/web/calendar_test.go new file mode 100644 index 0000000..032fb75 --- /dev/null +++ b/internal/app/controller/web/calendar_test.go @@ -0,0 +1,154 @@ +package web + +import ( + "net/http" + "os" + "path" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/jamiefdhurst/journal/internal/app" + "github.com/jamiefdhurst/journal/test/mocks/controller" + "github.com/jamiefdhurst/journal/test/mocks/database" +) + +func init() { + _, filename, _, _ := runtime.Caller(0) + dir := path.Join(path.Dir(filename), "../../../..") + err := os.Chdir(dir) + if err != nil { + panic(err) + } +} + +func TestCalendarRun(t *testing.T) { + db := &database.MockSqlite{} + configuration := app.DefaultConfiguration() + container := &app.Container{Configuration: configuration, Db: db} + response := controller.NewMockResponse() + controller := &Calendar{} + controller.DisableTracking() + + // Test showing current year/month (only prev nav) + today := time.Now() + firstOfMonth := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, today.Location()) + daysInMonth := firstOfMonth.AddDate(0, 1, -1).Day() + db.EnableMultiMode() + db.AppendResult(&database.MockJournal_SingleRow{}) + for d := 2; d <= daysInMonth; d++ { + db.AppendResult(&database.MockRowsEmpty{}) + } + db.AppendResult(&database.MockJournal_SingleRow{}) + request, _ := http.NewRequest("GET", "/calendar", strings.NewReader("")) + controller.Init(container, []string{}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title") { + t.Error("Expected title of journal to be shown in calendar") + } + if !strings.Contains(response.Content, "class=\"prev prev-year\"") { + t.Error("Expected previous year link to be shown") + } + if !strings.Contains(response.Content, "class=\"prev prev-month\"") { + t.Error("Expected previous month link to be shown") + } + if strings.Contains(response.Content, "class=\"next next-year\"") { + t.Error("Expected next year link to be missing") + } + if strings.Contains(response.Content, "class=\"next next-month\"") { + t.Error("Expected next month link to be missing") + } + + // Test showing beginning (only next nav) + response.Reset() + db.EnableMultiMode() + db.AppendResult(&database.MockJournal_SingleRow{}) + for d := 2; d <= 28; d++ { + db.AppendResult(&database.MockRowsEmpty{}) + } + db.AppendResult(&database.MockJournal_SingleRow{}) + request, _ = http.NewRequest("GET", "/calendar/2018/feb", strings.NewReader("")) + controller.Init(container, []string{"", "2018", "feb"}, request) + controller.Run(response, request) + if !strings.Contains(response.Content, "Title") { + t.Error("Expected title of journal to be shown in calendar") + } + if !strings.Contains(response.Content, "

2018

") || !strings.Contains(response.Content, "

February2019

") || !strings.Contains(response.Content, "

January 0 { + t.Errorf("Expected empty result set returned when error received") + } + + // Test empty result + db.ErrorMode = false + db.Rows = &database.MockRowsEmpty{} + journals = js.FetchByDate("2001-01-01") + if len(journals) > 0 { + t.Errorf("Expected empty result set returned") + } + + // Test successful result + db.Rows = &database.MockJournal_MultipleRows{} + journals = js.FetchByDate("2001-01-01") + if len(journals) < 2 || journals[0].ID != 1 || journals[1].Content != "Content 2" { + t.Errorf("Expected 2 rows returned and with correct data") + } +} + func TestJournals_FetchPaginated(t *testing.T) { // Test error diff --git a/web/templates/calendar.html.tmpl b/web/templates/calendar.html.tmpl index ef27e00..3d100f8 100644 --- a/web/templates/calendar.html.tmpl +++ b/web/templates/calendar.html.tmpl @@ -5,19 +5,19 @@