Compare commits

...

4 Commits

Author SHA1 Message Date
samantha42
fbc880f40b admin panel, login page with auth session. 2026-04-30 08:02:03 +02:00
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
zipfriis
1e64e45491 fix of db table creation 2026-04-09 13:19:35 +02:00
26 changed files with 2301 additions and 584 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
data/**
.env

Binary file not shown.

BIN
app.db

Binary file not shown.

9
go.mod
View File

@@ -2,4 +2,11 @@ module Portifolio
go 1.25.7
require github.com/mattn/go-sqlite3 v1.14.37 // indirect
require github.com/mattn/go-sqlite3 v1.14.37
require (
github.com/joho/godotenv v1.5.1 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
)
require github.com/FuLygon/go-totp/v2 v2.4.0

6
go.sum
View File

@@ -1,2 +1,8 @@
github.com/FuLygon/go-totp/v2 v2.4.0 h1:NTgab5GCVHHGjIjjOzRKKKxa6PhMI9o/fs2fFrJqqhE=
github.com/FuLygon/go-totp/v2 v2.4.0/go.mod h1:bEwZp8rat339MqPzXZ7FDl5eOJuXpQ35aYbk1qZbWLM=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=

View File

@@ -8,13 +8,14 @@ import (
_ "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
err := db.QueryRow(
`SELECT id, symbol, shares_outstanding, price, currency_id FROM companies WHERE symbol = ?`,
symbol,
).Scan(&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID)
err := db.QueryRow(`
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
WHERE `+where, arg,
).Scan(&c.ID, &c.Symbol, &c.SharesOutstanding, &c.Price, &c.CurrencyID, &c.CurrencyCode)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -24,20 +25,12 @@ func GetCompanyBySymbol(db *sql.DB, symbol string) (*model.Company, error) {
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) {
var c model.Company
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
return getCompany(db, "c.id = ?", id)
}
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) {
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 {
return nil, err
@@ -77,7 +72,7 @@ func GetAllCompanies(db *sql.DB) ([]model.Company, error) {
for rows.Next() {
var c model.Company
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 {
return nil, err
}

View File

@@ -82,7 +82,7 @@ func InitDB(db *sql.DB) {
);
-- parent table
CREATE TABLE closed_positions (
CREATE TABLE IF NOT EXISTS closed_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
currency_code TEXT NOT NULL,
@@ -94,7 +94,7 @@ func InitDB(db *sql.DB) {
);
-- child table, one row per close lot
CREATE TABLE close_entries (
CREATE TABLE IF NOT EXISTS close_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
closed_position_id INTEGER NOT NULL REFERENCES closed_positions(id),
shares INTEGER NOT NULL,

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

@@ -0,0 +1,58 @@
package middleware
import (
"net/http"
"sync"
)
// In-memory session store — swap for Redis/DB in production
var (
sessionStore = make(map[string]bool)
mu sync.RWMutex
)
// RegisterSession adds a token to the store (call this after login)
func RegisterSession(token string) {
mu.Lock()
defer mu.Unlock()
sessionStore[token] = true
}
// RevokeSession removes a token (call this on logout)
func RevokeSession(token string) {
mu.Lock()
defer mu.Unlock()
delete(sessionStore, token)
}
// RequireSession is the middleware — wraps any handler that needs auth
func RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session")
if err != nil {
// No cookie at all
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
mu.RLock()
valid := sessionStore[cookie.Value]
mu.RUnlock()
if !valid {
// Cookie exists but token is unknown/expired
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
MaxAge: -1, // delete it
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}

View File

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

View File

@@ -3,12 +3,51 @@ package service
import (
"Portifolio/internal/model"
"database/sql"
"encoding/json"
"net/http"
_ "github.com/mattn/go-sqlite3"
)
func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) {
res, err := db.Exec(
type Service struct {
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 (?, ?)`,
input.Code, input.Name,
)
@@ -19,9 +58,9 @@ func InsertCurrency(db *sql.DB, input model.CurrencyInput) (int, error) {
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{}
err := db.QueryRow(
err := s.db.QueryRow(
`SELECT id, code, name FROM currencies WHERE code = ?`, code,
).Scan(&c.ID, &c.Code, &c.Name)
if err == sql.ErrNoRows {
@@ -30,8 +69,8 @@ func GetCurrencyByCode(db *sql.DB, code string) (*model.Currency, error) {
return c, err
}
func GetAllCurrencies(db *sql.DB) ([]model.Currency, error) {
rows, err := db.Query(`SELECT id, code, name FROM currencies ORDER BY code`)
func (s *Service) GetAllCurrencies() ([]model.Currency, error) {
rows, err := s.db.Query(`SELECT id, code, name FROM currencies ORDER BY code`)
if err != nil {
return nil, err
}

View File

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

View File

@@ -3,15 +3,16 @@ package service
import (
"Portifolio/internal/database"
"Portifolio/internal/model"
"database/sql"
"encoding/json"
"fmt"
"net/http"
_ "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 {
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)
}
err = database.UpdatePositions(db, NewPositinos)
err = database.UpdatePositions(s.db, NewPositinos)
if err != nil {
return fmt.Errorf("Failed to insert the new postions number into db: %s", err)
}
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 (
"Portifolio/internal/database"
"Portifolio/internal/model"
"database/sql"
"encoding/json"
"fmt"
"net/http"
@@ -11,7 +10,7 @@ import (
_ "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) {
var input struct {
CompanyID int `json:"company_id"`
@@ -48,7 +47,7 @@ func AddRevenueEntryHandler(db *sql.DB) http.HandlerFunc {
Value: input.Value,
}
err := database.InsertRevenue(db, rev)
err := database.InsertRevenue(s.db, rev)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -60,27 +59,7 @@ func AddRevenueEntryHandler(db *sql.DB) 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 {
func (s *Service) GetCompanyRevenueCategories() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var input struct {
CompanyID int `json:"company_id"`
@@ -90,7 +69,7 @@ func GetCompanyRevenueCategories(db *sql.DB) http.HandlerFunc {
return
}
catlist, err := database.GetCategoriesByCompanyID(db, input.CompanyID)
catlist, err := database.GetCategoriesByCompanyID(s.db, input.CompanyID)
if err != nil {
http.Error(w, fmt.Sprintf("Could not find categories by that id:%s", err), http.StatusBadRequest)
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)
}
}

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

@@ -0,0 +1,368 @@
package website
import (
"Portifolio/internal/middleware"
"Portifolio/internal/model"
"Portifolio/internal/service"
"crypto/rand"
_ "embed"
"encoding/base64"
"fmt"
"net/http"
"os"
"strconv"
"time"
"github.com/FuLygon/go-totp/v2"
_ "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/login.html
var loginxHTML []byte
func GetAdminLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(loginxHTML)
}
}
type contextKey string
const ContextKeyRequestTime contextKey = "requestTime"
func LoginHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// checked once when the handler is registered
envUsername := os.Getenv("username")
envPassword := os.Getenv("password")
envTOTPSecret := os.Getenv("AUTH_TOTP_SECRET")
if envUsername == "" || envPassword == "" || envTOTPSecret == "" {
http.Error(w, "env var missing", http.StatusInternalServerError)
}
ts, ok := r.Context().Value(ContextKeyRequestTime).(time.Time)
if !ok {
ts = time.Now()
}
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form data", http.StatusBadRequest)
return
}
password := r.FormValue("password")
username := r.FormValue("username")
code := r.FormValue("auth_code")
fmt.Println(username, password, code)
if password == "" || username == "" || code == "" {
http.Error(w, "form value is empty", http.StatusBadRequest)
return
}
if password != envPassword || username != envUsername {
http.Error(w, "username or password is not valid", http.StatusUnauthorized)
return
}
v := totp.Validator{
Algorithm: totp.AlgorithmSHA1,
Digits: 6,
Period: 30,
Secret: envTOTPSecret,
}
valid, err := v.ValidateWithTimestamp(code, ts.Unix())
if err != nil {
http.Error(w, fmt.Sprintf("error validating TOTP code: %s", err), http.StatusUnauthorized)
return
}
if !valid {
http.Error(w, "invalid auth code", http.StatusUnauthorized)
return
}
// In your login POST handler, after setting the cookie:
token := generateSessionToken()
middleware.RegisterSession(token) // <-- register before writing cookie
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
w.Header().Set("HX-Redirect", "/admin")
w.WriteHeader(http.StatusOK)
}
}
// Simple session token generator
func generateSessionToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return base64.URLEncoding.EncodeToString(b)
}
//go:embed static/admin.html
var adminHTML []byte
func GetAdmin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(adminHTML)
}
}
//go:embed static/styles.css
var styleCSSmain []byte
func GetstylesheetMain() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSSmain)
}
}
//go:embed static/login.css
var styleCSSlogin []byte
func GetstylesheetLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSSlogin)
}
}
//go:embed static/admin.css
var styleCSSadmin []byte
func GetstylesheetAdmin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Write(styleCSSadmin)
}
}
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,444 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Fraunces:ital,wght@0,300;0,400;0,600;1,300;1,400&display=swap');
:root {
--bg: #f5efe7;;
--surface: #efefef;
--surface2:#e0e0e0;
--border: #8c8c8c;
--border2: #acacac;
--accent: #c8a96e;
--accent2: #f0d060;
--muted: #5a5a68;
--muted2: #3d3d4a;
--text: #000000;
--text-dim: #9e9a92;
--red: #f06060;
--blue: #60c0f0;
--radius: 2px;
--mono: 'Space Mono', monospace;
--serif: 'Fraunces', Georgia, serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 14px; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--mono);
min-height: 100vh;
}
/* GRID LAYOUT */
.app {
display: grid;
grid-template-columns: 220px 1fr;
grid-template-rows: 56px 1fr;
min-height: 100vh;
}
/* TOP NAV */
.topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 24px;
padding: 0 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.topbar-logo {
font-family: var(--serif);
font-size: 1rem;
font-style: italic;
font-weight: 300;
color: var(--text);
text-decoration: none;
}
.topbar-logo span { color: var(--accent); }
.topbar-tag {
font-size: 0.71rem;
color: var(--muted2);
letter-spacing: 0.04em;
}
.topbar-spacer { flex: 1; }
.status-pill {
display: flex;
align-items: center;
gap: 7px;
font-size: 0.71rem;
color: var(--muted2);
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--accent);
animation: pulse 2.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.dot.err { background: var(--red); animation: none; }
.dot.loading { background: var(--muted); animation: pulse 1s linear infinite; }
/* SIDEBAR */
.sidebar {
border-right: 1px solid var(--border);
background: var(--surface);
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-section-label {
font-size: 0.62rem;
color: var(--muted);
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 10px 18px 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 18px;
font-size: 0.75rem;
color: var(--muted2);
text-decoration: none;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
transition: color 0.15s, background 0.15s;
border-left: 2px solid transparent;
}
.nav-item:hover { color: var(--text); background: var(--surface2); }
.nav-item.active { color: var(--accent); border-left-color: var(--accent); background: rgba(200,240,96,0.05); }
.nav-icon { width: 14px; opacity: 0.7; font-style: normal; }
/* MAIN */
.main {
padding: 28px 32px;
overflow-y: auto;
max-height: calc(100vh - 56px);
}
/* PAGE HEADER */
.page-header {
margin-bottom: 28px;
display: flex;
align-items: flex-end;
gap: 16px;
}
.page-title {
font-family: var(--serif);
font-size: 2rem;
font-weight: 300;
line-height: 1;
}
.page-title span { font-style: italic; color: var(--accent); }
.page-subtitle {
font-size: 0.68rem;
color: var(--muted2);
padding-bottom: 4px;
letter-spacing: 0.04em;
}
/* SECTION */
.section { margin-bottom: 32px; }
.section-label {
font-size: 0.68rem;
color: var(--muted2);
letter-spacing: 0.08em;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 10px;
}
.section-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.section-label span { color: var(--accent); }
/* SUMMARY CARDS */
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 32px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
padding: 16px 18px;
}
.card-label {
font-size: 0.65rem;
color: var(--muted2);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 8px;
}
.card-value {
font-family: var(--serif);
font-size: 1.6rem;
font-weight: 300;
color: var(--text);
}
.card-value.accent { color: var(--accent); }
.card-value.dim { color: var(--muted); }
/* PANEL / FORM */
.panel {
background: var(--surface);
border: 1px solid var(--border);
}
.panel-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.panel-title {
font-size: 0.75rem;
letter-spacing: 0.04em;
}
.panel-title span { color: var(--accent); }
.panel-body { padding: 20px; }
/* FORM GRID */
.form-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.form-grid.two { grid-template-columns: repeat(2, 1fr); }
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group.full { grid-column: 1 / -1; }
.form-label {
font-size: 0.65rem;
color: var(--muted2);
letter-spacing: 0.07em;
text-transform: uppercase;
}
.form-label .required { color: var(--red); margin-left: 3px; }
input, select, textarea {
background: var(--bg);
border: 1px solid var(--border2);
color: var(--text);
font-family: var(--mono);
font-size: 0.78rem;
padding: 9px 12px;
border-radius: var(--radius);
outline: none;
width: 100%;
transition: border-color 0.15s, box-shadow 0.15s;
-webkit-appearance: none;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(200,240,96,0.1);
}
input::placeholder { color: var(--muted); }
select option { background: var(--surface2); }
.type-toggle {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0;
border: 1px solid var(--border2);
border-radius: var(--radius);
overflow: hidden;
}
.type-btn {
padding: 9px 8px;
font-family: var(--mono);
font-size: 0.72rem;
border: none;
cursor: pointer;
background: var(--bg);
color: var(--muted2);
transition: background 0.15s, color 0.15s;
letter-spacing: 0.03em;
border-right: 1px solid var(--border2);
}
.type-btn:last-child { border-right: none; }
.type-btn.active-buy { background: rgba(96,192,240,0.12); color: var(--blue); }
.type-btn.active-sell { background: rgba(240,96,96,0.12); color: var(--red); }
.type-btn.active-div { background: rgba(200,240,96,0.12); color: var(--accent); }
/* DIVIDEND FIELDS */
.dividend-fields {
grid-column: 1 / -1;
display: none;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
margin-top: 4px;
}
.dividend-fields.visible { display: grid; }
.div-label {
grid-column: 1 / -1;
font-size: 0.63rem;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: -8px;
}
/* FORM ACTIONS */
.form-actions {
display: flex;
align-items: center;
gap: 12px;
padding-top: 16px;
border-top: 1px solid var(--border);
margin-top: 4px;
grid-column: 1 / -1;
}
.btn {
font-family: var(--mono);
font-size: 0.72rem;
padding: 9px 20px;
border: 1px solid transparent;
cursor: pointer;
letter-spacing: 0.04em;
transition: background 0.15s, color 0.15s, transform 0.1s;
border-radius: var(--radius);
}
.btn:active { transform: scale(0.98); }
.btn-primary {
background: var(--accent);
color: #0d0d0d;
font-weight: 700;
}
.btn-primary:hover { background: #d8ff70; }
.btn-ghost {
background: transparent;
color: var(--muted2);
border-color: var(--border2);
}
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
/* FEEDBACK */
.feedback {
font-size: 0.72rem;
padding: 4px 12px;
border-radius: var(--radius);
display: none;
}
.feedback.success { display: inline; color: var(--accent); }
.feedback.error { display: inline; color: var(--red); }
/* TABLE */
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
}
thead {
background: var(--surface2);
}
th {
padding: 10px 14px;
text-align: left;
font-size: 0.62rem;
font-weight: 400;
color: var(--muted2);
letter-spacing: 0.07em;
text-transform: uppercase;
white-space: nowrap;
border-bottom: 1px solid var(--border);
}
td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
color: var(--text);
white-space: nowrap;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,0.025); }
.td-sym {
font-weight: 700;
color: var(--accent);
font-size: 0.78rem;
}
.td-muted { color: var(--muted2); }
.td-buy { color: var(--blue); }
.td-sell { color: var(--red); }
.td-div { color: var(--accent); }
.td-right { text-align: right; }
.badge {
font-size: 0.62rem;
padding: 2px 8px;
border-radius: 20px;
letter-spacing: 0.04em;
}
.badge-buy { background: rgba(96,192,240,0.12); color: var(--blue); }
.badge-sell { background: rgba(240,96,96,0.12); color: var(--red); }
.badge-div { background: rgba(200,240,96,0.12); color: var(--accent); }
/* TABS */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: -1px;
}
.tab {
padding: 10px 20px;
font-size: 0.72rem;
cursor: pointer;
color: var(--muted2);
border: none;
background: none;
font-family: var(--mono);
border-bottom: 2px solid transparent;
transition: color 0.15s;
letter-spacing: 0.03em;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* PILL TAG */
.tag-pill {
font-size: 0.60rem;
padding: 2px 7px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--muted2);
border-radius: 20px;
letter-spacing: 0.04em;
}
/* PAGES */
.page { display: none; }
.page.active { display: block; }
/* RESPONSIVE NOTE */
@media(max-width:900px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
.cards { grid-template-columns: repeat(2, 1fr); }
.form-grid { grid-template-columns: 1fr; }
.dividend-fields { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,582 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Portfolio Admin — Samantha Friis</title>
<link rel="stylesheet" href="/styles/admin">
</head>
<body>
<div class="app">
<!-- TOP BAR -->
<header class="topbar">
<a href="/" class="topbar-logo">Samantha <span>Friis</span></a>
<span class="topbar-tag">// portfolio admin</span>
<div class="topbar-spacer"></div>
<div class="status-pill">
<span class="dot" id="status-dot"></span>
<span id="status-text">live</span>
</div>
</header>
<!-- SIDEBAR -->
<nav class="sidebar">
<span class="nav-section-label">overview</span>
<button class="nav-item active" onclick="navigate('dashboard', this)">
<i class="nav-icon"></i> Dashboard
</button>
<button class="nav-item" onclick="navigate('positions', this)">
<i class="nav-icon"></i> Positions
</button>
<button class="nav-item" onclick="navigate('history', this)">
<i class="nav-icon"></i> Trade History
</button>
<span class="nav-section-label" style="margin-top:12px">add data</span>
<button class="nav-item" onclick="navigate('add-trade', this)">
<i class="nav-icon"></i> Add Trade
</button>
<button class="nav-item" onclick="navigate('add-company', this)">
<i class="nav-icon"></i> Add Company
</button>
</nav>
<!-- MAIN CONTENT -->
<main class="main">
<!-- ═══ DASHBOARD ═══ -->
<div class="page active" id="page-dashboard">
<div class="page-header">
<h1 class="page-title">Investment <span>Portfolio</span></h1>
<span class="page-subtitle">Equity positions &nbsp;·&nbsp; Personal research</span>
</div>
<div class="cards">
<div class="card">
<p class="card-label">Positions</p>
<p class="card-value accent" id="stat-positions"></p>
</div>
<div class="card">
<p class="card-label">Total Shares</p>
<p class="card-value" id="stat-shares"></p>
</div>
<div class="card">
<p class="card-label">Total Trades</p>
<p class="card-value" id="stat-trades"></p>
</div>
<div class="card">
<p class="card-label">Currencies</p>
<p class="card-value" id="stat-currencies"></p>
</div>
</div>
<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>
<p class="section-label" style="margin-top:28px">// <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 id="companies-body">
<tr><td colspan="5" class="td-muted">loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ═══ POSITIONS ═══ -->
<div class="page" id="page-positions">
<div class="page-header">
<h1 class="page-title">Open <span>Positions</span></h1>
</div>
<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>
</div>
<!-- ═══ HISTORY ═══ -->
<div class="page" id="page-history">
<div class="page-header">
<h1 class="page-title">Trade <span>History</span></h1>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th><th>Symbol</th><th>Type</th>
<th>Shares</th><th>Price</th><th>Currency</th><th>Total</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td colspan="7" class="td-muted">loading…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ═══ ADD TRADE ═══ -->
<div class="page" id="page-add-trade">
<div class="page-header">
<h1 class="page-title">Add <span>Trade</span></h1>
<span class="page-subtitle">Record a buy, sell, or dividend</span>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">// <span>new trade</span></span>
<span class="tag-pill">POST /trades</span>
</div>
<div class="panel-body">
<div class="form-grid" id="trade-form">
<!-- Type toggle -->
<div class="form-group">
<label class="form-label">Trade type <span class="required">*</span></label>
<div class="type-toggle">
<button class="type-btn active-buy" data-val="0" onclick="setTradeType(0, this)">BUY</button>
<button class="type-btn" data-val="1" onclick="setTradeType(1, this)">SELL</button>
<button class="type-btn" data-val="2" onclick="setTradeType(2, this)">DIVIDEND</button>
</div>
<input type="hidden" id="trade-type" value="0"/>
</div>
<!-- Symbol -->
<div class="form-group">
<label class="form-label" for="trade-symbol">Symbol <span class="required">*</span></label>
<input type="text" id="trade-symbol" placeholder="AAPL" style="text-transform:uppercase"/>
</div>
<!-- Date -->
<div class="form-group">
<label class="form-label" for="trade-date">Date <span class="required">*</span></label>
<input type="date" id="trade-date"/>
</div>
<!-- Shares -->
<div class="form-group">
<label class="form-label" for="trade-shares">Shares <span class="required">*</span></label>
<input type="number" id="trade-shares" placeholder="100" min="0" step="1"/>
</div>
<!-- Price -->
<div class="form-group">
<label class="form-label" for="trade-price">Price per share <span class="required">*</span></label>
<input type="number" id="trade-price" placeholder="0.00" min="0" step="0.01"/>
</div>
<!-- Currency -->
<div class="form-group">
<label class="form-label" for="trade-currency">Currency <span class="required">*</span></label>
<select id="trade-currency">
<option value="USD">USD — US Dollar</option>
<option value="EUR">EUR — Euro</option>
<option value="GBP">GBP — British Pound</option>
<option value="DKK">DKK — Danish Krone</option>
<option value="SEK">SEK — Swedish Krona</option>
<option value="NOK">NOK — Norwegian Krone</option>
<option value="JPY">JPY — Japanese Yen</option>
<option value="CHF">CHF — Swiss Franc</option>
<option value="CAD">CAD — Canadian Dollar</option>
</select>
</div>
<!-- Product ID -->
<div class="form-group">
<label class="form-label" for="trade-product">Product ID</label>
<input type="number" id="trade-product" placeholder="0" min="0" step="1"/>
</div>
<!-- DIVIDEND-ONLY FIELDS -->
<div class="dividend-fields" id="dividend-fields">
<span class="div-label">dividend fields</span>
<div class="form-group">
<label class="form-label" for="div-net">Net value</label>
<input type="number" id="div-net" placeholder="0.00" step="0.01"/>
</div>
<div class="form-group">
<label class="form-label" for="div-tax-amount">Tax amount</label>
<input type="number" id="div-tax-amount" placeholder="0.00" step="0.01"/>
</div>
<div class="form-group">
<label class="form-label" for="div-tax-rate">Tax rate</label>
<input type="number" id="div-tax-rate" placeholder="0.00" step="0.0001" min="0" max="1"/>
</div>
<div class="form-group">
<label class="form-label" for="div-payment-date">Payment date</label>
<input type="date" id="div-payment-date"/>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<button class="btn btn-primary" onclick="submitTrade()">Submit trade →</button>
<button class="btn btn-ghost" onclick="resetTradeForm()">Clear</button>
<span class="feedback" id="trade-feedback"></span>
</div>
</div>
</div>
</div>
<!-- Live preview -->
<div class="panel" style="margin-top:16px">
<div class="panel-header">
<span class="panel-title">// <span>payload preview</span></span>
</div>
<div class="panel-body">
<pre id="trade-preview" style="font-size:0.72rem;color:var(--muted2);line-height:1.7;white-space:pre-wrap">{}</pre>
</div>
</div>
</div>
<!-- ═══ ADD COMPANY ═══ -->
<div class="page" id="page-add-company">
<div class="page-header">
<h1 class="page-title">Add <span>Company</span></h1>
<span class="page-subtitle">Track a new company in the universe</span>
</div>
<div class="panel">
<div class="panel-header">
<span class="panel-title">// <span>new company</span></span>
<span class="tag-pill">POST /company</span>
</div>
<div class="panel-body">
<div class="form-grid two">
<div class="form-group">
<label class="form-label" for="co-symbol">Symbol <span class="required">*</span></label>
<input type="text" id="co-symbol" placeholder="AAPL" style="text-transform:uppercase"/>
</div>
<div class="form-group">
<label class="form-label" for="co-currency-code">Currency code <span class="required">*</span></label>
<select id="co-currency-code">
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="DKK">DKK</option>
<option value="SEK">SEK</option>
<option value="NOK">NOK</option>
<option value="JPY">JPY</option>
<option value="CHF">CHF</option>
<option value="CAD">CAD</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="co-currency-id">Currency ID <span class="required">*</span></label>
<input type="number" id="co-currency-id" placeholder="1" min="1" step="1"/>
</div>
<div class="form-group">
<label class="form-label" for="co-price">Current price <span class="required">*</span></label>
<input type="number" id="co-price" placeholder="0.00" step="0.01"/>
</div>
<div class="form-group full">
<label class="form-label" for="co-shares-out">Shares outstanding <span class="required">*</span></label>
<input type="number" id="co-shares-out" placeholder="1000000000" min="0" step="1"/>
</div>
<div class="form-actions">
<button class="btn btn-primary" onclick="submitCompany()">Add company →</button>
<button class="btn btn-ghost" onclick="resetCompanyForm()">Clear</button>
<span class="feedback" id="company-feedback"></span>
</div>
</div>
</div>
</div>
<div class="panel" style="margin-top:16px">
<div class="panel-header">
<span class="panel-title">// <span>payload preview</span></span>
</div>
<div class="panel-body">
<pre id="company-preview" style="font-size:0.72rem;color:var(--muted2);line-height:1.7;white-space:pre-wrap">{}</pre>
</div>
</div>
</div>
</main>
</div>
<script>
// ── MOCK DATA ────────────────────────────────────────────
const MOCK_POSITIONS = [
{ symbol:'AAPL', currency:'USD', shares:150, costBasis:22050, weight:'34.2%' },
{ symbol:'MSFT', currency:'USD', shares:80, costBasis:28800, weight:'27.1%' },
{ symbol:'NOVO B', currency:'DKK', shares:200, costBasis:18000, weight:'25.5%' },
{ symbol:'ASML', currency:'EUR', shares:15, costBasis:11250, weight:'13.2%' },
];
const MOCK_COMPANIES = [
{ symbol:'AAPL', currency:'USD', price:182.50, sharesOut:'15.4B', mktCap:'2.81T' },
{ symbol:'MSFT', currency:'USD', price:360.00, sharesOut:'7.4B', mktCap:'2.66T' },
{ symbol:'NOVO B', currency:'DKK', price:635.00, sharesOut:'2.2B', mktCap:'1.40T' },
{ symbol:'ASML', currency:'EUR', price:750.00, sharesOut:'406M', mktCap:'304B' },
];
const MOCK_TRADES = [
{ date:'2026-04-15', symbol:'AAPL', type:0, shares:50, price:175.20, currency:'USD', total:8760.00 },
{ date:'2026-03-22', symbol:'MSFT', type:0, shares:20, price:355.00, currency:'USD', total:7100.00 },
{ date:'2026-03-01', symbol:'NOVO B', type:2, shares:200, price:18.50, currency:'DKK', total:3700.00 },
{ date:'2026-02-10', symbol:'AAPL', type:1, shares:10, price:180.00, currency:'USD', total:1800.00 },
];
// ── NAV ──────────────────────────────────────────────────
function navigate(page, el) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
if (el) el.classList.add('active');
if (page === 'dashboard' || page === 'positions') loadPositions();
if (page === 'history') loadHistory();
}
// ── RENDER TABLES ────────────────────────────────────────
function loadPositions() {
const total = MOCK_POSITIONS.reduce((s,p)=>s+p.costBasis,0);
const tbody1 = document.getElementById('positions-body');
const tbody2 = document.getElementById('positions-body-2');
const rows = MOCK_POSITIONS.map(p => `
<tr>
<td class="td-sym">${p.symbol}</td>
<td class="td-muted">${p.currency}</td>
<td>${p.shares.toLocaleString()}</td>
<td>${p.costBasis.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
<td class="td-muted">${p.weight}</td>
</tr>`).join('');
if (tbody1) tbody1.innerHTML = rows;
if (tbody2) tbody2.innerHTML = rows;
// summary stats
document.getElementById('stat-positions').textContent = MOCK_POSITIONS.length;
document.getElementById('stat-shares').textContent = MOCK_POSITIONS.reduce((s,p)=>s+p.shares,0).toLocaleString();
document.getElementById('stat-trades').textContent = MOCK_TRADES.length;
document.getElementById('stat-currencies').textContent = [...new Set(MOCK_POSITIONS.map(p=>p.currency))].length;
const tbody3 = document.getElementById('companies-body');
if (tbody3) tbody3.innerHTML = MOCK_COMPANIES.map(c=>`
<tr>
<td class="td-sym">${c.symbol}</td>
<td class="td-muted">${c.currency}</td>
<td>${c.price.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
<td class="td-muted">${c.sharesOut}</td>
<td>${c.mktCap}</td>
</tr>`).join('');
}
function loadHistory() {
const typeMap = ['BUY','SELL','DIVIDEND'];
const classMap = ['td-buy','td-sell','td-div'];
const badgeMap = ['badge-buy','badge-sell','badge-div'];
document.getElementById('history-body').innerHTML = MOCK_TRADES.map(t=>`
<tr>
<td class="td-muted">${t.date}</td>
<td class="td-sym">${t.symbol}</td>
<td><span class="badge ${badgeMap[t.type]}">${typeMap[t.type]}</span></td>
<td>${t.shares}</td>
<td>${t.price.toFixed(2)}</td>
<td class="td-muted">${t.currency}</td>
<td>${t.total.toLocaleString(undefined,{minimumFractionDigits:2})}</td>
</tr>`).join('');
}
// ── TRADE TYPE TOGGLE ─────────────────────────────────────
function setTradeType(val, btn) {
document.querySelectorAll('.type-btn').forEach(b => {
b.classList.remove('active-buy','active-sell','active-div');
});
const cls = ['active-buy','active-sell','active-div'][val];
btn.classList.add(cls);
document.getElementById('trade-type').value = val;
const df = document.getElementById('dividend-fields');
df.classList.toggle('visible', val === 2);
updateTradePreview();
}
// ── PAYLOAD PREVIEW ──────────────────────────────────────
function tradePayload() {
const type = parseInt(document.getElementById('trade-type').value);
const p = {
symbol: document.getElementById('trade-symbol').value.toUpperCase() || '',
shares: parseInt(document.getElementById('trade-shares').value) || 0,
product: parseInt(document.getElementById('trade-product').value) || 0,
type,
price: parseFloat(document.getElementById('trade-price').value) || 0,
currency_code: document.getElementById('trade-currency').value,
date: document.getElementById('trade-date').value || new Date().toISOString().split('T')[0],
};
if (type === 2) {
p.net_value = parseFloat(document.getElementById('div-net').value) || 0;
p.tax_amount = parseFloat(document.getElementById('div-tax-amount').value) || 0;
p.tax_rate = parseFloat(document.getElementById('div-tax-rate').value) || 0;
p.payment_date = document.getElementById('div-payment-date').value || '';
}
return p;
}
function updateTradePreview() {
document.getElementById('trade-preview').textContent =
JSON.stringify(tradePayload(), null, 2);
}
function updateCompanyPreview() {
const p = {
symbol: document.getElementById('co-symbol').value.toUpperCase() || '',
shares_outstanding: parseInt(document.getElementById('co-shares-out').value) || 0,
price: parseFloat(document.getElementById('co-price').value) || 0,
currency_id: parseInt(document.getElementById('co-currency-id').value) || 0,
currency_code: document.getElementById('co-currency-code').value,
};
document.getElementById('company-preview').textContent =
JSON.stringify(p, null, 2);
}
// Hook preview update to all trade inputs
document.querySelectorAll('#trade-form input, #trade-form select').forEach(el => {
el.addEventListener('input', updateTradePreview);
});
// ── SUBMIT TRADE ─────────────────────────────────────────
async function submitTrade() {
const payload = tradePayload();
const fb = document.getElementById('trade-feedback');
fb.className = 'feedback';
if (!payload.symbol || !payload.price || !payload.shares) {
fb.textContent = '✗ fill in required fields';
fb.className = 'feedback error';
return;
}
try {
const res = await fetch('/trades', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
fb.textContent = '✓ trade recorded';
fb.className = 'feedback success';
// Add to mock history for demo
MOCK_TRADES.unshift({
date: payload.date, symbol: payload.symbol, type: payload.type,
shares: payload.shares, price: payload.price,
currency: payload.currency_code,
total: payload.shares * payload.price,
});
setTimeout(() => { fb.className = 'feedback'; }, 3000);
} catch(e) {
// For demo: show success anyway (backend not connected)
fb.textContent = '✓ trade recorded (demo)';
fb.className = 'feedback success';
setTimeout(() => { fb.className = 'feedback'; }, 3000);
}
}
async function submitCompany() {
const fb = document.getElementById('company-feedback');
fb.className = 'feedback';
const payload = {
symbol: document.getElementById('co-symbol').value.toUpperCase(),
shares_outstanding: parseInt(document.getElementById('co-shares-out').value) || 0,
price: parseFloat(document.getElementById('co-price').value) || 0,
currency_id: parseInt(document.getElementById('co-currency-id').value) || 0,
currency_code: document.getElementById('co-currency-code').value,
};
if (!payload.symbol || !payload.price) {
fb.textContent = '✗ fill in required fields';
fb.className = 'feedback error';
return;
}
try {
const res = await fetch('/company', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`${res.status}`);
fb.textContent = '✓ company added';
fb.className = 'feedback success';
} catch {
fb.textContent = '✓ company added (demo)';
fb.className = 'feedback success';
}
setTimeout(() => { fb.className = 'feedback'; }, 3000);
}
function resetTradeForm() {
['trade-symbol','trade-shares','trade-price','trade-product',
'div-net','div-tax-amount','div-tax-rate','div-payment-date'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('trade-date').value = '';
document.getElementById('trade-type').value = '0';
document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active-buy','active-sell','active-div'));
document.querySelector('.type-btn[data-val="0"]').classList.add('active-buy');
document.getElementById('dividend-fields').classList.remove('visible');
document.getElementById('trade-feedback').className = 'feedback';
updateTradePreview();
}
function resetCompanyForm() {
['co-symbol','co-currency-id','co-price','co-shares-out'].forEach(id => {
document.getElementById(id).value = '';
});
document.getElementById('company-feedback').className = 'feedback';
updateCompanyPreview();
}
// ── COMPANY FORM PREVIEW HOOKS ───────────────────────────
['co-symbol','co-currency-id','co-price','co-shares-out','co-currency-code'].forEach(id => {
document.getElementById(id).addEventListener('input', updateCompanyPreview);
});
// ── INIT ─────────────────────────────────────────────────
loadPositions();
const today = new Date().toISOString().split('T')[0];
document.getElementById('trade-date').value = today;
updateTradePreview();
updateCompanyPreview();
</script>
</body>
</html>

View File

@@ -0,0 +1,138 @@
<!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="/styles/main">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
</head>
<body>
<div class="container">
<header>
<a href="/" class="back">← back,</a>
<a href="/login" class="back"> login</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,224 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--ink: #1a1714;
--paper: #f5f0e8;
--accent: #c4622d;
--muted: #8c8070;
--border: #d6cfc3;
--field-bg: #ece7dd;
}
html, body {
height: 100%;
background: var(--paper);
color: var(--ink);
font-family: 'Space Mono', monospace;
font-size: 13px;
}
body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
}
/* Grain overlay */
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
opacity: 0.5;
}
.container {
position: relative;
z-index: 1;
width: 100%;
max-width: 420px;
animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes rise {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
header {
margin-bottom: 40px;
}
.back {
display: inline-block;
font-family: 'Space Mono', monospace;
font-size: 11px;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.04em;
margin-bottom: 28px;
transition: color 0.2s;
}
.back:hover { color: var(--ink); }
.tag {
font-family: 'Space Mono', monospace;
font-size: 11px;
color: var(--accent);
letter-spacing: 0.08em;
margin-bottom: 10px;
}
h1 {
font-family: 'Fraunces', serif;
font-weight: 300;
font-size: clamp(36px, 8vw, 52px);
line-height: 1.05;
letter-spacing: -0.02em;
color: var(--ink);
}
h1 span {
font-style: italic;
color: var(--accent);
}
.header-sub {
margin-top: 10px;
font-size: 11px;
color: var(--muted);
letter-spacing: 0.06em;
}
/* Rule */
.rule {
width: 100%;
height: 1px;
background: var(--border);
margin: 32px 0;
}
/* Form */
.form-group {
margin-bottom: 20px;
}
label {
display: block;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 7px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 11px 14px;
background: var(--field-bg);
border: 1px solid var(--border);
border-radius: 2px;
font-family: 'Space Mono', monospace;
font-size: 13px;
color: var(--ink);
outline: none;
transition: border-color 0.2s, background 0.2s;
-webkit-appearance: none;
}
input[type="text"]:focus,
input[type="password"]:focus {
border-color: var(--accent);
background: var(--paper);
}
input::placeholder { color: var(--muted); opacity: 0.7; }
.form-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 28px;
gap: 16px;
}
.btn-login {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--ink);
color: var(--paper);
font-family: 'Space Mono', monospace;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
border: none;
padding: 12px 24px;
cursor: pointer;
border-radius: 2px;
transition: background 0.2s, transform 0.15s;
}
.btn-login:hover {
background: var(--accent);
transform: translateY(-1px);
}
.btn-login:active { transform: translateY(0); }
.btn-login svg {
transition: transform 0.2s;
}
.btn-login:hover svg { transform: translateX(3px); }
.forgot {
font-size: 11px;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.03em;
border-bottom: 1px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.forgot:hover { color: var(--ink); border-color: var(--ink); }
footer {
margin-top: 48px;
display: flex;
justify-content: space-between;
align-items: flex-end;
border-top: 1px solid var(--border);
padding-top: 16px;
animation: rise 0.6s 0.15s cubic-bezier(0.22, 1, 0.36, 1) both;
}
footer a {
font-size: 10px;
color: var(--muted);
text-decoration: none;
letter-spacing: 0.04em;
transition: color 0.2s;
}
footer a:hover { color: var(--accent); }
.footer-copy {
font-size: 10px;
color: var(--border);
letter-spacing: 0.04em;
}
/* Decorative corner mark */
.corner-mark {
position: fixed;
bottom: 24px;
right: 28px;
font-family: 'Fraunces', serif;
font-style: italic;
font-size: 11px;
color: var(--border);
letter-spacing: 0.06em;
z-index: 1;
}

View File

@@ -0,0 +1,67 @@
<!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="/styles/login">
<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>
<div class="rule"></div>
<form
hx-post="/auth/login"
hx-target="#login-response"
hx-swap="innerHTML"
hx-indicator=".btn-login"
>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="your handle" autocomplete="username" required />
</div>
<div class="form-group">
<label for="password">Passphrase</label>
<input type="password" id="password" name="password" placeholder="········" autocomplete="current-password" required />
</div>
<div class="form-group">
<label>Auth Code</label>
<input type="text" id="auth-code" name="auth_code" />
</div>
<div class="form-footer">
<button class="btn-login" type="submit">
<span class="btn-label">
Enter
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5H13M9 1L13 5L9 9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="btn-spinner"></span>
</button>
</div>
<div id="login-response"></div>
</form>
<footer>
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
<p class="footer-copy">&copy; 2026 — All rights reserved</p>
</footer>
</div>
<p class="corner-mark">SF — 2026</p>
</body>
</html>

View File

@@ -0,0 +1,138 @@
:root {
--bg: #f5efe7;;
--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; }

160
main.go
View File

@@ -2,16 +2,17 @@ package main
import (
"Portifolio/internal/database"
"Portifolio/internal/handlers"
"Portifolio/internal/shell"
"bufio"
"Portifolio/internal/middleware"
"Portifolio/internal/service"
"Portifolio/internal/website"
"database/sql"
"flag"
"fmt"
"log"
"net/http"
"os"
"strings"
"github.com/joho/godotenv"
_ "github.com/mattn/go-sqlite3"
)
@@ -35,6 +36,57 @@ func corsMiddleware(next http.Handler) http.Handler {
})
}
func EnvCheck() error {
if os.Getenv("username") == "" {
return fmt.Errorf("Missing: username")
}
if os.Getenv("password") == "" {
return fmt.Errorf("Missing: password")
}
if os.Getenv("AUTH_TOTP_SECRET") == "" {
return fmt.Errorf("Missing: AUTH_TOTP_SECRET")
}
return nil
}
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("/styles/main", website.GetstylesheetMain())
mux.HandleFunc("/styles/login", website.GetstylesheetLogin())
mux.HandleFunc("/styles/admin", website.GetstylesheetAdmin())
mux.Handle("/admin", middleware.RequireSession(website.GetAdmin()))
mux.HandleFunc("/login", website.GetAdminLogin())
mux.HandleFunc("POST /auth/login", website.LoginHandler())
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())
mux.HandleFunc("POST /trade/add", svc.AddTradeHandler())
mux.HandleFunc("GET /trade/list", svc.GetTradeListHandler())
//mux.HandleFunc("GET /trade/search", svc.SearchTradeHandler(svc))
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))
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))
mux.HandleFunc("GET /currency/list", svc.GetCurrenciesHandler())
mux.HandleFunc("POST /currency/add", svc.AddCurrencyHandler())
mux.HandleFunc("POST /add/revenue/entry", svc.AddRevenueEntryHandler())
mux.HandleFunc("POST /api/v1/revenue/add", svc.AddRevenueEntryHandler())
}
func main() {
// --- CLI flags ---
port := flag.String("port", "8080", "Port to listen on")
@@ -55,98 +107,22 @@ func main() {
log.Fatal("Failed to connect to database:", err)
}
err = godotenv.Load(".env")
if err != nil {
log.Fatal("Error loading .env file")
}
err = EnvCheck()
if err != nil {
log.Fatal("Env vars not found:", err)
}
database.InitDB(db)
fmt.Println("Connected to SQLite database")
// --- Routes ---
mux := http.NewServeMux()
RegisterRoutes(mux, db)
mux.HandleFunc("/health", handlers.HealthHandler(db))
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])
}
}
fmt.Println("Server running on :8080")
http.ListenAndServe(addr, corsMiddleware(mux))
}