apps/habbits/handlers_web.go 9.8 K raw
1
package main
2
3
import (
4
	"net/http"
5
	"strings"
6
	"time"
7
8
	"github.com/stevedylandev/andromeda/pkg/auth"
9
	"github.com/stevedylandev/andromeda/pkg/web"
10
)
11
12
const (
13
	dateLayout  = "2006-01-02"
14
	timeLayout  = "15:04"
15
	inputLayout = "2006-01-02T15:04" // datetime-local
16
)
17
18
func habitToRow(h Habit, count int) habitRow {
19
	row := habitRow{ShortID: h.ShortID, Name: h.Name, ValueType: h.ValueType, RecordCount: count}
20
	if h.Unit != nil {
21
		row.Unit = *h.Unit
22
	}
23
	if h.Description != nil {
24
		row.Description = *h.Description
25
	}
26
	return row
27
}
28
29
func recordToRow(r Record) recordRow {
30
	t := time.Unix(r.RecordedAt, 0).Local()
31
	return recordRow{
32
		ShortID:         r.ShortID,
33
		HabitShortID:    r.HabitShortID,
34
		HabitName:       r.HabitName,
35
		ValueType:       r.ValueType,
36
		Value:           r.Value,
37
		Unit:            r.Unit,
38
		Date:            t.Format(dateLayout),
39
		TimeDisplay:     t.Format(timeLayout),
40
		RecordedAtInput: t.Format(inputLayout),
41
	}
42
}
43
44
// groupByDay buckets records (ordered recorded_at DESC) into day groups,
45
// preserving encounter order so records stay chronological within a day.
46
func groupByDay(recs []Record) []habitDay {
47
	var days []habitDay
48
	idx := map[string]int{}
49
	for _, r := range recs {
50
		row := recordToRow(r)
51
		i, ok := idx[row.Date]
52
		if !ok {
53
			i = len(days)
54
			idx[row.Date] = i
55
			days = append(days, habitDay{Date: row.Date})
56
		}
57
		days[i].Records = append(days[i].Records, row)
58
	}
59
	return days
60
}
61
62
// parseRecordedAt converts a datetime-local form value into unix seconds. An
63
// empty value defaults to now, matching the "auto-injected at time of entry"
64
// behaviour while still allowing a backdated entry from the UI.
65
func parseRecordedAt(raw string) (int64, error) {
66
	raw = strings.TrimSpace(raw)
67
	if raw == "" {
68
		return time.Now().Unix(), nil
69
	}
70
	t, err := time.ParseInLocation(inputLayout, raw, time.Local)
71
	if err != nil {
72
		return 0, err
73
	}
74
	return t.Unix(), nil
75
}
76
77
// --- auth ---
78
79
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
80
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
81
}
82
83
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
84
	if a.AdminPassword == "" {
85
		web.RedirectWithError(w, r, "/login", "No admin password configured")
86
		return
87
	}
88
	if err := r.ParseForm(); err != nil {
89
		web.RedirectWithError(w, r, "/login", "Bad request")
90
		return
91
	}
92
	if !auth.VerifyPassword(r.FormValue("password"), a.AdminPassword) {
93
		web.RedirectWithError(w, r, "/login", "Invalid password")
94
		return
95
	}
96
	token, err := a.Sessions.Create()
97
	if err != nil {
98
		a.Log.Error("create session failed", "err", err)
99
		web.RedirectWithError(w, r, "/login", "Session error")
100
		return
101
	}
102
	a.Sessions.PruneExpired()
103
	http.SetCookie(w, a.Sessions.SessionCookie(token))
104
	http.Redirect(w, r, "/", http.StatusSeeOther)
105
}
106
107
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
108
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
109
		a.Sessions.Delete(c.Value)
110
	}
111
	http.SetCookie(w, a.Sessions.ClearCookie())
112
	http.Redirect(w, r, "/login", http.StatusSeeOther)
113
}
114
115
// --- dashboard ---
116
117
func (a *App) dashboardHandler(w http.ResponseWriter, r *http.Request) {
118
	habits, counts, err := listHabits(a.DB)
119
	if err != nil {
120
		a.Log.Error("list habits", "err", err)
121
	}
122
	habitRows := make([]habitRow, 0, len(habits))
123
	for _, h := range habits {
124
		habitRows = append(habitRows, habitToRow(h, counts[h.ID]))
125
	}
126
127
	records, err := listRecords(a.DB, 200)
128
	if err != nil {
129
		a.Log.Error("list records", "err", err)
130
	}
131
132
	web.Render(a.Templates, w, "index.html", dashboardData{
133
		Success: r.URL.Query().Get("success"),
134
		Error:   r.URL.Query().Get("error"),
135
		Habits:  habitRows,
136
		Days:    groupByDay(records),
137
	}, a.Log)
138
}
139
140
// --- habits ---
141
142
func (a *App) newHabitHandler(w http.ResponseWriter, r *http.Request) {
143
	web.Render(a.Templates, w, "new.html", newHabitPageData{
144
		Error:      r.URL.Query().Get("error"),
145
		ValueTypes: valueTypes,
146
	}, a.Log)
147
}
148
149
func (a *App) settingsHandler(w http.ResponseWriter, r *http.Request) {
150
	habits, counts, err := listHabits(a.DB)
151
	if err != nil {
152
		a.Log.Error("list habits", "err", err)
153
	}
154
	habitRows := make([]habitRow, 0, len(habits))
155
	for _, h := range habits {
156
		habitRows = append(habitRows, habitToRow(h, counts[h.ID]))
157
	}
158
	web.Render(a.Templates, w, "settings.html", settingsPageData{
159
		Success:    r.URL.Query().Get("success"),
160
		Error:      r.URL.Query().Get("error"),
161
		ValueTypes: valueTypes,
162
		Habits:     habitRows,
163
	}, a.Log)
164
}
165
166
func (a *App) createHabitHandler(w http.ResponseWriter, r *http.Request) {
167
	if err := r.ParseForm(); err != nil {
168
		web.RedirectWithError(w, r, "/new", "Bad request")
169
		return
170
	}
171
	name := strings.TrimSpace(r.FormValue("name"))
172
	valueType := strings.TrimSpace(r.FormValue("value_type"))
173
	unit := strings.TrimSpace(r.FormValue("unit"))
174
	description := strings.TrimSpace(r.FormValue("description"))
175
	if name == "" {
176
		web.RedirectWithError(w, r, "/new", "Habit name is required")
177
		return
178
	}
179
	if !validValueType(valueType) {
180
		web.RedirectWithError(w, r, "/new", "Invalid value type")
181
		return
182
	}
183
	if _, err := insertHabit(a.DB, name, valueType, unit, description); err != nil {
184
		a.Log.Error("insert habit", "err", err)
185
		web.RedirectWithError(w, r, "/new", "Failed to create habit")
186
		return
187
	}
188
	web.RedirectWithSuccess(w, r, "/", "Habit created")
189
}
190
191
func (a *App) habitDetailHandler(w http.ResponseWriter, r *http.Request) {
192
	habit, err := getHabitByShortID(a.DB, r.PathValue("short_id"))
193
	if err != nil {
194
		a.Log.Error("get habit", "err", err)
195
		web.RedirectWithError(w, r, "/", "Failed to load habit")
196
		return
197
	}
198
	if habit == nil {
199
		http.NotFound(w, r)
200
		return
201
	}
202
	records, err := listRecordsForHabit(a.DB, habit.ID)
203
	if err != nil {
204
		a.Log.Error("list habit records", "err", err)
205
	}
206
	web.Render(a.Templates, w, "habit.html", habitPageData{
207
		Success: r.URL.Query().Get("success"),
208
		Error:   r.URL.Query().Get("error"),
209
		Habit:   habitToRow(*habit, len(records)),
210
		Days:    groupByDay(records),
211
	}, a.Log)
212
}
213
214
func (a *App) updateHabitHandler(w http.ResponseWriter, r *http.Request) {
215
	shortID := r.PathValue("short_id")
216
	target := "/settings"
217
	if err := r.ParseForm(); err != nil {
218
		web.RedirectWithError(w, r, target, "Bad request")
219
		return
220
	}
221
	name := strings.TrimSpace(r.FormValue("name"))
222
	valueType := strings.TrimSpace(r.FormValue("value_type"))
223
	if name == "" {
224
		web.RedirectWithError(w, r, target, "Habit name is required")
225
		return
226
	}
227
	if !validValueType(valueType) {
228
		web.RedirectWithError(w, r, target, "Invalid value type")
229
		return
230
	}
231
	if err := updateHabit(a.DB, shortID, name, valueType,
232
		strings.TrimSpace(r.FormValue("unit")), strings.TrimSpace(r.FormValue("description"))); err != nil {
233
		a.Log.Error("update habit", "err", err)
234
		web.RedirectWithError(w, r, target, "Failed to update habit")
235
		return
236
	}
237
	web.RedirectWithSuccess(w, r, target, "Habit updated")
238
}
239
240
func (a *App) deleteHabitHandler(w http.ResponseWriter, r *http.Request) {
241
	target := "/settings"
242
	if ref := r.FormValue("return_to"); ref != "" {
243
		target = ref
244
	}
245
	if err := deleteHabitByShortID(a.DB, r.PathValue("short_id")); err != nil {
246
		a.Log.Error("delete habit", "err", err)
247
		web.RedirectWithError(w, r, target, "Failed to delete habit")
248
		return
249
	}
250
	web.RedirectWithSuccess(w, r, target, "Habit deleted")
251
}
252
253
// --- records ---
254
255
func (a *App) createRecordHandler(w http.ResponseWriter, r *http.Request) {
256
	if err := r.ParseForm(); err != nil {
257
		web.RedirectWithError(w, r, "/", "Bad request")
258
		return
259
	}
260
	// Where to return to: the referring page (dashboard or habit detail).
261
	target := "/"
262
	if ref := r.FormValue("return_to"); ref != "" {
263
		target = ref
264
	}
265
266
	habit, err := getHabitByShortID(a.DB, strings.TrimSpace(r.FormValue("habit")))
267
	if err != nil {
268
		a.Log.Error("get habit for record", "err", err)
269
		web.RedirectWithError(w, r, target, "Failed to load habit")
270
		return
271
	}
272
	if habit == nil {
273
		web.RedirectWithError(w, r, target, "Select a habit")
274
		return
275
	}
276
	value, err := normalizeValue(habit.ValueType, r.FormValue("value"))
277
	if err != nil {
278
		web.RedirectWithError(w, r, target, err.Error())
279
		return
280
	}
281
	recordedAt, err := parseRecordedAt(r.FormValue("recorded_at"))
282
	if err != nil {
283
		web.RedirectWithError(w, r, target, "Invalid date/time")
284
		return
285
	}
286
	if _, err := insertRecord(a.DB, habit.ID, value, recordedAt); err != nil {
287
		a.Log.Error("insert record", "err", err)
288
		web.RedirectWithError(w, r, target, "Failed to add record")
289
		return
290
	}
291
	web.RedirectWithSuccess(w, r, target, "Record added")
292
}
293
294
func (a *App) updateRecordHandler(w http.ResponseWriter, r *http.Request) {
295
	shortID := r.PathValue("short_id")
296
	if err := r.ParseForm(); err != nil {
297
		web.RedirectWithError(w, r, "/", "Bad request")
298
		return
299
	}
300
	rec, err := getRecordByShortID(a.DB, shortID)
301
	if err != nil {
302
		a.Log.Error("get record", "err", err)
303
		web.RedirectWithError(w, r, "/", "Failed to load record")
304
		return
305
	}
306
	if rec == nil {
307
		http.NotFound(w, r)
308
		return
309
	}
310
	target := "/habits/" + rec.HabitShortID
311
	if ref := r.FormValue("return_to"); ref != "" {
312
		target = ref
313
	}
314
	value, err := normalizeValue(rec.ValueType, r.FormValue("value"))
315
	if err != nil {
316
		web.RedirectWithError(w, r, target, err.Error())
317
		return
318
	}
319
	recordedAt, err := parseRecordedAt(r.FormValue("recorded_at"))
320
	if err != nil {
321
		web.RedirectWithError(w, r, target, "Invalid date/time")
322
		return
323
	}
324
	if err := updateRecord(a.DB, shortID, value, recordedAt); err != nil {
325
		a.Log.Error("update record", "err", err)
326
		web.RedirectWithError(w, r, target, "Failed to update record")
327
		return
328
	}
329
	web.RedirectWithSuccess(w, r, target, "Record updated")
330
}
331
332
func (a *App) deleteRecordHandler(w http.ResponseWriter, r *http.Request) {
333
	target := "/"
334
	if ref := r.FormValue("return_to"); ref != "" {
335
		target = ref
336
	}
337
	if err := deleteRecordByShortID(a.DB, r.PathValue("short_id")); err != nil {
338
		a.Log.Error("delete record", "err", err)
339
		web.RedirectWithError(w, r, target, "Failed to delete record")
340
		return
341
	}
342
	web.RedirectWithSuccess(w, r, target, "Record deleted")
343
}