feat: shot-crafter-calculator with H2 persistence and production history

Quarkus 3.20.1 monolith serving React 18 + TypeScript + Tailwind SPA.

Features:
- Three-tab calculator (Insumos, Fórmulas, Calculadora) for
  Soulshot, Spiritshot and Blessed Spiritshot crafting in Lineage 2
  Interlude/Clásico with all 15 grades and pre-loaded recipes
- Real-time profitability computation (cristales → ore →
  crafteos → shots → cost → sale → ganancia)
- Multi-user auth with JWT in httpOnly cookie (bcrypt + RSA 2048)
- H2 file-based persistence in ./data/shots.mv.db (file-based, H2)
- Auto-save on state changes (debounced 500ms)
- Production history with stats (total/avg/best/worst/last5avg)
  and per-run detail modal with snapshot of insumos+formulas

Stack:
- Backend: Quarkus REST + Hibernate ORM Panache + smallrye-jwt
- Frontend: React 18 + TypeScript + Vite + Tailwind 3
- Build: Maven runs frontend-maven-plugin (Node 22 + npm ci)
  then copies dist to META-INF/resources for Quarkus to serve

Verified:
- 5 backend endpoints + 5 history endpoints with curl
- 35/35 browser tests via Playwright + Chromium
- All TS strict, all builds green
This commit is contained in:
2026-08-12 16:03:51 -04:00
commit 9c9fb3a2ca
57 changed files with 6260 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
target/
.mvn/
node/
node_modules/
src/frontend/node_modules/
src/frontend/dist/
src/frontend/tsconfig.app.tsbuildinfo
src/frontend/tsconfig.node.tsbuildinfo
data/
.idea/
.vscode/
*.iml
.DS_Store
*.log
+213
View File
@@ -0,0 +1,213 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.l2.shots</groupId>
<artifactId>shot-crafter-calculator</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.release>21</maven.compiler.release>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.version>3.20.1</quarkus.platform.version>
<compiler-plugin.version>3.13.0</compiler-plugin.version>
<surefire-plugin.version>3.5.0</surefire-plugin.version>
<failsafe-plugin.version>3.5.0</failsafe-plugin.version>
<frontend-maven-plugin.version>1.15.0</frontend-maven-plugin.version>
<node.version>v22.11.0</node.version>
<npm.version>10.9.0</npm.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>${quarkus.platform.artifact-id}</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-vertx-http</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-jwt-build</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-elytron-security-common</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<version>${quarkus.platform.version}</version>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>${frontend-maven-plugin.version}</version>
<configuration>
<nodeVersion>${node.version}</nodeVersion>
<npmVersion>${npm.version}</npmVersion>
<workingDirectory>src/frontend</workingDirectory>
<installDirectory>target</installDirectory>
</configuration>
<executions>
<execution>
<id>install-frontend-tools</id>
<phase>initialize</phase>
<goals>
<goal>install-node-and-npm</goal>
</goals>
</execution>
<execution>
<id>npm-install</id>
<phase>generate-resources</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>ci</arguments>
</configuration>
</execution>
<execution>
<id>npm-build</id>
<phase>generate-resources</phase>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
<executions>
<execution>
<id>copy-frontend-dist</id>
<phase>process-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/classes/META-INF/resources/</outputDirectory>
<resources>
<resource>
<directory>src/frontend/dist</directory>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>${compiler-plugin.version}</version>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>native</id>
<activation>
<property>
<name>native</name>
</property>
</activation>
<properties>
<quarkus.native.enabled>true</quarkus.native.enabled>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>${failsafe-plugin.version}</version>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
<configuration>
<systemPropertyVariables>
<native.image.path>${project.build.directory}/${project.build.finalName}-runner</native.image.path>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Calculadora de Craft de Shots — Lineage 2</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2698
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "shot-crafter-calculator",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<radialGradient id="g" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color="#fde68a"/>
<stop offset="60%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#b45309"/>
</radialGradient>
</defs>
<circle cx="32" cy="32" r="28" fill="url(#g)" stroke="#7c2d12" stroke-width="2"/>
<polygon points="32,12 38,28 54,28 42,38 46,54 32,46 18,54 22,38 10,28 26,28" fill="#fef3c7" stroke="#7c2d12" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 524 B

+130
View File
@@ -0,0 +1,130 @@
import { useState } from 'react'
import { AuthProvider, useAuth } from './auth/AuthContext'
import { LoginPage } from './auth/LoginPage'
import { TabBar, type TabKey } from './components/TabBar'
import { InsumosSection } from './components/InsumosSection'
import { FormulasSection } from './components/FormulasSection'
import { CalculadoraSection } from './components/CalculadoraSection'
import { HistorySection } from './components/HistorySection'
import { SaveIndicator } from './components/SaveIndicator'
import { usePersistedState } from './hooks/usePersistedState'
import { makeDefaultAppState, makeEmptyAppState } from './data/defaults'
export default function App() {
return (
<AuthProvider>
<AppRouter />
</AuthProvider>
)
}
function AppRouter() {
const { user, loading } = useAuth()
if (loading) return <LoadingScreen message="Verificando sesión…" />
if (!user) return <LoginPage />
return <AuthenticatedApp />
}
function AuthenticatedApp() {
const { user, logout } = useAuth()
const { state, setState, status, errorMessage, reset } = usePersistedState()
const [tab, setTab] = useState<TabKey>('insumos')
if (!state) return <LoadingScreen message="Cargando tu estado…" />
const handleResetExamples = () => {
setState(makeDefaultAppState())
}
const handleClearAll = () => {
setState(makeEmptyAppState())
}
const handleResetServer = async () => {
if (confirm('¿Borrar tu estado guardado en el servidor?')) {
await reset()
}
}
return (
<div className="min-h-screen flex flex-col">
<header className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-xl font-bold text-slate-900">
Calculadora de Craft de Shots
</h1>
<p className="text-sm text-slate-500">
<span className="font-medium text-slate-700">{user?.username ?? ''}</span>
{' · '}
Lineage 2 Interlude / Clásico
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<SaveIndicator status={status} errorMessage={errorMessage} />
<button
type="button"
onClick={handleResetExamples}
className="px-3 py-2 text-sm font-medium rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
>
Restablecer ejemplos
</button>
<button
type="button"
onClick={handleClearAll}
className="px-3 py-2 text-sm font-medium rounded border border-amber-300 bg-white text-amber-700 hover:bg-amber-50"
>
Poner todo en cero
</button>
<button
type="button"
onClick={handleResetServer}
className="px-3 py-2 text-sm font-medium rounded border border-rose-300 bg-white text-rose-700 hover:bg-rose-50"
title="Borrar el estado en el servidor (sólo este navegador)"
>
Borrar del servidor
</button>
<button
type="button"
onClick={logout}
className="px-3 py-2 text-sm font-medium rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
>
Cerrar sesión
</button>
</div>
</div>
<TabBar active={tab} onChange={setTab} />
</header>
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 py-6">
{tab === 'insumos' && (
<InsumosSection state={state} onChange={setState} />
)}
{tab === 'formulas' && (
<FormulasSection state={state} onChange={setState} />
)}
{tab === 'calculadora' && (
<CalculadoraSection state={state} onChange={setState} />
)}
{tab === 'historial' && <HistorySection />}
</main>
<footer className="bg-white border-t border-slate-200 py-3">
<p className="text-center text-xs text-slate-400">
Auto-guardado activo · Cambios persistidos en el servidor cada ~500ms
</p>
</footer>
</div>
)
}
function LoadingScreen({ message }: { message: string }) {
return (
<div className="min-h-screen bg-slate-100 flex items-center justify-center">
<div className="text-center">
<div className="inline-block w-8 h-8 border-4 border-blue-600 border-r-transparent rounded-full animate-spin mb-3" />
<p className="text-sm text-slate-600">{message}</p>
</div>
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
this.name = 'ApiError'
}
}
export interface User {
id: string
username: string
createdAt: string
}
export interface AppState {
insumos: {
cristales: Record<string, number>
soulOre: number
spiritOre: number
venta: Record<string, Record<string, number>>
}
formulas: Array<{
id: string
tipo: string
grado: string
cristalesReq: number
soulOreReq: number | null
spiritOreReq: number | null
shotsObtenidos: number
}>
disponibles: Record<string, Record<string, number>>
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (res.status === 204) {
return undefined as T
}
const text = await res.text()
let body: unknown = null
if (text) {
try {
body = JSON.parse(text)
} catch {
body = text
}
}
if (!res.ok) {
const message =
body && typeof body === 'object' && 'error' in body
? String((body as { error: string }).error)
: `HTTP ${res.status}`
throw new ApiError(res.status, message)
}
return body as T
}
export const api = {
async me(): Promise<User | null> {
try {
return await request<User>('/api/auth/me')
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null
throw e
}
},
async login(username: string, password: string): Promise<User> {
return request<User>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
},
async register(username: string, password: string): Promise<User> {
return request<User>('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
},
async logout(): Promise<void> {
return request<void>('/api/auth/logout', { method: 'POST' })
},
async getState(): Promise<AppState | null> {
try {
return await request<AppState>('/api/state')
} catch (e) {
if (e instanceof ApiError && e.status === 404) return null
throw e
}
},
async putState(state: AppState): Promise<void> {
return request<void>('/api/state', {
method: 'PUT',
body: JSON.stringify(state),
})
},
async resetState(): Promise<void> {
return request<void>('/api/state', { method: 'DELETE' })
},
}
+137
View File
@@ -0,0 +1,137 @@
import { ApiError } from './client'
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (res.status === 204) {
return undefined as T
}
const text = await res.text()
let body: unknown = null
if (text) {
try {
body = JSON.parse(text)
} catch {
body = text
}
}
if (!res.ok) {
const message =
body && typeof body === 'object' && 'error' in body
? String((body as { error: string }).error)
: `HTTP ${res.status}`
throw new ApiError(res.status, message)
}
return body as T
}
export interface RunItem {
tipo: string
grado: string
cristalesDisponibles: number
cristalesUsados: number
oreNecesario: number
crafteosPosibles: number
shotsObtenidos: number
costoTotal: number
valorVenta: number
ganancia: number
}
export interface RunSnapshot {
insumos: unknown
formulas: unknown
}
export interface RunSummary {
id: string
createdAt: string
label: string | null
totalCost: number
totalSale: number
totalProfit: number
totalShots: number
totalCristalesUsed: number
totalOreUsed: number
}
export interface RunDetails extends RunSummary {
items: RunItem[]
snapshot: RunSnapshot
}
export interface HistoryStats {
totalRuns: number
totalCost: number
totalSale: number
totalProfit: number
totalShots: number
avgProfit: number
avgCost: number
avgSale: number
bestRun: RunSummary | null
worstRun: RunSummary | null
last5Avg: number
last10Avg: number
}
export interface RunIn {
label: string | null
totalCost: number
totalSale: number
totalProfit: number
totalShots: number
totalCristalesUsed: number
totalOreUsed: number
items: RunItem[]
snapshot: RunSnapshot
}
export const historyApi = {
async saveRun(payload: RunIn): Promise<RunSummary> {
return request<RunSummary>('/api/history/runs', {
method: 'POST',
body: JSON.stringify(payload),
})
},
async listRuns(): Promise<RunSummary[]> {
return request<RunSummary[]>('/api/history/runs')
},
async getRun(id: string): Promise<RunDetails> {
try {
return await request<RunDetails>(`/api/history/runs/${id}`)
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
throw new Error('Producción no encontrada')
}
throw e
}
},
async deleteRun(id: string): Promise<void> {
try {
return await request<void>(`/api/history/runs/${id}`, { method: 'DELETE' })
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
throw new Error('Producción no encontrada')
}
throw e
}
},
async getStats(): Promise<HistoryStats> {
return request<HistoryStats>('/api/history/stats')
},
}
+64
View File
@@ -0,0 +1,64 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { ApiError, api, type User } from '../api/client'
interface AuthContextValue {
user: User | null
loading: boolean
login: (username: string, password: string) => Promise<void>
register: (username: string, password: string) => Promise<void>
logout: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
api
.me()
.then((u) => {
if (!cancelled) setUser(u)
})
.catch((e) => {
if (!(e instanceof ApiError) || e.status !== 401) {
console.error('auth check failed', e)
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const login = useCallback(async (username: string, password: string) => {
const u = await api.login(username, password)
setUser(u)
}, [])
const register = useCallback(async (username: string, password: string) => {
const u = await api.register(username, password)
setUser(u)
}, [])
const logout = useCallback(async () => {
await api.logout()
setUser(null)
}, [])
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth debe usarse dentro de <AuthProvider>')
return ctx
}
+128
View File
@@ -0,0 +1,128 @@
import { useState, type FormEvent } from 'react'
import { useAuth } from './AuthContext'
export function LoginPage() {
const { login, register } = useAuth()
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError(null)
setSubmitting(true)
try {
if (mode === 'login') {
await login(username, password)
} else {
await register(username, password)
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Error desconocido'
if (mode === 'login') {
setError('Usuario o contraseña incorrectos.')
} else if (msg.includes('no disponible')) {
setError('Ese username ya está en uso.')
} else if (msg.toLowerCase().includes('datos')) {
setError('Username (3-30 chars, alfanumérico o _) y password (>= 8 chars).')
} else {
setError(msg)
}
} finally {
setSubmitting(false)
}
}
const toggleMode = () => {
setMode((m) => (m === 'login' ? 'register' : 'login'))
setError(null)
}
return (
<div className="min-h-screen bg-slate-100 flex items-center justify-center px-4">
<div className="w-full max-w-md">
<div className="bg-white rounded-lg border border-slate-200 shadow-sm p-8">
<header className="mb-6 text-center">
<h1 className="text-2xl font-bold text-slate-900">
Calculadora de Craft de Shots
</h1>
<p className="text-sm text-slate-500 mt-1">
Lineage 2 Interlude / Clásico
</p>
</header>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
minLength={3}
maxLength={30}
pattern="[a-zA-Z0-9_]{3,30}"
className="input-editable w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Contraseña
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
required
minLength={8}
className="input-editable w-full"
/>
</div>
{error && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={submitting}
className="w-full px-4 py-2 text-sm font-semibold rounded bg-blue-600 text-white hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed"
>
{submitting
? 'Procesando…'
: mode === 'login'
? 'Iniciar sesión'
: 'Crear cuenta'}
</button>
</form>
<div className="mt-6 text-center">
<button
type="button"
onClick={toggleMode}
className="text-sm text-blue-700 hover:underline"
>
{mode === 'login'
? '¿No tenés cuenta? Creá una'
: '¿Ya tenés cuenta? Iniciá sesión'}
</button>
</div>
</div>
<p className="text-center text-xs text-slate-400 mt-4">
Cada usuario tiene su propio estado guardado en el servidor.
</p>
</div>
</div>
)
}
@@ -0,0 +1,188 @@
import { useMemo, useState } from 'react'
import { SHOT_TYPES, type CristalesDisponibles, type Formula, type Insumos } from '../types'
import { ShotTable } from './ShotTable'
import { TotalsSummary } from './TotalsSummary'
import { calcularFila, sumarTotales } from '../utils/calc'
import type { AppState } from '../api/client'
import { useHistory, type HistorySaveStatus } from '../hooks/useHistory'
interface CalculadoraSectionProps {
state: AppState
onChange: (next: AppState | ((prev: AppState) => AppState)) => void
}
export function CalculadoraSection({ state, onChange }: CalculadoraSectionProps) {
const insumos: Insumos = state.insumos
const formulas: Formula[] = state.formulas
const disponibles: CristalesDisponibles = state.disponibles
const { saveRun, saveStatus, saveError } = useHistory()
const [label, setLabel] = useState('')
const handleDisponibles = (tipo: string, grado: string, value: number) => {
onChange((prev) => ({
...prev,
disponibles: {
...prev.disponibles,
[tipo]: { ...prev.disponibles[tipo], [grado]: value },
},
}))
}
const tablas = useMemo(() => {
return SHOT_TYPES.map((tipo) => {
const formulasTipo = formulas.filter((f) => f.tipo === tipo)
const calculos = formulasTipo.map((f) =>
calcularFila(f, disponibles[tipo][f.grado], insumos),
)
const totales = sumarTotales(calculos)
return { tipo, formulas: formulasTipo, calculos, totales }
})
}, [insumos, formulas, disponibles])
const totalesGlobal = useMemo(
() => sumarTotales(tablas.flatMap((t) => t.calculos)),
[tablas],
)
const canSave = totalesGlobal.cristalesUsados > 0 && saveStatus !== 'saving'
const handleSave = async () => {
if (!canSave) return
const items = tablas.flatMap((t) =>
t.formulas.map((f, i) => {
const c = t.calculos[i]
return {
tipo: t.tipo,
grado: f.grado,
cristalesDisponibles: c.cristalesDisponibles,
cristalesUsados: c.cristalesUsados,
oreNecesario: c.oreNecesario,
crafteosPosibles: c.crafteosPosibles,
shotsObtenidos: c.shotsObtenidos,
costoTotal: c.costoTotal,
valorVenta: c.valorVenta,
ganancia: c.ganancia,
}
}),
)
try {
await saveRun({
label: label.trim() || null,
totalCost: totalesGlobal.costoTotal,
totalSale: totalesGlobal.valorVenta,
totalProfit: totalesGlobal.ganancia,
totalShots: totalesGlobal.shotsObtenidos,
totalCristalesUsed: totalesGlobal.cristalesUsados,
totalOreUsed: totalesGlobal.oreNecesario,
items,
snapshot: {
insumos: insumos as unknown,
formulas: formulas as unknown,
},
})
setLabel('')
} catch {
// error ya manejado en saveStatus/saveError
}
}
return (
<div className="space-y-6">
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 text-sm text-blue-900">
<p>
Ingresa solo los <strong>cristales disponibles</strong> por grado en cada tabla. La ore
necesaria, los crafteos, los shots producidos, el costo y la ganancia se calculan en
tiempo real a partir de los precios de la pestaña <em>Insumos</em> y las recetas de la
pestaña <em>Fórmulas</em>.
</p>
</div>
<section className="bg-white rounded-lg border border-slate-200 px-4 py-3">
<div className="flex flex-col sm:flex-row sm:items-end gap-3">
<div className="flex-1">
<label
htmlFor="save-run-label"
className="block text-xs font-medium text-slate-600 mb-1"
>
Label (opcional)
</label>
<input
id="save-run-label"
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={100}
placeholder="p.ej. Sesión lunes, crafteo nocturno…"
className="input-editable w-full text-left"
/>
</div>
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className="px-4 py-2 text-sm font-semibold rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:bg-slate-300 disabled:cursor-not-allowed whitespace-nowrap"
title={
totalesGlobal.cristalesUsados === 0
? 'Ingresa cristales disponibles para poder guardar'
: 'Guardar esta producción en el historial'
}
>
{saveStatus === 'saving' ? 'Guardando…' : 'Guardar producción'}
</button>
</div>
<SaveFeedback status={saveStatus} error={saveError} />
</section>
{tablas.map((t) => (
<ShotTable
key={t.tipo}
tipo={t.tipo}
formulas={t.formulas}
calculos={t.calculos}
totales={t.totales}
disponibles={disponibles[t.tipo]}
onChangeDisponibles={(grado, value) => handleDisponibles(t.tipo, grado, value)}
/>
))}
<TotalsSummary totales={totalesGlobal} />
</div>
)
}
function SaveFeedback({
status,
error,
}: {
status: HistorySaveStatus
error: string | null
}) {
if (status === 'idle') {
return (
<p className="text-xs text-slate-500 mt-2">
Se guardará la producción actual con los cristales usados (
{/* placeholder para texto */} verTotales). El cálculo se almacena con los precios y
fórmulas de este momento.
</p>
)
}
if (status === 'saving') {
return (
<p className="text-xs text-blue-700 mt-2 inline-flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-blue-600 animate-pulse" />
Guardando en historial
</p>
)
}
if (status === 'saved') {
return (
<p className="text-xs text-emerald-700 mt-2 inline-flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-emerald-600" />
Producción guardada . La podés ver en la pestaña Historial.
</p>
)
}
return (
<p className="text-xs text-rose-700 mt-2">Error al guardar: {error}</p>
)
}
@@ -0,0 +1,111 @@
import type { Formula } from '../types'
import { parseNumber } from '../utils/format'
import type { AppState } from '../api/client'
interface FormulasSectionProps {
state: AppState
onChange: (next: AppState | ((prev: AppState) => AppState)) => void
}
export function FormulasSection({ state, onChange }: FormulasSectionProps) {
const formulas = state.formulas
const updateField = (id: string, patch: Partial<Formula>) => {
onChange((prev) => ({
...prev,
formulas: prev.formulas.map((f) => (f.id === id ? { ...f, ...patch } : f)),
}))
}
return (
<section className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<header className="px-4 py-3 border-b border-slate-200 bg-slate-50">
<h2 className="text-sm font-semibold text-slate-800">Recetas de crafteo</h2>
<p className="text-xs text-slate-500">
Edita los recursos necesarios por cada acción de crafteo. Los cambios se reflejan en la
calculadora y se persisten automáticamente.
</p>
</header>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="cell-label text-left">Tipo</th>
<th className="cell-label text-center">Grado</th>
<th className="cell-label text-right">Cristales req.</th>
<th className="cell-label text-right">Soul Ore req.</th>
<th className="cell-label text-right">Spirit Ore req.</th>
<th className="cell-label text-right">Shots obtenidos</th>
</tr>
</thead>
<tbody>
{formulas.map((f) => {
const isSoul = f.tipo === 'Soulshot'
return (
<tr key={f.id} className="hover:bg-slate-50">
<td className="cell-input text-slate-700 font-medium">{f.tipo}</td>
<td className="cell-input text-center">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-200 text-slate-700 font-semibold text-xs">
{f.grado}
</span>
</td>
<td className="cell-input">
<input
type="number"
min={0}
step="1"
value={f.cristalesReq}
onChange={(e) =>
updateField(f.id, { cristalesReq: parseNumber(e.target.value) })
}
className="input-editable"
/>
</td>
<td className="cell-input">
<input
type="number"
min={0}
step="1"
value={f.soulOreReq ?? 0}
disabled={!isSoul}
onChange={(e) =>
updateField(f.id, { soulOreReq: parseNumber(e.target.value) })
}
className="input-editable"
/>
</td>
<td className="cell-input">
<input
type="number"
min={0}
step="1"
value={f.spiritOreReq ?? 0}
disabled={isSoul}
onChange={(e) =>
updateField(f.id, { spiritOreReq: parseNumber(e.target.value) })
}
className="input-editable"
/>
</td>
<td className="cell-input">
<input
type="number"
min={0}
step="1"
value={f.shotsObtenidos}
onChange={(e) =>
updateField(f.id, { shotsObtenidos: parseNumber(e.target.value) })
}
className="input-editable"
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</section>
)
}
@@ -0,0 +1,44 @@
import { useState } from 'react'
import { useHistory } from '../hooks/useHistory'
import { StatsCards } from './StatsCards'
import { RunsTable } from './RunsTable'
import { RunDetailsModal } from './RunDetailsModal'
export function HistorySection() {
const { runs, stats, loading, deleteRun } = useHistory()
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
if (loading && !stats) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center">
<p className="text-sm text-slate-500">Cargando historial</p>
</div>
)
}
return (
<div className="space-y-6">
{stats && <StatsCards stats={stats} />}
<section>
<header className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-slate-800">
Corridas guardadas
</h2>
{runs.length > 0 && (
<span className="text-xs text-slate-500">
{runs.length} en total · click en una fila para ver detalle
</span>
)}
</header>
<RunsTable
runs={runs}
onSelect={setSelectedRunId}
onDelete={deleteRun}
/>
</section>
<RunDetailsModal runId={selectedRunId} onClose={() => setSelectedRunId(null)} />
</div>
)
}
@@ -0,0 +1,140 @@
import { GRADOS, SHOT_TYPES, type Insumos } from '../types'
import { formatAdena, parseNumber } from '../utils/format'
import type { AppState } from '../api/client'
interface InsumosSectionProps {
state: AppState
onChange: (next: AppState | ((prev: AppState) => AppState)) => void
}
export function InsumosSection({ state, onChange }: InsumosSectionProps) {
const insumos = state.insumos
const updateInsumos = (updater: (curr: Insumos) => Insumos) => {
onChange((prev) => ({ ...prev, insumos: updater(prev.insumos) }))
}
const updateCristal = (grado: string, value: number) =>
updateInsumos((curr) => ({
...curr,
cristales: { ...curr.cristales, [grado]: value },
}))
const updateSale = (tipo: string, grado: string, value: number) =>
updateInsumos((curr) => ({
...curr,
venta: {
...curr.venta,
[tipo]: { ...curr.venta[tipo], [grado]: value },
},
}))
return (
<div className="space-y-6">
<Section title="Cristales" subtitle="Precio unitario en adena">
<GradeGrid>
{GRADOS.map((grado) => (
<GradeRow
key={grado}
label={`Cristal ${grado}`}
value={insumos.cristales[grado]}
onChange={(v) => updateCristal(grado, v)}
/>
))}
</GradeGrid>
</Section>
<Section title="Ores" subtitle="Precio por unidad">
<GradeGrid>
<GradeRow
label="Soul Ore (Soulstone)"
value={insumos.soulOre}
onChange={(v) => updateInsumos((curr) => ({ ...curr, soulOre: v }))}
/>
<GradeRow
label="Spirit Ore"
value={insumos.spiritOre}
onChange={(v) => updateInsumos((curr) => ({ ...curr, spiritOre: v }))}
/>
</GradeGrid>
</Section>
{SHOT_TYPES.map((tipo) => (
<Section
key={tipo}
title={`Precio de venta — ${tipo}`}
subtitle="Precio unitario del shot vendido"
>
<GradeGrid>
{GRADOS.map((grado) => (
<GradeRow
key={`${tipo}-${grado}`}
label={`${tipo} ${grado}`}
value={insumos.venta[tipo][grado]}
onChange={(v) => updateSale(tipo, grado, v)}
/>
))}
</GradeGrid>
</Section>
))}
</div>
)
}
function Section({
title,
subtitle,
children,
}: {
title: string
subtitle?: string
children: React.ReactNode
}) {
return (
<section className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<header className="px-4 py-3 border-b border-slate-200 bg-slate-50">
<h2 className="text-sm font-semibold text-slate-800">{title}</h2>
{subtitle && <p className="text-xs text-slate-500">{subtitle}</p>}
</header>
<div className="p-4">{children}</div>
</section>
)
}
function GradeGrid({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">{children}</div>
)
}
function GradeRow({
label,
value,
onChange,
}: {
label: string
value: number
onChange: (v: number) => void
}) {
return (
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-slate-600">{label}</span>
<div className="relative">
<input
type="number"
min={0}
step="1"
value={value}
onChange={(e) => onChange(parseNumber(e.target.value))}
className="input-editable pr-12"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-slate-400 pointer-events-none">
adena
</span>
</div>
<span className="text-xs text-slate-400 text-right font-mono">
{formatAdena(value)}
</span>
</label>
)
}
@@ -0,0 +1,324 @@
import { useEffect, useState } from 'react'
import { historyApi, type RunDetails } from '../api/history'
import { formatAdena } from '../utils/format'
import { GRADOS, SHOT_TYPES, type Formula, type Insumos } from '../types'
interface RunDetailsModalProps {
runId: string | null
onClose: () => void
}
export function RunDetailsModal({ runId, onClose }: RunDetailsModalProps) {
const [details, setDetails] = useState<RunDetails | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showSnapshot, setShowSnapshot] = useState(false)
useEffect(() => {
if (!runId) {
setDetails(null)
setError(null)
return
}
setLoading(true)
setError(null)
historyApi
.getRun(runId)
.then((d) => setDetails(d))
.catch((e) => setError(e instanceof Error ? e.message : 'Error'))
.finally(() => setLoading(false))
}, [runId])
if (!runId) return null
return (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
onClick={onClose}
>
<div
className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<header className="px-6 py-4 border-b border-slate-200 flex items-center justify-between sticky top-0 bg-white">
<div>
<h2 className="text-base font-semibold text-slate-900">
{details?.label || 'Detalle de producción'}
</h2>
{details && (
<p className="text-xs text-slate-500 font-mono">
{formatDateTime(details.createdAt)}
</p>
)}
</div>
<button
type="button"
onClick={onClose}
className="text-slate-400 hover:text-slate-700 text-2xl leading-none"
aria-label="Cerrar"
>
×
</button>
</header>
<div className="p-6 space-y-6">
{loading && <p className="text-sm text-slate-500">Cargando</p>}
{error && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded px-3 py-2">
{error}
</p>
)}
{details && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<SummaryStat label="Costo" value={formatAdena(details.totalCost)} tone="slate" />
<SummaryStat label="Venta" value={formatAdena(details.totalSale)} tone="slate" />
<SummaryStat
label="Ganancia"
value={formatAdena(details.totalProfit)}
tone={details.totalProfit > 0 ? 'emerald' : 'rose'}
highlight
/>
<SummaryStat
label="Shots"
value={String(details.totalShots)}
tone="slate"
/>
</div>
<section>
<h3 className="text-sm font-semibold text-slate-800 mb-2">
Desglose por grado
</h3>
<div className="overflow-x-auto rounded border border-slate-200">
<table className="w-full text-xs">
<thead>
<tr>
<th className="cell-label text-left">Tipo</th>
<th className="cell-label text-center">Grado</th>
<th className="cell-label text-right">Cristales</th>
<th className="cell-label text-right">Ore</th>
<th className="cell-label text-right">Crafteos</th>
<th className="cell-label text-right">Shots</th>
<th className="cell-label text-right">Costo</th>
<th className="cell-label text-right">Venta</th>
<th className="cell-label text-right">Ganancia</th>
</tr>
</thead>
<tbody>
{SHOT_TYPES.flatMap((tipo) =>
GRADOS.map((grado) => {
const item = details.items.find(
(it) => it.tipo === tipo && it.grado === grado,
)
if (!item) {
return (
<tr key={`${tipo}-${grado}`} className="text-slate-400">
<td className="cell-input">{tipo}</td>
<td className="cell-input text-center">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-700 font-semibold text-xs">
{grado}
</span>
</td>
<td colSpan={7} className="cell-input text-center italic">
(sin producción)
</td>
</tr>
)
}
return (
<tr
key={`${tipo}-${grado}`}
className={item.ganancia < 0 ? 'bg-rose-50/50' : ''}
>
<td className="cell-input font-medium">{item.tipo}</td>
<td className="cell-input text-center">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-slate-200 text-slate-700 font-semibold text-xs">
{item.grado}
</span>
</td>
<td className="cell-input text-right font-mono">
{item.cristalesUsados} / {item.cristalesDisponibles}
</td>
<td className="cell-input text-right font-mono">
{item.oreNecesario}
</td>
<td className="cell-input text-right font-mono">
{item.crafteosPosibles}
</td>
<td className="cell-input text-right font-mono">
{item.shotsObtenidos}
</td>
<td className="cell-input text-right font-mono">
{formatAdena(item.costoTotal)}
</td>
<td className="cell-input text-right font-mono">
{formatAdena(item.valorVenta)}
</td>
<td
className={`cell-input text-right font-mono font-semibold ${gananciaColor(item.ganancia)}`}
>
{formatAdena(item.ganancia)}
</td>
</tr>
)
}),
)}
</tbody>
</table>
</div>
</section>
<section>
<button
type="button"
onClick={() => setShowSnapshot((s) => !s)}
className="text-sm font-semibold text-slate-800 flex items-center gap-2 hover:text-blue-700"
>
<span>{showSnapshot ? '▼' : '▶'}</span>
Precios y fórmulas usados en ese momento
</button>
{showSnapshot && (
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
<SnapshotInsumos snapshot={details.snapshot} />
<SnapshotFormulas snapshot={details.snapshot} />
</div>
)}
</section>
</>
)}
</div>
<footer className="px-6 py-3 border-t border-slate-200 bg-slate-50 flex justify-end">
<button
type="button"
onClick={onClose}
className="px-3 py-2 text-sm font-medium rounded border border-slate-300 bg-white text-slate-700 hover:bg-slate-100"
>
Cerrar
</button>
</footer>
</div>
</div>
)
}
function SummaryStat({
label,
value,
tone,
highlight = false,
}: {
label: string
value: string
tone: 'slate' | 'emerald' | 'rose'
highlight?: boolean
}) {
const colorClass =
tone === 'emerald' ? 'text-emerald-700' : tone === 'rose' ? 'text-rose-700' : 'text-slate-700'
return (
<div className="bg-slate-50 rounded border border-slate-200 px-3 py-2">
<p className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</p>
<p className={`font-mono ${highlight ? 'text-2xl font-bold' : 'text-xl font-semibold'} ${colorClass}`}>
{value}
</p>
</div>
)
}
function SnapshotInsumos({ snapshot }: { snapshot: RunDetails['snapshot'] }) {
const insumos = snapshot.insumos as Insumos
if (!insumos || !insumos.cristales) {
return (
<div className="bg-slate-50 rounded border border-slate-200 p-3 text-xs text-slate-500">
Sin datos de insumos.
</div>
)
}
return (
<div className="bg-slate-50 rounded border border-slate-200 p-3 text-xs">
<h4 className="font-semibold text-slate-700 mb-2">Insumos</h4>
<table className="w-full">
<tbody>
{Object.entries(insumos.cristales).map(([grado, precio]) => (
<tr key={grado}>
<td className="py-0.5">Cristal {grado}</td>
<td className="text-right font-mono">{formatAdena(Number(precio))}</td>
</tr>
))}
<tr>
<td className="py-0.5">Soul Ore</td>
<td className="text-right font-mono">{formatAdena(insumos.soulOre)}</td>
</tr>
<tr>
<td className="py-0.5">Spirit Ore</td>
<td className="text-right font-mono">{formatAdena(insumos.spiritOre)}</td>
</tr>
</tbody>
</table>
</div>
)
}
function SnapshotFormulas({ snapshot }: { snapshot: RunDetails['snapshot'] }) {
const formulas = snapshot.formulas as Formula[]
if (!formulas || !Array.isArray(formulas)) {
return (
<div className="bg-slate-50 rounded border border-slate-200 p-3 text-xs text-slate-500">
Sin datos de fórmulas.
</div>
)
}
return (
<div className="bg-slate-50 rounded border border-slate-200 p-3 text-xs">
<h4 className="font-semibold text-slate-700 mb-2">
Fórmulas ({formulas.length})
</h4>
<div className="max-h-48 overflow-y-auto">
<table className="w-full">
<thead>
<tr className="text-slate-500">
<th className="text-left font-medium">Tipo</th>
<th className="text-center font-medium">Grado</th>
<th className="text-right font-medium">Crist</th>
<th className="text-right font-medium">Soul</th>
<th className="text-right font-medium">Spirit</th>
<th className="text-right font-medium">Shots</th>
</tr>
</thead>
<tbody>
{formulas.map((f) => (
<tr key={f.id}>
<td className="py-0.5">{f.tipo}</td>
<td className="text-center">{f.grado}</td>
<td className="text-right font-mono">{f.cristalesReq}</td>
<td className="text-right font-mono">{f.soulOreReq ?? '—'}</td>
<td className="text-right font-mono">{f.spiritOreReq ?? '—'}</td>
<td className="text-right font-mono">{f.shotsObtenidos}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('es-ES', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function gananciaColor(value: number): string {
if (value > 0) return 'text-emerald-700'
if (value < 0) return 'text-rose-700'
return 'text-slate-700'
}
+94
View File
@@ -0,0 +1,94 @@
import type { RunSummary } from '../api/history'
import { formatAdena } from '../utils/format'
interface RunsTableProps {
runs: RunSummary[]
onSelect: (id: string) => void
onDelete: (id: string) => void
}
export function RunsTable({ runs, onSelect, onDelete }: RunsTableProps) {
if (runs.length === 0) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center">
<p className="text-sm text-slate-500">No hay corridas para mostrar.</p>
</div>
)
}
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="cell-label text-left">Fecha</th>
<th className="cell-label text-left">Label</th>
<th className="cell-label text-right">Cristales</th>
<th className="cell-label text-right">Shots</th>
<th className="cell-label text-right">Costo</th>
<th className="cell-label text-right">Venta</th>
<th className="cell-label text-right">Ganancia</th>
<th className="cell-label text-center">Acción</th>
</tr>
</thead>
<tbody>
{runs.map((r) => (
<tr
key={r.id}
className="hover:bg-slate-50 cursor-pointer"
onClick={() => onSelect(r.id)}
>
<td className="cell-input text-slate-600 text-xs font-mono whitespace-nowrap">
{formatDateTime(r.createdAt)}
</td>
<td className="cell-input text-slate-700">
{r.label || <span className="text-slate-400 italic">(sin label)</span>}
</td>
<td className="cell-input text-right font-mono">{r.totalCristalesUsed}</td>
<td className="cell-input text-right font-mono">{r.totalShots}</td>
<td className="cell-input text-right font-mono">{formatAdena(r.totalCost)}</td>
<td className="cell-input text-right font-mono">{formatAdena(r.totalSale)}</td>
<td className={`cell-input text-right font-mono font-semibold ${gananciaColor(r.totalProfit)}`}>
{formatAdena(r.totalProfit)}
</td>
<td className="cell-input text-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (confirm(`¿Borrar la corrida "${r.label || 'sin label'}"?`)) {
onDelete(r.id)
}
}}
className="text-rose-600 hover:text-rose-800 text-xs font-medium"
title="Borrar corrida"
>
Borrar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function formatDateTime(iso: string): string {
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${yyyy}-${mm}-${dd} ${hh}:${min}`
}
function gananciaColor(value: number): string {
if (value > 0) return 'text-emerald-700'
if (value < 0) return 'text-rose-700'
return 'text-slate-700'
}
@@ -0,0 +1,34 @@
import type { SaveStatus } from '../hooks/usePersistedState'
interface SaveIndicatorProps {
status: SaveStatus
errorMessage: string | null
}
export function SaveIndicator({ status, errorMessage }: SaveIndicatorProps) {
if (status === 'idle') {
return <span className="text-xs text-slate-400">Guardado automáticamente</span>
}
if (status === 'saving') {
return (
<span className="text-xs text-blue-700 inline-flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-blue-600 animate-pulse" />
Guardando
</span>
)
}
if (status === 'saved') {
return (
<span className="text-xs text-emerald-700 inline-flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-emerald-600" />
Guardado
</span>
)
}
return (
<span className="text-xs text-rose-700 inline-flex items-center gap-1" title={errorMessage ?? ''}>
<span className="inline-block w-2 h-2 rounded-full bg-rose-600" />
Error al guardar
</span>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { GRADOS, type Calculo, type Formula, type OreType, type ShotType, type Totales } from '../types'
import { formatAdena, parseNumber } from '../utils/format'
interface ShotTableProps {
tipo: ShotType
formulas: Formula[]
calculos: Calculo[]
totales: Totales
disponibles: Record<string, number>
onChangeDisponibles: (grado: string, value: number) => void
}
export function ShotTable({
tipo,
formulas,
calculos,
totales,
disponibles,
onChangeDisponibles,
}: ShotTableProps) {
const formulasPorTipo = formulas.filter((f) => f.tipo === tipo)
const calcPorGrado = new Map(calculos.map((c) => [c.grado, c]))
const oreLabel: OreType = tipo === 'Soulshot' ? 'Soul Ore' : 'Spirit Ore'
const colOre = `${oreLabel} nec.`
return (
<section className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<header className="px-4 py-3 border-b border-slate-200 bg-slate-50">
<h2 className="text-sm font-semibold text-slate-800">{tipo}</h2>
<p className="text-xs text-slate-500">
Crafteo de {tipo}. Ingresa solo los cristales disponibles por grado.
</p>
</header>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr>
<th className="cell-label text-center">Grado</th>
<th className="cell-label text-right">Cristales disp.</th>
<th className="cell-label text-right">Cristales usados</th>
<th className="cell-label text-right">{colOre}</th>
<th className="cell-label text-right">Crafteos</th>
<th className="cell-label text-right">Shots</th>
<th className="cell-label text-right">Costo</th>
<th className="cell-label text-right">Venta</th>
<th className="cell-label text-right">Ganancia</th>
</tr>
</thead>
<tbody>
{formulasPorTipo.map((f) => {
const c = calcPorGrado.get(f.grado)
if (!c) return null
return (
<tr
key={f.id}
className={c.warning ? 'bg-orange-50' : 'hover:bg-slate-50'}
>
<td className="cell-input text-center">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-slate-200 text-slate-700 font-semibold text-xs">
{f.grado}
</span>
</td>
<td className="cell-input">
<input
type="number"
min={0}
step="1"
value={disponibles[f.grado]}
onChange={(e) => onChangeDisponibles(f.grado, parseNumber(e.target.value))}
className="input-editable"
/>
</td>
<td className="cell-calculated">{formatAdena(c.cristalesUsados)}</td>
<td className="cell-calculated">{formatAdena(c.oreNecesario)}</td>
<td className="cell-calculated">{formatAdena(c.crafteosPosibles)}</td>
<td className="cell-calculated">{formatAdena(c.shotsObtenidos)}</td>
<td className="cell-calculated">{formatAdena(c.costoTotal)}</td>
<td className="cell-calculated">{formatAdena(c.valorVenta)}</td>
<td className={`cell-calculated font-semibold ${gananciaColor(c.ganancia)}`}>
{formatAdena(c.ganancia)}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="bg-slate-100 font-semibold text-slate-800">
<td className="cell-label text-center">Σ</td>
<td className="cell-label text-right text-slate-400"></td>
<td className="cell-label text-right font-mono">{formatAdena(totales.cristalesUsados)}</td>
<td className="cell-label text-right font-mono">{formatAdena(totales.oreNecesario)}</td>
<td className="cell-label text-right font-mono">{formatAdena(totales.crafteosPosibles)}</td>
<td className="cell-label text-right font-mono">{formatAdena(totales.shotsObtenidos)}</td>
<td className="cell-label text-right font-mono">{formatAdena(totales.costoTotal)}</td>
<td className="cell-label text-right font-mono">{formatAdena(totales.valorVenta)}</td>
<td className={`cell-label text-right font-mono ${gananciaColor(totales.ganancia)}`}>
{formatAdena(totales.ganancia)}
</td>
</tr>
</tfoot>
</table>
</div>
{GRADOS.some((g) => {
const c = calcPorGrado.get(g)
const f = formulasPorTipo.find((x) => x.grado === g)
return c?.warning || (f && f.cristalesReq === 0)
}) && (
<p className="px-4 py-2 text-xs text-orange-700 bg-orange-50 border-t border-orange-200">
Una o más filas tienen cristales requeridos en 0. Completa la receta en la pestaña
Fórmulas para ver el cálculo.
</p>
)}
</section>
)
}
function gananciaColor(value: number): string {
if (value > 0) return 'text-emerald-700'
if (value < 0) return 'text-rose-700'
return 'text-slate-700'
}
@@ -0,0 +1,80 @@
import type { HistoryStats } from '../api/history'
import { formatAdena } from '../utils/format'
interface StatsCardsProps {
stats: HistoryStats
}
export function StatsCards({ stats }: StatsCardsProps) {
if (stats.totalRuns === 0) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-6 text-center">
<p className="text-sm text-slate-600">
Aún no hay producciones guardadas. Ve a la pestaña <strong>Calculadora</strong>,
configura los cristales disponibles y presiona <strong>Guardar producción</strong>.
</p>
</div>
)
}
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<MetricCard
label="Total ganado"
value={formatAdena(stats.totalProfit)}
tone={stats.totalProfit > 0 ? 'emerald' : stats.totalProfit < 0 ? 'rose' : 'slate'}
subtitle={`${stats.totalRuns} corrida${stats.totalRuns === 1 ? '' : 's'}`}
/>
<MetricCard
label="Promedio"
value={formatAdena(stats.avgProfit)}
tone={stats.avgProfit > 0 ? 'emerald' : stats.avgProfit < 0 ? 'rose' : 'slate'}
subtitle="por corrida"
/>
<MetricCard
label="Mejor corrida"
value={stats.bestRun ? formatAdena(stats.bestRun.totalProfit) : '—'}
tone="emerald"
subtitle={stats.bestRun?.label ?? stats.bestRun?.createdAt.slice(0, 10)}
/>
<MetricCard
label="Peor corrida"
value={stats.worstRun ? formatAdena(stats.worstRun.totalProfit) : '—'}
tone={stats.worstRun && stats.worstRun.totalProfit < 0 ? 'rose' : 'slate'}
subtitle={stats.worstRun?.label ?? stats.worstRun?.createdAt.slice(0, 10)}
/>
<MetricCard
label="Últimas 5"
value={formatAdena(stats.last5Avg)}
tone={stats.last5Avg > 0 ? 'emerald' : stats.last5Avg < 0 ? 'rose' : 'slate'}
subtitle="tendencia reciente"
/>
</div>
)
}
function MetricCard({
label,
value,
subtitle,
tone,
}: {
label: string
value: string
subtitle?: string
tone: 'slate' | 'emerald' | 'rose'
}) {
const colorClass =
tone === 'emerald'
? 'text-emerald-700'
: tone === 'rose'
? 'text-rose-700'
: 'text-slate-700'
return (
<div className="bg-white rounded-lg border border-slate-200 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</p>
<p className={`font-mono text-xl font-bold mt-1 ${colorClass}`}>{value}</p>
{subtitle && <p className="text-xs text-slate-400 mt-1 truncate">{subtitle}</p>}
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
export type TabKey = 'insumos' | 'formulas' | 'calculadora' | 'historial'
interface TabBarProps {
active: TabKey
onChange: (key: TabKey) => void
}
const TABS: Array<{ key: TabKey; label: string; subtitle: string }> = [
{ key: 'insumos', label: '1. Insumos', subtitle: 'Precios' },
{ key: 'formulas', label: '2. Fórmulas', subtitle: 'Recetas' },
{ key: 'calculadora', label: '3. Calculadora', subtitle: 'Rentabilidad' },
{ key: 'historial', label: '4. Historial', subtitle: 'Producción' },
]
export function TabBar({ active, onChange }: TabBarProps) {
return (
<div className="border-b border-slate-200 bg-white">
<nav className="flex gap-1 px-4" aria-label="Tabs">
{TABS.map((tab) => {
const isActive = tab.key === active
return (
<button
key={tab.key}
type="button"
onClick={() => onChange(tab.key)}
className={[
'px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
isActive
? 'border-blue-600 text-blue-700'
: 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300',
].join(' ')}
aria-current={isActive ? 'page' : undefined}
>
<span className="block">{tab.label}</span>
<span className="block text-xs font-normal text-slate-400">{tab.subtitle}</span>
</button>
)
})}
</nav>
</div>
)
}
@@ -0,0 +1,77 @@
import type { Totales } from '../types'
import { formatAdena } from '../utils/format'
interface TotalsSummaryProps {
totales: Totales
}
export function TotalsSummary({ totales }: TotalsSummaryProps) {
const margen = totales.costoTotal > 0
? (totales.ganancia / totales.costoTotal) * 100
: 0
return (
<section
className={[
'rounded-lg border-2 p-5',
totales.ganancia > 0
? 'bg-emerald-50 border-emerald-200'
: totales.ganancia < 0
? 'bg-rose-50 border-rose-200'
: 'bg-slate-50 border-slate-200',
].join(' ')}
>
<header className="mb-3">
<h2 className="text-base font-bold text-slate-800">Resumen global</h2>
<p className="text-xs text-slate-500">
Suma de Soulshots, Spiritshots y Blessed Spiritshots
</p>
</header>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<Metric label="Costo total" value={formatAdena(totales.costoTotal)} tone="slate" />
<Metric label="Valor de venta" value={formatAdena(totales.valorVenta)} tone="slate" />
<Metric
label="Ganancia neta"
value={formatAdena(totales.ganancia)}
tone={totales.ganancia > 0 ? 'emerald' : totales.ganancia < 0 ? 'rose' : 'slate'}
highlight
/>
<Metric
label="Margen"
value={`${margen.toFixed(1)}%`}
tone={margen > 0 ? 'emerald' : margen < 0 ? 'rose' : 'slate'}
/>
</div>
</section>
)
}
function Metric({
label,
value,
tone,
highlight = false,
}: {
label: string
value: string
tone: 'slate' | 'emerald' | 'rose'
highlight?: boolean
}) {
const colorClass =
tone === 'emerald' ? 'text-emerald-700' : tone === 'rose' ? 'text-rose-700' : 'text-slate-800'
return (
<div className="bg-white rounded border border-slate-200 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-slate-500 font-medium">{label}</p>
<p
className={[
'font-mono mt-1',
highlight ? 'text-2xl font-bold' : 'text-xl font-semibold',
colorClass,
].join(' ')}
>
{value}
</p>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
import { useCallback, useEffect, useState } from 'react'
import { historyApi, type HistoryStats, type RunIn, type RunSummary } from '../api/history'
export type HistorySaveStatus = 'idle' | 'saving' | 'saved' | 'error'
interface UseHistoryResult {
runs: RunSummary[]
stats: HistoryStats | null
loading: boolean
saveStatus: HistorySaveStatus
saveError: string | null
reload: () => Promise<void>
saveRun: (payload: RunIn) => Promise<RunSummary>
deleteRun: (id: string) => Promise<void>
}
export function useHistory(): UseHistoryResult {
const [runs, setRuns] = useState<RunSummary[]>([])
const [stats, setStats] = useState<HistoryStats | null>(null)
const [loading, setLoading] = useState(true)
const [saveStatus, setSaveStatus] = useState<HistorySaveStatus>('idle')
const [saveError, setSaveError] = useState<string | null>(null)
const reload = useCallback(async () => {
setLoading(true)
try {
const [runsData, statsData] = await Promise.all([
historyApi.listRuns(),
historyApi.getStats(),
])
setRuns(runsData)
setStats(statsData)
} catch (e) {
console.error('history load failed', e)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
reload()
}, [reload])
const saveRun = useCallback(async (payload: RunIn) => {
setSaveStatus('saving')
setSaveError(null)
try {
const saved = await historyApi.saveRun(payload)
setRuns((prev) => [saved, ...prev])
const newStats = await historyApi.getStats()
setStats(newStats)
setSaveStatus('saved')
window.setTimeout(() => setSaveStatus((s) => (s === 'saved' ? 'idle' : s)), 3000)
return saved
} catch (e) {
setSaveStatus('error')
setSaveError(e instanceof Error ? e.message : 'Error al guardar')
throw e
}
}, [])
const deleteRun = useCallback(async (id: string) => {
await historyApi.deleteRun(id)
setRuns((prev) => prev.filter((r) => r.id !== id))
const newStats = await historyApi.getStats()
setStats(newStats)
}, [])
return { runs, stats, loading, saveStatus, saveError, reload, saveRun, deleteRun }
}
@@ -0,0 +1,94 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, ApiError, type AppState } from '../api/client'
import { makeDefaultAppState } from '../data/defaults'
export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
interface UsePersistedStateResult {
state: AppState | null
setState: (next: AppState | ((prev: AppState) => AppState)) => void
status: SaveStatus
errorMessage: string | null
reset: () => Promise<void>
reload: () => Promise<void>
}
const DEBOUNCE_MS = 500
export function usePersistedState(): UsePersistedStateResult {
const [state, setStateInternal] = useState<AppState | null>(null)
const [status, setStatus] = useState<SaveStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const stateRef = useRef<AppState | null>(null)
const timerRef = useRef<number | null>(null)
const firstLoad = useRef(true)
const load = useCallback(async () => {
try {
const loaded = await api.getState()
setStateInternal(loaded ?? makeDefaultAppState())
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setStateInternal(null)
} else {
setErrorMessage(err instanceof Error ? err.message : 'Error cargando estado')
}
}
}, [])
useEffect(() => {
load()
}, [load])
useEffect(() => {
stateRef.current = state
}, [state])
useEffect(() => {
if (firstLoad.current) {
firstLoad.current = false
return
}
if (!state) return
setStatus('saving')
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
}
timerRef.current = window.setTimeout(async () => {
try {
if (stateRef.current) {
await api.putState(stateRef.current)
}
setStatus('saved')
setErrorMessage(null)
window.setTimeout(() => setStatus((s) => (s === 'saved' ? 'idle' : s)), 1500)
} catch (err) {
setStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Error al guardar')
}
}, DEBOUNCE_MS)
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current)
}
}
}, [state])
const setState = useCallback((next: AppState | ((prev: AppState) => AppState)) => {
setStateInternal((prev) => {
if (typeof next === 'function') {
return (next as (prev: AppState) => AppState)(prev as AppState)
}
return next
})
}, [])
const reset = useCallback(async () => {
await api.resetState()
setStateInternal(makeDefaultAppState())
}, [])
return { state, setState, status, errorMessage, reset, reload: load }
}
+34
View File
@@ -0,0 +1,34 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
@apply bg-slate-50 text-slate-900;
}
}
@layer components {
.input-editable {
@apply w-full bg-editable-50 border border-editable-200 rounded px-2 py-1 text-right font-mono text-sm
focus:outline-none focus:ring-2 focus:ring-editable-400 focus:border-editable-400;
}
.input-editable:disabled {
@apply bg-slate-100 text-slate-400 cursor-not-allowed;
}
.cell-calculated {
@apply bg-calculated-100 text-slate-700 font-mono text-sm text-right px-2 py-1;
}
.cell-label {
@apply px-3 py-2 text-sm font-semibold text-slate-700 bg-slate-100 border-b border-slate-200;
}
.cell-input {
@apply px-2 py-1 border-b border-slate-200;
}
.cell-calc {
@apply px-2 py-1 border-b border-slate-200 bg-calculated-50;
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+53
View File
@@ -0,0 +1,53 @@
export type Grado = 'D' | 'C' | 'B' | 'A' | 'S'
export const GRADOS: Grado[] = ['D', 'C', 'B', 'A', 'S']
export type ShotType = 'Soulshot' | 'Spiritshot' | 'Blessed Spiritshot'
export const SHOT_TYPES: ShotType[] = ['Soulshot', 'Spiritshot', 'Blessed Spiritshot']
export type OreType = 'Soul Ore' | 'Spirit Ore'
export interface Insumos {
cristales: Record<string, number>
soulOre: number
spiritOre: number
venta: Record<string, Record<string, number>>
}
export interface Formula {
id: string
tipo: string
grado: string
cristalesReq: number
soulOreReq: number | null
spiritOreReq: number | null
shotsObtenidos: number
}
export type CristalesDisponibles = Record<string, Record<string, number>>
export interface Calculo {
tipo: string
grado: string
cristalesDisponibles: number
cristalesUsados: number
oreNecesario: number
oreLabel: OreType | null
crafteosPosibles: number
shotsObtenidos: number
costoTotal: number
valorVenta: number
ganancia: number
warning: string | null
}
export interface Totales {
cristalesUsados: number
oreNecesario: number
crafteosPosibles: number
shotsObtenidos: number
costoTotal: number
valorVenta: number
ganancia: number
}
+83
View File
@@ -0,0 +1,83 @@
import type { Calculo, Formula, Insumos, OreType, Totales } from '../types'
export function calcularFila(
formula: Formula,
cristalesDisponibles: number,
insumos: Insumos,
): Calculo {
const orePerCraft = formula.soulOreReq ?? formula.spiritOreReq ?? 0
const oreLabel: OreType | null =
formula.soulOreReq != null ? 'Soul Ore' : formula.spiritOreReq != null ? 'Spirit Ore' : null
if (formula.cristalesReq <= 0) {
return {
tipo: formula.tipo,
grado: formula.grado,
cristalesDisponibles,
cristalesUsados: 0,
oreNecesario: 0,
oreLabel,
crafteosPosibles: 0,
shotsObtenidos: 0,
costoTotal: 0,
valorVenta: 0,
ganancia: 0,
warning: 'Completa la receta en la pestaña Fórmulas',
}
}
const crafteosPosibles = Math.floor(cristalesDisponibles / formula.cristalesReq)
const cristalesUsados = crafteosPosibles * formula.cristalesReq
const oreNecesario = crafteosPosibles * orePerCraft
const shotsObtenidos = crafteosPosibles * formula.shotsObtenidos
const precioCristal = insumos.cristales[formula.grado]
const precioOre = orePerCraft > 0
? formula.soulOreReq != null
? insumos.soulOre
: insumos.spiritOre
: 0
const precioVenta = insumos.venta[formula.tipo][formula.grado]
const costoTotal = cristalesUsados * precioCristal + oreNecesario * precioOre
const valorVenta = shotsObtenidos * precioVenta
const ganancia = valorVenta - costoTotal
return {
tipo: formula.tipo,
grado: formula.grado,
cristalesDisponibles,
cristalesUsados,
oreNecesario,
oreLabel,
crafteosPosibles,
shotsObtenidos,
costoTotal,
valorVenta,
ganancia,
warning: null,
}
}
export function sumarTotales(calculos: Calculo[]): Totales {
return calculos.reduce<Totales>(
(acc, c) => ({
cristalesUsados: acc.cristalesUsados + c.cristalesUsados,
oreNecesario: acc.oreNecesario + c.oreNecesario,
crafteosPosibles: acc.crafteosPosibles + c.crafteosPosibles,
shotsObtenidos: acc.shotsObtenidos + c.shotsObtenidos,
costoTotal: acc.costoTotal + c.costoTotal,
valorVenta: acc.valorVenta + c.valorVenta,
ganancia: acc.ganancia + c.ganancia,
}),
{
cristalesUsados: 0,
oreNecesario: 0,
crafteosPosibles: 0,
shotsObtenidos: 0,
costoTotal: 0,
valorVenta: 0,
ganancia: 0,
},
)
}
+16
View File
@@ -0,0 +1,16 @@
const formatter = new Intl.NumberFormat('es-ES', { maximumFractionDigits: 0 })
export function formatAdena(n: number): string {
if (!Number.isFinite(n)) return '0'
return formatter.format(Math.round(n))
}
export function formatNumber(n: number): string {
return formatter.format(n)
}
export function parseNumber(value: string): number {
if (value === '' || value === '-') return 0
const n = Number(value)
return Number.isFinite(n) && n >= 0 ? n : 0
}
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
editable: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
400: '#fbbf24',
500: '#f59e0b',
},
calculated: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
},
},
fontFamily: {
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'monospace'],
},
},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8080',
},
},
})
@@ -0,0 +1,18 @@
package com.l2.shots.auth;
import java.time.Instant;
import java.util.UUID;
public class AuthMeResponse {
public UUID id;
public String username;
public Instant createdAt;
public AuthMeResponse() {}
public AuthMeResponse(UUID id, String username, Instant createdAt) {
this.id = id;
this.username = username;
this.createdAt = createdAt;
}
}
@@ -0,0 +1,123 @@
package com.l2.shots.auth;
import io.quarkus.security.Authenticated;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.NewCookie;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.Optional;
@Path("/api/auth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AuthResource {
@Inject
AuthService authService;
@Inject
JwtCookieAuth jwtCookieAuth;
@ConfigProperty(name = "app.auth.cookie-name")
String cookieName;
@ConfigProperty(name = "app.auth.cookie-max-age-seconds")
int cookieMaxAge;
@POST
@Path("/register")
public Response register(Credentials creds) {
Optional<User> result = authService.register(creds.username, creds.password);
if (result.isEmpty()) {
return Response.status(409)
.entity(new ErrorBody("username no disponible o datos inválidos"))
.build();
}
User user = result.get();
String token = authService.buildToken(user.id);
return Response.ok(AuthService.toAuthMe(user))
.cookie(buildAuthCookie(token))
.build();
}
@POST
@Path("/login")
public Response login(Credentials creds) {
Optional<User> result = authService.authenticate(creds.username, creds.password);
if (result.isEmpty()) {
return Response.status(401)
.entity(new ErrorBody("credenciales inválidas"))
.build();
}
User user = result.get();
String token = authService.buildToken(user.id);
return Response.ok(AuthService.toAuthMe(user))
.cookie(buildAuthCookie(token))
.build();
}
@POST
@Path("/logout")
public Response logout() {
return Response.noContent()
.cookie(clearAuthCookie())
.build();
}
@GET
@Path("/me")
public Response me(@Context HttpHeaders headers) {
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) {
return Response.status(401).build();
}
return authService.getUserFromToken(jwt.get())
.map(u -> Response.ok(u).build())
.orElse(Response.status(401).build());
}
@GET
@Path("/check")
@Authenticated
public Response check() {
return Response.ok().build();
}
private NewCookie buildAuthCookie(String token) {
return new NewCookie.Builder(cookieName)
.value(token)
.path("/")
.httpOnly(true)
.secure(false)
.sameSite(NewCookie.SameSite.LAX)
.maxAge(cookieMaxAge)
.build();
}
private NewCookie clearAuthCookie() {
return new NewCookie.Builder(cookieName)
.value("")
.path("/")
.httpOnly(true)
.secure(false)
.sameSite(NewCookie.SameSite.LAX)
.maxAge(0)
.build();
}
public static class ErrorBody {
public String error;
public ErrorBody() {}
public ErrorBody(String error) { this.error = error; }
}
}
@@ -0,0 +1,75 @@
package com.l2.shots.auth;
import io.quarkus.elytron.security.common.BcryptUtil;
import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Pattern;
@ApplicationScoped
public class AuthService {
private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]{3,30}$");
public static final int MIN_PASSWORD_LENGTH = 8;
@ConfigProperty(name = "mp.jwt.verify.issuer")
String issuer;
@Transactional
public Optional<User> register(String username, String password) {
if (username == null || password == null) return Optional.empty();
username = username.trim();
if (!USERNAME_PATTERN.matcher(username).matches()) return Optional.empty();
if (password.length() < MIN_PASSWORD_LENGTH) return Optional.empty();
if (User.findByUsernameCaseInsensitive(username) != null) return Optional.empty();
User user = new User();
user.id = UUID.randomUUID();
user.username = username.toLowerCase();
user.passwordHash = BcryptUtil.bcryptHash(password);
user.createdAt = Instant.now();
user.persist();
return Optional.of(user);
}
public Optional<User> authenticate(String username, String password) {
if (username == null || password == null) return Optional.empty();
User user = User.findByUsernameCaseInsensitive(username.trim());
if (user == null) return Optional.empty();
if (!BcryptUtil.matches(password, user.passwordHash)) return Optional.empty();
return Optional.of(user);
}
public String buildToken(UUID userId) {
return Jwt.issuer(issuer)
.subject(userId.toString())
.groups(Set.of("user"))
.expiresIn(Duration.ofSeconds(86400))
.sign();
}
public Optional<AuthMeResponse> getUserFromToken(JsonWebToken jwt) {
if (jwt == null || jwt.getSubject() == null) return Optional.empty();
try {
UUID userId = UUID.fromString(jwt.getSubject());
User user = User.findById(userId);
if (user == null) return Optional.empty();
return Optional.of(new AuthMeResponse(user.id, user.username, user.createdAt));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
public static AuthMeResponse toAuthMe(User user) {
return new AuthMeResponse(user.id, user.username, user.createdAt);
}
}
@@ -0,0 +1,6 @@
package com.l2.shots.auth;
public class Credentials {
public String username;
public String password;
}
@@ -0,0 +1,45 @@
package com.l2.shots.auth;
import io.smallrye.jwt.auth.principal.JWTParser;
import io.smallrye.jwt.auth.principal.ParseException;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.HttpHeaders;
import java.util.Map;
import java.util.Optional;
@ApplicationScoped
public class JwtCookieAuth {
@Inject
JWTParser parser;
@ConfigProperty(name = "app.auth.cookie-name")
String cookieName;
public Optional<JsonWebToken> extractToken(HttpHeaders headers) {
String auth = headers.getHeaderString("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
return Optional.of(parseToken(auth.substring(7)));
}
Map<String, Cookie> cookies = headers.getCookies();
Cookie cookie = cookies.get(cookieName);
if (cookie != null && cookie.getValue() != null && !cookie.getValue().isEmpty()) {
return Optional.of(parseToken(cookie.getValue()));
}
return Optional.empty();
}
private JsonWebToken parseToken(String token) {
try {
return parser.parse(token);
} catch (ParseException e) {
return null;
}
}
}
+35
View File
@@ -0,0 +1,35 @@
package com.l2.shots.auth;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "users")
public class User extends PanacheEntityBase {
@Id
public UUID id;
@Column(unique = true, nullable = false, length = 30)
public String username;
@Column(name = "password_hash", nullable = false, length = 100)
public String passwordHash;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
public static User findByUsername(String username) {
return find("username", username.toLowerCase()).firstResult();
}
public static User findByUsernameCaseInsensitive(String username) {
return find("LOWER(username) = ?1", username.toLowerCase()).firstResult();
}
}
@@ -0,0 +1,29 @@
package com.l2.shots.auth;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "user_state")
public class UserState extends PanacheEntityBase {
@Id
@Column(name = "user_id")
public UUID userId;
@Column(name = "state_json", nullable = false, columnDefinition = "TEXT")
public String stateJson;
@Column(name = "updated_at", nullable = false)
public Instant updatedAt;
public static UserState findByUserId(UUID userId) {
return findById(userId);
}
}
@@ -0,0 +1,120 @@
package com.l2.shots.history;
import com.l2.shots.auth.JwtCookieAuth;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Path("/api/history")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class HistoryResource {
@Inject
HistoryService historyService;
@Inject
JwtCookieAuth jwtCookieAuth;
@POST
@Path("/runs")
public Response saveRun(@Context HttpHeaders headers, RunIn input) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
if (input == null || input.items == null || input.snapshot == null) {
return Response.status(400).entity("{\"error\":\"payload inválido\"}").build();
}
if (input.items.size() > 100) {
return Response.status(400).entity("{\"error\":\"demasiados items\"}").build();
}
if (input.label != null && input.label.length() > 100) {
return Response.status(400).entity("{\"error\":\"label demasiado largo\"}").build();
}
if (input.totalCristalesUsed <= 0) {
return Response.status(400).entity("{\"error\":\"no hay cristales usados\"}").build();
}
RunSummary saved = historyService.saveRun(userId.get(), input);
return Response.status(201).entity(saved).build();
}
@GET
@Path("/runs")
public Response list(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
List<RunSummary> runs = historyService.listForUser(userId.get());
return Response.ok(runs).build();
}
@GET
@Path("/runs/{id}")
public Response get(@Context HttpHeaders headers, @PathParam("id") String idStr) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
UUID id;
try {
id = UUID.fromString(idStr);
} catch (IllegalArgumentException e) {
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
}
return historyService.getById(userId.get(), id)
.map(d -> Response.ok(d).build())
.orElse(Response.status(404).build());
}
@DELETE
@Path("/runs/{id}")
public Response delete(@Context HttpHeaders headers, @PathParam("id") String idStr) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
UUID id;
try {
id = UUID.fromString(idStr);
} catch (IllegalArgumentException e) {
return Response.status(400).entity("{\"error\":\"id inválido\"}").build();
}
boolean deleted = historyService.deleteForUser(userId.get(), id);
return deleted ? Response.noContent().build() : Response.status(404).build();
}
@GET
@Path("/stats")
public Response stats(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
HistoryStats stats = historyService.computeStats(userId.get());
return Response.ok(stats).build();
}
private Optional<UUID> extractUserId(HttpHeaders headers) {
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) return Optional.empty();
try {
return Optional.of(UUID.fromString(jwt.get().getSubject()));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
}
@@ -0,0 +1,127 @@
package com.l2.shots.history;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class HistoryService {
private final ObjectMapper mapper = new ObjectMapper();
public List<RunSummary> listForUser(UUID userId) {
return ProductionRun.<ProductionRun>list("userId = ?1 ORDER BY createdAt DESC", userId)
.stream()
.map(RunSummary::new)
.toList();
}
public Optional<RunDetails> getById(UUID userId, UUID id) {
ProductionRun entity = ProductionRun.find("id = ?1 AND userId = ?2", id, userId).firstResult();
if (entity == null) return Optional.empty();
try {
List<RunItem> items = mapper.readValue(entity.itemsJson, mapper.getTypeFactory()
.constructCollectionType(List.class, RunItem.class));
RunSnapshot snapshot = mapper.readValue(entity.snapshotJson, RunSnapshot.class);
return Optional.of(new RunDetails(entity, items, snapshot));
} catch (JsonProcessingException e) {
return Optional.empty();
}
}
@Transactional
public RunSummary saveRun(UUID userId, RunIn input) {
ProductionRun entity = new ProductionRun();
entity.id = UUID.randomUUID();
entity.userId = userId;
entity.createdAt = Instant.now();
entity.label = input.label;
entity.totalCost = input.totalCost;
entity.totalSale = input.totalSale;
entity.totalProfit = input.totalProfit;
entity.totalShots = input.totalShots;
entity.totalCristalesUsed = input.totalCristalesUsed;
entity.totalOreUsed = input.totalOreUsed;
try {
entity.itemsJson = mapper.writeValueAsString(input.items);
entity.snapshotJson = mapper.writeValueAsString(input.snapshot);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize run payload", e);
}
entity.persist();
return new RunSummary(entity);
}
@Transactional
public boolean deleteForUser(UUID userId, UUID id) {
return ProductionRun.delete("id = ?1 AND userId = ?2", id, userId) > 0;
}
public HistoryStats computeStats(UUID userId) {
List<ProductionRun> runs = ProductionRun.list(
"userId = ?1 ORDER BY createdAt DESC", userId);
HistoryStats stats = new HistoryStats();
stats.totalRuns = runs.size();
if (runs.isEmpty()) {
stats.totalCost = 0;
stats.totalSale = 0;
stats.totalProfit = 0;
stats.totalShots = 0;
stats.avgProfit = 0;
stats.avgCost = 0;
stats.avgSale = 0;
stats.bestRun = null;
stats.worstRun = null;
stats.last5Avg = 0;
stats.last10Avg = 0;
return stats;
}
long totalCost = 0;
long totalSale = 0;
long totalProfit = 0;
long totalShots = 0;
ProductionRun best = runs.get(0);
ProductionRun worst = runs.get(0);
for (ProductionRun r : runs) {
totalCost += r.totalCost;
totalSale += r.totalSale;
totalProfit += r.totalProfit;
totalShots += r.totalShots;
if (r.totalProfit > best.totalProfit) best = r;
if (r.totalProfit < worst.totalProfit) worst = r;
}
stats.totalCost = totalCost;
stats.totalSale = totalSale;
stats.totalProfit = totalProfit;
stats.totalShots = totalShots;
stats.avgProfit = totalProfit / runs.size();
stats.avgCost = totalCost / runs.size();
stats.avgSale = totalSale / runs.size();
stats.bestRun = new RunSummary(best);
stats.worstRun = new RunSummary(worst);
int n5 = Math.min(5, runs.size());
int n10 = Math.min(10, runs.size());
long sum5 = 0;
long sum10 = 0;
for (int i = 0; i < n10; i++) {
sum10 += runs.get(i).totalProfit;
if (i < n5) sum5 += runs.get(i).totalProfit;
}
stats.last5Avg = n5 > 0 ? sum5 / n5 : 0;
stats.last10Avg = n10 > 0 ? sum10 / n10 : 0;
return stats;
}
}
@@ -0,0 +1,16 @@
package com.l2.shots.history;
public class HistoryStats {
public int totalRuns;
public long totalCost;
public long totalSale;
public long totalProfit;
public long totalShots;
public long avgProfit;
public long avgCost;
public long avgSale;
public RunSummary bestRun;
public RunSummary worstRun;
public long last5Avg;
public long last10Avg;
}
@@ -0,0 +1,54 @@
package com.l2.shots.history;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "production_runs", indexes = {
@Index(name = "idx_runs_user_created", columnList = "user_id, created_at")
})
public class ProductionRun extends PanacheEntityBase {
@Id
public UUID id;
@Column(name = "user_id", nullable = false)
public UUID userId;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
@Column(length = 100)
public String label;
@Column(name = "total_cost", nullable = false)
public long totalCost;
@Column(name = "total_sale", nullable = false)
public long totalSale;
@Column(name = "total_profit", nullable = false)
public long totalProfit;
@Column(name = "total_shots", nullable = false)
public long totalShots;
@Column(name = "total_cristales_used", nullable = false)
public long totalCristalesUsed;
@Column(name = "total_ore_used", nullable = false)
public long totalOreUsed;
@Column(name = "items_json", nullable = false, columnDefinition = "TEXT")
public String itemsJson;
@Column(name = "snapshot_json", nullable = false, columnDefinition = "TEXT")
public String snapshotJson;
}
@@ -0,0 +1,16 @@
package com.l2.shots.history;
import java.util.List;
public class RunDetails extends RunSummary {
public List<RunItem> items;
public RunSnapshot snapshot;
public RunDetails() {}
public RunDetails(ProductionRun r, List<RunItem> items, RunSnapshot snapshot) {
super(r);
this.items = items;
this.snapshot = snapshot;
}
}
@@ -0,0 +1,15 @@
package com.l2.shots.history;
import java.util.List;
public class RunIn {
public String label;
public long totalCost;
public long totalSale;
public long totalProfit;
public long totalShots;
public long totalCristalesUsed;
public long totalOreUsed;
public List<RunItem> items;
public RunSnapshot snapshot;
}
@@ -0,0 +1,31 @@
package com.l2.shots.history;
public class RunItem {
public String tipo;
public String grado;
public int cristalesDisponibles;
public int cristalesUsados;
public int oreNecesario;
public int crafteosPosibles;
public int shotsObtenidos;
public long costoTotal;
public long valorVenta;
public long ganancia;
public RunItem() {}
public RunItem(String tipo, String grado, int cristalesDisponibles, int cristalesUsados,
int oreNecesario, int crafteosPosibles, int shotsObtenidos,
long costoTotal, long valorVenta, long ganancia) {
this.tipo = tipo;
this.grado = grado;
this.cristalesDisponibles = cristalesDisponibles;
this.cristalesUsados = cristalesUsados;
this.oreNecesario = oreNecesario;
this.crafteosPosibles = crafteosPosibles;
this.shotsObtenidos = shotsObtenidos;
this.costoTotal = costoTotal;
this.valorVenta = valorVenta;
this.ganancia = ganancia;
}
}
@@ -0,0 +1,16 @@
package com.l2.shots.history;
import java.util.List;
import java.util.Map;
public class RunSnapshot {
public Map<String, Object> insumos;
public List<Map<String, Object>> formulas;
public RunSnapshot() {}
public RunSnapshot(Map<String, Object> insumos, List<Map<String, Object>> formulas) {
this.insumos = insumos;
this.formulas = formulas;
}
}
@@ -0,0 +1,30 @@
package com.l2.shots.history;
import java.time.Instant;
import java.util.UUID;
public class RunSummary {
public UUID id;
public Instant createdAt;
public String label;
public long totalCost;
public long totalSale;
public long totalProfit;
public long totalShots;
public long totalCristalesUsed;
public long totalOreUsed;
public RunSummary() {}
public RunSummary(ProductionRun r) {
this.id = r.id;
this.createdAt = r.createdAt;
this.label = r.label;
this.totalCost = r.totalCost;
this.totalSale = r.totalSale;
this.totalProfit = r.totalProfit;
this.totalShots = r.totalShots;
this.totalCristalesUsed = r.totalCristalesUsed;
this.totalOreUsed = r.totalOreUsed;
}
}
@@ -0,0 +1,59 @@
package com.l2.shots.state;
import java.util.List;
import java.util.Map;
public class AppState {
public Insumos insumos;
public List<FormulaDto> formulas;
public Map<String, Map<String, Integer>> disponibles;
public AppState() {}
public AppState(Insumos insumos, List<FormulaDto> formulas, Map<String, Map<String, Integer>> disponibles) {
this.insumos = insumos;
this.formulas = formulas;
this.disponibles = disponibles;
}
public static class Insumos {
public Map<String, Integer> cristales;
public int soulOre;
public int spiritOre;
public Map<String, Map<String, Integer>> venta;
public Insumos() {}
public Insumos(Map<String, Integer> cristales, int soulOre, int spiritOre,
Map<String, Map<String, Integer>> venta) {
this.cristales = cristales;
this.soulOre = soulOre;
this.spiritOre = spiritOre;
this.venta = venta;
}
}
public static class FormulaDto {
public String id;
public String tipo;
public String grado;
public int cristalesReq;
public Integer soulOreReq;
public Integer spiritOreReq;
public int shotsObtenidos;
public FormulaDto() {}
public FormulaDto(String id, String tipo, String grado, int cristalesReq,
Integer soulOreReq, Integer spiritOreReq, int shotsObtenidos) {
this.id = id;
this.tipo = tipo;
this.grado = grado;
this.cristalesReq = cristalesReq;
this.soulOreReq = soulOreReq;
this.spiritOreReq = spiritOreReq;
this.shotsObtenidos = shotsObtenidos;
}
}
}
@@ -0,0 +1,74 @@
package com.l2.shots.state;
import com.l2.shots.auth.JwtCookieAuth;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.jwt.JsonWebToken;
import java.util.Optional;
import java.util.UUID;
@Path("/api/state")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class StateResource {
@Inject
StateService stateService;
@Inject
JwtCookieAuth jwtCookieAuth;
@GET
public Response get(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
Optional<AppState> state = stateService.getForUser(userId.get());
return state.map(s -> Response.ok(s).build())
.orElse(Response.status(404).build());
}
@PUT
public Response put(@Context HttpHeaders headers, AppState state) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
if (state == null || state.insumos == null || state.formulas == null || state.disponibles == null) {
return Response.status(400).entity("{\"error\":\"estado inválido\"}").build();
}
if (state.formulas.size() > 200) {
return Response.status(400).entity("{\"error\":\"estado demasiado grande\"}").build();
}
stateService.saveForUser(userId.get(), state);
return Response.noContent().build();
}
@DELETE
public Response reset(@Context HttpHeaders headers) {
Optional<UUID> userId = extractUserId(headers);
if (userId.isEmpty()) return Response.status(401).build();
stateService.deleteForUser(userId.get());
return Response.noContent().build();
}
private Optional<UUID> extractUserId(HttpHeaders headers) {
Optional<JsonWebToken> jwt = jwtCookieAuth.extractToken(headers);
if (jwt.isEmpty()) return Optional.empty();
try {
return Optional.of(UUID.fromString(jwt.get().getSubject()));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
}
}
@@ -0,0 +1,48 @@
package com.l2.shots.state;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.l2.shots.auth.UserState;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.time.Instant;
import java.util.Optional;
@ApplicationScoped
public class StateService {
private final ObjectMapper mapper = new ObjectMapper();
public Optional<AppState> getForUser(java.util.UUID userId) {
UserState entity = UserState.findByUserId(userId);
if (entity == null) return Optional.empty();
try {
return Optional.of(mapper.readValue(entity.stateJson, AppState.class));
} catch (JsonProcessingException e) {
return Optional.empty();
}
}
@Transactional
public void saveForUser(java.util.UUID userId, AppState state) {
try {
String json = mapper.writeValueAsString(state);
UserState entity = UserState.findByUserId(userId);
if (entity == null) {
entity = new UserState();
entity.userId = userId;
}
entity.stateJson = json;
entity.updatedAt = Instant.now();
entity.persist();
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize state", e);
}
}
@Transactional
public void deleteForUser(java.util.UUID userId) {
UserState.deleteById(userId);
}
}
+26
View File
@@ -0,0 +1,26 @@
quarkus.http.port=8080
quarkus.http.host=0.0.0.0
quarkus.application.name=shot-crafter-calculator
# H2 file-based
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:file:./data/shots;DB_CLOSE_DELAY=-1
quarkus.datasource.username=sa
quarkus.datasource.password=
quarkus.hibernate-orm.database.generation=update
quarkus.hibernate-orm.log.sql=false
# JWT
mp.jwt.verify.issuer=shot-crafter-calculator
mp.jwt.verify.publickey.location=publicKey.pem
smallrye.jwt.sign.key.location=privateKey.pem
# Cookie auth
app.auth.cookie-name=auth-token
app.auth.cookie-max-age-seconds=86400
# Security
quarkus.http.auth.proactive=false
%native.quarkus.native.resources.includes=META-INF/resources/.*,publicKey.pem,privateKey.pem
+28
View File
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4Rey1Bjlao2e9
6AT++5zUVYZC+g3UIL29Nd/FG64+YZublZ8z9BEIG2IMm39B6XwgpyTIhVvW/lR1
qSEcBaVPjcJVx3grx3GCbqZ+00BlJM/jwRUFMRybNZ9pCmWcWW2JTBhHPjGtfFBd
kZS3kr0htccsWbILJUJlfwSyt2+rNwGNLBJfMoJBjmWjytK5wtgOTxReaUELHRqf
hn6EukIbmyQtATDXF0Xor/MWquGrYK29oNT/R5w2oMKV4IQtDPn/Es0xkFl+nrh9
FfgjOhOEctrf9KvPdwQIKdfhrQ/TH3iVzUxijNUOIemKoxmjf4KCz5boJHPPgSsq
A49fQ5xFAgMBAAECggEACugB51brzGxbxqwgUOfFyPVvjFruDZjgfKiJysnldAYP
5n33sw9Qy2jffT0zeMxfud5apCRBzXA2bH5VminQC8IpTIK79I9fLafsjRi7Y3Zx
zW+PQG7p3AUo9FfBWtHE1LmfEucLRqgoaNlQctWprFXsvg29psDjn0viFHfHPAax
iDe3l0ftILoUF62UEisJ8aAbd+tRPnN6uGD+f6b95+wCeRdK6WlA/ZATxie+O7A5
u/P0ID2WdVAiANB4MVo71zMEr3fXkHWX4NerlA3MGLGYCmkN28xFpvifPh1v84OX
oNHu0v7o88xxfHPmP/Zn138Uzad8N3uKb9n8XYzdMQKBgQDZUJNW/s6eQcq83Lpb
yRjMyB+uq4M1XVTLX9wENdG3UxI0Vfv7n5WWz+Gd2telyRryrDjzGWupTm0/HsuO
BrNfX2pwwuCdrbnTtdixMRImTipRxRXiRSMSn6pFqfpt3zrt+JzD1GHAtY3BcEi5
dvoLnTiSzGZw2pb7rwuyeVsdWQKBgQDZE5VpX2NYvdm3BwoOp/KllJxgttFVRbKs
KbfRVF1sQ68dBU/w2aUOF8dNxXzFc+nU+M8F4zz0YGbcsEhzkjKV8S/gRxiaso6c
Qqeba8/OxqeztQnHbVh6bbSFfG9i0+FXlJVqKttjitVB0QZB3gyLdkmL0frksrvL
F3UddwZ8zQKBgHTwWPjdUM30VWZf2KB/jCrWHcZeYNKckH6H7NsPIvTlbMxg4KG8
dECdSKkrFBQQLcIcTuDx8u8+Vqc6qQqaLHfL3nkjRL9UtsRn/F0NLNkUAs3Roj8K
OR9Sb8vg9fOdxhY8TA9M//U1PTy0cU3r6g3J4qGMACwGVGzG+yJlD1SxAoGBALyp
756QX+jdwB35yTzprNNKMQtBePhSxjIpY/BUEYop3UUsu8jJcFGqSvcF4CZAUwdd
Y5hrYivGqT+/GokPlFWLNKAJSpIRBC89IyzKa+b78v8WJjSkjVSCinXFq41KNzyG
D8IhE2IVZLl6MKUIlwCSwuL5kcQ4r0yYy5nbO9E1AoGAVATm7YeZxFd3WIZwMtk1
RkqMMGM6jZHk5aaHMR0YIsja4jSQjWu86y7y653nnxsVr08PnMESTREmEiyWNUC6
94VxRSqc0HSUXHsE5w1ig2rRJg4fhrgDiQeabXUHuldYIc4Dyfps92QXguVLBhde
o1gonQ1xfEz2HcfnDFwytGM=
-----END PRIVATE KEY-----
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuEXstQY5WqNnvegE/vuc
1FWGQvoN1CC9vTXfxRuuPmGbm5WfM/QRCBtiDJt/Qel8IKckyIVb1v5UdakhHAWl
T43CVcd4K8dxgm6mftNAZSTP48EVBTEcmzWfaQplnFltiUwYRz4xrXxQXZGUt5K9
IbXHLFmyCyVCZX8EsrdvqzcBjSwSXzKCQY5lo8rSucLYDk8UXmlBCx0an4Z+hLpC
G5skLQEw1xdF6K/zFqrhq2CtvaDU/0ecNqDCleCELQz5/xLNMZBZfp64fRX4IzoT
hHLa3/Srz3cECCnX4a0P0x94lc1MYozVDiHpiqMZo3+Cgs+W6CRzz4ErKgOPX0Oc
RQIDAQAB
-----END PUBLIC KEY-----