├── .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 | 
3 |
4 |
5 | ExpenseOwl
6 |
7 |
8 |
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 |
|
|
79 | | Light |
|
|
80 |
81 |
82 | Expand this to see screenshots of other pages
83 |
84 | | | Desktop View | Mobile View |
85 | | --- | --- | --- |
86 | | Table Dark |
|
|
87 | | Table Light |
|
|
88 | | Settings Dark |
|
|
89 | | Settings 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 |
42 |
43 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
72 |
73 |
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 |
29 |
30 |
31 |
45 |
46 |
47 |
48 |
58 |
59 |
60 |
68 |
69 |
70 |
71 |
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 |
29 |
30 |
35 |
36 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
Delete Expense
83 |
Are you sure you want to delete this expense? (cannot be undone)
84 |
85 |
86 |
87 |
88 |
89 |
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 |
--------------------------------------------------------------------------------