Move the webapp into its own folder

This commit is contained in:
iamBadgers
2025-06-02 17:56:58 -07:00
parent cf3abe92f5
commit f6fcf82eae
51 changed files with 2 additions and 2 deletions

51
app/Dockerfile Normal file
View File

@@ -0,0 +1,51 @@
# FROM ubuntu:24.04
# ADD ./loader/loader-crm /etc/cron.d/loader-crm
# RUN chmod 0644 /etc/cron/loader-crm
# RUN touch /var/log/chron.log
# RUN apt-get update
# RUN apt-get -y install cron
# RUN systemctl enable cron
# Setup the loader
FROM python:3.12 AS loader
COPY ./loader /srv/cprush-stats
WORKDIR /srv/cprush-stats
RUN python -m pip install --upgrade pip
RUN pip install -r requirements.txt
CMD ["python3", "createrushdatabase.py"]
FROM node:23
RUN corepack enable
# build the frontend
COPY ./frontend /frontend
WORKDIR /frontend
RUN npm install
RUN npm run build
RUN mkdir -p /srv/cprush-stats/frontend
RUN cp -rf ./dist/* /srv/cprush-stats/frontend
# build the backend
COPY ./backend /backend
WORKDIR /backend
RUN npm install
RUN npm run build
RUN mkdir -p /srv/cprush-stats
RUN cp -rf ./dist/* /srv/cprush-stats
RUN cp package.json /srv/cprush-stats/package.json
WORKDIR loader
EXPOSE 3001
ENV NODE_ENV=production
ENV MEMCACHE_ADDR="localhost:11211"
COPY --from=loader /srv/cprush-stats/testdb.db /srv/cprush-stats/testdb.db
WORKDIR /srv/cprush-stats
RUN npm install
CMD ["sh", "-c", "node app.js -m $MEMCACHE_ADDR"]

33
app/backend/.eslintrc.js Normal file
View File

@@ -0,0 +1,33 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"overrides": [
{
"env": {
"node": true
},
"files": [
".eslintrc.{js,cjs}"
],
"parserOptions": {
"sourceType": "script"
}
}
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
}
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

6766
app/backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
app/backend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"dev": "npx ts-node-dev src/app.ts",
"build": "webpack && cp package.json ./dist/package.json"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/express": "^4.17.21",
"@types/sequelize": "^4.28.20",
"@types/sqlite3": "^3.1.11",
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"crossenv": "^0.0.2-security",
"eslint": "^8.57.0",
"node-polyfill-webpack-plugin": "^4.0.0",
"prettier": "^3.2.5",
"ts-loader": "^9.5.1",
"typescript": "^5.4.5",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@types/body-parser": "^1.19.5",
"asty-astq": "^1.14.0",
"body-parser": "^1.20.2",
"express": "^4.18.3",
"memcached": "^2.2.2",
"net": "^1.0.2",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.1",
"sqlite3": "^5.1.7",
"tokenizr": "^1.7.0",
"webpack-node-externals": "^3.0.0"
}
}

40
app/backend/src/app.ts Normal file
View File

@@ -0,0 +1,40 @@
import express from 'express'
import { json } from 'body-parser'
import { Sequelize, Op, QueryTypes } from 'sequelize'
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser } from './tokenizer'
import { addGameApis } from './gameservice'
import { addCharacterApis } from './characterservice'
import { addRushStatsApis } from './rushstatsservice'
import { addDmApis } from './dmservice'
import { Memcache } from './memcache'
var Memcached = require('memcached')
const app = express()
const jsonParser = json()
const port = process.argv[process.argv.indexOf('-p')]
? process.argv[process.argv.indexOf('-p') + 1]
: 3001
const memcache_addr = process.argv[process.argv.indexOf('-m')]
? process.argv[process.argv.indexOf('-m') + 1]
: 'localhost:11211'
// const memcache = new memcached('localhost:11211', {})
const memcachep = new Memcache(memcache_addr)
addGameApis(app, jsonParser, memcachep)
addCharacterApis(app, jsonParser, memcachep)
addRushStatsApis(app, jsonParser, memcachep)
addDmApis(app, jsonParser, memcachep)
app.use(express.static(__dirname + '/frontend'))
app.use('/', (req, res) => {
res.sendFile(__dirname + '/frontend/index.html')
})
app.listen(port, async () => {
await database.authenticate()
console.log(`Using memcache at ${memcache_addr}`)
console.log(`Express is listening at http://localhost:${port}`)
return
})

View File

@@ -0,0 +1,97 @@
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
export function addCharacterApis(app, jsonParser, memcace) {
app.get('/api/character/:characterId', async (req, res) => {
try {
const character = await Character.findOne({
attributes: ['id', 'characterName', 'playerName', 'role', 'creationDate', 'status'],
where: { id: req.params['characterId'] }
})
if (character) {
res.send(character)
} else {
res.status(404).send('Cannot find character.')
}
} catch (e) {
res.status(500).send(e)
}
})
app.post('/api/character', jsonParser, async (req, res) => {
try {
const fp = new FilterParser()
const obp = new OrderByParser()
const page = req.body.page
const orderBy = req.body.orderBy ? obp.parse(req.body.orderBy) : ['id']
const count = req.body.count || 10
const filter = req.body.filter ? fp.parse(req.body.filter) : {}
const characterData = await Character.findAll({
offset: page * count,
limit: count,
order: orderBy,
where: filter
})
const totalCount = await Character.count({
where: filter
})
const pageCount = Math.ceil(totalCount / count)
res.setHeader('Content-Type', 'application/json')
res.send({ characterData, pageCount, totalCount })
} catch (e) {
if (e instanceof ParsingError) {
res.status(400).send('Could not parse filter.')
} else {
res.status(500).send(e)
}
}
})
app.post('/api/character/:characterId/apps', async (req, res) => {
try {
const apps = await App.findAll({ where: { characterId: req.params['characterId'] } })
if (apps) {
res.send(apps)
} else {
res.status(400).send('Could not find apps.')
}
} catch (e) {
res.status(500).send(e)
}
})
app.post('/api/character/:characterId/picks', async (req, res) => {
try {
const picks = await Pick.findAll({ where: { characterId: req.params['characterId'] } })
if (picks) {
res.send(picks)
} else {
res.status(400).send('Could not find picks.')
}
} catch (e) {
res.status(500).send(e)
}
})
app.post('/api/character/:characterId/gameHistory', async (req, res) => {
try {
const gameHistory = await Character.findOne({
include: [
{ model: Game, as: 'characterPickedForGame' },
{ model: Game, as: 'characterAppliedForGame' }
],
where: { id: req.params['characterId'] }
})
if (gameHistory) {
res.send(gameHistory)
} else {
res.status(404).send('Could nor find character.')
}
} catch (e) {
res.status(500).send(e)
}
})
}

102
app/backend/src/db.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Sequelize, Model, DataTypes } from 'sequelize'
export type RoleName =
| 'Fixer'
| 'Tech'
| 'Medtech'
| 'Media'
| 'Netrunner'
| 'Solo'
| 'Nomad'
| 'Exec'
| 'Lawman'
| 'Rocker'
export const roleNames: RoleName[] = [
'Fixer',
'Tech',
'Medtech',
'Media',
'Netrunner',
'Solo',
'Nomad',
'Exec',
'Lawman',
'Rocker'
]
const databasePath = './testdb.db'
export const database = new Sequelize({
dialect: 'sqlite',
storage: databasePath
})
export const Character = database.define(
'Characters',
{
id: { type: DataTypes.INTEGER, primaryKey: true },
characterName: { type: DataTypes.TEXT },
playerName: { type: DataTypes.TEXT },
role: { type: DataTypes.TEXT },
creationDate: { type: DataTypes.INTEGER },
status: { type: DataTypes.TEXT }
},
{ timestamps: false }
)
export const Game = database.define(
'Games',
{
id: { type: DataTypes.INTEGER, primaryKey: true },
title: { type: DataTypes.TEXT },
status: { type: DataTypes.TEXT },
fix: { type: DataTypes.BOOLEAN },
postdate: { type: DataTypes.INTEGER },
gamemaster: { type: DataTypes.TEXT },
payoutEB: { type: DataTypes.INTEGER },
payoutIP: { type: DataTypes.INTEGER },
payoutLoot: { type: DataTypes.INTEGER }
},
{ timestamps: false }
)
export const Pick = database.define(
'Picks',
{
gameId: { type: DataTypes.INTEGER, primaryKey: true },
gameTitle: { type: DataTypes.TEXT },
characterId: { type: DataTypes.INTEGER, primaryKey: true },
characterName: { type: DataTypes.TEXT }
},
{ timestamps: false }
)
export const App = database.define(
'Apps',
{
gameId: { type: DataTypes.INTEGER, primaryKey: true },
gameTitle: { type: DataTypes.TEXT },
characterId: { type: DataTypes.INTEGER, primaryKey: true },
characterName: { type: DataTypes.TEXT }
},
{ timestamps: false }
)
// Bind characters to applications and picks
Character.hasMany(App, { foreignKey: 'characterId', as: 'appliedCharacter' })
App.belongsTo(Character, { foreignKey: 'characterId', as: 'appliedCharacter' })
Character.hasMany(Pick, { foreignKey: 'characterId', as: 'pickedCharacter' })
Pick.belongsTo(Character, { foreignKey: 'characterId', as: 'pickedCharacter' })
// Bind games to applications and picks
Game.hasMany(App, { foreignKey: 'gameId', as: 'gameApplications' })
App.belongsTo(Game, { foreignKey: 'gameId', as: 'gameApplications' })
Game.hasMany(Pick, { foreignKey: 'gameId', as: 'gamePicks' })
Pick.belongsTo(Game, { foreignKey: 'gameId', as: 'gamePicks' })
// Bind picked characters to games.
Game.belongsToMany(Character, { through: 'Apps', as: 'characterAppliedForGame' })
Character.belongsToMany(Game, { through: 'Apps', as: 'characterAppliedForGame' })
Game.belongsToMany(Character, { through: 'Picks', as: 'characterPickedForGame' })
Character.belongsToMany(Game, { through: 'Picks', as: 'characterPickedForGame' })

View File

@@ -0,0 +1,58 @@
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
import { fn, col } from 'sequelize'
export function addDmApis(app, jsonParser, memcache) {
app.get('/api/dm/:dmName', async (req, res) => {
const dmName = req.params.dmName
let games = await Game.findAll({
where: { gamemaster: dmName }
})
res.send(games)
})
app.post('/api/dm', jsonParser, async (req, res) => {
try {
const fp = new FilterParser()
const obp = new OrderByParser()
const page = req.body.page || 0
const orderBy = req.body.orderBy ? obp.parse(req.body.orderBy) : ['id']
const count = req.body.count || 10
const filter = req.body.filter ? fp.parse(req.body.filter) : {}
const dms = await Game.findAll({
offset: page * count,
limit: count,
attributes: ['gamemaster', [fn('count', col('id')), 'gamecount']],
group: ['gamemaster'],
order: orderBy,
where: filter
})
const totalCount = (
await Game.count({
group: ['gamemaster'],
where: filter
})
).length
const pageCount = Math.ceil(totalCount / count)
res.setHeader('Content-Type', 'application/json')
res.send({
dms,
pageCount,
totalCount
})
} catch (e) {
if (e instanceof ParsingError) {
res.status(400).send('Could not parse filter.')
} else {
res.status(500).send(e)
}
}
})
}

View File

@@ -0,0 +1,82 @@
import { database, Character, Game, Pick, App } from './db'
import { OrderByParser, FilterParser, ParsingError } from './tokenizer'
export function addGameApis(app, jsonParser, memcache) {
app.get('/api/game/:gameId', async (req, res) => {
try {
const game = await Game.findOne({ where: { id: req.params['gameId'] } })
if (!game) {
res.status(404).send('Game Not Found')
} else {
res.send(game)
}
} catch (e) {
console.log(e)
res.status(500).send(e)
}
})
app.post('/api/game', jsonParser, async (req, res) => {
try {
const fp = new FilterParser()
const obp = new OrderByParser()
const page = req.body.page || 0
const orderBy = req.body.orderBy ? obp.parse(req.body.orderBy) : ['id']
const count = req.body.count || 10
const filter = req.body.filter ? fp.parse(req.body.filter) : {}
const gameData = await Game.findAll({
offset: page * count,
limit: count,
order: orderBy,
where: filter
})
const totalCount = await Game.count({
where: filter
})
const pageCount = Math.ceil(totalCount / count)
res.setHeader('Content-Type', 'application/json')
res.send({ gameData, pageCount, totalCount })
} catch (e) {
if (e instanceof ParsingError) {
res.status(400).send('Could not parse filter.')
} else {
res.status(500).send(e)
}
}
})
app.post('/api/game/:gameId/apps', async (req, res) => {
try {
const apps = await App.findAll({
include: { model: Character, as: 'appliedCharacter' },
where: { gameId: req.params['gameId'] }
})
if (!apps) {
res.status(404).send('Apps not found.')
} else {
res.send(apps)
}
} catch (e) {
res.status(500).send(e)
}
})
app.post('/api/game/:gameId/picks', async (req, res) => {
try {
const picks = await Pick.findAll({
include: { model: Character, as: 'pickedCharacter' },
where: { gameId: req.params['gameId'] }
})
if (!picks) {
res.status(404).send('Picks not found')
} else {
res.send(picks)
}
} catch (e) {
res.status(500).send(e)
}
})
}

View File

@@ -0,0 +1,29 @@
import { promisify } from 'util'
var Memcached = require('memcached')
export class Memcache {
readonly memcached
readonly getCallback
readonly setCallback
readonly replaceCallback
constructor(url: string) {
this.memcached = new Memcached(url, {
timeout: 30
})
this.getCallback = promisify(this.memcached.get).bind(this.memcached)
this.setCallback = promisify(this.memcached.set).bind(this.memcached)
this.replaceCallback = promisify(this.memcached.replace).bind(this.memcached)
}
async get(key: string) {
return this.getCallback(key)
}
async set(key: string, value) {
return this.setCallback(key, value, 200)
}
}

View File

@@ -0,0 +1,134 @@
import { database, Character, Game, Pick, App, roleNames } from './db'
import { Op, QueryTypes } from 'sequelize'
const serverGameStatsQuery =
'SELECT ' +
'COUNT(*) as TotalGames, ' +
'SUM(CASE WHEN status = "Complete" THEN 1 ELSE 0 END) as Complete, ' +
'SUM(CASE WHEN status = "Postponed" THEN 1 ELSE 0 END) as Postponed, ' +
'SUM(CASE WHEN status = "Pending" THEN 1 ELSE 0 END) as Pending, ' +
'SUM(CASE WHEN event = "TRUE" THEN 1 ELSE 0 END) as Events, ' +
'SUM(CASE WHEN fix = "TRUE" THEN 1 ELSE 0 END) as Fixes, ' +
'SUM(payoutEB) as TotalEB, ' +
'SUM(payoutIP) as TotalIP, ' +
'SUM(payoutEB) / SUM(CASE WHEN status = "Complete" THEN 1 ELSE 0 END) as AverageEB, ' +
'SUM(payoutIP) / SUM(CASE WHEN status = "Complete" THEN 1 ELSE 0 END) as AverageIP ' +
'FROM Games WHERE postdate BETWEEN :startSeconds AND :endSeconds'
const startingYear = 2023
function getMonthNumber(monthId: number): number {
return monthId % 12
}
function getYearNumber(monthId: number): number {
return Math.floor(monthId / 12 + startingYear)
}
function monthIdToStartSeconds(monthId: number): number {
if (monthId != -1) {
const yearNumber = getYearNumber(monthId)
const monthNumber = getMonthNumber(monthId)
return new Date(yearNumber, monthNumber, 1).getTime() / 1000
}
return 0
}
function monthIdToEndSeconds(monthId: number): number {
if (monthId != -1) {
const yearNumber = getYearNumber(monthId)
const monthNumber = getMonthNumber(monthId)
return new Date(yearNumber, monthNumber + 1, -1).getTime() / 1000
}
return Number.MAX_SAFE_INTEGER
}
export function addRushStatsApis(app, jsonParser, memcache) {
app.post('/api/serverstats/gamestats', jsonParser, async (req, res) => {
const monthId = req.body.monthId
let startSeconds = monthIdToStartSeconds(monthId)
let endSeconds = monthIdToEndSeconds(monthId)
let cachedResponse = await memcache.get('gamestats' + req.body.monthId)
if (cachedResponse) {
console.info('cache hit - ' + 'gamestats' + req.body.monthId)
res.send(cachedResponse)
return
}
console.log('cache miss')
const serverGameStats = await database.query(serverGameStatsQuery, {
type: QueryTypes.SELECT,
replacements: {
startSeconds,
endSeconds
}
})
await memcache.set('gamestats' + req.body.monthId, serverGameStats[0])
res.send(serverGameStats[0])
})
app.post('/api/serverstats/rolestats', jsonParser, async (req, res) => {
const monthId = req.body.monthId
let startSeconds = monthIdToStartSeconds(monthId)
let endSeconds = monthIdToEndSeconds(monthId)
let cachedResponse = await memcache.get('rolestats' + req.body.monthId)
if (cachedResponse) {
console.info('cache hit - ' + 'rolestats' + req.body.monthId)
res.send(cachedResponse)
return
}
const games = await Game.findAll({
include: [
{ model: Character, as: 'characterPickedForGame' },
{ model: Character, as: 'characterAppliedForGame' }
],
where: { postdate: { [Op.between]: [startSeconds, endSeconds] } }
})
// count active roles
let activeCharacters = new Map()
let pickedCharacterCount = new Map()
let appedCharacterCount = new Map()
games.forEach((game, gameNum) => {
const picks = game.dataValues.characterPickedForGame
const appls = game.dataValues.characterAppliedForGame
picks.forEach((character, characterNum) => {
const role = character.dataValues.role
// Count role application
pickedCharacterCount.set(role, (pickedCharacterCount.get(role) || 0) + 1)
})
appls.forEach((character, characterNum) => {
const role = character.dataValues.role
const charId = character.dataValues.id
// Add apllied characters to active list
if (!activeCharacters.has(role)) {
activeCharacters.set(role, new Set())
}
activeCharacters.get(role).add(charId)
// Count role application
appedCharacterCount.set(role, (appedCharacterCount.get(role) || 0) + 1)
})
})
const result = {}
roleNames.forEach((roleName) => {
result[roleName] = {
apps: appedCharacterCount.get(roleName) || 0,
picks: pickedCharacterCount.get(roleName) || 0,
active: activeCharacters.has(roleName) ? activeCharacters.get(roleName).size : 0
}
})
await memcache.set('rolestats' + req.body.monthId, result)
res.send(result)
})
}

View File

@@ -0,0 +1,177 @@
import Tokenizr from 'tokenizr'
import { Op } from 'sequelize'
export { ParsingError } from 'tokenizr'
export class OrderByParser {
lexer = new Tokenizr()
constructor() {
this.lexer.rule(/,/, (ctx, m) => {
ctx.accept('spacer')
})
this.lexer.rule(/ASC|DESC/, (ctx, m) => {
ctx.accept('direction', m[0])
})
this.lexer.rule(/[a-zA-Z]+/, (ctx, m) => {
ctx.accept('column', m[0])
})
this.lexer.rule(/\s/, (ctx, m) => {
ctx.ignore()
})
}
parse(orderBy: string) {
const output = []
let holding = []
this.lexer
.input(orderBy)
.tokens()
.forEach((token) => {
switch (token.type) {
case 'spacer':
output.push(holding)
holding = []
break
case 'column':
case 'direction':
holding.push(token.value)
break
}
})
if (holding) {
output.push(holding)
}
return output
}
}
const openGroupRegex = /\(/
const closeGroupRegex = /\)/
const conjunctinoRegex = /AND|OR/
const equalityRegex = /([a-zA-Z]+)\s?(=|!=|<|>|<=|>=|:)\s?([\d]+)/
const stringEqualityRegex = /([a-zA-Z]+)\s?(=|!=|<|>|<=|>=|:)\s?\"([a-zA-Z'"\d\s]*)\"/
const spacerRegex = /\s/
const opperatorMap = {
'=': Op.eq,
'!=': Op.ne,
'<=': Op.lte,
'>=': Op.gte,
'>': Op.gt,
'<': Op.lt,
':': Op.like,
AND: Op.and,
OR: Op.or
}
export class FilterParser {
lexer: Tokenizr
constructor() {
this.lexer = new Tokenizr()
this.lexer.rule(openGroupRegex, (ctx, m) => {
ctx.accept('opengroup')
})
this.lexer.rule(closeGroupRegex, (ctx, m) => {
ctx.accept('closegroup')
})
this.lexer.rule(conjunctinoRegex, (ctx, m) => {
ctx.accept('conjunction', m[0])
})
this.lexer.rule(stringEqualityRegex, (ctx, m) => {
ctx.accept('column', m[1])
ctx.accept('opperator', m[2])
ctx.accept('value', m[3])
})
this.lexer.rule(equalityRegex, (ctx, m) => {
ctx.accept('column', m[1])
ctx.accept('opperator', m[2])
ctx.accept('value', m[3])
})
this.lexer.rule(spacerRegex, (ctx, m) => {
ctx.ignore()
})
}
parse(filter: string) {
this.lexer.input(filter)
let block = this.parseBlock('AND')
this.lexer.consume('EOF')
this.lexer.reset()
return block
}
private parseBlock(conjunciton: string) {
let items = []
let activeCon = conjunciton
for (;;) {
let nextItem = this.lexer.alternatives(
() => this.parseEquality(),
() => this.parseGroup(),
() => {
let conToken = this.lexer.consume('conjunction')
if (items.length === 1) {
activeCon = conToken.value
}
if (conToken.value === activeCon) {
return this.parseEquality()
} else {
return this.parseBlock(conToken.value)
}
},
() => this.parseEmpty()
)
if (nextItem === undefined) {
break
}
items.push(nextItem)
}
return { [opperatorMap[activeCon]]: items }
}
private parseEquality() {
let columnToken = this.lexer.consume('column')
let opperatorToken = this.lexer.consume('opperator')
let valueToken = this.lexer.consume('value')
if (opperatorToken.value === ':') {
return {
[columnToken.value]: {
[opperatorMap[opperatorToken.value]]: `%${valueToken.value.toString()}%`
}
}
} else {
return {
[columnToken.value]: { [opperatorMap[opperatorToken.value]]: valueToken.value.toString() }
}
}
}
private parseGroup() {
this.lexer.consume('opengroup')
let block = this.parseBlock('AND')
this.lexer.consume('closegroup')
return block
}
private parseEmpty() {
return undefined
}
}

11
app/backend/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist"
},
"lib": ["es2015"]
}

View File

@@ -0,0 +1,30 @@
const path = require('path');
const NodePolyfillPlugin = require("node-polyfill-webpack-plugin")
const nodeExternals = require('webpack-node-externals');
module.exports = {
mode: 'production',
entry: './src/app.ts',
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
}, node: {
__dirname: false
},
resolve: {
extensions: ['.tsx', '.ts', '.js'],
},
output: {
filename: 'app.js',
path: path.resolve(__dirname, './dist'),
},
plugins: [
new NodePolyfillPlugin(),
],
externals: [nodeExternals()],
};

View File

@@ -0,0 +1,4 @@
> 1%
last 2 versions
not dead
not ie 11

View File

@@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

1
app/frontend/.env.dev Normal file
View File

@@ -0,0 +1 @@
VITE_API_TARGET=http://localhost:3001

0
app/frontend/.env.prod Normal file
View File

View File

@@ -0,0 +1,72 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"computed": true,
"createApp": true,
"customRef": true,
"defineAsyncComponent": true,
"defineComponent": true,
"effectScope": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"inject": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onUnmounted": true,
"onUpdated": true,
"provide": true,
"reactive": true,
"readonly": true,
"ref": true,
"resolveComponent": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"toRaw": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"unref": true,
"useAttrs": true,
"useCssModule": true,
"useCssVars": true,
"useLink": true,
"useRoute": true,
"useRouter": true,
"useSlots": true,
"watch": true,
"watchEffect": true,
"watchPostEffect": true,
"watchSyncEffect": true
}
}

20
app/frontend/.eslintrc.js Normal file
View File

@@ -0,0 +1,20 @@
/**
* .eslint.js
*
* ESLint configuration file.
*/
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript',
],
rules: {
'vue/multi-word-component-names': 'off',
},
}

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 80,
"trailingComma": "none"
}

81
app/frontend/README.md Normal file
View File

@@ -0,0 +1,81 @@
# Vuetify (Default)
This is the official scaffolding tool for Vuetify, designed to give you a head start in building your new Vuetify application. It sets up a base template with all the necessary configurations and standard directory structure, enabling you to begin development without the hassle of setting up the project from scratch.
## ❗️ Important Links
- 📄 [Docs](https://vuetifyjs.com/)
- 🚨 [Issues](https://issues.vuetifyjs.com/)
- 🏬 [Store](https://store.vuetifyjs.com/)
- 🎮 [Playground](https://play.vuetifyjs.com/)
- 💬 [Discord](https://community.vuetifyjs.com)
## 💿 Install
Set up your project using your preferred package manager. Use the corresponding command to install the dependencies:
| Package Manager | Command |
|---------------------------------------------------------------|----------------|
| [yarn](https://yarnpkg.com/getting-started) | `yarn install` |
| [npm](https://docs.npmjs.com/cli/v7/commands/npm-install) | `npm install` |
| [pnpm](https://pnpm.io/installation) | `pnpm install` |
| [bun](https://bun.sh/#getting-started) | `bun install` |
After completing the installation, your environment is ready for Vuetify development.
## ✨ Features
- 🖼️ **Optimized Front-End Stack**: Leverage the latest Vue 3 and Vuetify 3 for a modern, reactive UI development experience. [Vue 3](https://v3.vuejs.org/) | [Vuetify 3](https://vuetifyjs.com/en/)
- 🗃️ **State Management**: Integrated with [Pinia](https://pinia.vuejs.org/), the intuitive, modular state management solution for Vue.
- 🚦 **Routing and Layouts**: Utilizes Vue Router for SPA navigation and vite-plugin-vue-layouts for organizing Vue file layouts. [Vue Router](https://router.vuejs.org/) | [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts)
- 💻 **Enhanced Development Experience**: Benefit from TypeScript's static type checking and the ESLint plugin suite for Vue, ensuring code quality and consistency. [TypeScript](https://www.typescriptlang.org/) | [ESLint Plugin Vue](https://eslint.vuejs.org/)
-**Next-Gen Tooling**: Powered by Vite, experience fast cold starts and instant HMR (Hot Module Replacement). [Vite](https://vitejs.dev/)
- 🧩 **Automated Component Importing**: Streamline your workflow with unplugin-vue-components, automatically importing components as you use them. [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components)
- 🛠️ **Strongly-Typed Vue**: Use vue-tsc for type-checking your Vue components, and enjoy a robust development experience. [vue-tsc](https://github.com/johnsoncodehk/volar/tree/master/packages/vue-tsc)
These features are curated to provide a seamless development experience from setup to deployment, ensuring that your Vuetify application is both powerful and maintainable.
## 💡 Usage
This section covers how to start the development server and build your project for production.
### Starting the Development Server
To start the development server with hot-reload, run the following command. The server will be accessible at [http://localhost:3000](http://localhost:3000):
```bash
yarn dev
```
(Repeat for npm, pnpm, and bun with respective commands.)
> NODE_OPTIONS='--no-warnings' is added to suppress the JSON import warnings that happen as part of the Vuetify import mapping. If you are on Node [v21.3.0](https://nodejs.org/en/blog/release/v21.3.0) or higher, you can change this to NODE_OPTIONS='--disable-warning=5401'. If you don't mind the warning, you can remove this from your package.json dev script.
### Building for Production
To build your project for production, use:
```bash
yarn build
```
(Repeat for npm, pnpm, and bun with respective commands.)
Once the build process is completed, your application will be ready for deployment in a production environment.
## 💪 Support Vuetify Development
This project is built with [Vuetify](https://vuetifyjs.com/en/), a UI Library with a comprehensive collection of Vue components. Vuetify is an MIT licensed Open Source project that has been made possible due to the generous contributions by our [sponsors and backers](https://vuetifyjs.com/introduction/sponsors-and-backers/). If you are interested in supporting this project, please consider:
- [Requesting Enterprise Support](https://support.vuetifyjs.com/)
- [Sponsoring John on Github](https://github.com/users/johnleider/sponsorship)
- [Sponsoring Kael on Github](https://github.com/users/kaelwd/sponsorship)
- [Supporting the team on Open Collective](https://opencollective.com/vuetify)
- [Becoming a sponsor on Patreon](https://www.patreon.com/vuetify)
- [Becoming a subscriber on Tidelift](https://tidelift.com/subscription/npm/vuetify)
- [Making a one-time donation with Paypal](https://paypal.me/vuetify)
## 📑 License
[MIT](http://opensource.org/licenses/MIT)
Copyright (c) 2016-present Vuetify, LLC

197
app/frontend/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,197 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useLink: typeof import('vue-router')['useLink']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
import('vue')
}
// for vue template auto import
import { UnwrapRef } from 'vue'
declare module 'vue' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}
declare module '@vue/runtime-core' {
interface GlobalComponents {}
interface ComponentCustomProperties {
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly computed: UnwrapRef<typeof import('vue')['computed']>
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
readonly h: UnwrapRef<typeof import('vue')['h']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
readonly ref: UnwrapRef<typeof import('vue')['ref']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
readonly unref: UnwrapRef<typeof import('vue')['unref']>
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
readonly useLink: UnwrapRef<typeof import('vue-router')['useLink']>
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
readonly watch: UnwrapRef<typeof import('vue')['watch']>
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
}
}

13
app/frontend/components.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

16
app/frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rush Stats</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

6131
app/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
app/frontend/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "rushstats",
"version": "0.0.0",
"scripts": {
"dev": "cross-env NODE_OPTIONS='--no-warnings' vite --mode dev",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@mdi/font": "7.0.96",
"axios": "^1.6.8",
"chart.js": "^4.4.3",
"core-js": "^3.34.0",
"roboto-fontface": "*",
"sqlite3": "^5.1.7",
"vue": "^3.3.0",
"vuetify": "^3.0.0"
},
"devDependencies": {
"@babel/types": "^7.23.0",
"@types/node": "^20.10.0",
"@vitejs/plugin-vue": "^4.5.0",
"@vue/eslint-config-typescript": "^12.0.0",
"cross-env": "^7.0.3",
"eslint": "^8.56.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-n": "^16.4.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-vue": "^9.19.0",
"pinia": "^2.1.0",
"prettier": "^3.0.3",
"sass": "^1.69.0",
"typescript": "^5.3.0",
"typescript-eslint": "^7.0.2",
"unplugin-auto-import": "^0.17.3",
"unplugin-fonts": "^1.1.0",
"unplugin-vue-components": "^0.26.0",
"unplugin-vue-router": "^0.7.0",
"vite": "^5.0.0",
"vite-plugin-vue-layouts": "^0.10.0",
"vite-plugin-vuetify": "^2.0.0",
"vue-router": "^4.2.0",
"vue-tsc": "^1.8.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

15
app/frontend/src/App.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<v-app>
<v-main>
<v-app-bar>
<v-btn to="/serverstats">Server Stats</v-btn>
<v-btn to="/games">Games</v-btn>
<v-btn to="/characters">Characters</v-btn>
<!-- <v-btn to="/gamemasters">Gamemasters</v-btn> -->
</v-app-bar>
<router-view />
</v-main>
</v-app>
</template>
<script lang="ts" setup></script>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,6 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
</svg>

After

Width:  |  Height:  |  Size: 526 B

20
app/frontend/src/main.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* main.ts
*
* Bootstraps Vuetify and other plugins then mounts the App`
*/
// Plugins
import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
// Composables
import { createApp } from 'vue'
const app = createApp(App)
registerPlugins(app)
app.mount('#app')

View File

@@ -0,0 +1,16 @@
/**
* plugins/index.ts
*
* Automatically included in `./src/main.ts`
*/
// Plugins
import vuetify from './vuetify'
import router from '../router'
// Types
import type { App } from 'vue'
export function registerPlugins(app: App) {
app.use(vuetify).use(router)
}

View File

@@ -0,0 +1,17 @@
/**
* plugins/vuetify.ts
*
* Framework documentation: https://vuetifyjs.com`
*/
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
// Composables
import { createVuetify } from 'vuetify'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
theme: { defaultTheme: 'dark' }
})

View File

@@ -0,0 +1,60 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import GameList from '../vues/GameList.vue'
import GameDetails from '../vues/GameDetails.vue'
import CharacterList from '../vues/CharacterList.vue'
import CharacterDetails from '../vues/CharacterDetails.vue'
import ServerStats from '../vues/ServerStats.vue'
import DmList from '../vues/DmList.vue'
const root = {
path: '/',
redirect: '/serverstats'
}
const gameListRoute = {
path: '/games',
component: GameList
}
const gameDetailsRoute = {
name: 'game',
path: '/games/:gameId',
component: GameDetails
}
const characterListRoute = {
path: '/characters',
component: CharacterList
}
const characterDetailsRoute = {
name: 'character',
path: '/characters/:characterId',
component: CharacterDetails
}
const serverStatsRoute = {
path: '/serverstats',
component: ServerStats
}
const gameMasterListRoute = {
path: '/gamemasters',
component: DmList
}
const routes = [
root,
gameListRoute,
gameDetailsRoute,
characterListRoute,
characterDetailsRoute,
serverStatsRoute,
gameMasterListRoute
]
export default createRouter({
history: createWebHashHistory(),
routes
})

View File

@@ -0,0 +1,10 @@
/**
* src/styles/settings.scss
*
* Configures SASS variables and Vuetify overwrites
*/
// https://vuetifyjs.com/features/sass-variables/`
// @use 'vuetify/settings' with (
// $color-pack: false
// );

69
app/frontend/src/types.ts Normal file
View File

@@ -0,0 +1,69 @@
export interface Game {
id: number
title: string
gamemaster: string
payoutEB: number
payoutIP: number
payoutLoot: string
status: string
postdate: number
pickedCharacter?: Character[]
appliedCharacter?: Character[]
}
export interface Character {
id: number
characterName: string
playerName: string
role: string
status: string
lastGame: number
creationDate: number
}
export interface DmStats {
name: string
gameCount: string
lastGame: number
games: Game
}
export interface GameStats {
Complete: number
Postponed: number
Pending: number
Fixes: number
Events: number
AverageIP: number
AverageEB: number
TotalIP: number
TotalEB: number
}
export interface GameCounts {
apps: number
picks: number
active: number
}
export interface GameCharacter {
characterId: number
characterName: string
gameId: number
gameTite: string
pickedCharacter?: Character
appliedCharacter?: Character
}
export interface RoleStats {
Fixer: GameCounts
Tech: GameCounts
Medtech: GameCounts
Media: GameCounts
Netrunner: GameCounts
Solo: GameCounts
Nomad: GameCounts
Exec: GameCounts
Lawman: GameCounts
Rocker: GameCounts
}

7
app/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,127 @@
<template>
<v-container>
<h1>{{ character.characterName }} - {{ character.role }}</h1>
<h2>Status: {{ character.status }}</h2>
<h2>Player: {{ character.playerName }}</h2>
<div>Total Game EB: {{ earnedEB }} --- Total Game IP: {{ earnedIP }}</div>
<div>
Games Played: {{ gamesPlayedCount }} --- Games Applied:
{{ gamesAppliedCount }} -- Pick Rate: {{ pickRate.toFixed(2) }}%
</div>
<div>Last Game: {{ lastPlayedPostDate }}</div>
<div class="d-flex flex-row">
<div class="flex-column game-list">
<h3 class="game-list-title">
{{ (games.played || []).length }} Games Played
</h3>
<GameTable :gameList="games.played"></GameTable>
</div>
<div class="flex-column game-list">
<h3 class="game-list-title">
{{ (games.applied || []).length }} Games Applied
</h3>
<game-table :gameList="games.applied"></game-table>
</div>
</div>
</v-container>
</template>
<style>
.game-list {
margin: 20px;
margin-top: 25px;
}
.game-list-title {
margin-bottom: 10px;
}
</style>
<script setup lang="ts">
import { Character, Game } from '../types'
import GameTable from './GameTable.vue'
import { onMounted, watch, ref } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
interface GameList {
played: Game[]
applied: Game[]
}
const route = useRoute()
const characterId = ref(route.params.characterId)
const character = ref<Character>({
id: 0,
characterName: '',
playerName: '',
role: '',
status: '',
lastGame: 0,
creationDate: 0
})
const games = ref<GameList>({ played: [], applied: [] })
const earnedEB = ref(0)
const earnedIP = ref(0)
const gamesPlayedCount = ref(0)
const gamesAppliedCount = ref(0)
const pickRate = ref(0)
const lastPlayedPostDate = ref()
async function loadCharacterDetails() {
const characterResponse = await axios.get(
`/api/character/${characterId.value}`
)
const gameDetails = await axios.post(
`/api/character/${characterId.value}/gameHistory`
)
character.value = characterResponse.data
games.value = {
played: gameDetails.data.characterPickedForGame,
applied: gameDetails.data.characterAppliedForGame
}
}
function calculateDerivedEarnings(gamesPlayedList: Game[]) {
let runningEarnedEb = 0
let runningEarnedIp = 0
for (let game in gamesPlayedList) {
runningEarnedEb += gamesPlayedList[game]['payoutEB']
runningEarnedIp += gamesPlayedList[game]['payoutIP']
}
earnedEB.value = runningEarnedEb
earnedIP.value = runningEarnedIp
}
function calculateDerivedGameStats(
gamesPlayedList: Game[],
gamesAppliedList: Game[]
) {
gamesPlayedCount.value = gamesPlayedList.length
gamesAppliedCount.value = gamesAppliedList.length
pickRate.value = (gamesPlayedList.length / gamesAppliedList.length) * 100
}
function calculateDerivedLastPlayedPostDate(gamesPlayedList: Game[]) {
if (gamesPlayedList.length === 0) {
lastPlayedPostDate.value = 'N/A'
} else {
lastPlayedPostDate.value = new Date(
gamesPlayedList[gamesPlayedList.length - 1].postdate * 1000
)
.toISOString()
.split('T')[0]
}
}
watch(games, (newValue: GameList, oldValue: GameList) => {
calculateDerivedLastPlayedPostDate(newValue.played)
calculateDerivedEarnings(newValue.played)
calculateDerivedGameStats(newValue.played, newValue.applied)
})
onMounted(async () => {
loadCharacterDetails()
})
</script>

View File

@@ -0,0 +1,171 @@
<template>
<div class="d-flex flex-row">
<v-text-field
class="filter flex-1-0"
placeholder="Filter by Name"
v-model="filterValue"
></v-text-field>
</div>
<v-data-table-server
:headers="headers"
:items="characters"
:items-length="count"
v-model:items-per-page="resultePerPage"
v-model:page="pageValue"
v-model:sort-by="sortValue"
@update:options="loadData"
>
<template v-slot:item.id="{ item }">
<router-link
:to="{ name: 'character', params: { characterId: item.id } }"
>{{ item.id }}</router-link
>
</template>
<template v-slot:item.characterName="{ item }">
<router-link
:to="{ name: 'character', params: { characterId: item.id } }"
>{{ item.characterName }}</router-link
>
</template>
<template v-slot:item.creationDate="{ item }">
{{ new Date(item.creationDate * 1000).toISOString().split('T')[0] }}
</template>
<template #bottom></template>
</v-data-table-server>
<v-pagination
v-model:model-value="pageValue"
:length="pageCount"
></v-pagination>
</template>
<style type="text/css"></style>
<script setup lang="ts">
import { Character } from '../types'
import { onBeforeMount, ref, watch } from 'vue'
import { useRoute, useRouter, LocationQueryValue } from 'vue-router'
import { VDataTable } from 'vuetify/components'
import axios from 'axios'
type ReadonlyHeaders = VDataTable['$props']['headers']
type SortItems = VDataTable['sortBy']
const headers: ReadonlyHeaders = [
{ title: 'ID', align: 'start', sortable: true, key: 'id' },
{ title: 'Character', align: 'start', sortable: true, key: 'characterName' },
{ title: 'Role', align: 'start', sortable: true, key: 'role' },
{ title: 'Player', align: 'start', sortable: true, key: 'playerName' },
{ title: 'Status', align: 'start', sortable: true, key: 'status' },
{
title: 'Creation Date',
align: 'start',
sortable: true,
key: 'creationDate'
}
]
const route = useRoute()
const router = useRouter()
const characters = ref<Character[]>([])
const pageCount = ref(1)
const resultePerPage = ref(10)
const count = ref(10)
const pageValue = ref(1)
const filterValue = ref('')
const sortValue = ref<SortItems>([])
async function loadData({ page, itemsPerPage, sortBy }: any) {
let sortString = 'id asc'
if (sortBy[0]) {
sortString = `${sortBy[0].key} ${sortBy[0].order}`
}
const response = await axios.post('/api/character', {
page: `${page - 1}`,
count: `${itemsPerPage}`,
orderBy: sortString,
filter: filterValue.value ? `characterName:"${filterValue.value}"` : ''
})
characters.value = response.data.characterData
pageCount.value = response.data.pageCount
count.value = response.data.totalCount
router.replace({
query: {
page: pageValue.value,
sort: sortString === 'id asc' ? '' : sortString,
filter: filterValue.value
}
})
}
async function reloadData() {
loadData({
page: pageValue.value,
itemsPerPage: 10,
sortBy: sortValue.value
})
}
function buildSortFromQuery(query: string | LocationQueryValue[]): SortItems {
if (typeof route.query.sort === 'string') {
const key = route.query.sort.split(' ')[0]
const order = route.query.sort.split(' ')[1] === 'asc' ? 'asc' : 'desc'
return [{ key, order }]
}
return []
}
watch(route, (newRoute, oldRoute) => {
if (newRoute.query.page) {
pageValue.value = Number(newRoute.query.page)
}
if (newRoute.query.sort) {
sortValue.value = buildSortFromQuery(newRoute.query.sort)
}
if (newRoute.query.filter) {
filterValue.value = newRoute.query.filter.toString()
}
})
let debounce: ReturnType<typeof setTimeout>
watch(filterValue, (newFilter: string, oldFilter: string) => {
clearTimeout(debounce)
debounce = setTimeout(() => {
router.replace({ query: { page: route.query.page, filter: newFilter } })
}, 500)
loadData({
page: pageValue.value,
itemsPerPage: resultePerPage.value,
sortBy: []
})
})
onBeforeMount(async () => {
// Initialize Filter
if (!route.query.page) {
pageValue.value = 1
} else {
pageValue.value = Number(route.query.page)
count.value = Number(route.query.page) * 10
}
// Initialize Sort
if (!route.query.sort) {
sortValue.value = []
} else {
sortValue.value = buildSortFromQuery(route.query.sort)
}
// Initialize Filter
if (route.query.filter) {
filterValue.value = route.query.filter.toString()
}
})
</script>

View File

@@ -0,0 +1,17 @@
<template>Hello World</template>
<style></style>
<script setup lang="ts">
import { DmStats } from '../types'
import { onMounted, ref, watch } from 'vue'
import { VDataTable } from 'vuetify/components'
type ReadonlyHeaders = VDataTable['$props']['headers']
const headers: ReadonlyHeaders = [
{ title: 'Game Master', align: 'start', sortable: true, key: 'id' },
{ title: 'Game Count', align: 'start', sortable: true, key: 'characterName' },
{ title: 'Last Game', align: 'start', sortable: true, key: 'role' }
]
</script>

View File

@@ -0,0 +1,158 @@
<template>
<v-container>
<h1>#{{ game.id }} - {{ game.title }}</h1>
<h2>GameMaster: {{ game.gamemaster }}</h2>
<div class="container">
<div class="d-flex flex-row">
<div class="result-box">
<h3>Picks</h3>
<div v-for="pick in picks">
{{ pick.pickedCharacter!.playerName }} as {{ pick.characterName }}
</div>
</div>
<div class="result-box">
<h3>Payout</h3>
<div>{{ game.payoutEB }} EB</div>
<div>{{ game.payoutIP }} IP</div>
<div>{{ game.payoutLoot }}</div>
</div>
</div>
</div>
<div class="container">
<div class="d-flex flex-row">
<div class="character-list">
<h3>{{ picks.length }} Character Picks</h3>
<v-data-table-virtual
:headers="pickHeaders"
:items="picks"
height="500px"
fixed-header
>
<template v-slot:item.characterId="{ item }">
<a v-bind:href="`/#/characters/${item.characterId}`">{{
item.characterId
}}</a>
</template>
<template v-slot:item.characterName="{ item }">
<a v-bind:href="`/#/characters/${item.characterId}`">{{
item.characterName
}}</a>
</template>
</v-data-table-virtual>
</div>
<div class="character-list">
<h3>{{ apps.length }} Character Apps</h3>
<v-data-table-virtual
:headers="appHeaders"
:items="apps"
height="500px"
fixed-header
>
<template v-slot:item.characterId="{ item }">
<a v-bind:href="`/#/characters/${item.characterId}`">{{
item.characterId
}}</a>
</template>
<template v-slot:item.characterName="{ item }">
<a v-bind:href="`/#/characters/${item.characterId}`">{{
item.characterName
}}</a>
</template>
</v-data-table-virtual>
</div>
</div>
</div></v-container
>
</template>
<style>
.game-title-box {
margin-top: 25px;
margin-left: 20px;
}
.gm-title-box {
margin-top: 5px;
margin-left: 20px;
}
.result-box {
margin: 20px;
margin-top: 20px;
}
.character-list {
margin: 20px;
h3 {
margin-bottom: 10px;
}
}
</style>
<script setup lang="ts">
import { Game, Character, GameCharacter } from '../types'
import { watch, ref } from 'vue'
import { VDataTable } from 'vuetify/components'
import { useRoute } from 'vue-router'
import axios from 'axios'
type ReadonlyHeaders = VDataTable['$props']['headers']
const route = useRoute()
const pickHeaders: ReadonlyHeaders = [
{ title: 'ID', align: 'start', key: 'characterId' },
{ title: 'Character', align: 'start', key: 'characterName' },
{ title: 'Status', align: 'start', key: 'pickedCharacter.status' },
{ title: 'Player', align: 'start', key: 'pickedCharacter.playerName' }
]
const appHeaders: ReadonlyHeaders = [
{ title: 'ID', align: 'start', key: 'characterId' },
{ title: 'Character', align: 'start', key: 'characterName' },
{ title: 'Status', align: 'start', key: 'appliedCharacter.status' },
{ title: 'Player', align: 'start', key: 'appliedCharacter.playerName' }
]
const gameId = ref(route.params.gameId)
const game = ref<Game>({
id: 0,
title: '',
gamemaster: '',
payoutEB: 0,
payoutIP: 0,
payoutLoot: '',
status: '',
postdate: 0
})
const picks = ref<GameCharacter[]>([])
const apps = ref<GameCharacter[]>([])
loadGameDetails()
watch(
() => route.params.gameId,
(newId, oldId) => {
gameId.value = newId
loadGameDetails()
}
)
async function loadGameDetails() {
const response = await axios.get(`/api/game/${gameId.value}`)
game.value = response.data
loadPicks()
loadApps()
}
async function loadPicks() {
const response = await axios.post(`/api/game/${gameId.value}/picks`)
picks.value = response.data
}
async function loadApps() {
const response = await axios.post(`/api/game/${gameId.value}/apps`)
apps.value = response.data
}
</script>

View File

@@ -0,0 +1,166 @@
<template>
<div class="d-flex flex-row">
<v-text-field
class="filter flex-1-0"
placeholder="Filter by Name"
v-model="filterValue"
></v-text-field>
</div>
<v-data-table-server
:headers="headers"
:items="games"
:items-length="count"
v-model:items-per-page="resultsPerPage"
v-model:page="pageValue"
v-model:sort-by="sortValue"
@update:options="loadData"
>
<template v-slot:item.id="{ item }">
<router-link :to="{ name: 'game', params: { gameId: item.id } }">{{
item.id
}}</router-link>
</template>
<template v-slot:item.title="{ item }">
<router-link :to="{ name: 'game', params: { gameId: item.id } }">{{
item.title
}}</router-link>
</template>
<template v-slot:item.postdate="{ item }">
{{ new Date(item.postdate * 1000).toISOString().split('T')[0] }}
</template>
<template #bottom></template>
</v-data-table-server>
<v-pagination
v-model:model-value="pageValue"
:length="pageCount"
></v-pagination>
</template>
<style>
.filter {
margin-top: 10px;
margin-left: 5px;
margin-right: 5px;
margin-bottom: 0px;
}
</style>
<script setup lang="ts">
import { Game } from '../types'
import GameTable from './GameTable.vue'
import { onBeforeMount, watch, ref } from 'vue'
import { useRoute, useRouter, LocationQueryValue } from 'vue-router'
import axios from 'axios'
import { VDataTable } from 'vuetify/components'
type ReadonlyHeaders = VDataTable['$props']['headers']
type SortItems = VDataTable['sortBy']
const headers: ReadonlyHeaders = [
{ title: 'ID', align: 'start', sortable: true, key: 'id' },
{ title: 'Title', align: 'start', sortable: true, key: 'title' },
{ title: 'Status', align: 'start', sortable: true, key: 'status' },
{ title: 'Post Date', align: 'start', sortable: true, key: 'postdate' }
]
const route = useRoute()
const router = useRouter()
const games = ref<Game[]>([])
const pageCount = ref(1)
const resultsPerPage = ref(10)
const count = ref(0)
const pageValue = ref(1)
const filterValue = ref('')
const sortValue = ref<SortItems>([])
async function loadData({ page, itemsPerPage, sortBy }: any) {
let sortString = 'id asc'
if (sortBy[0]) {
sortString = `${sortBy[0].key} ${sortBy[0].order}`
}
const response = await axios.post('/api/game', {
page: `${page - 1}`,
count: `${itemsPerPage}`,
orderBy: sortString,
filter: filterValue.value ? `title:"${filterValue.value}"` : ''
})
games.value = response.data.gameData
pageCount.value = response.data.pageCount
count.value = response.data.totalCount
router.replace({
query: {
page: pageValue.value,
sort: sortString === 'id asc' ? '' : sortString,
filter: filterValue.value
}
})
}
async function reloadData() {
loadData({
page: pageValue.value,
itemsPerPage: 10,
sortBy: sortValue.value
})
}
function buildSortFromQuery(query: string | LocationQueryValue[]): SortItems {
if (typeof route.query.sort === 'string') {
const key = route.query.sort.split(' ')[0]
const order = route.query.sort.split(' ')[1] === 'asc' ? 'asc' : 'desc'
return [{ key, order }]
}
return []
}
watch(route, (newRoute, oldRoute) => {
if (newRoute.query.page) {
pageValue.value = Number(newRoute.query.page)
}
if (newRoute.query.sort) {
sortValue.value = buildSortFromQuery(newRoute.query.sort)
}
if (newRoute.query.filter) {
filterValue.value = newRoute.query.filter.toString()
}
})
let debounce: ReturnType<typeof setTimeout>
watch(filterValue, (newFilter: string, oldFilter: string) => {
clearTimeout(debounce)
debounce = setTimeout(() => {
reloadData()
}, 500)
})
onBeforeMount(async () => {
// Initialize Filter
if (!route.query.page) {
pageValue.value = 1
} else {
pageValue.value = Number(route.query.page)
count.value = Number(route.query.page) * 10
}
// Initialize Sort
if (!route.query.sort) {
sortValue.value = []
} else {
sortValue.value = buildSortFromQuery(route.query.sort)
}
// Initialize Filter
if (route.query.filter) {
filterValue.value = route.query.filter.toString()
}
})
</script>

View File

@@ -0,0 +1,41 @@
<template>
<v-data-table-virtual
:headers="headers"
:items="gameList"
height="500px"
fixed-header
>
<template v-slot:item.id="{ item }">
<a v-bind:href="`/#/games/${item.id}`">{{ item.id }}</a>
</template>
<template v-slot:item.title="{ item }">
<a v-bind:href="`/#/games/${item.id}`">{{ item.title }}</a>
</template>
<template v-slot:item.postdate="{ item }">
{{ new Date(item.postdate * 1000).toISOString().split('T')[0] }}
</template>
</v-data-table-virtual>
</template>
<style></style>
<script setup lang="ts">
import { Game } from '../types'
import { defineProps } from 'vue'
import { VDataTable } from 'vuetify/components'
type ReadonlyHeaders = VDataTable['$props']['headers']
interface Props {
gameList: Game[]
}
const props = defineProps<Props>()
const headers: ReadonlyHeaders = [
{ title: 'ID', align: 'start', key: 'id' },
{ title: 'Title', align: 'start', key: 'title' },
{ title: 'Status', align: 'start', key: 'status' },
{ title: 'Post Date', align: 'start', key: 'postdate' }
]
</script>

View File

@@ -0,0 +1,294 @@
<template>
<div class="d-flex flex-column">
<v-select
class="date-selector"
v-model="dateSelect"
variant="underlined"
:items="dateItems"
>
</v-select>
<div class="stat-bar d-flex flex-row justify-space-around">
<div class="d-flex flex-column">
<v-label class="title">Completed Games</v-label>
<v-label>{{ gameStats.Complete }}</v-label>
</div>
<div class="d-flex flex-column">
<v-label class="title">Postponed Games</v-label>
<v-label>{{ gameStats.Postponed }}</v-label>
</div>
<div class="d-flex flex-column">
<v-label class="title">Events / Fixes</v-label>
<v-label>{{ gameStats.Events }} / {{ gameStats.Fixes }}</v-label>
</div>
<div class="d-flex flex-column">
<v-label class="title">Total / Average EB</v-label>
<v-label
>{{ Math.floor(gameStats.TotalEB) }} /
{{ Math.floor(gameStats.AverageEB) }}</v-label
>
</div>
<div class="d-flex flex-column">
<v-label class="title">Total / Average IP</v-label>
<v-label
>{{ Math.floor(gameStats.TotalIP) }} /
{{ Math.floor(gameStats.AverageIP) }}</v-label
>
</div>
</div>
<div class="role-table d-flex flex-row">
<v-table class="flex-1-1">
<thead>
<tr>
<th class="text-left">Role</th>
<th class="text-left">Characters</th>
<th class="text-left">App Count</th>
<th class="text-left">Games Played</th>
<th class="text-left">Pick Rate %</th>
<th class="text-left">% Games With Role</th>
<th cladd="text-ledt">Apps Per Game</th>
</tr>
</thead>
<tbody>
<tr v-for="(roleStat, roleName) in roleStats">
<td>{{ roleName }}</td>
<td class="text-left">
{{ roleStat.active }}
</td>
<td class="text-left">{{ roleStat.apps }}</td>
<td class="text-left">{{ roleStat.picks }}</td>
<td class="text-left">
{{ Math.floor((roleStat.picks / roleStat.apps) * 100) }}%
</td>
<td class="text-left">
{{ Math.floor((roleStat.picks / gameStats.Complete) * 100) }}%
</td>
<td class="text-left">
{{ Math.round((roleStat.apps / gameStats.Complete) * 100) / 100 }}
</td>
</tr>
</tbody>
</v-table>
<v-sheet class="chart d-flex flex-column">
<v-select v-model="chartSelect" :items="chartItems"></v-select>
<canvas id="piechart"></canvas>
<div class="flex-1-1"></div>
</v-sheet>
</div>
</div>
</template>
<style>
.date-selector {
margin-left: 15px;
margin-right: 15px;
}
.graph-selector {
margin-left: 15px;
margin-right: 15px;
}
.stat-bar {
padding: 10px;
margin-left: 15px;
margin-right: 15px;
margin-bottom: 20px;
.title {
font-weight: bold;
}
}
.role-table {
margin-left: 15px;
margin-right: 15px;
}
.chart {
margin-left: 20px;
width: 25%;
width: 450px;
}
</style>
<script setup lang="ts">
import { GameStats, RoleStats } from '../types'
import { onMounted, watch, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Chart from 'chart.js/auto'
import axios from 'axios'
type ChartType = 'gametypes' | 'apps' | 'picks' | 'active'
const route = useRoute()
const router = useRouter()
const dateSelect = ref(-1)
const dateItems = buildDateItems()
const chartSelect = ref<ChartType>('active')
const chartItems = [
{ title: 'Game Types', value: 'gametypes' },
{ title: 'Active Characters', value: 'active' },
{ title: 'Role Picks', value: 'picks' },
{ title: 'Role Applications', value: 'apps' }
]
const gameStats = ref<GameStats>({
Complete: 0,
Postponed: 0,
Pending: 0,
Fixes: 0,
Events: 0,
AverageIP: 0,
AverageEB: 0,
TotalIP: 0,
TotalEB: 0
})
const roleStats = ref<RoleStats>({
Fixer: {
apps: 0,
picks: 0,
active: 0
},
Tech: {
apps: 0,
picks: 0,
active: 0
},
Medtech: {
apps: 0,
picks: 0,
active: 0
},
Media: {
apps: 0,
picks: 0,
active: 0
},
Netrunner: {
apps: 0,
picks: 0,
active: 0
},
Solo: {
apps: 0,
picks: 0,
active: 0
},
Nomad: {
apps: 0,
picks: 0,
active: 0
},
Exec: {
apps: 0,
picks: 0,
active: 0
},
Lawman: {
apps: 0,
picks: 0,
active: 0
},
Rocker: {
apps: 0,
picks: 0,
active: 0
}
})
let chart: Chart<'pie', number[], string>
async function loadData() {
const gameStatsResponse = await axios.post('/api/serverstats/gamestats', {
monthId: dateSelect.value
})
gameStats.value = gameStatsResponse.data
const roleStatsResponse = await axios.post('/api/serverstats/rolestats', {
monthId: dateSelect.value
})
roleStats.value = roleStatsResponse.data
}
function buildDateItems() {
const items = [{ title: 'All Time', value: -1 }]
const date = new Date()
while (date.getUTCFullYear() != 2023 || date.getUTCMonth() != 0) {
items.push(buildDateEntry(date))
date.setUTCMonth(date.getUTCMonth() - 1)
}
items.push(buildDateEntry(date))
return items
}
function buildDateEntry(date: Date) {
const monthId = dateToMonthId(date)
return {
title: date.toLocaleString('en-us', { month: 'short', year: 'numeric' }),
value: monthId
}
}
function dateToMonthId(date: Date) {
return (date.getUTCFullYear() - 2023) * 12 + date.getUTCMonth()
}
function updateChart(stats: RoleStats, tag: ChartType) {
if (tag === 'gametypes') {
chart.data = {
labels: ['Standard', 'Postponed', 'Pending', 'Event', 'Fix'],
datasets: [
{
label: 'Game Type',
data: [
gameStats.value.Complete -
gameStats.value.Events -
gameStats.value.Fixes,
gameStats.value.Postponed,
gameStats.value.Pending,
gameStats.value.Events,
gameStats.value.Fixes
]
}
]
}
} else {
chart.data = {
labels: Object.keys(stats),
datasets: [
{
label: tag,
data: Object.values(stats).map((p) => p[tag])
}
]
}
}
chart.update()
}
watch(dateSelect, async (newValue, oldValue) => {
router.replace({ query: { monthId: dateSelect.value } })
loadData()
})
watch(roleStats, async (newValue, oldValue) => {
updateChart(newValue, chartSelect.value)
})
watch(chartSelect, async (newValue: ChartType, oldValue: ChartType) => {
updateChart(roleStats.value, newValue)
})
onMounted(async () => {
if (!route.query.monthId) {
router.replace({ query: { monthId: -1 } })
} else {
dateSelect.value = Number(route.query.monthId)
}
loadData()
chart = new Chart(document.getElementById('piechart')! as HTMLCanvasElement, {
type: 'pie',
data: {
labels: [],
datasets: []
}
})
})
</script>

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": [
"src/*"
]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.mts"]
}

140
app/frontend/typed-router.d.ts vendored Normal file
View File

@@ -0,0 +1,140 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
// It's recommended to commit this file.
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
/// <reference types="unplugin-vue-router/client" />
import type {
// type safe route locations
RouteLocationTypedList,
RouteLocationResolvedTypedList,
RouteLocationNormalizedTypedList,
RouteLocationNormalizedLoadedTypedList,
RouteLocationAsString,
RouteLocationAsRelativeTypedList,
RouteLocationAsPathTypedList,
// helper types
// route definitions
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
// vue-router extensions
_RouterTyped,
RouterLinkTyped,
RouterLinkPropsTyped,
NavigationGuard,
UseLinkFnTyped,
// data fetching
_DataLoader,
_DefineLoaderOptions,
} from 'unplugin-vue-router/types'
declare module 'vue-router/auto/routes' {
export interface RouteNamedMap {
}
}
declare module 'vue-router/auto' {
import type { RouteNamedMap } from 'vue-router/auto/routes'
export type RouterTyped = _RouterTyped<RouteNamedMap>
/**
* Type safe version of `RouteLocationNormalized` (the type of `to` and `from` in navigation guards).
* Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocationNormalized<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationNormalizedTypedList<RouteNamedMap>[Name]
/**
* Type safe version of `RouteLocationNormalizedLoaded` (the return type of `useRoute()`).
* Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocationNormalizedLoaded<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]
/**
* Type safe version of `RouteLocationResolved` (the returned route of `router.resolve()`).
* Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocationResolved<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationResolvedTypedList<RouteNamedMap>[Name]
/**
* Type safe version of `RouteLocation` . Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocation<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationTypedList<RouteNamedMap>[Name]
/**
* Type safe version of `RouteLocationRaw` . Allows passing the name of the route to be passed as a generic.
*/
export type RouteLocationRaw<Name extends keyof RouteNamedMap = keyof RouteNamedMap> =
| RouteLocationAsString<RouteNamedMap>
| RouteLocationAsRelativeTypedList<RouteNamedMap>[Name]
| RouteLocationAsPathTypedList<RouteNamedMap>[Name]
/**
* Generate a type safe params for a route location. Requires the name of the route to be passed as a generic.
*/
export type RouteParams<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]['params']
/**
* Generate a type safe raw params for a route location. Requires the name of the route to be passed as a generic.
*/
export type RouteParamsRaw<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]['paramsRaw']
export function useRouter(): RouterTyped
export function useRoute<Name extends keyof RouteNamedMap = keyof RouteNamedMap>(name?: Name): RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]
export const useLink: UseLinkFnTyped<RouteNamedMap>
export function onBeforeRouteLeave(guard: NavigationGuard<RouteNamedMap>): void
export function onBeforeRouteUpdate(guard: NavigationGuard<RouteNamedMap>): void
export const RouterLink: RouterLinkTyped<RouteNamedMap>
export const RouterLinkProps: RouterLinkPropsTyped<RouteNamedMap>
// Experimental Data Fetching
export function defineLoader<
P extends Promise<any>,
Name extends keyof RouteNamedMap = keyof RouteNamedMap,
isLazy extends boolean = false,
>(
name: Name,
loader: (route: RouteLocationNormalizedLoaded<Name>) => P,
options?: _DefineLoaderOptions<isLazy>,
): _DataLoader<Awaited<P>, isLazy>
export function defineLoader<
P extends Promise<any>,
isLazy extends boolean = false,
>(
loader: (route: RouteLocationNormalizedLoaded) => P,
options?: _DefineLoaderOptions<isLazy>,
): _DataLoader<Awaited<P>, isLazy>
export {
_definePage as definePage,
_HasDataLoaderMeta as HasDataLoaderMeta,
_setupDataFetchingGuard as setupDataFetchingGuard,
_stopDataFetchingScope as stopDataFetchingScope,
} from 'unplugin-vue-router/runtime'
}
declare module 'vue-router' {
import type { RouteNamedMap } from 'vue-router/auto/routes'
export interface TypesConfig {
beforeRouteUpdate: NavigationGuard<RouteNamedMap>
beforeRouteLeave: NavigationGuard<RouteNamedMap>
$route: RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[keyof RouteNamedMap]
$router: _RouterTyped<RouteNamedMap>
RouterLink: RouterLinkTyped<RouteNamedMap>
}
}

View File

@@ -0,0 +1,73 @@
// Plugins
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import Fonts from 'unplugin-fonts/vite'
import Layouts from 'vite-plugin-vue-layouts'
import Vue from '@vitejs/plugin-vue'
import VueRouter from 'unplugin-vue-router/vite'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
// Utilities
import { defineConfig, loadEnv } from 'vite'
import { fileURLToPath, URL } from 'node:url'
// https://vitejs.dev/config/
export default ({mode}) => {
process.env = {...process.env, ...loadEnv(mode, process.cwd())}
return defineConfig({
plugins: [
VueRouter(),
Layouts(),
Vue({
template: { transformAssetUrls },
}),
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
Vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss',
},
}),
Components(),
Fonts({
google: {
families: [ {
name: 'Roboto',
styles: 'wght@100;300;400;500;700;900',
}],
},
}),
AutoImport({
imports: [
'vue',
'vue-router',
],
dts: true,
eslintrc: {
enabled: true,
},
vueTemplate: true,
}),
],
define: { 'process.env': {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: [
'.js',
'.json',
'.jsx',
'.mjs',
'.ts',
'.tsx',
'.vue',
],
},
server: {
port: 3000,
proxy: {
"/api": "http://localhost:3001"
}
},
})}