feat: add checkable goals list to org events (#24)

Goals are stored as EventGoal items embedded in the event document.
During active fiscal years, members can check/uncheck goals inline.
Goals can be added and deleted while the event is not yet approved.

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gonçalo Rodrigues 2026-06-14 16:15:55 +01:00 committed by GitHub
parent 26a7236494
commit 1fce3b36aa
6 changed files with 172 additions and 4 deletions

View File

@ -284,6 +284,9 @@ type storeIface interface {
createEvent(ctx context.Context, e *OrgEvent) error createEvent(ctx context.Context, e *OrgEvent) error
updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error updateEvent(ctx context.Context, eventID, orgID string, update bson.M) error
deleteEvent(ctx context.Context, eventID, orgID string) error deleteEvent(ctx context.Context, eventID, orgID string) error
addGoalItem(ctx context.Context, eventID, orgID string, goal EventGoal) error
toggleGoalItem(ctx context.Context, eventID, orgID, goalID string, done bool, doneBy string) error
deleteGoalItem(ctx context.Context, eventID, orgID, goalID string) error
getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error) getBudgetLines(ctx context.Context, eventID, orgID string) ([]BudgetLine, error)
createBudgetLine(ctx context.Context, l *BudgetLine) error createBudgetLine(ctx context.Context, l *BudgetLine) error
deleteBudgetLine(ctx context.Context, lineID, orgID string) error deleteBudgetLine(ctx context.Context, lineID, orgID string) error

View File

@ -891,6 +891,77 @@ func (h *Handler) OrgEventFeedback(w http.ResponseWriter, r *http.Request) {
})(w, r) })(w, r)
} }
// ── Goal items ────────────────────────────────────────────────────────────────
func (h *Handler) OrgGoalAdd(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
yearID := r.PathValue("year_id")
eventID := r.PathValue("event_id")
text := strings.TrimSpace(r.FormValue("text"))
if text == "" {
http.Error(w, "goal text required", http.StatusBadRequest)
return
}
goal := EventGoal{
ID: bson.NewObjectID().Hex(),
Text: text,
}
if err := h.store.addGoalItem(ctx, eventID, org.ID, goal); err != nil {
slog.Error("add goal item", "err", err)
http.Error(w, "could not add goal", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgGoalToggle(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
yearID := r.PathValue("year_id")
eventID := r.PathValue("event_id")
goalID := r.PathValue("goal_id")
// Only allow toggling when fiscal year is active
year, err := h.store.getFiscalYear(ctx, yearID, org.ID)
if err != nil || year.Status != FiscalYearActive {
http.Error(w, "goals can only be checked during an active fiscal year", http.StatusConflict)
return
}
done := r.FormValue("done") == "1"
if err := h.store.toggleGoalItem(ctx, eventID, org.ID, goalID, done, me.ID); err != nil {
slog.Error("toggle goal item", "err", err)
http.Error(w, "could not update goal", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(w, r)
}
func (h *Handler) OrgGoalDelete(w http.ResponseWriter, r *http.Request) {
h.requireOrgMember(func(w http.ResponseWriter, r *http.Request, org *Org, me *OrgMember) {
ctx := r.Context()
yearID := r.PathValue("year_id")
eventID := r.PathValue("event_id")
goalID := r.PathValue("goal_id")
ev, err := h.store.getEvent(ctx, eventID, org.ID)
if err != nil {
http.Error(w, "event not found", http.StatusNotFound)
return
}
if ev.Status == EventApproved {
http.Error(w, "cannot remove goals from an approved event", http.StatusConflict)
return
}
_ = h.store.deleteGoalItem(ctx, eventID, org.ID, goalID)
http.Redirect(w, r, "/orgs/"+org.Slug+"/years/"+yearID+"/events/"+eventID, http.StatusSeeOther)
})(w, r)
}
// ── Budget lines ────────────────────────────────────────────────────────────── // ── Budget lines ──────────────────────────────────────────────────────────────
func (h *Handler) OrgBudgetLineCreate(w http.ResponseWriter, r *http.Request) { func (h *Handler) OrgBudgetLineCreate(w http.ResponseWriter, r *http.Request) {
@ -1866,6 +1937,9 @@ func (h *Handler) RegisterOrgRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/feedback", h.OrgEventFeedback) mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/feedback", h.OrgEventFeedback)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/budget", h.OrgBudgetLineCreate) mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/budget", h.OrgBudgetLineCreate)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/budget/{line_id}/delete", h.OrgBudgetLineDelete) mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/budget/{line_id}/delete", h.OrgBudgetLineDelete)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/goals", h.OrgGoalAdd)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/goals/{goal_id}/toggle", h.OrgGoalToggle)
mux.HandleFunc("POST /orgs/{slug}/years/{year_id}/events/{event_id}/goals/{goal_id}/delete", h.OrgGoalDelete)
// Analysis & report // Analysis & report
mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/analysis", h.OrgAnalysis) mux.HandleFunc("GET /orgs/{slug}/years/{year_id}/analysis", h.OrgAnalysis)

View File

@ -207,6 +207,11 @@ func (m *mockStore) getEvent(_ context.Context, _, _ string) (*OrgEvent, error)
func (m *mockStore) createEvent(_ context.Context, _ *OrgEvent) error { return nil } func (m *mockStore) createEvent(_ context.Context, _ *OrgEvent) error { return nil }
func (m *mockStore) updateEvent(_ context.Context, _, _ string, _ bson.M) error { return nil } func (m *mockStore) updateEvent(_ context.Context, _, _ string, _ bson.M) error { return nil }
func (m *mockStore) deleteEvent(_ context.Context, _, _ string) error { return nil } func (m *mockStore) deleteEvent(_ context.Context, _, _ string) error { return nil }
func (m *mockStore) addGoalItem(_ context.Context, _, _ string, _ EventGoal) error { return nil }
func (m *mockStore) toggleGoalItem(_ context.Context, _, _, _ string, _ bool, _ string) error {
return nil
}
func (m *mockStore) deleteGoalItem(_ context.Context, _, _, _ string) error { return nil }
func (m *mockStore) getBudgetLines(_ context.Context, _, _ string) ([]BudgetLine, error) { return nil, nil } func (m *mockStore) getBudgetLines(_ context.Context, _, _ string) ([]BudgetLine, error) { return nil, nil }
func (m *mockStore) createBudgetLine(_ context.Context, _ *BudgetLine) error { return nil } func (m *mockStore) createBudgetLine(_ context.Context, _ *BudgetLine) error { return nil }
func (m *mockStore) deleteBudgetLine(_ context.Context, _, _ string) error { return nil } func (m *mockStore) deleteBudgetLine(_ context.Context, _, _ string) error { return nil }

View File

@ -100,6 +100,16 @@ const (
EventRejected EventStatus = "rejected" EventRejected EventStatus = "rejected"
) )
// EventGoal is a single checkable item in an event's goal list.
// Done can be toggled by any org member while the fiscal year is active.
type EventGoal struct {
ID string `bson:"id" json:"id"`
Text string `bson:"text" json:"text"`
Done bool `bson:"done" json:"done"`
DoneBy string `bson:"done_by,omitempty" json:"done_by,omitempty"`
DoneAt time.Time `bson:"done_at,omitempty" json:"done_at,omitempty"`
}
type OrgEvent struct { type OrgEvent struct {
ID string `bson:"_id" json:"id"` ID string `bson:"_id" json:"id"`
OrgID string `bson:"org_id" json:"org_id"` OrgID string `bson:"org_id" json:"org_id"`
@ -108,6 +118,7 @@ type OrgEvent struct {
Name string `bson:"name" json:"name"` Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"` Description string `bson:"description" json:"description"`
Goals string `bson:"goals" json:"goals"` Goals string `bson:"goals" json:"goals"`
GoalItems []EventGoal `bson:"goal_items" json:"goal_items"`
DateStart time.Time `bson:"date_start" json:"date_start"` DateStart time.Time `bson:"date_start" json:"date_start"`
DateEnd time.Time `bson:"date_end" json:"date_end"` DateEnd time.Time `bson:"date_end" json:"date_end"`
Status EventStatus `bson:"status" json:"status"` Status EventStatus `bson:"status" json:"status"`

View File

@ -406,6 +406,47 @@ func (s *Store) updateEvent(ctx context.Context, eventID, orgID string, update b
return err return err
} }
func (s *Store) addGoalItem(ctx context.Context, eventID, orgID string, goal EventGoal) error {
ctx, span := mongo.StartSpan(ctx, "Store.addGoalItem")
defer span.End()
_, err := s.orgEvents().UpdateOne(ctx,
bson.M{"_id": eventID, "org_id": orgID},
bson.M{"$push": bson.M{"goal_items": goal}},
)
return err
}
func (s *Store) toggleGoalItem(ctx context.Context, eventID, orgID, goalID string, done bool, doneBy string) error {
ctx, span := mongo.StartSpan(ctx, "Store.toggleGoalItem")
defer span.End()
set := bson.M{
"goal_items.$[g].done": done,
"goal_items.$[g].done_by": doneBy,
}
if done {
set["goal_items.$[g].done_at"] = time.Now()
} else {
set["goal_items.$[g].done_at"] = time.Time{}
set["goal_items.$[g].done_by"] = ""
}
_, err := s.orgEvents().UpdateOne(ctx,
bson.M{"_id": eventID, "org_id": orgID},
bson.M{"$set": set},
options.UpdateOne().SetArrayFilters([]any{bson.M{"g.id": goalID}}),
)
return err
}
func (s *Store) deleteGoalItem(ctx context.Context, eventID, orgID, goalID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteGoalItem")
defer span.End()
_, err := s.orgEvents().UpdateOne(ctx,
bson.M{"_id": eventID, "org_id": orgID},
bson.M{"$pull": bson.M{"goal_items": bson.M{"id": goalID}}},
)
return err
}
func (s *Store) deleteEvent(ctx context.Context, eventID, orgID string) error { func (s *Store) deleteEvent(ctx context.Context, eventID, orgID string) error {
ctx, span := mongo.StartSpan(ctx, "Store.deleteEvent") ctx, span := mongo.StartSpan(ctx, "Store.deleteEvent")
defer span.End() defer span.End()

View File

@ -127,12 +127,46 @@
<p style="font-size:13px; color:var(--text2); line-height:1.6;">{{$d.Event.Description}}</p> <p style="font-size:13px; color:var(--text2); line-height:1.6;">{{$d.Event.Description}}</p>
</div> </div>
{{end}} {{end}}
{{if $d.Event.Goals}}
<div> <div>
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:6px;">GOALS</div> <div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:8px;">GOALS</div>
<p style="font-size:13px; color:var(--text2); line-height:1.6;">{{$d.Event.Goals}}</p> {{if $d.Event.GoalItems}}
<ul style="list-style:none; padding:0; margin:0 0 10px;">
{{range $d.Event.GoalItems}}
{{$goal := .}}
<li style="display:flex; align-items:center; gap:8px; padding:5px 0; border-bottom:1px solid var(--border2);">
{{if eq (print $d.FiscalYear.Status) "active"}}
<form method="POST" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/goals/{{$goal.ID}}/toggle" style="display:contents;">
{{if $goal.Done}}
<input type="hidden" name="done" value="0">
<button type="submit" style="background:none;border:none;cursor:pointer;padding:0;color:var(--accent);" title="Mark undone"></button>
{{else}}
<input type="hidden" name="done" value="1">
<button type="submit" style="background:none;border:none;cursor:pointer;padding:0;color:var(--text3);" title="Mark done"></button>
{{end}}
</form>
{{else}}
{{if $goal.Done}}<span></span>{{else}}<span style="color:var(--text3);"></span>{{end}}
{{end}}
<span style="flex:1; font-size:13px; {{if $goal.Done}}text-decoration:line-through; color:var(--text3);{{end}}">{{$goal.Text}}</span>
{{if $goal.Done}}<span style="font-size:11px; color:var(--text3);">by {{$goal.DoneBy}}</span>{{end}}
{{if and (ne (print $d.Event.Status) "approved") (not $goal.Done)}}
<form method="POST" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/goals/{{$goal.ID}}/delete">
<button type="submit" style="background:none;border:none;cursor:pointer;font-size:12px;color:var(--text3);" title="Remove goal"></button>
</form>
{{end}}
</li>
{{end}}
</ul>
{{else}}
<p style="font-size:13px; color:var(--text3); margin:0 0 10px;">No goals yet.</p>
{{end}}
{{if ne (print $d.Event.Status) "approved"}}
<form method="POST" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/goals" style="display:flex; gap:8px;">
<input name="text" placeholder="Add a goal…" style="flex:1;" required>
<button type="submit" class="btn btn-outline btn-sm">Add</button>
</form>
{{end}}
</div> </div>
{{end}}
</div> </div>
<!-- Budget lines --> <!-- Budget lines -->