Merge pull request #55 from stevedylandev/feat/add-habbits
c801e227
28 file(s) · +1723 −9
| 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 |
|
| 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 |
|
| 32 | 32 | apps/kepler/kepler |
|
| 33 | 33 | apps/quotes/quotes |
|
| 34 | 34 | apps/quotes/quotes.csv |
|
| 35 | + | apps/habbits/habbits |
| 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 | |
|
| 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}') |
|
| 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} |
|
| 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 |
| 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 |
| 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"] |
| 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 | + | ``` |
| 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 | + | } |
| 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 | + | } |
| 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: |
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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> |
| 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> |
| 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> |
| 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> |
| 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> |
| 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 | + | } |
| 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 |
|