feat(workspace): journal with one note per day and a calendar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m6s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m6s
- journal_days (migration 8) maps a person's day to an ordinary note;
GET /journal lists the days, POST /journal/{date} opens or creates one
- the journal sits on top of the notes sidebar: days below each other
(today first, older ones load while scrolling), a calendar with month
and year picker follows the day in view and jumps to any day
- the header shares the day in view; journal notes stay out of the folder
tree; can be switched off per device under Darstellung
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
261579f0f2
commit
9008d7c915
19 files changed
+864
-19
No files matched your search
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
// Journal: one ordinary note per day, opened (and created on first use) by date. The web app shows the
|
||||
// days below each other with a calendar; the notes can be shared and searched like any other.
|
||||
|
||||
var journalMu sync.Mutex // one creation at a time, so a day never gets two notes
|
||||
|
||||
var (
|
||||
journalWeekdays = []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
|
||||
journalMonths = []string{"Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
|
||||
)
|
||||
|
||||
// journalTitle: "Freitag, 30. Oktober 2026"
|
||||
func journalTitle(day time.Time) string {
|
||||
return fmt.Sprintf("%s, %d. %s %d", journalWeekdays[day.Weekday()], day.Day(), journalMonths[day.Month()-1], day.Year())
|
||||
}
|
||||
|
||||
func (s *Server) handleGetJournal(w http.ResponseWriter, r *http.Request) {
|
||||
days, err := s.store.JournalDays(auth.GetUserID(r.Context()))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"days": days})
|
||||
}
|
||||
|
||||
// handleOpenJournalDay returns the note of a day and creates it when there is none yet.
|
||||
func (s *Server) handleOpenJournalDay(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
date := chi.URLParam(r, "date")
|
||||
day, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Datum (erwartet JJJJ-MM-TT)")
|
||||
return
|
||||
}
|
||||
journalMu.Lock()
|
||||
defer journalMu.Unlock()
|
||||
docID, err := s.store.JournalDocID(userID, date)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
if docID != "" {
|
||||
if doc, err := s.store.GetDocumentByID(userID, docID); err == nil {
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
return
|
||||
}
|
||||
}
|
||||
doc, err := s.store.CreateDocument(userID, "", journalTitle(day), "📅", "", nil, nil, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Tag konnte nicht angelegt werden")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetJournalDay(userID, date, doc.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Tag konnte nicht angelegt werden")
|
||||
return
|
||||
}
|
||||
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
|
||||
writeJSON(w, http.StatusCreated, doc)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJournalDayIsCreatedOnceAndListed(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
me, token := newTestUser(t, store, "journal@example.com")
|
||||
|
||||
rec := sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var doc struct {
|
||||
ID, Title, Icon string
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &doc)
|
||||
if doc.Title != "Freitag, 30. Oktober 2026" || doc.Icon != "📅" {
|
||||
t.Fatalf("unexpected note: %+v", doc)
|
||||
}
|
||||
|
||||
// Opening the same day again returns the same note
|
||||
rec = sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||
var again struct{ ID string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||
if rec.Code != http.StatusOK || again.ID != doc.ID {
|
||||
t.Fatalf("second open: %d id %s want %s", rec.Code, again.ID, doc.ID)
|
||||
}
|
||||
|
||||
rec = sessionCall(router, "GET", "/api/v1/journal", token, "", "")
|
||||
var list struct {
|
||||
Days []struct {
|
||||
Date, DocID string
|
||||
Empty bool
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
if len(list.Days) != 1 || list.Days[0].Date != "2026-10-30" || list.Days[0].DocID != doc.ID || !list.Days[0].Empty {
|
||||
t.Fatalf("list: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Other people have their own journal
|
||||
_, otherToken := newTestUser(t, store, "andere@example.com")
|
||||
rec = sessionCall(router, "GET", "/api/v1/journal", otherToken, "", "")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() == "" {
|
||||
t.Fatalf("other list: %d", rec.Code)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
if len(list.Days) != 0 {
|
||||
t.Fatalf("other user must not see my journal: %+v", list.Days)
|
||||
}
|
||||
|
||||
// A day whose note went to the trash gets a fresh note
|
||||
if err := store.DeleteDocument(me.ID, doc.ID); err != nil {
|
||||
t.Fatalf("trash: %v", err)
|
||||
}
|
||||
rec = sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||
if rec.Code != http.StatusCreated || again.ID == doc.ID {
|
||||
t.Fatalf("after trash: %d id %s", rec.Code, again.ID)
|
||||
}
|
||||
|
||||
if rec := sessionCall(router, "POST", "/api/v1/journal/30.10.2026", token, "", ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad date: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalTitle(t *testing.T) {
|
||||
day := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
if got := journalTitle(day); got != "Sonntag, 1. März 2026" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||||
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
|
||||
|
||||
r.Get("/api/v1/journal", s.handleGetJournal)
|
||||
r.Post("/api/v1/journal/{date}", s.handleOpenJournalDay)
|
||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
||||
|
||||
Reference in new issue
Block a user