Skip to content

Commit fbaf7bf

Browse files
authored
Merge pull request #32 from Tanq16/issuefix
Addressing Feature Reqs & Bug
2 parents 6f7a77c + dbb3179 commit fbaf7bf

File tree

14 files changed

+352
-61
lines changed

14 files changed

+352
-61
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ That's why I created this project and I use it in my home lab for my expense tra
3535
- REST API for expense management
3636
- Single-user focused (mainly for a home lab deployment)
3737
- CSV and JSON export and import of all expense data from the UI
38-
- Custom categories (and ordering) and currency symbol via app settings
38+
- Custom categories, currency symbol, and start date via app settings
3939
- Beautiful interface that automatically adapts to system for light/dark theme
4040
- UUID-based expense identification in the backend
4141
- Self-contained binary and container image to ensure no internet interaction
@@ -58,6 +58,7 @@ That's why I created this project and I use it in my home lab for my expense tra
5858
5. Settings page for configuring the application
5959
- Reorder, add, or remove custom categories
6060
- Select a custom currency to display
61+
- Select a custom start date to show expenses for a different period
6162
- Exporting data as CSV or JSON and import data from JSON or CSV
6263

6364
### Progressive Web App (PWA)
@@ -212,6 +213,8 @@ EXPENSE_CATEGORIES="Rent,Food,Transport,Fun,Bills" ./expenseowl
212213
> [!TIP]
213214
> 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.
214215

216+
Similarly, the start date can also be set via the settings UI or the `START_DATE` environment variable.
217+
215218
### Data Import/Export
216219

217220
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.

assets/desktop-dark-settings.png

14.7 KB
Loading

assets/desktop-light-settings.png

14.7 KB
Loading

assets/mobile-dark-settings.png

3.92 KB
Loading

assets/mobile-light-settings.png

3.77 KB
Loading

cmd/expenseowl/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ func runServer(dataPath string) {
2323
http.HandleFunc("/categories", handler.GetCategories)
2424
http.HandleFunc("/categories/edit", handler.EditCategories)
2525
http.HandleFunc("/currency", handler.EditCurrency)
26+
http.HandleFunc("/startdate", handler.EditStartDate)
2627
http.HandleFunc("/expense", handler.AddExpense)
2728
http.HandleFunc("/expenses", handler.GetExpenses)
29+
http.HandleFunc("/expense/edit", handler.EditExpense)
2830
http.HandleFunc("/table", handler.ServeTableView)
2931
http.HandleFunc("/settings", handler.ServeSettingsPage)
3032
http.HandleFunc("/expense/delete", handler.DeleteExpense)

internal/api/handlers.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type ExpenseRequest struct {
3737
type ConfigResponse struct {
3838
Categories []string `json:"categories"`
3939
Currency string `json:"currency"`
40+
StartDate int `json:"startDate"`
4041
}
4142

4243
func (h *Handler) GetCategories(w http.ResponseWriter, r *http.Request) {
@@ -48,6 +49,7 @@ func (h *Handler) GetCategories(w http.ResponseWriter, r *http.Request) {
4849
response := ConfigResponse{
4950
Categories: h.config.Categories,
5051
Currency: h.config.Currency,
52+
StartDate: h.config.StartDate,
5153
}
5254
writeJSON(w, http.StatusOK, response)
5355
}
@@ -86,6 +88,23 @@ func (h *Handler) EditCurrency(w http.ResponseWriter, r *http.Request) {
8688
log.Println("HTTP: Updated currency")
8789
}
8890

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+
89108
func (h *Handler) AddExpense(w http.ResponseWriter, r *http.Request) {
90109
if r.Method != http.MethodPut {
91110
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -120,6 +139,52 @@ func (h *Handler) AddExpense(w http.ResponseWriter, r *http.Request) {
120139
writeJSON(w, http.StatusOK, expense)
121140
}
122141

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+
123188
func (h *Handler) GetExpenses(w http.ResponseWriter, r *http.Request) {
124189
if r.Method != http.MethodGet {
125190
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)

internal/config/config.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"log"
77
"os"
88
"path/filepath"
9+
"strconv"
910
"strings"
1011
"sync"
1112
"time"
@@ -16,12 +17,14 @@ type Config struct {
1617
StoragePath string
1718
Categories []string
1819
Currency string
20+
StartDate int
1921
mu sync.RWMutex
2022
}
2123

2224
type FileConfig struct {
2325
Categories []string `json:"categories"`
2426
Currency string `json:"currency"`
27+
StartDate int `json:"startDate"`
2528
}
2629

2730
var defaultCategories = []string{
@@ -104,6 +107,7 @@ func NewConfig(dataPath string) *Config {
104107
ServerPort: "8080",
105108
StoragePath: finalPath,
106109
Categories: defaultCategories,
110+
StartDate: 1,
107111
Currency: "$", // Default to USD
108112
}
109113
configPath := filepath.Join(finalPath, "config.json")
@@ -123,9 +127,19 @@ func NewConfig(dataPath string) *Config {
123127
}
124128
log.Println("Using custom currency from environment variables")
125129
}
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+
}
126139
} else if fileConfig, err := loadConfigFile(configPath); err == nil {
127140
cfg.Categories = fileConfig.Categories
128141
cfg.Currency = fileConfig.Currency
142+
cfg.StartDate = fileConfig.StartDate
129143
log.Println("Loaded configuration from file")
130144
}
131145
cfg.SaveConfig()
@@ -151,6 +165,7 @@ func (c *Config) SaveConfig() error {
151165
fileConfig := FileConfig{
152166
Categories: c.Categories,
153167
Currency: c.Currency,
168+
StartDate: c.StartDate,
154169
}
155170
data, err := json.MarshalIndent(fileConfig, "", " ")
156171
if err != nil {
@@ -177,3 +192,10 @@ func (c *Config) UpdateCurrency(currencyCode string) error {
177192
c.mu.Unlock()
178193
return c.SaveConfig()
179194
}
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+
}

internal/storage/storage.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Storage interface {
2323
SaveExpense(expense *config.Expense) error
2424
GetAllExpenses() ([]*config.Expense, error)
2525
DeleteExpense(id string) error
26+
EditExpense(expense *config.Expense) error
2627
}
2728

2829
type jsonStore struct {
@@ -98,6 +99,29 @@ func (s *jsonStore) DeleteExpense(id string) error {
9899
return s.writeFile(data)
99100
}
100101

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+
101125
func (s *jsonStore) GetAllExpenses() ([]*config.Expense, error) {
102126
s.mu.RLock()
103127
defer s.mu.RUnlock()

internal/web/templates/index.html

Lines changed: 53 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,12 @@
9494
<input type="date" id="date" required>
9595
<script>
9696
// Set today's date as default
97-
const now = new Date();
98-
document.getElementById('date').valueAsDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
97+
const today = new Date();
98+
const year = today.getFullYear();
99+
const month = String(today.getMonth() + 1).padStart(2, '0'); // January is 0
100+
const day = String(today.getDate()).padStart(2, '0');
101+
const formattedDate = `${year}-${month}-${day}`;
102+
document.getElementById('date').value = formattedDate;
99103
</script>
100104
</div>
101105

@@ -107,6 +111,7 @@
107111
</div>
108112
<script>
109113
let currencySymbol = '$'; // Default to USD
114+
let startDate = 1;
110115
let pieChart = null;
111116
let currentDate = new Date();
112117
let allExpenses = [];
@@ -306,6 +311,7 @@
306311
`<option value="${cat}">${cat}</option>`
307312
).join('');
308313
currencySymbol = config.currency;
314+
startDate = config.startDate;
309315
// Fetch expenses
310316
const response = await fetch('/expenses');
311317
if (!response.ok) throw new Error('Failed to fetch data');
@@ -355,11 +361,46 @@
355361
// Get start and end of month
356362
function getMonthBounds(date) {
357363
const localDate = new Date(date);
358-
const startLocal = new Date(localDate.getFullYear(), localDate.getMonth(), 1);
359-
const endLocal = new Date(localDate.getFullYear(), localDate.getMonth() + 1, 0, 23, 59, 59, 999);
360-
const start = new Date(startLocal.toISOString());
361-
const end = new Date(endLocal.toISOString());
362-
return { start, end };
364+
// If startDate is 1, return generic month bounds
365+
if (startDate === 1) {
366+
const startLocal = new Date(localDate.getFullYear(), localDate.getMonth(), 1);
367+
const endLocal = new Date(localDate.getFullYear(), localDate.getMonth() + 1, 0, 23, 59, 59, 999);
368+
const start = new Date(startLocal.toISOString());
369+
const end = new Date(endLocal.toISOString());
370+
return { start, end };
371+
}
372+
// If startDate is not 1, need to account for variations in month length
373+
let thisMonthStartDate = startDate;
374+
let prevMonthStartDate = startDate;
375+
let nextMonthStartDate = startDate;
376+
// Adjust for variations in current, previous and next months
377+
const currentMonth = localDate.getMonth();
378+
const currentYear = localDate.getFullYear();
379+
const daysInCurrentMonth = new Date(currentYear, currentMonth + 1, 0).getDate(); // last day of current month
380+
thisMonthStartDate = Math.min(thisMonthStartDate, daysInCurrentMonth);
381+
const prevMonth = currentMonth === 0 ? 11 : currentMonth - 1;
382+
const prevYear = currentMonth === 0 ? currentYear - 1 : currentYear;
383+
const daysInPrevMonth = new Date(prevYear, prevMonth + 1, 0).getDate();
384+
prevMonthStartDate = Math.min(prevMonthStartDate, daysInPrevMonth);
385+
const nextMonth = currentMonth === 11 ? 0 : currentMonth + 1;
386+
const nextYear = currentMonth === 11 ? currentYear + 1 : currentYear;
387+
const daysInNextMonth = new Date(nextYear, nextMonth + 1, 0).getDate();
388+
nextMonthStartDate = Math.min(nextMonthStartDate, daysInNextMonth);
389+
390+
// Return bounds for current period or the previous period
391+
if (localDate.getDate() < thisMonthStartDate) {
392+
const startLocal = new Date(prevYear, prevMonth, prevMonthStartDate);
393+
const endLocal = new Date(currentYear, currentMonth, thisMonthStartDate - 1, 23, 59, 59, 999);
394+
const start = new Date(startLocal.toISOString());
395+
const end = new Date(endLocal.toISOString());
396+
return { start, end };
397+
} else {
398+
const startLocal = new Date(currentYear, currentMonth, thisMonthStartDate);
399+
const endLocal = new Date(nextYear, nextMonth, nextMonthStartDate - 1, 23, 59, 59, 999);
400+
const start = new Date(startLocal.toISOString());
401+
const end = new Date(endLocal.toISOString());
402+
return { start, end };
403+
}
363404
}
364405
// Filter expenses for current month
365406
function getMonthExpenses(expenses) {
@@ -425,11 +466,12 @@
425466
messageDiv.textContent = 'Expense added successfully!';
426467
messageDiv.className = 'form-message success';
427468
document.getElementById('expenseForm').reset();
428-
const now = new Date();
429-
document.getElementById('date').valueAsDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
430-
document.getElementById('name').value = 'unnamed';
431-
// Refresh the data
432469
await initialize();
470+
const today = new Date();
471+
const year = today.getFullYear();
472+
const month = String(today.getMonth() + 1).padStart(2, '0');
473+
const day = String(today.getDate()).padStart(2, '0');
474+
document.getElementById('date').value = `${year}-${month}-${day}`;
433475
} else {
434476
const error = await response.json();
435477
messageDiv.textContent = `Error: ${error.error || 'Failed to add expense'}`;

0 commit comments

Comments
 (0)