feat: #45 meta de poupança configurável + conquistas genéricas
- Nova tabela user_settings com savings_goal_pct (default 40%) - API GET /api/settings, PUT /api/settings - DashboardService inclui savings_goal_pct no payload - DashboardHandler dispara savings_checked com goal configurado - GameService.NotifyAction: checa pct >= goal (sem hardcode de 40%) - SettingsView: painel META DE POUPANÇA com input configurável - HomeView: widget mostra meta dinâmica (META: X%) e usa savingsGoal - stores/settings.ts: store com fetch e update Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -86,9 +86,13 @@ func main() {
|
||||
accountHandler := handler.NewAccountHandler(accountSvc)
|
||||
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
|
||||
|
||||
settingsRepo := repository.NewSettingsRepository(pool)
|
||||
settingsSvc := service.NewSettingsService(settingsRepo)
|
||||
settingsHandler := handler.NewSettingsHandler(settingsSvc)
|
||||
|
||||
dashboardRepo := repository.NewDashboardRepository(pool)
|
||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc)
|
||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc, settingsSvc)
|
||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc, gameSvc)
|
||||
|
||||
r.Get("/health", handler.Health)
|
||||
|
||||
@@ -142,6 +146,9 @@ func main() {
|
||||
|
||||
r.Get("/dashboard", dashboardHandler.Get)
|
||||
|
||||
r.Get("/settings", settingsHandler.Get)
|
||||
r.Put("/settings", settingsHandler.Update)
|
||||
|
||||
r.Get("/game/summary", gameHandler.Summary)
|
||||
r.Post("/game/xp", gameHandler.AwardXP)
|
||||
r.Post("/game/quests/{id}/claim", gameHandler.ClaimQuest)
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
|
||||
type DashboardHandler struct {
|
||||
svc *service.DashboardService
|
||||
gameSvc *service.GameService
|
||||
}
|
||||
|
||||
func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler {
|
||||
return &DashboardHandler{svc: svc}
|
||||
func NewDashboardHandler(svc *service.DashboardService, gameSvc *service.GameService) *DashboardHandler {
|
||||
return &DashboardHandler{svc: svc, gameSvc: gameSvc}
|
||||
}
|
||||
|
||||
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -26,5 +27,14 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load dashboard")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify game engine about savings percentage for the current month only
|
||||
if month == time.Now().Format("2006-01") {
|
||||
h.gameSvc.NotifyAction(r.Context(), "savings_checked", map[string]any{
|
||||
"pct": data.SavingsPct,
|
||||
"goal": data.SavingsGoalPct,
|
||||
})
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/service"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
svc *service.SettingsService
|
||||
}
|
||||
|
||||
func NewSettingsHandler(svc *service.SettingsService) *SettingsHandler {
|
||||
return &SettingsHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := h.svc.Get(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, s)
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var in model.UserSettings
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "invalid JSON")
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Update(r.Context(), in)
|
||||
if err != nil {
|
||||
if err == service.ErrInvalidSavingsGoal {
|
||||
respondError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -56,6 +56,9 @@ var m015 string
|
||||
//go:embed sql/016_pending_bill_imports.sql
|
||||
var m016 string
|
||||
|
||||
//go:embed sql/017_user_settings.sql
|
||||
var m017 string
|
||||
|
||||
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
// Bootstrap: ensure schema_migrations table exists before checking versions.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
@@ -67,7 +70,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
return fmt.Errorf("bootstrap schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016}
|
||||
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017}
|
||||
for i, sql := range migrations {
|
||||
version := i + 1
|
||||
var applied bool
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
profile_id INTEGER PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
savings_goal_pct NUMERIC(5,2) NOT NULL DEFAULT 40,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -28,6 +28,7 @@ type DashboardData struct {
|
||||
TotalIncome float64 `json:"total_income"`
|
||||
TotalExpenses float64 `json:"total_expenses"`
|
||||
SavingsPct float64 `json:"savings_pct"`
|
||||
SavingsGoalPct float64 `json:"savings_goal_pct"`
|
||||
TotalPatrimony float64 `json:"total_patrimony"`
|
||||
ByCategory []CategoryTotal `json:"by_category"`
|
||||
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package model
|
||||
|
||||
type UserSettings struct {
|
||||
SavingsGoalPct float64 `json:"savings_goal_pct"`
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"financeiro-carvalho/internal/middleware"
|
||||
"financeiro-carvalho/internal/model"
|
||||
)
|
||||
|
||||
type SettingsRepository interface {
|
||||
Get(ctx context.Context) (*model.UserSettings, error)
|
||||
Upsert(ctx context.Context, s model.UserSettings) (*model.UserSettings, error)
|
||||
}
|
||||
|
||||
type settingsRepo struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewSettingsRepository(pool *pgxpool.Pool) SettingsRepository {
|
||||
return &settingsRepo{pool: pool}
|
||||
}
|
||||
|
||||
func (r *settingsRepo) Get(ctx context.Context) (*model.UserSettings, error) {
|
||||
pid := middleware.ProfileIDFromCtx(ctx)
|
||||
var s model.UserSettings
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT savings_goal_pct FROM user_settings WHERE profile_id = $1
|
||||
`, pid).Scan(&s.SavingsGoalPct)
|
||||
if err != nil {
|
||||
// Return defaults if not yet configured
|
||||
return &model.UserSettings{SavingsGoalPct: 40}, nil
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *settingsRepo) Upsert(ctx context.Context, s model.UserSettings) (*model.UserSettings, error) {
|
||||
pid := middleware.ProfileIDFromCtx(ctx)
|
||||
var out model.UserSettings
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
INSERT INTO user_settings (profile_id, savings_goal_pct)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (profile_id) DO UPDATE
|
||||
SET savings_goal_pct = EXCLUDED.savings_goal_pct, updated_at = NOW()
|
||||
RETURNING savings_goal_pct
|
||||
`, pid, s.SavingsGoalPct).Scan(&out.SavingsGoalPct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -24,10 +24,11 @@ type DashboardService struct {
|
||||
patrimony PatrimonySource
|
||||
billSvc *CreditBillService
|
||||
pendingBillSvc *PendingBillService
|
||||
settingsSvc *SettingsService
|
||||
}
|
||||
|
||||
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService) *DashboardService {
|
||||
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc}
|
||||
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService, settingsSvc *SettingsService) *DashboardService {
|
||||
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc, settingsSvc: settingsSvc}
|
||||
}
|
||||
|
||||
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
|
||||
@@ -115,11 +116,18 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
|
||||
pendingBillImports = []model.PendingBillImport{}
|
||||
}
|
||||
|
||||
settings, _ := s.settingsSvc.Get(ctx)
|
||||
goalPct := 40.0
|
||||
if settings != nil {
|
||||
goalPct = settings.SavingsGoalPct
|
||||
}
|
||||
|
||||
return &model.DashboardData{
|
||||
Month: month,
|
||||
TotalIncome: income,
|
||||
TotalExpenses: expenses,
|
||||
SavingsPct: savingsPct,
|
||||
SavingsGoalPct: goalPct,
|
||||
TotalPatrimony: patrimony,
|
||||
ByCategory: byCategory,
|
||||
MonthlyEvolution: evolution,
|
||||
|
||||
@@ -61,12 +61,22 @@ func (m *mockPendingBillRepo) GetByID(_ context.Context, _ int) (*model.PendingB
|
||||
}
|
||||
func (m *mockPendingBillRepo) Delete(_ context.Context, _ int) error { return nil }
|
||||
|
||||
type mockSettingsRepo struct{}
|
||||
|
||||
func (m *mockSettingsRepo) Get(_ context.Context) (*model.UserSettings, error) {
|
||||
return &model.UserSettings{SavingsGoalPct: 40}, nil
|
||||
}
|
||||
func (m *mockSettingsRepo) Upsert(_ context.Context, s model.UserSettings) (*model.UserSettings, error) {
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func newDashboardSvc(income, expenses float64) *service.DashboardService {
|
||||
repo := &mockAccountRepo{}
|
||||
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
|
||||
billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo)
|
||||
pendingBillSvc := service.NewPendingBillService(&mockPendingBillRepo{}, &mockImportTxRepo{})
|
||||
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc, pendingBillSvc)
|
||||
settingsSvc := service.NewSettingsService(&mockSettingsRepo{})
|
||||
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc, pendingBillSvc, settingsSvc)
|
||||
}
|
||||
|
||||
func TestDashboard_SavingsPct_40(t *testing.T) {
|
||||
|
||||
@@ -165,9 +165,13 @@ func (s *GameService) NotifyAction(ctx context.Context, action string, extra map
|
||||
|
||||
case "savings_checked":
|
||||
pct, _ := extra["pct"].(float64)
|
||||
goal, _ := extra["goal"].(float64)
|
||||
if goal <= 0 {
|
||||
goal = 40
|
||||
}
|
||||
_ = s.repo.UpdateQuestPct(ctx, monthlyPeriod, pct)
|
||||
if pct >= 40 {
|
||||
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de 40% atingida!")
|
||||
if pct >= goal {
|
||||
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de poupança atingida!")
|
||||
s.tryUnlockAchievement(ctx, "first_40pct")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/repository"
|
||||
)
|
||||
|
||||
var ErrInvalidSavingsGoal = errors.New("savings_goal_pct must be between 1 and 100")
|
||||
|
||||
type SettingsService struct {
|
||||
repo repository.SettingsRepository
|
||||
}
|
||||
|
||||
func NewSettingsService(repo repository.SettingsRepository) *SettingsService {
|
||||
return &SettingsService{repo: repo}
|
||||
}
|
||||
|
||||
func (s *SettingsService) Get(ctx context.Context) (*model.UserSettings, error) {
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
func (s *SettingsService) Update(ctx context.Context, in model.UserSettings) (*model.UserSettings, error) {
|
||||
if in.SavingsGoalPct < 1 || in.SavingsGoalPct > 100 {
|
||||
return nil, ErrInvalidSavingsGoal
|
||||
}
|
||||
return s.repo.Upsert(ctx, in)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/services/api'
|
||||
|
||||
export interface UserSettings {
|
||||
savings_goal_pct: number
|
||||
}
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const settings = ref<UserSettings>({ savings_goal_pct: 40 })
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetch() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
settings.value = await api.get<UserSettings>('/settings')
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function update(input: UserSettings) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
settings.value = await api.put<UserSettings>('/settings', input)
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { settings, loading, error, fetch, update }
|
||||
})
|
||||
@@ -54,7 +54,8 @@ const xpPct = computed(() => {
|
||||
return Math.round((p.xp / p.xp_to_next) * 100)
|
||||
})
|
||||
const savingsPct = computed(() => dash.data?.savings_pct ?? 0)
|
||||
const savingsOk = computed(() => savingsPct.value >= 40)
|
||||
const savingsGoal = computed(() => dash.data?.savings_goal_pct ?? 40)
|
||||
const savingsOk = computed(() => savingsPct.value >= savingsGoal.value)
|
||||
|
||||
const today = new Date()
|
||||
const isCurrentMonth = computed(() => currentMonth.value === today.toISOString().slice(0, 7))
|
||||
@@ -142,7 +143,7 @@ async function markLate(id: number) {
|
||||
</div>
|
||||
<XPBar :pct="savingsPct" size="lg" :success="savingsOk" :danger="!savingsOk" />
|
||||
<div class="hero-sub fc-mono">
|
||||
META: 40% ·
|
||||
META: {{ savingsGoal.toFixed(0) }}% ·
|
||||
<span :class="savingsOk ? 'fc-text-green' : 'fc-text-red'">
|
||||
{{ savingsOk ? 'META BATIDA ✓' : 'ABAIXO DA META' }}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import NeonPanel from '@/components/NeonPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
onMounted(() => settingsStore.fetch())
|
||||
|
||||
const goalInput = ref<number>(40)
|
||||
const goalError = ref<string | null>(null)
|
||||
const goalSuccess = ref(false)
|
||||
const goalLoading = ref(false)
|
||||
|
||||
// sync input after fetch
|
||||
import { watch } from 'vue'
|
||||
watch(() => settingsStore.settings.savings_goal_pct, (v) => { goalInput.value = v }, { immediate: true })
|
||||
|
||||
async function saveGoal() {
|
||||
goalError.value = null
|
||||
goalSuccess.value = false
|
||||
if (goalInput.value < 1 || goalInput.value > 100) {
|
||||
goalError.value = 'Meta deve estar entre 1% e 100%'
|
||||
return
|
||||
}
|
||||
goalLoading.value = true
|
||||
try {
|
||||
await settingsStore.update({ savings_goal_pct: goalInput.value })
|
||||
goalSuccess.value = true
|
||||
} catch (e: any) {
|
||||
goalError.value = e.message ?? 'Erro ao salvar'
|
||||
} finally {
|
||||
goalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
@@ -57,6 +87,31 @@ async function logout() {
|
||||
<button class="fc-btn fc-btn--danger fc-settings-logout" @click="logout">SAIR</button>
|
||||
</NeonPanel>
|
||||
|
||||
<NeonPanel title="META DE POUPANÇA">
|
||||
<form class="fc-settings-goal" @submit.prevent="saveGoal">
|
||||
<div class="fc-settings-goal__row">
|
||||
<label class="fc-label fc-pixel" style="font-size:9px">META MENSAL (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="goalInput"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
class="fc-input fc-settings-goal__input"
|
||||
:disabled="goalLoading"
|
||||
/>
|
||||
<button type="submit" class="fc-btn fc-btn--primary" :disabled="goalLoading">
|
||||
{{ goalLoading ? '...' : 'SALVAR' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="fc-settings-goal__hint fc-mono">
|
||||
Conquistas e quests usam este valor como meta de poupança mensal.
|
||||
</p>
|
||||
<p v-if="goalError" class="fc-settings-goal__msg fc-settings-goal__msg--err fc-mono">{{ goalError }}</p>
|
||||
<p v-if="goalSuccess" class="fc-settings-goal__msg fc-settings-goal__msg--ok fc-mono">Meta atualizada!</p>
|
||||
</form>
|
||||
</NeonPanel>
|
||||
|
||||
<NeonPanel title="TROCAR SENHA">
|
||||
<form class="fc-settings-pw" @submit.prevent="changePassword">
|
||||
<input
|
||||
@@ -134,4 +189,12 @@ async function logout() {
|
||||
}
|
||||
.fc-settings-pw__msg--err { color: var(--fc-red); }
|
||||
.fc-settings-pw__msg--ok { color: var(--fc-green); }
|
||||
|
||||
.fc-settings-goal { display: flex; flex-direction: column; gap: var(--fc-space-2); }
|
||||
.fc-settings-goal__row { display: flex; align-items: center; gap: var(--fc-space-3); }
|
||||
.fc-settings-goal__input { width: 80px; }
|
||||
.fc-settings-goal__hint { font-size: 10px; color: var(--fc-text-dim); margin: 0; }
|
||||
.fc-settings-goal__msg { font-size: 11px; margin: 0; }
|
||||
.fc-settings-goal__msg--err { color: var(--fc-red); }
|
||||
.fc-settings-goal__msg--ok { color: var(--fc-green); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user