Compare commits

...

10 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
zipfriis
430127c0f1 Merge branch 'main' of git.samantha42.xyz:samantha/Portifolio-Engine 2026-04-09 13:17:21 +02:00
zipfriis
9f2448dacf port options 2026-04-09 13:13:50 +02:00
samantha42
57ae3cfb06 added dividend db table and insert by trade 2026-04-06 07:36:45 +02:00
samantha42
31108a16d0 new model and endpoints.. 2026-03-28 08:33:12 +01:00
zipfriis
c2276b6e13 Merge branch 'main' of git.samantha42.xyz:samantha/Portifolio-Engine 2026-03-27 21:27:35 +01:00
zipfriis
a949fde15c open list 2026-03-27 21:26:31 +01:00
28 changed files with 2450 additions and 589 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 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)
func GetCompanyBySymbol(db *sql.DB, symbol string) (*model.Company, error) {
return getCompany(db, "c.symbol = ?", symbol)
}
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query company: %w", err)
}
return &c, nil
func GetCompanyByID(db *sql.DB, id int) (*model.Company, error) {
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

@@ -17,14 +17,26 @@ func InitDB(db *sql.DB) {
);
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
currency_code TEXT NOT NULL,
shares INTEGER NOT NULL,
product INTEGER NOT NULL CHECK(product IN (0, 1, 2, 3)),
type INTEGER NOT NULL CHECK(type IN (0, 1)),
price REAL NOT NULL,
traded_at DATETIME NOT NULL
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
currency_code TEXT NOT NULL,
shares INTEGER NOT NULL,
product INTEGER NOT NULL CHECK(product IN (0, 1, 2, 3, 4)), -- added 4 for BondTrade
type INTEGER NOT NULL CHECK(type IN (0, 1)), -- Buy=0, Sell=1 only; Dividend has its own table
price REAL NOT NULL,
traded_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS dividends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
currency_code TEXT NOT NULL,
product INTEGER NOT NULL CHECK(product IN (0, 1, 2, 3, 4)),
value REAL NOT NULL,
tax_amount REAL NOT NULL DEFAULT 0,
tax_rate REAL NOT NULL DEFAULT 0,
net_value REAL NOT NULL,
payment_date DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS position (
@@ -69,6 +81,29 @@ func InitDB(db *sql.DB) {
UNIQUE(company_id, name)
);
-- parent table
CREATE TABLE IF NOT EXISTS closed_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL,
currency_code TEXT NOT NULL,
product TEXT NOT NULL,
open_time DATETIME NOT NULL,
realized_gain REAL,
tax_amount REAL,
holding_days INTEGER
);
-- child table, one row per close lot
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,
in_price REAL NOT NULL,
out_price REAL NOT NULL,
gain_price REAL NOT NULL,
close_time DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS revenue_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL,

View File

@@ -25,14 +25,7 @@ func GetTrades(db *sql.DB) ([]model.Trade, error) {
return nil, err
}
switch typeInt {
case 0:
t.Type = model.TradeType(false)
case 1:
t.Type = model.TradeType(true)
default:
return nil, fmt.Errorf("failed to convert given Type int to bool of trade type.")
}
t.Type = model.TradeType(typeInt)
trades = append(trades, t)
}
@@ -81,6 +74,21 @@ func InsertTrade(db *sql.DB, trade model.Trade) error {
return err
}
func InsertDividend(db *sql.DB, div model.Dividend) error {
_, err := db.Exec(
"INSERT INTO trades (symbol, currency_code, shares, product, value, tax_amount, tax_rate, net_value, payment_date) VALUES (?, ?, ?, ?, ?, ?, ?)",
div.Symbol,
div.CurrencyCode,
div.Product,
div.Value,
div.TaxAmount,
div.TaxRate,
div.NetValue,
div.PaymentDate,
)
return err
}
func UpdatePositions(db *sql.DB, positions []model.Position) error {
// Complete overwrite of the db positions
_, err := db.Exec("DELETE FROM position")

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,96 +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
}
err := req.Validate()
if err != nil {
http.Error(w, fmt.Sprintf("failed to validate trade: %s", err), http.StatusInternalServerError)
return
}
// check if currency is in the db.
currency, err := database.GetCurrencyByCode(db, req.CurrencyCode)
if err != nil {
http.Error(w, fmt.Sprintf("failed to find currency: %s", err), http.StatusInternalServerError)
return
}
trade := model.Trade{
Symbol: req.Symbol,
Shares: req.Shares,
Product: model.TradeProduct(req.Product),
Type: model.TradeType(req.Type),
Price: req.Price,
CurrencyCode: currency.Code,
Date: req.Date,
}
err = database.InsertTrade(db, trade)
if err != nil {
http.Error(w, fmt.Sprintf("failed to insert trade into db: %s", err), http.StatusInternalServerError)
return
}
err = service.UpdatePositionByTradeList(db)
update := true
if err != nil {
update = false
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{"success": true, "position update": update}); err != nil {
http.Error(w, fmt.Sprintf("failed to encode trades: %s", err), http.StatusInternalServerError)
return
}
}
}
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(map[string]any{"List": 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

@@ -2,9 +2,40 @@ package model
import (
"errors"
"fmt"
"time"
)
type Dividend struct {
Symbol string
CurrencyCode string
Product TradeProduct
Value float64
PaymentDate time.Time
TaxAmount float64
TaxRate float64
NetValue float64
}
type CloseEntry struct {
Shares int
InPrice float64
OutPrice float64
GainPrice float64
CloseTime time.Time
}
type ClosedPosition struct {
Symbol string
CurrencyCode string
Product TradeProduct
Closes []CloseEntry // each close carries its own prices + share count
OpenTime time.Time
RealizedGain float64
TaxAmount float64
HoldingDays int
}
type Position struct {
CompanyID int
Symbol string
@@ -25,21 +56,28 @@ const (
BondTrade
)
type TradeType bool
type TradeType int
const (
Buy TradeType = true
Sell TradeType = false
BuyType TradeType = iota // 0
SellType // 1
DividendType // 2
)
type AddTradeRequest struct {
Symbol string `json:"symbol"`
Shares int `json:"shares"`
Product int `json:"product"`
Type bool `json:"type"`
Type int `json:"type"` // was bool, now int
Price float64 `json:"price"`
CurrencyCode string `json:"currency_code"`
Date time.Time `json:"date"`
// Dividend-specific fields (only populated when Type == 2)
TaxAmount float64 `json:"tax_amount,omitempty"`
TaxRate float64 `json:"tax_rate,omitempty"`
NetValue float64 `json:"net_value,omitempty"`
PaymentDate time.Time `json:"payment_date,omitempty"`
}
type Trade struct {
@@ -77,8 +115,42 @@ func (r *AddTradeRequest) Validate() error {
return nil
}
func (r AddTradeRequest) ToDividend() (Dividend, error) {
if TradeType(r.Type) != DividendType {
return Dividend{}, fmt.Errorf("trade type is not a dividend")
}
return Dividend{
Symbol: r.Symbol,
CurrencyCode: r.CurrencyCode,
Product: TradeProduct(r.Product),
Value: r.Price, // gross value
PaymentDate: r.PaymentDate,
TaxAmount: r.TaxAmount,
TaxRate: r.TaxRate,
NetValue: r.NetValue,
}, nil
}
func (r AddTradeRequest) ToTrade() (Trade, error) {
t := TradeType(r.Type)
if t != BuyType && t != SellType {
return Trade{}, fmt.Errorf("trade type is not buy or sell")
}
return Trade{
Symbol: r.Symbol,
CurrencyCode: r.CurrencyCode,
Shares: r.Shares,
Product: TradeProduct(r.Product),
Type: t,
Price: r.Price,
Date: r.Date,
}, nil
}
// for now trades and none stock position will not be supported.
type Portifolio struct {
Positions []Position
Trades []Trade
closed []ClosedPosition
Dividends []Dividend
}

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)
}
@@ -19,7 +20,7 @@ func UpdatePositionByTradeList(db *sql.DB) error {
TradeSum := make(map[string]model.Position)
for _, trade := range trades {
if trade.Type == model.Buy {
if trade.Type == model.BuyType {
TradeSum[trade.Symbol] = model.Position{
Symbol: trade.Symbol,
CurrencyCode: trade.CurrencyCode,
@@ -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; }

173
main.go
View File

@@ -2,15 +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"
)
@@ -20,15 +22,80 @@ var db *sql.DB
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("Incoming request: %s %s from %s", r.Method, r.URL.Path, r.Header.Get("Origin"))
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8081")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
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")
host := flag.String("host", "", "Host/IP to listen on (default: all interfaces)")
flag.Parse()
addr := fmt.Sprintf("%s:%s", *host, *port)
// --- Database ---
var err error
db, err = sql.Open("sqlite3", "./app.db?_foreign_keys=on")
if err != nil {
@@ -40,96 +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()
mux.HandleFunc("/health", handlers.HealthHandler(db))
//Trades
mux.HandleFunc("POST /trade/add", handlers.AddTradeHandler(db))
mux.HandleFunc("GET /trade/list", handlers.GetTradeListHandler(db))
mux.HandleFunc("GET /positions/list", handlers.GetPositionListHandler(db))
// 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))
// Currency
mux.HandleFunc("GET /currency/list", handlers.GetCurrenciesHandler(db))
mux.HandleFunc("POST /currency/add", handlers.AddCurrencyHandler(db))
// Revenue
mux.HandleFunc("POST /add/revenue/entry", handlers.AddRevenueEntryHandler(db))
mux.HandleFunc("POST /api/v1/revenue/add", handlers.AddRevenueEntryHandler(db))
//http.HandleFunc("GET /revenue/report", handlers.GetRevenueReportHandler(db))
RegisterRoutes(mux, db)
fmt.Println("Server running on :8080")
go func() {
log.Fatal(http.ListenAndServe(":8080", 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])
}
}
http.ListenAndServe(addr, corsMiddleware(mux))
}