├── .github └── workflows │ ├── notifications.yml │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── desktop-dark-main.png ├── desktop-dark-settings.png ├── desktop-dark-table.png ├── desktop-light-main.png ├── desktop-light-settings.png ├── desktop-light-table.png ├── logo.png ├── mobile-dark-main.png ├── mobile-dark-settings.png ├── mobile-dark-table.png ├── mobile-light-main.png ├── mobile-light-settings.png └── mobile-light-table.png ├── cmd └── expenseowl │ └── main.go ├── go.mod ├── go.sum ├── internal ├── api │ ├── handlers.go │ └── import-export.go ├── config │ └── config.go ├── storage │ └── storage.go └── web │ ├── embed.go │ └── templates │ ├── chart.min.js │ ├── fa.min.css │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ ├── pwa │ ├── icon-192.png │ └── icon-512.png │ ├── settings.html │ ├── style.css │ ├── sw.js │ ├── table.html │ └── webfonts │ ├── fa-brands-400.woff2 │ ├── fa-regular-400.woff2 │ ├── fa-solid-900.woff2 │ └── fa-v4compatibility.woff2 ├── kubernetes ├── Expenseowl-Deployment.yml ├── Expenseowl-configmap.yml ├── Expenseowl-ingress.yml ├── Expenseowl-pvc.yml ├── Expenseowl-svc.yml ├── README.md └── _namespace.yml └── scripts ├── mock-data-populate.sh └── static-downloader.sh /.github/workflows/notifications.yml: -------------------------------------------------------------------------------- 1 | name: Custom Notifications 2 | on: 3 | schedule: 4 | - cron: '30 17 * * 6' # 5:30 pm UTC every saturday 5 | issues: 6 | types: [opened, edited, deleted, closed] 7 | issue_comment: 8 | types: [created] 9 | workflow_run: 10 | workflows: ["Release"] 11 | types: [completed] 12 | pull_request_target: 13 | types: [opened, closed, edited, review_requested] 14 | 15 | jobs: 16 | weekly-summary: 17 | if: github.event_name == 'schedule' 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Calculate Summary 21 | run: | 22 | REPO="${{ github.repository }}" 23 | STARS=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$REPO" | jq .stargazers_count) 24 | FORKS=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$REPO" | jq .forks_count) 25 | COMMITS=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 26 | "https://api.github.com/repos/$REPO/commits?since=$(date -u -d 'last saturday' '+%Y-%m-%dT%H:%M:%SZ')" | jq length) 27 | curl -H "Content-Type: application/json" -X POST \ 28 | -d "{\"content\": \"*Weekly summary for **$REPO***\nStars - $STARS, Forks - $FORKS, Commits this week - $COMMITS\"}" ${{ secrets.DISCORD_WEBHOOK }} 29 | 30 | issue-comment-notification: 31 | if: github.event_name == 'issues' || github.event_name == 'issue_comment' 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Notify on Issue or Comment 35 | if: github.actor != 'Tanq16' 36 | run: | 37 | curl -H "Content-Type: application/json" -X POST \ 38 | -d "{\"content\": \"*New issue/comment from **${{ github.actor }}***\n${{ github.event.issue.html_url }}\"}" ${{ secrets.DISCORD_WEBHOOK }} 39 | 40 | build-status-notification: 41 | if: github.event_name == 'workflow_run' 42 | runs-on: ubuntu-latest 43 | steps: 44 | - name: Notify on Build Status 45 | run: | 46 | curl -H "Content-Type: application/json" -X POST \ 47 | -d "{\"content\": \"*Workflow run for **${{ github.repository }}***\n${{ github.event.workflow_run.name }} - ${{ github.event.workflow_run.conclusion }}\"}" ${{ secrets.DISCORD_WEBHOOK }} 48 | 49 | pull-request-notification: 50 | if: github.event_name == 'pull_request_target' 51 | runs-on: ubuntu-latest 52 | steps: 53 | - name: Notify on PR related activities 54 | if: github.actor != 'Tanq16' 55 | run: | 56 | curl -H "Content-Type: application/json" -X POST \ 57 | -d "{\"content\": \"*New PR activity from **${{ github.actor }}***\n${{ github.event.pull_request.html_url }}\"}" ${{ secrets.DISCORD_WEBHOOK }} 58 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | create-release: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | version: ${{ steps.version.outputs.new_version }} 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | - name: Determine Version 22 | id: version 23 | run: | 24 | # Get the latest version tag, default to v0.1 if none exists 25 | LATEST_TAG=$(gh release list -L 1 | cut -f 1 | sed 's/Release //' || echo "v0.0") 26 | LATEST_TAG=${LATEST_TAG:-v0.0} 27 | 28 | # Extract current version numbers 29 | MAJOR=$(echo $LATEST_TAG | cut -d. -f1 | sed 's/v//') 30 | MINOR=$(echo $LATEST_TAG | cut -d. -f2) 31 | 32 | # Check commit message for version bump 33 | if git log -1 --pretty=%B | grep -i "version bump"; then 34 | NEW_VERSION="v$((MAJOR + 1)).0" 35 | else 36 | NEW_VERSION="v$MAJOR.$((MINOR + 1))" 37 | fi 38 | 39 | echo "Previous version: $LATEST_TAG" 40 | echo "New version: $NEW_VERSION" 41 | echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" 42 | env: 43 | GH_TOKEN: ${{ github.token }} 44 | - name: Create Release 45 | id: create_release 46 | run: | 47 | gh release create "${{ steps.version.outputs.new_version }}" \ 48 | --title "Release ${{ steps.version.outputs.new_version }}" \ 49 | --draft \ 50 | --notes "Release ${{ steps.version.outputs.new_version }}" \ 51 | --target ${{ github.sha }} 52 | env: 53 | GH_TOKEN: ${{ github.token }} 54 | 55 | build: 56 | needs: create-release 57 | runs-on: ubuntu-latest 58 | strategy: 59 | matrix: 60 | os: [linux, windows, darwin] 61 | arch: [amd64, arm64] 62 | steps: 63 | - uses: actions/checkout@v4 64 | - name: Set up Go 65 | uses: actions/setup-go@v5 66 | with: 67 | go-version: '1.23' 68 | - name: Build Binary 69 | run: | 70 | GOOS=${{ matrix.os }} GOARCH=${{ matrix.arch }} go build -o expenseowl-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} ./cmd/expenseowl 71 | - name: Upload Release Asset 72 | run: | 73 | gh release upload "${{ needs.create-release.outputs.version }}" \ 74 | "expenseowl-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }}" 75 | env: 76 | GH_TOKEN: ${{ github.token }} 77 | 78 | publish: 79 | needs: [create-release, build] 80 | runs-on: ubuntu-latest 81 | steps: 82 | - uses: actions/checkout@v4 83 | - name: Publish Release 84 | run: | 85 | gh release edit "${{ needs.create-release.outputs.version }}" --draft=false 86 | env: 87 | GH_TOKEN: ${{ github.token }} 88 | 89 | docker: 90 | needs: [create-release, build, publish] 91 | runs-on: ubuntu-latest 92 | steps: 93 | - name: Checkout 94 | uses: actions/checkout@v4 95 | - name: Set up QEMU 96 | uses: docker/setup-qemu-action@v3 97 | - name: Set up Docker Buildx 98 | uses: docker/setup-buildx-action@v3 99 | - name: Login to Docker Hub 100 | uses: docker/login-action@v3 101 | with: 102 | username: tanq16 103 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 104 | - name: Build and push 105 | uses: docker/build-push-action@v5 106 | with: 107 | context: . 108 | platforms: linux/amd64,linux/arm64 109 | push: true 110 | tags: | 111 | tanq16/expenseowl:main 112 | tanq16/expenseowl:${{ needs.create-release.outputs.version }} 113 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | main 3 | .DS_Store 4 | .vscode 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | # Build the application 8 | RUN go build -o expenseowl ./cmd/expenseowl 9 | 10 | # Use a minimal alpine image for running 11 | FROM alpine:latest 12 | 13 | WORKDIR /app 14 | 15 | # Create data directory if not exists 16 | RUN mkdir -p /app/data 17 | 18 | # Copy the binary from builder 19 | COPY --from=builder /app/expenseowl . 20 | 21 | # Expose the default port 22 | EXPOSE 8080 23 | 24 | # Run the server 25 | CMD ["./expenseowl"] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Tanishq Rupaal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | ExpenseOwl Logo
3 |

4 | 5 |

ExpenseOwl


6 | 7 |

8 | Release GitHub Release Docker Pulls 9 |

10 | 11 |

12 | Why Create This? • Features • Screenshots
Installation • Usage • Contributing • Tech Stack 13 |

14 | 15 |
16 | 17 |

18 | ExpenseOwl is an extremely simple self-hosted expense tracking system with a modern monthly pie-chart visualization and cashflow showcase. 19 |

20 | 21 |
22 | 23 | # Why Create This? 24 | 25 | There are a ton of amazing projects for expense tracking across GitHub ([Actual](https://github.com/actualbudget/actual), [Firefly III](https://github.com/firefly-iii/firefly-iii), etc.). They're all really incredible! I just find that they aren't the *fastest* or *simplest* to add expenses. They also offer too many features I never use (like varying data formats or complex budgeting). *Don't get me wrong*, they're amazing when complexity is needed, but I wanted something ***dead simple*** that only gives me a quick monthly pie chart and a tabular representation. NOTHING else! 26 | 27 | That's why I created this project and I use it in my home lab for my expense tracking. The intention is to track spending across your categories in a simplistic manner. No complicated searching or editing - just `add`, `delete`, and `view`! This intention will not change throughout the project's lifecycle. This is *not* an app for budgeting; it's for straightforward tracking. 28 | 29 | # Features 30 | 31 | ### Core Functionality 32 | 33 | - Expense tracking with essential details only (optional name, date, amount, and category) 34 | - Flat file storage system (`data/expenses.json`) 35 | - REST API for expense management 36 | - Single-user focused (mainly for a home lab deployment) 37 | - CSV and JSON export and import of all expense data from the UI 38 | - Custom categories, currency symbol, and start date via app settings 39 | - Beautiful interface that automatically adapts to system for light/dark theme 40 | - UUID-based expense identification in the backend 41 | - Self-contained binary and container image to ensure no internet interaction 42 | - Multi-architecture Docker container with support for persistent storage 43 | 44 | ### Visualization 45 | 46 | 1. Main dashboard - category breakdown (pie chart) 47 | - Click on a category to exclude it from the graph and total; click again to add it back 48 | - This helps visualize the breakdown without considering some categories like Rent 49 | - The legend shows categories that make up the the total expenditure of the month 50 | 2. Main dashboard - cashflow indicator 51 | - The default settings have an `Income` category, items in which are not considered expenses 52 | - If a month has an item in `Income`, ExpenseOwl automatically shows cashflow below the graph 53 | - Cashflow shows total income, total expenses, and balance (red or green based on +ve or -ve) 54 | 3. Table view for detailed expense listing 55 | - This is where you can view individual expenses chronologically and delete them 56 | - You can use the browser's search to find a name if needed 57 | 4. Month-by-month navigation in both dashboard and table views 58 | 5. Settings page for configuring the application 59 | - Reorder, add, or remove custom categories 60 | - Select a custom currency to display 61 | - Select a custom start date to show expenses for a different period 62 | - Exporting data as CSV or JSON and import data from JSON or CSV 63 | 64 | ### Progressive Web App (PWA) 65 | 66 | The front end of ExpenseOwl can be installed as a Progressive Web App on desktop and mobile devices (i.e., the back end still needs to be self-hosted). To install: 67 | 68 | - Desktop: Click the install icon in your browser's address bar 69 | - iOS: Use Safari's "Add to Home Screen" option in the share menu 70 | - Android: Use Chrome's "Install" option in the menu 71 | 72 | # Screenshots 73 | 74 | Dashboard Showcase: 75 | 76 | | | Desktop View | Mobile View | 77 | | --- | --- | --- | 78 | | Dark | Dashboard Dark | Mobile Dashboard Dark | 79 | | Light | Dashboard Light | Mobile Dashboard Light | 80 | 81 |
82 | Expand this to see screenshots of other pages 83 | 84 | | | Desktop View | Mobile View | 85 | | --- | --- | --- | 86 | | Table Dark | Dashboard Dark | Mobile Dashboard Dark | 87 | | Table Light | Dashboard Light | Mobile Dashboard Light | 88 | | Settings Dark | Table Dark | Mobile Table Dark | 89 | | Settings Light | Table Light | Mobile Table Light | 90 | 91 |
92 | 93 | # Installation 94 | 95 | ### Docker Installation (Recommended) 96 | 97 | Create a volume or a directory for the project: 98 | 99 | ```bash 100 | mkdir $HOME/expenseowl 101 | ``` 102 | 103 | ```bash 104 | docker run --rm -d \ 105 | --name expenseowl \ 106 | -p 8080:8080 \ 107 | -v $HOME/expenseowl:/app/data \ 108 | tanq16/expenseowl:main 109 | ``` 110 | 111 | To use it with Docker compose or a container-management system like Portainer or Dockge, use this YAML definition: 112 | 113 | ```yaml 114 | services: 115 | budgetlord: 116 | image: tanq16/expenseowl:main 117 | restart: unless-stopped 118 | ports: 119 | - 5006:8080 120 | volumes: 121 | - /home/tanq/expenseowl:/app/data # CHANGE DIR 122 | ``` 123 | 124 | ### Using the Binary 125 | 126 | Download the appropriate binary from the project releases. Running the binary automatically sets up a `data` directory in your CWD. You can visit the frontend at `http://localhost:8080`. 127 | 128 | ### Building from Source 129 | 130 | To directly install the binary from source into your GOBIN, use: 131 | 132 | ```bash 133 | go install github.com/tanq16/expenseowl/cmd/expenseowl@latest 134 | ``` 135 | 136 | Otherwise, to build it yourself: 137 | 138 | ```bash 139 | git clone https://github.com/tanq16/expenseowl.git && \ 140 | cd expenseowl && \ 141 | go build ./cmd/expenseowl 142 | ``` 143 | 144 | ### Kubernetes Deployment 145 | 146 | The project also has a community-contributed Kubernetes spec. The spec is a sample and you should review it before deploying in your cluster. Read the [associated readme](./kubernetes/README.md) for more information. 147 | 148 | # Usage 149 | 150 | Once deployed, use the web interface to do everything. Access it through your browser: 151 | 152 | - Dashboard: `http://localhost:8080/` 153 | - Table View: `http://localhost:8080/table` 154 | 155 | > [!NOTE] 156 | > This app has no authentication, so deploy carefully. It works very well with a reverse proxy like Nginx Proxy Manager and is mainly intended for homelab use. The app has not undergone a pentest to allow for any production deployment. It should strictly be deployed in a home lab setting, behind authentication, and for only one (or a few non-destructive) user(s). 157 | 158 | If command-line automations are required for use with the REST API, read on! 159 | 160 | ### Executable 161 | 162 | The application binary can be run directly within CLI for any common OS and architecture: 163 | 164 | ```bash 165 | ./expenseowl 166 | # or from a custom directory 167 | ./expenseowl -data /custom/path 168 | ``` 169 | 170 | ### REST API 171 | 172 | ExpenseOwl provides an API to allow adding expenses via automations or simply via cURL, Siri Shortcuts, or other automations. 173 | 174 | Add Expense: 175 | 176 | ```bash 177 | curl -X PUT http://localhost:8080/expense \ 178 | -H "Content-Type: application/json" \ 179 | -d '{ 180 | "name": "Groceries", 181 | "category": "Food", 182 | "amount": 75.50, 183 | "date": "2024-03-15T14:30:00Z" 184 | }' 185 | ``` 186 | 187 | Get All Expenses: 188 | 189 | ```bash 190 | curl http://localhost:8080/expenses 191 | ``` 192 | 193 | ### Config Options 194 | 195 | The primary config is stored in the data directory in the `config.json` file. A pre-defined configuration is automatically initialized. The currency in use and the categories can be customized from the `/settings` endpoint within the UI. 196 | 197 | ExpenseOwl supports multiple currencies through the CURRENCY environment variable. If not specified, it defaults to USD ($). All available options are shown in the UI settings page. 198 | 199 | If setting up for the first time, an environment variable can be used for ease. For example, to use Euro: 200 | 201 | ```bash 202 | CURRENCY=eur ./expenseowl 203 | ``` 204 | 205 | ExpenseOwl also supports custom categories. A default set is pre-loaded in the config for ease of use and can be easily changed within the UI. 206 | 207 | Like currency, if setting up for the first time, categories can be specified in an environment variable like so: 208 | 209 | ```bash 210 | EXPENSE_CATEGORIES="Rent,Food,Transport,Fun,Bills" ./expenseowl 211 | ``` 212 | 213 | > [!TIP] 214 | > The environment variables can be set in a compose stack or using `-e` in the command line with a Docker command. However, remember that they are only effective in setting up the configuration for first start. Otherwise, use the settings UI. 215 | 216 | Similarly, the start date can also be set via the settings UI or the `START_DATE` environment variable. 217 | 218 | ### Data Import/Export 219 | 220 | ExpenseOwl contains a sophisticated method for importing an exporting expenses. The settings page provides the options for exporting all expense data as JSON or CSV. The same page also allows importing data in both JSON and CSV formats. 221 | 222 | **Importing CSV** 223 | 224 | ExpenseOwl is meant to make things simple, and importing CSV abides by the same philosophy. ExpenseOwl will accept any CSV file as long as it contains the columns - `name`, `category`, `amount`, and `date`. This is case-insensitive so `name` or `Name` doesn't matter. 225 | 226 | > [!TIP] 227 | > This feature allows ExpenseOwl to use exported data from any tool as long as the required categories are present, making it insanely easy to shift from any provider. 228 | 229 | **Importing JSON** 230 | 231 | Primarily, ExpenseOwl maintains a JSON-backend for storing both expenses and config data. If you backed up a Docker volume containing the `config.json` and `expenses.json` files, the recommended way to restore is by mounting the same volume (or directory) to your new container. All data will be instantly usable. 232 | 233 | However, in case you need to import JSON formatted data from elsewhere (this is generally rare), you can use the import JSON feature. 234 | 235 | > [!WARNING] 236 | > If the time field is not a proper date string (i.e., including time and zone), ExpenseOwl will do a best guess match to set the time to midnight UTC equivalent. This is because time zones are a ... thing. 237 | 238 | > [!NOTE] 239 | > ExpenseOwl goes through every row in the imported data, and will intelligently fail on rows that have invalid or absent data. There is a 10 millisecond delay per record to reduce disk overhead, so please allow appropriate time for ingestion (eg. 10 seconds for 1000 records). 240 | 241 | # Contributing 242 | 243 | Contributions are welcome; please ensure they align with the project's philosophy of maintaining simplicity by strictly using the current [tech stack](#technology-stack). It is intended for home lab use, i.e., a self-hosted first approach (containerized use). Consider the following: 244 | 245 | - Additions should have sensible defaults without breaking foundations 246 | - Environment variables can be used for user configuration in containers 247 | - Found a typo or need to ask a question? Please open an issue instead of a PR 248 | 249 | # Technology Stack 250 | 251 | - Backend: Go 252 | - Storage: JSON file system 253 | - Frontend: Chart.js and vanilla web stack (HTML, JS, CSS) 254 | - Interface: CLI + Web UI 255 | -------------------------------------------------------------------------------- /assets/desktop-dark-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/desktop-dark-main.png -------------------------------------------------------------------------------- /assets/desktop-dark-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/desktop-dark-settings.png -------------------------------------------------------------------------------- /assets/desktop-dark-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/desktop-dark-table.png -------------------------------------------------------------------------------- /assets/desktop-light-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/desktop-light-main.png -------------------------------------------------------------------------------- /assets/desktop-light-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/desktop-light-settings.png -------------------------------------------------------------------------------- /assets/desktop-light-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/desktop-light-table.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/logo.png -------------------------------------------------------------------------------- /assets/mobile-dark-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/mobile-dark-main.png -------------------------------------------------------------------------------- /assets/mobile-dark-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/mobile-dark-settings.png -------------------------------------------------------------------------------- /assets/mobile-dark-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/mobile-dark-table.png -------------------------------------------------------------------------------- /assets/mobile-light-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/mobile-light-main.png -------------------------------------------------------------------------------- /assets/mobile-light-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/mobile-light-settings.png -------------------------------------------------------------------------------- /assets/mobile-light-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/assets/mobile-light-table.png -------------------------------------------------------------------------------- /cmd/expenseowl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "net/http" 7 | "path/filepath" 8 | 9 | "github.com/tanq16/expenseowl/internal/api" 10 | "github.com/tanq16/expenseowl/internal/config" 11 | "github.com/tanq16/expenseowl/internal/storage" 12 | "github.com/tanq16/expenseowl/internal/web" 13 | ) 14 | 15 | func runServer(dataPath string) { 16 | cfg := config.NewConfig(dataPath) 17 | storage, err := storage.New(filepath.Join(cfg.StoragePath, "expenses.json")) 18 | if err != nil { 19 | log.Fatalf("Failed to initialize storage: %v", err) 20 | } 21 | 22 | handler := api.NewHandler(storage, cfg) 23 | http.HandleFunc("/categories", handler.GetCategories) 24 | http.HandleFunc("/categories/edit", handler.EditCategories) 25 | http.HandleFunc("/currency", handler.EditCurrency) 26 | http.HandleFunc("/startdate", handler.EditStartDate) 27 | http.HandleFunc("/expense", handler.AddExpense) 28 | http.HandleFunc("/expenses", handler.GetExpenses) 29 | http.HandleFunc("/expense/edit", handler.EditExpense) 30 | http.HandleFunc("/table", handler.ServeTableView) 31 | http.HandleFunc("/settings", handler.ServeSettingsPage) 32 | http.HandleFunc("/expense/delete", handler.DeleteExpense) 33 | http.HandleFunc("/export/json", handler.ExportJSON) 34 | http.HandleFunc("/import/csv", handler.ImportCSV) 35 | http.HandleFunc("/import/json", handler.ImportJSON) 36 | http.HandleFunc("/export/csv", handler.ExportCSV) 37 | http.HandleFunc("/manifest.json", handler.ServeStaticFile) 38 | http.HandleFunc("/sw.js", handler.ServeStaticFile) 39 | http.HandleFunc("/pwa/", handler.ServeStaticFile) 40 | http.HandleFunc("/style.css", handler.ServeStaticFile) 41 | http.HandleFunc("/favicon.ico", handler.ServeStaticFile) 42 | http.HandleFunc("/chart.min.js", handler.ServeStaticFile) 43 | http.HandleFunc("/fa.min.css", handler.ServeStaticFile) 44 | http.HandleFunc("/webfonts/", handler.ServeStaticFile) 45 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 46 | if r.URL.Path != "/" { 47 | http.NotFound(w, r) 48 | return 49 | } 50 | w.Header().Set("Content-Type", "text/html") 51 | if err := web.ServeTemplate(w, "index.html"); err != nil { 52 | log.Printf("HTTP ERROR: Failed to serve template: %v", err) 53 | http.Error(w, "Failed to serve template", http.StatusInternalServerError) 54 | return 55 | } 56 | }) 57 | log.Printf("Starting server on port %s...\n", cfg.ServerPort) 58 | if err := http.ListenAndServe(":"+cfg.ServerPort, nil); err != nil { 59 | log.Fatalf("Server failed to start: %v", err) 60 | } 61 | } 62 | 63 | func main() { 64 | dataPath := flag.String("data", "data", "Path to data directory") 65 | flag.Parse() 66 | runServer(*dataPath) 67 | } 68 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tanq16/expenseowl 2 | 3 | go 1.23.2 4 | 5 | require github.com/google/uuid v1.6.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 2 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | -------------------------------------------------------------------------------- /internal/api/handlers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/tanq16/expenseowl/internal/config" 10 | "github.com/tanq16/expenseowl/internal/storage" 11 | "github.com/tanq16/expenseowl/internal/web" 12 | ) 13 | 14 | type Handler struct { 15 | storage storage.Storage 16 | config *config.Config 17 | } 18 | 19 | func NewHandler(s storage.Storage, cfg *config.Config) *Handler { 20 | return &Handler{ 21 | storage: s, 22 | config: cfg, 23 | } 24 | } 25 | 26 | type ErrorResponse struct { 27 | Error string `json:"error"` 28 | } 29 | 30 | type ExpenseRequest struct { 31 | Name string `json:"name"` 32 | Category string `json:"category"` 33 | Amount float64 `json:"amount"` 34 | Date time.Time `json:"date"` 35 | } 36 | 37 | type ConfigResponse struct { 38 | Categories []string `json:"categories"` 39 | Currency string `json:"currency"` 40 | StartDate int `json:"startDate"` 41 | } 42 | 43 | func (h *Handler) GetCategories(w http.ResponseWriter, r *http.Request) { 44 | if r.Method != http.MethodGet { 45 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 46 | log.Println("HTTP ERROR: Method not allowed") 47 | return 48 | } 49 | response := ConfigResponse{ 50 | Categories: h.config.Categories, 51 | Currency: h.config.Currency, 52 | StartDate: h.config.StartDate, 53 | } 54 | writeJSON(w, http.StatusOK, response) 55 | } 56 | 57 | func (h *Handler) EditCategories(w http.ResponseWriter, r *http.Request) { 58 | if r.Method != http.MethodPut { 59 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 60 | log.Println("HTTP ERROR: Method not allowed") 61 | return 62 | } 63 | var categories []string 64 | if err := json.NewDecoder(r.Body).Decode(&categories); err != nil { 65 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"}) 66 | log.Printf("HTTP ERROR: Failed to decode request body: %v\n", err) 67 | return 68 | } 69 | h.config.UpdateCategories(categories) 70 | writeJSON(w, http.StatusOK, map[string]string{"status": "success"}) 71 | log.Println("HTTP: Updated categories") 72 | } 73 | 74 | func (h *Handler) EditCurrency(w http.ResponseWriter, r *http.Request) { 75 | if r.Method != http.MethodPut { 76 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 77 | log.Println("HTTP ERROR: Method not allowed") 78 | return 79 | } 80 | var currency string 81 | if err := json.NewDecoder(r.Body).Decode(¤cy); err != nil { 82 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"}) 83 | log.Printf("HTTP ERROR: Failed to decode request body: %v\n", err) 84 | return 85 | } 86 | h.config.UpdateCurrency(currency) 87 | writeJSON(w, http.StatusOK, map[string]string{"status": "success"}) 88 | log.Println("HTTP: Updated currency") 89 | } 90 | 91 | func (h *Handler) EditStartDate(w http.ResponseWriter, r *http.Request) { 92 | if r.Method != http.MethodPut { 93 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 94 | log.Println("HTTP ERROR: Method not allowed") 95 | return 96 | } 97 | var startDate int 98 | if err := json.NewDecoder(r.Body).Decode(&startDate); err != nil { 99 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"}) 100 | log.Printf("HTTP ERROR: Failed to decode request body: %v\n", err) 101 | return 102 | } 103 | h.config.UpdateStartDate(startDate) 104 | writeJSON(w, http.StatusOK, map[string]string{"status": "success"}) 105 | log.Println("HTTP: Updated start date") 106 | } 107 | 108 | func (h *Handler) AddExpense(w http.ResponseWriter, r *http.Request) { 109 | if r.Method != http.MethodPut { 110 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 111 | log.Println("HTTP ERROR: Method not allowed") 112 | return 113 | } 114 | var req ExpenseRequest 115 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 116 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"}) 117 | log.Printf("HTTP ERROR: Failed to decode request body: %v\n", err) 118 | return 119 | } 120 | if !req.Date.IsZero() { 121 | req.Date = req.Date.UTC() 122 | } 123 | expense := &config.Expense{ 124 | Name: req.Name, 125 | Category: req.Category, 126 | Amount: req.Amount, 127 | Date: req.Date, 128 | } 129 | if err := expense.Validate(); err != nil { 130 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: err.Error()}) 131 | log.Printf("HTTP ERROR: Failed to validate expense: %v\n", err) 132 | return 133 | } 134 | if err := h.storage.SaveExpense(expense); err != nil { 135 | writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Failed to save expense"}) 136 | log.Printf("HTTP ERROR: Failed to save expense: %v\n", err) 137 | return 138 | } 139 | writeJSON(w, http.StatusOK, expense) 140 | } 141 | 142 | func (h *Handler) EditExpense(w http.ResponseWriter, r *http.Request) { 143 | if r.Method != http.MethodPut { 144 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 145 | log.Println("HTTP ERROR: Method not allowed") 146 | return 147 | } 148 | id := r.URL.Query().Get("id") 149 | if id == "" { 150 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "ID parameter is required"}) 151 | log.Println("HTTP ERROR: ID parameter is required") 152 | return 153 | } 154 | var req ExpenseRequest 155 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 156 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Invalid request body"}) 157 | log.Printf("HTTP ERROR: Failed to decode request body: %v\n", err) 158 | return 159 | } 160 | if !req.Date.IsZero() { 161 | req.Date = req.Date.UTC() 162 | } 163 | expense := &config.Expense{ 164 | ID: id, 165 | Name: req.Name, 166 | Category: req.Category, 167 | Amount: req.Amount, 168 | Date: req.Date, 169 | } 170 | if err := expense.Validate(); err != nil { 171 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: err.Error()}) 172 | log.Printf("HTTP ERROR: Failed to validate expense: %v\n", err) 173 | return 174 | } 175 | if err := h.storage.EditExpense(expense); err != nil { 176 | if err == storage.ErrExpenseNotFound { 177 | writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "Expense not found"}) 178 | return 179 | } 180 | writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Failed to edit expense"}) 181 | log.Printf("HTTP ERROR: Failed to edit expense: %v\n", err) 182 | return 183 | } 184 | writeJSON(w, http.StatusOK, expense) 185 | log.Printf("HTTP: Edited expense with ID %s\n", id) 186 | } 187 | 188 | func (h *Handler) GetExpenses(w http.ResponseWriter, r *http.Request) { 189 | if r.Method != http.MethodGet { 190 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 191 | log.Println("HTTP ERROR: Method not allowed") 192 | return 193 | } 194 | expenses, err := h.storage.GetAllExpenses() 195 | if err != nil { 196 | writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Failed to retrieve expenses"}) 197 | log.Printf("HTTP ERROR: Failed to retrieve expenses: %v\n", err) 198 | return 199 | } 200 | writeJSON(w, http.StatusOK, expenses) 201 | } 202 | 203 | func (h *Handler) ServeTableView(w http.ResponseWriter, r *http.Request) { 204 | if r.Method != http.MethodGet { 205 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 206 | log.Println("HTTP ERROR: Method not allowed") 207 | return 208 | } 209 | w.Header().Set("Content-Type", "text/html") 210 | if err := web.ServeTemplate(w, "table.html"); err != nil { 211 | http.Error(w, "Failed to serve template", http.StatusInternalServerError) 212 | log.Printf("HTTP ERROR: Failed to serve template: %v\n", err) 213 | return 214 | } 215 | } 216 | 217 | func (h *Handler) ServeSettingsPage(w http.ResponseWriter, r *http.Request) { 218 | if r.Method != http.MethodGet { 219 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 220 | log.Println("HTTP ERROR: Method not allowed") 221 | return 222 | } 223 | w.Header().Set("Content-Type", "text/html") 224 | if err := web.ServeTemplate(w, "settings.html"); err != nil { 225 | http.Error(w, "Failed to serve template", http.StatusInternalServerError) 226 | log.Printf("HTTP ERROR: Failed to serve template: %v\n", err) 227 | return 228 | } 229 | } 230 | 231 | func (h *Handler) DeleteExpense(w http.ResponseWriter, r *http.Request) { 232 | if r.Method != http.MethodDelete { 233 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 234 | log.Println("HTTP ERROR: Method not allowed") 235 | return 236 | } 237 | id := r.URL.Query().Get("id") 238 | if id == "" { 239 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "ID parameter is required"}) 240 | log.Println("HTTP ERROR: ID parameter is required") 241 | return 242 | } 243 | if err := h.storage.DeleteExpense(id); err != nil { 244 | if err == storage.ErrExpenseNotFound { 245 | writeJSON(w, http.StatusNotFound, ErrorResponse{Error: "Expense not found"}) 246 | log.Printf("HTTP ERROR: Expense not found: %v\n", err) 247 | return 248 | } 249 | writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Failed to delete expense"}) 250 | log.Printf("HTTP ERROR: Failed to delete expense: %v\n", err) 251 | return 252 | } 253 | writeJSON(w, http.StatusOK, map[string]string{"status": "success"}) 254 | log.Printf("HTTP: Deleted expense with ID %s\n", id) 255 | } 256 | 257 | // Static Handler 258 | func (h *Handler) ServeStaticFile(w http.ResponseWriter, r *http.Request) { 259 | if r.Method != http.MethodGet { 260 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 261 | log.Println("HTTP ERROR: Method not allowed") 262 | return 263 | } 264 | if err := web.ServeStatic(w, r.URL.Path); err != nil { 265 | http.Error(w, "Failed to serve static file", http.StatusInternalServerError) 266 | log.Printf("HTTP ERROR: Failed to serve static file %s: %v\n", r.URL.Path, err) 267 | return 268 | } 269 | } 270 | 271 | func writeJSON(w http.ResponseWriter, status int, v interface{}) { 272 | w.Header().Set("Content-Type", "application/json") 273 | w.WriteHeader(status) 274 | json.NewEncoder(w).Encode(v) 275 | } 276 | -------------------------------------------------------------------------------- /internal/api/import-export.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/csv" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "regexp" 10 | "slices" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/tanq16/expenseowl/internal/config" 16 | ) 17 | 18 | func (h *Handler) ExportCSV(w http.ResponseWriter, r *http.Request) { 19 | if r.Method != http.MethodGet { 20 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 21 | log.Println("HTTP ERROR: Method not allowed") 22 | return 23 | } 24 | expenses, err := h.storage.GetAllExpenses() 25 | if err != nil { 26 | writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Failed to retrieve expenses"}) 27 | log.Printf("HTTP ERROR: Failed to retrieve expenses: %v\n", err) 28 | return 29 | } 30 | w.Header().Set("Content-Type", "text/csv") 31 | w.Header().Set("Content-Disposition", "attachment; filename=expenses.csv") 32 | // write CSV data 33 | w.Write([]byte("ID,Name,Category,Amount,Date\n")) 34 | for _, expense := range expenses { 35 | line := fmt.Sprintf("%s,%s,%s,%.2f,%s\n", 36 | expense.ID, 37 | strings.ReplaceAll(expense.Name, ",", ";"), // Replace , in name with ; 38 | expense.Category, 39 | expense.Amount, 40 | expense.Date.Format("2006-01-02 15:04:05"), 41 | ) 42 | w.Write([]byte(line)) 43 | } 44 | log.Println("HTTP: Exported expenses to CSV") 45 | } 46 | 47 | func (h *Handler) ExportJSON(w http.ResponseWriter, r *http.Request) { 48 | if r.Method != http.MethodGet { 49 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 50 | log.Println("HTTP ERROR: Method not allowed") 51 | return 52 | } 53 | expenses, err := h.storage.GetAllExpenses() 54 | if err != nil { 55 | writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "Failed to retrieve expenses"}) 56 | log.Printf("HTTP ERROR: Failed to retrieve expenses: %v\n", err) 57 | return 58 | } 59 | w.Header().Set("Content-Type", "application/json") 60 | w.Header().Set("Content-Disposition", "attachment; filename=expenses.json") 61 | // Pretty print the JSON data for better readability 62 | jsonData, err := json.MarshalIndent(expenses, "", " ") 63 | if err != nil { 64 | http.Error(w, "Failed to marshal JSON data", http.StatusInternalServerError) 65 | log.Printf("HTTP ERROR: Failed to marshal JSON data: %v\n", err) 66 | return 67 | } 68 | w.Write(jsonData) 69 | log.Println("HTTP: Exported expenses to JSON") 70 | } 71 | 72 | func (h *Handler) ImportCSV(w http.ResponseWriter, r *http.Request) { 73 | if r.Method != http.MethodPost { 74 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 75 | log.Println("HTTP ERROR: Method not allowed") 76 | return 77 | } 78 | err := r.ParseMultipartForm(10 << 20) // 10MB max 79 | if err != nil { 80 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Error parsing form"}) 81 | log.Printf("HTTP ERROR: Error parsing multipart form: %v\n", err) 82 | return 83 | } 84 | file, _, err := r.FormFile("file") 85 | if err != nil { 86 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Error retrieving file"}) 87 | log.Printf("HTTP ERROR: Error retrieving file from form: %v\n", err) 88 | return 89 | } 90 | defer file.Close() 91 | 92 | reader := csv.NewReader(file) 93 | records, err := reader.ReadAll() 94 | if err != nil { 95 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Error reading CSV file"}) 96 | log.Printf("HTTP ERROR: Error reading CSV file: %v\n", err) 97 | return 98 | } 99 | if len(records) < 2 { // header + at least one data row 100 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "CSV file has no data rows"}) 101 | log.Println("HTTP ERROR: CSV file is empty or has no data rows") 102 | return 103 | } 104 | stringEscape := regexp.MustCompile(`[^a-zA-Z0-9_ \.]*`) 105 | header := records[0] 106 | // Find the indices of required columns 107 | var nameIdx, categoryIdx, amountIdx, dateIdx int = -1, -1, -1, -1 108 | for i, col := range header { 109 | colLower := strings.ToLower(strings.TrimSpace(stringEscape.ReplaceAllString(col, ""))) 110 | switch colLower { 111 | case "name": 112 | nameIdx = i 113 | case "category": 114 | categoryIdx = i 115 | case "amount": 116 | amountIdx = i 117 | case "date": 118 | dateIdx = i 119 | } 120 | } 121 | if nameIdx == -1 || categoryIdx == -1 || amountIdx == -1 || dateIdx == -1 { 122 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "CSV missing required columns"}) 123 | log.Println("HTTP ERROR: CSV file missing required columns") 124 | return 125 | } 126 | 127 | // Get current categories as lowercase to match new ones and replace as needed 128 | categoryMap := make(map[string]string) 129 | for _, cat := range h.config.Categories { 130 | catLower := strings.ToLower(cat) 131 | categoryMap[catLower] = cat 132 | } 133 | // Process data rows 134 | imported := 0 135 | var newCategories []string 136 | for i, record := range records { 137 | if i == 0 { // Skip header 138 | continue 139 | } 140 | // Considering max to support any CSV as long as it has the required columns 141 | if len(record) <= slices.Max([]int{nameIdx, categoryIdx, amountIdx, dateIdx}) { 142 | log.Printf("Warning: Skipping row %d due to insufficient columns\n", i) 143 | continue 144 | } 145 | // Handle name 146 | name := strings.TrimSpace(stringEscape.ReplaceAllString(record[nameIdx], "")) 147 | if name == "" { 148 | name = "-" 149 | } 150 | // Handle category 151 | rawCategory := strings.TrimSpace(stringEscape.ReplaceAllString(record[categoryIdx], "")) 152 | if rawCategory == "" { 153 | log.Printf("Warning: Skipping row %d due to missing category\n", i) 154 | continue 155 | } 156 | categoryLower := strings.ToLower(rawCategory) 157 | category := rawCategory 158 | if normalized, exists := categoryMap[categoryLower]; exists { // Matching lowercase category 159 | category = normalized 160 | } else { // New category found 161 | categoryMap[categoryLower] = rawCategory // Add to map for future steps 162 | newCategories = append(newCategories, rawCategory) 163 | } 164 | // Handle amount (skipping regex since parsing as float) 165 | amount, err := strconv.ParseFloat(strings.TrimSpace(record[amountIdx]), 64) 166 | if err != nil || amount <= 0 { 167 | log.Printf("Warning: Skipping row %d due to invalid amount: %s\n", i, record[amountIdx]) 168 | continue 169 | } 170 | // Handle date (skipping regex since parsing as time) 171 | dateStr := strings.TrimSpace(record[dateIdx]) 172 | var date time.Time 173 | var parsedDate bool 174 | dateFormats := []string{ // Common date formats 175 | time.RFC3339, // 2006-01-02T15:04:05Z07:00 176 | "2006-01-02 15:04:05", // SQL format 177 | "2006-01-02", // ISO date 178 | "01/02/2006", // US date 179 | "02/01/2006", // European date 180 | "Jan 2, 2006", // Month name format 181 | "2 Jan 2006", // European month name 182 | "January 2, 2006", // Full month name 183 | "2006-01-02T15:04:05", // ISO without timezone 184 | } 185 | for _, format := range dateFormats { 186 | if d, err := time.Parse(format, dateStr); err == nil { 187 | date = d.UTC() 188 | parsedDate = true 189 | break 190 | } 191 | } 192 | if !parsedDate { 193 | log.Printf("Warning: Skipping row %d due to invalid date format: %s\n", i, dateStr) 194 | continue 195 | } 196 | // Save the expense 197 | expense := &config.Expense{ 198 | ID: "", // Ensure new ID value 199 | Name: name, 200 | Category: category, 201 | Amount: amount, 202 | Date: date, 203 | } 204 | 205 | if err := h.storage.SaveExpense(expense); err != nil { 206 | log.Printf("Error saving expense from row %d: %v\n", i, err) 207 | continue 208 | } 209 | imported++ 210 | // Throttle for storage - usually not needed but can avoid error for large files 211 | time.Sleep(10 * time.Millisecond) 212 | } 213 | 214 | // Update the config with new categories if any 215 | if len(newCategories) > 0 { 216 | updatedCategories := append(h.config.Categories, newCategories...) 217 | if err := h.config.UpdateCategories(updatedCategories); err != nil { 218 | log.Printf("Warning: Failed to update categories: %v\n", err) 219 | } 220 | } 221 | // Return success response with summary 222 | writeJSON(w, http.StatusOK, map[string]any{ 223 | "status": "success", 224 | "imported": imported, 225 | "new_categories": newCategories, 226 | "skipped": len(records) - 1 - imported, 227 | "total_processed": len(records) - 1, 228 | }) 229 | log.Printf("HTTP: Imported %d expenses from CSV file\n", imported) 230 | } 231 | 232 | func (h *Handler) ImportJSON(w http.ResponseWriter, r *http.Request) { 233 | if r.Method != http.MethodPost { 234 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 235 | log.Println("HTTP ERROR: Method not allowed") 236 | return 237 | } 238 | err := r.ParseMultipartForm(10 << 20) // 10MB max 239 | if err != nil { 240 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Error parsing form"}) 241 | log.Printf("HTTP ERROR: Error parsing multipart form: %v\n", err) 242 | return 243 | } 244 | file, _, err := r.FormFile("file") 245 | if err != nil { 246 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Error retrieving file"}) 247 | log.Printf("HTTP ERROR: Error retrieving file from form: %v\n", err) 248 | return 249 | } 250 | defer file.Close() 251 | 252 | var expenses []*config.Expense 253 | decoder := json.NewDecoder(file) 254 | if err := decoder.Decode(&expenses); err != nil { 255 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "Error parsing JSON file"}) 256 | log.Printf("HTTP ERROR: Error parsing JSON file: %v\n", err) 257 | return 258 | } 259 | if len(expenses) == 0 { 260 | writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "JSON file contains no expenses"}) 261 | log.Println("HTTP ERROR: JSON file contains no expenses") 262 | return 263 | } 264 | 265 | stringEscape := regexp.MustCompile(`[^a-zA-Z0-9_ \.]*`) 266 | // Get current categories as lowercase to match new ones and replace as needed 267 | categoryMap := make(map[string]string) 268 | for _, cat := range h.config.Categories { 269 | catLower := strings.ToLower(cat) 270 | categoryMap[catLower] = cat 271 | } 272 | // Process elements 273 | imported := 0 274 | var newCategories []string 275 | for i, expense := range expenses { 276 | // Handle data 277 | if expense.Name == "" { 278 | expense.Name = "-" 279 | } else { 280 | expense.Name = strings.TrimSpace(stringEscape.ReplaceAllString(expense.Name, "")) 281 | } 282 | if expense.Category == "" { 283 | log.Printf("Warning: Skipping expense %d due to missing category\n", i+1) 284 | continue 285 | } else { 286 | expense.Category = strings.TrimSpace(stringEscape.ReplaceAllString(expense.Category, "")) 287 | } 288 | if expense.Amount <= 0 { 289 | log.Printf("Warning: Skipping expense %d due to bad amount: %f\n", i+1, expense.Amount) 290 | continue 291 | } 292 | if expense.Date.IsZero() { 293 | log.Printf("Warning: Skipping expense %d due to missing date\n", i+1) 294 | continue 295 | } 296 | // Set date to UTC for consistency 297 | expense.Date = expense.Date.UTC() 298 | // Handle category 299 | categoryLower := strings.ToLower(expense.Category) 300 | if normalized, exists := categoryMap[categoryLower]; exists { 301 | expense.Category = normalized 302 | } else { 303 | categoryMap[categoryLower] = expense.Category // Add to map for future steps 304 | newCategories = append(newCategories, expense.Category) 305 | } 306 | 307 | // Save the expense 308 | expense.ID = "" // Ensure new ID value 309 | if err := h.storage.SaveExpense(expense); err != nil { 310 | log.Printf("Error saving expense %d: %v\n", i+1, err) 311 | continue 312 | } 313 | imported++ 314 | // Throttle for storage - usually not needed but can avoid error for large files 315 | time.Sleep(10 * time.Millisecond) 316 | } 317 | 318 | // Update the config with new categories if any 319 | if len(newCategories) > 0 { 320 | updatedCategories := append(h.config.Categories, newCategories...) 321 | if err := h.config.UpdateCategories(updatedCategories); err != nil { 322 | log.Printf("Warning: Failed to update categories: %v\n", err) 323 | } 324 | } 325 | // Return success response with summary 326 | writeJSON(w, http.StatusOK, map[string]interface{}{ 327 | "status": "success", 328 | "imported": imported, 329 | "new_categories": newCategories, 330 | "skipped": len(expenses) - imported, 331 | "total_processed": len(expenses), 332 | }) 333 | log.Printf("HTTP: Imported %d expenses from JSON file\n", imported) 334 | } 335 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | type Config struct { 16 | ServerPort string 17 | StoragePath string 18 | Categories []string 19 | Currency string 20 | StartDate int 21 | mu sync.RWMutex 22 | } 23 | 24 | type FileConfig struct { 25 | Categories []string `json:"categories"` 26 | Currency string `json:"currency"` 27 | StartDate int `json:"startDate"` 28 | } 29 | 30 | var defaultCategories = []string{ 31 | "Food", 32 | "Groceries", 33 | "Travel", 34 | "Rent", 35 | "Utilities", 36 | "Entertainment", 37 | "Healthcare", 38 | "Shopping", 39 | "Miscellaneous", 40 | "Income", 41 | } 42 | 43 | var currencySymbols = map[string]string{ 44 | "usd": "$", // US Dollar 45 | "eur": "€", // Euro 46 | "gbp": "£", // British Pound 47 | "jpy": "¥", // Japanese Yen 48 | "cny": "¥", // Chinese Yuan 49 | "krw": "₩", // Korean Won 50 | "inr": "₹", // Indian Rupee 51 | "rub": "₽", // Russian Ruble 52 | "brl": "R$", // Brazilian Real 53 | "zar": "R", // South African Rand 54 | "aed": "AED", // UAE Dirham 55 | "aud": "A$", // Australian Dollar 56 | "cad": "C$", // Canadian Dollar 57 | "chf": "Fr", // Swiss Franc 58 | "hkd": "HK$", // Hong Kong Dollar 59 | "sgd": "S$", // Singapore Dollar 60 | "thb": "฿", // Thai Baht 61 | "try": "₺", // Turkish Lira 62 | "mxn": "Mex$", // Mexican Peso 63 | "php": "₱", // Philippine Peso 64 | "pln": "zł", // Polish Złoty 65 | "sek": "kr", // Swedish Krona 66 | "nzd": "NZ$", // New Zealand Dollar 67 | "dkk": "kr.", // Danish Krone 68 | "idr": "Rp", // Indonesian Rupiah 69 | "ils": "₪", // Israeli New Shekel 70 | "vnd": "₫", // Vietnamese Dong 71 | "myr": "RM", // Malaysian Ringgit 72 | } 73 | 74 | type Expense struct { 75 | ID string `json:"id"` 76 | Name string `json:"name"` 77 | Category string `json:"category"` 78 | Amount float64 `json:"amount"` 79 | Date time.Time `json:"date"` 80 | } 81 | 82 | func (e *Expense) Validate() error { 83 | if e.Name == "" { 84 | return errors.New("expense name is required") 85 | } 86 | if e.Category == "" { 87 | return errors.New("category is required") 88 | } 89 | if e.Amount <= 0 { 90 | return errors.New("amount must be greater than 0") 91 | } 92 | return nil 93 | } 94 | 95 | func NewConfig(dataPath string) *Config { 96 | finalPath := "" 97 | if dataPath == "data" { 98 | finalPath = filepath.Join(".", "data") 99 | } else { 100 | finalPath = filepath.Clean(dataPath) 101 | } 102 | if err := os.MkdirAll(finalPath, 0755); err != nil { 103 | log.Printf("Error creating data directory: %v", err) 104 | } 105 | log.Printf("Using data directory: %s\n", finalPath) 106 | cfg := &Config{ 107 | ServerPort: "8080", 108 | StoragePath: finalPath, 109 | Categories: defaultCategories, 110 | StartDate: 1, 111 | Currency: "$", // Default to USD 112 | } 113 | configPath := filepath.Join(finalPath, "config.json") 114 | if _, err := os.Stat(configPath); os.IsNotExist(err) { 115 | log.Println("Configuration file not found, creating default configuration") 116 | if envCategories := os.Getenv("EXPENSE_CATEGORIES"); envCategories != "" { 117 | categories := strings.Split(envCategories, ",") 118 | for i := range categories { 119 | categories[i] = strings.TrimSpace(categories[i]) 120 | } 121 | cfg.Categories = categories 122 | log.Println("Using custom categories from environment variables") 123 | } 124 | if envCurrency := strings.ToLower(os.Getenv("CURRENCY")); envCurrency != "" { 125 | if symbol, exists := currencySymbols[envCurrency]; exists { 126 | cfg.Currency = symbol 127 | } 128 | log.Println("Using custom currency from environment variables") 129 | } 130 | if envStartDate := strings.ToLower(os.Getenv("START_DATE")); envStartDate != "" { 131 | startDate, err := strconv.Atoi(envStartDate) 132 | if err != nil { 133 | log.Println("START_DATE is not a number, using default (1)") 134 | } else { 135 | cfg.StartDate = startDate 136 | log.Println("using custom start date from environment variables") 137 | } 138 | } 139 | } else if fileConfig, err := loadConfigFile(configPath); err == nil { 140 | cfg.Categories = fileConfig.Categories 141 | cfg.Currency = fileConfig.Currency 142 | cfg.StartDate = fileConfig.StartDate 143 | log.Println("Loaded configuration from file") 144 | } 145 | cfg.SaveConfig() 146 | return cfg 147 | } 148 | 149 | func loadConfigFile(filePath string) (*FileConfig, error) { 150 | data, err := os.ReadFile(filePath) 151 | if err != nil { 152 | return nil, err 153 | } 154 | var config FileConfig 155 | if err := json.Unmarshal(data, &config); err != nil { 156 | return nil, err 157 | } 158 | return &config, nil 159 | } 160 | 161 | func (c *Config) SaveConfig() error { 162 | c.mu.Lock() 163 | defer c.mu.Unlock() 164 | filePath := filepath.Join(c.StoragePath, "config.json") 165 | fileConfig := FileConfig{ 166 | Categories: c.Categories, 167 | Currency: c.Currency, 168 | StartDate: c.StartDate, 169 | } 170 | data, err := json.MarshalIndent(fileConfig, "", " ") 171 | if err != nil { 172 | return err 173 | } 174 | return os.WriteFile(filePath, data, 0644) 175 | } 176 | 177 | func (c *Config) UpdateCategories(categories []string) error { 178 | c.mu.Lock() 179 | c.Categories = categories 180 | c.mu.Unlock() 181 | return c.SaveConfig() 182 | } 183 | 184 | func (c *Config) UpdateCurrency(currencyCode string) error { 185 | c.mu.Lock() 186 | if symbol, exists := currencySymbols[strings.ToLower(currencyCode)]; exists { 187 | c.Currency = symbol 188 | } else { 189 | c.mu.Unlock() 190 | return errors.New("invalid currency code") 191 | } 192 | c.mu.Unlock() 193 | return c.SaveConfig() 194 | } 195 | 196 | func (c *Config) UpdateStartDate(startDate int) error { 197 | c.mu.Lock() 198 | c.StartDate = max(min(startDate, 31), 1) 199 | c.mu.Unlock() 200 | return c.SaveConfig() 201 | } 202 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "sync" 11 | "time" 12 | 13 | "github.com/google/uuid" 14 | "github.com/tanq16/expenseowl/internal/config" 15 | ) 16 | 17 | var ( 18 | ErrExpenseNotFound = errors.New("expense not found") 19 | ErrInvalidExpense = errors.New("invalid expense data") 20 | ) 21 | 22 | type Storage interface { 23 | SaveExpense(expense *config.Expense) error 24 | GetAllExpenses() ([]*config.Expense, error) 25 | DeleteExpense(id string) error 26 | EditExpense(expense *config.Expense) error 27 | } 28 | 29 | type jsonStore struct { 30 | filePath string 31 | mu sync.RWMutex 32 | } 33 | 34 | type fileData struct { 35 | Expenses []*config.Expense `json:"expenses"` 36 | } 37 | 38 | func New(filePath string) (*jsonStore, error) { 39 | dir := filepath.Dir(filePath) 40 | if err := os.MkdirAll(dir, 0755); err != nil { 41 | return nil, fmt.Errorf("failed to create storage directory: %v", err) 42 | } 43 | if _, err := os.Stat(filePath); os.IsNotExist(err) { 44 | initialData := fileData{Expenses: []*config.Expense{}} 45 | data, err := json.Marshal(initialData) 46 | if err != nil { 47 | return nil, fmt.Errorf("failed to marshal initial data: %v", err) 48 | } 49 | if err := os.WriteFile(filePath, data, 0644); err != nil { 50 | return nil, fmt.Errorf("failed to create storage file: %v", err) 51 | } 52 | } 53 | log.Println("Created expense storage file") 54 | return &jsonStore{ 55 | filePath: filePath, 56 | }, nil 57 | } 58 | 59 | func (s *jsonStore) SaveExpense(expense *config.Expense) error { 60 | s.mu.Lock() 61 | defer s.mu.Unlock() 62 | data, err := s.readFile() 63 | if err != nil { 64 | return fmt.Errorf("failed to read storage file: %v", err) 65 | } 66 | if expense.ID == "" { 67 | expense.ID = uuid.New().String() 68 | } 69 | if expense.Date.IsZero() { 70 | expense.Date = time.Now() 71 | } 72 | data.Expenses = append(data.Expenses, expense) 73 | log.Printf("Added expense with ID %s\n", expense.ID) 74 | return s.writeFile(data) 75 | } 76 | 77 | func (s *jsonStore) DeleteExpense(id string) error { 78 | s.mu.Lock() 79 | defer s.mu.Unlock() 80 | data, err := s.readFile() 81 | if err != nil { 82 | return fmt.Errorf("failed to read storage file: %v", err) 83 | } 84 | found := false 85 | newExpenses := make([]*config.Expense, 0, len(data.Expenses)-1) 86 | for _, exp := range data.Expenses { 87 | if exp.ID != id { 88 | newExpenses = append(newExpenses, exp) 89 | } else { 90 | found = true 91 | } 92 | } 93 | // log.Printf("Looped to find expense with ID %s. Found: %v\n", id, found) 94 | if !found { 95 | return fmt.Errorf("expense with ID %s not found", id) 96 | } 97 | data.Expenses = newExpenses 98 | log.Printf("Deleted expense with ID %s\n", id) 99 | return s.writeFile(data) 100 | } 101 | 102 | func (s *jsonStore) EditExpense(expense *config.Expense) error { 103 | s.mu.Lock() 104 | defer s.mu.Unlock() 105 | data, err := s.readFile() 106 | if err != nil { 107 | return fmt.Errorf("failed to read storage file: %v", err) 108 | } 109 | found := false 110 | for i, exp := range data.Expenses { 111 | if exp.ID == expense.ID { 112 | expense.Date = exp.Date 113 | data.Expenses[i] = expense 114 | found = true 115 | break 116 | } 117 | } 118 | if !found { 119 | return ErrExpenseNotFound 120 | } 121 | log.Printf("Edited expense with ID %s\n", expense.ID) 122 | return s.writeFile(data) 123 | } 124 | 125 | func (s *jsonStore) GetAllExpenses() ([]*config.Expense, error) { 126 | s.mu.RLock() 127 | defer s.mu.RUnlock() 128 | data, err := s.readFile() 129 | if err != nil { 130 | return nil, fmt.Errorf("failed to read storage file: %v", err) 131 | } 132 | log.Println("Retrieved all expenses") 133 | return data.Expenses, nil 134 | } 135 | 136 | func (s *jsonStore) readFile() (*fileData, error) { 137 | content, err := os.ReadFile(s.filePath) 138 | if err != nil { 139 | return nil, err 140 | } 141 | var data fileData 142 | if err := json.Unmarshal(content, &data); err != nil { 143 | return nil, err 144 | } 145 | return &data, nil 146 | } 147 | 148 | func (s *jsonStore) writeFile(data *fileData) error { 149 | content, err := json.MarshalIndent(data, "", " ") 150 | if err != nil { 151 | return err 152 | } 153 | return os.WriteFile(s.filePath, content, 0644) 154 | } 155 | -------------------------------------------------------------------------------- /internal/web/embed.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "net/http" 6 | "path/filepath" 7 | ) 8 | 9 | //go:embed templates 10 | var content embed.FS 11 | 12 | func GetTemplates() *embed.FS { 13 | return &content 14 | } 15 | 16 | func ServeTemplate(w http.ResponseWriter, templateName string) error { 17 | templateContent, err := content.ReadFile("templates/" + templateName) 18 | if err != nil { 19 | return err 20 | } 21 | _, err = w.Write(templateContent) 22 | return err 23 | } 24 | 25 | func ServeStatic(w http.ResponseWriter, staticPath string) error { 26 | staticContent, err := content.ReadFile("templates" + staticPath) 27 | if err != nil { 28 | return err 29 | } 30 | ext := filepath.Ext(staticPath) 31 | switch ext { 32 | case ".js": 33 | w.Header().Set("Content-Type", "application/javascript") 34 | case ".css": 35 | w.Header().Set("Content-Type", "text/css") 36 | case ".woff", ".woff2": 37 | w.Header().Set("Content-Type", "font/"+ext[1:]) 38 | case ".ttf": 39 | w.Header().Set("Content-Type", "font/ttf") 40 | case ".eot": 41 | w.Header().Set("Content-Type", "application/vnd.ms-fontobject") 42 | case ".svg": 43 | w.Header().Set("Content-Type", "image/svg+xml") 44 | case ".png": 45 | w.Header().Set("Content-Type", "image/png") 46 | case ".ico": 47 | w.Header().Set("Content-Type", "image/x-icon") 48 | case ".json": 49 | w.Header().Set("Content-Type", "application/json") 50 | } 51 | _, err = w.Write(staticContent) 52 | return err 53 | } 54 | -------------------------------------------------------------------------------- /internal/web/templates/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/internal/web/templates/favicon.ico -------------------------------------------------------------------------------- /internal/web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 20 | ExpenseOwl Dashboard 21 | 22 | 23 | 24 |
25 |
26 | 27 | 41 |
42 | 43 |
44 | 45 |
46 | 47 |
48 | 49 |
50 |
51 | 52 |
53 |
54 | 55 |
56 |
57 | 58 | 72 | 73 |
74 |
75 |
76 | 77 | 78 |
79 | 80 |
81 | 82 | 85 |
86 | 87 |
88 | 89 | 90 |
91 | 92 |
93 | 94 | 95 | 104 |
105 | 106 | 107 |
108 |
109 |
110 |
111 |
112 | 494 | 495 | 496 | -------------------------------------------------------------------------------- /internal/web/templates/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ExpenseOwl", 3 | "short_name": "ExpenseOwl", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#1a1a1a", 7 | "theme_color": "#1a1a1a", 8 | "icons": [ 9 | { 10 | "src": "/pwa/icon-192.png", 11 | "sizes": "192x192", 12 | "type": "image/png", 13 | "purpose": "any maskable" 14 | }, 15 | { 16 | "src": "/pwa/icon-512.png", 17 | "sizes": "512x512", 18 | "type": "image/png", 19 | "purpose": "any maskable" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /internal/web/templates/pwa/icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/internal/web/templates/pwa/icon-192.png -------------------------------------------------------------------------------- /internal/web/templates/pwa/icon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/internal/web/templates/pwa/icon-512.png -------------------------------------------------------------------------------- /internal/web/templates/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ExpenseOwl Settings 9 | 10 | 11 |
12 |
13 | 14 | 28 |
29 | 30 | 31 |
32 |

Category Settings

33 |
34 |
35 | 36 |
37 |
38 | 39 | 40 |
41 | 42 |
43 |
44 |
45 | 46 |
47 | 48 |
49 |

Currency Settings

50 |
51 | 54 | 55 |
56 |
57 |
58 | 59 | 60 |
61 |

Start Date Settings

62 |
63 | 64 | 65 |
66 |
67 |
68 |
69 | 70 | 71 |
72 |

Import/Export Data

73 |
74 | 75 | Export CSV 76 | 77 | 78 | Export JSON 79 | 80 | 83 | 84 | 87 | 88 |
89 |
90 | 91 |
92 |
93 | 94 | 461 | 462 | 463 | -------------------------------------------------------------------------------- /internal/web/templates/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Dark mode - modern dark blue theme */ 3 | --bg-primary: #081528; 4 | --bg-secondary: #101f3b; 5 | --text-primary: #f4f4f4; 6 | --text-secondary: #b3b3b3; 7 | --border: #1a365d; 8 | --accent: #69afde; 9 | } 10 | 11 | @media (prefers-color-scheme: light) { 12 | :root { 13 | --bg-primary: #f2e5d7; 14 | --bg-secondary: #faecdd; 15 | --text-primary: #2e2e2e; 16 | --text-secondary: #3e3e3e; 17 | --border: #e6ddd4; 18 | --accent: #69afde; 19 | } 20 | } 21 | 22 | body { 23 | margin: 0; 24 | padding: 0; 25 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 26 | background-color: var(--bg-primary); 27 | color: var(--text-primary); 28 | line-height: 1.6; 29 | } 30 | 31 | .container { 32 | max-width: 1200px; 33 | margin: 0 auto; 34 | padding: 1rem; 35 | } 36 | 37 | header { 38 | margin-bottom: 1rem; 39 | border-bottom: 1px solid var(--border); 40 | } 41 | 42 | .month-navigation { 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | gap: 1rem; 47 | margin-bottom: 1rem; 48 | } 49 | 50 | .nav-button { 51 | background-color: var(--bg-primary); 52 | border: 1px solid var(--border); 53 | color: var(--text-primary); 54 | padding: 0.5rem 1rem; 55 | border-radius: 4px; 56 | cursor: pointer; 57 | transition: background-color 0.2s; 58 | white-space: nowrap; 59 | } 60 | 61 | @media (prefers-color-scheme: light) { 62 | #prevMonth, #nextMonth { 63 | background-color: var(--bg-secondary); 64 | } 65 | } 66 | 67 | .nav-button, .view-button { 68 | border-radius: 9999px; 69 | padding: 0.5rem 1.25rem; 70 | font-weight: 500; 71 | transition: all 0.2s ease; 72 | border: 1px solid var(--border); 73 | } 74 | 75 | .nav-button:hover { 76 | background-color: var(--accent); 77 | } 78 | 79 | .nav-button:hover, .view-button:hover { 80 | background-color: var(--accent); 81 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 82 | } 83 | 84 | .nav-button:disabled { 85 | opacity: 0.5; 86 | cursor: not-allowed; 87 | } 88 | 89 | .current-month { 90 | font-size: 1.5rem; 91 | font-weight: bold; 92 | min-width: 200px; 93 | text-align: center; 94 | } 95 | 96 | .chart-container { 97 | display: flex; 98 | gap: 1rem; 99 | margin-bottom: 1rem; 100 | background-color: var(--bg-secondary); 101 | border-radius: 8px; 102 | padding: 0.5rem; 103 | min-height: 450px; 104 | align-items: center; 105 | } 106 | 107 | .chart-box { 108 | flex: 1; 109 | height: 380px; 110 | display: flex; 111 | align-items: center; 112 | } 113 | 114 | .chart-box canvas { 115 | width: 100%; 116 | max-height: 380px; 117 | } 118 | 119 | .legend-item { 120 | display: flex; 121 | align-items: center; 122 | margin-bottom: 1rem; 123 | cursor: pointer; 124 | user-select: none; 125 | } 126 | 127 | .legend-item.disabled .color-box { 128 | background-color: #808080 !important; 129 | position: relative; 130 | } 131 | 132 | .legend-item.disabled .color-box::after { 133 | content: '×'; 134 | position: absolute; 135 | top: 50%; 136 | left: 50%; 137 | transform: translate(-50%, -50%); 138 | color: var(--text-primary); 139 | font-weight: bold; 140 | } 141 | 142 | .legend-item.disabled .legend-text { 143 | opacity: 0.5; 144 | } 145 | 146 | .color-box { 147 | width: 16px; 148 | height: 16px; 149 | margin-right: 1rem; 150 | border-radius: 3px; 151 | transition: background-color 0.3s ease; 152 | } 153 | 154 | .legend-box { 155 | flex: 1; 156 | padding: 0.5rem 2.5rem 0.5rem 0.5rem; 157 | display: flex; 158 | flex-direction: column; 159 | justify-content: center; 160 | } 161 | 162 | .legend-text { 163 | display: flex; 164 | justify-content: space-between; 165 | flex: 1; 166 | } 167 | 168 | .amount { 169 | font-family: monospace; 170 | color: var(--text-secondary); 171 | } 172 | 173 | .no-data { 174 | text-align: center; 175 | color: var(--text-secondary); 176 | font-style: italic; 177 | padding: 2rem; 178 | } 179 | 180 | .nav-bar { 181 | display: flex; 182 | justify-content: center; 183 | gap: 1rem; 184 | margin-bottom: 1rem; 185 | align-items: center; 186 | } 187 | 188 | .view-button { 189 | background-color: var(--bg-secondary); 190 | border: 1px solid var(--border); 191 | color: var(--text-primary); 192 | padding: 0.5rem 1rem; 193 | border-radius: 9999px; 194 | cursor: pointer; 195 | transition: background-color 0.2s; 196 | text-decoration: none; 197 | align-content: center; 198 | height: fit-content; 199 | } 200 | 201 | .view-button:hover { 202 | background-color: var(--accent); 203 | } 204 | 205 | .view-button.active { 206 | background-color: var(--accent); 207 | cursor: default; 208 | } 209 | 210 | .view-button { 211 | position: relative; 212 | } 213 | 214 | .view-button[data-tooltip]:hover::after { 215 | content: attr(data-tooltip); 216 | position: absolute; 217 | bottom: -30px; 218 | left: 50%; 219 | transform: translateX(-50%); 220 | padding: 4px 8px; 221 | background-color: var(--bg-secondary); 222 | color: var(--text-primary); 223 | border-radius: 4px; 224 | font-size: 0.875rem; 225 | white-space: nowrap; 226 | z-index: 10; 227 | border: 1px solid var(--border); 228 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 229 | pointer-events: none; 230 | opacity: 0; 231 | animation: tooltip-appear 0.1s ease forwards; 232 | } 233 | 234 | @keyframes tooltip-appear { 235 | to { 236 | opacity: 1; 237 | } 238 | } 239 | 240 | /* Table styles */ 241 | .expense-table { 242 | width: 100%; 243 | border-collapse: collapse; 244 | background-color: var(--bg-secondary); 245 | border-radius: 8px; 246 | overflow: hidden; 247 | } 248 | 249 | .expense-table th, 250 | .expense-table td { 251 | padding: 1rem; 252 | text-align: left; 253 | border-bottom: 1px solid var(--border); 254 | } 255 | 256 | .expense-table th { 257 | background-color: var(--bg-primary); 258 | font-weight: 600; 259 | } 260 | 261 | .expense-table tr:last-child td { 262 | border-bottom: none; 263 | } 264 | 265 | .date-column { 266 | color: var(--text-secondary); 267 | } 268 | 269 | .delete-button { 270 | background: none; 271 | border: none; 272 | color: var(--text-secondary); 273 | cursor: pointer; 274 | padding: 4px 8px; 275 | border-radius: 4px; 276 | transition: all 0.2s; 277 | } 278 | 279 | .delete-button { 280 | border-radius: 9999px; 281 | padding: 6px 10px; 282 | } 283 | 284 | .delete-button:hover { 285 | background-color: rgba(255, 99, 99, 0.1); 286 | color: #ff6b6b; 287 | } 288 | 289 | /* Modal styles */ 290 | .modal { 291 | display: none; 292 | position: fixed; 293 | top: 0; 294 | left: 0; 295 | width: 100%; 296 | height: 100%; 297 | background-color: rgba(0, 0, 0, 0.5); 298 | z-index: 1000; 299 | align-items: center; 300 | justify-content: center; 301 | } 302 | 303 | .modal.active { 304 | display: flex; 305 | } 306 | 307 | .modal-content { 308 | background-color: var(--bg-primary); 309 | padding: 2rem; 310 | border-radius: 8px; 311 | max-width: 400px; 312 | width: 90%; 313 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 314 | } 315 | 316 | .modal-buttons { 317 | display: flex; 318 | gap: 1rem; 319 | margin-top: 1.5rem; 320 | justify-content: flex-end; 321 | } 322 | 323 | .modal-button { 324 | padding: 0.5rem 1rem; 325 | border-radius: 4px; 326 | border: 1px solid var(--border); 327 | background-color: var(--bg-secondary); 328 | color: var(--text-primary); 329 | cursor: pointer; 330 | transition: all 0.2s; 331 | } 332 | 333 | .modal-button { 334 | border-radius: 9999px; 335 | padding: 0.5rem 1.25rem; 336 | font-weight: 500; 337 | transition: all 0.2s ease; 338 | } 339 | 340 | .modal-button:hover { 341 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 342 | } 343 | 344 | .modal-button.confirm { 345 | background-color: #ff6b6b; 346 | border-color: #ff6b6b; 347 | color: white; 348 | } 349 | 350 | .modal-button:hover { 351 | opacity: 0.9; 352 | } 353 | 354 | .categories-list { 355 | display: flex; 356 | flex-wrap: wrap; 357 | gap: 0.5rem; 358 | margin-bottom: 1rem; 359 | } 360 | 361 | .category-item { 362 | cursor: grab; 363 | } 364 | .category-item.dragging { 365 | opacity: 0.5; 366 | cursor: grabbing; 367 | } 368 | .category-item.drag-over { 369 | border: 1px dashed var(--accent); 370 | background-color: rgba(105, 175, 222, 0.1); 371 | } 372 | .category-item .drag-handle { 373 | cursor: grab; 374 | margin-right: 6px; 375 | color: var(--text-secondary); 376 | } 377 | .category-handle-area { 378 | display: flex; 379 | align-items: center; 380 | } 381 | .placeholder { 382 | border: 2px dashed var(--accent); 383 | background-color: rgba(105, 175, 222, 0.1); 384 | margin: 0.25rem 0; 385 | height: 2px; 386 | border-radius: 9999px; 387 | } 388 | 389 | .category-item { 390 | background-color: var(--bg-primary); 391 | border: 1px solid var(--border); 392 | border-radius: 9999px; 393 | padding: 0.25rem 0.75rem; 394 | display: flex; 395 | align-items: center; 396 | gap: 0.25rem; 397 | } 398 | 399 | .category-item .delete-button { 400 | padding: 0.25rem; 401 | height: 1.5rem; 402 | width: 1.5rem; 403 | display: flex; 404 | align-items: center; 405 | justify-content: center; 406 | } 407 | 408 | .category-input-container { 409 | display: flex; 410 | gap: 0.5rem; 411 | margin-bottom: 1rem; 412 | } 413 | 414 | .category-input-container input { 415 | flex: 1; 416 | padding: 0.5rem; 417 | border: 1px solid var(--border); 418 | border-radius: 4px; 419 | background-color: var(--bg-primary); 420 | color: var(--text-primary); 421 | } 422 | 423 | .export-buttons { 424 | display: flex; 425 | gap: 1rem; 426 | } 427 | 428 | .export-buttons a, .import-option label { 429 | text-decoration: none; 430 | } 431 | 432 | .cashflow-container { 433 | display: flex; 434 | justify-content: space-between; 435 | gap: 1rem; 436 | margin-bottom: 1rem; 437 | align-items: stretch; 438 | } 439 | 440 | .cashflow-item { 441 | flex: 1; 442 | padding: 1rem; 443 | border-radius: 8px; 444 | text-align: center; 445 | display: flex; 446 | flex-direction: column; 447 | justify-content: center; 448 | } 449 | 450 | .cashflow-item.income { 451 | background-color: rgba(52, 211, 153, 0.1); 452 | } 453 | 454 | .cashflow-item.expenses { 455 | background-color: rgba(239, 68, 68, 0.1); 456 | } 457 | 458 | .cashflow-item.balance { 459 | background-color: rgba(251, 191, 36, 0.1); 460 | } 461 | 462 | .cashflow-label { 463 | font-size: 0.9rem; 464 | color: var(--text-secondary); 465 | margin-bottom: 0.5rem; 466 | } 467 | 468 | .cashflow-value { 469 | font-size: 1.4rem; 470 | font-weight: bold; 471 | } 472 | 473 | .cashflow-value.positive { 474 | color: #2EAB7D; 475 | } 476 | 477 | .cashflow-value.negative { 478 | color: #EF4444; 479 | } 480 | 481 | .import-section { 482 | margin-top: 1.5rem; 483 | padding-top: 1.5rem; 484 | border-top: 1px solid var(--border); 485 | } 486 | 487 | .import-options { 488 | display: flex; 489 | gap: 1rem; 490 | margin-bottom: 1rem; 491 | } 492 | 493 | .import-option { 494 | flex: 1; 495 | } 496 | 497 | .import-option label { 498 | display: block; 499 | text-align: center; 500 | cursor: pointer; 501 | } 502 | 503 | .import-summary { 504 | margin-top: 1rem; 505 | padding: 1rem; 506 | background-color: var(--bg-primary); 507 | border-radius: 8px; 508 | } 509 | 510 | .import-progress { 511 | margin-top: 0.5rem; 512 | height: 4px; 513 | background-color: var(--border); 514 | border-radius: 2px; 515 | overflow: hidden; 516 | } 517 | 518 | .import-progress-bar { 519 | height: 100%; 520 | background-color: var(--accent); 521 | width: 0%; 522 | transition: width 0.3s ease; 523 | } 524 | 525 | .settings-container { 526 | display: flex; 527 | gap: 1rem; 528 | } 529 | .form-container.half-width { 530 | flex: 1; 531 | } 532 | .form-help-text { 533 | font-size: 0.85rem; 534 | color: var(--text-secondary); 535 | margin-top: 0.5rem; 536 | } 537 | 538 | .currency-selector, .start-date-manager { 539 | display: flex; 540 | align-items: center; 541 | gap: 1rem; 542 | margin: 1rem 0; 543 | } 544 | 545 | .start-date-manager input, .currency-selector select { 546 | flex: 1; 547 | padding: 0.5rem; 548 | border: 1px solid var(--border); 549 | border-radius: 4px; 550 | background-color: var(--bg-primary); 551 | color: var(--text-primary); 552 | min-width: 200px; 553 | } 554 | 555 | /* Responsive styles */ 556 | @media (max-width: 768px) { 557 | .chart-container { 558 | flex-direction: column; 559 | min-height: auto; 560 | padding: 1rem; 561 | } 562 | 563 | .chart-box { 564 | display: flex; 565 | justify-content: center; 566 | align-items: center; 567 | padding: 1rem 0; 568 | margin: auto; 569 | } 570 | 571 | .chart-box canvas { 572 | max-height: 300px; 573 | } 574 | 575 | .legend-box { 576 | width: 100%; 577 | padding: 0.5rem; 578 | } 579 | 580 | .legend-item { 581 | margin-bottom: 0.5rem; 582 | } 583 | 584 | .month-navigation { 585 | gap: 0.2rem; 586 | } 587 | 588 | .nav-button { 589 | padding: 0.25rem 0.75rem; 590 | font-size: 1rem; 591 | } 592 | 593 | .current-month { 594 | font-size: 1rem; 595 | text-align: center; 596 | min-width: 150px; 597 | } 598 | 599 | .date-column, 600 | .date-header { 601 | display: none; 602 | } 603 | 604 | .expense-table th, 605 | .expense-table td { 606 | padding: 0.75rem; 607 | } 608 | 609 | .container { 610 | padding: 1rem; 611 | } 612 | 613 | .export-buttons { 614 | flex-direction: column; 615 | } 616 | 617 | .export-buttons .nav-button { 618 | text-align: center; 619 | } 620 | 621 | .cashflow-container { 622 | flex-direction: column; 623 | gap: 0.5rem; 624 | } 625 | 626 | .cashflow-item { 627 | padding: 0.75rem; 628 | } 629 | 630 | .cashflow-value { 631 | font-size: 1.2rem; 632 | } 633 | 634 | .import-options { 635 | flex-direction: column; 636 | } 637 | 638 | .settings-container { 639 | flex-direction: column; 640 | } 641 | } 642 | 643 | .form-container { 644 | background-color: var(--bg-secondary); 645 | border-radius: 8px; 646 | padding: 1.5rem; 647 | margin-bottom: 1rem; 648 | } 649 | 650 | .expense-form { 651 | display: grid; 652 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 653 | gap: 1rem; 654 | align-items: end; 655 | } 656 | 657 | .form-group { 658 | display: flex; 659 | flex-direction: column; 660 | gap: 0.5rem; 661 | } 662 | 663 | .form-group label { 664 | font-size: 0.875rem; 665 | color: var(--text-secondary); 666 | } 667 | 668 | .form-group input, 669 | .form-group select { 670 | padding: 0.5rem; 671 | border: 1px solid var(--border); 672 | border-radius: 4px; 673 | background-color: var(--bg-primary); 674 | color: var(--text-primary); 675 | font-size: 1rem; 676 | } 677 | 678 | .form-group input:focus, 679 | .form-group select:focus { 680 | outline: none; 681 | border-color: var(--accent); 682 | } 683 | 684 | .form-message { 685 | margin: 0; 686 | padding: 0; 687 | min-height: 0; 688 | height: 0; 689 | opacity: 0; 690 | overflow: hidden; 691 | transition: all 0.3s ease; 692 | } 693 | 694 | .form-message:not(:empty) { 695 | margin-top: 1rem; 696 | padding: 0.5rem; 697 | height: auto; 698 | min-height: 2rem; 699 | opacity: 1; 700 | border-radius: 4px; 701 | text-align: center; 702 | } 703 | 704 | .form-message.success { 705 | background-color: rgba(52, 211, 153, 0.1); 706 | color: #2EAB7D; 707 | } 708 | 709 | .form-message.error { 710 | background-color: rgba(239, 68, 68, 0.1); 711 | color: #EF4444; 712 | } 713 | -------------------------------------------------------------------------------- /internal/web/templates/sw.js: -------------------------------------------------------------------------------- 1 | self.addEventListener('fetch', (event) => { 2 | // Pass through all requests directly to network 3 | event.respondWith(fetch(event.request)); 4 | }); 5 | -------------------------------------------------------------------------------- /internal/web/templates/table.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ExpenseOwl Table 9 | 10 | 11 |
12 |
13 | 14 | 28 |
29 | 30 |
31 | 32 |
33 | 34 |
35 | 36 |
37 |
38 |
39 | 40 | 41 |
42 | 43 |
44 | 45 | 48 |
49 | 50 |
51 | 52 | 53 |
54 | 55 |
56 | 57 | 58 | 67 |
68 | 69 | 70 |
71 |
72 |
73 |
74 | 75 |
76 | 77 |
78 |
79 | 80 | 90 | 91 | 387 | 388 | 389 | -------------------------------------------------------------------------------- /internal/web/templates/webfonts/fa-brands-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/internal/web/templates/webfonts/fa-brands-400.woff2 -------------------------------------------------------------------------------- /internal/web/templates/webfonts/fa-regular-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/internal/web/templates/webfonts/fa-regular-400.woff2 -------------------------------------------------------------------------------- /internal/web/templates/webfonts/fa-solid-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/internal/web/templates/webfonts/fa-solid-900.woff2 -------------------------------------------------------------------------------- /internal/web/templates/webfonts/fa-v4compatibility.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Tanq16/ExpenseOwl/fbaf7bfb44bb5744790c837ef2bf8e4e2560b261/internal/web/templates/webfonts/fa-v4compatibility.woff2 -------------------------------------------------------------------------------- /kubernetes/Expenseowl-Deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: StatefulSet 3 | metadata: 4 | name: expenseowl 5 | namespace: expenseowl 6 | spec: 7 | serviceName: expenseowl-svc 8 | selector: 9 | matchLabels: 10 | app: expenseowl 11 | replicas: 1 12 | template: 13 | metadata: 14 | labels: 15 | app: expenseowl 16 | spec: 17 | containers: 18 | - name: expenseowl 19 | image: tanq16/expenseowl:main 20 | imagePullPolicy: Always 21 | ports: 22 | - containerPort: 8080 23 | name: expenseowl-port 24 | envFrom: 25 | - configMapRef: 26 | name: expenseowl-config 27 | volumeMounts: 28 | - mountPath: "/app/data" 29 | name: expenseowl-vol 30 | volumes: 31 | - name: expenseowl-vol 32 | persistentVolumeClaim: 33 | claimName: "expenseowl-pvc" 34 | -------------------------------------------------------------------------------- /kubernetes/Expenseowl-configmap.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: expenseowl-config 5 | namespace: expenseowl 6 | data: 7 | EXPENSE_CATEGORIES: "Food,Groceries,Travel,Rent,Utilities,Money Transfer,Entertainment,Healthcare,Shopping,Other" 8 | CURRENCY: jpy 9 | -------------------------------------------------------------------------------- /kubernetes/Expenseowl-ingress.yml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: expenseowl-ingress 5 | namespace: expenseowl 6 | labels: 7 | name: expenseowl 8 | spec: 9 | rules: 10 | - host: expenseowl.localhost 11 | http: 12 | paths: 13 | - path: / 14 | pathType: Prefix 15 | backend: 16 | service: 17 | name: expenseowl-svc 18 | port: 19 | number: 8080 20 | -------------------------------------------------------------------------------- /kubernetes/Expenseowl-pvc.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: expenseowl-pvc 5 | namespace: expenseowl 6 | spec: 7 | accessModes: 8 | - ReadWriteOnce 9 | storageClassName: local-path 10 | resources: 11 | requests: 12 | storage: 1Gi 13 | -------------------------------------------------------------------------------- /kubernetes/Expenseowl-svc.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: expenseowl-svc 5 | namespace: expenseowl 6 | spec: 7 | selector: 8 | app: expenseowl 9 | ports: 10 | - port: 8080 11 | targetPort: 8080 12 | -------------------------------------------------------------------------------- /kubernetes/README.md: -------------------------------------------------------------------------------- 1 | **NOTE:** The Kubernetes specification mentioned here has not been tested avidly and is a community supported definition. I (author) may disregard issues associated with the Kubernetes spec (until I personally shift my homelab to a cluster). 2 | 3 | Use the following instructions to apply the Kubernetes spec: 4 | 5 | ```bash 6 | kubectl apply -f kubernetes/_namespace.yml 7 | kubectl apply -f kubernetes/Expenseowl-Deployment.yml 8 | kubectl apply -f kubernetes/Expenseowl-configmap.yml 9 | kubectl apply -f kubernetes/Expenseowl-svc.yml 10 | kubectl apply -f kubernetes/Expenseowl-pvc.yml 11 | kubectl apply -f kubernetes/Expenseowl-ingress.yml 12 | kubectl port-forward pod/ 8080:8080 # Change Pod Name Here 13 | ``` 14 | 15 | ``` 16 | Dashboard available at http://expenseowl.localhost/ 17 | ``` 18 | -------------------------------------------------------------------------------- /kubernetes/_namespace.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: expenseowl 5 | -------------------------------------------------------------------------------- /scripts/mock-data-populate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | categories=("Food" "Groceries" "Travel" "Rent" "Income" "Utilities" "Entertainment" "Healthcare" "Shopping" "Miscellaneous") 4 | RENT_AMOUNT=2000 5 | CURRENT_YEAR=$(date +%Y) 6 | 7 | random_amount() { 8 | min=$1 9 | max=$2 10 | echo "scale=2; $min + ($max - $min) * $RANDOM / 32767" | bc 11 | } 12 | 13 | for month in {0..11}; do 14 | date="$CURRENT_YEAR-$(printf "%02d" $((month + 1)))-14T14:00:00Z" 15 | 16 | # Add fixed rent expense for each month 17 | curl -X PUT http://localhost:8080/expense \ 18 | -H "Content-Type: application/json" \ 19 | -d "{ 20 | \"name\": \"Monthly Rent\", 21 | \"category\": \"Rent\", 22 | \"amount\": $RENT_AMOUNT, 23 | \"date\": \"$date\" 24 | }" 25 | sleep 0.1 26 | 27 | # Add random income for each month 28 | amount=$(random_amount 2000 4000) 29 | curl -X PUT http://localhost:8080/expense \ 30 | -H "Content-Type: application/json" \ 31 | -d "{ 32 | \"name\": \"Monthly Income\", 33 | \"category\": \"Income\", 34 | \"amount\": $amount, 35 | \"date\": \"$date\" 36 | }" 37 | 38 | num_expenses=$((RANDOM % 5 + 8)) # Random number between 8 and 12 39 | 40 | for ((i=1; i<=num_expenses; i++)); do 41 | day=$((RANDOM % 28 + 1)) 42 | date="$CURRENT_YEAR-$(printf "%02d" $((month + 1)))-$(printf "%02d" $day)T14:00:00Z" 43 | while true; do 44 | category=${categories[$RANDOM % ${#categories[@]}]} 45 | if [ "$category" != "Rent" ] && [ "$category" != "Income" ]; then 46 | break 47 | fi 48 | done 49 | 50 | case $category in 51 | "Food") 52 | amount=$(random_amount 20 100) 53 | name="Restaurant meal" 54 | ;; 55 | "Groceries") 56 | amount=$(random_amount 50 200) 57 | name="Weekly groceries" 58 | ;; 59 | "Travel") 60 | amount=$(random_amount 100 500) 61 | name="Transportation" 62 | ;; 63 | "Utilities") 64 | amount=$(random_amount 80 200) 65 | name="Monthly utilities" 66 | ;; 67 | "Entertainment") 68 | amount=$(random_amount 30 150) 69 | name="Entertainment activity" 70 | ;; 71 | "Healthcare") 72 | amount=$(random_amount 50 300) 73 | name="Medical expense" 74 | ;; 75 | "Shopping") 76 | amount=$(random_amount 40 250) 77 | name="General shopping" 78 | ;; 79 | "Miscellaneous") 80 | amount=$(random_amount 20 100) 81 | name="Misc expense" 82 | ;; 83 | esac 84 | 85 | curl -X PUT http://localhost:8080/expense \ 86 | -H "Content-Type: application/json" \ 87 | -d "{ 88 | \"name\": \"$name\", 89 | \"category\": \"$category\", 90 | \"amount\": $amount, 91 | \"date\": \"$date\" 92 | }" 93 | sleep 0.1 94 | done 95 | done 96 | 97 | echo "Mock data generation complete!" 98 | -------------------------------------------------------------------------------- /scripts/static-downloader.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "downloading chart.js minified script" 4 | curl -sL https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js -o internal/web/templates/chart.min.js 5 | echo "downloading font awesome minified CSS" 6 | curl -sL https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css -o internal/web/templates/fa.min.css 7 | 8 | echo "downloading font awesome webfonts" 9 | mkdir -p temp_webfonts 10 | cd temp_webfonts 11 | curl -sL https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/fa-brands-400.woff2 -O 12 | curl -sL https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/fa-regular-400.woff2 -O 13 | curl -sL https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/fa-solid-900.woff2 -O 14 | curl -sL https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/fa-v4compatibility.woff2 -O 15 | mv *.woff2 ../internal/web/templates/webfonts/ 16 | cd .. 17 | rmdir temp_webfonts 18 | 19 | # change reference of cdn webfonts to local webfonts in css 20 | sed -i.bak 's|https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/|/webfonts/|g' internal/web/templates/fa.min.css 21 | rm internal/web/templates/fa.min.css.bak 22 | --------------------------------------------------------------------------------