| 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 | } |