Merge pull request #55 from stevedylandev/feat/add-habbits c801e227
Steve Simkins · 2026-08-10 17:44 28 file(s) · +1723 −9
.github/workflows/docker-test.yml +2 −2
19 19
      - name: Determine which apps to build
20 20
        id: filter
21 21
        run: |
22 -
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","quotes","shrink","sipp"]'
22 +
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","habbits","jotts","kepler","library","og","posts","quotes","shrink","sipp"]'
23 23
24 24
          changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
25 25
29 29
          fi
30 30
31 31
          apps=()
32 -
          for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts quotes shrink sipp; do
32 +
          for app in backup blobs bookmarks cellar easel feeds habbits jotts kepler library og posts quotes shrink sipp; do
33 33
            if echo "$changed" | grep -q "^apps/${app}/"; then
34 34
              apps+=("\"${app}\"")
35 35
            fi
.github/workflows/docker.yml +2 −2
25 25
      - name: Determine which apps to build
26 26
        id: filter
27 27
        run: |
28 -
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","quotes","shrink","sipp"]'
28 +
          ALL='["backup","blobs","bookmarks","cellar","easel","feeds","habbits","jotts","kepler","library","og","posts","quotes","shrink","sipp"]'
29 29
30 30
          # Tags: per-app (app/version) or bare (version)
31 31
          if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
52 52
          fi
53 53
54 54
          apps=()
55 -
          for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts quotes shrink sipp; do
55 +
          for app in backup blobs bookmarks cellar easel feeds habbits jotts kepler library og posts quotes shrink sipp; do
56 56
            if echo "$changed" | grep -q "^apps/${app}/"; then
57 57
              apps+=("\"${app}\"")
58 58
            fi
.gitignore +1 −0
32 32
apps/kepler/kepler
33 33
apps/quotes/quotes
34 34
apps/quotes/quotes.csv
35 +
apps/habbits/habbits
apps/backup/README.md +5 −2
1 1
# Backup
2 2
3 -
Automated SQLite backups for Jotts, Sipp, Cellar, Posts, Feeds, Library, Bookmarks, Parcels, and Easel to Cloudflare R2. Runs every 6 hours via cron inside a Docker container and prunes backups older than 30 days.
3 +
Automated SQLite backups for Jotts, Sipp, Cellar, Posts, Feeds, Library, Bookmarks, Parcels, Easel, and Habbits to Cloudflare R2. Runs every 6 hours via cron inside a Docker container and prunes backups older than 30 days.
4 4
5 5
## Setup
6 6
50 50
BOOKMARKS_VOLUME=bookmarks_bookmarks-data
51 51
PARCELS_VOLUME=parcels_parcels_data
52 52
EASEL_VOLUME=easel_easel-data
53 +
HABBITS_VOLUME=habbits_habbits-data
53 54
```
54 55
55 56
Run `docker volume ls` to check the actual names on your host.
93 94
  -v bookmarks_bookmarks-data:/data/bookmarks:ro \
94 95
  -v parcels_parcels_data:/data/parcels:ro \
95 96
  -v easel_easel-data:/data/easel:ro \
97 +
  -v habbits_habbits-data:/data/habbits:ro \
96 98
  ghcr.io/stevedylandev/andromeda-backup:latest
97 99
```
98 100
119 121
restore.sh <app|all> [--timestamp <ts>] [--list] [--yes]
120 122
```
121 123
122 -
- `<app>` — one of: `jotts sipp cellar posts feeds library bookmarks parcels easel`, or `all`.
124 +
- `<app>` — one of: `jotts sipp cellar posts feeds library bookmarks parcels easel habbits`, or `all`.
123 125
- `--timestamp <ts>` — restore a specific backup (e.g. `2026-04-04T060000Z`); the
124 126
  `.sqlite.gz` suffix is optional. Default: the latest backup. Not valid with `all`.
125 127
- `--list` — list available backups for the app(s) and exit (no restore).
178 180
| `BOOKMARKS_VOLUME` | `bookmarks_bookmarks-data` | Docker volume name for Bookmarks data |
179 181
| `PARCELS_VOLUME` | `parcels_parcels_data` | Docker volume name for Parcels data |
180 182
| `EASEL_VOLUME` | `easel_easel-data` | Docker volume name for Easel data |
183 +
| `HABBITS_VOLUME` | `habbits_habbits-data` | Docker volume name for Habbits data |
apps/backup/backup.sh +2 −2
5 5
BUCKET="${R2_BUCKET:-andromeda-backups}"
6 6
RETENTION_DAYS="${RETENTION_DAYS:-30}"
7 7
8 -
DBS="jotts:/data/jotts/jotts.sqlite sipp:/data/sipp/sipp.sqlite cellar:/data/cellar/cellar.sqlite posts:/data/posts/posts.sqlite feeds:/data/feeds/feeds.sqlite library:/data/library/library.sqlite bookmarks:/data/bookmarks/bookmarks.sqlite parcels:/data/parcels/parcels.db easel:/data/easel/easel.sqlite"
8 +
DBS="jotts:/data/jotts/jotts.sqlite sipp:/data/sipp/sipp.sqlite cellar:/data/cellar/cellar.sqlite posts:/data/posts/posts.sqlite feeds:/data/feeds/feeds.sqlite library:/data/library/library.sqlite bookmarks:/data/bookmarks/bookmarks.sqlite parcels:/data/parcels/parcels.db easel:/data/easel/easel.sqlite habbits:/data/habbits/habbits.sqlite"
9 9
10 10
for entry in $DBS; do
11 11
  name="${entry%%:*}"
28 28
29 29
# Prune old backups
30 30
cutoff=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%d 2>/dev/null || date -u -v-${RETENTION_DAYS}d +%Y-%m-%d)
31 -
for name in jotts sipp cellar posts feeds library bookmarks parcels easel; do
31 +
for name in jotts sipp cellar posts feeds library bookmarks parcels easel habbits; do
32 32
  aws s3 ls "s3://${BUCKET}/${name}/" --endpoint-url "${R2_ENDPOINT}" 2>/dev/null | while read -r line; do
33 33
    filedate=$(echo "$line" | awk '{print $1}')
34 34
    filename=$(echo "$line" | awk '{print $4}')
apps/backup/docker-compose.yml +4 −0
11 11
      - bookmarks-data:/data/bookmarks:ro
12 12
      - parcels-data:/data/parcels:ro
13 13
      - easel-data:/data/easel:ro
14 +
      - habbits-data:/data/habbits:ro
14 15
    env_file: .env
15 16
    restart: unless-stopped
16 17
42 43
  easel-data:
43 44
    external: true
44 45
    name: ${EASEL_VOLUME:-easel_easel-data}
46 +
  habbits-data:
47 +
    external: true
48 +
    name: ${HABBITS_VOLUME:-habbits_habbits-data}
apps/backup/restore.sh +2 −1
59 59
library:library.sqlite:library_data:library_library-data
60 60
bookmarks:bookmarks.sqlite:bookmarks_data:bookmarks_bookmarks-data
61 61
parcels:parcels.db:parcels_data:parcels_parcels_data
62 -
easel:easel.sqlite:easel_data:easel_easel-data"
62 +
easel:easel.sqlite:easel_data:easel_easel-data
63 +
habbits:habbits.sqlite:habbits_data:habbits_habbits-data"
63 64
64 65
RESTORE_IMAGE="debian:bookworm-slim"
65 66
apps/habbits/.env.example (added) +7 −0
1 +
HABBITS_PASSWORD=changeme
2 +
HABBITS_API_KEY=
3 +
HABBITS_DB_PATH=habbits.sqlite
4 +
HOST=0.0.0.0
5 +
PORT=3000
6 +
BASE_URL=http://localhost:3000
7 +
COOKIE_SECURE=false
apps/habbits/Dockerfile (added) +18 −0
1 +
# Build from repo root: docker build -t habbits -f apps/habbits/Dockerfile .
2 +
FROM golang:1.24-bookworm AS builder
3 +
WORKDIR /app
4 +
COPY pkg/ ./pkg/
5 +
COPY apps/habbits/go.mod apps/habbits/go.sum ./apps/habbits/
6 +
WORKDIR /app/apps/habbits
7 +
RUN go mod download
8 +
COPY apps/habbits/ ./
9 +
RUN CGO_ENABLED=0 go build -o /habbits .
10 +
11 +
FROM debian:bookworm-slim
12 +
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
13 +
COPY --from=builder /habbits /usr/local/bin/habbits
14 +
WORKDIR /data
15 +
ENV HOST=0.0.0.0
16 +
ENV PORT=3000
17 +
EXPOSE 3000
18 +
CMD ["habbits"]
apps/habbits/README.md (added) +66 −0
1 +
# habbits
2 +
3 +
A minimal, single-admin habit tracker. Define custom habits (each with a typed
4 +
value) and log records against them over time. The whole app is behind a
5 +
session login — there are no public pages.
6 +
7 +
## Concepts
8 +
9 +
- **Habit** — something you track. Has a `name`, a `value_type`
10 +
  (`int` | `float` | `bool` | `string`), and optional `unit` and `description`.
11 +
- **Record** — a single data point for a habit: a `value` (validated against the
12 +
  habit's type) and a `recorded_at` timestamp. The timestamp auto-fills to the
13 +
  current time on entry but can be edited/backdated in the UI.
14 +
15 +
## Routes
16 +
17 +
Web (all require a session except the login flow):
18 +
19 +
| Method | Path | Description |
20 +
| ------ | ---- | ----------- |
21 +
| GET  | `/login` / `/logout`            | Session login / logout |
22 +
| GET  | `/`                             | Dashboard: create habit, add record, recent activity |
23 +
| POST | `/habits`                       | Create a habit |
24 +
| GET  | `/habits/{short_id}`            | Habit detail: edit habit + its records |
25 +
| POST | `/habits/{short_id}`            | Update a habit |
26 +
| POST | `/habits/{short_id}/delete`     | Delete a habit (cascades its records) |
27 +
| POST | `/records`                      | Create a record |
28 +
| POST | `/records/{short_id}`           | Update a record |
29 +
| POST | `/records/{short_id}/delete`    | Delete a record |
30 +
31 +
Read-only JSON API (requires `X-API-Key` header; disabled when `HABBITS_API_KEY`
32 +
is empty):
33 +
34 +
| Method | Path | Description |
35 +
| ------ | ---- | ----------- |
36 +
| GET | `/api/habits`            | All habits |
37 +
| GET | `/api/records?limit=100` | Recent records (max 500) |
38 +
39 +
## Configuration
40 +
41 +
Copy the values below into `apps/habbits/.env` (create the file locally; it is
42 +
git-ignored):
43 +
44 +
```
45 +
HABBITS_PASSWORD=changeme        # admin password (plaintext or bcrypt hash); empty disables login
46 +
HABBITS_API_KEY=                 # optional; enables the read-only JSON API when set
47 +
HABBITS_DB_PATH=habbits.sqlite   # SQLite file path
48 +
HOST=0.0.0.0
49 +
PORT=3000
50 +
BASE_URL=http://localhost:3000
51 +
COOKIE_SECURE=false              # set true behind HTTPS
52 +
```
53 +
54 +
## Development
55 +
56 +
```
57 +
cd apps/habbits
58 +
go mod tidy
59 +
go run .          # serves on $HOST:$PORT
60 +
```
61 +
62 +
Or with Docker (built from repo root):
63 +
64 +
```
65 +
docker build -t habbits -f apps/habbits/Dockerfile .
66 +
```
apps/habbits/app.go (added) +103 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"embed"
6 +
	"html/template"
7 +
	"log/slog"
8 +
9 +
	"github.com/stevedylandev/andromeda/pkg/auth"
10 +
)
11 +
12 +
//go:embed templates/*.html static/*
13 +
var appFS embed.FS
14 +
15 +
type App struct {
16 +
	DB            *sql.DB
17 +
	Log           *slog.Logger
18 +
	Templates     *template.Template
19 +
	Sessions      *auth.Store
20 +
	AdminPassword string
21 +
	APIKey        string
22 +
	CookieSecure  bool
23 +
	BaseURL       string
24 +
}
25 +
26 +
// valueTypes is the fixed set of value types a habit may declare. Kept in sync
27 +
// with the CHECK constraint in habbitsSchema and the normalizeValue switch.
28 +
var valueTypes = []string{"int", "float", "bool", "string"}
29 +
30 +
// habitRow is the view model for a habit in list/detail templates.
31 +
type habitRow struct {
32 +
	ShortID     string
33 +
	Name        string
34 +
	ValueType   string
35 +
	Unit        string
36 +
	Description string
37 +
	RecordCount int
38 +
}
39 +
40 +
// recordRow is the view model for a record. Date is the grouping key
41 +
// (YYYY-MM-DD); TimeDisplay is the time-of-day shown within a day group;
42 +
// RecordedAtInput is the datetime-local value for edit forms.
43 +
type recordRow struct {
44 +
	ShortID         string
45 +
	HabitShortID    string
46 +
	HabitName       string
47 +
	ValueType       string
48 +
	Value           string
49 +
	Unit            string
50 +
	Date            string
51 +
	TimeDisplay     string
52 +
	RecordedAtInput string
53 +
}
54 +
55 +
// dashDay groups a day's records by habit for the dashboard (day -> habit -> records).
56 +
type dashDay struct {
57 +
	Date   string
58 +
	Habits []dashHabit
59 +
}
60 +
61 +
type dashHabit struct {
62 +
	HabitName    string
63 +
	HabitShortID string
64 +
	ValueType    string
65 +
	Unit         string
66 +
	Records      []recordRow
67 +
}
68 +
69 +
// habitDay groups a single habit's records by day for the habit detail page.
70 +
type habitDay struct {
71 +
	Date    string
72 +
	Records []recordRow
73 +
}
74 +
75 +
type loginPageData struct {
76 +
	Error string
77 +
}
78 +
79 +
type dashboardData struct {
80 +
	Success string
81 +
	Error   string
82 +
	Habits  []habitRow
83 +
	Days    []dashDay
84 +
}
85 +
86 +
type newHabitPageData struct {
87 +
	Error      string
88 +
	ValueTypes []string
89 +
}
90 +
91 +
type settingsPageData struct {
92 +
	Success    string
93 +
	Error      string
94 +
	ValueTypes []string
95 +
	Habits     []habitRow
96 +
}
97 +
98 +
type habitPageData struct {
99 +
	Success string
100 +
	Error   string
101 +
	Habit   habitRow
102 +
	Days    []habitDay
103 +
}
apps/habbits/db.go (added) +257 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"strings"
7 +
	"time"
8 +
9 +
	"github.com/stevedylandev/andromeda/pkg/auth"
10 +
)
11 +
12 +
const habbitsSchema = `
13 +
CREATE TABLE IF NOT EXISTS habits (
14 +
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
15 +
    short_id    TEXT NOT NULL UNIQUE,
16 +
    name        TEXT NOT NULL,
17 +
    value_type  TEXT NOT NULL CHECK(value_type IN ('int','float','bool','string')),
18 +
    unit        TEXT,
19 +
    description TEXT,
20 +
    created_at  INTEGER NOT NULL,
21 +
    updated_at  INTEGER NOT NULL
22 +
);
23 +
CREATE TABLE IF NOT EXISTS records (
24 +
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
25 +
    short_id    TEXT NOT NULL UNIQUE,
26 +
    habit_id    INTEGER NOT NULL REFERENCES habits(id) ON DELETE CASCADE,
27 +
    value       TEXT NOT NULL,
28 +
    recorded_at INTEGER NOT NULL,
29 +
    created_at  INTEGER NOT NULL,
30 +
    updated_at  INTEGER NOT NULL
31 +
);
32 +
CREATE INDEX IF NOT EXISTS idx_records_habit ON records(habit_id, recorded_at DESC);
33 +
`
34 +
35 +
type Habit struct {
36 +
	ID          int64   `json:"id"`
37 +
	ShortID     string  `json:"short_id"`
38 +
	Name        string  `json:"name"`
39 +
	ValueType   string  `json:"value_type"`
40 +
	Unit        *string `json:"unit,omitempty"`
41 +
	Description *string `json:"description,omitempty"`
42 +
	CreatedAt   int64   `json:"created_at"`
43 +
	UpdatedAt   int64   `json:"updated_at"`
44 +
}
45 +
46 +
type Record struct {
47 +
	ID         int64  `json:"id"`
48 +
	ShortID    string `json:"short_id"`
49 +
	HabitID    int64  `json:"habit_id"`
50 +
	Value      string `json:"value"`
51 +
	RecordedAt int64  `json:"recorded_at"`
52 +
	CreatedAt  int64  `json:"created_at"`
53 +
	UpdatedAt  int64  `json:"updated_at"`
54 +
	// Populated by joined queries; not stored on the records table.
55 +
	HabitShortID string `json:"habit_short_id,omitempty"`
56 +
	HabitName    string `json:"habit_name,omitempty"`
57 +
	ValueType    string `json:"value_type,omitempty"`
58 +
	Unit         string `json:"unit,omitempty"`
59 +
}
60 +
61 +
const habitCols = `id, short_id, name, value_type, unit, description, created_at, updated_at`
62 +
63 +
func scanHabit(s interface{ Scan(...any) error }) (*Habit, error) {
64 +
	var h Habit
65 +
	var unit, desc sql.NullString
66 +
	err := s.Scan(&h.ID, &h.ShortID, &h.Name, &h.ValueType, &unit, &desc, &h.CreatedAt, &h.UpdatedAt)
67 +
	if errors.Is(err, sql.ErrNoRows) {
68 +
		return nil, nil
69 +
	}
70 +
	if err != nil {
71 +
		return nil, err
72 +
	}
73 +
	if unit.Valid {
74 +
		v := unit.String
75 +
		h.Unit = &v
76 +
	}
77 +
	if desc.Valid {
78 +
		v := desc.String
79 +
		h.Description = &v
80 +
	}
81 +
	return &h, nil
82 +
}
83 +
84 +
// listHabits returns all habits ordered by name, each annotated with its record
85 +
// count via a LEFT JOIN so habits with zero records still appear.
86 +
func listHabits(db *sql.DB) ([]Habit, map[int64]int, error) {
87 +
	rows, err := db.Query(`
88 +
		SELECT ` + prefixCols("h", habitCols) + `, COUNT(r.id)
89 +
		FROM habits h
90 +
		LEFT JOIN records r ON r.habit_id = h.id
91 +
		GROUP BY h.id
92 +
		ORDER BY h.name COLLATE NOCASE ASC`)
93 +
	if err != nil {
94 +
		return nil, nil, err
95 +
	}
96 +
	defer rows.Close()
97 +
	var out []Habit
98 +
	counts := map[int64]int{}
99 +
	for rows.Next() {
100 +
		var h Habit
101 +
		var unit, desc sql.NullString
102 +
		var count int
103 +
		if err := rows.Scan(&h.ID, &h.ShortID, &h.Name, &h.ValueType, &unit, &desc, &h.CreatedAt, &h.UpdatedAt, &count); err != nil {
104 +
			return nil, nil, err
105 +
		}
106 +
		if unit.Valid {
107 +
			v := unit.String
108 +
			h.Unit = &v
109 +
		}
110 +
		if desc.Valid {
111 +
			v := desc.String
112 +
			h.Description = &v
113 +
		}
114 +
		out = append(out, h)
115 +
		counts[h.ID] = count
116 +
	}
117 +
	return out, counts, rows.Err()
118 +
}
119 +
120 +
func getHabitByShortID(db *sql.DB, shortID string) (*Habit, error) {
121 +
	return scanHabit(db.QueryRow(`SELECT `+habitCols+` FROM habits WHERE short_id = ?`, shortID))
122 +
}
123 +
124 +
func insertHabit(db *sql.DB, name, valueType, unit, description string) (int64, error) {
125 +
	shortID, err := auth.GenerateShortID(10)
126 +
	if err != nil {
127 +
		return 0, err
128 +
	}
129 +
	now := time.Now().UTC().Unix()
130 +
	res, err := db.Exec(
131 +
		`INSERT INTO habits (short_id, name, value_type, unit, description, created_at, updated_at)
132 +
		 VALUES (?, ?, ?, ?, ?, ?, ?)`,
133 +
		shortID, name, valueType, nullify(unit), nullify(description), now, now,
134 +
	)
135 +
	if err != nil {
136 +
		return 0, err
137 +
	}
138 +
	return res.LastInsertId()
139 +
}
140 +
141 +
func updateHabit(db *sql.DB, shortID, name, valueType, unit, description string) error {
142 +
	_, err := db.Exec(
143 +
		`UPDATE habits SET name = ?, value_type = ?, unit = ?, description = ?, updated_at = ?
144 +
		 WHERE short_id = ?`,
145 +
		name, valueType, nullify(unit), nullify(description), time.Now().UTC().Unix(), shortID,
146 +
	)
147 +
	return err
148 +
}
149 +
150 +
func deleteHabitByShortID(db *sql.DB, shortID string) error {
151 +
	_, err := db.Exec(`DELETE FROM habits WHERE short_id = ?`, shortID)
152 +
	return err
153 +
}
154 +
155 +
const recordJoin = `
156 +
	SELECT r.id, r.short_id, r.habit_id, r.value, r.recorded_at, r.created_at, r.updated_at,
157 +
	       h.short_id, h.name, h.value_type, IFNULL(h.unit,'')
158 +
	FROM records r JOIN habits h ON h.id = r.habit_id`
159 +
160 +
func scanRecordJoined(s interface{ Scan(...any) error }) (*Record, error) {
161 +
	var r Record
162 +
	err := s.Scan(&r.ID, &r.ShortID, &r.HabitID, &r.Value, &r.RecordedAt, &r.CreatedAt, &r.UpdatedAt,
163 +
		&r.HabitShortID, &r.HabitName, &r.ValueType, &r.Unit)
164 +
	if errors.Is(err, sql.ErrNoRows) {
165 +
		return nil, nil
166 +
	}
167 +
	if err != nil {
168 +
		return nil, err
169 +
	}
170 +
	return &r, nil
171 +
}
172 +
173 +
func queryRecords(db *sql.DB, where string, args ...any) ([]Record, error) {
174 +
	rows, err := db.Query(recordJoin+" "+where, args...)
175 +
	if err != nil {
176 +
		return nil, err
177 +
	}
178 +
	defer rows.Close()
179 +
	var out []Record
180 +
	for rows.Next() {
181 +
		rec, err := scanRecordJoined(rows)
182 +
		if err != nil {
183 +
			return nil, err
184 +
		}
185 +
		out = append(out, *rec)
186 +
	}
187 +
	return out, rows.Err()
188 +
}
189 +
190 +
func listRecords(db *sql.DB, limit int) ([]Record, error) {
191 +
	return queryRecords(db, `ORDER BY r.recorded_at DESC, r.id DESC LIMIT ?`, limit)
192 +
}
193 +
194 +
func listRecordsForHabit(db *sql.DB, habitID int64) ([]Record, error) {
195 +
	return queryRecords(db, `WHERE r.habit_id = ? ORDER BY r.recorded_at DESC, r.id DESC`, habitID)
196 +
}
197 +
198 +
func getRecordByShortID(db *sql.DB, shortID string) (*Record, error) {
199 +
	return scanRecordJoined(db.QueryRow(recordJoin+` WHERE r.short_id = ?`, shortID))
200 +
}
201 +
202 +
// recordsForExport returns records in chronological order for CSV export. When
203 +
// habitShortID is non-empty, only that habit's records are returned.
204 +
func recordsForExport(db *sql.DB, habitShortID string) ([]Record, error) {
205 +
	if habitShortID != "" {
206 +
		return queryRecords(db, `WHERE h.short_id = ? ORDER BY r.recorded_at ASC, r.id ASC`, habitShortID)
207 +
	}
208 +
	return queryRecords(db, `ORDER BY r.recorded_at ASC, r.id ASC`)
209 +
}
210 +
211 +
func insertRecord(db *sql.DB, habitID int64, value string, recordedAt int64) (int64, error) {
212 +
	shortID, err := auth.GenerateShortID(10)
213 +
	if err != nil {
214 +
		return 0, err
215 +
	}
216 +
	now := time.Now().UTC().Unix()
217 +
	res, err := db.Exec(
218 +
		`INSERT INTO records (short_id, habit_id, value, recorded_at, created_at, updated_at)
219 +
		 VALUES (?, ?, ?, ?, ?, ?)`,
220 +
		shortID, habitID, value, recordedAt, now, now,
221 +
	)
222 +
	if err != nil {
223 +
		return 0, err
224 +
	}
225 +
	return res.LastInsertId()
226 +
}
227 +
228 +
func updateRecord(db *sql.DB, shortID, value string, recordedAt int64) error {
229 +
	_, err := db.Exec(
230 +
		`UPDATE records SET value = ?, recorded_at = ?, updated_at = ? WHERE short_id = ?`,
231 +
		value, recordedAt, time.Now().UTC().Unix(), shortID,
232 +
	)
233 +
	return err
234 +
}
235 +
236 +
func deleteRecordByShortID(db *sql.DB, shortID string) error {
237 +
	_, err := db.Exec(`DELETE FROM records WHERE short_id = ?`, shortID)
238 +
	return err
239 +
}
240 +
241 +
// nullify returns nil for empty/whitespace strings so they store as SQL NULL.
242 +
func nullify(s string) any {
243 +
	if strings.TrimSpace(s) == "" {
244 +
		return nil
245 +
	}
246 +
	return s
247 +
}
248 +
249 +
// prefixCols rewrites a comma column list like "id, short_id" into
250 +
// "h.id, h.short_id" for use in JOIN selects.
251 +
func prefixCols(alias, cols string) string {
252 +
	parts := strings.Split(cols, ",")
253 +
	for i, p := range parts {
254 +
		parts[i] = " " + alias + "." + strings.TrimSpace(p)
255 +
	}
256 +
	return strings.Join(parts, ",")
257 +
}
apps/habbits/docker-compose.yml (added) +21 −0
1 +
services:
2 +
  app:
3 +
    build:
4 +
      context: ../..
5 +
      dockerfile: apps/habbits/Dockerfile
6 +
    ports:
7 +
      - "${PORT:-3000}:${PORT:-3000}"
8 +
    environment:
9 +
      - HOST=0.0.0.0
10 +
      - PORT=${PORT:-3000}
11 +
      - HABBITS_DB_PATH=/data/habbits.sqlite
12 +
      - HABBITS_PASSWORD=${HABBITS_PASSWORD:-changeme}
13 +
      - HABBITS_API_KEY=${HABBITS_API_KEY:-}
14 +
      - BASE_URL=${BASE_URL:-http://localhost:${PORT:-3000}}
15 +
      - COOKIE_SECURE=${COOKIE_SECURE:-false}
16 +
    volumes:
17 +
      - habbits-data:/data
18 +
    restart: unless-stopped
19 +
20 +
volumes:
21 +
  habbits-data:
apps/habbits/go.mod (added) +34 −0
1 +
module github.com/stevedylandev/andromeda/apps/habbits
2 +
3 +
go 1.24.4
4 +
5 +
require (
6 +
	github.com/stevedylandev/andromeda/pkg/auth v0.0.0
7 +
	github.com/stevedylandev/andromeda/pkg/config v0.0.0
8 +
	github.com/stevedylandev/andromeda/pkg/darkmatter v0.0.0
9 +
	github.com/stevedylandev/andromeda/pkg/sqlite v0.0.0
10 +
	github.com/stevedylandev/andromeda/pkg/web v0.0.0
11 +
)
12 +
13 +
require (
14 +
	github.com/dustin/go-humanize v1.0.1 // indirect
15 +
	github.com/google/uuid v1.6.0 // indirect
16 +
	github.com/mattn/go-isatty v0.0.20 // indirect
17 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
18 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
19 +
	golang.org/x/crypto v0.39.0 // indirect
20 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
21 +
	golang.org/x/sys v0.33.0 // indirect
22 +
	modernc.org/libc v1.65.7 // indirect
23 +
	modernc.org/mathutil v1.7.1 // indirect
24 +
	modernc.org/memory v1.11.0 // indirect
25 +
	modernc.org/sqlite v1.37.1 // indirect
26 +
)
27 +
28 +
replace (
29 +
	github.com/stevedylandev/andromeda/pkg/auth => ../../pkg/auth
30 +
	github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config
31 +
	github.com/stevedylandev/andromeda/pkg/darkmatter => ../../pkg/darkmatter
32 +
	github.com/stevedylandev/andromeda/pkg/sqlite => ../../pkg/sqlite
33 +
	github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web
34 +
)
apps/habbits/go.sum (added) +49 −0
1 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
4 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
5 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
8 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
9 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
10 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
11 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
12 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
13 +
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
14 +
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
15 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
16 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
17 +
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
18 +
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
19 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
20 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
21 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
22 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
23 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
24 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
25 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
26 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
27 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
28 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
29 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
30 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
31 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
32 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
33 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
34 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
35 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
36 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
37 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
38 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
39 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
40 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
41 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
42 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
43 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
44 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
45 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
46 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
47 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
48 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
49 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
apps/habbits/handlers_api.go (added) +40 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
	"strconv"
6 +
7 +
	"github.com/stevedylandev/andromeda/pkg/web"
8 +
)
9 +
10 +
func (a *App) apiListHabits(w http.ResponseWriter, r *http.Request) {
11 +
	habits, _, err := listHabits(a.DB)
12 +
	if err != nil {
13 +
		a.Log.Error("api list habits", "err", err)
14 +
		web.WriteError(w, http.StatusInternalServerError, "internal error")
15 +
		return
16 +
	}
17 +
	if habits == nil {
18 +
		habits = []Habit{}
19 +
	}
20 +
	web.WriteJSON(w, http.StatusOK, habits)
21 +
}
22 +
23 +
func (a *App) apiListRecords(w http.ResponseWriter, r *http.Request) {
24 +
	limit := 100
25 +
	if v := r.URL.Query().Get("limit"); v != "" {
26 +
		if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 {
27 +
			limit = n
28 +
		}
29 +
	}
30 +
	records, err := listRecords(a.DB, limit)
31 +
	if err != nil {
32 +
		a.Log.Error("api list records", "err", err)
33 +
		web.WriteError(w, http.StatusInternalServerError, "internal error")
34 +
		return
35 +
	}
36 +
	if records == nil {
37 +
		records = []Record{}
38 +
	}
39 +
	web.WriteJSON(w, http.StatusOK, records)
40 +
}
apps/habbits/handlers_export.go (added) +81 −0
1 +
package main
2 +
3 +
import (
4 +
	"encoding/csv"
5 +
	"net/http"
6 +
	"strings"
7 +
	"time"
8 +
9 +
	"github.com/stevedylandev/andromeda/pkg/web"
10 +
)
11 +
12 +
// exportHandler streams records as CSV. With ?habit=<short_id> it exports a
13 +
// single habit; otherwise every habit's records. Rows are chronological.
14 +
func (a *App) exportHandler(w http.ResponseWriter, r *http.Request) {
15 +
	habitShortID := strings.TrimSpace(r.URL.Query().Get("habit"))
16 +
17 +
	filename := "habbits-export.csv"
18 +
	if habitShortID != "" {
19 +
		habit, err := getHabitByShortID(a.DB, habitShortID)
20 +
		if err != nil {
21 +
			a.Log.Error("export get habit", "err", err)
22 +
			web.RedirectWithError(w, r, "/settings", "Failed to export")
23 +
			return
24 +
		}
25 +
		if habit == nil {
26 +
			http.NotFound(w, r)
27 +
			return
28 +
		}
29 +
		filename = "habbits-" + slugify(habit.Name) + ".csv"
30 +
	}
31 +
32 +
	records, err := recordsForExport(a.DB, habitShortID)
33 +
	if err != nil {
34 +
		a.Log.Error("export records", "err", err)
35 +
		web.RedirectWithError(w, r, "/settings", "Failed to export")
36 +
		return
37 +
	}
38 +
39 +
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
40 +
	w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
41 +
42 +
	cw := csv.NewWriter(w)
43 +
	_ = cw.Write([]string{"recorded_at", "habit", "value_type", "unit", "value"})
44 +
	for _, rec := range records {
45 +
		_ = cw.Write([]string{
46 +
			time.Unix(rec.RecordedAt, 0).Local().Format(time.RFC3339),
47 +
			rec.HabitName,
48 +
			rec.ValueType,
49 +
			rec.Unit,
50 +
			rec.Value,
51 +
		})
52 +
	}
53 +
	cw.Flush()
54 +
	if err := cw.Error(); err != nil {
55 +
		a.Log.Error("export csv write", "err", err)
56 +
	}
57 +
}
58 +
59 +
// slugify makes a filesystem-safe token from a habit name for the download
60 +
// filename, e.g. "Sleep hours" -> "sleep-hours".
61 +
func slugify(s string) string {
62 +
	var b strings.Builder
63 +
	prevDash := false
64 +
	for _, r := range strings.ToLower(strings.TrimSpace(s)) {
65 +
		switch {
66 +
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
67 +
			b.WriteRune(r)
68 +
			prevDash = false
69 +
		default:
70 +
			if !prevDash && b.Len() > 0 {
71 +
				b.WriteByte('-')
72 +
				prevDash = true
73 +
			}
74 +
		}
75 +
	}
76 +
	out := strings.Trim(b.String(), "-")
77 +
	if out == "" {
78 +
		return "habit"
79 +
	}
80 +
	return out
81 +
}
apps/habbits/handlers_web.go (added) +374 −0
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 +
// groupDashboard buckets records (ordered recorded_at DESC) into day groups,
45 +
// and within each day into per-habit groups, preserving encounter order.
46 +
func groupDashboard(recs []Record) []dashDay {
47 +
	var days []dashDay
48 +
	dayIdx := map[string]int{}
49 +
	habitIdx := map[string]map[string]int{}
50 +
	for _, r := range recs {
51 +
		row := recordToRow(r)
52 +
		di, ok := dayIdx[row.Date]
53 +
		if !ok {
54 +
			di = len(days)
55 +
			dayIdx[row.Date] = di
56 +
			habitIdx[row.Date] = map[string]int{}
57 +
			days = append(days, dashDay{Date: row.Date})
58 +
		}
59 +
		hi, ok := habitIdx[row.Date][r.HabitShortID]
60 +
		if !ok {
61 +
			hi = len(days[di].Habits)
62 +
			habitIdx[row.Date][r.HabitShortID] = hi
63 +
			days[di].Habits = append(days[di].Habits, dashHabit{
64 +
				HabitName:    r.HabitName,
65 +
				HabitShortID: r.HabitShortID,
66 +
				ValueType:    r.ValueType,
67 +
				Unit:         r.Unit,
68 +
			})
69 +
		}
70 +
		days[di].Habits[hi].Records = append(days[di].Habits[hi].Records, row)
71 +
	}
72 +
	return days
73 +
}
74 +
75 +
// groupByDay buckets a single habit's records (ordered recorded_at DESC) into
76 +
// day groups, preserving encounter order.
77 +
func groupByDay(recs []Record) []habitDay {
78 +
	var days []habitDay
79 +
	idx := map[string]int{}
80 +
	for _, r := range recs {
81 +
		row := recordToRow(r)
82 +
		i, ok := idx[row.Date]
83 +
		if !ok {
84 +
			i = len(days)
85 +
			idx[row.Date] = i
86 +
			days = append(days, habitDay{Date: row.Date})
87 +
		}
88 +
		days[i].Records = append(days[i].Records, row)
89 +
	}
90 +
	return days
91 +
}
92 +
93 +
// parseRecordedAt converts a datetime-local form value into unix seconds. An
94 +
// empty value defaults to now, matching the "auto-injected at time of entry"
95 +
// behaviour while still allowing a backdated entry from the UI.
96 +
func parseRecordedAt(raw string) (int64, error) {
97 +
	raw = strings.TrimSpace(raw)
98 +
	if raw == "" {
99 +
		return time.Now().Unix(), nil
100 +
	}
101 +
	t, err := time.ParseInLocation(inputLayout, raw, time.Local)
102 +
	if err != nil {
103 +
		return 0, err
104 +
	}
105 +
	return t.Unix(), nil
106 +
}
107 +
108 +
// --- auth ---
109 +
110 +
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
111 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
112 +
}
113 +
114 +
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
115 +
	if a.AdminPassword == "" {
116 +
		web.RedirectWithError(w, r, "/login", "No admin password configured")
117 +
		return
118 +
	}
119 +
	if err := r.ParseForm(); err != nil {
120 +
		web.RedirectWithError(w, r, "/login", "Bad request")
121 +
		return
122 +
	}
123 +
	if !auth.VerifyPassword(r.FormValue("password"), a.AdminPassword) {
124 +
		web.RedirectWithError(w, r, "/login", "Invalid password")
125 +
		return
126 +
	}
127 +
	token, err := a.Sessions.Create()
128 +
	if err != nil {
129 +
		a.Log.Error("create session failed", "err", err)
130 +
		web.RedirectWithError(w, r, "/login", "Session error")
131 +
		return
132 +
	}
133 +
	a.Sessions.PruneExpired()
134 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
135 +
	http.Redirect(w, r, "/", http.StatusSeeOther)
136 +
}
137 +
138 +
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
139 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
140 +
		a.Sessions.Delete(c.Value)
141 +
	}
142 +
	http.SetCookie(w, a.Sessions.ClearCookie())
143 +
	http.Redirect(w, r, "/login", http.StatusSeeOther)
144 +
}
145 +
146 +
// --- dashboard ---
147 +
148 +
func (a *App) dashboardHandler(w http.ResponseWriter, r *http.Request) {
149 +
	habits, counts, err := listHabits(a.DB)
150 +
	if err != nil {
151 +
		a.Log.Error("list habits", "err", err)
152 +
	}
153 +
	habitRows := make([]habitRow, 0, len(habits))
154 +
	for _, h := range habits {
155 +
		habitRows = append(habitRows, habitToRow(h, counts[h.ID]))
156 +
	}
157 +
158 +
	records, err := listRecords(a.DB, 200)
159 +
	if err != nil {
160 +
		a.Log.Error("list records", "err", err)
161 +
	}
162 +
163 +
	web.Render(a.Templates, w, "index.html", dashboardData{
164 +
		Success: r.URL.Query().Get("success"),
165 +
		Error:   r.URL.Query().Get("error"),
166 +
		Habits:  habitRows,
167 +
		Days:    groupDashboard(records),
168 +
	}, a.Log)
169 +
}
170 +
171 +
// --- habits ---
172 +
173 +
func (a *App) newHabitHandler(w http.ResponseWriter, r *http.Request) {
174 +
	web.Render(a.Templates, w, "new.html", newHabitPageData{
175 +
		Error:      r.URL.Query().Get("error"),
176 +
		ValueTypes: valueTypes,
177 +
	}, a.Log)
178 +
}
179 +
180 +
func (a *App) settingsHandler(w http.ResponseWriter, r *http.Request) {
181 +
	habits, counts, err := listHabits(a.DB)
182 +
	if err != nil {
183 +
		a.Log.Error("list habits", "err", err)
184 +
	}
185 +
	habitRows := make([]habitRow, 0, len(habits))
186 +
	for _, h := range habits {
187 +
		habitRows = append(habitRows, habitToRow(h, counts[h.ID]))
188 +
	}
189 +
	web.Render(a.Templates, w, "settings.html", settingsPageData{
190 +
		Success:    r.URL.Query().Get("success"),
191 +
		Error:      r.URL.Query().Get("error"),
192 +
		ValueTypes: valueTypes,
193 +
		Habits:     habitRows,
194 +
	}, a.Log)
195 +
}
196 +
197 +
func (a *App) createHabitHandler(w http.ResponseWriter, r *http.Request) {
198 +
	if err := r.ParseForm(); err != nil {
199 +
		web.RedirectWithError(w, r, "/new", "Bad request")
200 +
		return
201 +
	}
202 +
	name := strings.TrimSpace(r.FormValue("name"))
203 +
	valueType := strings.TrimSpace(r.FormValue("value_type"))
204 +
	unit := strings.TrimSpace(r.FormValue("unit"))
205 +
	description := strings.TrimSpace(r.FormValue("description"))
206 +
	if name == "" {
207 +
		web.RedirectWithError(w, r, "/new", "Habit name is required")
208 +
		return
209 +
	}
210 +
	if !validValueType(valueType) {
211 +
		web.RedirectWithError(w, r, "/new", "Invalid value type")
212 +
		return
213 +
	}
214 +
	if _, err := insertHabit(a.DB, name, valueType, unit, description); err != nil {
215 +
		a.Log.Error("insert habit", "err", err)
216 +
		web.RedirectWithError(w, r, "/new", "Failed to create habit")
217 +
		return
218 +
	}
219 +
	web.RedirectWithSuccess(w, r, "/", "Habit created")
220 +
}
221 +
222 +
func (a *App) habitDetailHandler(w http.ResponseWriter, r *http.Request) {
223 +
	habit, err := getHabitByShortID(a.DB, r.PathValue("short_id"))
224 +
	if err != nil {
225 +
		a.Log.Error("get habit", "err", err)
226 +
		web.RedirectWithError(w, r, "/", "Failed to load habit")
227 +
		return
228 +
	}
229 +
	if habit == nil {
230 +
		http.NotFound(w, r)
231 +
		return
232 +
	}
233 +
	records, err := listRecordsForHabit(a.DB, habit.ID)
234 +
	if err != nil {
235 +
		a.Log.Error("list habit records", "err", err)
236 +
	}
237 +
	web.Render(a.Templates, w, "habit.html", habitPageData{
238 +
		Success: r.URL.Query().Get("success"),
239 +
		Error:   r.URL.Query().Get("error"),
240 +
		Habit:   habitToRow(*habit, len(records)),
241 +
		Days:    groupByDay(records),
242 +
	}, a.Log)
243 +
}
244 +
245 +
func (a *App) updateHabitHandler(w http.ResponseWriter, r *http.Request) {
246 +
	shortID := r.PathValue("short_id")
247 +
	target := "/settings"
248 +
	if err := r.ParseForm(); err != nil {
249 +
		web.RedirectWithError(w, r, target, "Bad request")
250 +
		return
251 +
	}
252 +
	name := strings.TrimSpace(r.FormValue("name"))
253 +
	valueType := strings.TrimSpace(r.FormValue("value_type"))
254 +
	if name == "" {
255 +
		web.RedirectWithError(w, r, target, "Habit name is required")
256 +
		return
257 +
	}
258 +
	if !validValueType(valueType) {
259 +
		web.RedirectWithError(w, r, target, "Invalid value type")
260 +
		return
261 +
	}
262 +
	if err := updateHabit(a.DB, shortID, name, valueType,
263 +
		strings.TrimSpace(r.FormValue("unit")), strings.TrimSpace(r.FormValue("description"))); err != nil {
264 +
		a.Log.Error("update habit", "err", err)
265 +
		web.RedirectWithError(w, r, target, "Failed to update habit")
266 +
		return
267 +
	}
268 +
	web.RedirectWithSuccess(w, r, target, "Habit updated")
269 +
}
270 +
271 +
func (a *App) deleteHabitHandler(w http.ResponseWriter, r *http.Request) {
272 +
	target := "/settings"
273 +
	if ref := r.FormValue("return_to"); ref != "" {
274 +
		target = ref
275 +
	}
276 +
	if err := deleteHabitByShortID(a.DB, r.PathValue("short_id")); err != nil {
277 +
		a.Log.Error("delete habit", "err", err)
278 +
		web.RedirectWithError(w, r, target, "Failed to delete habit")
279 +
		return
280 +
	}
281 +
	web.RedirectWithSuccess(w, r, target, "Habit deleted")
282 +
}
283 +
284 +
// --- records ---
285 +
286 +
func (a *App) createRecordHandler(w http.ResponseWriter, r *http.Request) {
287 +
	if err := r.ParseForm(); err != nil {
288 +
		web.RedirectWithError(w, r, "/", "Bad request")
289 +
		return
290 +
	}
291 +
	// Where to return to: the referring page (dashboard or habit detail).
292 +
	target := "/"
293 +
	if ref := r.FormValue("return_to"); ref != "" {
294 +
		target = ref
295 +
	}
296 +
297 +
	habit, err := getHabitByShortID(a.DB, strings.TrimSpace(r.FormValue("habit")))
298 +
	if err != nil {
299 +
		a.Log.Error("get habit for record", "err", err)
300 +
		web.RedirectWithError(w, r, target, "Failed to load habit")
301 +
		return
302 +
	}
303 +
	if habit == nil {
304 +
		web.RedirectWithError(w, r, target, "Select a habit")
305 +
		return
306 +
	}
307 +
	value, err := normalizeValue(habit.ValueType, r.FormValue("value"))
308 +
	if err != nil {
309 +
		web.RedirectWithError(w, r, target, err.Error())
310 +
		return
311 +
	}
312 +
	recordedAt, err := parseRecordedAt(r.FormValue("recorded_at"))
313 +
	if err != nil {
314 +
		web.RedirectWithError(w, r, target, "Invalid date/time")
315 +
		return
316 +
	}
317 +
	if _, err := insertRecord(a.DB, habit.ID, value, recordedAt); err != nil {
318 +
		a.Log.Error("insert record", "err", err)
319 +
		web.RedirectWithError(w, r, target, "Failed to add record")
320 +
		return
321 +
	}
322 +
	web.RedirectWithSuccess(w, r, target, "Record added")
323 +
}
324 +
325 +
func (a *App) updateRecordHandler(w http.ResponseWriter, r *http.Request) {
326 +
	shortID := r.PathValue("short_id")
327 +
	if err := r.ParseForm(); err != nil {
328 +
		web.RedirectWithError(w, r, "/", "Bad request")
329 +
		return
330 +
	}
331 +
	rec, err := getRecordByShortID(a.DB, shortID)
332 +
	if err != nil {
333 +
		a.Log.Error("get record", "err", err)
334 +
		web.RedirectWithError(w, r, "/", "Failed to load record")
335 +
		return
336 +
	}
337 +
	if rec == nil {
338 +
		http.NotFound(w, r)
339 +
		return
340 +
	}
341 +
	target := "/habits/" + rec.HabitShortID
342 +
	if ref := r.FormValue("return_to"); ref != "" {
343 +
		target = ref
344 +
	}
345 +
	value, err := normalizeValue(rec.ValueType, r.FormValue("value"))
346 +
	if err != nil {
347 +
		web.RedirectWithError(w, r, target, err.Error())
348 +
		return
349 +
	}
350 +
	recordedAt, err := parseRecordedAt(r.FormValue("recorded_at"))
351 +
	if err != nil {
352 +
		web.RedirectWithError(w, r, target, "Invalid date/time")
353 +
		return
354 +
	}
355 +
	if err := updateRecord(a.DB, shortID, value, recordedAt); err != nil {
356 +
		a.Log.Error("update record", "err", err)
357 +
		web.RedirectWithError(w, r, target, "Failed to update record")
358 +
		return
359 +
	}
360 +
	web.RedirectWithSuccess(w, r, target, "Record updated")
361 +
}
362 +
363 +
func (a *App) deleteRecordHandler(w http.ResponseWriter, r *http.Request) {
364 +
	target := "/"
365 +
	if ref := r.FormValue("return_to"); ref != "" {
366 +
		target = ref
367 +
	}
368 +
	if err := deleteRecordByShortID(a.DB, r.PathValue("short_id")); err != nil {
369 +
		a.Log.Error("delete record", "err", err)
370 +
		web.RedirectWithError(w, r, target, "Failed to delete record")
371 +
		return
372 +
	}
373 +
	web.RedirectWithSuccess(w, r, target, "Record deleted")
374 +
}
apps/habbits/main.go (added) +55 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"log"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"os"
9 +
10 +
	"github.com/stevedylandev/andromeda/pkg/auth"
11 +
	"github.com/stevedylandev/andromeda/pkg/config"
12 +
	"github.com/stevedylandev/andromeda/pkg/sqlite"
13 +
)
14 +
15 +
func main() {
16 +
	config.LoadDotEnv(".env")
17 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
18 +
19 +
	dbPath := config.Getenv("HABBITS_DB_PATH", "habbits.sqlite")
20 +
21 +
	db, err := sqlite.Open(dbPath, habbitsSchema)
22 +
	if err != nil {
23 +
		log.Fatal(err)
24 +
	}
25 +
	defer db.Close()
26 +
27 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
28 +
	if err := sessions.EnsureSchema(); err != nil {
29 +
		log.Fatal(err)
30 +
	}
31 +
	sessions.PruneExpired()
32 +
33 +
	adminPassword := config.Getenv("HABBITS_PASSWORD", "")
34 +
	if adminPassword == "" {
35 +
		logger.Warn("HABBITS_PASSWORD not set; admin login is disabled")
36 +
	}
37 +
38 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
39 +
	app := &App{
40 +
		DB:            db,
41 +
		Log:           logger,
42 +
		Templates:     tmpl,
43 +
		Sessions:      sessions,
44 +
		AdminPassword: adminPassword,
45 +
		APIKey:        config.Getenv("HABBITS_API_KEY", ""),
46 +
		CookieSecure:  sessions.CookieSecure,
47 +
		BaseURL:       config.Getenv("BASE_URL", "http://localhost:3000"),
48 +
	}
49 +
50 +
	addr := config.Getenv("HOST", "0.0.0.0") + ":" + config.Getenv("PORT", "3000")
51 +
	logger.Info("habbits server running", "addr", addr)
52 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
53 +
		log.Fatal(err)
54 +
	}
55 +
}
apps/habbits/routes.go (added) +48 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/http"
5 +
6 +
	"github.com/stevedylandev/andromeda/pkg/auth"
7 +
	"github.com/stevedylandev/andromeda/pkg/darkmatter"
8 +
	"github.com/stevedylandev/andromeda/pkg/web"
9 +
)
10 +
11 +
func (a *App) routes() *http.ServeMux {
12 +
	mux := http.NewServeMux()
13 +
14 +
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
15 +
		return a.Sessions.RequireSession("/login", next)
16 +
	}
17 +
	requireAPIKey := func(next http.HandlerFunc) http.HandlerFunc {
18 +
		return auth.RequireAPIKey(a.APIKey, next)
19 +
	}
20 +
21 +
	// Public: only the login flow and static assets.
22 +
	mux.HandleFunc("GET /login", a.loginGetHandler)
23 +
	mux.HandleFunc("POST /login", a.loginPostHandler)
24 +
	mux.HandleFunc("GET /logout", a.logoutHandler)
25 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
26 +
	darkmatter.Mount(mux, "/assets")
27 +
28 +
	// Everything else requires a session (fully gated admin app).
29 +
	mux.HandleFunc("GET /", requireSession(a.dashboardHandler))
30 +
	mux.HandleFunc("GET /new", requireSession(a.newHabitHandler))
31 +
	mux.HandleFunc("GET /settings", requireSession(a.settingsHandler))
32 +
	mux.HandleFunc("GET /export.csv", requireSession(a.exportHandler))
33 +
34 +
	mux.HandleFunc("POST /habits", requireSession(a.createHabitHandler))
35 +
	mux.HandleFunc("GET /habits/{short_id}", requireSession(a.habitDetailHandler))
36 +
	mux.HandleFunc("POST /habits/{short_id}", requireSession(a.updateHabitHandler))
37 +
	mux.HandleFunc("POST /habits/{short_id}/delete", requireSession(a.deleteHabitHandler))
38 +
39 +
	mux.HandleFunc("POST /records", requireSession(a.createRecordHandler))
40 +
	mux.HandleFunc("POST /records/{short_id}", requireSession(a.updateRecordHandler))
41 +
	mux.HandleFunc("POST /records/{short_id}/delete", requireSession(a.deleteRecordHandler))
42 +
43 +
	// Read-only JSON API, gated by X-API-Key (disabled when key is empty).
44 +
	mux.HandleFunc("GET /api/habits", requireAPIKey(a.apiListHabits))
45 +
	mux.HandleFunc("GET /api/records", requireAPIKey(a.apiListRecords))
46 +
47 +
	return mux
48 +
}
apps/habbits/static/styles.css (added) +59 −0
1 +
/* App-local styles for habbits. The shared design system (palette, typography,
2 +
   forms, admin lists) comes from /assets/darkmatter.css. Only small layout
3 +
   extensions live here. */
4 +
5 +
.section {
6 +
  width: 100%;
7 +
  margin-bottom: 1.5rem;
8 +
}
9 +
10 +
.section h2 {
11 +
  font-size: 16px;
12 +
  margin: 0 0 0.5rem;
13 +
}
14 +
15 +
/* Inline record edit row: value + datetime + save on one line, aligned. */
16 +
.record-edit {
17 +
  align-items: flex-end;
18 +
  flex: 1;
19 +
  gap: 0.5rem;
20 +
}
21 +
22 +
.record-edit .form-field {
23 +
  margin: 0;
24 +
}
25 +
26 +
/* Each habit's edit form on the settings page, divided by a hairline. */
27 +
.settings-habit {
28 +
  padding: 1rem 0;
29 +
  border-bottom: 1px solid #333;
30 +
}
31 +
32 +
.settings-habit .inline-form {
33 +
  margin-top: 0.5rem;
34 +
}
35 +
36 +
/* Day / habit groupings in record lists. */
37 +
.day-group {
38 +
  margin-bottom: 1rem;
39 +
}
40 +
41 +
.day-heading {
42 +
  font-size: 12px;
43 +
  text-transform: uppercase;
44 +
  opacity: 0.5;
45 +
  margin: 0 0 0.25rem;
46 +
  border-bottom: 1px solid #333;
47 +
  padding-bottom: 0.25rem;
48 +
}
49 +
50 +
.habit-group {
51 +
  margin: 0.5rem 0 0.75rem;
52 +
}
53 +
54 +
.habit-group-name {
55 +
  display: block;
56 +
  font-size: 12px;
57 +
  opacity: 0.7;
58 +
  margin-bottom: 0.15rem;
59 +
}
apps/habbits/templates/habit.html (added) +126 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <title>Habbits | {{.Habit.Name}}</title>
10 +
  </head>
11 +
  <body>
12 +
    <div class="header">
13 +
      <a href="/" class="logo">HABBITS</a>
14 +
      <nav class="links">
15 +
        <a href="/">dashboard</a>
16 +
        <a href="/new">new habit</a>
17 +
        <a href="/settings">settings</a>
18 +
        <a href="/logout">logout</a>
19 +
      </nav>
20 +
    </div>
21 +
22 +
    {{if .Success}}<p class="success">{{.Success}}</p>{{end}}
23 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
24 +
25 +
    {{$habit := .Habit}}
26 +
27 +
    <section class="section">
28 +
      <div class="admin-toolbar">
29 +
        <h2>{{.Habit.Name}} <span class="tag">{{.Habit.ValueType}}</span>{{if .Habit.Unit}} <span class="tag">{{.Habit.Unit}}</span>{{end}}</h2>
30 +
        <a href="/settings">edit habit</a>
31 +
      </div>
32 +
      {{if .Habit.Description}}<p class="admin-list-meta">{{.Habit.Description}}</p>{{end}}
33 +
    </section>
34 +
35 +
    <section class="section">
36 +
      <h2>New record</h2>
37 +
      <form class="form" method="POST" action="/records">
38 +
        <input type="hidden" name="return_to" value="/habits/{{.Habit.ShortID}}" />
39 +
        <input type="hidden" name="habit" value="{{.Habit.ShortID}}" />
40 +
        <div class="form-row">
41 +
          <div class="form-field">
42 +
            <label for="value">Value{{if .Habit.Unit}} ({{.Habit.Unit}}){{end}}</label>
43 +
            {{if eq .Habit.ValueType "bool"}}
44 +
            <select id="value" name="value" required>
45 +
              <option value="true">true</option>
46 +
              <option value="false">false</option>
47 +
            </select>
48 +
            {{else if eq .Habit.ValueType "int"}}
49 +
            <input type="number" step="1" id="value" name="value" required />
50 +
            {{else if eq .Habit.ValueType "float"}}
51 +
            <input type="number" step="any" id="value" name="value" required />
52 +
            {{else}}
53 +
            <input type="text" id="value" name="value" required />
54 +
            {{end}}
55 +
          </div>
56 +
          <div class="form-field">
57 +
            <label for="recorded_at">When</label>
58 +
            <input type="datetime-local" id="recorded_at" name="recorded_at" class="now-default" />
59 +
          </div>
60 +
        </div>
61 +
        <div class="form-actions">
62 +
          <button type="submit">Add record</button>
63 +
        </div>
64 +
      </form>
65 +
    </section>
66 +
67 +
    <section class="section">
68 +
      <div class="admin-toolbar">
69 +
        <h2>Records ({{.Habit.RecordCount}})</h2>
70 +
      </div>
71 +
      {{if not .Days}}
72 +
      <p class="empty">No records yet. Add one above.</p>
73 +
      {{else}}
74 +
      {{range .Days}}
75 +
      <div class="day-group">
76 +
        <h3 class="day-heading">{{.Date}}</h3>
77 +
        <ul class="admin-list">
78 +
          {{range .Records}}
79 +
          <li class="admin-list-item">
80 +
            <form class="form-row record-edit" method="POST" action="/records/{{.ShortID}}">
81 +
              <input type="hidden" name="return_to" value="/habits/{{$habit.ShortID}}" />
82 +
              <div class="form-field">
83 +
                {{if eq $habit.ValueType "bool"}}
84 +
                <select name="value" required>
85 +
                  <option value="true"{{if eq .Value "true"}} selected{{end}}>true</option>
86 +
                  <option value="false"{{if eq .Value "false"}} selected{{end}}>false</option>
87 +
                </select>
88 +
                {{else if eq $habit.ValueType "int"}}
89 +
                <input type="number" step="1" name="value" value="{{.Value}}" required />
90 +
                {{else if eq $habit.ValueType "float"}}
91 +
                <input type="number" step="any" name="value" value="{{.Value}}" required />
92 +
                {{else}}
93 +
                <input type="text" name="value" value="{{.Value}}" required />
94 +
                {{end}}
95 +
              </div>
96 +
              <div class="form-field">
97 +
                <input type="datetime-local" name="recorded_at" value="{{.RecordedAtInput}}" required />
98 +
              </div>
99 +
              <button type="submit" class="link-button">save</button>
100 +
            </form>
101 +
            <div class="admin-list-actions">
102 +
              <form method="POST" action="/records/{{.ShortID}}/delete" class="inline-form">
103 +
                <input type="hidden" name="return_to" value="/habits/{{$habit.ShortID}}" />
104 +
                <button type="submit" class="link-button danger">delete</button>
105 +
              </form>
106 +
            </div>
107 +
          </li>
108 +
          {{end}}
109 +
        </ul>
110 +
      </div>
111 +
      {{end}}
112 +
      {{end}}
113 +
    </section>
114 +
115 +
    <script>
116 +
      (function () {
117 +
        var now = new Date();
118 +
        now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
119 +
        var stamp = now.toISOString().slice(0, 16);
120 +
        document.querySelectorAll("input.now-default").forEach(function (el) {
121 +
          if (!el.value) el.value = stamp;
122 +
        });
123 +
      })();
124 +
    </script>
125 +
  </body>
126 +
</html>
apps/habbits/templates/index.html (added) +145 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <title>Habbits</title>
10 +
  </head>
11 +
  <body>
12 +
    <div class="header">
13 +
      <a href="/" class="logo">HABBITS</a>
14 +
      <nav class="links">
15 +
        <a href="/new">new habit</a>
16 +
        <a href="/settings">settings</a>
17 +
        <a href="/logout">logout</a>
18 +
      </nav>
19 +
    </div>
20 +
21 +
    {{if .Success}}<p class="success">{{.Success}}</p>{{end}}
22 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
23 +
24 +
    {{if .Habits}}
25 +
    <section class="section">
26 +
      <h2>New record</h2>
27 +
      <form class="form" method="POST" action="/records">
28 +
        <input type="hidden" name="return_to" value="/" />
29 +
        <div class="form-row">
30 +
          <div class="form-field">
31 +
            <label for="habit">Habit</label>
32 +
            <select id="habit" name="habit" required>
33 +
              {{range .Habits}}<option value="{{.ShortID}}" data-type="{{.ValueType}}"{{if .Unit}} data-unit="{{.Unit}}"{{end}}>{{.Name}} ({{.ValueType}})</option>{{end}}
34 +
            </select>
35 +
          </div>
36 +
          <div class="form-field">
37 +
            <label for="value">Value</label>
38 +
            <div id="value-container"></div>
39 +
          </div>
40 +
          <div class="form-field">
41 +
            <label for="recorded_at">When</label>
42 +
            <input type="datetime-local" id="recorded_at" name="recorded_at" class="now-default" />
43 +
          </div>
44 +
        </div>
45 +
        <div class="form-actions">
46 +
          <button type="submit">Add record</button>
47 +
        </div>
48 +
      </form>
49 +
    </section>
50 +
    {{end}}
51 +
52 +
    <section class="section">
53 +
      <div class="admin-toolbar">
54 +
        <h2>Habits ({{len .Habits}})</h2>
55 +
      </div>
56 +
      {{if not .Habits}}
57 +
      <p class="empty">No habits yet. <a href="/new">Create one</a>.</p>
58 +
      {{else}}
59 +
      <ul class="admin-list">
60 +
        {{range .Habits}}
61 +
        <li class="admin-list-item">
62 +
          <div class="admin-list-info">
63 +
            <a class="admin-list-title" href="/habits/{{.ShortID}}">{{.Name}}</a>
64 +
            <span class="admin-list-meta">
65 +
              <span class="tag">{{.ValueType}}</span>
66 +
              {{if .Unit}}<span class="tag">{{.Unit}}</span>{{end}}
67 +
              <span class="admin-list-date">{{.RecordCount}} record{{if ne .RecordCount 1}}s{{end}}</span>
68 +
            </span>
69 +
          </div>
70 +
        </li>
71 +
        {{end}}
72 +
      </ul>
73 +
      {{end}}
74 +
    </section>
75 +
76 +
    <section class="section">
77 +
      <div class="admin-toolbar">
78 +
        <h2>Recent records</h2>
79 +
      </div>
80 +
      {{if not .Days}}
81 +
      <p class="empty">No records yet.</p>
82 +
      {{else}}
83 +
      {{range .Days}}
84 +
      <div class="day-group">
85 +
        <h3 class="day-heading">{{.Date}}</h3>
86 +
        {{range .Habits}}
87 +
        <div class="habit-group">
88 +
          <a class="habit-group-name" href="/habits/{{.HabitShortID}}">{{.HabitName}}</a>
89 +
          <ul class="admin-list">
90 +
            {{range .Records}}
91 +
            <li class="admin-list-item">
92 +
              <div class="admin-list-info">
93 +
                <span class="admin-list-title">{{.Value}}{{if .Unit}} {{.Unit}}{{end}}</span>
94 +
                <span class="admin-list-meta">
95 +
                  <span class="admin-list-date">{{.TimeDisplay}}</span>
96 +
                </span>
97 +
              </div>
98 +
            </li>
99 +
            {{end}}
100 +
          </ul>
101 +
        </div>
102 +
        {{end}}
103 +
      </div>
104 +
      {{end}}
105 +
      {{end}}
106 +
    </section>
107 +
108 +
    <script>
109 +
      // Swap the record value widget to match the selected habit's type. The
110 +
      // server re-validates, so this is only a convenience.
111 +
      (function () {
112 +
        var sel = document.getElementById("habit");
113 +
        var container = document.getElementById("value-container");
114 +
        if (!sel || !container) return;
115 +
        function render() {
116 +
          var opt = sel.options[sel.selectedIndex];
117 +
          var type = opt ? opt.getAttribute("data-type") : "string";
118 +
          var html;
119 +
          if (type === "bool") {
120 +
            html = '<select id="value" name="value" required><option value="true">true</option><option value="false">false</option></select>';
121 +
          } else if (type === "int") {
122 +
            html = '<input type="number" step="1" id="value" name="value" required />';
123 +
          } else if (type === "float") {
124 +
            html = '<input type="number" step="any" id="value" name="value" required />';
125 +
          } else {
126 +
            html = '<input type="text" id="value" name="value" required />';
127 +
          }
128 +
          container.innerHTML = html;
129 +
        }
130 +
        sel.addEventListener("change", render);
131 +
        render();
132 +
      })();
133 +
134 +
      // Default any empty datetime-local input to the current local time.
135 +
      (function () {
136 +
        var now = new Date();
137 +
        now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
138 +
        var stamp = now.toISOString().slice(0, 16);
139 +
        document.querySelectorAll("input.now-default").forEach(function (el) {
140 +
          if (!el.value) el.value = stamp;
141 +
        });
142 +
      })();
143 +
    </script>
144 +
  </body>
145 +
</html>
apps/habbits/templates/login.html (added) +26 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <title>Habbits | Login</title>
10 +
  </head>
11 +
  <body>
12 +
    <div class="header">
13 +
      <a href="/" class="logo">HABBITS</a>
14 +
    </div>
15 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
16 +
    <form class="form" method="POST" action="/login">
17 +
      <div class="form-field">
18 +
        <label for="password">Password</label>
19 +
        <input type="password" id="password" name="password" required autofocus />
20 +
      </div>
21 +
      <div class="form-actions">
22 +
        <button type="submit">Login</button>
23 +
      </div>
24 +
    </form>
25 +
  </body>
26 +
</html>
apps/habbits/templates/new.html (added) +52 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <title>Habbits | New habit</title>
10 +
  </head>
11 +
  <body>
12 +
    <div class="header">
13 +
      <a href="/" class="logo">HABBITS</a>
14 +
      <nav class="links">
15 +
        <a href="/">dashboard</a>
16 +
        <a href="/settings">settings</a>
17 +
        <a href="/logout">logout</a>
18 +
      </nav>
19 +
    </div>
20 +
21 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
22 +
23 +
    <section class="section">
24 +
      <h2>New habit</h2>
25 +
      <form class="form" method="POST" action="/habits">
26 +
        <div class="form-row">
27 +
          <div class="form-field">
28 +
            <label for="name">Name</label>
29 +
            <input type="text" id="name" name="name" placeholder="e.g. Sleep" required autofocus />
30 +
          </div>
31 +
          <div class="form-field">
32 +
            <label for="value_type">Value type</label>
33 +
            <select id="value_type" name="value_type" required>
34 +
              {{range .ValueTypes}}<option value="{{.}}">{{.}}</option>{{end}}
35 +
            </select>
36 +
          </div>
37 +
          <div class="form-field">
38 +
            <label for="unit">Unit</label>
39 +
            <input type="text" id="unit" name="unit" placeholder="hrs, kg… (optional)" />
40 +
          </div>
41 +
        </div>
42 +
        <div class="form-field">
43 +
          <label for="description">Description</label>
44 +
          <input type="text" id="description" name="description" placeholder="optional" />
45 +
        </div>
46 +
        <div class="form-actions">
47 +
          <button type="submit">Create habit</button>
48 +
        </div>
49 +
      </form>
50 +
    </section>
51 +
  </body>
52 +
</html>
apps/habbits/templates/settings.html (added) +77 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 +
    <meta name="theme-color" content="#121113" />
7 +
    <link rel="stylesheet" href="/assets/darkmatter.css" />
8 +
    <link rel="stylesheet" href="/static/styles.css" />
9 +
    <title>Habbits | Settings</title>
10 +
  </head>
11 +
  <body>
12 +
    <div class="header">
13 +
      <a href="/" class="logo">HABBITS</a>
14 +
      <nav class="links">
15 +
        <a href="/">dashboard</a>
16 +
        <a href="/new">new habit</a>
17 +
        <a href="/logout">logout</a>
18 +
      </nav>
19 +
    </div>
20 +
21 +
    {{if .Success}}<p class="success">{{.Success}}</p>{{end}}
22 +
    {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
23 +
24 +
    {{$types := .ValueTypes}}
25 +
26 +
    <section class="section">
27 +
      <div class="admin-toolbar">
28 +
        <h2>Habits ({{len .Habits}})</h2>
29 +
        <div class="admin-list-actions">
30 +
          {{if .Habits}}<a href="/export.csv">export all (csv)</a>{{end}}
31 +
          <a href="/new">new habit</a>
32 +
        </div>
33 +
      </div>
34 +
35 +
      {{if not .Habits}}
36 +
      <p class="empty">No habits yet. <a href="/new">Create one</a>.</p>
37 +
      {{else}}
38 +
      {{range .Habits}}
39 +
      <div class="settings-habit">
40 +
        <form class="form" method="POST" action="/habits/{{.ShortID}}">
41 +
          <div class="form-row">
42 +
            <div class="form-field">
43 +
              <label>Name</label>
44 +
              <input type="text" name="name" value="{{.Name}}" required />
45 +
            </div>
46 +
            <div class="form-field">
47 +
              <label>Value type</label>
48 +
              <select name="value_type" required>
49 +
                {{$vt := .ValueType}}
50 +
                {{range $types}}<option value="{{.}}"{{if eq . $vt}} selected{{end}}>{{.}}</option>{{end}}
51 +
              </select>
52 +
            </div>
53 +
            <div class="form-field">
54 +
              <label>Unit</label>
55 +
              <input type="text" name="unit" value="{{.Unit}}" placeholder="optional" />
56 +
            </div>
57 +
          </div>
58 +
          <div class="form-field">
59 +
            <label>Description</label>
60 +
            <input type="text" name="description" value="{{.Description}}" placeholder="optional" />
61 +
          </div>
62 +
          <div class="form-actions">
63 +
            <button type="submit">Save</button>
64 +
            <a href="/export.csv?habit={{.ShortID}}">export (csv)</a>
65 +
            <span class="admin-list-date">{{.RecordCount}} record{{if ne .RecordCount 1}}s{{end}}</span>
66 +
          </div>
67 +
        </form>
68 +
        <form method="POST" action="/habits/{{.ShortID}}/delete" class="inline-form" onsubmit="return confirm('Delete this habit and all its records?')">
69 +
          <input type="hidden" name="return_to" value="/settings" />
70 +
          <button type="submit" class="link-button danger">delete habit</button>
71 +
        </form>
72 +
      </div>
73 +
      {{end}}
74 +
      {{end}}
75 +
    </section>
76 +
  </body>
77 +
</html>
apps/habbits/validate.go (added) +54 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"strconv"
6 +
	"strings"
7 +
)
8 +
9 +
// normalizeValue validates raw against the habit's declared value type and
10 +
// returns the canonical stored form. The server is the source of truth; the
11 +
// UI widgets are only a convenience.
12 +
func normalizeValue(valueType, raw string) (string, error) {
13 +
	raw = strings.TrimSpace(raw)
14 +
	switch valueType {
15 +
	case "int":
16 +
		n, err := strconv.ParseInt(raw, 10, 64)
17 +
		if err != nil {
18 +
			return "", fmt.Errorf("value must be a whole number")
19 +
		}
20 +
		return strconv.FormatInt(n, 10), nil
21 +
	case "float":
22 +
		f, err := strconv.ParseFloat(raw, 64)
23 +
		if err != nil {
24 +
			return "", fmt.Errorf("value must be a number")
25 +
		}
26 +
		return strconv.FormatFloat(f, 'f', -1, 64), nil
27 +
	case "bool":
28 +
		switch strings.ToLower(raw) {
29 +
		case "true", "1", "on", "yes", "y":
30 +
			return "true", nil
31 +
		case "false", "0", "off", "no", "n":
32 +
			return "false", nil
33 +
		default:
34 +
			return "", fmt.Errorf("value must be true or false")
35 +
		}
36 +
	case "string":
37 +
		if raw == "" {
38 +
			return "", fmt.Errorf("value is required")
39 +
		}
40 +
		return raw, nil
41 +
	default:
42 +
		return "", fmt.Errorf("unknown value type %q", valueType)
43 +
	}
44 +
}
45 +
46 +
// validValueType reports whether t is one of the allowed habit value types.
47 +
func validValueType(t string) bool {
48 +
	for _, v := range valueTypes {
49 +
		if v == t {
50 +
			return true
51 +
		}
52 +
	}
53 +
	return false
54 +
}
docker-compose.yml +13 −0
114 114
      - quotes_data:/data
115 115
    env_file: apps/quotes/.env
116 116
117 +
  habbits:
118 +
    image: ghcr.io/stevedylandev/andromeda/habbits:latest
119 +
    restart: unless-stopped
120 +
    ports:
121 +
      - "4041:3000"
122 +
    volumes:
123 +
      - habbits_data:/data
124 +
    env_file: apps/habbits/.env
125 +
117 126
  backup:
118 127
    image: ghcr.io/stevedylandev/andromeda/backup:latest
119 128
    volumes:
127 136
      - easel_data:/data/easel:ro
128 137
      - blobs_data:/data/blobs:ro
129 138
      - quotes_data:/data/quotes:ro
139 +
      - habbits_data:/data/habbits:ro
130 140
    env_file: apps/backup/.env
131 141
    restart: unless-stopped
132 142
164 174
  quotes_data:
165 175
    external: true
166 176
    name: quotes_quotes-data
177 +
  habbits_data:
178 +
    external: true
179 +
    name: habbits_habbits-data