Compare commits

..

2 Commits

Author SHA1 Message Date
samantha42
676568100f Merge branch 'main' of git.samantha42.xyz:samantha/Portifolio-Engine 2026-04-29 08:40:31 +02:00
samantha42
22c6a22373 moving handlers into service. adding website 2026-04-29 08:38:41 +02:00
16 changed files with 751 additions and 581 deletions

Binary file not shown.

View File

@@ -8,13 +8,14 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
func GetCompanyBySymbol(db *sql.DB, symbol string) (*model.Company, error) { func getCompany(db *sql.DB, where string, arg any) (*model.Company, error) {
var c model.Company var c model.Company
err := db.QueryRow( err := db.QueryRow(`
`SELECT id, symbol, shares_outstanding, price, currency_id FROM companies WHERE symbol = ?`, SELECT c.id, c.symbol, c.shares_outstanding, c.price, cur.id, cur.code
symbol, FROM companies c
).Scan(&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID) JOIN currencies cur ON cur.id = c.currency_id
WHERE `+where, arg,
).Scan(&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID, &c.CurrencyCode)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -24,20 +25,12 @@ func GetCompanyBySymbol(db *sql.DB, symbol string) (*model.Company, error) {
return &c, nil return &c, nil
} }
func GetCompanyBySymbol(db *sql.DB, symbol string) (*model.Company, error) {
return getCompany(db, "c.symbol = ?", symbol)
}
func GetCompanyByID(db *sql.DB, id int) (*model.Company, error) { func GetCompanyByID(db *sql.DB, id int) (*model.Company, error) {
var c model.Company return getCompany(db, "c.id = ?", id)
err := db.QueryRow(
`SELECT id, symbol, shares_outstanding, price, currency_id FROM companies WHERE id = ?`,
id,
).Scan(&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query company: %w", err)
}
return &c, nil
} }
func AddCompany(db *sql.DB, input model.CompanyInput) (int, error) { func AddCompany(db *sql.DB, input model.CompanyInput) (int, error) {
@@ -66,7 +59,9 @@ func AddCompany(db *sql.DB, input model.CompanyInput) (int, error) {
func GetAllCompanies(db *sql.DB) ([]model.Company, error) { func GetAllCompanies(db *sql.DB) ([]model.Company, error) {
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, symbol, shares_outstanding, price, currency_id FROM companies SELECT c.id, c.symbol, c.shares_outstanding, c.price, cur.id, cur.code
FROM companies c
JOIN currencies cur ON cur.id = c.currency_id
`) `)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -77,7 +72,7 @@ func GetAllCompanies(db *sql.DB) ([]model.Company, error) {
for rows.Next() { for rows.Next() {
var c model.Company var c model.Company
if err := rows.Scan( if err := rows.Scan(
&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID, &c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID, &c.CurrencyCode,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@@ -1,44 +0,0 @@
package handlers
import (
"Portifolio/internal/model"
"Portifolio/internal/service"
"database/sql"
"encoding/json"
"net/http"
_ "github.com/mattn/go-sqlite3"
)
func AddCurrencyHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input model.CurrencyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
id, err := service.InsertCurrency(db, input)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"status": "created", "id": id})
}
}
func GetCurrenciesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currencies, err := service.GetAllCurrencies(db)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(currencies)
}
}

View File

@@ -1,108 +0,0 @@
package handlers
import (
"Portifolio/internal/database"
"Portifolio/internal/model"
"Portifolio/internal/service"
"database/sql"
"encoding/json"
"fmt"
"net/http"
_ "github.com/mattn/go-sqlite3"
)
func AddTradeHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.AddTradeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if err := req.Validate(); err != nil {
http.Error(w, fmt.Sprintf("failed to validate trade: %s", err), http.StatusBadRequest)
return
}
currency, err := database.GetCurrencyByCode(db, req.CurrencyCode)
if err != nil {
http.Error(w, fmt.Sprintf("failed to find currency: %s", err), http.StatusInternalServerError)
return
}
switch model.TradeType(req.Type) {
case model.DividendType:
dividend, err := req.ToDividend()
if err != nil {
http.Error(w, fmt.Sprintf("failed to build dividend: %s", err), http.StatusBadRequest)
return
}
dividend.CurrencyCode = currency.Code
if err := database.InsertDividend(db, dividend); err != nil {
http.Error(w, fmt.Sprintf("failed to insert dividend: %s", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"success": true})
case model.BuyType, model.SellType:
trade, err := req.ToTrade()
if err != nil {
http.Error(w, fmt.Sprintf("failed to build trade: %s", err), http.StatusBadRequest)
return
}
trade.CurrencyCode = currency.Code
if err := database.InsertTrade(db, trade); err != nil {
http.Error(w, fmt.Sprintf("failed to insert trade: %s", err), http.StatusInternalServerError)
return
}
update := true
if err := service.UpdatePositionByTradeList(db); err != nil {
update = false
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"success": true, "position_update": update})
default:
http.Error(w, fmt.Sprintf("unknown trade type: %d", req.Type), http.StatusBadRequest)
}
}
}
func GetTradeListHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tradeList, err := database.GetTrades(db)
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch trades: %s", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(tradeList); err != nil {
http.Error(w, fmt.Sprintf("failed to encode trades: %s", err), http.StatusInternalServerError)
return
}
}
}
func GetPositionListHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
posList, err := database.GetPositions(db)
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch postiton: %s", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(posList); err != nil {
http.Error(w, fmt.Sprintf("failed to encode positions: %s", err), http.StatusInternalServerError)
return
}
}
}

View File

@@ -6,6 +6,7 @@ type Company struct {
SharesOutstanding int SharesOutstanding int
Price float64 Price float64
CurrencyID int CurrencyID int
CurrencyCode string
} }
type CompanyInput struct { type CompanyInput struct {

View File

@@ -3,12 +3,51 @@ package service
import ( import (
"Portifolio/internal/model" "Portifolio/internal/model"
"database/sql" "database/sql"
"encoding/json"
"net/http"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) { type Service struct {
res, err := db.Exec( db *sql.DB
}
func (s *Service) AddCurrencyHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input model.CurrencyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
id, err := s.InsertCurrency(input)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"status": "created", "id": id})
}
}
func (s *Service) GetCurrenciesHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
currencies, err := s.GetAllCurrencies()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(currencies)
}
}
func (s *Service) InsertCurrency(input model.CurrencyInput) (int, error) {
res, err := s.db.Exec(
`INSERT INTO currencies (code, name) VALUES (?, ?)`, `INSERT INTO currencies (code, name) VALUES (?, ?)`,
input.Code, input.Name, input.Code, input.Name,
) )
@@ -19,9 +58,9 @@ func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) {
return int(id), err return int(id), err
} }
func GetCurrencyByCode(db *sql.DB, code string) (*model.Currency, error) { func (s *Service) GetCurrencyByCode(code string) (*model.Currency, error) {
c := &model.Currency{} c := &model.Currency{}
err := db.QueryRow( err := s.db.QueryRow(
`SELECT id, code, name FROM currencies WHERE code = ?`, code, `SELECT id, code, name FROM currencies WHERE code = ?`, code,
).Scan(&c.ID, &c.Code, &c.Name) ).Scan(&c.ID, &c.Code, &c.Name)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -30,8 +69,8 @@ func GetCurrencyByCode(db *sql.DB, code string) (*model.Currency, error) {
return c, err return c, err
} }
func GetAllCurrencies(db *sql.DB) ([]model.Currency, error) { func (s *Service) GetAllCurrencies() ([]model.Currency, error) {
rows, err := db.Query(`SELECT id, code, name FROM currencies ORDER BY code`) rows, err := s.db.Query(`SELECT id, code, name FROM currencies ORDER BY code`)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -1,4 +1,4 @@
package handlers package service
import ( import (
"Portifolio/internal/database" "Portifolio/internal/database"
@@ -14,11 +14,15 @@ import (
var startTime = time.Now() var startTime = time.Now()
func HealthHandler(db *sql.DB) http.HandlerFunc { func New(db *sql.DB) *Service {
return &Service{db: db}
}
func (s *Service) HealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
health := checkHealth(db) health := s.CheckHealth()
if health["status"] != "ok" { if health["status"] != "ok" {
w.WriteHeader(http.StatusServiceUnavailable) w.WriteHeader(http.StatusServiceUnavailable)
@@ -28,21 +32,17 @@ func HealthHandler(db *sql.DB) http.HandlerFunc {
} }
} }
func checkHealth(db *sql.DB) map[string]any { func (s *Service) CheckHealth() map[string]any {
// database
dbStatus := "ok" dbStatus := "ok"
dbLatency := "" dbLatency := ""
t := time.Now() t := time.Now()
if err := db.Ping(); err != nil { if err := s.db.Ping(); err != nil {
dbStatus = "error: " + err.Error() dbStatus = "error: " + err.Error()
} else { } else {
dbLatency = time.Since(t).String() dbLatency = time.Since(t).String()
} }
// db pool stats stats := s.db.Stats()
stats := db.Stats()
// overall status
status := "ok" status := "ok"
if dbStatus != "ok" { if dbStatus != "ok" {
status = "degraded" status = "degraded"
@@ -75,7 +75,7 @@ func bToMb(b uint64) float64 {
return float64(b) / 1024 / 1024 return float64(b) / 1024 / 1024
} }
func AddCompanyHandler(db *sql.DB) http.HandlerFunc { func (s *Service) AddCompanyHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var input model.CompanyInput var input model.CompanyInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil { if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
@@ -83,7 +83,7 @@ func AddCompanyHandler(db *sql.DB) http.HandlerFunc {
return return
} }
id, err := database.AddCompany(db, input) id, err := database.AddCompany(s.db, input)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -95,9 +95,14 @@ func AddCompanyHandler(db *sql.DB) http.HandlerFunc {
} }
} }
func GetCompaniesHandler(db *sql.DB) http.HandlerFunc { func (s *Service) GetAllCompanies() ([]model.Company, error) {
companies, err := database.GetAllCompanies(s.db)
return companies, err
}
func (s *Service) GetCompaniesHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
companies, err := database.GetAllCompanies(db) companies, err := database.GetAllCompanies(s.db)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return

View File

@@ -3,15 +3,16 @@ package service
import ( import (
"Portifolio/internal/database" "Portifolio/internal/database"
"Portifolio/internal/model" "Portifolio/internal/model"
"database/sql" "encoding/json"
"fmt" "fmt"
"net/http"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
func UpdatePositionByTradeList(db *sql.DB) error { func (s *Service) UpdatePositionByTradeList() error {
trades, err := database.GetTrades(db) trades, err := database.GetTrades(s.db)
if err != nil { if err != nil {
fmt.Printf("Failed to get the trades from db: %s", err) fmt.Printf("Failed to get the trades from db: %s", err)
} }
@@ -42,10 +43,115 @@ func UpdatePositionByTradeList(db *sql.DB) error {
NewPositinos = append(NewPositinos, pos) NewPositinos = append(NewPositinos, pos)
} }
err = database.UpdatePositions(db, NewPositinos) err = database.UpdatePositions(s.db, NewPositinos)
if err != nil { if err != nil {
return fmt.Errorf("Failed to insert the new postions number into db: %s", err) return fmt.Errorf("Failed to insert the new postions number into db: %s", err)
} }
return nil return nil
} }
func (s *Service) AddTradeHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.AddTradeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if err := req.Validate(); err != nil {
http.Error(w, fmt.Sprintf("failed to validate trade: %s", err), http.StatusBadRequest)
return
}
currency, err := database.GetCurrencyByCode(s.db, req.CurrencyCode)
if err != nil {
http.Error(w, fmt.Sprintf("failed to find currency: %s", err), http.StatusInternalServerError)
return
}
switch model.TradeType(req.Type) {
case model.DividendType:
dividend, err := req.ToDividend()
if err != nil {
http.Error(w, fmt.Sprintf("failed to build dividend: %s", err), http.StatusBadRequest)
return
}
dividend.CurrencyCode = currency.Code
if err := database.InsertDividend(s.db, dividend); err != nil {
http.Error(w, fmt.Sprintf("failed to insert dividend: %s", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"success": true})
case model.BuyType, model.SellType:
trade, err := req.ToTrade()
if err != nil {
http.Error(w, fmt.Sprintf("failed to build trade: %s", err), http.StatusBadRequest)
return
}
trade.CurrencyCode = currency.Code
if err := database.InsertTrade(s.db, trade); err != nil {
http.Error(w, fmt.Sprintf("failed to insert trade: %s", err), http.StatusInternalServerError)
return
}
update := true
if err := s.UpdatePositionByTradeList(); err != nil {
update = false
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"success": true, "position_update": update})
default:
http.Error(w, fmt.Sprintf("unknown trade type: %d", req.Type), http.StatusBadRequest)
}
}
}
func (s *Service) GetTrades() ([]model.Trade, error) {
TradeList, err := database.GetTrades(s.db)
return TradeList, err
}
func (s *Service) GetTradeListHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tradeList, err := database.GetTrades(s.db)
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch trades: %s", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(tradeList); err != nil {
http.Error(w, fmt.Sprintf("failed to encode trades: %s", err), http.StatusInternalServerError)
return
}
}
}
func (s *Service) GetPositions() ([]model.Position, error) {
posList, err := database.GetPositions(s.db)
return posList, err
}
func (s *Service) GetPositionListHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
posList, err := database.GetPositions(s.db)
if err != nil {
http.Error(w, fmt.Sprintf("failed to fetch postiton: %s", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(posList); err != nil {
http.Error(w, fmt.Sprintf("failed to encode positions: %s", err), http.StatusInternalServerError)
return
}
}
}

View File

@@ -1,9 +1,8 @@
package handlers package service
import ( import (
"Portifolio/internal/database" "Portifolio/internal/database"
"Portifolio/internal/model" "Portifolio/internal/model"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -11,7 +10,7 @@ import (
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc { func (s *Service) AddRevenueEntryHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var input struct { var input struct {
CompanyID int `json:"company_id"` CompanyID int `json:"company_id"`
@@ -48,7 +47,7 @@ func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
Value: input.Value, Value: input.Value,
} }
err := database.InsertRevenue(db, rev) err := database.InsertRevenue(s.db, rev)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -60,27 +59,7 @@ func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
} }
} }
/* func (s *Service) GetCompanyRevenueCategories() http.HandlerFunc {
func GetRevenueReportHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
companyID, _ := strconv.Atoi(r.URL.Query().Get("company_id"))
periodType := r.URL.Query().Get("period_type")
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
idx, _ := strconv.Atoi(r.URL.Query().Get("index"))
entries, err := database.GetRevenueByPeriod(db, companyID, )
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(entries)
}
}
*/
func GetCompanyRevenueCategories(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var input struct { var input struct {
CompanyID int `json:"company_id"` CompanyID int `json:"company_id"`
@@ -90,7 +69,7 @@ func GetCompanyRevenueCategories(db *sql.DB) http.HandlerFunc {
return return
} }
catlist, err := database.GetCategoriesByCompanyID(db, input.CompanyID) catlist, err := database.GetCategoriesByCompanyID(s.db, input.CompanyID)
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("Could not find categories by that id:%s", err), http.StatusBadRequest) http.Error(w, fmt.Sprintf("Could not find categories by that id:%s", err), http.StatusBadRequest)
return return

View File

@@ -1,76 +0,0 @@
package shell
import (
"Portifolio/internal/database"
"Portifolio/internal/model"
"bufio"
"database/sql"
"fmt"
"strconv"
"strings"
_ "github.com/mattn/go-sqlite3"
)
func AddCompany(scanner *bufio.Scanner, db *sql.DB) {
input := model.CompanyInput{}
fmt.Print(" symbol: ")
scanner.Scan()
input.Symbol = strings.TrimSpace(scanner.Text())
fmt.Print(" Shares outstanding: ")
scanner.Scan()
shares, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err != nil {
fmt.Println(" Invalid number for shares.")
return
}
input.SharesOutstanding = shares
fmt.Print(" Price: ")
scanner.Scan()
price, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
if err != nil {
fmt.Println(" Invalid number for price.")
return
}
input.Price = price
fmt.Print(" Currency Code: ")
scanner.Scan()
input.CurrencyCode = strings.TrimSpace(scanner.Text())
if _, err := database.AddCompany(db, input); err != nil {
fmt.Println(" Error:", err)
return
}
fmt.Printf(" ✓ Company '%s' added.\n", input.Symbol)
}
func ListCompanies(db *sql.DB) {
companies, err := database.GetAllCompanies(db)
if err != nil {
fmt.Println(" ✗ Error:", err)
return
}
if len(companies) == 0 {
fmt.Println(" No companies found.")
return
}
fmt.Printf("\n %-5s %-20s %-10s %-15s %s\n", "ID", "NAME", "CURRENCY", "PRICE", "SHARES")
fmt.Println(" " + strings.Repeat("-", 60))
for _, c := range companies {
currency, err := database.GetCurrencyByID(db, c.CurrencyID)
if err != nil {
fmt.Println("No currency by id.")
return
}
fmt.Printf(" %-5d %-20s %-10s %-15.2f %d\n",
c.ID, c.Symbol, currency.Code, c.Price, c.SharesOutstanding)
}
}

View File

@@ -1,44 +0,0 @@
package shell
import (
"Portifolio/internal/model"
"Portifolio/internal/service"
"bufio"
"database/sql"
"fmt"
"strings"
_ "github.com/mattn/go-sqlite3"
)
func AddCurrency(scanner *bufio.Scanner, db *sql.DB) {
input := model.CurrencyInput{}
fmt.Print(" Code (e.g. DKK): ")
scanner.Scan()
input.Code = strings.ToUpper(strings.TrimSpace(scanner.Text()))
fmt.Print(" Name (e.g. Danish Krone): ")
scanner.Scan()
input.Name = strings.TrimSpace(scanner.Text())
id, err := service.InsertCurrency(db, input)
if err != nil {
fmt.Println(" ✗ Error:", err)
return
}
fmt.Printf(" ✓ Currency '%s' (%s) added with ID %d\n", input.Name, input.Code, id)
}
func ListCurrencies(db *sql.DB) {
currencies, err := service.GetAllCurrencies(db)
if err != nil {
fmt.Println(" ✗ Error:", err)
return
}
fmt.Printf(" %-5s %-6s %s\n", "ID", "CODE", "NAME")
fmt.Println(" " + strings.Repeat("-", 30))
for _, c := range currencies {
fmt.Printf(" %-5d %-6s %s\n", c.ID, c.Code, c.Name)
}
}

View File

@@ -1,145 +0,0 @@
package shell
import (
"Portifolio/internal/database"
"Portifolio/internal/model"
"bufio"
"database/sql"
"fmt"
"strconv"
"strings"
)
func promptInt(scanner *bufio.Scanner, label string) (int, error) {
fmt.Print(label)
scanner.Scan()
v, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
if err != nil {
return 0, fmt.Errorf("invalid %s", label)
}
return v, nil
}
func promptFloat(scanner *bufio.Scanner, label string) (float64, error) {
fmt.Print(label)
scanner.Scan()
v, err := strconv.ParseFloat(strings.TrimSpace(scanner.Text()), 64)
if err != nil {
return 0, fmt.Errorf("invalid %s", label)
}
return v, nil
}
func promptString(scanner *bufio.Scanner, label string) string {
fmt.Print(label)
scanner.Scan()
return strings.TrimSpace(scanner.Text())
}
func promptPeriod(scanner *bufio.Scanner) (model.Period, error) {
periodType := strings.ToUpper(promptString(scanner, " Period type (Q/H/Y): "))
year, err := promptInt(scanner, " Year: ")
if err != nil {
return model.Period{}, err
}
switch periodType {
case "Y":
return model.FullYearPeriod(year), nil
case "Q", "H":
label := map[string]string{"Q": " Index (1-4): ", "H": " Index (1-2): "}[periodType]
idx, err := promptInt(scanner, label)
if err != nil {
return model.Period{}, err
}
if periodType == "Q" {
return model.QuarterPeriod(year, idx), nil
}
return model.HalfYearPeriod(year, idx), nil
default:
return model.Period{}, fmt.Errorf("invalid period type: %s", periodType)
}
}
func AddRevenue(scanner *bufio.Scanner, db *sql.DB) {
companyID, err := promptInt(scanner, " Company ID: ")
if err != nil {
fmt.Println(" ✗", err)
return
}
// checking if company exits
_, err = database.GetCompanyByID(db, companyID)
if err != nil {
fmt.Println("No company by that id:", err)
return
}
currencyID, err := promptInt(scanner, " Currency ID: ")
if err != nil {
fmt.Println(" ✗", err)
return
}
period, err := promptPeriod(scanner)
if err != nil {
fmt.Println(" ✗", err)
return
}
category := promptString(scanner, " Category name: ")
var parentID *int
parentStr := promptString(scanner, " Parent category ID (leave blank for root): ")
if parentStr != "" {
pid, err := strconv.Atoi(parentStr)
if err != nil {
fmt.Println(" ✗ Invalid parent ID")
return
}
parentID = &pid
}
value, err := promptFloat(scanner, " Value: ")
if err != nil {
fmt.Println(" ✗", err)
return
}
rev := model.RevenueInsert{
CompanyID: companyID,
CurrencyID: currencyID,
CategoryName: category,
ParentID: *parentID,
Period: period,
Value: value,
}
err = database.InsertRevenue(db, rev)
if err != nil {
fmt.Println(" ✗ Error:", err)
return
}
fmt.Printf(" ✓ Revenue added: %s = %.2f (%s)\n", category, value, period.String())
}
func ListRevenue(scanner *bufio.Scanner, db *sql.DB) {
companyID, err := promptInt(scanner, " Company ID: ")
if err != nil {
fmt.Println(" ✗", err)
return
}
period, err := promptPeriod(scanner)
if err != nil {
fmt.Println(" ✗", err)
return
}
entries, err := database.GetRevenueByPeriod(db, companyID, period.ID)
if err != nil {
fmt.Println(" ✗ Error:", err)
return
}
fmt.Printf("\n %-20s %12s\n", "CATEGORY", "VALUE")
fmt.Println(" " + strings.Repeat("-", 34))
for _, e := range entries {
fmt.Printf(" %-20s %12.2f\n", e.Category, e.Value)
}
}

239
internal/website/main.go Normal file
View File

@@ -0,0 +1,239 @@
package website
import (
"Portifolio/internal/model"
"Portifolio/internal/service"
_ "embed"
"fmt"
"net/http"
"strconv"
_ "github.com/mattn/go-sqlite3"
)
//go:embed static/index.html
var indexHTML []byte
func Getsite() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
}
}
//go:embed static/styles.css
var styleCSS []byte
func Getstylesheet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSS)
}
}
func HealthFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h := svc.CheckHealth()
status := h["status"].(string)
uptime := h["uptime"].(string)
dotClass := "ok"
statusText := "backend ok"
if status != "ok" {
dotClass = "error"
statusText = "backend " + status
w.WriteHeader(http.StatusServiceUnavailable) // ← triggers htmx:responseError
}
dbInfo := h["database"].(map[string]any)
dbStatus := dbInfo["status"].(string)
dbLatency := dbInfo["latency"].(string)
dbText := "db " + dbStatus
if dbLatency != "" {
dbText += " · " + dbLatency
}
fmt.Fprintf(w, `
<span class="status-dot %s"></span>
<span>%s</span>
<span class="status-sep">·</span>
<span style="color:var(--muted)">up %s</span>
<span class="status-sep">·</span>
<span style="color:var(--muted)">%s</span>
<span class="status-spacer"></span>
<button class="refresh-btn"
hx-get="/health/fragment"
hx-target="#statusBar"
hx-swap="innerHTML">↺ refresh</button>
`, dotClass, statusText, uptime, dbText)
}
}
// GET /positions/fragment — returns <tr> rows for positions tbody
func PositionsFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
positions, err := svc.GetPositions()
if err != nil {
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
return
}
if len(positions) == 0 {
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
return
}
var total float64
for _, p := range positions {
total += p.CostBasis
}
for _, p := range positions {
weight := 0.0 // ← renamed from w to avoid shadowing http.ResponseWriter
if total > 0 {
weight = p.CostBasis / total * 100
}
fmt.Fprintf(w, `<tr>
<td><span class="ticker">%s</span></td>
<td><span class="currency-badge">%s</span></td>
<td>%s</td>
<td>%s</td>
<td><div class="weight-cell">%.1f%%
<div class="weight-bar-track">
<div class="weight-bar-fill" style="width:%.0f%%"></div>
</div>
</div></td>
</tr>`, p.Symbol, p.CurrencyCode, fmtInt(p.Shares), fmtNum(p.CostBasis), weight, weight)
}
}
}
// GET /positions/fragment — returns <tr> rows for positions tbody
func CompanyFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
companies, err := svc.GetAllCompanies()
if err != nil {
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
return
}
if len(companies) == 0 {
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
return
}
for _, c := range companies {
fmt.Fprintf(w, `<tr>
<td><span class="ticker">%s</span></td>
<td>%s</td>
<td>%.2f</td>
<td>%d</td>
<td>%.2f</td>
</tr>`, c.Symbol, c.CurrencyCode, c.Price, c.SharesOutstanding, c.Price*float64(c.SharesOutstanding))
}
}
}
// GET /positions/fragment — returns <tr> rows for positions tbody
func SummaryFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
positions, err := svc.GetPositions()
if err != nil {
fmt.Fprintf(w, `<tr class="state-row"><td colspan="5">load failed: %s</td></tr>`, err)
return
}
if len(positions) == 0 {
fmt.Fprint(w, `<tr class="state-row"><td colspan="5">no positions</td></tr>`)
return
}
var total float64
for _, p := range positions {
total += p.CostBasis
}
for _, p := range positions {
weight := 0.0 // ← renamed from w to avoid shadowing http.ResponseWriter
if total > 0 {
weight = p.CostBasis / total * 100
}
fmt.Fprintf(w, `<tr>
<td><span class="ticker">%s</span></td>
<td><span class="currency-badge">%s</span></td>
<td>%s</td>
<td>%s</td>
<td><div class="weight-cell">%.1f%%
<div class="weight-bar-track">
<div class="weight-bar-fill" style="width:%.0f%%"></div>
</div>
</div></td>
</tr>`, p.Symbol, p.CurrencyCode, fmtInt(p.Shares), fmtNum(p.CostBasis), weight, weight)
}
}
}
func fmtNum(v float64) string {
if v == 0 {
return "—"
}
return strconv.FormatFloat(v, 'f', 2, 64)
}
func fmtInt(v int) string {
return strconv.Itoa(v)
}
var productLabels = map[model.TradeProduct]string{
model.StockTrade: "Stock",
model.OptionCallTrade: "Call Option",
model.OptionPutTrade: "Put Option",
model.CurrencyTrade: "Currency",
model.BondTrade: "Bond",
}
func TradeFragment(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
trades, err := svc.GetTrades()
if err != nil {
fmt.Fprintf(w, `<tr class="state-row"><td colspan="7">load failed: %s</td></tr>`, err)
return
}
if len(trades) == 0 {
fmt.Fprint(w, `<tr class="state-row"><td colspan="7">no trades</td></tr>`)
return
}
for _, t := range trades {
dir, cls := "SELL", "dir-sell"
if t.Type == model.BuyType {
dir, cls = "BUY", "dir-buy"
}
label := productLabels[t.Product]
fmt.Fprintf(w, `<tr>
<td><span class="trade-ticker">%s</span></td>
<td>%s</td>
<td>%s</td>
<td>%s</td>
<td class="%s">%s</td>
<td>%s</td>
<td>%s</td>
</tr>`, t.Symbol, label, t.CurrencyCode,
t.Date.Format("2006-01-02"), cls, dir,
fmtNum(float64(t.Shares)), fmtNum(t.Price))
}
}
}
func TradeCount(svc *service.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
trades, err := svc.GetTrades()
if err != nil {
fmt.Fprint(w, "—")
return
}
count := len(trades)
if count == 1 {
fmt.Fprint(w, "1 trade")
} else {
fmt.Fprintf(w, "%d trades", count)
}
}
}

View File

@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Portfolio — Samantha Friis</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/style">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<div class="container">
<header>
<a href="/" class="back">← back</a>
<p class="tag">// portfolio</p>
<h1>Investment<br/><span>Portfolio</span></h1>
<p class="header-sub">Equity positions &nbsp;·&nbsp; Personal research</p>
</header>
<!-- Health bar: polls every 60s, shows error on failure -->
<div class="status-bar"
hx-get="/health/fragment"
hx-trigger="load, every 60s"
hx-target="this"
hx-swap="innerHTML"
hx-on::response-error="this.innerHTML = '<span class=\'status-dot error\'></span><span>backend unreachable</span><span class=\'status-spacer\'></span><button class=\'refresh-btn\' hx-get=\'/health/fragment\' hx-target=\'closest .status-bar\' hx-swap=\'innerHTML\' onclick=\'htmx.process(this)\'>↺ retry</button>'"
hx-on::send-error="this.innerHTML = '<span class=\'status-dot error\'></span><span>failed to connect</span><span class=\'status-spacer\'></span><button class=\'refresh-btn\' hx-get=\'/health/fragment\' hx-target=\'closest .status-bar\' hx-swap=\'innerHTML\' onclick=\'htmx.process(this)\'>↺ retry</button>'">
<span class="status-dot loading"></span>
<span>connecting to backend…</span>
<span class="status-spacer"></span>
<button class="refresh-btn"
hx-get="/health/fragment"
hx-target="closest .status-bar"
hx-swap="innerHTML">↺ refresh</button>
</div>
<!-- Summary cards -->
<div class="cards"
hx-get="/summary/fragment"
hx-trigger="load"
hx-swap="outerHTML">
<div class="card"><p class="card-label">Positions</p><p class="card-value dim"></p></div>
<div class="card"><p class="card-label">Total Shares</p><p class="card-value dim"></p></div>
<div class="card"><p class="card-label">Total Trades</p><p class="card-value dim"></p></div>
<div class="card"><p class="card-label">Currencies</p><p class="card-value dim"></p></div>
</div>
<!-- Positions table -->
<p class="section-label">// <span>positions</span></p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Symbol</th><th>Currency</th><th>Shares</th>
<th>Cost Basis</th><th>Weight</th>
</tr>
</thead>
<tbody hx-get="/positions/fragment"
hx-trigger="load"
hx-swap="innerHTML">
<tr class="state-row"><td colspan="5">loading…</td></tr>
</tbody>
</table>
</div>
<!-- Companies table -->
<p class="section-label">// <span>companies</span> — tracked universe</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Symbol</th><th>Currency</th><th>Price</th>
<th>Shares Outstanding</th><th>Market Cap</th>
</tr>
</thead>
<tbody hx-get="/company/fragment"
hx-trigger="load"
hx-swap="innerHTML">
<tr class="state-row"><td colspan="5">loading…</td></tr>
</tbody>
</table>
</div>
<!-- Trades accordion: lazy-loads on open -->
<div class="trades-section">
<button class="trades-toggle"
aria-expanded="false"
onclick="this.setAttribute('aria-expanded', this.getAttribute('aria-expanded')==='true'?'false':'true'); this.nextElementSibling.classList.toggle('open')">
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
<span class="toggle-icon"></span>
</button>
<div class="trades-body" id="tradesAccordion">
<div class="trades-inner">
<!-- Filter buttons swap only the tbody -->
<div class="trades-filter" id="tradeFilters">
<button class="filter-btn active"
hx-get="/trade/fragment"
hx-target="#tradesBody"
hx-swap="innerHTML">All</button>
</div>
<p class="trades-count"
hx-get="/trade/count"
hx-trigger="load from:#tradesAccordion"
hx-swap="innerHTML"></p>
<div class="trades-table-wrap">
<table class="trades-table">
<thead>
<tr>
<th>Symbol</th><th>Asset</th><th>Currency</th>
<th>Date</th><th>Dir</th><th>Qty</th><th>Price</th>
</tr>
</thead>
<tbody id="tradesBody"
hx-get="/trade/fragment"
hx-trigger="revealed"
hx-swap="innerHTML">
<tr class="state-row"><td colspan="7">expand to load trades</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<footer>
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
<p class="footer-copy">&copy; 2026 — All rights reserved</p>
</footer>
</div>
</body>
</html>

View File

@@ -0,0 +1,138 @@
:root {
--bg: #f4f4f4;
--surface: #efefef;
--surface2:#e0e0e0;
--border: #8c8c8c;
--border2: #acacac;
--text: #000000;
--muted: #5a5a68;
--muted2: #3d3d4a;
--accent: #c8a96e;
--up: #5aad85;
--down: #c96b6b;
--mono: 'Space Mono', monospace;
--serif: 'Fraunces', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--mono);
font-size: 0.8rem;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.container { max-width: 1080px; margin: 0 auto; padding: 3rem 2rem 5rem; }
/* ── Header ── */
header { margin-bottom: 3rem; border-bottom: 1px solid var(--border); padding-bottom: 2rem; }
.back { display: inline-block; color: var(--muted); text-decoration: none; font-size: 0.72rem; letter-spacing: 0.05em; margin-bottom: 1.8rem; transition: color 0.15s; }
.back:hover { color: var(--accent); }
.tag { font-size: 0.68rem; color: var(--accent); letter-spacing: 0.12em; margin-bottom: 0.6rem; }
h1 { font-family: var(--serif); font-size: clamp(2.4rem, 5vw, 3.6rem); font-weight: 300; line-height: 1.1; color: var(--text); margin-bottom: 0.75rem; }
h1 span { font-style: italic; color: var(--accent); }
.header-sub { font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; }
/* ── Status bar ── */
.status-bar {
display: flex; align-items: center; gap: 0.6rem;
font-size: 0.65rem; color: var(--muted);
margin-bottom: 2rem; padding: 0.6rem 1rem;
background: var(--surface); border: 1px solid var(--border); border-radius: 5px;
}
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; background: var(--muted2); }
.status-dot.ok { background: var(--up); }
.status-dot.loading { background: var(--accent); animation: blink 1s ease-in-out infinite; }
.status-dot.error { background: var(--down); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
.status-sep { color: var(--muted2); margin: 0 0.25rem; }
.status-spacer { flex: 1; }
.refresh-btn {
background: none; border: 1px solid var(--border2); border-radius: 3px;
color: var(--muted); font-family: var(--mono); font-size: 0.62rem;
padding: 2px 9px; cursor: pointer; letter-spacing: 0.05em; transition: all 0.15s;
}
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
/* ── Summary cards ── */
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; margin-bottom: 2.5rem; }
.card { background: var(--surface); padding: 1.1rem 1.25rem; }
.card-label { font-size: 0.62rem; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.45rem; }
.card-value { font-family: var(--serif); font-size: 1.65rem; font-weight: 300; color: var(--text); }
.card-value.dim { color: var(--muted2); font-size: 1rem; }
/* ── Section label ── */
.section-label { font-size: 0.62rem; color: var(--muted); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.75rem; }
.section-label span { color: var(--accent); }
/* ── Tables ── */
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 2.5rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.75rem; }
thead tr { border-bottom: 1px solid var(--border2); background: var(--surface); }
th { color: var(--muted); font-weight: 400; font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; padding: 0.75rem 1rem; text-align: left; white-space: nowrap; }
td { padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); color: var(--text); white-space: nowrap; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(200,169,110,0.03); }
.ticker { font-weight: 700; font-size: 0.72rem; letter-spacing: 0.04em; color: var(--text); margin-right: 6px; }
.currency-badge { display: inline-block; font-size: 0.58rem; padding: 1px 5px; background: var(--surface2); border: 1px solid var(--border2); border-radius: 3px; color: var(--muted); letter-spacing: 0.06em; vertical-align: middle; }
.weight-cell { min-width: 100px; }
.weight-bar-track { height: 2px; background: var(--border2); border-radius: 1px; margin-top: 5px; }
.weight-bar-fill { height: 2px; background: var(--accent); border-radius: 1px; }
.state-row td { color: var(--muted2); font-size: 0.7rem; text-align: center; padding: 2rem; border-bottom: none; }
/* ── Trades accordion ── */
.trades-section { margin-top: 0; }
.trades-toggle {
display: flex; align-items: center; justify-content: space-between;
cursor: pointer; background: none; border: none;
border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
width: 100%; padding: 0.85rem 0; color: inherit;
font-family: var(--mono); font-size: 0.62rem; letter-spacing: 0.1em;
text-align: left; user-select: none; text-transform: uppercase;
}
.trades-toggle:hover .toggle-label { color: var(--accent); }
.toggle-label { color: var(--muted); transition: color 0.15s; }
.toggle-label span { color: var(--accent); }
.toggle-icon { color: var(--muted2); font-size: 0.9rem; transition: transform 0.25s ease; }
.trades-toggle[aria-expanded="true"] .toggle-icon { transform: rotate(180deg); }
.trades-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease; }
.trades-body.open { grid-template-rows: 1fr; }
.trades-inner { overflow: hidden; }
.trades-filter { display: flex; gap: 0.4rem; flex-wrap: wrap; padding: 1rem 0 0.75rem; }
.filter-btn {
background: none; border: 1px solid var(--border2); border-radius: 3px;
color: var(--muted); font-family: var(--mono); font-size: 0.62rem;
letter-spacing: 0.08em; padding: 3px 10px; cursor: pointer;
transition: all 0.15s; text-transform: uppercase;
}
.filter-btn:hover, .filter-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(200,169,110,0.06); }
.trades-count { font-size: 0.62rem; color: var(--muted2); letter-spacing: 0.06em; padding-bottom: 0.5rem; }
.trades-table-wrap { overflow-x: auto; padding-bottom: 1.5rem; border: 1px solid var(--border); border-radius: 6px; }
.trades-table { width: 100%; border-collapse: collapse; font-size: 0.73rem; }
.trades-table thead tr { border-bottom: 1px solid var(--border2); background: var(--surface); }
.trades-table th { color: var(--muted); font-weight: 400; letter-spacing: 0.1em; font-size: 0.6rem; text-transform: uppercase; padding: 0.6rem 0.9rem; text-align: left; white-space: nowrap; }
.trades-table td { padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--border); white-space: nowrap; color: var(--text); }
.trades-table tbody tr:last-child td { border-bottom: none; }
.trades-table tbody tr:hover td { background: rgba(200,169,110,0.03); }
.trades-table tr.hidden-row { display: none; }
.dir-buy { color: var(--up); }
.dir-sell { color: var(--down); }
.trade-code { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 0.6rem; background: var(--surface2); border: 1px solid var(--border2); color: var(--muted); letter-spacing: 0.05em; }
.trade-ticker { font-weight: 700; color: var(--text); letter-spacing: 0.03em; }
/* ── Footer ── */
footer { margin-top: 4rem; padding-top: 1.5rem; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
footer a { color: var(--muted); font-size: 0.68rem; text-decoration: none; letter-spacing: 0.04em; transition: color 0.15s; }
footer a:hover { color: var(--accent); }
.footer-copy { font-size: 0.65rem; color: var(--muted2); letter-spacing: 0.06em; }

134
main.go
View File

@@ -2,16 +2,13 @@ package main
import ( import (
"Portifolio/internal/database" "Portifolio/internal/database"
"Portifolio/internal/handlers" "Portifolio/internal/service"
"Portifolio/internal/shell" "Portifolio/internal/website"
"bufio"
"database/sql" "database/sql"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"strings"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -35,6 +32,42 @@ func corsMiddleware(next http.Handler) http.Handler {
}) })
} }
func RegisterRoutes(mux *http.ServeMux, db *sql.DB) {
svc := service.New(db) // ← create it here, once
// ── Website (HTMX fragments) ──────────────────────────────
mux.HandleFunc("/", website.Getsite())
mux.HandleFunc("/style", website.Getstylesheet())
mux.HandleFunc("/health/fragment", website.HealthFragment(svc))
mux.HandleFunc("/positions/fragment", website.PositionsFragment(svc))
mux.HandleFunc("/company/fragment", website.CompanyFragment(svc))
mux.HandleFunc("/summary/fragment", website.SummaryFragment(svc))
mux.HandleFunc("/trade/fragment", website.TradeFragment(svc))
mux.HandleFunc("/trade/count", website.TradeCount(svc))
// ── API (JSON) ────────────────────────────────────────────
mux.HandleFunc("/health", svc.HealthHandler())
// Trades
mux.HandleFunc("POST /trade/add", svc.AddTradeHandler())
mux.HandleFunc("GET /trade/list", svc.GetTradeListHandler())
//mux.HandleFunc("GET /trade/search", svc.SearchTradeHandler(svc))
// Positions
mux.HandleFunc("GET /positions/list", svc.GetPositionListHandler())
//mux.HandleFunc("GET /positions/closed/list", svc.GetClosedPositionListHandler(svc))
//mux.HandleFunc("GET /positions/closed/search", svc.SearchClosedPositionsHandler(svc))
// Company
mux.HandleFunc("POST /company/add", svc.AddCompanyHandler())
mux.HandleFunc("GET /company/list", svc.GetCompaniesHandler())
mux.HandleFunc("GET /company/revenue/categories", svc.GetCompanyRevenueCategories())
//mux.HandleFunc("POST /company/S-O/add", svc.AddSharesOutstandingHandler(svc))
//mux.HandleFunc("GET /company/S-O/list", svc.GetSharesOutstandingHandler(svc))
// Currency
mux.HandleFunc("GET /currency/list", svc.GetCurrenciesHandler())
mux.HandleFunc("POST /currency/add", svc.AddCurrencyHandler())
// Revenue
mux.HandleFunc("POST /add/revenue/entry", svc.AddRevenueEntryHandler())
mux.HandleFunc("POST /api/v1/revenue/add", svc.AddRevenueEntryHandler())
}
func main() { func main() {
// --- CLI flags --- // --- CLI flags ---
port := flag.String("port", "8080", "Port to listen on") port := flag.String("port", "8080", "Port to listen on")
@@ -60,93 +93,8 @@ func main() {
// --- Routes --- // --- Routes ---
mux := http.NewServeMux() mux := http.NewServeMux()
RegisterRoutes(mux, db)
mux.HandleFunc("/health", handlers.HealthHandler(db)) fmt.Println("Server running on :8080")
http.ListenAndServe(addr, corsMiddleware(mux))
mux.HandleFunc("POST /trade/add", handlers.AddTradeHandler(db))
mux.HandleFunc("GET /trade/list", handlers.GetTradeListHandler(db))
mux.HandleFunc("GET /trade/search", handlers.GetTradeListHandler(db)) // new
//Positions
mux.HandleFunc("GET /positions/list", handlers.GetPositionListHandler(db))
mux.HandleFunc("GET /positions/closed/list", handlers.GetPositionListHandler(db)) // new
mux.HandleFunc("GET /positions/closed/search", handlers.GetTradeListHandler(db)) // new
// Company
mux.HandleFunc("POST /company/add", handlers.AddCompanyHandler(db))
mux.HandleFunc("GET /company/list", handlers.GetCompaniesHandler(db))
mux.HandleFunc("GET /company/revenue/categories", handlers.GetCompanyRevenueCategories(db))
mux.HandleFunc("POST /company/S-O/add", handlers.GetCompaniesHandler(db)) // new
mux.HandleFunc("GET /company/S-O/list", handlers.GetCompaniesHandler(db)) // new
mux.HandleFunc("GET /currency/list", handlers.GetCurrenciesHandler(db))
mux.HandleFunc("POST /currency/add", handlers.AddCurrencyHandler(db))
mux.HandleFunc("POST /add/revenue/entry", handlers.AddRevenueEntryHandler(db))
mux.HandleFunc("POST /api/v1/revenue/add", handlers.AddRevenueEntryHandler(db))
// --- Start server ---
fmt.Printf("Server running on %s\n", addr)
go func() {
log.Fatal(http.ListenAndServe(addr, corsMiddleware(mux)))
}()
runShell(db)
}
func runShell(db *sql.DB) {
scanner := bufio.NewScanner(os.Stdin)
fmt.Println("\nShell ready. Type 'help' for commands.")
for {
fmt.Print("> ")
if !scanner.Scan() {
break
}
parts := strings.Fields(scanner.Text())
if len(parts) == 0 {
continue
}
switch parts[0] {
// Company
case "add-company":
shell.AddCompany(scanner, db)
case "list-companies":
shell.ListCompanies(db)
// Currency
case "add-currency":
shell.AddCurrency(scanner, db)
case "list-currency":
shell.ListCurrencies(db)
// Revenue
case "add-revenue":
shell.AddRevenue(scanner, db)
case "list-revenue":
shell.ListRevenue(scanner, db)
case "help":
fmt.Println("\nCommands:")
fmt.Println(" --- Company ---")
fmt.Println(" add-company add a new company")
fmt.Println(" list-companies list all companies")
fmt.Println(" --- Currency ---")
fmt.Println(" add-currency add a new currency")
fmt.Println(" list-currency list all currencies")
fmt.Println(" --- Revenue ---")
fmt.Println(" add-revenue add a revenue entry interactively")
fmt.Println(" list-revenue list revenue for a company/period")
fmt.Println(" sum-revenue sum revenue across periods")
fmt.Println(" --- Other ---")
fmt.Println(" exit quit")
case "exit":
fmt.Println("Bye!")
os.Exit(0)
default:
fmt.Printf("Unknown command: %s. Type 'help' for commands.\n", parts[0])
}
}
} }