Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

initial commit

ptdewey 6b90e310

+1865
+44
.air.toml
··· 1 + root = "." 2 + testdata_dir = "testdata" 3 + tmp_dir = "tmp" 4 + 5 + [build] 6 + args_bin = [] 7 + bin = "./tmp/main" 8 + cmd = "make build" 9 + delay = 1000 10 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 + exclude_file = [] 12 + exclude_regex = ["_test.go", "_templ.go"] 13 + exclude_unchanged = false 14 + follow_symlink = false 15 + full_bin = "" 16 + include_dir = [] 17 + include_ext = ["go", "tpl", "tmpl", "templ", "html"] 18 + include_file = [] 19 + kill_delay = "0s" 20 + log = "build-errors.log" 21 + poll = false 22 + poll_interval = 0 23 + rerun = false 24 + rerun_delay = 500 25 + send_interrupt = false 26 + stop_on_error = false 27 + 28 + [color] 29 + app = "" 30 + build = "yellow" 31 + main = "magenta" 32 + runner = "green" 33 + watcher = "cyan" 34 + 35 + [log] 36 + main_only = false 37 + time = false 38 + 39 + [misc] 40 + clean_on_exit = false 41 + 42 + [screen] 43 + clear_on_rebuild = false 44 + keep_scroll = true
+45
.gitignore
··· 1 + # Binaries 2 + bin/ 3 + tmp/ 4 + *.exe 5 + *.exe~ 6 + *.dll 7 + *.so 8 + *.dylib 9 + 10 + # Test binary 11 + *.test 12 + 13 + # Output of the go coverage tool 14 + *.out 15 + 16 + # Database 17 + *.db 18 + *.db-shm 19 + *.db-wal 20 + 21 + # Generated files 22 + *_templ.go 23 + web/static/css/output.css 24 + 25 + # IDE 26 + .vscode/ 27 + .idea/ 28 + *.swp 29 + *.swo 30 + *~ 31 + 32 + # OS 33 + .DS_Store 34 + Thumbs.db 35 + 36 + # Dependencies 37 + vendor/ 38 + 39 + # Logs 40 + *.log 41 + build-errors.log 42 + 43 + # Nix 44 + result 45 + result-*
+187
README.md
··· 1 + # Arabica - Coffee Brew Tracker 2 + 3 + A self-hosted web application for tracking your coffee brewing journey. Built with Go, Templ, and SQLite. 4 + 5 + ## Features 6 + 7 + - 📝 Quick entry of brew data (temperature, time, method, flexible grind size entry, etc.) 8 + - ☕ Organize beans by origin and roaster with quick-select dropdowns 9 + - 📱 Mobile-first PWA design for on-the-go tracking 10 + - 📊 Rating system and tasting notes 11 + - 📥 Export your data as JSON 12 + - 🔄 CRUD operations for all brew entries 13 + - 🗄️ SQLite database with abstraction layer for easy migration 14 + 15 + ## Tech Stack 16 + 17 + - **Backend**: Go 1.22+ (using stdlib router) 18 + - **Database**: SQLite (via modernc.org/sqlite - pure Go, no CGO) 19 + - **Templates**: Templ (type-safe HTML templates) 20 + - **Frontend**: HTMX + Alpine.js 21 + - **CSS**: Tailwind CSS 22 + - **PWA**: Service Worker for offline support 23 + 24 + ## Project Structure 25 + 26 + ``` 27 + arabica/ 28 + ├── cmd/server/ # Application entry point 29 + ├── internal/ 30 + │ ├── database/ # Database interface & SQLite implementation 31 + │ ├── models/ # Data models 32 + │ ├── handlers/ # HTTP handlers 33 + │ └── templates/ # Templ templates 34 + ├── web/static/ # Static assets (CSS, JS, PWA files) 35 + ├── migrations/ # Database migrations 36 + └── Makefile # Build commands 37 + ``` 38 + 39 + ## Getting Started 40 + 41 + ### Prerequisites 42 + 43 + - Go 1.22+ 44 + - Templ CLI 45 + - Tailwind CSS CLI 46 + - (Optional) Air for hot reload 47 + 48 + Or use Nix: 49 + 50 + ```bash 51 + nix develop 52 + ``` 53 + 54 + ### Installation 55 + 56 + 1. Clone the repository: 57 + ```bash 58 + cd arabica-site 59 + ``` 60 + 61 + 2. Install dependencies: 62 + ```bash 63 + make install-deps 64 + ``` 65 + 66 + 3. Build the application: 67 + ```bash 68 + make build 69 + ``` 70 + 71 + 4. Run the server: 72 + ```bash 73 + make run 74 + ``` 75 + 76 + The application will be available at `http://localhost:8080` 77 + 78 + ### Development 79 + 80 + For hot reload during development: 81 + 82 + ```bash 83 + make dev 84 + ``` 85 + 86 + This uses Air to automatically rebuild when you change Go files or templates. 87 + 88 + ### Building Assets 89 + 90 + ```bash 91 + # Generate templ files 92 + make templ 93 + 94 + # Build Tailwind CSS 95 + make css 96 + 97 + # Or build everything 98 + make build 99 + ``` 100 + 101 + ## Usage 102 + 103 + ### Adding a Brew 104 + 105 + 1. Navigate to "New Brew" from the home page 106 + 2. Select a bean (or add a new one with the "+ New" button) 107 + - When adding a new bean, provide a **Name** (required) like "Morning Blend" or "House Espresso" 108 + - Optionally add Origin, Roast Level, and Description 109 + 3. Select a roaster (or add a new one) 110 + 4. Fill in brewing details: 111 + - Method (Pour Over, French Press, etc.) 112 + - Temperature (°C) 113 + - Brew time (seconds) 114 + - Grind size (free text - enter numbers like "18" or "3.5" for grinder settings, or descriptions like "Medium" or "Fine") 115 + - Grinder (optional) 116 + - Tasting notes 117 + - Rating (1-10) 118 + 5. Click "Save Brew" 119 + 120 + ### Viewing Brews 121 + 122 + Navigate to the "Brews" page to see all your entries in a table format with: 123 + - Date 124 + - Bean details 125 + - Roaster 126 + - Method and parameters 127 + - Rating 128 + - Actions (View, Delete) 129 + 130 + ### Exporting Data 131 + 132 + Click "Export JSON" on the brews page to download all your data as JSON. 133 + 134 + ## Configuration 135 + 136 + Environment variables: 137 + 138 + - `DB_PATH`: Path to SQLite database (default: `./arabica.db`) 139 + - `PORT`: Server port (default: `8080`) 140 + 141 + ## Database Abstraction 142 + 143 + The application uses an interface-based approach for database operations, making it easy to swap SQLite for PostgreSQL or another database later. See `internal/database/store.go` for the interface definition. 144 + 145 + ## PWA Support 146 + 147 + The application includes: 148 + - Web App Manifest for "Add to Home Screen" 149 + - Service Worker for offline caching 150 + - Mobile-optimized UI with large touch targets 151 + 152 + ## Future Enhancements (Not in MVP) 153 + 154 + - Statistics and analytics page 155 + - CSV export 156 + - Multi-user support (database already has user_id column) 157 + - Search and filtering 158 + - Photo uploads for beans/brews 159 + - Brew recipes and sharing 160 + 161 + ## Development Notes 162 + 163 + ### Why These Choices? 164 + 165 + - **Go**: Fast compilation, single binary deployment, excellent stdlib 166 + - **modernc.org/sqlite**: Pure Go SQLite (no CGO), easy cross-compilation 167 + - **Templ**: Type-safe templates, better than text/template for HTML 168 + - **HTMX**: Progressive enhancement without heavy JS framework 169 + - **Nix**: Reproducible builds across environments 170 + 171 + ### Database Schema 172 + 173 + See `migrations/001_initial.sql` for the complete schema. 174 + 175 + Key tables: 176 + - `users`: Future multi-user support 177 + - `beans`: Coffee bean information 178 + - `roasters`: Roaster information 179 + - `brews`: Individual brew records with all parameters 180 + 181 + ## License 182 + 183 + MIT 184 + 185 + ## Contributing 186 + 187 + This is a personal project, but suggestions and improvements are welcome!
+22
build.sh
··· 1 + #!/usr/bin/env bash 2 + set -e 3 + 4 + echo "🔧 Building Arabica..." 5 + 6 + # Generate templ files 7 + echo "📝 Generating templates..." 8 + templ generate 9 + 10 + # Build CSS 11 + echo "🎨 Building CSS..." 12 + tailwindcss -i web/static/css/style.css -o web/static/css/output.css --minify 13 + 14 + # Build Go binary 15 + echo "🚀 Building Go application..." 16 + mkdir -p bin 17 + go build -o bin/arabica cmd/server/main.go 18 + 19 + echo "✅ Build complete!" 20 + echo "" 21 + echo "Run './bin/arabica' to start the server" 22 + echo "Or run 'make dev' for hot reload development mode"
+58
cmd/server/main.go
··· 1 + package main 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "os" 7 + 8 + "arabica/internal/database/sqlite" 9 + "arabica/internal/handlers" 10 + ) 11 + 12 + func main() { 13 + // Get database path from env or use default 14 + dbPath := os.Getenv("DB_PATH") 15 + if dbPath == "" { 16 + dbPath = "./arabica.db" 17 + } 18 + 19 + // Initialize database 20 + store, err := sqlite.NewSQLiteStore(dbPath) 21 + if err != nil { 22 + log.Fatalf("Failed to initialize database: %v", err) 23 + } 24 + defer store.Close() 25 + 26 + // Initialize handlers 27 + h := handlers.NewHandler(store) 28 + 29 + // Create router 30 + mux := http.NewServeMux() 31 + 32 + // Page routes (must come before static files) 33 + mux.HandleFunc("GET /{$}", h.HandleHome) // {$} means exact match 34 + mux.HandleFunc("GET /brews", h.HandleBrewList) 35 + mux.HandleFunc("GET /brews/new", h.HandleBrewNew) 36 + mux.HandleFunc("POST /brews", h.HandleBrewCreate) 37 + mux.HandleFunc("DELETE /brews/{id}", h.HandleBrewDelete) 38 + mux.HandleFunc("GET /brews/export", h.HandleBrewExport) 39 + 40 + // API routes for adding beans/roasters via AJAX 41 + mux.HandleFunc("POST /api/beans", h.HandleBeanCreate) 42 + mux.HandleFunc("POST /api/roasters", h.HandleRoasterCreate) 43 + 44 + // Static files (must come after specific routes) 45 + fs := http.FileServer(http.Dir("web/static")) 46 + mux.Handle("GET /static/", http.StripPrefix("/static/", fs)) 47 + 48 + // Get port from env or use default 49 + port := os.Getenv("PORT") 50 + if port == "" { 51 + port = "8080" 52 + } 53 + 54 + log.Printf("Starting Arabica server on http://localhost:%s", port) 55 + if err := http.ListenAndServe(":"+port, mux); err != nil { 56 + log.Fatalf("Server failed to start: %v", err) 57 + } 58 + }
+26
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1767026758, 6 + "narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "346dd96ad74dc4457a9db9de4f4f57dab2e5731d", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "id": "nixpkgs", 14 + "ref": "nixpkgs-unstable", 15 + "type": "indirect" 16 + } 17 + }, 18 + "root": { 19 + "inputs": { 20 + "nixpkgs": "nixpkgs" 21 + } 22 + } 23 + }, 24 + "root": "root", 25 + "version": 7 26 + }
+21
flake.nix
··· 1 + { 2 + description = "Dev Shells Flake"; 3 + inputs = { nixpkgs.url = "nixpkgs/nixpkgs-unstable"; }; 4 + outputs = { nixpkgs, ... }: 5 + let 6 + forAllSystems = function: 7 + nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ] 8 + (system: function nixpkgs.legacyPackages.${system}); 9 + in { 10 + devShells = forAllSystems (pkgs: { 11 + default = pkgs.mkShell { 12 + packages = with pkgs; [ 13 + go 14 + templ 15 + tailwindcss 16 + air # Live reload for Go 17 + ]; 18 + }; 19 + }); 20 + }; 21 + }
+21
go.mod
··· 1 + module arabica 2 + 3 + go 1.25.4 4 + 5 + require ( 6 + github.com/a-h/templ v0.3.960 7 + modernc.org/sqlite v1.42.2 8 + ) 9 + 10 + require ( 11 + github.com/dustin/go-humanize v1.0.1 // indirect 12 + github.com/google/uuid v1.6.0 // indirect 13 + github.com/mattn/go-isatty v0.0.20 // indirect 14 + github.com/ncruces/go-strftime v0.1.9 // indirect 15 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 16 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 17 + golang.org/x/sys v0.36.0 // indirect 18 + modernc.org/libc v1.66.10 // indirect 19 + modernc.org/mathutil v1.7.1 // indirect 20 + modernc.org/memory v1.11.0 // indirect 21 + )
+53
go.sum
··· 1 + github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM= 2 + github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= 3 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 4 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 5 + github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 6 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 7 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 8 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 9 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 10 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 11 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 12 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 13 + github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 14 + github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 15 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 16 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 17 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 18 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 19 + golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= 20 + golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 21 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 22 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 23 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 24 + golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 25 + golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 26 + golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= 27 + golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 28 + modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= 29 + modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 30 + modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= 31 + modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= 32 + modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= 33 + modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 34 + modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 35 + modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 36 + modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 37 + modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 38 + modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= 39 + modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= 40 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 41 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 42 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 43 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 44 + modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 45 + modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 46 + modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 47 + modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 48 + modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74= 49 + modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= 50 + modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 51 + modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 52 + modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 53 + modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+416
internal/database/sqlite/sqlite.go
··· 1 + package sqlite 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "os" 7 + 8 + "arabica/internal/models" 9 + 10 + _ "modernc.org/sqlite" 11 + ) 12 + 13 + type SQLiteStore struct { 14 + db *sql.DB 15 + } 16 + 17 + // NewSQLiteStore creates a new SQLite store and runs migrations 18 + func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { 19 + db, err := sql.Open("sqlite", dbPath) 20 + if err != nil { 21 + return nil, fmt.Errorf("failed to open database: %w", err) 22 + } 23 + 24 + // Enable foreign keys 25 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 26 + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) 27 + } 28 + 29 + store := &SQLiteStore{db: db} 30 + 31 + // Run migrations 32 + if err := store.runMigrations(); err != nil { 33 + return nil, fmt.Errorf("failed to run migrations: %w", err) 34 + } 35 + 36 + return store, nil 37 + } 38 + 39 + func (s *SQLiteStore) runMigrations() error { 40 + migration, err := os.ReadFile("migrations/001_initial.sql") 41 + if err != nil { 42 + return fmt.Errorf("failed to read migration file: %w", err) 43 + } 44 + 45 + if _, err := s.db.Exec(string(migration)); err != nil { 46 + return fmt.Errorf("failed to execute migration: %w", err) 47 + } 48 + 49 + return nil 50 + } 51 + 52 + func (s *SQLiteStore) Close() error { 53 + return s.db.Close() 54 + } 55 + 56 + // Brew operations 57 + 58 + func (s *SQLiteStore) CreateBrew(req *models.CreateBrewRequest, userID int) (*models.Brew, error) { 59 + result, err := s.db.Exec(` 60 + INSERT INTO brews (user_id, bean_id, roaster_id, method, temperature, time_seconds, 61 + grind_size, grinder, tasting_notes, rating) 62 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 63 + `, userID, req.BeanID, req.RoasterID, req.Method, req.Temperature, req.TimeSeconds, 64 + req.GrindSize, req.Grinder, req.TastingNotes, req.Rating) 65 + 66 + if err != nil { 67 + return nil, fmt.Errorf("failed to create brew: %w", err) 68 + } 69 + 70 + id, err := result.LastInsertId() 71 + if err != nil { 72 + return nil, fmt.Errorf("failed to get last insert id: %w", err) 73 + } 74 + 75 + return s.GetBrew(int(id)) 76 + } 77 + 78 + func (s *SQLiteStore) GetBrew(id int) (*models.Brew, error) { 79 + brew := &models.Brew{ 80 + Bean: &models.Bean{}, 81 + Roaster: &models.Roaster{}, 82 + } 83 + 84 + err := s.db.QueryRow(` 85 + SELECT 86 + b.id, b.user_id, b.bean_id, b.roaster_id, b.method, b.temperature, 87 + b.time_seconds, b.grind_size, b.grinder, b.tasting_notes, b.rating, b.created_at, 88 + bn.id, bn.name, bn.origin, bn.roast_level, bn.description, 89 + r.id, r.name 90 + FROM brews b 91 + JOIN beans bn ON b.bean_id = bn.id 92 + JOIN roasters r ON b.roaster_id = r.id 93 + WHERE b.id = ? 94 + `, id).Scan( 95 + &brew.ID, &brew.UserID, &brew.BeanID, &brew.RoasterID, &brew.Method, &brew.Temperature, 96 + &brew.TimeSeconds, &brew.GrindSize, &brew.Grinder, &brew.TastingNotes, &brew.Rating, &brew.CreatedAt, 97 + &brew.Bean.ID, &brew.Bean.Name, &brew.Bean.Origin, &brew.Bean.RoastLevel, &brew.Bean.Description, 98 + &brew.Roaster.ID, &brew.Roaster.Name, 99 + ) 100 + 101 + if err != nil { 102 + return nil, fmt.Errorf("failed to get brew: %w", err) 103 + } 104 + 105 + return brew, nil 106 + } 107 + 108 + func (s *SQLiteStore) ListBrews(userID int) ([]*models.Brew, error) { 109 + rows, err := s.db.Query(` 110 + SELECT 111 + b.id, b.user_id, b.bean_id, b.roaster_id, b.method, b.temperature, 112 + b.time_seconds, b.grind_size, b.grinder, b.tasting_notes, b.rating, b.created_at, 113 + bn.id, bn.name, bn.origin, bn.roast_level, bn.description, 114 + r.id, r.name 115 + FROM brews b 116 + JOIN beans bn ON b.bean_id = bn.id 117 + JOIN roasters r ON b.roaster_id = r.id 118 + WHERE b.user_id = ? 119 + ORDER BY b.created_at DESC 120 + `, userID) 121 + 122 + if err != nil { 123 + return nil, fmt.Errorf("failed to list brews: %w", err) 124 + } 125 + defer rows.Close() 126 + 127 + var brews []*models.Brew 128 + for rows.Next() { 129 + brew := &models.Brew{ 130 + Bean: &models.Bean{}, 131 + Roaster: &models.Roaster{}, 132 + } 133 + 134 + err := rows.Scan( 135 + &brew.ID, &brew.UserID, &brew.BeanID, &brew.RoasterID, &brew.Method, &brew.Temperature, 136 + &brew.TimeSeconds, &brew.GrindSize, &brew.Grinder, &brew.TastingNotes, &brew.Rating, &brew.CreatedAt, 137 + &brew.Bean.ID, &brew.Bean.Name, &brew.Bean.Origin, &brew.Bean.RoastLevel, &brew.Bean.Description, 138 + &brew.Roaster.ID, &brew.Roaster.Name, 139 + ) 140 + 141 + if err != nil { 142 + return nil, fmt.Errorf("failed to scan brew: %w", err) 143 + } 144 + 145 + brews = append(brews, brew) 146 + } 147 + 148 + return brews, nil 149 + } 150 + 151 + func (s *SQLiteStore) UpdateBrew(id int, req *models.CreateBrewRequest) error { 152 + _, err := s.db.Exec(` 153 + UPDATE brews 154 + SET bean_id = ?, roaster_id = ?, method = ?, temperature = ?, time_seconds = ?, 155 + grind_size = ?, grinder = ?, tasting_notes = ?, rating = ? 156 + WHERE id = ? 157 + `, req.BeanID, req.RoasterID, req.Method, req.Temperature, req.TimeSeconds, 158 + req.GrindSize, req.Grinder, req.TastingNotes, req.Rating, id) 159 + 160 + if err != nil { 161 + return fmt.Errorf("failed to update brew: %w", err) 162 + } 163 + 164 + return nil 165 + } 166 + 167 + func (s *SQLiteStore) DeleteBrew(id int) error { 168 + _, err := s.db.Exec("DELETE FROM brews WHERE id = ?", id) 169 + if err != nil { 170 + return fmt.Errorf("failed to delete brew: %w", err) 171 + } 172 + return nil 173 + } 174 + 175 + // Bean operations 176 + 177 + func (s *SQLiteStore) CreateBean(req *models.CreateBeanRequest) (*models.Bean, error) { 178 + result, err := s.db.Exec(` 179 + INSERT INTO beans (name, origin, roast_level, description) 180 + VALUES (?, ?, ?, ?) 181 + `, req.Name, req.Origin, req.RoastLevel, req.Description) 182 + 183 + if err != nil { 184 + return nil, fmt.Errorf("failed to create bean: %w", err) 185 + } 186 + 187 + id, err := result.LastInsertId() 188 + if err != nil { 189 + return nil, fmt.Errorf("failed to get last insert id: %w", err) 190 + } 191 + 192 + return s.GetBean(int(id)) 193 + } 194 + 195 + func (s *SQLiteStore) GetBean(id int) (*models.Bean, error) { 196 + bean := &models.Bean{} 197 + err := s.db.QueryRow(` 198 + SELECT id, name, origin, roast_level, description, created_at 199 + FROM beans WHERE id = ? 200 + `, id).Scan(&bean.ID, &bean.Name, &bean.Origin, &bean.RoastLevel, &bean.Description, &bean.CreatedAt) 201 + 202 + if err != nil { 203 + return nil, fmt.Errorf("failed to get bean: %w", err) 204 + } 205 + 206 + return bean, nil 207 + } 208 + 209 + func (s *SQLiteStore) ListBeans() ([]*models.Bean, error) { 210 + rows, err := s.db.Query(` 211 + SELECT id, name, origin, roast_level, description, created_at 212 + FROM beans 213 + ORDER BY created_at DESC 214 + `) 215 + 216 + if err != nil { 217 + return nil, fmt.Errorf("failed to list beans: %w", err) 218 + } 219 + defer rows.Close() 220 + 221 + var beans []*models.Bean 222 + for rows.Next() { 223 + bean := &models.Bean{} 224 + err := rows.Scan(&bean.ID, &bean.Name, &bean.Origin, &bean.RoastLevel, &bean.Description, &bean.CreatedAt) 225 + if err != nil { 226 + return nil, fmt.Errorf("failed to scan bean: %w", err) 227 + } 228 + beans = append(beans, bean) 229 + } 230 + 231 + return beans, nil 232 + } 233 + 234 + // Roaster operations 235 + 236 + func (s *SQLiteStore) CreateRoaster(req *models.CreateRoasterRequest) (*models.Roaster, error) { 237 + result, err := s.db.Exec(` 238 + INSERT INTO roasters (name, location, website) VALUES (?, ?, ?) 239 + `, req.Name, req.Location, req.Website) 240 + 241 + if err != nil { 242 + return nil, fmt.Errorf("failed to create roaster: %w", err) 243 + } 244 + 245 + id, err := result.LastInsertId() 246 + if err != nil { 247 + return nil, fmt.Errorf("failed to get last insert id: %w", err) 248 + } 249 + 250 + return s.GetRoaster(int(id)) 251 + } 252 + 253 + func (s *SQLiteStore) GetRoaster(id int) (*models.Roaster, error) { 254 + roaster := &models.Roaster{} 255 + err := s.db.QueryRow(` 256 + SELECT id, name, location, website, created_at 257 + FROM roasters WHERE id = ? 258 + `, id).Scan(&roaster.ID, &roaster.Name, &roaster.Location, &roaster.Website, &roaster.CreatedAt) 259 + 260 + if err != nil { 261 + return nil, fmt.Errorf("failed to get roaster: %w", err) 262 + } 263 + 264 + return roaster, nil 265 + } 266 + 267 + func (s *SQLiteStore) ListRoasters() ([]*models.Roaster, error) { 268 + rows, err := s.db.Query(` 269 + SELECT id, name, location, website, created_at 270 + FROM roasters 271 + ORDER BY name ASC 272 + `) 273 + 274 + if err != nil { 275 + return nil, fmt.Errorf("failed to list roasters: %w", err) 276 + } 277 + defer rows.Close() 278 + 279 + var roasters []*models.Roaster 280 + for rows.Next() { 281 + roaster := &models.Roaster{} 282 + err := rows.Scan(&roaster.ID, &roaster.Name, &roaster.Location, &roaster.Website, &roaster.CreatedAt) 283 + if err != nil { 284 + return nil, fmt.Errorf("failed to scan roaster: %w", err) 285 + } 286 + roasters = append(roasters, roaster) 287 + } 288 + 289 + return roasters, nil 290 + } 291 + 292 + func (s *SQLiteStore) UpdateRoaster(id int, req *models.UpdateRoasterRequest) error { 293 + _, err := s.db.Exec(` 294 + UPDATE roasters 295 + SET name = ?, location = ?, website = ? 296 + WHERE id = ? 297 + `, req.Name, req.Location, req.Website, id) 298 + 299 + if err != nil { 300 + return fmt.Errorf("failed to update roaster: %w", err) 301 + } 302 + 303 + return nil 304 + } 305 + 306 + func (s *SQLiteStore) DeleteRoaster(id int) error { 307 + _, err := s.db.Exec("DELETE FROM roasters WHERE id = ?", id) 308 + if err != nil { 309 + return fmt.Errorf("failed to delete roaster: %w", err) 310 + } 311 + return nil 312 + } 313 + 314 + // Bean update/delete operations 315 + 316 + func (s *SQLiteStore) UpdateBean(id int, req *models.UpdateBeanRequest) error { 317 + _, err := s.db.Exec(` 318 + UPDATE beans 319 + SET name = ?, origin = ?, roast_level = ?, description = ? 320 + WHERE id = ? 321 + `, req.Name, req.Origin, req.RoastLevel, req.Description, id) 322 + 323 + if err != nil { 324 + return fmt.Errorf("failed to update bean: %w", err) 325 + } 326 + 327 + return nil 328 + } 329 + 330 + func (s *SQLiteStore) DeleteBean(id int) error { 331 + _, err := s.db.Exec("DELETE FROM beans WHERE id = ?", id) 332 + if err != nil { 333 + return fmt.Errorf("failed to delete bean: %w", err) 334 + } 335 + return nil 336 + } 337 + 338 + // Grinder operations 339 + 340 + func (s *SQLiteStore) CreateGrinder(req *models.CreateGrinderRequest) (*models.Grinder, error) { 341 + result, err := s.db.Exec(` 342 + INSERT INTO grinders (name, type, notes) VALUES (?, ?, ?) 343 + `, req.Name, req.Type, req.Notes) 344 + 345 + if err != nil { 346 + return nil, fmt.Errorf("failed to create grinder: %w", err) 347 + } 348 + 349 + id, err := result.LastInsertId() 350 + if err != nil { 351 + return nil, fmt.Errorf("failed to get last insert id: %w", err) 352 + } 353 + 354 + return s.GetGrinder(int(id)) 355 + } 356 + 357 + func (s *SQLiteStore) GetGrinder(id int) (*models.Grinder, error) { 358 + grinder := &models.Grinder{} 359 + err := s.db.QueryRow(` 360 + SELECT id, name, type, notes, created_at 361 + FROM grinders WHERE id = ? 362 + `, id).Scan(&grinder.ID, &grinder.Name, &grinder.Type, &grinder.Notes, &grinder.CreatedAt) 363 + 364 + if err != nil { 365 + return nil, fmt.Errorf("failed to get grinder: %w", err) 366 + } 367 + 368 + return grinder, nil 369 + } 370 + 371 + func (s *SQLiteStore) ListGrinders() ([]*models.Grinder, error) { 372 + rows, err := s.db.Query(` 373 + SELECT id, name, type, notes, created_at 374 + FROM grinders 375 + ORDER BY name ASC 376 + `) 377 + 378 + if err != nil { 379 + return nil, fmt.Errorf("failed to list grinders: %w", err) 380 + } 381 + defer rows.Close() 382 + 383 + var grinders []*models.Grinder 384 + for rows.Next() { 385 + grinder := &models.Grinder{} 386 + err := rows.Scan(&grinder.ID, &grinder.Name, &grinder.Type, &grinder.Notes, &grinder.CreatedAt) 387 + if err != nil { 388 + return nil, fmt.Errorf("failed to scan grinder: %w", err) 389 + } 390 + grinders = append(grinders, grinder) 391 + } 392 + 393 + return grinders, nil 394 + } 395 + 396 + func (s *SQLiteStore) UpdateGrinder(id int, req *models.UpdateGrinderRequest) error { 397 + _, err := s.db.Exec(` 398 + UPDATE grinders 399 + SET name = ?, type = ?, notes = ? 400 + WHERE id = ? 401 + `, req.Name, req.Type, req.Notes, id) 402 + 403 + if err != nil { 404 + return fmt.Errorf("failed to update grinder: %w", err) 405 + } 406 + 407 + return nil 408 + } 409 + 410 + func (s *SQLiteStore) DeleteGrinder(id int) error { 411 + _, err := s.db.Exec("DELETE FROM grinders WHERE id = ?", id) 412 + if err != nil { 413 + return fmt.Errorf("failed to delete grinder: %w", err) 414 + } 415 + return nil 416 + }
+38
internal/database/store.go
··· 1 + package database 2 + 3 + import "arabica/internal/models" 4 + 5 + // Store defines the interface for all database operations 6 + // This abstraction allows swapping SQLite for PostgreSQL or other databases later 7 + type Store interface { 8 + // Brew operations 9 + CreateBrew(brew *models.CreateBrewRequest, userID int) (*models.Brew, error) 10 + GetBrew(id int) (*models.Brew, error) 11 + ListBrews(userID int) ([]*models.Brew, error) 12 + UpdateBrew(id int, brew *models.CreateBrewRequest) error 13 + DeleteBrew(id int) error 14 + 15 + // Bean operations 16 + CreateBean(bean *models.CreateBeanRequest) (*models.Bean, error) 17 + GetBean(id int) (*models.Bean, error) 18 + ListBeans() ([]*models.Bean, error) 19 + UpdateBean(id int, bean *models.UpdateBeanRequest) error 20 + DeleteBean(id int) error 21 + 22 + // Roaster operations 23 + CreateRoaster(roaster *models.CreateRoasterRequest) (*models.Roaster, error) 24 + GetRoaster(id int) (*models.Roaster, error) 25 + ListRoasters() ([]*models.Roaster, error) 26 + UpdateRoaster(id int, roaster *models.UpdateRoasterRequest) error 27 + DeleteRoaster(id int) error 28 + 29 + // Grinder operations 30 + CreateGrinder(grinder *models.CreateGrinderRequest) (*models.Grinder, error) 31 + GetGrinder(id int) (*models.Grinder, error) 32 + ListGrinders() ([]*models.Grinder, error) 33 + UpdateGrinder(id int, grinder *models.UpdateGrinderRequest) error 34 + DeleteGrinder(id int) error 35 + 36 + // Close the database connection 37 + Close() error 38 + }
+157
internal/handlers/handlers.go
··· 1 + package handlers 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + "strconv" 7 + 8 + "arabica/internal/database" 9 + "arabica/internal/models" 10 + "arabica/internal/templates" 11 + ) 12 + 13 + type Handler struct { 14 + store database.Store 15 + } 16 + 17 + func NewHandler(store database.Store) *Handler { 18 + return &Handler{store: store} 19 + } 20 + 21 + // Home page 22 + func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 23 + templates.Home().Render(r.Context(), w) 24 + } 25 + 26 + // List all brews 27 + func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) { 28 + brews, err := h.store.ListBrews(1) // Default user ID = 1 29 + if err != nil { 30 + http.Error(w, err.Error(), http.StatusInternalServerError) 31 + return 32 + } 33 + 34 + templates.BrewList(brews).Render(r.Context(), w) 35 + } 36 + 37 + // Show new brew form 38 + func (h *Handler) HandleBrewNew(w http.ResponseWriter, r *http.Request) { 39 + beans, err := h.store.ListBeans() 40 + if err != nil { 41 + http.Error(w, err.Error(), http.StatusInternalServerError) 42 + return 43 + } 44 + 45 + roasters, err := h.store.ListRoasters() 46 + if err != nil { 47 + http.Error(w, err.Error(), http.StatusInternalServerError) 48 + return 49 + } 50 + 51 + templates.BrewForm(beans, roasters, nil).Render(r.Context(), w) 52 + } 53 + 54 + // Create new brew 55 + func (h *Handler) HandleBrewCreate(w http.ResponseWriter, r *http.Request) { 56 + if err := r.ParseForm(); err != nil { 57 + http.Error(w, err.Error(), http.StatusBadRequest) 58 + return 59 + } 60 + 61 + beanID, _ := strconv.Atoi(r.FormValue("bean_id")) 62 + roasterID, _ := strconv.Atoi(r.FormValue("roaster_id")) 63 + temperature, _ := strconv.ParseFloat(r.FormValue("temperature"), 64) 64 + timeSeconds, _ := strconv.Atoi(r.FormValue("time_seconds")) 65 + rating, _ := strconv.Atoi(r.FormValue("rating")) 66 + 67 + req := &models.CreateBrewRequest{ 68 + BeanID: beanID, 69 + RoasterID: roasterID, 70 + Method: r.FormValue("method"), 71 + Temperature: temperature, 72 + TimeSeconds: timeSeconds, 73 + GrindSize: r.FormValue("grind_size"), 74 + Grinder: r.FormValue("grinder"), 75 + TastingNotes: r.FormValue("tasting_notes"), 76 + Rating: rating, 77 + } 78 + 79 + _, err := h.store.CreateBrew(req, 1) // Default user ID = 1 80 + if err != nil { 81 + http.Error(w, err.Error(), http.StatusInternalServerError) 82 + return 83 + } 84 + 85 + // Redirect to brew list 86 + w.Header().Set("HX-Redirect", "/brews") 87 + w.WriteHeader(http.StatusOK) 88 + } 89 + 90 + // Delete brew 91 + func (h *Handler) HandleBrewDelete(w http.ResponseWriter, r *http.Request) { 92 + idStr := r.PathValue("id") 93 + id, err := strconv.Atoi(idStr) 94 + if err != nil { 95 + http.Error(w, "Invalid ID", http.StatusBadRequest) 96 + return 97 + } 98 + 99 + if err := h.store.DeleteBrew(id); err != nil { 100 + http.Error(w, err.Error(), http.StatusInternalServerError) 101 + return 102 + } 103 + 104 + w.WriteHeader(http.StatusOK) 105 + } 106 + 107 + // Export brews as JSON 108 + func (h *Handler) HandleBrewExport(w http.ResponseWriter, r *http.Request) { 109 + brews, err := h.store.ListBrews(1) // Default user ID = 1 110 + if err != nil { 111 + http.Error(w, err.Error(), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + w.Header().Set("Content-Type", "application/json") 116 + w.Header().Set("Content-Disposition", "attachment; filename=arabica-brews.json") 117 + 118 + encoder := json.NewEncoder(w) 119 + encoder.SetIndent("", " ") 120 + encoder.Encode(brews) 121 + } 122 + 123 + // API endpoint to create bean 124 + func (h *Handler) HandleBeanCreate(w http.ResponseWriter, r *http.Request) { 125 + var req models.CreateBeanRequest 126 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 127 + http.Error(w, err.Error(), http.StatusBadRequest) 128 + return 129 + } 130 + 131 + bean, err := h.store.CreateBean(&req) 132 + if err != nil { 133 + http.Error(w, err.Error(), http.StatusInternalServerError) 134 + return 135 + } 136 + 137 + w.Header().Set("Content-Type", "application/json") 138 + json.NewEncoder(w).Encode(bean) 139 + } 140 + 141 + // API endpoint to create roaster 142 + func (h *Handler) HandleRoasterCreate(w http.ResponseWriter, r *http.Request) { 143 + var req models.CreateRoasterRequest 144 + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 145 + http.Error(w, err.Error(), http.StatusBadRequest) 146 + return 147 + } 148 + 149 + roaster, err := h.store.CreateRoaster(&req) 150 + if err != nil { 151 + http.Error(w, err.Error(), http.StatusInternalServerError) 152 + return 153 + } 154 + 155 + w.Header().Set("Content-Type", "application/json") 156 + json.NewEncoder(w).Encode(roaster) 157 + }
+103
internal/models/models.go
··· 1 + package models 2 + 3 + import "time" 4 + 5 + type User struct { 6 + ID int `json:"id"` 7 + Username string `json:"username"` 8 + CreatedAt time.Time `json:"created_at"` 9 + } 10 + 11 + type Bean struct { 12 + ID int `json:"id"` 13 + Name string `json:"name"` 14 + Origin string `json:"origin"` 15 + RoastLevel string `json:"roast_level"` 16 + Description string `json:"description"` 17 + CreatedAt time.Time `json:"created_at"` 18 + } 19 + 20 + type Roaster struct { 21 + ID int `json:"id"` 22 + Name string `json:"name"` 23 + Location string `json:"location"` 24 + Website string `json:"website"` 25 + CreatedAt time.Time `json:"created_at"` 26 + } 27 + 28 + type Grinder struct { 29 + ID int `json:"id"` 30 + Name string `json:"name"` 31 + Type string `json:"type"` 32 + Notes string `json:"notes"` 33 + CreatedAt time.Time `json:"created_at"` 34 + } 35 + 36 + type Brew struct { 37 + ID int `json:"id"` 38 + UserID int `json:"user_id"` 39 + BeanID int `json:"bean_id"` 40 + RoasterID int `json:"roaster_id"` 41 + Method string `json:"method"` 42 + Temperature float64 `json:"temperature"` 43 + TimeSeconds int `json:"time_seconds"` 44 + GrindSize string `json:"grind_size"` 45 + Grinder string `json:"grinder"` 46 + TastingNotes string `json:"tasting_notes"` 47 + Rating int `json:"rating"` 48 + CreatedAt time.Time `json:"created_at"` 49 + 50 + // Joined data for display 51 + Bean *Bean `json:"bean,omitempty"` 52 + Roaster *Roaster `json:"roaster,omitempty"` 53 + } 54 + 55 + type CreateBrewRequest struct { 56 + BeanID int `json:"bean_id"` 57 + RoasterID int `json:"roaster_id"` 58 + Method string `json:"method"` 59 + Temperature float64 `json:"temperature"` 60 + TimeSeconds int `json:"time_seconds"` 61 + GrindSize string `json:"grind_size"` 62 + Grinder string `json:"grinder"` 63 + TastingNotes string `json:"tasting_notes"` 64 + Rating int `json:"rating"` 65 + } 66 + 67 + type CreateBeanRequest struct { 68 + Name string `json:"name"` 69 + Origin string `json:"origin"` 70 + RoastLevel string `json:"roast_level"` 71 + Description string `json:"description"` 72 + } 73 + 74 + type CreateRoasterRequest struct { 75 + Name string `json:"name"` 76 + Location string `json:"location"` 77 + Website string `json:"website"` 78 + } 79 + 80 + type CreateGrinderRequest struct { 81 + Name string `json:"name"` 82 + Type string `json:"type"` 83 + Notes string `json:"notes"` 84 + } 85 + 86 + type UpdateBeanRequest struct { 87 + Name string `json:"name"` 88 + Origin string `json:"origin"` 89 + RoastLevel string `json:"roast_level"` 90 + Description string `json:"description"` 91 + } 92 + 93 + type UpdateRoasterRequest struct { 94 + Name string `json:"name"` 95 + Location string `json:"location"` 96 + Website string `json:"website"` 97 + } 98 + 99 + type UpdateGrinderRequest struct { 100 + Name string `json:"name"` 101 + Type string `json:"type"` 102 + Notes string `json:"notes"` 103 + }
+242
internal/templates/brew_form.templ
··· 1 + package templates 2 + 3 + import "arabica/internal/models" 4 + 5 + templ BrewForm(beans []*models.Bean, roasters []*models.Roaster, brew *models.Brew) { 6 + @Layout("New Brew") { 7 + <div class="max-w-2xl mx-auto"> 8 + <div class="bg-white rounded-lg shadow-md p-8"> 9 + <h2 class="text-3xl font-bold text-gray-800 mb-6"> 10 + if brew != nil { 11 + Edit Brew 12 + } else { 13 + New Brew 14 + } 15 + </h2> 16 + 17 + <form 18 + if brew != nil { 19 + hx-put={ "/brews/" + formatID(brew.ID) } 20 + } else { 21 + hx-post="/brews" 22 + } 23 + hx-target="body" 24 + class="space-y-6" 25 + x-data="brewForm()"> 26 + 27 + <!-- Bean Selection --> 28 + <div> 29 + <label class="block text-sm font-medium text-gray-700 mb-2">Coffee Bean</label> 30 + <div class="flex gap-2"> 31 + <select 32 + name="bean_id" 33 + required 34 + class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"> 35 + <option value="">Select a bean...</option> 36 + for _, bean := range beans { 37 + <option value={ formatID(bean.ID) }> 38 + if bean.Name != "" { 39 + { bean.Name } ({ bean.Origin } - { bean.RoastLevel }) 40 + } else { 41 + { bean.Origin } - { bean.RoastLevel } 42 + } 43 + </option> 44 + } 45 + </select> 46 + <button 47 + type="button" 48 + @click="showNewBean = true" 49 + class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"> 50 + + New 51 + </button> 52 + </div> 53 + 54 + <!-- New Bean Modal --> 55 + <div x-show="showNewBean" class="mt-4 p-4 bg-gray-50 rounded border"> 56 + <h4 class="font-medium mb-3">Add New Bean</h4> 57 + <div class="space-y-3"> 58 + <input type="text" x-model="newBean.name" placeholder="Name (e.g. Morning Blend, House Espresso) *" class="w-full rounded-md border-gray-300 py-2 px-3"/> 59 + <input type="text" x-model="newBean.origin" placeholder="Origin (e.g. Ethiopia)" class="w-full rounded-md border-gray-300 py-2 px-3"/> 60 + <input type="text" x-model="newBean.roastLevel" placeholder="Roast Level (e.g. Medium)" class="w-full rounded-md border-gray-300 py-2 px-3"/> 61 + <input type="text" x-model="newBean.description" placeholder="Description (optional)" class="w-full rounded-md border-gray-300 py-2 px-3"/> 62 + <div class="flex gap-2"> 63 + <button type="button" @click="addBean()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 64 + <button type="button" @click="showNewBean = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 65 + </div> 66 + </div> 67 + </div> 68 + </div> 69 + 70 + <!-- Roaster Selection --> 71 + <div> 72 + <label class="block text-sm font-medium text-gray-700 mb-2">Roaster</label> 73 + <div class="flex gap-2"> 74 + <select 75 + name="roaster_id" 76 + required 77 + class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"> 78 + <option value="">Select a roaster...</option> 79 + for _, roaster := range roasters { 80 + <option value={ formatID(roaster.ID) }>{ roaster.Name }</option> 81 + } 82 + </select> 83 + <button 84 + type="button" 85 + @click="showNewRoaster = true" 86 + class="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300"> 87 + + New 88 + </button> 89 + </div> 90 + 91 + <!-- New Roaster Modal --> 92 + <div x-show="showNewRoaster" class="mt-4 p-4 bg-gray-50 rounded border"> 93 + <h4 class="font-medium mb-3">Add New Roaster</h4> 94 + <div class="space-y-3"> 95 + <input type="text" x-model="newRoaster.name" placeholder="Roaster Name" class="w-full rounded-md border-gray-300 py-2 px-3"/> 96 + <div class="flex gap-2"> 97 + <button type="button" @click="addRoaster()" class="bg-brown-600 text-white px-4 py-2 rounded hover:bg-brown-700">Add</button> 98 + <button type="button" @click="showNewRoaster = false" class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400">Cancel</button> 99 + </div> 100 + </div> 101 + </div> 102 + </div> 103 + 104 + <!-- Brew Method --> 105 + <div> 106 + <label class="block text-sm font-medium text-gray-700 mb-2">Brew Method</label> 107 + <select name="method" required class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"> 108 + <option value="">Select method...</option> 109 + <option value="Pour Over">Pour Over</option> 110 + <option value="French Press">French Press</option> 111 + <option value="AeroPress">AeroPress</option> 112 + <option value="Espresso">Espresso</option> 113 + <option value="Moka Pot">Moka Pot</option> 114 + <option value="Cold Brew">Cold Brew</option> 115 + <option value="Other">Other</option> 116 + </select> 117 + </div> 118 + 119 + <!-- Temperature --> 120 + <div> 121 + <label class="block text-sm font-medium text-gray-700 mb-2">Temperature (°C)</label> 122 + <input 123 + type="number" 124 + name="temperature" 125 + step="0.1" 126 + placeholder="e.g. 93.5" 127 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 128 + </div> 129 + 130 + <!-- Brew Time --> 131 + <div> 132 + <label class="block text-sm font-medium text-gray-700 mb-2">Brew Time (seconds)</label> 133 + <input 134 + type="number" 135 + name="time_seconds" 136 + placeholder="e.g. 180" 137 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 138 + </div> 139 + 140 + <!-- Grind Size --> 141 + <div> 142 + <label class="block text-sm font-medium text-gray-700 mb-2">Grind Size</label> 143 + <input 144 + type="text" 145 + name="grind_size" 146 + placeholder="e.g. 18, Medium, 3.5, Fine" 147 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 148 + <p class="text-sm text-gray-500 mt-1">Enter a number (grinder setting) or description (e.g. "Medium", "Fine")</p> 149 + </div> 150 + 151 + <!-- Grinder --> 152 + <div> 153 + <label class="block text-sm font-medium text-gray-700 mb-2">Grinder (optional)</label> 154 + <input 155 + type="text" 156 + name="grinder" 157 + placeholder="e.g. Baratza Encore" 158 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"/> 159 + </div> 160 + 161 + <!-- Tasting Notes --> 162 + <div> 163 + <label class="block text-sm font-medium text-gray-700 mb-2">Tasting Notes</label> 164 + <textarea 165 + name="tasting_notes" 166 + rows="4" 167 + placeholder="Describe the flavors, aroma, and your thoughts..." 168 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-brown-500 focus:ring-brown-500 text-base py-3 px-4"></textarea> 169 + </div> 170 + 171 + <!-- Rating --> 172 + <div> 173 + <label class="block text-sm font-medium text-gray-700 mb-2">Rating</label> 174 + <input 175 + type="range" 176 + name="rating" 177 + min="1" 178 + max="10" 179 + value="5" 180 + x-model="rating" 181 + class="w-full"/> 182 + <div class="text-center text-2xl font-bold text-brown-600"> 183 + <span x-text="rating"></span>/10 184 + </div> 185 + </div> 186 + 187 + <!-- Submit --> 188 + <div class="flex gap-4"> 189 + <button 190 + type="submit" 191 + class="flex-1 bg-brown-600 text-white py-3 px-6 rounded-lg hover:bg-brown-700 transition font-medium text-lg"> 192 + if brew != nil { 193 + Update Brew 194 + } else { 195 + Save Brew 196 + } 197 + </button> 198 + <a href="/brews" class="flex-1 bg-gray-300 text-gray-700 py-3 px-6 rounded-lg hover:bg-gray-400 transition font-medium text-lg text-center"> 199 + Cancel 200 + </a> 201 + </div> 202 + </form> 203 + </div> 204 + </div> 205 + 206 + <script> 207 + function brewForm() { 208 + return { 209 + showNewBean: false, 210 + showNewRoaster: false, 211 + rating: 5, 212 + newBean: { name: '', origin: '', roastLevel: '', description: '' }, 213 + newRoaster: { name: '' }, 214 + async addBean() { 215 + if (!this.newBean.name) { 216 + alert('Bean name is required'); 217 + return; 218 + } 219 + const response = await fetch('/api/beans', { 220 + method: 'POST', 221 + headers: { 'Content-Type': 'application/json' }, 222 + body: JSON.stringify(this.newBean) 223 + }); 224 + if (response.ok) { 225 + window.location.reload(); 226 + } 227 + }, 228 + async addRoaster() { 229 + const response = await fetch('/api/roasters', { 230 + method: 'POST', 231 + headers: { 'Content-Type': 'application/json' }, 232 + body: JSON.stringify(this.newRoaster) 233 + }); 234 + if (response.ok) { 235 + window.location.reload(); 236 + } 237 + } 238 + } 239 + } 240 + </script> 241 + } 242 + }
+88
internal/templates/brew_list.templ
··· 1 + package templates 2 + 3 + import "arabica/internal/models" 4 + 5 + templ BrewList(brews []*models.Brew) { 6 + @Layout("All Brews") { 7 + <div class="max-w-6xl mx-auto"> 8 + <div class="flex justify-between items-center mb-6"> 9 + <h2 class="text-3xl font-bold text-gray-800">Your Brews</h2> 10 + <div class="space-x-4"> 11 + <a href="/brews/export" class="bg-green-600 text-white py-2 px-4 rounded hover:bg-green-700 transition"> 12 + 📥 Export JSON 13 + </a> 14 + <a href="/brews/new" class="bg-brown-600 text-white py-2 px-4 rounded hover:bg-brown-700 transition"> 15 + ➕ New Brew 16 + </a> 17 + </div> 18 + </div> 19 + 20 + if len(brews) == 0 { 21 + <div class="bg-white rounded-lg shadow-md p-8 text-center"> 22 + <p class="text-gray-600 text-lg mb-4">No brews yet! Start tracking your coffee journey.</p> 23 + <a href="/brews/new" class="inline-block bg-brown-600 text-white py-3 px-6 rounded-lg hover:bg-brown-700 transition"> 24 + Add Your First Brew 25 + </a> 26 + </div> 27 + } else { 28 + <div class="overflow-x-auto bg-white rounded-lg shadow-md"> 29 + <table class="min-w-full divide-y divide-gray-200"> 30 + <thead class="bg-gray-50"> 31 + <tr> 32 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th> 33 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bean</th> 34 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Roaster</th> 35 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Method</th> 36 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rating</th> 37 + <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th> 38 + </tr> 39 + </thead> 40 + <tbody class="bg-white divide-y divide-gray-200"> 41 + for _, brew := range brews { 42 + <tr class="hover:bg-gray-50"> 43 + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> 44 + { brew.CreatedAt.Format("Jan 2, 2006") } 45 + </td> 46 + <td class="px-6 py-4 text-sm text-gray-900"> 47 + if brew.Bean.Name != "" { 48 + <div class="font-medium">{ brew.Bean.Name }</div> 49 + <div class="text-gray-500 text-xs">{ brew.Bean.Origin } - { brew.Bean.RoastLevel }</div> 50 + } else { 51 + <div class="font-medium">{ brew.Bean.Origin }</div> 52 + <div class="text-gray-500">{ brew.Bean.RoastLevel }</div> 53 + } 54 + </td> 55 + <td class="px-6 py-4 text-sm text-gray-900"> 56 + { brew.Roaster.Name } 57 + </td> 58 + <td class="px-6 py-4 text-sm text-gray-900"> 59 + <div>{ brew.Method }</div> 60 + <div class="text-gray-500 text-xs"> 61 + { formatTemp(brew.Temperature) } • { formatTime(brew.TimeSeconds) } 62 + </div> 63 + </td> 64 + <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> 65 + <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"> 66 + ⭐ { formatRating(brew.Rating) } 67 + </span> 68 + </td> 69 + <td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2"> 70 + <a href={ templ.SafeURL("/brews/" + formatID(brew.ID)) } class="text-blue-600 hover:text-blue-900">View</a> 71 + <button 72 + hx-delete={ "/brews/" + formatID(brew.ID) } 73 + hx-confirm="Are you sure you want to delete this brew?" 74 + hx-target="closest tr" 75 + hx-swap="outerHTML swap:1s" 76 + class="text-red-600 hover:text-red-900"> 77 + Delete 78 + </button> 79 + </td> 80 + </tr> 81 + } 82 + </tbody> 83 + </table> 84 + </div> 85 + } 86 + </div> 87 + } 88 + }
+36
internal/templates/helpers.go
··· 1 + package templates 2 + 3 + import "fmt" 4 + 5 + func formatTemp(temp float64) string { 6 + if temp == 0 { 7 + return "N/A" 8 + } 9 + return fmt.Sprintf("%.1f°C", temp) 10 + } 11 + 12 + func formatTime(seconds int) string { 13 + if seconds == 0 { 14 + return "N/A" 15 + } 16 + if seconds < 60 { 17 + return fmt.Sprintf("%ds", seconds) 18 + } 19 + minutes := seconds / 60 20 + remaining := seconds % 60 21 + if remaining == 0 { 22 + return fmt.Sprintf("%dm", minutes) 23 + } 24 + return fmt.Sprintf("%dm %ds", minutes, remaining) 25 + } 26 + 27 + func formatRating(rating int) string { 28 + if rating == 0 { 29 + return "N/A" 30 + } 31 + return fmt.Sprintf("%d/10", rating) 32 + } 33 + 34 + func formatID(id int) string { 35 + return fmt.Sprintf("%d", id) 36 + }
+31
internal/templates/home.templ
··· 1 + package templates 2 + 3 + templ Home() { 4 + @Layout("Home") { 5 + <div class="max-w-4xl mx-auto"> 6 + <div class="bg-white rounded-lg shadow-md p-8 mb-8"> 7 + <h2 class="text-3xl font-bold text-gray-800 mb-4">Welcome to Arabica</h2> 8 + <p class="text-gray-600 mb-6">Track your coffee brewing journey with detailed logs of every cup.</p> 9 + 10 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> 11 + <a href="/brews/new" class="block bg-brown-600 text-white text-center py-4 px-6 rounded-lg hover:bg-brown-700 transition"> 12 + <span class="text-xl">➕ Add New Brew</span> 13 + </a> 14 + <a href="/brews" class="block bg-gray-600 text-white text-center py-4 px-6 rounded-lg hover:bg-gray-700 transition"> 15 + <span class="text-xl">📋 View All Brews</span> 16 + </a> 17 + </div> 18 + </div> 19 + 20 + <div class="bg-blue-50 rounded-lg p-6 border border-blue-200"> 21 + <h3 class="text-lg font-semibold text-blue-900 mb-2">Quick Start</h3> 22 + <ul class="text-blue-800 space-y-2"> 23 + <li>• Track brewing variables like temperature, time, and grind size</li> 24 + <li>• Organize beans by origin and roaster</li> 25 + <li>• Add tasting notes and ratings to each brew</li> 26 + <li>• Export your data as JSON</li> 27 + </ul> 28 + </div> 29 + </div> 30 + } 31 + }
+42
internal/templates/layout.templ
··· 1 + package templates 2 + 3 + templ Layout(title string) { 4 + <!DOCTYPE html> 5 + <html lang="en"> 6 + <head> 7 + <meta charset="UTF-8"/> 8 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 9 + <meta name="description" content="Arabica - Coffee brew tracker"/> 10 + <meta name="theme-color" content="#4a2c2a"/> 11 + <title>{ title } - Arabica</title> 12 + <link rel="stylesheet" href="/static/css/output.css"/> 13 + <link rel="manifest" href="/static/manifest.json"/> 14 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 15 + <script src="https://unpkg.com/alpinejs@3.13.3/dist/cdn.min.js" defer></script> 16 + <script> 17 + if ('serviceWorker' in navigator) { 18 + navigator.serviceWorker.register('/static/service-worker.js') 19 + .then(() => console.log('Service Worker registered')) 20 + .catch((err) => console.log('Service Worker registration failed:', err)); 21 + } 22 + </script> 23 + </head> 24 + <body class="bg-gray-50 min-h-screen"> 25 + <nav class="bg-brown-800 text-white shadow-lg"> 26 + <div class="container mx-auto px-4 py-4"> 27 + <div class="flex items-center justify-between"> 28 + <h1 class="text-2xl font-bold">☕ Arabica</h1> 29 + <div class="space-x-4"> 30 + <a href="/" class="hover:text-brown-200">Home</a> 31 + <a href="/brews" class="hover:text-brown-200">Brews</a> 32 + <a href="/brews/new" class="hover:text-brown-200">New Brew</a> 33 + </div> 34 + </div> 35 + </div> 36 + </nav> 37 + <main class="container mx-auto px-4 py-8"> 38 + { children... } 39 + </main> 40 + </body> 41 + </html> 42 + }
+59
migrations/001_initial.sql
··· 1 + -- Initial schema for Arabica coffee tracking 2 + 3 + CREATE TABLE IF NOT EXISTS users ( 4 + id INTEGER PRIMARY KEY AUTOINCREMENT, 5 + username TEXT UNIQUE NOT NULL, 6 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 7 + ); 8 + 9 + CREATE TABLE IF NOT EXISTS beans ( 10 + id INTEGER PRIMARY KEY AUTOINCREMENT, 11 + name TEXT, 12 + origin TEXT NOT NULL, 13 + roast_level TEXT, 14 + description TEXT, 15 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 16 + ); 17 + 18 + CREATE TABLE IF NOT EXISTS roasters ( 19 + id INTEGER PRIMARY KEY AUTOINCREMENT, 20 + name TEXT UNIQUE NOT NULL, 21 + location TEXT, 22 + website TEXT, 23 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 24 + ); 25 + 26 + CREATE TABLE IF NOT EXISTS grinders ( 27 + id INTEGER PRIMARY KEY AUTOINCREMENT, 28 + name TEXT UNIQUE NOT NULL, 29 + type TEXT, 30 + notes TEXT, 31 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 32 + ); 33 + 34 + CREATE TABLE IF NOT EXISTS brews ( 35 + id INTEGER PRIMARY KEY AUTOINCREMENT, 36 + user_id INTEGER NOT NULL DEFAULT 1, 37 + bean_id INTEGER NOT NULL, 38 + roaster_id INTEGER NOT NULL, 39 + method TEXT NOT NULL, 40 + temperature REAL, 41 + time_seconds INTEGER, 42 + grind_size TEXT, 43 + grinder TEXT, 44 + tasting_notes TEXT, 45 + rating INTEGER CHECK(rating >= 1 AND rating <= 10), 46 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 47 + FOREIGN KEY (user_id) REFERENCES users(id), 48 + FOREIGN KEY (bean_id) REFERENCES beans(id), 49 + FOREIGN KEY (roaster_id) REFERENCES roasters(id) 50 + ); 51 + 52 + -- Insert default user for single-user mode (ignore if exists) 53 + INSERT OR IGNORE INTO users (username) VALUES ('default'); 54 + 55 + -- Create indexes for common queries 56 + CREATE INDEX IF NOT EXISTS idx_brews_user_id ON brews(user_id); 57 + CREATE INDEX IF NOT EXISTS idx_brews_bean_id ON brews(bean_id); 58 + CREATE INDEX IF NOT EXISTS idx_brews_roaster_id ON brews(roaster_id); 59 + CREATE INDEX IF NOT EXISTS idx_brews_created_at ON brews(created_at DESC);
+26
tailwind.config.js
··· 1 + /** @type {import('tailwindcss').Config} */ 2 + module.exports = { 3 + content: [ 4 + "./internal/templates/**/*.templ", 5 + "./web/**/*.html", 6 + ], 7 + theme: { 8 + extend: { 9 + colors: { 10 + brown: { 11 + 50: '#fdf8f6', 12 + 100: '#f2e8e5', 13 + 200: '#eaddd7', 14 + 300: '#e0cec7', 15 + 400: '#d2bab0', 16 + 500: '#bfa094', 17 + 600: '#7f5539', 18 + 700: '#6b4423', 19 + 800: '#4a2c2a', 20 + 900: '#3d2319', 21 + }, 22 + }, 23 + }, 24 + }, 25 + plugins: [], 26 + }
+76
web/static/css/style.css
··· 1 + @tailwind base; 2 + @tailwind components; 3 + @tailwind utilities; 4 + 5 + /* Custom colors for coffee theme */ 6 + :root { 7 + --brown-50: #fdf8f6; 8 + --brown-100: #f2e8e5; 9 + --brown-200: #eaddd7; 10 + --brown-300: #e0cec7; 11 + --brown-600: #7f5539; 12 + --brown-700: #6b4423; 13 + --brown-800: #4a2c2a; 14 + } 15 + 16 + .bg-brown-800 { 17 + background-color: var(--brown-800); 18 + } 19 + 20 + .bg-brown-700 { 21 + background-color: var(--brown-700); 22 + } 23 + 24 + .bg-brown-600 { 25 + background-color: var(--brown-600); 26 + } 27 + 28 + .hover\:bg-brown-700:hover { 29 + background-color: var(--brown-700); 30 + } 31 + 32 + .hover\:text-brown-200:hover { 33 + color: var(--brown-200); 34 + } 35 + 36 + .text-brown-600 { 37 + color: var(--brown-600); 38 + } 39 + 40 + .focus\:border-brown-500:focus { 41 + border-color: var(--brown-600); 42 + } 43 + 44 + .focus\:ring-brown-500:focus { 45 + --tw-ring-color: var(--brown-600); 46 + } 47 + 48 + /* Mobile-first touch targets */ 49 + button, 50 + a, 51 + input[type="submit"] { 52 + min-height: 44px; 53 + min-width: 44px; 54 + } 55 + 56 + /* Form inputs - larger on mobile */ 57 + @media (max-width: 768px) { 58 + input, 59 + select, 60 + textarea { 61 + font-size: 16px; /* Prevents iOS zoom on focus */ 62 + } 63 + } 64 + 65 + /* Loading state for HTMX */ 66 + .htmx-swapping { 67 + opacity: 0; 68 + transition: opacity 0.3s ease-out; 69 + } 70 + 71 + /* Smooth transitions */ 72 + * { 73 + transition-property: background-color, border-color, color, fill, stroke; 74 + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 75 + transition-duration: 150ms; 76 + }
+4
web/static/icon-placeholder.svg
··· 1 + <svg width="512" height="512" xmlns="http://www.w3.org/2000/svg"> 2 + <rect width="512" height="512" fill="#4a2c2a"/> 3 + <text x="256" y="280" font-size="200" text-anchor="middle" fill="#fff">☕</text> 4 + </svg>
+24
web/static/manifest.json
··· 1 + { 2 + "name": "Arabica - Coffee Brew Tracker", 3 + "short_name": "Arabica", 4 + "description": "Track your coffee brewing journey", 5 + "start_url": "/", 6 + "display": "standalone", 7 + "background_color": "#4a2c2a", 8 + "theme_color": "#4a2c2a", 9 + "orientation": "portrait", 10 + "icons": [ 11 + { 12 + "src": "/static/icon-192.png", 13 + "sizes": "192x192", 14 + "type": "image/png", 15 + "purpose": "any maskable" 16 + }, 17 + { 18 + "src": "/static/icon-512.png", 19 + "sizes": "512x512", 20 + "type": "image/png", 21 + "purpose": "any maskable" 22 + } 23 + ] 24 + }
+46
web/static/service-worker.js
··· 1 + const CACHE_NAME = 'arabica-v1'; 2 + const urlsToCache = [ 3 + '/', 4 + '/static/css/style.css', 5 + 'https://unpkg.com/htmx.org@1.9.10', 6 + 'https://unpkg.com/alpinejs@3.13.3/dist/cdn.min.js' 7 + ]; 8 + 9 + // Install service worker and cache resources 10 + self.addEventListener('install', (event) => { 11 + event.waitUntil( 12 + caches.open(CACHE_NAME) 13 + .then((cache) => cache.addAll(urlsToCache)) 14 + ); 15 + }); 16 + 17 + // Fetch from cache, fallback to network 18 + self.addEventListener('fetch', (event) => { 19 + event.respondWith( 20 + caches.match(event.request) 21 + .then((response) => { 22 + // Cache hit - return response 23 + if (response) { 24 + return response; 25 + } 26 + return fetch(event.request); 27 + } 28 + ) 29 + ); 30 + }); 31 + 32 + // Update service worker 33 + self.addEventListener('activate', (event) => { 34 + const cacheWhitelist = [CACHE_NAME]; 35 + event.waitUntil( 36 + caches.keys().then((cacheNames) => { 37 + return Promise.all( 38 + cacheNames.map((cacheName) => { 39 + if (cacheWhitelist.indexOf(cacheName) === -1) { 40 + return caches.delete(cacheName); 41 + } 42 + }) 43 + ); 44 + }) 45 + ); 46 + });